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
55 changes: 55 additions & 0 deletions build/profile-esm-resolver.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { isBareSpecifier, listProfileNodeModules } from './profile-node-modules.mjs'

// The package was found in the profile but cannot be used from there. That is a
// better error than the generic "not found" the original importer produced, so
// it is reported instead of being swallowed by the next candidate.
const PROFILE_PACKAGE_ERRORS = new Set([
'ERR_PACKAGE_PATH_NOT_EXPORTED',
'ERR_PACKAGE_IMPORT_NOT_DEFINED',
'ERR_INVALID_PACKAGE_CONFIG',
'ERR_INVALID_PACKAGE_TARGET',
'ERR_UNSUPPORTED_DIR_IMPORT'
])

let dshHome
let profiles = []

export function initialize(data) {
dshHome = data?.dshHome
profiles = data?.profiles ?? []
}

// Two levels below node_modules, so that Node's own lookup walks up into
// <profile>/node_modules/<package> and applies package.json exports,
// conditions and subpaths exactly as it would for a local dependency.
function anchorFor(nodeModulesDirectory) {
return pathToFileURL(join(nodeModulesDirectory, '.dsh-desktop-anchor', 'anchor.js')).href
}

export async function resolve(specifier, context, nextResolve) {
const parentURL = context.parentURL

try {
// Default resolution first: the app bundle keeps ownership of its own
// dependency tree, and profiles only fill in what it cannot provide.
return await nextResolve(specifier, context)
} catch (error) {
if (!isBareSpecifier(specifier)) throw error

const directories = listProfileNodeModules(dshHome, profiles)
for (const directory of directories) {
try {
return await nextResolve(specifier, { ...context, parentURL: anchorFor(directory) })
} catch (profileError) {
if (PROFILE_PACKAGE_ERRORS.has(profileError?.code)) throw profileError
}
}

// nextResolve merges the context it is handed into the shared context
// object, so the synthetic parent has to be undone before giving up.
context.parentURL = parentURL
throw error
}
}
65 changes: 65 additions & 0 deletions build/profile-module-paths.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { createRequire, register } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
isBareSpecifier,
listProfileNodeModules,
parseProfileNames
} from './profile-node-modules.mjs'

// Plugins installed from the market live in DSH_HOME/profiles/<name>/node_modules,
// while the harness and its plugin loader run from the packaged app bundle. After
// packaging those are different trees, so a bare import of an installed plugin has
// to be able to fall back to the profile.
const dshHome = process.env.DSH_HOME
const profiles = parseProfileNames(process.env.DSH_DESKTOP_PROFILES)

if (dshHome) {
installCommonJsFallback()
installEsmFallback()
}

function installCommonJsFallback() {
try {
const require = createRequire(import.meta.url)
const Module = require('node:module')
const originalResolveFilename = Module._resolveFilename

Module._resolveFilename = function (request, parent, isMain, options) {
try {
return originalResolveFilename.call(this, request, parent, isMain, options)
} catch (error) {
if (!isBareSpecifier(request)) throw error
for (const directory of listProfileNodeModules(dshHome, profiles)) {
try {
return originalResolveFilename.call(this, request, parent, isMain, {
...options,
paths: [directory]
})
} catch {
// this profile does not provide the package
}
}
throw error
}
}
} catch (error) {
warn('CommonJS', error)
}
}

function installEsmFallback() {
try {
const resolver = join(dirname(fileURLToPath(import.meta.url)), 'profile-esm-resolver.mjs')
register(pathToFileURL(resolver).href, { data: { dshHome, profiles } })
} catch (error) {
warn('ESM', error)
}
}

function warn(loader, error) {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(
`[dsh-desktop] profile plugin resolution is unavailable for ${loader} imports: ${message}\n`
)
}
3 changes: 3 additions & 0 deletions build/profile-node-modules.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export declare function isBareSpecifier(specifier: unknown): boolean
export declare function parseProfileNames(value: string | undefined): string[]
export declare function listProfileNodeModules(home: string | undefined, profiles?: string[]): string[]
53 changes: 53 additions & 0 deletions build/profile-node-modules.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { existsSync, readdirSync } from 'node:fs'
import { join } from 'node:path'

