diff --git a/apps/desktop/e2e/browser-page-dialogs.spec.ts b/apps/desktop/e2e/browser-page-dialogs.spec.ts index 344e9fbdc19..e66012fbbaa 100644 --- a/apps/desktop/e2e/browser-page-dialogs.spec.ts +++ b/apps/desktop/e2e/browser-page-dialogs.spec.ts @@ -364,6 +364,48 @@ createRoot(document.getElementById('root')).render(createElement(Fixture));`, await panelAction({ action: 'reload' }) await expect.poll(() => pageTitle()).toBe('form') + await check('clean reloads and navigation never ask to discard changes', async () => { + await inPage("document.documentElement.dataset.reloadProbe = 'before'") + await panelAction({ action: 'reload' }) + await expect + .poll(() => inPage('document.documentElement.dataset.reloadProbe')) + .toBeUndefined() + await expect.poll(() => pageTitle()).toBe('form') + expect(await pageDialog()).toBeNull() + await panelAction({ action: 'navigate', url: `${site}/next` }) + await expect.poll(pageUrl).toBe(`${site}/next`) + expect(await pageDialog()).toBeNull() + await panelAction({ action: 'back' }) + await expect.poll(pageUrl).toBe(`${site}/form`) + expect(await pageDialog()).toBeNull() + }) + + await check('clearing a draft removes the leave warning', async () => { + await userInput('#draft', 'temporary draft') + await inPage("document.getElementById('draft').value = ''") + await panelAction({ action: 'navigate', url: `${site}/next` }) + await expect.poll(pageUrl).toBe(`${site}/next`) + expect(await pageDialog()).toBeNull() + await panelAction({ action: 'navigate', url: `${site}/form` }) + await expect.poll(pageUrl).toBe(`${site}/form`) + }) + + await check('same-page navigation preserves a draft without a leave warning', async () => { + await userInput('#draft', 'same-page draft') + await panelAction({ action: 'navigate', url: `${site}/form#section` }) + await expect.poll(pageUrl).toBe(`${site}/form#section`) + expect(await pageDialog()).toBeNull() + expect(await inPage("document.getElementById('draft').value")).toBe('same-page draft') + await panelAction({ action: 'back' }) + await expect.poll(pageUrl).toBe(`${site}/form`) + expect(await pageDialog()).toBeNull() + await inPage(`setTimeout(() => { location.href = ${JSON.stringify(`${site}/next`)} })`) + await expect.poll(pageUrl).toBe(`${site}/next`) + expect(await pageDialog()).toBeNull() + await panelAction({ action: 'navigate', url: `${site}/form` }) + await expect.poll(pageUrl).toBe(`${site}/form`) + }) + await check('leaving a draft from the URL bar asks, and Stay keeps it', async () => { await userInput('#draft', 'draft') await expect @@ -391,6 +433,12 @@ createRoot(document.getElementById('root')).render(createElement(Fixture));`, await userInput('#draft') await panelAction({ action: 'reload' }) await expect(shell.getByRole('button', { name: 'Stay', exact: true })).toBeFocused() + await shell.getByRole('dialog').screenshot({ + path: test.info().outputPath('leave-page-modal.png'), + animations: 'allow', + caret: 'initial', + }) + await expect(shell.getByRole('button', { name: 'Stay', exact: true })).toBeFocused() await shell.keyboard.press('Escape') await expect.poll(pageDialog).toBeNull() await expect diff --git a/apps/desktop/e2e/executor-sim.ts b/apps/desktop/e2e/executor-sim.ts index 0fd7f3d3153..f2b8e381042 100644 --- a/apps/desktop/e2e/executor-sim.ts +++ b/apps/desktop/e2e/executor-sim.ts @@ -462,6 +462,7 @@ export async function launch( app.context().pages().forEach(leaveDialogsToDesktop) app.context().on('page', leaveDialogsToDesktop) const window = await app.firstWindow() + await window.waitForURL((url) => url.origin === sim.origin, { waitUntil: 'load' }) return { app, window } } diff --git a/apps/desktop/e2e/local-files.spec.ts b/apps/desktop/e2e/local-files.spec.ts index bc55bbc2514..cfe20ba2244 100644 --- a/apps/desktop/e2e/local-files.spec.ts +++ b/apps/desktop/e2e/local-files.spec.ts @@ -304,7 +304,36 @@ createRoot(document.getElementById('settings')).render( .click({ noWaitAfter: true }) expect(await survivor).toMatchObject({ ok: true, data: { text: 'shared contents' } }) }) + await test.step('expired requests cannot enable Full file access from a confirmation', async () => { + if (!app) throw new Error('Desktop app is not running') + calls.expiringFullAccess = { + toolName: 'read_local_file', + args: { path: join(outside, 'private.txt') }, + } + const permission = await requestPermission({ + operation: 'read', + toolCallId: 'expiringFullAccess', + }) + const shown = app.waitForEvent('window') + await permission.prompt + .getByRole('button', { name: 'Full file access', exact: true }) + .click({ noWaitAfter: true }) + const confirmation = await shown + calls.expiringFullAccess = undefined + await confirmation + .getByRole('button', { name: 'Enable', exact: true }) + .click({ noWaitAfter: true }) + expect(await permission.result).toMatchObject({ ok: false }) + expect( + await window.evaluate(async () => + ( + globalThis as typeof globalThis & { simDesktop: SimDesktopApi } + ).simDesktop.settings.getPreferences() + ) + ).toMatchObject({ fullFileAccess: false }) + }) await test.step('Full file access is opt-in, survives restart, and stops granting access when disabled', async () => { + if (!app) throw new Error('Desktop app is not running') const fullFolder = join(root, 'Full access') mkdirSync(fullFolder) writeFileSync(join(fullFolder, 'file.txt'), 'full access contents') @@ -315,7 +344,40 @@ createRoot(document.getElementById('settings')).render( await expect( window.getByRole('switch', { name: 'Full file access', exact: true }) ).not.toBeChecked() - await window.getByRole('switch', { name: 'Full file access', exact: true }).click() + const permission = await requestPermission({ operation: 'read', toolCallId: 'fullAccess' }) + const confirmationShown = app.waitForEvent('window') + await permission.prompt + .getByRole('button', { name: 'Full file access', exact: true }) + .click({ noWaitAfter: true }) + const confirmation = await confirmationShown + await expect(confirmation.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused() + const returnedPrompt = app.waitForEvent('window') + await confirmation + .getByRole('button', { name: 'Cancel', exact: true }) + .click({ noWaitAfter: true }) + const folderPrompt = await returnedPrompt + expect( + await window.evaluate(async () => + ( + globalThis as typeof globalThis & { simDesktop: SimDesktopApi } + ).simDesktop.settings.getPreferences() + ) + ).toMatchObject({ fullFileAccess: false }) + const acceptedConfirmation = app.waitForEvent('window') + await folderPrompt + .getByRole('button', { name: 'Full file access', exact: true }) + .click({ noWaitAfter: true }) + const allowAll = await acceptedConfirmation + await allowAll.screenshot({ + path: test.info().outputPath('full-file-access-confirmation.png'), + }) + await allowAll + .getByRole('button', { name: 'Enable', exact: true }) + .click({ noWaitAfter: true }) + expect(await permission.result).toMatchObject({ + ok: true, + data: { text: 'full access contents' }, + }) await expect( window.getByRole('switch', { name: 'Full file access', exact: true }) ).toBeChecked() @@ -383,11 +445,11 @@ createRoot(document.getElementById('settings')).render( await expect( window.getByRole('switch', { name: 'Full file access', exact: true }) ).not.toBeChecked() - const permission = await requestPermission({ operation: 'read', toolCallId: 'fullAccess' }) - await permission.prompt + const revoked = await requestPermission({ operation: 'read', toolCallId: 'fullAccess' }) + await revoked.prompt .getByRole('button', { name: "Don't allow", exact: true }) .click({ noWaitAfter: true }) - expect(await permission.result).toMatchObject({ ok: false }) + expect(await revoked.result).toMatchObject({ ok: false }) }) await test.step('a folder grant works in another chat but does not permit symlink escapes', async () => { expect(await invoke({ operation: 'read', toolCallId: 'otherChat' })).toMatchObject({ @@ -877,8 +939,12 @@ createRoot(document.getElementById('settings')).render( try { await toggle.click() await expect( - window.getByText('Could not update file access', { exact: true }) + window.getByText( + 'Could not save file access settings. Your previous setting may return after restarting Sim.', + { exact: true } + ) ).toBeVisible() + await expect(toggle).not.toBeChecked() expect( await window.evaluate(async () => ( diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 08ace958c77..7867c67d108 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -138,6 +138,7 @@ test.describe('desktop shell smoke', () => { test('OAuth popups share the session without inheriting the privileged preload', async () => { app = await launchApp(origin) const window = await app.firstWindow() + await window.waitForURL(`${origin}/home`, { waitUntil: 'load' }) await window.evaluate(() => { document.cookie = 'sim-e2e-session=shared; Path=/; SameSite=Lax' }) @@ -319,7 +320,7 @@ test.describe('desktop shell smoke', () => { await window.locator('#server').click() const picker = await pickerPromise - expect(picker.url()).toBe('sim-shell://pages/server.html') + await expect(picker).toHaveURL('sim-shell://pages/server.html') await expect(picker.getByRole('dialog', { name: 'Sim server', exact: true })).toBeVisible() await expect(picker.getByLabel('Server URL')).toHaveValue('http://127.0.0.1:1') await expect(picker.getByLabel('Server URL')).toBeFocused() diff --git a/apps/desktop/src/main/desktop-settings.ts b/apps/desktop/src/main/desktop-settings.ts index af8119fd943..a237202a12f 100644 --- a/apps/desktop/src/main/desktop-settings.ts +++ b/apps/desktop/src/main/desktop-settings.ts @@ -53,6 +53,7 @@ export interface DesktopSettingsService { interface DesktopSettingsServiceDeps { config: ConfigStore + onFullFileAccessChanged?: (preferences: DesktopPreferences) => void getMainWindow: () => BrowserWindow | null openMainWindowAt: (route?: string) => void setAutoDownloadUpdates: (enabled: boolean) => void @@ -185,9 +186,14 @@ export function createDesktopSettingsService( deps.config.set('fullFileAccess', enabled) if (!deps.config.flush()) { deps.config.set('fullFileAccess', false) - throw new Error('Could not save file access settings') + deps.onFullFileAccessChanged?.(read()) + throw new Error( + 'Could not save file access settings. Your previous setting may return after restarting Sim.' + ) } - return read() + const preferences = read() + deps.onFullFileAccessChanged?.(preferences) + return preferences }, setPreventSleepWhileRunning(enabled) { deps.config.set('preventSleepWhileRunning', enabled) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 8bac14e70ee..71b2f67449e 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -172,7 +172,10 @@ function main(): void { }) const localFilePermissions = new LocalFilePermissions( localFilesystem, - () => config.get('fullFileAccess') === true + () => config.get('fullFileAccess') === true, + () => { + desktopSettings.setFullFileAccess(true) + } ) const clearLocalFileAccess = async () => { config.set('fullFileAccess', false) @@ -546,6 +549,9 @@ function main(): void { const desktopSettings = createDesktopSettingsService({ config, + onFullFileAccessChanged: (preferences) => { + broadcast('desktop:settings:full-file-access-changed', preferences) + }, getMainWindow, openMainWindowAt: (route) => void openMainWindowAt(route), setAutoDownloadUpdates: (enabled) => updater?.setAutoDownload(enabled), diff --git a/apps/desktop/src/main/local-file-permissions.ts b/apps/desktop/src/main/local-file-permissions.ts index fd9490cba4e..9504f591327 100644 --- a/apps/desktop/src/main/local-file-permissions.ts +++ b/apps/desktop/src/main/local-file-permissions.ts @@ -54,7 +54,8 @@ export class LocalFilePermissions { constructor( private readonly filesystem: LocalFilesystemService, - private readonly fullFileAccess: () => boolean = () => false + private readonly fullFileAccess: () => boolean = () => false, + private readonly enableFullFileAccess?: () => void ) {} async authorize( @@ -71,18 +72,7 @@ export class LocalFilePermissions { throw new Error('A valid destination workspace and folder are required for imports.') const path = await realpath(nativePath(authorization.args.path)) if (this.fullFileAccess()) { - const info = await stat(path) - const folder = info.isDirectory() ? path : dirname(path) - const identity = await stat(folder, { bigint: true }) - return this.authorizedAccess( - { - path, - resolve: realpath, - open: (requested, directory = false) => - openNativeFile(folder, relative(folder, requested), identity, directory), - }, - { ...context, isCurrent: () => context.isCurrent() && this.fullFileAccess() } - ) + return this.unrestrictedAccess(path, context) } const existing = await this.filesystem.nativeAccess(path) if (existing) return this.authorizedAccess(existing, context) @@ -114,11 +104,30 @@ export class LocalFilePermissions { } pending.contexts.add(context) await this.waitForDecision(pending, context) + if (this.fullFileAccess()) return this.unrestrictedAccess(path, context) const access = await this.filesystem.nativeAccess(path) if (!access) throw new Error('The approved folder is no longer available.') return this.authorizedAccess(access, context) } + private async unrestrictedAccess( + path: string, + context: LocalFilePermissionContext + ): Promise { + const info = await stat(path) + const folder = info.isDirectory() ? path : dirname(path) + const identity = await stat(folder, { bigint: true }) + return this.authorizedAccess( + { + path, + resolve: realpath, + open: (requested, directory = false) => + openNativeFile(folder, relative(folder, requested), identity, directory), + }, + { ...context, isCurrent: () => context.isCurrent() && this.fullFileAccess() } + ) + } + private waitForDecision( pending: PendingFolderDecision, context: LocalFilePermissionContext @@ -170,7 +179,7 @@ export class LocalFilePermissions { signal: AbortSignal ): Promise { const context = await this.currentContext(contexts, signal) - if (await this.filesystem.nativeAccess(folder)) return + if (this.fullFileAccess() || (await this.filesystem.nativeAccess(folder))) return const root = await lstat(folder, { bigint: true }) if (!root.isDirectory()) throw new Error('The folder is no longer available.') const displayedPath = JSON.stringify(folder).replace( @@ -184,16 +193,42 @@ export class LocalFilePermissions { signal, title: 'Allow access to this folder?', message: displayedPath, - detail: `Sim can read files in this folder and its subfolders, use them across chats, and import them into your workspaces on ${context.origin}.\n\nManage or remove access in File → Folder Access.`, - buttons: ['Allow folder', "Don't allow"], + detail: `Sim can read and import files from this folder and its subfolders across chats on ${context.origin}.\n\nManage access in File → Folder Access.`, + buttons: [ + 'Allow folder', + "Don't allow", + ...(this.enableFullFileAccess ? ['Full file access'] : []), + ], defaultId: 1, cancelId: 1, } - const result = await (parent ? showShellDialog(parent, options) : showShellDialog(options)) - signal.throwIfAborted() - if (result.response !== 0) throw new Error('The user did not allow this local file access.') - const current = await this.currentContext(contexts, signal) - await this.filesystem.grantDirectory({ path: folder }, current.generation, root) + while (true) { + await this.currentContext(contexts, signal) + const result = await (parent ? showShellDialog(parent, options) : showShellDialog(options)) + signal.throwIfAborted() + if (result.response === 2 && this.enableFullFileAccess) { + const confirmation = { + signal, + title: 'Enable full file access?', + message: `Sim can read and import files from any folder on this computer across chats on ${context.origin}.`, + detail: 'Turn this off in Settings → Desktop → Full file access.', + buttons: ['Enable', 'Cancel'], + defaultId: 1, + cancelId: 1, + } + const answer = await (parent + ? showShellDialog(parent, confirmation) + : showShellDialog(confirmation)) + await this.currentContext(contexts, signal) + if (answer.response !== 0) continue + this.enableFullFileAccess() + return + } + if (result.response !== 0) throw new Error('The user did not allow this local file access.') + const current = await this.currentContext(contexts, signal) + await this.filesystem.grantDirectory({ path: folder }, current.generation, root) + return + } } private async authorizedAccess( diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 3a181a33eee..3704ce86a74 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -181,6 +181,13 @@ const api: SimDesktopApi = { ipcRenderer.invoke('desktop:settings:set', key, value), setFullFileAccess: (enabled: boolean): Promise => ipcRenderer.invoke('desktop:settings:set-full-file-access', enabled), + onFullFileAccessChanged: ( + callback: (preferences: DesktopPreferences) => void + ): (() => void) => { + const listener = (_event: unknown, preferences: DesktopPreferences) => callback(preferences) + ipcRenderer.on('desktop:settings:full-file-access-changed', listener) + return () => ipcRenderer.removeListener('desktop:settings:full-file-access-changed', listener) + }, setPreventSleepWhileRunning: (enabled: boolean): Promise => ipcRenderer.invoke('desktop:settings:set-prevent-sleep', enabled), setBrowserSearchSuggestionsEnabled: (enabled: boolean): Promise => diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-dialog.tsx index 63b7cc67f36..3503d33a5ae 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-page-dialog.tsx @@ -17,8 +17,8 @@ export function BrowserPageDialogModal({ dialog, open, onAnswer }: BrowserPageDi !nextOpen && answer(false)} - title='Leave site?' - text='Changes you made may not be saved.' + title='Leave page?' + text='Changes on this page may not be saved.' defaultAction='dismiss' dismissLabel='Stay' confirm={{ label: 'Leave', onClick: () => answer(true) }} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx index 9d9591eef17..7e3b8bd6ded 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx @@ -52,6 +52,7 @@ export function Desktop() { ?.getDevice() .then((device) => setRunsInBackground(device !== null)) .catch(() => setRunsInBackground(false)) + return bridge.settings.onFullFileAccessChanged?.(setPreferences) }, [router, workspaceId]) const updatePreference = async (key: DesktopPreferenceKey, value: boolean) => { @@ -76,7 +77,7 @@ export function Desktop() { const { pending: fullFileAccessPending, mutate: setFullFileAccess } = useDesktopPreferenceMutation( async (bridge, enabled: boolean) => bridge.settings.setFullFileAccess?.(enabled), - 'Could not update file access', + 'Could not save file access settings. Your previous setting may return after restarting Sim.', setPreferences ) diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index 26ac81c3971..76148ab7d87 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -988,6 +988,8 @@ interface SimDesktopSettingsApi { setPreventSleepWhileRunning?(enabled: boolean): Promise /** Optional for installed shells that predate the explicit full-file-access setting. */ setFullFileAccess?(enabled: boolean): Promise + /** Keeps settings in sync when folder consent changes access in the native shell. */ + onFullFileAccessChanged?(callback: (preferences: DesktopPreferences) => void): () => void notify(payload: DesktopNotificationPayload): Promise /** Overrides the appearance requested by browser pages. */ setBrowserTheme(theme: DesktopAppearanceTheme): Promise