diff --git a/apps/desktop/src/application/folder-grant.port.ts b/apps/desktop/src/application/folder-grant.port.ts new file mode 100644 index 00000000..d9840e0e --- /dev/null +++ b/apps/desktop/src/application/folder-grant.port.ts @@ -0,0 +1,3 @@ +export interface FolderGrantPort { + grantFolder(): Promise; +} diff --git a/apps/desktop/src/main/adapters/electron-folder-grant.adapter.ts b/apps/desktop/src/main/adapters/electron-folder-grant.adapter.ts new file mode 100644 index 00000000..7c0fd125 --- /dev/null +++ b/apps/desktop/src/main/adapters/electron-folder-grant.adapter.ts @@ -0,0 +1,83 @@ +import { opendir as openDirectory } from 'node:fs/promises'; + +import type { FolderGrantPort } from '../../application/folder-grant.port.ts'; +import { parseFolderGrantState, type FolderGrantState } from '../../shared/desktop-contract-v1.ts'; + +const MAX_FOLDER_FILES = 10_000; + +interface FolderDialogLike { + showOpenDialog(options: { + readonly properties: readonly ['openDirectory']; + }): Promise<{ readonly canceled: boolean; readonly filePaths: readonly string[] }>; +} + +interface FolderEntryLike { + isFile(): boolean; + isSymbolicLink(): boolean; +} + +interface FolderDirectoryLike extends AsyncIterable { + close(): Promise; +} + +type OpenDirectory = (folderPath: string) => Promise; + +export interface ElectronFolderGrantAdapterInput { + readonly dialog: FolderDialogLike; + readonly now?: () => Date; + readonly opendir?: OpenDirectory; +} + +function notGranted(): FolderGrantState { + return parseFolderGrantState({ fileCount: 0, lastScanAt: null, status: 'not-granted' }); +} + +/** Selects and audits a local folder while returning no path or file names to the renderer. */ +export class ElectronFolderGrantAdapter implements FolderGrantPort { + readonly #dialog: FolderDialogLike; + readonly #now: () => Date; + readonly #opendir: OpenDirectory; + + public constructor({ + dialog, + now = () => new Date(), + opendir = openDirectory, + }: ElectronFolderGrantAdapterInput) { + this.#dialog = dialog; + this.#now = now; + this.#opendir = opendir; + } + + public async grantFolder(): Promise { + let directory: FolderDirectoryLike | undefined; + try { + const selection = await this.#dialog.showOpenDialog({ properties: ['openDirectory'] }); + const folderPath = selection.filePaths[0]; + if (selection.canceled || folderPath === undefined || folderPath.length === 0) + return notGranted(); + directory = await this.#opendir(folderPath); + let fileCount = 0; + for await (const entry of directory) { + if (entry.isFile() && !entry.isSymbolicLink()) { + fileCount += 1; + if (fileCount > MAX_FOLDER_FILES) return notGranted(); + } + } + return parseFolderGrantState({ + fileCount, + lastScanAt: this.#now().toISOString(), + status: 'granted', + }); + } catch { + return notGranted(); + } finally { + if (directory !== undefined) { + try { + await directory.close(); + } catch { + // The result remains content-free even when cleanup fails. + } + } + } + } +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 1e343c63..6fad2b11 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,6 +1,7 @@ import { createRequire } from 'node:module'; import path from 'node:path'; -import { app, BrowserWindow, ipcMain, session } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, session } from 'electron'; +import { ElectronFolderGrantAdapter } from './adapters/electron-folder-grant.adapter.ts'; import { LockedLocalStateAdapter } from './adapters/locked-local-state.adapter.ts'; import { UnavailableSidecarAdapter } from './adapters/unavailable-sidecar.adapter.ts'; import { @@ -26,6 +27,11 @@ async function openDesktopWindow(): Promise { applicationVersion: app.getVersion(), locale: 'vi-VN', }); + const folderGrant = new ElectronFolderGrantAdapter({ + dialog: { + showOpenDialog: (options) => dialog.showOpenDialog({ properties: [...options.properties] }), + }, + }); const sidecar = new UnavailableSidecarAdapter(); await createDesktopWindow({ @@ -36,6 +42,7 @@ async function openDesktopWindow(): Promise { expectedRendererUrl, getActiveWindow: () => activeWindow as unknown as DesktopWindowLike, ipcMain: ipcMain as unknown as DesktopIpcRegistrationInput['ipcMain'], + folderGrant, localState, sidecar, }); diff --git a/apps/desktop/src/main/ipc-registry.ts b/apps/desktop/src/main/ipc-registry.ts index e2dbbfdb..7d603638 100644 --- a/apps/desktop/src/main/ipc-registry.ts +++ b/apps/desktop/src/main/ipc-registry.ts @@ -1,8 +1,10 @@ import type { LocalStatePort } from '../application/local-state.port.ts'; +import type { FolderGrantPort } from '../application/folder-grant.port.ts'; import type { SidecarLifecyclePort } from '../application/sidecar-lifecycle.port.ts'; import { DESKTOP_IPC_CHANNELS, parseDesktopSafeState, + parseFolderGrantState, parseSidecarSafeStatus, type DesktopIpcChannel, } from '../shared/desktop-contract-v1.ts'; @@ -36,6 +38,7 @@ export interface DesktopIpcRegistrationInput { readonly expectedRendererUrl: string; readonly getActiveWindow: () => WindowLike | null; readonly ipcMain: IpcMainLike; + readonly folderGrant?: FolderGrantPort; readonly localState: LocalStatePort; readonly sidecar: SidecarLifecyclePort; } @@ -99,6 +102,7 @@ export function registerDesktopIpcV1({ expectedRendererUrl, getActiveWindow, ipcMain, + folderGrant, localState, sidecar, }: DesktopIpcRegistrationInput): () => void { @@ -106,7 +110,17 @@ export function registerDesktopIpcV1({ if (previous !== undefined) previous.active = false; for (const channel of Object.values(DESKTOP_IPC_CHANNELS)) ipcMain.removeHandler(channel); - const handlers: Record = { + const handlers: Partial> = { + ...(folderGrant === undefined + ? {} + : { + [DESKTOP_IPC_CHANNELS.folderGrant]: guardedHandler( + expectedRendererUrl, + getActiveWindow, + () => folderGrant.grantFolder(), + parseFolderGrantState, + ), + }), [DESKTOP_IPC_CHANNELS.sessionGetSafeState]: guardedHandler( expectedRendererUrl, getActiveWindow, diff --git a/apps/desktop/src/preload/bridge-v1.ts b/apps/desktop/src/preload/bridge-v1.ts index 1e87af80..72df103c 100644 --- a/apps/desktop/src/preload/bridge-v1.ts +++ b/apps/desktop/src/preload/bridge-v1.ts @@ -1,6 +1,7 @@ import { DESKTOP_IPC_CHANNELS, parseDesktopSafeState, + parseFolderGrantState, parseSidecarSafeStatus, type DesktopBridgeV1, type DesktopIpcChannel, @@ -19,11 +20,17 @@ export function createDesktopBridgeV1(invoke: DesktopInvoke): DesktopBridgeV1 { return parseDesktopSafeState(await invoke(DESKTOP_IPC_CHANNELS.sessionGetSafeState)); }, }); + const folder = Object.freeze({ + grant: async (...argumentsList: unknown[]) => { + rejectUnexpectedArguments(argumentsList); + return parseFolderGrantState(await invoke(DESKTOP_IPC_CHANNELS.folderGrant)); + }, + }); const sidecar = Object.freeze({ getStatus: async (...argumentsList: unknown[]) => { rejectUnexpectedArguments(argumentsList); return parseSidecarSafeStatus(await invoke(DESKTOP_IPC_CHANNELS.sidecarGetStatus)); }, }); - return Object.freeze({ v1: Object.freeze({ session, sidecar }) }); + return Object.freeze({ v1: Object.freeze({ folder, session, sidecar }) }); } diff --git a/apps/desktop/src/renderer/app.tsx b/apps/desktop/src/renderer/app.tsx index 9b9d4fa8..f0302f0a 100644 --- a/apps/desktop/src/renderer/app.tsx +++ b/apps/desktop/src/renderer/app.tsx @@ -3,6 +3,7 @@ import wordmarkUrl from '@databreeze/design-tokens/brand/generated/web/navigatio import type { DesktopLocale, DesktopSafeState, + FolderGrantState, SidecarSafeStatus, } from '../shared/desktop-contract-v1.ts'; @@ -18,6 +19,11 @@ const messages = { privacy: 'Không có đường dẫn hoặc nội dung tệp nào được gửi tới giao diện này.', privacyTitle: 'Ranh giới riêng tư', version: 'Phiên bản ứng dụng', + folderTitle: 'Thư mục được cấp quyền', + folderPick: 'Chọn thư mục để kiểm tra', + folderGranted: 'Đã cấp quyền cục bộ', + folderNotGranted: 'Chưa cấp quyền', + folderFiles: 'Tệp đã phát hiện', }, en: { agentDetail: 'The agent shows safe status only and contains no workspace data.', @@ -30,6 +36,11 @@ const messages = { privacy: 'No file path or file content is sent to this interface.', privacyTitle: 'Privacy boundary', version: 'Application version', + folderTitle: 'Approved folder', + folderPick: 'Choose a folder to audit', + folderGranted: 'Local permission granted', + folderNotGranted: 'No folder granted', + folderFiles: 'Files discovered', }, } as const; @@ -45,13 +56,29 @@ const initialSidecar: SidecarSafeStatus = { lifecycle: 'not-installed', protocolVersion: null, }; +const initialFolder: FolderGrantState = { + fileCount: 0, + lastScanAt: null, + status: 'not-granted', +}; export function DesktopApp() { const [locale, setLocale] = useState('vi-VN'); const [safeState, setSafeState] = useState(initialState); const [sidecarStatus, setSidecarStatus] = useState(initialSidecar); + const [folderState, setFolderState] = useState(initialFolder); const copy = messages[locale]; + async function grantFolder(): Promise { + const folder = window.databreezeDesktop?.v1.folder; + if (folder === undefined) return; + try { + setFolderState(await folder.grant()); + } catch { + setFolderState(initialFolder); + } + } + useEffect(() => { let active = true; const bridge = window.databreezeDesktop; @@ -127,6 +154,21 @@ export function DesktopApp() { +
+
+

{copy.folderTitle}

+

{folderState.status === 'granted' ? copy.folderGranted : copy.folderNotGranted}

+
+
+ + {copy.folderFiles}: {new Intl.NumberFormat(locale).format(folderState.fileCount)} + + +
+
+