fix(desktop): expand tilde paths when revealing generated files (#776)

Reveal-in-Explorer/Finder rejected ~-prefixed paths because no layer
expanded the tilde to the home directory. Expand it in the three path
normalization entry points: server validateOpenPath, frontend
resolveAbsolute, and Electron normalizeOpenPath. Tilde expansion is
platform-aware (~\ only on win32, where backslash is a separator).
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 16:23:06 +08:00
parent 34fe0761d2
commit 6b4f7e4733
6 changed files with 118 additions and 7 deletions

View File

@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import { chmodSync, mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { normalizeExternalUrl, normalizeOpenPath, normalizeSystemSettingsUrl } from './shell'
import { expandTildePath, normalizeExternalUrl, normalizeOpenPath, normalizeSystemSettingsUrl } from './shell'
describe('Electron shell service', () => {
it('allows only explicit external URL schemes', () => {
@ -36,6 +36,20 @@ describe('Electron shell service', () => {
expect(() => normalizeOpenPath('relative/report.md')).toThrow('absolute')
})
it('expands tilde paths per platform', () => {
expect(expandTildePath('~', 'darwin')).toBe(homedir())
expect(expandTildePath('~/reports/a.html', 'linux')).toBe(`${homedir()}/reports/a.html`)
expect(expandTildePath('~\\reports\\a.html', 'win32')).toBe(`${homedir()}\\reports\\a.html`)
// On POSIX "~\..." is a regular file name; "~user" expansion is unsupported.
expect(expandTildePath('~\\reports\\a.html', 'darwin')).toBe('~\\reports\\a.html')
expect(expandTildePath('~user/file.md', 'linux')).toBe('~user/file.md')
expect(expandTildePath('a/~/b.md', 'linux')).toBe('a/~/b.md')
})
it('expands tilde paths before the absolute-path check in openPath', () => {
expect(normalizeOpenPath('~')).toBe(realpathSync(homedir()))
})
it('allows only explicit system settings URLs', () => {
expect(normalizeSystemSettingsUrl('ms-settings:notifications')).toBe('ms-settings:notifications')
expect(normalizeSystemSettingsUrl('x-apple.systempreferences:com.apple.preference.notifications')).toBe(

View File

@ -1,4 +1,5 @@
import { realpathSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@ -33,8 +34,25 @@ export function normalizeExternalUrl(target: string): string {
return url.toString()
}
/**
* Expands a leading tilde to the home directory. `~\` is only a tilde path on
* Windows on POSIX the backslash is a valid filename character.
*/
export function expandTildePath(target: string, platform: NodeJS.Platform = process.platform): string {
if (
target === '~' ||
target.startsWith('~/') ||
(platform === 'win32' && target.startsWith('~\\'))
) {
return homedir() + target.slice(1)
}
return target
}
export function normalizeOpenPath(target: string): string {
const filePath = target.startsWith('file://') ? fileURLToPath(target) : target
const filePath = expandTildePath(
target.startsWith('file://') ? fileURLToPath(target) : target,
)
if (!path.isAbsolute(filePath)) {
throw new Error('System file paths must be absolute')
}

View File

@ -50,6 +50,25 @@ describe('openWithContextForHref', () => {
const result = openWithContextForHref('src/index.ts', { sessionId: SESSION, serverBaseUrl: BASE, workDir: '/proj/' })
expect(result).toEqual({ kind: 'file', absolutePath: '/proj/src/index.ts', relPath: 'src/index.ts', previewable: true })
})
it('tilde html path → absolutePath passed through, not joined onto workDir', () => {
const result = openWithContextForHref('~/reports/a.html', { sessionId: SESSION, serverBaseUrl: BASE, workDir: '/w' })
expect(result).toEqual({
kind: 'file',
absolutePath: '~/reports/a.html',
inAppBrowserUrl: previewFsUrl(BASE, SESSION, '~/reports/a.html'),
})
})
it('Windows backslash tilde html path → absolutePath passed through', () => {
const result = openWithContextForHref('~\\reports\\a.html', { sessionId: SESSION, serverBaseUrl: BASE, workDir: 'C:/w' })
expect(result).toMatchObject({ kind: 'file', absolutePath: '~\\reports\\a.html' })
})
it('tilde markdown path → absolutePath passed through in file-preview context', () => {
const result = openWithContextForHref('~/notes/a.md', { sessionId: SESSION, serverBaseUrl: BASE, workDir: '/w' })
expect(result).toEqual({ kind: 'file', absolutePath: '~/notes/a.md', relPath: '~/notes/a.md', previewable: true })
})
})
describe('openWithContextForWorkspaceFile', () => {

View File

@ -19,6 +19,9 @@ export function openWithContextForWorkspaceFile(
}
function resolveAbsolute(workDir: string | undefined, p: string): string {
// Tilde paths are home-relative, not workspace-relative — pass them through
// for the backend (which knows the home dir and platform) to expand.
if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) return p
if (!workDir || p.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(p)) return p
return `${workDir.replace(/[\\/]+$/, '')}/${p.replace(/^[/\\]+/, '')}`
}

View File

@ -1,7 +1,7 @@
import { describe, expect, it } from 'bun:test'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { homedir, tmpdir } from 'node:os'
import { createOpenTargetService } from '../services/openTargetService.js'
async function makeDir(prefix = 'cc-haha-open-target-') {
@ -327,6 +327,42 @@ describe('openTargetService', () => {
}
})
it('expands a tilde path to the home directory before validation', async () => {
const { service, launched } = createService('darwin')
await expect(service.openTarget({ targetId: 'finder', path: '~' }))
.resolves.toMatchObject({ ok: true, path: homedir() })
expect(launched).toEqual([{ command: 'open', args: [homedir()] }])
})
it('reports missing tilde paths with the expanded home path', async () => {
const { service } = createService('darwin')
const missing = `~/cc-haha-missing-${Date.now()}/report.html`
const rejection = expect(service.openTarget({ targetId: 'finder', path: missing })).rejects
await rejection.toMatchObject({ code: 'OPEN_TARGET_PATH_MISSING' })
await rejection.toThrow(join(homedir(), missing.slice(2)))
})
it('expands Windows backslash tilde paths on win32', async () => {
const { service } = createService('win32')
const missing = `~\\cc-haha-missing-${Date.now()}\\report.html`
const rejection = expect(service.openTarget({ targetId: 'explorer', path: missing })).rejects
await rejection.toMatchObject({ code: 'OPEN_TARGET_PATH_MISSING' })
await rejection.toThrow(homedir() + missing.slice(1))
})
it('keeps backslash tilde names untouched on POSIX platforms', async () => {
const { service } = createService('darwin')
// On POSIX "~\..." is a regular (if odd) file name, not a tilde path.
const rejection = expect(service.openTarget({ targetId: 'finder', path: '~\\report.html' })).rejects
await rejection.toMatchObject({ code: 'OPEN_TARGET_PATH_MISSING' })
await rejection.toThrow('~\\report.html')
})
it('reports launch failures instead of returning success', async () => {
const dir = await makeDir()
const { service } = createService('darwin', {

View File

@ -610,8 +610,29 @@ async function resolveLaunchPlan(
return null
}
async function validateOpenPath(targetPath: string): Promise<ResolvedOpenPath> {
const resolvedPath = resolve(targetPath)
/**
* Expands a leading tilde to the home directory. Models frequently emit
* `~/report.html` (and `~\report.html` on Windows) for generated files;
* without expansion those resolve against the server cwd and always miss.
* `~\` is only a tilde path on Windows — on POSIX the backslash is a valid
* filename character.
*/
function expandTildePath(targetPath: string, platform: OpenTargetPlatform): string {
if (
targetPath === '~' ||
targetPath.startsWith('~/') ||
(platform === 'win32' && targetPath.startsWith('~\\'))
) {
return homedir() + targetPath.slice(1)
}
return targetPath
}
async function validateOpenPath(
targetPath: string,
platform: OpenTargetPlatform,
): Promise<ResolvedOpenPath> {
const resolvedPath = resolve(expandTildePath(targetPath, platform))
let entry
try {
entry = await stat(resolvedPath)
@ -972,7 +993,7 @@ export function createOpenTargetService(overrides: Partial<Runtime> = {}) {
)
}
const resolvedPath = await validateOpenPath(input.path)
const resolvedPath = await validateOpenPath(input.path, runtime.platform)
const launchPlan = await resolveLaunchPlan(definition, runtime, resolvedPath)
if (!launchPlan) {
throw openTargetError(