From 314ce0e656107ab43a8e8681d85525a551f83f21 Mon Sep 17 00:00:00 2001
From: HsiangNianian <i@jyunko.cn>
Date: Thu, 15 Jan 2026 18:17:49 +0800
Subject: feat: Implement logout confirmation dialog and enhance account status
 display in BottomBar for improved user experience

---
 ui/src/components/BottomBar.svelte | 23 +++++++++++++++++------
 1 file changed, 17 insertions(+), 6 deletions(-)

(limited to 'ui/src/components/BottomBar.svelte')

diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte
index abb0b23..9dcb9ac 100644
--- a/ui/src/components/BottomBar.svelte
+++ b/ui/src/components/BottomBar.svelte
@@ -67,12 +67,23 @@
           {authState.currentAccount ? authState.currentAccount.username : "Login Account"}
         </div>
         <div class="text-[10px] uppercase tracking-wider dark:text-zinc-500 text-gray-500 flex items-center gap-2">
-          <span
-            class="w-1.5 h-1.5 rounded-full {authState.currentAccount
-              ? 'bg-emerald-500'
-              : 'bg-zinc-400'}"
-          ></span>
-          {authState.currentAccount ? "Online" : "Guest"}
+          {#if authState.currentAccount}
+            {#if authState.currentAccount.type === "Microsoft"}
+              {#if authState.currentAccount.expires_at && authState.currentAccount.expires_at * 1000 < Date.now()}
+                <span class="w-1.5 h-1.5 rounded-full bg-red-500"></span>
+                <span class="text-red-400">Expired</span>
+              {:else}
+                <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
+                Online
+              {/if}
+            {:else}
+              <span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span>
+              Offline
+            {/if}
+          {:else}
+            <span class="w-1.5 h-1.5 rounded-full bg-zinc-400"></span>
+            Guest
+          {/if}
         </div>
       </div>
     </div>
-- 
cgit v1.2.3-70-g09d2


From 7a6933edd9092a25d1f24d1e17ae9cd665816460 Mon Sep 17 00:00:00 2001
From: HsiangNianian <i@jyunko.cn>
Date: Thu, 15 Jan 2026 18:29:58 +0800
Subject: feat: Add functionality to list installed game versions in the
 application, enhancing version management and user experience in BottomBar

---
 src-tauri/src/main.rs              |  89 ++++++++++++++++++++++
 ui/src/components/BottomBar.svelte | 148 ++++++++++++++++++++++++++++---------
 2 files changed, 202 insertions(+), 35 deletions(-)

(limited to 'ui/src/components/BottomBar.svelte')

diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 24f3ce3..080ee93 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -1296,6 +1296,94 @@ async fn list_installed_fabric_versions(window: Window) -> Result<Vec<String>, S
         .map_err(|e| e.to_string())
 }
 
+/// Installed version info
+#[derive(serde::Serialize)]
+struct InstalledVersion {
+    id: String,
+    #[serde(rename = "type")]
+    version_type: String, // "release", "snapshot", "fabric", "forge"
+}
+
+/// List all installed versions from the data directory
+#[tauri::command]
+async fn list_installed_versions(window: Window) -> Result<Vec<InstalledVersion>, String> {
+    let app_handle = window.app_handle();
+    let game_dir = app_handle
+        .path()
+        .app_data_dir()
+        .map_err(|e| format!("Failed to get app data dir: {}", e))?;
+
+    let versions_dir = game_dir.join("versions");
+    let mut installed = Vec::new();
+
+    if !versions_dir.exists() {
+        return Ok(installed);
+    }
+
+    let mut entries = tokio::fs::read_dir(&versions_dir)
+        .await
+        .map_err(|e| e.to_string())?;
+
+    while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? {
+        let name = entry.file_name().to_string_lossy().to_string();
+        let version_dir = entry.path();
+
+        // Check if the version has a valid JSON file
+        let json_path = version_dir.join(format!("{}.json", name));
+        if !json_path.exists() {
+            continue;
+        }
+
+        // Check if client.jar exists (for vanilla versions)
+        let jar_path = version_dir.join(format!("{}.jar", name));
+        let has_jar = jar_path.exists();
+
+        // Determine version type
+        let version_type = if name.starts_with("fabric-loader-") {
+            // Fabric versions don't need their own jar, they inherit from vanilla
+            "fabric".to_string()
+        } else if name.contains("-forge-") {
+            "forge".to_string()
+        } else if has_jar {
+            // Read the JSON to determine if it's release or snapshot
+            if let Ok(content) = tokio::fs::read_to_string(&json_path).await {
+                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
+                    json.get("type")
+                        .and_then(|t| t.as_str())
+                        .unwrap_or("release")
+                        .to_string()
+                } else {
+                    "release".to_string()
+                }
+            } else {
+                "release".to_string()
+            }
+        } else {
+            // JSON exists but no jar - skip incomplete installations
+            continue;
+        };
+
+        installed.push(InstalledVersion {
+            id: name,
+            version_type,
+        });
+    }
+
+    // Sort: modded first, then by version id descending
+    installed.sort_by(|a, b| {
+        let a_modded = a.version_type == "fabric" || a.version_type == "forge";
+        let b_modded = b.version_type == "fabric" || b.version_type == "forge";
+        
+        match (a_modded, b_modded) {
+            (true, false) => std::cmp::Ordering::Less,
+            (false, true) => std::cmp::Ordering::Greater,
+            _ => b.id.cmp(&a.id), // Descending order
+        }
+    });
+
+    Ok(installed)
+}
+
 /// Check if Fabric is installed for a specific version
 #[tauri::command]
 async fn is_fabric_installed(
@@ -1531,6 +1619,7 @@ fn main() {
             get_versions,
             check_version_installed,
             install_version,
+            list_installed_versions,
             login_offline,
             get_active_account,
             logout,
diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte
index 9dcb9ac..04d53de 100644
--- a/ui/src/components/BottomBar.svelte
+++ b/ui/src/components/BottomBar.svelte
@@ -1,23 +1,68 @@
 <script lang="ts">
+  import { invoke } from "@tauri-apps/api/core";
+  import { listen, type UnlistenFn } from "@tauri-apps/api/event";
   import { authState } from "../stores/auth.svelte";
   import { gameState } from "../stores/game.svelte";
   import { uiState } from "../stores/ui.svelte";
-  import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte';
+  import { Terminal, ChevronDown, Play, User, Check, RefreshCw } from 'lucide-svelte';
+
+  interface InstalledVersion {
+    id: string;
+    type: string;
+  }
 
   let isVersionDropdownOpen = $state(false);
   let dropdownRef: HTMLDivElement;
+  let installedVersions = $state<InstalledVersion[]>([]);
+  let isLoadingVersions = $state(true);
+  let downloadCompleteUnlisten: UnlistenFn | null = null;
+
+  // Load installed versions on mount
+  $effect(() => {
+    loadInstalledVersions();
+    setupDownloadListener();
+    return () => {
+      if (downloadCompleteUnlisten) {
+        downloadCompleteUnlisten();
+      }
+    };
+  });
+
+  async function setupDownloadListener() {
+    // Refresh list when a download completes
+    downloadCompleteUnlisten = await listen("download-complete", () => {
+      loadInstalledVersions();
+    });
+  }
+
+  async function loadInstalledVersions() {
+    isLoadingVersions = true;
+    try {
+      installedVersions = await invoke<InstalledVersion[]>("list_installed_versions");
+      // If no version is selected but we have installed versions, select the first one
+      if (!gameState.selectedVersion && installedVersions.length > 0) {
+        gameState.selectedVersion = installedVersions[0].id;
+      }
+    } catch (e) {
+      console.error("Failed to load installed versions:", e);
+    } finally {
+      isLoadingVersions = false;
+    }
+  }
 
   let versionOptions = $derived(
-    gameState.versions.length === 0 
+    isLoadingVersions 
       ? [{ id: "loading", type: "loading", label: "Loading..." }]
-      : gameState.versions.map(v => ({
-          ...v,
-          label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}`
-        }))
+      : installedVersions.length === 0
+        ? [{ id: "empty", type: "empty", label: "No versions installed" }]
+        : installedVersions.map(v => ({
+            ...v,
+            label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}`
+          }))
   );
 
   function selectVersion(id: string) {
-    if (id !== "loading") {
+    if (id !== "loading" && id !== "empty") {
       gameState.selectedVersion = id;
       isVersionDropdownOpen = false;
     }
@@ -35,6 +80,15 @@
       return () => document.removeEventListener('click', handleClickOutside);
     }
   });
+
+  function getVersionTypeColor(type: string) {
+    switch (type) {
+      case 'fabric': return 'text-indigo-400';
+      case 'forge': return 'text-orange-400';
+      case 'snapshot': return 'text-amber-400';
+      default: return 'text-emerald-400';
+    }
+  }
 </script>
 
 <div
@@ -105,47 +159,70 @@
     <div class="flex flex-col items-end mr-2">
       <!-- Custom Version Dropdown -->
       <div class="relative" bind:this={dropdownRef}>
-        <button
-          type="button"
-          onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen}
-          class="flex items-center justify-between gap-2 w-56 px-4 py-2.5 text-left
-                 dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md 
-                 text-sm font-mono dark:text-white text-gray-900
-                 dark:hover:border-zinc-600 hover:border-zinc-400 
-                 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
-                 transition-colors cursor-pointer outline-none"
-        >
-          <span class="truncate">
-            {#if gameState.versions.length === 0}
-              Loading...
-            {:else}
-              {gameState.selectedVersion || "Select version"}
-            {/if}
-          </span>
-          <ChevronDown 
-            size={14} 
-            class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}" 
-          />
-        </button>
+        <div class="flex items-center gap-2">
+          <button
+            type="button"
+            onclick={() => loadInstalledVersions()}
+            class="p-2.5 dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md 
+                   dark:text-zinc-500 text-zinc-400 dark:hover:text-white hover:text-black
+                   dark:hover:border-zinc-600 hover:border-zinc-400 transition-colors"
+            title="Refresh installed versions"
+          >
+            <RefreshCw size={14} class={isLoadingVersions ? 'animate-spin' : ''} />
+          </button>
+          <button
+            type="button"
+            onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen}
+            disabled={installedVersions.length === 0 && !isLoadingVersions}
+            class="flex items-center justify-between gap-2 w-56 px-4 py-2.5 text-left
+                   dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md 
+                   text-sm font-mono dark:text-white text-gray-900
+                   dark:hover:border-zinc-600 hover:border-zinc-400 
+                   focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
+                   transition-colors cursor-pointer outline-none
+                   disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            <span class="truncate">
+              {#if isLoadingVersions}
+                Loading...
+              {:else if installedVersions.length === 0}
+                No versions installed
+              {:else}
+                {gameState.selectedVersion || "Select version"}
+              {/if}
+            </span>
+            <ChevronDown 
+              size={14} 
+              class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}" 
+            />
+          </button>
+        </div>
 
