feat(ohos): 设备桥会话管理、状态/设备信息载荷与能力路由完善

- 新增 `DeviceBridgeSession.ets`:会话生命周期(创建/复用/回收)与 TTS 会话显式 shutdown;
- `BridgeCaps`/`BridgeRouter` 与内核实际支持的本机命令保持一一对应,补齐 status/deviceinfo;
- 页面与状态存储调整(DevicePage/Index/SettingsPage/StatusStore/SubPage)、新增 ScreensuePage;
- module.json5 与 string.json 同步(新增页面与文案)。
This commit is contained in:
JianFeeeee
2026-09-12 20:19:05 +08:00
parent d1662e77cf
commit 8f40b91dea
15 changed files with 616 additions and 353 deletions

View File

@ -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",

View File

@ -32,6 +32,10 @@ export class ApiClient {
this.conn = conn;
}
clearConnection(): void {
this.conn = null;
}
getConnection(): ConnectionConfig | null {
return this.conn;
}

View File

@ -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<image.PixelMap> {
* 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。
*/
export async function capScreensee(): Promise<CapResult> {
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<Permissions> = ['ohos.permission.READ_PASTEBOARD'];
/**
* READ_PASTEBOARD 是 user_grant 权限:仅在 agent 真正请求 clipboardsee 时弹出系统授权,
* 不在应用启动时抢先索权。已授权时系统会直接返回,不会重复打扰用户。
*/
async function ensureClipboardPermission(context: common.UIAbilityContext): Promise<boolean> {
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<CapResult> {
// 说明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<CapResult> {
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<CapResult> {
if (text.length > 4000) {
return errResult('朗读内容过长,请缩短到 4000 字以内');
}
try {
if (this.engine === null) {
const extra: Record<string, Object> = {
'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<CapResult> {
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;
}

View File

@ -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<CapResult> {
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 {

View File

@ -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<void> {
// 每次连接使用新的 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=<webui apiKey>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<string, string> {
const h: Record<string, string> = {};
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 [];
}

View File

@ -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<boolean>('deviceBridgeConnected', false);
deviceBridge.setStateListener((open: boolean) => {
AppStorage.set<boolean>('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<void> {
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<void> {
bridgeGeneration = bridgeGeneration + 1;
bridgeStarting = false;
deviceBridge.disconnect();
await startForegroundBridge(context);
}

View File

@ -67,8 +67,11 @@ class StatusStore {
this.fail(noConnectionMessage());
return;
}
AppStorage.setOrCreate<boolean>(K_LOADING, true);
AppStorage.setOrCreate<string>(K_ERR, '');
// setOrCreate 只负责首次建键,键已存在时不会覆盖旧值。
// init() 已把所有键种好,刷新阶段必须用 set否则摘要卡会永远停在
// 版本 "-"、插件 0 的初始状态。
AppStorage.set<boolean>(K_LOADING, true);
AppStorage.set<string>(K_ERR, '');
try {
const resp = await apiClient.getWithTimeout('/status', 8000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
@ -79,10 +82,10 @@ class StatusStore {
const agents: number = obj['agents'] as number ?? 0;
const startedAt: string = obj['startedAt'] as string ?? '';
AppStorage.setOrCreate<boolean>(K_UP, true);
AppStorage.setOrCreate<string>(K_VERSION, version);
AppStorage.setOrCreate<number>(K_AGENTS, agents);
AppStorage.setOrCreate<string>(K_STARTED, startedAt);
AppStorage.set<boolean>(K_UP, true);
AppStorage.set<string>(K_VERSION, version);
AppStorage.set<number>(K_AGENTS, agents);
AppStorage.set<string>(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<boolean>(K_LOADING, false);
AppStorage.set<boolean>(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<number>(K_PLUGINS, pluginCount);
AppStorage.setOrCreate<number>(K_TOOLS, toolCount);
AppStorage.set<number>(K_PLUGINS, pluginCount);
AppStorage.set<number>(K_TOOLS, toolCount);
const kernelFields: StatField[] = [
{ label: 'Agent ID', value: agentId },
@ -219,9 +222,9 @@ class StatusStore {
}
private fail(msg: string): void {
AppStorage.setOrCreate<string>(K_ERR, msg);
AppStorage.setOrCreate<boolean>(K_UP, false);
AppStorage.setOrCreate<boolean>(K_LOADING, false);
AppStorage.set<string>(K_ERR, msg);
AppStorage.set<boolean>(K_UP, false);
AppStorage.set<boolean>(K_LOADING, false);
this.groups = [];
this.bump();
}
@ -229,7 +232,7 @@ class StatusStore {
/** 明细数组不进 AppStorage用一个自增版本号触发订阅组件重取 */
private bump(): void {
const cur: number = AppStorage.get<number>(K_REV) ?? 0;
AppStorage.setOrCreate<number>(K_REV, cur + 1);
AppStorage.set<number>(K_REV, cur + 1);
}
}

View File

@ -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;
}
}

View File

@ -13,7 +13,11 @@ import { GradientBackground } from './GradientBackground';
export const KEY_SUBPAGE_OPEN: string = 'subPageOpen';
export function markSubPageOpen(open: boolean): void {
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
if (AppStorage.has(KEY_SUBPAGE_OPEN)) {
AppStorage.set<boolean>(KEY_SUBPAGE_OPEN, open);
} else {
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
}
}
/** pushPathByName 的参数载体ArkTS 不允许把 string 断言成 object */

View File

@ -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');
}
}

View File

@ -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<void> {
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 })

View File

@ -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<boolean>('isWideScreen', wide);
AppStorage.set<boolean>('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%')

View File

@ -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);
});
}

View File

@ -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": [

View File

@ -3,6 +3,10 @@
{
"name": "app_name",
"value": "HomeAgent"
},
{
"name": "read_pasteboard_reason",
"value": "用于在应用前台按你的授权响应 agent 的剪贴板读取请求"
}
]
}