Compare commits

..

8 Commits

Author SHA1 Message Date
86e8f33de5 Merge pull request '从安卓客户端的密钥数据库读取密钥' (#24) from feat/import-from-android into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #24
2023-06-11 22:39:19 +00:00
3cb24b52ac fix: make modal don't close on overlay click; remove dismiss button in this modal.
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-06-11 23:25:49 +01:00
2a75d63b9a fix: reworded the instructions against rooting Android Device
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-11 23:23:40 +01:00
0b82d267c9 chore: trim whitespace when converting staging key to production
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-11 23:19:13 +01:00
e956fb60ad fix: added workaround for sql.js during vitest
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-11 22:42:19 +01:00
dec7f86115 feat: proper instructions for ps1/win as well. 2023-06-11 22:41:06 +01:00
eb87c0f2e0 chore: manually adjust chunks 2023-06-11 22:40:39 +01:00
da39d5f5c1 feat: import ekey from Android db (#20)
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-11 16:22:57 +01:00
18 changed files with 1485 additions and 1009 deletions

View File

@ -32,7 +32,10 @@
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-icons": "^4.9.0", "react-icons": "^4.9.0",
"react-promise-suspense": "^0.3.4", "react-promise-suspense": "^0.3.4",
"react-redux": "^8.0.5" "react-redux": "^8.0.5",
"react-syntax-highlighter": "^15.5.0",
"sass": "^1.63.3",
"sql.js": "^1.8.0"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-replace": "^5.0.2", "@rollup/plugin-replace": "^5.0.2",
@ -42,6 +45,8 @@
"@types/node": "^20.2.5", "@types/node": "^20.2.5",
"@types/react": "^18.2.7", "@types/react": "^18.2.7",
"@types/react-dom": "^18.2.4", "@types/react-dom": "^18.2.4",
"@types/react-syntax-highlighter": "^15.5.7",
"@types/sql.js": "^1.4.4",
"@types/testing-library__jest-dom": "^5.14.6", "@types/testing-library__jest-dom": "^5.14.6",
"@typescript-eslint/eslint-plugin": "^5.59.7", "@typescript-eslint/eslint-plugin": "^5.59.7",
"@typescript-eslint/parser": "^5.59.7", "@typescript-eslint/parser": "^5.59.7",
@ -71,5 +76,10 @@
"singleQuote": true, "singleQuote": true,
"printWidth": 120, "printWidth": 120,
"tabWidth": 2 "tabWidth": 2
},
"pnpm": {
"patchedDependencies": {
"sql.js@1.8.0": "patches/sql.js@1.8.0.patch"
}
} }
} }

View File

@ -0,0 +1,12 @@
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
index e0da60ba096433d9af1c7025d2ffb9c521f190ed..89a5da6af23e1a644106d38dafe7cfa85500a8c4 100644
--- a/dist/sql-wasm.js
+++ b/dist/sql-wasm.js
@@ -192,3 +192,7 @@ else if (typeof define === 'function' && define['amd']) {
else if (typeof exports === 'object'){
exports["Module"] = initSqlJs;
}
+
+var module;
+export default initSqlJs;
+

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
import { useDropzone } from 'react-dropzone';
import { Box } from '@chakra-ui/react';
export interface FileInputProps {
onReceiveFiles: (files: File[]) => void;
multiple?: boolean;
children: React.ReactNode;
}
export function FileInput({ children, onReceiveFiles }: FileInputProps) {
const { getRootProps, getInputProps, isDragActive } = useDropzone({
multiple: true,
onDropAccepted: onReceiveFiles,
});
return (
<Box
{...getRootProps()}
w="100%"
maxW={480}
borderWidth="1px"
borderRadius="lg"
transitionDuration="0.5s"
p="6"
cursor="pointer"
display="flex"
flexDir="column"
alignItems="center"
_hover={{
borderColor: 'gray.400',
bg: 'gray.50',
}}
{...(isDragActive && {
bg: 'blue.50',
borderColor: 'blue.700',
})}
>
<input {...getInputProps()} />
{children}
</Box>
);
}

View File

@ -1,16 +1,14 @@
import { useDropzone } from 'react-dropzone'; import { Box, Text } from '@chakra-ui/react';
import { chakra, Box, Text } from '@chakra-ui/react';
import { UnlockIcon } from '@chakra-ui/icons'; import { UnlockIcon } from '@chakra-ui/icons';
import { useAppDispatch } from '~/hooks'; import { useAppDispatch } from '~/hooks';
import { addNewFile, processFile } from '~/features/file-listing/fileListingSlice'; import { addNewFile, processFile } from '~/features/file-listing/fileListingSlice';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { FileInput } from './FileInput';
export function SelectFile() { export function SelectFile() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const handleFileReceived = (files: File[]) => {
multiple: true,
onDropAccepted(files, _event) {
console.debug( console.debug(
'react-dropzone/onDropAccepted(%o, %o)', 'react-dropzone/onDropAccepted(%o, %o)',
files.length, files.length,
@ -32,46 +30,23 @@ export function SelectFile() {
); );
dispatch(processFile({ fileId })); dispatch(processFile({ fileId }));
} }
}, };
});
return ( return (
<Box <FileInput multiple onReceiveFiles={handleFileReceived}>
{...getRootProps()}
w="100%"
maxW={480}
borderWidth="1px"
borderRadius="lg"
transitionDuration="0.5s"
p="6"
cursor="pointer"
display="flex"
flexDir="column"
alignItems="center"
_hover={{
borderColor: 'gray.400',
bg: 'gray.50',
}}
{...(isDragActive && {
bg: 'blue.50',
borderColor: 'blue.700',
})}
>
<input {...getInputProps()} />
<Box pb={3}> <Box pb={3}>
<UnlockIcon boxSize={8} /> <UnlockIcon boxSize={8} />
</Box> </Box>
<Text textAlign="center"> <Text as="div" textAlign="center">
<chakra.span as="span" color="teal.400"> <Text as="span" color="teal.400">
</chakra.span> </Text>
<Text fontSize="sm" opacity="50%"> <Text fontSize="sm" opacity="50%">
</Text> </Text>
</Text> </Text>
</Box> </FileInput>
); );
} }

