From 942bfcae369bc3d3c2a049189d4abe592569f7d6 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Mon, 7 Sep 2026 19:20:50 +0800 Subject: [PATCH 1/2] fix(desktop): read macOS notification authorization in process --- apps/desktop/README.md | 30 +++++ apps/desktop/electron-builder.config.mjs | 1 + apps/desktop/native/notification-settings.mm | 108 ++++++++++++++++++ apps/desktop/package.json | 3 +- .../scripts/build-notification-settings.mjs | 47 ++++++++ apps/desktop/scripts/dev.mjs | 2 + .../smoke-notification-settings-packaged.mjs | 95 +++++++++++++++ .../__tests__/notification-permission.test.ts | 67 +++++++++++ apps/desktop/src/main/capability-snapshot.ts | 25 +--- .../src/main/notification-permission.ts | 74 ++++++++++++ .../main/runtime-host-permissions-ipc-main.ts | 4 +- package-lock.json | 8 ++ scripts/asf-license-headers.mjs | 1 + 13 files changed, 440 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/native/notification-settings.mm create mode 100644 apps/desktop/scripts/build-notification-settings.mjs create mode 100644 apps/desktop/scripts/smoke-notification-settings-packaged.mjs create mode 100644 apps/desktop/src/main/__tests__/notification-permission.test.ts create mode 100644 apps/desktop/src/main/notification-permission.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 93e60739b1..c360cb8024 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -110,6 +110,36 @@ Recording changes require restarting the development app. Without `MAKA_DEV_TCC`, the permission overlay still runs, but its drag target is the npm Electron bundle, which macOS will not accept as a durable grant. +## macOS notification authorization + +The Permission Center reads `UNUserNotificationCenter` through a Node-API +module loaded in the Electron main process. The query belongs to the running +application bundle: plain Electron, Maka Dev, and packaged Maka have distinct +notification identities. Never query an independent command-line helper and +report its authorization as Maka's. + +`build:main` and the dev launcher compile `native/notification-settings.mm` +using Xcode Command Line Tools and the pinned `node-api-headers` development +dependency. Other platforms skip this build. The module uses Node-API 8 and is +unpacked from ASAR so electron-builder can sign and load it with the app. + +Queries run off the JS thread with a three-second native callback deadline, +without requesting authorization, sending a notification, or replacing +Electron's notification delegate. A new snapshot reads the current setting. +Denied and not-yet-requested remain distinct; provisional authorization permits +only quiet delivery. Query/load failures remain unknown with a diagnostic. +Authorization does not guarantee a banner, sound, or delivery during Focus. + +After building Desktop, run this on macOS: + +```sh +node apps/desktop/scripts/smoke-notification-settings-packaged.mjs +``` + +This packages and ad-hoc signs a minimal app with the production native module +and ASAR policy, then queries its fresh application identity. It does not grant +permission or change the installed Maka application's settings. + ## Three layers | Layer | Path | Role | diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 2e3130b8ee..7e70419541 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -72,6 +72,7 @@ const baseDesktopBuilderConfig = { productName: 'Maka', artifactName: 'Maka-${version}-mac-${arch}.${ext}', asar: true, + asarUnpack: ['dist/native/*.node'], beforePack: stageReleaseManifests, extraMetadata: { runtimeHostSetupPackage, makaUpdateChannel: 'release' }, directories: { diff --git a/apps/desktop/native/notification-settings.mm b/apps/desktop/native/notification-settings.mm new file mode 100644 index 0000000000..73f250419d --- /dev/null +++ b/apps/desktop/native/notification-settings.mm @@ -0,0 +1,108 @@ +/* + * 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 +#import +#include +#include + +struct Query { + napi_async_work work; + napi_deferred deferred; + int32_t status = -1; + std::string error; +}; + +static void Execute(napi_env, void* data) { + auto* query = static_cast(data); + @autoreleasepool { + @try { + NSBundle* bundle = NSBundle.mainBundle; + if (bundle.bundleIdentifier.length == 0 || + ![bundle.bundleURL.pathExtension isEqualToString:@"app"]) { + query->error = "Notification settings require an application bundle"; + return; + } + dispatch_semaphore_t done = dispatch_semaphore_create(0); + // The OS retains this block and its storage, even if our wait times out. + __block UNNotificationSettings* settings = nil; + [UNUserNotificationCenter.currentNotificationCenter + getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings* value) { + settings = value; + dispatch_semaphore_signal(done); + }]; + if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC)) != 0) { + query->error = "Notification settings query timed out"; + return; + } + if (settings == nil) { + query->error = "Notification settings query returned no settings"; + return; + } + query->status = static_cast(settings.authorizationStatus); + } @catch (NSException* exception) { + query->error = exception.reason.UTF8String ?: "Notification settings query failed"; + } + } +} + +static void Complete(napi_env env, napi_status status, void* data) { + auto* query = static_cast(data); + if (status != napi_ok && query->error.empty()) { + query->error = "Notification settings query cancelled"; + } + napi_value value; + if (query->error.empty()) { + napi_create_int32(env, query->status, &value); + napi_resolve_deferred(env, query->deferred, value); + } else { + napi_value message; + napi_create_string_utf8(env, query->error.c_str(), NAPI_AUTO_LENGTH, &message); + napi_create_error(env, nullptr, message, &value); + napi_reject_deferred(env, query->deferred, value); + } + napi_delete_async_work(env, query->work); + delete query; +} + +static napi_value GetAuthorizationStatus(napi_env env, napi_callback_info) { + auto* query = new Query{}; + napi_value promise; + napi_value name; + if (napi_create_promise(env, &query->deferred, &promise) != napi_ok || + napi_create_string_utf8(env, "notification-settings", NAPI_AUTO_LENGTH, &name) != napi_ok || + napi_create_async_work(env, nullptr, name, Execute, Complete, query, &query->work) != napi_ok || + napi_queue_async_work(env, query->work) != napi_ok) { + if (query->work) napi_delete_async_work(env, query->work); + delete query; + napi_throw_error(env, nullptr, "Could not schedule notification settings query"); + return nullptr; + } + return promise; +} + +static napi_value Init(napi_env env, napi_value exports) { + napi_value function; + napi_create_function(env, "getAuthorizationStatus", NAPI_AUTO_LENGTH, + GetAuthorizationStatus, nullptr, &function); + napi_set_named_property(env, exports, "getAuthorizationStatus", function); + return exports; +} + +NAPI_MODULE(notification_settings, Init) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6d8cb8e6f8..750e054bc2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -29,7 +29,7 @@ "build:test": "npm run build:main && npm run build:preload && npm run build:overlay", "build:smoke": "npm run build:resources && npm run build:renderer", "clean:main": "node ../../scripts/clean-paths.mjs dist/main tsconfig.main.tsbuildinfo", - "build:main": "tsc -p tsconfig.main.json", + "build:main": "node scripts/build-notification-settings.mjs && tsc -p tsconfig.main.json", "build:preload": "esbuild src/preload/preload.ts --bundle --platform=node --format=cjs --outfile=dist/preload/preload.cjs --external:electron", "build:overlay": "node ../../scripts/build-cursor-overlay.mjs", "build:renderer": "vite build && node scripts/check-renderer-entry-output.mjs && node ../../scripts/check-third-party-notices.mjs", @@ -90,6 +90,7 @@ "electron-builder": "26.15.3", "esbuild": "^0.28.1", "linkedom": "^0.18.13", + "node-api-headers": "1.9.0", "react": "^19.2.1", "react-dom": "^19.2.1", "simple-icons": "16.29.0", diff --git a/apps/desktop/scripts/build-notification-settings.mjs b/apps/desktop/scripts/build-notification-settings.mjs new file mode 100644 index 0000000000..70d58d0259 --- /dev/null +++ b/apps/desktop/scripts/build-notification-settings.mjs @@ -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 { spawnSync } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function buildNotificationSettings() { + if (process.platform !== 'darwin') return; + const require = createRequire(import.meta.url); + const desktop = fileURLToPath(new URL('..', import.meta.url)); + const include = join(dirname(require.resolve('node-api-headers/package.json')), 'include'); + const output = join(desktop, 'dist', 'native'); + mkdirSync(output, { recursive: true }); + const result = spawnSync('xcrun', [ + 'clang++', '-std=c++17', '-fobjc-arc', '-fblocks', + '-DNAPI_VERSION=8', '-mmacosx-version-min=12.0', + '-arch', process.arch === 'arm64' ? 'arm64' : 'x86_64', + '-bundle', '-undefined', 'dynamic_lookup', + '-framework', 'Foundation', '-framework', 'UserNotifications', + '-I', include, + join(desktop, 'native', 'notification-settings.mm'), + '-o', join(output, 'notification-settings.node'), + ], { stdio: 'inherit' }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error('Failed to build macOS notification settings bridge'); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) buildNotificationSettings(); diff --git a/apps/desktop/scripts/dev.mjs b/apps/desktop/scripts/dev.mjs index b58be3e5ba..790764b27e 100644 --- a/apps/desktop/scripts/dev.mjs +++ b/apps/desktop/scripts/dev.mjs @@ -47,6 +47,7 @@ import { handleDevelopmentLaunchOutcome, waitForDevelopmentLaunchVerdict, } from './dev-app-runtime.mjs'; +import { buildNotificationSettings } from './build-notification-settings.mjs'; const DESKTOP_DIR = resolve(fileURLToPath(new URL('..', import.meta.url))); const REPO_ROOT = resolve(DESKTOP_DIR, '..', '..'); @@ -78,6 +79,7 @@ function runNodeTool(dir, script, args) { // ── build phases ───────────────────────────────────────────────────────────── const TIMER_START = Date.now(); +buildNotificationSettings(); // A clean or ignore-scripts install has no generated model modules yet, and // `tsc --build` bypasses workspace prebuild hooks. Generate from the committed diff --git a/apps/desktop/scripts/smoke-notification-settings-packaged.mjs b/apps/desktop/scripts/smoke-notification-settings-packaged.mjs new file mode 100644 index 0000000000..88499ecb0f --- /dev/null +++ b/apps/desktop/scripts/smoke-notification-settings-packaged.mjs @@ -0,0 +1,95 @@ +/* + * 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 { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build, Platform } from 'electron-builder'; +import { _electron as electron } from 'playwright'; +import { closeElectronApplication } from '../../../scripts/electron-lifecycle.mjs'; +import config from '../electron-builder.config.mjs'; + +if (process.platform !== 'darwin') throw new Error('This smoke test requires macOS'); +const desktop = fileURLToPath(new URL('..', import.meta.url)); +const require = createRequire(import.meta.url); +const electronDirectory = dirname(require.resolve('electron/package.json')); +const root = await mkdtemp(join(tmpdir(), 'maka-notification-packaged-')); +const source = join(root, 'app'); +let application; +try { + await mkdir(join(source, 'dist', 'native'), { recursive: true }); + await mkdir(join(source, 'dist', 'main'), { recursive: true }); + await cp(join(desktop, 'dist', 'native', 'notification-settings.node'), + join(source, 'dist', 'native', 'notification-settings.node')); + await cp(join(desktop, 'dist', 'main', 'notification-permission.js'), + join(source, 'dist', 'main', 'notification-permission.js')); + await writeFile(join(source, 'package.json'), JSON.stringify({ + name: 'maka-notification-smoke', version: '1.0.0', type: 'module', + main: 'dist/main/main.js', description: 'Notification bridge packaging test', + author: 'The Maka Authors', + })); + await writeFile(join(source, 'dist', 'main', 'main.js'), ` + import { app, BrowserWindow } from 'electron'; + import { notificationPermissionSnapshot } from './notification-permission.js'; + app.whenReady().then(async () => { + globalThis.notificationResults = await Promise.all( + Array.from({ length: 8 }, () => notificationPermissionSnapshot(Date.now(), process.platform, true)) + ); + const window = new BrowserWindow({ show: false }); + await window.loadURL('about:blank'); + }); + `); + const { version } = JSON.parse(await readFile(join(electronDirectory, 'package.json'))); + await build({ + targets: Platform.MAC.createTarget('dir'), + projectDir: source, + config: { + appId: `com.maka.notification-smoke.${Date.now()}`, + productName: 'Maka Notification Smoke', + electronVersion: version, + electronDist: join(electronDirectory, 'dist'), + directories: { output: join(root, 'out') }, + files: ['dist/**/*', 'package.json'], + asar: config.asar, + asarUnpack: config.asarUnpack, + mac: { identity: '-', hardenedRuntime: true, notarize: false }, + }, + }); + const executable = join(root, 'out', process.arch === 'arm64' ? 'mac-arm64' : 'mac', + 'Maka Notification Smoke.app', 'Contents', 'MacOS', 'Maka Notification Smoke'); + application = await electron.launch({ executablePath: executable, args: [] }); + await application.firstWindow(); + const result = await application.evaluate(({ app }) => ({ + snapshots: globalThis.notificationResults, + packaged: app.isPackaged, + executable: process.execPath, + })); + assert.equal(result.packaged, true); + for (const snapshot of result.snapshots) { + assert.equal(snapshot.source, 'platform'); + assert.equal(snapshot.status, 'not_determined', JSON.stringify(result)); + } + console.log(JSON.stringify(result, null, 2)); +} finally { + if (application) await closeElectronApplication(application, 5_000); + await rm(root, { recursive: true, force: true }); +} diff --git a/apps/desktop/src/main/__tests__/notification-permission.test.ts b/apps/desktop/src/main/__tests__/notification-permission.test.ts new file mode 100644 index 0000000000..93af97e61c --- /dev/null +++ b/apps/desktop/src/main/__tests__/notification-permission.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { notificationPermissionSnapshot } from '../notification-permission.js'; + +test('maps native authorization without claiming unsupported future states are granted', async () => { + for (const [native, expected] of [ + [0, 'not_determined'], [1, 'denied'], [2, 'granted'], [3, 'granted'], [4, 'unknown'], + ] as const) { + const snapshot = await notificationPermissionSnapshot(123, 'darwin', true, async () => native); + assert.equal(snapshot.status, expected); + assert.equal(snapshot.source, 'platform'); + assert.equal(snapshot.checkedAt, 123); + assert.equal(snapshot.canRequest, false); + assert.equal(snapshot.canOpenSettings, true); + assert.equal(Boolean(snapshot.reason), native >= 3); + } +}); + +test('reports load and native query failures as unknown, not denied or unsupported', async () => { + for (const message of ['module could not be loaded', 'Notification settings query timed out']) { + const snapshot = await notificationPermissionSnapshot(123, 'darwin', true, async () => { + throw new Error(message); + }); + assert.equal(snapshot.status, 'unknown'); + assert.ok(snapshot.reason?.includes(message)); + assert.equal(snapshot.canOpenSettings, true); + } +}); + +test('never loads the native bridge on another platform or when notifications are unsupported', async () => { + const read = async (): Promise => { assert.fail('native query must not run'); }; + for (const platform of ['linux', 'win32'] as const) { + const snapshot = await notificationPermissionSnapshot(123, platform, true, read); + assert.equal(snapshot.status, 'unknown'); + assert.equal(snapshot.canOpenSettings, false); + assert.equal(snapshot.source, 'electron'); + } + const unsupported = await notificationPermissionSnapshot(123, 'darwin', false, read); + assert.equal(unsupported.status, 'unsupported'); +}); + +test('requeries after a system settings change instead of retaining a stale grant', async () => { + let status = 2; + const read = async () => status; + assert.equal((await notificationPermissionSnapshot(1, 'darwin', true, read)).status, 'granted'); + status = 1; + assert.equal((await notificationPermissionSnapshot(2, 'darwin', true, read)).status, 'denied'); +}); diff --git a/apps/desktop/src/main/capability-snapshot.ts b/apps/desktop/src/main/capability-snapshot.ts index 8c430d9b00..63b03efdfb 100644 --- a/apps/desktop/src/main/capability-snapshot.ts +++ b/apps/desktop/src/main/capability-snapshot.ts @@ -38,6 +38,7 @@ import { type AppSettings } from '@maka/core/settings'; import type { CuBackendId } from '@maka/computer-use'; import type { BotStatus } from '@maka/runtime/bots'; import type { computerUseServiceHealth } from './computer-use-host.js'; +import { notificationPermissionSnapshot } from './notification-permission.js'; import { mapMediaAccessStatus, mediaPermissionActions, @@ -46,14 +47,14 @@ import { const MAC_TCC_PERMISSIONS: OsPermissionId[] = ['accessibility', 'screen_recording', 'automation']; -export function buildPermissionSnapshot(now = Date.now(), platform: NodeJS.Platform = process.platform): PermissionSnapshot { +export async function buildPermissionSnapshot(now = Date.now(), platform: NodeJS.Platform = process.platform): Promise { return { checkedAt: now, platform, permissions: { accessibility: accessibilitySnapshot(now, platform), screen_recording: mediaPermissionSnapshot('screen_recording', 'screen', now, platform), - notifications: notificationSnapshot(now, platform), + notifications: await notificationPermissionSnapshot(now, platform, Notification.isSupported()), automation: automationSnapshot(now, platform), }, }; @@ -319,26 +320,6 @@ function mediaPermissionSnapshot( } } -function notificationSnapshot(now: number, platform: NodeJS.Platform): OsPermissionSnapshot { - const supported = Notification.isSupported(); - return { - id: 'notifications', - status: supported ? 'unknown' : 'unsupported', - source: 'electron', - checkedAt: now, - reason: supported - ? platform === 'darwin' - ? 'Electron 无法可靠读取 macOS 通知授权状态,请在系统设置中确认' - : 'Electron 无法可靠读取当前系统的通知授权状态' - : 'Electron 通知能力不可用', - canOpenSettings: platform === 'darwin', - // Showing a Notification is not an authorization API and does not report - // whether macOS delivered or suppressed it. Never present that probe as a - // successful permission request. - canRequest: false, - }; -} - function automationSnapshot(now: number, platform: NodeJS.Platform): OsPermissionSnapshot { if (platform !== 'darwin') return unsupportedPermission('automation', now, '仅 macOS TCC 权限适用'); return { diff --git a/apps/desktop/src/main/notification-permission.ts b/apps/desktop/src/main/notification-permission.ts new file mode 100644 index 0000000000..2f475dba58 --- /dev/null +++ b/apps/desktop/src/main/notification-permission.ts @@ -0,0 +1,74 @@ +/* + * 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 { createRequire } from 'node:module'; +import type { OsPermissionSnapshot } from '@maka/core/capabilities'; + +const require = createRequire(import.meta.url); + +function readNativeAuthorizationStatus(): Promise { + if (process.type !== 'browser') { + throw new Error('Notification settings must be queried in the Electron main process'); + } + const bridge = require('../native/notification-settings.node') as { + getAuthorizationStatus(): Promise; + }; + return bridge.getAuthorizationStatus(); +} + +export async function notificationPermissionSnapshot( + now: number, + platform: NodeJS.Platform, + supported: boolean, + readAuthorizationStatus: () => Promise = readNativeAuthorizationStatus, +): Promise { + const snapshot: OsPermissionSnapshot = { + id: 'notifications', + status: supported ? 'unknown' : 'unsupported', + source: platform === 'darwin' ? 'platform' : 'electron', + checkedAt: now, + canOpenSettings: platform === 'darwin', + // Reading settings must never trigger a consent prompt or send a notification. + canRequest: false, + }; + if (!supported) return { ...snapshot, reason: 'Electron 通知能力不可用' }; + if (platform !== 'darwin') { + return { ...snapshot, reason: 'Electron 无法可靠读取当前系统的通知授权状态' }; + } + try { + const status = await readAuthorizationStatus(); + switch (status) { + case 0: + return { ...snapshot, status: 'not_determined' }; + case 1: + return { ...snapshot, status: 'denied' }; + case 2: + return { ...snapshot, status: 'granted' }; + case 3: + return { ...snapshot, status: 'granted', reason: '仅允许安静通知,不显示横幅或播放声音' }; + default: + return { ...snapshot, reason: `macOS 返回未知通知授权状态:${status}` }; + } + } catch (error) { + return { + ...snapshot, + reason: `macOS 通知权限查询失败:${error instanceof Error ? error.message : 'unknown error'}`, + }; + } +} diff --git a/apps/desktop/src/main/runtime-host-permissions-ipc-main.ts b/apps/desktop/src/main/runtime-host-permissions-ipc-main.ts index c338b32cc9..edd533d75e 100644 --- a/apps/desktop/src/main/runtime-host-permissions-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-permissions-ipc-main.ts @@ -69,7 +69,7 @@ export function registerRuntimeHostPermissionsIpc( (_event, permissionId: unknown) => requestPermissionAccess(permissionId), ); handleReconnectableRead(deps.ipcMain, "capabilities:getSnapshot", async () => { - const snapshot = permissions(); + const snapshot = await permissions(); return buildCapabilitySnapshotCollection({ settings: await deps.getSettings(), permissions: snapshot, @@ -80,7 +80,7 @@ export function registerRuntimeHostPermissionsIpc( }); handleReconnectableRead(deps.ipcMain, "health:getSnapshot", async () => { const now = Date.now(); - const permissionSnapshot = permissions(now); + const permissionSnapshot = await permissions(now); const [settings, connections] = await Promise.all([ deps.getSettings(), deps.listConnections(), diff --git a/package-lock.json b/package-lock.json index 872cfd700d..ade0673c31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "electron-builder": "26.15.3", "esbuild": "^0.28.1", "linkedom": "^0.18.13", + "node-api-headers": "1.9.0", "react": "^19.2.1", "react-dom": "^19.2.1", "simple-icons": "16.29.0", @@ -12723,6 +12724,13 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, + "node_modules/node-api-headers": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/node-api-headers/-/node-api-headers-1.9.0.tgz", + "integrity": "sha512-2oNILP4jXwRB4ywnYKjVk1YyJ96n2D4EOVJO6S3oYZ5PtbJrw3Yt9TpAuX3nBLMuzn74rnfGQrv13pS9vC+YiA==", + "dev": true, + "license": "MIT" + }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", diff --git a/scripts/asf-license-headers.mjs b/scripts/asf-license-headers.mjs index edfe6194c4..54c30d4999 100644 --- a/scripts/asf-license-headers.mjs +++ b/scripts/asf-license-headers.mjs @@ -117,6 +117,7 @@ const coveredExtensions = new Map([ ['.jsonc', 'slash'], ['.md', 'html'], ['.mjs', 'block'], + ['.mm', 'block'], ['.mts', 'block'], ['.nsh', 'hash'], ['.ps1', 'hash'], From 17c29247f8cd7be6b15d03d591c35ff2c0d94d8f Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Mon, 7 Sep 2026 20:27:09 +0800 Subject: [PATCH 2/2] fix(desktop): register notification smoke test with Knip The initial change passed builds but omitted the Knip entry and generated-addon boundary. Register the npm command, reuse the declared Playwright dependency, and exempt only the generated native import. The existing CI Knip gate now passes locally; packaged loading remains smoke-tested. --- apps/desktop/README.md | 4 +++- apps/desktop/package.json | 1 + apps/desktop/scripts/smoke-notification-settings-packaged.mjs | 2 +- knip.json | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c360cb8024..4112ea9b64 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -122,6 +122,8 @@ report its authorization as Maka's. using Xcode Command Line Tools and the pinned `node-api-headers` development dependency. Other platforms skip this build. The module uses Node-API 8 and is unpacked from ASAR so electron-builder can sign and load it with the app. +Knip excludes only this generated `.node` import from source resolution; +the packaged smoke test verifies its runtime loading. Queries run off the JS thread with a three-second native callback deadline, without requesting authorization, sending a notification, or replacing @@ -133,7 +135,7 @@ Authorization does not guarantee a banner, sound, or delivery during Focus. After building Desktop, run this on macOS: ```sh -node apps/desktop/scripts/smoke-notification-settings-packaged.mjs +npm --workspace @maka/desktop run smoke:notification-settings ``` This packages and ad-hoc signs a minimal app with the production native module diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 750e054bc2..2b1aeebf7d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -48,6 +48,7 @@ "build:with-deps": "npm run build:workspace-deps && npm run build", "smoke:real-window": "npm run build:with-deps && node ../../scripts/desktop-real-window-smoke.mjs", "smoke:programmatic-window": "npm run build:with-deps && node ../../scripts/desktop-real-window-smoke.mjs --programmatic-only", + "smoke:notification-settings": "node scripts/smoke-notification-settings-packaged.mjs", "launch:fixture": "npm run build:with-deps && node ../../scripts/desktop-real-window-smoke.mjs --manual", "smoke:browser": "npm run build:workspace-deps && npm run build:main && npm run smoke:browser:run", "smoke:browser:run": "electron scripts/browser-observe-act-smoke.mjs" diff --git a/apps/desktop/scripts/smoke-notification-settings-packaged.mjs b/apps/desktop/scripts/smoke-notification-settings-packaged.mjs index 88499ecb0f..09759a6d98 100644 --- a/apps/desktop/scripts/smoke-notification-settings-packaged.mjs +++ b/apps/desktop/scripts/smoke-notification-settings-packaged.mjs @@ -24,7 +24,7 @@ import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { build, Platform } from 'electron-builder'; -import { _electron as electron } from 'playwright'; +import { _electron as electron } from '@playwright/test'; import { closeElectronApplication } from '../../../scripts/electron-lifecycle.mjs'; import config from '../electron-builder.config.mjs'; diff --git a/knip.json b/knip.json index 8158cfd292..50b2c00b95 100644 --- a/knip.json +++ b/knip.json @@ -23,6 +23,7 @@ ], "project": ["src/**/*.{ts,tsx}", "e2e/**/*.ts", "stories/**/*.{ts,tsx}", "scripts/**/*.mjs"], "ignoreDependencies": ["@fontsource-variable/geist", "@fontsource-variable/geist-mono"], + "ignoreUnresolved": ["../native/notification-settings.node"], "ignoreBinaries": ["plutil", "pgrep"] }, "packages/ui": {