Skip to content

Commit 423a924

Browse files
refactor(core): extract buildCoreConfig helper (#44)
2 parents 948f58f + 042437c commit 423a924

6 files changed

Lines changed: 176 additions & 24 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from "vitest";
2+
import { EMPTY_SETTINGS } from "../../store/defaults";
3+
import { activeConfigChanged, buildCoreConfig } from "../core-config";
4+
import { emptyProfile, type Profile } from "../schema";
5+
6+
function vless(overrides: Partial<Extract<Profile, { protocol: "vless" }>>): Profile {
7+
return { ...(emptyProfile("vless") as Extract<Profile, { protocol: "vless" }>), ...overrides };
8+
}
9+
10+
describe("buildCoreConfig / activeConfigChanged", () => {
11+
it("reports no change when only volatile fields (id) differ", () => {
12+
const base = { remarks: "Node", address: "ex.com", port: 443, uuid: "u-1" } as const;
13+
const a = buildCoreConfig(vless({ id: "p1", ...base }), EMPTY_SETTINGS, [], []);
14+
const b = buildCoreConfig(vless({ id: "p2", ...base }), EMPTY_SETTINGS, [], []);
15+
expect(activeConfigChanged(a, b)).toBe(false);
16+
});
17+
18+
it("reports a change when the port differs", () => {
19+
const base = { remarks: "Node", address: "ex.com", uuid: "u-1" } as const;
20+
const a = buildCoreConfig(vless({ id: "p1", port: 443, ...base }), EMPTY_SETTINGS, [], []);
21+
const b = buildCoreConfig(vless({ id: "p2", port: 8443, ...base }), EMPTY_SETTINGS, [], []);
22+
expect(activeConfigChanged(a, b)).toBe(true);
23+
});
24+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// ============================================================
2+
// src/lib/core-config.ts
3+
// Resolve the core engine for a profile and build the exact config
4+
// JSON that `kasumi-proxyctl start` would receive. Shared by the
5+
// bridge (to launch the core) and the store (to decide whether an
6+
// active profile actually needs a restart after a subscription
7+
// update — see activeConfigChanged).
8+
// ============================================================
9+
10+
import {
11+
type AdvancedSettings,
12+
type CoreEngineT,
13+
type Profile,
14+
type RoutingRule,
15+
resolveCore,
16+
} from "./schema";
17+
import { buildSingboxConfigJSON } from "./singbox-config";
18+
import { buildXrayConfigJSON } from "./xray-config";
19+
20+
export interface CoreConfig {
21+
engine: CoreEngineT;
22+
config: string;
23+
}
24+
25+
/** Engine + config JSON for a profile, mirroring what the core is launched with. */
26+
export function buildCoreConfig(
27+
profile: Profile,
28+
settings: AdvancedSettings,
29+
routingRules: RoutingRule[],
30+
profiles: Profile[],
31+
): CoreConfig {
32+
const engine = resolveCore(profile, settings);
33+
const config =
34+
engine === "sing-box"
35+
? buildSingboxConfigJSON(profile, settings, routingRules, profiles)
36+
: buildXrayConfigJSON(profile, settings, routingRules, profiles);
37+
return { engine, config };
38+
}
39+
40+
/** True when two resolved core configs differ — i.e. the core must be restarted. */
41+
export function activeConfigChanged(prev: CoreConfig, next: CoreConfig): boolean {
42+
return prev.engine !== next.engine || prev.config !== next.config;
43+
}

control-center/src/lib/ksu-bridge.ts

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -271,22 +271,13 @@ export const ksuBridge: Bridge = {
271271
const state = lastState ?? (await this.readState());
272272
const profile = state.profiles.find((p) => p.id === profileId);
273273
if (!profile) throw new Error(`Profile not found: ${profileId}`);
274-
const { resolveCore } = await import("./schema/core");
275-
const engine = resolveCore(profile, state.settings);
276-
const config =
277-
engine === "sing-box"
278-
? (await import("./singbox-config")).buildSingboxConfigJSON(
279-
profile,
280-
state.settings,
281-
state.routingRules ?? [],
282-
state.profiles,
283-
)
284-
: (await import("./xray-config")).buildXrayConfigJSON(
285-
profile,
286-
state.settings,
287-
state.routingRules ?? [],
288-
state.profiles,
289-
);
274+
const { buildCoreConfig } = await import("./core-config");
275+
const { engine, config } = buildCoreConfig(
276+
profile,
277+
state.settings,
278+
state.routingRules ?? [],
279+
state.profiles,
280+
);
290281
const socksPort = String(state.settings.localSocksPort ?? 10808);
291282
return acceptServiceState(
292283
"start",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { AppState } from "../../lib/bridge";
3+
import { emptyProfile, type Profile } from "../../lib/schema";
4+
import {
5+
mapFetchedSubscriptionProfiles,
6+
nextActiveIdAfterSubscriptionUpdate,
7+
} from "../state-mutations";
8+
9+
function vless(overrides: Partial<Extract<Profile, { protocol: "vless" }>>): Profile {
10+
return { ...(emptyProfile("vless") as Extract<Profile, { protocol: "vless" }>), ...overrides };
11+
}
12+
13+
function stateWith(profiles: Profile[], activeId: string | null): AppState {
14+
return { profiles, activeId } as AppState;
15+
}
16+
17+
const sub = { id: "s1", groupId: undefined } as never;
18+
19+
describe("nextActiveIdAfterSubscriptionUpdate", () => {
20+
it("keeps the active id when the active profile is not from this subscription", () => {
21+
const active = vless({ id: "p1", subId: "other" });
22+
const fresh = mapFetchedSubscriptionProfiles([vless({ remarks: "X" })], sub, null);
23+
expect(nextActiveIdAfterSubscriptionUpdate(stateWith([active], "p1"), "s1", fresh)).toBe("p1");
24+
});
25+
26+
it("matches the re-created active profile by exact identity", () => {
27+
const active = vless({ id: "p1", subId: "s1", remarks: "Node", address: "ex.com", port: 443 });
28+
const fresh = mapFetchedSubscriptionProfiles(
29+
[vless({ remarks: "Node", address: "ex.com", port: 443 })],
30+
sub,
31+
null,
32+
);
33+
const next = nextActiveIdAfterSubscriptionUpdate(stateWith([active], "p1"), "s1", fresh);
34+
expect(next).toBe(fresh[0].id);
35+
});
36+
37+
it("falls back to the same-name profile when the endpoint (port) changed", () => {
38+
const active = vless({ id: "p1", subId: "s1", remarks: "Node", address: "ex.com", port: 443 });
39+
const fresh = mapFetchedSubscriptionProfiles(
40+
[vless({ remarks: "Node", address: "ex.com", port: 8443 })],
41+
sub,
42+
null,
43+
);
44+
const next = nextActiveIdAfterSubscriptionUpdate(stateWith([active], "p1"), "s1", fresh);
45+
expect(next).toBe(fresh[0].id);
46+
});
47+
48+
it("returns null when the active profile no longer exists in the update", () => {
49+
const active = vless({ id: "p1", subId: "s1", remarks: "Gone" });
50+
const fresh = mapFetchedSubscriptionProfiles([vless({ remarks: "Other" })], sub, null);
51+
expect(nextActiveIdAfterSubscriptionUpdate(stateWith([active], "p1"), "s1", fresh)).toBeNull();
52+
});
53+
});

control-center/src/store/state-mutations.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,17 @@ export function nextActiveIdAfterSubscriptionUpdate(
114114
const activeProfile = current.profiles.find((profile) => profile.id === current.activeId);
115115
const activeAffected = activeProfile?.subId === subId;
116116
if (!activeAffected) return current.activeId;
117-
return freshMapped.find((profile) => sameProfileIdentity(profile, activeProfile))?.id ?? null;
117+
// Prefer an exact identity match; otherwise fall back to the profile with the
118+
// same name (remarks). The fallback lets the active selection "follow" an
119+
// endpoint change (e.g. the server port changed) instead of being treated as
120+
// removed — the caller then diffs the rebuilt config to decide on a restart.
121+
const exact = freshMapped.find((profile) => sameProfileIdentity(profile, activeProfile));
122+
if (exact) return exact.id;
123+
const byName = freshMapped.find(
124+
(profile) =>
125+
profile.protocol === activeProfile.protocol && profile.remarks === activeProfile.remarks,
126+
);
127+
return byName?.id ?? null;
118128
}
119129

120130
export function mergeBackupState(

control-center/src/store/useAppStore.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,29 @@ export const useAppStore = create<Store>((set, get) => {
160160

161161
set({ service, uploadRate, downloadRate });
162162
};
163+
// A subscription update re-creates the active profile under a new id. Rebuild
164+
// the core config for the old vs the new active profile and report whether
165+
// they actually differ — callers restart the core only when this is true, so
166+
// an unchanged re-fetch (or a no-op wake from the daemon) doesn't churn the
167+
// connection. `prev` is the pre-update snapshot; the new state is read live.
168+
const activeProfileConfigChanged = async (
169+
prev: Store,
170+
nextActiveId: string,
171+
): Promise<boolean> => {
172+
const oldActive = prev.profiles.find((p) => p.id === prev.activeId);
173+
const next = get();
174+
const newActive = next.profiles.find((p) => p.id === nextActiveId);
175+
if (!oldActive || !newActive) return true; // can't compare → restart to be safe
176+
try {
177+
const { buildCoreConfig, activeConfigChanged } = await import("../lib/core-config");
178+
return activeConfigChanged(
179+
buildCoreConfig(oldActive, prev.settings, prev.routingRules, prev.profiles),
180+
buildCoreConfig(newActive, next.settings, next.routingRules, next.profiles),
181+
);
182+
} catch {
183+
return true; // config build failed → don't risk leaving a stale config
184+
}
185+
};
163186
const consumeSubCacheImpl = async () => {
164187
let cached: Awaited<ReturnType<typeof bridge.listSubCache>>;
165188
try {
@@ -201,12 +224,16 @@ export const useAppStore = create<Store>((set, get) => {
201224
}));
202225
await get().flush();
203226
if (activeAffected && current.service.state === "running") {
204-
set({ busy: true });
205-
try {
206-
syncService(nextActiveId ? await bridge.start(nextActiveId) : await bridge.stop());
207-
} finally {
208-
set({ busy: false });
209-
await get().refreshStatus();
227+
const needsRestart =
228+
!nextActiveId || (await activeProfileConfigChanged(current, nextActiveId));
229+
if (needsRestart) {
230+
set({ busy: true });
231+
try {
232+
syncService(nextActiveId ? await bridge.start(nextActiveId) : await bridge.stop());
233+
} finally {
234+
set({ busy: false });
235+
await get().refreshStatus();
236+
}
210237
}
211238
}
212239
if (get().settings.dedupOnUpdate) {
@@ -800,7 +827,11 @@ export const useAppStore = create<Store>((set, get) => {
800827
}));
801828
await get().flush();
802829

803-
if (activeAffected && current.service.state === "running") {
830+
if (
831+
activeAffected &&
832+
current.service.state === "running" &&
833+
(!nextActiveId || (await activeProfileConfigChanged(current, nextActiveId)))
834+
) {
804835
set({ busy: true });
805836
try {
806837
if (nextActiveId) {

0 commit comments

Comments
 (0)