Compare commits

...

17 Commits

Author SHA1 Message Date
a72ec25088 chore: update CHANGELOG.md 2025-04-17 07:49:03 +00:00
5893536daa chore: bump ver 2025-04-17 07:44:58 +00:00
e9550e8eee feat: global progress bar
Also fix me having no foresight and executing things
inside log::debug! macros
2025-04-17 07:44:05 +00:00
658a69a1e2 feat: theme switcher 2025-04-16 22:50:15 +00:00
f3ee0d0068 fix: create a data dir for fern 2025-04-16 22:05:27 +00:00
43f885cffc fix: hotfix '@/types' 2025-04-16 21:54:51 +00:00
d0ce3cddc7 fix: better handling of broken shortcuts 2025-04-16 15:46:39 +00:00
9cbdf2a9c8 docs: update readme and changelog 2025-04-16 15:14:16 +00:00
54a6476010 chore: replace the icon 2025-04-16 15:12:25 +00:00
e4dc0b1f55 fix: prettier print 2025-04-16 13:26:01 +00:00
e6c21ef04a feat: shortcuts 2025-04-16 13:20:43 +00:00
d3145bfc4e fix: aimeio not applying 2025-04-16 07:41:38 +00:00
c7ddeb53e6 fix: native io4 regression 2025-04-15 18:27:42 +00:00
b82fcc942f feat: chuniio 2025-04-15 13:12:12 +00:00
ac18c34895 fix: disable unsupported mods
Also hotfix amdaemon crashing
2025-04-15 07:32:18 +00:00
f588892b05 feat: verbose toggle, info tab, many misc fixes 2025-04-14 19:20:08 +00:00
37df371006 fix: support for translationengine 2025-04-13 23:24:32 +00:00
54 changed files with 939 additions and 300 deletions

78
CHANGELOG.md Normal file
View File

@ -0,0 +1,78 @@
## 0.10.0
- Added a global progress bar
- Fixed issues with downloading under certain conditions
## 0.9.0
- Added a light/dark theme switcher
## 0.8.1
- Hotfixed the program failing to launch if the data dir hadn't already been created
## 0.8.0
- Added support for ChuniIO
- CHUNITHM support is now complete
- Added a context menu option to create a desktop shortcut
- Added a confirmation prompt before deleting a profile
- Removed Slow
## 0.7.1
- Hotfixed amdaemon crashing at launch
- Greyed out packages currently incompatible with STARTLINER
## 0.7.0
- Hopefully fixed issues with the download button
- Added a verbose logging option
- Added an info tab
- Instead of auto-installing segatools & mempatcher at launch, the package store now shows a "install recommended" button
## 0.6.1
- Added support for O.N.G.E.K.I. English Translation
- Disabled the icon buttons as they broke at some point
## 0.6.0
- Chunithm: added support for DLLs (saekawa, mempatcher)
- Chunithm: added a patch interface
- Chunithm: added display settings
- Chunithm: removed split IR
- Both games: added hardware aime reader support
- Added an update progress bar
## 0.5.0
- Added a keyboard configuration UI (for both games)
- Added a prompt after selecting `mu3.exe`/`chusanApp.exe` in the file picker, allowing you to copy much of the data from `segatools.ini`, if it already exists.
- Added an option to not switch the primary monitor.
- This is not recommended to use but it may help when the primary monitor switcher doesn't work correctly.
## 0.4.0
- New error dialog.
- Added tooltips for IO, Aime, Hook.
- Added a welcome message.
- Added a separate auto-update toggle.
- Fixed a display bug with the offline mode toggle.
## 0.3.0
- Added UI scaling, offline mode, and an 'update all' button
- First public release
## 0.2.0
- Added a context menu for the start button with additional launch options
- Added an audio mode button for ongeki
- Fixed the launcher freezing while the game is running
- Probably added auto-updates
## 0.1.0
⚠️ this release is incomplete and potentially cursed
it's more of a preview of a preview

View File

