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
47 changes: 47 additions & 0 deletions apps/desktop/e2e/about-page.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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 { ensureSidebarExpanded, expect, test } from './fixtures';

test('About renders channel facts, support, and privacy on a dev checkout', async ({ window: page }) => {
await ensureSidebarExpanded(page);
await page.getByRole('button', { name: '设置' }).click();
await page.getByRole('button', { name: '关于', exact: true }).click();

// The channel token must agree with the version string: the fixture app is a
// dev checkout, so it reads 本地开发版.
await expect(page.getByText('本地开发版', { exact: true })).toBeVisible();
await expect(page.getByText('本地开发构建,不检查更新。')).toBeVisible();

// A dev checkout follows no feed, so the whole update row is absent — it is
// not a disabled button next to a sentence repeating the line above it.
await expect(page.getByRole('button', { name: '检查更新' })).toHaveCount(0);

// Support lives outside the info conditional: usable even when `app.info`
// fails, which is exactly when a user reaches for it. Each row-end control is
// named by its row, not by the verb on its face.
await expect(page.getByRole('heading', { name: '支持', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '复制诊断信息' })).toBeEnabled();
await expect(page.getByRole('link', { name: '报告问题' })).toBeVisible();
await expect(page.getByRole('button', { name: '键盘快捷键' })).toBeEnabled();

// Three commitments, not the old wall of five bullets.
Comment thread
Astro-Han marked this conversation as resolved.
const privacyList = page.getByRole('list', { name: '隐私承诺' });
await expect(privacyList.getByRole('listitem')).toHaveCount(3);
});
3 changes: 0 additions & 3 deletions apps/desktop/renderer-architecture.json
Original file line number Diff line number Diff line change
Expand Up @@ -2585,17 +2585,14 @@
"../../preload/bridge-contract.js": 1,
"../default-runtime-host-operation.js": 1,
"../locales/settings-preferences-copy.js": 1,
"../locales/settings-shared-copy.js": 1,
"./about-update-status.js": 1,
"./settings-error-copy.js": 1,
"./settings-rows.js": 1,
"./settings-section.js": 1,
"./settings-skeleton.js": 1,
"./use-action-guard.js": 1,
"@astryxdesign/core": 1,
"@astryxdesign/core/Kbd": 1,
"@maka/ui": 1,
"@maka/ui/icons": 1,
"react": 1
}
},
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/main/__tests__/about-settings-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ test('keeps manual diagnostics available while About metadata is pending', () =>
createElement(LocaleProvider, { locale: 'en', children: withAstryxLocale }),
);

assert.match(markup, />Copy diagnostics</);
// The row LABEL also reads "Copy diagnostics", so match the control itself:
// its accessible name is the aria-label, not the verb on its face.
assert.match(markup, /<button[^>]*aria-label="Copy diagnostics"/);
assert.match(markup, /role="status"[^>]*aria-busy="true"/);
});
77 changes: 77 additions & 0 deletions apps/desktop/src/main/__tests__/about-update-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* 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 {
aboutChannelFacts,
aboutUpdateStatusDetail,
} from '../../renderer/settings/about-update-status.js';
import { getSettingsPreferencesCopy } from '../../renderer/locales/settings-preferences-copy.js';

const copy = getSettingsPreferencesCopy('zh').about;

test('a packaged nightly is tokened Nightly, never 正式版', () => {
const facts = aboutChannelFacts({ buildMode: 'packaged', updateChannel: 'nightly' }, copy);
assert.deepEqual(facts.token, { label: 'Nightly', color: 'orange' });
assert.match(facts.summary, /会覆盖正式版安装/);
});

test('a packaged release wears no token — it is the default state', () => {
const facts = aboutChannelFacts({ buildMode: 'packaged', updateChannel: 'release' }, copy);
assert.equal(facts.token, null);
assert.equal(facts.summary, '正式发布版,自动接收稳定更新。');
});

