164 lines
7.8 KiB
TypeScript
164 lines
7.8 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||
import { numberedInvoke as invoke } from '../numbered-ipc'
|
||
import './styles.css'
|
||
|
||
interface PairedDevice {
|
||
deviceId: string
|
||
displayName: string
|
||
platform: string
|
||
createdAtUnixMs: number
|
||
lastSeenAtUnixMs?: number
|
||
state: 'ACTIVE' | 'REVOKED'
|
||
}
|
||
|
||
interface MobileSyncStatus {
|
||
schema: string
|
||
state: 'OFFLINE' | 'PAIRING_READY' | 'RUNNING'
|
||
running: boolean
|
||
lanAddress?: string
|
||
port?: number
|
||
pairingUri?: string
|
||
pairingQrSvg?: string
|
||
pairingExpiresAtUnixMs?: number
|
||
pairedDevices: PairedDevice[]
|
||
transport: string
|
||
encryption: string
|
||
desktopIsRootNode: boolean
|
||
platformPrivateDataCustody: boolean
|
||
}
|
||
|
||
interface MobileCapture {
|
||
cursor: number
|
||
captureId: string
|
||
title: string
|
||
body: string
|
||
sourceDeviceId: string
|
||
createdAtUnixMs: number
|
||
}
|
||
|
||
interface MobileSyncSnapshot {
|
||
state: string
|
||
cursor: number
|
||
generatedAtUnixMs: number
|
||
desktopName: string
|
||
rootNodeOnline: boolean
|
||
personalChannel: { home: string; growthEventCount: number; integrity: string }
|
||
webNovel: {
|
||
workCount: number
|
||
volumeCount: number
|
||
chapterCount: number
|
||
works: Array<{ workId: string; title: string; status: string; updatedAtUnixMs: number }>
|
||
}
|
||
education: {
|
||
activeTableCount: number
|
||
archivedTableCount: number
|
||
unassignedTableCount: number
|
||
sensitiveValuesIncluded: boolean
|
||
}
|
||
recentCaptures: MobileCapture[]
|
||
boundary: {
|
||
mobileRole: string
|
||
remoteDesktopClone: boolean
|
||
desktopOfflineExecution: boolean
|
||
sensitiveEducationValues: string
|
||
modelApi: string
|
||
}
|
||
}
|
||
|
||
function formatTime(value?: number) {
|
||
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未连接'
|
||
}
|
||
|
||
export function MobileSyncPanel({ onBack }: { onBack: () => void }) {
|
||
const [status, setStatus] = useState<MobileSyncStatus | null>(null)
|
||
const [snapshot, setSnapshot] = useState<MobileSyncSnapshot | null>(null)
|
||
const [message, setMessage] = useState('手机是当前个人节点的远程入口,不是第二套人格系统。')
|
||
const [pending, setPending] = useState(false)
|
||
|
||
const refresh = useCallback(async () => {
|
||
const next = await invoke<MobileSyncStatus>('get_mobile_sync_status')
|
||
setStatus(next)
|
||
if (next.running) setSnapshot(await invoke<MobileSyncSnapshot>('get_mobile_sync_snapshot'))
|
||
else setSnapshot(null)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
refresh().catch((error) => !cancelled && setMessage(String(error)))
|
||
return () => { cancelled = true }
|
||
}, [refresh])
|
||
|
||
const run = async (action: () => Promise<unknown>, success: string) => {
|
||
if (pending) return
|
||
setPending(true)
|
||
try {
|
||
await action()
|
||
setMessage(success)
|
||
await refresh()
|
||
} catch (error) {
|
||
setMessage(String(error))
|
||
} finally {
|
||
setPending(false)
|
||
}
|
||
}
|
||
|
||
const qrSource = useMemo(
|
||
() => status?.pairingQrSvg ? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(status.pairingQrSvg)}` : '',
|
||
[status?.pairingQrSvg],
|
||
)
|
||
|
||
return <section className="mobile-sync-world" aria-label="HoloLake 多端同步">
|
||
<header className="mobile-sync-worldbar">
|
||
<button type="button" onClick={onBack}>← 返回我的频道</button>
|
||
<div><span>HOLOLAKE / SAME PERSONA SYSTEM</span><b>iPhone 多端同步</b></div>
|
||
<em>{status?.running ? '个人主节点在线' : '同步未开启'}</em>
|
||
</header>
|
||
|
||
<div className="mobile-sync-focus">
|
||
<div className={`mobile-sync-orbit ${status?.running ? 'is-online' : ''}`} aria-hidden="true"><i/><i/><i/></div>
|
||
<div><span>手机远程身体入口</span><h1>{status?.running ? '同一频道,正在等待 iPhone' : '由这台电脑守住唯一真相'}</h1><p>桌面保留身份、人格、数据与执行主权;手机只同步经过授权的投影与快速记录。</p></div>
|
||
</div>
|
||
|
||
{!status?.running ? <section className="mobile-sync-start">
|
||
<b>同一局域网 · 应用层端到端加密</b>
|
||
<p>开启后只在当前登录账号的数据根建立同步库。平台服务器不托管你的作品、教育数据、人格记忆或设备密钥。</p>
|
||
<button type="button" disabled={pending} onClick={() => void run(() => invoke('start_mobile_sync'), 'iPhone 同步入口已开启。')}>{pending ? '正在开启…' : '开启 iPhone 同步'}</button>
|
||
</section> : <div className="mobile-sync-grid">
|
||
<section className="mobile-sync-pairing">
|
||
<header><div><span>PAIRING</span><h2>配对这台 iPhone</h2></div><button type="button" disabled={pending} onClick={() => void run(() => invoke('rotate_mobile_pairing'), '已换成新的十分钟配对凭据。')}>换一枚配对凭据</button></header>
|
||
{status.pairingUri ? <>
|
||
{qrSource && <img src={qrSource} alt="HoloLake iPhone 配对二维码"/>}
|
||
<p>用 iPhone 版 HoloLake 扫描。二维码内含一次性高强度配对密钥,十分钟后或使用一次后失效。</p>
|
||
<button type="button" onClick={() => void navigator.clipboard.writeText(status.pairingUri || '').then(() => setMessage('配对链接已复制。'))}>复制配对链接</button>
|
||
<small>到期:{formatTime(status.pairingExpiresAtUnixMs)}</small>
|
||
</> : <div className="mobile-sync-pairing-used"><b>当前配对凭据已使用或到期</b><p>需要添加另一台手机时,再生成一枚新的凭据。</p></div>}
|
||
</section>
|
||
|
||
<section className="mobile-sync-projection">
|
||
<header><span>ROOT NODE PROJECTION</span><h2>当前同步投影</h2></header>
|
||
<div className="mobile-sync-metrics">
|
||
<article><b>{snapshot?.webNovel.workCount ?? 0}</b><span>网文作品</span></article>
|
||
<article><b>{snapshot?.webNovel.chapterCount ?? 0}</b><span>章节</span></article>
|
||
<article><b>{snapshot?.education.activeTableCount ?? 0}</b><span>教育表格</span></article>
|
||
<article><b>{snapshot?.personalChannel.growthEventCount ?? 0}</b><span>成长事件</span></article>
|
||
</div>
|
||
<p>教育敏感值:{snapshot?.education.sensitiveValuesIncluded ? '已包含' : '不会进入手机摘要'} · 电脑离线时手机不会冒充执行。</p>
|
||
</section>
|
||
|
||
<section className="mobile-sync-devices">
|
||
<header><span>DEVICES</span><h2>已配对设备</h2></header>
|
||
{status.pairedDevices.length ? status.pairedDevices.map((device) => <article key={device.deviceId}>
|
||
<div><b>{device.displayName}</b><span>{device.platform} · {device.state === 'ACTIVE' ? `最近连接 ${formatTime(device.lastSeenAtUnixMs)}` : '已撤销'}</span></div>
|
||
{device.state === 'ACTIVE' && <button type="button" disabled={pending} onClick={() => void run(() => invoke('revoke_mobile_sync_device', { input: { deviceId: device.deviceId } }), `已撤销 ${device.displayName}。`)}>撤销</button>}
|
||
</article>) : <p>还没有完成配对的 iPhone。</p>}
|
||
</section>
|
||
|
||
<section className="mobile-sync-captures">
|
||
<header><span>MOBILE CAPTURES</span><h2>手机快速记录</h2></header>
|
||
{snapshot?.recentCaptures.length ? snapshot.recentCaptures.map((capture) => <article key={capture.captureId}><div><b>{capture.title || '未命名记录'}</b><time>{formatTime(capture.createdAtUnixMs)}</time></div><p>{capture.body}</p></article>) : <p>手机写下的想法会加密回到这台电脑,并出现在这里。</p>}
|
||
</section>
|
||
</div>}
|
||
|
||
<footer className="mobile-sync-footer"><span>{message}</span><div><button type="button" disabled={pending} onClick={() => void run(refresh, '状态已从本机根节点重新读取。')}>刷新状态</button>{status?.running && <button type="button" disabled={pending} onClick={() => void run(() => invoke('stop_mobile_sync'), '同步入口已关闭;已配对设备登记仍保留,可单独撤销。')}>关闭本机同步入口</button>}</div></footer>
|
||
</section>
|
||
}
|