From 8f40b91deac808c9c92ee2a891a5916664368a6b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 12 Sep 2026 20:19:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(ohos):=20=E8=AE=BE=E5=A4=87=E6=A1=A5?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86=E3=80=81=E7=8A=B6=E6=80=81?= =?UTF-8?q?/=E8=AE=BE=E5=A4=87=E4=BF=A1=E6=81=AF=E8=BD=BD=E8=8D=B7?= =?UTF-8?q?=E4=B8=8E=E8=83=BD=E5=8A=9B=E8=B7=AF=E7=94=B1=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `DeviceBridgeSession.ets`:会话生命周期(创建/复用/回收)与 TTS 会话显式 shutdown; - `BridgeCaps`/`BridgeRouter` 与内核实际支持的本机命令保持一一对应,补齐 status/deviceinfo; - 页面与状态存储调整(DevicePage/Index/SettingsPage/StatusStore/SubPage)、新增 ScreensuePage; - module.json5 与 string.json 同步(新增页面与文案)。 --- cmd/ohos/HomeAgent/AppScope/app.json5 | 4 +- .../entry/src/main/ets/common/ApiClient.ets | 4 + .../entry/src/main/ets/common/BridgeCaps.ets | 216 +++++++++++++----- .../src/main/ets/common/BridgeRouter.ets | 79 +++++-- .../src/main/ets/common/DeviceBridge.ets | 167 ++++++++------ .../main/ets/common/DeviceBridgeSession.ets | 105 +++++++++ .../entry/src/main/ets/common/StatusStore.ets | 29 +-- .../src/main/ets/components/ScreensuePage.ets | 103 +++++++++ .../entry/src/main/ets/components/SubPage.ets | 6 +- .../main/ets/entryability/EntryAbility.ets | 6 + .../entry/src/main/ets/pages/DevicePage.ets | 144 +----------- .../entry/src/main/ets/pages/Index.ets | 85 ++++--- .../entry/src/main/ets/pages/SettingsPage.ets | 9 +- .../HomeAgent/entry/src/main/module.json5 | 8 + .../main/resources/base/element/string.json | 4 + 15 files changed, 616 insertions(+), 353 deletions(-) create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridgeSession.ets create mode 100644 cmd/ohos/HomeAgent/entry/src/main/ets/components/ScreensuePage.ets diff --git a/cmd/ohos/HomeAgent/AppScope/app.json5 b/cmd/ohos/HomeAgent/AppScope/app.json5 index 5291775..e6d0a76 100644 --- a/cmd/ohos/HomeAgent/AppScope/app.json5 +++ b/cmd/ohos/HomeAgent/AppScope/app.json5 @@ -2,8 +2,8 @@ "app": { "bundleName": "com.example.homeagent", "vendor": "HomeAgent", - "versionCode": 1000000, - "versionName": "1.0.0", + "versionCode": 1001001, + "versionName": "1.1.1", // 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。 // 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。 "icon": "$media:layered_image", diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ApiClient.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ApiClient.ets index ddc22b2..ec9ba97 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/ApiClient.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/ApiClient.ets @@ -32,6 +32,10 @@ export class ApiClient { this.conn = conn; } + clearConnection(): void { + this.conn = null; + } + getConnection(): ConnectionConfig | null { return this.conn; } diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets index ecf0eef..e6db188 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeCaps.ets @@ -4,16 +4,63 @@ import { pasteboard } from '@kit.BasicServicesKit'; import { deviceInfo } from '@kit.BasicServicesKit'; import { textToSpeech } from '@kit.CoreSpeechKit'; import { componentSnapshot } from '@kit.ArkUI'; -import { common } from '@kit.AbilityKit'; +import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit'; // ===== 能力结果 ===== +/** 与 BridgeRouter 实际支持的本机命令保持一一对应。 */ +export const LOCAL_DEVICE_CAPS: string[] = [ + 'status', + 'deviceinfo', + 'screensee', + 'screensue', + 'clipboardsee', + 'clipboardsue', + 'speakeruse', +]; + export interface CapResult { status: string; // 'ok' | 'error' output: string; error: string; } +interface DeviceStatusPayload { + device_id: string; + status: string; + hostname: string; + platform: string; + arch: string; + uptime: number; +} + +interface DeviceDetails { + hostname: string; + platform: string; + arch: string; + os_release: string; + version: string; + cpus: number; + brand: string; + manufacturer: string; + model: string; + series: string; + sdk_api_version: number; + security_patch: string; + abi_list: string; + device_type: string; +} + +interface DeviceInfoPayload { + device_id: string; + name: string; + kind: string; + caps: string[]; + info: DeviceDetails; +} + +const APP_STARTED_AT: number = Date.now(); + function okResult(output: string): CapResult { const r: CapResult = { status: 'ok', output: output, error: '' }; return r; @@ -44,53 +91,79 @@ async function captureScreenPixelMap(): Promise { * 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。 */ export async function capScreensee(): Promise { + let full: image.PixelMap | null = null; + let packer: image.ImagePacker | null = null; try { - const full: image.PixelMap = await captureScreenPixelMap(); + full = await captureScreenPixelMap(); const info: image.ImageInfo = await full.getImageInfo(); - const maxW: number = 720; - let targetW: number = info.size.width; - let targetH: number = info.size.height; - if (targetW > maxW) { - targetH = Math.floor(targetH * maxW / targetW); - targetW = maxW; + const maxW: number = 420; + const maxH: number = 640; + let scale: number = 1; + if (info.size.width > maxW) { + scale = maxW / info.size.width; } - let packed: ArrayBuffer; - if (targetW !== info.size.width) { - await full.scale(targetW / info.size.width, targetH / info.size.height); + if (info.size.height * scale > maxH) { + scale = maxH / info.size.height; } - const packer: image.ImagePacker = image.createImagePacker(); - const opt: image.PackingOption = { format: 'image/jpeg', quality: 70 }; - packed = await packer.packing(full, opt); - packer.release(); - full.release(); + if (scale < 1) { + await full.scale(scale, scale); + } + packer = image.createImagePacker(); + const opt: image.PackingOption = { format: 'image/jpeg', quality: 55 }; + const packed: ArrayBuffer = await packer.packing(full, opt); const helper: util.Base64Helper = new util.Base64Helper(); const b64: string = helper.encodeToStringSync(new Uint8Array(packed)); + if (b64.length > 950000) { + return errResult('当前画面数据过大,请稍后重试'); + } return okResult('data:image/jpeg;base64,' + b64); } catch (e) { - const msg: string = e instanceof Error ? e.message : String(e); - return errResult('screensee failed: ' + msg); + return errResult('无法读取当前应用画面,请保持应用在前台后重试'); + } finally { + if (packer !== null) { + packer.release(); + } + if (full !== null) { + full.release(); + } } } // ===== clipboardsee / clipboardsue ===== +const CLIPBOARD_PERMISSIONS: Array = ['ohos.permission.READ_PASTEBOARD']; + +/** + * READ_PASTEBOARD 是 user_grant 权限:仅在 agent 真正请求 clipboardsee 时弹出系统授权, + * 不在应用启动时抢先索权。已授权时系统会直接返回,不会重复打扰用户。 + */ +async function ensureClipboardPermission(context: common.UIAbilityContext): Promise { + try { + const atManager = abilityAccessCtrl.createAtManager(); + const result: PermissionRequestResult = + await atManager.requestPermissionsFromUser(context, CLIPBOARD_PERMISSIONS); + return result.authResults.length > 0 && result.authResults[0] === 0; + } catch (e) { + return false; + } +} + export async function capClipboardSee(context: common.UIAbilityContext): Promise { - // 说明:READ_PASTEBOARD 为受限权限,调试签名无法在真机安装时授予, - // 这里直接尝试读取;系统拒绝时回错误信息。 + const granted: boolean = await ensureClipboardPermission(context); + if (!granted) { + return errResult('剪贴板读取权限未授予,请在系统设置中允许后重试'); + } try { const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard(); const has: boolean = await clip.hasData(); if (!has) { - const empty: CapResult = { status: 'ok', output: '', error: '' }; - return empty; + return okResult(''); } const data: pasteboard.PasteData = await clip.getData(); const txt: string = data.getPrimaryText(); - const out: CapResult = { status: 'ok', output: txt ?? '', error: '' }; - return out; + return okResult(txt ?? ''); } catch (e) { - const msg: string = e instanceof Error ? e.message : String(e); - return errResult('clipboardsee failed (需系统剪贴板授权): ' + msg); + return errResult('剪贴板读取失败,请确认应用在前台并已获得系统授权'); } } @@ -99,10 +172,9 @@ export async function capClipboardsue(text: string): Promise { const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard(); const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text); await clip.setPasteData(data); - return okResult('written ' + text.length + ' chars'); + return okResult('clipboard written'); } catch (e) { - const msg: string = e instanceof Error ? e.message : String(e); - return errResult('clipboardsue failed: ' + msg); + return errResult('剪贴板写入失败,请保持应用在前台后重试'); } } @@ -112,13 +184,11 @@ class TtsSession { private engine: textToSpeech.TextToSpeechEngine | null = null; async speak(text: string): Promise { + if (text.length > 4000) { + return errResult('朗读内容过长,请缩短到 4000 字以内'); + } try { if (this.engine === null) { - const extra: Record = { - 'style': 'interaction-broadcast', - 'locate': 'CN', - 'name': 'EngineName', - }; const params: textToSpeech.CreateEngineParams = { language: 'zh-CN', person: 0, @@ -133,8 +203,7 @@ class TtsSession { this.engine.speak(text, sp); return okResult('speaking'); } catch (e) { - const msg: string = e instanceof Error ? e.message : String(e); - return errResult('speakeruse failed: ' + msg); + return errResult('语音服务暂时不可用,请稍后重试'); } } @@ -156,20 +225,49 @@ export async function capSpeakerUse(text: string): Promise { return ttsSession.speak(text); } -// ===== deviceinfo ===== +export function shutdownSpeakerUse(): void { + ttsSession.shutdown(); +} -export function capDeviceInfo(): CapResult { - const lines: string[] = []; - lines.push('brand=' + deviceInfo.brand); - lines.push('manufacturer=' + deviceInfo.manufacture); - lines.push('model=' + deviceInfo.productModel); - lines.push('series=' + deviceInfo.productSeries); - lines.push('osFullName=' + deviceInfo.osFullName); - lines.push('sdkApiVersion=' + deviceInfo.sdkApiVersion.toString()); - lines.push('securityPatch=' + deviceInfo.securityPatchTag); - lines.push('abiList=' + deviceInfo.abiList); - lines.push('deviceType=' + deviceInfo.deviceType); - return okResult(lines.join('\n')); +// ===== status / deviceinfo ===== + +export function capStatus(deviceId: string): CapResult { + const payload: DeviceStatusPayload = { + device_id: deviceId, + status: 'online', + hostname: 'ohos-phone', + platform: 'OpenHarmony', + arch: deviceInfo.abiList, + uptime: Math.floor((Date.now() - APP_STARTED_AT) / 1000), + }; + return okResult(JSON.stringify(payload)); +} + +export function capDeviceInfo(deviceId: string, deviceName: string): CapResult { + const details: DeviceDetails = { + hostname: 'ohos-phone', + platform: 'OpenHarmony', + arch: deviceInfo.abiList, + os_release: deviceInfo.osFullName, + version: '1.1.1', + cpus: 0, + brand: deviceInfo.brand, + manufacturer: deviceInfo.manufacture, + model: deviceInfo.productModel, + series: deviceInfo.productSeries, + sdk_api_version: deviceInfo.sdkApiVersion, + security_patch: deviceInfo.securityPatchTag, + abi_list: deviceInfo.abiList, + device_type: deviceInfo.deviceType, + }; + const payload: DeviceInfoPayload = { + device_id: deviceId, + name: deviceName, + kind: 'ohos-phone', + caps: LOCAL_DEVICE_CAPS, + info: details, + }; + return okResult(JSON.stringify(payload)); } // ===== screensue 内容解析 ===== @@ -182,14 +280,18 @@ export interface ScreensuePayload { export function parseScreensue(rawArgs: string): ScreensuePayload { const p: ScreensuePayload = { duration: 5, content: '' }; - let rest: string = rawArgs.trim(); - const tokens: string[] = rest.split(/\s+/); - if (tokens.length > 1 && /^\d+$/.test(tokens[0])) { - p.duration = parseInt(tokens[0], 10); - rest = tokens.slice(1).join(' '); - } else { - rest = tokens.join(' '); + const leadingSpaces: RegExp = new RegExp('^\\s+'); + const firstSpace: RegExp = new RegExp('\\s'); + let rest: string = rawArgs.replace(leadingSpaces, ''); + const splitAt: number = rest.search(firstSpace); + if (splitAt > 0) { + const first: string = rest.substring(0, splitAt); + const digits: RegExp = new RegExp('^\\d+$'); + if (digits.test(first)) { + p.duration = Math.min(parseInt(first, 10), 86400); + rest = rest.substring(splitAt).replace(leadingSpaces, ''); + } } - p.content = rest.trim(); + p.content = rest; return p; } diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeRouter.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeRouter.ets index 280d654..bc02bb7 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeRouter.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/BridgeRouter.ets @@ -1,4 +1,4 @@ -import { deviceBridge, CmdReply } from './DeviceBridge'; +import { deviceBridge } from './DeviceBridge'; import { CapResult, capScreensee, @@ -6,12 +6,14 @@ import { capClipboardsue, capSpeakerUse, capDeviceInfo, + capStatus, parseScreensue, ScreensuePayload, } from './BridgeCaps'; +import { connStore } from './ConnStore'; import { common } from '@kit.AbilityKit'; -// screensue 展示回调由 UI 层注册(Index 挂全局悬浮层) +// screensue 展示回调由根 UI 注册:窄屏整页,宽屏右侧内容栏。 export type ScreensueHandler = (payload: ScreensuePayload) => void; let screensueHandler: ScreensueHandler | null = null; @@ -25,10 +27,23 @@ export function setBridgeAppContext(ctx: common.UIAbilityContext): void { appContext = ctx; } -/** 解析 homeagent-* 命令:返回能力名与参数串。 */ +/** 解析裸能力名或过渡期 homeagent-* 命令;参数正文不裁剪,避免改变推送内容。 */ function splitCapability(command: string): string[] { - const cmd: string = command.trim(); - const idx: number = cmd.indexOf(' '); + let start: number = 0; + while (start < command.length && isCommandSpace(command.charAt(start))) { + start = start + 1; + } + let cmd: string = command.substring(start); + if (cmd.startsWith('homeagent-')) { + cmd = cmd.substring('homeagent-'.length); + } + let idx: number = -1; + for (let i: number = 0; i < cmd.length; i++) { + if (isCommandSpace(cmd.charAt(i))) { + idx = i; + break; + } + } if (idx < 0) { return [cmd]; } @@ -36,51 +51,73 @@ function splitCapability(command: string): string[] { return out; } +function isCommandSpace(ch: string): boolean { + return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; +} + +function hasArgs(args: string): boolean { + return args.trim().length > 0; +} + async function executeCommand(reqId: string, command: string): Promise { const parts: string[] = splitCapability(command); const name: string = parts[0]; const args: string = parts.length > 1 ? parts[1] : ''; - // screensee 截屏回传(data URL 走文本结果,服务端兼容) if (name === 'screensee') { + if (hasArgs(args)) { + return errRes('screensee 不接受额外参数'); + } return capScreensee(); } if (name === 'screensue') { + if (!hasArgs(args)) { + return errRes('screensue 需要展示内容'); + } const payload: ScreensuePayload = parseScreensue(args); + if (payload.content.length === 0) { + return errRes('screensue 需要展示内容'); + } if (screensueHandler !== null) { screensueHandler(payload); - return okRes('shown'); + return okRes('内容已显示'); } - return errRes('screensue: display layer not ready'); + return errRes('展示界面尚未就绪,请保持应用在前台后重试'); } if (name === 'clipboardsee') { + if (hasArgs(args)) { + return errRes('clipboardsee 不接受额外参数'); + } if (appContext === null) { - return errRes('clipboardsee: app context missing'); + return errRes('应用界面尚未就绪,请保持应用在前台后重试'); } return capClipboardSee(appContext); } if (name === 'clipboardsue') { - if (args.length === 0) { - return errRes('clipboardsue: empty text'); + if (!hasArgs(args)) { + return errRes('clipboardsue 需要写入文字'); } return capClipboardsue(args); } if (name === 'speakeruse') { - if (args.length === 0) { - return errRes('speakeruse: empty text'); + if (!hasArgs(args)) { + return errRes('speakeruse 需要朗读文字'); } return capSpeakerUse(args); } - if (name === 'deviceinfo' || name === 'status') { - return capDeviceInfo(); + if (name === 'status') { + if (hasArgs(args)) { + return errRes('status 不接受额外参数'); + } + return capStatus(connStore.getDeviceId()); } - if (name === 'camerasue') { - return errRes('camerasue: camera capture not supported on this build'); + if (name === 'deviceinfo') { + if (hasArgs(args)) { + return errRes('deviceinfo 不接受额外参数'); + } + return capDeviceInfo(connStore.getDeviceId(), connStore.getDeviceName()); } - if (name === 'computeruse') { - return errRes('computeruse: not applicable to touch-only device'); - } - return errRes('unsupported homeagent capability: ' + name); + return errRes('不支持的本机能力:' + name); } function okRes(output: string): CapResult { diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets index 8d261ba..76c41e3 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets @@ -1,5 +1,4 @@ import { webSocket } from '@kit.NetworkKit'; -import { DeviceInfo } from '../model/Model'; import { CapResult } from './BridgeCaps'; // ===== 协议消息(与 remotedevice 插件对齐)===== @@ -33,13 +32,6 @@ interface BindMessage { token: string; } -interface CmdMessage { - op: string; - req_id: string; - command: string; - cmd_type: string; -} - export interface CmdReply { op: string; // 'cmd_result' req_id: string; @@ -81,14 +73,15 @@ export class DeviceBridgeClient { private caps: string[] = []; private hostname: string = 'ohos'; private connected: boolean = false; - private everConnected: boolean = false; + private bound: boolean = false; private manualClose: boolean = false; private reconnectTimer: number = -1; + private connectionGeneration: number = 0; private cmdHandler: BridgeCmdHandler | null = null; private onStateChange: ((open: boolean) => void) | null = null; isConnected(): boolean { - return this.connected; + return this.connected && this.bound; } getDeviceId(): string { @@ -117,37 +110,40 @@ export class DeviceBridgeClient { } private async openAndRegister(authorized: boolean): Promise { - // 每次连接使用新的 WebSocket 实例,避免旧实例事件残留 try { this.ws.off('open'); this.ws.off('message'); this.ws.off('close'); this.ws.off('error'); + this.ws.close().catch(() => { + // ignore stale socket close failure + }); } catch (e) { - // ignore + // ignore stale socket cleanup failure } - this.ws = webSocket.createWebSocket(); - this.bindWsEvents(authorized); - // 鉴权必须走请求头,不能拼 ?token= : - // 1) webui 的 /api/v1/device/* 反代包在 requireAPI 里, - // validAPIKey 只认 X-API-Key 头或 Authorization: Bearer, - // 查询参数一律视为未授权 → 握手被 401 顶掉, - // 表现为 NETSTACK 日志 "Lws client connection error HS: ws upgrade unauthorized"。 - // 2) 反代到 remotedevice 时会自行注入网关的 ws_token; - // 如果我们再带 ?token=,remotedevice 的 ServeWS - // 会拿它和 ws_token 比对并 401。留空反而放行。 + this.connectionGeneration = this.connectionGeneration + 1; + const generation: number = this.connectionGeneration; + const socket: webSocket.WebSocket = webSocket.createWebSocket(); + this.ws = socket; + this.connected = false; + this.bound = false; + this.bindWsEvents(socket, authorized, generation); const opts: webSocket.WebSocketRequestOptions = { header: this.authHeader(), }; try { - await this.ws.connect(this.url, opts); + await socket.connect(this.url, opts); } catch (e) { - this.connected = false; - this.scheduleReconnect(); + if (generation === this.connectionGeneration && !this.manualClose) { + this.connected = false; + this.bound = false; + this.notifyState(false); + this.scheduleReconnect(); + } } } - /** 握手请求头:X-API-Key + Authorization 双写,兼容不同后端校验实现。 */ + /** WebUI 用 API key 验证外层连接,并由反代向设备网关注入其内部 token。 */ private authHeader(): Record { const h: Record = {}; if (this.token.length > 0) { @@ -157,38 +153,48 @@ export class DeviceBridgeClient { return h; } - private bindWsEvents(authorized: boolean): void { - this.ws.on('open', (err: Error, value: Object) => { + private bindWsEvents(socket: webSocket.WebSocket, authorized: boolean, generation: number): void { + socket.on('open', (err: Error, value: Object) => { + if (generation !== this.connectionGeneration || this.manualClose) { + socket.close().catch(() => { + // ignore stale socket close failure + }); + return; + } this.connected = true; - this.everConnected = true; - this.cancelReconnect(); + this.bound = false; this.sendHello(authorized); this.sendBind(); - if (this.onStateChange !== null) { - this.onStateChange(true); - } }); - this.ws.on('message', (err: Error, value: string | ArrayBuffer) => { - if (typeof value === 'string') { + socket.on('message', (err: Error, value: string | ArrayBuffer) => { + if (generation === this.connectionGeneration && typeof value === 'string') { this.handleTextFrame(value); } }); - this.ws.on('close', (err: Error, value: webSocket.CloseResult) => { - this.connected = false; - if (this.onStateChange !== null) { - this.onStateChange(false); - } - this.scheduleReconnect(); + socket.on('close', (err: Error, value: webSocket.CloseResult) => { + this.handleSocketEnd(generation); }); - this.ws.on('error', (err: Error) => { - this.connected = false; - if (this.onStateChange !== null) { - this.onStateChange(false); - } - this.scheduleReconnect(); + socket.on('error', (err: Error) => { + this.handleSocketEnd(generation); }); } + private handleSocketEnd(generation: number): void { + if (generation !== this.connectionGeneration) { + return; + } + this.connected = false; + this.bound = false; + this.notifyState(false); + this.scheduleReconnect(); + } + + private notifyState(open: boolean): void { + if (this.onStateChange !== null) { + this.onStateChange(open); + } + } + private scheduleReconnect(): void { if (this.manualClose || this.reconnectTimer >= 0) { return; @@ -211,18 +217,20 @@ export class DeviceBridgeClient { } } - /** 更新本地授权状态并立即重新 hello 同步到服务端。 */ + /** 更新本地授权状态并在已绑定连接上同步到服务端。 */ updateAuthorized(authorized: boolean): void { this.lastAuthorized = authorized; - if (this.connected) { + if (this.connected && this.bound) { this.sendHello(authorized); } } disconnect(): void { this.manualClose = true; + this.connectionGeneration = this.connectionGeneration + 1; this.cancelReconnect(); this.connected = false; + this.bound = false; try { this.ws.off('open'); this.ws.off('message'); @@ -234,9 +242,7 @@ export class DeviceBridgeClient { } catch (e) { // ignore } - if (this.onStateChange !== null) { - this.onStateChange(false); - } + this.notifyState(false); } private sendHello(authorized: boolean): void { @@ -246,7 +252,7 @@ export class DeviceBridgeClient { platform: 'OpenHarmony', arch: '', os_release: '', - version: '1.1.0', + version: '1.1.1', cpus: 0, }; const device: HelloDevice = { @@ -280,37 +286,50 @@ export class DeviceBridgeClient { return; } const op: string = obj['op'] as string ?? ''; - if (op === 'cmd') { - const reqId: string = obj['req_id'] as string ?? ''; - const command: string = obj['command'] as string ?? ''; - if (reqId.length === 0 || command.length === 0) { - return; - } - if (!this.lastAuthorized) { - this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权'); - return; - } - this.dispatchCommand(reqId, command); - } else if (op === 'hello_ack' || op === 'bind_ack') { - if (this.onAck !== null) { - this.onAck(op); + if (op === 'bind_ack') { + const accepted: boolean = obj['ok'] === true; + if (accepted && this.connected && !this.manualClose) { + this.bound = true; + this.cancelReconnect(); + this.notifyState(true); + } else { + this.bound = false; + this.notifyState(false); + try { + this.ws.close().catch(() => { + // ignore bind rejection close failure + }); + } catch (e) { + this.scheduleReconnect(); + } } + return; } + if (op !== 'cmd' || !this.bound) { + return; + } + const reqId: string = obj['req_id'] as string ?? ''; + const command: string = obj['command'] as string ?? ''; + if (reqId.length === 0 || command.length === 0) { + return; + } + if (!this.lastAuthorized) { + this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权'); + return; + } + this.dispatchCommand(reqId, command); } - onAck: ((op: string) => void) | null = null; - private dispatchCommand(reqId: string, command: string): void { if (this.cmdHandler === null) { - this.sendResult(reqId, 'error', '', 'no capability handler registered'); + this.sendResult(reqId, 'error', '', '本机能力尚未就绪,请保持应用在前台后重试'); return; } const handler: BridgeCmdHandler = this.cmdHandler; handler(reqId, command).then((res: CapResult) => { this.sendResult(reqId, res.status, res.output, res.error); }).catch((e: Object) => { - const msg: string = e instanceof Error ? e.message : String(e); - this.sendResult(reqId, 'error', '', msg); + this.sendResult(reqId, 'error', '', '本机能力执行失败,请稍后重试'); }); } @@ -388,7 +407,3 @@ export class DeviceBridgeClient { } export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient(); - -export function parseDevicesPayload(jsonStr: string): DeviceInfo[] { - return []; -} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridgeSession.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridgeSession.ets new file mode 100644 index 0000000..bad8510 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridgeSession.ets @@ -0,0 +1,105 @@ +import { common } from '@kit.AbilityKit'; +import { deviceBridge } from './DeviceBridge'; +import { installCmdRouter, setBridgeAppContext } from './BridgeRouter'; +import { LOCAL_DEVICE_CAPS, shutdownSpeakerUse } from './BridgeCaps'; +import { connStore } from './ConnStore'; +import { ConnectionConfig } from '../model/Model'; + +export { LOCAL_DEVICE_CAPS } from './BridgeCaps'; + +let bridgeStarting: boolean = false; +let foregroundActive: boolean = false; +let rootUIReady: boolean = false; +let bridgeGeneration: number = 0; +let stateTrackingReady: boolean = false; + +function ensureBridgeStateTracking(): void { + if (stateTrackingReady) { + return; + } + stateTrackingReady = true; + AppStorage.setOrCreate('deviceBridgeConnected', false); + deviceBridge.setStateListener((open: boolean) => { + AppStorage.set('deviceBridgeConnected', open); + }); +} + +/** 把当前后端 HTTP 地址转换为同源设备桥 WebSocket 地址。 */ +export function deviceGatewayUrl(base: string): string { + let trimmed: string = base.trim(); + while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') { + trimmed = trimmed.substring(0, trimmed.length - 1); + } + let scheme: string = 'ws://'; + let rest: string = trimmed; + if (trimmed.startsWith('https://')) { + scheme = 'wss://'; + rest = trimmed.substring('https://'.length); + } else if (trimmed.startsWith('http://')) { + rest = trimmed.substring('http://'.length); + } else if (trimmed.startsWith('wss://')) { + scheme = 'wss://'; + rest = trimmed.substring('wss://'.length); + } else if (trimmed.startsWith('ws://')) { + rest = trimmed.substring('ws://'.length); + } + return scheme + rest + '/api/v1/device/ws'; +} + +/** + * 应用进入前台后建立全局设备桥。它不再依赖用户先打开“设备”Tab, + * 因而 screensue、clipboardsee 等前台能力从主页面加载后即可接收。 + */ +export async function startForegroundBridge(context: common.UIAbilityContext): Promise { + foregroundActive = true; + setBridgeAppContext(context); + installCmdRouter(); + ensureBridgeStateTracking(); + if (!rootUIReady || deviceBridge.isConnected() || bridgeStarting) { + return; + } + const cur: ConnectionConfig | null = connStore.getCurrentConnection(); + if (cur === null || cur.url.length === 0 || cur.apiKey.length === 0) { + return; + } + bridgeStarting = true; + bridgeGeneration = bridgeGeneration + 1; + const generation: number = bridgeGeneration; + const deviceId: string = connStore.ensureDeviceId(); + try { + await deviceBridge.connect( + deviceGatewayUrl(cur.url), cur.apiKey, deviceId, + LOCAL_DEVICE_CAPS, 'ohos-phone', connStore.getDeviceAuth(), connStore.getDeviceName()); + if (!foregroundActive || generation !== bridgeGeneration) { + deviceBridge.disconnect(); + } + } catch (e) { + // DeviceBridge 自己会安排重连;前台启动不弹技术错误打扰用户。 + } + if (generation === bridgeGeneration) { + bridgeStarting = false; + } +} + +/** 根页面挂载完成后才连接,避免首条 screensue 到达时展示层尚未注册。 */ +export function markForegroundBridgeUIReady(context: common.UIAbilityContext): void { + rootUIReady = true; + startForegroundBridge(context); +} + +/** 后台不接受需要前台 UI/剪贴板授权的命令。 */ +export function stopForegroundBridge(): void { + foregroundActive = false; + bridgeGeneration = bridgeGeneration + 1; + bridgeStarting = false; + shutdownSpeakerUse(); + deviceBridge.disconnect(); +} + +/** 连接配置切换或修改后立即让设备桥使用新地址和 Token。 */ +export async function restartForegroundBridge(context: common.UIAbilityContext): Promise { + bridgeGeneration = bridgeGeneration + 1; + bridgeStarting = false; + deviceBridge.disconnect(); + await startForegroundBridge(context); +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets index f37563e..59e8d9a 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/StatusStore.ets @@ -67,8 +67,11 @@ class StatusStore { this.fail(noConnectionMessage()); return; } - AppStorage.setOrCreate(K_LOADING, true); - AppStorage.setOrCreate(K_ERR, ''); + // setOrCreate 只负责首次建键,键已存在时不会覆盖旧值。 + // init() 已把所有键种好,刷新阶段必须用 set,否则摘要卡会永远停在 + // 版本 "-"、插件 0 的初始状态。 + AppStorage.set(K_LOADING, true); + AppStorage.set(K_ERR, ''); try { const resp = await apiClient.getWithTimeout('/status', 8000); const obj: Record = JSON.parse(resp.body) as Record; @@ -79,10 +82,10 @@ class StatusStore { const agents: number = obj['agents'] as number ?? 0; const startedAt: string = obj['startedAt'] as string ?? ''; - AppStorage.setOrCreate(K_UP, true); - AppStorage.setOrCreate(K_VERSION, version); - AppStorage.setOrCreate(K_AGENTS, agents); - AppStorage.setOrCreate(K_STARTED, startedAt); + AppStorage.set(K_UP, true); + AppStorage.set(K_VERSION, version); + AppStorage.set(K_AGENTS, agents); + AppStorage.set(K_STARTED, startedAt); const systemFields: StatField[] = [ { label: '版本', value: version }, @@ -103,7 +106,7 @@ class StatusStore { } catch (e) { this.fail(userMessage('status.refresh', e)); } - AppStorage.setOrCreate(K_LOADING, false); + AppStorage.set(K_LOADING, false); } /** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */ @@ -119,8 +122,8 @@ class StatusStore { const pluginCount: number = pluginsArr !== undefined ? pluginsArr.length : 0; const toolCount: number = toolsArr !== undefined ? toolsArr.length : 0; - AppStorage.setOrCreate(K_PLUGINS, pluginCount); - AppStorage.setOrCreate(K_TOOLS, toolCount); + AppStorage.set(K_PLUGINS, pluginCount); + AppStorage.set(K_TOOLS, toolCount); const kernelFields: StatField[] = [ { label: 'Agent ID', value: agentId }, @@ -219,9 +222,9 @@ class StatusStore { } private fail(msg: string): void { - AppStorage.setOrCreate(K_ERR, msg); - AppStorage.setOrCreate(K_UP, false); - AppStorage.setOrCreate(K_LOADING, false); + AppStorage.set(K_ERR, msg); + AppStorage.set(K_UP, false); + AppStorage.set(K_LOADING, false); this.groups = []; this.bump(); } @@ -229,7 +232,7 @@ class StatusStore { /** 明细数组不进 AppStorage,用一个自增版本号触发订阅组件重取 */ private bump(): void { const cur: number = AppStorage.get(K_REV) ?? 0; - AppStorage.setOrCreate(K_REV, cur + 1); + AppStorage.set(K_REV, cur + 1); } } diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/ScreensuePage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ScreensuePage.ets new file mode 100644 index 0000000..3c1e7a6 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/ScreensuePage.ets @@ -0,0 +1,103 @@ +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../common/Constants'; +import { GradientBackground } from './GradientBackground'; +import { PageTopBar } from './PageTopBar'; +import { MotionBase } from './MotionBase'; + +/** + * agent 主动推送的前台内容页。 + * + * 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏, + * 从而让左侧一级页面和主导航保持可见、可操作。 + */ +@Component +export struct ScreensuePage { + @StorageProp('themeIsDark') private isDark: boolean = true; + @Prop pushedText: string = ''; + @Prop countdown: number = 0; + onClose: () => void = () => { + }; + + build() { + Stack({ alignContent: Alignment.Bottom }) { + GradientBackground() + + Scroll() { + Column({ space: 14 }) { + Row({ space: 8 }) { + Circle({ width: 8, height: 8 }) + .fill(this.palette().accent) + Text('agent 推送') + .fontSize(12) + .fontWeight(FontWeight.Medium) + .fontColor(this.palette().textSecondary) + Blank() + if (this.countdown > 0) { + Text(this.countdown.toString() + 's') + .fontSize(12) + .fontColor(this.palette().textMuted) + } else { + Text('常驻') + .fontSize(12) + .fontColor(this.palette().textMuted) + } + } + .width('100%') + + Column() { + Text(this.pushedText) + .fontSize(16) + .lineHeight(25) + .fontColor(this.palette().textPrimary) + .width('100%') + .textAlign(TextAlign.Start) + .copyOption(CopyOptions.LocalDevice) + } + .width('100%') + .padding(18) + .borderRadius(18) + .backgroundColor(this.palette().bgCard) + .border({ width: 1, color: this.palette().glassBorder }) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .padding({ left: 18, right: 18, top: 82, bottom: 96 }) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .height('100%') + .scrollBar(BarState.Auto) + .align(Alignment.Top) + + PageTopBar({ title: '推送内容' }) + + Row() { + MotionBase({ pressEnabled: true, fillWidth: false }) { + Button('关闭') + .height(42) + .padding({ left: 22, right: 22 }) + .fontSize(14) + .fontWeight(FontWeight.Medium) + .fontColor(Color.White) + .backgroundColor(this.palette().accent) + .borderRadius(21) + .onClick(() => { + this.onClose(); + }) + } + } + .width('100%') + .padding({ left: 18, right: 18, bottom: 22 }) + .justifyContent(FlexAlign.End) + .transition(TransitionEffect.OPACITY + .combine(TransitionEffect.translate({ y: 18 })) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) + } + .width('100%') + .height('100%') + .backgroundColor(this.palette().bgPrimary) + } + + private palette(): ThemePalette { + return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; + } +} diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets index 5a4069e..8199a14 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets @@ -13,7 +13,11 @@ import { GradientBackground } from './GradientBackground'; export const KEY_SUBPAGE_OPEN: string = 'subPageOpen'; export function markSubPageOpen(open: boolean): void { - AppStorage.setOrCreate(KEY_SUBPAGE_OPEN, open); + if (AppStorage.has(KEY_SUBPAGE_OPEN)) { + AppStorage.set(KEY_SUBPAGE_OPEN, open); + } else { + AppStorage.setOrCreate(KEY_SUBPAGE_OPEN, open); + } } /** pushPathByName 的参数载体:ArkTS 不允许把 string 断言成 object */ diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/entryability/EntryAbility.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/entryability/EntryAbility.ets index 7962c91..11ef764 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/entryability/EntryAbility.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/entryability/EntryAbility.ets @@ -4,6 +4,7 @@ import { BusinessError } from '@kit.BasicServicesKit'; import { connStore } from '../common/ConnStore'; import { apiClient } from '../common/ApiClient'; import { themeIsDark, seedTheme, seedSystemIsDark, resolveIsDark, applyThemeMode } from '../common/Constants'; +import { startForegroundBridge, stopForegroundBridge } from '../common/DeviceBridgeSession'; /** Read the persisted theme mode ('system'|'dark'|'light'), defaulting to 'system'. */ function storedThemeMode(): string { @@ -31,6 +32,7 @@ export default class EntryAbility extends UIAbility { } onDestroy(): void { + stopForegroundBridge(); console.info('[HomeAgent] ability onDestroy'); } @@ -86,6 +88,7 @@ export default class EntryAbility extends UIAbility { apiClient.setConnection(cur); } this.reapplyStoredTheme(); + startForegroundBridge(this.context); startUI(); }).catch(() => { startUI(); @@ -107,6 +110,7 @@ export default class EntryAbility extends UIAbility { // init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏 this.reapplyStoredTheme(); this.applySystemBar(); + startForegroundBridge(this.context); startUI(); }).catch((e: Error) => { console.error('[HomeAgent] connStore init failed: ' + e.message); @@ -129,10 +133,12 @@ export default class EntryAbility extends UIAbility { } onForeground(): void { + startForegroundBridge(this.context); console.info('[HomeAgent] ability onForeground'); } onBackground(): void { + stopForegroundBridge(); console.info('[HomeAgent] ability onBackground'); } } diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets index 617dfdf..428c5bc 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets @@ -1,27 +1,14 @@ import { deviceBridge } from '../common/DeviceBridge'; -import { installCmdRouter, registerScreensueHandler, setBridgeAppContext } from '../common/BridgeRouter'; import { connStore } from '../common/ConnStore'; import { apiClient } from '../common/ApiClient'; -import { noConnectionMessage } from '../common/UserError'; import { handleNavOnScroll } from '../common/NavBarController'; import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry'; import { DeviceInfo } from '../model/Model'; import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; import { MotionBase } from '../components/MotionBase'; -import { common } from '@kit.AbilityKit'; import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar'; import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage'; - -// 本机声明的能力(与 BridgeRouter 支持的命令一一对应) -const LOCAL_CAPS: string[] = [ - 'status', - 'deviceinfo', - 'screensee', - 'screensue', - 'clipboardsee', - 'clipboardsue', - 'speakeruse', -]; +import { LOCAL_DEVICE_CAPS, deviceGatewayUrl } from '../common/DeviceBridgeSession'; /** 二级页面标识 */ const SUB_NONE: string = ''; @@ -39,19 +26,17 @@ export struct DevicePage { @StorageProp('isWideScreen') private isWide: boolean = false; /** 当前右栏展示的二级页面 id,用于宽屏下高亮左侧入口行 */ @State activeSub: string = SUB_NONE; - @State bridgeConnected: boolean = false; + @StorageProp('deviceBridgeConnected') private bridgeConnected: boolean = false; @State bridgeUrl: string = ''; @State bridgeToken: string = ''; @State deviceId: string = ''; @State authorized: boolean = false; @State devices: DeviceInfo[] = []; @State loadingDevices: boolean = false; - @State lastError: string = ''; @State toastMsg: string = ''; @State toastIsError: boolean = false; /** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */ private navStack: NavPathStack = new NavPathStack(); - private autoConnectTried: boolean = false; aboutToAppear(): void { this.deviceId = deviceBridge.getDeviceId(); @@ -70,25 +55,9 @@ export struct DevicePage { // Gateway URL derives from current connection const cur = connStore.getCurrentConnection(); if (cur !== null) { - this.bridgeUrl = this.gatewayUrlOf(cur.url); + this.bridgeUrl = deviceGatewayUrl(cur.url); this.bridgeToken = cur.apiKey; } - installCmdRouter(); - try { - setBridgeAppContext(getContext(this) as common.UIAbilityContext); - } catch (e) { - // ignore context errors - } - deviceBridge.setStateListener((open: boolean) => { - this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { - this.bridgeConnected = open; - }); - if (open) { - this.lastError = ''; - this.showToast('设备网关已连接', false); - this.refreshDevices(); - } - }); this.refreshDevices(); // 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来 registerNavStack(2, this.navStack, () => { @@ -104,37 +73,6 @@ export struct DevicePage { return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; } - /** - * 把后端 HTTP 地址转成设备桥的 WebSocket 地址。 - * - * 关键:@ohos.net.webSocket 只接受 ws:// / wss:// 协议头, - * 直接把 http:// 传进 connect() 会在 native 层报 - * "protocol failed" + "ParseUrl failed"(NETSTACK websocket_exec.cpp), - * 表现为设备通道永远连不上。所以这里必须做协议替换。 - */ - private gatewayUrlOf(base: string): string { - let trimmed: string = base.trim(); - while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') { - trimmed = trimmed.substring(0, trimmed.length - 1); - } - let scheme: string = 'ws://'; - let rest: string = trimmed; - if (trimmed.startsWith('https://')) { - scheme = 'wss://'; - rest = trimmed.substring('https://'.length); - } else if (trimmed.startsWith('http://')) { - scheme = 'ws://'; - rest = trimmed.substring('http://'.length); - } else if (trimmed.startsWith('wss://')) { - scheme = 'wss://'; - rest = trimmed.substring('wss://'.length); - } else if (trimmed.startsWith('ws://')) { - scheme = 'ws://'; - rest = trimmed.substring('ws://'.length); - } - return scheme + rest + '/api/v1/device/ws'; - } - /** * 打开二级页面。 * @@ -170,45 +108,9 @@ export struct DevicePage { // ignore persist failure } deviceBridge.updateAuthorized(on); - if (!this.bridgeConnected) { - this.connectBridge(); - } this.showToast(on ? '已授权,agent 可下发能力命令' : '已取消授权', false); } - // ===================== gateway connection ===================== - - private async connectBridge(): Promise { - if (this.bridgeUrl.length === 0 || this.bridgeToken.length === 0) { - this.lastError = noConnectionMessage(); - return; - } - this.lastError = ''; - if (this.deviceId.length === 0) { - this.deviceId = 'ohos-' + Date.now().toString(36); - } - try { - connStore.saveDeviceId(this.deviceId); - } catch (e) { - // ignore - } - const name: string = 'HomeAgent OHOS'; - await deviceBridge.connect( - this.bridgeUrl, this.bridgeToken, this.deviceId, - LOCAL_CAPS, 'ohos-phone', this.authorized, name); - } - - /** 首次进入自动尝试连接(静默,失败不打扰)。 */ - private maybeAutoConnect(): void { - if (this.autoConnectTried || this.bridgeConnected) { - return; - } - this.autoConnectTried = true; - if (this.bridgeUrl.length > 0 && this.bridgeToken.length > 0) { - this.connectBridge(); - } - } - private showToast(msg: string, isError: boolean): void { // 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色 this.toastIsError = isError; @@ -287,9 +189,6 @@ export struct DevicePage { .onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => { handleNavOnScroll(state); }) - .onAppear(() => { - this.maybeAutoConnect(); - }) } .width('100%') .height('100%') @@ -479,7 +378,7 @@ export struct DevicePage { } private capsCount(): number { - return LOCAL_CAPS.length; + return LOCAL_DEVICE_CAPS.length; } // ===================== 二级:本机设备 ===================== @@ -542,7 +441,7 @@ export struct DevicePage { .margin({ bottom: 10 }) Flex({ wrap: FlexWrap.Wrap }) { - ForEach(LOCAL_CAPS, (cap: string) => { + ForEach(LOCAL_DEVICE_CAPS, (cap: string) => { Text(cap) .fontSize(11) .fontColor(this.palette().accent) @@ -567,39 +466,8 @@ export struct DevicePage { this.KvRow('状态', this.bridgeConnected ? '已连接' : '未连接') } - if (this.lastError.length > 0) { - Text(this.lastError) - .fontSize(12) - .fontColor('#E84026') - .padding({ left: 4 }) - .transition(TransitionEffect.OPACITY - .combine(TransitionEffect.translate({ y: -8 })) - .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) - } - PlainCard({ caption: '操作' }) { Row() { - // 缩放反馈由 MotionBase 统一;连接态的底色/字色切换仍需按钮自己缓动, - // 因为父容器的 .animation() 到不了子节点。 - MotionBase({ pressEnabled: true, fillWidth: false }) { - Button(this.bridgeConnected ? '断开' : '连接网关') - .height(34) - .fontSize(12) - .backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent) - .fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White) - .animation({ duration: ANIM_FAST, curve: Curve.EaseOut }) - .border({ - width: this.bridgeConnected ? 1 : 0, - color: this.palette().btnGhostBorder, - }) - .onClick(() => { - if (this.bridgeConnected) { - deviceBridge.disconnect(); - } else { - this.connectBridge(); - } - }) - } Blank() MotionBase({ pressEnabled: true, fillWidth: false }) { Button('刷新设备') @@ -615,7 +483,7 @@ export struct DevicePage { } .width('100%') - Text('进入本页自动连接;断开后每 5 秒自动重连。hello 登记能力与授权状态,bind 携带 Token 完成身份绑定。') + Text('设备通道由应用前台生命周期统一管理;切换连接配置后会自动使用新地址和 Token。') .fontSize(11) .fontColor(this.palette().textMuted) .margin({ top: 10 }) diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets index e0ee45c..b2143ba 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets @@ -11,7 +11,9 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants'; import { MotionBase } from '../components/MotionBase'; import { GradientBackground } from '../components/GradientBackground'; -import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter'; +import { ScreensuePage } from '../components/ScreensuePage'; +import { registerScreensueHandler } from '../common/BridgeRouter'; +import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession'; import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps'; import { window, display } from '@kit.ArkUI'; import { common } from '@kit.AbilityKit'; @@ -110,14 +112,14 @@ struct Index { this.syncSystemBar(); // 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44) this.resolveSafeArea(); - // 设备桥:注册命令路由与 screensue 悬浮层回调 - installCmdRouter(); + // 根 UI 只负责 screensue 呈现;命令路由由前台全局设备桥安装。 registerScreensueHandler((payload: ScreensuePayload) => { this.showScreensue(payload); }); + markForegroundBridgeUIReady(getContext(this) as common.UIAbilityContext); } - /** agent 下发的 screensue 内容展示(悬浮卡片,倒计时自动关闭;0=常驻)。 */ + /** agent 下发的 screensue 内容展示(窄屏整页、宽屏右栏;0=常驻)。 */ private showScreensue(payload: ScreensuePayload): void { this.screensueText = payload.content; this.screensueCountdown = payload.duration; @@ -218,7 +220,7 @@ struct Index { private updateWideScreen(w: number): void { const wide: boolean = w >= WIDE_MIN_WIDTH; if (wide !== this.isWide) { - AppStorage.setOrCreate('isWideScreen', wide); + AppStorage.set('isWideScreen', wide); } } @@ -230,6 +232,10 @@ struct Index { * 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。 */ onBackPress(): boolean { + if (this.screensueVisible) { + this.closeScreensue(); + return true; + } return handleBackPress(this.currentTab, this.isWide); } @@ -339,51 +345,42 @@ struct Index { // 导航栏本身不吃触摸空白区,避免遮住下层内容点击 .hitTestBehavior(HitTestMode.Transparent) - // screensue 悬浮层:agent 推送给用户看的内容(置顶展示) + // screensue 是前台内容页:窄屏覆盖整页;宽屏仅覆盖右侧内容栏, + // 左侧一级页面与主导航保持可见、可操作。 if (this.screensueVisible) { - Column() { + if (this.isWide) { Row() { - Circle({ width: 8, height: 8 }) - .fill(this.palette().accent) - Text('agent 推送') - .fontSize(12) - .fontWeight(FontWeight.Medium) - .fontColor(this.palette().textSecondary) - .margin({ left: 8 }) - Blank() - if (this.screensueCountdown > 0) { - Text(this.screensueCountdown.toString() + 's') - .fontSize(11) - .fontColor(this.palette().textMuted) - } - Text('关闭') - .fontSize(12) - .fontColor(this.palette().accent) - .padding({ left: 10, right: 2, top: 4, bottom: 4 }) - .onClick(() => { + Column() + .width(WIDE_NAV_BAR_WIDTH) + .height('100%') + .hitTestBehavior(HitTestMode.None) + + ScreensuePage({ + pushedText: this.screensueText, + countdown: this.screensueCountdown, + onClose: () => { this.closeScreensue(); - }) + }, + }) + .layoutWeight(1) + .height('100%') + .padding({ top: this.topInset }) } .width('100%') - .margin({ bottom: 10 }) - - Scroll() { - Text(this.screensueText) - .fontSize(15) - .fontColor(this.palette().textPrimary) - .width('100%') - } - .constraintSize({ maxHeight: 320 }) - .scrollBar(BarState.Auto) - .align(Alignment.Top) + .height('100%') + .hitTestBehavior(HitTestMode.Transparent) + } else { + ScreensuePage({ + pushedText: this.screensueText, + countdown: this.screensueCountdown, + onClose: () => { + this.closeScreensue(); + }, + }) + .width('100%') + .height('100%') + .padding({ top: this.topInset }) } - .width('86%') - .padding(18) - .borderRadius(18) - .backgroundColor(this.palette().bgCard) - - .border({ width: 1, color: this.palette().glassBorder }) - .shadow({ radius: 32, color: this.palette().shadow, offsetY: 10 }) } } .width('100%') diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets index ec45d56..e4a9ecd 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets @@ -13,6 +13,7 @@ import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../co import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage'; import { StatusSummaryCard, StatusDetailContent } from '../components/StatusCards'; import { statusStore } from '../common/StatusStore'; +import { restartForegroundBridge } from '../common/DeviceBridgeSession'; /** One settings key card rendered in the editor list. */ interface SettingEntry { @@ -201,6 +202,7 @@ export struct SettingsPage { apiClient.setConnection(cur); } this.loadConnections(); + restartForegroundBridge(getContext(this) as common.UIAbilityContext); this.showToast('已切换连接', false); }); } @@ -224,6 +226,7 @@ export struct SettingsPage { apiClient.setConnection(cur); } this.loadConnections(); + restartForegroundBridge(getContext(this) as common.UIAbilityContext); this.showToast('连接已添加', false); }); } @@ -242,6 +245,7 @@ export struct SettingsPage { apiClient.setConnection(cur); } this.loadConnections(); + restartForegroundBridge(getContext(this) as common.UIAbilityContext); this.showToast('连接已更新', false); }); } @@ -278,10 +282,13 @@ export struct SettingsPage { private deleteConnection(id: string): void { connStore.deleteConnection(id).then(() => { this.loadConnections(); - const cur = connStore.getCurrentConnection(); + const cur: ConnectionConfig | null = connStore.getCurrentConnection(); if (cur !== null) { apiClient.setConnection(cur); + } else { + apiClient.clearConnection(); } + restartForegroundBridge(getContext(this) as common.UIAbilityContext); this.showToast('连接已删除', false); }); } diff --git a/cmd/ohos/HomeAgent/entry/src/main/module.json5 b/cmd/ohos/HomeAgent/entry/src/main/module.json5 index 6dcbc37..0e79524 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/module.json5 +++ b/cmd/ohos/HomeAgent/entry/src/main/module.json5 @@ -17,6 +17,14 @@ }, { "name": "ohos.permission.GET_NETWORK_INFO" + }, + { + "name": "ohos.permission.READ_PASTEBOARD", + "reason": "$string:read_pasteboard_reason", + "usedScene": { + "abilities": ["EntryAbility"], + "when": "inuse" + } } ], "abilities": [ diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/element/string.json b/cmd/ohos/HomeAgent/entry/src/main/resources/base/element/string.json index 72792ba..68ffb65 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/resources/base/element/string.json +++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/element/string.json @@ -3,6 +3,10 @@ { "name": "app_name", "value": "HomeAgent" + }, + { + "name": "read_pasteboard_reason", + "value": "用于在应用前台按你的授权响应 agent 的剪贴板读取请求" } ] }