1 Commits

Author SHA1 Message Date
f478ad9216 feat: add package creator 2025-04-28 16:44:04 +00:00
11 changed files with 302 additions and 50 deletions

View File

@ -1,3 +1,8 @@
## 0.17.0
- Added a package creation prompt
- Added a default package icon
## 0.16.0 ## 0.16.0
- Fixed the clear cache button not working - Fixed the clear cache button not working

BIN
public/no-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -1,11 +1,12 @@
use ini::Ini; use ini::Ini;
use log; use log;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::PathBuf; use std::path::PathBuf;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::fs; use tokio::fs;
use tauri::{AppHandle, Manager, State}; use tauri::{AppHandle, Manager, State};
use crate::model::config::GlobalConfigField; use crate::model::config::GlobalConfigField;
use crate::model::local::PackageManifest;
use crate::model::misc::Game; use crate::model::misc::Game;
use crate::model::patch::Patch; use crate::model::patch::Patch;
use crate::modules::package::prepare_dlls; use crate::modules::package::prepare_dlls;
@ -166,6 +167,65 @@ pub async fn toggle_package(state: State<'_, tokio::sync::Mutex<AppData>>, key:
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command]
pub async fn create_package(
name: String,
description: String,
website: String,
r#type: String,
games: Vec<Game>
) -> Result<(), String> {
log::debug!("invoke: create_package");
let dir = util::pkg_dir_of("local", &name);
if dir.exists() {
return Err("Package already exists".to_owned());
}
let mut installers = Vec::new();
if r#type == "segatools" {
let mut map = BTreeMap::new();
map.insert(
"identifier".to_owned(),
serde_json::Value::String("segatools".to_owned())
);
installers.push(map);
} else if r#type == "native" {
let mut map = BTreeMap::new();
map.insert(
"identifier".to_owned(),
serde_json::Value::String("native_mod".to_owned())
);
map.insert(
"dll-game".to_owned(),
serde_json::Value::String("some.dll".to_owned())
);
map.insert(
"dll-amdaemon".to_owned(),
serde_json::Value::String("another.dll".to_owned())
);
installers.push(map);
}
let manifest = PackageManifest {
name,
version_number: "1.0.0".to_owned(),
description,
website_url: website,
dependencies: BTreeSet::new(),
installers,
games: Some(games)
};
std::fs::create_dir(&dir).map_err(|e| e.to_string())?;
let json = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
std::fs::write(dir.join("manifest.json"), json).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command] #[tauri::command]
pub async fn reload_all_packages(state: State<'_, tokio::sync::Mutex<AppData>>) -> Result<(), String> { pub async fn reload_all_packages(state: State<'_, tokio::sync::Mutex<AppData>>) -> Result<(), String> {
log::debug!("invoke: reload_all_packages"); log::debug!("invoke: reload_all_packages");

View File

@ -193,6 +193,7 @@ pub async fn run(_args: Vec<String>) {
cmd::install_package, cmd::install_package,
cmd::delete_package, cmd::delete_package,
cmd::toggle_package, cmd::toggle_package,
cmd::create_package,
cmd::list_profiles, cmd::list_profiles,
cmd::init_profile, cmd::init_profile,

View File

@ -6,10 +6,11 @@ use super::misc::Game;
// manifest.json // manifest.json
#[derive(Deserialize)] #[derive(Serialize, Deserialize)]
pub struct PackageManifest { pub struct PackageManifest {
pub name: String, pub name: String,
pub version_number: String, pub version_number: String,
pub website_url: String,
pub description: String, pub description: String,
pub dependencies: BTreeSet<PkgKeyVersion>, pub dependencies: BTreeSet<PkgKeyVersion>,

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

View File

@ -1,12 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { Ref, computed, ref } from 'vue'; import { Ref, computed, ref } from 'vue';
import Button from 'primevue/button'; import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import Fieldset from 'primevue/fieldset'; import Fieldset from 'primevue/fieldset';
import InputText from 'primevue/inputtext';
import SelectButton from 'primevue/selectbutton';
import ToggleSwitch from 'primevue/toggleswitch';
import ModListEntry from './ModListEntry.vue'; import ModListEntry from './ModListEntry.vue';
import ModTitlecard from './ModTitlecard.vue'; import ModTitlecard from './ModTitlecard.vue';
import { invoke } from '../invoke'; import { invoke } from '../invoke';
import { usePkgStore, usePrfStore } from '../stores'; import { useClientStore, usePkgStore, usePrfStore } from '../stores';
import { Package } from '../types'; import { Game, Package } from '../types';
import { pkgKey } from '../util'; import { pkgKey } from '../util';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@ -17,38 +21,47 @@ const props = defineProps({
}); });
const pkgs = usePkgStore(); const pkgs = usePkgStore();
const client = useClientStore();
const prf = usePrfStore(); const prf = usePrfStore();
const empty = ref(false);
const gameSublist: Ref<string[]> = ref([]); const gameSublist: Ref<string[]> = ref([]);
invoke('get_game_packages', { const loadPackages = () => {
game: prf.current?.meta.game ?? null, invoke('get_game_packages', {
}).then((list) => { game: prf.current?.meta.game ?? null,
gameSublist.value = list as string[]; }).then((list) => {
}); gameSublist.value = list as string[];
});
};
loadPackages();
const group = computed(() => { const group = computed(() => {
const res = Object.assign( const grouped = Object.groupBy(
{}, pkgs.allLocal
Object.groupBy( .filter((p) => gameSublist.value.includes(pkgKey(p)))
pkgs.allLocal .filter(
.filter((p) => gameSublist.value.includes(pkgKey(p))) (p) =>
.filter( props.search === undefined ||
(p) => p.name.toLowerCase().includes(props.search.toLowerCase()) ||
props.search === undefined || p.namespace
p.name .toLowerCase()
.toLowerCase() .includes(props.search.toLowerCase())
.includes(props.search.toLowerCase()) || ),
p.namespace ({ namespace }) => namespace
.toLowerCase()
.includes(props.search.toLowerCase())
)
.sort((p1, p2) => p1.namespace.localeCompare(p2.namespace))
.sort((p1, p2) => p1.name.localeCompare(p2.name)),
({ namespace }) => namespace
)
); );
empty.value = Object.keys(res).length === 0; if (!('local' in grouped)) {
grouped['local'] = [];
}
const res: [string, Package[]][] = [];
for (const [k, v] of Object.entries(grouped)) {
if (v !== undefined) {
res.push([k, v]);
}
}
res.sort((a, b) => {
return a[0] === 'local' ? -1000000 : `${a[0]}`.localeCompare(`${b[0]}`);
});
return res; return res;
}); });
@ -56,11 +69,132 @@ const missing = computed(() => {
return prf.current?.data.mods.filter((m) => !pkgs.hasLocal(m)) ?? []; return prf.current?.data.mods.filter((m) => !pkgs.hasLocal(m)) ?? [];
}); });
const emptyVisible = ref(false); const dialogVisible = ref(false);
setTimeout(() => (emptyVisible.value = true), 500);
const defaultModel = {
name: '',
description: '',
website: '',
type: 'rainy',
games: [] as string[],
};
const creatorModel = ref({ ...defaultModel });
const gameModel = (game: Game) =>
computed({
get() {
return (creatorModel.value.games as string[]).includes(game);
},
set(v: boolean) {
creatorModel.value.games = creatorModel.value.games.filter(
(g) => g !== game
);
if (v) {
creatorModel.value.games.push(game);
}
},
});
const gameModelOngeki = gameModel('ongeki');
const gameModelChunithm = gameModel('chunithm');
</script> </script>
<template> <template>
<Dialog
modal
:visible="dialogVisible"
:closable="false"
:header="t('creator.header')"
:style="{ width: '500px', scale: client.scaleValue }"
class="creation-dialog"
>
<div style="position: absolute; left: 250px; top: 25px">
<a
href="https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Package-format"
target="_blank"
style="text-decoration: underline"
class="self-center"
>{{ t('creator.packageFormat') }}</a
>
</div>
<h2>{{ t('creator.basic') }}</h2>
<div class="flex flex-col gap-3">
<InputText
size="small"
style="width: 100%"
:placeholder="t('creator.name')"
v-model="creatorModel.name"
/>
<InputText
size="small"
style="width: 100%"
:placeholder="t('creator.description')"
v-model="creatorModel.description"
/>
<InputText
size="small"
style="width: 100%"
:placeholder="t('creator.website')"
v-model="creatorModel.website"
/>
</div>
<h2>{{ t('creator.type') }}</h2>
<div class="flex flex-col items-center">
<SelectButton
:options="[
{ title: t('creator.rainy'), value: 'rainy' },
{ title: t('creator.native'), value: 'native' },
{ title: t('creator.segatools'), value: 'segatools' },
]"
v-model="creatorModel.type"
:allow-empty="false"
option-label="title"
option-value="value"
/>
</div>
<h2>{{ t('creator.games') }}</h2>
<div class="flex flex-col gap-4">
<div class="flex flex-row">
<div class="grow">{{ t('game.ongeki') }}</div>
<ToggleSwitch v-model="gameModelOngeki" />
</div>
<div class="flex flex-row">
<div class="grow">{{ t('game.chunithm') }}</div>
<ToggleSwitch v-model="gameModelChunithm" />
</div>
</div>
<div class="flex flex-row mt-5">
<Button
class="ml-auto mr-1"
style="width: 80px"
:label="t('ok')"
:disabled="creatorModel.games.length === 0"
@click="
async () => {
await invoke('create_package', creatorModel);
await pkgs.reloadAll();
loadPackages();
dialogVisible = false;
creatorModel = { ...defaultModel };
}
"
/>
<Button
class="mr-auto ml-1"
style="width: 80px"
:label="t('cancel')"
@click="
() => (
(dialogVisible = false),
(creatorModel = { ...defaultModel })
)
"
/>
</div>
</Dialog>
<Fieldset :legend="t('store.missing')" v-if="(missing?.length ?? 0) > 0"> <Fieldset :legend="t('store.missing')" v-if="(missing?.length ?? 0) > 0">
<div class="flex items-center" v-for="p in missing"> <div class="flex items-center" v-for="p in missing">
<ModTitlecard <ModTitlecard
@ -84,10 +218,27 @@ setTimeout(() => (emptyVisible.value = true), 500);
/> />
</div> </div>
</Fieldset> </Fieldset>
<Fieldset v-for="(namespace, key) in group" :legend="key.toString()"> <Fieldset v-for="[namespace, pkgs] in group" :legend="namespace">
<ModListEntry v-for="p in namespace" :pkg="p" /> <ModListEntry v-for="p in pkgs" :pkg="p" />
<div v-if="namespace === 'local'">
<Button
rounded
icon="pi pi-plus"
severity="success"
aria-label="install"
size="small"
class="self-center"
style="width: 2rem; height: 2rem"
v-on:click="() => (dialogVisible = true)"
/>
</div>
</Fieldset> </Fieldset>
<div v-if="empty === true && emptyVisible === true" class="text-3xl fadein">
</div>
</template> </template>
<style lang="css" scoped>
.creation-dialog h2 {
margin-top: 0.6em;
margin-bottom: 0.4em;
font-size: 110%;
}
</style>