-        {#if isVersionDropdownOpen}
+        {#if isVersionDropdownOpen && installedVersions.length > 0}
           <div 
             class="absolute z-50 w-full mt-1 py-1 dark:bg-zinc-900 bg-white border dark:border-zinc-700 border-zinc-300 rounded-md shadow-xl
-                   max-h-72 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 bottom-full mb-1"
+                   max-h-72 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 bottom-full mb-1 right-0"
           >
             {#each versionOptions as version}
               <button
                 type="button"
                 onclick={() => selectVersion(version.id)}
-                disabled={version.id === "loading"}
+                disabled={version.id === "loading" || version.id === "empty"}
                 class="w-full flex items-center justify-between px-3 py-2 text-sm font-mono text-left
                        transition-colors outline-none
                        {version.id === gameState.selectedVersion 
                          ? 'bg-indigo-600 text-white' 
                          : 'dark:text-zinc-300 text-gray-700 dark:hover:bg-zinc-800 hover:bg-zinc-100'}
-                       {version.id === 'loading' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
+                       {version.id === 'loading' || version.id === 'empty' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
               >
-                <span class="truncate">{version.label}</span>
+                <span class="truncate flex items-center gap-2">
+                  {version.id}
+                  {#if version.type && version.type !== 'release' && version.type !== 'loading' && version.type !== 'empty'}
+                    <span class="text-[10px] uppercase {version.id === gameState.selectedVersion ? 'text-white/70' : getVersionTypeColor(version.type)}">
+                      {version.type}
+                    </span>
+                  {/if}
+                </span>
                 {#if version.id === gameState.selectedVersion}
                   <Check size={14} class="shrink-0 ml-2" />
                 {/if}
@@ -158,7 +235,8 @@
 
     <button
       onclick={() => gameState.startGame()}
-      class="bg-emerald-600 hover:bg-emerald-500 text-white h-14 px-10 rounded-sm transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-emerald-500/20 flex items-center gap-3 font-bold text-lg tracking-widest uppercase"
+      disabled={installedVersions.length === 0 || !gameState.selectedVersion}
+      class="bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white h-14 px-10 rounded-sm transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-emerald-500/20 flex items-center gap-3 font-bold text-lg tracking-widest uppercase"
     >
       <Play size={24} fill="currentColor" />
       <span>Launch</span>
-- 
cgit v1.2.3-70-g09d2


From f584425ca78eab398a150aa9ffc3ed74f0cc8a70 Mon Sep 17 00:00:00 2001
From: HsiangNianian <i@jyunko.cn>
Date: Thu, 15 Jan 2026 18:38:19 +0800
Subject: feat: Implement local version fetching and enhance version type
 detection to support modpacks in version management

---
 src-tauri/src/main.rs                 | 94 ++++++++++++++++++++++-------------
 ui/src/components/BottomBar.svelte    |  1 +
 ui/src/components/VersionsView.svelte |  2 +
 3 files changed, 63 insertions(+), 34 deletions(-)

(limited to 'ui/src/components/BottomBar.svelte')

diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 080ee93..07e190e 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -740,6 +740,27 @@ async fn install_version(
         format!("Loading version details for {}...", version_id)
     );
 
+    // First, try to fetch the vanilla version from Mojang and save it locally
+    let version_details = match core::manifest::load_local_version(&game_dir, &version_id).await {
+        Ok(v) => v,
+        Err(_) => {
+            // Not found locally, fetch from Mojang
+            emit_log!(window, format!("Fetching version {} from Mojang...", version_id));
+            let fetched = core::manifest::fetch_vanilla_version(&version_id)
+                .await
+                .map_err(|e| e.to_string())?;
+            
+            // Save the version JSON locally
+            emit_log!(window, format!("Saving version JSON..."));
+            core::manifest::save_local_version(&game_dir, &fetched)
+                .await
+                .map_err(|e| e.to_string())?;
+            
+            fetched
+        }
+    };
+
+    // Now load the full version with inheritance resolved
     let version_details = core::manifest::load_version(&game_dir, &version_id)
         .await
         .map_err(|e| e.to_string())?;
@@ -1301,10 +1322,11 @@ async fn list_installed_fabric_versions(window: Window) -> Result<Vec<String>, S
 struct InstalledVersion {
     id: String,
     #[serde(rename = "type")]
-    version_type: String, // "release", "snapshot", "fabric", "forge"
+    version_type: String, // "release", "snapshot", "fabric", "forge", "modpack"
 }
 
 /// List all installed versions from the data directory
+/// Simply lists all folders in the versions directory without validation
 #[tauri::command]
 async fn list_installed_versions(window: Window) -> Result<Vec<InstalledVersion>, String> {
     let app_handle = window.app_handle();
@@ -1325,42 +1347,39 @@ async fn list_installed_versions(window: Window) -> Result<Vec<InstalledVersion>
         .map_err(|e| e.to_string())?;
 
     while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? {
-        let name = entry.file_name().to_string_lossy().to_string();
-        let version_dir = entry.path();
-
-        // Check if the version has a valid JSON file
-        let json_path = version_dir.join(format!("{}.json", name));
-        if !json_path.exists() {
+        // Only include directories
+        if !entry.file_type().await.map_err(|e| e.to_string())?.is_dir() {
             continue;
         }
 
-        // Check if client.jar exists (for vanilla versions)
-        let jar_path = version_dir.join(format!("{}.jar", name));
-        let has_jar = jar_path.exists();
+        let name = entry.file_name().to_string_lossy().to_string();
+        let version_dir = entry.path();
 
-        // Determine version type
+        // Determine version type based on folder name or JSON content
         let version_type = if name.starts_with("fabric-loader-") {
-            // Fabric versions don't need their own jar, they inherit from vanilla
             "fabric".to_string()
-        } else if name.contains("-forge-") {
+        } else if name.contains("-forge") || name.contains("forge-") {
             "forge".to_string()
-        } else if has_jar {
-            // Read the JSON to determine if it's release or snapshot
-            if let Ok(content) = tokio::fs::read_to_string(&json_path).await {
-                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
-                    json.get("type")
-                        .and_then(|t| t.as_str())
-                        .unwrap_or("release")
-                        .to_string()
+        } else {
+            // Try to read JSON to get type, otherwise guess from name
+            let json_path = version_dir.join(format!("{}.json", name));
+            if json_path.exists() {
+                if let Ok(content) = tokio::fs::read_to_string(&json_path).await {
+                    if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
+                        json.get("type")
+                            .and_then(|t| t.as_str())
+                            .unwrap_or("modpack")
+                            .to_string()
+                    } else {
+                        "modpack".to_string()
+                    }
                 } else {
-                    "release".to_string()
+                    "modpack".to_string()
                 }
             } else {
-                "release".to_string()
+                // No JSON file - treat as modpack/custom
+                "modpack".to_string()
             }
-        } else {
-            // JSON exists but no jar - skip incomplete installations
-            continue;
         };
 
         installed.push(InstalledVersion {
@@ -1369,15 +1388,22 @@ async fn list_installed_versions(window: Window) -> Result<Vec<InstalledVersion>
         });
     }
 
-    // Sort: modded first, then by version id descending
+    // Sort: modded/modpack first, then by version id descending
     installed.sort_by(|a, b| {
-        let a_modded = a.version_type == "fabric" || a.version_type == "forge";
-        let b_modded = b.version_type == "fabric" || b.version_type == "forge";
-        
-        match (a_modded, b_modded) {
-            (true, false) => std::cmp::Ordering::Less,
-            (false, true) => std::cmp::Ordering::Greater,
-            _ => b.id.cmp(&a.id), // Descending order
+        let a_priority = match a.version_type.as_str() {
+            "fabric" | "forge" => 0,
+            "modpack" => 1,
+            _ => 2,
+        };
+        let b_priority = match b.version_type.as_str() {
+            "fabric" | "forge" => 0,
+            "modpack" => 1,
+            _ => 2,
+        };
+
+        match a_priority.cmp(&b_priority) {
+            std::cmp::Ordering::Equal => b.id.cmp(&a.id), // Descending order
+            other => other,
         }
     });
 
diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte
index 04d53de..b7bbf71 100644
--- a/ui/src/components/BottomBar.svelte
+++ b/ui/src/components/BottomBar.svelte
@@ -86,6 +86,7 @@
       case 'fabric': return 'text-indigo-400';
       case 'forge': return 'text-orange-400';
       case 'snapshot': return 'text-amber-400';
+      case 'modpack': return 'text-purple-400';
       default: return 'text-emerald-400';
     }
   }
diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte
index 99cc296..ce354b9 100644
--- a/ui/src/components/VersionsView.svelte
+++ b/ui/src/components/VersionsView.svelte
@@ -80,6 +80,8 @@
         return { text: "Fabric", class: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-300 dark:border-indigo-500/30" };
       case "forge":
         return { text: "Forge", class: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/20 dark:text-orange-300 dark:border-orange-500/30" };
+      case "modpack":
+        return { text: "Modpack", class: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-500/20 dark:text-purple-300 dark:border-purple-500/30" };
       default:
         return { text: type, class: "bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-zinc-500/20 dark:text-zinc-300 dark:border-zinc-500/30" };
     }
-- 
cgit v1.2.3-70-g09d2