test('buildMode decides before updateChannel, whose dev value is a placeholder', () => {
const facts = aboutChannelFacts({ buildMode: 'dev', updateChannel: 'nightly' }, copy);
assert.deepEqual(facts.token, { label: '本地开发版', color: 'gray' });
assert.equal(facts.summary, '本地开发构建,不检查更新。');
});

test('the nightly steady states each read as themselves', () => {
const detail = (status: Parameters<typeof aboutUpdateStatusDetail>[0]) =>
aboutUpdateStatusDetail(status, copy);

assert.equal(
detail({
state: 'downloading',
currentVersion: '0.2.0-dev.11.20260831',
latestVersion: '0.2.0-dev.12.20260901',
progress: { percent: 42.4, bytesPerSecond: 1, transferred: 1, total: 2 },
}),
'正在下载 v0.2.0-dev.12.20260901(42%)…',
);
assert.equal(
detail({
state: 'verifying',
currentVersion: '0.2.0-dev.11.20260831',
latestVersion: '0.2.0-dev.12.20260901',
}),
'正在验证 v0.2.0-dev.12.20260901 的发布来源…',
);
assert.equal(
detail({
state: 'downloaded',
currentVersion: '0.2.0-dev.11.20260831',
latestVersion: '0.2.0-dev.12.20260901',
}),
'v0.2.0-dev.12.20260901 已下载,可在侧栏选择重启安装。',
);
});
29 changes: 29 additions & 0 deletions apps/desktop/src/main/__tests__/app-update-attestation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import {
desktopDiagnosticUpdateChannel,
desktopUpdateChannelFromManifest,
verifyDownloadedUpdateAttestation,
} from '../app-update-attestation.js';
Expand Down Expand Up @@ -294,6 +295,34 @@ test('packaged update trust accepts only an explicit release or nightly channel'
);
});

test('a diagnostic report names the channel it can prove, and admits when it cannot', async () => {
const appPath = await mkdtemp(join(tmpdir(), 'maka-channel-'));
const channelOf = (isPackaged: boolean) => desktopDiagnosticUpdateChannel({ isPackaged, appPath });
try {
// Packaged: the manifest electron-builder stamped is the authority, and the
// two feeds must come back distinct — they carry different signers.
await writeFile(join(appPath, 'package.json'), JSON.stringify({ makaUpdateChannel: 'nightly' }));
assert.equal(channelOf(true), 'nightly');
await writeFile(join(appPath, 'package.json'), JSON.stringify({ makaUpdateChannel: 'release' }));
assert.equal(channelOf(true), 'release');

// A checkout follows no feed, and never reads the manifest: the `release`
// value the updater falls back to is a placeholder, not a fact.
assert.equal(channelOf(false), 'dev');

// Unlike the strict parser, this one must not throw — the report has to
// copy even when the manifest is the thing that is broken.
await writeFile(join(appPath, 'package.json'), '{ not json');
assert.equal(channelOf(true), 'unknown');
await writeFile(join(appPath, 'package.json'), JSON.stringify({ makaUpdateChannel: 'preview' }));
assert.equal(channelOf(true), 'unknown');
await rm(join(appPath, 'package.json'));
assert.equal(channelOf(true), 'unknown');
} finally {
await rm(appPath, { recursive: true, force: true });
}
});

