Compare commits

...

20 Commits

Author SHA1 Message Date
b75cc8f240 docs: update README.md 2025-04-21 04:19:12 -12:00
407b34a884 feat: profile imports/exports 2025-04-21 04:15:52 -12:00
890d26e883 chore: bump ver 2025-04-20 06:38:44 +00:00
2aff5834b9 fix: chunithm crashing with mempatcher 2025-04-20 06:37:46 +00:00
69f2c83109 chore: update CHANGELOG.md 2025-04-19 20:13:11 +00:00
dbbd80c6c3 feat: add 'games' to the manifest 2025-04-19 20:09:32 +00:00
3479804dca feat: 0.12 update 2025-04-19 19:48:08 +00:00
aaeed669df chore: bump ver 2025-04-19 11:46:07 +00:00
7084f40404 fix: improve help pages 2025-04-19 11:44:16 +00:00
f7e9d7d7db docs: rewrite README.md 2025-04-18 19:55:42 +00:00
e87b661f08 feat: onboarding 2025-04-18 15:00:52 +00:00
5d2d407659 chore: update CHANGELOG.md 2025-04-18 06:59:55 +00:00
795e889bd0 fix: better keyboard
* Scale the font as necessary
* Fix CHUNITHM order
* Fix num-unlocked numpad
2025-04-18 06:55:59 +00:00
7071f19877 fix: don't switch primary, for real 2025-04-17 18:43:27 +00:00
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
56 changed files with 1457 additions and 331 deletions

View File

@ -1,3 +1,52 @@
## 0.13.0
- Added profile imports/exports
- Fixed error when trying to open an empty Ongeki profile
- Switched the default color scheme from invisible to purple
## 0.12.1
- Chunithm: fixed crash when using mempatcher
## 0.12.0
- Ongeki: cache and mu3.ini config are now split per-profile (requires mu3-mods 3.7+)
- Ongeki: added the few config options of mu3.ini that aren't available in TestMenuConfig, or require a restart
- Chunithm: added Lumi+ patches
- Added support for a non-standard `games` manifest entry intended for local packages
- Expected to be an array containing "ongeki", "chunithm" or both
- Example: { "games": ["ongeki"] }
- Added a button linking to the profile config folder
- Fixed the button linking to the data folder showing up when the folder does not exist
- Uninstalled tool packages are no longer automatically deselected, as that caused issues
## 0.11.1
- Improved help pages
## 0.11.0
- Added help pages
## 0.10.1
- Fixed the order of cells in the CHUNITHM keyboard
- Fixed numpad bindings with numlock disabled
- Disabled primary monitor cleanup when "don't switch primary monitor" is enabled
## 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 ## 0.8.0
- Added support for ChuniIO - Added support for ChuniIO

View File

@ -1,17 +1,21 @@
Looking for maimai DX players willing to help develop/test maimai DX support
# STARTLINER # STARTLINER
A simple and easy to use launcher, configuration tool and mod manager This is a program that seeks to streamline game data configuration, currently supporting O.N.G.E.K.I. and CHUNITHM.
for O.N.G.E.K.I. and CHUNITHM, using [Rainycolor Watercolor](https://rainy.patafour.zip).
STARTLINER is four things:
- a mod installer and updater, powered by [Rainycolor Watercolor](https://rainy.patafour.zip),
- a configuration GUI for segatools,
- a glorified `start.bat` clicker, with automatic monitor setup and rollback,
- [an abstraction allowing data configuration without touching the game directory](https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Architecture-details).
STARTLINER's core design principle is to modify, configure and launch games without tampering with them.
This makes it possible to keep data cleaner than ever, and to have several configurations pointing at the same data.
Made with Rust (Tauri) and Vue. Technically multiplatform. Contributions welcome. Made with Rust (Tauri) and Vue. Technically multiplatform. Contributions welcome.
## Features
- [Clean](https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Architecture-details) data modding
- Segatools configuration
- Monitor configuration with automatic rollback
- Support for multiple configurations pointing at the same data
## 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:

View File

@ -4,6 +4,5 @@
### Long-term ### Long-term
- Progress bars and other GUI sugar
- artemis as a special package - artemis as a special package
- Other arcade games (if there is demand) - Other arcade games (if there is demand)

View File

@ -0,0 +1,3 @@
If you're stuck on this screen, restart the game.
If the problem persists, <a href="https://gitea.tendokyu.moe/Dniel97/SEGAguide/wiki/FAQ#game-is-stuck-at-checking-distribution-server" target="_blank">check your network configuration</a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

8
public/help-finale.md Normal file
View File

@ -0,0 +1,8 @@
You can access this page any time by right-clicking the START button.
Additional resources:
- <a href="https://gitea.tendokyu.moe/Dniel97/SEGAguide/wiki/FAQ" target="_blank">SEGAguide</a>
- <a href="https://two-torial.xyz/" target="_blank">two-torial</a>
## Have fun

View File

@ -0,0 +1,3 @@
You also have to calibrate the lever, or you may get the error 3301.
Go to lever settings (<span class="bg-black text-white">レバー設定</span>), move the lever to both edges, then press "end" (<span class="bg-black text-white">終了</span>) and "save" (<span class="bg-black text-white">保存する</span>).

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@ -0,0 +1,3 @@
You might get stuck on this screen for several minutes. _This is normal_. The game just takes a long time to load data.
If you install <code>7EVENDAYSHOLIDAYS/LoadBoost</code>, subsequent launches will be much faster.

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

7
public/help-standard.md Normal file
View File

@ -0,0 +1,7 @@
You might get stuck on the following screen:
<div class="p-2 mt-1 mb-1 bg-black text-white">Aグループの基準機から設定を取得</div>
In which case, you should go to the test menu, and in game settings <span class="bg-black text-white">ゲーム設定</span> switch from "follow the standard machine" <span class="bg-black text-white">基準機に従う</span> to "standard machine" <span class="bg-black text-white">基準機</span>.
The test menu can be accessed with %TESTMENU%.

View File

@ -1,7 +1,8 @@
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use std::time::SystemTime; use std::time::SystemTime;
use crate::model::config::GlobalConfig; use crate::model::config::GlobalConfig;
use crate::model::patch::PatchFileVec; use crate::model::patch::{PatchFileVec, PatchList};
use crate::pkg::{Feature, Status}; use crate::pkg::{Feature, Status};
use crate::profiles::types::Profile; use crate::profiles::types::Profile;
use crate::{model::misc::Game, pkg::PkgKey}; use crate::{model::misc::Game, pkg::PkgKey};
@ -133,6 +134,8 @@ impl AppData {
} }
fn init_logger(cfg: &GlobalConfig) { fn init_logger(cfg: &GlobalConfig) {
_ = std::fs::create_dir_all(util::data_dir());
let mut fern_builder; let mut fern_builder;
let colors = ColoredLevelConfig::new() let colors = ColoredLevelConfig::new()
.debug(Color::Green) .debug(Color::Green)
@ -163,4 +166,22 @@ impl AppData {
panic!("unable to initialize the logger? {:?}", e); panic!("unable to initialize the logger? {:?}", e);
} }
} }
pub fn patches_enabled(&self, game_target: impl AsRef<Path>, amd_target: impl AsRef<Path>) -> Result<Vec<&PatchList>> {
let ch1 = sha256::try_digest(game_target.as_ref())?;
let ch2 = sha256::try_digest(amd_target.as_ref())?;
let mut res = Vec::new();
for pfile in &self.patch_vec.0 {
for plist in &pfile.0 {
let this_hash = plist.sha256.to_ascii_lowercase();
log::debug!("checking {}", this_hash);
if this_hash == ch1 || this_hash == ch2 {
log::debug!("enabling {this_hash}");
res.push(plist);
}
}
}
Ok(res)
}
} }

View File

