Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/desktop/src/main/__tests__/health-center-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getHealthCenterCopy } from '../../renderer/locales/settings-health-copy.js';

test('labels blocker counts as global across filtered health views', () => {
assert.equal(
getHealthCenterCopy('zh').blockers.send(1, 6),
'全部健康信号中,1/6 条会阻塞发送',
);
assert.equal(
getHealthCenterCopy('en').blockers.send(1, 6),
'Across all health signals, 1 of 6 blocks sending',
);
});
27 changes: 27 additions & 0 deletions apps/desktop/src/main/__tests__/permission-center-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getPermissionCenterCopy } from '../../renderer/locales/permission-center-copy.js';

test('presents a granted OS permission as a verified success', () => {
assert.equal(getPermissionCenterCopy('zh').osStates.granted.tone, 'success');
assert.equal(getPermissionCenterCopy('en').osStates.granted.tone, 'success');
});
9 changes: 5 additions & 4 deletions apps/desktop/src/renderer/locales/permission-center-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export type PermissionCenterCopy = {
lastRead: string;
detectAgain: string;
summaryAria: string;
summaryFilterAria(label: string, count: number, selected: boolean): string;
granted: string;
pending: string;
denied: string;
Expand Down Expand Up @@ -112,7 +113,7 @@ const PERMISSION_CENTER_COPY = {
},
osStates: {
unsupported: { label: '当前平台不支持', tone: 'neutral' }, unknown: { label: '无法读取状态', tone: 'neutral' },
not_determined: { label: '等待授权', tone: 'attention' }, denied: { label: '已拒绝', tone: 'error' }, granted: { label: '已授权', tone: 'neutral' },
not_determined: { label: '等待授权', tone: 'attention' }, denied: { label: '已拒绝', tone: 'error' }, granted: { label: '已授权', tone: 'success' },
},
loading: '正在加载权限快照', readFailed: '无法读取权限快照', noData: '权限服务未返回数据。', readAgain: '重新读取',
actionFailed: '权限操作失败',
Expand All @@ -126,7 +127,7 @@ const PERMISSION_CENTER_COPY = {
failed: '权限操作未成功,请稍后重试。',
},
title: '权限与能力', subtitle: '查看 Maka 需要的系统权限和当前授权状态,直接从这里前往「系统设置 → 隐私与安全性」完成授权或撤销,不必自己翻菜单。',
lastRead: '最近读取:', detectAgain: '重新检测', summaryAria: '权限概览', granted: '已授权', pending: '等待授权', denied: '已拒绝', other: '未知 / 不支持',
lastRead: '最近读取:', detectAgain: '重新检测', summaryAria: '按授权状态筛选系统权限', summaryFilterAria: (label, count, selected) => selected ? `${label} ${count} 项,当前筛选;再次按下显示全部` : `仅显示${label}权限,共 ${count} 项`, granted: '已授权', pending: '等待授权', denied: '已拒绝', other: '未知 / 不支持',
osSection: '系统权限', osSectionHelp: 'Maka 读到的 OS 级权限状态。点击右侧按钮可以直接前往「系统设置 → 隐私与安全性」对应分区。', osListAria: '系统权限列表',
capabilitiesSection: '功能能力', capabilitiesHelp: '每个能力的就绪状态由「功能开关 · 配置 · 系统权限 · 运行态探测」共同决定。',
capabilityListAria: '功能能力列表',
Expand Down Expand Up @@ -159,7 +160,7 @@ const PERMISSION_CENTER_COPY = {
},
osStates: {
unsupported: { label: 'Unsupported on this platform', tone: 'neutral' }, unknown: { label: 'Status unavailable', tone: 'neutral' },
not_determined: { label: 'Waiting for permission', tone: 'attention' }, denied: { label: 'Denied', tone: 'error' }, granted: { label: 'Granted', tone: 'neutral' },
not_determined: { label: 'Waiting for permission', tone: 'attention' }, denied: { label: 'Denied', tone: 'error' }, granted: { label: 'Granted', tone: 'success' },
},
loading: 'Loading permission snapshot', readFailed: 'Could not read permission snapshot', noData: 'The permission service returned no data.', readAgain: 'Read again',
actionFailed: 'Permission action failed',
Expand All @@ -173,7 +174,7 @@ const PERMISSION_CENTER_COPY = {
failed: 'The permission action did not succeed. Try again later.',
},
title: 'Permissions and capabilities', subtitle: 'Review the system permissions Maka needs and their current state. Open the matching Privacy & Security section directly to grant or revoke access.',
lastRead: 'Last read: ', detectAgain: 'Check again', summaryAria: 'Permission overview', granted: 'Granted', pending: 'Waiting', denied: 'Denied', other: 'Unknown / unsupported',
lastRead: 'Last read: ', detectAgain: 'Check again', summaryAria: 'Filter system permissions by authorization status', summaryFilterAria: (label, count, selected) => selected ? `${label}, ${count}; filter selected. Press again to show all permissions` : `Show only ${label.toLowerCase()} permissions, ${count}`, granted: 'Granted', pending: 'Waiting', denied: 'Denied', other: 'Unknown / unsupported',
osSection: 'System permissions', osSectionHelp: 'OS-level permission states reported to Maka. Use the action on the right to open the matching Privacy & Security section in System Settings.', osListAria: 'System permission list',
capabilitiesSection: 'Feature capabilities', capabilitiesHelp: 'Each readiness state combines the feature toggle, configuration, system permissions, and runtime probe.',
capabilityListAria: 'Feature capability list',
Expand Down
20 changes: 15 additions & 5 deletions apps/desktop/src/renderer/locales/settings-health-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ export type HealthCenterCopy = {
lastRead: string;
refresh: string;
summaryAria: string;
blockers: { send(count: number): string; capability(count: number): string };
summaryFilterAria(label: string, count: number, selected: boolean): string;
blockers: {
send(count: number, totalCount: number): string;
capability(count: number, totalCount: number): string;
};
layerAria(label: string): string;
layerListAria(label: string): string;
footnote: string;
Expand Down Expand Up @@ -86,8 +90,11 @@ const SETTINGS_HEALTH_COPY = {
zh: {
loading: '正在加载健康快照', readFailed: '无法读取健康快照', noData: '健康服务未返回数据。', readAgain: '重新读取',
title: '健康中心', subtitle: '各项能力当前的运行状况检查。',
badge: '只读快照', lastRead: '最近一次读取:', refresh: '刷新', summaryAria: '健康摘要',
blockers: { send: (count) => `${count} 条健康信号会阻塞发送`, capability: (count) => `${count} 条健康信号会阻塞能力` },
badge: '只读快照', lastRead: '最近一次读取:', refresh: '刷新', summaryAria: '按状态筛选健康信号', summaryFilterAria: (label, count, selected) => selected ? `${label} ${count} 项,当前筛选;再次按下显示全部` : `仅显示${label}健康信号,共 ${count} 项`,
blockers: {
send: (count, totalCount) => `全部健康信号中,${count}/${totalCount} 条会阻塞发送`,
capability: (count, totalCount) => `全部健康信号中,${count}/${totalCount} 条会阻塞能力`,
},
layerAria: (label) => `${label}健康信号`, layerListAria: (label) => `${label}健康信号列表`,
footnote: '本页不直接执行测试、修复或权限变更;它只汇总当前已记录的健康信号。需要处理问题时,请进入对应设置页或重新触发相关功能。',
layers: layersZh,
Expand All @@ -102,8 +109,11 @@ const SETTINGS_HEALTH_COPY = {
en: {
loading: 'Loading health snapshot', readFailed: 'Could not read health snapshot', noData: 'The health service returned no data.', readAgain: 'Read again',
title: 'Health center', subtitle: 'How each capability is currently doing.',
badge: 'Read-only snapshot', lastRead: 'Last read: ', refresh: 'Refresh', summaryAria: 'Health summary',
blockers: { send: (count) => `${count} health ${count === 1 ? 'signal blocks' : 'signals block'} sending`, capability: (count) => `${count} health ${count === 1 ? 'signal blocks' : 'signals block'} capabilities` },
badge: 'Read-only snapshot', lastRead: 'Last read: ', refresh: 'Refresh', summaryAria: 'Filter health signals by status', summaryFilterAria: (label, count, selected) => selected ? `${label}, ${count}; filter selected. Press again to show all signals` : `Show only ${label.toLowerCase()} health signals, ${count}`,
blockers: {
send: (count, totalCount) => `Across all health signals, ${count} of ${totalCount} ${count === 1 ? 'blocks' : 'block'} sending`,
capability: (count, totalCount) => `Across all health signals, ${count} of ${totalCount} ${count === 1 ? 'blocks' : 'block'} capabilities`,
},
layerAria: (label) => `${label} health signals`, layerListAria: (label) => `${label} health signal list`,
footnote: 'This page does not run tests, repairs, or permission changes. It only summarizes recorded health signals. Open the relevant settings page or retry the related feature to address an issue.',
layers: layersEn,
Expand Down
53 changes: 36 additions & 17 deletions apps/desktop/src/renderer/settings/health-center-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useEffect, useState } from 'react';
import type {
HealthSignal,
HealthSignalLayer,
HealthSignalStatus,
HealthSnapshot,
} from '@maka/core/health';
import { HEALTH_SIGNAL_LAYERS } from '@maka/core/health';
Expand All @@ -32,6 +33,10 @@ import { SettingsPage, SettingsRow, SettingsSection } from './settings-section';
import { SettingsSkeletonStack } from './settings-skeleton';
import { dotForStatus } from '@maka/ui';
import { useRuntimeHostSettingsTarget } from './runtime-host-settings-target.js';
import {
SettingsStatusSummaryFilter,
type SettingsStatusSummaryOption,
} from './settings-status-summary-filter';

/**
* PR-UI-9 — Health Center read-only page. Consumes `window.maka.health.getSnapshot()`
Expand All @@ -56,6 +61,7 @@ export function HealthCenterPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshTick, setRefreshTick] = useState(0);
const [signalFilter, setSignalFilter] = useState<HealthSignalStatus | null>(null);

useEffect(() => {
let cancelled = false;
Expand All @@ -78,6 +84,14 @@ export function HealthCenterPage() {
};
}, [host, locale, refreshTick]);

useEffect(() => {
if (!snapshot) return;
setSignalFilter((current) => {
if (!current) return current;
return snapshot.signals.some((signal) => signal.status === current) ? current : null;
});
}, [snapshot]);

if (loading) {
return (
<SettingsSkeletonStack label={copy.loading} />
Expand All @@ -97,15 +111,18 @@ export function HealthCenterPage() {
}

const healthCheckedAtMs = snapshot.checkedAt;
const signalsByLayer = groupSignalsByLayer(snapshot.signals);
const visibleSignals = signalFilter
? snapshot.signals.filter((signal) => signal.status === signalFilter)
: snapshot.signals;
const signalsByLayer = groupSignalsByLayer(visibleSignals);
const blocksSendCount = snapshot.signals.filter((signal) => signal.blocksSend).length;
const blocksCapabilityCount = snapshot.signals.filter((signal) => signal.blocksCapability).length;
const summaryParts: Array<{ key: string; label: string; count: number; tone: 'neutral' | 'warning' | 'destructive' }> = [
{ key: 'ok', label: copy.statuses.ok.label, count: snapshot.summary.ok, tone: 'neutral' },
{ key: 'info', label: copy.statuses.info.label, count: snapshot.summary.info, tone: 'neutral' },
{ key: 'warning', label: copy.statuses.warning.label, count: snapshot.summary.warning, tone: 'warning' },
{ key: 'error', label: copy.statuses.error.label, count: snapshot.summary.error, tone: 'destructive' },
{ key: 'unknown', label: copy.statuses.unknown.label, count: snapshot.summary.unknown, tone: 'neutral' },
const summaryParts: Array<SettingsStatusSummaryOption<HealthSignalStatus>> = [
{ value: 'ok', label: copy.statuses.ok.label, count: snapshot.summary.ok, tone: 'neutral' },
{ value: 'info', label: copy.statuses.info.label, count: snapshot.summary.info, tone: 'neutral' },
{ value: 'warning', label: copy.statuses.warning.label, count: snapshot.summary.warning, tone: 'warning' },
{ value: 'error', label: copy.statuses.error.label, count: snapshot.summary.error, tone: 'destructive' },
{ value: 'unknown', label: copy.statuses.unknown.label, count: snapshot.summary.unknown, tone: 'neutral' },
];

return (
Expand All @@ -132,28 +149,30 @@ export function HealthCenterPage() {
</div>
)}
>
<p className="settingsHealthSummaryLine" role="group" aria-label={copy.summaryAria}>
{summaryParts.map((part) => (
<span key={part.key} data-tone={part.count > 0 ? part.tone : 'neutral'}>
{part.label} {part.count}
</span>
))}
</p>
<SettingsStatusSummaryFilter<HealthSignalStatus>
value={signalFilter}
options={summaryParts}
label={copy.summaryAria}
optionLabel={(option, selected) => copy.summaryFilterAria(option.label, option.count, selected)}
onChange={setSignalFilter}
/>
</SettingsSection>

{blocksSendCount > 0 && (
<Banner
status="error"
role="status"
title={copy.blockers.send(blocksSendCount)}
description={blocksCapabilityCount > 0 ? copy.blockers.capability(blocksCapabilityCount) : undefined}
title={copy.blockers.send(blocksSendCount, snapshot.signals.length)}
description={blocksCapabilityCount > 0
? copy.blockers.capability(blocksCapabilityCount, snapshot.signals.length)
: undefined}
/>
)}
{blocksSendCount === 0 && blocksCapabilityCount > 0 && (
<Banner
status="warning"
role="status"
title={copy.blockers.capability(blocksCapabilityCount)}
title={copy.blockers.capability(blocksCapabilityCount, snapshot.signals.length)}
/>
)}

Expand Down
52 changes: 45 additions & 7 deletions apps/desktop/src/renderer/settings/permission-center-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ import {
import { dotForStatus } from '@maka/ui';
import { SettingsSkeletonStack } from './settings-skeleton';
import { useActionGuard } from './use-action-guard';
import {
SettingsStatusSummaryFilter,
type SettingsStatusSummaryOption,
} from './settings-status-summary-filter';

/**
* PR-UI-8 — Permission Center read-only page. Consumes `window.maka.permissions.getSnapshot()`
Expand Down Expand Up @@ -88,6 +92,8 @@ const OS_PERMISSION_ICONS: Record<OsPermissionId, ComponentType<LucideProps>> =
automation: MousePointer2,
};

type PermissionStatusFilter = 'granted' | 'pending' | 'denied' | 'other';

export function PermissionCenterPage() {
const host = useRuntimeHostSettingsTarget();
const locale = useUiLocale();
Expand All @@ -98,6 +104,7 @@ export function PermissionCenterPage() {
const [error, setError] = useState<string | null>(null);
const [refreshTick, setRefreshTick] = useState(0);
const [pendingPermAction, setPendingPermAction] = useState<string | null>(null);
const [permissionFilter, setPermissionFilter] = useState<PermissionStatusFilter | null>(null);
const reportHostError = useRuntimeHostSettingsErrorReporter();
const mountedRef = useMountedRef();
const permissionActionGuard = useActionGuard<string>();
Expand Down Expand Up @@ -140,6 +147,14 @@ export function PermissionCenterPage() {
};
}, []);

useEffect(() => {
if (!permissions) return;
setPermissionFilter((current) => {
if (!current) return current;
return permissionIdsForFilter(permissions, current).length > 0 ? current : null;
});
}, [permissions]);

async function runPermissionAction(
permId: OsPermissionId,
kind: 'request' | 'openSettings' | 'dragGrant',
Expand Down Expand Up @@ -206,6 +221,15 @@ export function PermissionCenterPage() {

const checkedAtMs = capabilities.checkedAt;
const counts = summarizePermissionStatuses(permissions);
const visiblePermissionIds = permissionFilter
? permissionIdsForFilter(permissions, permissionFilter)
: OS_PERMISSION_IDS;
const summaryFilters: Array<SettingsStatusSummaryOption<PermissionStatusFilter>> = [
{ value: 'granted', label: copy.granted, count: counts.granted, tone: 'success' },
{ value: 'pending', label: copy.pending, count: counts.pending, tone: 'warning' },
{ value: 'denied', label: copy.denied, count: counts.denied, tone: 'destructive' },
{ value: 'other', label: copy.other, count: counts.other, tone: 'neutral' },
];

return (
<SettingsPage>
Expand Down Expand Up @@ -235,14 +259,15 @@ export function PermissionCenterPage() {
</div>
)}
>
<p className="settingsHealthSummaryLine" role="group" aria-label={copy.summaryAria}>
<span data-tone="neutral">{copy.granted} {counts.granted}</span>
<span data-tone={counts.pending > 0 ? 'warning' : 'neutral'}>{copy.pending} {counts.pending}</span>
<span data-tone={counts.denied > 0 ? 'destructive' : 'neutral'}>{copy.denied} {counts.denied}</span>
<span data-tone="neutral">{copy.other} {counts.other}</span>
</p>
<SettingsStatusSummaryFilter<PermissionStatusFilter>
value={permissionFilter}
options={summaryFilters}
label={copy.summaryAria}
optionLabel={(option, selected) => copy.summaryFilterAria(option.label, option.count, selected)}
onChange={setPermissionFilter}
/>
<List hasDividers aria-label={copy.osListAria}>
{OS_PERMISSION_IDS.map((id) => (
{visiblePermissionIds.map((id) => (
<OsPermissionRow
key={id}
snapshot={permissions.permissions[id]}
Expand Down Expand Up @@ -340,6 +365,18 @@ function summarizePermissionStatuses(snapshot: PermissionSnapshot): {
return { granted, pending, denied, other };
}

function permissionIdsForFilter(
snapshot: PermissionSnapshot,
filter: PermissionStatusFilter,
): OsPermissionId[] {
return OS_PERMISSION_IDS.filter((id) => {
const status = snapshot.permissions[id]?.status;
if (filter === 'pending') return status === 'not_determined';
if (filter === 'other') return status !== 'granted' && status !== 'not_determined' && status !== 'denied';
return status === filter;
});
}

function permissionActionFailureCopy(reason: string, message: string | undefined, copy: PermissionCenterCopy): string {
switch (reason) {
case 'invalid_id':
Expand Down Expand Up @@ -619,6 +656,7 @@ function OsPermissionRow(props: {

return (
<ListItem
data-permission-id={snapshot.id}
data-state={snapshot.status}
/* The plate keeps its class: a status-tinted rounded icon well is
product artwork (it turns red when a permission is denied), not
Expand Down
Loading