12 Commits
0.7.0 ... 0.9.0

Author SHA1 Message Date
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
39 changed files with 408 additions and 101 deletions

View File

@ -1,3 +1,24 @@
## 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

View File

@ -1,6 +1,7 @@
# 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.
@ -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
- Segatools configuration
- Display configuration with automatic rollback
- Monitor configuration with automatic rollback
- Support for multiple configurations pointing at the same data
![Mod list](res/list.png)
![Configuration](res/cfg.png)
## Usage
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
```
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:
```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`.
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.
## Package format
- [Package format requirements](https://rainy.patafour.zip/package/create/docs/)
- 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.
Refer to [the wiki](https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Package-format).
## 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)

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

2
rust/Cargo.lock generated
View File

@ -803,7 +803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]
[[package]]

View File

@ -53,5 +53,5 @@ tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
tauri-plugin-updater = "2"
[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"

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

@ -133,6 +133,8 @@ impl AppData {
}
fn init_logger(cfg: &GlobalConfig) {
_ = std::fs::create_dir_all(util::data_dir());
let mut fern_builder;
let colors = ColoredLevelConfig::new()
.debug(Color::Green)

View File

@ -397,6 +397,13 @@ pub async fn load_segatools_ini(state: State<'_, Mutex<AppData>>, path: PathBuf)
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]
pub async fn list_platform_capabilities() -> Result<Vec<String>, ()> {
log::debug!("invoke: list_platform_capabilities");

View File

@ -16,7 +16,7 @@ use appdata::{AppData, ToggleAction};
use model::misc::Game;
use pkg::PkgKey;
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_cli::CliExt;
use tokio::{fs, sync::Mutex, try_join};
@ -66,13 +66,8 @@ pub async fn run(_args: Vec<String>) {
log::debug!("{:?} {:?} {:?}", start_arg, game_arg, name_arg);
if start_arg.occurrences > 0 {
start_immediately = true;
app_data.state.remain_open = false;
} else {
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into()))
.title("STARTLINER")
.inner_size(900f64, 480f64)
.min_inner_size(900f64, 480f64)
.build()?;
open_window(apph.clone())?;
start_immediately = false;
}
@ -157,13 +152,20 @@ pub async fn run(_args: Vec<String>) {
{
let mut appd = mtx.lock().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);
}
}
if let Err(e) = cmd::startline(apph.clone(), false).await {
log::error!("Unable to launch: {}", e);
apph.exit(1);
log::error!("unable to launch: {}", e);
_ = 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 {
@ -199,6 +201,7 @@ pub async fn run(_args: Vec<String>) {
cmd::sync_current_profile,
cmd::save_current_profile,
cmd::load_segatools_ini,
cmd::create_shortcut,
cmd::get_global_config,
cmd::set_global_config,
@ -320,5 +323,16 @@ async fn update(app: tauri::AppHandle) -> tauri_plugin_updater::Result<()> {
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(())
}

View File

@ -20,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 {
match self {
Game::Ongeki => "mu3hook.dll",
@ -88,9 +95,10 @@ pub enum StartCheckError {
}
#[derive(Default, Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct ConfigHook {
#[serde(skip_serializing_if = "Option::is_none")]
pub allnet_auth: Option<ConfigHookAuth>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aime: Option<ConfigHookAime>,
}

View File

@ -13,6 +13,14 @@ pub enum Aime {
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)]
#[serde(default)]
pub struct AMNet {
@ -32,7 +40,9 @@ impl Default for AMNet {
pub struct Segatools {
pub target: PathBuf,
pub hook: Option<PkgKey>,
#[serde(skip_serializing_if = "Option::is_none")]
pub io: Option<PkgKey>,
pub io2: IOSelection,
pub aime: Aime,
pub amfs: PathBuf,
pub option: PathBuf,
@ -51,6 +61,7 @@ impl Segatools {
Game::Chunithm => Some(PkgKey("segatools-chusanhook".to_owned()))
},
io: None,
io2: IOSelection::SegatoolsBuiltIn,
amfs: PathBuf::default(),
option: PathBuf::default(),
appdata: PathBuf::from("appdata"),

View File

@ -75,6 +75,12 @@ impl Keyboard {
// This is assumed to run in sync after the segatools module
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 {
Keyboard::Ongeki(kb) => {
if kb.enabled {
@ -95,7 +101,19 @@ impl Keyboard {
.set("mouse", if kb.use_mouse { "1" } else { "0" });
} else {
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) => {
@ -109,14 +127,21 @@ impl Keyboard {
ini.with_section(Some("io3"))
.set("test", kb.test.to_string())
.set("service", kb.svc.to_string())
.set("coin", kb.coin.to_string())
.set("ir", "0");
.set("coin", kb.coin.to_string());
} else {
ini.with_section(Some("io4"))
.set("enable", "0");
ini.with_section(Some("slider"))
.set("enable", "0");
for (i, _) in kb.cell.iter().enumerate() {
ini.with_section(Some("slider")).set(format!("cell{}", i + 1), "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

@ -1,7 +1,7 @@
use std::path::{PathBuf, Path};
use anyhow::{anyhow, Result};
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;
impl Segatools {
@ -21,8 +21,8 @@ impl Segatools {
if let Some(key) = &self.hook {
remove_if_nonpresent!(self.hook, key, None, store);
}
if let Some(key) = &self.io {
remove_if_nonpresent!(self.io, key, None, store);
if let IOSelection::Custom(key) = &self.io2 {
remove_if_nonpresent!(self.io2, key, IOSelection::default(), store);
}
match &self.aime {
Aime::AMNet(key) => remove_if_nonpresent!(self.aime, key, Aime::BuiltIn, store),
@ -134,6 +134,9 @@ impl Segatools {
if self.amnet.name.len() > 0 {
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 {
ini_out.with_section(Some("aime"))
@ -141,7 +144,7 @@ impl Segatools {
}
if game == Game::Ongeki {
if let Some(io) = &self.io {
if let IOSelection::Custom(io) = &self.io2 {
ini_out.with_section(Some("mu3io"))
.set("path", util::pkg_dir().join(io.to_string()).join("segatools").join("mu3io.dll").stringify()?);
} else {
@ -149,6 +152,44 @@ impl Segatools {
.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);

View File

@ -128,7 +128,7 @@ impl Package {
.unwrap()
.to_owned();
let status = Self::parse_status(&mft);
let status = Self::parse_status(&mft, &dir);
let dependencies = Self::sanitize_deps(mft.dependencies);
Ok(Package {
@ -221,9 +221,15 @@ impl Package {
res
}
fn parse_status(mft: &PackageManifest) -> Status {
fn parse_status(mft: &PackageManifest, dir: impl AsRef<Path>) -> Status {
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 {
let mut flags = BitFlags::default();
let mut game_dll = None;
@ -233,20 +239,14 @@ impl Package {
if id == "rainycolor" {
flags |= Feature::Mod;
} 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 module == "mu3hook" {
flags |= Feature::Mu3Hook;
} else if module == "chusanhook" {
flags |= Feature::ChusanHook;
} else if module == "amnet" {
flags |= Feature::AMNet | Feature::Aime;
} else if module == "aimeio" {
flags |= Feature::Aime;
} else if module == "mu3io" {
flags |= Feature::Mu3IO;
} else if module == "chuniio" {
flags |= Feature::ChuniIO;
flags |= Self::parse_segatools_module(&module);
}
if let Some(serde_json::Value::Array(arr)) = installer.get("module") {
for elem in arr {
if let serde_json::Value::String(module) = elem {
flags |= Self::parse_segatools_module(module);
}
}
}
} else if id == "native_mod" {
@ -274,4 +274,16 @@ impl Package {
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,6 +1,6 @@
pub use types::{Profile, ProfileData, ProfileMeta, ProfilePaths, StartPayload};
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 std::process::Stdio;
use crate::model::profile::BepInEx;
@ -59,8 +59,14 @@ impl Profile {
log::debug!("{:?}", data);
// Backwards compat
if game == Game::Ongeki && data.keyboard.is_none() {
data.keyboard = Some(Keyboard::Ongeki(OngekiKeyboard::default()));
if game == Game::Ongeki {
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 data.keyboard.is_none() {
@ -112,7 +118,7 @@ impl Profile {
if let Some(hook) = &self.data.sgt.hook {
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());
}
if let Aime::AMNet(aime) = &self.data.sgt.aime {

View File

@ -173,4 +173,44 @@ pub async fn remove_dir_all(path: impl AsRef<Path>) -> Result<()> {
} 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",
"productName": "STARTLINER",
"version": "0.7.0",
"version": "0.9.0",
"identifier": "zip.patafour.startliner",
"build": {
"beforeDevCommand": "bun run dev",
@ -66,7 +66,7 @@
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/slow.png", "icons/slow.ico"],
"icon": ["icons/icon.png", "icons/icon.ico"],
"createUpdaterArtifacts": true
}
}

View File

@ -28,7 +28,9 @@ import {
usePrfStore,
} from '../stores';
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 prf = usePrfStore();
@ -80,6 +82,12 @@ listen<{ message: string; header: string }>('invoke-error', (event) => {
errorMessage.value = event.payload.message;
errorHeader.value = event.payload.header;
});
listen<string>('launch-error', (event) => {
errorVisible.value = true;
errorMessage.value = event.payload;
errorHeader.value = 'Launch error';
});
</script>
<template>
@ -122,7 +130,7 @@ listen<{ message: string; header: string }>('invoke-error', (event) => {
{{ errorMessage }}
<Button
class="m-auto"
label="A sad state of affairs"
label="OK"
@click="errorVisible = false"
/>
</div>
@ -154,7 +162,10 @@ listen<{ message: string; header: string }>('invoke-error', (event) => {
><div class="pi pi-box"></div
></Tab>
<Tab
v-if="prf.current?.meta.game === 'chunithm'"
v-if="
prf.current?.meta.game === 'chunithm' &&
prf.current.data.sgt.target.length > 0
"
value="patches"
><div class="pi pi-ticket"></div
></Tab>
@ -255,6 +266,20 @@ listen<{ message: string; header: string }>('invoke-error', (event) => {
<div v-else>
Patches require <code>mempatcher</code> to be installed
and enabled.
<div>
<Button
label="Add mempatcher"
icon="pi pi-plus"
class="mt-3"
@click="
() =>
pkg.installFromKey(
'mempatcher-mempatcher'
)
"
/>
</div>
</div>
</TabPanel>
<TabPanel value="info">

View File

@ -26,19 +26,32 @@ const model = computed({
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>
<template>
<div class="flex items-center">
<ModTitlecard show-version show-icon show-description :pkg="pkg" />
<UpdateButton :pkg="pkg" />
<ToggleSwitch
v-if="hasFeature(pkg, Feature.Mod)"
class="scale-[1.33] shrink-0"
inputId="switch"
:disabled="pkg!.loc!.status === 'Unsupported'"
v-model="model"
/>
<span
v-tooltip="
unsupported &&
'This package is currently incompatible with STARTLINER.'
"
>
<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" />
<Button
rounded

View File

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

View File

@ -2,6 +2,7 @@
import { ref } from 'vue';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import { useConfirm } from 'primevue/useconfirm';
import * as path from '@tauri-apps/api/path';
import { invoke } from '../invoke';
import { useGeneralStore, usePrfStore } from '../stores';
@ -9,6 +10,8 @@ import { ProfileMeta } from '../types';
const general = useGeneralStore();
const prf = usePrfStore();
const confirmDialog = useConfirm();
const isEditing = ref(false);
const props = defineProps({
@ -54,6 +57,14 @@ const deleteProfile = async () => {
await prf.reloadList();
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>
<template>
@ -90,7 +101,7 @@ const deleteProfile = async () => {
size="small"
class="self-center ml-2"
style="width: 2rem; height: 2rem"
@click="deleteProfile"
@click="promptDeleteProfile"
/>
<Button
rounded

View File

@ -85,6 +85,15 @@ listen('launch-end', () => {
getCurrentWindow().setFocus();
});
const createShortcut = async () => {
const current = prf.current;
if (current !== null) {
await invoke('create_shortcut', {
profileMeta: current.meta,
});
}
};
const menuItems = [
{
label: 'Refresh and start',
@ -96,6 +105,11 @@ const menuItems = [
icon: 'pi pi-exclamation-circle',
command: async () => await startline(true, false),
},
{
label: 'Create desktop shortcut',
icon: 'pi pi-link',
command: createShortcut,
},
];
const menu = ref();

View File

@ -84,6 +84,7 @@ load();
</OptionRow>
<OptionRow
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'"
>
<InputText

View File

@ -11,7 +11,10 @@ const prf = usePrfStore();
<template>
<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" />
</OptionRow>
<OptionRow
@ -30,6 +33,7 @@ const prf = usePrfStore();
/>
</OptionRow>
<div
v-if="prf.current!.data.keyboard!.data.enabled"
:style="`position: relative; height: ${prf.current!.data.keyboard!.game === 'Ongeki' ? 400 : 250}px`"
>
<div

View File

@ -126,17 +126,28 @@ const checkSegatoolsIni = async (target: string) => {
</OptionRow>
<OptionRow
:title="names.io"
v-if="prf.current?.meta.game === 'ongeki'"
tooltip="IO plugins can be downloaded from the package store."
>
<Select
v-model="prf.current!.data.sgt.io"
placeholder="segatools built-in"
v-model="prf.current!.data.sgt.io2"
:options="[
{ title: 'segatools built-in', value: null },
...pkgs.byFeature(Feature.Mu3IO).map((p) => {
return { title: pkgKey(p), value: pkgKey(p) };
}),
{ title: 'native io4', value: 'hardware' },
{
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-value="value"

View File

@ -34,6 +34,15 @@ const verboseModel = computed({
await client.setVerbose(value);
},
});
const themeModel = computed({
get() {
return client.theme;
},
async set(value: 'light' | 'dark' | 'system') {
await client.setTheme(value);
},
});
</script>
<template>
@ -67,5 +76,18 @@ const verboseModel = computed({
>
<ToggleSwitch v-model="verboseModel" />
</OptionRow>
<OptionRow title="Light 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>
</template>

View File

@ -17,6 +17,9 @@ app.use(pinia);
app.use(PrimeVue, {
theme: {
preset: Preset,
options: {
darkModeSelector: '.use-dark-mode',
},
},
});
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 { invoke, invoke_nopopup } from './invoke';
import { Dirs, Feature, Game, Package, Profile, ProfileMeta } from './types';
import { changePrimaryColor, hasFeature, pkgKey } from './util';
import {
changePrimaryColor,
hasFeature,
pkgKey,
shouldPreferDark,
} from './util';
type InstallStatus = {
pkg: string;
@ -329,7 +334,7 @@ export const usePrfStore = defineStore('prf', () => {
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => invoke('save_current_profile'), 2000);
timeout = setTimeout(() => invoke('save_current_profile'), 600);
}
});
@ -356,6 +361,7 @@ export const useClientStore = defineStore('client', () => {
const offlineMode = ref(false);
const enableAutoupdates = ref(true);
const verbose = ref(false);
const theme: Ref<'light' | 'dark' | 'system'> = ref('system');
const scaleValue = (value: ScaleType) =>
value === 's' ? 1 : value === 'm' ? 1.25 : value === 'l' ? 1.5 : 2;
@ -406,6 +412,11 @@ export const useClientStore = defineStore('client', () => {
if (input.scaleFactor) {
await setScaleFactor(input.scaleFactor);
}
if (input.theme) {
theme.value = input.theme;
}
await setTheme(theme.value);
} catch (e) {
console.error(`Error reading client options: ${e}`);
}
@ -436,6 +447,7 @@ export const useClientStore = defineStore('client', () => {
w: Math.floor(size.width),
h: Math.floor(size.height),
},
theme: theme.value,
})
);
};
@ -468,6 +480,21 @@ export const useClientStore = defineStore('client', () => {
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 }) => {
// For whatever reason this is 0 when minimized
if (payload.width > 0) {
@ -480,6 +507,7 @@ export const useClientStore = defineStore('client', () => {
offlineMode,
enableAutoupdates,
verbose,
theme,
timeout,
scaleModel,
load,
@ -488,5 +516,6 @@ export const useClientStore = defineStore('client', () => {
setOfflineMode,
setAutoupdates,
setVerbose,
setTheme,
};
});

View File

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

View File

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