@lobehub/lobehub 2.0.0-next.251 → 2.0.0-next.253

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/apps/desktop/build/entitlements.mac.plist +9 -0
  3. package/apps/desktop/resources/locales/zh-CN/dialog.json +5 -1
  4. package/apps/desktop/resources/locales/zh-CN/menu.json +7 -0
  5. package/apps/desktop/src/main/controllers/SystemCtr.ts +186 -94
  6. package/apps/desktop/src/main/controllers/__tests__/SystemCtr.test.ts +200 -31
  7. package/apps/desktop/src/main/core/browser/Browser.ts +9 -0
  8. package/apps/desktop/src/main/locales/default/dialog.ts +7 -2
  9. package/apps/desktop/src/main/locales/default/menu.ts +7 -0
  10. package/apps/desktop/src/main/menus/impls/macOS.ts +44 -1
  11. package/apps/desktop/src/main/utils/fullDiskAccess.ts +121 -0
  12. package/changelog/v1.json +14 -0
  13. package/package.json +1 -1
  14. package/packages/builtin-tool-notebook/src/client/Render/CreateDocument/DocumentCard.tsx +0 -2
  15. package/packages/database/migrations/meta/_journal.json +1 -1
  16. package/packages/database/src/models/__tests__/topics/topic.create.test.ts +37 -8
  17. package/packages/database/src/models/topic.ts +71 -4
  18. package/packages/database/src/schemas/agentCronJob.ts +1 -2
  19. package/packages/electron-client-ipc/src/events/system.ts +1 -0
  20. package/packages/memory-user-memory/src/extractors/context.ts +1 -4
  21. package/packages/memory-user-memory/src/extractors/experience.ts +2 -8
  22. package/packages/memory-user-memory/src/extractors/preference.ts +2 -8
  23. package/packages/memory-user-memory/src/prompts/gatekeeper.ts +123 -123
  24. package/packages/memory-user-memory/src/prompts/layers/context.ts +152 -152
  25. package/packages/memory-user-memory/src/prompts/layers/experience.ts +159 -159
  26. package/packages/memory-user-memory/src/prompts/layers/identity.ts +213 -213
  27. package/packages/memory-user-memory/src/prompts/layers/preference.ts +160 -160
  28. package/packages/memory-user-memory/src/services/extractExecutor.ts +33 -30
  29. package/packages/memory-user-memory/src/types.ts +10 -8
  30. package/packages/types/src/topic/topic.ts +9 -0
  31. package/src/app/[variants]/(desktop)/desktop-onboarding/features/PermissionsStep.tsx +16 -30
  32. package/src/app/[variants]/(desktop)/desktop-onboarding/index.tsx +19 -9
  33. package/src/app/[variants]/(desktop)/desktop-onboarding/storage.ts +49 -0
  34. package/src/app/[variants]/(main)/chat/_layout/Sidebar/Body.tsx +4 -1
  35. package/src/app/[variants]/(main)/chat/_layout/Sidebar/Topic/CronTopicList/CronTopicGroup.tsx +74 -0
  36. package/src/app/[variants]/(main)/chat/_layout/Sidebar/Topic/CronTopicList/CronTopicItem.tsx +40 -0
  37. package/src/app/[variants]/(main)/chat/_layout/Sidebar/Topic/CronTopicList/index.tsx +140 -0
  38. package/src/app/[variants]/(main)/chat/_layout/Sidebar/Topic/List/index.tsx +1 -1
  39. package/src/app/[variants]/(main)/chat/_layout/Sidebar/Topic/TopicListContent/index.tsx +1 -1
  40. package/src/app/[variants]/(main)/chat/_layout/Sidebar/Topic/index.tsx +1 -1
  41. package/src/app/[variants]/(main)/chat/cron/[cronId]/index.tsx +664 -0
  42. package/src/app/[variants]/(main)/chat/profile/features/AgentCronJobs/CronJobCards.tsx +160 -0
  43. package/src/app/[variants]/(main)/chat/profile/features/AgentCronJobs/CronJobForm.tsx +202 -0
  44. package/src/app/[variants]/(main)/chat/profile/features/AgentCronJobs/CronJobList.tsx +137 -0
  45. package/src/app/[variants]/(main)/chat/profile/features/AgentCronJobs/hooks/useAgentCronJobs.ts +138 -0
  46. package/src/app/[variants]/(main)/chat/profile/features/AgentCronJobs/index.tsx +130 -0
  47. package/src/app/[variants]/(main)/chat/profile/features/ProfileEditor/index.tsx +33 -3
  48. package/src/app/[variants]/router/desktopRouter.config.tsx +7 -0
  49. package/src/features/ChatInput/ActionBar/Params/Controls.tsx +7 -6
  50. package/src/hooks/useFetchCronTopics.ts +29 -0
  51. package/src/hooks/useFetchCronTopicsWithJobInfo.ts +56 -0
  52. package/src/hooks/useFetchTopics.ts +4 -1
  53. package/src/locales/default/setting.ts +44 -1
  54. package/src/server/routers/lambda/agentCronJob.ts +367 -0
  55. package/src/server/routers/lambda/image/index.test.ts +2 -2
  56. package/src/server/routers/lambda/index.ts +2 -0
  57. package/src/server/routers/lambda/topic.ts +15 -3
  58. package/src/server/services/aiAgent/index.ts +18 -1
  59. package/src/server/services/memory/userMemory/extract.ts +14 -6
  60. package/src/services/agentCronJob.ts +95 -0
  61. package/src/services/topic/index.ts +1 -0
  62. package/src/store/chat/slices/topic/action.ts +53 -2
  63. package/src/store/chat/slices/topic/initialState.ts +1 -0
  64. package/src/store/chat/slices/topic/selectors.ts +14 -6
  65. package/src/tools/placeholders.ts +1 -4
  66. package/apps/desktop/src/main/controllers/scripts/full-disk-access.applescript +0 -85