View File

@ -24,12 +24,14 @@ import {
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { qmc2AddKey, qmc2ClearKeys, qmc2DeleteKey, qmc2UpdateKey } from '../settingsSlice'; import { qmc2AddKey, qmc2ClearKeys, qmc2DeleteKey, qmc2UpdateKey } from '../settingsSlice';
import { selectStagingQMCv2Settings } from '../settingsSelector'; import { selectStagingQMCv2Settings } from '../settingsSelector';
import React from 'react'; import React, { useState } from 'react';
import { MdAdd, MdDelete, MdDeleteForever, MdExpandMore, MdFileUpload, MdVpnKey } from 'react-icons/md'; import { MdAdd, MdDelete, MdDeleteForever, MdExpandMore, MdFileUpload, MdVpnKey } from 'react-icons/md';
import { ImportFileModal } from './QMCv2/ImportFileModal';
export function PanelQMCv2Key() { export function PanelQMCv2Key() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const qmc2Keys = useSelector(selectStagingQMCv2Settings).keys; const qmc2Keys = useSelector(selectStagingQMCv2Settings).keys;
const [showImportModal, setShowImportModal] = useState(false);
const addKey = () => dispatch(qmc2AddKey()); const addKey = () => dispatch(qmc2AddKey());
const updateKey = (prop: 'name' | 'key', id: string, e: React.ChangeEvent<HTMLInputElement>) => const updateKey = (prop: 'name' | 'key', id: string, e: React.ChangeEvent<HTMLInputElement>) =>
@ -53,12 +55,10 @@ export function PanelQMCv2Key() {
<Menu> <Menu>
<MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton> <MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton>
<MenuList> <MenuList>
{/* 目前的想法是弹出一个 modal给用户一些信息如期待的格式、如何导出或寻找对应的文件 */} <MenuItem onClick={() => setShowImportModal(true)} icon={<Icon as={MdFileUpload} boxSize={5} />}>
{/* 但是这样的话就不太方便放在这个分支里面做了,下次一定。 */}
<MenuItem hidden onClick={() => alert('TODO!')} icon={<Icon as={MdFileUpload} boxSize={5} />}>
</MenuItem> </MenuItem>
<MenuDivider hidden /> <MenuDivider />
<MenuItem color="red" onClick={clearAll} icon={<Icon as={MdDeleteForever} boxSize={5} />}> <MenuItem color="red" onClick={clearAll} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
</MenuItem> </MenuItem>
@ -111,6 +111,8 @@ export function PanelQMCv2Key() {
</List> </List>
{qmc2Keys.length === 0 && <Text></Text>} {qmc2Keys.length === 0 && <Text></Text>}
</Box> </Box>
<ImportFileModal show={showImportModal} onClose={() => setShowImportModal(false)} />
</Flex> </Flex>
); );
} }

