mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
34fe0761d2
commit
6b4f7e4733
@ -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(
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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(/^[/\\]+/, '')}`
|
||||
}
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user