Skip to content
Open
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
32 changes: 32 additions & 0 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,38 @@ 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.
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
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
npm --workspace @maka/desktop run smoke:notification-settings
```

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 |
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron-builder.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
108 changes: 108 additions & 0 deletions apps/desktop/native/notification-settings.mm
Original file line number Diff line number Diff line change
@@ -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 <Foundation/Foundation.h>
#import <UserNotifications/UserNotifications.h>
#include <node_api.h>
#include <string>

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<Query*>(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<int32_t>(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<Query*>(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)
4 changes: 3 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down Expand Up @@ -90,6 +91,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",
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/scripts/build-notification-settings.mjs
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 { 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();
2 changes: 2 additions & 0 deletions apps/desktop/scripts/dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..', '..');
Expand Down Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions apps/desktop/scripts/smoke-notification-settings-packaged.mjs
Original file line number Diff line number Diff line change
@@ -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/test';
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 });
}
Loading
Loading