├── src
├── renderer
│ ├── pages
│ │ ├── Hello.css
│ │ ├── Hello.tsx
│ │ ├── Settings.tsx
│ │ └── Songs.tsx
│ ├── index.tsx
│ ├── index.ejs
│ ├── App.css
│ ├── components
│ │ ├── MonacoEditor.tsx
│ │ └── AppLayout.tsx
│ ├── store.ts
│ ├── App.tsx
│ └── preload.d.ts
├── main
│ ├── utils
│ │ ├── ipcResponse.ts
│ │ ├── songDeps.ts
│ │ └── assets.ts
│ ├── util.ts
│ ├── preload.ts
│ ├── menuHandler.ts
│ ├── main.ts
│ └── menu.ts
├── globalStore.ts
├── stateSlices
│ └── assets.ts
└── type.ts
├── .erb
├── mocks
│ └── fileMock.js
├── img
│ ├── erb-logo.png
│ ├── erb-banner.svg
│ └── palette-sponsor-banner.svg
├── configs
│ ├── .eslintrc
│ ├── webpack.config.eslint.ts
│ ├── webpack.paths.ts
│ ├── webpack.config.base.ts
│ ├── webpack.config.renderer.dev.dll.ts
│ ├── webpack.config.preload.dev.ts
│ ├── webpack.config.main.prod.ts
│ ├── webpack.config.renderer.prod.ts
│ └── webpack.config.renderer.dev.ts
└── scripts
│ ├── .eslintrc
│ ├── clean.js
│ ├── delete-source-maps.js
│ ├── link-modules.ts
│ ├── check-node-env.js
│ ├── check-port-in-use.js
│ ├── electron-rebuild.js
│ ├── check-build-exists.ts
│ ├── notarize.js
│ └── check-native-dep.js
├── assets
├── aam.psd
├── icon.icns
├── icon.ico
├── icon.png
├── aam_mac.psd
├── icons
│ ├── 128x128.png
│ ├── 16x16.png
│ ├── 256x256.png
│ ├── 32x32.png
│ ├── 48x48.png
│ ├── 512x512.png
│ ├── 64x64.png
│ └── 1024x1024.png
├── entitlements.mac.plist
└── assets.d.ts
├── .husky
└── pre-commit
├── .vscode
├── extensions.json
├── settings.json
└── launch.json
├── release
└── app
│ ├── yarn.lock
│ ├── package-lock.json
│ └── package.json
├── .editorconfig
├── .gitattributes
├── .gitignore
├── README.md
├── tsconfig.json
├── .eslintignore
├── LICENSE
├── .eslintrc.js
├── CODE_OF_CONDUCT.md
└── package.json
/src/renderer/pages/Hello.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.erb/mocks/fileMock.js:
--------------------------------------------------------------------------------
1 | export default 'test-file-stub';
2 |
--------------------------------------------------------------------------------
/assets/aam.psd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feightwywx/aam/HEAD/assets/aam.psd
--------------------------------------------------------------------------------
/assets/icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feightwywx/aam/HEAD/assets/icon.icns
--------------------------------------------------------------------------------
/assets/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feightwywx/aam/HEAD/assets/icon.ico
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feightwywx/aam/HEAD/assets/icon.png
--------------------------------------------------------------------------------
/assets/aam_mac.psd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feightwywx/aam/HEAD/assets/aam_mac.psd
--------------------------------------------------------------------------------
/.erb/img/erb-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feightwywx/aam/HEAD/.erb/img/erb-logo.png
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/assets/icons/128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feightwywx/aam/HEAD/assets/icons/128x128.png
--------------------------------------------------------------------------------
/assets/icons/16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feightwywx/aam/HEAD/assets/icons/16x16.png
--------------------------------------------------------------------------------
/assets/icons/256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feightwywx/aam/HEAD/assets/icons/256x256.png
--------------------------------------------------------------------------------
/assets/icons/32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feightwywx/aam/HEAD/assets/icons/32x32.png
--------------------------------------------------------------------------------
/assets/icons/48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feightwywx/aam/HEAD/assets/icons/48x48.png
--------------------------------------------------------------------------------
/assets/icons/512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feightwywx/aam/HEAD/assets/icons/512x512.png
--------------------------------------------------------------------------------
/assets/icons/64x64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feightwywx/aam/HEAD/assets/icons/64x64.png
--------------------------------------------------------------------------------
/assets/icons/1024x1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/feightwywx/aam/HEAD/assets/icons/1024x1024.png
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["dbaeumer.vscode-eslint", "EditorConfig.EditorConfig"]
3 | }
4 |
--------------------------------------------------------------------------------
/release/app/yarn.lock:
--------------------------------------------------------------------------------
1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2 | # yarn lockfile v1
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.erb/configs/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "no-console": "off",
4 | "global-require": "off",
5 | "import/no-dynamic-require": "off"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/.erb/configs/webpack.config.eslint.ts:
--------------------------------------------------------------------------------
1 | /* eslint import/no-unresolved: off, import/no-self-import: off */
2 |
3 | module.exports = require('./webpack.config.renderer.dev').default;
4 |
--------------------------------------------------------------------------------
/.erb/scripts/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "no-console": "off",
4 | "global-require": "off",
5 | "import/no-dynamic-require": "off",
6 | "import/no-extraneous-dependencies": "off"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.md]
12 | trim_trailing_whitespace = false
13 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text eol=lf
2 | *.exe binary
3 | *.png binary
4 | *.jpg binary
5 | *.jpeg binary
6 | *.ico binary
7 | *.icns binary
8 | *.eot binary
9 | *.otf binary
10 | *.ttf binary
11 | *.woff binary
12 | *.woff2 binary
13 |
--------------------------------------------------------------------------------
/src/renderer/index.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client';
2 | import App from './App';
3 | import store from './store';
4 |
5 | const container = document.getElementById('root')!;
6 | const root = createRoot(container);
7 | root.render();
8 |
9 |
--------------------------------------------------------------------------------
/release/app/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "aam",
3 | "version": "0.0.1",
4 | "lockfileVersion": 2,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "aam",
9 | "version": "0.0.1",
10 | "hasInstallScript": true,
11 | "license": "MIT"
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/.erb/scripts/clean.js:
--------------------------------------------------------------------------------
1 | import rimraf from 'rimraf';
2 | import webpackPaths from '../configs/webpack.paths';
3 |
4 | const foldersToRemove = [
5 | webpackPaths.distPath,
6 | webpackPaths.buildPath,
7 | webpackPaths.dllPath,
8 | ];
9 |
10 | foldersToRemove.forEach((folder) => {
11 | rimraf.sync(folder);
12 | });
13 |
--------------------------------------------------------------------------------
/.erb/scripts/delete-source-maps.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import rimraf from 'rimraf';
3 | import webpackPaths from '../configs/webpack.paths';
4 |
5 | export default function deleteSourceMaps() {
6 | rimraf.sync(path.join(webpackPaths.distMainPath, '*.js.map'));
7 | rimraf.sync(path.join(webpackPaths.distRendererPath, '*.js.map'));
8 | }
9 |
--------------------------------------------------------------------------------
/src/renderer/index.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 | Arcaea Assets Manager
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/.erb/scripts/link-modules.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import webpackPaths from '../configs/webpack.paths';
3 |
4 | const { srcNodeModulesPath } = webpackPaths;
5 | const { appNodeModulesPath } = webpackPaths;
6 |
7 | if (!fs.existsSync(srcNodeModulesPath) && fs.existsSync(appNodeModulesPath)) {
8 | fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction');
9 | }
10 |
--------------------------------------------------------------------------------
/assets/entitlements.mac.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.cs.allow-unsigned-executable-memory
6 |
7 | com.apple.security.cs.allow-jit
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/main/utils/ipcResponse.ts:
--------------------------------------------------------------------------------
1 | import { IPCResponse } from 'type';
2 |
3 | export function makeSuccessResp(data: T): IPCResponse {
4 | return {
5 | code: 0,
6 | message: 'success',
7 | data,
8 | };
9 | }
10 |
11 | export function makeFailResp(message: string, code = -1): IPCResponse {
12 | return {
13 | code,
14 | message,
15 | data: null,
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/.erb/scripts/check-node-env.js:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk';
2 |
3 | export default function checkNodeEnv(expectedEnv) {
4 | if (!expectedEnv) {
5 | throw new Error('"expectedEnv" not set');
6 | }
7 |
8 | if (process.env.NODE_ENV !== expectedEnv) {
9 | console.log(
10 | chalk.whiteBright.bgRed.bold(
11 | `"process.env.NODE_ENV" must be "${expectedEnv}" to use this webpack config`
12 | )
13 | );
14 | process.exit(2);
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/main/util.ts:
--------------------------------------------------------------------------------
1 | /* eslint import/prefer-default-export: off */
2 | import { URL } from 'url';
3 | import path from 'path';
4 |
5 | export function resolveHtmlPath(htmlFileName: string) {
6 | if (process.env.NODE_ENV === 'development') {
7 | const port = process.env.PORT || 1212;
8 | const url = new URL(`http://localhost:${port}`);
9 | url.pathname = htmlFileName;
10 | return url.href;
11 | }
12 | return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`;
13 | }
14 |
--------------------------------------------------------------------------------
/.erb/scripts/check-port-in-use.js:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk';
2 | import detectPort from 'detect-port';
3 |
4 | const port = process.env.PORT || '1212';
5 |
6 | detectPort(port, (err, availablePort) => {
7 | if (port !== String(availablePort)) {
8 | throw new Error(
9 | chalk.whiteBright.bgRed.bold(
10 | `Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start`
11 | )
12 | );
13 | } else {
14 | process.exit(0);
15 | }
16 | });
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 |
5 | # Runtime data
6 | pids
7 | *.pid
8 | *.seed
9 |
10 | # Coverage directory used by tools like istanbul
11 | coverage
12 | .eslintcache
13 |
14 | # Dependency directory
15 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
16 | node_modules
17 |
18 | # OSX
19 | .DS_Store
20 |
21 | release/app/dist
22 | release/build
23 | .erb/dll
24 |
25 | .idea
26 | npm-debug.log.*
27 | *.css.d.ts
28 | *.sass.d.ts
29 | *.scss.d.ts
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Arcaea Assets Manager
2 |
3 | 一个[Arcaea](https://arcaea.lowiro.com/)资产管理器。
4 |
5 | ## 功能
6 |
7 | - 资产导入
8 | - 曲目导入,支持每次打包时自动更新曲目资源
9 | - 背景导入
10 | - 打包
11 | - ipa和apk打包
12 | - 依赖验证,检查可能被漏打包的文件
13 |
14 | ## 使用方法
15 |
16 | [看这里](https://www.direcore.xyz/archives/43/)
17 |
18 | ## Troubleshooting
19 |
20 | ### Linux
21 |
22 | #### 提示找不到libz.so
23 |
24 | ARM架构的AppImage打包可能存在这样的问题。参见:[这个Issue](https://github.com/AppImage/AppImageKit/issues/964)
25 |
26 | 你可以安装`zlib1g-dev`来解决这个问题,把`libz.so.1`链接到`libz.so`似乎也是可行的。
27 |
28 |
29 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "incremental": true,
4 | "target": "es2021",
5 | "module": "commonjs",
6 | "lib": ["dom", "es2021"],
7 | "jsx": "react-jsx",
8 | "strict": true,
9 | "sourceMap": true,
10 | "baseUrl": "./src",
11 | "moduleResolution": "node",
12 | "esModuleInterop": true,
13 | "allowSyntheticDefaultImports": true,
14 | "resolveJsonModule": true,
15 | "allowJs": true,
16 | "outDir": ".erb/dll"
17 | },
18 | "exclude": ["test", "release/build", "release/app/dist", ".erb/dll"]
19 | }
20 |
--------------------------------------------------------------------------------
/src/renderer/App.css:
--------------------------------------------------------------------------------
1 | /*
2 | * @NOTE: Prepend a `~` to css file paths that are in your node_modules
3 | * See https://github.com/webpack-contrib/sass-loader#imports
4 | */
5 |
6 | html {
7 | height: 100%;
8 | width: 100%;
9 | }
10 |
11 | * {
12 | transition: all 0.02s;
13 | }
14 |
15 | body {
16 | margin: 0;
17 | background-color: #fff;
18 | height: 100%;
19 | }
20 |
21 | @media (prefers-color-scheme: dark) {
22 | body {
23 | background-color: #141414;
24 | }
25 | }
26 |
27 | input:focus {
28 | outline: none;
29 | box-shadow: none;
30 | }
31 |
--------------------------------------------------------------------------------
/src/globalStore.ts:
--------------------------------------------------------------------------------
1 | import Store from 'electron-store';
2 | import { StoreType } from 'type';
3 |
4 | export const globalStore = new Store({
5 | defaults: {
6 | assets: {
7 | path: '',
8 | },
9 | settings: {
10 | logLevel: 'info',
11 | minimalRating: 0,
12 | ignoredSong:
13 | 'arcahv,tempestissimo,defection,infinitestrife,worldender,pentiment,arcanaeden,testify,lovelessdress,last,lasteternity,callimakarma,ignotusafterburn,redandblueandgreen,singularityvvvip,overdead,mismal',
14 | },
15 | },
16 | });
17 |
18 | export default { globalStore };
19 |
--------------------------------------------------------------------------------
/release/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "aam",
3 | "version": "0.0.2",
4 | "description": "一个Arcaea资产管理工具",
5 | "license": "MIT",
6 | "author": {
7 | "name": ".direwolf",
8 | "email": "canis@direcore.xyz",
9 | "url": "https://github.com/feightwywx/aam"
10 | },
11 | "main": "./dist/main/main.js",
12 | "scripts": {
13 | "rebuild": "node -r ts-node/register ../../.erb/scripts/electron-rebuild.js",
14 | "postinstall": "npm run rebuild && npm run link-modules",
15 | "link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts"
16 | },
17 | "dependencies": {}
18 | }
19 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 |
5 | # Runtime data
6 | pids
7 | *.pid
8 | *.seed
9 |
10 | # Coverage directory used by tools like istanbul
11 | coverage
12 | .eslintcache
13 |
14 | # Dependency directory
15 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
16 | node_modules
17 |
18 | # OSX
19 | .DS_Store
20 |
21 | release/app/dist
22 | release/build
23 | .erb/dll
24 |
25 | .idea
26 | npm-debug.log.*
27 | *.css.d.ts
28 | *.sass.d.ts
29 | *.scss.d.ts
30 |
31 | # eslint ignores hidden directories by default:
32 | # https://github.com/eslint/eslint/issues/8429
33 | !.erb
34 |
--------------------------------------------------------------------------------
/.erb/scripts/electron-rebuild.js:
--------------------------------------------------------------------------------
1 | import { execSync } from 'child_process';
2 | import fs from 'fs';
3 | import { dependencies } from '../../release/app/package.json';
4 | import webpackPaths from '../configs/webpack.paths';
5 |
6 | if (
7 | Object.keys(dependencies || {}).length > 0 &&
8 | fs.existsSync(webpackPaths.appNodeModulesPath)
9 | ) {
10 | const electronRebuildCmd =
11 | '../../node_modules/.bin/electron-rebuild --force --types prod,dev,optional --module-dir .';
12 | const cmd =
13 | process.platform === 'win32'
14 | ? electronRebuildCmd.replace(/\//g, '\\')
15 | : electronRebuildCmd;
16 | execSync(cmd, {
17 | cwd: webpackPaths.appPath,
18 | stdio: 'inherit',
19 | });
20 | }
21 |
--------------------------------------------------------------------------------
/src/renderer/components/MonacoEditor.tsx:
--------------------------------------------------------------------------------
1 | import * as monaco from 'monaco-editor';
2 | import OriginalMonaco, { EditorProps, loader } from '@monaco-editor/react';
3 | import React, { useEffect } from 'react';
4 | import { Spin } from 'antd';
5 |
6 | loader.config({ monaco });
7 |
8 | export const MonacoEditor: React.FC = (props) => {
9 | return (
10 | }
17 | // eslint-disable-next-line react/jsx-props-no-spreading
18 | {...props}
19 | />
20 | );
21 | };
22 |
23 | export default MonacoEditor;
24 |
--------------------------------------------------------------------------------
/src/stateSlices/assets.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit';
2 | import { Song } from 'type';
3 |
4 | interface assetsState {
5 | path: string;
6 | songs?: Song[];
7 | }
8 |
9 | const initialState: assetsState = {
10 | path: '',
11 | };
12 |
13 | export const assetsSlice = createSlice({
14 | name: 'assets',
15 | initialState,
16 | reducers: {
17 | setPath: (state, action: PayloadAction) => {
18 | state.path = action.payload;
19 | },
20 | setSongs: (state, action: PayloadAction) => {
21 | state.songs = action.payload;
22 | }
23 | },
24 | });
25 |
26 | export const { setPath, setSongs } = assetsSlice.actions;
27 | export default assetsSlice.reducer;
28 |
--------------------------------------------------------------------------------
/assets/assets.d.ts:
--------------------------------------------------------------------------------
1 | type Styles = Record;
2 |
3 | declare module '*.svg' {
4 | export const ReactComponent: React.FC>;
5 |
6 | const content: string;
7 | export default content;
8 | }
9 |
10 | declare module '*.png' {
11 | const content: string;
12 | export default content;
13 | }
14 |
15 | declare module '*.jpg' {
16 | const content: string;
17 | export default content;
18 | }
19 |
20 | declare module '*.scss' {
21 | const content: Styles;
22 | export default content;
23 | }
24 |
25 | declare module '*.sass' {
26 | const content: Styles;
27 | export default content;
28 | }
29 |
30 | declare module '*.css' {
31 | const content: Styles;
32 | export default content;
33 | }
34 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.associations": {
3 | ".eslintrc": "jsonc",
4 | ".prettierrc": "jsonc",
5 | ".eslintignore": "ignore"
6 | },
7 |
8 | "eslint.validate": [
9 | "javascript",
10 | "javascriptreact",
11 | "html",
12 | "typescriptreact"
13 | ],
14 |
15 | "javascript.validate.enable": false,
16 | "javascript.format.enable": false,
17 | "typescript.format.enable": false,
18 |
19 | "search.exclude": {
20 | ".git": true,
21 | ".eslintcache": true,
22 | ".erb/dll": true,
23 | "release/{build,app/dist}": true,
24 | "node_modules": true,
25 | "npm-debug.log.*": true,
26 | "test/**/__snapshots__": true,
27 | "package-lock.json": true,
28 | "*.{css,sass,scss}.d.ts": true
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "Electron: Main",
6 | "type": "node",
7 | "request": "launch",
8 | "protocol": "inspector",
9 | "runtimeExecutable": "npm",
10 | "runtimeArgs": ["run", "start"],
11 | "env": {
12 | "MAIN_ARGS": "--inspect=5858 --remote-debugging-port=9223"
13 | }
14 | },
15 | {
16 | "name": "Electron: Renderer",
17 | "type": "chrome",
18 | "request": "attach",
19 | "port": 9223,
20 | "webRoot": "${workspaceFolder}",
21 | "timeout": 15000
22 | }
23 | ],
24 | "compounds": [
25 | {
26 | "name": "Electron: All",
27 | "configurations": ["Electron: Main", "Electron: Renderer"]
28 | }
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/.erb/scripts/check-build-exists.ts:
--------------------------------------------------------------------------------
1 | // Check if the renderer and main bundles are built
2 | import path from 'path';
3 | import chalk from 'chalk';
4 | import fs from 'fs';
5 | import webpackPaths from '../configs/webpack.paths';
6 |
7 | const mainPath = path.join(webpackPaths.distMainPath, 'main.js');
8 | const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js');
9 |
10 | if (!fs.existsSync(mainPath)) {
11 | throw new Error(
12 | chalk.whiteBright.bgRed.bold(
13 | 'The main process is not built yet. Build it by running "npm run build:main"'
14 | )
15 | );
16 | }
17 |
18 | if (!fs.existsSync(rendererPath)) {
19 | throw new Error(
20 | chalk.whiteBright.bgRed.bold(
21 | 'The renderer process is not built yet. Build it by running "npm run build:renderer"'
22 | )
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/renderer/store.ts:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
3 | import assetsReducer from '../stateSlices/assets';
4 |
5 | const store = configureStore({
6 | reducer: { assets: assetsReducer },
7 | });
8 |
9 | export default store;
10 |
11 | // Infer the `RootState` and `AppDispatch` types from the store itself
12 | export type RootState = ReturnType
13 | // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
14 | export type AppDispatch = typeof store.dispatch
15 |
16 | // Use throughout your app instead of plain `useDispatch` and `useSelector`
17 | export const useAppDispatch: () => AppDispatch = useDispatch
18 | export const useAppSelector: TypedUseSelectorHook = useSelector
19 |
--------------------------------------------------------------------------------
/.erb/scripts/notarize.js:
--------------------------------------------------------------------------------
1 | const { notarize } = require('electron-notarize');
2 | const { build } = require('../../package.json');
3 |
4 | exports.default = async function notarizeMacos(context) {
5 | const { electronPlatformName, appOutDir } = context;
6 | if (electronPlatformName !== 'darwin') {
7 | return;
8 | }
9 |
10 | if (process.env.CI !== 'true') {
11 | console.warn('Skipping notarizing step. Packaging is not running in CI');
12 | return;
13 | }
14 |
15 | if (!('APPLE_ID' in process.env && 'APPLE_ID_PASS' in process.env)) {
16 | console.warn(
17 | 'Skipping notarizing step. APPLE_ID and APPLE_ID_PASS env variables must be set'
18 | );
19 | return;
20 | }
21 |
22 | const appName = context.packager.appInfo.productFilename;
23 |
24 | await notarize({
25 | appBundleId: build.appId,
26 | appPath: `${appOutDir}/${appName}.app`,
27 | appleId: process.env.APPLE_ID,
28 | appleIdPassword: process.env.APPLE_ID_PASS,
29 | });
30 | };
31 |
--------------------------------------------------------------------------------
/src/renderer/pages/Hello.tsx:
--------------------------------------------------------------------------------
1 | import './Hello.css';
2 | import { setPath, setSongs } from 'stateSlices/assets';
3 | import { Button } from 'antd';
4 | import { useNavigate } from 'react-router-dom';
5 |
6 | import { useAppDispatch, useAppSelector } from '../store';
7 |
8 | const Hello = () => {
9 | const assets = useAppSelector((state) => state.assets);
10 | const dispatch = useAppDispatch();
11 | const navigate = useNavigate();
12 |
13 | return (
14 | <>
15 |
30 | {assets.path}
31 | >
32 | );
33 | };
34 |
35 | export default Hello;
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2022 .direwolf
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.erb/configs/webpack.paths.ts:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const rootPath = path.join(__dirname, '../..');
4 |
5 | const dllPath = path.join(__dirname, '../dll');
6 |
7 | const srcPath = path.join(rootPath, 'src');
8 | const srcMainPath = path.join(srcPath, 'main');
9 | const srcRendererPath = path.join(srcPath, 'renderer');
10 |
11 | const releasePath = path.join(rootPath, 'release');
12 | const appPath = path.join(releasePath, 'app');
13 | const appPackagePath = path.join(appPath, 'package.json');
14 | const appNodeModulesPath = path.join(appPath, 'node_modules');
15 | const srcNodeModulesPath = path.join(srcPath, 'node_modules');
16 |
17 | const distPath = path.join(appPath, 'dist');
18 | const distMainPath = path.join(distPath, 'main');
19 | const distRendererPath = path.join(distPath, 'renderer');
20 |
21 | const buildPath = path.join(releasePath, 'build');
22 |
23 | export default {
24 | rootPath,
25 | dllPath,
26 | srcPath,
27 | srcMainPath,
28 | srcRendererPath,
29 | releasePath,
30 | appPath,
31 | appPackagePath,
32 | appNodeModulesPath,
33 | srcNodeModulesPath,
34 | distPath,
35 | distMainPath,
36 | distRendererPath,
37 | buildPath,
38 | };
39 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: 'erb',
3 | rules: {
4 | // A temporary hack related to IDE not resolving correct package.json
5 | 'import/no-extraneous-dependencies': 'off',
6 | 'import/no-unresolved': 'error',
7 | // Since React 17 and typescript 4.1 you can safely disable the rule
8 | 'react/react-in-jsx-scope': 'off',
9 | 'prettier/prettier': 'warn',
10 | // some custom rules
11 | 'no-underscore-dangle': 'off',
12 | 'no-restricted-syntax': 'off',
13 | 'no-await-in-loop': 'warn',
14 | },
15 | parserOptions: {
16 | ecmaVersion: 2020,
17 | sourceType: 'module',
18 | project: './tsconfig.json',
19 | tsconfigRootDir: __dirname,
20 | createDefaultProgram: true,
21 | },
22 | settings: {
23 | 'import/resolver': {
24 | // See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
25 | node: {},
26 | webpack: {
27 | config: require.resolve('./.erb/configs/webpack.config.eslint.ts'),
28 | },
29 | typescript: {},
30 | },
31 | 'import/parsers': {
32 | '@typescript-eslint/parser': ['.ts', '.tsx'],
33 | },
34 | },
35 | };
36 |
--------------------------------------------------------------------------------
/src/renderer/App.tsx:
--------------------------------------------------------------------------------
1 | import { HashRouter, Routes, Route } from 'react-router-dom';
2 | import { Provider as ReduxProvider } from 'react-redux';
3 | import { ConfigProvider, theme } from 'antd';
4 | import zhCN from 'antd/locale/zh_CN';
5 | import { useMediaQuery } from 'usehooks-ts';
6 |
7 | import './App.css';
8 | import { AppLayout } from './components/AppLayout';
9 | import Hello from './pages/Hello';
10 | import Songs from './pages/Songs';
11 | import store from './store';
12 | import Settings from './pages/Settings';
13 |
14 | export default function App() {
15 | const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
16 |
17 | return (
18 |
19 |
28 |
29 |
30 |
31 | } />
32 | } />
33 | } />
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/type.ts:
--------------------------------------------------------------------------------
1 | import { LogLevel } from 'electron-log';
2 |
3 | export interface IPCResponse {
4 | code: number;
5 | message: string;
6 | data: T;
7 | }
8 |
9 | export interface SongBase {
10 | idx: number;
11 | id: string;
12 | title_localized: {
13 | en: string;
14 | ja?: string;
15 | };
16 | artist: string;
17 | bpm: string;
18 | bpm_base: number;
19 | set: string;
20 | purchase: string;
21 | audioPreview: number;
22 | audioPreviewEnd: number;
23 | side: 0 | 1 | 2;
24 | bg: string;
25 | remote_dl: boolean;
26 | date: number;
27 | version: string;
28 | difficulties: SongDifficulty[];
29 | }
30 |
31 | export interface Song extends SongBase {
32 | _external?: string;
33 | }
34 |
35 | export interface SongDifficulty {
36 | ratingClass: 0 | 1 | 2 | 3;
37 | chartDesigner: string;
38 | jacketDesigner: string;
39 | rating: number;
40 | ratingPlus?: boolean;
41 | jacketOverride?: boolean;
42 | audioOverride?: boolean;
43 | hidden_until_unlocked?: true;
44 | hidden_until?: 'none' | 'always' | 'difficulty' | 'song';
45 | }
46 |
47 | export interface Songlist {
48 | songs: Song[];
49 | }
50 |
51 | export interface AssetDependence {
52 | dep: string;
53 | sourceID: string;
54 | }
55 |
56 | export interface StoreType {
57 | assets: {
58 | path: string;
59 | songs?: Song[];
60 | };
61 | settings: SettingsType;
62 | }
63 |
64 | export interface SettingsType {
65 | logLevel: LogLevel;
66 | minimalRating: number;
67 | ignoredSong: string;
68 | }
69 |
70 | export interface AppInfo {
71 | version: string;
72 | isDebug: boolean;
73 | }
74 |
--------------------------------------------------------------------------------
/.erb/configs/webpack.config.base.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Base webpack config used across other specific configs
3 | */
4 |
5 | import webpack from 'webpack';
6 | import CopyWebpackPlugin from 'copy-webpack-plugin';
7 | import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin';
8 | import webpackPaths from './webpack.paths';
9 | import { dependencies as externals } from '../../release/app/package.json';
10 |
11 | const configuration: webpack.Configuration = {
12 | externals: [...Object.keys(externals || {})],
13 |
14 | stats: 'errors-only',
15 |
16 | module: {
17 | rules: [
18 | {
19 | test: /\.[jt]sx?$/,
20 | exclude: /node_modules/,
21 | use: {
22 | loader: 'ts-loader',
23 | options: {
24 | // Remove this line to enable type checking in webpack builds
25 | transpileOnly: true,
26 | compilerOptions: {
27 | module: 'esnext',
28 | },
29 | },
30 | },
31 | },
32 | ],
33 | },
34 |
35 | output: {
36 | path: webpackPaths.srcPath,
37 | // https://github.com/webpack/webpack/issues/1114
38 | library: {
39 | type: 'commonjs2',
40 | },
41 | },
42 |
43 | /**
44 | * Determine the array of extensions that should be used to resolve modules.
45 | */
46 | resolve: {
47 | extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
48 | modules: [webpackPaths.srcPath, 'node_modules'],
49 | fallback: { path: require.resolve('path-browserify') },
50 | },
51 |
52 | plugins: [
53 | new webpack.EnvironmentPlugin({
54 | NODE_ENV: 'production',
55 | }),
56 | new MonacoWebpackPlugin(),
57 | ],
58 | };
59 |
60 | export default configuration;
61 |
--------------------------------------------------------------------------------
/src/renderer/preload.d.ts:
--------------------------------------------------------------------------------
1 | import { Channels } from 'main/preload';
2 | import { AppInfo, IPCResponse, Song, Songlist } from '../type';
3 |
4 | declare global {
5 | interface Window {
6 | electron: {
7 | ipcRenderer: {
8 | sendMessage(channel: Channels, args: unknown[]): void;
9 | on(
10 | channel: Channels,
11 | func: (...args: unknown[]) => void
12 | ): (() => void) | undefined;
13 | once(channel: Channels, func: (...args: unknown[]) => void): void;
14 | openDirectory: () => Promise;
15 | showLogFile: () => Promise;
16 | reset: () => Promise;
17 | getAppInfo: () => Promise;
18 |
19 | store: {
20 | get: (key: string) => unknown;
21 | set: (key: string, val: unknown) => void;
22 | // any other methods you've defined...
23 | };
24 | };
25 | };
26 |
27 | aam: {
28 | ipcRenderer: {
29 | loadSongs: (path: string) => Promise>;
30 | saveSonglist: (songlist: Songlist) => Promise;
31 | deleteSongs: (ids: string[]) => Promise;
32 |
33 | onCloseFolder: (
34 | callback: (event: Event, args: unknown) => void
35 | ) => void;
36 | onPushSongs: (
37 | callback: (
38 | event: Event,
39 | args: { path: string; songs: Song[] }
40 | ) => void
41 | ) => void;
42 | onStartGeneratePackage: (
43 | callback: (event: Event, args: unknown) => void
44 | ) => void;
45 | onStopGeneratePackage: (
46 | callback: (event: Event, args: unknown) => void
47 | ) => void;
48 | onLog: (callback: (event: Event, args: string) => void) => void;
49 | };
50 | };
51 | }
52 | }
53 |
54 | export {};
55 |
--------------------------------------------------------------------------------
/src/main/preload.ts:
--------------------------------------------------------------------------------
1 | import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
2 | import { Song, Songlist } from 'type';
3 |
4 | export type Channels = 'ipc-example';
5 |
6 | contextBridge.exposeInMainWorld('electron', {
7 | ipcRenderer: {
8 | store: {
9 | get(key: string) {
10 | return ipcRenderer.sendSync('electron-store-get', key);
11 | },
12 | set(property: string, val: unknown) {
13 | ipcRenderer.send('electron-store-set', property, val);
14 | },
15 | // Other method you want to add like has(), reset(), etc.
16 | },
17 | openDirectory: () => ipcRenderer.invoke('dialog:openDirectory'),
18 | showLogFile: () => ipcRenderer.invoke('showLogFile'),
19 | reset: () => ipcRenderer.invoke('reset'),
20 | getAppInfo: () => ipcRenderer.invoke('getAppInfo'),
21 | },
22 | });
23 |
24 | contextBridge.exposeInMainWorld('aam', {
25 | ipcRenderer: {
26 | loadSongs: (path: string) => ipcRenderer.invoke('aam:loadSongs', path),
27 | saveSonglist: (songlist: Songlist) =>
28 | ipcRenderer.invoke('aam:saveSonglist', songlist),
29 | deleteSongs: (ids: string[]) => ipcRenderer.invoke('aam:deleteSongs', ids),
30 |
31 | onCloseFolder: (callback: (event: Event, args: unknown) => void) =>
32 | ipcRenderer.on('aam:closeFolder', callback),
33 | onPushSongs: (callback: (event: Event, args: unknown) => void) => {
34 | ipcRenderer.on('aam:pushSongs', callback);
35 | },
36 | onStartGeneratePackage: (
37 | callback: (event: Event, args: unknown) => void
38 | ) => {
39 | ipcRenderer.on('aam:startGeneratePackage', callback);
40 | },
41 | onStopGeneratePackage: (
42 | callback: (event: Event, args: unknown) => void
43 | ) => {
44 | ipcRenderer.on('aam:stopGeneratePackage', callback);
45 | },
46 | onLog: (callback: (event: Event, args: string) => void) => {
47 | ipcRenderer.on('aam:log', callback);
48 | },
49 | },
50 | });
51 |
--------------------------------------------------------------------------------
/.erb/configs/webpack.config.renderer.dev.dll.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Builds the DLL for development electron renderer process
3 | */
4 |
5 | import webpack from 'webpack';
6 | import path from 'path';
7 | import { merge } from 'webpack-merge';
8 | import baseConfig from './webpack.config.base';
9 | import webpackPaths from './webpack.paths';
10 | import { dependencies } from '../../package.json';
11 | import checkNodeEnv from '../scripts/check-node-env';
12 |
13 | checkNodeEnv('development');
14 |
15 | const dist = webpackPaths.dllPath;
16 |
17 | const configuration: webpack.Configuration = {
18 | context: webpackPaths.rootPath,
19 |
20 | devtool: 'eval',
21 |
22 | mode: 'development',
23 |
24 | target: 'electron-renderer',
25 |
26 | externals: ['fsevents', 'crypto-browserify'],
27 |
28 | /**
29 | * Use `module` from `webpack.config.renderer.dev.js`
30 | */
31 | module: require('./webpack.config.renderer.dev').default.module,
32 |
33 | entry: {
34 | renderer: Object.keys(dependencies || {}),
35 | },
36 |
37 | output: {
38 | path: dist,
39 | filename: '[name].dev.dll.js',
40 | library: {
41 | name: 'renderer',
42 | type: 'var',
43 | },
44 | },
45 |
46 | plugins: [
47 | new webpack.DllPlugin({
48 | path: path.join(dist, '[name].json'),
49 | name: '[name]',
50 | }),
51 |
52 | /**
53 | * Create global constants which can be configured at compile time.
54 | *
55 | * Useful for allowing different behaviour between development builds and
56 | * release builds
57 | *
58 | * NODE_ENV should be production so that modules do not perform certain
59 | * development checks
60 | */
61 | new webpack.EnvironmentPlugin({
62 | NODE_ENV: 'development',
63 | }),
64 |
65 | new webpack.LoaderOptionsPlugin({
66 | debug: true,
67 | options: {
68 | context: webpackPaths.srcPath,
69 | output: {
70 | path: webpackPaths.dllPath,
71 | },
72 | },
73 | }),
74 | ],
75 | };
76 |
77 | export default merge(baseConfig, configuration);
78 |
--------------------------------------------------------------------------------
/.erb/configs/webpack.config.preload.dev.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import webpack from 'webpack';
3 | import { merge } from 'webpack-merge';
4 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
5 | import baseConfig from './webpack.config.base';
6 | import webpackPaths from './webpack.paths';
7 | import checkNodeEnv from '../scripts/check-node-env';
8 |
9 | // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
10 | // at the dev webpack config is not accidentally run in a production environment
11 | if (process.env.NODE_ENV === 'production') {
12 | checkNodeEnv('development');
13 | }
14 |
15 | const configuration: webpack.Configuration = {
16 | devtool: 'inline-source-map',
17 |
18 | mode: 'development',
19 |
20 | target: 'electron-preload',
21 |
22 | entry: path.join(webpackPaths.srcMainPath, 'preload.ts'),
23 |
24 | output: {
25 | path: webpackPaths.dllPath,
26 | filename: 'preload.js',
27 | library: {
28 | type: 'umd',
29 | },
30 | },
31 |
32 | plugins: [
33 | new BundleAnalyzerPlugin({
34 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
35 | }),
36 |
37 | /**
38 | * Create global constants which can be configured at compile time.
39 | *
40 | * Useful for allowing different behaviour between development builds and
41 | * release builds
42 | *
43 | * NODE_ENV should be production so that modules do not perform certain
44 | * development checks
45 | *
46 | * By default, use 'development' as NODE_ENV. This can be overriden with
47 | * 'staging', for example, by changing the ENV variables in the npm scripts
48 | */
49 | new webpack.EnvironmentPlugin({
50 | NODE_ENV: 'development',
51 | }),
52 |
53 | new webpack.LoaderOptionsPlugin({
54 | debug: true,
55 | }),
56 | ],
57 |
58 | /**
59 | * Disables webpack processing of __dirname and __filename.
60 | * If you run the bundle in node.js it falls back to these values of node.js.
61 | * https://github.com/webpack/webpack/issues/2010
62 | */
63 | node: {
64 | __dirname: false,
65 | __filename: false,
66 | },
67 |
68 | watch: true,
69 | };
70 |
71 | export default merge(baseConfig, configuration);
72 |
--------------------------------------------------------------------------------
/.erb/scripts/check-native-dep.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import chalk from 'chalk';
3 | import { execSync } from 'child_process';
4 | import { dependencies } from '../../package.json';
5 |
6 | if (dependencies) {
7 | const dependenciesKeys = Object.keys(dependencies);
8 | const nativeDeps = fs
9 | .readdirSync('node_modules')
10 | .filter((folder) => fs.existsSync(`node_modules/${folder}/binding.gyp`));
11 | if (nativeDeps.length === 0) {
12 | process.exit(0);
13 | }
14 | try {
15 | // Find the reason for why the dependency is installed. If it is installed
16 | // because of a devDependency then that is okay. Warn when it is installed
17 | // because of a dependency
18 | const { dependencies: dependenciesObject } = JSON.parse(
19 | execSync(`npm ls ${nativeDeps.join(' ')} --json`).toString()
20 | );
21 | const rootDependencies = Object.keys(dependenciesObject);
22 | const filteredRootDependencies = rootDependencies.filter((rootDependency) =>
23 | dependenciesKeys.includes(rootDependency)
24 | );
25 | if (filteredRootDependencies.length > 0) {
26 | const plural = filteredRootDependencies.length > 1;
27 | console.log(`
28 | ${chalk.whiteBright.bgYellow.bold(
29 | 'Webpack does not work with native dependencies.'
30 | )}
31 | ${chalk.bold(filteredRootDependencies.join(', '))} ${
32 | plural ? 'are native dependencies' : 'is a native dependency'
33 | } and should be installed inside of the "./release/app" folder.
34 | First, uninstall the packages from "./package.json":
35 | ${chalk.whiteBright.bgGreen.bold('npm uninstall your-package')}
36 | ${chalk.bold(
37 | 'Then, instead of installing the package to the root "./package.json":'
38 | )}
39 | ${chalk.whiteBright.bgRed.bold('npm install your-package')}
40 | ${chalk.bold('Install the package to "./release/app/package.json"')}
41 | ${chalk.whiteBright.bgGreen.bold(
42 | 'cd ./release/app && npm install your-package'
43 | )}
44 | Read more about native dependencies at:
45 | ${chalk.bold(
46 | 'https://electron-react-boilerplate.js.org/docs/adding-dependencies/#module-structure'
47 | )}
48 | `);
49 | process.exit(1);
50 | }
51 | } catch (e) {
52 | console.log('Native dependencies could not be checked');
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/.erb/configs/webpack.config.main.prod.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Webpack config for production electron main process
3 | */
4 |
5 | import path from 'path';
6 | import webpack from 'webpack';
7 | import { merge } from 'webpack-merge';
8 | import TerserPlugin from 'terser-webpack-plugin';
9 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
10 | import baseConfig from './webpack.config.base';
11 | import webpackPaths from './webpack.paths';
12 | import checkNodeEnv from '../scripts/check-node-env';
13 | import deleteSourceMaps from '../scripts/delete-source-maps';
14 |
15 | checkNodeEnv('production');
16 | deleteSourceMaps();
17 |
18 | const configuration: webpack.Configuration = {
19 | devtool: 'source-map',
20 |
21 | mode: 'production',
22 |
23 | target: 'electron-main',
24 |
25 | entry: {
26 | main: path.join(webpackPaths.srcMainPath, 'main.ts'),
27 | preload: path.join(webpackPaths.srcMainPath, 'preload.ts'),
28 | },
29 |
30 | output: {
31 | path: webpackPaths.distMainPath,
32 | filename: '[name].js',
33 | library: {
34 | type: 'umd',
35 | },
36 | },
37 |
38 | optimization: {
39 | minimizer: [
40 | new TerserPlugin({
41 | parallel: true,
42 | }),
43 | ],
44 | },
45 |
46 | plugins: [
47 | new BundleAnalyzerPlugin({
48 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
49 | analyzerPort: 8888,
50 | }),
51 |
52 | /**
53 | * Create global constants which can be configured at compile time.
54 | *
55 | * Useful for allowing different behaviour between development builds and
56 | * release builds
57 | *
58 | * NODE_ENV should be production so that modules do not perform certain
59 | * development checks
60 | */
61 | new webpack.EnvironmentPlugin({
62 | NODE_ENV: 'production',
63 | DEBUG_PROD: false,
64 | START_MINIMIZED: false,
65 | }),
66 |
67 | new webpack.DefinePlugin({
68 | 'process.type': '"main"',
69 | }),
70 | ],
71 |
72 | /**
73 | * Disables webpack processing of __dirname and __filename.
74 | * If you run the bundle in node.js it falls back to these values of node.js.
75 | * https://github.com/webpack/webpack/issues/2010
76 | */
77 | node: {
78 | __dirname: false,
79 | __filename: false,
80 | },
81 | };
82 |
83 | export default merge(baseConfig, configuration);
84 |
--------------------------------------------------------------------------------
/src/main/utils/songDeps.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import log from 'electron-log';
3 |
4 | import type { AssetDependence, Song } from 'type';
5 | import { globalStore } from '../../globalStore';
6 |
7 | export async function buildSrcSongDepList(song: Song): Promise {
8 | log.info(`buildSrcSongDepList(): build for ${song.id}`);
9 | const deps = [];
10 |
11 | deps.push('base.ogg');
12 | deps.push('base.jpg');
13 | deps.push('base_256.jpg');
14 |
15 | song.difficulties.forEach((diff) => {
16 | if (diff.rating >= globalStore.store.settings.minimalRating) {
17 | deps.push(`${diff.ratingClass}.aff`);
18 | }
19 |
20 | if (diff.jacketOverride) {
21 | deps.push(`${diff.ratingClass}.jpg`);
22 | deps.push(`${diff.ratingClass}_256.jpg`);
23 | }
24 |
25 | if (diff.audioOverride) {
26 | deps.push(`${diff.ratingClass}.ogg`);
27 | }
28 | });
29 |
30 | return deps;
31 | }
32 |
33 | export async function buildAssetsSongDepList(
34 | song: Song
35 | ): Promise {
36 | log.info(`buildAssetsSongDepList(): build for ${song.id}`);
37 | if (globalStore.store.settings.ignoredSong.split(',').includes(song.id)) {
38 | log.info(`skipped, song in ignoredSong`);
39 | return [];
40 | }
41 |
42 | const deps: AssetDependence[] = [];
43 |
44 | function pushSongDeps(dep: string) {
45 | deps.push({
46 | dep: path.join('songs', song.remote_dl ? `dl_${song.id}` : song.id, dep),
47 | sourceID: song.id,
48 | });
49 | }
50 |
51 | function pushBgDeps(dep: string) {
52 | deps.push({ dep: path.join('img', 'bg', dep), sourceID: song.id });
53 | }
54 |
55 | if (song.bg) {
56 | pushBgDeps(`${song.bg}.jpg`);
57 | } else {
58 | switch (song.side) {
59 | case 0:
60 | pushBgDeps('base_light.jpg');
61 | break;
62 | case 1:
63 | pushBgDeps('base_conflict.jpg');
64 | break;
65 | default:
66 | break;
67 | }
68 | }
69 |
70 | pushSongDeps('base.jpg');
71 | pushSongDeps('base_256.jpg');
72 | song.difficulties.forEach((diff) => {
73 | if (diff.jacketOverride) {
74 | pushSongDeps(`${diff.ratingClass}.jpg`);
75 | pushSongDeps(`${diff.ratingClass}_256.jpg`);
76 | }
77 | });
78 |
79 | if (song.remote_dl) {
80 | pushSongDeps('preview.ogg');
81 |
82 | song.difficulties.forEach((diff) => {
83 | if (diff.audioOverride) {
84 | pushSongDeps(`${diff.ratingClass}_preview.ogg`);
85 | }
86 | });
87 | } else {
88 | pushSongDeps('base.ogg');
89 |
90 | song.difficulties.forEach((diff) => {
91 | if (diff.rating >= globalStore.store.settings.minimalRating) {
92 | pushSongDeps(`${diff.ratingClass}.aff`);
93 | }
94 |
95 | if (diff.audioOverride) {
96 | pushSongDeps(`${diff.ratingClass}.ogg`);
97 | }
98 | });
99 | }
100 |
101 | return deps;
102 | }
103 |
104 | export default { buildSrcSongDepList };
105 |
--------------------------------------------------------------------------------
/src/renderer/components/AppLayout.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Layout, Menu, message, theme } from 'antd';
2 | import type { MenuProps } from 'antd';
3 | import PropTypes from 'prop-types';
4 | import { useAppDispatch, useAppSelector } from 'renderer/store';
5 | import { useLocation, useNavigate, useRoutes } from 'react-router-dom';
6 | import { setPath, setSongs } from 'stateSlices/assets';
7 | import { useEffect } from 'react';
8 | import {
9 | AlignLeftOutlined,
10 | PictureOutlined,
11 | SettingOutlined,
12 | } from '@ant-design/icons';
13 |
14 | export const AppLayout: React.FC<{
15 | children: React.ReactNode;
16 | siderHidden?: boolean;
17 | }> = ({ children, siderHidden }) => {
18 | const {
19 | token: { colorBgContainer },
20 | } = theme.useToken();
21 | const { path } = useAppSelector((state) => state.assets);
22 |
23 | const assets = useAppSelector((state) => state.assets);
24 | const dispatch = useAppDispatch();
25 | const navigate = useNavigate();
26 | const location = useLocation();
27 |
28 | const [messageApi, contextHolder] = message.useMessage();
29 | console.log(location.pathname);
30 |
31 | const MainMenu: MenuProps['items'] = [
32 | {
33 | key: 'songs',
34 | label: '曲目',
35 | icon: ,
36 | onClick: () => navigate('/songs'),
37 | },
38 | {
39 | key: 'settings',
40 | label: '设置',
41 | icon: ,
42 | onClick: () => navigate('/settings'),
43 | },
44 | ];
45 |
46 | useEffect(() => {
47 | window.electron.ipcRenderer.store.set('assets', assets);
48 | if (!assets.path) {
49 | navigate('/');
50 | }
51 | }, [assets, navigate]);
52 |
53 | // 注册ipc listener
54 | useEffect(() => {
55 | window.aam.ipcRenderer.onCloseFolder(() => {
56 | dispatch(setPath(''));
57 | navigate('/');
58 | });
59 |
60 | window.aam.ipcRenderer.onPushSongs((_, args) => {
61 | dispatch(setPath(args.path));
62 | dispatch(setSongs(args.songs));
63 | navigate('/songs');
64 | });
65 | }, [dispatch, navigate]);
66 |
67 | return (
68 |
69 | {contextHolder}
70 |
83 |
96 |
103 | {children}
104 |
105 |
106 | );
107 | };
108 |
109 | export default AppLayout;
110 |
111 | AppLayout.propTypes = {
112 | children: PropTypes.element.isRequired,
113 | siderHidden: PropTypes.bool,
114 | };
115 |
116 | AppLayout.defaultProps = {
117 | siderHidden: false,
118 | };
119 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at electronreactboilerplate@gmail.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/.erb/configs/webpack.config.renderer.prod.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Build config for electron renderer process
3 | */
4 |
5 | import path from 'path';
6 | import webpack from 'webpack';
7 | import HtmlWebpackPlugin from 'html-webpack-plugin';
8 | import MiniCssExtractPlugin from 'mini-css-extract-plugin';
9 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
10 | import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
11 | import { merge } from 'webpack-merge';
12 | import TerserPlugin from 'terser-webpack-plugin';
13 | import baseConfig from './webpack.config.base';
14 | import webpackPaths from './webpack.paths';
15 | import checkNodeEnv from '../scripts/check-node-env';
16 | import deleteSourceMaps from '../scripts/delete-source-maps';
17 |
18 | checkNodeEnv('production');
19 | deleteSourceMaps();
20 |
21 | const configuration: webpack.Configuration = {
22 | devtool: 'source-map',
23 |
24 | mode: 'production',
25 |
26 | target: ['web', 'electron-renderer'],
27 |
28 | entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
29 |
30 | output: {
31 | path: webpackPaths.distRendererPath,
32 | publicPath: './',
33 | filename: 'renderer.js',
34 | library: {
35 | type: 'umd',
36 | },
37 | },
38 |
39 | module: {
40 | rules: [
41 | {
42 | test: /\.s?(a|c)ss$/,
43 | use: [
44 | MiniCssExtractPlugin.loader,
45 | {
46 | loader: 'css-loader',
47 | options: {
48 | modules: true,
49 | sourceMap: true,
50 | importLoaders: 1,
51 | },
52 | },
53 | 'sass-loader',
54 | ],
55 | include: /\.module\.s?(c|a)ss$/,
56 | },
57 | {
58 | test: /\.s?(a|c)ss$/,
59 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
60 | exclude: /\.module\.s?(c|a)ss$/,
61 | },
62 | // Fonts
63 | {
64 | test: /\.(woff|woff2|eot|ttf|otf)$/i,
65 | type: 'asset/resource',
66 | },
67 | // Images
68 | {
69 | test: /\.(png|jpg|jpeg|gif)$/i,
70 | type: 'asset/resource',
71 | },
72 | // SVG
73 | {
74 | test: /\.svg$/,
75 | use: [
76 | {
77 | loader: '@svgr/webpack',
78 | options: {
79 | prettier: false,
80 | svgo: false,
81 | svgoConfig: {
82 | plugins: [{ removeViewBox: false }],
83 | },
84 | titleProp: true,
85 | ref: true,
86 | },
87 | },
88 | 'file-loader',
89 | ],
90 | },
91 | ],
92 | },
93 |
94 | optimization: {
95 | minimize: true,
96 | minimizer: [
97 | new TerserPlugin({
98 | parallel: true,
99 | }),
100 | new CssMinimizerPlugin(),
101 | ],
102 | },
103 |
104 | plugins: [
105 | /**
106 | * Create global constants which can be configured at compile time.
107 | *
108 | * Useful for allowing different behaviour between development builds and
109 | * release builds
110 | *
111 | * NODE_ENV should be production so that modules do not perform certain
112 | * development checks
113 | */
114 | new webpack.EnvironmentPlugin({
115 | NODE_ENV: 'production',
116 | DEBUG_PROD: false,
117 | }),
118 |
119 | new MiniCssExtractPlugin({
120 | filename: 'style.css',
121 | }),
122 |
123 | new BundleAnalyzerPlugin({
124 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
125 | analyzerPort: 8889,
126 | }),
127 |
128 | new HtmlWebpackPlugin({
129 | filename: 'index.html',
130 | template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
131 | minify: {
132 | collapseWhitespace: true,
133 | removeAttributeQuotes: true,
134 | removeComments: true,
135 | },
136 | isBrowser: false,
137 | isDevelopment: process.env.NODE_ENV !== 'production',
138 | }),
139 |
140 | new webpack.DefinePlugin({
141 | 'process.type': '"renderer"',
142 | }),
143 | ],
144 | };
145 |
146 | export default merge(baseConfig, configuration);
147 |
--------------------------------------------------------------------------------
/src/renderer/pages/Settings.tsx:
--------------------------------------------------------------------------------
1 | import { ExclamationCircleFilled } from '@ant-design/icons';
2 | import {
3 | Button,
4 | Form,
5 | Input,
6 | InputNumber,
7 | Layout,
8 | Modal,
9 | Select,
10 | Space,
11 | Typography,
12 | message,
13 | } from 'antd';
14 | import React, { useState } from 'react';
15 | import { AppInfo, SettingsType } from 'type';
16 |
17 | const Settings: React.FC = () => {
18 | const [form] = Form.useForm();
19 | const settings = window.electron.ipcRenderer.store.get('settings') as
20 | | SettingsType
21 | | undefined;
22 | console.log(settings);
23 |
24 | const [messageApi] = message.useMessage();
25 |
26 | const [appInfo, setAppInfo] = useState();
27 | const appInfoPromise = window.electron.ipcRenderer
28 | .getAppInfo()
29 | .then((resp) => setAppInfo(resp));
30 |
31 | return (
32 |
33 |
59 |
60 |
61 |
76 |
77 | 控制日志文件的记录等级。
78 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 | 低于这个标级的难度将被视为谱面不存在。0表示“?”难度。不支持难度中的“+”。
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | 需要忽略的曲目id。多个曲目id用“,”隔开。
106 |
107 |
108 |
109 |
110 |
111 |
112 |
132 |
133 | 如果您遇到了问题,可以尝试初始化AAM。这将把所有设置恢复到默认状态。
134 |
135 |
136 |
137 |
138 |
139 | aam -{' '}
140 | {appInfo?.isDebug
141 | ? `development mode, electron ${appInfo.version}`
142 | : appInfo?.version}
143 |
144 |
145 |
146 |
147 | );
148 | };
149 |
150 | export default Settings;
151 |
--------------------------------------------------------------------------------
/.erb/configs/webpack.config.renderer.dev.ts:
--------------------------------------------------------------------------------
1 | import 'webpack-dev-server';
2 | import path from 'path';
3 | import fs from 'fs';
4 | import webpack from 'webpack';
5 | import HtmlWebpackPlugin from 'html-webpack-plugin';
6 | import chalk from 'chalk';
7 | import { merge } from 'webpack-merge';
8 | import { execSync, spawn } from 'child_process';
9 | import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
10 | import baseConfig from './webpack.config.base';
11 | import webpackPaths from './webpack.paths';
12 | import checkNodeEnv from '../scripts/check-node-env';
13 |
14 | // When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
15 | // at the dev webpack config is not accidentally run in a production environment
16 | if (process.env.NODE_ENV === 'production') {
17 | checkNodeEnv('development');
18 | }
19 |
20 | const port = process.env.PORT || 1212;
21 | const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json');
22 | const skipDLLs =
23 | module.parent?.filename.includes('webpack.config.renderer.dev.dll') ||
24 | module.parent?.filename.includes('webpack.config.eslint');
25 |
26 | /**
27 | * Warn if the DLL is not built
28 | */
29 | if (
30 | !skipDLLs &&
31 | !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))
32 | ) {
33 | console.log(
34 | chalk.black.bgYellow.bold(
35 | 'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"'
36 | )
37 | );
38 | execSync('npm run postinstall');
39 | }
40 |
41 | const configuration: webpack.Configuration = {
42 | devtool: 'inline-source-map',
43 |
44 | mode: 'development',
45 |
46 | target: ['web', 'electron-renderer'],
47 |
48 | entry: [
49 | `webpack-dev-server/client?http://localhost:${port}/dist`,
50 | 'webpack/hot/only-dev-server',
51 | path.join(webpackPaths.srcRendererPath, 'index.tsx'),
52 | ],
53 |
54 | output: {
55 | path: webpackPaths.distRendererPath,
56 | publicPath: '/',
57 | filename: 'renderer.dev.js',
58 | library: {
59 | type: 'umd',
60 | },
61 | },
62 |
63 | module: {
64 | rules: [
65 | {
66 | test: /\.s?(c|a)ss$/,
67 | use: [
68 | 'style-loader',
69 | {
70 | loader: 'css-loader',
71 | options: {
72 | modules: true,
73 | sourceMap: true,
74 | importLoaders: 1,
75 | },
76 | },
77 | 'sass-loader',
78 | ],
79 | include: /\.module\.s?(c|a)ss$/,
80 | },
81 | {
82 | test: /\.s?css$/,
83 | use: ['style-loader', 'css-loader', 'sass-loader'],
84 | exclude: /\.module\.s?(c|a)ss$/,
85 | },
86 | // Fonts
87 | {
88 | test: /\.(woff|woff2|eot|ttf|otf)$/i,
89 | type: 'asset/resource',
90 | },
91 | // Images
92 | {
93 | test: /\.(png|jpg|jpeg|gif)$/i,
94 | type: 'asset/resource',
95 | },
96 | // SVG
97 | {
98 | test: /\.svg$/,
99 | use: [
100 | {
101 | loader: '@svgr/webpack',
102 | options: {
103 | prettier: false,
104 | svgo: false,
105 | svgoConfig: {
106 | plugins: [{ removeViewBox: false }],
107 | },
108 | titleProp: true,
109 | ref: true,
110 | },
111 | },
112 | 'file-loader',
113 | ],
114 | },
115 | ],
116 | },
117 | plugins: [
118 | ...(skipDLLs
119 | ? []
120 | : [
121 | new webpack.DllReferencePlugin({
122 | context: webpackPaths.dllPath,
123 | manifest: require(manifest),
124 | sourceType: 'var',
125 | }),
126 | ]),
127 |
128 | new webpack.NoEmitOnErrorsPlugin(),
129 |
130 | /**
131 | * Create global constants which can be configured at compile time.
132 | *
133 | * Useful for allowing different behaviour between development builds and
134 | * release builds
135 | *
136 | * NODE_ENV should be production so that modules do not perform certain
137 | * development checks
138 | *
139 | * By default, use 'development' as NODE_ENV. This can be overriden with
140 | * 'staging', for example, by changing the ENV variables in the npm scripts
141 | */
142 | new webpack.EnvironmentPlugin({
143 | NODE_ENV: 'development',
144 | }),
145 |
146 | new webpack.LoaderOptionsPlugin({
147 | debug: true,
148 | }),
149 |
150 | new ReactRefreshWebpackPlugin(),
151 |
152 | new HtmlWebpackPlugin({
153 | filename: path.join('index.html'),
154 | template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
155 | minify: {
156 | collapseWhitespace: true,
157 | removeAttributeQuotes: true,
158 | removeComments: true,
159 | },
160 | isBrowser: false,
161 | env: process.env.NODE_ENV,
162 | isDevelopment: process.env.NODE_ENV !== 'production',
163 | nodeModules: webpackPaths.appNodeModulesPath,
164 | }),
165 | ],
166 |
167 | node: {
168 | __dirname: false,
169 | __filename: false,
170 | },
171 |
172 | devServer: {
173 | port,
174 | compress: true,
175 | hot: true,
176 | headers: { 'Access-Control-Allow-Origin': '*' },
177 | static: {
178 | publicPath: '/',
179 | },
180 | historyApiFallback: {
181 | verbose: true,
182 | },
183 | setupMiddlewares(middlewares) {
184 | console.log('Starting preload.js builder...');
185 | const preloadProcess = spawn('npm', ['run', 'start:preload'], {
186 | shell: true,
187 | stdio: 'inherit',
188 | })
189 | .on('close', (code: number) => process.exit(code!))
190 | .on('error', (spawnError) => console.error(spawnError));
191 |
192 | console.log('Starting Main Process...');
193 | let args = ['run', 'start:main'];
194 | if (process.env.MAIN_ARGS) {
195 | args = args.concat(
196 | ['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat()
197 | );
198 | }
199 | spawn('npm', args, {
200 | shell: true,
201 | stdio: 'inherit',
202 | })
203 | .on('close', (code: number) => {
204 | preloadProcess.kill();
205 | process.exit(code!);
206 | })
207 | .on('error', (spawnError) => console.error(spawnError));
208 | return middlewares;
209 | },
210 | },
211 | };
212 |
213 | export default merge(baseConfig, configuration);
214 |
--------------------------------------------------------------------------------
/src/main/menuHandler.ts:
--------------------------------------------------------------------------------
1 | import { BrowserWindow, dialog } from 'electron';
2 | import fs from 'fs';
3 | import fsAsync from 'fs/promises';
4 | import path from 'path';
5 | import log from 'electron-log';
6 |
7 | import type { AssetDependence, Song, Songlist } from 'type';
8 | import { globalStore } from '../globalStore';
9 | import { importSong, makePackage, mergeSonglist } from './utils/assets';
10 | import { buildAssetsSongDepList } from './utils/songDeps';
11 |
12 | export function importSongMenuHandlerFactory(
13 | mainWindow: BrowserWindow,
14 | external = false
15 | ) {
16 | return async () => {
17 | const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
18 | properties: [
19 | 'openDirectory',
20 | 'treatPackageAsDirectory',
21 | 'multiSelections',
22 | ],
23 | });
24 | if (!canceled) {
25 | const songs: Song[] = [];
26 | const failedPaths: string[] = [];
27 | const assetsPath = globalStore.get('assets.path') as string;
28 | for (const srcPath of filePaths) {
29 | const imported = await importSong(srcPath, assetsPath);
30 | if (imported) {
31 | if (external) {
32 | imported._external = srcPath;
33 | }
34 | songs.push(imported);
35 | } else {
36 | failedPaths.push(srcPath);
37 | }
38 | }
39 | const mergedSongs = await mergeSonglist(
40 | songs,
41 | globalStore.get('assets.songs') as Song[]
42 | );
43 | globalStore.set('assets.songs', mergedSongs);
44 | fs.writeFileSync(
45 | path.join(assetsPath, 'songs', 'songlist'),
46 | JSON.stringify({ songs: mergedSongs }, null, 2)
47 | );
48 |
49 | dialog.showMessageBox({
50 | message: '\n'.concat(
51 | `已导入 ${songs.length} 首歌曲`,
52 | failedPaths.length === 0
53 | ? ''
54 | : `\n\n以下歌曲导入失败:\n${failedPaths.join('\n')}`
55 | ),
56 | type: failedPaths.length === 0 ? 'info' : 'error',
57 | });
58 | }
59 | };
60 | }
61 |
62 | export function importBgMenuHandlerFactory(mainWindow: BrowserWindow) {
63 | return async () => {
64 | const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
65 | properties: ['openFile', 'treatPackageAsDirectory', 'multiSelections'],
66 | filters: [{ name: '背景图片', extensions: ['jpg'] }],
67 | });
68 | if (!canceled) {
69 | const importedBgs = [];
70 | const failedPaths: string[] = [];
71 | const assetsPath = globalStore.get('assets.path') as string;
72 | const imgBgPath = path.join(assetsPath, 'img', 'bg');
73 | filePaths.forEach((srcBgPath) => {
74 | const destBgPath = path.join(imgBgPath, path.basename(srcBgPath));
75 | log.verbose(`${srcBgPath} -> ${destBgPath}`);
76 | try {
77 | fs.copyFileSync(srcBgPath, destBgPath);
78 | importedBgs.push(srcBgPath);
79 | } catch (e) {
80 | log.error(e);
81 | failedPaths.push(srcBgPath);
82 | }
83 | });
84 |
85 | dialog.showMessageBox({
86 | message: '\n'.concat(
87 | `已导入 ${importedBgs.length} 张背景`,
88 | failedPaths.length === 0
89 | ? ''
90 | : `\n\n以下背景导入失败:\n${failedPaths.join('\n')}`
91 | ),
92 | type: failedPaths.length === 0 ? 'info' : 'error',
93 | });
94 | }
95 | };
96 | }
97 |
98 | export function generatePackageMenuHandlerFactory(mainWindow: BrowserWindow) {
99 | return async () => {
100 | const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
101 | title: '选择导出安装包格式与位置',
102 | filters: [
103 | { name: 'iOS Application', extensions: ['ipa'] },
104 | { name: 'Android Package', extensions: ['apk'] },
105 | ],
106 | properties: ['createDirectory'],
107 | });
108 | if (!canceled && filePath) {
109 | const savePath = filePath;
110 | const saveExt = path.extname(savePath);
111 |
112 | const assetsPath = globalStore.get('assets.path') as string;
113 | const packageRoot = path.resolve(
114 | assetsPath,
115 | saveExt === '.ipa' ? '../..' : '..'
116 | );
117 |
118 | const songs = globalStore.get('assets.songs') as Song[];
119 | const songCopyPromises = songs.map((song) => {
120 | if (song._external) {
121 | log.info(`外置依赖 ${song._external}`);
122 | return importSong(song._external, assetsPath);
123 | }
124 | return new Promise((resolve) => {
125 | resolve();
126 | });
127 | });
128 | const copyExtResult = await Promise.allSettled(songCopyPromises).catch(
129 | (e) => {
130 | dialog.showErrorBox('外部文件复制失败', e.message);
131 | }
132 | );
133 |
134 | if (
135 | copyExtResult &&
136 | copyExtResult.filter((r) => r.status === 'rejected').length < 1
137 | ) {
138 | const { response } = await dialog.showMessageBox(mainWindow, {
139 | message: `确定包路径为 ${packageRoot} 吗?`,
140 | type: 'question',
141 | buttons: ['好', '取消'],
142 | defaultId: 0,
143 | title: '确认包路径',
144 | detail:
145 | '这个文件夹应该包含所有需要被打包的文件。\n\n此外,强烈建议您在打包之前进行依赖验证。',
146 | cancelId: 1,
147 | });
148 | if (response === 0) {
149 | makePackage(packageRoot, savePath, mainWindow);
150 | }
151 | }
152 | }
153 | };
154 | }
155 |
156 | export function verifyMenuHandlerFactory(mainWindow: BrowserWindow) {
157 | return async () => {
158 | const assetsPath = globalStore.get('assets.path') as string;
159 | const songsPath = path.join(assetsPath, 'songs');
160 | const songlistPath = path.join(songsPath, 'songlist');
161 |
162 | const songlistFile = await fsAsync.readFile(songlistPath);
163 | const songlist = JSON.parse(songlistFile.toString()) as Songlist;
164 |
165 | const songlistDepPromises = songlist.songs.map((song) => {
166 | return buildAssetsSongDepList(song);
167 | });
168 |
169 | const songlistDeps = await Promise.all(songlistDepPromises);
170 | log.debug(songlistDeps);
171 | const flattenDeps = songlistDeps.flat();
172 |
173 | const errPath: AssetDependence[] = [];
174 | flattenDeps.forEach((dep) => {
175 | if (!fs.existsSync(path.join(assetsPath, dep.dep))) {
176 | errPath.push(dep);
177 | }
178 | });
179 |
180 | let dialogType = 'info';
181 | let errCount = 0;
182 | const errHints: string[] = [];
183 | errPath.forEach((err) => {
184 | if (err.dep.endsWith('3.aff')) {
185 | dialogType = 'warning';
186 | errHints.push(
187 | `[W] ${err.sourceID} 的Beyond难度已定义但是谱面不存在,可能需要在线加载。`
188 | );
189 | } else {
190 | dialogType = 'error';
191 | errCount += 1;
192 | errHints.unshift(`[E] ${err.sourceID} 需要 ${err.dep},但是它不存在。`);
193 | }
194 | });
195 |
196 | dialog.showMessageBox(mainWindow, {
197 | message:
198 | errPath.length === 0
199 | ? '没有检测到依赖问题。'
200 | : `检测到以下问题,其中有 ${errCount} 个错误。`,
201 | detail: errHints.join('\n'),
202 | type: dialogType,
203 | });
204 | };
205 | }
206 |
--------------------------------------------------------------------------------
/src/main/utils/assets.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import fs from 'fs';
3 | import fsAsync from 'fs/promises';
4 | import log from 'electron-log';
5 | import archiver, { ProgressData } from 'archiver';
6 | import { app, BrowserWindow, dialog } from 'electron';
7 |
8 | import { makeSuccessResp, makeFailResp } from './ipcResponse';
9 | import type { Song, Songlist } from '../../type';
10 | import { buildSrcSongDepList } from './songDeps';
11 | import { globalStore } from '../../globalStore';
12 |
13 | export async function loadSonglistIPC(assetsPath: string) {
14 | log.info(`loadSonglistIPC(): assets: ${assetsPath}`);
15 | const songlistPath = path.join(assetsPath, 'songs', 'songlist');
16 | if (fs.existsSync(songlistPath)) {
17 | const songlistString = fs.readFileSync(songlistPath).toString();
18 |
19 | try {
20 | const songlist = JSON.parse(songlistString);
21 | if ('songs' in songlist && Array.isArray(songlist.songs)) {
22 | return makeSuccessResp(songlist.songs);
23 | }
24 | return makeFailResp('songlist格式错误');
25 | } catch (e) {
26 | return makeFailResp((e as Error).toString());
27 | }
28 | }
29 | return makeFailResp('不合法的Assets文件夹:未找到songlist');
30 | }
31 |
32 | export async function saveSonglistIPC(songlist: Songlist) {
33 | log.info(`saveSongsIPC()`);
34 | const assetsPath = globalStore.get('assets.path') as string;
35 | const songlistPath = path.join(assetsPath, 'songs', 'songlist');
36 | if (fs.existsSync(songlistPath)) {
37 | try {
38 | fs.writeFileSync(songlistPath, JSON.stringify(songlist, undefined, 2));
39 | return makeSuccessResp({});
40 | } catch (e) {
41 | return makeFailResp((e as Error).toString());
42 | }
43 | }
44 | return makeFailResp('不合法的Assets文件夹:未找到songlist');
45 | }
46 |
47 | export async function deleteSongsIPC(ids: string[]) {
48 | log.info(`deleteSongsIPC(): ${ids.join(',')}`);
49 | const assetsPath = globalStore.get('assets.path') as string;
50 | const deleteSongPromises = ids.map(
51 | (songId) =>
52 | new Promise((resolve, reject) => {
53 | const songDir = path.join(assetsPath, 'songs', songId);
54 | log.info(`deleting ${songDir}`);
55 | try {
56 | fs.rmSync(songDir, { recursive: true, force: true });
57 | resolve();
58 | } catch (e) {
59 | log.error(e);
60 | reject(e);
61 | }
62 | })
63 | );
64 | const deleteSongResults = await Promise.allSettled(deleteSongPromises);
65 | for (const result of deleteSongResults) {
66 | if (result.status === 'rejected') {
67 | return makeFailResp(result.reason);
68 | }
69 | }
70 | return makeSuccessResp({});
71 | }
72 |
73 | export async function importSong(
74 | src: string,
75 | assets: string
76 | ): Promise {
77 | log.info(`importSong(): trying import song from ${src} to ${assets}`);
78 | const srcSonglistPath = path.join(src, 'songlist');
79 | if (fs.existsSync(srcSonglistPath)) {
80 | const srcSonglistString = fs.readFileSync(srcSonglistPath).toString();
81 | const srcSonglist = JSON.parse(srcSonglistString);
82 | if (Array.isArray(srcSonglist.songs) && srcSonglist.songs.length > 0) {
83 | const srcSong = srcSonglist.songs[0] as Song;
84 | const destSongPath = path.join(assets, 'songs', srcSong.id);
85 |
86 | if (!fs.existsSync(destSongPath)) {
87 | fs.mkdirSync(destSongPath);
88 | }
89 |
90 | const srcFiles = await buildSrcSongDepList(srcSong);
91 |
92 | srcFiles.concat('songlist').forEach((file) => {
93 | const fullSrcPath = path.join(src, file);
94 | const fullDestPath = path.join(destSongPath, file);
95 | log.verbose(fullSrcPath, ' -> ', fullDestPath);
96 |
97 | fs.copyFileSync(fullSrcPath, fullDestPath);
98 | });
99 |
100 | return srcSong;
101 | }
102 | }
103 | return undefined;
104 | }
105 |
106 | export async function mergeSonglist(src: Song[], dest: Song[]) {
107 | log.info(`mergeSonglist()`);
108 | src.forEach((song) => {
109 | if (!dest.find((s) => s.id === song.id)) {
110 | dest.push(song);
111 | } else {
112 | const destIndex = dest.findIndex((s) => s.id === song.id);
113 | dest[destIndex] = song;
114 | }
115 | });
116 | return dest;
117 | }
118 |
119 | export async function verifyAssets(path: string) {}
120 |
121 | export async function makePackage(
122 | src: string,
123 | dest: string,
124 | mainWindow?: BrowserWindow
125 | ): Promise {
126 | log.info(`makePackage(): ${src} -> ${dest}`);
127 | mainWindow?.webContents.send('aam:startGeneratePackage');
128 | const sysTemp = app.getPath('temp');
129 | const tmpDir = fs.mkdtempSync(path.join(sysTemp, 'aam-assets-'));
130 | const tmpPackagePath = path.join(tmpDir, path.basename(dest));
131 |
132 | const assetContains = await fsAsync.readdir(src);
133 | const output = fs.createWriteStream(tmpPackagePath);
134 | const archive = archiver('zip');
135 | archive.pipe(output);
136 | for (const rootAsset of assetContains) {
137 | const fullRootAssetPath = path.join(src, rootAsset);
138 | if (fs.lstatSync(fullRootAssetPath).isFile()) {
139 | archive.file(fullRootAssetPath, { name: rootAsset });
140 | } else {
141 | archive.directory(fullRootAssetPath, rootAsset);
142 | }
143 | }
144 |
145 | const parseSize = (size: number) => {
146 | if (size < 1024) {
147 | return `${size} B`;
148 | }
149 | if (size < 1024 * 1024) {
150 | return `${(size / 1024).toFixed(2)} KB`;
151 | }
152 | if (size < 1024 * 1024 * 1024) {
153 | return `${(size / 1024 / 1024).toFixed(2)} MB`;
154 | }
155 | return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`;
156 | };
157 |
158 | let lastReport = Date.now();
159 | archive.on('entry', () => {
160 | const now = Date.now();
161 | if (now - lastReport > 1000) {
162 | const size = archive.pointer();
163 | mainWindow?.webContents.send('aam:log', parseSize(size));
164 | lastReport = Date.now();
165 | }
166 | });
167 |
168 | // const progressHandler = (e: ProgressData) => {
169 | // log.debug(`${e.entries.processed} entries / ${e.fs.processedBytes} bytes`);
170 | // mainWindow?.webContents.send(
171 | // 'aam:log',
172 | // `${(e.fs.processedBytes / 1024 / 1024).toLocaleString(undefined, {
173 | // maximumFractionDigits: 2,
174 | // })} MB`
175 | // );
176 | // setTimeout(() => {
177 | // archive.once('progress', progressHandler);
178 | // }, 1000);
179 | // };
180 | // archive.once('progress', progressHandler);
181 |
182 | await archive.finalize().catch((e: Error) => {
183 | log.error(e);
184 | dialog.showErrorBox('打包错误', e.toString());
185 | });
186 |
187 | await new Promise((resolve) => {
188 | // 文件流关闭时触发复制
189 | output.once('close', () => {
190 | try {
191 | mainWindow?.webContents.send('aam:log', '即将完成...');
192 | log.info(`打包完成,复制文件 ${tmpPackagePath} -> ${dest}`);
193 | fs.copyFileSync(tmpPackagePath, dest);
194 | mainWindow?.webContents.send('aam:stopGeneratePackage');
195 | dialog.showMessageBox({
196 | message: '导出成功',
197 | detail: `已导出:${dest}`,
198 | });
199 | resolve();
200 | } catch (e) {
201 | log.error(e);
202 | dialog.showErrorBox('打包错误', (e as Error).toString());
203 | } finally {
204 | log.info(`清理 ${tmpDir}`);
205 | fs.rmSync(tmpDir, { recursive: true });
206 | }
207 | });
208 | });
209 | }
210 |
--------------------------------------------------------------------------------
/src/main/main.ts:
--------------------------------------------------------------------------------
1 | /* eslint global-require: off, no-console: off, promise/always-return: off */
2 |
3 | /**
4 | * This module executes inside of electron's main process. You can start
5 | * electron renderer process from here and communicate with the other processes
6 | * through IPC.
7 | *
8 | * When running `npm run build` or `npm run build:main`, this file is compiled to
9 | * `./src/main.js` using webpack. This gives us some performance wins.
10 | */
11 | import path from 'path';
12 | import fs from 'fs';
13 | import { app, BrowserWindow, shell, ipcMain, dialog } from 'electron';
14 | // import { autoUpdater } from 'electron-updater';
15 | import log, { LevelOption } from 'electron-log';
16 | import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer';
17 | import { AppInfo, Song, Songlist } from 'type';
18 |
19 | import MenuBuilder from './menu';
20 | import { resolveHtmlPath } from './util';
21 | import { makeFailResp, makeSuccessResp } from './utils/ipcResponse';
22 | import {
23 | deleteSongsIPC,
24 | loadSonglistIPC,
25 | saveSonglistIPC,
26 | } from './utils/assets';
27 | import { globalStore } from '../globalStore';
28 |
29 | // class AppUpdater {
30 | // constructor() {
31 | // log.transports.file.level = 'info';
32 | // autoUpdater.logger = log;
33 | // autoUpdater.checkForUpdatesAndNotify();
34 | // }
35 | // }
36 |
37 | let mainWindow: BrowserWindow | null = null;
38 |
39 | if (process.env.NODE_ENV === 'production') {
40 | const sourceMapSupport = require('source-map-support');
41 | sourceMapSupport.install();
42 | }
43 |
44 | const isDebug =
45 | process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';
46 |
47 | if (isDebug) {
48 | require('electron-debug')();
49 | }
50 |
51 | const installExtensions = async () => {
52 | const installer = require('electron-devtools-installer');
53 | const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
54 | const extensions = ['REACT_DEVELOPER_TOOLS'];
55 |
56 | return installer
57 | .default(
58 | extensions.map((name) => installer[name]),
59 | forceDownload
60 | )
61 | .catch(console.log);
62 | };
63 |
64 | const createWindow = async () => {
65 | if (isDebug) {
66 | await installExtensions();
67 | }
68 |
69 | const RESOURCES_PATH = app.isPackaged
70 | ? path.join(process.resourcesPath, 'assets')
71 | : path.join(__dirname, '../../assets');
72 |
73 | const getAssetPath = (...paths: string[]): string => {
74 | return path.join(RESOURCES_PATH, ...paths);
75 | };
76 |
77 | mainWindow = new BrowserWindow({
78 | show: false,
79 | width: 1015,
80 | height: 620,
81 | icon: getAssetPath('icon.png'),
82 | webPreferences: {
83 | preload: app.isPackaged
84 | ? path.join(__dirname, 'preload.js')
85 | : path.join(__dirname, '../../.erb/dll/preload.js'),
86 | },
87 | });
88 |
89 | mainWindow.loadURL(resolveHtmlPath('index.html'));
90 |
91 | mainWindow.on('ready-to-show', () => {
92 | if (!mainWindow) {
93 | throw new Error('"mainWindow" is not defined');
94 | }
95 | if (process.env.START_MINIMIZED) {
96 | mainWindow.minimize();
97 | } else {
98 | mainWindow.show();
99 | }
100 | });
101 |
102 | mainWindow.on('close', (e) => {
103 | if (mainWindow && !isDebug) {
104 | const response = dialog.showMessageBoxSync(mainWindow, {
105 | message: '要退出吗?',
106 | type: 'question',
107 | buttons: ['好', '取消'],
108 | defaultId: 0,
109 | title: '退出',
110 | detail: '所有更改已自动保存。',
111 | cancelId: 1,
112 | });
113 | if (response === 1) {
114 | e.preventDefault();
115 | } else {
116 | globalStore.reset('assets');
117 | }
118 | }
119 | });
120 |
121 | mainWindow.on('closed', () => {
122 | mainWindow = null;
123 | });
124 |
125 | const menuBuilder = new MenuBuilder(mainWindow);
126 | const menu = menuBuilder.buildMenu();
127 |
128 | globalStore.onDidChange('assets.path', (newValue) => {
129 | log.debug(!!newValue, menu.getMenuItemById('file.import')?.enabled);
130 | menu.getMenuItemById('file.import.song')!.enabled = !!newValue;
131 | menu.getMenuItemById('file.import.songLink')!.enabled = !!newValue;
132 | menu.getMenuItemById('file.import.bg')!.enabled = !!newValue;
133 |
134 | menu.getMenuItemById('file.closeFolder')!.enabled = !!newValue;
135 |
136 | menu.getMenuItemById('build.build')!.enabled = !!newValue;
137 | menu.getMenuItemById('build.verify')!.enabled = !!newValue;
138 | });
139 |
140 | // Open urls in the user's browser
141 | mainWindow.webContents.setWindowOpenHandler((edata) => {
142 | shell.openExternal(edata.url);
143 | return { action: 'deny' };
144 | });
145 |
146 | // // Remove this if your app does not use auto updates
147 | // // eslint-disable-next-line
148 | // new AppUpdater();
149 | };
150 |
151 | /**
152 | * Add event listeners...
153 | */
154 |
155 | app.on('window-all-closed', () => {
156 | app.quit();
157 | });
158 |
159 | app
160 | .whenReady()
161 | .then(() => {
162 | installExtension(REDUX_DEVTOOLS)
163 | .then((name) => console.log(`Added Extension: ${name}`))
164 | .catch((err) => console.log('An error occurred: ', err));
165 |
166 | createWindow();
167 |
168 | log.transports.file.level = globalStore.get('settings.logLevel');
169 |
170 | ipcMain.on('electron-store-get', async (event, val) => {
171 | event.returnValue = globalStore.get(val);
172 | });
173 | ipcMain.on('electron-store-set', async (event, key, val) => {
174 | globalStore.set(key, val);
175 | });
176 |
177 | globalStore.onDidChange('assets', (newValue) => {
178 | mainWindow?.webContents.send(
179 | 'aam:pushSongs',
180 | (newValue as { assets: unknown }).assets
181 | );
182 | });
183 |
184 | globalStore.onDidChange('settings.logLevel', (newValue: LevelOption) => {
185 | if (newValue) {
186 | log.transports.file.level = newValue;
187 | }
188 | log.info(log.transports.file.resolvePath);
189 | });
190 |
191 | ipcMain.handle('dialog:openDirectory', async () => {
192 | if (mainWindow) {
193 | const { canceled, filePaths } = await dialog.showOpenDialog(
194 | mainWindow,
195 | {
196 | properties: ['openDirectory', 'treatPackageAsDirectory'],
197 | }
198 | );
199 | if (canceled) {
200 | return '';
201 | }
202 | return filePaths[0];
203 | }
204 | return '';
205 | });
206 |
207 | ipcMain.handle('aam:loadSongs', async (_, assetsPath: string) => {
208 | const songlist = await loadSonglistIPC(assetsPath);
209 | if (songlist.code !== 0) {
210 | dialog.showErrorBox('错误', songlist.message);
211 | }
212 | return songlist;
213 | });
214 | ipcMain.handle('aam:saveSonglist', async (_, songlist: Songlist) => {
215 | const resp = await saveSonglistIPC(songlist);
216 | if (resp.code !== 0) {
217 | dialog.showErrorBox('错误', resp.message);
218 | }
219 | return resp;
220 | });
221 | ipcMain.handle('aam:deleteSongs', async (_, ids: string[]) => {
222 | const resp = await deleteSongsIPC(ids);
223 | if (resp.code !== 0) {
224 | dialog.showErrorBox('错误', resp.message);
225 | }
226 | return resp;
227 | });
228 |
229 | ipcMain.handle('showLogFile', async () => {
230 | shell.openPath(path.dirname(log.transports.file.getFile().path));
231 | });
232 |
233 | ipcMain.handle('reset', async () => {
234 | globalStore.reset('settings');
235 | app.exit();
236 | });
237 |
238 | ipcMain.handle('getAppInfo', async () => {
239 | return {
240 | version: app.getVersion(),
241 | isDebug,
242 | } as AppInfo;
243 | });
244 | })
245 | .catch(console.log);
246 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "一个Arcaea资产管理工具",
3 | "keywords": [
4 | "electron",
5 | "react",
6 | "typescript",
7 | "arcaea"
8 | ],
9 | "homepage": "https://github.com/feightwywx/aam#readme",
10 | "bugs": {
11 | "url": "https://github.com/feightwywx/aam/issues"
12 | },
13 | "repository": {
14 | "type": "git",
15 | "url": "git+https://github.com/feightwywx/aam.git"
16 | },
17 | "license": "MIT",
18 | "author": {
19 | "name": ".direwolf",
20 | "email": "canis@direcore.xyz",
21 | "url": "https://drwf.ink"
22 | },
23 | "main": "./src/main/main.ts",
24 | "scripts": {
25 | "build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
26 | "build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
27 | "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
28 | "postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
29 | "lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
30 | "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
31 | "prepare": "husky install",
32 | "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
33 | "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
34 | "start:main": "cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only .",
35 | "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
36 | "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
37 | "test": "jest"
38 | },
39 | "lint-staged": {
40 | "*.{js,jsx,ts,tsx}": [
41 | "cross-env NODE_ENV=development eslint --cache"
42 | ],
43 | "*.json,.{eslintrc,prettierrc}": [
44 | "prettier --ignore-path .eslintignore --parser json --write"
45 | ],
46 | "*.{css,scss}": [
47 | "prettier --ignore-path .eslintignore --single-quote --write"
48 | ],
49 | "*.{html,md,yml}": [
50 | "prettier --ignore-path .eslintignore --single-quote --write"
51 | ]
52 | },
53 | "browserslist": [],
54 | "prettier": {
55 | "singleQuote": true,
56 | "overrides": [
57 | {
58 | "files": [
59 | ".prettierrc",
60 | ".eslintrc"
61 | ],
62 | "options": {
63 | "parser": "json"
64 | }
65 | }
66 | ]
67 | },
68 | "jest": {
69 | "moduleDirectories": [
70 | "node_modules",
71 | "release/app/node_modules",
72 | "src"
73 | ],
74 | "moduleFileExtensions": [
75 | "js",
76 | "jsx",
77 | "ts",
78 | "tsx",
79 | "json"
80 | ],
81 | "moduleNameMapper": {
82 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/.erb/mocks/fileMock.js",
83 | "\\.(css|less|sass|scss)$": "identity-obj-proxy"
84 | },
85 | "setupFiles": [
86 | "./.erb/scripts/check-build-exists.ts"
87 | ],
88 | "testEnvironment": "jsdom",
89 | "testEnvironmentOptions": {
90 | "url": "http://localhost/"
91 | },
92 | "testPathIgnorePatterns": [
93 | "release/app/dist"
94 | ],
95 | "transform": {
96 | "\\.(ts|tsx|js|jsx)$": "ts-jest"
97 | }
98 | },
99 | "dependencies": {
100 | "@ant-design/icons": "^5.0.1",
101 | "@monaco-editor/react": "^4.4.6",
102 | "@reduxjs/toolkit": "^1.9.1",
103 | "antd": "^5.2.3",
104 | "archiver": "^5.3.1",
105 | "electron-debug": "^3.2.0",
106 | "electron-log": "^4.4.8",
107 | "electron-redux": "^1.5.4",
108 | "electron-store": "^8.1.0",
109 | "electron-updater": "^5.2.3",
110 | "monaco-editor": "^0.36.1",
111 | "path-browserify": "^1.0.1",
112 | "react": "^18.2.0",
113 | "react-dom": "^18.2.0",
114 | "react-redux": "^8.0.5",
115 | "react-router-dom": "^6.4.5",
116 | "redux": "^4.2.0",
117 | "usehooks-ts": "^2.9.1"
118 | },
119 | "devDependencies": {
120 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",
121 | "@redux-devtools/core": "^3.13.1",
122 | "@svgr/webpack": "^6.3.1",
123 | "@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
124 | "@testing-library/jest-dom": "^5.16.5",
125 | "@testing-library/react": "^13.3.0",
126 | "@types/archiver": "^5.3.1",
127 | "@types/jest": "^28.1.7",
128 | "@types/node": "18.7.6",
129 | "@types/react": "^18.0.17",
130 | "@types/react-dom": "^18.0.6",
131 | "@types/react-test-renderer": "^18.0.0",
132 | "@types/terser-webpack-plugin": "^5.0.4",
133 | "@types/webpack-bundle-analyzer": "^4.4.2",
134 | "@typescript-eslint/eslint-plugin": "^5.33.1",
135 | "@typescript-eslint/parser": "^5.33.1",
136 | "browserslist-config-erb": "^0.0.3",
137 | "chalk": "^4.1.2",
138 | "concurrently": "^7.3.0",
139 | "copy-webpack-plugin": "^11.0.0",
140 | "core-js": "^3.24.1",
141 | "cross-env": "^7.0.3",
142 | "css-loader": "^6.7.1",
143 | "css-minimizer-webpack-plugin": "^4.0.0",
144 | "detect-port": "^1.3.0",
145 | "electron": "^20.0.2",
146 | "electron-builder": "^23.3.3",
147 | "electron-devtools-installer": "^3.2.0",
148 | "electron-notarize": "^1.2.1",
149 | "electron-rebuild": "^3.2.9",
150 | "electronmon": "^2.0.2",
151 | "eslint": "^8.22.0",
152 | "eslint-config-airbnb-base": "^15.0.0",
153 | "eslint-config-erb": "^4.0.3",
154 | "eslint-import-resolver-typescript": "^3.4.1",
155 | "eslint-import-resolver-webpack": "^0.13.2",
156 | "eslint-plugin-compat": "^4.0.2",
157 | "eslint-plugin-import": "^2.26.0",
158 | "eslint-plugin-jest": "^26.8.3",
159 | "eslint-plugin-jsx-a11y": "^6.6.1",
160 | "eslint-plugin-promise": "^6.0.0",
161 | "eslint-plugin-react": "^7.30.1",
162 | "eslint-plugin-react-hooks": "^4.6.0",
163 | "file-loader": "^6.2.0",
164 | "html-webpack-plugin": "^5.5.0",
165 | "husky": "^8.0.1",
166 | "identity-obj-proxy": "^3.0.0",
167 | "jest": "^28.1.3",
168 | "jest-environment-jsdom": "^28.1.3",
169 | "lint-staged": "^13.0.3",
170 | "mini-css-extract-plugin": "^2.6.1",
171 | "monaco-editor-webpack-plugin": "^7.0.1",
172 | "prettier": "^2.7.1",
173 | "react-refresh": "^0.14.0",
174 | "react-test-renderer": "^18.2.0",
175 | "rimraf": "^3.0.2",
176 | "sass": "^1.54.4",
177 | "sass-loader": "^13.0.2",
178 | "style-loader": "^3.3.1",
179 | "terser-webpack-plugin": "^5.3.5",
180 | "ts-jest": "^28.0.8",
181 | "ts-loader": "^9.3.1",
182 | "ts-node": "^10.9.1",
183 | "typescript": "^4.7.4",
184 | "url-loader": "^4.1.1",
185 | "webpack": "^5.74.0",
186 | "webpack-bundle-analyzer": "^4.5.0",
187 | "webpack-cli": "^4.10.0",
188 | "webpack-dev-server": "^4.10.0",
189 | "webpack-merge": "^5.8.0"
190 | },
191 | "build": {
192 | "productName": "Arcaea Assets Manager",
193 | "appId": "icu.arcaea.am",
194 | "asar": true,
195 | "asarUnpack": "**\\*.{node,dll}",
196 | "files": [
197 | "dist",
198 | "node_modules",
199 | "package.json"
200 | ],
201 | "afterSign": ".erb/scripts/notarize.js",
202 | "mac": {
203 | "target": {
204 | "target": "default",
205 | "arch": [
206 | "arm64",
207 | "x64"
208 | ]
209 | },
210 | "type": "distribution",
211 | "hardenedRuntime": true,
212 | "entitlements": "assets/entitlements.mac.plist",
213 | "entitlementsInherit": "assets/entitlements.mac.plist",
214 | "gatekeeperAssess": false
215 | },
216 | "dmg": {
217 | "contents": [
218 | {
219 | "x": 130,
220 | "y": 220
221 | },
222 | {
223 | "x": 410,
224 | "y": 220,
225 | "type": "link",
226 | "path": "/Applications"
227 | }
228 | ]
229 | },
230 | "win": {
231 | "target": [
232 | "zip"
233 | ]
234 | },
235 | "linux": {
236 | "target": [
237 | "AppImage",
238 | "deb",
239 | "tar.xz"
240 | ],
241 | "category": "Development"
242 | },
243 | "directories": {
244 | "app": "release/app",
245 | "buildResources": "assets",
246 | "output": "release/build"
247 | },
248 | "extraResources": [
249 | "./assets/**"
250 | ],
251 | "publish": {
252 | "provider": "github",
253 | "owner": "feightwywx",
254 | "repo": "aam"
255 | }
256 | },
257 | "devEngines": {
258 | "node": ">=14.x",
259 | "npm": ">=7.x"
260 | },
261 | "electronmon": {
262 | "patterns": [
263 | "!**/**",
264 | "src/main/**"
265 | ],
266 | "logLevel": "quiet"
267 | }
268 | }
269 |
--------------------------------------------------------------------------------
/src/main/menu.ts:
--------------------------------------------------------------------------------
1 | import {
2 | app,
3 | Menu,
4 | shell,
5 | BrowserWindow,
6 | MenuItemConstructorOptions,
7 | dialog,
8 | } from 'electron';
9 | import log from 'electron-log';
10 |
11 | import { globalStore } from '../globalStore';
12 | import { loadSonglistIPC } from './utils/assets';
13 | import {
14 | generatePackageMenuHandlerFactory,
15 | importBgMenuHandlerFactory,
16 | importSongMenuHandlerFactory,
17 | verifyMenuHandlerFactory,
18 | } from './menuHandler';
19 |
20 | interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
21 | selector?: string;
22 | submenu?: DarwinMenuItemConstructorOptions[] | Menu;
23 | }
24 |
25 | export default class MenuBuilder {
26 | mainWindow: BrowserWindow;
27 |
28 | constructor(mainWindow: BrowserWindow) {
29 | this.mainWindow = mainWindow;
30 | }
31 |
32 | buildMenu(): Menu {
33 | if (
34 | process.env.NODE_ENV === 'development' ||
35 | process.env.DEBUG_PROD === 'true'
36 | ) {
37 | this.setupDevelopmentEnvironment();
38 | }
39 |
40 | const template =
41 | process.platform === 'darwin'
42 | ? this.buildDarwinTemplate()
43 | : this.buildDefaultTemplate();
44 |
45 | const menu = Menu.buildFromTemplate(template);
46 | Menu.setApplicationMenu(menu);
47 |
48 | return menu;
49 | }
50 |
51 | setupDevelopmentEnvironment(): void {
52 | this.mainWindow.webContents.on('context-menu', (_, props) => {
53 | const { x, y } = props;
54 |
55 | Menu.buildFromTemplate([
56 | {
57 | label: '审查元素',
58 | click: () => {
59 | this.mainWindow.webContents.inspectElement(x, y);
60 | },
61 | },
62 | ]).popup({ window: this.mainWindow });
63 | });
64 | }
65 |
66 | buildDarwinTemplate(): MenuItemConstructorOptions[] {
67 | const subMenuAbout: DarwinMenuItemConstructorOptions = {
68 | label: 'Arcaea 资产管理器',
69 | submenu: [
70 | {
71 | label: '关于 AAM',
72 | selector: 'orderFrontStandardAboutPanel:',
73 | },
74 | { type: 'separator' },
75 | {
76 | label: '打开...',
77 | accelerator: 'Command+O',
78 | click: async () => {
79 | const { canceled, filePaths } = await dialog.showOpenDialog(
80 | this.mainWindow,
81 | {
82 | properties: ['openDirectory', 'treatPackageAsDirectory'],
83 | }
84 | );
85 | if (!canceled) {
86 | const songlist = await loadSonglistIPC(filePaths[0]);
87 | if (songlist.code === 0) {
88 | this.mainWindow.webContents.send('aam:pushSongs', {
89 | path: filePaths[0],
90 | songs: songlist.data,
91 | });
92 | } else {
93 | dialog.showErrorBox('错误', songlist.message);
94 | }
95 | }
96 | },
97 | },
98 | {
99 | id: 'file.import',
100 | label: '导入',
101 | submenu: [
102 | {
103 | id: 'file.import.song',
104 | label: '歌曲',
105 | accelerator: 'Command+I',
106 | click: importSongMenuHandlerFactory(this.mainWindow),
107 | enabled: false,
108 | },
109 | {
110 | id: 'file.import.songLink',
111 | label: '歌曲(链接模式)',
112 | accelerator: 'Shift+Command+I',
113 | click: importSongMenuHandlerFactory(this.mainWindow, true),
114 | enabled: false,
115 | },
116 | { type: 'separator' },
117 | {
118 | id: 'file.import.bg',
119 | label: '背景',
120 | click: importBgMenuHandlerFactory(this.mainWindow),
121 | enabled: false,
122 | },
123 | ],
124 | },
125 | {
126 | id: 'file.closeFolder',
127 | label: '关闭文件夹',
128 | accelerator: 'Shift+Command+W',
129 | click: () => {
130 | this.mainWindow.webContents.send('aam:closeFolder');
131 | },
132 | enabled: false,
133 | },
134 | { type: 'separator' },
135 | { label: '服务', role: 'services', submenu: [] },
136 | { type: 'separator' },
137 | {
138 | label: '隐藏 AAM',
139 | accelerator: 'Command+H',
140 | selector: 'hide:',
141 | },
142 | {
143 | label: '隐藏其他',
144 | accelerator: 'Command+Shift+H',
145 | selector: 'hideOtherApplications:',
146 | },
147 | { label: '全部显示', selector: 'unhideAllApplications:' },
148 | { type: 'separator' },
149 | {
150 | label: '退出 AAM',
151 | accelerator: 'Command+Q',
152 | click: () => {
153 | app.quit();
154 | },
155 | },
156 | ],
157 | };
158 | const subMenuEdit: DarwinMenuItemConstructorOptions = {
159 | label: '编辑',
160 | submenu: [
161 | { label: '撤销', accelerator: 'Command+Z', selector: 'undo:' },
162 | { label: '重做', accelerator: 'Shift+Command+Z', selector: 'redo:' },
163 | { type: 'separator' },
164 | { label: '剪切', accelerator: 'Command+X', selector: 'cut:' },
165 | { label: '复制', accelerator: 'Command+C', selector: 'copy:' },
166 | { label: '粘贴', accelerator: 'Command+V', selector: 'paste:' },
167 | {
168 | label: '全选',
169 | accelerator: 'Command+A',
170 | selector: 'selectAll:',
171 | },
172 | ],
173 | };
174 | const subMenuBuild: DarwinMenuItemConstructorOptions = {
175 | id: 'build',
176 | label: '生成',
177 | submenu: [
178 | {
179 | id: 'build.build',
180 | label: '生成...',
181 | accelerator: 'F5',
182 | click: generatePackageMenuHandlerFactory(this.mainWindow),
183 | enabled: false,
184 | },
185 | { type: 'separator' },
186 | {
187 | id: 'build.verify',
188 | label: '验证依赖',
189 | accelerator: 'F6',
190 | click: verifyMenuHandlerFactory(this.mainWindow),
191 | enabled: false,
192 | },
193 | ],
194 | };
195 | const subMenuViewDev: MenuItemConstructorOptions = {
196 | label: '查看',
197 | submenu: [
198 | {
199 | label: '重新载入',
200 | accelerator: 'Command+R',
201 | click: () => {
202 | this.mainWindow.webContents.reload();
203 | },
204 | },
205 | {
206 | label: '切换全屏幕',
207 | accelerator: 'Ctrl+Command+F',
208 | click: () => {
209 | this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
210 | },
211 | },
212 | {
213 | label: '切换开发者工具',
214 | accelerator: 'Alt+Command+I',
215 | click: () => {
216 | this.mainWindow.webContents.toggleDevTools();
217 | },
218 | },
219 | ],
220 | };
221 | const subMenuViewProd: MenuItemConstructorOptions = {
222 | label: '查看',
223 | submenu: [
224 | {
225 | label: '切换全屏幕',
226 | accelerator: 'Ctrl+Command+F',
227 | click: () => {
228 | this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
229 | },
230 | },
231 | ],
232 | };
233 | const subMenuWindow: DarwinMenuItemConstructorOptions = {
234 | label: '窗口',
235 | role: 'window',
236 | submenu: [
237 | {
238 | label: '最小化',
239 | accelerator: 'Command+M',
240 | selector: 'performMiniaturize:',
241 | },
242 | { label: '关闭', accelerator: 'Command+W', selector: 'performClose:' },
243 | { type: 'separator' },
244 | { label: '全部置于顶层', selector: 'arrangeInFront:' },
245 | ],
246 | };
247 | const subMenuHelp: MenuItemConstructorOptions = {
248 | label: '帮助',
249 | role: 'help',
250 | submenu: [
251 | {
252 | label: '了解更多',
253 | click() {
254 | shell.openExternal('https://github.com/feightwywx/aam');
255 | },
256 | },
257 | ],
258 | };
259 |
260 | const subMenuView =
261 | process.env.NODE_ENV === 'development' ||
262 | process.env.DEBUG_PROD === 'true'
263 | ? subMenuViewDev
264 | : subMenuViewProd;
265 |
266 | return [
267 | subMenuAbout,
268 | subMenuEdit,
269 | subMenuBuild,
270 | subMenuView,
271 | subMenuWindow,
272 | subMenuHelp,
273 | ];
274 | }
275 |
276 | buildDefaultTemplate() {
277 | const templateDefault = [
278 | {
279 | label: '文件(&F)',
280 | submenu: [
281 | {
282 | label: '打开...(&O)',
283 | accelerator: 'Ctrl+O',
284 | click: async () => {
285 | const { canceled, filePaths } = await dialog.showOpenDialog(
286 | this.mainWindow,
287 | {
288 | properties: ['openDirectory', 'treatPackageAsDirectory'],
289 | }
290 | );
291 | if (!canceled) {
292 | const songlist = await loadSonglistIPC(filePaths[0]);
293 | if (songlist.code === 0) {
294 | this.mainWindow.webContents.send('aam:pushSongs', {
295 | path: filePaths[0],
296 | songs: songlist.data,
297 | });
298 | } else {
299 | dialog.showErrorBox('错误', songlist.message);
300 | }
301 | }
302 | },
303 | },
304 | {
305 | id: 'file.import',
306 | label: '导入',
307 | submenu: [
308 | {
309 | id: 'file.import.song',
310 | label: '歌曲(&S)',
311 | accelerator: 'Ctrl+I',
312 | click: importSongMenuHandlerFactory(this.mainWindow),
313 | enabled: false,
314 | },
315 | {
316 | id: 'file.import.songLink',
317 | label: '歌曲(链接模式)(&L)',
318 | accelerator: 'Shift+Ctrl+I',
319 | click: importSongMenuHandlerFactory(this.mainWindow, true),
320 | enabled: false,
321 | },
322 | { type: 'separator' },
323 | {
324 | id: 'file.import.bg',
325 | label: '背景(&B)',
326 | click: importBgMenuHandlerFactory(this.mainWindow),
327 | enabled: false,
328 | },
329 | ],
330 | },
331 | {
332 | id: 'file.closeFolder',
333 | label: '关闭文件夹(&F)',
334 | accelerator: 'Shift+Ctrl+W',
335 | click: () => {
336 | this.mainWindow.webContents.send('aam:closeFolder');
337 | },
338 | enabled: false,
339 | },
340 | {
341 | label: '关闭(&C)',
342 | accelerator: 'Ctrl+W',
343 | click: () => {
344 | this.mainWindow.close();
345 | },
346 | },
347 | ],
348 | },
349 | {
350 | label: '生成(&B)',
351 | submenu: [
352 | {
353 | id: 'build.build',
354 | label: '生成...(&G)',
355 | accelerator: 'F5',
356 | click: generatePackageMenuHandlerFactory(this.mainWindow),
357 | enabled: false,
358 | },
359 | { type: 'separator' },
360 | {
361 | id: 'build.verify',
362 | label: '验证依赖(&V)',
363 | accelerator: 'F6',
364 | click: verifyMenuHandlerFactory(this.mainWindow),
365 | enabled: false,
366 | },
367 | ],
368 | },
369 | {
370 | label: '查看(&V)',
371 | submenu:
372 | process.env.NODE_ENV === 'development' ||
373 | process.env.DEBUG_PROD === 'true'
374 | ? [
375 | {
376 | label: '重新加载(&R)',
377 | accelerator: 'Ctrl+R',
378 | click: () => {
379 | this.mainWindow.webContents.reload();
380 | },
381 | },
382 | {
383 | label: '切换全屏(&F)',
384 | accelerator: 'F11',
385 | click: () => {
386 | this.mainWindow.setFullScreen(
387 | !this.mainWindow.isFullScreen()
388 | );
389 | },
390 | },
391 | {
392 | label: '切换开发者工具(&D)',
393 | accelerator: 'Alt+Ctrl+I',
394 | click: () => {
395 | this.mainWindow.webContents.toggleDevTools();
396 | },
397 | },
398 | ]
399 | : [
400 | {
401 | label: '切换全屏(&F)',
402 | accelerator: 'F11',
403 | click: () => {
404 | this.mainWindow.setFullScreen(
405 | !this.mainWindow.isFullScreen()
406 | );
407 | },
408 | },
409 | ],
410 | },
411 | {
412 | label: '帮助',
413 | submenu: [
414 | {
415 | label: '了解更多',
416 | click() {
417 | shell.openExternal('https://github.com/feightwywx/aam');
418 | },
419 | },
420 | ],
421 | },
422 | ];
423 |
424 | return templateDefault;
425 | }
426 | }
427 |
--------------------------------------------------------------------------------
/.erb/img/erb-banner.svg:
--------------------------------------------------------------------------------
1 |
33 |
--------------------------------------------------------------------------------
/src/renderer/pages/Songs.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Input,
4 | Menu,
5 | message,
6 | Modal,
7 | Popover,
8 | Space,
9 | Spin,
10 | Table,
11 | TableProps,
12 | Tag,
13 | theme,
14 | Tooltip,
15 | } from 'antd';
16 | import type { TableColumnsType } from 'antd';
17 |
18 | import './Hello.css';
19 | import { useNavigate } from 'react-router-dom';
20 | import type { SettingsType, Song, SongDifficulty } from 'type';
21 | import React, { useEffect, useRef, useState } from 'react';
22 | import type {
23 | FilterValue,
24 | SorterResult,
25 | TableRowSelection,
26 | } from 'antd/es/table/interface';
27 |
28 | import {
29 | BorderInnerOutlined,
30 | CheckCircleOutlined,
31 | CloseCircleOutlined,
32 | DashOutlined,
33 | DeleteOutlined,
34 | EditOutlined,
35 | ImportOutlined,
36 | ReloadOutlined,
37 | SearchOutlined,
38 | } from '@ant-design/icons';
39 |
40 | import { useAppDispatch, useAppSelector } from '../store';
41 | import { setSongs } from 'stateSlices/assets';
42 | import MonacoEditor from 'renderer/components/MonacoEditor';
43 |
44 | interface SongTableData extends Song {
45 | key: string;
46 | idWithExtFlag: {
47 | id: string;
48 | ext?: string;
49 | };
50 | title: string;
51 | bpm_combine: string;
52 | }
53 |
54 | function convertRatingClass(rating: number) {
55 | if (rating === 0) {
56 | return 'Past';
57 | }
58 | if (rating === 1) {
59 | return 'Present';
60 | }
61 | if (rating === 2) {
62 | return 'Future';
63 | }
64 | if (rating === 3) {
65 | return 'Beyond';
66 | }
67 | return 'Unknown';
68 | }
69 |
70 | function convertRatingClassColor(rating: number) {
71 | if (rating === 0) {
72 | return 'cyan';
73 | }
74 | if (rating === 1) {
75 | return 'green';
76 | }
77 | if (rating === 2) {
78 | return 'purple';
79 | }
80 | if (rating === 3) {
81 | return 'volcano';
82 | }
83 | return 'gray';
84 | }
85 |
86 | const Songs: React.FC = () => {
87 | const assets = useAppSelector((state) => state.assets);
88 | const dispatch = useAppDispatch();
89 | const navigate = useNavigate();
90 | const settings = window.electron.ipcRenderer.store.get(
91 | 'settings'
92 | ) as SettingsType;
93 |
94 | const [messageApi, contextHolder] = message.useMessage();
95 |
96 | const [filteredInfo, setFilteredInfo] = useState<
97 | Record
98 | >({});
99 |
100 | const handleTableChange: TableProps['onChange'] = (
101 | pagination,
102 | filters,
103 | sorter
104 | ) => {
105 | console.log('Various parameters', pagination, filters, sorter);
106 | setFilteredInfo(filters);
107 | };
108 |
109 | const column: TableColumnsType = [
110 | {
111 | title: 'ID',
112 | dataIndex: 'idWithExtFlag',
113 | key: 'id',
114 | width: 150,
115 | fixed: 'left',
116 | ellipsis: true,
117 | sorter: (a, b) => a.id.localeCompare(b.id),
118 | sortDirections: ['ascend', 'descend'],
119 | render: ({ id, ext }) => {
120 | return (
121 | <>
122 | {id}
123 | {ext && (
124 |
130 | 外
131 |
132 | )}
133 | >
134 | );
135 | },
136 | },
137 | {
138 | title: '曲名',
139 | dataIndex: 'title_localized',
140 | key: 'title_localized',
141 | width: 250,
142 | ellipsis: true,
143 | sorter: (a, b) =>
144 | a.title_localized.en.localeCompare(b.title_localized.en, 'ja'),
145 | sortDirections: ['ascend', 'descend'],
146 | render: (title: { en: string; ja?: string }) => (
147 | <>
148 | EN
149 | {title.en}
150 | {title.ja && (
151 | <>
152 |
153 | JA
154 | {title.ja}
155 | >
156 | )}
157 | >
158 | ),
159 | },
160 | {
161 | title: '曲师',
162 | dataIndex: 'artist',
163 | key: 'artist',
164 | width: 200,
165 | ellipsis: true,
166 | sorter: (a, b) => a.artist.localeCompare(b.artist, 'ja'),
167 | sortDirections: ['ascend', 'descend'],
168 | },
169 | {
170 | title: '谱面',
171 | dataIndex: 'difficulties',
172 | key: 'difficulties',
173 | width: 100,
174 | filters: [
175 | {
176 | text: 'Past',
177 | value: 0,
178 | },
179 | {
180 | text: 'Present',
181 | value: 1,
182 | },
183 | {
184 | text: 'Future',
185 | value: 2,
186 | },
187 | {
188 | text: 'Beyond',
189 | value: 3,
190 | },
191 | ],
192 | filteredValue: filteredInfo.difficulties || null,
193 | onFilter: (value, record) =>
194 | record.difficulties.filter((x) => x.ratingClass === value).length > 0,
195 | // TODO 可选不同难度的谱面难度排序
196 | sorter: (a, b) => {
197 | function levelNormalizer(diff: SongDifficulty) {
198 | return (
199 | // diff.ratingClass * 1000 +
200 | diff.rating * 10 + (diff.ratingPlus ? 5 : 0)
201 | );
202 | }
203 |
204 | const lvA = a.difficulties.map(levelNormalizer);
205 | const lvB = b.difficulties.map(levelNormalizer);
206 |
207 | // 如果有筛选,从筛选条件的最高难度和歌曲拥有的最高难度之间选择较低的一个难度分级进行排序
208 | if (filteredInfo.difficulties) {
209 | const highestRatingClass = filteredInfo.difficulties[
210 | filteredInfo.difficulties.length - 1
211 | ] as number;
212 | return (
213 | lvA[Math.min(highestRatingClass, lvA.length - 1)] -
214 | lvB[Math.min(highestRatingClass, lvB.length - 1)]
215 | );
216 | }
217 | // 否则直接按照歌曲最高难度排序
218 | return lvA[lvA.length - 1] - lvB[lvB.length - 1];
219 | },
220 | render: (diffs: SongDifficulty[]) => {
221 | return diffs.map((diff) => {
222 | if (diff.rating < 0) return <>>;
223 | if (
224 | filteredInfo.difficulties &&
225 | !filteredInfo.difficulties.includes(diff.ratingClass)
226 | )
227 | return <>>;
228 | return (
229 |
234 |
237 | {convertRatingClass(diff.ratingClass)}{' '}
238 | {diff.rating === 0 ? '?' : diff.rating}
239 | {diff.ratingPlus ? '+' : ''}
240 |
241 |
242 | );
243 | });
244 | },
245 | },
246 | {
247 | title: 'BPM',
248 | dataIndex: 'bpm_combine',
249 | key: 'bpm_combine',
250 | width: 120,
251 | ellipsis: true,
252 | sorter: (a, b) => a.bpm_base - b.bpm_base,
253 | sortDirections: ['ascend', 'descend'],
254 | },
255 | {
256 | title: '曲包',
257 | dataIndex: 'set',
258 | key: 'set',
259 | width: 100,
260 | sorter: (a, b) => a.set.localeCompare(b.set),
261 | sortDirections: ['ascend', 'descend'],
262 | },
263 | {
264 | title: '侧',
265 | dataIndex: 'side',
266 | key: 'side',
267 | width: 80,
268 | filters: [
269 | {
270 | text: '光芒',
271 | value: 0,
272 | },
273 | {
274 | text: '纷争',
275 | value: 1,
276 | },
277 | {
278 | text: '消色',
279 | value: 2,
280 | },
281 | ],
282 | onFilter: (value, record) => record.side === value,
283 | filteredValue: filteredInfo.side || null,
284 | render: (side) => {
285 | if (side === 0) {
286 | return 光芒;
287 | }
288 | if (side === 1) {
289 | return 纷争;
290 | }
291 | if (side === 2) {
292 | return 消色;
293 | }
294 | return <>>;
295 | },
296 | },
297 | {
298 | title: '背景',
299 | dataIndex: 'bg',
300 | key: 'bg',
301 | width: 150,
302 | ellipsis: true,
303 | sorter: (a, b) => a.bg.localeCompare(b.bg),
304 | sortDirections: ['ascend', 'descend'],
305 | },
306 | {
307 | title: '日期',
308 | dataIndex: 'date',
309 | key: 'date',
310 | width: 150,
311 | sorter: (a, b) => a.date - b.date,
312 | sortDirections: ['ascend', 'descend'],
313 | render: (date) => (
314 | {new Date(date * 1000).toLocaleString()}
315 | ),
316 | },
317 | {
318 | title: '版本',
319 | dataIndex: 'version',
320 | key: 'version',
321 | width: 80,
322 | sorter: (a, b) => a.version.localeCompare(b.version),
323 | sortDirections: ['ascend', 'descend'],
324 | },
325 | ];
326 |
327 | const assetsSongsFilteredBySettings: Song[] =
328 | assets.songs && Array.isArray(assets.songs)
329 | ? assets.songs
330 | .filter((song) => !settings.ignoredSong.split(',').includes(song.id))
331 | .map((song) => ({
332 | ...song,
333 | difficulties: song.difficulties.filter(
334 | (dif) => dif.rating >= settings.minimalRating
335 | ),
336 | }))
337 | : [];
338 |
339 | let data: SongTableData[] = [];
340 | if (assetsSongsFilteredBySettings) {
341 | data = assetsSongsFilteredBySettings.map((song, index) => ({
342 | key: song.id,
343 | title: song.title_localized.ja ?? song.title_localized.en,
344 | bpm_combine: `${song.bpm.split('\n')[0]} (${song.bpm_base})`,
345 | idWithExtFlag: {
346 | id: song.id,
347 | ext: song._external,
348 | },
349 | ...song,
350 | }));
351 | }
352 |
353 | const [searchValue, setSearchValue] = useState('');
354 | const filteredData = data.filter((song) => {
355 | const cons = searchValue.split(' ');
356 |
357 | function parseCon(con: string): boolean {
358 | if (con.length === 0) return true;
359 |
360 | if (song.id.toLocaleLowerCase().includes(con.toLocaleLowerCase()))
361 | return true;
362 | if (
363 | song.title_localized.en
364 | .toLocaleLowerCase()
365 | .includes(con.toLocaleLowerCase())
366 | )
367 | return true;
368 | if (
369 | song.title_localized.ja
370 | ?.toLocaleLowerCase()
371 | ?.includes(con.toLocaleLowerCase())
372 | )
373 | return true;
374 | if (song.artist.toLocaleLowerCase().includes(con.toLocaleLowerCase()))
375 | return true;
376 | if (song.set.toLocaleLowerCase().includes(con.toLocaleLowerCase()))
377 | return true;
378 | if (song.bg.toLocaleLowerCase().includes(con.toLocaleLowerCase()))
379 | return true;
380 | if (song.version.toLocaleLowerCase().includes(con.toLocaleLowerCase()))
381 | return true;
382 |
383 | function shortDiffNameToRatingClass(name: string) {
384 | switch (name) {
385 | case 'pst':
386 | return 0;
387 | case 'prs':
388 | return 1;
389 | case 'ftr':
390 | return 2;
391 | case 'byd':
392 | return 3;
393 | default:
394 | return -1;
395 | }
396 | }
397 | if (
398 | (con.startsWith('pst') ||
399 | con.startsWith('prs') ||
400 | con.startsWith('ftr') ||
401 | con.startsWith('byd') ||
402 | con.startsWith('any')) &&
403 | song.difficulties.filter(
404 | (diff) =>
405 | diff.rating ===
406 | +(con.endsWith('+')
407 | ? con.substring(3, con.length - 1)
408 | : con.substring(3, con.length)) &&
409 | (diff.ratingPlus
410 | ? diff.ratingPlus === con.endsWith('+')
411 | : con.endsWith('+') === false) &&
412 | (con.startsWith('any')
413 | ? true
414 | : diff.ratingClass ===
415 | shortDiffNameToRatingClass(con.substring(0, 3)))
416 | ).length > 0
417 | )
418 | return true;
419 |
420 | return false;
421 | }
422 |
423 | const result = cons.map(parseCon).reduce((a, b) => a && b);
424 |
425 | return result;
426 | });
427 |
428 | const [selectedRowKeys, setSelectedRowKeys] = useState([]);
429 |
430 | const onSelectChange = (newSelectedRowKeys: React.Key[]) => {
431 | console.log('selectedRowKeys changed: ', newSelectedRowKeys);
432 | setSelectedRowKeys(newSelectedRowKeys);
433 | };
434 |
435 | const rowSelection: TableRowSelection = {
436 | selectedRowKeys,
437 | onChange: onSelectChange,
438 | };
439 |
440 | const [loading, setLoading] = useState(false);
441 | const [log, setLog] = useState('');
442 |
443 | const themeToken = theme.useToken();
444 |
445 | const refreshButtonClickHandler = async () => {
446 | const songsResp = await window.aam.ipcRenderer.loadSongs(assets.path);
447 | if (songsResp.code === 0) {
448 | messageApi.success('已刷新');
449 | dispatch(setSongs(songsResp.data));
450 | } else {
451 | messageApi.error(songsResp.message);
452 | }
453 | };
454 |
455 | const reverseSelectButtonClickHandler = async () => {
456 | const newSelectedRowKeys = filteredData
457 | .map((row) => row.key)
458 | .filter((key) => !selectedRowKeys.includes(key));
459 | setSelectedRowKeys(newSelectedRowKeys);
460 | };
461 |
462 | const [editJsonModalOpen, setEditJsonModalOpen] = useState(false);
463 | const [editJsonModalContent, setEditJsonModalContent] = useState('');
464 | const [editorMarkers, setEditorMarkers] = useState([]);
465 | const editorNoError = editorMarkers.length === 0;
466 | const parsedMarkers = editorMarkers
467 | .map((marker) => {
468 | return `${marker.message} [Ln ${marker.startLineNumber}, Col ${marker.startColumn}];`;
469 | })
470 | .join('\n');
471 | const editorRef = useRef(null);
472 |
473 | function handleEditorDidMount(editor, monaco) {
474 | editorRef.current = editor;
475 | }
476 | const editJsonButtonClickHandler = async () => {
477 | if (assets.songs && Array.isArray(assets.songs)) {
478 | setEditJsonModalContent(
479 | JSON.stringify(
480 | {
481 | songs: assets.songs.filter((song) =>
482 | selectedRowKeys.includes(song.id)
483 | ),
484 | },
485 | undefined,
486 | 2
487 | )
488 | );
489 | }
490 | setEditJsonModalOpen(true);
491 | };
492 | const editJsonModalOkHandler = async () => {
493 | const submitContent = editorRef.current!.getValue();
494 | console.log('submit content', submitContent);
495 | try {
496 | const songlistPatch = JSON.parse(submitContent) as { songs: Song[] };
497 | console.log('songlist patch:', songlistPatch);
498 |
499 | const indexedSonglistPatch = songlistPatch.songs.map((song, index) => {
500 | const correspondIndex = assets.songs?.findIndex((sourceSong) => {
501 | return sourceSong.id === song.id;
502 | });
503 | console.log(correspondIndex);
504 | if (correspondIndex !== undefined && correspondIndex > -1) {
505 | return { correspondIndex, song };
506 | }
507 | return null;
508 | });
509 |
510 | console.log('indexedSonglistPatch', indexedSonglistPatch);
511 |
512 | if (assets.songs) {
513 | const patchedSonglist = assets.songs.map((sourceSong, index) => {
514 | for (const patchSongStruct of indexedSonglistPatch) {
515 | if (patchSongStruct && patchSongStruct.correspondIndex === index) {
516 | return patchSongStruct.song;
517 | }
518 | }
519 | return sourceSong;
520 | });
521 |
522 | const saveSonglistResp = await window.aam.ipcRenderer.saveSonglist({
523 | songs: patchedSonglist,
524 | });
525 |
526 | console.log('patched: ', patchedSonglist);
527 |
528 | if (saveSonglistResp.code === 0) {
529 | messageApi.success('已保存');
530 | // window.aam.ipcRenderer.loadSongs(assets.path);
531 | refreshButtonClickHandler();
532 | setEditJsonModalOpen(false);
533 | } else {
534 | messageApi.error(saveSonglistResp.message);
535 | }
536 | }
537 | } catch (e: Error) {
538 | if (e.name === 'SyntaxError') {
539 | messageApi.error(`JSON格式错误`);
540 | } else {
541 | messageApi.error(`未知错误:${e.name}`);
542 | }
543 | }
544 | };
545 | const editJsonModalCancelHandler = () => {
546 | setEditJsonModalContent('');
547 | setEditorMarkers([]);
548 | setEditJsonModalOpen(false);
549 | };
550 | function handleEditorValidation(markers) {
551 | // model markers
552 | setEditorMarkers(markers);
553 | console.log(markers);
554 | }
555 |
556 | const [deleteModalOpen, setDeleteModalOpen] = useState(false);
557 | const deleteModalOkButtonClickHandler = async () => {
558 | if (assets.songs) {
559 | const patchedSonglist = assets.songs.filter((song, index) => {
560 | if (selectedRowKeys.includes(song.id)) {
561 | return false;
562 | }
563 | return true;
564 | });
565 |
566 | const mergedResults = await Promise.allSettled([
567 | window.aam.ipcRenderer.saveSonglist({
568 | songs: patchedSonglist,
569 | }),
570 | window.aam.ipcRenderer.deleteSongs(selectedRowKeys as string[]),
571 | ]);
572 |
573 | console.log(mergedResults);
574 | const isAllResultsOk = mergedResults
575 | .map((result) => {
576 | return result.status === 'rejected' ? 1 : result.value.code;
577 | })
578 | .reduce((a, b) => a + b);
579 | if (isAllResultsOk === 0) {
580 | messageApi.success('已保存');
581 | // window.aam.ipcRenderer.loadSongs(assets.path);
582 | refreshButtonClickHandler();
583 | setDeleteModalOpen(false);
584 | } else {
585 | messageApi.error('遇到了未知错误');
586 | }
587 | }
588 | };
589 |
590 | const unlinkButtonClickHandler = async () => {
591 | if (assets.songs) {
592 | const patchedSonglist = assets.songs.map((song, index) => {
593 | if (selectedRowKeys.includes(song.id) && song._external) {
594 | return { ...song, _external: undefined };
595 | }
596 | return song;
597 | });
598 |
599 | const saveSonglistResp = await window.aam.ipcRenderer.saveSonglist({
600 | songs: patchedSonglist,
601 | });
602 |
603 | console.log('patched: ', patchedSonglist);
604 |
605 | if (saveSonglistResp.code === 0) {
606 | messageApi.success('已保存');
607 | // window.aam.ipcRenderer.loadSongs(assets.path);
608 | refreshButtonClickHandler();
609 | setEditJsonModalOpen(false);
610 | } else {
611 | messageApi.error(saveSonglistResp.message);
612 | }
613 | }
614 | };
615 |
616 | useEffect(() => {
617 | window.aam.ipcRenderer.onStartGeneratePackage(() => {
618 | setLog('');
619 | setLoading(true);
620 | });
621 | window.aam.ipcRenderer.onStopGeneratePackage(() => {
622 | setLoading(false);
623 | setLog('');
624 | });
625 | window.aam.ipcRenderer.onLog((_, args) => {
626 | setLog(args);
627 | });
628 | }, []);
629 |
630 | return (
631 | <>
632 | {contextHolder}
633 |
634 |
641 |
642 |
649 |
657 |
666 |
674 |
683 |
692 |
701 |
702 |
}
705 | allowClear
706 | style={{ width: '250px' }}
707 | value={searchValue}
708 | placeholder="例:base ftr9+/any11"
709 | onChange={(e) => {
710 | setSearchValue(e.target.value);
711 | }}
712 | />
713 |
714 |
715 |
725 |
726 |
741 |
747 |
755 |
756 |
757 |
758 |
765 |
766 | }
767 | destroyOnClose
768 | >
769 |
774 |
775 | setDeleteModalOpen(false)}
779 | okType="danger"
780 | onOk={deleteModalOkButtonClickHandler}
781 | >
782 | 将会删除以下歌曲(包括对应的文件)。
783 |
784 | {selectedRowKeys.map((id) => (
785 | <>
786 | {id}
787 |
788 | >
789 | ))}
790 |
791 | 此操作不能撤销。
792 |
793 |
794 | >
795 | );
796 | };
797 |
798 | export default Songs;
799 |
--------------------------------------------------------------------------------
/.erb/img/palette-sponsor-banner.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------