@@ -7,12 +7,13 @@ import { IpcHandler } from '@/utils/ipc/base';
7
7
 
8
8
  import SystemController from '../SystemCtr';
9
9
 
10
- const { ipcHandlers, ipcMainHandleMock } = vi.hoisted(() => {
10
+ const { ipcHandlers, ipcMainHandleMock, readdirSyncMock } = vi.hoisted(() => {
11
11
  const handlers = new Map<string, (event: any, ...args: any[]) => any>();
12
12
  const handle = vi.fn((channel: string, handler: any) => {
13
13
  handlers.set(channel, handler);
14
14
  });
15
- return { ipcHandlers: handlers, ipcMainHandleMock: handle };
15
+ const readdirSync = vi.fn();
16
+ return { ipcHandlers: handlers, ipcMainHandleMock: handle, readdirSyncMock: readdirSync };
16
17
  });
17
18
 
18
19
  const invokeIpc = async <T = any>(
@@ -44,28 +45,18 @@ vi.mock('@/utils/logger', () => ({
44
45
  }),
45
46
  }));
46
47
 
47
- const { spawnMock } = vi.hoisted(() => ({
48
- spawnMock: vi.fn(() => {
49
- const handlers = new Map<string, (...args: any[]) => void>();
50
- return {
51
- on: vi.fn((event: string, cb: (...args: any[]) => void) => {
52
- handlers.set(event, cb);
53
- return undefined;
54
- }),
55
- } as any;
56
- }),
57
- }));
58
-
59
- vi.mock('node:child_process', () => ({
60
- spawn: (...args: any[]) => spawnMock.call(null, ...args),
61
- }));
62
-
63
48
  // Mock electron
64
49
  vi.mock('electron', () => ({
65
50
  app: {
66
51
  getLocale: vi.fn(() => 'en-US'),
67
52
  getPath: vi.fn((name: string) => `/mock/path/${name}`),
68
53
  },
54
+ desktopCapturer: {
55
+ getSources: vi.fn(async () => []),
56
+ },
57
+ dialog: {
58
+ showMessageBox: vi.fn(async () => ({ response: 0 })),
59
+ },
69
60
  ipcMain: {
70
61
  handle: ipcMainHandleMock,
71
62
  },
@@ -89,6 +80,32 @@ vi.mock('electron-is', () => ({
89
80
  macOS: vi.fn(() => true),
90
81
  }));
91
82
 
83
+ // Mock node:fs for Full Disk Access check
84
+ vi.mock('node:fs', () => ({
85
+ default: {
86
+ readdirSync: readdirSyncMock,
87
+ },
88
+ readdirSync: readdirSyncMock,
89
+ }));
90
+
91
+ // Mock node:os for homedir and release
92
+ vi.mock('node:os', () => ({
93
+ default: {
94
+ homedir: vi.fn(() => '/Users/testuser'),
95
+ release: vi.fn(() => '23.0.0'), // Darwin 23 = macOS 14 (Sonoma)
96
+ },
97
+ homedir: vi.fn(() => '/Users/testuser'),
98
+ release: vi.fn(() => '23.0.0'),
99
+ }));
100
+
101
+ // Mock node:path
102
+ vi.mock('node:path', () => ({
103
+ default: {
104
+ join: vi.fn((...args: string[]) => args.join('/')),
105
+ },
106
+ join: vi.fn((...args: string[]) => args.join('/')),
107
+ }));
108
+
92
109
  // Mock browserManager
93
110
  const mockBrowserManager = {
94
111
  broadcastToAllWindows: vi.fn(),
@@ -112,6 +129,7 @@ const mockStoreManager = {
112
129
  // Mock i18n
113
130
  const mockI18n = {
114
131
  changeLanguage: vi.fn().mockResolvedValue(undefined),
132
+ ns: vi.fn((namespace: string) => (key: string) => `${namespace}.${key}`),
115
133
  };
116
134
 
117
135
  const mockApp = {
@@ -177,14 +195,78 @@ describe('SystemController', () => {
177
195
  });
178
196
  });
179
197
 
180
- describe('screen recording', () => {
181
- it('should request screen recording access and open System Settings on macOS', async () => {
198
+ describe('microphone access', () => {
199
+ it('should ask for microphone access when status is not-determined', async () => {
200
+ const { systemPreferences } = await import('electron');
201
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined');
202
+
203
+ await invokeIpc('system.requestMicrophoneAccess');
204
+
205
+ expect(systemPreferences.getMediaAccessStatus).toHaveBeenCalledWith('microphone');
206
+ expect(systemPreferences.askForMediaAccess).toHaveBeenCalledWith('microphone');
207
+
208
+ // Reset
209
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined');
210
+ });
211
+
212
+ it('should return true immediately if microphone access is already granted', async () => {
182
213
  const { shell, systemPreferences } = await import('electron');
214
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('granted');
215
+
216
+ const result = await invokeIpc('system.requestMicrophoneAccess');
217
+
218
+ expect(result).toBe(true);
219
+ expect(systemPreferences.askForMediaAccess).not.toHaveBeenCalled();
220
+ expect(shell.openExternal).not.toHaveBeenCalled();
221
+
222
+ // Reset
223
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined');
224
+ });
225
+
226
+ it('should open System Settings if microphone access is denied', async () => {
227
+ const { shell, systemPreferences } = await import('electron');
228
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('denied');
229
+
230
+ const result = await invokeIpc('system.requestMicrophoneAccess');
231
+
232
+ expect(result).toBe(false);
233
+ expect(systemPreferences.askForMediaAccess).not.toHaveBeenCalled();
234
+ expect(shell.openExternal).toHaveBeenCalledWith(
235
+ 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone',
236
+ );
237
+
238
+ // Reset
239
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined');
240
+ });
241
+
242
+ it('should return true on non-macOS', async () => {
243
+ const { macOS } = await import('electron-is');
244
+ const { shell, systemPreferences } = await import('electron');
245
+ vi.mocked(macOS).mockReturnValue(false);
246
+
247
+ const result = await invokeIpc('system.requestMicrophoneAccess');
248
+
249
+ expect(result).toBe(true);
250
+ expect(systemPreferences.getMediaAccessStatus).not.toHaveBeenCalled();
251
+ expect(shell.openExternal).not.toHaveBeenCalled();
252
+
253
+ // Reset
254
+ vi.mocked(macOS).mockReturnValue(true);
255
+ });
256
+ });
257
+
258
+ describe('screen recording', () => {
259
+ it('should use desktopCapturer and getDisplayMedia to trigger TCC and open System Settings on macOS', async () => {
260
+ const { desktopCapturer, shell, systemPreferences } = await import('electron');
183
261
 
184
262
  const result = await invokeIpc('system.requestScreenAccess');
185
263
 
186
264
  expect(systemPreferences.getMediaAccessStatus).toHaveBeenCalledWith('screen');
187
- expect(systemPreferences.askForMediaAccess).toHaveBeenCalledWith('screen');
265
+ expect(desktopCapturer.getSources).toHaveBeenCalledWith({
266
+ fetchWindowIcons: true,
267
+ thumbnailSize: { height: 144, width: 256 },
268
+ types: ['screen', 'window'],
269
+ });
188
270
  expect(mockBrowserManager.getMainWindow).toHaveBeenCalled();
189
271
  expect(shell.openExternal).toHaveBeenCalledWith(
190
272
  'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
@@ -192,15 +274,29 @@ describe('SystemController', () => {
192
274
  expect(typeof result).toBe('boolean');
193
275
  });
194
276
 
277
+ it('should return true immediately if screen access is already granted', async () => {
278
+ const { desktopCapturer, shell, systemPreferences } = await import('electron');
279
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('granted');
280
+
281
+ const result = await invokeIpc('system.requestScreenAccess');
282
+
283
+ expect(result).toBe(true);
284
+ expect(desktopCapturer.getSources).not.toHaveBeenCalled();
285
+ expect(shell.openExternal).not.toHaveBeenCalled();
286
+
287
+ // Reset
288
+ vi.mocked(systemPreferences.getMediaAccessStatus).mockReturnValue('not-determined');
289
+ });
290
+
195
291
  it('should return true on non-macOS and not open settings', async () => {
196
292
  const { macOS } = await import('electron-is');
197
- const { shell, systemPreferences } = await import('electron');
293
+ const { desktopCapturer, shell } = await import('electron');
198
294
  vi.mocked(macOS).mockReturnValue(false);
199
295
 
200
296
  const result = await invokeIpc('system.requestScreenAccess');
201
297
 
202
298
  expect(result).toBe(true);
203
- expect(systemPreferences.askForMediaAccess).not.toHaveBeenCalled();
299
+ expect(desktopCapturer.getSources).not.toHaveBeenCalled();
204
300
  expect(shell.openExternal).not.toHaveBeenCalled();
205
301
 
206
302
  // Reset
@@ -209,6 +305,40 @@ describe('SystemController', () => {
209
305
  });
210
306
 
211
307
  describe('full disk access', () => {
308
+ it('should return true when Full Disk Access is granted (can read protected directory)', async () => {
309
+ readdirSyncMock.mockReturnValue(['file1', 'file2']);
310
+
311
+ const result = await invokeIpc('system.getFullDiskAccessStatus');
312
+
313
+ expect(result).toBe(true);
314
+ // On macOS 14 (Darwin 23), should check com.apple.stocks
315
+ expect(readdirSyncMock).toHaveBeenCalledWith(
316
+ '/Users/testuser/Library/Containers/com.apple.stocks',
317
+ );
318
+ });
319
+
320
+ it('should return false when Full Disk Access is not granted (cannot read protected directory)', async () => {
321
+ readdirSyncMock.mockImplementation(() => {
322
+ throw new Error('EPERM: operation not permitted');
323
+ });
324
+
325
+ const result = await invokeIpc('system.getFullDiskAccessStatus');
326
+
327
+ expect(result).toBe(false);
328
+ });
329
+
330
+ it('should return true on non-macOS', async () => {
331
+ const { macOS } = await import('electron-is');
332
+ vi.mocked(macOS).mockReturnValue(false);
333
+
334
+ const result = await invokeIpc('system.getFullDiskAccessStatus');
335
+
336
+ expect(result).toBe(true);
337
+
338
+ // Reset
339
+ vi.mocked(macOS).mockReturnValue(true);
340
+ });
341
+
212
342
  it('should try to open Full Disk Access settings with fallbacks', async () => {
213
343
  const { shell } = await import('electron');
214
344
  vi.mocked(shell.openExternal)
@@ -218,25 +348,64 @@ describe('SystemController', () => {
218
348
  await invokeIpc('system.openFullDiskAccessSettings');
219
349
 
220
350
  expect(shell.openExternal).toHaveBeenCalledWith(
221
- 'com.apple.settings:Privacy&path=FullDiskAccess',
351
+ 'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles',
222
352
  );
223
353
  expect(shell.openExternal).toHaveBeenCalledWith(
224
354
  'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles',
225
355
  );
226
356
  });
227
357
 
228
- it('should spawn osascript when autoAdd is enabled', async () => {
358
+ it('should open fallback Privacy settings if all candidates fail', async () => {
229
359
  const { shell } = await import('electron');
230
- vi.mocked(shell.openExternal).mockResolvedValueOnce(undefined);
360
+ vi.mocked(shell.openExternal)
361
+ .mockRejectedValueOnce(new Error('fail first'))
362
+ .mockRejectedValueOnce(new Error('fail second'))
363
+ .mockResolvedValueOnce(undefined);
231
364
 
232
- await invokeIpc('system.openFullDiskAccessSettings', { autoAdd: true });
365
+ await invokeIpc('system.openFullDiskAccessSettings');
233
366
 
234
- expect(spawnMock).toHaveBeenCalledWith(
235
- 'osascript',
236
- expect.arrayContaining(['-e', expect.any(String), expect.any(String)]),
237
- expect.objectContaining({ env: expect.any(Object) }),
367
+ expect(shell.openExternal).toHaveBeenCalledWith(
368
+ 'x-apple.systempreferences:com.apple.preference.security?Privacy',
238
369
  );
239
370
  });
371
+
372
+ it('should return granted if Full Disk Access is already granted', async () => {
373
+ readdirSyncMock.mockReturnValue(['file1', 'file2']);
374
+
375
+ const result = await invokeIpc('system.promptFullDiskAccessIfNotGranted');
376
+
377
+ expect(result).toBe('granted');
378
+ });
379
+
380
+ it('should show dialog and open settings when user clicks Open Settings', async () => {
381
+ const { dialog, shell } = await import('electron');
382
+ readdirSyncMock.mockImplementation(() => {
383
+ throw new Error('EPERM: operation not permitted');
384
+ });
385
+ vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0 } as any);
386
+
387
+ const result = await invokeIpc('system.promptFullDiskAccessIfNotGranted');
388
+
389
+ expect(result).toBe('opened_settings');
390
+ expect(dialog.showMessageBox).toHaveBeenCalled();
391
+ expect(shell.openExternal).toHaveBeenCalled();
392
+ });
393
+
394
+ it('should return skipped when user clicks Later', async () => {
395
+ const { dialog, shell } = await import('electron');
396
+ readdirSyncMock.mockImplementation(() => {
397
+ throw new Error('EPERM: operation not permitted');
398
+ });
399
+ vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1 } as any);
400
+ vi.mocked(shell.openExternal).mockClear();
401
+
402
+ const result = await invokeIpc('system.promptFullDiskAccessIfNotGranted');
403
+
404
+ expect(result).toBe('skipped');
405
+ expect(dialog.showMessageBox).toHaveBeenCalled();
406
+ // Should not open settings when user skips
407
+ expect(shell.openExternal).not.toHaveBeenCalled();
408
+ });
240
409
  });
241
410
 
242
411
  describe('openExternalLink', () => {
@@ -183,6 +183,7 @@ export default class Browser {
183
183
  private setupEventListeners(browserWindow: BrowserWindow): void {
184
184
  this.setupReadyToShowListener(browserWindow);
185
185
  this.setupCloseListener(browserWindow);
186
+ this.setupFocusListener(browserWindow);
186
187
  }
187
188
 
188
189
  private setupReadyToShowListener(browserWindow: BrowserWindow): void {
@@ -207,6 +208,14 @@ export default class Browser {
207
208
  browserWindow.on('close', closeHandler);
208
209
  }
209
210
 
211
+ private setupFocusListener(browserWindow: BrowserWindow): void {
212
+ logger.debug(`[${this.identifier}] Setting up 'focus' event listener.`);
213
+ browserWindow.on('focus', () => {
214
+ logger.debug(`[${this.identifier}] Window 'focus' event fired.`);
215
+ this.broadcast('windowFocused');
216
+ });
217
+ }
218
+
210
219
  // ==================== Window Actions ====================
211
220
 
212
221
  show(): void {
@@ -8,9 +8,14 @@ const dialog = {
8
8
  'confirm.title': 'Please confirm',
9
9
  'confirm.yes': 'Continue',
10
10
  'error.button': 'OK',
11
- 'error.detail': 'Couldn\'t complete the action. Retry or try again later.',
11
+ 'error.detail': "Couldn't complete the action. Retry or try again later.",
12
12
  'error.message': 'An error occurred',
13
13
  'error.title': 'Error',
14
+ 'fullDiskAccess.message':
15
+ 'LobeHub needs Full Disk Access to read files and enable knowledge base features. Please grant access in System Settings.',
16
+ 'fullDiskAccess.openSettings': 'Open Settings',
17
+ 'fullDiskAccess.skip': 'Later',
18
+ 'fullDiskAccess.title': 'Full Disk Access Required',
14
19
  'update.downloadAndInstall': 'Download and Install',
15
20
  'update.downloadComplete': 'Download Complete',
16
21
  'update.downloadCompleteMessage': 'Update downloaded. Install now?',
@@ -22,4 +27,4 @@ const dialog = {
22
27
  'update.skipThisVersion': 'Skip This Version',
23
28
  };
24
29
 
25
- export default dialog;
30
+ export default dialog;
@@ -7,6 +7,13 @@ const menu = {
7
7
  'dev.openStore': 'Open Data Folder',
8
8
  'dev.openUpdaterCacheDir': 'Open Updater Cache',
9
9
  'dev.openUserDataDir': 'Open User Data',
10
+ 'dev.permissions.accessibility.request': 'Request Accessibility Permission',
11
+ 'dev.permissions.fullDisk.open': 'Open Full Disk Access Settings',
12
+ 'dev.permissions.fullDisk.request': 'Request Full Disk Access Permission',
13
+ 'dev.permissions.microphone.request': 'Request Microphone Permission',
14
+ 'dev.permissions.notification.request': 'Request Notification Permission',
15
+ 'dev.permissions.screen.request': 'Request Screen Recording Permission',
16
+ 'dev.permissions.title': 'Permissions',
10
17
  'dev.refreshMenu': 'Refresh Menu',
11
18
  'dev.reload': 'Reload',
12
19
  'dev.simulateAutoDownload': 'Simulate Auto Download (3s)',
@@ -2,6 +2,8 @@ import { Menu, MenuItemConstructorOptions, app, shell } from 'electron';
2
2
  import * as path from 'node:path';
3
3
 
4
4
  import { isDev } from '@/const/env';
5
+ import NotificationCtr from '@/controllers/NotificationCtr';
6
+ import SystemController from '@/controllers/SystemCtr';
5
7
 
6
8
  import type { IMenuPlatform, MenuOptions } from '../types';
7
9
  import { BaseMenuPlatform } from './BaseMenuPlatform';
@@ -57,7 +59,6 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
57
59
  private getAppMenuTemplate(options?: MenuOptions): MenuItemConstructorOptions[] {
58
60
  const appName = app.getName();
59
61
  const showDev = isDev || options?.showDevItems;
60
-
61
62
  // 创建命名空间翻译函数
62
63
  const t = this.app.i18n.ns('menu');
63
64
 
@@ -269,6 +270,48 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
269
270
  label: t('dev.refreshMenu'),
270
271
  },
271
272
  { type: 'separator' },
273
+ {
274
+ label: t('dev.permissions.title'),
275
+ submenu: [
276
+ {
277
+ click: () => {
278
+ const notificationCtr = this.app.getController(NotificationCtr);
279
+ void notificationCtr.requestNotificationPermission();
280
+ },
281
+ label: t('dev.permissions.notification.request'),
282
+ },
283
+ { type: 'separator' },
284
+ {
285
+ click: () => {
286
+ const systemCtr = this.app.getController(SystemController);
287
+ void systemCtr.requestAccessibilityAccess();
288
+ },
289
+ label: t('dev.permissions.accessibility.request'),
290
+ },
291
+ {
292
+ click: () => {
293
+ const systemCtr = this.app.getController(SystemController);
294
+ void systemCtr.requestMicrophoneAccess();
295
+ },
296
+ label: t('dev.permissions.microphone.request'),
297
+ },
298
+ {
299
+ click: () => {
300
+ const systemCtr = this.app.getController(SystemController);
301
+ void systemCtr.requestScreenAccess();
302
+ },
303
+ label: t('dev.permissions.screen.request'),
304
+ },
305
+ { type: 'separator' },
306
+ {
307
+ click: () => {
308
+ const systemCtr = this.app.getController(SystemController);
309
+ void systemCtr.promptFullDiskAccessIfNotGranted();
310
+ },
311
+ label: t('dev.permissions.fullDisk.request'),
312
+ },
313
+ ],
314
+ },
272
315
  {
273
316
  click: () => {
274
317
  const userDataPath = app.getPath('userData');
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Full Disk Access utilities for macOS
3
+ * Based on https://github.com/inket/FullDiskAccess
4
+ */
5
+ import { shell } from 'electron';
6
+ import { macOS } from 'electron-is';
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ import { createLogger } from './logger';
12
+
13
+ const logger = createLogger('utils:fullDiskAccess');
14
+
15
+ /**
16
+ * Get the macOS major version number
17
+ * Returns 0 if not macOS or unable to determine
18
+ *
19
+ * Darwin version to macOS version mapping:
20
+ * - Darwin 23.x = macOS 14 (Sonoma)
21
+ * - Darwin 22.x = macOS 13 (Ventura)
22
+ * - Darwin 21.x = macOS 12 (Monterey)
23
+ * - Darwin 20.x = macOS 11 (Big Sur)
24
+ * - Darwin 19.x = macOS 10.15 (Catalina)
25
+ * - Darwin 18.x = macOS 10.14 (Mojave)
26
+ */
27
+ export function getMacOSMajorVersion(): number {
28
+ if (!macOS()) return 0;
29
+ try {
30
+ const release = os.release(); // e.g., "23.0.0" for macOS 14 (Sonoma)
31
+ const darwinMajor = Number.parseInt(release.split('.')[0], 10);
32
+ if (darwinMajor >= 20) {
33
+ return darwinMajor - 9; // Darwin 20 = macOS 11, Darwin 21 = macOS 12, etc.
34
+ }
35
+ // For older versions, return 10 (covers Mojave and Catalina)
36
+ return 10;
37
+ } catch {
38
+ return 0;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Check if Full Disk Access is granted by attempting to read a protected directory.
44
+ *
45
+ * On macOS 12+ (Monterey, Ventura, Sonoma, Sequoia): checks ~/Library/Containers/com.apple.stocks
46
+ * On macOS 10.14-11 (Mojave, Catalina, Big Sur): checks ~/Library/Safari
47
+ *
48
+ * Reading these directories will also register the app in TCC database,
49
+ * making it appear in System Settings > Privacy & Security > Full Disk Access
50
+ */
51
+ export function checkFullDiskAccess(): boolean {
52
+ if (!macOS()) return true;
53
+
54
+ const homeDir = os.homedir();
55
+ const macOSVersion = getMacOSMajorVersion();
56
+
57
+ // Determine which protected directory to check based on macOS version
58
+ let checkPath: string;
59
+ if (macOSVersion >= 12) {
60
+ // macOS 12+ (Monterey, Ventura, Sonoma, Sequoia)
61
+ checkPath = path.join(homeDir, 'Library', 'Containers', 'com.apple.stocks');
62
+ } else {
63
+ // macOS 10.14-11 (Mojave, Catalina, Big Sur)
64
+ checkPath = path.join(homeDir, 'Library', 'Safari');
65
+ }
66
+
67
+ try {
68
+ fs.readdirSync(checkPath);
69
+ logger.info(`[FullDiskAccess] Access granted (able to read ${checkPath})`);
70
+ return true;
71
+ } catch {
72
+ logger.info(`[FullDiskAccess] Access not granted (unable to read ${checkPath})`);
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Open Full Disk Access settings page in System Settings
79
+ *
80
+ * NOTE: Full Disk Access cannot be requested programmatically.
81
+ * User must manually add the app in System Settings.
82
+ * There is NO entitlement for Full Disk Access - it's purely TCC controlled.
83
+ */
84
+ export async function openFullDiskAccessSettings(): Promise<void> {
85
+ if (!macOS()) {
86
+ logger.info('[FullDiskAccess] Not macOS, skipping');
87
+ return;
88
+ }
89
+
90
+ logger.info('[FullDiskAccess] Opening Full Disk Access settings...');
91
+
92
+ // On macOS 13+ (Ventura), System Preferences is replaced by System Settings,
93
+ // and deep links may differ. We try multiple known schemes for compatibility.
94
+ const candidates = [
95
+ // macOS 13+ (Ventura and later) - System Settings
96
+ 'x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles',
97
+ // macOS 13+ alternative format
98
+ 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles',
99
+ ];
100
+
101
+ for (const url of candidates) {
102
+ try {
103
+ logger.info(`[FullDiskAccess] Trying URL: ${url}`);
104
+ await shell.openExternal(url);
105
+ logger.info(`[FullDiskAccess] Successfully opened via ${url}`);
106
+ return;
107
+ } catch (error) {
108
+ logger.warn(`[FullDiskAccess] Failed with URL ${url}:`, error);
109
+ }
110
+ }
111
+
112
+ // Fallback: open Privacy & Security pane
113
+ try {
114
+ const fallbackUrl = 'x-apple.systempreferences:com.apple.preference.security?Privacy';
115
+ logger.info(`[FullDiskAccess] Trying fallback URL: ${fallbackUrl}`);
116
+ await shell.openExternal(fallbackUrl);
117
+ logger.info('[FullDiskAccess] Opened Privacy & Security settings as fallback');
118
+ } catch (error) {
119
+ logger.error('[FullDiskAccess] Failed to open any Privacy settings:', error);
120
+ }
121
+ }
package/changelog/v1.json CHANGED
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "children": {},
4
+ "date": "2026-01-09",
5
+ "version": "2.0.0-next.253"
6
+ },
7
+ {
8
+ "children": {
9
+ "features": [
10
+ "Add the agent cron job."
11
+ ]
12
+ },
13
+ "date": "2026-01-09",
14
+ "version": "2.0.0-next.252"
15
+ },
2
16
  {
3
17
  "children": {},
4
18
  "date": "2026-01-09",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/lobehub",
3
- "version": "2.0.0-next.251",
3
+ "version": "2.0.0-next.253",
4
4
  "description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -11,7 +11,6 @@ import { useChatStore } from '@/store/chat';
11
11
 
12
12
  import { NotebookDocument } from '../../../types';
13
13
 
14
-
15
14
  const styles = createStaticStyles(({ css, cssVar }) => ({
16
15
  container: css`
17
16
  position: relative;
@@ -26,7 +25,6 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
26
25
  `,
27
26
  content: css`
28
27
  padding-inline: 16px;
29
-
30
28
  font-size: 14px;
31
29
  `,
32
30
  expandButton: css`
@@ -479,4 +479,4 @@
479
479
  }
480
480
  ],
481
481
  "version": "6"
482
- }
482
+ }