@ -69,9 +69,15 @@ pub async fn startline(app: AppHandle, refresh: bool) -> Result<(), String> {
} }
if let Some(p) = &appd.profile { if let Some(p) = &appd.profile {
log::debug!("{}", hash); log::debug!("{}", hash);
let patches_enabled = appd.patches_enabled(
&p.data.sgt.target,
&p.data.sgt.target.parent().unwrap().join("amdaemon.exe")
).map_err(|e| e.to_string())?;
let info = p.prepare_display() let info = p.prepare_display()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let lineup_res = p.line_up(hash, refresh, &appd.patch_vec).await let lineup_res = p.line_up(hash, refresh, patches_enabled).await
.map_err(|e| e.to_string()); .map_err(|e| e.to_string());
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@ -404,6 +410,33 @@ pub async fn create_shortcut(app: AppHandle, profile_meta: ProfileMeta) -> Resul
util::create_shortcut(app, &profile_meta).map_err(|e| e.to_string()) util::create_shortcut(app, &profile_meta).map_err(|e| e.to_string())
} }
#[tauri::command]
pub async fn export_profile(state: State<'_, Mutex<AppData>>, export_keychip: bool, files: Vec<String>) -> Result<(), String> {
log::debug!("invoke: export_profile({:?}, {:?} files)", export_keychip, files.len());
let appd = state.lock().await;
match &appd.profile {
Some(p) => {
p.export(export_keychip, files)
.map_err(|e| e.to_string())?;
}
None => {
let err = "export_profile: no profile".to_owned();
log::error!("{}", err);
return Err(err);
}
}
Ok(())
}
#[tauri::command]
pub async fn import_profile(state: State<'_, Mutex<AppData>>, path: PathBuf) -> Result<(), String> {
log::debug!("invoke: import_profile({:?})", path);
Profile::import(path).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");

View File

@ -1,5 +1,4 @@
use std::{collections::HashSet, path::PathBuf}; use std::{collections::HashSet, path::PathBuf};
use futures::Stream;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tokio::fs::File; use tokio::fs::File;
@ -15,7 +14,7 @@ pub struct DownloadHandler {
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct DownloadTick { pub struct DownloadTick {
pkg_key: PkgKey, pkg_key: PkgKey,
ratio: f32 ratio: f32,
} }
impl DownloadHandler { impl DownloadHandler {
@ -50,14 +49,15 @@ 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 first_hint = byte_stream.size_hint().0 as f32; 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?;
app.emit("download-tick", DownloadTick { total_bytes += i.len();
_ = app.emit("download-progress", DownloadTick {
pkg_key: pkg_key.clone(), pkg_key: pkg_key.clone(),
ratio: 1.0f32 - (byte_stream.size_hint().0 as f32) / first_hint 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?;
} }

View File

@ -102,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);
}); });
})); }));
@ -126,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());
@ -202,6 +205,8 @@ pub async fn run(_args: Vec<String>) {
cmd::save_current_profile, cmd::save_current_profile,
cmd::load_segatools_ini, cmd::load_segatools_ini,
cmd::create_shortcut, cmd::create_shortcut,
cmd::export_profile,
cmd::import_profile,
cmd::get_global_config, cmd::get_global_config,
cmd::set_global_config, cmd::set_global_config,
@ -232,7 +237,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);
} }
}); });
@ -330,8 +336,8 @@ fn open_window(apph: AppHandle) -> anyhow::Result<()> {
let config = apph.config().clone(); let config = apph.config().clone();
tauri::WebviewWindowBuilder::new(&apph, "main", tauri::WebviewUrl::App("index.html".into())) tauri::WebviewWindowBuilder::new(&apph, "main", tauri::WebviewUrl::App("index.html".into()))
.title(format!("STARTLINER {}", config.version.unwrap_or_default())) .title(format!("STARTLINER {}", config.version.unwrap_or_default()))
.inner_size(900f64, 480f64) .inner_size(900f64, 600f64)
.min_inner_size(900f64, 480f64) .min_inner_size(900f64, 600f64)
.build()?; .build()?;
Ok(()) Ok(())

View File

@ -1,6 +1,6 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::pkg::{Status, PkgKey, PkgKeyVersion}; use crate::pkg::{PkgKey, PkgKeyVersion};
use super::misc::Game; use super::misc::Game;
@ -14,7 +14,10 @@ pub struct PackageManifest {
pub dependencies: BTreeSet<PkgKeyVersion>, pub dependencies: BTreeSet<PkgKeyVersion>,
#[serde(default)] #[serde(default)]
pub installers: Vec<BTreeMap<String, serde_json::Value>> pub installers: Vec<BTreeMap<String, serde_json::Value>>,
#[serde(default)]
pub games: Option<Vec<Game>>,
} }
pub type PackageList = BTreeMap<PkgKey, PackageListEntry>; pub type PackageList = BTreeMap<PkgKey, PackageListEntry>;
@ -22,6 +25,5 @@ pub type PackageList = BTreeMap<PkgKey, PackageListEntry>;
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct PackageListEntry { pub struct PackageListEntry {
pub version: String, pub version: String,
pub status: Status,
pub games: Vec<Game>, pub games: Vec<Game>,
} }

View File

@ -166,11 +166,26 @@ pub enum Mu3Audio {
Excl2Ch, Excl2Ch,
} }
#[derive(Deserialize, Serialize, Clone, Debug, Default)] #[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(default)] #[serde(default)]
pub struct Mu3Ini { pub struct Mu3Ini {
pub audio: Option<Mu3Audio>, pub audio: Option<Mu3Audio>,
pub sample_rate: i32,
pub blacklist: Option<(i32, i32)>, pub blacklist: Option<(i32, i32)>,
pub gp: i32,
pub enable_bonus_tracks: bool,
}
impl Default for Mu3Ini {
fn default() -> Self {
Self {
audio: Some(Mu3Audio::Shared),
sample_rate: 48_000,
blacklist: Some((10000, 19999)),
gp: 999,
enable_bonus_tracks: true
}
}
} }
#[derive(Deserialize, Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug)]

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

