17 KiB
DSH Desktop plugin services
English | 中文
This document is the supported Host-side integration contract for plugin authors. It covers the public desktopProfiles and desktopPnpm Cordis services exported by DSH Desktop 2.x in both compatibility and advanced presentation modes. It does not grant third-party access to raw Electron APIs, the renderer, or launcher bootstrap state.
Layers and data flow
flowchart LR
subgraph Electron["Electron main process"]
Launcher["Launcher<br/>select profile and create bootstrap facts"]
Native["Native runtime<br/>window, tray, terminal, updates"]
end
subgraph Host["Host Cordis generation"]
Profiles["Public service<br/>ctx.desktopProfiles"]
Bootstrap["Launcher-private service<br/>desktopPnpmBootstrap"]
Pnpm["Public service<br/>ctx.desktopPnpm"]
Runtime["Desktop-internal adapter<br/>ctx.desktopRuntime"]
Upstream["Upstream Host services<br/>subprocess, webServer, settings"]
Plugin["Third-party Host plugin"]
end
subgraph Renderer["Sandboxed Web renderer"]
Client["Desktop and third-party<br/>Web Client modules"]
end
Launcher -->|"register before Loader entries"| Profiles
Launcher -->|"provide private paths and ABI facts"| Bootstrap
Launcher -->|"provide internal native adapter"| Runtime
Bootstrap --> Pnpm
Upstream --> Pnpm
Runtime --> Native
Plugin --> Profiles
Plugin --> Pnpm
Upstream <-->|"loopback HTTP and WebSocket"| Client
The launcher resolves one profile before the Loader tree mounts. desktopProfiles.current remains fixed until that whole Cordis generation is disposed. The desktop-pnpm Host row builds desktopPnpm from launcher-private facts and the upstream subprocess service. A profile or mode switch disposes the current generation and starts a new one; service references must not cross that boundary.
The renderer receives ordinary Web Client modules over the existing loopback carrier. It cannot read these Host services directly, and DSH Desktop adds no preload or Electron IPC bridge for them. A plugin with browser UI continues to use normal DSH Host routes, RPC, client metadata, services, and slots.
Public Cordis services
Use type-only imports from the supported contract paths:
import type {
DesktopCurrentProfile,
DesktopProfiles,
} from 'dsh-plugin-desktop/profile-service'
import type {
DesktopPnpm,
DesktopPnpmHandle,
DesktopPnpmOutcome,
} from 'dsh-plugin-desktop/pnpm'
dsh-plugin-desktop/profiles is the Desktop-owned tray consumer, not the profile service contract. Do not import it for service types.
desktopProfiles
interface DesktopProfiles {
readonly current: {
readonly name: string
readonly dir: string
}
list(): readonly DesktopProfileSummary[]
select(name: string): Promise<void>
}
currentis immutable for one generation.nameis the launcher-selected profile name anddiris its absolute manifest directory. Do not infer either value from argv,ctx.baseUrl, settings, Loader rows, or$DSH_HOME.list()re-reads profile manifests without changing their patches, dependencies, or bundle order. Entries can describe profiles that are visible but not selectable.select(name)is a restart operation, not an in-place mutation. It persists an accepted target before requesting orderly Cordis teardown and Electron relaunch.- Concurrent calls for the same target share one operation. After a target has been committed as pending, a different target is rejected until restart. A persistence failure releases the selection slot; a restart failure retains the committed target so the same restart can be retried without overwriting state.
- Calls through a retained reference fail after service disposal. Read
currentagain from the next generation instead of caching the old service globally.
desktopPnpm
interface DesktopPnpm {
run(args: readonly string[], signal?: AbortSignal): DesktopPnpmHandle
runPlugin(
args: readonly string[],
invokingDir: string,
signal?: AbortSignal,
): DesktopPnpmHandle
}
interface DesktopPnpmHandle {
readonly stdout: NodeJS.ReadableStream
readonly stderr: NodeJS.ReadableStream
readonly done: Promise<{
readonly exitCode: number | null
readonly signal: NodeJS.Signals | null
}>
cancel(): void
}
The actual stream type is Node's Readable. Both methods validate non-empty, NUL-free argv. runPlugin() additionally requires an absolute, NUL-free invokingDir.
| Method | Process and working directory | Supported purpose |
|---|---|---|
run(args, signal?) |
Runs the packaged pnpm JavaScript entry directly, with the active profile directory as cwd. |
Low-level pnpm work whose caller deliberately does not need DSH plugin reconciliation. |
runPlugin(args, invokingDir, signal?) |
Runs packaged dsh plugin --profile <active> ... with the absolute caller directory as CLI cwd; upstream DSH changes into the profile for pnpm. |
Plugin add, remove, update, collection repair, or dependency repair. |
run() is not a shorter spelling of runPlugin(). Direct pnpm does not promise first-use profile initialization, caller-relative file: or link: source anchoring, or successful dsh.profile.bundles reconciliation. A package can appear in dependencies yet fail to join the Loader layer stack if a plugin manager uses the wrong method.
runPlugin() preserves the ordinary DSH CLI as the authority for those behaviors. Its args are the pnpm arguments forwarded after dsh plugin --profile <active>, for example:
['add', 'example-plugin']
['remove', 'example-plugin']
['update']
['install', '--no-frozen-lockfile']
The service starts at most one package operation per generation. A second call while one is active throws synchronously. It exposes output instead of choosing a progress UI, and it has no built-in timeout. The consumer owns deadlines, reads both streams, reports progress, calls cancel() or aborts its signal when needed, awaits done, and checks both exitCode and signal.
Invalid argv, an invalid invokingDir, a closed or busy generation, and a signal that was already aborted all throw synchronously before a handle is returned. After a handle exists, cancellation and generation teardown target the complete subprocess tree. done does not settle merely because the direct wrapper exits; the operation gate remains held until descendants are gone. An asynchronous spawn-level failure rejects done, while a normal command failure resolves it with a nonzero exit code. On Windows the provider launches exact packaged entries with argv and delegates tree ownership to the subprocess service, so plugin authors do not need to discover .cmd shims or concatenate shell text.
Internal and launcher-private capabilities
| Name | Boundary | Plugin-author status |
|---|---|---|
desktopProfiles |
Generation-scoped Host service. | Public and supported through dsh-plugin-desktop/profile-service. |
desktopPnpm |
Generation-scoped Host service. | Public and supported through dsh-plugin-desktop/pnpm. |
desktopRuntime |
Launcher-provided native adapter used by Desktop-owned shell, tray, terminal, profile, and update rows. | Desktop-internal. Third-party plugins must not inject it or rely on its window/tray methods. |
desktopPnpmBootstrap |
Absolute packaged paths, selected profile facts, Electron ABI values, and private Node helpers supplied to the desktop-pnpm provider. |
Launcher-private. Never read, provide, intercept, or declare it as a dependency. |
DesktopProfileServiceBootstrap |
Constructor input used while the launcher registers desktopProfiles; it is not a Cordis service. |
Launcher-private implementation detail. |
The fact that a private type is present in emitted declarations does not make its runtime service a supported third-party capability. The two public service names and their contract modules are the compatibility boundary.
Injection patterns
Desktop-only plugin: required injection
A plugin that only makes sense inside DSH Desktop can declare both services as required dependencies. Cordis keeps the plugin pending until both providers are available and unloads its effects if a required service disappears.
import type { Context } from '@deepseek-ai/cordis'
import type {} from 'dsh-plugin-desktop/profile-service'
import type { DesktopPnpmHandle } from 'dsh-plugin-desktop/pnpm'
export const name = 'example-desktop-plugin-manager'
export const inject = ['desktopProfiles', 'desktopPnpm']
declare function registerInstallAction(
callback: (target: string) => Promise<void>,
): () => void
export function apply(ctx: Context): void {
ctx.logger.info(`active Desktop profile: ${ctx.desktopProfiles.current.name}`)
ctx.effect(() => {
let active: DesktopPnpmHandle | undefined
const disposeAction = registerInstallAction(async (target) => {
// Validate target first. This callback represents an explicit user action.
const signal = AbortSignal.timeout(5 * 60_000)
const operation = ctx.desktopPnpm.runPlugin(['add', target], process.cwd(), signal)
active = operation
operation.stdout.setEncoding('utf8')
operation.stderr.setEncoding('utf8')
operation.stdout.on('data', chunk => ctx.logger.info(String(chunk).trimEnd()))
operation.stderr.on('data', chunk => ctx.logger.warn(String(chunk).trimEnd()))
try {
const outcome = await operation.done
if (outcome.exitCode !== 0) {
throw new Error(`plugin install failed: exit=${String(outcome.exitCode)} signal=${String(outcome.signal)}`)
}
} finally {
if (active === operation) active = undefined
}
})
return async () => {
disposeAction()
const operation = active
operation?.cancel()
await operation?.done.catch(() => {})
}
}, 'example: package-manager user action')
}
In production, validate target against the plugin's trust policy before invoking a package manager. A process exit code of zero does not replace domain-specific post-install validation.
Cross-environment plugin: optional Desktop adapter and ordinary DSH fallback
Do not put Desktop services in the top-level required inject list when the same package must activate under ordinary DSH. The launcher registers desktopProfiles before Loader entries mount, so its presence is the Desktop environment discriminator. If present, create a nested ctx.inject() callback that waits for desktopPnpm; if absent, mount the existing ordinary DSH implementation.
import type { Context } from '@deepseek-ai/cordis'
import type {} from 'dsh-plugin-desktop/profile-service'
import type {} from 'dsh-plugin-desktop/pnpm'
export const name = 'cross-environment-plugin-manager'
export const inject = ['webServer', 'loader']
interface ManagerAdapter {
readonly profile: string
readonly profileDir?: string
runPlugin(
args: readonly string[],
invokingDir: string,
signal?: AbortSignal,
): unknown
}
declare function mountManager(ctx: Context, adapter: ManagerAdapter): () => void
declare function ordinaryDshAdapter(profile: string): ManagerAdapter
export function apply(ctx: Context, config: { profile?: string }): void {
const profiles = ctx.get('desktopProfiles')
if (profiles === undefined) {
// Existing non-Desktop behavior remains authoritative here.
const profile = config.profile ?? 'web'
ctx.effect(
() => mountManager(ctx, ordinaryDshAdapter(profile)),
'example: ordinary DSH plugin manager',
)
return
}
// ctx.inject() still treats desktopPnpm as required for this nested
// callback. The parent plugin remains loadable in ordinary DSH because the
// Desktop-only dependency is not in its top-level inject list.
ctx.inject(['desktopPnpm'], (desktopCtx) => {
desktopCtx.effect(() => mountManager(desktopCtx, {
profile: profiles.current.name,
profileDir: profiles.current.dir,
runPlugin: (args, invokingDir, signal) =>
desktopCtx.desktopPnpm.runPlugin(args, invokingDir, signal),
}), 'example: Desktop plugin manager')
})
}
ctx.inject() is not an optional-dependency declaration: every name passed to that callback is required for the callback. It is useful here because only the nested Desktop adapter waits for desktopPnpm; the outer plugin still owns the ordinary fallback. For a purely additive Desktop feature, the same nested pattern can simply contribute effects while the services exist and do nothing elsewhere.
Never fall back to a guessed web profile after desktopProfiles is present. A partial or failed Desktop provider set is a Desktop generation failure, not permission to mutate another profile through an ambient CLI. Also do not use ctx.baseUrl, settings, Loader inventory, or the launcher's inner cmdlineArgs as a substitute for desktopProfiles.current.
Type-only imports are erased from JavaScript. A cross-environment package can keep dsh-plugin-desktop as a development dependency for compilation, or as an optional peer if it publishes declarations that expose these types; it does not need a runtime import merely to probe the services.
Minimal runnable test plugin
The repository includes a two-file profile-local fixture at tests/fixtures/desktop-host-services-smoke-plugin. Its entry declares inject = ['desktopProfiles', 'desktopPnpm'], reads desktopProfiles.current, and confirms that desktopPnpm.run() and runPlugin() are available. It only publishes the result as a test probe; it never executes pnpm or changes a profile.
The complete Profile Loader smoke copies that package into a temporary profile's node_modules, loads it as a normal bare-package Loader entry, and fails unless the probe reports the active profile and both package-manager methods. Run it with:
yarn workspace dsh-plugin-desktop build
yarn workspace dsh-plugin-desktop verify:profile
This fixture is under tests/, is absent from the npm files list and Electron build files, and never enters a production archive.
Failure and teardown checklist
- Start package mutations only from an explicit user or administrator action.
- Use
desktopProfiles.currentas one snapshot; do not retain the service across restart. - Use
runPlugin()for every operation intended to change DSH plugins or repair their dependency tree. - Pass an absolute caller directory so relative package specifications preserve user intent.
- Supply an
AbortSignalfor the user-facing deadline and retain the handle for explicit cancellation. - Drain stdout and stderr, but bound any in-memory history used by a status endpoint.
- Await
done; handle rejection, nonzeroexitCode, and terminatingsignalseparately. - Surface the generation-wide busy error instead of starting concurrent profile mutations.
- Cancel active work from the owning Cordis effect disposer and wait for its completion when coordinating teardown.
- Treat
desktopProfiles.select()as a restart boundary. Do not continue assuming the selected target is live in the old generation.
Current dshmarket boundary
[email protected] predates this contract. It chooses config.profile, then launcher argv, then web; it privately imports node:child_process, discovers a bare dsh command, and runs dsh plugin --profile ... itself. Its public package exports expose no route or runner injection seam. An external config patch can correct the profile name and a PATH shim can make its legacy command discoverable, but neither adaptation makes version 1.2.3 consume desktopProfiles or desktopPnpm.
DSH Desktop therefore does not preinstall or depend on that version. A compatible future release must:
- use
desktopProfiles.currentas the authoritative Desktop identity; - wait for and invoke
desktopPnpm.runPlugin()for add, remove, update, collection cleanup, and dependency repair; - derive progress from the returned streams and own its timeout through
AbortSignal; - keep its current config/argv/CLI path when Desktop services are absent under ordinary DSH; and
- avoid treating Desktop services as required top-level injections for the cross-environment package.
There is a separate redistribution gate. The 1.2.3 manifest and README say MIT, but its source repository and npm tarball contain no complete MIT license text or copyright notice. Until a newly audited release includes the required notice, user-directed installation remains distinct from Desktop embedding the package in its application archive or installer.
Stability boundary
The supported plugin-author surface is the desktopProfiles and desktopPnpm service contract described here and exported by dsh-plugin-desktop/profile-service and dsh-plugin-desktop/pnpm. Launcher bootstrap values, native adapters, generated shims, state-file formats, Loader row ordering, and Electron implementation details may change without becoming third-party APIs. Keep fallbacks explicit, lifecycle-scoped, and headless-safe.