Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
289 changes: 289 additions & 0 deletions docs/local-artifacts-mockup.html

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions src/main/file-save.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,40 @@ describe('file save IPC handlers', () => {
})
})

it('accepts a local source and saves through the same managed pipeline', async () => {
const resolveManagedFilePath = vi.fn().mockResolvedValue('/Users/example/logs/proxy.log')
const copyTo = vi.fn().mockResolvedValue(undefined)
const close = vi.fn().mockResolvedValue(undefined)
const openManagedFile = vi.fn().mockResolvedValue({ copyTo, close })
showSaveDialog.mockResolvedValue({
canceled: false,
filePath: join(downloadsPath, 'proxy.log')
})
registerFileSaveHandlers({
resolveManagedFilePath,
openManagedFile
})

const result = await handlers.get('file:save-managed')!(
{ sender: {} },
{
source: 'local',
path: '/Users/example/logs/proxy.log',
suggestedName: 'proxy.log'
}
)

expect(resolveManagedFilePath).toHaveBeenCalledWith('local', {
path: '/Users/example/logs/proxy.log'
})
expect(copyTo).toHaveBeenCalledWith(join(downloadsPath, 'proxy.log'))
expect(close).toHaveBeenCalledTimes(1)
expect(result).toEqual({
saved: true,
filePath: join(downloadsPath, 'proxy.log')
})
})

