diff --git a/src/renderer/src/companion-recommend.test.ts b/src/renderer/src/companion-recommend.test.ts new file mode 100644 index 0000000..4f1d263 --- /dev/null +++ b/src/renderer/src/companion-recommend.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { filterRecommendedCompanionApps } from "./companion-recommend.js"; + +function app(pluginId: string, detected = false): DetectedCompanionApp { + return { pluginId, appName: pluginId, description: "", icon: "", detected }; +} + +function plugin(id: string): PluginStatus { + return { id, name: id, version: "0.1.0", enabled: true, state: "ready", settingsPages: [] }; +} + +const ALL_APPS: DetectedCompanionApp[] = [ + app("classisland-connector"), + app("class-widgets"), + app("secrandom"), + app("iccce-connector"), + app("secscore-connector") +]; + +const emptyInput = { + plugins: [] as PluginStatus[], + classIslandTargets: [] as ClassIslandInstallCandidate[], + secRandomTargets: [] as SecRandomInstallCandidate[], + iccceTargets: [] as IccceInstallCandidate[], + cwTargets: [] as ClassWidgetsInstallCandidate[] +}; + +test("shows no cards on a fresh machine without any companion app installed", () => { + const recommended = filterRecommendedCompanionApps(ALL_APPS, emptyInput); + assert.deepEqual(recommended, []); +}); + +test("shows a card when the app was auto-detected", () => { + const detected = ALL_APPS.map((item) => item.pluginId === "class-widgets" ? { ...item, detected: true } : item); + const recommended = filterRecommendedCompanionApps(detected, emptyInput); + assert.deepEqual(recommended.map((item) => item.pluginId), ["class-widgets"]); +}); + +test("shows a card when its SecAgent connector plugin is already installed", () => { + const recommended = filterRecommendedCompanionApps(ALL_APPS, { ...emptyInput, plugins: [plugin("class-widgets")] }); + assert.deepEqual(recommended.map((item) => item.pluginId), ["class-widgets"]); +}); + +test("shows a card when a manual installation target was picked", () => { + const recommended = filterRecommendedCompanionApps(ALL_APPS, { ...emptyInput, cwTargets: [{} as ClassWidgetsInstallCandidate] }); + assert.deepEqual(recommended.map((item) => item.pluginId), ["class-widgets"]); +}); + +test("each linkage app is gated by its own target list", () => { + const recommended = filterRecommendedCompanionApps(ALL_APPS, { ...emptyInput, classIslandTargets: [{} as ClassIslandInstallCandidate] }); + assert.deepEqual(recommended.map((item) => item.pluginId), ["classisland-connector"]); +}); + +test("single-end apps without detection are never force-listed", () => { + const recommended = filterRecommendedCompanionApps([app("secscore-connector")], emptyInput); + assert.deepEqual(recommended, []); +}); diff --git a/src/renderer/src/companion-recommend.ts b/src/renderer/src/companion-recommend.ts new file mode 100644 index 0000000..03b41e6 --- /dev/null +++ b/src/renderer/src/companion-recommend.ts @@ -0,0 +1,34 @@ +export interface CompanionRecommendationInput { + plugins: PluginStatus[]; + classIslandTargets: ClassIslandInstallCandidate[]; + secRandomTargets: SecRandomInstallCandidate[]; + iccceTargets: IccceInstallCandidate[]; + cwTargets: ClassWidgetsInstallCandidate[]; +} + +// Which dual-end companion apps deserve a card on the OOBE plugins page. +// A card must be backed by real evidence — otherwise a machine that never had +// the companion app installed would still be offered its linkage card (the +// bug: Class Widgets shown without Class Widgets installed). The app qualifies +// when: +// 1. auto-detection found it (`detected`), or +// 2. its SecAgent-side connector is already installed (the user has started +// configuring this linkage), or +// 3. an installation target was found or manually picked (the user selected +// an executable via the file dialog), which covers non-standard installs. +export function filterRecommendedCompanionApps( + apps: DetectedCompanionApp[], + input: CompanionRecommendationInput +): DetectedCompanionApp[] { + return apps.filter((app) => { + if (app.detected) return true; + if (input.plugins.some((plugin) => plugin.id === app.pluginId)) return true; + switch (app.pluginId) { + case "classisland-connector": return input.classIslandTargets.length > 0; + case "secrandom": return input.secRandomTargets.length > 0; + case "iccce-connector": return input.iccceTargets.length > 0; + case "class-widgets": return input.cwTargets.length > 0; + default: return false; + } + }); +} diff --git a/src/renderer/src/components/OobeWizard.tsx b/src/renderer/src/components/OobeWizard.tsx index fbdeb4b..e3ae21f 100644 --- a/src/renderer/src/components/OobeWizard.tsx +++ b/src/renderer/src/components/OobeWizard.tsx @@ -3,6 +3,7 @@ import { ArrowRight, Check, ChevronDown, ChevronRight } from "lucide-react"; import { PresetCombobox } from "./PresetCombobox.js"; import { SelectCombobox } from "./SelectCombobox.js"; import { emptyProvider } from "../utils.js"; +import { filterRecommendedCompanionApps } from "../companion-recommend.js"; type SourcePath = "official" | "custom"; type OobeStep = "source" | "config" | "plugins"; @@ -907,7 +908,15 @@ export function OobeWizard() { } }; - const recommended = useMemo(() => apps.filter((app) => app.detected || app.pluginId === "classisland-connector" || app.pluginId === "secrandom" || app.pluginId === "iccce-connector" || app.pluginId === "class-widgets"), [apps]); + // A dual-end card is only shown when there is real evidence for it: the app + // was auto-detected, its SecAgent connector is already installed, or an + // installation target was found/manually picked. Previously the four linkage + // apps were always listed, so e.g. Class Widgets appeared as "detected" even + // when it was never installed on the machine. + const recommended = useMemo( + () => filterRecommendedCompanionApps(apps, { plugins, classIslandTargets, secRandomTargets, iccceTargets, cwTargets }), + [apps, classIslandTargets, cwTargets, iccceTargets, plugins, secRandomTargets] + ); const allDetectedCompanionsInstalled = useMemo(() => { const detectedApps = apps.filter((app) => app.detected); if (!detectedApps.length) return false; @@ -952,7 +961,7 @@ export function OobeWizard() { {OOBE_STEP_ORDER.map((item, index) => )}