View File

@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { ref } from 'vue';
import Chip from 'primevue/chip'; import Chip from 'primevue/chip';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import { invoke } from '../invoke';
import { Feature, Package } from '../types'; import { Feature, Package } from '../types';
import { hasFeature, needsUpdate } from '../util'; import { hasFeature, needsUpdate } from '../util';
@ -14,23 +15,30 @@ const props = defineProps({
showIcon: Boolean, showIcon: Boolean,
}); });
const iconSrc = computed(() => { const icon = ref('/no-icon.png');
const icon = props.pkg?.loc?.icon ?? props.pkg?.rmt?.icon;
if (icon === undefined) { (async () => {
return ''; const src = props.pkg?.loc?.icon ?? props.pkg?.rmt?.icon;
} else if (icon.startsWith('https://')) {
return icon; if (src === undefined) {
icon.value = '/no-icon.png';
} else if (src.startsWith('https://')) {
icon.value = src;
} else { } else {
return convertFileSrc(icon); const convt = convertFileSrc(src);
if (await invoke('file_exists', { path: src })) {
icon.value = convt;
} else {
icon.value = '/no-icon.png';
}
} }
}); })();
</script> </script>
<template> <template>
<img <img
v-if="showIcon" v-if="showIcon"
:src="iconSrc" :src="icon"
class="self-center rounded-sm" class="self-center rounded-sm"
width="32px" width="32px"
height="32px" height="32px"

