From 66a12607ce60a8959943045ddc262dff77f8aba2 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:50:57 +0800 Subject: [PATCH] fix(profile): project IPC inputs to consumed fields --- src-tauri/src/cmd/profile.rs | 93 ++++++++++++++++++++--- src/components/profile/profile-viewer.tsx | 8 +- src/hooks/use-profiles.ts | 5 +- src/services/bindings.ts | 54 ++++++++++++- src/services/cmds.test.ts | 63 +++++++++++++++ src/services/cmds.ts | 41 +++++++--- 6 files changed, 238 insertions(+), 26 deletions(-) create mode 100644 src/services/cmds.test.ts diff --git a/src-tauri/src/cmd/profile.rs b/src-tauri/src/cmd/profile.rs index 0adbe1ce8..c70351059 100644 --- a/src-tauri/src/cmd/profile.rs +++ b/src-tauri/src/cmd/profile.rs @@ -121,7 +121,8 @@ pub async fn reorder_profile(active_id: std::string::String, over_id: std::strin #[tauri::command] #[specta::specta] -pub async fn create_profile(item: PrfItem, file_data: Option) -> CmdResult { +pub async fn create_profile(item: ProfileCreate, file_data: Option) -> CmdResult { + let item: PrfItem = item.into(); let file_data = file_data.map(String::from); match profiles_append_item_with_filedata_safe(&item, file_data).await { Ok(_) => { @@ -129,9 +130,6 @@ pub async fn create_profile(item: PrfItem, file_data: Option Err(coded_error("PROFILE_CREATE_FAILED", err)), @@ -325,10 +323,82 @@ async fn perform_config_update( } } +#[derive(Default, serde::Deserialize, specta::Type)] +#[serde(default)] +pub struct ProfileSelection { + #[specta(type = Option)] + pub current: Option, +} + +#[derive(Default, serde::Deserialize, specta::Type)] +#[serde(default)] +pub struct ProfileCreate { + #[serde(rename = "type")] + #[specta(type = Option)] + pub itype: Option, + #[specta(type = Option)] + pub name: Option, + #[specta(type = Option)] + pub desc: Option, + #[specta(type = Option)] + pub url: Option, + pub option: Option, +} + +impl From for PrfItem { + fn from(item: ProfileCreate) -> Self { + Self { + itype: item.itype, + name: item.name, + desc: item.desc, + url: item.url, + option: item.option, + ..Self::default() + } + } +} + +#[derive(Default, serde::Deserialize, specta::Type)] +#[serde(default)] +pub struct ProfilePatch { + #[serde(rename = "type")] + #[specta(type = Option)] + pub itype: Option, + #[specta(type = Option)] + pub name: Option, + #[specta(type = Option)] + pub desc: Option, + #[specta(type = Option)] + pub file: Option, + #[specta(type = Option)] + pub url: Option, + pub selected: Option>, + pub extra: Option, + pub updated: Option, + pub option: Option, +} + +impl From for PrfItem { + fn from(patch: ProfilePatch) -> Self { + Self { + itype: patch.itype, + name: patch.name, + desc: patch.desc, + file: patch.file, + url: patch.url, + selected: patch.selected, + extra: patch.extra, + updated: patch.updated, + option: patch.option, + ..Self::default() + } + } +} + #[tauri::command] #[tracing::instrument(skip_all, level = "info", fields(target = ?profiles.current))] #[specta::specta] -pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult { +pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult { if CURRENT_SWITCHING_PROFILE .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() @@ -342,7 +412,12 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult CmdResult CmdResult { - let profiles = IProfiles { + let profiles = ProfileSelection { current: Some(profile_index), - items: None, }; patch_profiles_config(profiles).await } #[tauri::command] #[specta::specta] -pub async fn patch_profile(index: std::string::String, profile: PrfItem) -> CmdResult { +pub async fn patch_profile(index: std::string::String, profile: ProfilePatch) -> CmdResult { + let profile: PrfItem = profile.into(); let index: String = index.into(); let profiles = Config::profiles().await; let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index) diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx index e8344960a..6846c0763 100644 --- a/src/components/profile/profile-viewer.tsx +++ b/src/components/profile/profile-viewer.tsx @@ -115,7 +115,13 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { } const name = form.name || `${form.type} file` - const item = { ...form, name, option } + const item = { + type: form.type, + name, + desc: form.desc, + option, + ...(form.type === 'remote' ? { url: form.url } : {}), + } const isRemote = form.type === 'remote' const isUpdate = openType === 'edit' diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index 306013afa..a234b5c3d 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -1,5 +1,6 @@ import { useCallback, useRef } from 'react' +import type { ProfilePatch, ProfileSelection } from '@/services/bindings' import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds' import { mutate } from '@/services/mutate' import { fetchCacheData, setCacheData, useQuery } from '@/services/query-client' @@ -41,7 +42,7 @@ export const useProfiles = () => { }, []) const patchProfiles = useCallback( - async (value: Partial) => { + async (value: ProfileSelection) => { try { const result = await mutate(() => patchProfilesConfig(value), { id: 'patch-profiles-config', @@ -72,7 +73,7 @@ export const useProfiles = () => { ) const patchCurrent = useCallback( - async (value: Partial) => { + async (value: ProfilePatch) => { if (profiles?.current) { const uid = profiles.current await mutate(() => patchProfile(uid, value), { diff --git a/src/services/bindings.ts b/src/services/bindings.ts index c2a7d19d6..7c10f1b72 100644 --- a/src/services/bindings.ts +++ b/src/services/bindings.ts @@ -140,13 +140,13 @@ export const commands = { __TAURI_INVOKE('get_network_interfaces_info'), getProfiles: () => __TAURI_INVOKE('get_profiles'), enhanceProfiles: () => __TAURI_INVOKE('enhance_profiles'), - patchProfilesConfig: (profiles: IProfiles_Deserialize) => + patchProfilesConfig: (profiles: ProfileSelection) => __TAURI_INVOKE('patch_profiles_config', { profiles }), viewProfile: (index: string) => __TAURI_INVOKE('view_profile', { index }), - patchProfile: (index: string, profile: PrfItem_Deserialize) => + patchProfile: (index: string, profile: ProfilePatch_Deserialize) => __TAURI_INVOKE('patch_profile', { index, profile }), - createProfile: (item: PrfItem_Deserialize, fileData: string | null) => + createProfile: (item: ProfileCreate_Deserialize, fileData: string | null) => __TAURI_INVOKE('create_profile', { item, fileData }), importProfile: ( url: string, @@ -733,6 +733,24 @@ export type PrfSelected = { now?: string | null } +export type ProfileCreate = ProfileCreate_Serialize | ProfileCreate_Deserialize + +export type ProfileCreate_Deserialize = { + type?: string | null + name?: string | null + desc?: string | null + url?: string | null + option?: PrfOption_Deserialize | null +} + +export type ProfileCreate_Serialize = { + type: string | null + name: string | null + desc: string | null + url: string | null + option: PrfOption_Serialize | null +} + export type ProfileDnsSettings = | ProfileDnsSettings_Serialize | ProfileDnsSettings_Deserialize @@ -747,6 +765,36 @@ export type ProfileDnsSettings_Serialize = { confirmation?: string | null } +export type ProfilePatch = ProfilePatch_Serialize | ProfilePatch_Deserialize + +export type ProfilePatch_Deserialize = { + type?: string | null + name?: string | null + desc?: string | null + file?: string | null + url?: string | null + selected?: PrfSelected[] | null + extra?: PrfExtra | null + updated?: number | null + option?: PrfOption_Deserialize | null +} + +export type ProfilePatch_Serialize = { + type: string | null + name: string | null + desc: string | null + file: string | null + url: string | null + selected: PrfSelected[] | null + extra: PrfExtra | null + updated: number | null + option: PrfOption_Serialize | null +} + +export type ProfileSelection = { + current?: string | null +} + export type ProfileUpdatePayload = { uid: string } diff --git a/src/services/cmds.test.ts b/src/services/cmds.test.ts new file mode 100644 index 000000000..32cf2b85a --- /dev/null +++ b/src/services/cmds.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { commands } from './bindings' +import { createProfile, patchProfile, patchProfilesConfig } from './cmds' + +vi.mock('./bindings', () => ({ + commands: { + createProfile: vi.fn(), + patchProfile: vi.fn(), + patchProfilesConfig: vi.fn(), + }, +})) + +const wirePayload = (value: unknown) => JSON.parse(JSON.stringify(value)) + +beforeEach(() => vi.clearAllMocks()) + +describe('profile IPC projections', () => { + it('sends only current for profile selection even with a full snapshot', async () => { + const snapshot = { current: 'profile', items: [{ uid: 'profile' }] } + await patchProfilesConfig(snapshot) + expect(commands.patchProfilesConfig).toHaveBeenCalledWith({ + current: 'profile', + }) + }) + + it('strips profile output fields while retaining explicit selections and options', async () => { + const selected = [{ name: 'group', now: 'node' }] + const option = { + script: 'script', + merge: 'merge', + rules: 'rules', + proxies: 'proxies', + groups: 'groups', + } + const profile = { + uid: 'profile', + home: 'https://example.com', + file_data: 'unused', + name: 'renamed', + selected, + option, + } + await patchProfile('profile', profile) + expect( + wirePayload(vi.mocked(commands.patchProfile).mock.calls[0][1]), + ).toEqual({ name: 'renamed', selected, option }) + }) + + it('omits ignored creation fields and a local profile URL', async () => { + const profile = { + type: 'local', + name: 'local', + url: '', + uid: 'old', + selected: [{ name: 'group', now: 'node' }], + } + await createProfile(profile, 'content') + const [payload, content] = vi.mocked(commands.createProfile).mock.calls[0] + expect(wirePayload(payload)).toEqual({ type: 'local', name: 'local' }) + expect(content).toBe('content') + }) +}) diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 7a5d97dca..df37644ab 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -12,8 +12,9 @@ import type { ProxyPortSettings, UnlockItem, ProxyViewV1_Serialize as ProxyViewV1, - IProfiles_Deserialize, - PrfItem_Deserialize, + ProfileSelection, + ProfilePatch, + ProfileCreate, } from './bindings' export type { @@ -41,15 +42,24 @@ export async function enhanceProfiles() { return (await commands.enhanceProfiles()).status === 'valid' } -export async function patchProfilesConfig(profiles: IProfiles_Deserialize) { - return commands.patchProfilesConfig(profiles) +export async function patchProfilesConfig(profiles: ProfileSelection) { + return commands.patchProfilesConfig({ current: profiles.current }) } export async function createProfile( - item: PrfItem_Deserialize, + item: ProfileCreate, fileData?: string | null, ) { - return commands.createProfile(item, fileData ?? null) + return commands.createProfile( + { + type: item.type, + name: item.name, + desc: item.desc, + ...(item.type === 'remote' ? { url: item.url } : {}), + option: item.option, + }, + fileData ?? null, + ) } export async function viewProfile(index: string) { @@ -80,11 +90,20 @@ export async function deleteProfile(index: string) { return commands.deleteProfile(index) } -export async function patchProfile( - index: string, - profile: PrfItem_Deserialize, -) { - return commands.patchProfile(index, profile) +export async function patchProfile(index: string, profile: ProfilePatch) { + const { type, name, desc, file, url, selected, extra, updated, option } = + profile + return commands.patchProfile(index, { + type, + name, + desc, + file, + url, + selected, + extra, + updated, + option, + }) } export async function getClashInfo() {