@ -6,14 +6,14 @@ use tauri::{AppHandle, Listener};
#[derive(Clone)] #[derive(Clone)]
pub struct DisplayInfo { pub struct DisplayInfo {
pub primary: String, pub primary: Option<String>,
pub set: Option<DisplaySet>, pub set: Option<DisplaySet>,
} }
impl Default for DisplayInfo { impl Default for DisplayInfo {
fn default() -> Self { fn default() -> Self {
DisplayInfo { DisplayInfo {
primary: "default".to_owned(), primary: None,
set: query_displays().ok(), set: query_displays().ok(),
} }
} }
@ -60,7 +60,7 @@ impl Display {
.ok_or_else(|| anyhow!("Unable to query display settings"))?; .ok_or_else(|| anyhow!("Unable to query display settings"))?;
let res = DisplayInfo { let res = DisplayInfo {
primary: primary.name().to_owned(), primary: if self.dont_switch_primary { None } else { Some(primary.name().to_owned()) },
set: Some(display_set.clone()), set: Some(display_set.clone()),
}; };
@ -132,12 +132,14 @@ impl Display {
let display_set = info.set.as_ref() let display_set = info.set.as_ref()
.ok_or_else(|| anyhow!("Unable to clean up displays: no display set"))?; .ok_or_else(|| anyhow!("Unable to clean up displays: no display set"))?;
let primary = display_set if let Some(info_primary) = &info.primary {
.displays() let primary = display_set
.find(|display| display.name() == info.primary) .displays()
.ok_or_else(|| anyhow!("Display {} not found", info.primary))?; .find(|display| display.name() == info_primary)
.ok_or_else(|| anyhow!("Display {} not found", info_primary))?;
primary.set_primary()?; primary.set_primary()?;
}
display_set.apply()?; display_set.apply()?;
displayz::refresh()?; displayz::refresh()?;

View File

@ -1,25 +1,23 @@
use std::path::Path; use std::path::Path;
use anyhow::Result; use anyhow::Result;
use crate::model::patch::{Patch, PatchData, PatchFileVec, PatchSelection, PatchSelectionData}; use crate::model::patch::{Patch, PatchData, PatchList, PatchSelection, PatchSelectionData};
impl PatchSelection { impl PatchSelection {
pub async fn render_to_file( pub async fn render_to_file(
&self, &self,
filename: &str, filename: &str,
patches: &PatchFileVec, patch_lists: &Vec<&PatchList>,
path: impl AsRef<Path> path: impl AsRef<Path>
) -> Result<()> { ) -> Result<()> {
let mut res = "".to_owned(); let mut res = "".to_owned();
for file in &patches.0 { for list in patch_lists {
for list in &file.0 { if list.filename != filename {
if list.filename != filename { continue;
continue; }
} for patch in &list.patches {
for patch in &list.patches { if let Some(selection) = self.0.get(&patch.id) {
if let Some(selection) = self.0.get(&patch.id) { res += &Self::render(filename, patch, selection);
res += &Self::render(filename, patch, selection);
}
} }
} }
} }

View File

@ -1,11 +1,11 @@
use std::path::Path; use std::path::Path;
use anyhow::Result; use anyhow::{anyhow, Result};
use ini::Ini; use ini::Ini;
use crate::model::profile::{Mu3Audio, Mu3Ini}; use crate::model::profile::{Mu3Audio, Mu3Ini};
impl Mu3Ini { impl Mu3Ini {
pub fn line_up(&self, game_path: impl AsRef<Path>) -> Result<()> { pub fn line_up(&self, data_dir: impl AsRef<Path>, cfg_dir: impl AsRef<Path>) -> Result<()> {
let file = game_path.as_ref().join("mu3.ini"); let file = cfg_dir.as_ref().join("mu3.ini");
if !file.exists() { if !file.exists() {
std::fs::write(&file, "")?; std::fs::write(&file, "")?;
@ -20,9 +20,26 @@ impl Mu3Ini {
Mu3Audio::Excl2Ch => "2", Mu3Audio::Excl2Ch => "2",
}; };
ini.with_section(Some("Sound")).set("WasapiExclusive", value); ini.with_section(Some("Sound"))
.set("WasapiExclusive", value)
.set("SampleRate", self.sample_rate.to_string());
} }
if let Some(blacklist) = self.blacklist {
ini.with_section(Some("Extra"))
.set("BlacklistMin", blacklist.0.to_string())
.set("BlacklistMax", blacklist.1.to_string());
}
let cache_path = data_dir.as_ref().join("mu3-mods-cache");
let cache_path = cache_path.to_str()
.ok_or_else(|| anyhow!("Invalid cache path"))?;
ini.with_section(Some("Extra"))
.set("GP", self.gp.to_string())
.set("CacheDir", cache_path)
.set("UnlockBonusTracks", crate::util::bool_to_01(self.enable_bonus_tracks));
ini.write_to_file(file)?; ini.write_to_file(file)?;
Ok(()) Ok(())

View File

@ -5,30 +5,30 @@ use crate::{model::{misc::{ConfigHook, ConfigHookAime, ConfigHookAimeUnit, Confi
use crate::pkg_store::PackageStore; use crate::pkg_store::PackageStore;
impl Segatools { impl Segatools {
pub fn fix(&mut self, store: &PackageStore) { pub fn fix(&mut self, _store: &PackageStore) {
macro_rules! remove_if_nonpresent { // macro_rules! remove_if_nonpresent {
($item:expr,$key:expr,$emptyval:expr,$store:expr) => { // ($item:expr,$key:expr,$emptyval:expr,$store:expr) => {
if let Ok(pkg) = $store.get($key) { // if let Ok(pkg) = $store.get($key) {
if pkg.loc.is_none() { // if pkg.loc.is_none() {
$item = $emptyval; // $item = $emptyval;
} // }
} else { // } else {
$item = $emptyval; // $item = $emptyval;
} // }
} // }
} // }
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 IOSelection::Custom(key) = &self.io2 { // if let IOSelection::Custom(key) = &self.io2 {
remove_if_nonpresent!(self.io2, key, IOSelection::default(), 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),
Aime::Other(key) => remove_if_nonpresent!(self.aime, key, Aime::BuiltIn, store), // Aime::Other(key) => remove_if_nonpresent!(self.aime, key, Aime::BuiltIn, store),
_ => {}, // _ => {},
} // }
} }
pub fn load_from_ini(&mut self, ini: &Ini, config_dir: impl AsRef<Path>) -> Result<()> { pub fn load_from_ini(&mut self, ini: &Ini, config_dir: impl AsRef<Path>) -> Result<()> {
log::debug!("loading sgt"); log::debug!("loading sgt");

View File

@ -15,10 +15,14 @@ impl PatchFileVec {
let mut res = Vec::new(); let mut res = Vec::new();
for f in std::fs::read_dir(path)? { for f in std::fs::read_dir(path)? {
let f = f?; let f = f?;
let f = f.path(); let f = &f.path();
res.push( match serde_json5::from_str::<PatchFile>(&std::fs::read_to_string(f)?) {
serde_json5::from_str::<PatchFile>(&std::fs::read_to_string(f)?)? Ok(parsed) => res.push(parsed),
); Err(e) => {
log::error!("Error parsing {f:?}: {e}");
anyhow::bail!("Error parsing {f:?}: {e}");
}
}
} }
Ok(PatchFileVec(res)) Ok(PatchFileVec(res))
} }
@ -26,20 +30,21 @@ impl PatchFileVec {
pub fn find_patches(&self, target: impl AsRef<Path>) -> Result<Vec<Patch>> { pub fn find_patches(&self, target: impl AsRef<Path>) -> Result<Vec<Patch>> {
let checksum = try_digest(target.as_ref())?; let checksum = try_digest(target.as_ref())?;
let mut res = Vec::new(); let mut res_patches = Vec::new();
for pfile in &self.0 { for pfile in &self.0 {
for plist in &pfile.0 { for plist in &pfile.0 {
log::debug!("checking {}", plist.sha256); let this_hash = plist.sha256.to_ascii_lowercase();
if plist.sha256 == checksum { log::debug!("checking {}", this_hash);
if this_hash == checksum {
let mut cloned = plist.clone().patches; let mut cloned = plist.clone().patches;
res.append(&mut cloned); res_patches.append(&mut cloned);
} }
} }
} }
if res.len() == 0 { if res_patches.len() == 0 {
log::warn!("no matching patchset for {:?} ({})", target.as_ref(), checksum); log::warn!("no matching patchset for {:?} ({})", target.as_ref(), checksum);
} }
Ok(res) Ok(res_patches)
} }
} }

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,13 +113,14 @@ 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,
}) })
} }
pub async fn from_dir(dir: PathBuf, source: PackageSource) -> Result<Package> { pub async fn from_dir(dir: PathBuf, source: PackageSource) -> Result<(Package, Option<Vec<Game>>)> {
let str = fs::read_to_string(dir.join("manifest.json")).await?; let str = fs::read_to_string(dir.join("manifest.json")).await?;
let mft: local::PackageManifest = serde_json::from_str(&str)?; let mft: local::PackageManifest = serde_json::from_str(&str)?;
@ -131,7 +133,7 @@ impl Package {
let status = Self::parse_status(&mft, &dir); 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 {
namespace: Self::dir_to_namespace(&dir)?, namespace: Self::dir_to_namespace(&dir)?,
name: mft.name.clone(), name: mft.name.clone(),
description: mft.description.clone(), description: mft.description.clone(),
@ -144,7 +146,7 @@ impl Package {
}), }),
rmt: None, rmt: None,
source source
}) }, mft.games))
} }
pub fn key(&self) -> PkgKey { pub fn key(&self) -> PkgKey {

View File

@ -21,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
} }
@ -83,7 +83,7 @@ impl PackageStore {
pub async fn reload_package(&mut self, key: PkgKey) { pub async fn reload_package(&mut self, key: PkgKey) {
let dir = util::pkg_dir().join(&key.0); let dir = util::pkg_dir().join(&key.0);
if let Ok(pkg) = Package::from_dir(dir, PackageSource::Rainy).await { if let Ok((pkg, _)) = Package::from_dir(dir, PackageSource::Rainy).await {
self.update_nonremote(key, pkg); self.update_nonremote(key, pkg);
} else { } else {
log::error!("couldn't reload {}", key); log::error!("couldn't reload {}", key);
@ -102,7 +102,13 @@ impl PackageStore {
} }
while let Some(res) = futures.join_next().await { while let Some(res) = futures.join_next().await {
if let Ok(Ok(pkg)) = res { if let Ok(Ok((pkg, locally_declared_games))) = res {
if let Some(games) = locally_declared_games {
self.meta_list.insert(pkg.key(), PackageListEntry {
version: pkg.loc.as_ref().unwrap().version.clone(),
games
});
}
self.update_nonremote(pkg.key(), pkg); self.update_nonremote(pkg.key(), pkg);
} }
} }
@ -152,7 +158,6 @@ impl PackageStore {
PackageListEntry { PackageListEntry {
// from_rainy() is guaranteed to include rmt // from_rainy() is guaranteed to include rmt
version: r.rmt.as_ref().unwrap().version.clone(), version: r.rmt.as_ref().unwrap().version.clone(),
status: Status::Unchecked,
games: vec![ game ], games: vec![ game ],
} }
}); });

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, IOSelection, Keyboard, Mu3Ini, OngekiKeyboard, ProfileModule}}, modules::{display_windows::DisplayInfo, package::prepare_packages}, pkg::PkgKey, pkg_store::PackageStore, util}; use crate::{model::{misc::Game, patch::{PatchList, 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;
@ -10,6 +10,7 @@ use std::fs::File;
use tokio::process::Command; use tokio::process::Command;
use tokio::task::JoinSet; use tokio::task::JoinSet;
pub mod template;
pub mod types; pub mod types;
impl Profile { impl Profile {
@ -28,7 +29,7 @@ impl Profile {
bepinex: if meta.game == Game::Ongeki { Some(BepInEx::default()) } else { None }, bepinex: if meta.game == Game::Ongeki { Some(BepInEx::default()) } else { None },
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
wine: crate::model::profile::Wine::default(), wine: crate::model::profile::Wine::default(),
mu3_ini: if meta.game == Game::Ongeki { Some(Mu3Ini { audio: None, blacklist: None }) } else { None }, mu3_ini: if meta.game == Game::Ongeki { Some(Mu3Ini::default()) } else { None },
keyboard: keyboard:
if meta.game == Game::Ongeki { if meta.game == Game::Ongeki {
Some(Keyboard::Ongeki(OngekiKeyboard::default())) Some(Keyboard::Ongeki(OngekiKeyboard::default()))
@ -67,6 +68,18 @@ impl Profile {
data.sgt.io2 = IOSelection::Custom(io); data.sgt.io2 = IOSelection::Custom(io);
data.sgt.io = None; data.sgt.io = None;
} }
if let Some(ini) = &mut data.mu3_ini {
if ini.audio.is_none() {
ini.audio = Some(crate::model::profile::Mu3Audio::Shared);
}
if ini.blacklist.is_none() {
ini.blacklist = Some((10000, 19999));
}
} else {
data.mu3_ini = Some(Mu3Ini::default());
}
Self::load_existing_mu3_ini(&data, &ProfileMeta { game, name: name.clone() })?;
} }
if game == Game::Chunithm { if game == Game::Chunithm {
if data.keyboard.is_none() { if data.keyboard.is_none() {
@ -168,7 +181,7 @@ impl Profile {
Ok(info) Ok(info)
} }
pub async fn line_up(&self, pkg_hash: String, refresh: bool, patch_files: &PatchFileVec) -> Result<()> { pub async fn line_up(&self, pkg_hash: String, refresh: bool, patchlists_enabled: Vec<&PatchList>) -> Result<()> {
if !self.data_dir().exists() { if !self.data_dir().exists() {
tokio::fs::create_dir(self.data_dir()).await?; tokio::fs::create_dir(self.data_dir()).await?;
} }
@ -203,13 +216,13 @@ impl Profile {
} }
if let Some(mu3ini) = &self.data.mu3_ini { if let Some(mu3ini) = &self.data.mu3_ini {
mu3ini.line_up(&self.data.sgt.target.parent().unwrap())?; mu3ini.line_up(&self.data_dir(), &self.config_dir())?;
} }
if let Some(patches) = &self.data.patches { if let Some(patches) = &self.data.patches {
futures::try_join!( futures::try_join!(
patches.render_to_file("amdaemon.exe", patch_files, self.data_dir().join("patch-amd.mph")), patches.render_to_file("amdaemon.exe", &patchlists_enabled, self.data_dir().join("patch-amd.mph")),
patches.render_to_file("chusanApp.exe", patch_files, self.data_dir().join("patch-game.mph")) patches.render_to_file("chusanApp.exe", &patchlists_enabled, self.data_dir().join("patch-game.mph"))
)?; )?;
} }
@ -283,6 +296,14 @@ impl Profile {
"ONGEKI_LANG_PATH", "ONGEKI_LANG_PATH",
self.data_dir().join("lang"), self.data_dir().join("lang"),
) )
.env(
"MU3_MODS_CONFIG_PATH",
self.config_dir().join("mu3.ini"),
)
.env(
"STARTLINER",
"1"
)
.current_dir(&exe_dir) .current_dir(&exe_dir)
.raw_arg("-d") .raw_arg("-d")
.raw_arg("-k") .raw_arg("-k")
@ -403,6 +424,19 @@ impl Profile {
Ok(false) Ok(false)
} }
} }
fn load_existing_mu3_ini(data: &ProfileData, meta: &ProfileMeta) -> Result<()> {
if let Some(parent) = data.sgt.target.parent() {
let mu3_ini_target_path = parent.join("mu3.ini");
let mu3_ini_profile_path = util::profile_config_dir(meta.game, &meta.name).join("mu3.ini");
log::debug!("mu3.ini paths: {:?} {:?}", mu3_ini_target_path, mu3_ini_profile_path);
if mu3_ini_target_path.exists() && !mu3_ini_profile_path.exists() {
std::fs::copy(&mu3_ini_target_path, &mu3_ini_profile_path)?;
log::info!("copied mu3.ini from {:?}", &mu3_ini_target_path);
}
}
Ok(())
}
} }
impl ProfilePaths for Profile { impl ProfilePaths for Profile {

View File

@ -0,0 +1,90 @@
use std::{fs::File, io::{Read, Write}, path::PathBuf};
use zip::{write::FileOptions, ZipArchive, ZipWriter};
use crate::util;
use super::{Profile, ProfilePaths};
impl Profile {
fn find_template_json(archive: &mut ZipArchive<File>) -> anyhow::Result<String> {
if let Ok(mut file) = archive.by_name("template.json") {
let mut contents = Vec::new();
file.read_to_end(&mut contents)?;
Ok(String::from_utf8(contents)?)
} else {
anyhow::bail!("invalid template: no template.json found")
}
}
pub fn import(path: PathBuf) -> anyhow::Result<()> {
let file = File::open(path)?;
let mut archive = ZipArchive::new(file)?;
match Self::find_template_json(&mut archive) {
Ok(raw_p) => {
let p = serde_json::from_str::<Profile>(&raw_p)?;
let dir = util::config_dir().join(format!("profile-{}-{}", &p.meta.game, &p.meta.name));
if dir.exists() {
anyhow::bail!("profile {} already exists", &p.meta.name);
}
std::fs::create_dir(&dir)?;
archive.extract(&dir)?;
std::fs::remove_file(dir.join("template.json"))?;
std::fs::write(dir.join("profile.json"), serde_json::to_string_pretty(&p.data)?)?;
}
Err(e) => {
return Err(e);
}
}
Ok(())
}
pub fn export(&self, export_keychip: bool, extra_files: Vec<String>) -> anyhow::Result<()> {
let mut prf = self.clone();
let dir = util::config_dir().join("exports");
if !dir.exists() {
std::fs::create_dir(&dir)?;
}
let path = dir.join(format!("{}-{}-template.zip", &self.meta.game, &self.meta.name));
{
let sgt = &mut prf.data.sgt;
sgt.target = PathBuf::new();
if sgt.amfs.is_absolute() {
sgt.amfs = PathBuf::new();
}
if sgt.option.is_absolute() {
sgt.option = PathBuf::new();
}
if sgt.appdata.is_absolute() {
sgt.appdata = PathBuf::new();
}
}
{
let network = &mut prf.data.network;
if network.local_path.is_absolute() {
network.local_path = PathBuf::new();
}
if !export_keychip {
network.keychip = String::new();
}
}
let file = File::create(&path)?;
let mut zip = ZipWriter::new(file);
let options: FileOptions<'_, ()> = FileOptions::default();
zip.start_file("template.json", options)?;
zip.write_all(&serde_json::to_string_pretty(&prf)?.as_bytes())?;
for file in extra_files {
log::debug!("extra file: {file}");
zip.start_file(&file, options)?;
zip.write_all(&std::fs::read(self.config_dir().join(file))?)?;
}
zip.finish()?;
Ok(())
}
}

View File

@ -199,7 +199,7 @@ pub fn create_shortcut(
obj.SetDescription(&format!("{} {} (STARTLINER)", &meta.game.print(), &meta.name))?; obj.SetDescription(&format!("{} {} (STARTLINER)", &meta.game.print(), &meta.name))?;
obj.SetArguments(&format!("--start --game {} --profile {}", &meta.game, &meta.name))?; obj.SetArguments(&format!("--start --game {} --profile {}", &meta.game, &meta.name))?;
obj.SetIconLocation( obj.SetIconLocation(
target_dir.join(format!("icon-{}.ico", &meta.game)).to_str().ok_or_else(|| anyhow!("Illegal icon path"))?, target_dir.join(format!("icon-{}.ico", &meta.game)).to_str().ok_or_else(|| anyhow!("Illegal icon path"))?,
0 0
)?; )?;

View File

@ -1,4 +1,154 @@
[ [
{
filename: 'chusanApp.exe',
version: '2.26.00',
sha256: 'AD2DCC02CE52B3FFF24A2919F8617854581DD2E2C0378EA13D84438FCCA2D522',
patches: [
{
id: 'standard-shared-audio',
name: "Force shared audio mode, system audio sample rate must be 48000Hz",
tooltip: "Improves compatibility, but may increase latency",
patches: [
{offset: 0xF233DA, off: [0x01], on: [0x00]}
]
},
{
id: 'standard-2ch',
name: "Force 2 channel audio output",
tooltip: "May cause bass overload",
patches: [
{offset: 0xF234B1, off: [0x75, 0x3f], on: [0x90, 0x90]}
]
},
{
id: 'standard-song-timer',
name: "Disable song select timer",
patches: [
{offset: 0xA03916, off: [0x74], on: [0xeb]}
]
},
{
id: 'standard-map-timer',
name: "Map selection timer",
tooltip: "If set to negative, the timer becomes 968 + value (e.g. 968 + -1 = 967)",
type: "number",
offset: 0x965B37,
default: 30,
size: 1,
min: -128,
max: 127,
},
{
id: 'standard-ticket-timer',
name: "Ticket selection timer",
tooltip: "If set to negative, the timer becomes 968 + value (e.g. 968 + -1 = 967)",
type: "number",
offset: 0x9592C2,
default: 60,
size: 1,
min: -128,
max: 127,
},
{
id: 'standard-course-timer',
name: "Course selection timer",
tooltip: "If set to negative, the timer becomes 968 + value (e.g. 968 + -1 = 967)",
type: "number",
offset: 0xA0EADB,
default: 30,
size: 1,
min: -128,
max: 127,
},
{
id: 'standard-unlimited-tracks',
name: "Unlimited maximum tracks",
tooltip: "Must check to play more than 7 tracks per credit",
patches: [
{offset: 0x71E2E0, off: [0xf0], on: [0xc0]}
]
},
{
id: 'standard-maximum-tracks',
type: "number",
name: "Maximum tracks",
offset: 0x3980C1,
default: 3,
size: 1,
min: 3,
max: 12
},
{
id: 'standard-no-encryption',
name: "No encryption",
tooltip: "Will also disable TLS",
patches: [
{offset: 0x1DE29E8, off: [0xE1], on: [0x00]},
{offset: 0x1DE29EC, off: [0xE1], on: [0x00]}
]
},
{
id: 'standard-no-tls',
name: "No TLS",
tooltip: "Title server workaround",
patches: [
{offset: 0xF06447, off: [0x80], on: [0x00]}
]
},
{
id: 'standard-head-to-head',
name: "Patch for head-to-head play",
tooltip: "Fix infinite sync while trying to connect to head to head play",
patches: [
{offset: 0x6533A3, off: [0x01], on: [0x00]}
]
},
{
id: 'standard-bypass-1080p',
name: "Bypass 1080p monitor check",
patches: [
{offset: 0x1CCBF, off: [0x81, 0xbc, 0x24, 0xb8, 0x02, 0x00, 0x00, 0x80, 0x07, 0x00, 0x00, 0x75, 0x1f, 0x81, 0xbc, 0x24, 0xbc, 0x02, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00, 0x75, 0x12], on: [0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90]}
]
},
{
id: 'standard-bypass-120hz',
name: "Bypass 120Hz monitor check",
patches: [
{offset: 0x1CCB1, off: [0x85, 0xc0], on: [0xeb, 0x30]}
]
},
{
id: 'standard-force-free-play-text',
name: "Force FREE PLAY credit text",
tooltip: "Replaces the credit count with FREE PLAY",
patches: [
{offset: 0x3875A4, off: [0x3c, 0x01], on: [0x38, 0xc0]}
]
},
],
},
{
filename: 'amdaemon.exe',
version: '2.25.00',
sha256: '00FB867D1EE821033101B8773FAC116A45DF1939D23C38E9DAFC9B86CD5A3777',
patches: [
{
id: 'standard-localhost',
name: "Allow 127.0.0.1/localhost as the network server",
patches: [
{ offset: 0x6E28A4, off: [0x31, 0x32, 0x37, 0x2F], on: [0x30, 0x2F, 0x38, 0x00] },
{ offset: 0x3C94C4, off: [0xFF, 0x15, 0xC6, 0x2F, 0x1B, 0x00, 0x8B], on: [0x33, 0xC0, 0x48, 0x83, 0xC4, 0x28, 0xC3] }
]
},
{
id: 'standard-credit-freeze',
name: "Infinite credits",
patches: [
{ offset: 0x2BBBC8, off: [0x28], on: [0x08] }
]
}
]
},
{ {
filename: 'chusanApp.exe', filename: 'chusanApp.exe',
version: '2.30.00', version: '2.30.00',

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.8.0", "version": "0.13.0",
"identifier": "zip.patafour.startliner", "identifier": "zip.patafour.startliner",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",

View File

@ -28,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();
@ -86,6 +88,60 @@ listen<string>('launch-error', (event) => {
errorMessage.value = event.payload; errorMessage.value = event.payload;
errorHeader.value = 'Launch error'; 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>
@ -100,6 +156,16 @@ listen<string>('launch-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
@ -351,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

@ -65,7 +65,7 @@ const filePick = async () => {
<template> <template>
<Button v-if="!exists" icon="pi pi-plus" size="small" @click="filePick" /> <Button v-if="!exists" icon="pi pi-plus" size="small" @click="filePick" />
<div v-else> <div class="primitive-base" v-else>
<Button <Button
v-if="exists" v-if="exists"
icon="pi pi-pen-to-square" icon="pi pi-pen-to-square"
@ -102,12 +102,20 @@ const filePick = async () => {
font-family: monospace; font-family: monospace;
white-space: nowrap; white-space: nowrap;
position: fixed; position: fixed;
top: 10vh; top: 50%;
left: 10vw; left: 50%;
height: 80vh; height: 500px;
width: 80vw; width: 800px;
margin-left: -400px;
margin-top: -250px;
z-index: 1000; z-index: 1000;
padding: 20px;
border-radius: 20px;
background-color: #151515; background-color: #151515;
color: #ddd; color: #ddd;
} }
.primitive-base ::-webkit-scrollbar {
display: none;
}
</style> </style>

View File

@ -16,7 +16,7 @@ invoke('get_changelog').then((s) => (changelog.value = s as string));
O.N.G.E.K.I. and CHUNITHM. O.N.G.E.K.I. and CHUNITHM.
<h1>Changelog</h1> <h1>Changelog</h1>
<ScrollPanel style="height: 200px"> <ScrollPanel style="height: 200px">
<div class="changelog"> <div class="markdown">
<vue-markdown-it <vue-markdown-it
:source="changelog" :source="changelog"
:options="{ typographer: true, breaks: true }" :options="{ typographer: true, breaks: true }"
@ -44,13 +44,20 @@ h1 {
font-size: 1.7rem; font-size: 1.7rem;
} }
.changelog h2 { .markdown h3 {
font-size: 1.2rem;
}
.markdown h2 {
font-size: 1.4rem; font-size: 1.4rem;
} }
.changelog ul { .markdown ul {
list-style-type: circle; list-style-type: circle;
} }
.changelog li { .markdown li {
margin-left: 40px; margin-left: 40px;
} }
.markdown a {
text-decoration: underline;
}
</style> </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

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import InputText from 'primevue/inputtext'; import InputText from 'primevue/inputtext';
import { fromKeycode, toKeycode } from '../keyboard';
import { usePrfStore } from '../stores'; import { usePrfStore } from '../stores';
import { OngekiButtons } from '../types'; import { OngekiButtons } from '../types';
@ -15,9 +16,51 @@ const handleKey = (
) => { ) => {
event.preventDefault(); event.preventDefault();
const keycode = toKeycode(event.code); let keycode = toKeycode(event.code);
if (keycode !== null && button !== undefined) { if (keycode !== null && button !== undefined) {
const data = prf.current!.data.keyboard!.data as any; const data = prf.current!.data.keyboard!.data as any;
if (event.getModifierState('NumLock') === false) {
switch (event.code) {
case 'NumpadDecimal':
keycode = toKeycode('Delete');
break;
case 'Numpad0':
keycode = toKeycode('Insert');
break;
case 'Numpad1':
keycode = toKeycode('End');
break;
case 'Numpad2':
keycode = toKeycode('ArrowDown');
break;
case 'Numpad3':
keycode = toKeycode('PageDown');
break;
case 'Numpad4':
keycode = toKeycode('ArrowLeft');
break;
case 'Numpad5':
keycode = toKeycode('Clear');
break;
case 'Numpad6':
keycode = toKeycode('ArrowRight');
break;
case 'Numpad7':
keycode = toKeycode('Home');
break;
case 'Numpad8':
keycode = toKeycode('ArrowUp');
break;
case 'Numpad9':
keycode = toKeycode('PageUp');
break;
default:
break;
}
}
if (index !== undefined) { if (index !== undefined) {
data[button][index] = keycode; data[button][index] = keycode;
} else { } else {
@ -75,7 +118,7 @@ const handleMouse = (
} }
}; };
const getKey = (key: keyof OngekiButtons, index?: number) => const getKey = (key: keyof OngekiButtons, index?: number): any =>
computed(() => { computed(() => {
const data = prf.current!.data.keyboard?.data as any; const data = prf.current!.data.keyboard?.data as any;
const keycode = const keycode =
@ -85,147 +128,45 @@ const getKey = (key: keyof OngekiButtons, index?: number) =>
return keycode && fromKeycode(keycode) ? fromKeycode(keycode) : ''; return keycode && fromKeycode(keycode) ? fromKeycode(keycode) : '';
}); });
const KEY_MAP: { [key: number]: string } = { const props = defineProps({
1: 'M1',
2: 'M2',
4: 'M3',
5: 'M4',
6: 'M5',
8: 'Backspace',
9: 'Tab',
13: 'Enter',
19: 'Pause',
20: 'CapsLock',
27: 'Escape',
32: 'Space',
33: 'PageUp',
34: 'PageDown',
35: 'End',
36: 'Home',
37: 'ArrowLeft',
38: 'ArrowUp',
39: 'ArrowRight',
40: 'ArrowDown',
45: 'Insert',
46: 'Delete',
48: 'Digit0',
49: 'Digit1',
50: 'Digit2',
51: 'Digit3',
52: 'Digit4',
53: 'Digit5',
54: 'Digit6',
55: 'Digit7',
56: 'Digit8',
57: 'Digit9',
65: 'KeyA',
66: 'KeyB',
67: 'KeyC',
68: 'KeyD',
69: 'KeyE',
70: 'KeyF',
71: 'KeyG',
72: 'KeyH',
73: 'KeyI',
74: 'KeyJ',
75: 'KeyK',
76: 'KeyL',
77: 'KeyM',
78: 'KeyN',
79: 'KeyO',
80: 'KeyP',
81: 'KeyQ',
82: 'KeyR',
83: 'KeyS',
84: 'KeyT',
85: 'KeyU',
86: 'KeyV',
87: 'KeyW',
88: 'KeyX',
89: 'KeyY',
90: 'KeyZ',
91: 'MetaLeft',
92: 'MetaRight',
93: 'ContextMenu',
96: 'Numpad0',
97: 'Numpad1',
98: 'Numpad2',
99: 'Numpad3',
100: 'Numpad4',
101: 'Numpad5',
102: 'Numpad6',
103: 'Numpad7',
104: 'Numpad8',
105: 'Numpad9',
106: 'NumpadMultiply',
107: 'NumpadAdd',
109: 'NumpadSubtract',
110: 'NumpadDecimal',
111: 'NumpadDivide',
112: 'F1',
113: 'F2',
114: 'F3',
115: 'F4',
116: 'F5',
117: 'F6',
118: 'F7',
119: 'F8',
120: 'F9',
121: 'F10',
122: 'F11',
123: 'F12',
144: 'NumLock',
145: 'ScrollLock',
160: 'ShiftLeft',
161: 'ShiftRight',
162: 'ControlLeft',
163: 'ControlRight',
164: 'AltLeft',
165: 'AltRight',
186: 'Semicolon',
187: 'Equal',
188: 'Comma',
189: 'Minus',
190: 'Period',
191: 'Slash',
192: 'Backquote',
219: 'BracketLeft',
220: 'Backslash',
221: 'BracketRight',
222: 'Quote',
};
const fromKeycode = (keyCode: number): string | null => {
return KEY_MAP[keyCode] ?? null;
};
const toKeycode = (key: string): number | null => {
const res = Object.entries(KEY_MAP).find(([_, v]) => v === key)?.[0];
return res ? parseInt(res) : null;
};
defineProps({
small: Boolean, small: Boolean,
verySmall: Boolean,
tall: Boolean, tall: Boolean,
tooltip: String, tooltip: String,
button: String, button: String,
color: String, color: String,
index: Number, index: Number,
}); });
const modelValue = computed(() => {
return getKey(props.button as keyof OngekiButtons, props.index).value;
});
const fontSize = computed(() => {
if (!props.small) {
return '1rem';
}
const len = modelValue.value.length;
if (len < 5) {
return '1rem';
}
if (len < 7) {
return '0.75rem';
}
return '0.5rem';
});
</script> </script>
<template> <template>
<InputText <InputText
:style="{ :style="{
width: small ? '3em' : '5em', width: small ? '2.8rem' : '5rem',
height: small ? '3em' : tall ? '10em' : '5em', height: small ? '2.8rem' : tall ? '10rem' : '5rem',
fontSize: small ? '0.9em' : '1em', fontSize,
backgroundColor: color, backgroundColor: color,
}" }"
unstyled unstyled
class="text-center buttoninputtext" class="text-center buttoninputtext"
v-tooltip="tooltip" v-tooltip="tooltip ? `${tooltip}: ${modelValue}` : undefined"
@contextmenu.prevent="() => {}" @contextmenu.prevent="() => {}"
@keydown="(ev: KeyboardEvent) => handleKey(button, ev, index)" @keydown="(ev: KeyboardEvent) => handleKey(button, ev, index)"
@mousedown=" @mousedown="
@ -233,7 +174,7 @@ defineProps({
handleMouse(button as keyof OngekiButtons, ev, index) handleMouse(button as keyof OngekiButtons, ev, index)
" "
@focusout="() => (hasClickedM1Once = false)" @focusout="() => (hasClickedM1Once = false)"
:model-value="getKey(button as keyof OngekiButtons, index) as any" :model-value="modelValue"
/> />
</template> </template>
@ -241,5 +182,7 @@ defineProps({
.buttoninputtext { .buttoninputtext {
border-radius: 6px; border-radius: 6px;
border: 1px solid rgba(200, 200, 200, 0.3); border: 1px solid rgba(200, 200, 200, 0.3);
overflow: scroll !important;
text-align: center !important;
} }
</style> </style>

View File

@ -15,7 +15,6 @@ const props = defineProps({
const pkgs = usePkgStore(); const pkgs = usePkgStore();
const prf = usePrfStore(); const prf = usePrfStore();
const groupCallIndex = ref(0);
const empty = ref(false); const empty = ref(false);
const gameSublist: Ref<string[]> = ref([]); const gameSublist: Ref<string[]> = ref([]);
@ -46,10 +45,7 @@ const group = computed(() => {
({ namespace }) => namespace ({ namespace }) => namespace
) )
); );
if (groupCallIndex.value > 0) { empty.value = Object.keys(res).length === 0;
empty.value = Object.keys(res).length === 0;
}
groupCallIndex.value += 1;
return res; return res;
}); });

View File

@ -38,7 +38,7 @@ const iconSrc = computed(() => {
<label class="m-3 align-middle text grow z-5 h-50px"> <label class="m-3 align-middle text grow z-5 h-50px">
<div> <div>
<span class="text-lg"> <span class="text-lg">
{{ pkg?.name ?? 'Untitled' }} {{ pkg?.name.replaceAll('_', ' ') ?? 'Untitled' }}
</span> </span>
<span <span
v-if="pkg?.rmt?.deprecated" v-if="pkg?.rmt?.deprecated"

View File

@ -0,0 +1,175 @@
<script setup lang="ts">
import { ComputedRef, computed, onMounted, ref } from 'vue';
import Button from 'primevue/button';
import Carousel from 'primevue/carousel';
import Dialog from 'primevue/dialog';
import { fromKeycode } from '../keyboard';
import { useClientStore, usePrfStore } from '../stores';
import { prettyPrint } from '../util';
import { VueMarkdownIt } from '@f3ve/vue-markdown-it';
const prf = usePrfStore();
const client = useClientStore();
const props = defineProps({
visible: Boolean,
firstTime: Boolean,
onFinish: Function,
});
interface Datum {
text: string;
image: string;
}
const game = computed(() => prf.current?.meta.game);
const processText = (s: string) => {
if (prf.current!.data.keyboard?.data.enabled) {
const testKey = prf.current!.data.keyboard?.data.test;
const readable = fromKeycode(testKey);
if (readable !== null) {
return s.replace(
'%TESTMENU%',
`${readable} or a button on the back of the controller`
);
}
}
return s.replace('%TESTMENU%', 'a button on the back of the controller');
};
const loadPage = async (title: string) => {
return {
text: await (await fetch(`/help-${title}.md`)).text(),
image: `help-${title}.png`,
};
};
let systemProcessing: Datum;
let standardOngeki: Datum;
let standardChunithm: Datum;
let lever: Datum;
let server: Datum;
let finaleOngeki: Datum;
let finaleChunithm: Datum;
const data: ComputedRef<Datum[]> = computed(() => {
const res = [];
switch (prf.current?.meta.game) {
case 'ongeki':
res.push(systemProcessing);
res.push(standardOngeki);
res.push(lever);
res.push(finaleOngeki);
break;
case 'chunithm':
res.push(standardChunithm);
res.push(server);
res.push(finaleChunithm);
break;
default:
break;
}
return res;
});
onMounted(async () => {
[standardOngeki, systemProcessing, lever, server, finaleOngeki] =
await Promise.all([
loadPage('standard'),
loadPage('ongeki-system-processing'),
loadPage('ongeki-lever'),
loadPage('chunithm-server'),
loadPage('finale'),
]);
standardOngeki = {
...standardOngeki,
image: '/help-standard-ongeki.png',
};
standardChunithm = {
...standardOngeki,
image: '/help-standard-chunithm.png',
};
finaleOngeki = {
...finaleOngeki,
image: '/help-finale-ongeki.png',
};
finaleChunithm = {
...finaleOngeki,
image: '/help-finale-chunithm.png',
};
});
const counter = ref(0);
const exitLabel = computed(() => {
return props.firstTime === true && counter.value < data.value.length - 1
? 'Skip'
: 'Close';
});
</script>
<template>
<Dialog
modal
:visible="visible"
:closable="false"
:header="
firstTime
? `It looks like you're running ${game ? prettyPrint(game) : '<game>'} for the first time`
: `${game ? prettyPrint(game) : '<game>'} help`
"
:style="{ width: '760px', scale: client.scaleValue }"
>
<Carousel
:value="data"
:num-visible="1"
:num-scroll="1"
:page="counter"
v-on:update:page="(p) => (counter = p)"
>
<template #item="slotProps">
<div class="md-container markdown">
<vue-markdown-it
:source="processText(slotProps.data?.text)"
:options="{
typographer: true,
breaks: true,
html: true,
}"
/>
</div>
<div
class="border border-surface-200 dark:border-surface-700 rounded m-2"
>
<img :src="slotProps.data.image" />
</div>
</template>
</Carousel>
<div style="width: 100%; text-align: center">
<Button
v-if="counter < data.length - 1"
class="m-auto mr-4"
label="Next"
@click="() => (counter += 1)"
/>
<Button
class="m-auto"
:label="exitLabel"
@click="() => onFinish && onFinish()"
/>
</div>
</Dialog>
</template>
<style lang="css">
.p-dialog ::-webkit-scrollbar {
display: none;
}
.md-container {
height: 9.5rem;
}
</style>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import InputNumber from 'primevue/inputnumber';
import SelectButton from 'primevue/selectbutton'; import SelectButton from 'primevue/selectbutton';
import ToggleSwitch from 'primevue/toggleswitch'; import ToggleSwitch from 'primevue/toggleswitch';
import FileEditor from './FileEditor.vue'; import FileEditor from './FileEditor.vue';
@ -16,53 +17,35 @@ import { usePrfStore } from '../stores';
const prf = usePrfStore(); const prf = usePrfStore();
const audioModel = computed({ const blacklistMinModel = computed({
get() { get() {
return prf.current?.data.mu3_ini?.audio ?? null; if (prf.current?.data.mu3_ini?.blacklist === undefined) {
}, return null;
set(value: 'Shared' | 'Excl6Ch' | 'Excl2Ch') {
if (prf.current!.data.mu3_ini === undefined) {
prf.current!.data.mu3_ini = {};
} }
prf.current!.data.mu3_ini!.audio = value; return prf.current?.data.mu3_ini?.blacklist[0];
},
set(value: number) {
prf.current!.data.mu3_ini!.blacklist = [
value,
prf.current!.data.mu3_ini!.blacklist?.[1] ?? 19999,
];
}, },
}); });
// const blacklistMinModel = computed({ const blacklistMaxModel = computed({
// get() { get() {
// if (prf.current?.data.mu3_ini?.blacklist === undefined) { if (prf.current?.data.mu3_ini?.blacklist === undefined) {
// return null; return null;
// } }
// return prf.current?.data.mu3_ini?.blacklist[0]; return prf.current?.data.mu3_ini.blacklist[1];
// }, },
// set(value: number) { set(value: number) {
// if (prf.current!.data.mu3_ini === undefined) { prf.current!.data.mu3_ini!.blacklist = [
// prf.current!.data.mu3_ini = {}; prf.current!.data.mu3_ini!.blacklist?.[0] ?? 10000,
// } value,
// prf.current!.data.mu3_ini!.blacklist = [ ];
// value, },
// prf.current!.data.mu3_ini!.blacklist?.[1] ?? 19999, });
// ];
// },
// });
// const blacklistMaxModel = computed({
// get() {
// if (prf.current?.data.mu3_ini?.blacklist === undefined) {
// return null;
// }
// return prf.current?.data.mu3_ini?.blacklist[1];
// },
// set(value: number) {
// if (prf.current!.data.mu3_ini === undefined) {
// prf.current!.data.mu3_ini = {};
// }
// prf.current!.data.mu3_ini!.blacklist = [
// prf.current!.data.mu3_ini!.blacklist?.[0] ?? 10000,
// value,
// ];
// },
// });
prf.reload(); prf.reload();
</script> </script>
@ -102,34 +85,59 @@ prf.reload();
<OptionRow <OptionRow
title="Audio mode" title="Audio mode"
tooltip="Exclusive 2-channel mode requires a patch" tooltip="Exclusive 2-channel mode requires 7EVENDAYSHOLIDAYS-ExclusiveAudio"
> >
<SelectButton <SelectButton
v-model="audioModel" v-model="prf.current!.data.mu3_ini!.audio"
:options="[ :options="[
{ title: 'Shared', value: 'Shared' }, { title: 'Shared', value: 'Shared' },
{ title: 'Exclusive 6-channel', value: 'Excl6Ch' }, { title: 'Exclusive 6-channel', value: 'Excl6Ch' },
{ title: 'Exclusive 2-channel', value: 'Excl2Ch' }, { title: 'Exclusive 2-channel', value: 'Excl2Ch' },
]" ]"
:allow-empty="true" :allow-empty="false"
option-label="title" option-label="title"
option-value="value" option-value="value"
/></OptionRow> /></OptionRow>
<!-- <OptionRow <OptionRow
title="Sample rate"
v-if="
prf.current?.data.mods.includes(
'7EVENDAYSHOLIDAYS-ExclusiveAudio'
)
"
>
<SelectButton
v-model="prf.current!.data.mu3_ini!.sample_rate"
:disabled="prf.current!.data.mu3_ini!.audio === 'Shared'"
:options="[
{ title: '44.1KHz', value: 44100 },
{ title: '48KHz', value: 48000 },
{ title: '96KHz', value: 96000 },
{ title: '192KHz', value: 192000 },
]"
:allow-empty="false"
option-label="title"
option-value="value"
/></OptionRow>
<OptionRow
v-if="
prf.current?.data.mods.includes('7EVENDAYSHOLIDAYS-Blacklist')
"
class="number-input" class="number-input"
title="Song ID Blacklist" title="Song ID Blacklist"
tooltip="Requires a patch" tooltip="Scores on charts within this ID range will not be saved nor uploaded"
><InputNumber ><InputNumber
class="shrink" class="shrink"
size="small" size="small"
:min="10000" :min="9000"
:max="99999" :max="99999"
placeholder="10000" placeholder="10000"
:use-grouping="false" :use-grouping="false"
:allow-empty="false" :allow-empty="false"
v-model="blacklistMinModel" /> v-model="blacklistMinModel" />
x ~
<InputNumber <InputNumber
class="shrink" class="shrink"
size="small" size="small"
@ -139,7 +147,36 @@ prf.reload();
:use-grouping="false" :use-grouping="false"
:allow-empty="false" :allow-empty="false"
v-model="blacklistMaxModel" v-model="blacklistMaxModel"
/></OptionRow> --> /></OptionRow>
<OptionRow
class="number-input"
title="GP"
v-if="
prf.current?.data.mods.includes('7EVENDAYSHOLIDAYS-DisableGP')
"
><InputNumber
class="shrink"
size="small"
:min="0"
:max="9999"
:use-grouping="false"
:allow-empty="false"
v-model="prf.current!.data.mu3_ini!.gp"
/>
</OptionRow>
<OptionRow
title="Unlock Bonus Tracks"
tooltip="Disabling this option can help declutter the song list"
v-if="
prf.current?.data.mods.includes(
'7EVENDAYSHOLIDAYS-UnlockAllMusic'
)
"
>
<ToggleSwitch
v-model="prf.current!.data.mu3_ini!.enable_bonus_tracks"
/>
</OptionRow>
</OptionCategory> </OptionCategory>
<KeyboardOptions /> <KeyboardOptions />
<StartlinerOptions /> <StartlinerOptions />

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

@ -1,29 +1,154 @@
<script setup lang="ts"> <script setup lang="ts">
import { Ref, ref } from 'vue';
import Button from 'primevue/button'; import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import ToggleSwitch from 'primevue/toggleswitch';
import * as path from '@tauri-apps/api/path';
import { open } from '@tauri-apps/plugin-dialog';
import ProfileListEntry from './ProfileListEntry.vue'; import ProfileListEntry from './ProfileListEntry.vue';
import { usePrfStore } from '../stores'; import { invoke } from '../invoke';
import { useClientStore, useGeneralStore, usePrfStore } from '../stores';
const prf = usePrfStore(); const prf = usePrfStore();
const client = useClientStore();
const general = useGeneralStore();
const exportVisible = ref(false);
const exportKeychip = ref(false);
const files = new Set<string>();
const exportTemplate = async () => {
const fl = [...files.values()];
exportVisible.value = false;
await invoke('export_profile', {
exportKeychip: exportKeychip.value,
files: fl,
});
await invoke('open_file', {
path: await path.join(general.configDir, 'exports'),
});
};
const fileList = {
ongeki: ['aime.txt', 'inohara.cfg', 'mu3.ini', 'segatools-base.ini'],
chunithm: ['aime.txt', 'saekawa.toml', 'segatools-base.ini'],
};
const fileListCurrent: Ref<string[]> = ref([]);
const recalcFileList = async () => {
const res: string[] = [];
files.clear();
for (const idx in fileList[prf.current!.meta.game]) {
const f = fileList[prf.current!.meta.game][idx];
const p = await path.join(await prf.configDir, f);
if (await invoke('file_exists', { path: p })) {
res.push(f);
files.add(f);
}
}
fileListCurrent.value = res;
};
const openExportDialog = async () => {
await recalcFileList();
exportVisible.value = true;
};
const importPick = async () => {
const res = await open({
multiple: false,
directory: false,
filters: [
{
name: 'STARTLINER template',
extensions: ['zip'],
},
],
});
if (res != null) {
await invoke('import_profile', { path: res });
await prf.reloadList();
}
};
</script> </script>
<template> <template>
<Dialog
modal
:visible="exportVisible"
:closable="false /*this shit doesn't work */"
:header="`Export ${prf.current?.meta.name}`"
:style="{ width: '300px', scale: client.scaleValue }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-row">
<div class="grow">Export keychip</div>
<ToggleSwitch v-model="exportKeychip" />
</div>
<div class="flex flex-row" v-for="f in fileListCurrent">
<div class="grow">Export {{ f }}</div>
<ToggleSwitch
:model-value="true"
@update:model-value="
(v) => {
if (v === true) {
files.add(f);
} else {
files.delete(f);
}
}
"
/>
</div>
<div style="width: 100%; text-align: center">
<Button
class="m-auto mr-3"
style="width: 80px"
label="OK"
@click="() => exportTemplate()"
/>
<Button
class="m-auto"
style="width: 80px"
label="Cancel"
@click="() => (exportVisible = false)"
/>
</div>
</div>
</Dialog>
<div v-if="prf.list.length === 0"> <div v-if="prf.list.length === 0">
Welcome to STARTLINER! Start by creating a profile. Welcome to STARTLINER! Start by creating a profile.
</div> </div>
<div class="mt-4 flex flex-row flex-wrap align-middle gap-4"> <div class="mt-4 flex flex-row flex-wrap align-middle gap-4">
<Button <Button
label="O.N.G.E.K.I. profile" label="O.N.G.E.K.I. profile"
icon="pi pi-plus" icon="pi pi-file-plus"
class="ongeki-button profile-button" class="ongeki-button profile-button"
@click="() => prf.create('ongeki')" @click="() => prf.create('ongeki')"
/> />
<Button <Button
label="CHUNITHM profile" label="CHUNITHM profile"
icon="pi pi-plus" icon="pi pi-file-plus"
class="chunithm-button profile-button" class="chunithm-button profile-button"
@click="() => prf.create('chunithm')" @click="() => prf.create('chunithm')"
/> />
</div> </div>
<div class="mt-4 flex flex-row flex-wrap align-middle gap-4">
<Button
label="Import template"
icon="pi pi-file-import"
class="import-button profile-button"
@click="() => importPick()"
/>
<Button
:disabled="prf.current === null"
label="Export template"
icon="pi pi-file-export"
class="profile-button"
@click="() => openExportDialog()"
/>
</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">
<div v-for="p in prf.list"> <div v-for="p in prf.list">
<ProfileListEntry :p="p" /> <ProfileListEntry :p="p" />
@ -57,4 +182,14 @@ const prf = usePrfStore();
background-color: var(--p-yellow-300) !important; background-color: var(--p-yellow-300) !important;
border-color: var(--p-yellow-300) !important; border-color: var(--p-yellow-300) !important;
} }
.import-button {
background-color: var(--p-purple-400) !important;
border-color: var(--p-purple-400) !important;
}
.import-button:hover,
.import-button:active {
background-color: var(--p-purple-300) !important;
border-color: var(--p-purple-300) !important;
}
</style> </style>

View File

@ -65,6 +65,14 @@ const promptDeleteProfile = async () => {
accept: deleteProfile, accept: deleteProfile,
}); });
}; };
const dataExists = ref(false);
path.join(general.dataDir, `profile-${props.p!.game}-${props.p!.name}`).then(
async (p) => {
dataExists.value = await invoke('file_exists', { path: p });
}
);
</script> </script>
<template> <template>
@ -124,10 +132,27 @@ const promptDeleteProfile = async () => {
@click="isEditing = true" @click="isEditing = true"
/> />
<Button <Button
rounded
icon="pi pi-cog"
severity="help"
aria-label="open-config-directory"
size="small"
class="self-center"
style="width: 2rem; height: 2rem"
@click="
path
.join(general.configDir, `profile-${p!.game}-${p!.name}`)
.then(async (path) => {
await invoke('open_file', { path });
})
"
/>
<Button
v-if="dataExists"
rounded rounded
icon="pi pi-folder" icon="pi pi-folder"
severity="help" severity="help"
aria-label="open-directory" aria-label="open-data-directory"
size="small" size="small"
class="self-center" class="self-center"
style="width: 2rem; height: 2rem" style="width: 2rem; height: 2rem"
@ -135,9 +160,7 @@ const promptDeleteProfile = async () => {
path path
.join(general.dataDir, `profile-${p!.game}-${p!.name}`) .join(general.dataDir, `profile-${p!.game}-${p!.name}`)
.then(async (path) => { .then(async (path) => {
if (await invoke('file_exists', { path })) { await invoke('open_file', { path });
await invoke('open_file', { path });
}
}) })
" "
/> />

View File

@ -5,10 +5,12 @@ import ContextMenu from 'primevue/contextmenu';
import { useConfirm } from 'primevue/useconfirm'; import { useConfirm } from 'primevue/useconfirm';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import Onboarding from './Onboarding.vue';
import { invoke } from '../invoke'; import { invoke } from '../invoke';
import { usePrfStore } from '../stores'; import { useClientStore, usePrfStore } from '../stores';
const prf = usePrfStore(); const prf = usePrfStore();
const client = useClientStore();
const confirmDialog = useConfirm(); const confirmDialog = useConfirm();
type StartStatus = 'ready' | 'preparing' | 'running'; type StartStatus = 'ready' | 'preparing' | 'running';
@ -98,6 +100,7 @@ const menuItems = [
{ {
label: 'Refresh and start', label: 'Refresh and start',
icon: 'pi pi-sync', icon: 'pi pi-sync',
tooltip: 'test',
command: async () => await startline(false, true), command: async () => await startline(false, true),
}, },
{ {
@ -110,6 +113,14 @@ const menuItems = [
icon: 'pi pi-link', icon: 'pi pi-link',
command: createShortcut, command: createShortcut,
}, },
{
label: 'Help',
icon: 'pi pi-question-circle',
command: () => {
onboardingFirstTime.value = false;
onboardingVisible.value = true;
},
},
]; ];
const menu = ref(); const menu = ref();
@ -117,9 +128,38 @@ const showContextMenu = (event: Event) => {
event.preventDefault(); event.preventDefault();
menu.value.show(event); menu.value.show(event);
}; };
const onboardingVisible = ref(false);
const onboardingFirstTime = ref(false);
const tryStart = () => {
const game = prf.current?.meta.game;
if (game !== undefined) {
if (client.onboarded.includes(game)) {
startline(false, false);
} else {
onboardingVisible.value = true;
onboardingFirstTime.value = true;
client.setOnboarded(game);
}
}
};
</script> </script>
<template> <template>
<Onboarding
:visible="onboardingVisible"
:first-time="onboardingFirstTime"
:on-finish="
() => {
onboardingVisible = false;
if (onboardingFirstTime === true) {
startline(false, false);
}
}
"
/>
<ContextMenu ref="menu" :model="menuItems" /> <ContextMenu ref="menu" :model="menuItems" />
<Button <Button
v-if="startStatus === 'ready'" v-if="startStatus === 'ready'"
@ -130,7 +170,7 @@ const showContextMenu = (event: Event) => {
aria-label="start" aria-label="start"
size="small" size="small"
class="m-2.5" class="m-2.5"
@click="startline(false, false)" @click="tryStart"
@contextmenu="showContextMenu" @contextmenu="showContextMenu"
/> />
<Button <Button

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

@ -124,7 +124,7 @@ const prf = usePrfStore();
<div <div
v-for="idx in Array(16) v-for="idx in Array(16)
.fill(0) .fill(0)
.map((_, i) => 16 - i)" .map((_, i) => 32 - 2 * i - 1)"
> >
<KeyboardKey <KeyboardKey
button="cell" button="cell"
@ -142,7 +142,7 @@ const prf = usePrfStore();
<div <div
v-for="idx in Array(16) v-for="idx in Array(16)
.fill(0) .fill(0)
.map((_, i) => 32 - i)" .map((_, i) => 32 - 2 * i)"
> >
<KeyboardKey <KeyboardKey
button="cell" button="cell"

View File

@ -34,6 +34,15 @@ const verboseModel = computed({
await client.setVerbose(value); 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>
@ -67,5 +76,18 @@ const verboseModel = computed({
> >
<ToggleSwitch v-model="verboseModel" /> <ToggleSwitch v-model="verboseModel" />
</OptionRow> </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>

119
src/keyboard.ts Normal file
View File

@ -0,0 +1,119 @@
const KEY_MAP: { [key: number]: string } = {
1: 'M1',
2: 'M2',
4: 'M3',
5: 'M4',
6: 'M5',
8: 'Backspace',
9: 'Tab',
12: 'Clear',
13: 'Enter',
19: 'Pause',
20: 'CapsLock',
27: 'Escape',
32: 'Space',
33: 'PageUp',
34: 'PageDown',
35: 'End',
36: 'Home',
37: 'ArrowLeft',
38: 'ArrowUp',
39: 'ArrowRight',
40: 'ArrowDown',
45: 'Insert',
46: 'Delete',
48: 'Digit0',
49: 'Digit1',
50: 'Digit2',
51: 'Digit3',
52: 'Digit4',
53: 'Digit5',
54: 'Digit6',
55: 'Digit7',
56: 'Digit8',
57: 'Digit9',
65: 'KeyA',
66: 'KeyB',
67: 'KeyC',
68: 'KeyD',
69: 'KeyE',
70: 'KeyF',
71: 'KeyG',
72: 'KeyH',
73: 'KeyI',
74: 'KeyJ',
75: 'KeyK',
76: 'KeyL',
77: 'KeyM',
78: 'KeyN',
79: 'KeyO',
80: 'KeyP',
81: 'KeyQ',
82: 'KeyR',
83: 'KeyS',
84: 'KeyT',
85: 'KeyU',
86: 'KeyV',
87: 'KeyW',
88: 'KeyX',
89: 'KeyY',
90: 'KeyZ',
91: 'MetaLeft',
92: 'MetaRight',
93: 'ContextMenu',
96: 'Numpad0',
97: 'Numpad1',
98: 'Numpad2',
99: 'Numpad3',
100: 'Numpad4',
101: 'Numpad5',
102: 'Numpad6',
103: 'Numpad7',
104: 'Numpad8',
105: 'Numpad9',
106: 'NumpadMultiply',
107: 'NumpadAdd',
109: 'NumpadSubtract',
110: 'NumpadDecimal',
111: 'NumpadDivide',
112: 'F1',
113: 'F2',
114: 'F3',
115: 'F4',
116: 'F5',
117: 'F6',
118: 'F7',
119: 'F8',
120: 'F9',
121: 'F10',
122: 'F11',
123: 'F12',
144: 'NumLock',
145: 'ScrollLock',
160: 'ShiftLeft',
161: 'ShiftRight',
162: 'ControlLeft',
163: 'ControlRight',
164: 'AltLeft',
165: 'AltRight',
186: 'Semicolon',
187: 'Equal',
188: 'Comma',
189: 'Minus',
190: 'Period',
191: 'Slash',
192: 'Backquote',
219: 'BracketLeft',
220: 'Backslash',
221: 'BracketRight',
222: 'Quote',
};
export const fromKeycode = (keyCode: number): string | null => {
return KEY_MAP[keyCode] ?? null;
};
export const toKeycode = (key: string): number | null => {
const res = Object.entries(KEY_MAP).find(([_, v]) => v === key)?.[0];
return res ? parseInt(res) : null;
};

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,9 +198,8 @@ 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;
} }
} }
}, },
@ -348,6 +357,10 @@ export const usePrfStore = defineStore('prf', () => {
}; };
}); });
export enum ClientData {
Onboarded,
}
export const useClientStore = defineStore('client', () => { export const useClientStore = defineStore('client', () => {
type ScaleType = 's' | 'm' | 'l' | 'xl'; type ScaleType = 's' | 'm' | 'l' | 'xl';
const scaleFactor: Ref<ScaleType> = ref('s'); const scaleFactor: Ref<ScaleType> = ref('s');
@ -356,16 +369,22 @@ 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 verbose = ref(false);
const theme: Ref<'light' | 'dark' | 'system'> = ref('system');
const onboarded: Ref<Game[]> = ref([]);
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;
const scaleValue = computed(() => {
return _scaleValue(scaleFactor.value);
});
const setScaleFactor = async (value: ScaleType) => { const setScaleFactor = async (value: ScaleType) => {
scaleFactor.value = value; scaleFactor.value = value;
const window = getCurrentWindow(); const window = getCurrentWindow();
const w = Math.floor(scaleValue(value) * 900); const w = Math.floor(_scaleValue(value) * 900);
const h = Math.floor(scaleValue(value) * 480); const h = Math.floor(_scaleValue(value) * 600);
let size = await window.innerSize(); let size = await window.innerSize();
@ -406,6 +425,15 @@ 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;
}
if (input.onboarded) {
onboarded.value = input.onboarded;
}
await setTheme(theme.value);
} catch (e) { } catch (e) {
console.error(`Error reading client options: ${e}`); console.error(`Error reading client options: ${e}`);
} }
@ -436,6 +464,8 @@ 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,
onboarded: onboarded.value,
}) })
); );
}; };
@ -468,6 +498,26 @@ export const useClientStore = defineStore('client', () => {
await invoke('set_global_config', { field: 'verbose', 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();
};
const setOnboarded = async (game: Game) => {
onboarded.value = [...onboarded.value, game];
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) {
@ -480,13 +530,19 @@ export const useClientStore = defineStore('client', () => {
offlineMode, offlineMode,
enableAutoupdates, enableAutoupdates,
verbose, verbose,
theme,
onboarded,
timeout, timeout,
scaleModel, scaleModel,
_scaleValue,
scaleValue,
load, load,
save, save,
queueSave, queueSave,
setOfflineMode, setOfflineMode,
setAutoupdates, setAutoupdates,
setVerbose, setVerbose,
setTheme,
setOnboarded,
}; };
}); });