View File

@ -0,0 +1,92 @@
import {
Center,
Flex,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
useToast,
} from '@chakra-ui/react';
import { FileInput } from '~/components/FileInput';
import { qmc2ImportKeys } from '../../settingsSlice';
import { useAppDispatch } from '~/hooks';
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
import { QMCv2AndroidInstructions } from './QMCv2AndroidInstructions';
export interface ImportFileModalProps {
show: boolean;
onClose: () => void;
}
export function ImportFileModal({ onClose, show }: ImportFileModalProps) {
const dispatch = useAppDispatch();
const toast = useToast();
const handleFileReceived = async (files: File[]) => {
try {
const file = files[0];
const fileBuffer = await file.arrayBuffer();
if (/[_.]db$/i.test(file.name)) {
const extractor = await DatabaseKeyExtractor.getInstance();
const qmc2Keys = extractor.extractQmAndroidDbKeys(fileBuffer);
if (qmc2Keys) {
dispatch(qmc2ImportKeys(qmc2Keys));
onClose();
toast({
title: `导入成功 (${qmc2Keys.length})`,
description: '记得保存更改来应用。',
isClosable: true,
duration: 5000,
status: 'success',
});
return;
}
alert(`不是支持的 SQLite 数据库文件。\n表名${qmc2Keys}`);
} else {
alert(`不支持的文件:${file.name}`);
}
} catch (e) {
console.error('error during import: ', e);
alert(`导入数据库时发生错误:${e}`);
}
};
return (
<Modal isOpen={show} onClose={onClose} closeOnOverlayClick={false} scrollBehavior="inside" size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader></ModalHeader>
<ModalCloseButton />
<Flex as={ModalBody} gap={2} flexDir="column" flex={1}>
<Center>
<FileInput onReceiveFiles={handleFileReceived}></FileInput>
</Center>
<Flex as={Tabs} variant="enclosed" flexDir="column" mt={4} flex={1} minH={0}>
<TabList>
<Tab></Tab>
{/* <Tab>Two</Tab> */}
</TabList>
<TabPanels flex={1} overflow="auto">
<TabPanel>
<QMCv2AndroidInstructions />
</TabPanel>
<TabPanel>
<p>two!</p>
</TabPanel>
</TabPanels>
</Flex>
</Flex>
</ModalContent>
</Modal>
);
}

View File

@ -0,0 +1,117 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Heading,
Link,
ListItem,
OrderedList,
Text,
chakra,
} from '@chakra-ui/react';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/github';
import PowerShellAdbDumpCommand from './adb_dump.ps1?raw';
import ShellAdbDumpCommand from './adb_dump.sh?raw';
export function QMCv2AndroidInstructions() {
return (
<>
<Text>
<code>root</code> 访访
</Text>
<Text>
<code>root</code>
<chakra.span color="red.400"></chakra.span>
</Text>
<Accordion allowToggle mt="2">
<AccordionItem>
<Heading as="h3" size="md">
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
</Box>
<AccordionIcon />
</AccordionButton>
</Heading>
<AccordionPanel pb={4}>
<OrderedList>
<ListItem>
使 <code>root</code> 访 <code>/data/data/com.tencent.qqmusic/databases/</code>
{' 目录,将文件 '}
<code>player_process_db</code> 访
</ListItem>
<ListItem></ListItem>
</OrderedList>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<Heading as="h3" size="md">
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
PC ADB / PowerShell
</Box>
<AccordionIcon />
</AccordionButton>
</Heading>
<AccordionPanel pb={4}>
<OrderedList>
<ListItem>
<code>adb</code>
<br />
💡
<Link href="https://scoop.sh/#/apps?q=adb" isExternal color="blue.600">
使 Scoop <ExternalLinkIcon />
</Link>
</ListItem>
<ListItem> PowerShell 7 </ListItem>
<ListItem></ListItem>
<ListItem>
<SyntaxHighlighter language="ps1" style={hljsStyleGitHub}>
{PowerShellAdbDumpCommand}
</SyntaxHighlighter>
</ListItem>
<ListItem>
<code>player_process_db</code>
</ListItem>
</OrderedList>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<Heading as="h3" size="md">
<AccordionButton>
<Box as="span" flex="1" textAlign="left">
Linux / Mac ADB / Shell
</Box>
<AccordionIcon />
</AccordionButton>
</Heading>
<AccordionPanel pb={4}>
<OrderedList>
<ListItem></ListItem>
<ListItem>
<SyntaxHighlighter language="bash" style={hljsStyleGitHub}>
{ShellAdbDumpCommand}
</SyntaxHighlighter>
</ListItem>
<ListItem>
<code>player_process_db</code>
</ListItem>
</OrderedList>
</AccordionPanel>
</AccordionItem>
</Accordion>
</>
);
}

