Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 42 additions & 0 deletions build/profile-esm-resolver.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { pathToFileURL } from 'node:url'

let profileNodeModules = []

export function initialize(data) {
if (data?.profileNodeModules) {
profileNodeModules = data.profileNodeModules
}
}

export async function resolve(specifier, context, nextResolve) {
if (profileNodeModules.length === 0) {
return nextResolve(specifier, context)
}

const isBareSpecifier = !specifier.startsWith('.') &&
!specifier.startsWith('/') &&
!specifier.startsWith('file://') &&
!specifier.startsWith('node:') &&
!specifier.includes('://')

if (!isBareSpecifier) {
return nextResolve(specifier, context)
}

try {
return await nextResolve(specifier, context)
} catch (defaultError) {
for (const nodeModulesDir of profileNodeModules) {
const syntheticParent = pathToFileURL(nodeModulesDir + '/.resolve-anchor/anchor.js').href
try {
return await nextResolve(specifier, {
...context,
parentURL: syntheticParent
})
} catch {
// try next profile
}
}
throw defaultError
}
}
49 changes: 49 additions & 0 deletions build/profile-module-paths.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { readdirSync, existsSync } from 'node:fs'
import { createRequire, register } from 'node:module'
import { join, delimiter, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'

const dshHome = process.env.DSH_HOME
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

function collectProfileNodeModules(home) {
if (!home) return []
const profilesDir = join(home, 'profiles')
try {
return readdirSync(profilesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => join(profilesDir, entry.name, 'node_modules'))
.filter((dir) => existsSync(dir))
} catch {
return []
}
}

const profileNodeModules = collectProfileNodeModules(dshHome)

if (profileNodeModules.length > 0) {
const paths = profileNodeModules.join(delimiter)
if (process.env.NODE_PATH) {
process.env.NODE_PATH = process.env.NODE_PATH + delimiter + paths
} else {
process.env.NODE_PATH = paths
}

try {
const require = createRequire(import.meta.url)
const Module = require('node:module')
if (Module.globalPaths) {
for (const dir of profileNodeModules) {
if (!Module.globalPaths.includes(dir)) {
Module.globalPaths.push(dir)
}
}
}
} catch {
// Best effort for CJS fallback
}

const resolverUrl = pathToFileURL(join(__dirname, 'profile-esm-resolver.mjs')).href
register(resolverUrl, { data: { profileNodeModules } })
}
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@
"from": "build/harness-node-entry.mjs",
"to": "harness-node-entry.mjs"
},
{
"from": "build/profile-module-paths.mjs",
"to": "profile-module-paths.mjs"
},
{
"from": "build/profile-esm-resolver.mjs",
"to": "profile-esm-resolver.mjs"
},
{
"from": "build/dsh-desktop.patch.yml",
"to": "dsh-desktop.patch.yml"
Expand Down
1 change: 1 addition & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ async function bootstrap(): Promise<void> {
dshEntryPath: dshEntryPath(),
nodeExecutablePath: bundledNodePath(),
nodeEntryPath: harnessNodeEntryPath(),
profileModulePathsPath: desktopResourcePath('profile-module-paths.mjs'),
dshPatchPath: desktopResourcePath('dsh-desktop.patch.yml'),
dshHome: join(app.getPath('userData'), 'harness'),
logPath: join(app.getPath('logs'), 'harness.log'),
Expand Down
14 changes: 10 additions & 4 deletions src/main/runtime/harness-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface HarnessRuntimeOptions {
dshEntryPath: string
nodeExecutablePath: string
nodeEntryPath: string
profileModulePathsPath: string
dshPatchPath: string
dshHome: string
logPath: string
Expand Down Expand Up @@ -36,7 +37,8 @@ export function buildHarnessSpawnOptions(
launchDirectory: string,
dshHome: string,
platform: NodeJS.Platform = process.platform,
environment: NodeJS.ProcessEnv = process.env
environment: NodeJS.ProcessEnv = process.env,
profileModulePathsPath?: string
): SpawnOptionsWithoutStdio {
const { ELECTRON_RUN_AS_NODE: _runAsNode, ...parentEnvironment } = environment
const pathKey = platform === 'win32' ? 'Path' : 'PATH'
Expand All @@ -47,6 +49,7 @@ export function buildHarnessSpawnOptions(
...parentEnvironment,
DSH_HOME: dshHome,
NO_COLOR: '1',
...(profileModulePathsPath ? { DSH_DESKTOP_PROFILE_MODULE_PATHS: profileModulePathsPath } : {}),
[pathKey]: environment[pathKey] ?? environment.PATH ?? ''
},
stdio: ['pipe', 'pipe', 'pipe'],
Expand All @@ -58,10 +61,12 @@ export function buildNodeArguments(
nodeEntryPath: string,
dshEntryPath: string,
port: number,
patchPath?: string
patchPath?: string,
profileModulePathsPath?: string
): string[] {
return [
'--expose-internals',
...(profileModulePathsPath ? ['--import', profileModulePathsPath] : []),
nodeEntryPath,
dshEntryPath,
...buildHarnessArguments(port, patchPath)
Expand Down Expand Up @@ -121,7 +126,8 @@ export class HarnessRuntime {
this.options.nodeEntryPath,
this.options.dshEntryPath,
port,
this.options.dshPatchPath
this.options.dshPatchPath,
this.options.profileModulePathsPath
)
const startupTimeoutMs =
this.options.startupTimeoutMs ?? (process.platform === 'win32' ? 120_000 : 45_000)
Expand All @@ -136,7 +142,7 @@ export class HarnessRuntime {
child = this.options.launchProcess(
this.options.nodeExecutablePath,
args,
buildHarnessSpawnOptions(launchDirectory, this.options.dshHome)
buildHarnessSpawnOptions(launchDirectory, this.options.dshHome, undefined, undefined, this.options.profileModulePathsPath)
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
Expand Down
72 changes: 72 additions & 0 deletions test/profile-module-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { mkdir, writeFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { pathToFileURL } from 'node:url'

describe('Profile module paths resolution', () => {
it('resolves bare specifiers from profile node_modules via synthetic parent', async () => {
const testDir = join(tmpdir(), `dsh-desktop-resolver-${Date.now()}`)
const profileNodeModules = join(testDir, 'profiles', 'web', 'node_modules')
const packageDir = join(profileNodeModules, 'test-profile-pkg')

await mkdir(packageDir, { recursive: true })
await writeFile(
join(packageDir, 'package.json'),
JSON.stringify({ name: 'test-profile-pkg', version: '1.0.0', main: 'index.js', type: 'module' })
)
await writeFile(join(packageDir, 'index.js'), 'export const value = 42;\n')

try {
const resolverUrl = pathToFileURL(join(process.cwd(), 'build', 'profile-esm-resolver.mjs')).href
const mod = await import(resolverUrl)

mod.initialize({ profileNodeModules: [profileNodeModules] })

const fakeParent = 'file:///app/somewhere/module.js'
const nextResolve = (specifier: string, _context: unknown) => {
if (specifier.includes('test-profile-pkg')) {
return Promise.resolve({ url: pathToFileURL(join(packageDir, 'index.js')).href, shortCircuit: true })
}
return Promise.reject(new Error(`not found: ${specifier}`))
}

const result = await mod.resolve(
'test-profile-pkg',
{ parentURL: fakeParent, conditions: ['import'] },
nextResolve
)

expect(result.url).toContain('test-profile-pkg')
expect(result.url).toContain('index.js')
} finally {
await rm(testDir, { recursive: true, force: true })
}
})

it('passes through non-bare specifiers unchanged', async () => {
const resolverUrl = pathToFileURL(join(process.cwd(), 'build', 'profile-esm-resolver.mjs')).href
const mod = await import(resolverUrl)

mod.initialize({ profileNodeModules: ['/fake/profile/node_modules'] })

const fakeParent = 'file:///app/module.js'
let capturedSpecifier = ''
const nextResolve = (specifier: string, _context: unknown) => {
capturedSpecifier = specifier
return Promise.resolve({ url: `file:///resolved/${specifier}`, shortCircuit: true })
}

await mod.resolve('./relative.js', { parentURL: fakeParent }, nextResolve)
expect(capturedSpecifier).toBe('./relative.js')

await mod.resolve('/absolute/path.js', { parentURL: fakeParent }, nextResolve)
expect(capturedSpecifier).toBe('/absolute/path.js')

await mod.resolve('node:path', { parentURL: fakeParent }, nextResolve)
expect(capturedSpecifier).toBe('node:path')

await mod.resolve('file:///some/file.js', { parentURL: fakeParent }, nextResolve)
expect(capturedSpecifier).toBe('file:///some/file.js')
})
})
47 changes: 47 additions & 0 deletions test/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,27 @@ describe('Harness launch contract', () => {
Path: 'windows-path'
}
})
expect(options.env).not.toHaveProperty('DSH_DESKTOP_PROFILE_MODULE_PATHS')
})

it('passes profile module paths env when provided', () => {
const options = buildHarnessSpawnOptions(
'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\launch-root',
'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\harness',
'win32',
{
ELECTRON_RUN_AS_NODE: '1',
PATH: 'fallback-path',
Path: 'windows-path'
},
'C:\\app\\profile-module-paths.mjs'
)

expect(options.env).toMatchObject({
DSH_HOME: 'C:\\Users\\tester\\AppData\\Roaming\\dsh-desktop\\harness',
DSH_DESKTOP_PROFILE_MODULE_PATHS: 'C:\\app\\profile-module-paths.mjs',
NO_COLOR: '1'
})
expect(options.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE')
})

Expand All @@ -81,6 +102,32 @@ describe('Harness launch contract', () => {
])
})

it('injects profile module paths via --import when provided', () => {
expect(
buildNodeArguments(
'C:\\app\\harness-node-entry.mjs',
'C:\\app\\dsh\\lib\\bin.js',
43127,
'C:\\app\\dsh-desktop.patch.yml',
'C:\\app\\profile-module-paths.mjs'
)
).toEqual([
'--expose-internals',
'--import',
'C:\\app\\profile-module-paths.mjs',
'C:\\app\\harness-node-entry.mjs',
'C:\\app\\dsh\\lib\\bin.js',
'web',
'--patch',
'C:\\app\\dsh-desktop.patch.yml',
'--host',
'127.0.0.1',
'--port',
'43127'
])
})


it('makes native Windows termination codes diagnosable', () => {
expect(formatExitCode(4294930435)).toContain(
'0xFFFF7003, Crashpad handler unavailable'
Expand Down
Loading