test('TUF verifies ECDSA without breaking Ed25519 in the packaged Electron runtime', () => {
assertElectronVerification(
String.raw`
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@ import {
MAIN_PROCESS_DIAGNOSTIC_LOG_MAX_BYTES,
mainProcessLogBuffer,
parseDesktopDiagnosticInput,
type DesktopDiagnosticChannel,
} from '../main-process-diagnostics.js';

const environment = {
appVersion: '0.1.8',
buildMode: 'dev' as const,
updateChannel: 'dev' as const,
buildCommit: 'a'.repeat(40),
electronVersion: '38.0.0',
nodeVersion: '22.0.0',
Expand Down Expand Up @@ -107,6 +109,25 @@ test('formats one redacted Desktop and Runtime Host diagnostic report', () => {
assert.doesNotMatch(report, /sk-secretvalue123|\/home\/tester/);
});

test('names the update channel, which buildMode alone cannot distinguish', () => {
const reportFor = (channel: DesktopDiagnosticChannel, buildMode: 'dev' | 'packaged') =>
formatDesktopDiagnosticReport(
{ surface: 'manual', hostTarget: 'default' },
{ ...environment, buildMode, updateChannel: channel },
[],
{ ok: false, error: 'not started' },
undefined,
new Date('2026-08-09T00:00:00Z'),
);

// Both packaged installs report `Build: packaged`; only the channel line
// says which feed and which attestation signer they trust.
assert.match(reportFor('nightly', 'packaged'), /^Channel: nightly$/mu);
assert.match(reportFor('release', 'packaged'), /^Channel: release$/mu);
// A checkout follows no feed, so it is neither of them.
assert.match(reportFor('dev', 'dev'), /^Channel: dev$/mu);
});

test('bounds renderer diagnostic text and rejects unknown fields', () => {
const input = parseDesktopDiagnosticInput({
surface: 'toast',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
const diagnosticEnvironment = () => ({
appVersion: '0.1.8',
buildMode: 'packaged' as const,
updateChannel: 'release' as const,
buildCommit: null,
electronVersion: '38.0.0',
nodeVersion: '22.0.0',
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/app-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { ProjectRootController } from './project-root-controller.js';
import { resolveOpenPath, type OpenPathResult } from './open-path-guard.js';
import { getE2eFixtureState, type resolveE2eFixture } from './e2e-fixture.js';
import type { resolveBuildInfo } from './build-info.js';
import type { DesktopUpdateChannel } from './app-update-attestation.js';
import type {
AppUpdateInstallRequest,
AppUpdateService,
Expand All @@ -47,6 +48,7 @@ export interface AppIpcDeps {
getProjectRoot(sessionId: unknown): Promise<string>;
workspaceRoot: string;
buildInfo: BuildInfo;
updateChannel: DesktopUpdateChannel;
e2eFixture: E2eFixture;
projectManagement: ProjectManagementService;
allowLocalProjectPaths?: boolean;
Expand Down Expand Up @@ -89,7 +91,7 @@ export function registerAppIpc(
deps: AppIpcDeps,
targetIpc: ReconnectableReadIpcMain = ipcMain,
): void {
const { projectRoot, workspaceRoot, buildInfo, e2eFixture } = deps;
const { projectRoot, workspaceRoot, buildInfo, updateChannel, e2eFixture } = deps;
const allowLocalProjectPaths = deps.allowLocalProjectPaths !== false;
// Call-time read of the shared project-root authority: every handler must
// observe the latest selection, not a snapshot taken at registration.
Expand Down Expand Up @@ -120,6 +122,7 @@ export function registerAppIpc(
: { isGitRepo: false },
buildMode: buildInfo.mode,
buildCommit: buildInfo.commit,
updateChannel,
};
});
handleReconnectableRead(targetIpc, 'projects:getSnapshot', () =>
Expand Down
27 changes: 25 additions & 2 deletions apps/desktop/src/main/app-update-attestation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ import { bundleFromJSON, type Bundle } from '@sigstore/bundle';
import { getTrustedRoot } from '@sigstore/tuf';
import { toSignedEntity, toTrustMaterial, Verifier } from '@sigstore/verify';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { basename } from 'node:path';
import { createReadStream, readFileSync } from 'node:fs';
import { basename, join } from 'node:path';

const PRODUCT_REPOSITORY = 'apache/maka';
const PRODUCT_RELEASE_WORKFLOW = '.github/workflows/release-cli-finalize.yml';
Expand Down Expand Up @@ -80,6 +80,29 @@ export function desktopUpdateChannelFromManifest(manifest: unknown): DesktopUpda
return channel;
}

/**
* The channel a diagnostic report names for this binary.
*
* Total by construction, unlike `desktopUpdateChannelFromManifest`: a report
* must still copy when the manifest is the very thing that is broken, and the
* report saying `unknown` is more useful than the copy failing. A checkout
* follows no feed at all, so it reports `dev` rather than the updater's
* `release` placeholder.
*/
export function desktopDiagnosticUpdateChannel(input: {
Comment thread
Astro-Han marked this conversation as resolved.
readonly isPackaged: boolean;
readonly appPath: string;
}): DesktopUpdateChannel | 'dev' | 'unknown' {
if (!input.isPackaged) return 'dev';
try {
return desktopUpdateChannelFromManifest(
JSON.parse(readFileSync(join(input.appPath, 'package.json'), 'utf8')),
);
} catch {
return 'unknown';
}
}

function productWorkflowSigner(channel: DesktopUpdateChannel): RegExp {
const workflow = channel === 'nightly' ? PRODUCT_NIGHTLY_WORKFLOW : PRODUCT_RELEASE_WORKFLOW;
return new RegExp(
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/main/main-process-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,18 @@ const INPUT_TRUNCATION_MARKER = '\n<diagnostic input truncated>';
const EXECUTION_DIAGNOSTIC_TIMEOUT_MS = 2_000;
export const MAIN_PROCESS_DIAGNOSTIC_LOG_MAX_BYTES = 256 * 1024;

/**
* The release feed a build follows, as a report names it. `buildMode` alone
* cannot say this: every packaged install reports `packaged`, so a nightly and
* a release looked identical in a report while following different feeds and
* different attestation signers. `dev` is not a feed — a checkout follows none.
*/
export type DesktopDiagnosticChannel = 'release' | 'nightly' | 'dev' | 'unknown';

export interface DesktopDiagnosticEnvironment {
readonly appVersion: string;
readonly buildMode: 'dev' | 'packaged';
readonly updateChannel: DesktopDiagnosticChannel;
readonly buildCommit: string | null;
readonly electronVersion: string;
readonly nodeVersion: string;
Expand All @@ -63,6 +72,7 @@ export interface DesktopDiagnosticEnvironment {
export interface DesktopDiagnosticEnvironmentSource {
readonly appVersion: string;
readonly buildMode: 'dev' | 'packaged';
readonly updateChannel: DesktopDiagnosticChannel;
readonly buildCommit: string | null;
readonly locale: string;
readonly workspacePath: string;
Expand Down Expand Up @@ -431,6 +441,7 @@ export function formatDesktopDiagnosticReport(
'Environment',
`Maka: ${environment.appVersion}`,
`Build: ${environment.buildMode}${environment.buildCommit ? ` @ ${environment.buildCommit.slice(0, 12)}` : ''}`,
`Channel: ${environment.updateChannel}`,
`Electron: ${environment.electronVersion}`,
`Chrome: ${environment.chromeVersion}`,
`Node: ${environment.nodeVersion}`,
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { app, clipboard, dialog, ipcMain } from 'electron';
import { join } from 'node:path';
import { resolveBuildInfo } from './build-info.js';
import { resolveUpdateTestUserDataDirectory } from './app-update-test-context.js';
import { desktopDiagnosticUpdateChannel } from './app-update-attestation.js';
import {
captureDesktopDiagnosticEnvironment,
copyDesktopDiagnosticReport,
Expand Down Expand Up @@ -176,6 +177,10 @@ if (!app.requestSingleInstanceLock()) {
captureDesktopDiagnosticEnvironment({
appVersion: app.getVersion(),
buildMode: buildInfo.mode,
updateChannel: desktopDiagnosticUpdateChannel({
isPackaged: app.isPackaged,
appPath: app.getAppPath(),
}),
buildCommit: buildInfo.commit,
locale: app.getLocale(),
workspacePath: join(app.getPath('userData'), 'workspaces', 'default'),
Expand Down Expand Up @@ -212,6 +217,10 @@ if (!app.requestSingleInstanceLock()) {
captureDesktopDiagnosticEnvironment({
appVersion: app.getVersion(),
buildMode: buildInfo.mode,
updateChannel: desktopDiagnosticUpdateChannel({
isPackaged: app.isPackaged,
appPath: app.getAppPath(),
}),
buildCommit: buildInfo.commit,
locale: app.getLocale(),
workspacePath: join(app.getPath('userData'), 'workspaces', 'default'),
Expand Down
Loading