View File

@ -19,7 +19,7 @@ export interface Package {
icon: string; icon: string;
} | null; } | null;
js: { js: {
busy: boolean; downloading: boolean;
}; };
} }
@ -107,7 +107,10 @@ export interface BepInExConfig {
export interface Mu3IniConfig { export interface Mu3IniConfig {
audio?: 'Shared' | 'Excl6Ch' | 'Excl2Ch'; audio?: 'Shared' | 'Excl6Ch' | 'Excl2Ch';
// blacklist?: [number, number]; sample_rate: number;
blacklist?: [number, number];
gp: number;
enable_bonus_tracks: boolean;
} }
export interface OngekiButtons { export interface OngekiButtons {

View File

@ -3,11 +3,7 @@ import { Feature, Game, Package } from './types';
export const changePrimaryColor = (game: Game | null) => { export const changePrimaryColor = (game: Game | null) => {
const color = const color =
game === 'ongeki' game === 'ongeki' ? 'pink' : game === 'chunithm' ? 'yellow' : 'purple';
? 'pink'
: game === 'chunithm'
? 'yellow'
: 'bluegray';
updatePrimaryPalette({ updatePrimaryPalette({
50: `{${color}.50}`, 50: `{${color}.50}`,
@ -59,3 +55,16 @@ 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;
};
export const prettyPrint = (game: Game) => {
switch (game) {
case 'ongeki':
return 'O.N.G.E.K.I.';
case 'chunithm':
return 'CHUNITHM';
}
};

View File

@ -1,6 +1,6 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
// @ts-expect-error process is a nodejs global // @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
@ -30,4 +30,7 @@ export default defineConfig(async () => ({
ignored: ['**/rust/**'], ignored: ['**/rust/**'],
}, },
}, },
build: {
chunkSizeWarningLimit: 1024,
},
})); }));