PCPluginHost¶
PCPluginHost is the host-side runtime for Peach Commander's in-process plugin system. It discovers plugin bundles, validates their manifests, loads their dylibs with dlopen/dlsym, wraps every fatal-signal-prone C call in a crash guard, and adapts each of the five plugin ABIs to the host's Swift protocols (VirtualFileSystem, ContentFieldProvider, viewer, packer, contribution UI).
This page is a host-architecture overview. The C ABI itself (the exact function prototypes plugin authors implement, in Plugins/SDK/*.h) is the subject of the separate plugin SDK reference.
Purpose and responsibility¶
PCPluginHost owns the whole lifecycle of a plugin from "a directory on disk" to "a live Swift object the rest of the app can call":
- Discovery — scan plugin directories for
*.pcxplugin/*.pfxplugin/*.plxplugin/*.pdxplugin/*.ptxpluginbundles (PluginHost.discover). - Validation — parse and validate each bundle's
Contents/Info.plistinto aPluginManifest, confirm the dylib exists (PluginManifestParser,PluginHost.load). - State & association — track which plugins are enabled and which packer plugin handles which extension, persisted to
plugins.ini(PluginManager,PluginConfig). - Loading —
dlopenthe dylib, resolve the symbol table for the plugin's type, run the optional API-version handshake (PluginLibrary). - Isolation — run each synchronous plugin call under an in-process fatal-signal guard with per-plugin quarantine (
PluginGuard). - Adaptation — bridge the raw C entry points to host-native types (
PCXArchive/PCXArchiveFS,PDXPlugin/PDXContentProvider,PFXPlugin/PFXFileSystem,PLXLister,ContribPlugin). - Declarative UI — parse a plugin's
PCContributions(commands, menus, keybindings, views) and evaluatewhenvisibility expressions without ever loading the dylib (ContributionModel,WhenExpression,DetectString).
The five plugin types derive from the Total Commander model (see PluginType):
| Native | TC analog | Purpose | Host adapter |
|---|---|---|---|
pcx |
wcx | Packer / archive format | PCXArchive, PCXArchiveFS |
pfx |
wfx | File-system (virtual drive) | PFXPlugin, PFXFileSystem |
plx |
wlx | Lister / viewer | PLXLister |
pdx |
wdx | Content field / detector | PDXPlugin, PDXContentProvider |
ptx |
— (Peach extension) | Tool / action plugin | ContribPlugin |
PluginType.fromTCType(_:) accepts both the native names and the TC descriptor names (wcx/wfx/wlx/wdx), so TC pluginst.inf install descriptors map cleanly.
Why in-process C dylibs (ADR-004)¶
Plugins are macOS bundles containing a dylib that exports flat C functions with TC-preserved names (OpenArchive, ContentGetValue, ListLoad, …). This was a deliberate decision (ADR-004): it enables near function-for-function porting of existing TC plugins and maximum speed — content plugins are called once per row per column. The tradeoff, also per ADR-004, is that a crashing plugin can in principle take down the app (as in TC); PluginGuard mitigates this for the common signal-crash case but is explicitly not a sandbox. Out-of-process/XPC hosting is deferred as post-1.0 hardening.
Public interfaces and key types¶
Discovery and manifests¶
PluginHost(PluginHost.swift) — an enum namespace, the non-loading half of the host.discover(in:)walks directories and returns aPluginDiscoveryResult(validated[DiscoveredPlugin]plus per-bundlefailures).load(bundle:)validates a single bundle.bundleExtensionsis the recognized extension list. Missing directories are skipped silently; entries are sorted for deterministic order.DiscoveredPlugin— aSendable,Equatablevalue:bundlePath,manifest, and the absolutebinaryPathtoContents/MacOS/<name>(the dylib todlopen).PluginManifest(PluginManifest.swift) — the validated manifest:type,apiVersion,name, defaultextensions(lowercased, dot-stripped), optionaldetectString, optionalminHostVersion.PluginManifestParser— validates anInfo.plistdict into a manifest. Reads keysPCPluginType,PCPluginName,PCPluginAPIVersion,PCPluginExtensions(array or;/,/whitespace-delimited string),PCPluginDetectString,PCPluginMinHostVersion.currentAPIVersion = 1.PluginLoadError/PluginManifestError— structured failure enums (notABundle,missingInfoPlist,invalidType,unsupportedAPIVersion(Int, current:),missingBinary(String), …). Failures are collected, never thrown, so one bad bundle can't hide the rest.PluginInstallInfo/PluginInstallInfoParser— parser for the TCpluginst.inf[plugininstall]install descriptor (type/file/description/defaultdir), used when installing a downloaded plugin.zip(F-235).
Manager and configuration¶
PluginManager(PluginManager.swift) — a Swiftactor. Ties discovery to persisted state. Scans a user-writablepluginsDirfirst and an optional read-onlybundledPluginsDir(the app'sContents/PlugIns) second, so a user-installed plugin overrides a bundled one of the samemanifest.name. Exposesreload(),enabledPlugins(),isEnabled/setEnabled,packerPlugin(forExtension:),setAssociation(ext:plugin:),install(bundleURL:),installFromZip(zipURL:)(shells out to/usr/bin/unzip, thenlocatePluginBundle), andremove(name:)(deletes a user bundle; only disables a bundled one, since the file can't be removed).PluginConfig(PluginConfig.swift) — the pure, IO-free,Sendablemodel ofplugins.ini. Two sections:[Plugins] Disabled=(plugins are enabled by default; only disabled ones are listed) and[PackerAssoc](lowercased extension → plugin name). Parses/serializes viaINIDocumentfromPCFoundation, with deterministic key ordering.PluginInstallError—.unzipFailed/.noPluginFound.
Dynamic loading¶
PluginLibrary(PluginLibrary.swift) — a loaded dylib.open(path:required:optional:expectedAPIVersion:)callsdlopen(path, RTLD_NOW | RTLD_LOCAL), resolves the required symbols (failing with.missingRequiredSymbolsif any are absent), then the optional ones, then runs the version handshake via the optionalPcGetApiVersionexport (mismatch →.apiVersionMismatch(found:expected:)).symbol(_:)returns a raw pointer forunsafeBitCastto a@convention(c)function type.RTLD_LOCALkeeps plugin symbols from polluting the global namespace and colliding across plugins.PluginLibraryError—.dlopenFailed(String),.missingRequiredSymbols([String]),.apiVersionMismatch(found:expected:).- Per-type symbol tables:
PCXSymbols,PDXSymbols,PLXSymbols,PFXSymbols,ContribSymbols— each arequired/optionalname list.PluginHost.openLibrary(_:)picks the right table bymanifest.type;PluginHost.openContribLibrary(_:)opens any plugin for the contribution behavior ABI regardless of its file-op type.
Crash guard¶
PluginGuard(PluginGuard.swift) — an@unchecked Sendableclass (shared singletonPluginGuard.shared).guarded(_ id:_ work:)runs a synchronous closure under a fatal-signal guard implemented in theCPluginGuardC shim (pc_guard_call, asigsetjmp-based trampoline catchingSIGSEGV/SIGBUS/SIGILL/SIGFPE). On a crash it returnsnil, quarantines the plugin id (quarantine/isQuarantined/quarantinedIDs, guarded by anNSLock), logs viaPCFoundationLogger, and skips all future calls to that id for the session (F-230).
Type adapters¶
PCXArchive(PCXArchive.swift) — drives a PCX packer viapcx.h/CPCX:list,extract,pack,delete, capability probes (packerCaps,canPack,canDelete,canHandle). Every public call is wrapped in aguarded { … }that throwsPCXError.crashedif the plugin faults. Progress and change-volume callbacks (F-231) are routed through the process-widePCXCallbackRouterbecause the C prototypes carry no user-context pointer (PCX calls are serialized, so a single "current handlers" pair suffices).PCXArchiveFS(PCXArchiveFS.swift) — a read-onlyVirtualFileSystem(scheme"pcx") backed by aPCXArchive. Builds an in-memory tree from the flat entry list (synthesizing intermediate directories) so plugin archives browse like the built-in zip support; reads extract an entry to a temp file.PDXPlugin(PDXPlugin.swift) — drives a PDX content plugin viapdx.h/CPDX:supportedFields()(enumeratesContentGetSupportedFielduntilPC_FT_NOMOREFIELDS, with a runaway backstop),value(fileName:fieldIndex:…), optionalsetValue(F-234) andcompareFiles.PDXFieldKindmaps thePC_FT_*codes;decode(type:buffer:)interprets the plugin's out-buffer.PDXContentProvider— bridges aPDXPlugininto the PCVFSContentFieldProviderregistry so plugin fields become custom columns, search criteria, and multi-rename placeholders exactly like built-in providers.PFXPlugin/PFXFileSystem(PFXFileSystem.swift) — the WFX-style file-system ABI (CPFX).PFXPluginis a facet-probing wrapper (capabilities,isVolatile,volumes(),connect,contentFields(),lookup).PFXFileSystemis a full async streamingVirtualFileSystem: directory enumeration maps toPfxFindFirst/Next(metadata only, streamed in batches of 128),openReadmaterializes the whole file viaPfxGetFile,openWritebuffers to a temp file uploaded byPfxPutFileon close. It also publishes per-entry content columns (PFXContentField, qualified as<qualifier>.<leaf>, cached per listing) and drive-bar volumes (PFXVolume).PLXLister(PLXLister.swift) — a viewer/lister viaplx.h/CPLX:load/loadNext/close,searchText,send(_:to:)viewer commands (PLXCommand),printFile,previewBitmap(window-less PNG thumbnail, 512 KiB ceiling). View handles are opaquePLXHandle(UnsafeMutableRawPointer, anNSView*on the app side); the adapter never touches AppKit itself, keeping it headlessly testable.handles(_:)dispatches via the sharedDetectStringengine.ContribPlugin(ContribPlugin.swift) — the contribution behavior ABI (contrib.h/CContrib):runCommand(_:services:),makeView/closeView/notifyView, passing a host-providedPcHostServicestable. Nothing is strictly required — a plugin may contribute only commands, only views, or both. This is the behavior side; placement is declarative (below).
Declarative contributions (no dylib loaded)¶
PluginContributionsand its element typesCommandContribution,MenuContribution,ContextMenuContribution,KeybindingContribution,ViewContribution,HideContribution(ContributionModel.swift) — the typed,Sendablemodel of what a plugin adds to the UI and where.ContributionParser— parses thePCContributionsInfo.plistdict. Tolerant: a malformed entry is skipped and recorded inwarningsrather than failing the whole plugin. This runs at discovery from the plist alone, so a disabled or removed plugin contributes nothing and no plugin code runs to decide menu presence (SPEC-013).WhenExpression(WhenExpression.swift) — a small boolean expression language (==,!=,=~,startswith/endswith/contains,</>/<=/>=,in (…),&&/||/!) evaluated by the host against aContributionContextsnapshot (WhenValuemap). Never evaluated by the plugin, so it works for disabled plugins and on every menu-open with no IPC. Fail-closed: a malformed expression evaluates tofalse(hides its item); an empty/absentwhenistrue.DetectString(DetectString.swift) — a TC-compatible detect-string parser/evaluator (EXT,SIZE,FORCE,MULTIMEDIA, byte probes[N], boolean& | !), evaluated against a pureDetectContext(extension, size, first ≤8192 bytes, multimedia flag). Decides whether a plugin claims a file (F-238). A malformed detect string never matches;isValid(_:)validates user-entered overrides.
Dependencies¶
Needs (points down):
graph TD
PCApp --> PCPluginHost
PCPluginHost --> PCVFS
PCPluginHost --> PCFoundation
PCVFS --> PCFoundation
PCPluginHost --> CABIs["C ABI libs<br/>CPCX / CPDX / CPLX / CPFX<br/>CContrib / CPluginGuard"]
PCFoundation— logging (PCFoundationLogger),INIDocument(config parse/serialize).PCVFS— theVirtualFileSystem,VFSEntry,VFSPath,VFSCapabilities,ContentFieldProvider,ContentValue,ContentFieldtypes the adapters conform to and produce.- C static libs (per
project.yml) —CPCX,CPDX,CPLX,CPFX,CContrib(the five ABI header modules) andCPluginGuard(thesigsetjmpcrash-guard shim, linked via-lCPluginGuard).SWIFT_INCLUDE_PATHSpoints at eachinclude/dir. The module is built withCODE_SIGNING_ALLOWED: NO.
Depended on by: PCApp only. PCApp owns everything AppKit — it turns PLXHandle/view raw pointers into real NSViews, drives PluginManager from the settings UI, builds menus/keybindings from PluginContributions, and manages plugin lifetimes. No other module depends on PCPluginHost.
Inputs and outputs¶
- Inputs: plugin bundle directories (user + bundled); each bundle's
Contents/Info.plist(PCPlugin*keys,PCContributions); the plugin dylib and its C exports; aplugins.iniconfig file; downloaded plugin.ziparchives with an optionalpluginst.inf. - Outputs:
DiscoveredPlugin/PluginManifestvalues;PluginContributionsfor the UI builder; live adapter objects (PCXArchiveFS,PFXFileSystem,PDXContentProvider,PLXLister,ContribPlugin); a rewrittenplugins.inion enable/disable/associate; log entries for load failures and crash quarantines.
Lifecycle¶
sequenceDiagram
participant App as PCApp
participant Mgr as PluginManager (actor)
participant Host as PluginHost
participant Lib as PluginLibrary
participant Guard as PluginGuard
participant Plug as plugin dylib
App->>Mgr: reload()
Mgr->>Host: discover(in: [userDir, bundledDir])
Host->>Host: load(bundle:) → parse Info.plist → PluginManifest
Host-->>Mgr: PluginDiscoveryResult (discovered + failures)
Mgr->>Mgr: dedupe by name, load plugins.ini
Note over App: build menus/keys from PCContributions (no dylib loaded)
App->>Host: openLibrary(plugin) (on demand)
Host->>Lib: dlopen + resolve symbols + PcGetApiVersion handshake
Lib-->>App: PluginLibrary
App->>Guard: guarded(id) { adapter call → plugin }
Guard->>Plug: pc_guard_call (sigsetjmp)
Plug-->>Guard: return, or fatal signal → quarantine id
Note over Lib: deinit → dlclose only if PcSafeToUnload exported
Key points:
- Discovery/validation is eager and cheap; loading is lazy. The dylib is opened only when a plugin's function is actually needed (e.g. a
.pakis opened, or aptxcommand runs). - Unload policy:
PluginLibrarycallsdlcloseondeinitonly if the plugin exportsPcSafeToUnload. Otherwise the library stays resident — pragmatic parity with TC, avoiding unload-time crashes in plugins that register non-removable callbacks. - Quarantine is session-scoped. A crashed plugin id stays quarantined until the app restarts.
Threading and concurrency¶
PluginManageris anactor— all discovery/config mutation is serialized on its executor.PluginConfig,PluginManifest,DiscoveredPlugin, and the contribution/expression models are pureSendablevalue types — no IO, no shared state.PFXFileSystemruns every blocking C call on one dedicated serialDispatchQueue(pcx.pfx.<fsID>), off the Swift concurrency cooperative pool, giving the per-connection serialization the WFX ABI assumes.list(_:)yields batches from anAsyncThrowingStreamand honors early termination via a thread-safeCancelFlagso navigating away aborts a slow/remote enumeration instead of draining it.PCXArchivecalls are serialized, which is why its progress/change-volume callbacks can safely route through the single process-widePCXCallbackRouter(the C prototypes carry no context pointer).PluginGuardis thread-safe (NSLock-guarded quarantine set) but runsworksynchronously on the calling thread;pc_guard_callinvokes the closure inline, so it never truly escapes.- The
@unchecked Sendableadapters (PDXPlugin,PLXLister,PFXPlugin,PFXFileSystem) opt out of automatic checking because they hold raw C handles; their safety rests on the serialization described above.
Error handling¶
- Discovery never throws.
PluginHost.discoverreturns validated plugins and afailureslist of(bundlePath, PluginLoadError); the UI surfaces these. Contribution parsing collectswarningsinstead of failing. - Loading returns
Result.PluginLibrary.openyields.failure(PluginLibraryError)fordlopenfailure, missing required symbols, or a version mismatch. - Adapter calls throw typed errors (
PCXError,PDXError) or returnnil/falsefor absent optional exports (capability-by-symbol-presence). PFXFileSystemmapsPC_E_*return codes toVFSError(.notFound,.permissionDenied,.unsupported,.cancelled,.underlying).- Fatal signals are caught, not propagated. A crash inside a guarded call becomes
PCXError.crashed(or anilfromPluginGuard.guarded), the plugin is quarantined, and the app keeps running. This is best-effort, not isolation: recovering from memory corruption can leave host state inconsistent, which is precisely why the offending plugin is treated as untrusted for the rest of the session (see thePluginGuard.swiftheader note).
Testing¶
PCPluginHostTests (per project.yml; depends on PCFoundation, PCVFS, PCPluginHost) covers the module extensively — the pure logic directly and the ABI adapters against tiny purpose-built C plugins compiled at test time:
- Pure/model:
PluginManifestTests,PluginConfigTests,ContributionModelTests,DetectStringTests,PluginHostTests,PluginInstallZipTests. - Loading & guard:
PluginLibraryTests;PluginGuardTestscompiles a dylib exporting araise(SIGSEGV)function and asserts the guard catches the crash, returnsnil, and quarantines the plugin while letting a well-behaved call through — without taking down the test process. - Adapters against sample plugins:
PCXArchiveTests,PCXArchiveFSTests,SamplePackerTests,SampleListerTests,SampleCSVListerTests,SampleContentPluginTests,PFXFileSystemTests,TaskManagerPluginTests.
Because PLXLister and the adapters keep AppKit and the filesystem out (view handles are opaque raw pointers), the call choreography is unit-testable headlessly with fake plugins; the app layer owns the NSView bridging that can't be tested here.
Extension points¶
- A new plugin type would add a
PluginTypecase, a symbol table, anopenLibrarybranch, and an adapter — mirroring the existing five. - New declarative UI surfaces extend
PluginContributions+ContributionParser(and the app's UI builder); thewhen/detect languages are already reusable. - Adapters plug into existing host protocols, so new file-system, content, or viewer plugins need no host changes beyond the adapter —
PFXFileSystemconforms toVirtualFileSystemandPDXContentProvidertoContentFieldProvider, and the rest of the app consumes them uniformly. - Plugin authors implement the flat C exports in
Plugins/SDK/*.h; see the plugin SDK reference for the full ABI.
Open questions / notes¶
- Out-of-process isolation (XPC) is explicitly deferred (ADR-004). Until then,
PluginGuardis the only crash containment, and it does not protect against non-signal corruption. minHostVersionis parsed into the manifest but the host does not yet appear to enforce it during load — enforcement is a candidate follow-up.PFXSymbols.requiredis empty and PFX facets are entirely capability-probed, so a PFX bundle with no usable exports loads "successfully" and simply offers nothing; validation there is looser than forpcx/pdx/plx.