diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad63863..e59d525 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -299,7 +299,7 @@ fn import_theme(app: AppHandle) -> Result { #[tauri::command] fn pick_folder() -> Result { let folder = rfd::FileDialog::new() - .set_title("Select Custom TU Folder") + .set_title("Select Export Folder") .pick_folder(); if let Some(path) = folder { @@ -309,6 +309,44 @@ fn pick_folder() -> Result { } } +#[tauri::command] +fn pick_file(title: String, filters: Vec) -> Result { + let mut dialog = rfd::FileDialog::new().set_title(&title); + if !filters.is_empty() { + let filters_ref: Vec<&str> = filters.iter().map(|s| s.as_str()).collect(); + dialog = dialog.add_filter("Files", &filters_ref); + } + if let Some(path) = dialog.pick_file() { + Ok(path.to_string_lossy().to_string()) + } else { + Err("CANCELED".into()) + } +} + +#[tauri::command] +fn save_file_dialog(title: String, filename: String, filters: Vec) -> Result { + let mut dialog = rfd::FileDialog::new().set_title(&title).set_file_name(&filename); + if !filters.is_empty() { + let filters_ref: Vec<&str> = filters.iter().map(|s| s.as_str()).collect(); + dialog = dialog.add_filter("Files", &filters_ref); + } + if let Some(path) = dialog.save_file() { + Ok(path.to_string_lossy().to_string()) + } else { + Err("CANCELED".into()) + } +} + +#[tauri::command] +fn write_binary_file(path: String, data: Vec) -> Result<(), String> { + fs::write(path, data).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn read_binary_file(path: String) -> Result, String> { + fs::read(path).map_err(|e| e.to_string()) +} + #[tauri::command] fn get_available_runners(app: AppHandle) -> Vec { let mut runners = Vec::new(); @@ -1686,7 +1724,7 @@ pub fn run() { } } }) - .invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, pick_folder, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, workshop_uninstall, workshop_list_installed, get_screenshots, delete_screenshot, open_screenshot_folder, save_global_skin_pck, check_game_update, check_macos_runtime_installed, check_macos_runtime_installed_fast, download_logo]) + .invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, pick_folder, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, workshop_uninstall, workshop_list_installed, get_screenshots, delete_screenshot, open_screenshot_folder, save_global_skin_pck, check_game_update, check_macos_runtime_installed, check_macos_runtime_installed_fast, download_logo, pick_file, save_file_dialog, write_binary_file, read_binary_file]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/components/views/ArcEditorView.tsx b/src/components/views/ArcEditorView.tsx index dfe6990..877c9ea 100644 --- a/src/components/views/ArcEditorView.tsx +++ b/src/components/views/ArcEditorView.tsx @@ -3,11 +3,14 @@ import { motion, AnimatePresence } from "framer-motion"; import { useUI, useAudio, useConfig } from "../../context/LauncherContext"; import { ArcService } from "../../services/ArcService"; import { ArcFile, ArcEntry, LocFile, LocLanguage } from "../../types/arc"; +import { TauriService } from "../../services/TauriService"; + export const ArcEditorView: React.FC = () => { const { setActiveView } = useUI(); const { playPressSound, playBackSound } = useAudio(); const { animationsEnabled } = useConfig(); const [arc, setArc] = useState(null); + const [openedPath, setOpenedPath] = useState(null); const [loc, setLoc] = useState(null); const [activeTab, setActiveTab] = useState<"arc" | "loc">("arc"); const [searchTerm, setSearchTerm] = useState(""); @@ -18,9 +21,9 @@ export const ArcEditorView: React.FC = () => { const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false); const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); const [isLocEditModalOpen, setIsLocEditModalOpen] = useState<{ langIdx: number, strIdx: number, isNew: boolean } | null>(null); - const fileInputRef = useRef(null); const injectInputRef = useRef(null); const replaceInputRef = useRef(null); + const filteredEntries = useMemo(() => { if (!arc) return []; return arc.entries.map((e, i) => ({ ...e, originalIdx: i })) @@ -43,15 +46,17 @@ export const ArcEditorView: React.FC = () => { setTimeout(() => setNotification(null), 3000); }; - const handleFileLoad = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - playPressSound(); - const buffer = await file.arrayBuffer(); + const handleFileLoad = async () => { try { - const parsed = await ArcService.readARC(buffer); - parsed.name = file.name; + const path = await TauriService.pickFile("Open ARC", ["arc"]); + if (!path) return; + playPressSound(); + const bytes = await TauriService.readBinaryFile(path); + const parsed = await ArcService.readARC(bytes.buffer as ArrayBuffer); + parsed.name = path.split(/[\/\\]/).pop() || "archive.arc"; setArc(parsed); + setOpenedPath(path); + const locEntry = parsed.entries.find(entry => entry.filename.toLowerCase() === "languages.loc"); if (locEntry) { try { @@ -65,37 +70,48 @@ export const ArcEditorView: React.FC = () => { setLoc(null); } setSelectedEntryIdx(null); - showNotification(`Loaded ${file.name}`); - } catch (err) { - console.error("Failed to parse ARC", err); - showNotification("Failed to parse ARC", "error"); + showNotification(`Loaded ${parsed.name}`); + } catch (err: any) { + if (err !== "CANCELED") { + console.error("Failed to parse ARC", err); + showNotification("Failed to parse ARC", "error"); + } } }; - const handleSaveArc = () => { + const handleSaveArc = async () => { if (!arc) return; playPressSound(); const buffer = ArcService.serializeARC(arc); - const blob = new Blob([buffer]); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = arc.name || "archive.arc"; - a.click(); - URL.revokeObjectURL(url); - showNotification("ARC Saved Successfully"); + const data = new Uint8Array(buffer); + + try { + let targetPath = openedPath; + if (!targetPath) { + targetPath = await TauriService.saveFileDialog("Save ARC", arc.name || "archive.arc", ["arc"]); + } + + if (targetPath) { + await TauriService.writeBinaryFile(targetPath, data); + setOpenedPath(targetPath); + showNotification("ARC Saved Successfully"); + } + } catch (err: any) { + if (err !== "CANCELED") showNotification("Save failed", "error"); + } }; - const handleExtractEntry = (entry: ArcEntry) => { - playPressSound(); - const blob = new Blob([entry.data as any]); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = entry.filename.split("/").pop() || "asset"; - a.click(); - URL.revokeObjectURL(url); - showNotification(`Extracted: ${entry.filename}`); + const handleExtractEntry = async (entry: ArcEntry) => { + try { + const fileName = entry.filename.split("/").pop() || "asset"; + const path = await TauriService.saveFileDialog("Export Asset", fileName, []); + if (!path) return; + playPressSound(); + await TauriService.writeBinaryFile(path, entry.data); + showNotification(`Extracted: ${entry.filename}`); + } catch (err: any) { + if (err !== "CANCELED") showNotification("Extraction failed", "error"); + } }; const handleDeleteEntry = (idx: number) => { @@ -195,34 +211,140 @@ export const ArcEditorView: React.FC = () => { showNotification("String Deleted"); }; + const treeData = useMemo(() => { + const root: any = { name: "", children: {}, isFolder: true }; + filteredEntries.forEach((entry) => { + const parts = entry.filename.split(/\//); + let current = root; + parts.forEach((part, i) => { + const isLast = i === parts.length - 1; + if (!current.children[part]) { + current.children[part] = isLast + ? { ...entry, isFolder: false } + : { name: part, children: {}, isFolder: true }; + } + current = current.children[part]; + }); + }); + return root; + }, [filteredEntries]); + + const [expandedNodes, setExpandedNodes] = useState>(new Set([""])); + const toggleNode = (path: string) => { + const newExpanded = new Set(expandedNodes); + if (newExpanded.has(path)) newExpanded.delete(path); + else newExpanded.add(path); + setExpandedNodes(newExpanded); + }; + + const renderTree = (node: any, path: string = "") => { + const nodePath = path ? `${path}/${node.name}` : node.name; + const isExpanded = expandedNodes.has(nodePath); + + if (!node.isFolder) { + const isSelected = selectedEntryIdx === node.originalIdx; + return ( +
{ playPressSound(); setSelectedEntryIdx(node.originalIdx); }} + className={`group flex items-center gap-2 px-2 py-1 cursor-pointer transition-colors ${isSelected ? "bg-[#FFFF55]/20 text-[#FFFF55]" : "hover:bg-white/5 text-white/80"}`} + > + + + {node.filename.split("/").pop()} + + {node.isCompressed && ( + ZLIB + )} +
+ ); + } + + return ( +
+
{ playPressSound(); toggleNode(nodePath); }} + className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-white/5 text-white/50 transition-colors group" + > + + ▶ + + + + {node.name} + +
+ + {isExpanded && ( + + {Object.values(node.children).map((child: any) => renderTree(child, nodePath))} + + )} + +
+ ); + }; + + const handleExportAll = async () => { + if (!arc || arc.entries.length === 0) return; + try { + const baseFolder = await TauriService.pickFolder(); + if (!baseFolder) return; + playPressSound(); + showNotification("Exporting all archive entries..."); + + for (const entry of arc.entries) { + const fileName = entry.filename.replace(/\//g, "_"); + await TauriService.writeBinaryFile(`${baseFolder}/${fileName}`, entry.data); + } + showNotification("All Entries Exported"); + } catch (err: any) { + if (err !== "CANCELED") showNotification("Export failed", "error"); + } + }; + + const selectedEntry = selectedEntryIdx !== null ? arc?.entries[selectedEntryIdx] : null; + return ( - - - -
-
-

ARC Editor

- {arc && editing: {arc.name}} -
+
+

+ ARC Editor +

+
+ + + {!arc ? (
@@ -244,74 +369,115 @@ export const ArcEditorView: React.FC = () => { className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "arc" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`} > - Archive + Archive
-
+
{activeTab === "arc" ? ( -
-
- setSearchTerm(e.target.value)} - className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors" - /> - +
+
+
+ setSearchTerm(e.target.value)} + className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors" + /> + +
+
+ {renderTree(treeData)} +
-
- - - - - - - - - - - - {filteredEntries.map((entry) => ( - - - - - - - - ))} - -
FilenameOffsetSizeFlagsActions
{entry.filename}0x{entry.ptr.toString(16).toUpperCase().padStart(8, '0')}{(entry.size / 1024).toFixed(1)} KB - {entry.isCompressed && ( - zlib - )} - - - - - -
+
+ {selectedEntry ? ( +
+
+

Entry Details

+

{selectedEntry.filename}

+
+
+
+ Size + {(selectedEntry.size / 1024).toFixed(1)} KB +
+
+ Format + {selectedEntry.isCompressed ? "Compressed" : "Raw"} +
+
+ +
+ + + + +
+ +
+
+
+ ) : ( +
+ +

Select an entry to view details

+
+ )}
) : ( -
+
{!loc ? (

No languages.loc found in archive

@@ -401,7 +567,7 @@ export const ArcEditorView: React.FC = () => {
)} -
+
+
- + {pck && ( +
+
+
+ Endianness: + +
+
+ XML Support: + +
+
+ Version: + {pck.version} +
+
+
+ )} + {!pck ? ( @@ -678,6 +771,13 @@ export default function PckEditorView() { Replace
+
)} + + {isRenamingAsset && ( + f.id === isRenamingAsset)?.path || ""} + onClose={() => setIsRenamingAsset(null)} + onConfirm={(newPath) => { + handleRenameAsset(isRenamingAsset, newPath); + setIsRenamingAsset(null); + }} + /> + )} + ); } + +function RenameAssetModal({ initialPath, onClose, onConfirm }: { initialPath: string, onClose: () => void, onConfirm: (path: string) => void }) { + const [path, setPath] = useState(initialPath); + return ( +
+ + +

Rename Asset

+
+
+ + setPath(e.target.value)} + className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-3 outline-none focus:border-[#FFFF55] transition-colors" + autoFocus + /> +
+
+ + +
+
+
+
+ ); +} diff --git a/src/services/TauriService.ts b/src/services/TauriService.ts index bf0e203..43a7f9f 100644 --- a/src/services/TauriService.ts +++ b/src/services/TauriService.ts @@ -206,6 +206,23 @@ export class TauriService { return invoke("pick_folder"); } + static async pickFile(title: string, filters: string[]): Promise { + return invoke("pick_file", { title, filters }); + } + + static async saveFileDialog(title: string, filename: string, filters: string[]): Promise { + return invoke("save_file_dialog", { title, filename, filters }); + } + + static async writeBinaryFile(path: string, data: Uint8Array): Promise { + return invoke("write_binary_file", { path, data: Array.from(data) }); + } + + static async readBinaryFile(path: string): Promise { + const data: number[] = await invoke("read_binary_file", { path }); + return new Uint8Array(data); + } + static async downloadLogo(id: string, url: string): Promise { return invoke("download_logo", { id, url }); }