diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 7ab023b..b909ccf 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -217,6 +217,30 @@ pub struct PlayerProfile { pub available_capes: Vec, } +/// A saved skin and cape preset (wardrobe look). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SkinProfile { + pub id: String, + pub name: String, + pub skin_data: Option, + #[serde(default = "default_skin_model")] + pub skin_model: String, + #[serde(default)] + pub cape_id: Option, + #[serde(default)] + pub cape_name: Option, + #[serde(default)] + pub cape_url: Option, + #[serde(default)] + pub account_id: Option, + #[serde(default)] + pub created_at: String, +} + +fn default_skin_model() -> String { + "classic".into() +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoginRequest { pub username: String, diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index 28d9eed..7c1a3ab 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -6,7 +6,7 @@ use crate::settings::Settings; use crate::state::{build, AppState}; use crate::updater; use scopenet_core::ping::ServerStatus; -use scopenet_shared::{LauncherManifest, PlayerProfile}; +use scopenet_shared::{LauncherManifest, PlayerProfile, SkinProfile}; use serde::Serialize; use tauri::{AppHandle, Manager, State}; use tauri_plugin_opener::OpenerExt; @@ -220,6 +220,109 @@ pub async fn set_cape(state: State<'_, AppState>, cape_id: Option) -> Res