it('copies the original pending file identity after it is finalized during Save As', async () => {
const resolveManagedFilePath = vi.fn().mockResolvedValue('/managed/.pending/report.csv')
const copyTo = vi.fn().mockResolvedValue(undefined)
Expand Down
3 changes: 2 additions & 1 deletion src/main/file-save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ const assertSaveManagedFileRequest = (request: SaveManagedFileRequest): void =>
request === null ||
(request.source !== 'artifact' &&
request.source !== 'upload' &&
request.source !== 'notebook-input') ||
request.source !== 'notebook-input' &&
request.source !== 'local') ||
typeof request.path !== 'string' ||
request.path.trim().length === 0 ||
typeof request.suggestedName !== 'string'
Expand Down
58 changes: 38 additions & 20 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
import { registerManagedPreviewIpcHandlers } from './managed-preview-ipc'
import { registerManagedPreviewProtocol } from './managed-preview-protocol'
import { ManagedPreviewResources } from './managed-preview-resources'
import type { ManagedPreviewSource } from '../shared/preview-resources'
import {
createOfficePreviewFrameProcessResolver,
createOfficePreviewProcessMemoryReader
Expand Down Expand Up @@ -90,6 +91,8 @@ import { SessionPersistenceCoordinator } from './session-persistence/coordinator
import { type SessionPersistenceBackend } from './session-persistence/ipc'
import { tryDecryptKey } from './settings/crypto'
import { registerSettingsIpcHandlers } from './settings/ipc'
import { registerLocalFsIpcHandlers } from './local-fs/ipc'
import { LocalFsService } from './local-fs/service'
import { getAppClaudeConfigDir } from './settings/provider-env'
import { createDefaultSettingsService, type SettingsService } from './settings/service'
import type { StoredConnectors } from './settings/types'
Expand Down Expand Up @@ -252,28 +255,35 @@ const registerIpcHandlers = async ({
storageRoot: resolveDataRoot(),
getClient: () => getProjectDbClient(resolveStorageRoot())
})
// Shared local-fs service backs both the "This computer" browser IPC and the managed-preview
// resolver below, so path validation stays identical across both entry points.
const localFsService = new LocalFsService()
// One source-neutral resolver keeps previews and user-requested exports on identical trust checks.
const resolveManagedFilePath = (
source: 'artifact' | 'upload' | 'notebook-input',
source: ManagedPreviewSource,
request: { path: string; projectId?: string; sessionId?: string }
): Promise<string> =>
source === 'artifact'
? (() => {
const versionIdentity = parseArtifactVersionLocator(request.path)
return versionIdentity
? artifactProvenanceRepository
.resolveVersionContent(versionIdentity)
.then((resolved) => resolved.path)
: artifactRepository.resolveManagedFilePath(request)
})()
: source === 'upload'
? uploadRepository.resolveManagedUploadPath(request, {
projectId: request.projectId,
sessionId: request.sessionId
})
: notebookInputRegistry
.resolvePreviewKey(request.path)
.then((target) => target.absolutePath)
): Promise<string> => {
if (source === 'artifact') {
const versionIdentity = parseArtifactVersionLocator(request.path)
return versionIdentity
? artifactProvenanceRepository
.resolveVersionContent(versionIdentity)
.then((resolved) => resolved.path)
: artifactRepository.resolveManagedFilePath(request)
}
if (source === 'upload') {
return uploadRepository.resolveManagedUploadPath(request, {
projectId: request.projectId,
sessionId: request.sessionId
})
}
if (source === 'notebook-input') {
return notebookInputRegistry
.resolvePreviewKey(request.path)
.then((target) => target.absolutePath)
}
return localFsService.resolveFilePath(request)
}
// One registry owns short-lived capability URLs for both managed artifact repositories.
const previewResources = new ManagedPreviewResources({
resolvePath: resolveManagedFilePath
Expand Down Expand Up @@ -779,11 +789,19 @@ const registerIpcHandlers = async ({
)
registerUploadIpcHandlers(uploadRepository, {
withSessionMutation: (projectId, sessionId, mutation) =>
sessionPersistenceCoordinator.runSessionMutation(projectId, sessionId, mutation)
sessionPersistenceCoordinator.runSessionMutation(projectId, sessionId, mutation),
onStandaloneUploadSaved: (projectId, sessionId) =>
broadcastToRenderers('project-files:changed', {
projectId,
sessionId,
sources: ['upload'],
kind: 'upsert'
})
})
ipcMain.handle('notebook:read-input-preview', (_event, request) =>
notebookInputRegistry.readPreview(request)
)
registerLocalFsIpcHandlers(localFsService)
registerSessionPersistenceIpcHandlers(sessionPersistenceBackend, reviewRepository)
registerProjectFilesIpcHandlers(
projectFilesRepository,
Expand Down
33 changes: 33 additions & 0 deletions src/main/local-fs/ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ipcMain } from 'electron'

import type { ArtifactPreviewResult, ReadArtifactPreviewRequest } from '../../shared/artifacts'
import type { LocalDirListing, LocalRoots } from '../../shared/local-fs'
import { LocalFsService } from './service'

// Channel names for the local ("This computer") file browser. Grouped under the local-fs: prefix.
export const LOCAL_FS_LIST_DIR_CHANNEL = 'local-fs:list-dir'
export const LOCAL_FS_READ_PREVIEW_CHANNEL = 'local-fs:read-preview'
export const LOCAL_FS_GET_ROOTS_CHANNEL = 'local-fs:get-roots'
export const LOCAL_FS_REVEAL_CHANNEL = 'local-fs:reveal'
export const LOCAL_FS_OPEN_PATH_CHANNEL = 'local-fs:open-path'

// Registers the local-fs IPC handlers against a service instance (injectable for tests).
export const registerLocalFsIpcHandlers = (
service: LocalFsService = new LocalFsService()
): void => {
ipcMain.handle(LOCAL_FS_LIST_DIR_CHANNEL, (_event, path: string): Promise<LocalDirListing> =>
service.listDir(path)
)
ipcMain.handle(
LOCAL_FS_READ_PREVIEW_CHANNEL,
(_event, request: ReadArtifactPreviewRequest): Promise<ArtifactPreviewResult> =>
service.readPreview(request)
)
ipcMain.handle(LOCAL_FS_GET_ROOTS_CHANNEL, (): LocalRoots => service.getRoots())
ipcMain.handle(LOCAL_FS_REVEAL_CHANNEL, (_event, path: string): void => {
service.revealInFolder(path)
})
ipcMain.handle(LOCAL_FS_OPEN_PATH_CHANNEL, (_event, path: string): Promise<string> =>
service.openPath(path)
)
}
77 changes: 77 additions & 0 deletions src/main/local-fs/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'

// electron's app/shell are unavailable in the test runner; stub the two calls the service makes.
vi.mock('electron', () => ({
app: { getPath: (name: string) => (name === 'home' ? '/home/testuser' : '/tmp') },
shell: { showItemInFolder: vi.fn(), openPath: vi.fn(async () => '') }
}))

import { LocalFsService } from './service'

let root = ''
const service = new LocalFsService()

beforeAll(async () => {
root = await mkdtemp(join(tmpdir(), 'local-fs-test-'))
await mkdir(join(root, 'sub'))
await mkdir(join(root, 'Alpha'))
await writeFile(join(root, 'notes.md'), '# Title\n\nHello world.\n', 'utf8')
await writeFile(join(root, 'data.csv'), 'a,b\n1,2\n', 'utf8')
})

afterAll(() => {
vi.restoreAllMocks()
})

describe('LocalFsService.listDir', () => {
it('lists entries directories-first, alphabetical', async () => {
const listing = await service.listDir(root)
expect(listing.entries.map((e) => e.name)).toEqual(['Alpha', 'sub', 'data.csv', 'notes.md'])
expect(listing.entries[0].isDirectory).toBe(true)
expect(listing.entries.find((e) => e.name === 'notes.md')?.size).toBeGreaterThan(0)
})

it('resolves symlinks and .. via realpath', async () => {
const link = join(root, 'link-to-sub')
await symlink(join(root, 'sub'), link)
const listing = await service.listDir(link)
// realpath both sides: macOS resolves the tmp dir through a /private symlink prefix.
expect(listing.resolvedPath).toBe(await realpath(join(root, 'sub')))
})

it('rejects relative paths', async () => {
await expect(service.listDir('relative')).rejects.toThrow(/absolute/)
})

it('rejects paths with control characters', async () => {
await expect(service.listDir('/tmp/\x00x')).rejects.toThrow(/invalid characters/)
})
})

describe('LocalFsService.readPreview', () => {
it('reads a bounded UTF-8 preview', async () => {
const result = await service.readPreview({ path: join(root, 'notes.md') })
expect(result.content).toContain('# Title')
expect(result.encoding).toBe('utf8')
})

it('refuses to preview a directory', async () => {
await expect(service.readPreview({ path: join(root, 'sub') })).rejects.toThrow(/not a file/)
})

it('rejects relative preview paths', async () => {
await expect(service.readPreview({ path: 'notes.md' })).rejects.toThrow(/absolute/)
})
})

describe('LocalFsService.getRoots', () => {
it('returns home and a machine name', () => {
const roots = service.getRoots()
expect(roots.home).toBe('/home/testuser')
expect(roots.machineName.length).toBeGreaterThan(0)
})
})
98 changes: 98 additions & 0 deletions src/main/local-fs/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { hostname, userInfo } from 'node:os'
import { readdir, realpath, stat } from 'node:fs/promises'
import { join } from 'node:path'

import { app, shell } from 'electron'

import type { ArtifactPreviewResult, ReadArtifactPreviewRequest } from '../../shared/artifacts'
import type { LocalDirEntry, LocalDirListing, LocalRoots } from '../../shared/local-fs'
import { LOCAL_DIR_ENTRY_CAP, sortLocalEntries, validateLocalPath } from '../../shared/local-fs'
import { readBoundedManagedFilePreview } from '../managed-file-preview'

// Builds a user-facing machine name from the OS hostname (stripping a trailing ".local" that macOS
// appends) with a possessive owner prefix when the login name is available — e.g. "roxi's MacBook".
const buildMachineName = (): string => {
const raw = hostname().replace(/\.local$/i, '')
let owner = ''
try {
owner = userInfo().username
} catch {
// userInfo() throws when there is no OS user record (e.g. some CI); fall back to the bare host.
}
return owner ? `${owner}'s ${raw}` : raw
}

// Throws a tagged error when the path fails the shared validation (non-absolute / control chars).
const assertValidLocalPath = (path: string): void => {
const problem = validateLocalPath(path)
if (problem === 'not_absolute') throw new Error('Local path must be absolute.')
if (problem === 'control_chars') throw new Error('Local path contains invalid characters.')
}

// Service for browsing and previewing arbitrary local files. Unlike the artifact/upload readers,
// this deliberately does NOT confine paths to a storage root: the feature's contract is
// "Home start, full-disk navigable". Path validation rejects only malformed input; sensitive-file
// warnings are surfaced in the renderer (see isSensitiveLocalPath).
export class LocalFsService {
// Absolute paths for the browser's initial location and "Go to → Home".
getRoots(): LocalRoots {
return { home: app.getPath('home'), machineName: buildMachineName() }
}

// Lists one directory. Resolves symlinks/.. via realpath, sorts dirs-first, caps entry count.
async listDir(path: string): Promise<LocalDirListing> {
assertValidLocalPath(path)
const resolvedPath = await realpath(path)
const dirents = await readdir(resolvedPath, { withFileTypes: true })
const truncated = dirents.length > LOCAL_DIR_ENTRY_CAP
const capped = truncated ? dirents.slice(0, LOCAL_DIR_ENTRY_CAP) : dirents

const entries: LocalDirEntry[] = []
for (const dirent of capped) {
const isDirectory = dirent.isDirectory()
// Stat each entry for size/mtime; skip entries that vanish or deny access mid-listing so one
// unreadable file never fails the whole directory.
try {
const entryStat = await stat(join(resolvedPath, dirent.name))
entries.push({
name: dirent.name,
isDirectory: isDirectory || entryStat.isDirectory(),
size: entryStat.isDirectory() ? 0 : entryStat.size,
mtimeMs: Math.round(entryStat.mtimeMs)
})
} catch {
entries.push({ name: dirent.name, isDirectory, size: 0, mtimeMs: 0 })
}
}

return { entries: sortLocalEntries(entries), truncated, resolvedPath }
}

// Validates + canonicalizes an absolute file path, asserting it is a regular file. Shared by the
// bounded preview reader and the streaming managed-preview resolver (binary renderers).
async resolveFilePath(request: { path: string }): Promise<string> {
assertValidLocalPath(request.path)
const resolvedPath = await realpath(request.path)
const fileStat = await stat(resolvedPath)
if (!fileStat.isFile()) throw new Error('Local preview path is not a file.')
return resolvedPath
}

// Reads a bounded preview of one local file, reusing the shared bounded reader.
async readPreview(request: ReadArtifactPreviewRequest): Promise<ArtifactPreviewResult> {
const resolvedPath = await this.resolveFilePath(request)
return readBoundedManagedFilePreview(resolvedPath, request, 'Invalid local preview encoding.')
}

// Reveals a file in the OS file manager (Finder / Explorer).
revealInFolder(path: string): void {
assertValidLocalPath(path)
shell.showItemInFolder(path)
}

// Opens a file with the OS default application. Returns the shell error string, or '' on success.
async openPath(path: string): Promise<string> {
assertValidLocalPath(path)
return shell.openPath(path)
}
}
Loading
Loading