@ -1,6 +1,7 @@
# STARTLINER # STARTLINER
A simple and easy to use launcher, configuration tool and mod manager for O.N.G.E.K.I. and CHUNITHM, using [Rainycolor Watercolor](https://rainy.patafour.zip). A simple and easy to use launcher, configuration tool and mod manager
for O.N.G.E.K.I. and CHUNITHM, using [Rainycolor Watercolor](https://rainy.patafour.zip).
Made with Rust (Tauri) and Vue. Technically multiplatform. Contributions welcome. Made with Rust (Tauri) and Vue. Technically multiplatform. Contributions welcome.
@ -8,12 +9,9 @@ Made with Rust (Tauri) and Vue. Technically multiplatform. Contributions welcome
- [Clean](https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Architecture-details) data modding - [Clean](https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Architecture-details) data modding
- Segatools configuration - Segatools configuration
- Display configuration with automatic rollback - Monitor configuration with automatic rollback
- Support for multiple configurations pointing at the same data - Support for multiple configurations pointing at the same data
![Mod list](res/list.png)
![Configuration](res/cfg.png)
## Usage ## Usage
Download a prebuilt binary from [Releases](https://gitea.tendokyu.moe/akanyan/STARTLINER/releases) or build it yourself: Download a prebuilt binary from [Releases](https://gitea.tendokyu.moe/akanyan/STARTLINER/releases) or build it yourself:
@ -23,37 +21,22 @@ bun install
bun run tauri build bun run tauri build
``` ```
Create a profile, then click on things in the configuration tab (game path, `amfs` and network at the least). STARTLINER expects clean data with unpacked binaries. Anything else you may have in the game directory (segatools, BepInEx, etc.) can be present, but will not be used. Create a profile, then click on things in the configuration tab (game path, `amfs` and network at the least).
Once a profile has been set up, it is possible to bypass the GUI: STARTLINER expects clean data with unpacked binaries. Anything else you may have in the game directory
(segatools, BepInEx, etc.) can be present, but will not be used.
```sh
startliner --start --game ongeki --profile <name>
```
To create a desktop shortcut: `Copy -> Paste Shortcut -> Properties`, and then append `--start --game ongeki --profile <name>` to `Target`.
## Package format ## Package format
- [Package format requirements](https://rainy.patafour.zip/package/create/docs/) Refer to [the wiki](https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Package-format).
- A subset of the simple BlueSteel Rainycolor format is currently supported. [Full reference (CW: vore)](https://yozora.bluesteel.737.jp.net/HarmonyPublic/SOS-Kongou/wiki/Create-Module#user-content-rainycolor-simple)
```
├───app
│ └───BepInEx
│ └───*
├───option
│ └───Axyz
│ └───*
├───icon.png
├───README.md
└───manifest.json
```
More file overrides may be supported in the future.
Arbitrary scripts are not supported by design and that will probably never change.
## See also ## See also
- [BlueSteel launcher (CW: vore)](https://yozora.bluesteel.737.jp.net/HarmonyPublic/SOS-Kongou) [BlueSteel launcher (CW: vore)](https://yozora.bluesteel.737.jp.net/HarmonyPublic/SOS-Kongou)
## Screenshots
![Package list](res/list.png)
![Package store](res/store.png)
![Configuration](res/cfg.png)
![Keyboard](res/cfg2.png)

View File

@ -1,11 +1,8 @@
### Short-term ### Short-term
- CHUNITHM support
- https://gitea.tendokyu.moe/TeamTofuShop/segatools/issues/63 - https://gitea.tendokyu.moe/TeamTofuShop/segatools/issues/63
### Long-term ### Long-term
- Auto-updates - artemis as a special package
- Progress bars and other GUI sugar
- IO DLLs and artemis as special packages
- Other arcade games (if there is demand) - Other arcade games (if there is demand)

View File

@ -4,10 +4,11 @@
"": { "": {
"name": "startliner", "name": "startliner",
"dependencies": { "dependencies": {
"@f3ve/vue-markdown-it": "^0.2.3",
"@mdi/font": "7.4.47", "@mdi/font": "7.4.47",
"@primevue/forms": "^4.3.3", "@primevue/forms": "^4.3.3",
"@primevue/themes": "^4.3.3", "@primevue/themes": "^4.3.3",
"@tailwindcss/vite": "^4.1.2", "@tailwindcss/vite": "^4.1.3",
"@tauri-apps/api": "^2.4.1", "@tauri-apps/api": "^2.4.1",
"@tauri-apps/plugin-cli": "^2.2.0", "@tauri-apps/plugin-cli": "^2.2.0",
"@tauri-apps/plugin-deep-link": "~2.2.1", "@tauri-apps/plugin-deep-link": "~2.2.1",
@ -17,29 +18,31 @@
"@tauri-apps/plugin-shell": "~2.2.1", "@tauri-apps/plugin-shell": "~2.2.1",
"@tauri-apps/plugin-updater": "^2.7.0", "@tauri-apps/plugin-updater": "^2.7.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2", "@trivago/prettier-plugin-sort-imports": "^5.2.2",
"pinia": "^3.0.1", "@types/markdown-it": "^14.1.2",
"markdown-it": "^14.1.0",
"pinia": "^3.0.2",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.3.3", "primevue": "^4.3.3",
"roboto-fontface": "^0.10.0", "roboto-fontface": "^0.10.0",
"tailwindcss": "^4.1.2", "tailwindcss": "^4.1.3",
"tailwindcss-primeui": "^0.4.0", "tailwindcss-primeui": "^0.4.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vuetify": "^3.8.0", "vuetify": "^3.8.1",
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.4.1", "@tauri-apps/cli": "^2.4.1",
"@tsconfig/node22": "^22.0.1", "@tsconfig/node22": "^22.0.1",
"@types/node": "^22.14.0", "@types/node": "^22.14.1",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"@vue/eslint-config-typescript": "^14.5.0", "@vue/eslint-config-typescript": "^14.5.0",
"@vue/tsconfig": "^0.5.1", "@vue/tsconfig": "^0.5.1",
"npm-run-all2": "^7.0.2", "npm-run-all2": "^7.0.2",
"sass": "1.77.8", "sass": "1.77.8",
"sass-embedded": "^1.86.3", "sass-embedded": "^1.86.3",
"typescript": "^5.8.2", "typescript": "^5.8.3",
"unplugin-fonts": "^1.3.1", "unplugin-fonts": "^1.3.1",
"unplugin-vue-components": "^0.27.5", "unplugin-vue-components": "^0.27.5",
"vite": "^6.2.5", "vite": "^6.2.6",
"vite-plugin-vuetify": "^2.1.1", "vite-plugin-vuetify": "^2.1.1",
"vue-tsc": "^2.2.8", "vue-tsc": "^2.2.8",
}, },
@ -132,6 +135,8 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.2.5", "", { "dependencies": { "@eslint/core": "^0.10.0", "levn": "^0.4.1" } }, "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A=="], "@eslint/plugin-kit": ["@eslint/plugin-kit@0.2.5", "", { "dependencies": { "@eslint/core": "^0.10.0", "levn": "^0.4.1" } }, "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A=="],
"@f3ve/vue-markdown-it": ["@f3ve/vue-markdown-it@0.2.3", "", { "dependencies": { "markdown-it": "^14.1.0" }, "peerDependencies": { "vue": "^3.3.4" } }, "sha512-v0VNd7wb55kwsUUy3n6DLI9+0FYSG0PrCTD3bWuSRo6WS3OHD5wghh/aHzebVdsVkSBXfVpiEUlMA3DrxLs7Lw=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
@ -292,6 +297,12 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
"@types/node": ["@types/node@22.14.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw=="], "@types/node": ["@types/node@22.14.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.29.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.29.0", "@typescript-eslint/type-utils": "8.29.0", "@typescript-eslint/utils": "8.29.0", "@typescript-eslint/visitor-keys": "8.29.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-PAIpk/U7NIS6H7TEtN45SPGLQaHNgB7wSjsQV/8+KYokAb2T/gloOA/Bee2yd4/yKVhPKe5LlaUGhAZk5zmSaQ=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.29.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.29.0", "@typescript-eslint/type-utils": "8.29.0", "@typescript-eslint/utils": "8.29.0", "@typescript-eslint/visitor-keys": "8.29.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-PAIpk/U7NIS6H7TEtN45SPGLQaHNgB7wSjsQV/8+KYokAb2T/gloOA/Bee2yd4/yKVhPKe5LlaUGhAZk5zmSaQ=="],
@ -540,6 +551,8 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.29.2", "", { "os": "win32", "cpu": "x64" }, "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA=="], "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.29.2", "", { "os": "win32", "cpu": "x64" }, "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA=="],
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
"local-pkg": ["local-pkg@0.5.1", "", { "dependencies": { "mlly": "^1.7.3", "pkg-types": "^1.2.1" } }, "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ=="], "local-pkg": ["local-pkg@0.5.1", "", { "dependencies": { "mlly": "^1.7.3", "pkg-types": "^1.2.1" } }, "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@ -550,6 +563,10 @@
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
"markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="],
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
"memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="], "memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
@ -620,6 +637,8 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"read-package-json-fast": ["read-package-json-fast@4.0.0", "", { "dependencies": { "json-parse-even-better-errors": "^4.0.0", "npm-normalize-package-bin": "^4.0.0" } }, "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg=="], "read-package-json-fast": ["read-package-json-fast@4.0.0", "", { "dependencies": { "json-parse-even-better-errors": "^4.0.0", "npm-normalize-package-bin": "^4.0.0" } }, "sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg=="],
@ -726,6 +745,8 @@
"typescript-eslint": ["typescript-eslint@8.29.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.29.0", "@typescript-eslint/parser": "8.29.0", "@typescript-eslint/utils": "8.29.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-ep9rVd9B4kQsZ7ZnWCVxUE/xDLUUUsRzE0poAeNu+4CkFErLfuvPt/qtm2EpnSyfvsR0S6QzDFSrPCFBwf64fg=="], "typescript-eslint": ["typescript-eslint@8.29.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.29.0", "@typescript-eslint/parser": "8.29.0", "@typescript-eslint/utils": "8.29.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-ep9rVd9B4kQsZ7ZnWCVxUE/xDLUUUsRzE0poAeNu+4CkFErLfuvPt/qtm2EpnSyfvsR0S6QzDFSrPCFBwf64fg=="],
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
"ufo": ["ufo@1.5.4", "", {}, "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ=="], "ufo": ["ufo@1.5.4", "", {}, "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],

View File

@ -10,6 +10,7 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@f3ve/vue-markdown-it": "^0.2.3",
"@mdi/font": "7.4.47", "@mdi/font": "7.4.47",
"@primevue/forms": "^4.3.3", "@primevue/forms": "^4.3.3",
"@primevue/themes": "^4.3.3", "@primevue/themes": "^4.3.3",
@ -23,6 +24,8 @@
"@tauri-apps/plugin-shell": "~2.2.1", "@tauri-apps/plugin-shell": "~2.2.1",
"@tauri-apps/plugin-updater": "^2.7.0", "@tauri-apps/plugin-updater": "^2.7.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2", "@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/markdown-it": "^14.1.2",
"markdown-it": "^14.1.0",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.3.3", "primevue": "^4.3.3",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 43 KiB

BIN
res/cfg2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
res/icon-chunithm.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
res/icon-ongeki.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 70 KiB

BIN
res/store.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

3
rust/Cargo.lock generated
View File

@ -803,7 +803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [ dependencies = [
"lazy_static", "lazy_static",
"windows-sys 0.48.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@ -4730,6 +4730,7 @@ dependencies = [
"humantime", "humantime",
"junction", "junction",
"log", "log",
"open",
"regex", "regex",
"reqwest", "reqwest",
"rust-ini", "rust-ini",

View File

@ -45,6 +45,7 @@ sha256 = "1.6.0"
serialport = "4.7.1" serialport = "4.7.1"
fern = { version ="0.7.1", features = ["colored"] } fern = { version ="0.7.1", features = ["colored"] }
humantime = "2.2.0" humantime = "2.2.0"
open = "5.3.2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-cli = "2" tauri-plugin-cli = "2"
@ -52,5 +53,5 @@ tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
winsafe = { version = "0.0.23", features = ["user"] } winsafe = { version = "0.0.23", features = ["user", "ole", "shell"] }
displayz = "^0.2.0" displayz = "^0.2.0"

View File

@ -23,6 +23,7 @@
"fs:allow-data-read-recursive", "fs:allow-data-read-recursive",
"fs:allow-data-write-recursive", "fs:allow-data-write-recursive",
"fs:allow-config-read-recursive", "fs:allow-config-read-recursive",
"fs:allow-config-write-recursive" "fs:allow-config-write-recursive",
"shell:allow-open"
] ]
} }

BIN
rust/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
rust/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@ -1,4 +1,5 @@
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use std::time::SystemTime;
use crate::model::config::GlobalConfig; use crate::model::config::GlobalConfig;
use crate::model::patch::PatchFileVec; use crate::model::patch::PatchFileVec;
use crate::pkg::{Feature, Status}; use crate::pkg::{Feature, Status};
@ -7,6 +8,7 @@ use crate::{model::misc::Game, pkg::PkgKey};
use crate::pkg_store::PackageStore; use crate::pkg_store::PackageStore;
use crate::util; use crate::util;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use fern::colors::{Color, ColoredLevelConfig};
use tauri::AppHandle; use tauri::AppHandle;
pub struct GlobalState { pub struct GlobalState {
@ -34,6 +36,8 @@ impl AppData {
.and_then(|s| Ok(serde_json::from_str::<GlobalConfig>(&s)?)) .and_then(|s| Ok(serde_json::from_str::<GlobalConfig>(&s)?))
.unwrap_or_default(); .unwrap_or_default();
Self::init_logger(&cfg);
let profile = match cfg.recent_profile { let profile = match cfg.recent_profile {
Some((game, ref name)) => Profile::load(game, name.clone()).ok(), Some((game, ref name)) => Profile::load(game, name.clone()).ok(),
None => None None => None
@ -127,4 +131,38 @@ impl AppData {
p.fix(&self.pkgs); p.fix(&self.pkgs);
} }
} }
fn init_logger(cfg: &GlobalConfig) {
_ = std::fs::create_dir_all(util::data_dir());
let mut fern_builder;
let colors = ColoredLevelConfig::new()
.debug(Color::Green)
.info(Color::Blue)
.warn(Color::Yellow)
.error(Color::Red);
fern_builder = fern::Dispatch::new()
.format(move |out, message, record| {
out.finish(format_args!(
"[{} {} {}] {}",
humantime::format_rfc3339_seconds(SystemTime::now()),
colors.color(record.level()),
record.target(),
message
))
})
.chain(std::io::stdout())
.chain(fern::log_file(util::data_dir().join("log.txt")).expect("unable to initialize the logger"));
if cfg.verbose == true {
fern_builder = fern_builder.level(log::LevelFilter::Debug);
} else {
fern_builder = fern_builder.level(log::LevelFilter::Info);
}
if let Err(e) = fern_builder.apply() {
panic!("unable to initialize the logger? {:?}", e);
}
}
} }

View File

@ -323,9 +323,10 @@ pub async fn duplicate_profile(profile: ProfileMeta) -> Result<(), String> {
pub async fn delete_profile(state: State<'_, Mutex<AppData>>, profile: ProfileMeta) -> Result<(), String> { pub async fn delete_profile(state: State<'_, Mutex<AppData>>, profile: ProfileMeta) -> Result<(), String> {
log::debug!("invoke: delete_profile({:?})", profile); log::debug!("invoke: delete_profile({:?})", profile);
std::fs::remove_dir_all(profile.config_dir()) util::remove_dir_all(profile.config_dir())
.await
.map_err(|e| format!("Unable to delete {:?}: {}", profile.config_dir(), e))?; .map_err(|e| format!("Unable to delete {:?}: {}", profile.config_dir(), e))?;
if let Err(e) = std::fs::remove_dir_all(profile.data_dir()) { if let Err(e) = util::remove_dir_all(profile.data_dir()).await {
log::warn!("Unable to delete: {:?} {}", profile.data_dir(), e); log::warn!("Unable to delete: {:?} {}", profile.data_dir(), e);
} }
@ -396,6 +397,13 @@ pub async fn load_segatools_ini(state: State<'_, Mutex<AppData>>, path: PathBuf)
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn create_shortcut(app: AppHandle, profile_meta: ProfileMeta) -> Result<(), String> {
log::debug!("invoke: create_shortcut({:?})", profile_meta);
util::create_shortcut(app, &profile_meta).map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
pub async fn list_platform_capabilities() -> Result<Vec<String>, ()> { pub async fn list_platform_capabilities() -> Result<Vec<String>, ()> {
log::debug!("invoke: list_platform_capabilities"); log::debug!("invoke: list_platform_capabilities");
@ -414,7 +422,8 @@ pub async fn get_global_config(state: State<'_, Mutex<AppData>>, field: GlobalCo
let appd = state.lock().await; let appd = state.lock().await;
match field { match field {
GlobalConfigField::OfflineMode => Ok(appd.cfg.offline_mode), GlobalConfigField::OfflineMode => Ok(appd.cfg.offline_mode),
GlobalConfigField::EnableAutoupdates => Ok(appd.cfg.enable_autoupdates) GlobalConfigField::EnableAutoupdates => Ok(appd.cfg.enable_autoupdates),
GlobalConfigField::Verbose => Ok(appd.cfg.verbose)
} }
} }
@ -425,7 +434,8 @@ pub async fn set_global_config(state: State<'_, Mutex<AppData>>, field: GlobalCo
let mut appd = state.lock().await; let mut appd = state.lock().await;
match field { match field {
GlobalConfigField::OfflineMode => appd.cfg.offline_mode = value, GlobalConfigField::OfflineMode => appd.cfg.offline_mode = value,
GlobalConfigField::EnableAutoupdates => appd.cfg.enable_autoupdates = value GlobalConfigField::EnableAutoupdates => appd.cfg.enable_autoupdates = value,
GlobalConfigField::Verbose => appd.cfg.verbose = value,
}; };
appd.write().map_err(|e| e.to_string()) appd.write().map_err(|e| e.to_string())
} }
@ -472,6 +482,18 @@ pub async fn file_exists(path: String) -> Result<bool, ()> {
Ok(std::fs::exists(path).unwrap_or(false)) Ok(std::fs::exists(path).unwrap_or(false))
} }
// Easier than trying to get the barely-documented tauri permissions system to work
#[tauri::command]
pub async fn open_file(path: String) -> Result<(), String> {
open::that(path).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn get_changelog() -> Result<String, ()> {
Ok(include_str!("../../CHANGELOG.md").to_owned())
}
#[tauri::command] #[tauri::command]
pub async fn list_com_ports() -> Result<BTreeMap<String, i32>, String> { pub async fn list_com_ports() -> Result<BTreeMap<String, i32>, String> {
let ports = serialport::available_ports().unwrap_or(Vec::new()); let ports = serialport::available_ports().unwrap_or(Vec::new());

View File

@ -1,4 +1,5 @@
use std::{collections::HashSet, path::PathBuf}; use std::{collections::HashSet, path::PathBuf};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tokio::fs::File; use tokio::fs::File;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
@ -6,14 +7,20 @@ use anyhow::{anyhow, Result};
use crate::pkg::{Package, PkgKey, Remote}; use crate::pkg::{Package, PkgKey, Remote};
pub struct DownloadHandler { pub struct DownloadHandler {
set: HashSet<String>, paths: HashSet<PathBuf>,
app: AppHandle app: AppHandle
} }
#[derive(Serialize, Deserialize, Clone)]
pub struct DownloadTick {
pkg_key: PkgKey,
ratio: f32,
}
impl DownloadHandler { impl DownloadHandler {
pub fn new(app: AppHandle) -> DownloadHandler { pub fn new(app: AppHandle) -> DownloadHandler {
DownloadHandler { DownloadHandler {
set: HashSet::new(), paths: HashSet::new(),
app app
} }
} }
@ -22,11 +29,11 @@ impl DownloadHandler {
let rmt = pkg.rmt.as_ref() let rmt = pkg.rmt.as_ref()
.ok_or_else(|| anyhow!("Attempted to download a package without remote data"))? .ok_or_else(|| anyhow!("Attempted to download a package without remote data"))?
.clone(); .clone();
if self.set.contains(zip_path.to_string_lossy().as_ref()) { if self.paths.contains(zip_path) {
// Todo when there is a clear cache button, it should clear the set Ok(())
Err(anyhow!("Already downloading"))
} else { } else {
self.set.insert(zip_path.to_string_lossy().to_string()); // TODO clear cache button should clear this
self.paths.insert(zip_path.clone());
tauri::async_runtime::spawn(Self::download_zip_proc(self.app.clone(), zip_path.clone(), pkg.key(), rmt)); tauri::async_runtime::spawn(Self::download_zip_proc(self.app.clone(), zip_path.clone(), pkg.key(), rmt));
Ok(()) Ok(())
} }
@ -42,10 +49,16 @@ impl DownloadHandler {
let mut cache_file_w = File::create(&zip_path_part).await?; let mut cache_file_w = File::create(&zip_path_part).await?;
let mut byte_stream = reqwest::get(&rmt.download_url).await?.bytes_stream(); let mut byte_stream = reqwest::get(&rmt.download_url).await?.bytes_stream();
let mut total_bytes = 0;
log::info!("downloading: {}", rmt.download_url); log::info!("downloading: {}", rmt.download_url);
while let Some(item) = byte_stream.next().await { while let Some(item) = byte_stream.next().await {
let i = item?; let i = item?;
total_bytes += i.len();
_ = app.emit("download-progress", DownloadTick {
pkg_key: pkg_key.clone(),
ratio: (total_bytes as f32) / (rmt.file_size as f32),
})?;
cache_file_w.write_all(&mut i.as_ref()).await?; cache_file_w.write_all(&mut i.as_ref()).await?;
} }
cache_file_w.sync_all().await?; cache_file_w.sync_all().await?;

View File

@ -9,15 +9,14 @@ mod modules;
mod profiles; mod profiles;
mod patcher; mod patcher;
use std::{sync::OnceLock, time::SystemTime}; use std::sync::OnceLock;
use anyhow::anyhow; use anyhow::anyhow;
use closure::closure; use closure::closure;
use appdata::{AppData, ToggleAction}; use appdata::{AppData, ToggleAction};
use fern::colors::{Color, ColoredLevelConfig};
use model::misc::Game; use model::misc::Game;
use pkg::PkgKey; use pkg::PkgKey;
use pkg_store::Payload; use pkg_store::Payload;
use tauri::{AppHandle, Listener, Manager, RunEvent}; use tauri::{AppHandle, Emitter, Listener, Manager, RunEvent};
use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_cli::CliExt; use tauri_plugin_cli::CliExt;
use tokio::{fs, sync::Mutex, try_join}; use tokio::{fs, sync::Mutex, try_join};
@ -48,42 +47,7 @@ pub async fn run(_args: Vec<String>) {
util::init_dirs(&apph); util::init_dirs(&apph);
let mut fern_builder; let mut app_data = AppData::new(app.handle().clone());
{
let colors = ColoredLevelConfig::new()
.debug(Color::Green)
.info(Color::Blue)
.warn(Color::Yellow)
.error(Color::Red);
fern_builder = fern::Dispatch::new()
.format(move |out, message, record| {
out.finish(format_args!(
"[{} {} {}] {}",
humantime::format_rfc3339_seconds(SystemTime::now()),
colors.color(record.level()),
record.target(),
message
))
})
.chain(std::io::stdout())
.chain(fern::log_file(util::data_dir().join("log.txt")).expect("unable to initialize the logger"));
}
#[cfg(debug_assertions)]
{
fern_builder = fern_builder.level(log::LevelFilter::Debug);
}
#[cfg(not(debug_assertions))]
{
if std::env::var("DEBUG_LOG").is_ok() {
fern_builder = fern_builder.level(log::LevelFilter::Debug);
} else {
fern_builder = fern_builder.level(log::LevelFilter::Info);
}
}
fern_builder.apply()?;
log::info!( log::info!(
"running from {}", "running from {}",
@ -93,7 +57,6 @@ pub async fn run(_args: Vec<String>) {
.unwrap_or_default() .unwrap_or_default()
); );
let mut app_data = AppData::new(app.handle().clone());
let start_immediately; let start_immediately;
if let Ok(matches) = app.cli().matches() { if let Ok(matches) = app.cli().matches() {
@ -103,13 +66,8 @@ pub async fn run(_args: Vec<String>) {
log::debug!("{:?} {:?} {:?}", start_arg, game_arg, name_arg); log::debug!("{:?} {:?} {:?}", start_arg, game_arg, name_arg);
if start_arg.occurrences > 0 { if start_arg.occurrences > 0 {
start_immediately = true; start_immediately = true;
app_data.state.remain_open = false;
} else { } else {
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into())) open_window(apph.clone())?;
.title("STARTLINER")
.inner_size(900f64, 480f64)
.min_inner_size(900f64, 480f64)
.build()?;
start_immediately = false; start_immediately = false;
} }
@ -144,14 +102,15 @@ pub async fn run(_args: Vec<String>) {
}); });
app.listen("download-end", closure!(clone apph, |ev| { app.listen("download-end", closure!(clone apph, |ev| {
log::debug!("download-end triggered: {}", ev.payload());
let raw = ev.payload(); let raw = ev.payload();
log::debug!("download-end triggered: {}", raw);
let key = PkgKey(raw[1..raw.len()-1].to_owned()); let key = PkgKey(raw[1..raw.len()-1].to_owned());
let apph = apph.clone(); let apph = apph.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let mutex = apph.state::<Mutex<AppData>>(); let mutex = apph.state::<Mutex<AppData>>();
let mut appd = mutex.lock().await; let mut appd = mutex.lock().await;
log::debug!("download-end install {:?}", appd.pkgs.install_package(&key, true, false).await); let res = appd.pkgs.install_package(&key, true, false).await;
log::debug!("download-end install {:?}", res);
}); });
})); }));
@ -168,19 +127,21 @@ pub async fn run(_args: Vec<String>) {
})); }));
app.listen("install-end-prelude", closure!(clone apph, |ev| { app.listen("install-end-prelude", closure!(clone apph, |ev| {
log::debug!("install-end-prelude triggered: {}", ev.payload());
let payload = serde_json::from_str::<Payload>(ev.payload()); let payload = serde_json::from_str::<Payload>(ev.payload());
log::debug!("install-end-prelude triggered: {:?}", payload);
let apph = apph.clone(); let apph = apph.clone();
if let Ok(payload) = payload { if let Ok(payload) = payload {
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let mutex = apph.state::<Mutex<AppData>>(); let mutex = apph.state::<Mutex<AppData>>();
let mut appd = mutex.lock().await; let mut appd = mutex.lock().await;
let res = appd.toggle_package(payload.pkg.clone(), ToggleAction::EnableSelf);
log::debug!( log::debug!(
"install-end-prelude toggle {:?}", "install-end-prelude toggle {:?}",
appd.toggle_package(payload.pkg.clone(), ToggleAction::EnableSelf) res
); );
use tauri::Emitter; use tauri::Emitter;
log::debug!("install-end {:?}", apph.emit("install-end", payload)); let res = apph.emit("install-end", payload);
log::debug!("install-end {:?}", res);
}); });
} else { } else {
log::error!("install-end-prelude: invalid payload: {}", ev.payload()); log::error!("install-end-prelude: invalid payload: {}", ev.payload());
@ -194,13 +155,20 @@ pub async fn run(_args: Vec<String>) {
{ {
let mut appd = mtx.lock().await; let mut appd = mtx.lock().await;
if let Err(e) = appd.pkgs.reload_all().await { if let Err(e) = appd.pkgs.reload_all().await {
log::error!("Unable to reload packages: {}", e); log::error!("unable to reload packages: {}", e);
apph.exit(1); apph.exit(1);
} }
} }
if let Err(e) = cmd::startline(apph.clone(), false).await { if let Err(e) = cmd::startline(apph.clone(), false).await {
log::error!("Unable to launch: {}", e); log::error!("unable to launch: {}", e);
apph.exit(1); _ = open_window(apph.clone());
// stupid but effective
std::thread::sleep(std::time::Duration::from_secs(3));
_ = apph.emit("launch-error", e.to_string());
} else {
let mut appd = mtx.lock().await;
appd.state.remain_open = false;
log::info!("started quietly");
} }
}); });
} else { } else {
@ -236,6 +204,7 @@ pub async fn run(_args: Vec<String>) {
cmd::sync_current_profile, cmd::sync_current_profile,
cmd::save_current_profile, cmd::save_current_profile,
cmd::load_segatools_ini, cmd::load_segatools_ini,
cmd::create_shortcut,
cmd::get_global_config, cmd::get_global_config,
cmd::set_global_config, cmd::set_global_config,
@ -244,6 +213,8 @@ pub async fn run(_args: Vec<String>) {
cmd::list_platform_capabilities, cmd::list_platform_capabilities,
cmd::list_directories, cmd::list_directories,
cmd::file_exists, cmd::file_exists,
cmd::open_file,
cmd::get_changelog,
cmd::list_com_ports, cmd::list_com_ports,
@ -264,7 +235,8 @@ pub async fn run(_args: Vec<String>) {
let mutex = app.state::<Mutex<AppData>>(); let mutex = app.state::<Mutex<AppData>>();
let appd = mutex.lock().await; let appd = mutex.lock().await;
if let Some(p) = &appd.profile { if let Some(p) = &appd.profile {
log::debug!("save: {:?}", p.save()); let res = p.save();
log::debug!("save: {:?}", res);
app.exit(0); app.exit(0);
} }
}); });
@ -326,7 +298,7 @@ async fn update(app: tauri::AppHandle) -> tauri_plugin_updater::Result<()> {
update.download_and_install( update.download_and_install(
|chunk_length, content_length| { |chunk_length, content_length| {
downloaded += chunk_length; downloaded += chunk_length;
_ = app.emit("update-progress", (chunk_length as f64) / (content_length.unwrap_or(u64::MAX) as f64)); _ = app.emit("update-progress", (downloaded as f64) / (content_length.unwrap_or(u64::MAX) as f64));
}, },
|| { || {
log::info!("download finished"); log::info!("download finished");
@ -355,5 +327,16 @@ async fn update(app: tauri::AppHandle) -> tauri_plugin_updater::Result<()> {
log::info!("ending auto-update check"); log::info!("ending auto-update check");
Ok(())
}
fn open_window(apph: AppHandle) -> anyhow::Result<()> {
let config = apph.config().clone();
tauri::WebviewWindowBuilder::new(&apph, "main", tauri::WebviewUrl::App("index.html".into()))
.title(format!("STARTLINER {}", config.version.unwrap_or_default()))
.inner_size(900f64, 480f64)
.min_inner_size(900f64, 480f64)
.build()?;
Ok(()) Ok(())
} }

View File

@ -2,10 +2,12 @@ use serde::{Deserialize, Serialize};
use super::misc::Game; use super::misc::Game;
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct GlobalConfig { pub struct GlobalConfig {
pub recent_profile: Option<(Game, String)>, pub recent_profile: Option<(Game, String)>,
pub offline_mode: bool, pub offline_mode: bool,
pub enable_autoupdates: bool, pub enable_autoupdates: bool,
pub verbose: bool,
} }
impl Default for GlobalConfig { impl Default for GlobalConfig {
@ -13,13 +15,16 @@ impl Default for GlobalConfig {
Self { Self {
recent_profile: Default::default(), recent_profile: Default::default(),
offline_mode: false, offline_mode: false,
enable_autoupdates: true enable_autoupdates: true,
verbose: false,
} }
} }
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "snake_case")]
pub enum GlobalConfigField { pub enum GlobalConfigField {
OfflineMode, OfflineMode,
EnableAutoupdates EnableAutoupdates,
Verbose
} }

View File

@ -5,10 +5,9 @@ use crate::pkg::PkgKey;
use super::profile::ProfileModule; use super::profile::ProfileModule;
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Copy)] #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Copy)]
#[serde(rename_all = "snake_case")]
pub enum Game { pub enum Game {
#[serde(rename = "ongeki")]
Ongeki, Ongeki,
#[serde(rename = "chunithm")]
Chunithm, Chunithm,
} }
@ -21,6 +20,13 @@ impl Game {
} }
} }
pub fn print(&self) -> &'static str {
match self {
Game::Ongeki => "O.N.G.E.K.I.",
Game::Chunithm => "CHUNITHM"
}
}
pub fn hook_exe(&self) -> &'static str { pub fn hook_exe(&self) -> &'static str {
match self { match self {
Game::Ongeki => "mu3hook.dll", Game::Ongeki => "mu3hook.dll",

View File

@ -13,7 +13,16 @@ pub enum Aime {
Other(PkgKey), Other(PkgKey),
} }
#[derive(Deserialize, Serialize, Clone, Default, PartialEq, Debug)]
#[serde(rename_all = "snake_case")]
pub enum IOSelection {
Hardware,
#[default] SegatoolsBuiltIn,
Custom(PkgKey)
}
#[derive(Deserialize, Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(default)]
pub struct AMNet { pub struct AMNet {
pub name: String, pub name: String,
pub addr: String, pub addr: String,
@ -26,18 +35,19 @@ impl Default for AMNet {
} }
} }
#[derive(Deserialize, Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug, Default )]
#[serde(default)]
pub struct Segatools { pub struct Segatools {
pub target: PathBuf, pub target: PathBuf,
pub hook: Option<PkgKey>, pub hook: Option<PkgKey>,
#[serde(skip_serializing_if = "Option::is_none")]
pub io: Option<PkgKey>, pub io: Option<PkgKey>,
#[serde(default)] pub io2: IOSelection,
pub aime: Aime, pub aime: Aime,
pub amfs: PathBuf, pub amfs: PathBuf,
pub option: PathBuf, pub option: PathBuf,
pub appdata: PathBuf, pub appdata: PathBuf,
pub intel: bool, pub intel: bool,
#[serde(default)]
pub amnet: AMNet, pub amnet: AMNet,
pub aime_port: Option<i32>, pub aime_port: Option<i32>,
} }
@ -51,6 +61,7 @@ impl Segatools {
Game::Chunithm => Some(PkgKey("segatools-chusanhook".to_owned())) Game::Chunithm => Some(PkgKey("segatools-chusanhook".to_owned()))
}, },
io: None, io: None,
io2: IOSelection::SegatoolsBuiltIn,
amfs: PathBuf::default(), amfs: PathBuf::default(),
option: PathBuf::default(), option: PathBuf::default(),
appdata: PathBuf::from("appdata"), appdata: PathBuf::from("appdata"),
@ -69,7 +80,8 @@ pub enum DisplayMode {
Fullscreen Fullscreen
} }
#[derive(Deserialize, Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[serde(default)]
pub struct Display { pub struct Display {
pub target: String, pub target: String,
pub rez: (i32, i32), pub rez: (i32, i32),
@ -77,11 +89,7 @@ pub struct Display {
pub rotation: Option<i32>, pub rotation: Option<i32>,
pub frequency: i32, pub frequency: i32,
pub borderless_fullscreen: bool, pub borderless_fullscreen: bool,
#[serde(default)]
pub dont_switch_primary: bool, pub dont_switch_primary: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub monitor_index_override: Option<i32>, pub monitor_index_override: Option<i32>,
} }
@ -113,6 +121,7 @@ pub enum NetworkType {
} }
#[derive(Deserialize, Serialize, Clone, Default, Debug)] #[derive(Deserialize, Serialize, Clone, Default, Debug)]
#[serde(default)]
pub struct Network { pub struct Network {
pub network_type: NetworkType, pub network_type: NetworkType,
@ -127,11 +136,13 @@ pub struct Network {
} }
#[derive(Deserialize, Serialize, Clone, Default, Debug)] #[derive(Deserialize, Serialize, Clone, Default, Debug)]
#[serde(default)]
pub struct BepInEx { pub struct BepInEx {
pub console: bool, pub console: bool,
} }
#[derive(Deserialize, Serialize, Clone)] #[derive(Deserialize, Serialize, Clone)]
#[serde(default)]
pub struct Wine { pub struct Wine {
pub runtime: PathBuf, pub runtime: PathBuf,
pub prefix: PathBuf, pub prefix: PathBuf,
@ -155,20 +166,17 @@ pub enum Mu3Audio {
Excl2Ch, Excl2Ch,
} }
#[derive(Deserialize, Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[serde(default)]
pub struct Mu3Ini { pub struct Mu3Ini {
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<Mu3Audio>, pub audio: Option<Mu3Audio>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blacklist: Option<(i32, i32)>, pub blacklist: Option<(i32, i32)>,
} }
fn default_true() -> bool { true }
#[derive(Deserialize, Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(default)]
pub struct OngekiKeyboard { pub struct OngekiKeyboard {
#[serde(default = "default_true")] pub enabled: bool, pub enabled: bool,
pub use_mouse: bool, pub use_mouse: bool,
pub coin: i32, pub coin: i32,
pub svc: i32, pub svc: i32,
@ -208,8 +216,9 @@ impl Default for OngekiKeyboard {
} }
#[derive(Deserialize, Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(default)]
pub struct ChunithmKeyboard { pub struct ChunithmKeyboard {
#[serde(default = "default_true")] pub enabled: bool, pub enabled: bool,
pub coin: i32, pub coin: i32,
pub svc: i32, pub svc: i32,
pub test: i32, pub test: i32,

View File

@ -22,4 +22,5 @@ pub struct V1Version {
pub icon: String, pub icon: String,
pub dependencies: BTreeSet<PkgKeyVersion>, pub dependencies: BTreeSet<PkgKeyVersion>,
pub download_url: String, pub download_url: String,
pub file_size: i64,
} }

View File

@ -75,6 +75,12 @@ impl Keyboard {
// This is assumed to run in sync after the segatools module // This is assumed to run in sync after the segatools module
pub fn line_up(&self, ini: &mut Ini) -> Result<()> { pub fn line_up(&self, ini: &mut Ini) -> Result<()> {
if let Some(enable) = ini.section(Some("io4")).and_then(|s| s.get("enable")) {
// io4 was disabled by the Segatools module -> abort
if enable == "0" {
return Ok(());
}
}
match self { match self {
Keyboard::Ongeki(kb) => { Keyboard::Ongeki(kb) => {
if kb.enabled { if kb.enabled {
@ -95,7 +101,19 @@ impl Keyboard {
.set("mouse", if kb.use_mouse { "1" } else { "0" }); .set("mouse", if kb.use_mouse { "1" } else { "0" });
} else { } else {
ini.with_section(Some("io4")) ini.with_section(Some("io4"))
.set("enable", "0"); .set("test", "0")
.set("service", "0")
.set("coin", "0")
.set("left1", "0")
.set("left2", "0")
.set("left3", "0")
.set("right1", "0")
.set("right2", "0")
.set("right3", "0")
.set("leftSide", "0")
.set("rightSide", "0")
.set("leftMenu", "0")
.set("rightMenu", "0");
} }
} }
Keyboard::Chunithm(kb) => { Keyboard::Chunithm(kb) => {
@ -109,14 +127,21 @@ impl Keyboard {
ini.with_section(Some("io3")) ini.with_section(Some("io3"))
.set("test", kb.test.to_string()) .set("test", kb.test.to_string())
.set("service", kb.svc.to_string()) .set("service", kb.svc.to_string())
.set("coin", kb.coin.to_string()) .set("coin", kb.coin.to_string());
.set("ir", "0");
} else { } else {
ini.with_section(Some("io4")) for (i, _) in kb.cell.iter().enumerate() {
.set("enable", "0"); ini.with_section(Some("slider")).set(format!("cell{}", i + 1), "0");
ini.with_section(Some("slider")) }
.set("enable", "0"); for (i, _) in kb.ir.iter().enumerate() {
ini.with_section(Some("ir")).set(format!("ir{}", i + 1), "0");
}
ini.with_section(Some("io3"))
.set("test", "0")
.set("service", "0")
.set("coin", "0");
} }
ini.with_section(Some("io3"))
.set("ir", "0");
} }
} }

View File

@ -14,7 +14,10 @@ pub async fn prepare_packages<'a>(p: &'a impl ProfilePaths, pkgs: &BTreeSet<PkgK
if redo_bepinex { if redo_bepinex {
if pfx_dir.join("BepInEx").exists() { if pfx_dir.join("BepInEx").exists() {
tokio::fs::remove_dir_all(pfx_dir.join("BepInEx")).await?; util::remove_dir_all(pfx_dir.join("BepInEx")).await?;
}
if pfx_dir.join("lang").exists() {
util::remove_dir_all(pfx_dir.join("lang")).await?;
} }
} }
@ -33,6 +36,13 @@ pub async fn prepare_packages<'a>(p: &'a impl ProfilePaths, pkgs: &BTreeSet<PkgK
if bpx_dir.exists() { if bpx_dir.exists() {
util::copy_directory(&bpx_dir, &pfx_dir.join("BepInEx"), true)?; util::copy_directory(&bpx_dir, &pfx_dir.join("BepInEx"), true)?;
} }
let lang_dir = util::pkg_dir_of(&namespace, &name)
.join("app")
.join("lang");
if lang_dir.exists() {
util::copy_directory(&lang_dir, &pfx_dir.join("lang"), true)?;
}
} }
let opt_dir = util::pkg_dir_of(&namespace, &name).join("option"); let opt_dir = util::pkg_dir_of(&namespace, &name).join("option");

View File

@ -1,7 +1,7 @@
use std::path::{PathBuf, Path}; use std::path::{PathBuf, Path};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use ini::Ini; use ini::Ini;
use crate::{model::{misc::{ConfigHook, ConfigHookAime, ConfigHookAimeUnit, ConfigHookAuth, Game}, profile::{Aime, Segatools}}, profiles::ProfilePaths, util::{self, PathStr}}; use crate::{model::{misc::{ConfigHook, ConfigHookAime, ConfigHookAimeUnit, ConfigHookAuth, Game}, profile::{Aime, IOSelection, Segatools}}, profiles::ProfilePaths, util::{self, PathStr}};
use crate::pkg_store::PackageStore; use crate::pkg_store::PackageStore;
impl Segatools { impl Segatools {
@ -21,8 +21,8 @@ impl Segatools {
if let Some(key) = &self.hook { if let Some(key) = &self.hook {
remove_if_nonpresent!(self.hook, key, None, store); remove_if_nonpresent!(self.hook, key, None, store);
} }
if let Some(key) = &self.io { if let IOSelection::Custom(key) = &self.io2 {
remove_if_nonpresent!(self.io, key, None, store); remove_if_nonpresent!(self.io2, key, IOSelection::default(), store);
} }
match &self.aime { match &self.aime {
Aime::AMNet(key) => remove_if_nonpresent!(self.aime, key, Aime::BuiltIn, store), Aime::AMNet(key) => remove_if_nonpresent!(self.aime, key, Aime::BuiltIn, store),
@ -134,6 +134,9 @@ impl Segatools {
if self.amnet.name.len() > 0 { if self.amnet.name.len() > 0 {
aimeio.set("serverName", &self.amnet.name); aimeio.set("serverName", &self.amnet.name);
} }
} else if let Aime::Other(key) = &self.aime {
ini_out.with_section(Some("aimeio"))
.set("path", util::pkg_dir().join(key.to_string()).join("segatools").join("aimeio.dll").stringify()?);
} }
} else { } else {
ini_out.with_section(Some("aime")) ini_out.with_section(Some("aime"))
@ -141,7 +144,7 @@ impl Segatools {
} }
if game == Game::Ongeki { if game == Game::Ongeki {
if let Some(io) = &self.io { if let IOSelection::Custom(io) = &self.io2 {
ini_out.with_section(Some("mu3io")) ini_out.with_section(Some("mu3io"))
.set("path", util::pkg_dir().join(io.to_string()).join("segatools").join("mu3io.dll").stringify()?); .set("path", util::pkg_dir().join(io.to_string()).join("segatools").join("mu3io.dll").stringify()?);
} else { } else {
@ -149,6 +152,44 @@ impl Segatools {
.set("path", ""); .set("path", "");
} }
} }
match game {
Game::Ongeki => {
match &self.io2 {
IOSelection::Custom(io) => {
ini_out.with_section(Some("mu3io"))
.set("path", util::pkg_dir().join(io.to_string()).join("segatools").join("mu3io.dll").stringify()?);
}
IOSelection::SegatoolsBuiltIn => {
ini_out.with_section(Some("mu3io"))
.set("path", "");
}
IOSelection::Hardware => {
ini_out.with_section(Some("io4"))
.set("enable", "0");
}
}
},
Game::Chunithm => {
match &self.io2 {
IOSelection::Custom(io) => {
ini_out.with_section(Some("chuniio"))
.set("path32", util::pkg_dir().join(io.to_string()).join("segatools").join("chuniio32.dll").stringify()?)
.set("path64", util::pkg_dir().join(io.to_string()).join("segatools").join("chuniio64.dll").stringify()?);
}
IOSelection::SegatoolsBuiltIn => {
ini_out.with_section(Some("chuniio"))
.set("path32", "")
.set("path64", "");
}
IOSelection::Hardware => {
ini_out.with_section(Some("io4"))
.set("enable", "0");
ini_out.with_section(Some("slider"))
.set("enable", "0");
}
}
}
};
log::debug!("option dir: {:?} -> {:?}", opt_dir_in, opt_dir_out); log::debug!("option dir: {:?} -> {:?}", opt_dir_in, opt_dir_out);

View File

@ -81,6 +81,7 @@ pub struct Remote {
pub nsfw: bool, pub nsfw: bool,
pub categories: Vec<String>, pub categories: Vec<String>,
pub dependencies: BTreeSet<PkgKey>, pub dependencies: BTreeSet<PkgKey>,
pub file_size: i64,
} }
impl PkgKey { impl PkgKey {
@ -112,7 +113,8 @@ impl Package {
nsfw: p.has_nsfw_content, nsfw: p.has_nsfw_content,
version: v.version_number, version: v.version_number,
categories: p.categories, categories: p.categories,
dependencies: Self::sanitize_deps(v.dependencies) dependencies: Self::sanitize_deps(v.dependencies),
file_size: v.file_size
}), }),
source: PackageSource::Rainy, source: PackageSource::Rainy,
}) })
@ -128,7 +130,7 @@ impl Package {
.unwrap() .unwrap()
.to_owned(); .to_owned();
let status = Self::parse_status(&mft); let status = Self::parse_status(&mft, &dir);
let dependencies = Self::sanitize_deps(mft.dependencies); let dependencies = Self::sanitize_deps(mft.dependencies);
Ok(Package { Ok(Package {
@ -221,9 +223,15 @@ impl Package {
res res
} }
fn parse_status(mft: &PackageManifest) -> Status { fn parse_status(mft: &PackageManifest, dir: impl AsRef<Path>) -> Status {
if mft.installers.len() == 0 { if mft.installers.len() == 0 {
return Status::OK(make_bitflags!(Feature::Mod), DLLs { game: None, amd: None }); //Unchecked if dir.as_ref().join("post_load.ps1").exists() {
return Status::Unsupported;
}
if dir.as_ref().join("app").join("data").exists() {
return Status::Unsupported;
}
return Status::OK(make_bitflags!(Feature::Mod), DLLs { game: None, amd: None });
} else { } else {
let mut flags = BitFlags::default(); let mut flags = BitFlags::default();
let mut game_dll = None; let mut game_dll = None;
@ -233,20 +241,14 @@ impl Package {
if id == "rainycolor" { if id == "rainycolor" {
flags |= Feature::Mod; flags |= Feature::Mod;
} else if id == "segatools" { } else if id == "segatools" {
// Multiple features in the same dll (yubideck etc.) should be supported at some point
if let Some(serde_json::Value::String(module)) = installer.get("module") { if let Some(serde_json::Value::String(module)) = installer.get("module") {
if module == "mu3hook" { flags |= Self::parse_segatools_module(&module);
flags |= Feature::Mu3Hook; }
} else if module == "chusanhook" { if let Some(serde_json::Value::Array(arr)) = installer.get("module") {
flags |= Feature::ChusanHook; for elem in arr {
} else if module == "amnet" { if let serde_json::Value::String(module) = elem {
flags |= Feature::AMNet | Feature::Aime; flags |= Self::parse_segatools_module(module);
} else if module == "aimeio" { }
flags |= Feature::Aime;
} else if module == "mu3io" {
flags |= Feature::Mu3IO;
} else if module == "chuniio" {
flags |= Feature::ChuniIO;
} }
} }
} else if id == "native_mod" { } else if id == "native_mod" {
@ -274,4 +276,16 @@ impl Package {
Status::OK(flags, DLLs { game: game_dll, amd: amd_dll }) Status::OK(flags, DLLs { game: game_dll, amd: amd_dll })
} }
} }
fn parse_segatools_module(module: &str) -> BitFlags<Feature, u16> {
match module {
"mu3hook" => make_bitflags!(Feature::Mu3Hook),
"chusanhook" => make_bitflags!(Feature::ChusanHook),
"amnet" => make_bitflags!(Feature::{AMNet | Aime}),
"aimeio" => make_bitflags!(Feature::Aime),
"mu3io" => make_bitflags!(Feature::Mu3IO),
"chuniio" => make_bitflags!(Feature::ChuniIO),
_ => BitFlags::default()
}
}
} }

View File

@ -1,5 +1,4 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::Path;
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
@ -22,7 +21,7 @@ pub struct PackageStore {
offline: bool, offline: bool,
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Payload { pub struct Payload {
pub pkg: PkgKey pub pkg: PkgKey
} }
@ -207,8 +206,9 @@ impl PackageStore {
"{}-{}-{}.zip", "{}-{}-{}.zip",
pkg.namespace, pkg.name, rmt.version pkg.namespace, pkg.name, rmt.version
)); ));
let part_path = zip_path.join(".part");
if !zip_path.exists() { if !zip_path.exists() && !part_path.exists() {
self.dlh.download_zip(&zip_path, &pkg)?; self.dlh.download_zip(&zip_path, &pkg)?;
log::debug!("deferring {}", key); log::debug!("deferring {}", key);
return Ok(InstallResult::Deferred); return Ok(InstallResult::Deferred);
@ -243,7 +243,7 @@ impl PackageStore {
if path.exists() && path.join("manifest.json").exists() { if path.exists() && path.join("manifest.json").exists() {
pkg.loc = None; pkg.loc = None;
let rv = Self::clean_up_package(&path).await; let rv = util::remove_dir_all(&path).await;
if rv.is_ok() { if rv.is_ok() {
self.app.emit("install-end-prelude", Payload { self.app.emit("install-end-prelude", Payload {
@ -269,48 +269,6 @@ impl PackageStore {
self.store.insert(key, new); self.store.insert(key, new);
} }
async fn clean_up_dir(path: impl AsRef<Path>, name: &str) -> Result<()> {
let path = path.as_ref().join(name);
if path.exists() {
tokio::fs::remove_dir_all(path)
.await
.map_err(|e| anyhow!("could not delete {}: {}", name, e))?;
}
Ok(())
}
async fn clean_up_file(path: impl AsRef<Path>, name: &str, force: bool) -> Result<()> {
let path = path.as_ref().join(name);
if force || path.exists() {
tokio::fs::remove_file(path).await
.map_err(|e| anyhow!("Could not delete /{}: {}", name, e))?;
}
Ok(())
}
async fn clean_up_package(path: impl AsRef<Path>) -> Result<()> {
// todo case sensitivity for linux
Self::clean_up_dir(&path, "app").await?;
Self::clean_up_dir(&path, "option").await?;
Self::clean_up_dir(&path, "segatools").await?;
Self::clean_up_file(&path, "icon.png", true).await?;
Self::clean_up_file(&path, "manifest.json", true).await?;
Self::clean_up_file(&path, "README.md", true).await?;
Self::clean_up_file(&path, "post_load.ps1", false).await?;
// todo search for the proper dll
Self::clean_up_file(&path, "saekawa.dll", false).await?;
Self::clean_up_file(&path, "mempatcher32.dll", false).await?;
Self::clean_up_file(&path, "mempatcher64.dll", false).await?;
tokio::fs::remove_dir(path.as_ref())
.await
.map_err(|e| anyhow!("Could not delete {}: {}", path.as_ref().to_string_lossy(), e))?;
Ok(())
}
fn resolve_deps(&self, rmt: Remote, set: &mut HashSet<PkgKey>) -> Result<()> { fn resolve_deps(&self, rmt: Remote, set: &mut HashSet<PkgKey>) -> Result<()> {
for d in rmt.dependencies { for d in rmt.dependencies {
set.insert(d.clone()); set.insert(d.clone());

View File

@ -1,6 +1,6 @@
pub use types::{Profile, ProfileData, ProfileMeta, ProfilePaths, StartPayload}; pub use types::{Profile, ProfileData, ProfileMeta, ProfilePaths, StartPayload};
use std::{collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}}; use std::{collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}};
use crate::{model::{misc::Game, patch::{PatchFileVec, PatchSelection}, profile::{Aime, ChunithmKeyboard, Keyboard, Mu3Ini, OngekiKeyboard, ProfileModule}}, modules::{display_windows::DisplayInfo, package::prepare_packages}, pkg::PkgKey, pkg_store::PackageStore, util}; use crate::{model::{misc::Game, patch::{PatchFileVec, PatchSelection}, profile::{Aime, ChunithmKeyboard, IOSelection, Keyboard, Mu3Ini, OngekiKeyboard, ProfileModule}}, modules::{display_windows::DisplayInfo, package::prepare_packages}, pkg::PkgKey, pkg_store::PackageStore, util};
use tauri::Emitter; use tauri::Emitter;
use std::process::Stdio; use std::process::Stdio;
use crate::model::profile::BepInEx; use crate::model::profile::BepInEx;
@ -59,8 +59,14 @@ impl Profile {
log::debug!("{:?}", data); log::debug!("{:?}", data);
// Backwards compat // Backwards compat
if game == Game::Ongeki && data.keyboard.is_none() { if game == Game::Ongeki {
data.keyboard = Some(Keyboard::Ongeki(OngekiKeyboard::default())); if data.keyboard.is_none() {
data.keyboard = Some(Keyboard::Ongeki(OngekiKeyboard::default()));
}
if let Some(io) = data.sgt.io {
data.sgt.io2 = IOSelection::Custom(io);
data.sgt.io = None;
}
} }
if game == Game::Chunithm { if game == Game::Chunithm {
if data.keyboard.is_none() { if data.keyboard.is_none() {
@ -94,7 +100,7 @@ impl Profile {
} }
std::fs::write(&path, s) std::fs::write(&path, s)
.map_err(|e| anyhow!("error when writing to {:?}: {}", path, e))?; .map_err(|e| anyhow!("error when writing to {:?}: {}", path, e))?;
log::info!("profile written to {:?}", path); log::info!("profile saved to {:?}", path);
Ok(()) Ok(())
} }
@ -112,7 +118,7 @@ impl Profile {
if let Some(hook) = &self.data.sgt.hook { if let Some(hook) = &self.data.sgt.hook {
res.push(hook.clone()); res.push(hook.clone());
} }
if let Some(io) = &self.data.sgt.io { if let IOSelection::Custom(io) = &self.data.sgt.io2 {
res.push(io.clone()); res.push(io.clone());
} }
if let Aime::AMNet(aime) = &self.data.sgt.aime { if let Aime::AMNet(aime) = &self.data.sgt.aime {
@ -273,6 +279,10 @@ impl Profile {
"SAEKAWA_CONFIG_PATH", "SAEKAWA_CONFIG_PATH",
self.config_dir().join("saekawa.toml"), self.config_dir().join("saekawa.toml"),
) )
.env(
"ONGEKI_LANG_PATH",
self.data_dir().join("lang"),
)
.current_dir(&exe_dir) .current_dir(&exe_dir)
.raw_arg("-d") .raw_arg("-d")
.raw_arg("-k") .raw_arg("-k")

View File

@ -154,4 +154,63 @@ impl PathStr for PathBuf {
pub fn bool_to_01(val: bool) -> &'static str { pub fn bool_to_01(val: bool) -> &'static str {
return if val { "1" } else { "0" } return if val { "1" } else { "0" }
}
// rm -r with checks
pub async fn remove_dir_all(path: impl AsRef<Path>) -> Result<()> {
let canon = path.as_ref().canonicalize()?;
if canon.to_string_lossy().len() < 10 {
return Err(anyhow!("invalid remove_dir_all target: too short"));
}
if canon.starts_with(data_dir().canonicalize()?)
|| canon.starts_with(config_dir().canonicalize()?)
|| canon.starts_with(cache_dir().canonicalize()?) {
tokio::fs::remove_dir_all(path).await
.map_err(|e| anyhow!("invalid remove_dir_all target: {:?}", e))?;
Ok(())
} else {
Err(anyhow!("invalid remove_dir_all target: not in a data directory"))
}
}
#[cfg(target_os = "windows")]
pub fn create_shortcut(
apph: AppHandle,
meta: &crate::profiles::ProfileMeta
) -> Result<()> {
use winsafe::{co, prelude::{ole_IPersistFile, ole_IUnknown, shell_IShellLink}, CoCreateInstance, CoInitializeEx, IPersistFile};
let _com_guard = CoInitializeEx(
co::COINIT::APARTMENTTHREADED
| co::COINIT::DISABLE_OLE1DDE,
)?;
let obj = CoCreateInstance::<winsafe::IShellLink>(
&co::CLSID::ShellLink,
None,
co::CLSCTX::INPROC_SERVER,
)?;
let target_dir = apph.path().cache_dir()?.join(NAME);
let target_path = target_dir.join("startliner.exe");
let lnk_path = apph.path().desktop_dir()?.join(format!("{} {}.lnk", &meta.game.print(), &meta.name));
obj.SetPath(target_path.to_str().ok_or_else(|| anyhow!("Illegal target path"))?)?;
obj.SetDescription(&format!("{} {} (STARTLINER)", &meta.game.print(), &meta.name))?;
obj.SetArguments(&format!("--start --game {} --profile {}", &meta.game, &meta.name))?;
obj.SetIconLocation(
target_dir.join(format!("icon-{}.ico", &meta.game)).to_str().ok_or_else(|| anyhow!("Illegal icon path"))?,
0
)?;
match meta.game {
Game::Ongeki => std::fs::write(target_dir.join("icon-ongeki.ico"), include_bytes!("../../res/icon-ongeki.ico")),
Game::Chunithm => std::fs::write(target_dir.join("icon-chunithm.ico"), include_bytes!("../../res/icon-chunithm.ico"))
}?;
let file = obj.QueryInterface::<IPersistFile>()?;
file.Save(Some(lnk_path.to_str().ok_or_else(|| anyhow!("Illegal shortcut path"))?), true)?;
Ok(())
} }

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "STARTLINER", "productName": "STARTLINER",
"version": "0.6.0", "version": "0.10.0",
"identifier": "zip.patafour.startliner", "identifier": "zip.patafour.startliner",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@ -66,7 +66,7 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"icon": ["icons/slow.png", "icons/slow.ico"], "icon": ["icons/icon.png", "icons/icon.ico"],
"createUpdaterArtifacts": true "createUpdaterArtifacts": true
} }
} }

View File

@ -13,6 +13,7 @@ import TabPanel from 'primevue/tabpanel';
import TabPanels from 'primevue/tabpanels'; import TabPanels from 'primevue/tabpanels';
import Tabs from 'primevue/tabs'; import Tabs from 'primevue/tabs';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import InfoPage from './InfoPage.vue';
import ModList from './ModList.vue'; import ModList from './ModList.vue';
import ModStore from './ModStore.vue'; import ModStore from './ModStore.vue';
import OptionList from './OptionList.vue'; import OptionList from './OptionList.vue';
@ -27,7 +28,9 @@ import {
usePrfStore, usePrfStore,
} from '../stores'; } from '../stores';
import { Dirs } from '../types'; import { Dirs } from '../types';
import { messageSplit } from '../util'; import { messageSplit, shouldPreferDark } from '../util';
document.documentElement.classList.toggle('use-dark-mode', shouldPreferDark());
const pkg = usePkgStore(); const pkg = usePkgStore();
const prf = usePrfStore(); const prf = usePrfStore();
@ -36,7 +39,8 @@ const client = useClientStore();
pkg.setupListeners(); pkg.setupListeners();
const currentTab: Ref<string | number> = ref(3); const currentTab: Ref<'users' | 'loc' | 'patches' | 'rmt' | 'cfg' | 'info'> =
ref('users');
const pkgSearchTerm = ref(''); const pkgSearchTerm = ref('');
const isProfileDisabled = computed(() => prf.current === null); const isProfileDisabled = computed(() => prf.current === null);
@ -62,20 +66,11 @@ onMounted(async () => {
await Promise.all([prf.reloadList(), prf.reload()]); await Promise.all([prf.reloadList(), prf.reload()]);
if (prf.current !== null) { if (prf.current !== null) {
currentTab.value = 0; currentTab.value = 'loc';
await pkg.reloadAll(); await pkg.reloadAll();
} }
fetch_promise.then(async () => { await fetch_promise;
await invoke('install_package', {
key: 'segatools-mu3hook',
force: false,
});
await invoke('install_package', {
key: 'segatools-chusanhook',
force: false,
});
});
}); });
const errorVisible = ref(false); const errorVisible = ref(false);
@ -87,6 +82,66 @@ listen<{ message: string; header: string }>('invoke-error', (event) => {
errorMessage.value = event.payload.message; errorMessage.value = event.payload.message;
errorHeader.value = event.payload.header; errorHeader.value = event.payload.header;
}); });
listen<string>('launch-error', (event) => {
errorVisible.value = true;
errorMessage.value = event.payload;
errorHeader.value = 'Launch error';
});
interface DownloadingStatus {
ratio: number;
pkg_key: string;
}
const downloading_status: Ref<DownloadingStatus[]> = ref([]);
const download_value = computed(() => {
return (
downloading_status.value.map((v) => v.ratio).reduce((a, v) => a * v) *
100
);
});
const downloadProgressText = computed(() => {
if (download_value.value < 7) {
return '';
}
let pkgs = `${downloading_status.value.length} package${downloading_status.value.length === 1 ? '' : 's'}`;
if (download_value.value < 14) {
return pkgs;
} else {
return `${pkgs} (${Math.floor(download_value.value)}%)`;
}
});
listen<DownloadingStatus>('download-progress', (event) => {
let status = downloading_status.value.find(
(v) => v.pkg_key === event.payload.pkg_key
);
if (status === undefined) {
status = {
ratio: 0,
pkg_key: event.payload.pkg_key,
};
downloading_status.value.push(status);
}
status.ratio = event.payload.ratio;
const remove = () => {
if (status !== undefined) {
downloading_status.value = downloading_status.value.filter(
(v) => v.pkg_key !== event.payload.pkg_key
);
}
};
if (status.ratio === 1.0) {
remove();
}
setTimeout(() => remove, 10_000);
});
</script> </script>
<template> <template>
@ -101,6 +156,16 @@ listen<{ message: string; header: string }>('invoke-error', (event) => {
: 'main-scale-xl' : 'main-scale-xl'
" "
> >
<div
v-if="downloading_status.length > 0"
class="download-progress-bg"
></div>
<ProgressBar
v-if="downloading_status.length > 0"
:value="download_value"
class="download-progress"
>{{ downloadProgressText }}</ProgressBar
>
<ConfirmDialog> <ConfirmDialog>
<template #message="{ message }"> <template #message="{ message }">
<ScrollPanel <ScrollPanel
@ -129,7 +194,7 @@ listen<{ message: string; header: string }>('invoke-error', (event) => {
{{ errorMessage }} {{ errorMessage }}
<Button <Button
class="m-auto" class="m-auto"
label="A sad state of affairs" label="OK"
@click="errorVisible = false" @click="errorVisible = false"
/> />
</div> </div>
@ -149,42 +214,50 @@ listen<{ message: string; header: string }>('invoke-error', (event) => {
:value="currentTab" :value="currentTab"
v-on:update:value=" v-on:update:value="
(value) => { (value) => {
currentTab = value; currentTab = value as any;
} }
" "
class="h-screen" class="h-screen"
> >
<div class="fixed w-full flex z-100"> <div class="fixed w-full flex z-100">
<TabList class="grow" :show-navigators="false"> <TabList class="grow" :show-navigators="false">
<Tab :value="3"><div class="pi pi-users"></div></Tab> <Tab value="users"><div class="pi pi-users"></div></Tab>
<Tab :disabled="isProfileDisabled" :value="0" <Tab :disabled="isProfileDisabled" value="loc"
><div class="pi pi-box"></div ><div class="pi pi-box"></div
></Tab> ></Tab>
<Tab v-if="prf.current?.meta.game === 'chunithm'" :value="4" <Tab
v-if="
prf.current?.meta.game === 'chunithm' &&
prf.current.data.sgt.target.length > 0
"
value="patches"
><div class="pi pi-ticket"></div ><div class="pi pi-ticket"></div
></Tab> ></Tab>
<Tab <Tab
v-if="pkg.networkStatus === 'online'" v-if="pkg.networkStatus === 'online'"
:disabled="isProfileDisabled" :disabled="isProfileDisabled"
:value="1" value="rmt"
><div class="pi pi-download"></div ><div class="pi pi-download"></div
></Tab> ></Tab>
<Tab :disabled="isProfileDisabled" :value="2" <Tab :disabled="isProfileDisabled" value="cfg"
><div class="pi pi-cog"></div ><div class="pi pi-cog"></div
></Tab> ></Tab>
<Tab value="info"
><div class="pi pi-info-circle"></div
></Tab>
<div class="grow"></div> <div class="grow"></div>
<div class="flex gap-4"> <div class="flex gap-4">
<div <div
class="flex" class="flex"
v-if="[0, 1, 2].includes(currentTab as number)" v-if="['loc', 'rmt', 'cfg'].includes(currentTab)"
> >
<InputIcon class="self-center mr-2"> <InputIcon class="self-center mr-2">
<i class="pi pi-search" /> <i class="pi pi-search" />
</InputIcon> </InputIcon>
<InputText <InputText
v-if="currentTab === 2" v-if="currentTab === 'cfg'"
style="min-width: 0; width: 25dvw" style="min-width: 0; width: 25dvw"
class="self-center" class="self-center"
size="small" size="small"
@ -234,28 +307,19 @@ listen<{ message: string; header: string }>('invoke-error', (event) => {
</TabList> </TabList>
</div> </div>
<TabPanels class="w-full grow mt-[3rem]"> <TabPanels class="w-full grow mt-[3rem]">
<TabPanel :value="0"> <TabPanel value="loc">
<ModList :search="pkgSearchTerm" /> <ModList :search="pkgSearchTerm" />
</TabPanel> </TabPanel>
<TabPanel :value="1"> <TabPanel value="rmt">
<ModStore :search="pkgSearchTerm" /> <ModStore :search="pkgSearchTerm" />
</TabPanel> </TabPanel>
<TabPanel :value="2"> <TabPanel value="cfg">
<OptionList /> <OptionList />
</TabPanel> </TabPanel>
<TabPanel :value="3"> <TabPanel value="users">
<ProfileList /> <ProfileList />
<br /><br /><br />
<footer>
<Button
icon="pi pi-discord"
as="a"
target="_blank"
href="https://discord.gg/jxvzHjjEmc"
/>
</footer>
</TabPanel> </TabPanel>
<TabPanel :value="4"> <TabPanel value="patches">
<PatchList <PatchList
v-if=" v-if="
pkg.hasLocal('mempatcher-mempatcher') && pkg.hasLocal('mempatcher-mempatcher') &&
@ -266,10 +330,27 @@ listen<{ message: string; header: string }>('invoke-error', (event) => {
<div v-else> <div v-else>
Patches require <code>mempatcher</code> to be installed Patches require <code>mempatcher</code> to be installed
and enabled. and enabled.
<div>
<Button
label="Add mempatcher"
icon="pi pi-plus"
class="mt-3"
@click="
() =>
pkg.installFromKey(
'mempatcher-mempatcher'
)
"
/>
</div>
</div> </div>
</TabPanel> </TabPanel>
<TabPanel value="info">
<InfoPage />
</TabPanel>
</TabPanels> </TabPanels>
<div v-if="currentTab === 5 || currentTab === 3"> <div v-if="currentTab === 'users' || currentTab === 'info'">
<img <img
v-if="prf.current?.meta.game === 'ongeki'" v-if="prf.current?.meta.game === 'ongeki'"
src="/sticker-ongeki.svg" src="/sticker-ongeki.svg"
@ -336,4 +417,23 @@ body {
.p-progressbar-label { .p-progressbar-label {
transition-duration: 0s !important; transition-duration: 0s !important;
} }
.download-progress {
position: fixed !important;
bottom: 0;
left: 5vw;
width: 90vw;
z-index: 10000 !important;
margin: 20px auto;
}
.download-progress-bg {
position: fixed;
bottom: 0;
left: 0;
width: 100vw;
height: 60px;
background-color: var(--p-surface-900);
border-top: 1px solid var(--p-surface-600);
z-index: 998;
}
</style> </style>

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref } from 'vue';
import Button from 'primevue/button';
import ScrollPanel from 'primevue/scrollpanel';
import { invoke } from '../invoke';
import { VueMarkdownIt } from '@f3ve/vue-markdown-it';
const changelog = ref('');
invoke('get_changelog').then((s) => (changelog.value = s as string));
</script>
<template>
<h1>About</h1>
STARTLINER is a simple launcher, configuration tool and mod manager for
O.N.G.E.K.I. and CHUNITHM.
<h1>Changelog</h1>
<ScrollPanel style="height: 200px">
<div class="changelog">
<vue-markdown-it
:source="changelog"
:options="{ typographer: true, breaks: true }"
/>
</div>
</ScrollPanel>
<footer class="mt-10 flex gap-3">
<Button
icon="pi pi-discord"
as="a"
target="_blank"
href="https://discord.gg/jxvzHjjEmc"
/>
<Button
icon="pi pi-github"
as="a"
target="_blank"
href="https://gitea.tendokyu.moe/akanyan/STARTLINER"
/>
</footer>
</template>
<style lang="css">
h1 {
font-size: 1.7rem;
}
.changelog h2 {
font-size: 1.4rem;
}
.changelog ul {
list-style-type: circle;
}
.changelog li {
margin-left: 40px;
}
</style>

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import Button from 'primevue/button'; import Button from 'primevue/button';
import { invoke } from '../invoke'; import { invoke } from '../invoke';
import { usePkgStore } from '../stores'; import { usePkgStore } from '../stores';
@ -11,20 +12,26 @@ const props = defineProps({
pkg: Object as () => Package, pkg: Object as () => Package,
}); });
const deleting = ref(false);
const remove = async () => { const remove = async () => {
if (props.pkg === undefined) { if (props.pkg === undefined) {
return; return;
} }
deleting.value = true;
await invoke('delete_package', { await invoke('delete_package', {
key: pkgKey(props.pkg), key: pkgKey(props.pkg),
}); });
deleting.value = false;
}; };
</script> </script>
<template> <template>
<Button <Button
v-if="pkg?.loc && !pkg?.js.busy" v-if="pkg?.loc && !pkg?.js.downloading"
rounded rounded
icon="pi pi-trash" icon="pi pi-trash"
severity="danger" severity="danger"
@ -32,7 +39,7 @@ const remove = async () => {
size="small" size="small"
class="self-center ml-4" class="self-center ml-4"
style="width: 2rem; height: 2rem" style="width: 2rem; height: 2rem"
:loading="pkg?.js.busy" :loading="deleting"
v-on:click="remove()" v-on:click="remove()"
/> />
@ -45,7 +52,7 @@ const remove = async () => {
size="small" size="small"
class="self-center ml-4" class="self-center ml-4"
style="width: 2rem; height: 2rem" style="width: 2rem; height: 2rem"
:loading="pkg?.js.busy" :loading="pkg?.js.downloading"
v-on:click="async () => await pkgs.install(pkg)" v-on:click="async () => await pkgs.install(pkg)"
/> />
</template> </template>

View File

@ -15,7 +15,7 @@ const props = defineProps({
const pkgs = usePkgStore(); const pkgs = usePkgStore();
const prf = usePrfStore(); const prf = usePrfStore();
const empty = ref(true); const empty = ref(false);
const gameSublist: Ref<string[]> = ref([]); const gameSublist: Ref<string[]> = ref([]);
invoke('get_game_packages', { invoke('get_game_packages', {
@ -81,5 +81,5 @@ const missing = computed(() => {
<Fieldset v-for="(namespace, key) in group" :legend="key.toString()"> <Fieldset v-for="(namespace, key) in group" :legend="key.toString()">
<ModListEntry v-for="p in namespace" :pkg="p" /> <ModListEntry v-for="p in namespace" :pkg="p" />
</Fieldset> </Fieldset>
<div v-if="empty" class="text-3xl"></div> <div v-if="empty === true" class="text-3xl fadein"></div>
</template> </template>

View File

@ -2,11 +2,11 @@
import { computed } from 'vue'; import { computed } from 'vue';
import Button from 'primevue/button'; import Button from 'primevue/button';
import ToggleSwitch from 'primevue/toggleswitch'; import ToggleSwitch from 'primevue/toggleswitch';
import { open } from '@tauri-apps/plugin-shell';
import InstallButton from './InstallButton.vue'; import InstallButton from './InstallButton.vue';
import LinkButton from './LinkButton.vue'; import LinkButton from './LinkButton.vue';
import ModTitlecard from './ModTitlecard.vue'; import ModTitlecard from './ModTitlecard.vue';
import UpdateButton from './UpdateButton.vue'; import UpdateButton from './UpdateButton.vue';
import { invoke } from '../invoke';
import { usePkgStore, usePrfStore } from '../stores'; import { usePkgStore, usePrfStore } from '../stores';
import { Feature, Package } from '../types'; import { Feature, Package } from '../types';
import { hasFeature } from '../util'; import { hasFeature } from '../util';
@ -26,19 +26,32 @@ const model = computed({
await prf.togglePkg(props.pkg, value); await prf.togglePkg(props.pkg, value);
}, },
}); });
const unsupported = computed(() => props.pkg!.loc!.status === 'Unsupported');
if (unsupported.value === true && model.value === true) {
prf.togglePkg(props.pkg, false);
}
</script> </script>
<template> <template>
<div class="flex items-center"> <div class="flex items-center">
<ModTitlecard show-version show-icon show-description :pkg="pkg" /> <ModTitlecard show-version show-icon show-description :pkg="pkg" />
<UpdateButton :pkg="pkg" /> <UpdateButton :pkg="pkg" />
<ToggleSwitch <span
v-if="hasFeature(pkg, Feature.Mod)" v-tooltip="
class="scale-[1.33] shrink-0" unsupported &&
inputId="switch" 'This package is currently incompatible with STARTLINER.'
:disabled="pkg!.loc!.status === 'Unsupported'" "
v-model="model" >
/> <ToggleSwitch
v-if="hasFeature(pkg, Feature.Mod) || unsupported === true"
class="scale-[1.33] shrink-0"
inputId="switch"
:disabled="unsupported === true"
v-model="model"
/>
</span>
<InstallButton :pkg="pkg" /> <InstallButton :pkg="pkg" />
<Button <Button
rounded rounded
@ -48,7 +61,9 @@ const model = computed({
size="small" size="small"
class="ml-2 shrink-0" class="ml-2 shrink-0"
style="width: 2rem; height: 2rem" style="width: 2rem; height: 2rem"
v-on:click="pkg?.loc && open(pkg.loc.path ?? '')" v-on:click="
pkg?.loc?.path && invoke('open_file', { path: pkg.loc.path })
"
/> />
<LinkButton v-if="pkgs.networkStatus === 'online'" :pkg="pkg" /> <LinkButton v-if="pkgs.networkStatus === 'online'" :pkg="pkg" />
</div> </div>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Ref, ref } from 'vue'; import { Ref, computed, ref } from 'vue';
import Button from 'primevue/button';
import Divider from 'primevue/divider'; import Divider from 'primevue/divider';
import MultiSelect from 'primevue/multiselect'; import MultiSelect from 'primevue/multiselect';
import ToggleSwitch from 'primevue/toggleswitch'; import ToggleSwitch from 'primevue/toggleswitch';
@ -40,6 +41,39 @@ const list = () => {
empty.value = res.length === 0; empty.value = res.length === 0;
return res; return res;
}; };
const shouldShowRecommended = computed(() => {
if (prf.current!.meta.game === 'ongeki') {
return !pkgs.allLocal.some((p) => pkgKey(p) === 'segatools-mu3hook');
}
if (prf.current!.meta.game === 'chunithm') {
return (
!pkgs.allLocal.some((p) => pkgKey(p) === 'segatools-chusanhook') ||
!pkgs.allLocal.some((p) => pkgKey(p) === 'mempatcher-mempatcher')
);
}
return false;
});
const getRecommendedTooltip = () => {
if (prf.current!.meta.game === 'ongeki') {
return 'segatools-mu3hook';
}
if (prf.current!.meta.game === 'chunithm') {
return 'segatools-chusanhook and mempatcher';
}
return '';
};
const installRecommended = () => {
if (prf.current!.meta.game === 'ongeki') {
pkgs.installFromKey('segatools-mu3hook');
}
if (prf.current!.meta.game === 'chunithm') {
pkgs.installFromKey('segatools-chusanhook');
pkgs.installFromKey('mempatcher-mempatcher');
}
};
</script> </script>
<template> <template>
@ -78,6 +112,14 @@ const list = () => {
</div> </div>
</div> </div>
<Divider /> <Divider />
<Button
v-if="shouldShowRecommended"
label="Install recommended packages"
v-tooltip="getRecommendedTooltip"
icon="pi pi-plus"
class="mb-3"
@click="installRecommended"
/>
<div v-for="p in list()" class="flex flex-row"> <div v-for="p in list()" class="flex flex-row">
<ModStoreEntry :pkg="p" /> <ModStoreEntry :pkg="p" />
</div> </div>

View File

@ -3,7 +3,7 @@ import InputNumber from 'primevue/inputnumber';
import ToggleSwitch from 'primevue/toggleswitch'; import ToggleSwitch from 'primevue/toggleswitch';
import OptionRow from './OptionRow.vue'; import OptionRow from './OptionRow.vue';
import { usePrfStore } from '../stores'; import { usePrfStore } from '../stores';
import { Patch } from '@/types'; import { Patch } from '../types';
const prf = usePrfStore(); const prf = usePrfStore();

View File

@ -22,7 +22,6 @@ const prf = usePrfStore();
icon="pi pi-plus" icon="pi pi-plus"
class="chunithm-button profile-button" class="chunithm-button profile-button"
@click="() => prf.create('chunithm')" @click="() => prf.create('chunithm')"
v-tooltip="'!!! Experimental !!!'"
/> />
</div> </div>
<div class="mt-12 flex flex-col flex-wrap align-middle gap-4"> <div class="mt-12 flex flex-col flex-wrap align-middle gap-4">

View File

@ -2,14 +2,16 @@
import { ref } from 'vue'; import { ref } from 'vue';
import Button from 'primevue/button'; import Button from 'primevue/button';
import InputText from 'primevue/inputtext'; import InputText from 'primevue/inputtext';
import { useConfirm } from 'primevue/useconfirm';
import * as path from '@tauri-apps/api/path'; import * as path from '@tauri-apps/api/path';
import { open } from '@tauri-apps/plugin-shell';
import { invoke } from '../invoke'; import { invoke } from '../invoke';
import { useGeneralStore, usePrfStore } from '../stores'; import { useGeneralStore, usePrfStore } from '../stores';
import { ProfileMeta } from '../types'; import { ProfileMeta } from '../types';
const prf = usePrfStore();
const general = useGeneralStore(); const general = useGeneralStore();
const prf = usePrfStore();
const confirmDialog = useConfirm();
const isEditing = ref(false); const isEditing = ref(false);
const props = defineProps({ const props = defineProps({
@ -55,6 +57,14 @@ const deleteProfile = async () => {
await prf.reloadList(); await prf.reloadList();
await prf.reload(); await prf.reload();
}; };
const promptDeleteProfile = async () => {
confirmDialog.require({
message: `Are you sure you want to delete ${props.p?.game}-${props.p?.name}?`,
header: 'Delete profile',
accept: deleteProfile,
});
};
</script> </script>
<template> <template>
@ -91,7 +101,7 @@ const deleteProfile = async () => {
size="small" size="small"
class="self-center ml-2" class="self-center ml-2"
style="width: 2rem; height: 2rem" style="width: 2rem; height: 2rem"
@click="deleteProfile" @click="promptDeleteProfile"
/> />
<Button <Button
rounded rounded
@ -124,7 +134,11 @@ const deleteProfile = async () => {
@click=" @click="
path path
.join(general.dataDir, `profile-${p!.game}-${p!.name}`) .join(general.dataDir, `profile-${p!.game}-${p!.name}`)
.then(open) .then(async (path) => {
if (await invoke('file_exists', { path })) {
await invoke('open_file', { path });
}
})
" "
/> />
</div> </div>

View File

@ -26,7 +26,7 @@ const startline = async (force: boolean, refresh: boolean) => {
} else if ('MissingLocalPackage' in o) { } else if ('MissingLocalPackage' in o) {
return `Package missing: ${o.MissingLocalPackage}`; return `Package missing: ${o.MissingLocalPackage}`;
} else if ('MissingDependency' in o) { } else if ('MissingDependency' in o) {
return `Dependency missing: ${o.MissingDependency}`; return `Dependency missing: ${(o.MissingDependency as string[]).join(' ')}`;
} else if ('MissingTool' in o) { } else if ('MissingTool' in o) {
return `Tool missing: ${o.MissingTool}`; return `Tool missing: ${o.MissingTool}`;
} else { } else {
@ -85,6 +85,15 @@ listen('launch-end', () => {
getCurrentWindow().setFocus(); getCurrentWindow().setFocus();
}); });
const createShortcut = async () => {
const current = prf.current;
if (current !== null) {
await invoke('create_shortcut', {
profileMeta: current.meta,
});
}
};
const menuItems = [ const menuItems = [
{ {
label: 'Refresh and start', label: 'Refresh and start',
@ -96,6 +105,11 @@ const menuItems = [
icon: 'pi pi-exclamation-circle', icon: 'pi pi-exclamation-circle',
command: async () => await startline(true, false), command: async () => await startline(true, false),
}, },
{
label: 'Create desktop shortcut',
icon: 'pi pi-link',
command: createShortcut,
},
]; ];
const menu = ref(); const menu = ref();

View File

@ -20,17 +20,15 @@ const install = async () => {
}); });
} catch (err) { } catch (err) {
if (props.pkg !== undefined) { if (props.pkg !== undefined) {
props.pkg.js.busy = false; props.pkg.js.downloading = false;
} }
} }
//if (rv === 'Deferred') { /* download progress */ }
}; };
</script> </script>
<template> <template>
<Button <Button
v-if="needsUpdate(pkg) && !pkg?.js.busy" v-if="needsUpdate(pkg) && !pkg?.js.downloading"
rounded rounded
icon="pi pi-download" icon="pi pi-download"
severity="success" severity="success"

View File

@ -84,6 +84,7 @@ load();
</OptionRow> </OptionRow>
<OptionRow <OptionRow
title="Aime code" title="Aime code"
tooltip="Only applicable with the segatools built-in emulation or with compatible third-party packages"
v-if="prf.current!.data.sgt.aime !== 'Disabled'" v-if="prf.current!.data.sgt.aime !== 'Disabled'"
> >
<InputText <InputText

View File

@ -11,7 +11,10 @@ const prf = usePrfStore();
<template> <template>
<OptionCategory title="Keyboard"> <OptionCategory title="Keyboard">
<OptionRow title="Enable"> <OptionRow
title="Enable"
tooltip="Only applicable if the IO module is set to segatools built-in (keyboard) or a compatible third-party module (like mu3io.NET)"
>
<ToggleSwitch v-model="prf.current!.data.keyboard!.data.enabled" /> <ToggleSwitch v-model="prf.current!.data.keyboard!.data.enabled" />
</OptionRow> </OptionRow>
<OptionRow <OptionRow
@ -30,6 +33,7 @@ const prf = usePrfStore();
/> />
</OptionRow> </OptionRow>
<div <div
v-if="prf.current!.data.keyboard!.data.enabled"
:style="`position: relative; height: ${prf.current!.data.keyboard!.game === 'Ongeki' ? 400 : 250}px`" :style="`position: relative; height: ${prf.current!.data.keyboard!.game === 'Ongeki' ? 400 : 250}px`"
> >
<div <div

View File

@ -119,23 +119,35 @@ const checkSegatoolsIni = async (target: string) => {
return { title: pkgKey(p), value: pkgKey(p) }; return { title: pkgKey(p), value: pkgKey(p) };
}) })
" "
placeholder="none"
option-label="title" option-label="title"
option-value="value" option-value="value"
></Select> ></Select>
</OptionRow> </OptionRow>
<OptionRow <OptionRow
:title="names.io" :title="names.io"
v-if="prf.current?.meta.game === 'ongeki'"
tooltip="IO plugins can be downloaded from the package store." tooltip="IO plugins can be downloaded from the package store."
> >
<Select <Select
v-model="prf.current!.data.sgt.io" v-model="prf.current!.data.sgt.io2"
placeholder="segatools built-in"
:options="[ :options="[
{ title: 'segatools built-in', value: null }, { title: 'native io4', value: 'hardware' },
...pkgs.byFeature(Feature.Mu3IO).map((p) => { {
return { title: pkgKey(p), value: pkgKey(p) }; title: 'segatools built-in (keyboard)',
}), value: 'segatools_built_in',
},
...pkgs
.byFeature(
prf.current?.meta.game === 'ongeki'
? Feature.Mu3IO
: Feature.ChuniIO
)
.map((p) => {
return {
title: pkgKey(p),
value: { custom: pkgKey(p) },
};
}),
]" ]"
option-label="title" option-label="title"
option-value="value" option-value="value"

View File

@ -25,6 +25,24 @@ const updatesModel = computed({
await client.setAutoupdates(value); await client.setAutoupdates(value);
}, },
}); });
const verboseModel = computed({
get() {
return client.verbose;
},
async set(value: boolean) {
await client.setVerbose(value);
},
});
const themeModel = computed({
get() {
return client.theme;
},
async set(value: 'light' | 'dark' | 'system') {
await client.setTheme(value);
},
});
</script> </script>
<template> <template>
@ -45,12 +63,31 @@ const updatesModel = computed({
</OptionRow> </OptionRow>
<OptionRow <OptionRow
title="Offline mode" title="Offline mode"
tooltip="Disables the package store. Requires a restart." tooltip="Disables the package store. Applies after a restart."
> >
<ToggleSwitch v-model="offlineModel" /> <ToggleSwitch v-model="offlineModel" />
</OptionRow> </OptionRow>
<OptionRow title="Enable automatic updates"> <OptionRow title="Enable automatic updates">
<ToggleSwitch v-model="updatesModel" /> <ToggleSwitch v-model="updatesModel" />
</OptionRow> </OptionRow>
<OptionRow
title="Enable detailed logs"
tooltip="Applies after a restart."
>
<ToggleSwitch v-model="verboseModel" />
</OptionRow>
<OptionRow title="Theme">
<SelectButton
v-model="themeModel"
:options="[
{ title: 'System', value: 'system' },
{ title: 'Light', value: 'light' },
{ title: 'Dark', value: 'dark' },
]"
:allow-empty="false"
option-label="title"
option-value="value"
/>
</OptionRow>
</OptionCategory> </OptionCategory>
</template> </template>

View File

@ -17,6 +17,9 @@ app.use(pinia);
app.use(PrimeVue, { app.use(PrimeVue, {
theme: { theme: {
preset: Preset, preset: Preset,
options: {
darkModeSelector: '.use-dark-mode',
},
}, },
}); });
app.use(ConfirmationService); app.use(ConfirmationService);

View File

@ -6,7 +6,12 @@ import { PhysicalSize, getCurrentWindow } from '@tauri-apps/api/window';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'; import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import { invoke, invoke_nopopup } from './invoke'; import { invoke, invoke_nopopup } from './invoke';
import { Dirs, Feature, Game, Package, Profile, ProfileMeta } from './types'; import { Dirs, Feature, Game, Package, Profile, ProfileMeta } from './types';
import { changePrimaryColor, hasFeature, pkgKey } from './util'; import {
changePrimaryColor,
hasFeature,
pkgKey,
shouldPreferDark,
} from './util';
type InstallStatus = { type InstallStatus = {
pkg: string; pkg: string;
@ -114,13 +119,13 @@ export const usePkgStore = defineStore('pkg', {
listen<InstallStatus>('install-start', async (ev) => { listen<InstallStatus>('install-start', async (ev) => {
const key = ev.payload.pkg; const key = ev.payload.pkg;
await this.reload(key); await this.reload(key);
this.pkg[key].js.busy = true; this.pkg[key].js.downloading = true;
}); });
listen<InstallStatus>('install-end', async (ev) => { listen<InstallStatus>('install-end', async (ev) => {
const key = ev.payload.pkg; const key = ev.payload.pkg;
await this.reload(key); await this.reload(key);
this.pkg[key].js.busy = false; this.pkg[key].js.downloading = false;
}); });
}, },
@ -147,17 +152,22 @@ export const usePkgStore = defineStore('pkg', {
async reloadWith(key: string, pkg: Package) { async reloadWith(key: string, pkg: Package) {
if (this.pkg[key] === undefined) { if (this.pkg[key] === undefined) {
this.pkg[key] = { js: { busy: false } } as Package; this.pkg[key] = { js: { downloading: false } } as Package;
} else { } else {
this.pkg[key].loc = null; this.pkg[key].loc = null;
this.pkg[key].rmt = null; this.pkg[key].rmt = null;
} }
Object.assign(this.pkg[key], pkg); Object.assign(this.pkg[key], pkg);
if (!pkg.js) {
pkg.js = { downloading: false };
}
if (pkg.rmt !== null) { if (pkg.rmt !== null) {
pkg.rmt.categories.forEach((c) => pkg.rmt.categories.forEach((c) =>
this.availableCategories.add(c) this.availableCategories.add(c)
); );
pkg.js.downloading = false;
} }
}, },
@ -188,13 +198,21 @@ export const usePkgStore = defineStore('pkg', {
force: true, force: true,
}); });
} catch (err) { } catch (err) {
console.error(err);
if (pkg !== undefined) { if (pkg !== undefined) {
pkg.js.busy = false; pkg.js.downloading = false;
} }
} }
},
//if (rv === 'Deferred') { /* download progress */ } async installFromKey(key: string) {
try {
await invoke('install_package', {
key,
force: true,
});
} catch (err) {
console.error(err);
}
}, },
async updateAll() { async updateAll() {
@ -320,7 +338,7 @@ export const usePrfStore = defineStore('prf', () => {
if (timeout !== null) { if (timeout !== null) {
clearTimeout(timeout); clearTimeout(timeout);
} }
timeout = setTimeout(() => invoke('save_current_profile'), 2000); timeout = setTimeout(() => invoke('save_current_profile'), 600);
} }
}); });
@ -346,6 +364,8 @@ export const useClientStore = defineStore('client', () => {
const offlineMode = ref(false); const offlineMode = ref(false);
const enableAutoupdates = ref(true); const enableAutoupdates = ref(true);
const verbose = ref(false);
const theme: Ref<'light' | 'dark' | 'system'> = ref('system');
const scaleValue = (value: ScaleType) => const scaleValue = (value: ScaleType) =>
value === 's' ? 1 : value === 'm' ? 1.25 : value === 'l' ? 1.5 : 2; value === 's' ? 1 : value === 'm' ? 1.25 : value === 'l' ? 1.5 : 2;
@ -396,16 +416,25 @@ export const useClientStore = defineStore('client', () => {
if (input.scaleFactor) { if (input.scaleFactor) {
await setScaleFactor(input.scaleFactor); await setScaleFactor(input.scaleFactor);
} }
if (input.theme) {
theme.value = input.theme;
}
await setTheme(theme.value);
} catch (e) { } catch (e) {
console.error(`Error reading client options: ${e}`); console.error(`Error reading client options: ${e}`);
} }
offlineMode.value = await invoke('get_global_config', { offlineMode.value = await invoke('get_global_config', {
field: 'OfflineMode', field: 'offline_mode',
}); });
enableAutoupdates.value = await invoke('get_global_config', { enableAutoupdates.value = await invoke('get_global_config', {
field: 'EnableAutoupdates', field: 'enable_autoupdates',
});
verbose.value = await invoke('get_global_config', {
field: 'verbose',
}); });
}; };
@ -422,6 +451,7 @@ export const useClientStore = defineStore('client', () => {
w: Math.floor(size.width), w: Math.floor(size.width),
h: Math.floor(size.height), h: Math.floor(size.height),
}, },
theme: theme.value,
}) })
); );
}; };
@ -438,17 +468,37 @@ export const useClientStore = defineStore('client', () => {
const setOfflineMode = async (value: boolean) => { const setOfflineMode = async (value: boolean) => {
offlineMode.value = value; offlineMode.value = value;
await invoke('set_global_config', { field: 'OfflineMode', value }); await invoke('set_global_config', { field: 'offline_mode', value });
}; };
const setAutoupdates = async (value: boolean) => { const setAutoupdates = async (value: boolean) => {
enableAutoupdates.value = value; enableAutoupdates.value = value;
await invoke('set_global_config', { await invoke('set_global_config', {
field: 'EnableAutoupdates', field: 'enable_autoupdates',
value, value,
}); });
}; };
const setVerbose = async (value: boolean) => {
verbose.value = value;
await invoke('set_global_config', { field: 'verbose', value });
};
const setTheme = async (value: 'light' | 'dark' | 'system') => {
if (value === 'dark') {
document.documentElement.classList.add('use-dark-mode');
} else if (value === 'light') {
document.documentElement.classList.remove('use-dark-mode');
} else {
document.documentElement.classList.toggle(
'use-dark-mode',
shouldPreferDark()
);
}
theme.value = value;
await save();
};
getCurrentWindow().onResized(async ({ payload }) => { getCurrentWindow().onResized(async ({ payload }) => {
// For whatever reason this is 0 when minimized // For whatever reason this is 0 when minimized
if (payload.width > 0) { if (payload.width > 0) {
@ -460,6 +510,8 @@ export const useClientStore = defineStore('client', () => {
scaleFactor, scaleFactor,
offlineMode, offlineMode,
enableAutoupdates, enableAutoupdates,
verbose,
theme,
timeout, timeout,
scaleModel, scaleModel,
load, load,
@ -467,5 +519,7 @@ export const useClientStore = defineStore('client', () => {
queueSave, queueSave,
setOfflineMode, setOfflineMode,
setAutoupdates, setAutoupdates,
setVerbose,
setTheme,
}; };
}); });

View File

@ -19,7 +19,7 @@ export interface Package {
icon: string; icon: string;
} | null; } | null;
js: { js: {
busy: boolean; downloading: boolean;
}; };
} }
@ -66,7 +66,7 @@ export interface ProfileData {
export interface SegatoolsConfig { export interface SegatoolsConfig {
target: string; target: string;
hook: string | null; hook: string | null;
io: string | null; io2: 'segatools_built_in' | 'hardware' | { custom: string };
amfs: string; amfs: string;
option: string; option: string;
appdata: string; appdata: string;

View File

@ -59,3 +59,7 @@ export const hasFeature = (pkg: Package | undefined, feature: Feature) => {
export const messageSplit = (message: any) => { export const messageSplit = (message: any) => {
return message.message?.split('\n'); return message.message?.split('\n');
}; };
export const shouldPreferDark = () => {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};