Vec { + let path = data_dir.join("skin_profiles.json"); + std::fs::read(&path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() +} + +fn write_skin_profiles(data_dir: &std::path::Path, profiles: &[SkinProfile]) -> anyhow::Result<()> { + let path = data_dir.join("skin_profiles.json"); + let json = serde_json::to_vec_pretty(profiles)?; + std::fs::write(path, json)?; + Ok(()) +} + +#[tauri::command] +pub fn get_skin_profiles(state: State<'_, AppState>) -> Res> { + Ok(load_skin_profiles(&state.data_dir)) +} + +#[tauri::command] +pub async fn save_skin_profile(state: State<'_, AppState>, mut profile: SkinProfile) -> Res> { + use base64::Engine; + if let Some(ref mut data) = profile.skin_data { + if data.starts_with('/') { + if let Some(panel) = state.panel_url() { + *data = format!("{panel}{data}"); + } + } + if data.starts_with("http://") || data.starts_with("https://") { + if let Ok(resp) = state.http.get(&*data).send().await { + if resp.status().is_success() { + if let Ok(bytes) = resp.bytes().await { + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + *data = format!("data:image/png;base64,{b64}"); + } + } + } + } else if !data.starts_with("data:") && !data.is_empty() { + *data = format!("data:image/png;base64,{data}"); + } + } + if profile.created_at.is_empty() { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs().to_string()) + .unwrap_or_default(); + profile.created_at = ts; + } + let mut profiles = load_skin_profiles(&state.data_dir); + if let Some(idx) = profiles.iter().position(|x| x.id == profile.id) { + profiles[idx] = profile; + } else { + profiles.push(profile); + } + write_skin_profiles(&state.data_dir, &profiles).map_err(aerr)?; + Ok(profiles) +} + +#[tauri::command] +pub fn delete_skin_profile(state: State<'_, AppState>, id: String) -> Res> { + let mut profiles = load_skin_profiles(&state.data_dir); + profiles.retain(|x| x.id != id); + write_skin_profiles(&state.data_dir, &profiles).map_err(aerr)?; + Ok(profiles) +} + +#[tauri::command] +pub async fn apply_skin_profile(state: State<'_, AppState>, id: String) -> Res { + use base64::Engine; + let profiles = load_skin_profiles(&state.data_dir); + let p = profiles.into_iter().find(|x| x.id == id).ok_or_else(|| "profile not found".to_string())?; + + if let Some(skin_data) = p.skin_data { + if !skin_data.is_empty() { + let bytes = if skin_data.starts_with("data:") { + let comma = skin_data.find(',').ok_or_else(|| "invalid data uri".to_string())?; + base64::engine::general_purpose::STANDARD.decode(skin_data[comma + 1..].trim()).map_err(err)? + } else if skin_data.starts_with("http://") || skin_data.starts_with("https://") { + state.http.get(&skin_data).send().await.map_err(err)?.bytes().await.map_err(err)?.to_vec() + } else { + base64::engine::general_purpose::STANDARD.decode(skin_data.trim()).map_err(err)? + }; + let form = reqwest::multipart::Form::new() + .text("model", p.skin_model.clone()) + .part("file", reqwest::multipart::Part::bytes(bytes).file_name("skin.png").mime_str("image/png").map_err(err)?); + let req = account_api(&state, reqwest::Method::POST, "/account/skin").await?; + profile_response(req.multipart(form).send().await.map_err(err)?).await?; + } else { + let req = account_api(&state, reqwest::Method::PUT, "/account/skin/model").await?; + profile_response(req.json(&serde_json::json!({ "model": p.skin_model })).send().await.map_err(err)?).await?; + } + } else { + let req = account_api(&state, reqwest::Method::PUT, "/account/skin/model").await?; + profile_response(req.json(&serde_json::json!({ "model": p.skin_model })).send().await.map_err(err)?).await?; + } + + let req = account_api(&state, reqwest::Method::PUT, "/account/cape").await?; + profile_response(req.json(&serde_json::json!({ "cape_id": p.cape_id })).send().await.map_err(err)?).await?; + + account_profile(state).await +} + // ---- game ---- #[tauri::command] diff --git a/launcher/src-tauri/src/lib.rs b/launcher/src-tauri/src/lib.rs index 3efb16d..273bfde 100644 --- a/launcher/src-tauri/src/lib.rs +++ b/launcher/src-tauri/src/lib.rs @@ -59,6 +59,10 @@ pub fn run() { commands::set_skin_model, commands::delete_skin, commands::set_cape, + commands::get_skin_profiles, + commands::save_skin_profile, + commands::delete_skin_profile, + commands::apply_skin_profile, commands::set_username, commands::select_account, commands::remove_account, diff --git a/launcher/src/lib/types.ts b/launcher/src/lib/types.ts index 723b464..bcaf3c6 100644 --- a/launcher/src/lib/types.ts +++ b/launcher/src/lib/types.ts @@ -110,4 +110,15 @@ export type ProgressEvent = export interface ServerStatus { online: boolean; version: string; players_online: number; players_max: number; sample: string[]; motd: string; favicon: string | null; latency_ms: number } export interface CapeInfo { id: number; name: string; url: string } export interface PlayerProfile { uuid: string; name: string; skin_url: string | null; skin_model: 'classic' | 'slim'; cape: CapeInfo | null; available_capes: CapeInfo[] } +export interface SkinProfile { + id: string; + name: string; + skin_data: string | null; + skin_model: 'classic' | 'slim'; + cape_id: number | null; + cape_name: string | null; + cape_url: string | null; + account_id?: string | null; + created_at: string; +} export interface UpdateInfo { version: string; notes: string; url: string; size: number } diff --git a/launcher/src/pages/Settings.svelte b/launcher/src/pages/Settings.svelte index fdf3778..248bcba 100644 --- a/launcher/src/pages/Settings.svelte +++ b/launcher/src/pages/Settings.svelte @@ -2,15 +2,17 @@ import { UserRound, Gamepad2, Cpu, Keyboard, Palette, Rocket, HardDrive, Info, Plus, LogOut, Check, FolderOpen, Trash2, FileCode, RotateCcw, Download, LoaderCircle, RefreshCw, MousePointerClick, Zap, Gauge, Feather, Link2, Shirt, Upload, Ban, + BookmarkPlus, Sparkles, } from '@lucide/svelte'; import Avatar from '../components/Avatar.svelte'; + import Modal from '../components/Modal.svelte'; import SkinView from '../components/SkinView.svelte'; import Toggle from '../components/Toggle.svelte'; import { activeAccount, app, connect, instances, refresh, removeAccount, saveSettings, switchAccount, toast } from '../lib/store.svelte'; import { BIND_GROUPS, defaults, fromKeyboard, fromMouse, keyLabel } from '../lib/keys'; import { bytes, gb } from '../lib/format'; import { errorText, invoke, openUrl, pickFile } from '../lib/tauri'; - import type { Account, Gc, PlayerProfile, UpdateInfo } from '../lib/types'; + import type { Account, Gc, PlayerProfile, SkinProfile, UpdateInfo } from '../lib/types'; const serverAccount = $derived(activeAccount()?.kind === 'panel'); const tabs = $derived([ @@ -114,6 +116,109 @@ uploadSkin(e.dataTransfer?.files[0]); } + // ---- saved skin profiles (wardrobe) ---- + let skinProfiles = $state([]); + let profileNameInput = $state(''); + let savingCurrentLook = $state(false); + let applyingProfileId = $state(null); + let newProfileModal = $state(false); + let newProfileName = $state(''); + let newProfileFile = $state(null); + let newProfileModel = $state<'classic' | 'slim'>('classic'); + let newProfileCapeId = $state(null); + + async function loadSkinProfiles() { + try { + skinProfiles = await invoke('get_skin_profiles'); + } catch { + skinProfiles = []; + } + } + + $effect(() => { + if (app.settingsTab === 'skin') { + loadSkinProfiles(); + } + }); + + async function saveCurrentLook() { + if (!profile) return; + const name = profileNameInput.trim() || `Look ${skinProfiles.length + 1}`; + try { + skinProfiles = await invoke('save_skin_profile', { + profile: { + id: 'sp_' + Math.random().toString(36).slice(2, 10), + name, + skin_data: profile.skin_url, + skin_model: profile.skin_model, + cape_id: profile.cape?.id ?? null, + cape_name: profile.cape?.name ?? null, + cape_url: profile.cape?.url ?? null, + account_id: activeAccount()?.id ?? null, + created_at: new Date().toISOString(), + } + }); + profileNameInput = ''; + savingCurrentLook = false; + toast(`Saved "${name}" to wardrobe`); + } catch (e) { + toast(errorText(e), 'error'); + } + } + + async function equipProfile(p: SkinProfile) { + applyingProfileId = p.id; + try { + profile = await invoke('apply_skin_profile', { id: p.id }); + app.skinVersion++; + toast(`Equipped "${p.name}"`); + } catch (e) { + toast(errorText(e), 'error'); + } finally { + applyingProfileId = null; + } + } + + async function deleteProfile(id: string, name: string) { + try { + skinProfiles = await invoke('delete_skin_profile', { id }); + toast(`Removed "${name}"`); + } catch (e) { + toast(errorText(e), 'error'); + } + } + + async function createProfileFromFile() { + if (!newProfileFile) return toast('Please select a skin PNG file', 'error'); + const name = newProfileName.trim() || 'Custom Look'; + const buf = new Uint8Array(await newProfileFile.arrayBuffer()); + let bin = ''; + for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode(...buf.subarray(i, i + 0x8000)); + const base64Data = 'data:image/png;base64,' + btoa(bin); + const chosenCape = profile?.available_capes.find(c => c.id === newProfileCapeId); + try { + skinProfiles = await invoke('save_skin_profile', { + profile: { + id: 'sp_' + Math.random().toString(36).slice(2, 10), + name, + skin_data: base64Data, + skin_model: newProfileModel, + cape_id: newProfileCapeId, + cape_name: chosenCape?.name ?? null, + cape_url: chosenCape?.url ?? null, + account_id: activeAccount()?.id ?? null, + created_at: new Date().toISOString(), + } + }); + newProfileModal = false; + newProfileName = ''; + newProfileFile = null; + toast(`Saved "${name}" to wardrobe`); + } catch (e) { + toast(errorText(e), 'error'); + } + } + // ---- storage ---- let storage = $state<{ shared: number; runtimes: number; instances: [string, number][] } | null>(null); function loadStorage() { @@ -273,6 +378,114 @@

No capes are available to you yet. Server staff can hand them out from the panel.

{/if} + +
+
+
+

Saved Profiles & Wardrobe

+

Save and swap between multiple skin + cape combinations in one click.

+
+
+ {#if !savingCurrentLook} + + + {/if} +
+
+ + {#if savingCurrentLook} +
{ e.preventDefault(); saveCurrentLook(); }}> + + + +
+ {/if} + + {#if skinProfiles.length} +
+ {#each skinProfiles as sp (sp.id)} + {@const isCurrent = profile.skin_model === sp.skin_model && (sp.cape_id === (profile.cape?.id ?? null))} +
+
+ +
+
+
+ {sp.name} + {#if isCurrent} + Active + {/if} +
+
+ {sp.skin_model === 'slim' ? 'Slim' : 'Classic'} + · + {sp.cape_name ?? 'No cape'} +
+
+ + +
+
+
+ {/each} +
+ {:else} +

No saved profiles yet. Click "Save current look" to bookmark your current skin and cape, or "New preset" to add one.

+ {/if} +
+ + {#if newProfileModal} + +
{ e.preventDefault(); createProfileFromFile(); }}> + + + + +
+ + +
+
+
+ {/if} {/if} {:else if app.settingsTab === 'game'}

Game

@@ -559,5 +772,23 @@ /* Cape texture: the outer face is the 10×16 region at (1,1) of a 64×32 sheet. */ .cape-img { width: 2.5rem; height: 4rem; border-radius: 0.2rem; image-rendering: pixelated; background-color: color-mix(in srgb, var(--text) 8%, transparent); background-size: 640% 200%; background-position: 1.85% 6.25%; background-repeat: no-repeat; } .cape-img.none { display: grid; place-items: center; color: var(--muted); } + .align-center { align-items: center; } + .justify-between { justify-content: space-between; } + .justify-end { justify-content: flex-end; } + .gap-xs { gap: 0.35rem; } + .gap-sm { gap: 0.6rem; } + :global(.accent-icon) { color: var(--accent); } + .save-look-box { padding: 0.6rem 0.8rem; background: color-mix(in srgb, var(--bg) 60%, transparent); border-radius: var(--radius-sm); border: 1px solid var(--line); } + .save-look-box input { flex: 1; } + .profiles-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: 0.8rem; } + .profile-card { display: flex; gap: 0.8rem; align-items: center; padding: 0.75rem 0.85rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--bg) 50%, transparent); border: 1px solid var(--line); transition: border-color 0.15s, background 0.15s; } + .profile-card:hover { border-color: var(--line-strong); background: color-mix(in srgb, var(--bg) 70%, transparent); } + .profile-card.active-look { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent); } + .profile-preview { flex-shrink: 0; padding: 0.4rem 0.5rem; background: color-mix(in srgb, var(--bg) 80%, transparent); border-radius: var(--radius-sm); display: grid; place-items: center; } + .profile-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.35rem; } + .profile-name { font-size: 0.88rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .profile-meta { display: flex; gap: 0.3rem; align-items: center; } + .tiny-badge { padding: 0.15rem 0.4rem; font-size: 0.72rem; } + .profile-actions { margin-top: 0.2rem; } @media (max-width: 1050px) { .binds { grid-template-columns: 1fr; } .tabs { width: 12rem; } .skin-card { flex-direction: column; align-items: stretch; } .preview { justify-content: center; } }