View File

@ -0,0 +1,11 @@
try {
$gz_b64 = adb shell su -c "cat '/data/data/com.tencent.qqmusic/databases/player_process_db' | gzip | base64" | Out-String
$bStream = New-Object System.IO.MemoryStream(,[System.Convert]::FromBase64String($gz_b64))
$decoded = New-Object System.IO.Compression.GzipStream($bStream, [System.IO.Compression.CompressionMode]::Decompress)
$outFile = New-Object System.IO.FileStream("player_process_db", [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write)
$decoded.CopyTo($outFile)
} finally {
if ($outFile -ne $null) { $outFile.Dispose() }
if ($decoded -ne $null) { $decoded.Dispose() }
if ($bStream -ne $null) { $bStream.Dispose() }
}

View File

@ -0,0 +1,2 @@
sh adb shell su -c "cat '/data/data/com.tencent.qqmusic/databases/player_process_db' | gzip | base64" \
| base64 -d | gzip -d player_process_db

View File

@ -6,9 +6,10 @@ export const selectStagingQMCv2Settings = (state: RootState) => state.settings.s
export const selectFinalQMCv2Settings = (state: RootState) => state.settings.production.qmc2; export const selectFinalQMCv2Settings = (state: RootState) => state.settings.production.qmc2;
export const selectDecryptOptionByFile = (state: RootState, name: string): DecryptCommandOptions => { export const selectDecryptOptionByFile = (state: RootState, name: string): DecryptCommandOptions => {
const normalizedName = name.normalize();
const qmc2Keys = selectFinalQMCv2Settings(state).keys; const qmc2Keys = selectFinalQMCv2Settings(state).keys;
return { return {
qmc2Key: hasOwn(qmc2Keys, name) ? qmc2Keys[name] : undefined, qmc2Key: hasOwn(qmc2Keys, normalizedName) ? qmc2Keys[normalizedName] : undefined,
}; };
}; };

View File

@ -36,8 +36,8 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
qmc2: { qmc2: {
keys: objectify( keys: objectify(
staging.qmc2.keys, staging.qmc2.keys,
(item) => item.name, (item) => item.name.normalize(),
(item) => item.key (item) => item.key.trim()
), ),
}, },
}); });
@ -61,6 +61,10 @@ export const settingsSlice = createSlice({
qmc2AddKey(state) { qmc2AddKey(state) {
state.staging.qmc2.keys.push({ id: nanoid(), name: '', key: '' }); state.staging.qmc2.keys.push({ id: nanoid(), name: '', key: '' });
}, },
qmc2ImportKeys(state, { payload }: PayloadAction<{ name: string; key: string }[]>) {
const newItems = payload.map((item) => ({ id: nanoid(), ...item }));
state.staging.qmc2.keys.push(...newItems);
},
qmc2DeleteKey(state, { payload: { id } }: PayloadAction<{ id: string }>) { qmc2DeleteKey(state, { payload: { id } }: PayloadAction<{ id: string }>) {
const qmc2 = state.staging.qmc2; const qmc2 = state.staging.qmc2;
qmc2.keys = qmc2.keys.filter((item) => item.id !== id); qmc2.keys = qmc2.keys.filter((item) => item.id !== id);
@ -102,6 +106,7 @@ export const {
qmc2UpdateKey, qmc2UpdateKey,
qmc2DeleteKey, qmc2DeleteKey,
qmc2ClearKeys, qmc2ClearKeys,
qmc2ImportKeys,
commitStagingChange, commitStagingChange,
discardStagingChanges, discardStagingChanges,

View File

@ -3,6 +3,13 @@ import ReactDOM from 'react-dom/client';
import { AppRoot } from './components/AppRoot'; import { AppRoot } from './components/AppRoot';
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
import hljsSyntaxPowerShell from 'react-syntax-highlighter/dist/esm/languages/hljs/powershell';
import hljsSyntaxBash from 'react-syntax-highlighter/dist/esm/languages/hljs/bash';
SyntaxHighlighter.registerLanguage('ps1', hljsSyntaxPowerShell);
SyntaxHighlighter.registerLanguage('bash', hljsSyntaxBash);
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<AppRoot /> <AppRoot />

View File

@ -0,0 +1,44 @@
import { SQLDatabase, SQLStatic, loadSQL } from './sqlite';
export interface QMAndroidKeyEntry {
name: string;
key: string;
}
export class DatabaseKeyExtractor {
private static _instance: DatabaseKeyExtractor;
static async getInstance() {
if (!DatabaseKeyExtractor._instance) {
DatabaseKeyExtractor._instance = new DatabaseKeyExtractor(await loadSQL());
}
return DatabaseKeyExtractor._instance;
}
constructor(private SQL: SQLStatic) {}
private hasTable(db: SQLDatabase, name: string): boolean {
const tables = db.exec('SELECT name FROM sqlite_master WHERE type="table"')[0].values.map((x) => x[0]);
return tables.includes(name);
}
extractQmAndroidDbKeys(buffer: ArrayBuffer): null | QMAndroidKeyEntry[] {
let db: SQLDatabase | null = null;
try {
db = new this.SQL.Database(new Uint8Array(buffer));
if (!this.hasTable(db, 'audio_file_ekey_table')) {
return null;
}
const keys = db.exec('select file_path, ekey from `audio_file_ekey_table`')[0].values;
return keys.map(([path, key]) => ({
// strip dir name
name: String(path).replace(/.+\//, ''),
key: String(key),
}));
} finally {
db?.close();
}
}
}

15
src/util/sqlite.ts Normal file
View File

@ -0,0 +1,15 @@
import * as initSqlite from 'sql.js';
const urlWasm = new URL('@nm/sql.js/dist/sql-wasm.wasm', import.meta.url).toString();
export type SQLStatic = Awaited<ReturnType<(typeof initSqlite)['default']>>;
export type SQLDatabase = SQLStatic['Database']['prototype'];
let sqlLoaderPromise: Promise<SQLStatic> | void;
export async function loadSQL() {
if (!sqlLoaderPromise) {
sqlLoaderPromise = initSqlite.default({ locateFile: () => urlWasm });
}
return await sqlLoaderPromise;
}

View File

@ -1,15 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ESNext", "target": "ESNext",
"lib": [ "lib": ["DOM", "DOM.Iterable", "ESNext"],
"DOM", "types": ["vitest/globals", "@testing-library/jest-dom"],
"DOM.Iterable",
"ESNext"
],
"types": [
"vitest/globals",
"@testing-library/jest-dom"
],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
@ -27,14 +20,11 @@
"baseUrl": ".", "baseUrl": ".",
"esModuleInterop": true, "esModuleInterop": true,
"paths": { "paths": {
"~/*": [ "~/*": ["./src/*"],
"./src/*" "@nm/*": ["./node_modules/*"]
] }
}, },
}, "include": ["src"],
"include": [
"src"
],
"references": [ "references": [
{ {
"path": "./tsconfig.node.json" "path": "./tsconfig.node.json"

View File

@ -43,7 +43,7 @@ export default defineConfig({
}, },
base: './', base: './',
optimizeDeps: { optimizeDeps: {
exclude: ['@jixun/libparakeet'], exclude: ['@jixun/libparakeet', 'sql.js'],
}, },
plugins: [ plugins: [
replace({ replace({
@ -88,6 +88,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'~': path.resolve(__dirname, 'src'), '~': path.resolve(__dirname, 'src'),
'@nm': path.resolve(__dirname, 'node_modules'),
module: path.resolve(__dirname, 'src', 'dummy.mjs'), module: path.resolve(__dirname, 'src', 'dummy.mjs'),
}, },
}, },
@ -95,8 +96,10 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks: {
reacts: ['react', 'react-dom', 'react-promise-suspense', 'react-redux', '@reduxjs/toolkit'], reacts: ['react', 'react-dom', 'react-dropzone', 'react-promise-suspense', 'react-redux', '@reduxjs/toolkit'],
chakra: ['@chakra-ui/icons', '@chakra-ui/react', '@emotion/react', '@emotion/styled', 'framer-motion'], chakra: ['@chakra-ui/react', '@emotion/react', '@emotion/styled', 'framer-motion'],
icons: ['react-icons', '@chakra-ui/icons'],
utility: ['radash', 'nanoid', 'immer', 'react-syntax-highlighter'],
}, },
}, },
}, },

View File

@ -12,6 +12,10 @@ export default defineConfig({
replacement: 'src/$1', replacement: 'src/$1',
}, },
], ],
// workaround: sql.js is not ESModule friendly, yet...
deps: {
inline: ['sql.js'],
},
api: { api: {
port: 5174, // vite port + 1 port: 5174, // vite port + 1
}, },