From a82efbd1b5666e35ee49c623c002ee1f8ee4fe76 Mon Sep 17 00:00:00 2001
From: Peanut-Puff <97236799+Peanut-Puff@users.noreply.github.com>
Date: Tue, 4 Aug 2026 20:48:54 +0800
Subject: [PATCH] feat(ohos): implement privacy and anonymous feedback
---
package.json | 1 +
.../core-boundaries/rules/feature-rules.mjs | 21 +-
scripts/core-boundaries/self-test.mjs | 28 +
scripts/feedback-mock-server.mjs | 424 +++
src/apps/desktop/Cargo.toml | 3 +-
src/apps/desktop/src/api/feedback_api.rs | 179 ++
src/apps/desktop/src/api/mod.rs | 4 +-
.../src/api/ohos/feedback_credentials.rs | 84 +
src/apps/desktop/src/api/ohos/mod.rs | 4 +-
src/apps/desktop/src/api/privacy_api.rs | 229 ++
.../desktop/src/api/remote_connect_api.rs | 3 +
.../src/api/remote_workspace_policy.rs | 36 +
src/apps/desktop/src/api/system_api.rs | 7 +-
src/apps/desktop/src/lib.rs | 76 +-
.../main/ets/entryability/EntryAbility.ets | 16 +-
.../ets/utils/FeedbackCredentialStore.ets | 247 ++
.../contracts/product-domains/src/feedback.rs | 251 ++
.../contracts/product-domains/src/lib.rs | 2 +
.../contracts/product-domains/src/privacy.rs | 137 +
.../services/services-integrations/Cargo.toml | 18 +
.../src/feedback/identity.rs | 85 +
.../src/feedback/message_cache.rs | 186 ++
.../services-integrations/src/feedback/mod.rs | 8 +
.../src/feedback/service.rs | 2528 +++++++++++++++++
.../src/feedback/state_cache.rs | 187 ++
.../src/feedback/vault.rs | 156 +
.../services/services-integrations/src/lib.rs | 6 +
.../src/privacy/assets/zh-CN.md | 114 +
.../services-integrations/src/privacy/mod.rs | 582 ++++
src/web-ui/src/app/BusinessApplication.tsx | 21 +
.../components/AboutDialog/AboutDialog.tsx | 28 +-
.../FeedbackConversationView.tsx | 530 ++++
.../FeedbackDialog/FeedbackDialog.scss | 653 +++++
.../FeedbackDialog/FeedbackDialog.tsx | 506 ++++
.../FeedbackDialog/FeedbackInboxView.tsx | 253 ++
.../FeedbackDialog/PrivacyStatementLink.tsx | 26 +
.../feedbackConversationContract.test.ts | 98 +
.../FeedbackDialog/feedbackInboxStore.test.ts | 118 +
.../FeedbackDialog/feedbackInboxStore.ts | 143 +
.../feedbackSubmissionContract.test.ts | 123 +
.../app/components/FeedbackDialog/index.ts | 2 +
.../src/app/components/NavPanel/NavPanel.scss | 25 +
.../components/PersistentFooterActions.tsx | 94 +-
.../src/app/components/Privacy/Privacy.scss | 236 ++
.../app/components/Privacy/PrivacyContext.tsx | 82 +
.../components/Privacy/PrivacyDocument.tsx | 16 +
.../app/components/Privacy/PrivacyGate.tsx | 255 ++
.../Privacy/PrivacyStatementDialog.tsx | 233 ++
.../src/app/components/Privacy/index.ts | 3 +
.../components/Privacy/privacyGateCopy.json | 53 +
.../Privacy/privacyLifecycleContract.test.ts | 104 +
.../privacyPolicyManagementContract.test.ts | 67 +
src/web-ui/src/app/layout/AppLayout.tsx | 19 +-
.../ConfirmDialog/ConfirmDialog.tsx | 12 +
.../components/Modal/Modal.test.tsx | 108 +-
.../components/Modal/Modal.tsx | 71 +-
src/web-ui/src/infrastructure/api/index.ts | 5 +-
.../api/service-api/ApiClient.test.ts | 48 +-
.../api/service-api/ApiClient.ts | 59 +-
.../api/service-api/FeedbackAPI.test.ts | 133 +
.../api/service-api/FeedbackAPI.ts | 233 ++
.../api/service-api/PrivacyAPI.test.ts | 83 +
.../api/service-api/PrivacyAPI.ts | 123 +
.../api/service-api/SystemAPI.ts | 8 +-
.../infrastructure/api/service-api/types.ts | 4 +-
src/web-ui/src/locales/en-US/common.json | 149 +-
src/web-ui/src/locales/zh-CN/common.json | 149 +-
src/web-ui/src/locales/zh-TW/common.json | 149 +-
src/web-ui/src/main.tsx | 21 +-
.../criticalOperationExitGuard.test.ts | 28 +
.../services/criticalOperationExitGuard.ts | 21 +
71 files changed, 10603 insertions(+), 111 deletions(-)
create mode 100644 scripts/feedback-mock-server.mjs
create mode 100644 src/apps/desktop/src/api/feedback_api.rs
create mode 100644 src/apps/desktop/src/api/ohos/feedback_credentials.rs
create mode 100644 src/apps/desktop/src/api/privacy_api.rs
create mode 100644 src/apps/ohos/entry/src/main/ets/utils/FeedbackCredentialStore.ets
create mode 100644 src/crates/contracts/product-domains/src/feedback.rs
create mode 100644 src/crates/contracts/product-domains/src/privacy.rs
create mode 100644 src/crates/services/services-integrations/src/feedback/identity.rs
create mode 100644 src/crates/services/services-integrations/src/feedback/message_cache.rs
create mode 100644 src/crates/services/services-integrations/src/feedback/mod.rs
create mode 100644 src/crates/services/services-integrations/src/feedback/service.rs
create mode 100644 src/crates/services/services-integrations/src/feedback/state_cache.rs
create mode 100644 src/crates/services/services-integrations/src/feedback/vault.rs
create mode 100644 src/crates/services/services-integrations/src/privacy/assets/zh-CN.md
create mode 100644 src/crates/services/services-integrations/src/privacy/mod.rs
create mode 100644 src/web-ui/src/app/BusinessApplication.tsx
create mode 100644 src/web-ui/src/app/components/FeedbackDialog/FeedbackConversationView.tsx
create mode 100644 src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.scss
create mode 100644 src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.tsx
create mode 100644 src/web-ui/src/app/components/FeedbackDialog/FeedbackInboxView.tsx
create mode 100644 src/web-ui/src/app/components/FeedbackDialog/PrivacyStatementLink.tsx
create mode 100644 src/web-ui/src/app/components/FeedbackDialog/feedbackConversationContract.test.ts
create mode 100644 src/web-ui/src/app/components/FeedbackDialog/feedbackInboxStore.test.ts
create mode 100644 src/web-ui/src/app/components/FeedbackDialog/feedbackInboxStore.ts
create mode 100644 src/web-ui/src/app/components/FeedbackDialog/feedbackSubmissionContract.test.ts
create mode 100644 src/web-ui/src/app/components/FeedbackDialog/index.ts
create mode 100644 src/web-ui/src/app/components/Privacy/Privacy.scss
create mode 100644 src/web-ui/src/app/components/Privacy/PrivacyContext.tsx
create mode 100644 src/web-ui/src/app/components/Privacy/PrivacyDocument.tsx
create mode 100644 src/web-ui/src/app/components/Privacy/PrivacyGate.tsx
create mode 100644 src/web-ui/src/app/components/Privacy/PrivacyStatementDialog.tsx
create mode 100644 src/web-ui/src/app/components/Privacy/index.ts
create mode 100644 src/web-ui/src/app/components/Privacy/privacyGateCopy.json
create mode 100644 src/web-ui/src/app/components/Privacy/privacyLifecycleContract.test.ts
create mode 100644 src/web-ui/src/app/components/Privacy/privacyPolicyManagementContract.test.ts
create mode 100644 src/web-ui/src/infrastructure/api/service-api/FeedbackAPI.test.ts
create mode 100644 src/web-ui/src/infrastructure/api/service-api/FeedbackAPI.ts
create mode 100644 src/web-ui/src/infrastructure/api/service-api/PrivacyAPI.test.ts
create mode 100644 src/web-ui/src/infrastructure/api/service-api/PrivacyAPI.ts
create mode 100644 src/web-ui/src/shared/services/criticalOperationExitGuard.test.ts
create mode 100644 src/web-ui/src/shared/services/criticalOperationExitGuard.ts
diff --git a/package.json b/package.json
index ddf3316196..551de00812 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"desktop:preview:debug": "node scripts/dev.cjs desktop-preview",
"desktop:dev:raw": "cross-env-shell CI=true \"cd src/apps/desktop && tauri dev\"",
"target:gc": "node scripts/cargo-target-gc.mjs",
+ "feedback:mock": "node scripts/feedback-mock-server.mjs",
"desktop:build": "node scripts/desktop-tauri-build.mjs",
"desktop:build:fast": "node scripts/desktop-tauri-build.mjs --debug --no-bundle",
"desktop:build:release-fast": "node scripts/desktop-tauri-build.mjs --no-bundle -- --profile release-fast --features devtools",
diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs
index 6e118b17ca..c0829c3e4e 100644
--- a/scripts/core-boundaries/rules/feature-rules.mjs
+++ b/scripts/core-boundaries/rules/feature-rules.mjs
@@ -57,26 +57,26 @@ export const optionalDependencyFeatureOwnerRules = [
'services-integrations optional runtime dependencies must stay owned by explicit integration features',
dependencies: [
{ depName: 'aes', ownerFeatures: ['remote-connect'] },
- { depName: 'aes-gcm', ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete'] },
- { depName: 'anyhow', ownerFeatures: ['browser-control', 'debug-log', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] },
+ { depName: 'aes-gcm', ownerFeatures: ['feedback', 'mcp', 'remote-connect', 'remote-ssh-concrete'] },
+ { depName: 'anyhow', ownerFeatures: ['browser-control', 'debug-log', 'feedback', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] },
{
depName: 'async-trait',
- ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'workspace-search'],
+ ownerFeatures: ['feedback', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'workspace-search'],
},
{
depName: 'base64',
- ownerFeatures: ['mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete', 'speech'],
+ ownerFeatures: ['feedback', 'mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete', 'speech'],
},
{ depName: 'bitfun-agent-runtime', ownerFeatures: ['deep-research'] },
{ depName: 'bitfun-core-types', ownerFeatures: ['speech'] },
- { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'miniapp-runtime', 'plugin-source'] },
+ { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'feedback', 'function-agents', 'miniapp-runtime', 'plugin-source', 'privacy'] },
{ depName: 'bitfun-runtime-ports', ownerFeatures: ['remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] },
{
depName: 'bitfun-services-core',
ownerFeatures: ['browser-control', 'git', 'mcp', 'miniapp-runtime', 'process-tree', 'remote-connect', 'review-platform', 'workspace-search'],
},
{ depName: 'bzip2', ownerFeatures: ['speech'] },
- { depName: 'chrono', ownerFeatures: ['debug-log', 'git', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech'] },
+ { depName: 'chrono', ownerFeatures: ['debug-log', 'feedback', 'git', 'privacy', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech'] },
{ depName: 'dirs', ownerFeatures: ['browser-control', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete'] },
{ depName: 'dunce', ownerFeatures: ['plugin-source', 'remote-ssh', 'workspace-search'] },
{ depName: 'fs2', ownerFeatures: ['plugin-source'] },
@@ -93,11 +93,11 @@ export const optionalDependencyFeatureOwnerRules = [
{ depName: 'notify', ownerFeatures: ['file-watch'] },
{ depName: 'oxc', ownerFeatures: ['canvas-runtime'] },
{ depName: 'qrcode', ownerFeatures: ['remote-connect'] },
- { depName: 'rand', ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete'] },
+ { depName: 'rand', ownerFeatures: ['feedback', 'mcp', 'remote-connect', 'remote-ssh-concrete'] },
// remote-ssh-concrete: one-click relay deploy fetches the signed release
// checksum over HTTPS and verifies it on this device, because the target
// server has no minisign and no trust root of its own.
- { depName: 'reqwest', ownerFeatures: ['announcement', 'browser-control', 'debug-log', 'mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools'] },
+ { depName: 'reqwest', ownerFeatures: ['announcement', 'browser-control', 'debug-log', 'feedback', 'mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools'] },
{ depName: 'rmcp', ownerFeatures: ['mcp'] },
{ depName: 'russh', ownerFeatures: ['remote-ssh-concrete'] },
{ depName: 'russh-keys', ownerFeatures: ['remote-ssh-concrete'] },
@@ -105,7 +105,7 @@ export const optionalDependencyFeatureOwnerRules = [
{ depName: 'rustls', ownerFeatures: ['remote-connect'] },
{ depName: 'rustls-native-certs', ownerFeatures: ['remote-connect'] },
{ depName: 'schannel', ownerFeatures: ['remote-connect'] },
- { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'plugin-source', 'remote-connect', 'remote-ssh', 'review-platform', 'speech'] },
+ { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'feedback', 'plugin-source', 'privacy', 'remote-connect', 'remote-ssh', 'review-platform', 'speech'] },
{ depName: 'sherpa-onnx', ownerFeatures: ['speech'] },
{ depName: 'shellexpand', ownerFeatures: ['remote-ssh-concrete'] },
{ depName: 'sse-stream', ownerFeatures: ['mcp'] },
@@ -116,7 +116,7 @@ export const optionalDependencyFeatureOwnerRules = [
{ depName: 'tokio-tungstenite', ownerFeatures: ['remote-connect'] },
{ depName: 'tokio-util', ownerFeatures: ['remote-ssh', 'speech'] },
{ depName: 'urlencoding', ownerFeatures: ['canvas-runtime', 'remote-connect', 'review-platform'] },
- { depName: 'uuid', ownerFeatures: ['canvas-runtime', 'debug-log', 'miniapp-runtime', 'plugin-source', 'remote-connect', 'remote-ssh-concrete', 'speech'] },
+ { depName: 'uuid', ownerFeatures: ['canvas-runtime', 'debug-log', 'feedback', 'miniapp-runtime', 'plugin-source', 'remote-connect', 'remote-ssh-concrete', 'speech'] },
{ depName: 'which', ownerFeatures: ['miniapp-runtime', 'remote-connect', 'script-tool-runtime', 'workspace-search'] },
{ depName: 'windows', ownerFeatures: ['plugin-source', 'review-platform'] },
{ depName: 'x25519-dalek', ownerFeatures: ['remote-connect'] },
@@ -212,6 +212,7 @@ export const ownerCrateFeatureAssemblyRules = [
'debug-log',
'deep-research',
'file-watch',
+ 'feedback',
'function-agents',
'git',
'miniapp-runtime',
diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs
index 2765725e56..e585a3bdb5 100644
--- a/scripts/core-boundaries/self-test.mjs
+++ b/scripts/core-boundaries/self-test.mjs
@@ -669,6 +669,34 @@ export function runManifestParserSelfTest({
const servicesOptionalOwnerDeps = new Set(
servicesOptionalOwnerRule?.dependencies.map((dependency) => dependency.depName) ?? [],
);
+
+ const expectedFeatureDependencies = {
+ feedback: [
+ 'aes-gcm',
+ 'anyhow',
+ 'async-trait',
+ 'base64',
+ 'bitfun-product-domains',
+ 'chrono',
+ 'rand',
+ 'reqwest',
+ 'sha2',
+ 'uuid',
+ ],
+ privacy: ['bitfun-product-domains', 'chrono', 'sha2'],
+ };
+ for (const [featureName, dependencies] of Object.entries(expectedFeatureDependencies)) {
+ for (const dep of dependencies) {
+ const owner = servicesOptionalOwnerRule?.dependencies.find(
+ (dependency) => dependency.depName === dep,
+ );
+ if (!owner?.ownerFeatures.includes(featureName)) {
+ throw new Error(
+ `services-integrations ${featureName} must own optional dependency ${dep}`,
+ );
+ }
+ }
+ }
for (const dep of servicesIntegrationsDefaultProfile?.forbiddenNonOptionalDeps ?? []) {
if (!servicesOptionalOwnerDeps.has(dep)) {
throw new Error(
diff --git a/scripts/feedback-mock-server.mjs b/scripts/feedback-mock-server.mjs
new file mode 100644
index 0000000000..ca596f57be
--- /dev/null
+++ b/scripts/feedback-mock-server.mjs
@@ -0,0 +1,424 @@
+import http from 'node:http';
+import { randomUUID } from 'node:crypto';
+
+const port = Number.parseInt(process.env.BITFUN_FEEDBACK_MOCK_PORT ?? '38971', 10);
+const enrollments = new Map();
+const refreshTokens = new Map();
+const createRequests = new Map();
+const replyRequests = new Map();
+const records = new Map();
+let nextFault = null;
+
+const server = http.createServer(async (request, response) => {
+ const requestId = normalizeRequestId(header(request, 'x-request-id'));
+ const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
+ logRequestStage(requestStage(request.method, url.pathname), requestId);
+
+ if (request.method === 'GET' && url.pathname === '/health') {
+ return send(response, 200, { status: 'ok' }, requestId);
+ }
+ if (request.method === 'POST' && url.pathname === '/__mock/control') {
+ const body = await readJson(request);
+ nextFault = typeof body.fault === 'string' ? body.fault : null;
+ if (Number.isInteger(body.seed_inbox) && body.seed_inbox > 0) {
+ seedInbox(Math.min(body.seed_inbox, 120));
+ }
+ if (body.admin_reply && typeof body.admin_reply.feedback_id === 'string') {
+ addAdminReply(body.admin_reply.feedback_id, body.admin_reply.content ?? 'Mock support reply');
+ }
+ if (body.seed_messages && typeof body.seed_messages.feedback_id === 'string') {
+ seedMessages(body.seed_messages.feedback_id, Math.min(body.seed_messages.count ?? 0, 250));
+ }
+ return send(response, 200, { next_fault: nextFault, records: records.size }, requestId);
+ }
+
+ const fault = takeFault();
+ if (fault === 'timeout') {
+ setTimeout(() => sendError(response, 500, 'INTERNAL_ERROR', requestId), 25_000);
+ return;
+ }
+ if (fault === '403') return sendError(response, 403, 'SCOPE_INSUFFICIENT', requestId);
+ if (fault === '429') return sendError(response, 429, 'RATE_LIMITED', requestId, { 'Retry-After': '30' });
+ if (fault === '5xx') return sendError(response, 503, 'INTERNAL_ERROR', requestId);
+ if (fault === '401') return sendError(response, 401, 'ACCESS_TOKEN_INVALID', requestId);
+ if (fault === 'cursor_invalid') return sendError(response, 400, 'CURSOR_INVALID', requestId);
+ if (fault === 'capability_invalid') return sendError(response, 403, 'CAPABILITY_INVALID', requestId);
+
+ if (request.method === 'POST' && url.pathname === '/auth/v1/anonymous/enroll') {
+ const body = await readJson(request);
+ const idempotencyKey = requireUuidHeader(request, response, requestId);
+ if (!idempotencyKey) return;
+ if (typeof body.key !== 'string' || body.key.length === 0) {
+ return sendError(response, 400, 'ENROLL_KEY_REQUIRED', requestId);
+ }
+ const identity = `${body.key}:${idempotencyKey}`;
+ const existing = enrollments.get(identity);
+ if (existing) return send(response, 201, existing, requestId, { 'Idempotency-Replayed': 'true' });
+ const created = tokenPair(randomUUID());
+ enrollments.set(identity, created);
+ refreshTokens.set(created.refresh_token, created.anonymous_id);
+ return send(response, 201, created, requestId, { 'Idempotency-Replayed': 'false' });
+ }
+
+ if (request.method === 'POST' && url.pathname === '/auth/v1/anonymous/token') {
+ const body = await readJson(request);
+ const anonymousId = refreshTokens.get(body.refresh_token);
+ if (!anonymousId) return sendError(response, 401, 'REFRESH_TOKEN_INVALID', requestId);
+ refreshTokens.delete(body.refresh_token);
+ const refreshed = tokenPair(anonymousId);
+ refreshTokens.set(refreshed.refresh_token, anonymousId);
+ return send(response, 200, refreshed, requestId);
+ }
+
+ if (request.method === 'POST' && url.pathname === '/support/v1/feedback') {
+ if (!validBearer(request)) return sendError(response, 401, 'ACCESS_TOKEN_INVALID', requestId);
+ const body = await readJson(request);
+ const idempotencyKey = requireUuidHeader(request, response, requestId);
+ if (!idempotencyKey) return;
+ const fingerprint = JSON.stringify(body);
+ const existing = createRequests.get(idempotencyKey);
+ if (existing && existing.fingerprint !== fingerprint) {
+ return sendError(response, 409, 'FEEDBACK_IDEMPOTENT_CONFLICT', requestId);
+ }
+ if (existing) {
+ return send(response, 201, { ...existing.result, idempotency_replayed: true }, requestId, {
+ 'Idempotency-Replayed': 'true',
+ });
+ }
+ if (!['runtime_error', 'feature_request', 'usage_question', 'other'].includes(body.category)) {
+ return sendError(response, 400, 'CATEGORY_INVALID', requestId);
+ }
+ if (typeof body.content !== 'string' || body.content.trim().length === 0) {
+ return sendError(response, 400, 'CONTENT_EMPTY', requestId);
+ }
+ if (Array.from(body.content.trim()).length > 2_000) {
+ return sendError(response, 400, 'CONTENT_TOO_LONG', requestId);
+ }
+ const feedbackId = randomUUID();
+ const createdAt = new Date().toISOString();
+ const result = {
+ feedback_id: feedbackId,
+ capability_token: randomUUID(),
+ status: 'submitted',
+ inbox_cursor: new Date().toISOString(),
+ schema_version: '1.0.0',
+ };
+ if (fault === 'capability_missing') delete result.capability_token;
+ createRequests.set(idempotencyKey, { fingerprint, result });
+ records.set(feedbackId, {
+ ...body,
+ ...result,
+ has_new_reply: false,
+ created_at: createdAt,
+ updated_at: createdAt,
+ read_cursor: createdAt,
+ messages: [{
+ message_id: randomUUID(),
+ sender_type: 'user',
+ content: body.content.trim(),
+ content_deleted: false,
+ created_at: createdAt,
+ }],
+ });
+ return send(response, 201, result, requestId, { 'Idempotency-Replayed': 'false' });
+ }
+
+ if (request.method === 'GET' && url.pathname === '/support/v1/feedback/inbox') {
+ if (!validBearer(request)) return sendError(response, 401, 'ACCESS_TOKEN_INVALID', requestId);
+ const limit = parseLimit(url.searchParams.get('limit'), 20, 100);
+ if (limit === null) return sendError(response, 400, 'PAGE_SIZE_INVALID', requestId);
+ const offset = decodeCursor(url.searchParams.get('cursor'));
+ if (offset === null) return sendError(response, 400, 'CURSOR_INVALID', requestId);
+ const ordered = [...records.values()].sort((left, right) =>
+ right.created_at.localeCompare(left.created_at) || right.feedback_id.localeCompare(left.feedback_id));
+ const page = ordered.slice(offset, offset + limit);
+ const nextOffset = offset + page.length;
+ return send(response, 200, {
+ items: page.map(record => ({
+ feedback_id: record.feedback_id,
+ category: record.category,
+ status: record.status,
+ has_new_reply: record.has_new_reply,
+ created_at: record.created_at,
+ updated_at: record.updated_at,
+ })),
+ cursor: encodeCursor(nextOffset),
+ has_more: nextOffset < ordered.length,
+ }, requestId);
+ }
+
+ const messagesMatch = /^\/support\/v1\/feedback\/([^/]+)\/messages$/.exec(url.pathname);
+ if (request.method === 'POST' && messagesMatch) {
+ const record = authorizeRecord(request, response, requestId, messagesMatch[1]);
+ if (!record) return;
+ const idempotencyKey = requireUuidHeader(request, response, requestId);
+ if (!idempotencyKey) return;
+ const body = await readJson(request);
+ const fingerprint = `${record.feedback_id}:${JSON.stringify(body)}`;
+ const existing = replyRequests.get(idempotencyKey);
+ if (existing && existing.fingerprint !== fingerprint) {
+ return sendError(response, 409, 'IDEMPOTENCY_CONFLICT', requestId);
+ }
+ if (existing) {
+ return send(response, 201, existing.result, requestId, {
+ 'Idempotency-Replayed': 'true',
+ });
+ }
+ if (record.status === 'resolved') {
+ return sendError(response, 409, 'FEEDBACK_ALREADY_RESOLVED', requestId);
+ }
+ if (typeof body.content !== 'string' || body.content.trim().length === 0) {
+ return sendError(response, 400, 'CONTENT_EMPTY', requestId);
+ }
+ if (Array.from(body.content.trim()).length > 2_000) {
+ return sendError(response, 400, 'CONTENT_TOO_LONG', requestId);
+ }
+ const previousTime = Date.parse(record.messages.at(-1)?.created_at ?? record.created_at);
+ const createdAt = new Date(Math.max(Date.now(), previousTime + 1)).toISOString();
+ const message = {
+ message_id: randomUUID(),
+ sender_type: 'user',
+ content: body.content.trim(),
+ content_deleted: false,
+ created_at: createdAt,
+ };
+ record.messages.push(message);
+ record.status = 'in_progress';
+ record.has_new_reply = false;
+ record.updated_at = createdAt;
+ const result = {
+ message_id: message.message_id,
+ sender_type: message.sender_type,
+ created_at: message.created_at,
+ feedback_status: record.status,
+ };
+ replyRequests.set(idempotencyKey, { fingerprint, result });
+ return send(response, 201, result, requestId, { 'Idempotency-Replayed': 'false' });
+ }
+ if (request.method === 'GET' && messagesMatch) {
+ const record = authorizeRecord(request, response, requestId, messagesMatch[1]);
+ if (!record) return;
+ const limit = parseLimit(url.searchParams.get('limit'), 50, 200);
+ if (limit === null) return sendError(response, 400, 'PAGE_SIZE_INVALID', requestId);
+ const offset = decodeCursor(url.searchParams.get('cursor'), 'messages');
+ if (offset === null) return sendError(response, 400, 'CURSOR_INVALID', requestId);
+ const page = record.messages.slice(offset, offset + limit);
+ const nextOffset = offset + page.length;
+ return send(response, 200, {
+ feedback_id: record.feedback_id,
+ messages: page,
+ cursor: encodeCursor(nextOffset, 'messages'),
+ has_more: nextOffset < record.messages.length,
+ }, requestId);
+ }
+
+ const ackMatch = /^\/support\/v1\/feedback\/([^/]+)\/ack$/.exec(url.pathname);
+ if (request.method === 'POST' && ackMatch) {
+ const record = authorizeRecord(request, response, requestId, ackMatch[1]);
+ if (!record) return;
+ const body = await readJson(request);
+ const requested = Date.parse(body.read_cursor);
+ if (!Number.isFinite(requested)) return sendError(response, 400, 'READ_CURSOR_INVALID', requestId);
+ const latest = record.messages.at(-1)?.created_at ?? record.created_at;
+ const effective = new Date(Math.max(
+ Date.parse(record.read_cursor ?? record.created_at),
+ Math.min(requested, Date.parse(latest)),
+ )).toISOString();
+ record.read_cursor = effective;
+ if (record.status === 'waiting_user' && Date.parse(effective) >= Date.parse(latest)) {
+ record.status = 'in_progress';
+ }
+ record.has_new_reply = record.messages.some(message =>
+ message.sender_type === 'admin' && Date.parse(message.created_at) > Date.parse(effective));
+ return send(response, 200, {
+ feedback_id: record.feedback_id,
+ read_cursor: effective,
+ feedback_status: record.status,
+ }, requestId);
+ }
+
+ return sendError(response, 404, 'NOT_FOUND', requestId);
+});
+
+server.listen(port, '127.0.0.1', () => {
+ process.stdout.write(`Feedback mock listening at http://127.0.0.1:${port}\n`);
+});
+
+function tokenPair(anonymousId) {
+ return {
+ anonymous_id: anonymousId,
+ access_token: `access-${randomUUID()}`,
+ refresh_token: `refresh-${randomUUID()}`,
+ expires_in: 3_600,
+ refresh_expires_in: 2_592_000,
+ scope: 'feedback:write,feedback:read',
+ schema_version: '1.0.0',
+ };
+}
+
+function seedInbox(count) {
+ for (let index = 0; index < count; index += 1) {
+ const feedbackId = randomUUID();
+ const createdAt = new Date(Date.now() - index * 60_000).toISOString();
+ records.set(feedbackId, {
+ feedback_id: feedbackId,
+ category: ['runtime_error', 'feature_request', 'usage_question', 'other'][index % 4],
+ status: ['submitted', 'in_progress', 'waiting_user', 'resolved'][index % 4],
+ has_new_reply: index % 3 === 0,
+ created_at: createdAt,
+ updated_at: createdAt,
+ });
+ }
+}
+
+function parseLimit(value, fallback, maximum) {
+ if (value === null) return fallback;
+ const parsed = Number.parseInt(value, 10);
+ return Number.isInteger(parsed) && parsed >= 1 && parsed <= maximum ? parsed : null;
+}
+
+function encodeCursor(offset, kind = 'inbox') {
+ return Buffer.from(`${kind}:${offset}`, 'utf8').toString('base64url');
+}
+
+function decodeCursor(cursor, kind = 'inbox') {
+ if (cursor === null) return 0;
+ try {
+ const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
+ const match = new RegExp(`^${kind}:(\\d+)$`).exec(decoded);
+ return match ? Number.parseInt(match[1], 10) : null;
+ } catch {
+ return null;
+ }
+}
+
+function authorizeRecord(request, response, requestId, feedbackId) {
+ if (!validBearer(request)) {
+ sendError(response, 401, 'ACCESS_TOKEN_INVALID', requestId);
+ return null;
+ }
+ const record = records.get(feedbackId);
+ if (!record) {
+ sendError(response, 404, 'FEEDBACK_NOT_FOUND', requestId);
+ return null;
+ }
+ if (!record.capability_token
+ || header(request, 'x-feedback-capability') !== record.capability_token) {
+ sendError(response, 403, 'CAPABILITY_INVALID', requestId);
+ return null;
+ }
+ return record;
+}
+
+function addAdminReply(feedbackId, content) {
+ const record = records.get(feedbackId);
+ if (!record || !Array.isArray(record.messages)) return;
+ const createdAt = new Date(Date.now() + record.messages.length).toISOString();
+ record.messages.push({
+ message_id: randomUUID(),
+ sender_type: 'admin',
+ content: String(content),
+ content_deleted: false,
+ created_at: createdAt,
+ });
+ record.status = 'waiting_user';
+ record.has_new_reply = true;
+ record.updated_at = createdAt;
+}
+
+function seedMessages(feedbackId, count) {
+ const record = records.get(feedbackId);
+ if (!record || !Array.isArray(record.messages) || count <= 0) return;
+ for (let index = 0; index < count; index += 1) {
+ const createdAt = new Date(Date.parse(record.created_at) + (index + 1) * 1_000).toISOString();
+ record.messages.push({
+ message_id: randomUUID(),
+ sender_type: index % 2 === 0 ? 'admin' : 'user',
+ content: `Mock message ${index + 1}`,
+ content_deleted: false,
+ created_at: createdAt,
+ });
+ }
+ record.status = 'waiting_user';
+ record.has_new_reply = true;
+ record.updated_at = record.messages.at(-1).created_at;
+}
+
+function takeFault() {
+ const fault = nextFault;
+ nextFault = null;
+ return fault;
+}
+
+function validBearer(request) {
+ return /^Bearer access-/.test(header(request, 'authorization') ?? '');
+}
+
+function requireUuidHeader(request, response, requestId) {
+ const value = header(request, 'idempotency-key');
+ if (!value || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
+ sendError(response, 409, 'IDEMPOTENCY_KEY_INVALID', requestId);
+ return null;
+ }
+ return value;
+}
+
+function header(request, name) {
+ const value = request.headers[name];
+ return Array.isArray(value) ? value[0] : value;
+}
+
+function normalizeRequestId(value) {
+ return typeof value === 'string' && /^[a-zA-Z0-9._:-]{1,128}$/.test(value)
+ ? value
+ : randomUUID();
+}
+
+function requestStage(method, pathname) {
+ if (method === 'GET' && pathname === '/health') return 'health';
+ if (method === 'POST' && pathname === '/__mock/control') return 'control';
+ if (method === 'POST' && pathname === '/auth/v1/anonymous/enroll') return 'enroll';
+ if (method === 'POST' && pathname === '/auth/v1/anonymous/token') return 'refresh';
+ if (method === 'POST' && pathname === '/support/v1/feedback') return 'create';
+ if (method === 'GET' && pathname === '/support/v1/feedback/inbox') return 'inbox';
+ if (/^\/support\/v1\/feedback\/[^/]+\/messages$/.test(pathname)) {
+ return method === 'GET' ? 'message_history' : method === 'POST' ? 'reply' : 'unknown';
+ }
+ if (method === 'POST' && /^\/support\/v1\/feedback\/[^/]+\/ack$/.test(pathname)) {
+ return 'acknowledge';
+ }
+ return 'unknown';
+}
+
+function logRequestStage(stage, requestId) {
+ process.stdout.write(`${JSON.stringify({ stage, requestId })}\n`);
+}
+
+async function readJson(request) {
+ const chunks = [];
+ for await (const chunk of request) chunks.push(chunk);
+ if (chunks.length === 0) return {};
+ try {
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
+ } catch {
+ return {};
+ }
+}
+
+function sendError(response, status, code, requestId, headers = {}) {
+ send(response, status, {
+ error_code: code,
+ error_message: 'Mock diagnostic text must never be shown directly by the client.',
+ request_id: requestId,
+ }, requestId, headers);
+}
+
+function send(response, status, body, requestId, headers = {}) {
+ response.writeHead(status, {
+ 'Cache-Control': 'no-store',
+ 'Content-Type': 'application/json',
+ 'X-Request-ID': requestId,
+ ...headers,
+ });
+ response.end(JSON.stringify(body));
+}
diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml
index 305c03055b..d6ed61af7e 100644
--- a/src/apps/desktop/Cargo.toml
+++ b/src/apps/desktop/Cargo.toml
@@ -24,7 +24,7 @@ bitfun-relay-service = { path = "../../crates/services/relay-service" }
bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" }
bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" }
bitfun-product-domains = { path = "../../crates/contracts/product-domains", default-features = false }
-bitfun-services-integrations = { path = "../../crates/services/services-integrations", default-features = false, features = ["canvas-runtime", "speech"] }
+bitfun-services-integrations = { path = "../../crates/services/services-integrations", default-features = false, features = ["canvas-runtime", "feedback", "privacy", "speech"] }
bitfun-core-types = { path = "../../crates/contracts/core-types" }
bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" }
bitfun-transport = { path = "../../crates/adapters/transport", features = ["tauri-adapter"] }
@@ -35,7 +35,6 @@ bitfun-acp = { path = "../../crates/interfaces/acp" }
# Tauri
tauri = { workspace = true }
tauri-plugin-opener = { workspace = true }
-tauri-plugin-dialog = { workspace = true }
tauri-plugin-fs = { workspace = true }
tauri-plugin-log = { workspace = true }
napi-ohos = { workspace = true }
diff --git a/src/apps/desktop/src/api/feedback_api.rs b/src/apps/desktop/src/api/feedback_api.rs
new file mode 100644
index 0000000000..c895c04ff3
--- /dev/null
+++ b/src/apps/desktop/src/api/feedback_api.rs
@@ -0,0 +1,179 @@
+use bitfun_product_domains::feedback::{
+ AcknowledgeFeedbackRequest, AcknowledgeFeedbackResponse, FeedbackAccessState,
+ FeedbackConversationPage, FeedbackError, FeedbackInboxPage, ListFeedbackRecordsRequest,
+ OpenFeedbackConversationRequest, ReplyFeedbackRequest, ReplyFeedbackResponse,
+ SubmitFeedbackRequest, SubmitFeedbackResponse,
+};
+use bitfun_services_integrations::feedback::FeedbackService;
+use serde::Deserialize;
+use tauri::State;
+
+use crate::api::privacy_api::PrivacyServiceState;
+
+pub struct FeedbackServiceState {
+ service: Option,
+}
+
+impl FeedbackServiceState {
+ pub fn enabled(service: FeedbackService) -> Self {
+ Self {
+ service: Some(service),
+ }
+ }
+
+ pub fn disabled() -> Self {
+ Self { service: None }
+ }
+
+ fn service(&self) -> Result<&FeedbackService, FeedbackError> {
+ self.service.as_ref().ok_or_else(|| {
+ FeedbackError::new(
+ "FEEDBACK_PLATFORM_UNSUPPORTED",
+ "In-app feedback is only available on OpenHarmony",
+ false,
+ )
+ })
+ }
+}
+
+#[derive(Debug, Default, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FeedbackAccessStateRequest {}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ListFeedbackCommandRequest {
+ #[serde(default)]
+ pub cursor: Option,
+ pub page_size: u16,
+ #[serde(default)]
+ pub user_initiated: bool,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct OpenFeedbackCommandRequest {
+ pub feedback_id: String,
+ #[serde(default)]
+ pub cursor: Option,
+ pub page_size: u16,
+ #[serde(default)]
+ pub user_initiated: bool,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AcknowledgeFeedbackCommandRequest {
+ pub feedback_id: String,
+ pub last_visible_at: String,
+ #[serde(default)]
+ pub foreground_visible: bool,
+}
+
+#[tauri::command]
+pub async fn feedback_get_access_state(
+ feedback_state: State<'_, FeedbackServiceState>,
+ request: FeedbackAccessStateRequest,
+) -> Result {
+ let _ = request;
+ feedback_state.service()?.access_state().await
+}
+
+#[tauri::command]
+pub async fn list_feedback(
+ feedback_state: State<'_, FeedbackServiceState>,
+ privacy_state: State<'_, PrivacyServiceState>,
+ request: ListFeedbackCommandRequest,
+) -> Result {
+ if !privacy_state.collection_allowed() && !request.user_initiated {
+ return Err(FeedbackError::new(
+ "PRIVACY_BACKGROUND_REQUEST_BLOCKED",
+ "Background feedback requests require full privacy mode",
+ false,
+ ));
+ }
+ feedback_state
+ .service()?
+ .list_feedback(ListFeedbackRecordsRequest {
+ cursor: request.cursor,
+ page_size: request.page_size,
+ })
+ .await
+}
+
+#[tauri::command]
+pub async fn open_feedback_conversation(
+ feedback_state: State<'_, FeedbackServiceState>,
+ privacy_state: State<'_, PrivacyServiceState>,
+ request: OpenFeedbackCommandRequest,
+) -> Result {
+ if !privacy_state.collection_allowed() && !request.user_initiated {
+ return Err(FeedbackError::new(
+ "PRIVACY_BACKGROUND_REQUEST_BLOCKED",
+ "Background feedback requests require full privacy mode",
+ false,
+ ));
+ }
+ feedback_state
+ .service()?
+ .open_conversation(OpenFeedbackConversationRequest {
+ feedback_id: request.feedback_id,
+ cursor: request.cursor,
+ page_size: request.page_size,
+ })
+ .await
+}
+
+#[tauri::command]
+pub async fn acknowledge_feedback(
+ feedback_state: State<'_, FeedbackServiceState>,
+ _privacy_state: State<'_, PrivacyServiceState>,
+ request: AcknowledgeFeedbackCommandRequest,
+) -> Result {
+ if !request.foreground_visible {
+ return Err(FeedbackError::new(
+ "FEEDBACK_NOT_VISIBLE",
+ "Feedback must be visible before it can be marked as read",
+ false,
+ ));
+ }
+ feedback_state
+ .service()?
+ .acknowledge_feedback(AcknowledgeFeedbackRequest {
+ feedback_id: request.feedback_id,
+ last_visible_at: request.last_visible_at,
+ })
+ .await
+}
+
+#[tauri::command]
+pub async fn reply_feedback(
+ feedback_state: State<'_, FeedbackServiceState>,
+ privacy_state: State<'_, PrivacyServiceState>,
+ request: ReplyFeedbackRequest,
+) -> Result {
+ if !privacy_state.collection_allowed() {
+ return Err(FeedbackError::new(
+ "PRIVACY_CONSENT_REQUIRED",
+ "Feedback replies require full privacy mode",
+ false,
+ ));
+ }
+ feedback_state.service()?.reply_feedback(request).await
+}
+
+#[tauri::command]
+pub async fn submit_feedback(
+ feedback_state: State<'_, FeedbackServiceState>,
+ privacy_state: State<'_, PrivacyServiceState>,
+ request: SubmitFeedbackRequest,
+) -> Result {
+ if !privacy_state.collection_allowed() {
+ return Err(FeedbackError::new(
+ "PRIVACY_CONSENT_REQUIRED",
+ "Feedback submission requires full privacy mode",
+ false,
+ ));
+ }
+ feedback_state.service()?.submit_feedback(request).await
+}
diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs
index 746ba10bf1..7507e548d2 100644
--- a/src/apps/desktop/src/api/mod.rs
+++ b/src/apps/desktop/src/api/mod.rs
@@ -22,6 +22,7 @@ pub mod dto;
pub mod editor_ai_api;
pub mod external_hooks_api;
pub mod external_sources_api;
+pub mod feedback_api;
pub mod git_agent_api;
pub mod git_api;
pub mod i18n_api;
@@ -33,8 +34,10 @@ pub mod miniapp_agent_api;
pub mod miniapp_api;
pub mod miniapp_export_api;
pub mod pages_api;
+pub mod ohos;
pub mod path_target;
pub mod peer_host_invoke;
+pub mod privacy_api;
pub mod relay_deploy_api;
pub mod remote_connect_api;
pub mod remote_workspace_policy;
@@ -53,7 +56,6 @@ pub mod subagent_api;
pub mod system_api;
pub mod terminal_api;
pub mod tool_api;
-pub mod ohos;
pub mod workspace_activation;
pub use app_state::{AppState, AppStatistics, HealthStatus, RemoteWorkspace};
diff --git a/src/apps/desktop/src/api/ohos/feedback_credentials.rs b/src/apps/desktop/src/api/ohos/feedback_credentials.rs
new file mode 100644
index 0000000000..c556889da2
--- /dev/null
+++ b/src/apps/desktop/src/api/ohos/feedback_credentials.rs
@@ -0,0 +1,84 @@
+#![cfg(target_env = "ohos")]
+
+use anyhow::{anyhow, Context, Result};
+use async_trait::async_trait;
+use bitfun_services_integrations::feedback::FeedbackCredentialStore;
+use serde::{Deserialize, Serialize};
+
+const ARKTS_FUNCTION: &str = "feedback_secure_credentials";
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "snake_case")]
+enum CredentialAction {
+ Load,
+ Store,
+}
+
+#[derive(Debug, Serialize)]
+struct CredentialRequest<'a> {
+ action: CredentialAction,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ value: Option<&'a str>,
+}
+
+#[derive(Debug, Deserialize)]
+struct CredentialResponse {
+ status: String,
+ value: Option,
+ code: Option,
+}
+
+pub struct OhosFeedbackCredentialStore;
+
+impl OhosFeedbackCredentialStore {
+ pub fn new() -> Self {
+ Self
+ }
+
+ async fn call(&self, request: CredentialRequest<'_>) -> Result {
+ let input = serde_json::to_string(&request).context("encode secure credential request")?;
+ let output = bitfun_core::util::call_arkts_string_function(ARKTS_FUNCTION, input)
+ .await
+ .map_err(|error| anyhow!("call OpenHarmony secure credential store: {error}"))?;
+ serde_json::from_str(&output).context("decode secure credential response")
+ }
+}
+
+#[async_trait]
+impl FeedbackCredentialStore for OhosFeedbackCredentialStore {
+ async fn load(&self) -> Result
');
+ expect(source).toContain("data-content-deleted={message.contentDeleted ? 'true' : undefined}");
+ expect(mock).toContain('content_deleted: false');
+ expect(source).not.toContain('dangerouslySetInnerHTML');
+ });
+
+ it('gates replies on consent while preserving and Unicode-truncating the draft', () => {
+ const source = readSource('./FeedbackConversationView.tsx');
+ const acceptPosition = source.indexOf('await accept({');
+ const replyPosition = source.indexOf('await executeReply(content);', acceptPosition);
+
+ expect(source).toContain('truncateFeedbackContent(value)');
+ expect(source).toContain('feedbackContentLength(draft)');
+ expect(source).toContain("status?.effectiveMode !== 'full'");
+ expect(source).toContain('setShowConsent(true)');
+ expect(acceptPosition).toBeGreaterThan(0);
+ expect(replyPosition).toBeGreaterThan(acceptPosition);
+ expect(source).toContain("setReplyError('PRIVACY_SAVE_FAILED')");
+ expect(source).toContain('if (!sending) setShowConsent(false)');
+ });
+
+ it('opens the read-only privacy statement from the inline reply consent prompt', () => {
+ const source = readSource('./FeedbackConversationView.tsx');
+ const zh = JSON.parse(readSource('../../../locales/zh-CN/common.json')) as {
+ feedback: {
+ privacyStatement: string;
+ reply: { consentPrefix: string; consentSuffix: string };
+ };
+ };
+
+ expect(source).toContain("t('feedback.reply.consentPrefix')");
+ expect(source).toContain(' {
+ const conversation = readSource('./FeedbackConversationView.tsx');
+ const dialog = readSource('./FeedbackDialog.tsx');
+
+ expect(conversation).toContain('disabled={sending}');
+ expect(conversation).toContain('confirmDisabled={sending}');
+ expect(dialog).toContain("setPendingReplyExit({ kind: 'close' })");
+ expect(dialog).toContain("t('feedback.reply.discardConfirm')");
+ expect(dialog).toContain('setReplyResetVersion(current => current + 1)');
+ });
+
+ it('uses a reply-specific label when a failed reply is retryable', () => {
+ const source = readSource('./FeedbackConversationView.tsx');
+ const zh = JSON.parse(readSource('../../../locales/zh-CN/common.json')) as {
+ feedback: { reply: { retry: string } };
+ };
+
+ expect(source).toContain("? t('feedback.reply.retry')");
+ expect(source).not.toContain("? t('feedback.actions.retry')");
+ expect(zh.feedback.reply.retry).toBe('重试发送');
+ });
+});
diff --git a/src/web-ui/src/app/components/FeedbackDialog/feedbackInboxStore.test.ts b/src/web-ui/src/app/components/FeedbackDialog/feedbackInboxStore.test.ts
new file mode 100644
index 0000000000..82d256bd86
--- /dev/null
+++ b/src/web-ui/src/app/components/FeedbackDialog/feedbackInboxStore.test.ts
@@ -0,0 +1,118 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const getAccessState = vi.fn();
+const listFeedbackRecords = vi.fn();
+
+vi.mock('@/infrastructure/api', () => ({
+ feedbackAPI: { getAccessState, listFeedbackRecords },
+ normalizeFeedbackError: (error: unknown) => error,
+}));
+
+describe('feedbackInboxStore', () => {
+ beforeEach(async () => {
+ vi.resetModules();
+ getAccessState.mockReset();
+ listFeedbackRecords.mockReset();
+ });
+
+ it('does not inspect or query access in privacy-not-accepted mode', async () => {
+ const { useFeedbackInboxStore } = await import('./feedbackInboxStore');
+ await useFeedbackInboxStore.getState().initializeForMode('privacy_not_accepted');
+ expect(getAccessState).not.toHaveBeenCalled();
+ expect(listFeedbackRecords).not.toHaveBeenCalled();
+ });
+
+ it('checks once but does not enroll or query when there is no history', async () => {
+ getAccessState.mockResolvedValue({
+ hasHistory: false,
+ canReuseAccess: false,
+ cachedInbox: { items: [], hasMore: false },
+ });
+ const { useFeedbackInboxStore } = await import('./feedbackInboxStore');
+ await useFeedbackInboxStore.getState().initializeForMode('full');
+ await useFeedbackInboxStore.getState().initializeForMode('full');
+ expect(getAccessState).toHaveBeenCalledTimes(1);
+ expect(listFeedbackRecords).not.toHaveBeenCalled();
+ });
+
+ it('preserves cached records when an active refresh fails', async () => {
+ const cached = {
+ feedbackId: 'feedback-1',
+ category: 'other',
+ status: 'waiting_user',
+ hasNewReply: true,
+ createdAt: '2026-07-28T01:00:00Z',
+ updatedAt: '2026-07-28T02:00:00Z',
+ canOpen: true,
+ };
+ getAccessState.mockResolvedValue({
+ hasHistory: true,
+ canReuseAccess: true,
+ cachedInbox: { items: [cached], nextCursor: 'cached-cursor', hasMore: true },
+ });
+ listFeedbackRecords.mockRejectedValue({ code: 'NETWORK_ERROR' });
+ const { useFeedbackInboxStore } = await import('./feedbackInboxStore');
+
+ expect(await useFeedbackInboxStore.getState().refresh(true)).toBe(false);
+ expect(useFeedbackInboxStore.getState().records).toEqual([cached]);
+ expect(useFeedbackInboxStore.getState().nextCursor).toBe('cached-cursor');
+ });
+
+ it('performs one startup Inbox query when full mode has reusable history', async () => {
+ getAccessState.mockResolvedValue({
+ hasHistory: true,
+ canReuseAccess: true,
+ cachedInbox: { items: [], hasMore: false },
+ });
+ listFeedbackRecords.mockResolvedValue({ items: [], hasMore: false });
+ const { useFeedbackInboxStore } = await import('./feedbackInboxStore');
+
+ await useFeedbackInboxStore.getState().initializeForMode('full');
+ await useFeedbackInboxStore.getState().initializeForMode('full');
+
+ expect(listFeedbackRecords).toHaveBeenCalledTimes(1);
+ expect(listFeedbackRecords).toHaveBeenCalledWith({}, { userInitiated: false });
+ });
+
+ it('clears the unread marker when a conversation result is committed', async () => {
+ const record = {
+ feedbackId: 'feedback-1',
+ category: 'other' as const,
+ status: 'waiting_user' as const,
+ hasNewReply: true,
+ createdAt: '2026-07-28T01:00:00Z',
+ updatedAt: '2026-07-28T02:00:00Z',
+ canOpen: true,
+ };
+ const { useFeedbackInboxStore } = await import('./feedbackInboxStore');
+ useFeedbackInboxStore.setState({ records: [record] });
+
+ useFeedbackInboxStore.getState().applyServerStatus('feedback-1', 'in_progress');
+
+ expect(useFeedbackInboxStore.getState().records[0]).toMatchObject({
+ status: 'in_progress',
+ hasNewReply: false,
+ });
+ });
+
+ it('does not surface an unread reply that cannot be opened or acknowledged', async () => {
+ const record = {
+ feedbackId: 'feedback-1',
+ category: 'other' as const,
+ status: 'waiting_user' as const,
+ hasNewReply: true,
+ createdAt: '2026-07-28T01:00:00Z',
+ updatedAt: '2026-07-28T02:00:00Z',
+ canOpen: true,
+ };
+ const { hasActionableUnreadReply, useFeedbackInboxStore } = await import('./feedbackInboxStore');
+ useFeedbackInboxStore.setState({ records: [record] });
+
+ expect(hasActionableUnreadReply(record)).toBe(true);
+
+ useFeedbackInboxStore.getState().markInaccessible('feedback-1');
+
+ expect(hasActionableUnreadReply(useFeedbackInboxStore.getState().records[0])).toBe(false);
+ expect(useFeedbackInboxStore.getState().records[0].hasNewReply).toBe(true);
+ });
+});
diff --git a/src/web-ui/src/app/components/FeedbackDialog/feedbackInboxStore.ts b/src/web-ui/src/app/components/FeedbackDialog/feedbackInboxStore.ts
new file mode 100644
index 0000000000..f6a3331a67
--- /dev/null
+++ b/src/web-ui/src/app/components/FeedbackDialog/feedbackInboxStore.ts
@@ -0,0 +1,143 @@
+import { create } from 'zustand';
+import {
+ feedbackAPI,
+ normalizeFeedbackError,
+ type FeedbackAccessState,
+ type FeedbackApiError,
+ type FeedbackRecordSummary,
+} from '@/infrastructure/api';
+import type { PrivacyEffectiveMode } from '@/infrastructure/api/service-api/PrivacyAPI';
+
+interface FeedbackInboxState {
+ records: FeedbackRecordSummary[];
+ nextCursor?: string;
+ hasMore: boolean;
+ loaded: boolean;
+ loading: boolean;
+ loadingMore: boolean;
+ backgroundAttempted: boolean;
+ error: FeedbackApiError | null;
+ initializeForMode: (mode: PrivacyEffectiveMode) => Promise;
+ refresh: (userInitiated: boolean) => Promise;
+ loadMore: () => Promise;
+ applyServerStatus: (feedbackId: string, status: FeedbackRecordSummary['status']) => void;
+ markInaccessible: (feedbackId: string) => void;
+}
+
+export function hasActionableUnreadReply(record: FeedbackRecordSummary): boolean {
+ return record.canOpen && record.hasNewReply;
+}
+
+function cachedState(access: FeedbackAccessState) {
+ return {
+ records: access.cachedInbox.items,
+ nextCursor: access.cachedInbox.nextCursor,
+ hasMore: access.cachedInbox.hasMore,
+ loaded: true,
+ };
+}
+
+export const useFeedbackInboxStore = create((set, get) => ({
+ records: [],
+ nextCursor: undefined,
+ hasMore: false,
+ loaded: false,
+ loading: false,
+ loadingMore: false,
+ backgroundAttempted: false,
+ error: null,
+
+ initializeForMode: async mode => {
+ if (mode !== 'full' || get().backgroundAttempted) return;
+ set({ backgroundAttempted: true });
+ try {
+ const access = await feedbackAPI.getAccessState();
+ set(cachedState(access));
+ if (access.hasHistory && access.canReuseAccess) {
+ await get().refresh(false);
+ }
+ } catch (error) {
+ set({ error: normalizeFeedbackError(error), loaded: true });
+ }
+ },
+
+ refresh: async userInitiated => {
+ if (get().loading || get().loadingMore) return false;
+ set({ loading: true, error: null });
+ try {
+ const access = await feedbackAPI.getAccessState();
+ set(cachedState(access));
+ if (!access.hasHistory) {
+ set({ loading: false });
+ return true;
+ }
+ if (!access.canReuseAccess) {
+ set({
+ loading: false,
+ error: normalizeFeedbackError({
+ code: 'FEEDBACK_ACCESS_UNAVAILABLE',
+ message: 'Saved feedback access is unavailable',
+ retryable: false,
+ }),
+ });
+ return false;
+ }
+ const page = await feedbackAPI.listFeedbackRecords({}, { userInitiated });
+ set({
+ records: page.items,
+ nextCursor: page.nextCursor,
+ hasMore: page.hasMore,
+ loaded: true,
+ loading: false,
+ error: null,
+ });
+ return true;
+ } catch (error) {
+ set({ loading: false, loaded: true, error: normalizeFeedbackError(error) });
+ return false;
+ }
+ },
+
+ loadMore: async () => {
+ const { hasMore, nextCursor, loading, loadingMore, records } = get();
+ if (!hasMore || !nextCursor || loading || loadingMore) return false;
+ set({ loadingMore: true, error: null });
+ try {
+ const page = await feedbackAPI.listFeedbackRecords(
+ { cursor: nextCursor },
+ { userInitiated: true },
+ );
+ const knownIds = new Set(records.map(record => record.feedbackId));
+ set({
+ records: [
+ ...records,
+ ...page.items.filter(record => !knownIds.has(record.feedbackId)),
+ ],
+ nextCursor: page.nextCursor,
+ hasMore: page.hasMore,
+ loadingMore: false,
+ error: null,
+ });
+ return true;
+ } catch (error) {
+ set({ loadingMore: false, error: normalizeFeedbackError(error) });
+ return false;
+ }
+ },
+
+ applyServerStatus: (feedbackId, status) => {
+ set(state => ({
+ records: state.records.map(record =>
+ record.feedbackId === feedbackId
+ ? { ...record, status, hasNewReply: false }
+ : record),
+ }));
+ },
+
+ markInaccessible: feedbackId => {
+ set(state => ({
+ records: state.records.map(record =>
+ record.feedbackId === feedbackId ? { ...record, canOpen: false } : record),
+ }));
+ },
+}));
diff --git a/src/web-ui/src/app/components/FeedbackDialog/feedbackSubmissionContract.test.ts b/src/web-ui/src/app/components/FeedbackDialog/feedbackSubmissionContract.test.ts
new file mode 100644
index 0000000000..b7105290e1
--- /dev/null
+++ b/src/web-ui/src/app/components/FeedbackDialog/feedbackSubmissionContract.test.ts
@@ -0,0 +1,123 @@
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+const readSource = (relativePath: string): string =>
+ readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8').replace(/\r\n?/g, '\n');
+
+describe('OpenHarmony feedback submission contract', () => {
+ it('keeps other platforms on the external GitCode route', () => {
+ const footer = readSource('../NavPanel/components/PersistentFooterActions.tsx');
+
+ expect(footer).toContain("systemInfo.platform === 'openharmony'");
+ expect(footer).toContain('setShowFeedback(true)');
+ expect(footer).toContain("systemAPI.openExternal('https://gitcode.com/OpenHarmonyPCDeveloper/BitFun/issues')");
+ });
+
+ it('shows unread attention only for conversations that can be opened and acknowledged', () => {
+ const footer = readSource('../NavPanel/components/PersistentFooterActions.tsx');
+ const inbox = readSource('./FeedbackInboxView.tsx');
+ const store = readSource('./feedbackInboxStore.ts');
+
+ expect(store).toContain('export function hasActionableUnreadReply');
+ expect(store).toContain('record.canOpen && record.hasNewReply');
+ expect(footer).toContain('state.records.some(hasActionableUnreadReply)');
+ expect(inbox).toContain('hasActionableUnreadReply(record)');
+ });
+
+ it('requires total consent before a feedback request in not-accepted mode', () => {
+ const dialog = readSource('./FeedbackDialog.tsx');
+ const preparePosition = dialog.indexOf('await feedbackAPI.prepareSubmission({');
+ const acceptPosition = dialog.indexOf('await accept({');
+ const submitPosition = dialog.indexOf('await submitPreparedFeedback();');
+
+ expect(preparePosition).toBeGreaterThan(0);
+ expect(acceptPosition).toBeGreaterThan(preparePosition);
+ expect(acceptPosition).toBeGreaterThan(0);
+ expect(submitPosition).toBeGreaterThan(acceptPosition);
+ expect(dialog).toContain("setSubmitError('PRIVACY_SAVE_FAILED')");
+ expect(dialog).toContain('return;');
+ });
+
+ it('counts the privacy checkbox as draft state and freezes close while submitting', () => {
+ const dialog = readSource('./FeedbackDialog.tsx');
+ const layout = readSource('../../layout/AppLayout.tsx');
+
+ expect(dialog).toContain('category || content || includeCorrelation || privacyChecked');
+ expect(dialog).toContain('if (submitting || replyState.sending) return;');
+ expect(dialog).toContain('showCloseButton={!submitting && !replyState.sending}');
+ expect(dialog).toContain('closeOnOverlayClick={!submitting && !replyState.sending}');
+ expect(dialog).toContain('registerCriticalOperationExitGuard');
+ expect(layout).toContain('await confirmCriticalOperationExit()');
+ });
+
+ it('opens the read-only privacy statement from inline consent copy', () => {
+ const dialog = readSource('./FeedbackDialog.tsx');
+ const privacySection = dialog.slice(
+ dialog.indexOf('className="bitfun-feedback__privacy"'),
+ dialog.indexOf('{submitError ?'),
+ );
+
+ expect(privacySection).toContain(' {
+ const dialog = readSource('./FeedbackDialog.tsx');
+ const completeView = dialog.slice(
+ dialog.indexOf('className="bitfun-feedback__complete"'),
+ dialog.indexOf(') : (\n {
+ const dialog = readSource('./FeedbackDialog.tsx');
+
+ expect(dialog).toContain("feedbackError.retryAfterSeconds > 0");
+ expect(dialog).toContain("error.code === 'FEEDBACK_QUOTA_EXCEEDED'");
+ expect(dialog).toContain("t('feedback.errors.quotaExceeded')");
+ expect(dialog).toContain('submissionRetryUntilMs = Date.now()');
+ expect(dialog).toContain('submissionRetrySecondsRemaining');
+ expect(dialog).toContain('const submitErrorMessage = retryWaitSeconds > 0');
+ expect(dialog).toContain('if (next === 0)');
+ expect(dialog).not.toContain('setQuotaBlocked(true)');
+ expect(dialog).not.toContain('error.retryAfterSeconds || 0');
+ });
+
+ it('uses the feedback container width for the 840px layout threshold', () => {
+ const dialog = readSource('./FeedbackDialog.tsx');
+ const styles = readSource('./FeedbackDialog.scss');
+
+ expect(dialog).toContain('new ResizeObserver');
+ expect(dialog).toContain('setWideLayout(width >= 840)');
+ expect(styles).toContain('&.is-wide');
+ expect(styles).toContain('grid-template-columns: minmax(300px, 36%) minmax(0, 1fr)');
+ });
+
+ it('keeps the dialog below the host window controls at every supported size', () => {
+ const styles = readSource('./FeedbackDialog.scss');
+
+ expect(styles).toContain('padding: 48px 40px 32px;');
+ expect(styles).toContain('width: min(960px, calc(100vw - 80px));');
+ expect(styles).toContain('height: min(620px, calc(100vh - 114px));');
+ expect(styles).toContain('padding: 44px 12px 12px;');
+ expect(styles).toContain('max-height: calc(100vh - 56px);');
+ });
+
+ it('keeps Mock request logs limited to a fixed stage and request id', () => {
+ const mock = readSource('../../../../../../scripts/feedback-mock-server.mjs');
+
+ expect(mock).toContain('logRequestStage(requestStage(request.method, url.pathname), requestId);');
+ expect(mock).toContain("return method === 'GET' ? 'message_history' : method === 'POST' ? 'reply' : 'unknown';");
+ expect(mock).toContain('process.stdout.write(`${JSON.stringify({ stage, requestId })}\\n`);');
+ expect(mock).not.toContain('JSON.stringify({ stage, requestId, url');
+ expect(mock).not.toContain('JSON.stringify({ stage, requestId, body');
+ });
+});
diff --git a/src/web-ui/src/app/components/FeedbackDialog/index.ts b/src/web-ui/src/app/components/FeedbackDialog/index.ts
new file mode 100644
index 0000000000..b92e8bdc10
--- /dev/null
+++ b/src/web-ui/src/app/components/FeedbackDialog/index.ts
@@ -0,0 +1,2 @@
+export { FeedbackDialog as default, FeedbackDialog } from './FeedbackDialog';
+export { useFeedbackInboxStore } from './feedbackInboxStore';
diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss
index 4070f712ec..d498a72087 100644
--- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss
+++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss
@@ -1614,6 +1614,22 @@ $_section-header-height: 24px;
position: relative;
}
+.bitfun-nav-panel__footer-more-btn {
+ position: relative;
+}
+
+.bitfun-nav-panel__footer-more-unread {
+ position: absolute;
+ top: 3px;
+ right: 3px;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--color-error);
+ box-shadow: 0 0 0 2px var(--color-bg-primary);
+ pointer-events: none;
+}
+
// ──────────────────────────────────────────────
// Multimodal tools picker (Browser + Mermaid)
// ──────────────────────────────────────────────
@@ -1830,6 +1846,15 @@ $_section-header-height: 24px;
background: var(--color-success);
}
+.bitfun-nav-panel__footer-menu-unread {
+ width: 7px;
+ height: 7px;
+ margin-left: auto;
+ border-radius: 50%;
+ background: var(--color-error);
+ box-shadow: 0 0 0 2px var(--color-bg-elevated);
+}
+
.bitfun-nav-panel__remote-disclaimer {
display: flex;
flex-direction: column;
diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx
index 02c21c8853..f74f3799c6 100644
--- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx
+++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx
@@ -24,6 +24,11 @@ import { useNotification } from '@/shared/notification-system';
import { useAccountLoginState } from '@/infrastructure/account/useAccountLoginState';
// import { remoteConnectAPI } from '@/infrastructure/api/service-api/RemoteConnectAPI';
import NotificationButton from '../../TitleBar/NotificationButton';
+import { usePrivacy } from '../../Privacy/PrivacyContext';
+import {
+ hasActionableUnreadReply,
+ useFeedbackInboxStore,
+} from '../../FeedbackDialog/feedbackInboxStore';
import {
RemoteConnectDisclaimerContent,
} from '../../RemoteConnectDialog/RemoteConnectDisclaimer';
@@ -38,6 +43,7 @@ const RemoteConnectDialog = lazy(() => import('../../RemoteConnectDialog'));
const AboutDialog = lazy(() =>
import('../../AboutDialog').then(module => ({ default: module.AboutDialog }))
);
+const FeedbackDialog = lazy(() => import('../../FeedbackDialog'));
const PersistentFooterActions: React.FC = () => {
const { t } = useI18n('common');
@@ -55,6 +61,15 @@ const PersistentFooterActions: React.FC = () => {
});
const { warning } = useNotification();
const { loggedIn: accountLoggedIn, deviceName: accountDeviceName } = useAccountLoginState();
+ const { status: privacyStatus } = usePrivacy();
+ const initializeFeedbackForMode = useFeedbackInboxStore(state => state.initializeForMode);
+ const hasUnreadFeedback = useFeedbackInboxStore(state =>
+ state.records.some(hasActionableUnreadReply),
+ );
+ const hasPrivacyUpdate = Boolean(
+ privacyStatus?.enabled && privacyStatus.hasUnreadUpdate,
+ );
+ const hasMoreMenuAttention = hasUnreadFeedback || hasPrivacyUpdate;
useEffect(() => {
const onAutoExit = (event: Event) => {
@@ -73,24 +88,33 @@ const PersistentFooterActions: React.FC = () => {
const [menuOpen, setMenuOpen] = useState(false);
const [menuClosing, setMenuClosing] = useState(false);
const [showAbout, setShowAbout] = useState(false);
+ const [showFeedback, setShowFeedback] = useState(false);
// const [showAccountLogin, setShowAccountLogin] = useState(false);
const [showRemoteConnect, setShowRemoteConnect] = useState(false);
const [remoteInitialGroup, setRemoteInitialGroup] = useState<'network' | 'bot' | 'account' | undefined>(undefined);
const [showRemoteDisclaimer, setShowRemoteDisclaimer] = useState(false);
+ const [feedbackPlatformEnabled, setFeedbackPlatformEnabled] = useState(null);
const [hasAgreedRemoteDisclaimer, setHasAgreedRemoteDisclaimer] = useState(() => getRemoteConnectDisclaimerAgreed());
- // Account login retirement: do not reopen the login dialog when a stored
- // account token expires.
- // useEffect(() => {
- // const expiryCheck = setInterval(() => {
- // remoteConnectAPI.accountTokenExpired().then((expired) => {
- // if (expired) {
- // setShowAccountLogin(true);
- // }
- // });
- // }, 60000);
- // return () => clearInterval(expiryCheck);
- // }, []);
+ useEffect(() => {
+ let active = true;
+ void systemAPI.getSystemInfo().then(info => {
+ if (active) setFeedbackPlatformEnabled(info.platform === 'openharmony');
+ }).catch(() => {
+ if (active) setFeedbackPlatformEnabled(false);
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!feedbackPlatformEnabled || !privacyStatus) return;
+ void initializeFeedbackForMode(privacyStatus.effectiveMode);
+ }, [feedbackPlatformEnabled, initializeFeedbackForMode, privacyStatus]);
+
+ // Account login retirement: do not reopen the disabled login dialog when a
+ // stored account token expires.
const closeMenu = useCallback(() => {
setMenuClosing(true);
@@ -148,10 +172,24 @@ const PersistentFooterActions: React.FC = () => {
setShowAbout(true);
};
- const handleFeedback = useCallback(() => {
+ const handleFeedback = useCallback(async () => {
closeMenu();
- void systemAPI.openExternal('https://gitcode.com/OpenHarmonyPCDeveloper/BitFun/issues');
- }, [closeMenu]);
+ if (feedbackPlatformEnabled) {
+ setShowFeedback(true);
+ return;
+ }
+ try {
+ const systemInfo = await systemAPI.getSystemInfo();
+ if (systemInfo.platform === 'openharmony') {
+ setFeedbackPlatformEnabled(true);
+ setShowFeedback(true);
+ return;
+ }
+ } catch {
+ // Web and older desktop hosts retain the external feedback behavior.
+ }
+ await systemAPI.openExternal('https://gitcode.com/OpenHarmonyPCDeveloper/BitFun/issues');
+ }, [closeMenu, feedbackPlatformEnabled]);
// const handleAccountLogin = () => {
// closeMenu();
@@ -192,8 +230,10 @@ const PersistentFooterActions: React.FC = () => {
@@ -261,18 +304,30 @@ const PersistentFooterActions: React.FC = () => {
className="bitfun-nav-panel__footer-menu-item"
role="menuitem"
onClick={handleFeedback}
+ aria-label={hasUnreadFeedback
+ ? t('feedback.inbox.entryUnread')
+ : t('header.feedback')}
>
{t('header.feedback')}
+ {hasUnreadFeedback ? (
+
+ ) : null}
>
@@ -323,6 +378,11 @@ const PersistentFooterActions: React.FC = () => {
setShowAbout(false)} />
)}
+ {showFeedback && (
+
+ setShowFeedback(false)} />
+
+ )}
{/* BitFun account login dialog is intentionally disabled. */}
{showRemoteConnect && (
diff --git a/src/web-ui/src/app/components/Privacy/Privacy.scss b/src/web-ui/src/app/components/Privacy/Privacy.scss
new file mode 100644
index 0000000000..fdab2a795b
--- /dev/null
+++ b/src/web-ui/src/app/components/Privacy/Privacy.scss
@@ -0,0 +1,236 @@
+.bitfun-privacy-gate {
+ position: fixed;
+ inset: 0;
+ z-index: 10000;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ min-width: 320px;
+ min-height: 480px;
+ color: var(--color-text-primary);
+ background: var(--color-bg-primary);
+ font-family: var(--font-family-sans);
+}
+
+.bitfun-privacy-gate--status {
+ place-content: center;
+ justify-items: center;
+ gap: 16px;
+ padding: 28px;
+ text-align: center;
+
+ h1,
+ p { margin: 0; }
+
+ h1 {
+ font-size: 18px;
+ letter-spacing: 0;
+ }
+
+ p {
+ max-width: 560px;
+ color: var(--color-text-secondary);
+ font-size: 13px;
+ line-height: 1.6;
+ }
+}
+
+.bitfun-privacy-gate__loading-icon { animation: bitfun-privacy-spin 1s linear infinite; }
+
+.bitfun-privacy-gate__header,
+.bitfun-privacy-gate__footer {
+ padding: 18px clamp(20px, 5vw, 64px);
+ border-color: var(--border-subtle);
+ background: var(--element-bg-subtle);
+}
+
+.bitfun-privacy-gate__header {
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr) 28px 32px;
+ align-items: center;
+ gap: 14px;
+ border-bottom: 1px solid var(--border-subtle);
+
+ h1 {
+ margin: 0 0 3px;
+ font-size: 20px;
+ line-height: 1.3;
+ letter-spacing: 0;
+ }
+
+ p {
+ margin: 0;
+ color: var(--color-text-secondary);
+ font-size: 13px;
+ }
+}
+
+.bitfun-privacy-gate__close {
+ width: 32px;
+ height: 32px;
+}
+
+.bitfun-privacy-loading {
+ position: fixed;
+ inset: auto 16px 16px auto;
+ z-index: 10001;
+ color: var(--color-text-muted);
+}
+
+.bitfun-privacy-gate__logo {
+ width: 42px;
+ height: 42px;
+ object-fit: contain;
+}
+
+.bitfun-privacy-gate__document {
+ overflow: auto;
+ padding: 24px clamp(20px, 8vw, 112px);
+}
+
+.bitfun-privacy-gate__footer {
+ display: grid;
+ gap: 12px;
+ border-top: 1px solid var(--border-subtle);
+}
+
+.bitfun-privacy-gate__metadata,
+.bitfun-privacy-dialog__metadata {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 20px;
+ color: var(--color-text-muted);
+ font-size: 12px;
+}
+
+.bitfun-privacy-dialog__metadata strong {
+ color: var(--color-accent-500);
+ font-weight: 600;
+}
+
+.bitfun-privacy-dialog__mode {
+ display: grid;
+ gap: 4px;
+ padding: 12px 20px;
+ border-bottom: 1px solid var(--border-subtle);
+
+ strong {
+ font-size: 13px;
+ letter-spacing: 0;
+ }
+
+ span {
+ color: var(--color-text-secondary);
+ font-size: 12px;
+ line-height: 1.5;
+ }
+}
+
+.bitfun-privacy-gate__consent-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+}
+
+.bitfun-privacy-gate__actions,
+.bitfun-privacy-dialog__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.bitfun-privacy-gate__configuration-error {
+ color: var(--color-error);
+ font-size: 12px;
+}
+
+.bitfun-privacy-document {
+ width: min(100%, 860px);
+ margin: 0 auto;
+ color: var(--color-text-primary);
+ font-size: 14px;
+ line-height: 1.75;
+ overflow-wrap: anywhere;
+
+ h1,
+ h2,
+ h3 {
+ margin: 1.4em 0 0.65em;
+ line-height: 1.35;
+ letter-spacing: 0;
+ }
+
+ h1 { font-size: 22px; }
+ h2 { font-size: 17px; }
+ h3 { font-size: 15px; }
+ p,
+ ul,
+ ol { margin: 0.75em 0; }
+ code {
+ font-family: var(--font-family-mono);
+ overflow-wrap: anywhere;
+ }
+}
+
+.bitfun-privacy-dialog {
+ display: flex;
+ flex-direction: column;
+ min-height: min(72vh, 680px);
+ max-height: 82vh;
+}
+
+.bitfun-privacy-dialog__metadata {
+ padding: 14px 20px;
+ border-bottom: 1px solid var(--border-subtle);
+ background: var(--element-bg-subtle);
+}
+
+.bitfun-privacy-dialog__document {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 20px 28px;
+}
+
+.bitfun-privacy-dialog__actions {
+ padding: 14px 20px;
+ border-top: 1px solid var(--border-subtle);
+}
+
+.bitfun-privacy-dialog__error {
+ padding: 10px 20px 0;
+ border-top: 1px solid var(--border-subtle);
+}
+
+.bitfun-privacy-dialog__consent {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+}
+
+.bitfun-privacy-updated {
+ display: inline-flex;
+ margin-left: 5px;
+ color: var(--color-accent-500);
+ font-size: 10px;
+}
+
+@media (max-width: 620px) {
+ .bitfun-privacy-gate__header { grid-template-columns: 36px minmax(0, 1fr) 32px; }
+ .bitfun-privacy-gate__header > svg:not(.lucide-x) { display: none; }
+ .bitfun-privacy-gate__consent-row {
+ align-items: stretch;
+ flex-direction: column;
+ }
+ .bitfun-privacy-gate__actions > button { flex: 1; }
+ .bitfun-privacy-dialog__consent {
+ align-items: stretch;
+ flex-direction: column;
+ }
+}
+
+@keyframes bitfun-privacy-spin {
+ to { transform: rotate(360deg); }
+}
diff --git a/src/web-ui/src/app/components/Privacy/PrivacyContext.tsx b/src/web-ui/src/app/components/Privacy/PrivacyContext.tsx
new file mode 100644
index 0000000000..64e82b52c6
--- /dev/null
+++ b/src/web-ui/src/app/components/Privacy/PrivacyContext.tsx
@@ -0,0 +1,82 @@
+import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
+import {
+ disabledPrivacyStatus,
+ privacyAPI,
+ type AcceptPrivacyRequest,
+ type PrivacyEffectiveMode,
+ type PrivacyStatus,
+} from '@/infrastructure/api/service-api/PrivacyAPI';
+
+interface PrivacyContextValue {
+ status: PrivacyStatus | null;
+ initialize: () => Promise;
+ refresh: (locale: string) => Promise;
+ accept: (request: AcceptPrivacyRequest) => Promise;
+ enterNotAccepted: (locale: string) => Promise;
+ markViewed: (policyUpdatedAt: string, locale: string) => Promise;
+ applyCollectionPolicy: (
+ mode: PrivacyEffectiveMode,
+ locale: string,
+ ) => Promise;
+}
+
+const unavailable = async (): Promise => disabledPrivacyStatus;
+
+const PrivacyContext = createContext({
+ status: null,
+ initialize: unavailable,
+ refresh: unavailable,
+ accept: unavailable,
+ enterNotAccepted: unavailable,
+ markViewed: unavailable,
+ applyCollectionPolicy: unavailable,
+});
+
+export const PrivacyProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [status, setStatus] = useState(null);
+
+ const update = useCallback(async (operation: () => Promise) => {
+ const next = await operation();
+ setStatus(next);
+ return next;
+ }, []);
+ const initialize = useCallback(() => update(() => privacyAPI.initialize()), [update]);
+ const refresh = useCallback(
+ (locale: string) => update(() => privacyAPI.getStatus(locale)),
+ [update],
+ );
+ const accept = useCallback(
+ (request: AcceptPrivacyRequest) => update(() => privacyAPI.accept(request)),
+ [update],
+ );
+ const enterNotAccepted = useCallback(
+ (locale: string) => update(() => privacyAPI.enterNotAccepted(locale)),
+ [update],
+ );
+ const markViewed = useCallback(
+ (policyUpdatedAt: string, locale: string) =>
+ update(() => privacyAPI.markViewed(policyUpdatedAt, locale)),
+ [update],
+ );
+ const applyCollectionPolicy = useCallback(
+ (mode: PrivacyEffectiveMode, locale: string) =>
+ update(() => privacyAPI.applyCollectionPolicy(mode, locale)),
+ [update],
+ );
+
+ const value = useMemo(
+ () => ({
+ status,
+ initialize,
+ refresh,
+ accept,
+ enterNotAccepted,
+ markViewed,
+ applyCollectionPolicy,
+ }),
+ [accept, applyCollectionPolicy, enterNotAccepted, initialize, markViewed, refresh, status],
+ );
+ return {children};
+};
+
+export const usePrivacy = (): PrivacyContextValue => useContext(PrivacyContext);
diff --git a/src/web-ui/src/app/components/Privacy/PrivacyDocument.tsx b/src/web-ui/src/app/components/Privacy/PrivacyDocument.tsx
new file mode 100644
index 0000000000..b1cd4ecdae
--- /dev/null
+++ b/src/web-ui/src/app/components/Privacy/PrivacyDocument.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import ReactMarkdown from 'react-markdown';
+import rehypeSanitize from 'rehype-sanitize';
+import remarkGfm from 'remark-gfm';
+
+export const PrivacyDocument: React.FC<{ content: string }> = ({ content }) => (
+
+ {children} }}
+ >
+ {content}
+
+
+);
diff --git a/src/web-ui/src/app/components/Privacy/PrivacyGate.tsx b/src/web-ui/src/app/components/Privacy/PrivacyGate.tsx
new file mode 100644
index 0000000000..58b3dee127
--- /dev/null
+++ b/src/web-ui/src/app/components/Privacy/PrivacyGate.tsx
@@ -0,0 +1,255 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { AlertTriangle, LoaderCircle, ShieldCheck, X } from 'lucide-react';
+import { Button, Checkbox } from '@/component-library';
+import { hideStartupOverlay } from '@/app/startup/startupOverlay';
+import { privacyAPI } from '@/infrastructure/api/service-api/PrivacyAPI';
+import { isTauriRuntime } from '@/infrastructure/runtime';
+import { createLogger } from '@/shared/utils/logger';
+import { PrivacyDocument } from './PrivacyDocument';
+import { usePrivacy } from './PrivacyContext';
+import copyByLocale from './privacyGateCopy.json';
+import './Privacy.scss';
+
+const log = createLogger('PrivacyGate');
+type PrivacyLocale = keyof typeof copyByLocale;
+
+function detectedLocale(): PrivacyLocale {
+ const locale = navigator.language.toLowerCase();
+ if (locale.includes('hant') || locale.startsWith('zh-tw') || locale.startsWith('zh-hk')) {
+ return 'zh-TW';
+ }
+ if (locale.startsWith('zh')) return 'zh-CN';
+ return 'en-US';
+}
+
+export const PrivacyGate: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const {
+ status,
+ initialize,
+ refresh,
+ accept,
+ enterNotAccepted,
+ applyCollectionPolicy,
+ } = usePrivacy();
+ const [dismissed, setDismissed] = useState(false);
+ const [checked, setChecked] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [loadError, setLoadError] = useState(false);
+ const [mutationError, setMutationError] = useState(false);
+ const [applyRetryRequired, setApplyRetryRequired] = useState(false);
+ const locale = detectedLocale();
+ const copy = copyByLocale[locale];
+
+ const reveal = useCallback(async () => {
+ await hideStartupOverlay();
+ if (!isTauriRuntime()) return;
+ try {
+ await privacyAPI.showGateWindow();
+ } catch (error) {
+ log.warn('Failed to reveal privacy window', error);
+ }
+ }, []);
+
+ const loadStatus = useCallback(async () => {
+ setLoadError(false);
+ if (!isTauriRuntime()) return;
+ try {
+ const next = await initialize();
+ if (next.lifecycleState === 'choice_required' || next.lifecycleState === 'resource_error') {
+ await reveal();
+ }
+ } catch (error) {
+ log.error('Privacy initialization failed', error);
+ setLoadError(true);
+ await reveal();
+ }
+ }, [initialize, reveal]);
+
+ useEffect(() => {
+ void loadStatus();
+ }, [loadStatus]);
+
+ const needsChoice = status?.enabled && status.lifecycleState === 'choice_required';
+ const resourceError = loadError || status?.lifecycleState === 'resource_error';
+ const choicePanelVisible = needsChoice || applyRetryRequired;
+ const overlayVisible = !dismissed && (choicePanelVisible || resourceError);
+
+ const dismiss = useCallback(() => {
+ if (!submitting) setDismissed(true);
+ }, [submitting]);
+
+ useEffect(() => {
+ if (!overlayVisible) return;
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ dismiss();
+ }
+ };
+ const handleBack = () => dismiss();
+ window.addEventListener('keydown', handleKeyDown);
+ window.addEventListener('popstate', handleBack);
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ window.removeEventListener('popstate', handleBack);
+ };
+ }, [dismiss, overlayVisible]);
+
+ const handleAccept = async () => {
+ const policy = status?.policy;
+ if (!checked || !policy || !status.releaseReady || submitting) return;
+ setSubmitting(true);
+ setMutationError(false);
+ try {
+ await accept({
+ policyUpdatedAt: policy.updatedAt,
+ consentVersion: policy.consentVersion,
+ documentSha256: policy.documentSha256,
+ locale: policy.locale,
+ });
+ setDismissed(true);
+ } catch (error) {
+ log.error('Privacy consent could not be saved or applied', error);
+ setMutationError(true);
+ try {
+ const next = await refresh(locale);
+ setApplyRetryRequired(
+ next.lifecycleState === 'full' && next.effectiveMode === 'privacy_not_accepted',
+ );
+ } catch {
+ setApplyRetryRequired(false);
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleApplyRetry = async () => {
+ if (submitting) return;
+ setSubmitting(true);
+ setMutationError(false);
+ try {
+ await applyCollectionPolicy('full', locale);
+ setApplyRetryRequired(false);
+ setDismissed(true);
+ } catch (error) {
+ log.error('Full privacy mode could not be applied', error);
+ setMutationError(true);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleNotAccepted = async () => {
+ if (submitting) return;
+ setSubmitting(true);
+ setMutationError(false);
+ try {
+ await enterNotAccepted(status?.policy?.locale ?? locale);
+ setDismissed(true);
+ } catch (error) {
+ log.error('Privacy not-accepted state could not be saved', error);
+ setMutationError(true);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+ <>
+ {children}
+ {overlayVisible && resourceError && (
+
+
+ {copy.loadError}
+ {copy.resourceErrorHint}
+
+
+
+
+
+ )}
+ {overlayVisible && choicePanelVisible && !resourceError && status?.policy && (
+
+
+
+
+
+
+
+
+
+
+ )}
+ {!status && isTauriRuntime() && !loadError && (
+
+
+ {copy.loading}
+
+ )}
+ >
+ );
+};
diff --git a/src/web-ui/src/app/components/Privacy/PrivacyStatementDialog.tsx b/src/web-ui/src/app/components/Privacy/PrivacyStatementDialog.tsx
new file mode 100644
index 0000000000..005906bc52
--- /dev/null
+++ b/src/web-ui/src/app/components/Privacy/PrivacyStatementDialog.tsx
@@ -0,0 +1,233 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { Alert, Button, Checkbox, ConfirmDialog, Modal } from '@/component-library';
+import { useI18n } from '@/infrastructure/i18n';
+import { createLogger } from '@/shared/utils/logger';
+import { PrivacyDocument } from './PrivacyDocument';
+import { usePrivacy } from './PrivacyContext';
+
+const log = createLogger('PrivacyStatementDialog');
+
+interface PrivacyStatementDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ variant?: 'about' | 'readonly';
+}
+
+type OperationError = 'accept_save' | 'apply' | 'withdraw' | 'mark_viewed' | null;
+
+export const PrivacyStatementDialog: React.FC = ({
+ isOpen,
+ onClose,
+ variant = 'about',
+}) => {
+ const { t, currentLanguage, formatDate } = useI18n('common');
+ const {
+ status,
+ refresh,
+ accept,
+ enterNotAccepted,
+ markViewed,
+ applyCollectionPolicy,
+ } = usePrivacy();
+ const [checked, setChecked] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [confirmWithdraw, setConfirmWithdraw] = useState(false);
+ const [operationError, setOperationError] = useState(null);
+ const [openedWithUpdate, setOpenedWithUpdate] = useState(false);
+
+ useEffect(() => {
+ if (!isOpen) {
+ setChecked(false);
+ setBusy(false);
+ setConfirmWithdraw(false);
+ setOperationError(null);
+ setOpenedWithUpdate(false);
+ return;
+ }
+ if (!status?.enabled) return;
+
+ void refresh(currentLanguage)
+ .then(async next => {
+ setOpenedWithUpdate(next.hasUnreadUpdate);
+ if (next.hasUnreadUpdate && next.policy) {
+ try {
+ await markViewed(next.policy.updatedAt, currentLanguage);
+ } catch (error) {
+ log.warn('Privacy policy viewed state could not be saved', error);
+ setOperationError('mark_viewed');
+ }
+ }
+ })
+ .catch(error => {
+ log.warn('Privacy status could not be refreshed', error);
+ });
+ }, [currentLanguage, isOpen, markViewed, refresh, status?.enabled]);
+
+ const policy = status?.policy;
+ const fullMode = status?.lifecycleState === 'full' && status.effectiveMode === 'full';
+ const fullModeNeedsRetry =
+ status?.lifecycleState === 'full' && status.effectiveMode === 'privacy_not_accepted';
+
+ const close = useCallback(() => {
+ if (!busy) onClose();
+ }, [busy, onClose]);
+
+ const handleAccept = async () => {
+ if (!policy || !checked || busy) return;
+ setBusy(true);
+ setOperationError(null);
+ try {
+ await accept({
+ policyUpdatedAt: policy.updatedAt,
+ consentVersion: policy.consentVersion,
+ documentSha256: policy.documentSha256,
+ locale: policy.locale,
+ });
+ setChecked(false);
+ } catch (error) {
+ log.warn('Privacy consent could not be saved or applied', error);
+ try {
+ const next = await refresh(currentLanguage);
+ setOperationError(next.lifecycleState === 'full' ? 'apply' : 'accept_save');
+ } catch {
+ setOperationError('accept_save');
+ }
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleApplyRetry = async () => {
+ if (busy) return;
+ setBusy(true);
+ setOperationError(null);
+ try {
+ await applyCollectionPolicy('full', currentLanguage);
+ } catch (error) {
+ log.warn('Full privacy mode could not be applied', error);
+ setOperationError('apply');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleWithdraw = async () => {
+ if (busy) return;
+ setConfirmWithdraw(false);
+ setBusy(true);
+ setOperationError(null);
+ try {
+ await enterNotAccepted(policy?.locale ?? currentLanguage);
+ } catch (error) {
+ log.warn('Privacy withdrawal state could not be saved', error);
+ setOperationError('withdraw');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ if (!policy) return null;
+
+ const errorMessage =
+ operationError === 'accept_save'
+ ? t('privacy.acceptSaveFailed')
+ : operationError === 'apply'
+ ? t('privacy.applyFailed')
+ : operationError === 'withdraw'
+ ? t('privacy.withdrawFailed')
+ : operationError === 'mark_viewed'
+ ? t('privacy.markViewedFailed')
+ : null;
+
+ return (
+ <>
+
+
+
+ {t('privacy.effectiveAt', {
+ date: formatDate(new Date(policy.effectiveAt), { dateStyle: 'long' }),
+ })}
+
+
+ {t('privacy.updatedAt', {
+ date: formatDate(new Date(policy.updatedAt), { dateStyle: 'long' }),
+ })}
+
+ {openedWithUpdate && policy.changeType === 'editorial' ? (
+ {t('privacy.editorialChange')}
+ ) : null}
+
+ {variant === 'about' ? (
+
+ {t(fullMode ? 'privacy.fullMode' : 'privacy.notAcceptedMode')}
+
+ {t(
+ fullMode
+ ? 'privacy.fullModeDescription'
+ : 'privacy.notAcceptedModeDescription',
+ )}
+
+
+ ) : null}
+
+ {errorMessage ? (
+
+ ) : null}
+ {variant === 'about' ? (
+
+ {fullMode ? (
+
+ ) : fullModeNeedsRetry ? (
+
+ ) : (
+
+ setChecked(event.target.checked)}
+ label={t('privacy.consentCheckbox')}
+ />
+
+
+ )}
+
+ ) : null}
+
+ setConfirmWithdraw(false)}
+ onConfirm={() => void handleWithdraw()}
+ title={t('privacy.withdrawConfirmTitle')}
+ message={t('privacy.withdrawConfirmMessage')}
+ confirmText={t('privacy.withdrawConfirmAction')}
+ cancelText={t('privacy.cancel')}
+ confirmDanger
+ />
+ >
+ );
+};
diff --git a/src/web-ui/src/app/components/Privacy/index.ts b/src/web-ui/src/app/components/Privacy/index.ts
new file mode 100644
index 0000000000..9e7ddf09f4
--- /dev/null
+++ b/src/web-ui/src/app/components/Privacy/index.ts
@@ -0,0 +1,3 @@
+export { PrivacyGate } from './PrivacyGate';
+export { PrivacyProvider, usePrivacy } from './PrivacyContext';
+export { PrivacyStatementDialog } from './PrivacyStatementDialog';
diff --git a/src/web-ui/src/app/components/Privacy/privacyGateCopy.json b/src/web-ui/src/app/components/Privacy/privacyGateCopy.json
new file mode 100644
index 0000000000..e8bab04fe1
--- /dev/null
+++ b/src/web-ui/src/app/components/Privacy/privacyGateCopy.json
@@ -0,0 +1,53 @@
+{
+ "zh-CN": {
+ "title": "隐私声明",
+ "intro": "请阅读隐私声明并选择使用模式。",
+ "checkbox": "我已阅读并同意隐私声明",
+ "disagree": "不同意并继续",
+ "agree": "同意并继续",
+ "retryFullMode": "重试启用完整模式",
+ "effective": "生效日期",
+ "updated": "更新时间",
+ "loading": "正在校验隐私声明",
+ "loadError": "隐私声明暂时无法加载",
+ "resourceErrorHint": "你可以重试加载,或关闭后继续使用本地功能。当前不能同意,数据收集保持关闭。",
+ "retry": "重试",
+ "closeAndContinue": "关闭并继续使用",
+ "saveFailed": "选择尚未保存,数据收集保持关闭。请重试。",
+ "releaseBlocked": "内置隐私声明尚未完成发布审核,当前版本不能启用完整模式。"
+ },
+ "zh-TW": {
+ "title": "隱私聲明",
+ "intro": "請閱讀隱私聲明並選擇使用模式。",
+ "checkbox": "我已閱讀並同意隱私聲明",
+ "disagree": "不同意並繼續",
+ "agree": "同意並繼續",
+ "retryFullMode": "重試啟用完整模式",
+ "effective": "生效日期",
+ "updated": "更新時間",
+ "loading": "正在校驗隱私聲明",
+ "loadError": "隱私聲明暫時無法載入",
+ "resourceErrorHint": "你可以重試載入,或關閉後繼續使用本機功能。目前不能同意,資料收集保持關閉。",
+ "retry": "重試",
+ "closeAndContinue": "關閉並繼續使用",
+ "saveFailed": "選擇尚未儲存,資料收集保持關閉。請重試。",
+ "releaseBlocked": "內置隱私聲明尚未完成發佈審核,目前版本不能啟用完整模式。"
+ },
+ "en-US": {
+ "title": "Privacy Statement",
+ "intro": "Review the Privacy Statement and choose how to continue.",
+ "checkbox": "I have read and agree to the Privacy Statement",
+ "disagree": "Disagree and continue",
+ "agree": "Agree and continue",
+ "retryFullMode": "Retry enabling full mode",
+ "effective": "Effective date",
+ "updated": "Updated",
+ "loading": "Validating Privacy Statement",
+ "loadError": "The Privacy Statement could not be loaded",
+ "resourceErrorHint": "Retry, or close this view and continue with local features. Agreement is unavailable and data collection remains off.",
+ "retry": "Retry",
+ "closeAndContinue": "Close and continue",
+ "saveFailed": "Your choice was not saved. Data collection remains off. Try again.",
+ "releaseBlocked": "The bundled Privacy Statement has not completed release review, so full mode is unavailable."
+ }
+}
diff --git a/src/web-ui/src/app/components/Privacy/privacyLifecycleContract.test.ts b/src/web-ui/src/app/components/Privacy/privacyLifecycleContract.test.ts
new file mode 100644
index 0000000000..a489e97649
--- /dev/null
+++ b/src/web-ui/src/app/components/Privacy/privacyLifecycleContract.test.ts
@@ -0,0 +1,104 @@
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+const readSource = (relativePath: string): string =>
+ readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8').replace(/\r\n?/g, '\n');
+
+describe('OpenHarmony privacy lifecycle contract', () => {
+ it('mounts business providers while the privacy choice is still visible', () => {
+ const main = readSource('../../../main.tsx');
+ const business = readSource('../../BusinessApplication.tsx');
+
+ expect(main).toContain('');
+ expect(main).toContain('');
+ expect(main).not.toContain('onBusinessAuthorized');
+ expect(business).toContain('');
+ expect(business).toContain('');
+ });
+
+ it('keeps close separate from a persisted not-accepted choice', () => {
+ const gate = readSource('./PrivacyGate.tsx');
+
+ expect(gate).toContain('const dismiss = useCallback');
+ expect(gate).toContain('setDismissed(true)');
+ expect(gate).toContain("event.key === 'Escape'");
+ expect(gate).toContain('await enterNotAccepted');
+ expect(gate).not.toContain('quitApp');
+ });
+
+ it('uses the explicit lifecycle and collection-policy command surface', () => {
+ const api = readSource('../../../infrastructure/api/service-api/PrivacyAPI.ts');
+ const nativeApi = readSource('../../../../../apps/desktop/src/api/privacy_api.rs');
+
+ for (const command of [
+ 'privacy_initialize',
+ 'privacy_get_status',
+ 'privacy_accept',
+ 'privacy_enter_not_accepted',
+ 'privacy_mark_viewed',
+ 'privacy_apply_collection_policy',
+ ]) {
+ expect(`${api}\n${nativeApi}`).toContain(command);
+ }
+ expect(`${api}\n${nativeApi}`).not.toContain('privacy_withdraw');
+ expect(`${api}\n${nativeApi}`).not.toContain('privacy_release_business_integrations');
+ });
+
+ it('scopes the collection policy to feedback instead of disabling product capabilities', () => {
+ const desktop = readSource('../../../../../apps/desktop/src/lib.rs');
+ const privacyApi = readSource('../../../../../apps/desktop/src/api/privacy_api.rs');
+ const feedbackApi = readSource('../../../../../apps/desktop/src/api/feedback_api.rs');
+ const privacy = readSource('../../../../../crates/services/services-integrations/src/privacy/mod.rs');
+ const capabilitySources = [
+ 'agentic_api.rs',
+ 'announcement_api.rs',
+ 'btw_api.rs',
+ 'commands.rs',
+ 'editor_ai_api.rs',
+ 'miniapp_agent_api.rs',
+ 'remote_connect_api.rs',
+ 'startchat_agent_api.rs',
+ 'system_api.rs',
+ ].map(file => readSource(`../../../../../apps/desktop/src/api/${file}`));
+
+ expect(desktop).toContain('PrivacyServiceState::enabled(');
+ expect(desktop).toContain('remote_connect_api::init_on_startup();');
+ expect(desktop).not.toContain('if privacy_state.collection_allowed()');
+ expect(privacy).toContain('PrivacyCollectionPolicy::new(false)');
+ expect(feedbackApi).toContain('!privacy_state.collection_allowed()');
+ expect(privacyApi).not.toContain('require_collection_allowed');
+ expect(privacyApi).not.toContain('suspend_for_privacy');
+ for (const source of capabilitySources) {
+ expect(source).not.toContain('require_collection_allowed');
+ }
+ });
+
+ it('requests calendar permission only when calendar is used and does not auto-update at startup', () => {
+ const entryAbility = readSource('../../../../../apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets');
+ const startup = entryAbility.slice(
+ entryAbility.indexOf('onWindowStageCreate'),
+ entryAbility.indexOf("registerArktsFunction('call_calendar'"),
+ );
+ const calendar = entryAbility.slice(
+ entryAbility.indexOf("registerArktsFunction('call_calendar'"),
+ entryAbility.indexOf("registerArktsFunction('call_harmony_build'"),
+ );
+
+ expect(startup).not.toContain('requestPermissionsFromUser');
+ expect(calendar).toContain('requestPermissionsFromUser');
+ expect(entryAbility).not.toContain('this.appUpdater.check');
+ });
+
+ it('renders resource failure without an agreement action', () => {
+ const gate = readSource('./PrivacyGate.tsx');
+ const errorView = gate.slice(
+ gate.indexOf('data-testid="privacy-resource-error"'),
+ gate.indexOf('data-testid="privacy-consent-gate"'),
+ );
+
+ expect(errorView).toContain('copy.closeAndContinue');
+ expect(errorView).toContain('copy.retry');
+ expect(errorView).not.toContain('handleAccept');
+ });
+});
diff --git a/src/web-ui/src/app/components/Privacy/privacyPolicyManagementContract.test.ts b/src/web-ui/src/app/components/Privacy/privacyPolicyManagementContract.test.ts
new file mode 100644
index 0000000000..45239e9890
--- /dev/null
+++ b/src/web-ui/src/app/components/Privacy/privacyPolicyManagementContract.test.ts
@@ -0,0 +1,67 @@
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+const readSource = (relativePath: string): string =>
+ readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8').replace(/\r\n?/g, '\n');
+
+describe('OpenHarmony privacy policy management contract', () => {
+ it('offers managed full, not-accepted, and read-only detail modes', () => {
+ const dialog = readSource('./PrivacyStatementDialog.tsx');
+ const about = readSource('../AboutDialog/AboutDialog.tsx');
+
+ expect(dialog).toContain("variant?: 'about' | 'readonly'");
+ expect(dialog).toContain("t('privacy.withdraw')");
+ expect(dialog).toContain("t('privacy.enableFull')");
+ expect(dialog).toContain("variant === 'about'");
+ expect(about).toContain("setSubDialog('privacy')");
+ expect(about).toContain('privacyStatus.hasUnreadUpdate');
+ });
+
+ it('keeps collection disabled when withdrawal persistence or full-mode application fails', () => {
+ const native = readSource('../../../../../apps/desktop/src/api/privacy_api.rs');
+ const dialog = readSource('./PrivacyStatementDialog.tsx');
+ const gate = readSource('./PrivacyGate.tsx');
+
+ const withdraw = native.slice(
+ native.indexOf('pub async fn privacy_enter_not_accepted'),
+ native.indexOf('pub async fn privacy_mark_viewed'),
+ );
+ expect(withdraw.indexOf('state.enter_not_accepted_mode()?')).toBeLessThan(
+ withdraw.indexOf('.enter_not_accepted('),
+ );
+ expect(withdraw).not.toContain('suspend_for_privacy');
+ expect(dialog).toContain("operationError === 'withdraw'");
+ expect(dialog).toContain("operationError === 'apply'");
+ expect(gate).toContain('applyRetryRequired');
+ expect(gate).toContain("applyCollectionPolicy('full', locale)");
+ expect(`${native}\n${dialog}`).not.toContain('quitApp');
+ });
+
+ it('uses only the policy timestamp for editorial update state', () => {
+ const service = readSource(
+ '../../../../../crates/services/services-integrations/src/privacy/mod.rs',
+ );
+
+ expect(service).toContain('PrivacyChangeType::Editorial');
+ expect(service).not.toContain('const POLICY_VERSION');
+ expect(service).toContain('const CONSENT_VERSION: &str = "4"');
+ expect(service).toContain(
+ 'state.viewed_policy_updated_at.as_deref() != Some(POLICY_UPDATED_AT)',
+ );
+ expect(service).toContain('new_state_persists_timestamps_without_policy_versions');
+ expect(service).not.toContain('accepted_policy_version');
+ expect(service).not.toContain('viewed_policy_version');
+ });
+
+ it('projects privacy updates and feedback replies onto the home more-options indicator', () => {
+ const footer = readSource('../NavPanel/components/PersistentFooterActions.tsx');
+ const styles = readSource('../NavPanel/NavPanel.scss');
+
+ expect(footer).toContain('privacyStatus.hasUnreadUpdate');
+ expect(footer).toContain('hasUnreadFeedback || hasPrivacyUpdate');
+ expect(footer).toContain('bitfun-nav-panel__footer-more-unread');
+ expect(footer).toContain("t('privacy.aboutEntryUpdated')");
+ expect(styles).toContain('.bitfun-nav-panel__footer-more-unread');
+ });
+});
diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx
index 8f15538cf7..760bae8ec8 100644
--- a/src/web-ui/src/app/layout/AppLayout.tsx
+++ b/src/web-ui/src/app/layout/AppLayout.tsx
@@ -36,6 +36,7 @@ import { useSessionModeStore } from '../stores/sessionModeStore';
import { isMacOSDesktopRuntime } from '@/infrastructure/runtime';
import { flowChatSessionConfigForWorkspace } from '../utils/projectSessionWorkspace';
import { notificationService } from '@/shared/notification-system';
+import { confirmCriticalOperationExit } from '@/shared/services/criticalOperationExitGuard';
import './AppLayout.scss';
type TransitionDirection = 'entering' | 'returning' | null;
@@ -471,21 +472,27 @@ const AppLayout: React.FC = ({ className = '' }) => {
showCancel: true,
});
if (shouldQuit) {
- await persistInterruptedTurnsForExit();
- await systemAPI.quitApp();
+ if (await confirmCriticalOperationExit()) {
+ await persistInterruptedTurnsForExit();
+ await systemAPI.quitApp();
+ }
} else {
await systemAPI.minimizeToTray();
}
} else {
// quit
- await persistInterruptedTurnsForExit();
- await systemAPI.quitApp();
+ if (await confirmCriticalOperationExit()) {
+ await persistInterruptedTurnsForExit();
+ await systemAPI.quitApp();
+ }
}
} catch (error) {
log.error('Failed to handle close request', { behavior, error });
try {
- await persistInterruptedTurnsForExit();
- await systemAPI.quitApp();
+ if (await confirmCriticalOperationExit()) {
+ await persistInterruptedTurnsForExit();
+ await systemAPI.quitApp();
+ }
} catch { /* ignore */ }
} finally {
handlingClose = false;
diff --git a/src/web-ui/src/component-library/components/ConfirmDialog/ConfirmDialog.tsx b/src/web-ui/src/component-library/components/ConfirmDialog/ConfirmDialog.tsx
index 4f4e354325..0ad0a94494 100644
--- a/src/web-ui/src/component-library/components/ConfirmDialog/ConfirmDialog.tsx
+++ b/src/web-ui/src/component-library/components/ConfirmDialog/ConfirmDialog.tsx
@@ -39,6 +39,12 @@ export interface ConfirmDialogProps {
confirmDanger?: boolean;
/** Whether to show the cancel button */
showCancel?: boolean;
+ /** Whether the confirm action is disabled */
+ confirmDisabled?: boolean;
+ /** Whether the cancel action is disabled */
+ cancelDisabled?: boolean;
+ /** Whether the confirm action is in progress */
+ confirmLoading?: boolean;
/** Preview content (e.g. multi-line text) */
preview?: string;
/** Max preview height */
@@ -66,6 +72,9 @@ export const ConfirmDialog: React.FC = ({
cancelText,
confirmDanger = false,
showCancel = true,
+ confirmDisabled = false,
+ cancelDisabled = false,
+ confirmLoading = false,
preview,
previewMaxHeight = 200,
}) => {
@@ -140,6 +149,7 @@ export const ConfirmDialog: React.FC = ({