View File

@ -46,6 +46,19 @@ export default {
exportTemplate: 'Export template', exportTemplate: 'Export template',
export: 'Export', export: 'Export',
}, },
creator: {
header: 'Package creator',
basic: 'Basic information',
name: 'Name',
description: 'Description',
website: 'Website',
type: 'Package type',
rainy: 'Standard',
segatools: 'Segatools',
native: 'Native',
games: 'Games',
packageFormat: 'Package format spec',
},
store: { store: {
installRecommended: 'Install recommended packages', installRecommended: 'Install recommended packages',
installed: 'Show installed', installed: 'Show installed',

View File

@ -46,6 +46,19 @@ export default {
exportTemplate: 'Eksportuj szablon', exportTemplate: 'Eksportuj szablon',
export: 'Eksportuj', export: 'Eksportuj',
}, },
creator: {
header: 'Kreator pakietów',
basic: 'Podstawowe informacje',
name: 'Nazwa',
description: 'Opis',
website: 'Strona internetowa',
type: 'Typ',
rainy: 'Standardowy',
segatools: 'Segatools',
native: 'Natywny',
games: 'Gry',
packageFormat: 'Specyfikacja formatu',
},
store: { store: {
installRecommended: 'Dodaj zalecane pakiety', installRecommended: 'Dodaj zalecane pakiety',
installed: 'Pokaż zainstalowane', installed: 'Pokaż zainstalowane',