Skip to content

Commit fc4e9ce

Browse files
committed
fix(sync): 修复云同步密钥切换并发布 1.2.5-rc.202610012156
1 parent 8da6f94 commit fc4e9ce

9 files changed

Lines changed: 363 additions & 37 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "frkb",
3-
"version": "1.2.5-rc.202610011947",
3+
"version": "1.2.5-rc.202610012156",
44
"packageManager": "pnpm@9.15.9",
55
"description": "Track Studio — Rapid Audio Organization Tool",
66
"license": "PolyForm-Noncommercial-1.0.0",

‎src/main/cloudSync.ts‎

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@ import {
1414
import { resolveBaseUrl } from './serverDiscovery'
1515
import type { CloudSyncTrigger } from '../types/cloudSync'
1616
import { resolveDevCloudSyncUserKey } from '../shared/cloudSyncDevUserKey'
17-
import { isCuratedLibrarySyncEnabled, saveLibrarySettingsFromConfig } from './librarySettingsDb'
17+
import {
18+
forgetCuratedLibrarySyncJoinState,
19+
isCuratedLibrarySyncEnabled,
20+
saveLibrarySettingsFromConfig
21+
} from './librarySettingsDb'
1822
import { bindCloudSyncScheduler, restartCloudSyncScheduler } from './cloudSyncScheduler'
1923
import { syncCuratedLibraryLiveSync } from './curatedLibrarySync/liveSync'
2024
import { enqueueCloudWork } from './curatedLibrarySync/queue'
25+
import { clearPendingCuratedLibraryJoinPrompt } from './curatedLibrarySync/joinPrompt'
2126

2227
const CLOUD_SYNC = {
2328
PREFIX: '/frkbapi/v1/fingerprint-sync',
@@ -240,36 +245,42 @@ ipcMain.handle('cloudSync/resetUserData', async (_e, payload: { notes?: string }
240245
}
241246
})
242247

243-
ipcMain.handle('cloudSync/config/save', async (_e, payload: { userKey: string }) => {
244-
const userKey = (payload?.userKey || '').trim()
245-
try {
246-
const currentKey = String(store.settingConfig?.cloudSyncUserKey || '').trim()
247-
if (isCuratedLibrarySyncEnabled() && currentKey && currentKey !== userKey) {
248-
return { success: false, message: 'cloudSync.curatedLibrary.errors.cannotChangeUserKey' }
249-
}
250-
const baseUrl = await resolveBaseUrl()
251-
const json = await validateUserKeyRequest(userKey, baseUrl)
252-
if (json?.success === true && json?.data?.isActive === true) {
253-
cloudSyncConfig.userKey = json?.data?.userKey || userKey
254-
store.settingConfig.cloudSyncUserKey = cloudSyncConfig.userKey
255-
await persistSettingConfig()
256-
await saveLibrarySettingsFromConfig()
257-
restartCloudSyncScheduler({ immediate: true })
258-
syncCuratedLibraryLiveSync()
259-
return { success: true }
260-
}
261-
const error = String(json?.error || '').toUpperCase()
262-
if (error === 'INVALID_USER_KEY' || error === 'USER_KEY_NOT_FOUND') {
263-
return { success: false, message: 'cloudSync.errors.keyInvalid' }
264-
}
265-
if (error === 'USER_KEY_INACTIVE' || json?.data?.isActive === false) {
266-
return { success: false, message: 'cloudSync.errors.keyDisabled' }
248+
ipcMain.handle('cloudSync/config/save', (_e, payload: { userKey: string }) =>
249+
enqueueCloudWork(async () => {
250+
const userKey = (payload?.userKey || '').trim()
251+
try {
252+
const currentKey = String(store.settingConfig?.cloudSyncUserKey || '').trim()
253+
if (isCuratedLibrarySyncEnabled() && currentKey && currentKey !== userKey) {
254+
return { success: false, message: 'cloudSync.curatedLibrary.errors.cannotChangeUserKey' }
255+
}
256+
const baseUrl = await resolveBaseUrl()
257+
const json = await validateUserKeyRequest(userKey, baseUrl)
258+
if (json?.success === true && json?.data?.isActive === true) {
259+
cloudSyncConfig.userKey = json?.data?.userKey || userKey
260+
store.settingConfig.cloudSyncUserKey = cloudSyncConfig.userKey
261+
await persistSettingConfig()
262+
await saveLibrarySettingsFromConfig()
263+
if (currentKey !== cloudSyncConfig.userKey) {
264+
forgetCuratedLibrarySyncJoinState()
265+
clearPendingCuratedLibraryJoinPrompt()
266+
}
267+
restartCloudSyncScheduler({ immediate: true })
268+
syncCuratedLibraryLiveSync()
269+
return { success: true, userKey: cloudSyncConfig.userKey }
270+
}
271+
const error = String(json?.error || '').toUpperCase()
272+
if (error === 'INVALID_USER_KEY' || error === 'USER_KEY_NOT_FOUND') {
273+
return { success: false, message: 'cloudSync.errors.keyInvalid' }
274+
}
275+
if (error === 'USER_KEY_INACTIVE' || json?.data?.isActive === false) {
276+
return { success: false, message: 'cloudSync.errors.keyDisabled' }
277+
}
278+
return { success: false, message: 'cloudSync.errors.cannotConnect' }
279+
} catch (_err) {
280+
return { success: false, message: 'cloudSync.errors.cannotConnect' }
267281
}
268-
return { success: false, message: 'cloudSync.errors.cannotConnect' }
269-
} catch (_err) {
270-
return { success: false, message: 'cloudSync.errors.cannotConnect' }
271-
}
272-
})
282+
})
283+
)
273284

274285
ipcMain.handle('cloudSync/testConnectivity', async (_e, payload: { userKey: string }) => {
275286
try {
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Run: pnpm exec vitest run src/main/ipc/cloudSyncUserKey.spec.ts
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import type { ISettingConfig } from '../../types/globals'
4+
5+
const mocks = vi.hoisted(() => ({
6+
handlers: new Map<string, (event: unknown, payload?: unknown) => unknown>(),
7+
store: { settingConfig: {} as Partial<ISettingConfig>, songFingerprintList: [] },
8+
is: { dev: false },
9+
validate: vi.fn(),
10+
persist: vi.fn(),
11+
saveLibrary: vi.fn(),
12+
forgetJoin: vi.fn(),
13+
clearPrompt: vi.fn(),
14+
restartScheduler: vi.fn(),
15+
liveSync: vi.fn(),
16+
syncTick: vi.fn()
17+
}))
18+
19+
vi.mock('electron', () => ({
20+
ipcMain: {
21+
handle: (channel: string, handler: (event: unknown, payload?: unknown) => unknown) => {
22+
mocks.handlers.set(channel, handler)
23+
}
24+
},
25+
BrowserWindow: { getAllWindows: () => [] }
26+
}))
27+
vi.mock('@electron-toolkit/utils', () => ({ is: mocks.is }))
28+
vi.mock('../store', () => ({ default: mocks.store }))
29+
vi.mock('../settingsPersistence', () => ({ persistSettingConfig: mocks.persist }))
30+
vi.mock('../librarySettingsDb', () => ({
31+
isCuratedLibrarySyncEnabled: () => mocks.store.settingConfig.curatedLibrarySyncEnabled === true,
32+
saveLibrarySettingsFromConfig: mocks.saveLibrary,
33+
forgetCuratedLibrarySyncJoinState: mocks.forgetJoin
34+
}))
35+
vi.mock('../curatedLibrarySync/joinPrompt', () => ({
36+
clearPendingCuratedLibraryJoinPrompt: mocks.clearPrompt
37+
}))
38+
vi.mock('../fetchWithSystemProxy', () => ({ fetchWithSystemProxy: mocks.validate }))
39+
vi.mock('../serverDiscovery', () => ({ resolveBaseUrl: async () => 'https://sync.test' }))
40+
vi.mock('../fingerprintStore', () => ({
41+
getCollectionHashForSync: vi.fn(),
42+
unionFingerprintList: vi.fn()
43+
}))
44+
vi.mock('../log', () => ({ log: { error: vi.fn() } }))
45+
vi.mock('../curatedArtistCloudSync', () => ({
46+
getCuratedArtistSyncErrorPayload: vi.fn(),
47+
isCuratedArtistSyncUnsupportedServer: vi.fn(),
48+
syncCuratedArtistCloudSnapshot: vi.fn()
49+
}))
50+
vi.mock('../cloudSyncScheduler', () => ({
51+
bindCloudSyncScheduler: vi.fn(),
52+
restartCloudSyncScheduler: mocks.restartScheduler,
53+
runCuratedLibrarySyncTick: mocks.syncTick
54+
}))
55+
vi.mock('../curatedLibrarySync/liveSync', () => ({ syncCuratedLibraryLiveSync: mocks.liveSync }))
56+
vi.mock('../curatedLibrarySync/queue', () => {
57+
let queued: Promise<unknown> = Promise.resolve()
58+
return {
59+
enqueueCloudWork: <T>(task: () => Promise<T>): Promise<T> => {
60+
const run = queued.then(task, task)
61+
queued = run.catch(() => undefined)
62+
return run
63+
}
64+
}
65+
})
66+
vi.mock('../window/mainWindow', () => ({
67+
default: { instance: null },
68+
syncWindowScreenshotShortcut: vi.fn()
69+
}))
70+
vi.mock('../bootstrap/settings', () => ({
71+
applyThemeFromSettings: vi.fn(),
72+
broadcastSystemThemeIfNeeded: vi.fn()
73+
}))
74+
vi.mock('../platform/windowsContextMenu', () => ({
75+
clearWindowsContextMenuSignature: vi.fn(),
76+
ensureWindowsContextMenuIfNeeded: vi.fn(),
77+
removeWindowsContextMenu: vi.fn()
78+
}))
79+
vi.mock('../menu/macMenu', () => ({ rebuildMacMenusForCurrentFocus: vi.fn() }))
80+
vi.mock('../curatedArtistLibrary', () => ({
81+
clearCuratedArtistLibrary: vi.fn(),
82+
getCuratedArtistLibrarySnapshot: vi.fn(),
83+
importCuratedArtistsFromTracks: vi.fn(),
84+
replaceCuratedArtistLibrary: vi.fn(),
85+
removeCuratedArtist: vi.fn()
86+
}))
87+
vi.mock('../services/libraryMerge/runtime', () => ({ assertLibraryMergeMutationAllowed: vi.fn() }))
88+
89+
import '../cloudSync'
90+
import { registerSettingsHandlers } from './settingsHandlers'
91+
import { DEV_DEFAULT_CLOUD_SYNC_USER_KEY } from '../../shared/cloudSyncDevUserKey'
92+
93+
const invoke = async (channel: string, payload?: unknown) => {
94+
const handler = mocks.handlers.get(channel)
95+
if (!handler) throw new Error(`Missing IPC handler: ${channel}`)
96+
return await handler(undefined, payload)
97+
}
98+
99+
const oldKey = '11111111-1111-4111-8111-111111111111'
100+
const newKey = '22222222-2222-4222-8222-222222222222'
101+
102+
beforeEach(() => {
103+
vi.clearAllMocks()
104+
mocks.is.dev = false
105+
mocks.store.settingConfig = {
106+
cloudSyncUserKey: oldKey,
107+
curatedLibrarySyncEnabled: true,
108+
cloudSyncAutoEnabled: false
109+
}
110+
registerSettingsHandlers({ loadFingerprintList: async () => [] })
111+
mocks.validate.mockImplementation(async (_url: string, init: { body: string }) => {
112+
const body = JSON.parse(init.body) as { userKey: string }
113+
return {
114+
status: 200,
115+
text: async () =>
116+
JSON.stringify({ success: true, data: { isActive: true, userKey: body.userKey } })
117+
}
118+
})
119+
})
120+
121+
describe('cloud sync userKey lifecycle', () => {
122+
it.each([false, true])('关闭同步、换密钥、重新打开后保持新密钥 (dev=%s)', async (isDev) => {
123+
mocks.is.dev = isDev
124+
const staleRendererSettings = { ...mocks.store.settingConfig }
125+
await invoke('setSetting', { ...staleRendererSettings, curatedLibrarySyncEnabled: false })
126+
expect(await invoke('cloudSync/config/save', { userKey: newKey })).toEqual({
127+
success: true,
128+
userKey: newKey
129+
})
130+
expect(await invoke('cloudSync/config/get')).toMatchObject({ userKey: newKey })
131+
await invoke('setSetting', { ...staleRendererSettings, curatedLibrarySyncEnabled: true })
132+
expect(mocks.store.settingConfig.cloudSyncUserKey).toBe(newKey)
133+
expect(await invoke('cloudSync/config/get')).toMatchObject({ userKey: newKey })
134+
expect(await invoke('cloudSync/config/save', { userKey: newKey })).toMatchObject({
135+
success: true
136+
})
137+
expect(mocks.forgetJoin).toHaveBeenCalledTimes(1)
138+
expect(mocks.clearPrompt).toHaveBeenCalledTimes(1)
139+
expect(mocks.syncTick).toHaveBeenCalledTimes(1)
140+
})
141+
142+
it('精选库同步开启时仍拦截更换密钥', async () => {
143+
expect(await invoke('cloudSync/config/save', { userKey: newKey })).toEqual({
144+
success: false,
145+
message: 'cloudSync.curatedLibrary.errors.cannotChangeUserKey'
146+
})
147+
expect(mocks.validate).not.toHaveBeenCalled()
148+
expect(mocks.store.settingConfig.cloudSyncUserKey).toBe(oldKey)
149+
})
150+
151+
it('普通设置不能绕过校验更换或清除密钥', async () => {
152+
await invoke('setSetting', { ...mocks.store.settingConfig, cloudSyncUserKey: newKey })
153+
expect(mocks.store.settingConfig.cloudSyncUserKey).toBe(oldKey)
154+
await invoke('setSetting', { curatedLibrarySyncEnabled: false })
155+
expect(mocks.store.settingConfig.cloudSyncUserKey).toBe(oldKey)
156+
})
157+
158+
it('密钥校验失败时保留原密钥与对齐状态', async () => {
159+
mocks.store.settingConfig.curatedLibrarySyncEnabled = false
160+
mocks.validate.mockResolvedValue({
161+
status: 200,
162+
text: async () => JSON.stringify({ success: false, error: 'INVALID_USER_KEY' })
163+
})
164+
expect(await invoke('cloudSync/config/save', { userKey: newKey })).toMatchObject({
165+
success: false
166+
})
167+
expect(mocks.store.settingConfig.cloudSyncUserKey).toBe(oldKey)
168+
expect(mocks.forgetJoin).not.toHaveBeenCalled()
169+
})
170+
171+
it('保存服务端返回的规范密钥', async () => {
172+
mocks.store.settingConfig.curatedLibrarySyncEnabled = false
173+
mocks.validate.mockResolvedValue({
174+
status: 200,
175+
text: async () => JSON.stringify({ success: true, data: { isActive: true, userKey: newKey } })
176+
})
177+
expect(await invoke('cloudSync/config/save', { userKey: 'input-key' })).toEqual({
178+
success: true,
179+
userKey: newKey
180+
})
181+
expect(mocks.store.settingConfig.cloudSyncUserKey).toBe(newKey)
182+
})
183+
184+
it('开发模式仅在未配置密钥时提供默认值', async () => {
185+
mocks.is.dev = true
186+
mocks.store.settingConfig.cloudSyncUserKey = ''
187+
expect(await invoke('cloudSync/config/get')).toMatchObject({
188+
userKey: DEV_DEFAULT_CLOUD_SYNC_USER_KEY
189+
})
190+
})
191+
192+
it('正式模式未配置密钥时保持为空', async () => {
193+
mocks.store.settingConfig.cloudSyncUserKey = ''
194+
expect(await invoke('cloudSync/config/get')).toMatchObject({ userKey: '' })
195+
})
196+
})

‎src/main/ipc/settingsHandlers.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export function registerSettingsHandlers(deps: Dependencies) {
5959
store.settingConfig?.curatedLibrarySyncEnabled === true
6060
const normalizedSetting = {
6161
...setting,
62+
// userKey 只能由 cloudSync/config/save 校验后修改,不能被 renderer 的旧设置覆盖。
63+
cloudSyncUserKey: store.settingConfig.cloudSyncUserKey,
6264
analysisBpmRange: normalizeAnalysisBpmRangeId(setting?.analysisBpmRange),
6365
trackAnalysisSelection: normalizeTrackReanalysisSelection(
6466
setting?.trackAnalysisSelection

‎src/renderer/src/components/cloudSyncSettingsDialog.vue‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ const clickSave = async () => {
134134
userKey: userKey.value
135135
})
136136
if (res?.success) {
137+
runtime.setting.cloudSyncUserKey = res.userKey
137138
cancel()
138139
return
139140
}
@@ -212,6 +213,7 @@ const emailHintIconRef = useTemplateRef<HTMLImageElement>('emailHintIconRef')
212213
onMounted(async () => {
213214
const cfg = await window.electron.ipcRenderer.invoke('cloudSync/config/get')
214215
userKey.value = cfg?.userKey || ''
216+
runtime.setting.cloudSyncUserKey = userKey.value
215217
hotkeys('E,Enter', uuid, () => {
216218
void clickSave()
217219
})

0 commit comments

Comments
 (0)