PlaylistBackupManager.ets 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289
  1. import { Context } from '@kit.AbilityKit';
  2. import { fileIo } from '@kit.CoreFileKit';
  3. import { picker } from '@kit.CoreFileKit';
  4. import { http } from '@kit.NetworkKit';
  5. import { rcp } from '@kit.RemoteCommunicationKit';
  6. import { buffer, util } from '@kit.ArkTS';
  7. import { cryptoFramework } from '@kit.CryptoArchitectureKit';
  8. import { PreferencesUtil, ToastUtil, AppUtil } from '@pura/harmony-utils';
  9. import Logger from './Logger';
  10. import PlaylistTable from './PlaylistTable';
  11. import {
  12. PlaylistBackupData,
  13. ImportResult,
  14. BackupHistoryItem,
  15. BACKUP_FORMAT_VERSION,
  16. WebDavAccountBackupItem,
  17. SettingsBackupData,
  18. SettingBoolItem,
  19. SettingNumberItem,
  20. SettingStringItem
  21. } from '../../viewmodel/PlaylistBackup';
  22. import { RemoteDriveManager } from './RemoteDriveManager';
  23. import { WebDavAccount } from '../../viewmodel/WebDavAccount';
  24. import { RemoteDriveType } from '../enums/RemoteDriveType';
  25. const TAG = 'heanup PlaylistBackupManager';
  26. const BACKUP_HISTORY_KEY = 'playlist_backup_history';
  27. const AUTO_BACKUP_ENABLED_KEY = 'playlist_auto_backup_enabled';
  28. const MAX_HISTORY_ITEMS = 20;
  29. const AUTO_BACKUP_DELAY_MS = 5 * 60 * 1000; // 5分钟
  30. /**
  31. * 备份JSON校验结果
  32. */
  33. export interface ValidateResult {
  34. valid: boolean;
  35. errorMessage: string;
  36. data: PlaylistBackupData | null;
  37. }
  38. /**
  39. * 网盘账户导入统计
  40. */
  41. interface AccountImportResult {
  42. imported: number;
  43. skipped: number;
  44. }
  45. /**
  46. * 加密结果
  47. */
  48. interface EncryptionResult {
  49. cipherText: string; // base64
  50. salt: string; // base64
  51. iv: string; // base64
  52. tag: string; // base64
  53. }
  54. /**
  55. * 歌单备份管理器
  56. */
  57. export class PlaylistBackupManager {
  58. private static instance: PlaylistBackupManager | null = null;
  59. private context: Context | null = null;
  60. private autoBackupTimer: number = -1;
  61. private constructor() {
  62. }
  63. static getInstance(): PlaylistBackupManager {
  64. if (!PlaylistBackupManager.instance) {
  65. PlaylistBackupManager.instance = new PlaylistBackupManager();
  66. }
  67. return PlaylistBackupManager.instance;
  68. }
  69. setContext(context: Context): void {
  70. this.context = context;
  71. // 注册自动备份回调到 PlaylistTable(避免循环依赖)
  72. PlaylistTable.registerAutoBackupCallback(() => {
  73. this.scheduleAutoBackup();
  74. });
  75. }
  76. // ========== 序列化 ==========
  77. /**
  78. * 导出完整备份为 JSON 字符串(v2:歌单 + 网盘配置 + 设置项)
  79. * @param passphrase 可选加密密码,不为空时对密码字段加密
  80. */
  81. async exportToJson(passphrase: string = ''): Promise<string> {
  82. if (!this.context) {
  83. Logger.error(TAG, 'exportToJson: context 未设置');
  84. return '';
  85. }
  86. try {
  87. // 1. 导出歌单
  88. const playlistTable = new PlaylistTable(this.context);
  89. const playlistData = await playlistTable.exportAllPlaylists();
  90. // 2. 导出网盘账户
  91. const accounts = this.exportWebDavAccounts();
  92. // 3. 导出用户设置
  93. const settings = this.exportUserSettings();
  94. // 4. 构建 v2 备份数据
  95. const backupData: PlaylistBackupData = {
  96. version: BACKUP_FORMAT_VERSION,
  97. exportTime: new Date().toISOString(),
  98. appVersion: AppUtil.getVersionName(),
  99. playlists: playlistData.playlists,
  100. webDavAccounts: accounts,
  101. userSettings: settings
  102. };
  103. // 5. 加密敏感数据(密码)
  104. if (passphrase.length > 0 && accounts.length > 0) {
  105. const passwords: string[] = [];
  106. for (let i = 0; i < accounts.length; i++) {
  107. passwords.push(accounts[i].password);
  108. accounts[i].password = ''; // 清除明文密码
  109. }
  110. const encResult = await this.encryptData(JSON.stringify(passwords), passphrase);
  111. backupData.encrypted = true;
  112. backupData.encryptionSalt = encResult.salt;
  113. backupData.encryptionIv = encResult.iv;
  114. backupData.encryptionTag = encResult.tag;
  115. backupData.encryptedPasswords = encResult.cipherText;
  116. Logger.info(TAG, 'exportToJson: 密码已加密');
  117. }
  118. const jsonStr = JSON.stringify(backupData);
  119. Logger.info(TAG, `exportToJson: 导出成功, ${backupData.playlists.length} 个歌单, ${accounts.length} 个网盘账户, 加密=${backupData.encrypted === true}, ${jsonStr.length} 字节`);
  120. return jsonStr;
  121. } catch (error) {
  122. Logger.error(TAG, `exportToJson: 导出失败: ${(error as Error).message}`);
  123. return '';
  124. }
  125. }
  126. /**
  127. * 检查备份 JSON 是否已加密
  128. */
  129. isBackupEncrypted(jsonStr: string): boolean {
  130. try {
  131. const parsed = JSON.parse(jsonStr) as PlaylistBackupData;
  132. return parsed.encrypted === true;
  133. } catch (e) {
  134. return false;
  135. }
  136. }
  137. /**
  138. * 导出所有网盘账户
  139. */
  140. private exportWebDavAccounts(): WebDavAccountBackupItem[] {
  141. const backupItems: WebDavAccountBackupItem[] = [];
  142. try {
  143. const manager = RemoteDriveManager.getInstance();
  144. const allAccounts = manager.getAllWebDavAccounts();
  145. for (let i = 0; i < allAccounts.length; i++) {
  146. const acct = allAccounts[i];
  147. const item: WebDavAccountBackupItem = {
  148. name: acct.name,
  149. isActivate: acct.isActivate,
  150. sortOrder: acct.sortOrder,
  151. host: acct.host,
  152. localHost: acct.localHost,
  153. isUseLocalHost: acct.isUseLocalHost,
  154. port: acct.port,
  155. enableHttps: acct.enableHttps,
  156. filepath: acct.filepath,
  157. imageFilePath: acct.imageFilePath,
  158. lyricFilePath: acct.lyricFilePath,
  159. uploadFilePath: acct.uploadFilePath,
  160. coverPath: acct.coverPath || '',
  161. account: acct.account,
  162. password: acct.password,
  163. webType: acct.webType,
  164. smbShare: acct.smbShare,
  165. smbDomain: acct.smbDomain,
  166. navidromeBasePath: acct.navidromeBasePath,
  167. jellyfinBasePath: acct.jellyfinBasePath,
  168. embyBasePath: acct.embyBasePath,
  169. ftpEncoding: acct.ftpEncoding
  170. };
  171. backupItems.push(item);
  172. }
  173. Logger.info(TAG, `exportWebDavAccounts: 导出 ${backupItems.length} 个网盘账户`);
  174. } catch (error) {
  175. Logger.error(TAG, `exportWebDavAccounts: 失败: ${(error as Error).message}`);
  176. }
  177. return backupItems;
  178. }
  179. /**
  180. * 导出用户设置
  181. */
  182. private exportUserSettings(): SettingsBackupData {
  183. const boolKeys: string[] = [
  184. 'isBgPlayOpen', 'isAutoRatate', 'isMemoryPlay', 'isMusicMemoryPlay',
  185. 'isStartAutoPlay', 'isMemoryLastPlay', 'isSameTimePlay', 'isSavePlayMode',
  186. 'isMediacodec', 'preload_next_song', 'is_customize_bg', 'isMusicBGCover',
  187. 'is_grid_music', 'isCoverRectangle', 'isCircleBtn', 'isAutoScrollHide',
  188. 'IS_SHOW_ALLBAR', 'IS_SHOW_TITLTBAR', 'IS_SHOW_PLAYPAGE_BACK', 'IS_SHOW_SLLYRIC',
  189. 'IS_SHOW_HEADER', 'IS_COVER_TOP_BIG', 'IS_PLAYLIST_BG_GRASS', 'IS_AUTO_HIDE_PROGRESS',
  190. 'isSwipe', 'isShowSimi', 'isShowFAV', 'isShowHistory',
  191. 'isShowPrecious', 'isShowBackFast', 'openSkipSongAnimate', 'isShowFileName',
  192. 'isLongNameRoLL', 'showFindLocation', 'showZMIndex', 'autoHideTitle',
  193. 'autoParseMusicName', 'isNoJumpToHome', 'isCopyFileToDownLoad', 'isDeleteYuan',
  194. 'isDeletePicture', 'isDeleteLrc', 'webdavUploadAutoClear', 'webdavUploadAllowMobile',
  195. 'volumeSmall'
  196. ];
  197. const numberKeys: string[] = [
  198. 'longPressSpeed', 'twoFingerType', 'sonTwoFingerType', 'musicSortType',
  199. 'webDavSortType', 'navidromeSortType', 'customize_bg_blur', 'bg_brightness',
  200. 'lastColumns', 'defalut_home_type', 'themeMode', 'webdavUploadRetryCount'
  201. ];
  202. const stringKeys: string[] = [
  203. 'THEME_COLOR', 'is_customize_bg_path', 'fastForwardSeconds',
  204. 'webdavUploadDuplicateAction', 'LRC_API', 'COVER_API'
  205. ];
  206. const boolSettings: SettingBoolItem[] = [];
  207. for (let i = 0; i < boolKeys.length; i++) {
  208. const key = boolKeys[i];
  209. boolSettings.push({ key: key, value: PreferencesUtil.getBooleanSync(key, false) });
  210. }
  211. const numberSettings: SettingNumberItem[] = [];
  212. for (let i = 0; i < numberKeys.length; i++) {
  213. const key = numberKeys[i];
  214. numberSettings.push({ key: key, value: PreferencesUtil.getNumberSync(key, 0) });
  215. }
  216. const stringSettings: SettingStringItem[] = [];
  217. for (let i = 0; i < stringKeys.length; i++) {
  218. const key = stringKeys[i];
  219. const value = PreferencesUtil.getStringSync(key, '');
  220. // 跳过未设置的 string(空字符串),避免恢复时覆盖默认值
  221. if (value.length > 0) {
  222. stringSettings.push({ key: key, value: value });
  223. }
  224. }
  225. Logger.info(TAG, `exportUserSettings: 导出 ${boolSettings.length + numberSettings.length + stringSettings.length} 个设置项`);
  226. return {
  227. booleanSettings: boolSettings,
  228. numberSettings: numberSettings,
  229. stringSettings: stringSettings
  230. };
  231. }
  232. /**
  233. * 校验备份 JSON 字符串
  234. */
  235. validateBackupJson(jsonStr: string): ValidateResult {
  236. const result: ValidateResult = {
  237. valid: false,
  238. errorMessage: '',
  239. data: null
  240. };
  241. if (!jsonStr || jsonStr.length === 0) {
  242. result.errorMessage = '备份文件内容为空';
  243. return result;
  244. }
  245. try {
  246. const parsed = JSON.parse(jsonStr) as PlaylistBackupData;
  247. if (parsed.version === undefined || parsed.version === null) {
  248. result.errorMessage = '备份文件缺少版本号';
  249. return result;
  250. }
  251. if (parsed.version > BACKUP_FORMAT_VERSION) {
  252. result.errorMessage = `备份文件版本 (${parsed.version}) 不兼容,当前支持版本 ${BACKUP_FORMAT_VERSION}`;
  253. return result;
  254. }
  255. if (!parsed.playlists || !Array.isArray(parsed.playlists)) {
  256. result.errorMessage = '备份文件格式无效:缺少 playlists 字段';
  257. return result;
  258. }
  259. if (!parsed.exportTime) {
  260. result.errorMessage = '备份文件格式无效:缺少 exportTime 字段';
  261. return result;
  262. }
  263. // 校验每个歌单的必要字段
  264. for (let i = 0; i < parsed.playlists.length; i++) {
  265. const playlist = parsed.playlists[i];
  266. if (!playlist.name) {
  267. result.errorMessage = `备份文件格式无效:第 ${i + 1} 个歌单缺少名称`;
  268. return result;
  269. }
  270. if (!playlist.songs || !Array.isArray(playlist.songs)) {
  271. result.errorMessage = `备份文件格式无效:歌单 "${playlist.name}" 缺少 songs 字段`;
  272. return result;
  273. }
  274. }
  275. // v2 格式校验网盘账户(可选字段)
  276. if (parsed.version >= 2 && parsed.webDavAccounts !== undefined) {
  277. if (!Array.isArray(parsed.webDavAccounts)) {
  278. result.errorMessage = '备份文件格式无效:webDavAccounts 必须是数组';
  279. return result;
  280. }
  281. for (let i = 0; i < parsed.webDavAccounts.length; i++) {
  282. const acct = parsed.webDavAccounts[i];
  283. if (!acct.host || acct.port === undefined) {
  284. result.errorMessage = `备份文件格式无效:第 ${i + 1} 个网盘账户缺少必要字段`;
  285. return result;
  286. }
  287. }
  288. }
  289. // v2 格式校验用户设置(可选字段)
  290. if (parsed.version >= 2 && parsed.userSettings !== undefined) {
  291. const settings = parsed.userSettings;
  292. if (!settings.booleanSettings || !settings.numberSettings || !settings.stringSettings) {
  293. result.errorMessage = '备份文件格式无效:userSettings 缺少必要字段';
  294. return result;
  295. }
  296. }
  297. result.valid = true;
  298. result.data = parsed;
  299. return result;
  300. } catch (error) {
  301. result.errorMessage = `备份文件不是有效的 JSON 格式: ${(error as Error).message}`;
  302. return result;
  303. }
  304. }
  305. /**
  306. * 从 JSON 字符串导入完整备份(歌单 + 网盘配置 + 设置项)
  307. * @param passphrase 解密密码(若备份已加密则必填)
  308. */
  309. async importFromJson(
  310. jsonStr: string,
  311. conflictsToSkip: Set<string>,
  312. conflictsToOverwrite: Set<string>,
  313. conflictsToRename: Set<string>,
  314. passphrase: string = ''
  315. ): Promise<ImportResult | null> {
  316. if (!this.context) {
  317. Logger.error(TAG, 'importFromJson: context 未设置');
  318. return null;
  319. }
  320. const validateResult = this.validateBackupJson(jsonStr);
  321. if (!validateResult.valid || !validateResult.data) {
  322. ToastUtil.showToast(validateResult.errorMessage);
  323. return null;
  324. }
  325. const backupData = validateResult.data;
  326. // 解密密码字段
  327. if (backupData.encrypted === true && backupData.encryptedPasswords &&
  328. backupData.webDavAccounts && backupData.webDavAccounts.length > 0) {
  329. if (passphrase.length === 0) {
  330. ToastUtil.showToast('该备份已加密,请输入备份密码');
  331. return null;
  332. }
  333. try {
  334. const decryptedJson = await this.decryptData(
  335. backupData.encryptedPasswords,
  336. passphrase,
  337. backupData.encryptionSalt || '',
  338. backupData.encryptionIv || '',
  339. backupData.encryptionTag || ''
  340. );
  341. const passwords = JSON.parse(decryptedJson) as string[];
  342. for (let i = 0; i < backupData.webDavAccounts.length && i < passwords.length; i++) {
  343. backupData.webDavAccounts[i].password = passwords[i];
  344. }
  345. Logger.info(TAG, 'importFromJson: 密码解密成功');
  346. } catch (error) {
  347. Logger.error(TAG, `importFromJson: 密码解密失败: ${(error as Error).message}`);
  348. ToastUtil.showToast('备份密码错误,解密失败');
  349. return null;
  350. }
  351. }
  352. try {
  353. // 1. 导入歌单
  354. const playlistTable = new PlaylistTable(this.context);
  355. const playlistResult = await playlistTable.importPlaylists(
  356. backupData,
  357. conflictsToSkip,
  358. conflictsToOverwrite,
  359. conflictsToRename
  360. );
  361. const result: ImportResult = {
  362. totalPlaylists: playlistResult.totalPlaylists,
  363. importedPlaylists: playlistResult.importedPlaylists,
  364. skippedPlaylists: playlistResult.skippedPlaylists,
  365. overwrittenPlaylists: playlistResult.overwrittenPlaylists,
  366. renamedPlaylists: playlistResult.renamedPlaylists,
  367. totalSongs: playlistResult.totalSongs,
  368. importedSongs: playlistResult.importedSongs,
  369. importedAccounts: 0,
  370. skippedAccounts: 0,
  371. settingsImported: false
  372. };
  373. // 2. 导入网盘账户(v2)
  374. if (backupData.version >= 2 && backupData.webDavAccounts && backupData.webDavAccounts.length > 0) {
  375. const accountResult = await this.importWebDavAccounts(backupData.webDavAccounts);
  376. result.importedAccounts = accountResult.imported;
  377. result.skippedAccounts = accountResult.skipped;
  378. }
  379. // 3. 导入用户设置(v2)
  380. if (backupData.version >= 2 && backupData.userSettings) {
  381. result.settingsImported = this.importUserSettings(backupData.userSettings);
  382. }
  383. Logger.info(TAG, `importFromJson: 导入完成 - 歌单 ${result.importedPlaylists}, 网盘账户 ${result.importedAccounts}, 设置 ${result.settingsImported}`);
  384. return result;
  385. } catch (error) {
  386. Logger.error(TAG, `importFromJson: 导入失败: ${(error as Error).message}`);
  387. return null;
  388. }
  389. }
  390. /**
  391. * 导入网盘账户(按 host+port+account 去重)
  392. */
  393. private async importWebDavAccounts(accounts: WebDavAccountBackupItem[]): Promise<AccountImportResult> {
  394. const result: AccountImportResult = { imported: 0, skipped: 0 };
  395. try {
  396. const manager = RemoteDriveManager.getInstance();
  397. const existingAccounts = manager.getAllWebDavAccounts();
  398. for (let i = 0; i < accounts.length; i++) {
  399. const backupAcct = accounts[i];
  400. // 检查重复
  401. let isDuplicate = false;
  402. for (let j = 0; j < existingAccounts.length; j++) {
  403. const existing = existingAccounts[j];
  404. if (existing.host === backupAcct.host &&
  405. existing.port === backupAcct.port &&
  406. existing.account === backupAcct.account) {
  407. isDuplicate = true;
  408. break;
  409. }
  410. }
  411. if (isDuplicate) {
  412. result.skipped++;
  413. Logger.info(TAG, `importWebDavAccounts: 跳过重复账户 ${backupAcct.name} (${backupAcct.host}:${backupAcct.port})`);
  414. continue;
  415. }
  416. // 插入账户(注意参数顺序与 RemoteDriveManager.insertAccount 一致)
  417. await manager.insertAccount(
  418. backupAcct.name,
  419. backupAcct.host,
  420. backupAcct.localHost,
  421. backupAcct.isUseLocalHost,
  422. backupAcct.port,
  423. backupAcct.filepath,
  424. backupAcct.lyricFilePath,
  425. backupAcct.uploadFilePath,
  426. backupAcct.imageFilePath,
  427. backupAcct.account,
  428. backupAcct.password,
  429. backupAcct.enableHttps,
  430. backupAcct.coverPath,
  431. backupAcct.webType,
  432. backupAcct.smbShare,
  433. backupAcct.smbDomain,
  434. backupAcct.navidromeBasePath,
  435. backupAcct.jellyfinBasePath,
  436. backupAcct.embyBasePath,
  437. backupAcct.ftpEncoding,
  438. '', '', 0, // baiduAccessToken, baiduRefreshToken, baiduTokenExpiresAt(不恢复临时凭证)
  439. backupAcct.sortOrder
  440. );
  441. result.imported++;
  442. Logger.info(TAG, `importWebDavAccounts: 导入账户 ${backupAcct.name}`);
  443. }
  444. Logger.info(TAG, `importWebDavAccounts: 完成 - 导入 ${result.imported}, 跳过 ${result.skipped}`);
  445. } catch (error) {
  446. Logger.error(TAG, `importWebDavAccounts: 失败: ${(error as Error).message}`);
  447. }
  448. return result;
  449. }
  450. /**
  451. * 导入用户设置
  452. */
  453. private importUserSettings(settings: SettingsBackupData): boolean {
  454. try {
  455. let count = 0;
  456. // 导入 boolean 设置
  457. for (let i = 0; i < settings.booleanSettings.length; i++) {
  458. const item = settings.booleanSettings[i];
  459. PreferencesUtil.putSync(item.key, item.value);
  460. count++;
  461. }
  462. // 导入 number 设置
  463. for (let i = 0; i < settings.numberSettings.length; i++) {
  464. const item = settings.numberSettings[i];
  465. PreferencesUtil.putSync(item.key, item.value);
  466. count++;
  467. }
  468. // 导入 string 设置
  469. for (let i = 0; i < settings.stringSettings.length; i++) {
  470. const item = settings.stringSettings[i];
  471. // 跳过空字符串,避免覆盖默认值(如主题色)
  472. if (item.value.length === 0) {
  473. continue;
  474. }
  475. PreferencesUtil.putSync(item.key, item.value);
  476. // 特殊处理:THEME_COLOR 需要同步到 AppStorage 的 themeColor
  477. if (item.key === 'THEME_COLOR') {
  478. AppStorage.setOrCreate('themeColor', item.value);
  479. }
  480. count++;
  481. }
  482. Logger.info(TAG, `importUserSettings: 导入 ${count} 个设置项`);
  483. return true;
  484. } catch (error) {
  485. Logger.error(TAG, `importUserSettings: 失败: ${(error as Error).message}`);
  486. return false;
  487. }
  488. }
  489. /**
  490. * 检测冲突
  491. */
  492. async detectConflicts(jsonStr: string): Promise<string[]> {
  493. if (!this.context) {
  494. return [];
  495. }
  496. const validateResult = this.validateBackupJson(jsonStr);
  497. if (!validateResult.valid || !validateResult.data) {
  498. return [];
  499. }
  500. const playlistTable = new PlaylistTable(this.context);
  501. return playlistTable.detectConflicts(validateResult.data);
  502. }
  503. // ========== 本地文件操作 ==========
  504. /**
  505. * 导出备份到本地文件
  506. * @param passphrase 可选加密密码
  507. */
  508. async saveToLocal(passphrase: string = ''): Promise<boolean> {
  509. try {
  510. const jsonStr = await this.exportToJson(passphrase);
  511. if (!jsonStr || jsonStr.length === 0) {
  512. ToastUtil.showToast('导出备份数据失败');
  513. return false;
  514. }
  515. const now = new Date();
  516. const year = now.getFullYear();
  517. const month = String(now.getMonth() + 1).padStart(2, '0');
  518. const day = String(now.getDate()).padStart(2, '0');
  519. const hours = String(now.getHours()).padStart(2, '0');
  520. const minutes = String(now.getMinutes()).padStart(2, '0');
  521. const seconds = String(now.getSeconds()).padStart(2, '0');
  522. const fileName = `ttmusic_backup_${year}${month}${day}_${hours}${minutes}${seconds}.json`;
  523. const documentSaveOptions = new picker.DocumentSaveOptions();
  524. documentSaveOptions.newFileNames = [fileName];
  525. documentSaveOptions.fileSuffixChoices = ['.json'];
  526. const documentViewPicker = new picker.DocumentViewPicker();
  527. const saveResult = await documentViewPicker.save(documentSaveOptions);
  528. if (!saveResult || saveResult.length === 0) {
  529. Logger.info(TAG, 'saveToLocal: 用户取消了文件选择');
  530. return false;
  531. }
  532. const uri = saveResult[0];
  533. const file = fileIo.openSync(uri, fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.CREATE);
  534. fileIo.writeSync(file.fd, jsonStr);
  535. fileIo.closeSync(file.fd);
  536. // 记录历史
  537. const validateResult = this.validateBackupJson(jsonStr);
  538. const data = validateResult.data;
  539. const playlistCount = data ? data.playlists.length : 0;
  540. const accountCount = (data && data.webDavAccounts) ? data.webDavAccounts.length : 0;
  541. const hasSettings = !!(data && data.userSettings);
  542. this.addHistoryRecord('local', playlistCount, fileName, accountCount, hasSettings);
  543. ToastUtil.showToast(`备份成功: ${fileName}`);
  544. Logger.info(TAG, `saveToLocal: 文件保存成功: ${uri}`);
  545. return true;
  546. } catch (error) {
  547. const err = error as Error;
  548. Logger.error(TAG, `saveToLocal: 保存失败: ${err.message}`);
  549. ToastUtil.showToast(`备份失败: ${err.message}`);
  550. return false;
  551. }
  552. }
  553. /**
  554. * 从本地文件加载备份
  555. */
  556. async loadFromLocal(): Promise<string> {
  557. try {
  558. const documentSelectOptions = new picker.DocumentSelectOptions();
  559. documentSelectOptions.fileSuffixFilters = ['.json'];
  560. documentSelectOptions.maxSelectNumber = 1;
  561. const documentViewPicker = new picker.DocumentViewPicker();
  562. const selectResult = await documentViewPicker.select(documentSelectOptions);
  563. if (!selectResult || selectResult.length === 0) {
  564. Logger.info(TAG, 'loadFromLocal: 用户取消了文件选择');
  565. return '';
  566. }
  567. const uri = selectResult[0];
  568. const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
  569. const stat = fileIo.statSync(file.fd);
  570. const buffer = new ArrayBuffer(stat.size);
  571. fileIo.readSync(file.fd, buffer);
  572. fileIo.closeSync(file.fd);
  573. const textDecoder = new util.TextDecoder('utf-8');
  574. const jsonStr = textDecoder.decodeWithStream(new Uint8Array(buffer));
  575. Logger.info(TAG, `loadFromLocal: 文件读取成功, ${jsonStr.length} 字节`);
  576. return jsonStr;
  577. } catch (error) {
  578. const err = error as Error;
  579. Logger.error(TAG, `loadFromLocal: 读取失败: ${err.message}`);
  580. ToastUtil.showToast(`读取备份文件失败: ${err.message}`);
  581. return '';
  582. }
  583. }
  584. // ========== WebDAV 备份 ==========
  585. /**
  586. * 生成备份文件名
  587. */
  588. private generateBackupFileName(): string {
  589. const now = new Date();
  590. const year = now.getFullYear();
  591. const month = String(now.getMonth() + 1).padStart(2, '0');
  592. const day = String(now.getDate()).padStart(2, '0');
  593. const hours = String(now.getHours()).padStart(2, '0');
  594. const minutes = String(now.getMinutes()).padStart(2, '0');
  595. const seconds = String(now.getSeconds()).padStart(2, '0');
  596. return `ttmusic_backup_${year}${month}${day}_${hours}${minutes}${seconds}.json`;
  597. }
  598. /**
  599. * 获取 WebDAV 账户的基准路径
  600. */
  601. private getWebDavBasePath(account: WebDavAccount): string {
  602. const base = account.filepath || '/';
  603. if (base.length === 0) {
  604. return '/';
  605. }
  606. // 确保以 / 开头并以 / 结尾(用于拼接)
  607. let normalized = base;
  608. if (!normalized.startsWith('/')) {
  609. normalized = '/' + normalized;
  610. }
  611. return normalized.replace(/\/+$/, '');
  612. }
  613. /**
  614. * 确保 WebDAV 备份目录存在
  615. */
  616. private async ensureWebDavBackupDir(account: WebDavAccount): Promise<void> {
  617. const manager = RemoteDriveManager.getInstance();
  618. const basePath = this.getWebDavBasePath(account);
  619. try {
  620. await manager.createWebDavFolder(account, 'TTMusic', basePath);
  621. } catch (e) {
  622. // 目录可能已存在
  623. }
  624. try {
  625. await manager.createWebDavFolder(account, 'backups', basePath + '/TTMusic');
  626. } catch (e) {
  627. // 目录可能已存在
  628. }
  629. }
  630. /**
  631. * 上传备份到 WebDAV
  632. * @param passphrase 可选加密密码
  633. */
  634. async uploadToWebDav(account: WebDavAccount, passphrase: string = ''): Promise<boolean> {
  635. try {
  636. const jsonStr = await this.exportToJson(passphrase);
  637. if (!jsonStr || jsonStr.length === 0) {
  638. ToastUtil.showToast('导出备份数据失败');
  639. return false;
  640. }
  641. if (!this.context) {
  642. ToastUtil.showToast('上下文未初始化');
  643. return false;
  644. }
  645. const manager = RemoteDriveManager.getInstance();
  646. // 确保备份目录存在
  647. await this.ensureWebDavBackupDir(account);
  648. const fileName = this.generateBackupFileName();
  649. // 写入临时文件
  650. const tempPath = this.context.cacheDir + '/' + fileName;
  651. const tempFile = fileIo.openSync(tempPath, fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.CREATE);
  652. fileIo.writeSync(tempFile.fd, jsonStr);
  653. fileIo.closeSync(tempFile.fd);
  654. // 通过 rcpSocket 上传
  655. const basePath = this.getWebDavBasePath(account);
  656. const remotePath = `${basePath}/TTMusic/backups/${fileName}`;
  657. const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  658. await manager.rcpSocket.uploadFile(
  659. tempPath,
  660. remotePath,
  661. host,
  662. account.port,
  663. account.account,
  664. account.password,
  665. account.enableHttps,
  666. () => {} // 备份文件很小,不需要进度回调
  667. );
  668. // 清理临时文件
  669. try {
  670. fileIo.unlinkSync(tempPath);
  671. } catch (e) {
  672. // 忽略
  673. }
  674. // 记录历史
  675. const validateResult = this.validateBackupJson(jsonStr);
  676. const data = validateResult.data;
  677. const playlistCount = data ? data.playlists.length : 0;
  678. const accountCount = (data && data.webDavAccounts) ? data.webDavAccounts.length : 0;
  679. const hasSettings = !!(data && data.userSettings);
  680. this.addHistoryRecord('webdav', playlistCount, fileName, accountCount, hasSettings);
  681. ToastUtil.showToast(`WebDAV备份成功: ${fileName}`);
  682. Logger.info(TAG, `uploadToWebDav: 上传成功: ${remotePath}`);
  683. return true;
  684. } catch (error) {
  685. const err = error as Error;
  686. Logger.error(TAG, `uploadToWebDav: 上传失败: ${err.message}`);
  687. ToastUtil.showToast(`WebDAV备份失败: ${err.message}`);
  688. return false;
  689. }
  690. }
  691. /**
  692. * 列出 WebDAV 上的备份文件
  693. */
  694. async listWebDavBackups(account: WebDavAccount): Promise<string[]> {
  695. try {
  696. const manager = RemoteDriveManager.getInstance();
  697. const basePath = this.getWebDavBasePath(account);
  698. const files = await manager.rcpSocket.getFileList(
  699. account.host,
  700. account.localHost,
  701. account.isUseLocalHost,
  702. account.port,
  703. `${basePath}/TTMusic/backups/`,
  704. account.account,
  705. account.password,
  706. account.enableHttps
  707. );
  708. const jsonFiles: string[] = [];
  709. for (let i = 0; i < files.length; i++) {
  710. const file = files[i];
  711. const name = file.fileName ? file.fileName : '';
  712. if (name.endsWith('.json')) {
  713. jsonFiles.push(name);
  714. }
  715. }
  716. // 按文件名倒序排列(最新的在前)
  717. jsonFiles.sort((a, b) => b.localeCompare(a));
  718. Logger.info(TAG, `listWebDavBackups: 找到 ${jsonFiles.length} 个备份文件`);
  719. return jsonFiles;
  720. } catch (error) {
  721. Logger.error(TAG, `listWebDavBackups: 列表失败: ${(error as Error).message}`);
  722. return [];
  723. }
  724. }
  725. /**
  726. * 从 WebDAV 下载备份文件(通过 rcp HTTP GET)
  727. */
  728. async downloadFromWebDav(account: WebDavAccount, fileName: string): Promise<string> {
  729. try {
  730. if (!this.context) {
  731. return '';
  732. }
  733. const basePath = this.getWebDavBasePath(account);
  734. const remotePath = `${basePath}/TTMusic/backups/${fileName}`;
  735. const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  736. const protocol = account.enableHttps ? 'https' : 'http';
  737. const url = `${protocol}://${host}:${account.port}${remotePath}`;
  738. const encodedCredentials = buffer.from(`${account.account}:${account.password}`).toString('base64');
  739. const secCfg: rcp.SecurityConfiguration = { remoteValidation: 'skip' };
  740. const reqCfg: rcp.Configuration = {
  741. security: secCfg,
  742. transfer: {
  743. timeout: { connectMs: 15000 }
  744. }
  745. };
  746. const sessionCfg: rcp.SessionConfiguration = { requestConfiguration: reqCfg };
  747. const rcpSession = rcp.createSession(sessionCfg);
  748. const headers: rcp.RequestHeaders = {
  749. 'Authorization': `Basic ${encodedCredentials}`,
  750. 'Accept': '*/*'
  751. };
  752. const req = new rcp.Request(url, 'GET', headers);
  753. const response = await rcpSession.fetch(req);
  754. rcpSession.close();
  755. if (response.statusCode !== 200) {
  756. throw new Error(`HTTP ${response.statusCode}`);
  757. }
  758. let jsonStr = '';
  759. if (response.body) {
  760. const textDecoder = new util.TextDecoder('utf-8');
  761. jsonStr = textDecoder.decodeWithStream(new Uint8Array(response.body as ArrayBuffer));
  762. }
  763. Logger.info(TAG, `downloadFromWebDav: 下载成功: ${fileName}, ${jsonStr.length} 字节`);
  764. return jsonStr;
  765. } catch (error) {
  766. Logger.error(TAG, `downloadFromWebDav: 下载失败: ${(error as Error).message}`);
  767. ToastUtil.showToast(`下载备份失败: ${(error as Error).message}`);
  768. return '';
  769. }
  770. }
  771. // ========== 服务器备份(VIP) ==========
  772. /**
  773. * 检查 VIP 状态
  774. */
  775. isVipUser(): boolean {
  776. return PreferencesUtil.getBooleanSync('hasActiveSubscription', false);
  777. }
  778. /**
  779. * 上传备份到服务器
  780. * @param passphrase 可选加密密码
  781. */
  782. async uploadToServer(passphrase: string = ''): Promise<boolean> {
  783. if (!this.isVipUser()) {
  784. ToastUtil.showToast('该功能仅限VIP用户使用');
  785. return false;
  786. }
  787. try {
  788. const jsonStr = await this.exportToJson(passphrase);
  789. if (!jsonStr || jsonStr.length === 0) {
  790. ToastUtil.showToast('导出备份数据失败');
  791. return false;
  792. }
  793. const token = PreferencesUtil.getStringSync('userToken', '');
  794. if (!token) {
  795. ToastUtil.showToast('请先登录');
  796. return false;
  797. }
  798. const httpRequest = http.createHttp();
  799. const options: http.HttpRequestOptions = {
  800. method: http.RequestMethod.POST,
  801. readTimeout: 10000,
  802. connectTimeout: 10000,
  803. header: {
  804. 'Content-Type': 'application/json'
  805. },
  806. extraData: JSON.stringify({
  807. token: token,
  808. backup_data: jsonStr
  809. })
  810. };
  811. const response = await httpRequest.request(
  812. 'https://pay.ss5.xyz/backup/playlist/upload',
  813. options
  814. );
  815. if (response.responseCode === 200) {
  816. const res = response.result as string;
  817. const json = JSON.parse(res) as ServerBackupResponse;
  818. if (json.code === 0) {
  819. const validateResult = this.validateBackupJson(jsonStr);
  820. const data = validateResult.data;
  821. const playlistCount = data ? data.playlists.length : 0;
  822. const accountCount = (data && data.webDavAccounts) ? data.webDavAccounts.length : 0;
  823. const hasSettings = !!(data && data.userSettings);
  824. this.addHistoryRecord('server', playlistCount, '', accountCount, hasSettings);
  825. ToastUtil.showToast('服务器备份成功');
  826. Logger.info(TAG, 'uploadToServer: 上传成功');
  827. return true;
  828. } else if (json.code === 40002 || json.code === 40003) {
  829. ToastUtil.showToast('登录已过期,请重新登录');
  830. this.pauseAutoBackup();
  831. return false;
  832. } else {
  833. ToastUtil.showToast(`服务器备份失败: ${json.msg || '未知错误'}`);
  834. return false;
  835. }
  836. } else {
  837. ToastUtil.showToast('服务器备份请求失败');
  838. return false;
  839. }
  840. } catch (error) {
  841. Logger.error(TAG, `uploadToServer: 上传失败: ${(error as Error).message}`);
  842. ToastUtil.showToast(`服务器备份失败: ${(error as Error).message}`);
  843. return false;
  844. }
  845. }
  846. /**
  847. * 从服务器下载备份
  848. */
  849. async downloadFromServer(backupId: string): Promise<string> {
  850. if (!this.isVipUser()) {
  851. ToastUtil.showToast('该功能仅限VIP用户使用');
  852. return '';
  853. }
  854. try {
  855. const token = PreferencesUtil.getStringSync('userToken', '');
  856. if (!token) {
  857. ToastUtil.showToast('请先登录');
  858. return '';
  859. }
  860. const httpRequest = http.createHttp();
  861. const url = `https://pay.ss5.xyz/backup/playlist/download?token=${encodeURIComponent(token)}&id=${encodeURIComponent(backupId)}`;
  862. const options: http.HttpRequestOptions = {
  863. method: http.RequestMethod.GET,
  864. readTimeout: 10000,
  865. connectTimeout: 10000,
  866. };
  867. const response = await httpRequest.request(url, options);
  868. if (response.responseCode === 200) {
  869. const res = response.result as string;
  870. const json = JSON.parse(res) as ServerBackupDownloadResponse;
  871. if (json.code === 0 && json.data && json.data.backup_data) {
  872. Logger.info(TAG, 'downloadFromServer: 下载成功');
  873. return json.data.backup_data;
  874. } else if (json.code === 40002 || json.code === 40003) {
  875. ToastUtil.showToast('登录已过期,请重新登录');
  876. return '';
  877. } else {
  878. ToastUtil.showToast(`下载失败: ${json.msg || '未知错误'}`);
  879. return '';
  880. }
  881. } else {
  882. ToastUtil.showToast('下载请求失败');
  883. return '';
  884. }
  885. } catch (error) {
  886. Logger.error(TAG, `downloadFromServer: 下载失败: ${(error as Error).message}`);
  887. ToastUtil.showToast(`下载失败: ${(error as Error).message}`);
  888. return '';
  889. }
  890. }
  891. /**
  892. * 获取服务器备份列表
  893. */
  894. async listServerBackups(): Promise<ServerBackupItem[]> {
  895. if (!this.isVipUser()) {
  896. return [];
  897. }
  898. try {
  899. const token = PreferencesUtil.getStringSync('userToken', '');
  900. if (!token) {
  901. return [];
  902. }
  903. const httpRequest = http.createHttp();
  904. const url = `https://pay.ss5.xyz/backup/playlist/lists?token=${encodeURIComponent(token)}`;
  905. const options: http.HttpRequestOptions = {
  906. method: http.RequestMethod.GET,
  907. readTimeout: 6000,
  908. connectTimeout: 6000,
  909. };
  910. const response = await httpRequest.request(url, options);
  911. if (response.responseCode === 200) {
  912. const res = response.result as string;
  913. const json = JSON.parse(res) as ServerBackupListResponse;
  914. if (json.code === 0 && json.data && json.data.backups) {
  915. Logger.info(TAG, `listServerBackups: 获取到 ${json.data.backups.length} 条备份`);
  916. return json.data.backups;
  917. }
  918. }
  919. return [];
  920. } catch (error) {
  921. Logger.error(TAG, `listServerBackups: 获取列表失败: ${(error as Error).message}`);
  922. return [];
  923. }
  924. }
  925. // ========== 自动备份防抖 ==========
  926. /**
  927. * 调度自动备份(防抖)
  928. */
  929. scheduleAutoBackup(): void {
  930. if (!this.isAutoBackupEnabled()) {
  931. return;
  932. }
  933. if (!this.isVipUser()) {
  934. this.setAutoBackupEnabled(false);
  935. Logger.info(TAG, 'scheduleAutoBackup: VIP已过期,关闭自动备份');
  936. return;
  937. }
  938. // 清除之前的定时器
  939. if (this.autoBackupTimer !== -1) {
  940. clearTimeout(this.autoBackupTimer);
  941. }
  942. // 设置新的 5 分钟定时器
  943. this.autoBackupTimer = setTimeout(() => {
  944. this.autoBackupTimer = -1;
  945. this.uploadToServer().then((success: boolean) => {
  946. if (success) {
  947. Logger.info(TAG, 'scheduleAutoBackup: 自动备份完成');
  948. }
  949. }).catch((error: Error) => {
  950. Logger.error(TAG, `scheduleAutoBackup: 自动备份失败: ${error.message}`);
  951. });
  952. }, AUTO_BACKUP_DELAY_MS);
  953. Logger.info(TAG, 'scheduleAutoBackup: 已设置 5 分钟后自动备份');
  954. }
  955. /**
  956. * 暂停自动备份
  957. */
  958. private pauseAutoBackup(): void {
  959. if (this.autoBackupTimer !== -1) {
  960. clearTimeout(this.autoBackupTimer);
  961. this.autoBackupTimer = -1;
  962. }
  963. }
  964. isAutoBackupEnabled(): boolean {
  965. return PreferencesUtil.getBooleanSync(AUTO_BACKUP_ENABLED_KEY, false);
  966. }
  967. setAutoBackupEnabled(enabled: boolean): void {
  968. PreferencesUtil.putSync(AUTO_BACKUP_ENABLED_KEY, enabled);
  969. }
  970. // ========== 备份历史 ==========
  971. /**
  972. * 添加备份历史记录
  973. */
  974. addHistoryRecord(type: string, playlistCount: number, filePath: string, accountCount: number = 0, hasSettings: boolean = false): void {
  975. try {
  976. const history = this.getBackupHistory();
  977. const record: BackupHistoryItem = {
  978. type: type,
  979. timestamp: new Date().toISOString(),
  980. playlistCount: playlistCount,
  981. filePath: filePath,
  982. accountCount: accountCount,
  983. hasSettings: hasSettings
  984. };
  985. history.unshift(record);
  986. // 保留最多 20 条
  987. while (history.length > MAX_HISTORY_ITEMS) {
  988. history.pop();
  989. }
  990. PreferencesUtil.putSync(BACKUP_HISTORY_KEY, JSON.stringify(history));
  991. } catch (error) {
  992. Logger.error(TAG, `addHistoryRecord: 保存历史失败: ${(error as Error).message}`);
  993. }
  994. }
  995. /**
  996. * 获取备份历史
  997. */
  998. getBackupHistory(): BackupHistoryItem[] {
  999. try {
  1000. const jsonStr = PreferencesUtil.getStringSync(BACKUP_HISTORY_KEY, '[]');
  1001. return JSON.parse(jsonStr) as BackupHistoryItem[];
  1002. } catch (error) {
  1003. return [];
  1004. }
  1005. }
  1006. /**
  1007. * 获取仅WebDAV类型的账户列表
  1008. */
  1009. getWebDavAccounts(): WebDavAccount[] {
  1010. const manager = RemoteDriveManager.getInstance();
  1011. const allAccounts = manager.getAllWebDavAccounts();
  1012. const webdavAccounts: WebDavAccount[] = [];
  1013. for (let i = 0; i < allAccounts.length; i++) {
  1014. if (allAccounts[i].webType === RemoteDriveType.WebDav) {
  1015. webdavAccounts.push(allAccounts[i]);
  1016. }
  1017. }
  1018. return webdavAccounts;
  1019. }
  1020. // ========== 加密/解密(AES-256-GCM) ==========
  1021. /**
  1022. * 使用 AES-256-GCM 加密数据
  1023. * 密钥由 passphrase + 随机 salt 通过 SHA-256 派生
  1024. */
  1025. private async encryptData(plaintext: string, passphrase: string): Promise<EncryptionResult> {
  1026. // 生成随机 salt (16字节) 和 IV (12字节)
  1027. const random = cryptoFramework.createRandom();
  1028. const saltBlob = await random.generateRandom(16);
  1029. const ivBlob = await random.generateRandom(12);
  1030. // 派生密钥: SHA-256(passphrase + salt)
  1031. const symKey = await this.deriveKey(passphrase, saltBlob);
  1032. // AES-256-GCM 加密
  1033. const cipher = cryptoFramework.createCipher('AES256|GCM|NoPadding');
  1034. const gcmParams: cryptoFramework.GcmParamsSpec = {
  1035. iv: { data: ivBlob.data },
  1036. aad: { data: new Uint8Array(0) },
  1037. authTag: { data: new Uint8Array(16) },
  1038. algName: 'GcmParamsSpec'
  1039. };
  1040. await cipher.init(cryptoFramework.CryptoMode.ENCRYPT_MODE, symKey, gcmParams);
  1041. const encoder = new util.TextEncoder();
  1042. const plaintextBytes = encoder.encodeInto(plaintext);
  1043. // GCM doFinal 输出 = 密文 + authTag(末尾16字节)
  1044. const cipherOutput = await cipher.doFinal({ data: plaintextBytes });
  1045. const fullOutput = new Uint8Array(cipherOutput.data);
  1046. const tagLen = 16;
  1047. const cipherBytes = fullOutput.slice(0, fullOutput.byteLength - tagLen);
  1048. const tagBytes = fullOutput.slice(fullOutput.byteLength - tagLen);
  1049. // 使用 Base64Helper 编码
  1050. const base64Helper = new util.Base64Helper();
  1051. const result: EncryptionResult = {
  1052. cipherText: base64Helper.encodeToStringSync(cipherBytes),
  1053. salt: base64Helper.encodeToStringSync(saltBlob.data),
  1054. iv: base64Helper.encodeToStringSync(ivBlob.data),
  1055. tag: base64Helper.encodeToStringSync(tagBytes)
  1056. };
  1057. Logger.info(TAG, `encryptData: fullOutput=${fullOutput.byteLength}B, cipher=${cipherBytes.byteLength}B, tag=${tagBytes.byteLength}B, tagHead=[${tagBytes[0]},${tagBytes[1]},${tagBytes[2]},${tagBytes[3]}]`);
  1058. return result;
  1059. }
  1060. /**
  1061. * 使用 AES-256-GCM 解密数据
  1062. */
  1063. private async decryptData(
  1064. cipherTextB64: string,
  1065. passphrase: string,
  1066. saltB64: string,
  1067. ivB64: string,
  1068. tagB64: string
  1069. ): Promise<string> {
  1070. // 使用 Base64Helper 解码,避免 buffer 内部池导致的数据错位
  1071. const base64Helper = new util.Base64Helper();
  1072. const saltData = base64Helper.decodeSync(saltB64);
  1073. const ivData = base64Helper.decodeSync(ivB64);
  1074. const tagData = base64Helper.decodeSync(tagB64);
  1075. const cipherData = base64Helper.decodeSync(cipherTextB64);
  1076. Logger.info(TAG, `decryptData: cipher=${cipherData.byteLength}B, salt=${saltData.byteLength}B, iv=${ivData.byteLength}B, tag=${tagData.byteLength}B`);
  1077. // 同样方式派生密钥
  1078. const symKey = await this.deriveKey(passphrase, { data: saltData });
  1079. // AES-256-GCM 解密
  1080. const cipher = cryptoFramework.createCipher('AES256|GCM|NoPadding');
  1081. const gcmParams: cryptoFramework.GcmParamsSpec = {
  1082. iv: { data: ivData },
  1083. aad: { data: new Uint8Array(0) },
  1084. authTag: { data: tagData },
  1085. algName: 'GcmParamsSpec'
  1086. };
  1087. await cipher.init(cryptoFramework.CryptoMode.DECRYPT_MODE, symKey, gcmParams);
  1088. // GCM 解密:密文(不含authTag)传入 doFinal,authTag 通过 gcmParams 传入
  1089. const decryptOutput = await cipher.doFinal({ data: cipherData });
  1090. const textDecoder = new util.TextDecoder('utf-8');
  1091. return textDecoder.decodeWithStream(new Uint8Array(decryptOutput.data));
  1092. }
  1093. /**
  1094. * 从 passphrase + salt 派生 AES-256 密钥(SHA-256)
  1095. */
  1096. private async deriveKey(passphrase: string, salt: cryptoFramework.DataBlob): Promise<cryptoFramework.SymKey> {
  1097. const md = cryptoFramework.createMd('SHA256');
  1098. const encoder = new util.TextEncoder();
  1099. await md.update({ data: encoder.encodeInto(passphrase) });
  1100. await md.update(salt);
  1101. const hash = await md.digest();
  1102. const keyGenerator = cryptoFramework.createSymKeyGenerator('AES256');
  1103. return await keyGenerator.convertKey(hash);
  1104. }
  1105. }
  1106. // ========== 服务器API响应接口 ==========
  1107. interface ServerBackupResponse {
  1108. code: number;
  1109. msg: string;
  1110. }
  1111. export interface ServerBackupItem {
  1112. id: string;
  1113. timestamp: string;
  1114. playlist_count: number;
  1115. }
  1116. interface ServerBackupDownloadResponse {
  1117. code: number;
  1118. msg: string;
  1119. data: ServerBackupDownloadData;
  1120. }
  1121. interface ServerBackupDownloadData {
  1122. backup_data: string;
  1123. }
  1124. interface ServerBackupListResponse {
  1125. code: number;
  1126. msg: string;
  1127. data: ServerBackupListData;
  1128. }
  1129. interface ServerBackupListData {
  1130. backups: ServerBackupItem[];
  1131. }