From 838ff2fed4f431e09c5e1d730e800493d91e33a2 Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 9 Oct 2026 19:28:32 +0800 Subject: [PATCH] Fix Desktop link-install boot and show Netx Ops settings. Prefer shared schemastery 3.18.2 peers (Desktop 3.18.4 alone breaks Config), and register settings.section eagerly with a deferred form scope so the Settings nav appears without waiting on configForms. Co-authored-by: Cursor --- docs/INSTALL.md | 24 ++++ lib/client.js | 214 ++++++++++++++++++++++--------- package.json | 6 +- scripts/link-dsh-peers.mjs | 100 +++++++++++++++ scripts/link-dsh-peers.ps1 | 40 +++--- src/client/index.ts | 252 ++++++++++++++++++++++++++----------- 6 files changed, 487 insertions(+), 149 deletions(-) create mode 100644 scripts/link-dsh-peers.mjs diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 3614470..1d64a64 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -24,6 +24,30 @@ No local Python / `netx_mcp` install is required for DSH. (OpenClaw and other MC `@deepseek-ai/*` 由 **DSH Host profile** 提供,**不**声明为可从公共 registry 安装的依赖。仓库根目录 `pnpm install` 只会装公开包(如 `fflate`)。完整运行请用上面的 `dsh plugin add`,不要指望裸 clone 能从 npm 拉齐 Harness 内部包。 +## Desktop 本地开发(`link:` 路径安装) + +Desktop「添加插件」填本地目录时,会写成 `link:D:/…/netxops`。Node 从**工作区真实路径**解析 `@deepseek-ai/*`,不会走到 Desktop profile 的 `node_modules`,于是 Host 行报 `Cannot find package '@deepseek-ai/schemastery'`,插件页显示 **netxops 未运行**(预设行可能仍正常)。 + +装完本地路径后在仓库根执行一次(把 Host 的 `@deepseek-ai/*` peer junction 进本仓库 `node_modules`): + +```powershell +bun run link:peers +# 或 PowerShell: .\scripts\link-dsh-peers.ps1 +``` + +脚本优先用 `~/.dsh/profiles/node_modules` 里那一套 peers(schemastery 3.18.2 + dsh-*),**不要**单独链 Desktop profile 里的 schemastery 3.18.4——混链会导致 `Config({})` 变成空代理,Desktop 直接 **web boot: dsh-netxops failed**。 + +然后**重启 Desktop**(或在插件页关掉再启用 dsh-netxops)。症状对照: + +| 现象 | 原因 | +|------|------| +| `Cannot find package '@deepseek-ai/schemastery'` / **netxops 未运行** | 未跑 `link:peers` | +| **web boot: dsh-netxops failed** | peers 版本错乱(例如链了 Desktop 的 schemastery 3.18.4)→ 重跑 `bun run link:peers` 再重启 | + +改 Host/Client 后照常 `bun run bundle`,一般不用重装;换机器或清过本仓库 `node_modules` 才需再 `link:peers`。 + +备选:`npm pack` 后在 Desktop 安装 `.tgz`(复制进 profile,不走 `link:`,也就不用 link peers)。 + ## Install ```powershell diff --git a/lib/client.js b/lib/client.js index a24e4a4..bb113d1 100644 --- a/lib/client.js +++ b/lib/client.js @@ -2656,6 +2656,98 @@ function namespaceCandidates(ctx) { out.push(NETXOPS_NS); return out; } +function createMemoryScope(initial) { + let value = { ...initial }; + let revision = 0; + const listeners = new Set; + const notify = () => { + for (const listener of listeners) + listener(); + }; + return { + getSnapshot: () => ({ + status: "ready", + value, + writable: true, + revision + }), + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + set: async (field, next) => { + value = { ...value, [field]: next }; + revision += 1; + notify(); + return true; + }, + unset: async (field) => { + const next = { ...value }; + delete next[field]; + value = next; + revision += 1; + notify(); + return true; + } + }; +} +function createDeferredScope(fallback) { + let inner = fallback; + const outerListeners = new Set; + let innerOff; + const relay = () => { + for (const listener of outerListeners) + listener(); + }; + const bindInner = () => { + innerOff?.(); + innerOff = inner.subscribe(relay); + }; + bindInner(); + return { + getSnapshot: () => inner.getSnapshot(), + subscribe: (listener) => { + outerListeners.add(listener); + return () => { + outerListeners.delete(listener); + }; + }, + set: (field, next) => inner.set(field, next), + unset: (field) => inner.unset(field), + attach: (real) => { + if (inner === real) + return; + inner = real; + bindInner(); + relay(); + } + }; +} +var MEMORY_DEFAULTS = { + apiUrl: "http://127.0.0.1:8890", + lang: "zh", + thinkingLanguage: "auto", + replyLanguage: "follow-user", + nmsProvider: "zte-ume", + tokenCredentialRef: "NETX_API_TOKEN", + alarmPushEnabled: false, + alarmDeliverDsh: true, + alarmDeliverIm: false, + imBotId: "", + imTargetId: "", + imTargets: "", + groupOpsInPreset: true, + groupOpsPublic: false, + groupTopologyInPreset: false, + groupTopologyPublic: false, + groupBizMonitorInPreset: false, + groupBizMonitorPublic: false, + kbRoot: "", + groupKbInPreset: true, + groupKbPublic: false +}; function apply(ctx) { ctx.effect(() => { try { @@ -2670,43 +2762,36 @@ function apply(ctx) { } }, "netxops: locales"); const t = ctx.locale.bind(LOCALE_NS); - let card; - let wiredExtras = false; - let sectionRegistered = false; + const deferred = createDeferredScope(createMemoryScope(MEMORY_DEFAULTS)); + const card = new NetxopsCardController(deferred, ctx); let pluginsItemId; - const ensureCard = (scope) => { - if (card) - return card; - card = new NetxopsCardController(scope, ctx); - if (!wiredExtras) { - wiredExtras = true; - wireOptionalServices(ctx, card); - } - return card; - }; - const registerSection = (owner) => { - if (sectionRegistered) - return () => {}; + let wiredExtras = false; + const ensureExtras = () => { + if (wiredExtras) + return; + wiredExtras = true; try { - const off = ctx.slots.inject("settings.section", () => ctx.slots.register({ - name: "settings.section", - id: NETXOPS_NS, - order: 24, - label: () => t("title"), - locale: LOCALE_NS, - inject: () => owner.inject() - }, NetxopsCard)); - sectionRegistered = true; - return () => { - sectionRegistered = false; - off(); - }; + wireOptionalServices(ctx, card); } catch (error) { - ctx.logger?.warn?.("netxops: settings.section unavailable: %s", error); - return () => {}; + ctx.logger?.warn?.("netxops: optional remotes skipped: %s", error); } }; - const registerPluginsItem = (owner, id) => { + try { + ctx.slots.inject("settings.section", () => ctx.slots.register({ + name: "settings.section", + id: NETXOPS_NS, + order: 24, + label: () => t("title"), + locale: LOCALE_NS, + inject: () => { + ensureExtras(); + return card.inject(); + } + }, NetxopsCard)); + } catch (error) { + ctx.logger?.warn?.("netxops: settings.section unavailable: %s", error); + } + const registerPluginsItem = (id) => { if (pluginsItemId === id) return () => {}; try { @@ -2716,7 +2801,10 @@ function apply(ctx) { order: 24, label: () => t("title"), locale: LOCALE_NS, - inject: () => owner.inject() + inject: () => { + ensureExtras(); + return card.inject(); + } }, NetxopsCard)); pluginsItemId = id; return () => { @@ -2729,17 +2817,6 @@ function apply(ctx) { return () => {}; } }; - const mountUi = (owner, ns, opts) => { - const offs = []; - if (opts.section) - offs.push(registerSection(owner)); - if (opts.pluginsItem) - offs.push(registerPluginsItem(owner, ns)); - return () => { - for (const off of offs) - off(); - }; - }; const tryGetForm = (forms, candidates) => { if (typeof forms.get !== "function") return; @@ -2752,6 +2829,12 @@ function apply(ctx) { } return; }; + const attachForm = (hit, source) => { + ctx.logger?.info?.("netxops: attach form via %s (%s)", source, hit.ns); + deferred.attach(hit.scope); + ensureExtras(); + return registerPluginsItem(hit.ns); + }; ctx.inject(["configForms"], (formsCtx) => { const forms = formsCtx.configForms; if (!forms) { @@ -2772,35 +2855,42 @@ function apply(ctx) { formsCtx.logger?.warn?.("netxops: served %s but configForms.get failed", ns); return () => {}; } - formsCtx.logger?.info?.("netxops: settings via whileServed (%s)", hit.ns); - const owner = ensureCard(hit.scope); - return mountUi(owner, hit.ns, { section: true, pluginsItem: true }); + return attachForm(hit, "whileServed"); }), "netxops: configForms whileServed"); } const immediate = tryGetForm(forms, candidates); if (immediate) { - formsCtx.logger?.info?.("netxops: settings eager mount (%s)", immediate.ns); - const owner = ensureCard(immediate.scope); - formsCtx.effect(() => mountUi(owner, immediate.ns, { section: true, pluginsItem: true }), "netxops: configForms eager slots"); + formsCtx.effect(() => attachForm(immediate, "eager"), "netxops: configForms eager attach"); } else if (typeof forms.whileServed !== "function") { formsCtx.logger?.warn?.("netxops: configForms present but no get/whileServed match for %s", candidates.join(",")); } }); ctx.inject(["settingsScope"], (scopeCtx) => { - if (card) - return; const binder = scopeCtx.settingsScope; if (!binder || typeof binder.bind !== "function") { scopeCtx.logger?.warn?.("netxops: settingsScope present but .bind missing"); return; } - scopeCtx.logger?.info?.("netxops: settings card via settingsScope"); - const owner = ensureCard(binder.bind({ namespace: NETXOPS_NS })); - scopeCtx.effect(() => registerSection(owner), "netxops: settings.section"); + scopeCtx.logger?.info?.("netxops: attach form via settingsScope"); + deferred.attach(binder.bind({ namespace: NETXOPS_NS })); + ensureExtras(); }); } function wireOptionalServices(ctx, card) { - ctx.inject(["remote.credentials"], (credCtx) => { + const soft = (deps, run, label) => { + try { + ctx.inject(deps, (inner) => { + try { + run(inner); + } catch (error) { + inner.logger?.warn?.("netxops: %s handler failed: %s", label, error); + } + }); + } catch (error) { + ctx.logger?.warn?.("netxops: soft-inject %s skipped: %s", label, error); + } + }; + soft(["remote.credentials"], (credCtx) => { card.setCredentialsAvailable(true); credCtx.effect(() => { const off = credCtx.remote.$on("credentials/reference-updated", (ref) => { @@ -2811,32 +2901,32 @@ function wireOptionalServices(ctx, card) { card.setCredentialsAvailable(false); }; }, "netxops: credential invalidations"); - }); - ctx.inject(["connection"], (connCtx) => { + }, "remote.credentials"); + soft(["connection"], (connCtx) => { const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc); if (typeof call !== "function") { - connCtx.logger.warn("netxops: connection.rpc.call unavailable — alarm status UI disabled"); + connCtx.logger?.warn?.("netxops: connection.rpc.call unavailable — alarm status UI disabled"); return; } card.setAlarmPushRpc(call); connCtx.effect(() => () => { card.setAlarmPushRpc(undefined); }, "netxops: clear alarm-push rpc"); - }); + }, "connection"); const bindDirectoryPicker = (picker) => { if (!picker || typeof picker.pick !== "function") return; card.setDirectoryPicker(picker); }; bindDirectoryPicker(ctx.remote?.directoryPicker); - ctx.inject(["remote.directoryPicker"], (dpCtx) => { + soft(["remote.directoryPicker"], (dpCtx) => { const viaGet = typeof dpCtx.get === "function" ? dpCtx.get("remote.directoryPicker") : undefined; const viaNested = dpCtx.remote?.directoryPicker; bindDirectoryPicker(viaGet ?? viaNested); dpCtx.effect(() => () => { card.setDirectoryPicker(undefined); }, "netxops: clear directory picker"); - }); + }, "remote.directoryPicker"); } return module.exports; diff --git a/package.json b/package.json index 5bef352..7258292 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.44", + "version": "0.1.45", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", @@ -56,7 +56,9 @@ "bundle": "bun run scripts/gen-preset-patch.mjs && bun run scripts/build-host.mjs && bun run scripts/build-client.mjs", "bundle:host": "bun run scripts/build-host.mjs", "bundle:client": "bun run scripts/build-client.mjs", - "gen:preset-patch": "bun run scripts/gen-preset-patch.mjs" + "gen:preset-patch": "bun run scripts/gen-preset-patch.mjs", + "link:peers": "node scripts/link-dsh-peers.mjs --profile desktop", + "link:peers:ps1": "powershell -NoProfile -File ./scripts/link-dsh-peers.ps1" }, "dsh": { "bundle": { diff --git a/scripts/link-dsh-peers.mjs b/scripts/link-dsh-peers.mjs new file mode 100644 index 0000000..063968d --- /dev/null +++ b/scripts/link-dsh-peers.mjs @@ -0,0 +1,100 @@ +/** + * Desktop / pnpm `link:` installs resolve `@deepseek-ai/*` from this package's + * real path (the workspace), not from the profile `node_modules`. GitHub/file + * installs work because the package is copied under the profile. + * + * Junction Host peer packages from the DSH installation into + * `./node_modules/@deepseek-ai/*` so linked local installs can boot. + * + * Usage: + * bun run link:peers + * node scripts/link-dsh-peers.mjs --profile desktop + * .\scripts\link-dsh-peers.ps1 + */ + +import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const dshHome = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh') + +/** Runtime imports from src/index.ts (+ commonly needed neighbors). */ +const peers = [ + 'schemastery', + 'cordis', + 'dsh-credentials', + 'dsh-settings', + 'dsh-tools', + 'dsh-llm', +] + +function profileArg() { + const idx = process.argv.indexOf('--profile') + if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1] + return 'desktop' +} + +/** + * Prefer the shared healed fallback `profiles/node_modules` (one coherent peer + * set: schemastery 3.18.2 + dsh-* 0.1.5-rc.2). Do NOT prefer the Desktop + * profile's schemastery first — Desktop 0.2 ships 3.18.4 which, when linked + * alone into this package, makes `Config({})` return cosmokit volatile proxies + * instead of defaults and Host apply fails ("web boot: dsh-netxops failed"). + * Do NOT createRequire() — that can walk into an unrelated checkout via NODE_PATH. + */ +function candidateDirs(profile) { + return [ + join(dshHome, 'profiles', 'node_modules', '@deepseek-ai'), + join(dshHome, 'profiles', profile, 'node_modules', '@deepseek-ai'), + join(dshHome, 'profiles', 'web', 'node_modules', '@deepseek-ai'), + ] +} + +function resolvePeer(shortName, dirs) { + for (const dir of dirs) { + const target = join(dir, shortName) + if (existsSync(join(target, 'package.json'))) return target + } + return null +} + +function linkOne(shortName, target) { + const dest = join(root, 'node_modules', '@deepseek-ai', shortName) + mkdirSync(dirname(dest), { recursive: true }) + if (existsSync(dest)) { + const st = lstatSync(dest) + if (st.isSymbolicLink() || st.isDirectory()) { + rmSync(dest, { recursive: true, force: true }) + } else { + throw new Error(`refusing to replace non-link path ${dest}`) + } + } + symlinkSync(target, dest, 'junction') + const ver = JSON.parse(readFileSync(join(target, 'package.json'), 'utf8')).version + console.log(`linked @deepseek-ai/${shortName}@${ver} <- ${target}`) +} + +const profile = profileArg() +const dirs = candidateDirs(profile) +console.log(`DSH_HOME=${dshHome} profile=${profile}`) +console.log(`search:\n ${dirs.join('\n ')}`) + +let missing = 0 +for (const name of peers) { + const target = resolvePeer(name, dirs) + if (!target) { + console.error(`missing peer @deepseek-ai/${name}`) + missing++ + continue + } + linkOne(name, target) +} + +if (missing > 0) { + console.error(`\n${missing} peer(s) missing — run Desktop/web once so profiles/node_modules is healed.`) + process.exit(1) +} + +console.log('\nDone. Restart DeepSeek Harness Desktop (or disable/enable dsh-netxops).') diff --git a/scripts/link-dsh-peers.ps1 b/scripts/link-dsh-peers.ps1 index 3ca8dff..88bb975 100644 --- a/scripts/link-dsh-peers.ps1 +++ b/scripts/link-dsh-peers.ps1 @@ -11,13 +11,20 @@ # .\scripts\link-dsh-peers.ps1 -DshHome $env:DSH_HOME param( - [string]$DshHome = $(if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE '.dsh' }) + [string]$DshHome = $(if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE '.dsh' }), + [string]$Profile = 'desktop' ) $ErrorActionPreference = 'Stop' $Root = Split-Path -Parent $PSScriptRoot -$SrcRoot = Join-Path $DshHome 'profiles\node_modules\@deepseek-ai' $DstRoot = Join-Path $Root 'node_modules\@deepseek-ai' +# Shared heal first — profile-local schemastery (Desktop 0.2 = 3.18.4) breaks +# Config({}) when mixed with shared dsh-* 0.1.5-rc.2 (volatile proxies, Host fail). +$SearchRoots = @( + (Join-Path $DshHome 'profiles\node_modules\@deepseek-ai'), + (Join-Path $DshHome "profiles\$Profile\node_modules\@deepseek-ai"), + (Join-Path $DshHome 'profiles\web\node_modules\@deepseek-ai') +) # Runtime imports from src/index.ts (and peers they commonly need nearby). $Peers = @( @@ -25,22 +32,26 @@ $Peers = @( 'cordis', 'dsh-credentials', 'dsh-settings', - 'dsh-mcp-client', - 'dsh-tools' + 'dsh-tools', + 'dsh-llm' ) -if (-not (Test-Path $SrcRoot)) { - throw "DSH installation fallback missing: $SrcRoot`nRun ``dsh web`` once (or any profile) so dsh heals profiles/node_modules." -} - New-Item -ItemType Directory -Force -Path $DstRoot | Out-Null +Write-Host "DSH_HOME=$DshHome profile=$Profile" foreach ($name in $Peers) { - $src = Join-Path $SrcRoot $name - $dst = Join-Path $DstRoot $name - if (-not (Test-Path $src)) { - throw "Peer not found under installation fallback: $src" + $src = $null + foreach ($root in $SearchRoots) { + $candidate = Join-Path $root $name + if (Test-Path (Join-Path $candidate 'package.json')) { + $src = $candidate + break + } } + if (-not $src) { + throw "Peer @deepseek-ai/$name not found under:`n $($SearchRoots -join "`n ")" + } + $dst = Join-Path $DstRoot $name if (Test-Path $dst) { $item = Get-Item $dst -Force if ($item.LinkType -eq 'Junction' -or $item.Attributes -band [IO.FileAttributes]::ReparsePoint) { @@ -50,7 +61,8 @@ foreach ($name in $Peers) { } } New-Item -ItemType Junction -Path $dst -Target $src | Out-Null - Write-Host "linked @deepseek-ai/$name -> $src" + $ver = (Get-Content (Join-Path $src 'package.json') -Raw | ConvertFrom-Json).version + Write-Host "linked @deepseek-ai/$name@$ver <- $src" } -Write-Host "Done. Re-run: dsh web" +Write-Host "Done. Restart DeepSeek Harness Desktop (or disable/enable dsh-netxops)." diff --git a/src/client/index.ts b/src/client/index.ts index 7db3a9a..55397b1 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -6,10 +6,13 @@ * (`configForms` replaced it) and a hard wait kills web boot. * * Surfaces (dual-stack), matching working plugins like dsh-im-ops: - * - Always register `settings.section` once a form scope is available - * (Settings sidebar — what operators look for). - * - On ≥0.1.7 also register `plugins.item` via `configForms.whileServed` - * (Plugins page companion card). + * - Always register `settings.section` in `apply` (Settings sidebar). + * Gate only the form transport on `configForms` / `settingsScope` — + * never gate the nav entry itself (Desktop 0.2 often never serves + * our ns via whileServed, which previously hid the whole page). + * - Soft-attach real form scopes into a deferred memory scope so Save + * still writes the host entry when the transport is ready. + * - On ≥0.1.7 also register `plugins.item` via `configForms.whileServed`. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' @@ -81,6 +84,106 @@ type SettingsScopeLike = { bind?: (spec: { namespace: string }) => SettingsFormScope } +type DeferredSettingsScope = SettingsFormScope & { + attach: (real: SettingsFormScope) => void +} + +/** In-memory form so Settings nav can mount before configForms attaches. */ +function createMemoryScope(initial: NetxopsSettings): SettingsFormScope { + let value: NetxopsSettings = { ...initial } + let revision = 0 + const listeners = new Set<() => void>() + const notify = (): void => { + for (const listener of listeners) listener() + } + return { + getSnapshot: () => ({ + status: 'ready', + value, + writable: true, + revision, + }), + subscribe: (listener) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + set: async (field, next) => { + value = { ...value, [field]: next as never } + revision += 1 + notify() + return true + }, + unset: async (field) => { + const next = { ...value } + delete next[field as keyof NetxopsSettings] + value = next + revision += 1 + notify() + return true + }, + } +} + +/** Swap the live transport without recreating the card controller. */ +function createDeferredScope( + fallback: SettingsFormScope, +): DeferredSettingsScope { + let inner = fallback + const outerListeners = new Set<() => void>() + let innerOff: (() => void) | undefined + const relay = (): void => { + for (const listener of outerListeners) listener() + } + const bindInner = (): void => { + innerOff?.() + innerOff = inner.subscribe(relay) + } + bindInner() + return { + getSnapshot: () => inner.getSnapshot(), + subscribe: (listener) => { + outerListeners.add(listener) + return () => { + outerListeners.delete(listener) + } + }, + set: (field, next) => inner.set(field, next), + unset: (field) => inner.unset(field), + attach: (real) => { + if (inner === real) return + inner = real + bindInner() + relay() + }, + } +} + +const MEMORY_DEFAULTS: NetxopsSettings = { + apiUrl: 'http://127.0.0.1:8890', + lang: 'zh', + thinkingLanguage: 'auto', + replyLanguage: 'follow-user', + nmsProvider: 'zte-ume', + tokenCredentialRef: 'NETX_API_TOKEN', + alarmPushEnabled: false, + alarmDeliverDsh: true, + alarmDeliverIm: false, + imBotId: '', + imTargetId: '', + imTargets: '', + groupOpsInPreset: true, + groupOpsPublic: false, + groupTopologyInPreset: false, + groupTopologyPublic: false, + groupBizMonitorInPreset: false, + groupBizMonitorPublic: false, + kbRoot: '', + groupKbInPreset: true, + groupKbPublic: false, +} + export function apply(ctx: ClientContext): void { ctx.effect(() => { try { @@ -96,46 +199,42 @@ export function apply(ctx: ClientContext): void { }, 'netxops: locales') const t = ctx.locale.bind(LOCALE_NS) as (key: NetxopsLocaleKey) => string - let card: NetxopsCardController | undefined - let wiredExtras = false - let sectionRegistered = false + const deferred = createDeferredScope(createMemoryScope(MEMORY_DEFAULTS)) + const card = new NetxopsCardController(deferred, ctx) let pluginsItemId: string | undefined + let wiredExtras = false - const ensureCard = (scope: SettingsFormScope): NetxopsCardController => { - if (card) return card - card = new NetxopsCardController(scope, ctx) - if (!wiredExtras) { - wiredExtras = true + const ensureExtras = (): void => { + if (wiredExtras) return + wiredExtras = true + // Soft-inject optional remotes only after the section is up. Unknown + // inject keys on some Desktop builds must not take down web boot. + try { wireOptionalServices(ctx, card) + } catch (error) { + ctx.logger?.warn?.('netxops: optional remotes skipped: %s', error) } - return card } - /** Settings sidebar page — same surface im-ops uses (must not wait on whileServed alone). */ - const registerSection = (owner: NetxopsCardController): (() => void) => { - if (sectionRegistered) return () => {} - try { - const off = ctx.slots.inject('settings.section', () => ctx.slots.register({ - name: 'settings.section', - id: NETXOPS_NS, - order: 24, - label: () => t('title'), - locale: LOCALE_NS, - inject: () => owner.inject(), - }, NetxopsCard)) - sectionRegistered = true - return () => { - sectionRegistered = false - off() - } - } catch (error) { - ctx.logger?.warn?.('netxops: settings.section unavailable: %s', error) - return () => {} - } + /** Settings sidebar — same unconditional surface im-ops uses. */ + try { + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: NETXOPS_NS, + order: 24, + label: () => t('title'), + locale: LOCALE_NS, + inject: () => { + ensureExtras() + return card.inject() + }, + }, NetxopsCard)) + } catch (error) { + ctx.logger?.warn?.('netxops: settings.section unavailable: %s', error) } /** Plugins-page companion card (DSH ≥0.1.7 / 0.2.0). */ - const registerPluginsItem = (owner: NetxopsCardController, id: string): (() => void) => { + const registerPluginsItem = (id: string): (() => void) => { if (pluginsItemId === id) return () => {} try { const off = ctx.slots.inject('plugins.item', () => ctx.slots.register({ @@ -144,7 +243,10 @@ export function apply(ctx: ClientContext): void { order: 24, label: () => t('title'), locale: LOCALE_NS, - inject: () => owner.inject(), + inject: () => { + ensureExtras() + return card.inject() + }, }, NetxopsCard)) pluginsItemId = id return () => { @@ -157,19 +259,6 @@ export function apply(ctx: ClientContext): void { } } - const mountUi = ( - owner: NetxopsCardController, - ns: string, - opts: { section: boolean, pluginsItem: boolean }, - ): (() => void) => { - const offs: Array<() => void> = [] - if (opts.section) offs.push(registerSection(owner)) - if (opts.pluginsItem) offs.push(registerPluginsItem(owner, ns)) - return () => { - for (const off of offs) off() - } - } - const tryGetForm = ( forms: ConfigFormsLike, candidates: readonly string[], @@ -186,7 +275,17 @@ export function apply(ctx: ClientContext): void { return undefined } - // DSH ≥0.1.7 / 0.2.0 — configForms. + const attachForm = ( + hit: { ns: string, scope: SettingsFormScope }, + source: string, + ): (() => void) => { + ctx.logger?.info?.('netxops: attach form via %s (%s)', source, hit.ns) + deferred.attach(hit.scope) + ensureExtras() + return registerPluginsItem(hit.ns) + } + + // DSH ≥0.1.7 / 0.2.0 — configForms (persist + plugins.item). ctx.inject(['configForms'], (formsCtx) => { const forms = (formsCtx as { configForms?: ConfigFormsLike }).configForms if (!forms) { @@ -212,43 +311,54 @@ export function apply(ctx: ClientContext): void { formsCtx.logger?.warn?.('netxops: served %s but configForms.get failed', ns) return () => {} } - formsCtx.logger?.info?.('netxops: settings via whileServed (%s)', hit.ns) - const owner = ensureCard(hit.scope) - return mountUi(owner, hit.ns, { section: true, pluginsItem: true }) + return attachForm(hit, 'whileServed') }), 'netxops: configForms whileServed') } - // Eager fallback: do not leave Settings blank if whileServed is late/empty. const immediate = tryGetForm(forms, candidates) if (immediate) { - formsCtx.logger?.info?.('netxops: settings eager mount (%s)', immediate.ns) - const owner = ensureCard(immediate.scope) formsCtx.effect( - () => mountUi(owner, immediate.ns, { section: true, pluginsItem: true }), - 'netxops: configForms eager slots', + () => attachForm(immediate, 'eager'), + 'netxops: configForms eager attach', ) } else if (typeof forms.whileServed !== 'function') { - formsCtx.logger?.warn?.('netxops: configForms present but no get/whileServed match for %s', candidates.join(',')) + formsCtx.logger?.warn?.( + 'netxops: configForms present but no get/whileServed match for %s', + candidates.join(','), + ) } }) - // DSH ≤0.1.5 — settingsScope binder + settings.section only. + // DSH ≤0.1.5 — settingsScope binder. ctx.inject(['settingsScope'], (scopeCtx) => { - if (card) return const binder = (scopeCtx as { settingsScope?: SettingsScopeLike }).settingsScope if (!binder || typeof binder.bind !== 'function') { scopeCtx.logger?.warn?.('netxops: settingsScope present but .bind missing') return } - scopeCtx.logger?.info?.('netxops: settings card via settingsScope') - const owner = ensureCard(binder.bind({ namespace: NETXOPS_NS })) - scopeCtx.effect(() => registerSection(owner), 'netxops: settings.section') + scopeCtx.logger?.info?.('netxops: attach form via settingsScope') + deferred.attach(binder.bind({ namespace: NETXOPS_NS })) + ensureExtras() }) } /** Soft-wire credentials / connection / directoryPicker when present. */ function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController): void { - ctx.inject(['remote.credentials'], (credCtx) => { + const soft = (deps: string[], run: (c: ClientContext) => void, label: string): void => { + try { + ctx.inject(deps, (inner) => { + try { + run(inner) + } catch (error) { + inner.logger?.warn?.('netxops: %s handler failed: %s', label, error) + } + }) + } catch (error) { + ctx.logger?.warn?.('netxops: soft-inject %s skipped: %s', label, error) + } + } + + soft(['remote.credentials'], (credCtx) => { card.setCredentialsAvailable(true) credCtx.effect(() => { const off = credCtx.remote.$on('credentials/reference-updated', (ref) => { @@ -259,19 +369,19 @@ function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController): card.setCredentialsAvailable(false) } }, 'netxops: credential invalidations') - }) + }, 'remote.credentials') - ctx.inject(['connection'], (connCtx) => { + soft(['connection'], (connCtx) => { const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc) if (typeof call !== 'function') { - connCtx.logger.warn('netxops: connection.rpc.call unavailable — alarm status UI disabled') + connCtx.logger?.warn?.('netxops: connection.rpc.call unavailable — alarm status UI disabled') return } card.setAlarmPushRpc(call) connCtx.effect(() => () => { card.setAlarmPushRpc(undefined) }, 'netxops: clear alarm-push rpc') - }) + }, 'connection') const bindDirectoryPicker = (picker: { pick?: (signal?: AbortSignal) => Promise } | undefined): void => { if (!picker || typeof picker.pick !== 'function') return @@ -281,7 +391,7 @@ function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController): (ctx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise } } }) .remote?.directoryPicker, ) - ctx.inject(['remote.directoryPicker'], (dpCtx) => { + soft(['remote.directoryPicker'], (dpCtx) => { const viaGet = typeof (dpCtx as { get?: (name: string) => unknown }).get === 'function' ? (dpCtx as { get: (name: string) => unknown }).get('remote.directoryPicker') as | { pick?: (signal?: AbortSignal) => Promise } @@ -293,5 +403,5 @@ function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController): dpCtx.effect(() => () => { card.setDirectoryPicker(undefined) }, 'netxops: clear directory picker') - }) + }, 'remote.directoryPicker') }