第 {step === "source" ? "1" : step === "config" ? "2" : "3"} / 3 步

- {step === "plugins" ?

安装课堂联动插件

:

{step === "source" ? "选择模型服务" : "配置模型服务"}

} + {step === "plugins" ?

安装课堂联动插件

:

{step === "source" ? "选择模型服务" : "配置模型服务"}

} {step !== "plugins" &&

{step === "source" ? "先选择使用 SECTL 官方模型服务,还是接入自己的模型提供商。" : step === "config" @@ -1026,7 +1035,15 @@ export function OobeWizard() { 正在检测本机课堂软件… :

本机已检测到

- {!apps.some((app) => app.detected) &&

没有自动检测到已适配的课堂应用。你可以在 ClassIsland 卡片中手动选择安装位置,或稍后在设置里处理。

} + {!recommended.length &&
+

没有自动检测到已适配的课堂应用。安装对应应用后会自动出现在这里;若已安装但未被识别,可手动选择其可执行文件。

+
+ + + + +
+
} {recommended.map((app, index) => { const market = marketPlugins.find((plugin) => plugin.id === app.pluginId); const installed = plugins.find((plugin) => plugin.id === app.pluginId); diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 8013280..e1c1f1d 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -424,6 +424,10 @@ html.wake-mode, html.wake-mode body, body.wake-mode { min-width: 0; overflow: hi @keyframes oobe-plugin-detection-spin { to { transform: rotate(360deg); } } .oobe-plugin-list { max-width: 760px; margin: 0 auto 22px; } .oobe-plugin-list h2 { margin: 0 0 10px; font-size: 15px; } +.oobe-plugin-empty { display: grid; gap: 10px; } +.oobe-plugin-empty .empty-list { margin: 0; } +.oobe-plugin-manual-picks { display: flex; flex-wrap: wrap; gap: 8px; } +.oobe-plugin-manual-picks .secondary-button { padding: 7px 10px; font-size: 12px; white-space: nowrap; } @property --oobe-plugin-progress { syntax: ""; inherits: false; initial-value: 0%; } .oobe-plugin-card { --oobe-plugin-progress: 0%; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; position: relative; overflow: hidden; border-radius: 0; box-shadow: none; background: linear-gradient(90deg, rgba(35,137,236,.15) 0 var(--oobe-plugin-progress), #fff var(--oobe-plugin-progress) 100%); transition: --oobe-plugin-progress 420ms cubic-bezier(.2, .8, .2, 1), background 420ms cubic-bezier(.2, .8, .2, 1); } .oobe-plugin-card > * { position: relative; z-index: 1; }