fix(stage-tamagotchi-kirie): connect Android QR scanner

This commit is contained in:
LodyAI
2026-10-14 17:42:38 +08:00
parent e2b9f38ae0
commit e533ffcff9
9 changed files with 120 additions and 20 deletions
+1 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { OnboardingDialog, OnboardingStepAnalyticsNotice, StartupOverlay, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useStartupResourceTimeout } from '@proj-airi/stage-ui/composables/use-startup-resource-timeout'
import { getHostWebSocketConnector } from '@proj-airi/stage-ui/libs/host-websocket-bridge'
import { usePiniaSynced } from '@proj-airi/stage-ui/libs/pinia'
import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/product-signals'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
@@ -28,8 +29,6 @@ import { toast, Toaster } from 'vue-sonner'
import OnboardingPermissionsStep from './components/onboarding/step-permissions.vue'
import { getHostWebSocketConnector } from './modules/websocket-bridge'
const contextBridgeStore = useContextBridgeStore()
const authStore = useAuthStore()
const i18n = useI18n()
@@ -2,14 +2,13 @@
import { CapacitorBarcodeScanner, CapacitorBarcodeScannerTypeHint } from '@capacitor/barcode-scanner'
import { errorMessageFrom } from '@moeru/std'
import { parseServerChannelQrPayload } from '@proj-airi/stage-shared/server-channel-qr'
import { probeServerChannelQrPayload } from '@proj-airi/stage-ui/libs/server-channel-qr-probe'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
import { GhostButton } from '@proj-airi/ui'
import { shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { probeServerChannelQrPayload } from '../../../modules/server-channel-qr-probe'
const { t } = useI18n()
const serverChannelStore = useModsServerChannelStore()
@@ -0,0 +1,18 @@
import type { AiriAndroidBarcodeScanResult } from '../../shared/eventa'
import { airiAndroidBarcodeEventIds } from '../../shared/eventa'
import { invokeAndroidEventa } from './android-permissions'
export async function scanAndroidBarcode(scanInstructions: string) {
const result = await invokeAndroidEventa<AiriAndroidBarcodeScanResult>(
airiAndroidBarcodeEventIds.scan,
{ scanInstructions },
)
if (result.error)
throw new Error(result.error)
if (!result.ScanResult)
throw new Error('The barcode scanner returned no result')
return result.ScanResult
}
@@ -1,6 +1,5 @@
import type {
AiriAndroidPermission,
AiriAndroidPermissionPayload,
AiriAndroidPermissionSnapshot,
} from '../../shared/eventa'
@@ -69,7 +68,7 @@ function getAndroidEventaChannel() {
return channel
}
function invokeAndroidEventa<Response>(eventId: string, content: AiriAndroidPermissionPayload): Promise<Response> {
export function invokeAndroidEventa<Response>(eventId: string, content: unknown): Promise<Response> {
const channel = getAndroidEventaChannel()
const invokeId = crypto.randomUUID()
return new Promise<Response>((resolve, reject) => {
@@ -8,8 +8,10 @@ import { computed, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ServerChannelQrCard from './server-channel-qr-card.vue'
import ServerChannelQrScanner from './server-channel-qr-scanner.vue'
import { useServerChannelSettingsStore } from '../../../stores/settings/server-channel'
import { isAndroidRenderer } from '../../../window-context'
const serverChannelSettingsStore = useServerChannelSettingsStore()
const { authToken, hostname, lastApplyError, tlsConfig } = storeToRefs(serverChannelSettingsStore)
@@ -29,7 +31,7 @@ const exposeToAll = computed({
},
})
const showDesktopServerControls = computed(() => isStageTamagotchi())
const showDesktopServerControls = computed(() => isStageTamagotchi() && !isAndroidRenderer())
const authTokenInput = shallowRef(authToken.value)
const authTokenInputDebounced = refDebounced(authTokenInput, 500)
const authTokenVisible = shallowRef(false)
@@ -57,7 +59,7 @@ watch(authTokenInputDebounced, (value) => {
>
{{ lastApplyError }}
</Callout>
<ConnectionSettings server-address-disabled>
<ConnectionSettings :server-address-disabled="!isAndroidRenderer()">
<template #before-server-address>
<FieldCheckbox
v-if="showDesktopServerControls"
@@ -109,12 +111,14 @@ watch(authTokenInputDebounced, (value) => {
</div>
<FieldCheckbox
v-if="!isAndroidRenderer()"
v-model="websocketTlsEnabled"
:label="t('settings.websocket-secure-enabled.title')"
:description="t('settings.websocket-secure-enabled.description')"
/>
<ServerChannelQrCard />
<ServerChannelQrScanner v-if="isAndroidRenderer()" />
<ServerChannelQrCard v-else />
</template>
</ConnectionSettings>
</div>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { errorMessageFrom } from '@moeru/std'
import { parseServerChannelQrPayload } from '@proj-airi/stage-shared/server-channel-qr'
import { probeServerChannelQrPayload } from '@proj-airi/stage-ui/libs/server-channel-qr-probe'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
import { GhostButton } from '@proj-airi/ui'
import { shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { scanAndroidBarcode } from '../../../host-context/android-barcode-scanner'
const { t } = useI18n()
const serverChannelStore = useModsServerChannelStore()
const scanning = shallowRef(false)
const errorMessage = shallowRef('')
async function scanServerChannelQrCode() {
scanning.value = true
errorMessage.value = ''
try {
const scanResult = await scanAndroidBarcode(t('settings.pages.connection.qr-scan.instructions'))
const payload = parseServerChannelQrPayload(scanResult)
const url = await probeServerChannelQrPayload(payload)
serverChannelStore.websocketAuthToken = payload.authToken
serverChannelStore.websocketUrl = url
toast.success(t('settings.pages.connection.qr-scan.success.title'), {
description: t('settings.pages.connection.qr-scan.success.description', { url }),
})
}
catch (error) {
errorMessage.value = errorMessageFrom(error) ?? t('settings.pages.connection.qr-scan.errors.failed')
toast.error(t('settings.pages.connection.qr-scan.errors.title'), {
description: errorMessage.value,
})
}
finally {
scanning.value = false
}
}
</script>
<template>
<div class="flex flex-col items-start justify-between gap-3">
<div class="flex flex-col gap-1">
<div class="text-sm text-neutral-900 font-medium dark:text-neutral-100">
{{ t('settings.pages.connection.qr-scan.title') }}
</div>
<p class="m-0 text-xs text-neutral-500 leading-5 dark:text-neutral-400">
{{ t('settings.pages.connection.qr-scan.description') }}
</p>
</div>
<GhostButton
:loading="scanning"
:label="t('settings.pages.connection.qr-scan.action')"
@click="scanServerChannelQrCode"
/>
</div>
</template>
@@ -95,6 +95,20 @@ export const airiAndroidPermissionEventIds = {
request: 'eventa:invoke:airi:android:permission:request',
} as const
export interface AiriAndroidBarcodeScanPayload {
scanInstructions: string
}
export interface AiriAndroidBarcodeScanResult {
ScanResult?: string
error?: string
format?: number
}
export const airiAndroidBarcodeEventIds = {
scan: 'eventa:invoke:airi:android:barcode:scan',
} as const
export const electronStartTrackMousePosition = defineInvokeEventa('eventa:invoke:electron:start-tracking-mouse-position')
export const electronStartDraggingWindow = defineInvokeEventa('eventa:invoke:electron:start-dragging-window')
@@ -13,24 +13,29 @@ type HostBridgeEvent
| { kind: 'error', id: string, message: string }
| { kind: 'close', id: string, code?: number, reason?: string }
interface WebkitHostBridge {
messageHandlers?: {
airiHostBridge?: {
postMessage: (payload: string) => void
}
}
}
declare global {
interface Window {
AiriHostBridge?: {
postMessage: (payload: string) => void
}
webkit?: {
messageHandlers?: {
airiHostBridge?: {
postMessage: (payload: string) => void
}
}
}
__airiHostBridge?: {
onNativeMessage?: (payload: string) => void
}
}
}
function getWebkitHostBridge() {
return (window as unknown as { webkit?: WebkitHostBridge }).webkit?.messageHandlers?.airiHostBridge
}
const connections = new Map<string, HostBridgeConnection>()
function postBridgeMessage(command: HostBridgeCommand) {
@@ -39,8 +44,9 @@ function postBridgeMessage(command: HostBridgeCommand) {
return
}
if (window.webkit?.messageHandlers?.airiHostBridge) {
window.webkit.messageHandlers.airiHostBridge.postMessage(JSON.stringify(command))
const webkitHostBridge = getWebkitHostBridge()
if (webkitHostBridge) {
webkitHostBridge.postMessage(JSON.stringify(command))
return
}
@@ -149,7 +155,7 @@ function createCloseBeforeOpenError(event: Extract<HostBridgeEvent, { kind: 'clo
}
export function getHostWebSocketConnector(url: string): ClientConnector<string> | undefined {
if (!window.AiriHostBridge && !window.webkit?.messageHandlers?.airiHostBridge) {
if (!window.AiriHostBridge && !getWebkitHostBridge()) {
return undefined
}
@@ -3,7 +3,7 @@ import type { ServerChannelQrPayload } from '@proj-airi/stage-shared/server-chan
import { errorMessageFrom } from '@moeru/std'
import { Client, createTextProtocolConnector, WebSocketEventSource } from '@proj-airi/server-sdk'
import { getHostWebSocketConnector } from './websocket-bridge'
import { getHostWebSocketConnector } from './host-websocket-bridge'
export async function probeServerChannelQrPayload(payload: ServerChannelQrPayload) {
if (!payload.urls.some(url => getHostWebSocketConnector(url))) {