// A specifier is "bare" when Node has to look it up in a node_modules directory.
// Relative paths, absolute paths, package imports (`#name`) and anything with a
// URL scheme — including Windows drive letters such as `C:\` — resolve against
// the importer and must never be redirected to a profile.
export function isBareSpecifier(specifier) {
if (typeof specifier !== 'string' || specifier.length === 0) return false
if (
specifier.startsWith('.') ||
specifier.startsWith('/') ||
specifier.startsWith('\\') ||
specifier.startsWith('#')
) {
return false
}
return !/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(specifier)
}

export function parseProfileNames(value) {
if (!value) return []
return value
.split(',')
.map((name) => name.trim())
.filter((name) => name.length > 0 && !name.includes('/') && !name.includes('\\'))
}

// Read on every lookup instead of snapshotting at startup: a profile only gains
// a node_modules directory when its first plugin is installed, which happens
// long after the harness process starts.
export function listProfileNodeModules(home, profiles = []) {
if (!home) return []
const profilesDir = join(home, 'profiles')
const names = profiles.length > 0 ? profiles : readProfileNames(profilesDir)
const directories = []
for (const name of names) {
const directory = join(profilesDir, name, 'node_modules')
if (existsSync(directory)) directories.push(directory)
}
return directories
}

function readProfileNames(profilesDir) {
try {
return readdirSync(profilesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()
} catch {
return []
}
}
12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,18 @@
"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/profile-node-modules.mjs",
"to": "profile-node-modules.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
26 changes: 23 additions & 3 deletions src/main/runtime/harness-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { createWriteStream, existsSync, type WriteStream } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import { createServer } from 'node:net'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { RuntimePhase, RuntimeSnapshot } from '../../shared/contracts'

export interface HarnessRuntimeOptions {
dshEntryPath: string
nodeExecutablePath: string
nodeEntryPath: string
profileModulePathsPath: string
dshPatchPath: string
dshHome: string
logPath: string
Expand All @@ -21,9 +23,13 @@ export interface HarnessRuntimeOptions {
onChanged(snapshot: RuntimeSnapshot): void
}

// The desktop app always runs the `web` profile, which is also where the plugin
// market installs packages (DSH_HOME/profiles/web/node_modules).
export const HARNESS_PROFILE = 'web'

export function buildHarnessArguments(port: number, patchPath?: string): string[] {
return [
'web',
HARNESS_PROFILE,
...(patchPath ? ['--patch', patchPath] : []),
'--host',
'127.0.0.1',
Expand All @@ -47,6 +53,7 @@ export function buildHarnessSpawnOptions(
...parentEnvironment,
DSH_HOME: dshHome,
NO_COLOR: '1',
DSH_DESKTOP_PROFILES: HARNESS_PROFILE,
[pathKey]: environment[pathKey] ?? environment.PATH ?? ''
},
stdio: ['pipe', 'pipe', 'pipe'],
Expand All @@ -58,10 +65,12 @@ export function buildNodeArguments(
nodeEntryPath: string,
dshEntryPath: string,
port: number,
patchPath?: string
patchPath?: string,
profileModulePathsUrl?: string
): string[] {
return [
'--expose-internals',
...(profileModulePathsUrl ? ['--import', profileModulePathsUrl] : []),
nodeEntryPath,
dshEntryPath,
...buildHarnessArguments(port, patchPath)
Expand Down Expand Up @@ -117,17 +126,28 @@ export class HarnessRuntime {

const port = await reservePort()
const url = `http://127.0.0.1:${port}`
// Resolving profile plugins is best effort: a missing shim must not turn
// into an unexplained `--import` crash before the harness entry even runs.
const profileModulePathsUrl = existsSync(this.options.profileModulePathsPath)
? pathToFileURL(this.options.profileModulePathsPath).href
: undefined
const args = buildNodeArguments(
this.options.nodeEntryPath,
this.options.dshEntryPath,
port,
this.options.dshPatchPath
this.options.dshPatchPath,
profileModulePathsUrl
)
const startupTimeoutMs =
this.options.startupTimeoutMs ?? (process.platform === 'win32' ? 120_000 : 45_000)

this.writeLog(`\n[desktop] starting ${new Date().toISOString()}`)
this.writeLog(`[desktop] launch directory ${launchDirectory}`)
if (!profileModulePathsUrl) {
this.writeLog(
`[desktop] profile plugin resolution is disabled: ${this.options.profileModulePathsPath} was not found`
)
}
this.writeLog(`[desktop] endpoint ${url}`)
this.setState('starting', 'Starting DeepSeek Harness…')

Expand Down
Loading