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
58 changes: 58 additions & 0 deletions src/renderer/src/companion-recommend.test.ts
Original file line number Diff line number Diff line change
@@ -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, []);
});
34 changes: 34 additions & 0 deletions src/renderer/src/companion-recommend.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
}
23 changes: 20 additions & 3 deletions src/renderer/src/components/OobeWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -952,7 +961,7 @@ export function OobeWizard() {
{OOBE_STEP_ORDER.map((item, index) => <span className={`oobe-progress-segment ${index <= OOBE_STEP_ORDER.indexOf(step) ? "is-active" : ""}`} key={item} />)}
</div>
<p className="oobe-step-label">第 {step === "source" ? "1" : step === "config" ? "2" : "3"} / 3 步</p>
{step === "plugins" ? <div className="oobe-plugin-heading"><h1>安装课堂联动插件</h1><button className="secondary-button oobe-install-all-button" type="button" disabled={!companionDetectionReady || Boolean(installingId) || busy || allDetectedCompanionsInstalled} onClick={() => void installAllPlugins()}>{!companionDetectionReady ? "检测本机应用中…" : allDetectedCompanionsInstalled ? <><Check aria-hidden="true" size={16} strokeWidth={2.5} />已安装所有</> : "一键安装所有"}</button></div> : <h1>{step === "source" ? "选择模型服务" : "配置模型服务"}</h1>}
{step === "plugins" ? <div className="oobe-plugin-heading"><h1>安装课堂联动插件</h1><button className="secondary-button oobe-install-all-button" type="button" disabled={!companionDetectionReady || Boolean(installingId) || busy || allDetectedCompanionsInstalled || recommended.length === 0} onClick={() => void installAllPlugins()}>{!companionDetectionReady ? "检测本机应用中…" : allDetectedCompanionsInstalled ? <><Check aria-hidden="true" size={16} strokeWidth={2.5} />已安装所有</> : "一键安装所有"}</button></div> : <h1>{step === "source" ? "选择模型服务" : "配置模型服务"}</h1>}
{step !== "plugins" && <p>{step === "source"
? "先选择使用 SECTL 官方模型服务,还是接入自己的模型提供商。"
: step === "config"
Expand Down Expand Up @@ -1026,7 +1035,15 @@ export function OobeWizard() {
<span className="visually-hidden">正在检测本机课堂软件…</span>
</div> : <section className="oobe-plugin-list">
<h2>本机已检测到</h2>
{!apps.some((app) => app.detected) && <p className="empty-list">没有自动检测到已适配的课堂应用。你可以在 ClassIsland 卡片中手动选择安装位置,或稍后在设置里处理。</p>}
{!recommended.length && <div className="oobe-plugin-empty">
<p className="empty-list">没有自动检测到已适配的课堂应用。安装对应应用后会自动出现在这里;若已安装但未被识别,可手动选择其可执行文件。</p>
<div className="oobe-plugin-manual-picks">
<button className="secondary-button" type="button" onClick={() => void pickClassIslandExecutable()}>选择 ClassIsland.exe</button>
<button className="secondary-button" type="button" onClick={() => void pickClassWidgetsExecutable()}>选择 Class Widgets 可执行文件</button>
<button className="secondary-button" type="button" onClick={() => void pickSecRandomExecutable()}>选择 SecRandom 可执行文件</button>
<button className="secondary-button" type="button" onClick={() => void pickIccceExecutable()}>选择 ICC-CE 可执行文件</button>
</div>
</div>}
{recommended.map((app, index) => {
const market = marketPlugins.find((plugin) => plugin.id === app.pluginId);
const installed = plugins.find((plugin) => plugin.id === app.pluginId);
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/styles.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading