Add profiles

This commit is contained in:
NotThorny
2025-11-14 02:09:10 -07:00
parent d2b8124877
commit 6f2be3c5a5
13 changed files with 260 additions and 27 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "cultivation", "name": "cultivation",
"version": "1.6.3", "version": "1.7.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@tauri-apps/api": "^1.0.0-rc.5", "@tauri-apps/api": "^1.0.0-rc.5",

View File

@@ -1,7 +1,7 @@
{ {
"lang_name": "English", "lang_name": "English",
"main": { "main": {
"title": "Cultivation", "title": "Cultivation: Thorny Edition",
"launch_button": "Launch", "launch_button": "Launch",
"gc_enable": "Connect to Grasscutter", "gc_enable": "Connect to Grasscutter",
"https_enable": "Use HTTPS", "https_enable": "Use HTTPS",
@@ -39,7 +39,9 @@
"web_cache": "Delete webCaches folder", "web_cache": "Delete webCaches folder",
"launch_args": "Launch Args", "launch_args": "Launch Args",
"offline_mode": "Offline Mode", "offline_mode": "Offline Mode",
"fix_res": "Fix Login Timeout" "fix_res": "Fix Login Timeout",
"show_version": "Show game version in buttons",
"save_profile": "Save profile"
}, },
"downloads": { "downloads": {
"grasscutter_fullbuild": "Download Grasscutter 4.0 All-in-One", "grasscutter_fullbuild": "Download Grasscutter 4.0 All-in-One",

View File

@@ -30,20 +30,33 @@ pub struct Configuration {
pub redirect_more: Option<bool>, pub redirect_more: Option<bool>,
pub launch_args: Option<String>, pub launch_args: Option<String>,
pub offline_mode: Option<bool>, pub offline_mode: Option<bool>,
pub show_version: Option<bool>,
pub profile: Option<String>,
} }
pub fn config_path() -> PathBuf { pub fn config_path(profile: String) -> PathBuf {
let mut path = tauri::api::path::data_dir().unwrap(); let mut path = tauri::api::path::data_dir().unwrap();
path.push("cultivation"); path.push("cultivation");
path.push("configuration.json"); if profile.as_str() == "default" {
path.push("configuration.json");
} else {
path.push("profile");
path.push(profile);
}
path path
} }
pub fn get_config() -> Configuration { pub fn get_config(profile_name: String) -> Configuration {
let path = config_path(); let path = config_path(profile_name);
let config = std::fs::read_to_string(path).unwrap_or("{}".to_string()); let config = std::fs::read_to_string(path).unwrap_or("{}".to_string());
let config: Configuration = serde_json::from_str(&config).unwrap_or_default(); let config: Configuration = serde_json::from_str(&config).unwrap_or_default();
let default = String::from("default");
let prof = config.profile.as_ref().unwrap_or(&default);
if *prof != String::from("default") {
get_config(prof.clone());
}
config config
} }

View File

@@ -101,7 +101,7 @@ async fn parse_args(inp: &Vec<String>) -> Result<Args, ArgsError> {
args.parse(inp).unwrap(); args.parse(inp).unwrap();
let config = config::get_config(); let config = config::get_config(String::from("default"));
if args.value_of("help")? { if args.value_of("help")? {
println!("{}", args.full_usage()); println!("{}", args.full_usage());
@@ -207,6 +207,7 @@ fn main() -> Result<(), ArgsError> {
is_grasscutter_running, is_grasscutter_running,
restart_grasscutter, restart_grasscutter,
get_theme_list, get_theme_list,
get_profile_list,
system_helpers::run_command, system_helpers::run_command,
system_helpers::run_program, system_helpers::run_program,
system_helpers::run_program_args, system_helpers::run_program_args,
@@ -536,3 +537,20 @@ async fn get_theme_list(data_dir: String) -> Vec<HashMap<String, String>> {
themes themes
} }
#[tauri::command]
async fn get_profile_list(data_dir: String) -> Vec<String> {
let profile_loc = format!("{}/profiles", data_dir);
// Ensure folder exists
if !std::path::Path::new(&profile_loc).exists() {
std::fs::create_dir_all(&profile_loc).unwrap();
}
let mut p_list = Vec::new();
for entry in std::fs::read_dir(&profile_loc).unwrap() {
p_list.push(entry.unwrap().file_name().into_string().unwrap());
}
p_list
}

View File

@@ -349,7 +349,7 @@ pub async fn unpatch_game() -> bool {
} }
pub async fn get_game_rsa_path() -> Option<String> { pub async fn get_game_rsa_path() -> Option<String> {
let config = config::get_config(); let config = config::get_config(String::from("default"));
config.game_install_path.as_ref()?; config.game_install_path.as_ref()?;

View File

@@ -7,7 +7,7 @@
}, },
"package": { "package": {
"productName": "Cultivation", "productName": "Cultivation",
"version": "1.6.3" "version": "1.7.0"
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {

View File

@@ -170,6 +170,8 @@ export class Main extends React.Component<IProps, IState> {
} }
// Ensure old configs are updated to use RSA // Ensure old configs are updated to use RSA
const updatedProfile = await getConfigOption('profile')
await setConfigOption('profile', updatedProfile)
const updatedConfig = await getConfigOption('patch_rsa') const updatedConfig = await getConfigOption('patch_rsa')
await setConfigOption('patch_rsa', updatedConfig) await setConfigOption('patch_rsa', updatedConfig)

View File

@@ -14,7 +14,7 @@
} }
#playButton > div { #playButton > div {
margin-bottom: 6px; margin-bottom: 2px;
} }
#playButton .BigButton { #playButton .BigButton {
@@ -26,10 +26,27 @@
#serverControls { #serverControls {
color: white; color: white;
display: flex;
justify-content: space-between;
flex-direction: row;
text-shadow: 1px 1px 8px black; text-shadow: 1px 1px 8px black;
} }
#menuOptionsContainerProfiles {
justify-self: right;
align-self: right;
padding-left: 8px;
width: min-content;
}
#serverControls select {
width: 150px;
height: 30px;
border: none;
border-bottom: 2px solid #cecece;
border-radius: 6px;
}
.BottomSection .CheckboxDisplay { .BottomSection .CheckboxDisplay {
border-color: #c5c5c5; border-color: #c5c5c5;
background: #fff; background: #fff;

View File

@@ -3,7 +3,7 @@ import Checkbox from './common/Checkbox'
import BigButton from './common/BigButton' import BigButton from './common/BigButton'
import TextInput from './common/TextInput' import TextInput from './common/TextInput'
import HelpButton from './common/HelpButton' import HelpButton from './common/HelpButton'
import { getConfig, saveConfig, setConfigOption } from '../../utils/configuration' import { getConfig, saveConfig, setConfigOption, setProfileOption } from '../../utils/configuration'
import { translate } from '../../utils/language' import { translate } from '../../utils/language'
import { invoke } from '@tauri-apps/api/tauri' import { invoke } from '@tauri-apps/api/tauri'
@@ -43,6 +43,8 @@ interface IState {
migotoSet: boolean migotoSet: boolean
unElevated: boolean unElevated: boolean
profile: string
profiles: string[]
} }
export default class ServerLaunchSection extends React.Component<IProps, IState> { export default class ServerLaunchSection extends React.Component<IProps, IState> {
@@ -66,6 +68,8 @@ export default class ServerLaunchSection extends React.Component<IProps, IState>
akebiSet: false, akebiSet: false,
migotoSet: false, migotoSet: false,
unElevated: false, unElevated: false,
profile: 'default',
profiles: ['default'],
} }
this.toggleGrasscutter = this.toggleGrasscutter.bind(this) this.toggleGrasscutter = this.toggleGrasscutter.bind(this)
@@ -75,6 +79,7 @@ export default class ServerLaunchSection extends React.Component<IProps, IState>
this.toggleHttps = this.toggleHttps.bind(this) this.toggleHttps = this.toggleHttps.bind(this)
this.launchServer = this.launchServer.bind(this) this.launchServer = this.launchServer.bind(this)
this.setButtonLabel = this.setButtonLabel.bind(this) this.setButtonLabel = this.setButtonLabel.bind(this)
this.setProfile = this.setProfile.bind(this)
listen('start_grasscutter', async () => { listen('start_grasscutter', async () => {
this.launchServer() this.launchServer()
@@ -102,6 +107,8 @@ export default class ServerLaunchSection extends React.Component<IProps, IState>
akebiSet: config.akebi_path !== '', akebiSet: config.akebi_path !== '',
migotoSet: config.migoto_path !== '', migotoSet: config.migoto_path !== '',
unElevated: config.un_elevated || false, unElevated: config.un_elevated || false,
profile: config.profile || 'default',
profiles: (await this.getProfileList()).map((t) => t),
}) })
this.setButtonLabel() this.setButtonLabel()
@@ -393,6 +400,25 @@ export default class ServerLaunchSection extends React.Component<IProps, IState>
} }
} }
async setProfile(value: string) {
this.setState({ profile: value })
await setProfileOption('profile', value)
window.location.reload()
}
async getProfileList() {
const profiles: string[] = await invoke('get_profile_list', {
dataDir: `${await dataDir()}/cultivation`,
})
const list = ['default']
profiles.forEach((t) => {
list.push(t.split('.json')[0])
})
return list
}
render() { render() {
return ( return (
<div id="playButton"> <div id="playButton">
@@ -403,6 +429,23 @@ export default class ServerLaunchSection extends React.Component<IProps, IState>
onChange={this.toggleGrasscutter} onChange={this.toggleGrasscutter}
checked={this.state.grasscutterEnabled} checked={this.state.grasscutterEnabled}
/> />
<div className="OptionSection" id="menuOptionsContainerProfiles">
<div className="OptionValue" id="menuOptionsSelectProfiles">
<select
value={this.state.profile}
id="menuOptionsSelectMenuProfiles"
onChange={(event) => {
this.setProfile(event.target.value)
}}
>
{this.state.profiles.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
</div>
</div> </div>
{this.state.grasscutterEnabled && ( {this.state.grasscutterEnabled && (

View File

@@ -20,3 +20,8 @@
.OptionSection .HelpButton img { .OptionSection .HelpButton img {
filter: invert(0%) sepia(91%) saturate(7464%) hue-rotate(101deg) brightness(0%) contrast(107%); filter: invert(0%) sepia(91%) saturate(7464%) hue-rotate(101deg) brightness(0%) contrast(107%);
} }
input#profile_name {
height: 25px;
border-radius: 6px;
}

View File

@@ -4,7 +4,14 @@ import { dataDir } from '@tauri-apps/api/path'
import DirInput from '../common/DirInput' import DirInput from '../common/DirInput'
import Menu from './Menu' import Menu from './Menu'
import Tr, { getLanguages } from '../../../utils/language' import Tr, { getLanguages } from '../../../utils/language'
import { setConfigOption, getConfig, getConfigOption, Configuration } from '../../../utils/configuration' import {
setConfigOption,
getConfig,
getConfigOption,
Configuration,
saveNewProfileConfig,
setProfileOption,
} from '../../../utils/configuration'
import Checkbox from '../common/Checkbox' import Checkbox from '../common/Checkbox'
import Divider from './Divider' import Divider from './Divider'
import { getThemeList } from '../../../utils/themes' import { getThemeList } from '../../../utils/themes'
@@ -57,6 +64,8 @@ interface IState {
launch_args: string launch_args: string
offline_mode: boolean offline_mode: boolean
newer_game: boolean newer_game: boolean
show_version: boolean
profile_name: string
// Linux stuff // Linux stuff
grasscutter_elevation: string grasscutter_elevation: string
@@ -95,6 +104,8 @@ export default class Options extends React.Component<IProps, IState> {
launch_args: '', launch_args: '',
offline_mode: false, offline_mode: false,
newer_game: false, newer_game: false,
show_version: true,
profile_name: '',
// Linux stuff // Linux stuff
grasscutter_elevation: GrasscutterElevation.None, grasscutter_elevation: GrasscutterElevation.None,
@@ -118,6 +129,9 @@ export default class Options extends React.Component<IProps, IState> {
this.addMigotoDelay = this.addMigotoDelay.bind(this) this.addMigotoDelay = this.addMigotoDelay.bind(this)
this.toggleUnElevatedGame = this.toggleUnElevatedGame.bind(this) this.toggleUnElevatedGame = this.toggleUnElevatedGame.bind(this)
this.setLaunchArgs = this.setLaunchArgs.bind(this) this.setLaunchArgs = this.setLaunchArgs.bind(this)
this.toggleShowVersion = this.toggleShowVersion.bind(this)
this.setProfileName = this.setProfileName.bind(this)
this.saveProfile = this.saveProfile.bind(this)
} }
async componentDidMount() { async componentDidMount() {
@@ -156,6 +170,7 @@ export default class Options extends React.Component<IProps, IState> {
launch_args: config.launch_args, launch_args: config.launch_args,
offline_mode: config.offline_mode || false, offline_mode: config.offline_mode || false,
newer_game: config.newer_game || false, newer_game: config.newer_game || false,
show_version: config.show_version || false,
// Linux stuff // Linux stuff
grasscutter_elevation: config.grasscutter_elevation || GrasscutterElevation.None, grasscutter_elevation: config.grasscutter_elevation || GrasscutterElevation.None,
@@ -350,6 +365,17 @@ export default class Options extends React.Component<IProps, IState> {
}) })
} }
async toggleShowVersion() {
const changedVal = !(await getConfigOption('show_version'))
await setConfigOption('show_version', changedVal)
this.setState({
show_version: changedVal,
})
emit('set_config', { show_version: changedVal })
}
async setGCElevation(value: string) { async setGCElevation(value: string) {
setConfigOption('grasscutter_elevation', value) setConfigOption('grasscutter_elevation', value)
@@ -487,6 +513,28 @@ export default class Options extends React.Component<IProps, IState> {
}) })
} }
setProfileName(text: string) {
this.setState({
profile_name: text,
})
}
async saveProfile() {
if (this.state.profile_name == '') {
alert('No name set')
return
}
const config = await getConfig()
await saveNewProfileConfig(config, this.state.profile_name)
await setProfileOption('profile', this.state.profile_name)
this.setState({
profile_name: '',
})
window.location.reload()
}
render() { render() {
return ( return (
<Menu closeFn={this.props.closeFn} className="Options" heading="Options"> <Menu closeFn={this.props.closeFn} className="Options" heading="Options">
@@ -659,6 +707,24 @@ export default class Options extends React.Component<IProps, IState> {
<Divider /> <Divider />
<div className="OptionSection" id="profileConfigContainer">
<div className="OptionLabel" id="menuOptionsLabelProfile">
<Tr text="options.save_profile" />
</div>
<TextInput
id="profile_name"
key="profile_name"
placeholder={'Profile name...'}
onChange={this.setProfileName}
initalValue={''}
/>
<BigButton onClick={this.saveProfile} id="saveProfile">
{'Save'}
</BigButton>
</div>
<Divider />
<div className="OptionSection" id="menuOptionsContainerGCWGame"> <div className="OptionSection" id="menuOptionsContainerGCWGame">
<div className="OptionLabel" id="menuOptionsLabelGCWDame"> <div className="OptionLabel" id="menuOptionsLabelGCWDame">
<Tr text="options.grasscutter_with_game" /> <Tr text="options.grasscutter_with_game" />
@@ -711,19 +777,14 @@ export default class Options extends React.Component<IProps, IState> {
/> />
</div> </div>
</div> </div>
<div className="OptionSection" id="menuOptionsContainerShowVer">
{/* <div className="OptionSection" id="menuOptionsContainerNewerGame"> <div className="OptionLabel" id="menuOptionsLabelShowVer">
<div className="OptionLabel" id="menuOptionsLabelNewerGame"> <Tr text="options.show_version" />
<Tr text="Patch Mihoyonet" />
</div> </div>
<div className="OptionValue" id="menuOptionsCheckboxNewerGame"> <div className="OptionValue" id="menuOptionsButtonShowVer">
<Checkbox <Checkbox onChange={() => this.toggleShowVersion()} checked={this.state.show_version} id="showVer" />
onChange={() => this.toggleOption('newer_game')}
checked={this.state?.newer_game}
id="newerGame"
/>
</div> </div>
</div> */} </div>
<Divider /> <Divider />

View File

@@ -143,7 +143,7 @@ export default class NewsSection extends React.Component<IProps, IState> {
<tr> <tr>
<td> <td>
Work in progress area! These numbers may be outdated, so please do not use them as reference. Latest Work in progress area! These numbers may be outdated, so please do not use them as reference. Latest
version: Grasscutter 1.7.4 - Cultivation 1.6.3 version: Grasscutter 1.7.4 (4.0) / Forks (6.1) - Cultivation 1.7.0
</td> </td>
</tr> </tr>
) )

View File

@@ -31,6 +31,8 @@ let defaultConfig: Configuration
launch_args: '', launch_args: '',
offline_mode: false, offline_mode: false,
newer_game: false, newer_game: false,
show_version: true,
profile: 'default',
// Linux stuff // Linux stuff
grasscutter_elevation: 'None', grasscutter_elevation: 'None',
@@ -68,6 +70,8 @@ export interface Configuration {
launch_args: string launch_args: string
offline_mode: boolean offline_mode: boolean
newer_game: boolean newer_game: boolean
show_version: boolean
profile: string
// Linux stuff // Linux stuff
grasscutter_elevation: string grasscutter_elevation: string
@@ -90,6 +94,15 @@ export async function setConfigOption<K extends keyof Configuration>(key: K, val
await saveConfig(<Configuration>config) await saveConfig(<Configuration>config)
} }
export async function setProfileOption<K extends keyof Configuration>(key: K, value: Configuration[K]): Promise<void> {
const config = await getConfig()
config[key] = value
const defaultConfig = await getDefaultConfig()
defaultConfig[key] = value
await saveProfileConfig(<Configuration>defaultConfig)
}
export async function getConfigOption<K extends keyof Configuration>(key: K): Promise<Configuration[K]> { export async function getConfigOption<K extends keyof Configuration>(key: K): Promise<Configuration[K]> {
const config = await getConfig() const config = await getConfig()
const defaults = defaultConfig const defaults = defaultConfig
@@ -113,16 +126,69 @@ export async function getConfig() {
return parsed return parsed
} }
export async function getDefaultConfig() {
const raw = await readDefaultConfigFile()
let parsed: Configuration = defaultConfig
try {
parsed = <Configuration>JSON.parse(raw)
} catch (e) {
// We could not open the file
console.log(e)
}
return parsed
}
export async function saveConfig(obj: Configuration) { export async function saveConfig(obj: Configuration) {
const raw = JSON.stringify(obj) const raw = JSON.stringify(obj)
await writeConfigFile(raw) await writeConfigFile(raw)
} }
export async function saveProfileConfig(obj: Configuration) {
const local = await dataDir()
const raw = JSON.stringify(obj)
const prevPath = configFilePath
configFilePath = local + 'cultivation/configuration.json'
await writeConfigFile(raw)
configFilePath = prevPath
}
export async function saveNewProfileConfig(obj: Configuration, prof: string) {
obj['profile'] = prof
const local = await dataDir()
const raw = JSON.stringify(obj)
configFilePath = local + 'cultivation/profiles/' + obj['profile'] + '.json'
const file: fs.FsTextFileOption = {
path: configFilePath,
contents: raw,
}
await fs.writeFile(file)
}
async function readConfigFile() { async function readConfigFile() {
const local = await dataDir() const local = await dataDir()
if (!configFilePath) configFilePath = local + 'cultivation/configuration.json' if (!configFilePath) {
configFilePath = local + 'cultivation/configuration.json'
}
// Read existing config to get profile name
const raw = await fs.readTextFile(configFilePath)
const cfg = <Configuration>JSON.parse(raw)
// Switch file to config-specified profile
let pf = cfg['profile']
if (pf != 'default') {
const pff = pf
pf = 'profiles/' + pff + '.json'
} else {
pf = 'configuration.json'
}
configFilePath = local + 'cultivation/' + pf
// Ensure Cultivation dir exists // Ensure Cultivation dir exists
const dirs = await fs.readDir(local) const dirs = await fs.readDir(local)
@@ -157,6 +223,12 @@ async function readConfigFile() {
return await fs.readTextFile(configFilePath) return await fs.readTextFile(configFilePath)
} }
async function readDefaultConfigFile() {
const local = await dataDir()
configFilePath = local + 'cultivation/configuration.json'
return await fs.readTextFile(configFilePath)
}
async function writeConfigFile(raw: string) { async function writeConfigFile(raw: string) {
// All external config functions call readConfigFile, which ensure files exists // All external config functions call readConfigFile, which ensure files exists
await fs.writeFile({ await fs.writeFile({