diff --git a/.env.example b/.env.example index 4d6d3b1..18cffc7 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,19 @@ +# Global variable for development mode, enables dev features DEVELOPMENT=true + USE_LOCAL_SERVER=false + +# Using a mock server for updates (localhost:8000), check scripts/serve_mock_release.py for more info LOCAL_UPDATER_URL=false -SKIP_AGENT_OVERLAY_VERIFICATION=false \ No newline at end of file + +# Skips verification of the agent overlay files +SKIP_AGENT_OVERLAY_VERIFICATION=false + +# Mock some clients (for offline development without connection to servers) +MOCK_CLIENTS=false + +# Force a specific CDN server (overrides default list) +FORCE_CDN= + +# Force a specific Auth server (overrides default list) +FORCE_AUTH= \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index fa91aff..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,110 +0,0 @@ -## CollapseLoader – Copilot Instructions - -Purpose: GUI (Vue 3 + Tauri 2) launcher for secure Minecraft clients. Paired Django backend (`CollapseAuth`) provides auth, friends, admin + client distribution endpoints. Focus on fast startup, cached batched API calls, minimal network chatter, desktop-native UX via Tauri plugins. - -### Tech & Build - -- Frontend: Vue 3 (script setup + composition API), TypeScript, Vite, Tailwind (via `@tailwindcss/vite`) + DaisyUI. -- Desktop wrapper: Tauri 2 (`src-tauri/tauri.conf.json`); backend commands defined there (`beforeDevCommand`, `beforeBuildCommand`). -- Build scripts: `npm run tauri dev` (Vite + Tauri dev auto-run), `npm run build` (type check + build), `npm run tauri dev`, `npm run tauri build`. -- Type safety: Always run `yarn check` before large refactors; keep zero vue-tsc errors. - -### Runtime Initialization Flow - -1. Tauri starts, invokes Rust command `get_auth_url` -> `initializeAuthUrl()` stores base auth URL (`src/config.ts`). -2. i18n loads saved language via Tauri `get_settings` (`src/i18n/index.ts`). -3. Critical data preloading may call `/auth/init/` and `/auth/friends/batch/` (see `apiClient.preloadCriticalData`). -4. Friends subsystem sets up status polling (`useFriends.ts`). - -### API Layer Pattern (`src/services/apiClient.ts`) - -- Single exported instance (`apiClient`) with helper bindings (`apiGet`, `apiPost`, …); use these—not raw axios. -- Automatic base URL injection: prefix relative (`/...`) paths with `getAuthUrl()` result. -- Headers auto-set: `Authorization: Token ` (localStorage key: `authToken`), `Accept-Language` from i18n. -- Request de-duplication: concurrent identical GETs share a Promise (`pendingRequests` map). -- Smart caching (GET only) for whitelisted endpoints (friends, profile, init, admin). TTL varies by endpoint; DO NOT add new cache use without updating `shouldCache` + `getCacheTTL`. -- Cache invalidation on mutating calls uses substring/relationship heuristics (`invalidateCache` + `isRelatedEndpoint`). If adding new friend/admin endpoints ensure they land in relationship groups. -- Batch logic: `batchGet` tries aggregated friends endpoint (`/auth/friends/batch/`) else falls back to individual calls. -- Metrics available via `apiMetrics` & `apiCacheStats` (avoid expensive computations inside UI reactivity; snapshot when needed). - -### State & Composables - -- Global singleton state patterns: modules like `useFriends.ts` declare a reactive object outside the composable, then return computed wrappers. Re-importing keeps a shared instance—avoid manual new state copies. -- Friends module strategies: - - Primary load via `/auth/friends/batch/`; fallback to separate `/auth/friends/` + `/auth/friends/requests/`. - - Adaptive polling: interval increases with consecutive “no-change” status updates; resets on change. - - When mutating (send / respond / remove) rely on server response + local surgical updates instead of full reload where possible. - - Guard against redundant reload if data is <30s old unless `forceRefresh` param. -- Introduce new global reactive modules following: (1) external `reactive` store, (2) composable that only exposes computed & action functions, (3) defensive early returns if unauthenticated. - -### UI / Component Conventions - -- Modals: Controlled via `modalService` (`showModal(id, component, options, props, listeners)`); components stored with `markRaw` to avoid unnecessary reactivity. Provide a stable `id`; closing triggers delayed removal (300ms) for transition. -- Notifications: Use `sendNativeNotification` (permission negotiation included); keep failures silent beyond console. -- Avoid directly manipulating DOM (use Vue reactivity). For animations prefer GSAP if sequence logic is complex. - -### i18n - -- Add translations in `src/i18n/locales/.json`; register new locale in `getAvailableLanguages()` and backend `LANGUAGES` (Django). -- Changing language updates localStorage and persists via `save_settings` Tauri command. Always use `changeLanguage(locale)`. - -### Auth & Tokens - -- `authToken` stored in localStorage. On logout-like flows ensure: remove token, clear cached API (`apiClient.clearCache()`), reset composable states (e.g. call `clearFriendsData()`). -- Any feature needing early API calls must await `initializeAuthUrl()` before first request or use existing helpers that already run after init. - -### Adding New API Endpoints - -- Extend backend path under `/auth/...` for authenticated resources or `/api/...` for public/asset/client operations (see Django `Core/urls.py`). Align naming with existing verbs: list: `/resource/`, single action: `/resource//verb/`. -- Frontend: add wrapper in a dedicated service file under `src/services/` if logically grouped (avoid bloating `apiClient`). Keep raw endpoint strings central: if adding several related endpoints consider an enum or object literal export. -- If GET endpoint benefits from caching: add to `cacheableEndpoints` and determine TTL in `getCacheTTL`. - -### Performance & Network Hygiene - -- Prefer batch endpoints for related entities (pattern shown with friends). If building a batch, also supply graceful fallback. -- Logging: Keep `console.log` for performance metrics brief; follow existing descriptive style (action + duration + counts). Avoid noisy per-item logs. - -### Tauri Integration - -- Use `@tauri-apps/api/*` for core & plugin interactions; wrap new invoke calls in a small helper (like `initializeAuthUrl`) that validates types. -- Security: CSP disabled (`csp: null`)—do not inject remote scripts or eval; keep dependencies vetted. - -### Styling / Tailwind - -- Use semantic utility class groupings; DaisyUI components allowed. Prefer consistent sizing tokens already present—inspect existing components before new custom CSS. - -### Versioning & Release Helpers - -- Version lives both in `package.json` and `src-tauri/tauri.conf.json`; scripts in `scripts/` (e.g. `bump_version.py`) manage sync. When bumping manually, update both or run helper script. - -### When Contributing Code - -- Use existing helper bindings (`apiGet` etc.) instead of recreating axios logic. -- Maintain singleton composable pattern; never export raw reactive state without encapsulation. -- Update relationships in `apiClient.isRelatedEndpoint` when new endpoints should invalidate caches. -- Keep mutation methods optimistic only if server returns minimal data; otherwise fetch and rely on batch loader for coherence. - -### Quick Examples - -Fetch + cache profile: - -```ts -import { apiGet } from '@/services/apiClient'; -const profile = await apiGet('/auth/profile/'); -``` - -Invalidate via mutation: - -```ts -import { apiPost } from '@/services/apiClient'; -await apiPost('/auth/friends/send/', { username }); // related friends caches auto-invalidated -``` - -### Avoid / Anti-Patterns - -- Do NOT call axios directly; breaks caching & metrics. -- Do NOT duplicate polling timers; reuse existing adaptive logic or centralize new pollers similarly. -- Avoid refetch loops: always check freshness timestamps like friends module (<30s shortcut) before heavy reloads. -- Do NOT run `yarn check` after changes, i will check it myself, and say if there are any issues. - -Questions / gaps: If adding (1) new batch patterns, (2) new Tauri commands, or (3) complex cache invalidation groups—highlight them for doc update. diff --git a/.github/daisyui.instructions.md b/.github/instructions/daisyui.instructions.md similarity index 100% rename from .github/daisyui.instructions.md rename to .github/instructions/daisyui.instructions.md diff --git a/.github/instructions/vue.instructions.md b/.github/instructions/vue.instructions.md deleted file mode 100644 index 34bd5c8..0000000 --- a/.github/instructions/vue.instructions.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -applyTo: "**/*.vue" ---- - -Do not use `@` in import paths. Use relative paths instead. -To check code problems in code use your tools, not request to run tasks \ No newline at end of file diff --git a/README.md b/README.md index b0b7091..81d957e 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,69 @@ -

+

-

+

CollapseLoader

-

CollapseLoader

+[![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/dest4590/CollapseLoader/build.yml?style=for-the-badge&logo=githubactions&logoColor=ffffff&color=%2382B155)](https://github.com/dest4590/CollapseLoader/actions) +[![GitHub Release](https://img.shields.io/github/v/release/dest4590/CollapseLoader?display_name=tag&style=for-the-badge&logo=alwaysdata&logoColor=ffffff)](https://github.com/dest4590/CollapseLoader/releases/latest) +[![GitHub Pre-release](https://img.shields.io/github/v/release/dest4590/CollapseLoader?include_prereleases&display_name=tag&style=for-the-badge&logo=buffer&label=Prerelease)](https://github.com/dest4590/CollapseLoader/releases) -

- GitHub Actions Workflow Status - - GitHub Release - - GitHub Pre-release -

+## A Simple Tool to Run Safe Minecraft Cheat Clients -

GUI Utility to Run Secure Minecraft Hacked Clients

+Download the latest version from our [official website](https://collapseloader.org) or [GitHub releases](https://github.com/dest4590/CollapseLoader/releases). -

- Download the latest version from our official website or GitHub releases. -

+
--- ## About CollapseLoader -CollapseLoader is an open-source GUI utility designed to securely launch Minecraft cheat clients on Windows and Linux. Built with Rust and Tauri, it prioritizes safety, transparency, and ease of use. Key features include: +CollapseLoader is a free, open-source tool built with Rust and Tauri. It helps you safely launch Minecraft cheat clients on Windows and Linux. We focus on safety, honesty, and making things easy to use. -- **Secure Client Management**: Only thoroughly vetted clients are supported, ensuring a safe experience. -- **Open-Source Transparency**: All code is available on [GitHub](https://github.com/dest4590/CollapseLoader) for community review. -- **Cross-Platform Support**: Works seamlessly on Windows and Linux. -- **Virus-Free Guarantee**: Every client and build is scanned for malware using advanced tools and VirusTotal. +### Key Features -Join our [Discord community](https://collapseloader.org/discord) for support and updates! +- **Strict Checks**: We only support clients that pass our security tests. +- **Open Source**: Anyone can check our code on [GitHub](https://github.com/dest4590/CollapseLoader). +- **Works Everywhere**: Runs great on both Windows and Linux. +- **No Viruses**: We scan every client and update with powerful antivirus tools to make sure they are clean. + +Join our [Discord community](https://collapseloader.org/discord) for help and news! --- ## Why CollapseLoader is Safe -CollapseLoader is designed with security as a top priority. Here’s why you can trust it: - -- **Fully Open-Source**: All components, including the launcher, API, and Discord bot, are available on [GitHub](https://github.com/CollapseLoader) for public auditing. -- **Rigorous Client Verification**: Each client is tested using virtual machines, decompilers, debuggers, and VirusTotal to ensure it’s free of malware. -- **No Obfuscation**: Clients with protectors like VMProtect or Themida are prohibited, and CollapseLoader’s code is free of obfuscation for full transparency. -- **Automated Builds**: Built via GitHub Actions, ensuring reproducible and tamper-free releases. Download builds directly from [GitHub Actions](https://github.com/dest4590/CollapseLoader/actions). +We built CollapseLoader with safety in mind: +- **Public Code**: All parts of our project are on [GitHub](https://github.com/CollapseLoader) for anyone to check. +- **Tested Clients**: We test each client using special tools and virus scanners to ensure they are safe, using our own [CollapseScanner](https://github.com/CollapseLoader/CollapseScanner) tool. +- **No Hidden Code**: We do not allow clients with hidden or "obfuscated" code. All clients code are fully open for review. +- **Automatic Builds**: Our downloads are built automatically by GitHub Actions, so you know the files are safe and unchanged. -> **Note**: While CollapseLoader ensures maximum safety, always exercise caution with cheat clients, as they carry inherent risks. Download only from official sources. +> **Note**: Even though we make things as safe as possible, always be careful with cheat clients. Only download from official sources. -For more details, see our [Security Overview](SECURITY.md). +For more info, read our [Security Overview](SECURITY.md). --- -## Showcase +## Screenshots -

- - -

-

- - -

+
+ + + + + + +
--- -## Installation +## How to Install + +### What You Need + +- **Windows**: Windows 10 or newer. +- **Linux**: A modern Linux system (like Ubuntu 20.04 or newer). -### Prerequisites -- **Windows**: Windows 10 or later. -- **Linux**: A modern Linux distribution (e.g., Ubuntu 20.04+). -- **Dependencies**: Rust and Tauri (for building from source). +### Download -**Download the Latest Release**: - - Visit [GitHub Releases](https://github.com/dest4590/CollapseLoader/releases) or [collapseloader.org](https://collapseloader.org). - - Download the appropriate binary for your platform (Windows or Linux). \ No newline at end of file +Go to [GitHub Releases](https://github.com/dest4590/CollapseLoader/releases) or [collapseloader.org](https://collapseloader.org). Download the file for your system (Windows or Linux). diff --git a/SECURITY-RU.md b/SECURITY-RU.md new file mode 100644 index 0000000..e3a6198 --- /dev/null +++ b/SECURITY-RU.md @@ -0,0 +1,45 @@ +# CollapseLoader: Почему это безопасно + +### [Русская версия](SECURITY-RU.md) + +### Дополнительный документ о безопасности [здесь](https://docs.google.com/document/d/1XNgaQqfSbuimnGh2WTufgm0pXGoeDI3ezNgcF6hPq9s/edit?usp=sharing) + +CollapseLoader — это бесплатная программа для запуска чит-клиентов Minecraft. Мы на 100% открыты и проверяем всё, чтобы обеспечить вашу безопасность. + +Наша система безопасности состоит из трёх простых частей: + +## 1. Каждый может посмотреть наш код + +- **Полностью открытый исходный код:** Весь код CollapseLoader доступен на нашей [организации GitHub](https://github.com/CollapseLoader). +- **Никаких секретов:** У нас нет ничего скрытого, бэкдоров или вредоносного кода. +- **Проверка сообществом:** Люди могут изучить наш код, чтобы убедиться в его безопасности. + +## 2. Мы проверяем клиенты на вирусы + +У нас есть строгое правило: **если клиент выглядит небезопасным или пытается скрыть свой код (обфускацией), мы его не добавляем.** + +### Наш инструмент: CollapseScanner + +Мы используем нашу программу **CollapseScanner** для проверки файлов. Он находит: + +- **Вирусы:** Любое вредоносное ПО. +- **Подозрительные соединения:** Попытки подключения к странным сайтам или сбор данных. +- **Скрытый код:** Код, который был обфусцирован, чтобы скрыть свои действия. **Мы отклоняем любой клиент, который пытается скрыть свой код.** + +## 3. Безопасные загрузки + +- **Автоматическая сборка:** Мы используем **GitHub Actions** для сборки программы. Это означает, что никто не может тайно изменить код. +- **Никаких подмен:** Файл, который вы скачиваете, в точности соответствует нашему коду. Никто не может тайно добавить вирус в ссылку для скачивания. + +--- + +## Советы по безопасности + +Даже с нашими проверками всегда будьте осторожны. Следуйте этим советам: + +- **Официальные источники:** Скачивайте только с нашей [страницы релизов GitHub](https://github.com/CollapseLoader) или с сайта [collapseloader.org](https://collapseloader.org). +- **Обновляйтесь чаще:** Всегда используйте последнюю версию для максимальной безопасности. + +--- + +_Последнее обновление: 2 декабря 2025 г._ diff --git a/SECURITY.md b/SECURITY.md index d0ddaf7..5a9dc94 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,69 +1,45 @@ -# CollapseLoader Security Overview +# CollapseLoader: Why It's Safe -CollapseLoader is an open-source launcher for Minecraft cheat clients, designed with a strong emphasis on safety, transparency, and user trust. Below, we outline the key measures that ensure CollapseLoader is a secure tool for users. Our commitment to security is built into every aspect of the project, from development to distribution. +### [Russian version](SECURITY-RU.md) ---- - -## Why CollapseLoader is Safe - -### 1. Fully Open-Source Code -CollapseLoader is entirely open-source, with all components—launcher, API, and Discord bot—publicly available on GitHub ([CollapseLoader Organization](https://github.com/CollapseLoader)). This transparency allows anyone to: -- Review the code for potential vulnerabilities or malicious components. -- Verify the integrity of the launcher and its processes. -- Contribute to improving security through community audits. +### Another security document [here](https://docs.google.com/document/d/1XNgaQqfSbuimnGh2WTufgm0pXGoeDI3ezNgcF6hPq9s/edit?usp=sharing) -**Why it matters**: Open-source software eliminates the risk of hidden malicious code, as the community and security experts can independently verify its safety. +CollapseLoader is a free tool for Minecraft cheat clients. We are 100% open and check everything to keep you safe. -### 2. Rigorous Client Verification -Every cheat client included in CollapseLoader undergoes thorough security checks to ensure it is free of malware or harmful code: -- **Multi-layered scanning**: Clients are tested using virtual machines, decompilers, and debuggers to detect any suspicious behavior. -- **VirusTotal integration**: Each build is automatically submitted to VirusTotal for independent analysis, providing an additional layer of assurance. -- **No obfuscation or protected clients**: Clients using protectors like VMProtect or Themida are strictly prohibited, as they can hide malicious code. CollapseLoader ensures all clients are free of obfuscation, making their code fully transparent and verifiable. +Our safety plan has three simple parts: -**Why it matters**: These checks minimize the risk of distributing harmful clients, protecting users from potential threats. +## 1. Everyone Can See Our Code -### 3. Transparent Build Process -CollapseLoader is built with a focus on reproducibility and trust: -- **Automated builds via GitHub Actions**: Every version of the launcher is compiled on GitHub Actions servers, reducing the risk of human interference or tampering. -- **Downloadable builds**: All builds are available for download directly from GitHub Actions, allowing users to verify their integrity. -- **Python-based implementation**: Written in Python, CollapseLoader’s code can be easily decompiled and inspected, further enhancing transparency. +- **Fully Open Source:** All the code for CollapseLoader is on our [GitHub organization](https://github.com/CollapseLoader). +- **No Secrets:** We have no hidden tricks, backdoors, or harmful code. +- **Community Checks:** People can look at our code to make sure it is safe. -**Why it matters**: An automated and verifiable build process ensures that what you download is exactly what was intended, with no hidden modifications. +## 2. We Check Clients for Viruses -### 4. No Code Obfuscation -CollapseLoader strictly avoids code obfuscation, particularly for strings, ensuring that the launcher’s functionality is fully readable and understandable. This approach eliminates the possibility of hidden malicious code and makes it easier for users and developers to audit the software. +We have a strict rule: **if a client looks unsafe or tries to hide its code (obfuscation), we do not add it.** -**Why it matters**: Transparent code is a cornerstone of trust, as it allows anyone to confirm that CollapseLoader operates as intended. +### Our Tool: CollapseScanner -### 5. Active Maintenance and Community Support -The CollapseLoader team is committed to maintaining a secure and reliable tool: -- **Regular updates**: The launcher is actively maintained, with updates to address bugs, improve security, and add new features. -- **Responsive support**: Users can report issues or seek help via GitHub Issues or our Discord server, ensuring quick resolution of any concerns. -- **Community-driven improvements**: Contributions from the community help identify and fix potential vulnerabilities, keeping the launcher secure. +We use a special tool called **CollapseScanner** to check files. It finds: -**Why it matters**: Ongoing support and community involvement ensure that CollapseLoader remains safe and up-to-date with the latest security practices. +- **Viruses:** Any bad software or malware. +- **Bad Connections:** Attempts to connect to strange websites or collecting data. +- **Hidden Code:** Code that is obfuscated to hide what it does. **We reject any client that tries to hide its code.** -### 6. Strict Client Selection Criteria -Only clients that meet stringent safety standards are included in CollapseLoader: -- Clients must be free of any obfuscation or protection mechanisms that could hide malicious code. -- Each client is vetted to ensure compatibility with Minecraft and compliance with CollapseLoader’s security policies. +## 3. Safe Downloads -**Why it matters**: By carefully curating the list of supported clients, we reduce the risk of users encountering unsafe or unverified software. +- **Built Automatically:** We use **GitHub Actions** to build the program. This means no one can secretly change the code. +- **No Tricks:** The file you download matches our code exactly. No one can secretly add a virus to the download link. --- -## Additional Safety Recommendations -While CollapseLoader is designed with robust security measures, users should exercise caution when using cheat clients, as they inherently carry risks due to their nature. To maximize your safety: -- Always download CollapseLoader from official sources, such as our GitHub repository or [collapseloader.org](https://collapseloader.org/). -- Verify the integrity of downloaded files by checking their checksums or reviewing VirusTotal reports. -- Stay engaged with our community on Discord or GitHub to report any issues or suspicious behavior. +## Safety Tips ---- - -## Conclusion -CollapseLoader is built with security and transparency at its core. Through open-source development, rigorous client verification, automated builds, and active community support, we ensure that users can trust our launcher. By adhering to these principles, CollapseLoader provides a safe and reliable way to access Minecraft cheat clients. +Even with our checks, always be careful. Follow these tips: -For more details, explore our source code on [GitHub](https://github.com/CollapseLoader) or join our [Discord community](https://collapseloader.org/discord) for support and updates. +- **Official Sources:** Only download from our [GitHub releases page](https://github.com/CollapseLoader) or [collapseloader.org](https://collapseloader.org). +- **Update Often:** Always use the latest version for the best safety. --- -*Last updated: July 22, 2025* + +_Last updated: 2 December, 2025_ diff --git a/docs/body.md b/docs/body.md index 0ce48e2..7415d61 100644 --- a/docs/body.md +++ b/docs/body.md @@ -1,98 +1,86 @@ -COLLAPSELOADER 0.2.0 (FABRIC) | BETA ERA +CollapseLoader 0.2.3 (NullPtr) -The project has moved from alpha to beta testing +<< CHANGELOG >> +[~] fixes for most of the interface, lag, freezes, etc. -Important notice: -There may be bugs with Fabric — please report them immediately in Discord: https://collapseloader.org/discord +<< LINKS >> + +[VirusTotal](https://www.virustotal.com/gui/file-analysis/YWMxNjhjYmY1ZDA2YmVkNGEwODEzMWM4ZGZhYTU1M2Y6MTc2MzMxOTAzMg==/detection) -[+] added fabric support — a long-awaited feature -[+] added profile avatars -[+] added client filters -[~] optimized some tabs -[~] redesigned parts of the UI
do not read, for internal updater -```changelog +```json { - "entries": [ + "entries": [ + { + "version": "v0.2.3", + "date": "2025-11-16", + "highlights": [ + "Bug fixes & interface fixes", + "DPI Bypass" + ], + "changes": [ + { + "category": "improvement", + "description_key": "updater.changelogs.improvement.v0_2_3.bug_fixes", + "icon": "✨" + }, { - "version": "v0.2.0", - "date": "2025-09-16", - "highlights": [ - "Переход из альфа в бета", - "Добавлена поддержка Fabric" - ], - "changes": [ - { - "category": "feature", - "description_key": "updater.changelogs.feature.v0_2_0.fabric_support", - "icon": "✨" - }, - { - "category": "feature", - "description_key": "updater.changelogs.feature.v0_2_0.avatars_added", - "icon": "🖼️" - }, - { - "category": "feature", - "description_key": "updater.changelogs.feature.v0_2_0.client_filters", - "icon": "🔎" - }, - { - "category": "improvement", - "description_key": "updater.changelogs.improvement.v0_2_0.optimized_tabs", - "icon": "⚡" - }, - { - "category": "improvement", - "description_key": "updater.changelogs.improvement.v0_2_0.redesigned_ui", - "icon": "🎨" - } - ] + "category": "feature", + "description_key": "updater.changelogs.feature.v0_2_3.dpi_bypass", + "icon": "🛠" } - ], - "translations": { - "en": { - "updater": { - "changelogs": { - "feature": { - "v0_2_0": { - "fabric_support": "Added Fabric support", - "avatars_added": "Added profile avatars", - "client_filters": "Added client filters" - } - }, - "improvement": { - "v0_2_0": { - "optimized_tabs": "Optimized some tabs", - "redesigned_ui": "Redesigned parts of the UI" - } - } - } + ] + } + ], + "translations": { + "en": { + "updater": { + "categories": { + "feature": "Feature", + "improvement": "Improvement", + "bugfix": "Bugfix", + "other": "Other" + }, + "changelogs": { + "feature": { + "v0_2_3": { + "dpi_bypass": "DPI Bypass, now the loader works anywhere in the world, even where it is banned, powered by Zapret" + } + }, + "improvement": { + "v0_2_3": { + "bug_fixes": "Fixes for most of the interface, lag, freezes, etc." } + } + } + } + }, + "ru": { + "updater": { + "categories": { + "feature": "Фича", + "improvement": "Улучшение", + "bugfix": "Исправление ошибки", + "other": "Другое" }, - "ru": { - "updater": { - "changelogs": { - "feature": { - "v0_2_0": { - "fabric_support": "Добавлена поддержка Fabric", - "avatars_added": "Добавлены аватарки для профилей", - "client_filters": "Добавлены фильтры для клиентов" - } - }, - "improvement": { - "v0_2_0": { - "optimized_tabs": "Оптимизированы некоторые вкладки", - "redesigned_ui": "Переделаны некоторые части интерфейса" - } - } - } + "changelogs": { + "feature": { + "v0_2_3": { + "dpi_bypass": "DPI Bypass, теперь лоадер работает с любой точки мира, даже там где он запрещён, powered by Zapret" + } + }, + "improvement": { + "v0_2_3": { + "bug_fixes": "Исправления большей части интерфейса, лагов, фризов и т.д" } + } } + } } + } } ``` diff --git a/docs/mock_release.json b/docs/mock_release.json index a86fa3e..328630c 100644 --- a/docs/mock_release.json +++ b/docs/mock_release.json @@ -1,15 +1,15 @@ { - "tag_name": "v0.2.1", - "name": "v0.2.1", + "tag_name": "v0.2.3", + "name": "v0.2.3", "body": "#file:body.md", - "html_url": "https://github.com/dest4590/CollapseLoader/releases/tag/v0.2.1", + "html_url": "https://github.com/dest4590/CollapseLoader/releases/tag/v0.2.3", "assets": [ { - "name": "CollapseLoader-0.2.1.msi", - "browser_download_url": "https://example.com/CollapseLoader-0.2.1.msi", + "name": "CollapseLoader-0.2.3.msi", + "browser_download_url": "https://example.com/CollapseLoader-0.2.3.msi", "size": 100 } ], - "published_at": "2025-08-29T12:00:00Z", + "published_at": "2025-11-16T12:00:00Z", "prerelease": false } diff --git a/package-lock.json b/package-lock.json index 75ef0d7..4743553 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,41 +1,41 @@ { "name": "collapseloader", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "collapseloader", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@guolao/vue-monaco-editor": "1.6.0", - "@tauri-apps/api": "2.9.0", + "@tauri-apps/api": "2.9.1", "@tauri-apps/plugin-dialog": "2.4.2", "@tauri-apps/plugin-fs": "2.4.4", "@tauri-apps/plugin-notification": "2.3.3", "@tauri-apps/plugin-opener": "2.5.2", "axios": "1.13.2", - "daisyui": "5.5.5", - "gsap": "3.13.0", - "lucide-vue-next": "0.553.0", - "monaco-editor": "^0.54.0", - "vue": "3.5.24", - "vue-i18n": "11.1.12", + "daisyui": "5.5.8", + "gsap": "3.14.1", + "lucide-vue-next": "0.556.0", + "monaco-editor": "^0.55.1", + "vue": "3.5.25", + "vue-i18n": "11.2.2", "vue3-lottie": "3.3.1" }, "devDependencies": { "@tailwindcss/vite": "4.1.17", - "@tauri-apps/cli": "2.9.4", - "@types/node": "24.10.1", - "@typescript-eslint/eslint-plugin": "8.46.4", - "@typescript-eslint/parser": "8.46.4", - "@vitejs/plugin-vue": "6.0.1", + "@tauri-apps/cli": "2.9.6", + "@types/node": "24.10.2", + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", + "@vitejs/plugin-vue": "6.0.2", "eslint": "9.39.1", - "eslint-plugin-vue": "10.5.1", + "eslint-plugin-vue": "10.6.2", "tailwindcss": "4.1.17", "typescript": "5.9.3", - "vite": "7.2.2", - "vue-tsc": "3.1.4" + "vite": "7.2.7", + "vue-tsc": "3.1.8" } }, "node_modules/@babel/helper-string-parser": { @@ -814,13 +814,13 @@ } }, "node_modules/@intlify/core-base": { - "version": "11.1.12", - "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.12.tgz", - "integrity": "sha512-whh0trqRsSqVLNEUCwU59pyJZYpU8AmSWl8M3Jz2Mv5ESPP6kFh4juas2NpZ1iCvy7GlNRffUD1xr84gceimjg==", + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.2.tgz", + "integrity": "sha512-0mCTBOLKIqFUP3BzwuFW23hYEl9g/wby6uY//AC5hTgQfTsM2srCYF2/hYGp+a5DZ/HIFIgKkLJMzXTt30r0JQ==", "license": "MIT", "dependencies": { - "@intlify/message-compiler": "11.1.12", - "@intlify/shared": "11.1.12" + "@intlify/message-compiler": "11.2.2", + "@intlify/shared": "11.2.2" }, "engines": { "node": ">= 16" @@ -830,12 +830,12 @@ } }, "node_modules/@intlify/message-compiler": { - "version": "11.1.12", - "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.12.tgz", - "integrity": "sha512-Fv9iQSJoJaXl4ZGkOCN1LDM3trzze0AS2zRz2EHLiwenwL6t0Ki9KySYlyr27yVOj5aVz0e55JePO+kELIvfdQ==", + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.2.tgz", + "integrity": "sha512-XS2p8Ff5JxWsKhgfld4/MRQzZRQ85drMMPhb7Co6Be4ZOgqJX1DzcZt0IFgGTycgqL8rkYNwgnD443Q+TapOoA==", "license": "MIT", "dependencies": { - "@intlify/shared": "11.1.12", + "@intlify/shared": "11.2.2", "source-map-js": "^1.0.2" }, "engines": { @@ -846,9 +846,9 @@ } }, "node_modules/@intlify/shared": { - "version": "11.1.12", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.12.tgz", - "integrity": "sha512-Om86EjuQtA69hdNj3GQec9ZC0L0vPSAnXzB3gP/gyJ7+mA7t06d9aOAiqMZ+xEOsumGP4eEBlfl8zF2LOTzf2A==", + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.2.tgz", + "integrity": "sha512-OtCmyFpSXxNu/oET/aN6HtPCbZ01btXVd0f3w00YsHOb13Kverk1jzA2k47pAekM55qbUw421fvPF1yxZ+gicw==", "license": "MIT", "engines": { "node": ">= 16" @@ -907,56 +907,18 @@ } }, "node_modules/@monaco-editor/loader": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.6.1.tgz", - "integrity": "sha512-w3tEnj9HYEC73wtjdpR089AqkUPskFRcdkxsiSFt3SoUc3OHpmu+leP94CXBm4mHfefmhsdfI0ZQu6qJ0wgtPg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", "license": "MIT", "dependencies": { "state-local": "^1.0.6" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.29", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", - "integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==", + "version": "1.0.0-beta.50", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz", + "integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==", "dev": true, "license": "MIT" }, @@ -1541,9 +1503,9 @@ } }, "node_modules/@tauri-apps/api": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.0.tgz", - "integrity": "sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz", + "integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==", "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -1551,9 +1513,9 @@ } }, "node_modules/@tauri-apps/cli": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.4.tgz", - "integrity": "sha512-pvylWC9QckrOS9ATWXIXcgu7g2hKK5xTL5ZQyZU/U0n9l88SEFGcWgLQNa8WZmd+wWIOWhkxOFcOl3i6ubDNNw==", + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.6.tgz", + "integrity": "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==", "dev": true, "license": "Apache-2.0 OR MIT", "bin": { @@ -1567,23 +1529,23 @@ "url": "https://opencollective.com/tauri" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.9.4", - "@tauri-apps/cli-darwin-x64": "2.9.4", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.4", - "@tauri-apps/cli-linux-arm64-gnu": "2.9.4", - "@tauri-apps/cli-linux-arm64-musl": "2.9.4", - "@tauri-apps/cli-linux-riscv64-gnu": "2.9.4", - "@tauri-apps/cli-linux-x64-gnu": "2.9.4", - "@tauri-apps/cli-linux-x64-musl": "2.9.4", - "@tauri-apps/cli-win32-arm64-msvc": "2.9.4", - "@tauri-apps/cli-win32-ia32-msvc": "2.9.4", - "@tauri-apps/cli-win32-x64-msvc": "2.9.4" + "@tauri-apps/cli-darwin-arm64": "2.9.6", + "@tauri-apps/cli-darwin-x64": "2.9.6", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6", + "@tauri-apps/cli-linux-arm64-gnu": "2.9.6", + "@tauri-apps/cli-linux-arm64-musl": "2.9.6", + "@tauri-apps/cli-linux-riscv64-gnu": "2.9.6", + "@tauri-apps/cli-linux-x64-gnu": "2.9.6", + "@tauri-apps/cli-linux-x64-musl": "2.9.6", + "@tauri-apps/cli-win32-arm64-msvc": "2.9.6", + "@tauri-apps/cli-win32-ia32-msvc": "2.9.6", + "@tauri-apps/cli-win32-x64-msvc": "2.9.6" } }, "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.4.tgz", - "integrity": "sha512-9rHkMVtbMhe0AliVbrGpzMahOBg3rwV46JYRELxR9SN6iu1dvPOaMaiC4cP6M/aD1424ziXnnMdYU06RAH8oIw==", + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.6.tgz", + "integrity": "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==", "cpu": [ "arm64" ], @@ -1598,9 +1560,9 @@ } }, "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.4.tgz", - "integrity": "sha512-VT9ymNuT06f5TLjCZW2hfSxbVtZDhORk7CDUDYiq5TiSYQdxkl8MVBy0CCFFcOk4QAkUmqmVUA9r3YZ/N/vPRQ==", + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.6.tgz", + "integrity": "sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==", "cpu": [ "x64" ], @@ -1615,9 +1577,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.4.tgz", - "integrity": "sha512-tTWkEPig+2z3Rk0zqZYfjUYcgD+aSm72wdrIhdYobxbQZOBw0zfn50YtWv+av7bm0SHvv75f0l7JuwgZM1HFow==", + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.6.tgz", + "integrity": "sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==", "cpu": [ "arm" ], @@ -1632,9 +1594,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.4.tgz", - "integrity": "sha512-ql6vJ611qoqRYHxkKPnb2vHa27U+YRKRmIpLMMBeZnfFtZ938eao7402AQCH1mO2+/8ioUhbpy9R/ZcLTXVmkg==", + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.6.tgz", + "integrity": "sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==", "cpu": [ "arm64" ], @@ -1649,9 +1611,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.4.tgz", - "integrity": "sha512-vg7yNn7ICTi6hRrcA/6ff2UpZQP7un3xe3SEld5QM0prgridbKAiXGaCKr3BnUBx/rGXegQlD/wiLcWdiiraSw==", + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.6.tgz", + "integrity": "sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==", "cpu": [ "arm64" ], @@ -1666,9 +1628,9 @@ } }, "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.4.tgz", - "integrity": "sha512-l8L+3VxNk6yv5T/Z/gv5ysngmIpsai40B9p6NQQyqYqxImqYX37pqREoEBl1YwG7szGnDibpWhidPrWKR59OJA==", + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.6.tgz", + "integrity": "sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==", "cpu": [ "riscv64" ], @@ -1683,9 +1645,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.4.tgz", - "integrity": "sha512-PepPhCXc/xVvE3foykNho46OmCyx47E/aG676vKTVp+mqin5d+IBqDL6wDKiGNT5OTTxKEyNlCQ81Xs2BQhhqA==", + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.6.tgz", + "integrity": "sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==", "cpu": [ "x64" ], @@ -1700,9 +1662,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.4.tgz", - "integrity": "sha512-zcd1QVffh5tZs1u1SCKUV/V7RRynebgYUNWHuV0FsIF1MjnULUChEXhAhug7usCDq4GZReMJOoXa6rukEozWIw==", + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.6.tgz", + "integrity": "sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==", "cpu": [ "x64" ], @@ -1717,9 +1679,9 @@ } }, "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.4.tgz", - "integrity": "sha512-/7ZhnP6PY04bEob23q8MH/EoDISdmR1wuNm0k9d5HV7TDMd2GGCDa8dPXA4vJuglJKXIfXqxFmZ4L+J+MO42+w==", + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.6.tgz", + "integrity": "sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==", "cpu": [ "arm64" ], @@ -1734,9 +1696,9 @@ } }, "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.4.tgz", - "integrity": "sha512-1LmAfaC4Cq+3O1Ir1ksdhczhdtFSTIV51tbAGtbV/mr348O+M52A/xwCCXQank0OcdBxy5BctqkMtuZnQvA8uQ==", + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.6.tgz", + "integrity": "sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==", "cpu": [ "ia32" ], @@ -1751,9 +1713,9 @@ } }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz", - "integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==", + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.6.tgz", + "integrity": "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==", "cpu": [ "x64" ], @@ -1818,29 +1780,34 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "24.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.2.tgz", + "integrity": "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", - "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", + "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/type-utils": "8.46.4", - "@typescript-eslint/utils": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", - "graphemer": "^1.4.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/type-utils": "8.49.0", + "@typescript-eslint/utils": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" @@ -1853,23 +1820,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.4", + "@typescript-eslint/parser": "^8.49.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", - "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", + "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4" }, "engines": { @@ -1885,14 +1851,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", - "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", + "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.4", - "@typescript-eslint/types": "^8.46.4", + "@typescript-eslint/tsconfig-utils": "^8.49.0", + "@typescript-eslint/types": "^8.49.0", "debug": "^4.3.4" }, "engines": { @@ -1907,14 +1873,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", - "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", + "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4" + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1925,9 +1891,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", - "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", + "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", "dev": true, "license": "MIT", "engines": { @@ -1942,15 +1908,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", - "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", + "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1967,9 +1933,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", - "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", + "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", "dev": true, "license": "MIT", "engines": { @@ -1981,21 +1947,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", - "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", + "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.4", - "@typescript-eslint/tsconfig-utils": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/project-service": "8.49.0", + "@typescript-eslint/tsconfig-utils": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", + "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "engines": { @@ -2010,16 +1975,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", - "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", + "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4" + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2034,13 +1999,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", - "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", + "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/types": "8.49.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2065,13 +2030,13 @@ } }, "node_modules/@vitejs/plugin-vue": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz", - "integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.2.tgz", + "integrity": "sha512-iHmwV3QcVGGvSC1BG5bZ4z6iwa1SOpAPWmnjOErd4Ske+lZua5K9TtAVdx0gMBClJ28DViCbSmZitjWZsWO3LA==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.29" + "@rolldown/pluginutils": "1.0.0-beta.50" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -2082,68 +2047,68 @@ } }, "node_modules/@volar/language-core": { - "version": "2.4.23", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.23.tgz", - "integrity": "sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==", + "version": "2.4.26", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.26.tgz", + "integrity": "sha512-hH0SMitMxnB43OZpyF1IFPS9bgb2I3bpCh76m2WEK7BE0A0EzpYsRp0CCH2xNKshr7kacU5TQBLYn4zj7CG60A==", "dev": true, "license": "MIT", "dependencies": { - "@volar/source-map": "2.4.23" + "@volar/source-map": "2.4.26" } }, "node_modules/@volar/source-map": { - "version": "2.4.23", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.23.tgz", - "integrity": "sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q==", + "version": "2.4.26", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.26.tgz", + "integrity": "sha512-JJw0Tt/kSFsIRmgTQF4JSt81AUSI1aEye5Zl65EeZ8H35JHnTvFGmpDOBn5iOxd48fyGE+ZvZBp5FcgAy/1Qhw==", "dev": true, "license": "MIT" }, "node_modules/@volar/typescript": { - "version": "2.4.23", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.23.tgz", - "integrity": "sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==", + "version": "2.4.26", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.26.tgz", + "integrity": "sha512-N87ecLD48Sp6zV9zID/5yuS1+5foj0DfuYGdQ6KHj/IbKvyKv1zNX6VCmnKYwtmHadEO6mFc2EKISiu3RDPAvA==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.23", + "@volar/language-core": "2.4.26", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "node_modules/@vue/compiler-core": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz", - "integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==", + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", + "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==", "license": "MIT", "dependencies": { "@babel/parser": "^7.28.5", - "@vue/shared": "3.5.24", + "@vue/shared": "3.5.25", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz", - "integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==", + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz", + "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.24", - "@vue/shared": "3.5.24" + "@vue/compiler-core": "3.5.25", + "@vue/shared": "3.5.25" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz", - "integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==", + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz", + "integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==", "license": "MIT", "dependencies": { "@babel/parser": "^7.28.5", - "@vue/compiler-core": "3.5.24", - "@vue/compiler-dom": "3.5.24", - "@vue/compiler-ssr": "3.5.24", - "@vue/shared": "3.5.24", + "@vue/compiler-core": "3.5.25", + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", @@ -2151,13 +2116,13 @@ } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz", - "integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==", + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz", + "integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.24", - "@vue/shared": "3.5.24" + "@vue/compiler-dom": "3.5.25", + "@vue/shared": "3.5.25" } }, "node_modules/@vue/devtools-api": { @@ -2167,13 +2132,13 @@ "license": "MIT" }, "node_modules/@vue/language-core": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.1.4.tgz", - "integrity": "sha512-n/58wm8SkmoxMWkUNUH/PwoovWe4hmdyPJU2ouldr3EPi1MLoS7iDN46je8CsP95SnVBs2axInzRglPNKvqMcg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.1.8.tgz", + "integrity": "sha512-PfwAW7BLopqaJbneChNL6cUOTL3GL+0l8paYP5shhgY5toBNidWnMXWM+qDwL7MC9+zDtzCF2enT8r6VPu64iw==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.23", + "@volar/language-core": "2.4.26", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.0.0", @@ -2190,67 +2155,54 @@ } } }, - "node_modules/@vue/language-core/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@vue/reactivity": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz", - "integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==", + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz", + "integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.24" + "@vue/shared": "3.5.25" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz", - "integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==", + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz", + "integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.24", - "@vue/shared": "3.5.24" + "@vue/reactivity": "3.5.25", + "@vue/shared": "3.5.25" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz", - "integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==", + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz", + "integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.24", - "@vue/runtime-core": "3.5.24", - "@vue/shared": "3.5.24", + "@vue/reactivity": "3.5.25", + "@vue/runtime-core": "3.5.25", + "@vue/shared": "3.5.25", "csstype": "^3.1.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz", - "integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==", + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz", + "integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.24", - "@vue/shared": "3.5.24" + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25" }, "peerDependencies": { - "vue": "3.5.24" + "vue": "3.5.25" } }, "node_modules/@vue/shared": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz", - "integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==", + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz", + "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==", "license": "MIT" }, "node_modules/acorn": { @@ -2259,7 +2211,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2295,9 +2246,9 @@ } }, "node_modules/alien-signals": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.0.tgz", - "integrity": "sha512-yufC6VpSy8tK3I0lO67pjumo5JvDQVQyr38+3OHqe6CHl1t2VZekKZ7EKKZSqk0cRmE7U7tfZbpXiKNzuc+ckg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.1.tgz", + "integrity": "sha512-ogkIWbVrLwKtHY6oOAXaYkAxP+cTH7V5FZ5+Tm4NZFd8VDZ6uNMDrfzqctTZ42eTMCSR3ne3otpcxmqSnFfPYA==", "dev": true, "license": "MIT" }, @@ -2365,19 +2316,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2486,15 +2424,15 @@ } }, "node_modules/csstype": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.1.tgz", - "integrity": "sha512-98XGutrXoh75MlgLihlNxAGbUuFQc7l1cqcnEZlLNKc0UrVdPndgmaDmYTDDh929VS/eqTZV0rozmhu2qqT1/g==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/daisyui": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.5.tgz", - "integrity": "sha512-ekvI93ZkWIJoCOtDl0D2QMxnWvTejk9V5nWBqRv+7t0xjiBXqAK5U6o6JE2RPvlIC3EqwNyUoIZSdHX9MZK3nw==", + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.8.tgz", + "integrity": "sha512-6psL9jIEOFOw68V10j/BKCWcRgx8dh81mmNxShr+g7HDM6UHNoPharlp9zq/PQkHNuGU1ZQsajR3HgpvavbRKQ==", "license": "MIT", "funding": { "url": "https://github.com/saadeghi/daisyui?sponsor=1" @@ -2545,10 +2483,13 @@ } }, "node_modules/dompurify": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", - "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", - "license": "(MPL-2.0 OR Apache-2.0)" + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -2696,7 +2637,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2752,16 +2692,16 @@ } }, "node_modules/eslint-plugin-vue": { - "version": "10.5.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.5.1.tgz", - "integrity": "sha512-SbR9ZBUFKgvWAbq3RrdCtWaW0IKm6wwUiApxf3BVTNfqUIo4IQQmreMg2iHFJJ6C/0wss3LXURBJ1OwS/MhFcQ==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.6.2.tgz", + "integrity": "sha512-nA5yUs/B1KmKzvC42fyD0+l9Yd+LtEpVhWRbXuDj0e+ZURcTtyRbMDWUeJmTAh2wC6jC83raS63anNM2YT3NPw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", "nth-check": "^2.1.1", - "postcss-selector-parser": "^6.0.15", + "postcss-selector-parser": "^7.1.0", "semver": "^7.6.3", "xml-name-validator": "^4.0.0" }, @@ -2949,36 +2889,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2993,16 +2903,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3016,19 +2916,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3209,17 +3096,10 @@ "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/gsap": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz", - "integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==", + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.1.tgz", + "integrity": "sha512-nOFRJpVu+U0IPgKzfC5MEdZrJ1iVidGaOuxK0+Un+7vMOHa5rw/SKOaMaRRyHZiJKaeAEALTBwnj6R3gifyGBw==", "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, "node_modules/has-flag": { @@ -3331,16 +3211,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3716,9 +3586,9 @@ "license": "MIT" }, "node_modules/lucide-vue-next": { - "version": "0.553.0", - "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.553.0.tgz", - "integrity": "sha512-0tg9XT+VCElTT+7EXXbBRhWe1nU7Doa32Xv/dHP5/LCleFVgV6cAqziM3C7AetqmsYIsfAtNwRYdtvs4Ds7aUg==", + "version": "0.556.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.556.0.tgz", + "integrity": "sha512-JvdCM2smkWrMDhkfD/FpZiWekkbWD6MZLstIFx/FOVZgULrnMr5hegCB9LlTdgllEFnQYQs8hhHC1WYcAV9HTA==", "license": "ISC", "peerDependencies": { "vue": ">=3.0.1" @@ -3754,30 +3624,6 @@ "node": ">= 0.4" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3816,13 +3662,12 @@ } }, "node_modules/monaco-editor": { - "version": "0.54.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz", - "integrity": "sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==", + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { - "dompurify": "3.1.7", + "dompurify": "3.2.7", "marked": "14.0.0" } }, @@ -3975,13 +3820,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -4016,9 +3861,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", "dependencies": { @@ -4055,27 +3900,6 @@ "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4086,17 +3910,6 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", @@ -4139,30 +3952,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -4296,33 +4085,6 @@ } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -4355,7 +4117,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4389,12 +4150,11 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", - "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4482,20 +4242,6 @@ } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -4504,17 +4250,16 @@ "license": "MIT" }, "node_modules/vue": { - "version": "3.5.24", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz", - "integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==", + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", + "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "license": "MIT", - "peer": true, "dependencies": { - "@vue/compiler-dom": "3.5.24", - "@vue/compiler-sfc": "3.5.24", - "@vue/runtime-dom": "3.5.24", - "@vue/server-renderer": "3.5.24", - "@vue/shared": "3.5.24" + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-sfc": "3.5.25", + "@vue/runtime-dom": "3.5.25", + "@vue/server-renderer": "3.5.25", + "@vue/shared": "3.5.25" }, "peerDependencies": { "typescript": "*" @@ -4531,6 +4276,7 @@ "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", @@ -4555,6 +4301,7 @@ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -4563,13 +4310,13 @@ } }, "node_modules/vue-i18n": { - "version": "11.1.12", - "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.1.12.tgz", - "integrity": "sha512-BnstPj3KLHLrsqbVU2UOrPmr0+Mv11bsUZG0PyCOzsawCivk8W00GMXHeVUWIDOgNaScCuZah47CZFE+Wnl8mw==", + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.2.tgz", + "integrity": "sha512-ULIKZyRluUPRCZmihVgUvpq8hJTtOqnbGZuv4Lz+byEKZq4mU0g92og414l6f/4ju+L5mORsiUuEPYrAuX2NJg==", "license": "MIT", "dependencies": { - "@intlify/core-base": "11.1.12", - "@intlify/shared": "11.1.12", + "@intlify/core-base": "11.2.2", + "@intlify/shared": "11.2.2", "@vue/devtools-api": "^6.5.0" }, "engines": { @@ -4583,14 +4330,14 @@ } }, "node_modules/vue-tsc": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.1.4.tgz", - "integrity": "sha512-GsRJxttj4WkmXW/zDwYPGMJAN3np/4jTzoDFQTpTsI5Vg/JKMWamBwamlmLihgSVHO66y9P7GX+uoliYxeI4Hw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.1.8.tgz", + "integrity": "sha512-deKgwx6exIHeZwF601P1ktZKNF0bepaSN4jBU3AsbldPx9gylUc1JDxYppl82yxgkAgaz0Y0LCLOi+cXe9HMYA==", "dev": true, "license": "MIT", "dependencies": { - "@volar/typescript": "2.4.23", - "@vue/language-core": "3.1.4" + "@volar/typescript": "2.4.26", + "@vue/language-core": "3.1.8" }, "bin": { "vue-tsc": "bin/vue-tsc.js" diff --git a/package.json b/package.json index 4617ef4..f0264b1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "collapseloader", "private": true, - "version": "0.2.3", + "version": "0.2.4", "type": "module", "scripts": { "dev": "vite", @@ -16,32 +16,32 @@ }, "dependencies": { "@guolao/vue-monaco-editor": "1.6.0", - "@tauri-apps/api": "2.9.0", + "@tauri-apps/api": "2.9.1", "@tauri-apps/plugin-dialog": "2.4.2", "@tauri-apps/plugin-fs": "2.4.4", "@tauri-apps/plugin-notification": "2.3.3", "@tauri-apps/plugin-opener": "2.5.2", "axios": "1.13.2", - "daisyui": "5.5.5", - "gsap": "3.13.0", - "lucide-vue-next": "0.553.0", - "monaco-editor": "^0.54.0", - "vue": "3.5.24", - "vue-i18n": "11.1.12", + "daisyui": "5.5.8", + "gsap": "3.14.1", + "lucide-vue-next": "0.556.0", + "monaco-editor": "^0.55.1", + "vue": "3.5.25", + "vue-i18n": "11.2.2", "vue3-lottie": "3.3.1" }, "devDependencies": { "@tailwindcss/vite": "4.1.17", - "@tauri-apps/cli": "2.9.4", - "@types/node": "24.10.1", - "@typescript-eslint/eslint-plugin": "8.46.4", - "@typescript-eslint/parser": "8.46.4", - "@vitejs/plugin-vue": "6.0.1", + "@tauri-apps/cli": "2.9.6", + "@types/node": "24.10.2", + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", + "@vitejs/plugin-vue": "6.0.2", "eslint": "9.39.1", - "eslint-plugin-vue": "10.5.1", + "eslint-plugin-vue": "10.6.2", "tailwindcss": "4.1.17", "typescript": "5.9.3", - "vite": "7.2.2", - "vue-tsc": "3.1.4" + "vite": "7.2.7", + "vue-tsc": "3.1.8" } -} \ No newline at end of file +} diff --git a/scripts/bump_version.py b/scripts/bump_version.py index 13f077d..748eaf3 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -17,11 +17,28 @@ r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" ) +CODENAME_RE = re.compile( + r'^pub\s+(?:const|static)\s+CODENAME\s*:\s*&str\s*=\s*"([^"]+)";', re.M +) + -def is_valid_semver(v: str) -> bool: +def is_valid_semver(v: Optional[str]) -> bool: + if v is None: + return False return bool(SEMVER_RE.match(v)) +def read_codename_version(path: Path) -> Optional[str]: + try: + text = path.read_text(encoding="utf-8") + m = CODENAME_RE.search(text) + if m: + return m.group(1) + return None + except Exception: + return None + + def read_package_json_version(path: Path) -> Optional[str]: try: data = json.loads(path.read_text(encoding="utf-8")) @@ -94,11 +111,30 @@ def _replace(match: re.Match) -> str: path.write_text(new_text, encoding="utf-8") +def write_codename_version(path: Path, new_codename: str) -> None: + text = path.read_text(encoding="utf-8") + + def _replace(match: re.Match) -> str: + kind = match.group(1) + return f'pub {kind} CODENAME: &str = "{new_codename}";' + + new_text, n = re.subn( + r"(?m)^pub\s+(const|static)\s+CODENAME\s*:\s*&str\s*=\s*\"([^\"]+)\";", + _replace, + text, + count=1, + ) + if n == 0: + raise RuntimeError("Could not find CODENAME line in file") + path.write_text(new_text, encoding="utf-8") + + def gather_versions(root: Path) -> Dict[str, Optional[str]]: out: Dict[str, Optional[str]] = {} pkg = root / "package.json" cargo = root / "src-tauri" / "Cargo.toml" tauri = root / "src-tauri" / "tauri.conf.json" + globals_rs = root / "src-tauri" / "src" / "core" / "utils" / "globals.rs" out[str(pkg.relative_to(root))] = ( read_package_json_version(pkg) if pkg.exists() else None @@ -109,35 +145,44 @@ def gather_versions(root: Path) -> Dict[str, Optional[str]]: out[str(tauri.relative_to(root))] = ( read_tauri_conf_version(tauri) if tauri.exists() else None ) + out[str(globals_rs.relative_to(root))] = ( + read_codename_version(globals_rs) if globals_rs.exists() else None + ) return out def apply_version( - root: Path, new_version: str, apply: bool + root: Path, new_version: Optional[str], new_codename: Optional[str], apply: bool ) -> Dict[str, Dict[str, Optional[str]]]: results: Dict[str, Dict[str, Optional[str]]] = {} pkg = root / "package.json" cargo = root / "src-tauri" / "Cargo.toml" tauri = root / "src-tauri" / "tauri.conf.json" + globals_rs = root / "src-tauri" / "src" / "core" / "utils" / "globals.rs" files = [ - (pkg, read_package_json_version, write_package_json_version), - (cargo, read_cargo_toml_version, write_cargo_toml_version), - (tauri, read_tauri_conf_version, write_tauri_conf_version), + (pkg, read_package_json_version, write_package_json_version, "version"), + (cargo, read_cargo_toml_version, write_cargo_toml_version, "version"), + (tauri, read_tauri_conf_version, write_tauri_conf_version, "version"), + (globals_rs, read_codename_version, write_codename_version, "codename"), ] - for path, reader, writer in files: + for path, reader, writer, ftype in files: key = str(path.relative_to(root)) if not path.exists(): results[key] = {"old": None, "new": None} continue old = reader(path) - results[key] = {"old": old, "new": new_version} - if apply: + if ftype == "codename": + new_val = new_codename + else: + new_val = new_version + results[key] = {"old": old, "new": new_val} + if apply and new_val is not None: backup = path.with_suffix(path.suffix + ".bak") if not backup.exists(): backup.write_bytes(path.read_bytes()) - writer(path, new_version) + writer(path, new_val) return results @@ -147,11 +192,13 @@ def undo_version(root: Path) -> Dict[str, Dict[str, Optional[str]]]: pkg = root / "package.json" cargo = root / "src-tauri" / "Cargo.toml" tauri = root / "src-tauri" / "tauri.conf.json" + globals_rs = root / "src-tauri" / "src" / "core" / "utils" / "globals.rs" files = [ (pkg, read_package_json_version), (cargo, read_cargo_toml_version), (tauri, read_tauri_conf_version), + (globals_rs, read_codename_version), ] for path, reader in files: @@ -234,11 +281,19 @@ def main(argv: list[str]) -> int: action="store_true", help="Restore files from .bak backups (revert previous --apply)", ) + p.add_argument( + "--codename", + "-c", + help="New codename to write (if omitted, show current codename)", + ) args = p.parse_args(argv) - if args.version is None: + if args.version is None and args.codename is None: if args.apply: - print("Error: --apply requires a version to write.", file=sys.stderr) + print( + "Error: --apply requires a version or codename to write.", + file=sys.stderr, + ) return 2 if args.undo: results = undo_version(ROOT) @@ -248,18 +303,31 @@ def main(argv: list[str]) -> int: print_current_versions(versions) return 0 - new_version = args.version.strip() - if not is_valid_semver(new_version): - print( - f"Error: '{new_version}' is not a valid semantic version.", file=sys.stderr - ) - return 2 + new_version: Optional[str] = None + if args.version is not None: + new_version = args.version.strip() + if not is_valid_semver(new_version): + print( + f"Error: '{new_version}' is not a valid semantic version.", + file=sys.stderr, + ) + return 2 + new_codename: Optional[str] = None + if args.codename is not None: + new_codename = args.codename.strip() + if new_codename == "": + print("Error: --codename requires a non-empty value.", file=sys.stderr) + return 2 if args.undo: - print("Error: --undo cannot be used together with a version.", file=sys.stderr) - return 2 + if new_version is not None or new_codename is not None: + print( + "Error: --undo cannot be used together with a version or codename.", + file=sys.stderr, + ) + return 2 - results = apply_version(ROOT, new_version, args.apply) + results = apply_version(ROOT, new_version, new_codename, args.apply) print_preview(results, args.apply) return 0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 502e86c..220cb35 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -187,7 +187,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -222,7 +222,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -260,6 +260,20 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "futures-core", + "getrandom 0.2.16", + "instant", + "pin-project-lite", + "rand 0.8.5", + "tokio", +] + [[package]] name = "base64" version = "0.21.7" @@ -464,9 +478,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.46" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "jobserver", @@ -488,7 +502,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" dependencies = [ "byteorder", "fnv", - "uuid 1.18.1", + "uuid 1.19.0", ] [[package]] @@ -539,8 +553,9 @@ dependencies = [ [[package]] name = "collapseloader" -version = "0.2.3" +version = "0.2.4" dependencies = [ + "backoff", "base64 0.22.1", "chrono", "colored", @@ -557,6 +572,7 @@ dependencies = [ "semver", "serde", "serde_json", + "socket2", "sysinfo", "tauri", "tauri-build", @@ -566,7 +582,8 @@ dependencies = [ "tauri-plugin-opener", "thiserror 2.0.17", "tokio", - "uuid 1.18.1", + "uuid 1.19.0", + "windows 0.62.2", "winreg", "zip", ] @@ -682,9 +699,9 @@ dependencies = [ [[package]] name = "crc" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] @@ -753,7 +770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -763,7 +780,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -787,7 +804,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -798,7 +815,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -825,7 +842,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -838,7 +855,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -914,7 +931,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -928,9 +945,9 @@ dependencies = [ [[package]] name = "dlopen2" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b54f373ccf864bf587a89e880fb7610f8d73f3045f13580948ccbcaff26febff" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" dependencies = [ "dlopen2_derive", "libc", @@ -940,13 +957,13 @@ dependencies = [ [[package]] name = "dlopen2_derive" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1034,9 +1051,9 @@ dependencies = [ [[package]] name = "endi" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enumflags2" @@ -1056,7 +1073,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1188,7 +1205,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1282,7 +1299,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1532,7 +1549,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1611,7 +1628,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1626,7 +1643,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.12.0", + "indexmap 2.12.1", "slab", "tokio", "tokio-util", @@ -1641,9 +1658,9 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -1692,12 +1709,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1786,9 +1802,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "base64 0.22.1", "bytes", @@ -1892,9 +1908,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1906,9 +1922,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1965,12 +1981,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -1993,6 +2009,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2100,9 +2125,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -2149,7 +2174,7 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ "cssparser", "html5ever", - "indexmap 2.12.0", + "indexmap 2.12.1", "selectors", ] @@ -2191,9 +2216,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "libloading" @@ -2227,9 +2252,9 @@ dependencies = [ [[package]] name = "libz-rs-sys" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" +checksum = "8b484ba8d4f775eeca644c452a56650e544bf7e617f1d170fe7298122ead5222" dependencies = [ "zlib-rs", ] @@ -2257,9 +2282,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lzma-rust2" @@ -2279,9 +2304,9 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mac-notification-sys" -version = "0.6.8" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee70bb2bba058d58e252d2944582d634fc884fc9c489a966d428dedcf653e97" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" dependencies = [ "cc", "objc2 0.6.3", @@ -2311,7 +2336,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -2359,9 +2384,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -2391,9 +2416,9 @@ dependencies = [ [[package]] name = "native-dialog" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "454a816a8fed70bb5ba4ae90901073173dd5142f5df5ee503acde1ebcfaa4c4b" +checksum = "89853bb05334e192e6646290ea94ca31bcb80443f25ad40ebf478b6dafb08d6c" dependencies = [ "ascii", "block2 0.6.2", @@ -2560,7 +2585,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -2845,9 +2870,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "open" -version = "5.3.2" +version = "5.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" dependencies = [ "dunce", "is-wsl", @@ -2889,7 +2914,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3112,7 +3137,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3178,7 +3203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.12.0", + "indexmap 2.12.1", "quick-xml 0.38.4", "serde", "time", @@ -3273,7 +3298,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.23.7", + "toml_edit 0.23.9", ] [[package]] @@ -3501,7 +3526,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3535,9 +3560,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" dependencies = [ "base64 0.22.1", "bytes", @@ -3653,9 +3678,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" dependencies = [ "zeroize", ] @@ -3713,7 +3738,7 @@ dependencies = [ "serde", "serde_json", "url", - "uuid 1.18.1", + "uuid 1.19.0", ] [[package]] @@ -3749,7 +3774,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3854,7 +3879,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3865,7 +3890,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3889,7 +3914,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3924,15 +3949,15 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.15.1" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.12.0", + "indexmap 2.12.1", "schemars 0.9.0", "schemars 1.1.0", "serde_core", @@ -3943,14 +3968,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.15.1" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3972,7 +3997,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -4015,18 +4040,18 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" dependencies = [ "libc", ] [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "siphasher" @@ -4183,9 +4208,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.110" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -4209,7 +4234,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -4223,7 +4248,7 @@ dependencies = [ "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows", + "windows 0.61.3", ] [[package]] @@ -4294,7 +4319,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -4308,7 +4333,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -4319,9 +4344,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.9.3" +version = "2.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e492485dd390b35f7497401f67694f46161a2a00ffd800938d5dd3c898fb9d8" +checksum = "8a3868da5508446a7cd08956d523ac3edf0a8bc20bf7e4038f9a95c2800d2033" dependencies = [ "anyhow", "bytes", @@ -4365,14 +4390,14 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] name = "tauri-build" -version = "2.5.2" +version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87d6f8cafe6a75514ce5333f115b7b1866e8e68d9672bf4ca89fc0f35697ea9d" +checksum = "17fcb8819fd16463512a12f531d44826ce566f486d7ccd211c9c8cebdaec4e08" dependencies = [ "anyhow", "cargo_toml", @@ -4392,9 +4417,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.5.1" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ef707148f0755110ca54377560ab891d722de4d53297595380a748026f139f" +checksum = "9fa9844cefcf99554a16e0a278156ae73b0d8680bbc0e2ad1e4287aadd8489cf" dependencies = [ "base64 0.22.1", "brotli", @@ -4408,34 +4433,34 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.110", + "syn 2.0.111", "tauri-utils", "thiserror 2.0.17", "time", "url", - "uuid 1.18.1", + "uuid 1.19.0", "walkdir", ] [[package]] name = "tauri-macros" -version = "2.5.1" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71664fd715ee6e382c05345ad258d6d1d50f90cf1b58c0aa726638b33c2a075d" +checksum = "3764a12f886d8245e66b7ee9b43ccc47883399be2019a61d80cf0f4117446fde" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", "tauri-codegen", "tauri-utils", ] [[package]] name = "tauri-plugin" -version = "2.5.1" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076c78a474a7247c90cad0b6e87e593c4c620ed4efdb79cbe0214f0021f6c39d" +checksum = "0e1d0a4860b7ff570c891e1d2a586bf1ede205ff858fbc305e0b5ae5d14c1377" dependencies = [ "anyhow", "glob", @@ -4525,15 +4550,15 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.17", "url", - "windows", + "windows 0.61.3", "zbus", ] [[package]] name = "tauri-runtime" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926" +checksum = "87f766fe9f3d1efc4b59b17e7a891ad5ed195fa8d23582abb02e6c9a01137892" dependencies = [ "cookie", "dpi", @@ -4551,14 +4576,14 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] name = "tauri-runtime-wry" -version = "2.9.1" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93" +checksum = "187a3f26f681bdf028f796ccf57cf478c1ee422c50128e5a0a6ebeb3f5910065" dependencies = [ "gtk", "http", @@ -4577,15 +4602,15 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] [[package]] name = "tauri-utils" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673" +checksum = "76a423c51176eb3616ee9b516a9fa67fed5f0e78baaba680e44eb5dd2cc37490" dependencies = [ "anyhow", "brotli", @@ -4615,7 +4640,7 @@ dependencies = [ "toml 0.9.8", "url", "urlpattern", - "uuid 1.18.1", + "uuid 1.19.0", "walkdir", ] @@ -4638,7 +4663,7 @@ checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml 0.37.5", "thiserror 2.0.17", - "windows", + "windows 0.61.3", "windows-version", ] @@ -4692,7 +4717,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -4703,7 +4728,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -4772,7 +4797,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -4826,13 +4851,13 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.12.1", "serde_core", "serde_spanned 1.0.3", "toml_datetime 0.7.3", "toml_parser", "toml_writer", - "winnow 0.7.13", + "winnow 0.7.14", ] [[package]] @@ -4859,7 +4884,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.12.1", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -4870,7 +4895,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.12.1", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -4879,14 +4904,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.23.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.12.1", "toml_datetime 0.7.3", "toml_parser", - "winnow 0.7.13", + "winnow 0.7.14", ] [[package]] @@ -4895,7 +4920,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ - "winnow 0.7.13", + "winnow 0.7.14", ] [[package]] @@ -4921,9 +4946,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", @@ -4951,9 +4976,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -4962,20 +4987,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", ] @@ -5137,13 +5162,13 @@ dependencies = [ [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -5237,9 +5262,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -5250,9 +5275,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", @@ -5263,9 +5288,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5273,22 +5298,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] @@ -5368,9 +5393,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -5428,7 +5453,7 @@ checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -5442,7 +5467,7 @@ checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -5452,7 +5477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" dependencies = [ "thiserror 2.0.17", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5530,11 +5555,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5546,6 +5583,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -5580,7 +5626,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -5591,7 +5648,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -5602,7 +5659,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -5627,6 +5684,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -5776,6 +5843,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -5934,9 +6010,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] @@ -6008,7 +6084,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -6054,7 +6130,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", "synstructure", ] @@ -6085,9 +6161,9 @@ dependencies = [ "tokio", "tracing", "uds_windows", - "uuid 1.18.1", + "uuid 1.19.0", "windows-sys 0.61.2", - "winnow 0.7.13", + "winnow 0.7.14", "zbus_macros", "zbus_names", "zvariant", @@ -6102,7 +6178,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", "zbus_names", "zvariant", "zvariant_utils", @@ -6116,28 +6192,28 @@ checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" dependencies = [ "serde", "static_assertions", - "winnow 0.7.13", + "winnow 0.7.14", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -6157,7 +6233,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", "synstructure", ] @@ -6178,7 +6254,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -6211,7 +6287,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -6229,7 +6305,7 @@ dependencies = [ "flate2", "getrandom 0.3.4", "hmac", - "indexmap 2.12.0", + "indexmap 2.12.1", "lzma-rust2", "memchr", "pbkdf2", @@ -6243,9 +6319,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" +checksum = "36134c44663532e6519d7a6dfdbbe06f6f8192bde8ae9ed076e9b213f0e31df7" [[package]] name = "zopfli" @@ -6297,7 +6373,7 @@ dependencies = [ "enumflags2", "serde", "url", - "winnow 0.7.13", + "winnow 0.7.14", "zvariant_derive", "zvariant_utils", ] @@ -6311,7 +6387,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", "zvariant_utils", ] @@ -6324,6 +6400,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.110", - "winnow 0.7.13", + "syn 2.0.111", + "winnow 0.7.14", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 09ff3b8..4024a04 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "collapseloader" -version = "0.2.3" +version = "0.2.4" description = "CollapseLoader" authors = ["dest4590"] edition = "2021" @@ -11,25 +11,25 @@ name = "collapseloader_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] -tauri-build = { version = "2.5.2", features = [] } +tauri-build = { version = "2.5.3", features = [] } [dependencies] -tauri = { version = "2.9.3", features = ["devtools"] } +tauri = { version = "2.9.5", features = ["devtools"] } tauri-plugin-opener = "2.5.2" tauri-plugin-notification = "2.3.3" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" colored = "3.0.0" rand = "0.9.2" -reqwest = { version = "0.12.24", features = ["blocking", "json", "stream"] } +reqwest = { version = "0.12.25", features = ["blocking", "json", "stream"] } semver = "1.0.27" zip = "6.0.0" tokio = { version = "1.48.0", features = ["macros"] } opener = "0.8.3" -uuid = { version = "1.18.1", features = ["v4"] } +uuid = { version = "1.19.0", features = ["v4"] } chrono = { version = "0.4.42", features = ["serde"] } paste = "1.0.15" -open = "5.3.2" +open = "5.3.3" md5 = "0.8.0" discord-rich-presence = "1.0.0" futures-util = "0.3.31" @@ -38,8 +38,14 @@ tauri-plugin-dialog = "2.4.2" dotenvy = "0.15.7" tauri-plugin-fs = "2.4.4" thiserror = "2.0.17" -native-dialog = "0.9.3" +native-dialog = "0.9.4" sysinfo = "0.37.2" +backoff = { version = "0.4.0", features = ["tokio"] } +socket2 = "0.6.1" [target.'cfg(windows)'.dependencies] winreg = { version = "0.55.0" } +windows = { version = "0.62.2", features = ["Win32_UI_HiDpi"] } + +[profile.dev] +opt-level = 0 diff --git a/src-tauri/src/commands/clients.rs b/src-tauri/src/commands/clients.rs index c0bb2c1..f64c3db 100644 --- a/src-tauri/src/commands/clients.rs +++ b/src-tauri/src/commands/clients.rs @@ -1,11 +1,12 @@ use core::clients::{ client::{Client, CLIENT_LOGS}, - manager::{initialize_client_manager, CLIENT_MANAGER}, + manager::ClientManager, }; -use tauri::AppHandle; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, State}; use crate::core::{ - clients::custom_clients::{CustomClient, Version}, + clients::custom_clients::CustomClient, network::analytics::Analytics, storage::{ custom_clients::{CustomClientUpdate, CUSTOM_CLIENT_MANAGER}, @@ -19,7 +20,7 @@ use crate::core::{ }; use crate::core::{ storage::settings::SETTINGS, - utils::{discord_rpc, logging}, + utils::{discord_rpc, hashing::calculate_md5_hash, logging}, }; use crate::{ commands::utils::get_auth_url, @@ -33,16 +34,16 @@ use crate::{ core::{self, storage::data::DATA}, log_debug, log_error, log_info, log_warn, }; -use std::path::MAIN_SEPARATOR; +use std::fs::File; +use std::io::Read; use std::path::PathBuf; +use zip::ZipArchive; -fn get_client_by_id(id: u32) -> Result { - CLIENT_MANAGER +fn get_client_by_id(id: u32, manager: &Arc>) -> Result { + manager .lock() .map_err(|_| "Failed to acquire lock on client manager".to_string())? - .as_ref() - .ok_or_else(|| "Client manager not initialized".to_string())? .clients .iter() .find(|c| c.id == id) @@ -60,9 +61,15 @@ pub fn get_app_logs() -> Vec { } #[tauri::command] -pub async fn initialize_api() -> Result<(), String> { - log_info!("Initializing client manager via API"); - initialize_client_manager().await +pub async fn initialize_api(state: State<'_, Arc>>) -> Result<(), String> { + let clients = ClientManager::fetch_clients() + .await + .map_err(|e| e.to_string())?; + let mut manager = state + .lock() + .map_err(|_| "Failed to lock state".to_string())?; + manager.clients = clients; + Ok(()) } #[tauri::command] @@ -76,17 +83,16 @@ pub fn initialize_rpc() -> Result<(), String> { #[tauri::command] pub fn get_server_connectivity_status() -> ServerConnectivityStatus { - log_debug!("Fetching server connectivity status"); let servers = &SERVERS; servers.connectivity_status.lock().unwrap().clone() } #[tauri::command] -pub fn get_clients() -> Vec { - CLIENT_MANAGER +pub fn get_clients(state: State<'_, Arc>>) -> Vec { + state .lock() .ok() - .and_then(|manager| manager.as_ref().map(|m| m.clients.clone())) + .map(|manager| manager.clients.clone()) .unwrap_or_default() } @@ -95,9 +101,10 @@ pub async fn launch_client( id: u32, user_token: String, app_handle: AppHandle, + state: State<'_, Arc>>, ) -> Result<(), String> { log_info!("Attempting to launch client with ID: {}", id); - let client = get_client_by_id(id)?; + let client = get_client_by_id(id, &state)?; log_debug!("Found client '{}' for launch", client.name); let filename_for_if = if client.filename.contains("fabric/") { @@ -108,6 +115,8 @@ pub async fn launch_client( client.filename.clone() }; + const MAIN_SEPARATOR: char = std::path::MAIN_SEPARATOR; + let file_name = Data::get_filename(&client.filename); let jar_path = match client.client_type { ClientType::Default => { @@ -157,7 +166,7 @@ pub async fn launch_client( "Verifying MD5 hash for client {} before launch", client.name ); - let current_hash = Data::calculate_md5_hash(&jar_path)?; + let current_hash = calculate_md5_hash(&jar_path)?; if current_hash == client.md5_hash { log_info!( "MD5 hash verification successful for client {}", @@ -195,11 +204,11 @@ pub async fn launch_client( log_warn!("Failed to remove corrupted client file: {}", e); } - update_client_installed_status(id, false)?; + update_client_installed_status(id, false, state.clone())?; log_info!("Redownloading client: {} (ID: {})", client.name, id); client - .download() + .download(&state) .await .map_err(|e| { if e.contains("Hash verification failed") { @@ -232,9 +241,7 @@ pub async fn launch_client( log_info!("Verifying agent and overlay files before launch"); match AgentOverlayManager::verify_agent_overlay_files().await { - Ok(true) => { - log_debug!("Agent and overlay files verified successfully"); - } + Ok(true) => {} Ok(false) => { if !*SKIP_AGENT_OVERLAY_VERIFICATION { log_warn!("Agent/overlay files verification failed, attempting to download..."); @@ -253,28 +260,33 @@ pub async fn launch_client( let options = LaunchOptions::new(app_handle.clone(), user_token.clone(), false); log_info!("Executing client run for '{}'", client.name); - client.run(options).await + client.run(options, (*state).clone()).await } #[tauri::command] -pub async fn get_running_client_ids() -> Vec { - let handle = tokio::task::spawn_blocking(|| { - Client::get_running_clients() +pub async fn get_running_client_ids( + state: State<'_, Arc>>, +) -> Result, String> { + let manager = (*state).clone(); + let handle = tokio::task::spawn_blocking(move || { + Client::get_running_clients(&manager) .iter() .map(|client| client.id) .collect() }); - handle.await.unwrap_or_else(|e| { - log_error!("Failed to get running client IDs: {}", e); - Vec::new() - }) + handle + .await + .map_err(|e| format!("Failed to get running client IDs: {}", e)) } #[tauri::command] -pub async fn stop_client(id: u32) -> Result<(), String> { +pub async fn stop_client( + id: u32, + state: State<'_, Arc>>, +) -> Result<(), String> { log_info!("Attempting to stop client with ID: {}", id); - let client = get_client_by_id(id)?; + let client = get_client_by_id(id, &state)?; log_debug!("Found client '{}' to stop", client.name); let client_clone = client.clone(); @@ -295,18 +307,24 @@ pub fn get_client_logs(id: u32) -> Vec { } #[tauri::command] -pub async fn download_client_only(id: u32, app_handle: AppHandle) -> Result<(), String> { - let client = get_client_by_id(id)?; +pub async fn download_client_only( + id: u32, + app_handle: AppHandle, + state: State<'_, Arc>>, +) -> Result<(), String> { + let client = get_client_by_id(id, &state)?; log_info!("Starting download for client: {} (ID: {})", client.name, id); - let client_download = async { - client.download().await.map_err(|e| { + let state_clone = state.clone(); + let client_clone = client.clone(); + let client_download = async move { + client_clone.download(&state_clone).await.map_err(|e| { if e.contains("Hash verification failed") { - let _ = update_client_installed_status(id, false); + let _ = update_client_installed_status(id, false, state_clone.clone()); format!( "Hash verification failed for {}: The downloaded file is corrupted. Please try downloading again.", - client.name + client_clone.name ) } else { e @@ -322,7 +340,7 @@ pub async fn download_client_only(id: u32, app_handle: AppHandle) -> Result<(), tokio::try_join!(client_download, requirements_download)?; log_info!( - "Successfully downloaded client and requirements for '{}'", + "Client '{}' successfully installed with all requirements", client.name ); @@ -330,15 +348,20 @@ pub async fn download_client_only(id: u32, app_handle: AppHandle) -> Result<(), } #[tauri::command] -pub async fn reinstall_client(id: u32, app_handle: AppHandle) -> Result<(), String> { +pub async fn reinstall_client( + id: u32, + app_handle: AppHandle, + state: State<'_, Arc>>, +) -> Result<(), String> { log_info!("Starting reinstall for client ID: {}", id); - let client = get_client_by_id(id)?; + let client = get_client_by_id(id, &state)?; log_debug!("Found client '{}' for reinstall", client.name); let client_clone = client.clone(); + let manager = (*state).clone(); let handle = tokio::task::spawn_blocking(move || -> Result<(), String> { log_info!("Removing existing installation for '{}'", client_clone.name); - client_clone.remove_installation()?; + client_clone.remove_installation(&manager)?; log_info!( "Successfully removed existing installation for '{}'", client_clone.name @@ -350,13 +373,13 @@ pub async fn reinstall_client(id: u32, app_handle: AppHandle) -> Result<(), Stri .await .map_err(|e| format!("Reinstall task error: {e}"))??; - update_client_installed_status(id, false)?; + update_client_installed_status(id, false, state.clone())?; log_debug!( "Updated installed status to false for client '{}'", client.name ); - let download_result = client.download().await.map_err(|e| { + let download_result = client.download(&state).await.map_err(|e| { if e.contains("Hash verification failed") { format!( "Hash verification failed for {}: The downloaded file is corrupted. Please try again.", @@ -376,35 +399,26 @@ pub async fn reinstall_client(id: u32, app_handle: AppHandle) -> Result<(), Stri ); return Err(e.clone()); } - log_info!("Client '{}' downloaded successfully", client.name); let result = client.download_requirements(&app_handle).await; - if result.is_ok() { + + if download_result.is_ok() && result.is_ok() { log_info!( - "Client requirements for '{}' downloaded successfully", + "Client '{}' successfully installed with all requirements", client.name ); } - if result.is_ok() { - let _ = AgentOverlayManager::download_agent_overlay_files() - .await - .map_err(|e| { - log_warn!( - "Failed to download agent/overlay files during reinstall: {}", - e - ); - e - }); - } - result } #[tauri::command] -pub fn open_client_folder(id: u32) -> Result<(), String> { +pub fn open_client_folder( + id: u32, + state: State<'_, Arc>>, +) -> Result<(), String> { log_info!("Attempting to open folder for client ID: {}", id); - let client = get_client_by_id(id)?; + let client = get_client_by_id(id, &state)?; log_debug!("Found client '{}' to open folder", client.name); let client_dir_relative = DATA.get_as_folder(&client.filename); @@ -452,27 +466,19 @@ pub fn get_latest_client_logs(id: u32) -> Result { } #[tauri::command] -pub fn update_client_installed_status(id: u32, installed: bool) -> Result<(), String> { - log_debug!( - "Updating installed status for client ID {} to {}", - id, - installed - ); - if let Some(client) = CLIENT_MANAGER +pub fn update_client_installed_status( + id: u32, + installed: bool, + state: State<'_, Arc>>, +) -> Result<(), String> { + if let Some(client) = state .lock() .map_err(|_| "Failed to acquire lock on client manager".to_string())? - .as_mut() - .ok_or_else(|| "Client manager not initialized".to_string())? .clients .iter_mut() .find(|c| c.id == id) { client.meta.installed = installed; - log_info!( - "Set installed status of client '{}' to {}", - client.name, - installed - ); Ok(()) } else { log_warn!( @@ -484,18 +490,22 @@ pub fn update_client_installed_status(id: u32, installed: bool) -> Result<(), St } #[tauri::command] -pub async fn delete_client(id: u32) -> Result<(), String> { +pub async fn delete_client( + id: u32, + state: State<'_, Arc>>, +) -> Result<(), String> { log_info!("Attempting to delete client with ID: {}", id); - let client = get_client_by_id(id)?; + let client = get_client_by_id(id, &state)?; log_debug!("Found client '{}' for deletion", client.name); - let handle = tokio::task::spawn_blocking(move || client.remove_installation()); + let manager = (*state).clone(); + let handle = tokio::task::spawn_blocking(move || client.remove_installation(&manager)); match handle.await { Ok(result) => { if result.is_ok() { log_info!("Successfully deleted files for client ID: {}", id); - update_client_installed_status(id, false)?; + update_client_installed_status(id, false, state.clone())?; } else { log_error!("Failed to delete files for client ID {}: {:?}", id, result); } @@ -540,12 +550,14 @@ pub async fn get_client_details(client_id: u32) -> Result Result<(), String> { - if let Some(client) = CLIENT_MANAGER +pub fn increment_client_counter( + id: u32, + counter_type: String, + state: State<'_, Arc>>, +) -> Result<(), String> { + if let Some(client) = state .lock() .map_err(|_| "Failed to acquire lock on client manager".to_string())? - .as_mut() - .ok_or_else(|| "Client manager not initialized".to_string())? .clients .iter_mut() .find(|c| c.id == id) @@ -579,6 +591,34 @@ pub fn increment_client_counter(id: u32, counter_type: String) -> Result<(), Str } } +#[tauri::command] +pub fn detect_main_class(file_path: String) -> Result { + log_info!("Attempting to detect main class from: {}", file_path); + + let file = File::open(&file_path).map_err(|e| format!("Failed to open file: {}", e))?; + let mut archive = ZipArchive::new(file).map_err(|e| format!("Failed to read jar: {}", e))?; + + let mut manifest = archive + .by_name("META-INF/MANIFEST.MF") + .map_err(|_| "MANIFEST.MF not found in jar".to_string())?; + + let mut content = String::new(); + manifest + .read_to_string(&mut content) + .map_err(|e| format!("Failed to read manifest: {}", e))?; + + for line in content.lines() { + if line.starts_with("Main-Class:") { + let main_class = line.replace("Main-Class:", "").trim().to_string(); + log_info!("Detected main class: {}", main_class); + return Ok(main_class); + } + } + + log_warn!("Main-Class attribute not found in manifest"); + Err("Main-Class attribute not found in manifest".to_string()) +} + #[tauri::command] pub fn get_custom_clients() -> Vec { CUSTOM_CLIENT_MANAGER @@ -601,10 +641,9 @@ pub fn add_custom_client( .lock() .map_err(|_| "Failed to acquire lock on custom client manager".to_string())?; - let version_enum = Version::from_string(&version); let path_buf = PathBuf::from(file_path); - let custom_client = CustomClient::new(0, name, version_enum, filename, path_buf, main_class); + let custom_client = CustomClient::new(0, name, version, filename, path_buf, main_class); log_debug!("New custom client details: {:?}", custom_client); manager.add_client(custom_client) @@ -632,11 +671,9 @@ pub fn update_custom_client( .lock() .map_err(|_| "Failed to acquire lock on custom client manager".to_string())?; - let version_enum = version.map(|v| Version::from_string(&v)); - let updates = CustomClientUpdate { name, - version: version_enum, + version, main_class, }; @@ -649,6 +686,7 @@ pub async fn launch_custom_client( id: u32, user_token: String, app_handle: AppHandle, + state: State<'_, Arc>>, ) -> Result<(), String> { log_info!("Attempting to launch custom client with ID: {}", id); let custom_client = { @@ -689,7 +727,7 @@ pub async fn launch_custom_client( let options = LaunchOptions::new(app_handle.clone(), user_token.clone(), true); - client.run(options).await + client.run(options, (*state).clone()).await } #[tauri::command] diff --git a/src-tauri/src/commands/irc.rs b/src-tauri/src/commands/irc.rs new file mode 100644 index 0000000..e6495ed --- /dev/null +++ b/src-tauri/src/commands/irc.rs @@ -0,0 +1,143 @@ +use crate::core::utils::globals::IRC_HOST; +use crate::{log_error, log_info}; +use serde_json::json; +use std::sync::Arc; +use tauri::{AppHandle, Emitter, State}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; +use tokio::sync::Mutex; + +pub struct IrcState { + pub writer: Arc>>, +} + +impl Default for IrcState { + fn default() -> Self { + Self { + writer: Arc::new(Mutex::new(None)), + } + } +} + +#[tauri::command] +pub async fn connect_irc( + app: AppHandle, + state: State<'_, IrcState>, + token: Option, +) -> Result<(), String> { + let token = token.unwrap_or_default(); + let mode = if token.is_empty() { + "guest" + } else { + "authenticated" + }; + + log_info!("Connecting to IRC server at {} as {}", IRC_HOST, mode); + + match TcpStream::connect(IRC_HOST).await { + Ok(stream) => { + if let Err(e) = (|| -> std::io::Result<()> { + let sock_ref = socket2::SockRef::from(&stream); + let mut ka = socket2::TcpKeepalive::new(); + ka = ka.with_time(std::time::Duration::from_secs(20)); + ka = ka.with_interval(std::time::Duration::from_secs(20)); + sock_ref.set_tcp_keepalive(&ka) + })() { + log_error!("Failed to set TCP keepalive: {}", e); + return Err(format!("Failed to set TCP keepalive: {}", e)); + } + + let (reader, mut writer) = stream.into_split(); + let writer_handle = state.writer.clone(); + + let auth_packet = json!({ + "op": "auth", + "token": token, + "type": "loader", + "client": "CollapseLoader" + }); + + let auth_str = format!("{}\n", auth_packet.to_string()); + + if let Err(e) = writer.write_all(auth_str.as_bytes()).await { + log_error!("Failed to send auth to IRC: {}", e); + return Err(format!("Failed to send auth: {}", e)); + } + + *writer_handle.lock().await = Some(writer); + + let app_clone = app.clone(); + let writer_for_task = writer_handle.clone(); + + tokio::spawn(async move { + let mut reader = BufReader::new(reader); + let mut line = String::new(); + + app_clone.emit("irc-connected", ()).unwrap_or_default(); + + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => { + log_info!("IRC connection closed by server"); + app_clone.emit("irc-disconnected", ()).unwrap_or_default(); + let mut writer_guard = writer_for_task.lock().await; + *writer_guard = None; + break; + } + Ok(_) => { + let msg = line.trim().to_string(); + app_clone.emit("irc-message", msg).unwrap_or_default(); + } + Err(e) => { + log_error!("Error reading from IRC: {}", e); + app_clone + .emit("irc-error", e.to_string()) + .unwrap_or_default(); + app_clone.emit("irc-disconnected", ()).unwrap_or_default(); + let mut writer_guard = writer_for_task.lock().await; + *writer_guard = None; + break; + } + } + } + }); + + Ok(()) + } + Err(e) => { + log_error!("Failed to connect to IRC: {}", e); + Err(format!("Failed to connect: {}", e)) + } + } +} + +#[tauri::command] +pub async fn disconnect_irc(state: State<'_, IrcState>) -> Result<(), String> { + let mut writer = state.writer.lock().await; + if let Some(mut w) = writer.take() { + let _ = w.shutdown().await; + } + Ok(()) +} + +#[tauri::command] +pub async fn send_irc_message(state: State<'_, IrcState>, message: String) -> Result<(), String> { + let mut writer_guard = state.writer.lock().await; + if let Some(writer) = writer_guard.as_mut() { + let packet = json!({ + "op": "chat", + "content": message + }); + + let msg_str = format!("{}\n", packet.to_string()); + + if let Err(e) = writer.write_all(msg_str.as_bytes()).await { + log_error!("Failed to send IRC message: {}", e); + return Err(format!("Failed to send message: {}", e)); + } + Ok(()) + } else { + Err("Not connected".to_string()) + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e257577..f79083f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod clients; pub mod discord_rpc; +pub mod irc; pub mod presets; pub mod settings; pub mod updater; diff --git a/src-tauri/src/commands/presets.rs b/src-tauri/src/commands/presets.rs index e6c6923..4fb2c75 100644 --- a/src-tauri/src/commands/presets.rs +++ b/src-tauri/src/commands/presets.rs @@ -4,6 +4,7 @@ use chrono::Utc; use uuid::Uuid; #[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] pub struct CreatePresetInput { pub name: String, pub description: Option, @@ -35,6 +36,7 @@ pub struct CreatePresetInput { } #[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] pub struct UpdatePresetInput { pub id: String, pub name: String, diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index c2f2feb..cd25cdc 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -31,11 +31,11 @@ pub fn reset_flags() -> Result<(), String> { #[tauri::command] pub fn save_settings(input_settings: InputSettings) -> Result<(), String> { - log_info!("Saving application settings"); let mut current_settings = SETTINGS.lock().unwrap(); let config_path = current_settings.config_path.clone(); let old_discord_rpc_enabled = current_settings.discord_rpc_enabled.value; + #[cfg(target_os = "windows")] let old_dpi_bypass_enabled = current_settings.dpi_bypass.value; let discord_rpc_changed = old_discord_rpc_enabled != input_settings.discord_rpc_enabled.value; @@ -46,13 +46,11 @@ pub fn save_settings(input_settings: InputSettings) -> Result<(), String> { #[cfg(target_os = "windows")] let new_dpi_bypass_value = input_settings.dpi_bypass.value; - log_debug!("Applying new settings"); let input_settings_clone = input_settings.clone(); let new_settings = Settings::from_input(input_settings_clone, config_path); *current_settings = new_settings.clone(); new_settings.save_to_disk(); - log_info!("Settings saved to disk"); drop(current_settings); @@ -71,7 +69,7 @@ pub fn save_settings(input_settings: InputSettings) -> Result<(), String> { if dpi_bypass_changed && new_dpi_bypass_value { log_info!("DPI bypass enabled. Preparing to download and run package"); - if let Err(e) = dpi::enable_dpi_bypass_async() { + if let Err(e) = dpi::download_dpi_bypass() { log_error!("Failed to initiate DPI bypass setup: {e}"); } } @@ -277,6 +275,23 @@ pub fn remove_favorite_client(client_id: u32) -> Result<(), String> { ) } +#[tauri::command] +pub fn set_all_favorites(client_ids: Vec) -> Result<(), String> { + log_info!("Setting all favorites to: {:?}", client_ids); + FAVORITE_MANAGER.lock().map_or_else( + |e| { + log_error!("Failed to acquire lock on favorite manager: {}", e); + Err("Failed to acquire lock on favorite manager".to_string()) + }, + |mut favorite_manager| { + favorite_manager.favorites = client_ids; + favorite_manager.save_to_disk(); + log_info!("All favorites updated and saved"); + Ok(()) + }, + ) +} + #[tauri::command] pub fn is_client_favorite(client_id: u32) -> Result { log_debug!("Checking if client ID {} is a favorite", client_id); diff --git a/src-tauri/src/commands/updater.rs b/src-tauri/src/commands/updater.rs index f3f7f76..7dc0c20 100644 --- a/src-tauri/src/commands/updater.rs +++ b/src-tauri/src/commands/updater.rs @@ -139,11 +139,6 @@ pub async fn check_for_updates() -> Result { format!("Failed to parse release data: {e}") })?; - log_debug!( - "Successfully fetched and parsed latest release: '{}'", - release.name - ); - if release.prerelease { log_info!("Latest release is a pre-release, skipping update check."); return Ok(UpdateInfo { diff --git a/src-tauri/src/commands/utils.rs b/src-tauri/src/commands/utils.rs index ecf23f5..8e216b4 100644 --- a/src-tauri/src/commands/utils.rs +++ b/src-tauri/src/commands/utils.rs @@ -3,17 +3,18 @@ use base64::{engine::general_purpose, Engine}; use crate::commands::clients::{ get_running_client_ids, get_running_custom_client_ids, stop_client, stop_custom_client, }; +use crate::core::clients::manager::ClientManager; use crate::core::utils::globals::CODENAME; use crate::core::utils::helpers::is_development_enabled; use crate::core::{network::servers::SERVERS, storage::data::DATA}; use crate::{log_debug, log_error, log_info, log_warn}; +use std::sync::{Arc, Mutex}; use std::{fs, path::PathBuf}; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Emitter, Manager, State}; use tokio::task; #[tauri::command] pub fn get_version() -> Result { - log_debug!("Fetching application version information"); let result = serde_json::json!({ "version": env!("CARGO_PKG_VERSION").to_string(), "codename": CODENAME, @@ -44,9 +45,9 @@ pub fn open_data_folder() -> Result { } #[tauri::command] -pub fn reset_requirements() -> Result<(), String> { +pub async fn reset_requirements() -> Result<(), String> { log_info!("Resetting client requirements"); - if let Err(e) = DATA.reset_requirements() { + if let Err(e) = DATA.reset_requirements().await { log_error!("Failed to reset requirements: {}", e); return Err(format!("Failed to reset requirements: {e}")); } @@ -54,17 +55,6 @@ pub fn reset_requirements() -> Result<(), String> { Ok(()) } -#[tauri::command] -pub fn reset_cache() -> Result<(), String> { - log_info!("Resetting application cache"); - if let Err(e) = DATA.reset_cache() { - log_error!("Failed to reset cache: {}", e); - return Err(format!("Failed to reset cache: {e}")); - } - log_info!("Application cache reset successfully"); - Ok(()) -} - #[tauri::command] pub fn get_data_folder() -> Result { let path = DATA.root_dir.to_string_lossy().to_string(); @@ -77,6 +67,7 @@ pub async fn change_data_folder( app: AppHandle, new_path: String, mode: String, + state: State<'_, Arc>>, ) -> Result<(), String> { log_info!( "Changing data folder to '{}' with mode '{}'", @@ -101,10 +92,12 @@ pub async fn change_data_folder( } log_info!("Stopping all running clients before changing data folder"); - let running: Vec = get_running_client_ids().await; + let running: Vec = get_running_client_ids(state.clone()) + .await + .map_err(|e| e.to_string())?; for id in running { log_debug!("Stopping client with ID: {}", id); - let _ = stop_client(id).await; + let _ = stop_client(id, state.clone()).await; } let running_custom: Vec = get_running_custom_client_ids().await; @@ -192,7 +185,6 @@ pub async fn change_data_folder( #[tauri::command] pub async fn get_auth_url() -> Result { - log_debug!("Fetching authentication server URL"); SERVERS .get_auth_server_url() .map_or_else(|| Ok("https://auth.collapseloader.org".to_string()), Ok) @@ -200,14 +192,12 @@ pub async fn get_auth_url() -> Result { #[tauri::command] pub async fn encode_base64(input: String) -> Result { - log_debug!("Encoding string to Base64"); let encoded = general_purpose::STANDARD.encode(input); Ok(encoded) } #[tauri::command] pub async fn decode_base64(input: String) -> Result { - log_debug!("Decoding string from Base64"); general_purpose::STANDARD.decode(&input).ok().map_or_else( || { log_warn!("Failed to decode Base64 string"); diff --git a/src-tauri/src/core/clients/client.rs b/src-tauri/src/core/clients/client.rs index 32e699c..a31d585 100644 --- a/src-tauri/src/core/clients/client.rs +++ b/src-tauri/src/core/clients/client.rs @@ -9,13 +9,21 @@ use std::{ #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; -use super::manager::CLIENT_MANAGER; -use crate::core::storage::accounts::ACCOUNT_MANAGER; -use crate::core::utils::globals::FILE_EXTENSION; -use crate::core::utils::helpers::{emit_to_main_window, emit_to_main_window_filtered}; -use crate::core::{clients::internal::agent_overlay::AgentArguments, utils::globals::JDK_FOLDER}; -use crate::core::{clients::log_checker::LogChecker, utils::globals::IS_LINUX}; +use super::manager::ClientManager; +use crate::core::clients::internal::agent_overlay::AgentArguments; +use crate::core::clients::log_checker::LogChecker; +use crate::core::utils::globals::{ + AGENT_OVERLAY_FOLDER, ASSETS_FABRIC_FOLDER, ASSETS_FABRIC_ZIP, ASSETS_FOLDER, ASSETS_ZIP, + CUSTOM_CLIENTS_FOLDER, FILE_EXTENSION, IS_LINUX, JDK_FOLDER, LEGACY_SUFFIX, + LIBRARIES_FABRIC_FOLDER, LIBRARIES_FABRIC_ZIP, LIBRARIES_FOLDER, LIBRARIES_LEGACY_FOLDER, + LIBRARIES_LEGACY_ZIP, LIBRARIES_ZIP, LINUX_SUFFIX, MINECRAFT_VERSIONS_FOLDER, MODS_FOLDER, + NATIVES_FOLDER, NATIVES_LEGACY_ZIP, NATIVES_LINUX_ZIP, NATIVES_ZIP, PATH_SEPARATOR, +}; +use crate::core::utils::hashing::calculate_md5_hash; +use crate::core::utils::helpers::emit_to_main_window; +use crate::core::utils::process; use crate::core::{network::analytics::Analytics, storage::data::Data}; +use crate::{core::storage::accounts::ACCOUNT_MANAGER, log_warn}; use crate::{ core::storage::{data::DATA, settings::SETTINGS}, log_debug, log_error, log_info, @@ -36,6 +44,7 @@ pub static REQUIREMENTS_SEMAPHORE: std::sync::LazyLock> = #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)] pub struct Meta { pub is_new: bool, + pub is_fabric: bool, pub asset_index: String, pub installed: bool, pub is_custom: bool, @@ -51,6 +60,7 @@ impl Meta { let asset_index = format!("{}.{}", semver.major, semver.minor); let is_new_version = semver.minor >= 16; + let is_fabric = filename.contains("fabric/"); let client_base_name = Data::get_filename(filename); let jar_path = if filename.contains("fabric/") { @@ -60,7 +70,7 @@ impl Meta { .unwrap_or(filename); DATA.root_dir .join(&client_base_name) - .join("mods") + .join(MODS_FOLDER) .join(jar_basename) } else { DATA.get_local(&format!( @@ -76,6 +86,7 @@ impl Meta { asset_index, installed: jar_path.exists(), is_custom: false, + is_fabric, size: 0, } } @@ -127,6 +138,7 @@ pub struct Client { const fn default_meta() -> Meta { Meta { is_new: false, + is_fabric: false, asset_index: String::new(), installed: false, is_custom: false, @@ -155,7 +167,66 @@ impl LaunchOptions { } impl Client { - pub async fn download(&self) -> Result<(), String> { + async fn verify_hash(&self) -> Result<(), String> { + let hash_verify_enabled = SETTINGS.lock().map(|s| s.hash_verify.value).unwrap_or(true); + if !hash_verify_enabled { + log_info!( + "Hash verification is disabled, skipping check for {}", + self.name + ); + return Ok(()); + } + + log_info!("Verifying MD5 hash for client: {}", self.name); + + let file_path = if self.client_type == ClientType::Fabric { + let jar_basename = std::path::Path::new(&self.filename) + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| "Invalid fabric client filename".to_string())?; + DATA.root_dir + .join(Data::get_filename(&self.filename)) + .join(MODS_FOLDER) + .join(jar_basename) + } else { + DATA.get_local(&format!( + "{}{}{}", + Data::get_filename(&self.filename), + std::path::MAIN_SEPARATOR, + self.filename + )) + }; + + let path_clone = file_path.clone(); + let calculated_hash = tokio::task::spawn_blocking(move || calculate_md5_hash(&path_clone)) + .await + .map_err(|e| e.to_string())??; + + if calculated_hash != self.md5_hash { + log_warn!( + "Hash mismatch for {}: expected {}, got {}", + self.name, + self.md5_hash, + calculated_hash + ); + if let Err(e) = std::fs::remove_file(&file_path) { + log_warn!( + "Failed to remove corrupted file {}: {}", + file_path.display(), + e + ); + } + return Err(format!( + "Hash verification failed. Expected: {}, Got: {}", + self.md5_hash, calculated_hash + )); + } + + log_info!("MD5 hash verification successful for {}", self.name); + Ok(()) + } + + pub async fn download(&self, manager: &Arc>) -> Result<(), String> { log_debug!( "Starting download for client '{}' filename='{}'", self.name, @@ -163,20 +234,29 @@ impl Client { ); match DATA.download(&self.filename).await { Ok(()) => { - log_info!("Successfully downloaded client '{}'", self.name); - if let Ok(mut manager) = CLIENT_MANAGER.lock() { - if let Some(manager) = manager.as_mut() { - if let Some(client) = manager.clients.iter_mut().find(|c| c.id == self.id) { - client.meta.installed = true; - client.meta.size = self.size; - log_debug!( - "Updated manager: marked '{}' installed, size={}", - self.name, - self.size - ); - } - } + if let Err(e) = self.verify_hash().await { + ClientManager::get_client(manager, self.id, |c| { + c.meta.installed = false; + }); + return Err(e); + } + + if let Err(e) = self.download_fabric_mods().await { + ClientManager::get_client(manager, self.id, |c| { + c.meta.installed = false; + }); + return Err(e); } + + ClientManager::get_client(manager, self.id, |c| { + c.meta.installed = true; + c.meta.size = self.size; + log_debug!( + "Updated manager: marked '{}' installed, size={}", + self.name, + self.size + ); + }); Ok(()) } Err(e) => { @@ -186,23 +266,19 @@ impl Client { self.filename, e ); - if let Ok(mut manager) = CLIENT_MANAGER.lock() { - if let Some(manager) = manager.as_mut() { - if let Some(client) = manager.clients.iter_mut().find(|c| c.id == self.id) { - client.meta.installed = false; - log_debug!( - "Updated manager: marked '{}' not installed after failure", - self.name - ); - } - } - } + ClientManager::get_client(manager, self.id, |c| { + c.meta.installed = false; + log_debug!( + "Updated manager: marked '{}' not installed after failure", + self.name + ); + }); Err(e) } } } - pub fn remove_installation(&self) -> Result<(), String> { + pub fn remove_installation(&self, manager: &Arc>) -> Result<(), String> { let client_folder = DATA.get_as_folder(&self.filename); log_debug!( "Removing installation for client '{}' at {}", @@ -211,15 +287,35 @@ impl Client { ); if client_folder.exists() { - std::fs::remove_dir_all(&client_folder).map_err(|e| { - log_error!( - "Failed to remove client folder '{}' : {}", + match std::fs::read_dir(&client_folder) { + Ok(entries) => { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Err(e) = std::fs::remove_dir_all(&path) { + log_warn!("Failed to remove directory '{}': {}", path.display(), e); + } + } else if let Err(e) = std::fs::remove_file(&path) { + log_warn!("Failed to remove file '{}': {}", path.display(), e); + } + } + } + Err(e) => { + log_error!( + "Failed to read client folder '{}': {}", + client_folder.display(), + e + ); + } + } + + if let Err(e) = std::fs::remove_dir(&client_folder) { + log_warn!( + "Failed to remove client folder '{}': {}", client_folder.display(), e ); - format!("Failed to remove client folder: {e}") - })?; - log_info!("Removed installation folder for '{}'", self.name); + } } else { log_debug!( "No installation folder found for '{}', skipping removal", @@ -227,139 +323,48 @@ impl Client { ); } - if let Ok(mut manager) = CLIENT_MANAGER.lock() { - if let Some(manager) = manager.as_mut() { - if let Some(client) = manager.clients.iter_mut().find(|c| c.id == self.id) { - client.meta.installed = false; - log_debug!( - "Updated manager: marked '{}' not installed after removal", - self.name - ); - } - } - } + ClientManager::get_client(manager, self.id, |c| { + c.meta.installed = false; + }); Ok(()) } - pub fn get_running_clients() -> Vec { - let jps_path = DATA - .root_dir - .join(JDK_FOLDER) - .join("bin") - .join("jps".to_owned() + FILE_EXTENSION); - let mut command = Command::new(jps_path); - - #[cfg(windows)] - command.creation_flags(0x0800_0000); - - let output = match command.arg("-m").output() { - Ok(output) => output, - Err(_) => { - return Vec::new(); - } - }; - - let binding = String::from_utf8_lossy(&output.stdout); - let outputs: Vec<&str> = binding.lines().collect(); - - let clients = CLIENT_MANAGER + pub fn get_running_clients(manager: &Arc>) -> Vec { + let clients = manager .lock() .ok() - .and_then(|guard| guard.as_ref().map(|manager| manager.clients.clone())) + .map(|manager| manager.clients.clone()) .unwrap_or_default(); - clients - .into_iter() - .filter(|client| outputs.iter().any(|line| line.contains(&client.filename))) - .collect() + process::filter_running(clients, |client| &client.filename) } pub fn stop(&self) -> Result<(), String> { - let jps_path = DATA - .root_dir - .join(JDK_FOLDER) - .join("bin") - .join("jps".to_owned() + FILE_EXTENSION); - let mut command = Command::new(jps_path); - - #[cfg(windows)] - command.creation_flags(0x0800_0000); - - let output = command - .arg("-m") - .output() - .map_err(|e| format!("Failed to execute jps command: {e}"))?; - - let binding = String::from_utf8_lossy(&output.stdout); - let outputs: Vec<&str> = binding.lines().collect(); - - let mut process_found = false; - for line in &outputs { - if line.contains(&self.filename) { - process_found = true; - let pid = line.split_whitespace().next().unwrap_or_default(); - let mut kill_command = Command::new("taskkill"); - - #[cfg(windows)] - kill_command.creation_flags(0x0800_0000); - - let kill_output = kill_command - .arg("/PID") - .arg(pid) - .arg("/F") - .output() - .map_err(|e| { - log_error!( - "Failed to kill process {} for client '{}': {}", - pid, - self.name, - e - ); - format!("Failed to kill process: {e}") - })?; - - if !kill_output.status.success() { - log_error!( - "taskkill returned non-zero for PID {}: stdout='{}' stderr='{}'", - pid, - String::from_utf8_lossy(&kill_output.stdout), - String::from_utf8_lossy(&kill_output.stderr) - ); - } else { - log_info!("Successfully killed PID {} for client '{}'", pid, self.name); - } - } - } - - if !process_found { - log_info!("No process found for client: {}", self.name); - } - - Ok(()) + process::stop_process_by_filename(&self.filename, &self.name) } fn determine_requirements_to_check(&self) -> Vec { let mut requirements = vec![format!("{JDK_FOLDER}.zip")]; if self.client_type == ClientType::Fabric { - requirements.push("assets_fabric.zip".to_string()); - requirements.push("libraries_fabric.zip".to_string()); + requirements.push(ASSETS_FABRIC_ZIP.to_string()); + requirements.push(LIBRARIES_FABRIC_ZIP.to_string()); } else { - requirements.push("assets.zip".to_string()); + requirements.push(ASSETS_ZIP.to_string()); if self.meta.is_new { requirements.push(if !IS_LINUX { - "natives.zip".to_string() + NATIVES_ZIP.to_string() } else { - "natives-linux.zip".to_string() + NATIVES_LINUX_ZIP.to_string() }); - requirements.push("libraries.zip".to_string()); + requirements.push(LIBRARIES_ZIP.to_string()); } else { requirements.push(if !IS_LINUX { - "natives-1.12.zip".to_string() + NATIVES_LEGACY_ZIP.to_string() } else { - "natives-linux.zip".to_string() + NATIVES_LINUX_ZIP.to_string() }); - requirements.push("libraries-1.12.zip".to_string()); + requirements.push(LIBRARIES_LEGACY_ZIP.to_string()); } } requirements @@ -386,7 +391,7 @@ impl Client { if let Some(ref fabric_jar) = client_jar { if !DATA .root_dir - .join("minecraft_versions") + .join(MINECRAFT_VERSIONS_FOLDER) .join(fabric_jar) .exists() { @@ -397,7 +402,7 @@ impl Client { if self.client_type == ClientType::Fabric { if let Some(mods) = &self.requirement_mods { let client_base = Data::get_filename(&self.filename); - let mods_folder = DATA.root_dir.join(&client_base).join("mods"); + let mods_folder = DATA.root_dir.join(&client_base).join(MODS_FOLDER); if mods.iter().any(|mod_name| { let mod_basename = mod_name.trim_end_matches(".jar"); !mods_folder.join(format!("{mod_basename}.jar")).exists() @@ -451,16 +456,18 @@ impl Client { async fn download_fabric_mods(&self) -> Result<(), String> { if self.client_type == ClientType::Fabric { + const MAIN_SEPARATOR: char = std::path::MAIN_SEPARATOR; + if let Some(mods) = &self.requirement_mods { for mod_name in mods.iter() { let mod_basename = mod_name.trim_end_matches(".jar"); let filename_on_cdn = format!("fabric/deps/{mod_basename}.jar"); let client_base = Data::get_filename(&self.filename); - let dest_folder = format!("{client_base}/mods"); + let dest_folder = format!("{client_base}{MAIN_SEPARATOR}{MODS_FOLDER}"); let dest_path = DATA .root_dir .join(&client_base) - .join("mods") + .join(MODS_FOLDER) .join(format!("{mod_basename}.jar")); if !dest_path.exists() { @@ -498,14 +505,17 @@ impl Client { } if let Some(client_jar) = client_jar { - let dest_path = DATA.root_dir.join("minecraft_versions").join(&client_jar); + let dest_path = DATA + .root_dir + .join(MINECRAFT_VERSIONS_FOLDER) + .join(&client_jar); if !dest_path.exists() { log_info!( "Downloading MC client jar '{}' for '{}'", client_jar, self.name ); - DATA.download_to_folder(&client_jar, "minecraft_versions") + DATA.download_to_folder(&client_jar, MINECRAFT_VERSIONS_FOLDER) .await .map_err(|e| { log_error!("Failed to download MC client jar '{}': {}", client_jar, e); @@ -517,7 +527,6 @@ impl Client { self.download_fabric_mods().await?; - log_info!("All requirements downloaded successfully"); tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; { @@ -569,7 +578,79 @@ impl Client { .await } - pub async fn run(self, options: LaunchOptions) -> Result<(), String> { + pub async fn ensure_java_available( + &self, + app_handle: &AppHandle, + app_handle_for_crash: &AppHandle, + client_id: u32, + client_name: &str, + ) -> Result<(), String> { + let java_executable = DATA + .root_dir + .join(JDK_FOLDER) + .join("bin") + .join("java".to_owned() + FILE_EXTENSION); + + if java_executable.exists() { + return Ok(()); + } + + log_warn!( + "Java executable not found at {}, attempting to redownload...", + java_executable.display() + ); + + let jdk_folder = DATA.root_dir.join(JDK_FOLDER); + if jdk_folder.exists() { + if let Err(e) = tokio::fs::remove_dir_all(&jdk_folder).await { + log_error!("Failed to remove JDK folder: {}", e); + } + } + + let jdk_zip = DATA.root_dir.join(format!("{}.zip", JDK_FOLDER)); + if jdk_zip.exists() { + if let Err(e) = tokio::fs::remove_file(&jdk_zip).await { + log_error!("Failed to remove JDK zip: {}", e); + } + } + + if let Err(e) = self.download_requirements(app_handle).await { + log_error!("Failed to redownload Java: {}", e); + emit_to_main_window( + app_handle_for_crash, + "client-crashed", + serde_json::json!({ + "id": client_id, + "name": client_name, + "error": format!("Failed to redownload Java: {}", e) + }), + ); + return Err(e); + } + + if !java_executable.exists() { + let msg = "Java executable still missing after redownload".to_string(); + log_error!("{}", msg); + emit_to_main_window( + app_handle_for_crash, + "client-crashed", + serde_json::json!({ + "id": client_id, + "name": client_name, + "error": msg + }), + ); + return Err(msg); + } + + Ok(()) + } + + pub async fn run( + self, + options: LaunchOptions, + manager: Arc>, + ) -> Result<(), String> { if !options.is_custom && SETTINGS.lock().is_ok_and(|s| s.optional_telemetry.value) { Analytics::send_client_analytics(self.id); } @@ -613,7 +694,7 @@ impl Client { ); if let Err(e) = self.download_requirements(&app_handle_clone_for_run).await { log_info!("Error downloading requirements for '{}' : {}", self.name, e); - emit_to_main_window_filtered( + emit_to_main_window( &app_handle_clone_for_crash_handling, "client-crashed", serde_json::json!({ @@ -625,29 +706,50 @@ impl Client { return Err(e); } - log_debug!("Spawning thread to run client '{}'", self.name); + if let Err(e) = self + .ensure_java_available( + &app_handle_clone_for_run, + &app_handle_clone_for_crash_handling, + client_id, + &client_name, + ) + .await + { + return Err(e); + } + let self_clone = self.clone(); + let manager_clone = manager.clone(); let handle = std::thread::spawn(move || -> Result<(), String> { let (natives_path, libraries_path) = if self_clone.meta.is_new { ( DATA.root_dir - .join("natives".to_owned() + if IS_LINUX { "-linux" } else { "" }), + .join(NATIVES_FOLDER.to_owned() + if IS_LINUX { LINUX_SUFFIX } else { "" }), if self_clone.client_type == ClientType::Fabric { - DATA.root_dir.join("libraries_fabric") + DATA.root_dir.join(LIBRARIES_FABRIC_FOLDER) } else { - DATA.root_dir.join("libraries") + DATA.root_dir.join(LIBRARIES_FOLDER) }, ) } else { ( - DATA.root_dir - .join("natives".to_owned() + if IS_LINUX { "-linux" } else { "-1.12" }), - DATA.root_dir.join("libraries-1.12"), + DATA.root_dir.join( + NATIVES_FOLDER.to_owned() + + if IS_LINUX { + LINUX_SUFFIX + } else { + LEGACY_SUFFIX + }, + ), + DATA.root_dir.join(LIBRARIES_LEGACY_FOLDER), ) }; let (client_folder, client_jar_path) = if self_clone.meta.is_custom { - let folder = DATA.root_dir.join("custom_clients").join(&self_clone.name); + let folder = DATA + .root_dir + .join(CUSTOM_CLIENTS_FOLDER) + .join(&self_clone.name); let jar = folder.join(&self_clone.filename); (folder, jar) } else if self_clone.filename.contains("fabric/") { @@ -656,7 +758,7 @@ impl Client { let jar_basename = std::path::Path::new(&self_clone.filename) .file_name() .unwrap(); - let jar = folder.join("mods").join(jar_basename); + let jar = folder.join(MODS_FOLDER).join(jar_basename); (folder, jar) } else { let folder = DATA @@ -666,10 +768,10 @@ impl Client { (folder, jar) }; - let agent_overlay_folder = DATA.root_dir.join("agent_overlay"); - let minecraft_client_folder = DATA.root_dir.join("minecraft_versions"); + let agent_overlay_folder = DATA.root_dir.join(AGENT_OVERLAY_FOLDER); + let minecraft_client_folder = DATA.root_dir.join(MINECRAFT_VERSIONS_FOLDER); - let sep = if IS_LINUX { ":" } else { ";" }; + let sep = PATH_SEPARATOR; let classpath = if self_clone.client_type == ClientType::Fabric { format!( @@ -723,9 +825,9 @@ impl Client { }); let assets_dir = if self_clone.client_type == ClientType::Fabric { - DATA.root_dir.join("assets_fabric") + DATA.root_dir.join(ASSETS_FABRIC_FOLDER) } else { - DATA.root_dir.join("assets") + DATA.root_dir.join(ASSETS_FOLDER) }; let ram_mb = SETTINGS.lock().map(|s| s.ram.value).unwrap_or(3072); @@ -801,7 +903,7 @@ impl Client { .spawn() .map_err(|e| format!("Failed to start client: {e}"))?; - emit_to_main_window_filtered( + emit_to_main_window( &app_handle_clone_for_crash_handling, "client-launched", serde_json::json!({ @@ -832,25 +934,23 @@ impl Client { match child.wait() { Ok(status) => { let log_line = format!("Process finished with status: {status:?}"); - log_debug!("{}", log_line); + log_info!("{}", log_line); add_log_line(client_id, log_line); let log_checker = LogChecker::new(self_clone.clone()); log_checker.check(&app_handle_clone_for_crash_handling); - if let Ok(client_manager) = CLIENT_MANAGER.lock() { - if let Some(manager) = client_manager.as_ref() { - if let Err(e) = manager - .update_status_on_client_exit(&app_handle_clone_for_crash_handling) - { - log_error!("Failed to update user status on client exit: {}", e); - } else { - log_debug!("User status updated on client exit, cleared client playing status"); - } + if let Ok(manager) = manager_clone.lock() { + if let Err(e) = manager + .update_status_on_client_exit(&app_handle_clone_for_crash_handling) + { + log_error!("Failed to update user status on client exit: {}", e); + } else { + log_info!("User status updated on client exit"); } } - emit_to_main_window_filtered( + emit_to_main_window( &app_handle_clone_for_crash_handling, "client-exited", serde_json::json!({ @@ -865,7 +965,7 @@ impl Client { let log_line = format!("Error waiting for process: {e}"); log_error!("{}", log_line); add_log_line(client_id, log_line.clone()); - emit_to_main_window_filtered( + emit_to_main_window( &app_handle_clone_for_crash_handling, "client-crashed", serde_json::json!({ diff --git a/src-tauri/src/core/clients/custom_clients.rs b/src-tauri/src/core/clients/custom_clients.rs index 122f8f0..be992e6 100644 --- a/src-tauri/src/core/clients/custom_clients.rs +++ b/src-tauri/src/core/clients/custom_clients.rs @@ -1,48 +1,17 @@ use crate::core::{ clients::client::{Client, ClientType, Meta}, - storage::{custom_clients::CUSTOM_CLIENT_MANAGER, data::DATA}, - utils::globals::{FILE_EXTENSION, JDK_FOLDER}, + storage::custom_clients::CUSTOM_CLIENT_MANAGER, + utils::process, }; -use crate::{log_debug, log_error, log_info, log_warn}; +use crate::{log_debug, log_error}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -pub enum Version { - V1_16_5, - V1_12_2, -} - -impl Version { - pub fn from_string(version: &str) -> Self { - match version { - "1.16.5" => Self::V1_16_5, - "1.12.2" => Self::V1_12_2, - _ => { - log_warn!( - "Unsupported version string '{}', defaulting to 1.16.5", - version - ); - Self::V1_16_5 - } - } - } -} - -impl std::fmt::Display for Version { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::V1_16_5 => write!(f, "1.16.5"), - Self::V1_12_2 => write!(f, "1.12.2"), - } - } -} - #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct CustomClient { pub id: u32, pub name: String, - pub version: Version, + pub version: String, pub filename: String, pub file_path: PathBuf, pub main_class: String, @@ -56,7 +25,7 @@ impl CustomClient { pub fn new( id: u32, name: String, - version: Version, + version: String, filename: String, file_path: PathBuf, main_class: String, @@ -79,7 +48,7 @@ impl CustomClient { Client { id: self.id, name: self.name.clone(), - version: self.version.to_string(), + version: self.version.clone(), filename: self.filename.clone(), md5_hash: String::new(), main_class: self.main_class.clone(), @@ -93,7 +62,8 @@ impl CustomClient { client_type: ClientType::Default, created_at: chrono::Utc::now(), meta: Meta { - is_new: self.version == Version::V1_16_5, + is_new: false, + is_fabric: false, asset_index: String::new(), installed: self.is_installed, is_custom: true, @@ -140,118 +110,16 @@ impl CustomClient { } pub fn get_running_custom_clients() -> Vec { - use std::process::Command; - - #[cfg(target_os = "windows")] - use std::os::windows::process::CommandExt; - - let jps_path = DATA - .root_dir - .join(JDK_FOLDER) - .join("bin") - .join("jps".to_owned() + FILE_EXTENSION); - let mut command = Command::new(jps_path); - - #[cfg(windows)] - command.creation_flags(0x0800_0000); - - let output = match command.arg("-m").output() { - Ok(output) => output, - Err(e) => { - log_error!("Failed to execute jps command: {}", e); - return Vec::new(); - } - }; - - let binding = String::from_utf8_lossy(&output.stdout); - let outputs: Vec<&str> = binding.lines().collect(); - let custom_clients = CUSTOM_CLIENT_MANAGER .lock() .ok() .map(|manager| manager.clients.clone()) .unwrap_or_default(); - let running_clients: Vec = custom_clients - .into_iter() - .filter(|client| outputs.iter().any(|line| line.contains(&client.filename))) - .collect(); - - running_clients + process::filter_running(custom_clients, |client| &client.filename) } pub fn stop(&self) -> Result<(), String> { - use crate::core::storage::data::DATA; - use std::process::Command; - - #[cfg(target_os = "windows")] - use std::os::windows::process::CommandExt; - - log_info!("Attempting to stop custom client '{}'", self.name); - let jps_path = DATA - .root_dir - .join(JDK_FOLDER) - .join("bin") - .join("jps".to_owned() + FILE_EXTENSION); - let mut command = Command::new(jps_path); - - #[cfg(windows)] - command.creation_flags(0x0800_0000); - - let output = command.arg("-m").output().map_err(|e| { - log_error!("Failed to execute jps command for stopping: {}", e); - format!("Failed to execute jps command: {e}") - })?; - - let binding = String::from_utf8_lossy(&output.stdout); - let outputs: Vec<&str> = binding.lines().collect(); - - let mut process_found = false; - for line in &outputs { - if line.contains(&self.filename) { - process_found = true; - let pid = line.split_whitespace().next().unwrap_or_default(); - log_debug!( - "Found process for custom client '{}' with PID: {}", - self.name, - pid - ); - - let mut kill_command = Command::new("taskkill"); - - #[cfg(windows)] - kill_command.creation_flags(0x0800_0000); - - let kill_output = kill_command - .arg("/PID") - .arg(pid) - .arg("/F") - .output() - .map_err(|e| { - log_error!("Failed to execute taskkill for PID {}: {}", pid, e); - format!("Failed to kill process: {e}") - })?; - - if kill_output.status.success() { - log_info!( - "Successfully killed process {} for custom client '{}'", - pid, - self.name - ); - } else { - log_error!( - "taskkill failed for PID {}: {}", - pid, - String::from_utf8_lossy(&kill_output.stderr) - ); - } - } - } - - if !process_found { - crate::log_info!("No process found for custom client: {}", self.name); - } - - Ok(()) + process::stop_process_by_filename(&self.filename, &self.name) } } diff --git a/src-tauri/src/core/clients/internal/agent_overlay.rs b/src-tauri/src/core/clients/internal/agent_overlay.rs index b89cb9e..c9ea544 100644 --- a/src-tauri/src/core/clients/internal/agent_overlay.rs +++ b/src-tauri/src/core/clients/internal/agent_overlay.rs @@ -1,5 +1,7 @@ use crate::core::network::servers::SERVERS; -use crate::core::storage::data::{Data, DATA}; +use crate::core::storage::data::DATA; +use crate::core::utils::globals::AGENT_OVERLAY_FOLDER; +use crate::core::utils::hashing::calculate_md5_hash; use crate::{log_debug, log_error, log_info, log_warn}; use base64::Engine; use serde::{Deserialize, Serialize}; @@ -67,6 +69,8 @@ impl AgentOverlayManager { fn get_api_base_url() -> Result { SERVERS .selected_auth + .read() + .unwrap() .as_ref() .map(|server| server.url.clone()) .ok_or_else(|| "No API server available".to_string()) @@ -77,7 +81,7 @@ impl AgentOverlayManager { let info = Self::get_agent_overlay_info().await?; - let folder = DATA.root_dir.join("agent_overlay"); + let folder = DATA.root_dir.join(AGENT_OVERLAY_FOLDER); if !folder.exists() { log_debug!( "Agent overlay folder missing, creating: {}", @@ -103,7 +107,7 @@ impl AgentOverlayManager { e })?; - let downloaded_hash = Data::calculate_md5_hash(&agent_path)?; + let downloaded_hash = calculate_md5_hash(&agent_path)?; if downloaded_hash != info.agent_hash { log_error!( "Agent file hash mismatch. expected={} got={}", @@ -127,7 +131,7 @@ impl AgentOverlayManager { e })?; - let downloaded_overlay_hash = Data::calculate_md5_hash(&overlay_path)?; + let downloaded_overlay_hash = calculate_md5_hash(&overlay_path)?; if downloaded_overlay_hash != info.overlay_hash { log_error!( "Overlay file hash mismatch. expected={} got={}", @@ -207,7 +211,7 @@ impl AgentOverlayManager { pub async fn verify_agent_overlay_files() -> Result { log_debug!("Verifying agent and overlay files..."); - let folder = DATA.root_dir.join("agent_overlay"); + let folder = DATA.root_dir.join(AGENT_OVERLAY_FOLDER); if !folder.exists() { log_debug!( "Agent overlay folder missing during verify, creating: {}", @@ -230,9 +234,9 @@ impl AgentOverlayManager { let info = Self::get_agent_overlay_info().await?; - let agent_hash = Data::calculate_md5_hash(&agent_path)?; + let agent_hash = calculate_md5_hash(&agent_path)?; if agent_hash != info.agent_hash { - log_error!( + log_warn!( "Agent file hash verification failed. Expected: {}, Got: {}", info.agent_hash, agent_hash @@ -240,7 +244,7 @@ impl AgentOverlayManager { return Ok(false); } - let overlay_hash = Data::calculate_md5_hash(&overlay_path)?; + let overlay_hash = calculate_md5_hash(&overlay_path)?; if overlay_hash != info.overlay_hash { log_error!( "Overlay file hash verification failed. Expected: {}, Got: {}", diff --git a/src-tauri/src/core/clients/log_checker.rs b/src-tauri/src/core/clients/log_checker.rs index 263f42d..40033ad 100644 --- a/src-tauri/src/core/clients/log_checker.rs +++ b/src-tauri/src/core/clients/log_checker.rs @@ -1,9 +1,11 @@ use tauri::AppHandle; -use crate::core::utils::helpers::emit_to_main_window_filtered; use crate::{ - core::clients::client::{Client, CLIENT_LOGS}, - log_debug, log_info, log_warn, + core::{ + clients::client::{Client, CLIENT_LOGS}, + utils::helpers::emit_to_main_window, + }, + log_debug, log_warn, }; pub struct LogChecker { @@ -23,17 +25,11 @@ impl LogChecker { } pub fn check(&self, app_handle_clone_for_crash_handling: &AppHandle) { - log_debug!("Checking logs for client '{}'", self.client.name); if let Ok(logs_guard) = CLIENT_LOGS.lock() { if let Some(client_logs) = logs_guard.get(&self.client.id) { let full_log_string = client_logs.join("\\\\n"); if let Some(crash_type) = self.detect_crash_type(&full_log_string) { - log_warn!( - "Detected crash for client '{}': {:?}", - self.client.name, - crash_type - ); self.handle_crash(crash_type, client_logs, app_handle_clone_for_crash_handling); } else { log_debug!( @@ -71,18 +67,14 @@ impl LogChecker { } fn handle_crash(&self, crash_type: CrashType, client_logs: &[String], app_handle: &AppHandle) { - log_info!( - "Handling crash type {:?} for client '{}'", - crash_type, - self.client.name + log_warn!( + "Client {} crashed! Detected reason: {:?}", + self.client.name, + crash_type ); match crash_type { CrashType::MissingMainClass => { - log_info!( - "Client {} (ID: {}) crash likely due to missing main class. Triggering reinstall.", - self.client.name, self.client.id - ); - emit_to_main_window_filtered( + emit_to_main_window( app_handle, "client-needs-reinstall", serde_json::json!({ @@ -92,13 +84,8 @@ impl LogChecker { ); } CrashType::OutOfMemory => { - log_info!( - "Client {} (ID: {}) crash likely due to OutOfMemoryError.", - self.client.name, - self.client.id - ); self.emit_crash_details(client_logs, app_handle); - emit_to_main_window_filtered( + emit_to_main_window( app_handle, "client-crashed", serde_json::json!({ @@ -109,12 +96,8 @@ impl LogChecker { ); } CrashType::GameCrashed => { - log_warn!( - "Client '{}' crashed with a generic game error.", - self.client.name - ); self.emit_crash_details(client_logs, app_handle); - emit_to_main_window_filtered( + emit_to_main_window( app_handle, "client-crashed", serde_json::json!({ @@ -132,7 +115,7 @@ impl LogChecker { "Emitting client-crash-details for client '{}'", self.client.name ); - emit_to_main_window_filtered( + emit_to_main_window( app_handle, "client-crash-details", serde_json::json!({ diff --git a/src-tauri/src/core/clients/manager.rs b/src-tauri/src/core/clients/manager.rs index 3f2365e..0cf7404 100644 --- a/src-tauri/src/core/clients/manager.rs +++ b/src-tauri/src/core/clients/manager.rs @@ -1,147 +1,155 @@ -use std::{ - fs::File, - io::BufReader, - sync::{LazyLock, Mutex}, -}; +use rand::Rng; +use std::sync::{Arc, Mutex}; +use std::{fs::File, io::BufReader}; use tauri::AppHandle; use super::client::Client; +use crate::core::clients::client::Meta; +use crate::core::utils::globals::MOCK_CLIENTS; use crate::core::utils::helpers::emit_to_main_window; use crate::{ core::{ network::api::{API, API_CACHE_DIR}, storage::data::DATA, }, - log_debug, log_error, log_info, log_warn, + log_debug, log_info, log_warn, }; +#[derive(Default)] pub struct ClientManager { pub clients: Vec, } impl ClientManager { - pub fn new() -> Result> { - log_debug!("ClientManager::new() starting initialization"); - let api_option = API.as_ref(); - - if let Some(api_instance) = api_option { - log_debug!("API instance available — fetching clients from API"); - let mut clients: Vec = match api_instance.json::>("clients") { - Ok(clients) => { - log_info!("Fetched {} clients from API", clients.len()); - clients - } - Err(e) => { - log_warn!( - "Failed to fetch clients from API: {}. Attempting to load from cache.", - e - ); - let cache_path = DATA.root_dir.join(API_CACHE_DIR).join("clients.json"); - if cache_path.exists() { - let file = File::open(cache_path)?; - let reader = BufReader::new(file); - let cached_clients: Vec = - serde_json::from_reader(reader).map_err(|e| { - log_warn!("Failed to deserialize cached clients: {}", e); - Box::new(e) as Box - })?; - log_debug!("Loaded {} clients from cache", cached_clients.len()); - cached_clients - } else { - log_warn!("Clients cache not found. Returning empty client list."); - return Ok(Self { - clients: Vec::new(), - }); - } - } - }; - - match api_instance.json::>("fabric-clients") { - Ok(mut fabric_clients) => { - let fabric_count = fabric_clients.len(); - if fabric_count > 0 { - log_info!("Fetched {} fabric clients from API", fabric_count); - clients.append(&mut fabric_clients); - clients.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - } else { - log_debug!("API returned 0 fabric clients"); + async fn mock_clients() -> Result, Box> { + let mut clients = Vec::new(); + let mut rng = rand::rng(); + + for i in 1..5 { + clients.push(Client { + id: i, + name: "Mock client #".to_owned() + &i.to_string(), + version: "1.16.5".to_string(), + meta: Meta { + asset_index: "1.16".to_string(), + is_new: false, + is_fabric: false, + installed: rng.random_bool(1.0 / 3.0), + is_custom: false, + size: rng.random_range(50..=100), + }, + ..Default::default() + }); + } + + Ok(clients) + } + + pub fn get_client(manager: &Arc>, client_id: u32, f: F) + where + F: FnOnce(&mut Client), + { + if let Ok(mut mgr) = manager.lock() { + if let Some(client) = mgr.clients.iter_mut().find(|c| c.id == client_id) { + f(client); + } + } + } + + pub async fn fetch_clients() -> Result, Box> { + if *MOCK_CLIENTS { + log_info!("Skipping client manager initialization, mock clients enabled, generating client list..."); + return Self::mock_clients().await; + } + + let clients_task = tokio::task::spawn_blocking(|| { + let api_option = API.as_ref(); + if let Some(api_instance) = api_option { + match api_instance.json::>("clients") { + Ok(clients) => Ok(clients), + Err(e) => { + log_warn!( + "Failed to fetch clients from API: {}. Attempting to load from cache.", + e + ); + Self::load_from_cache("clients.json") } } - Err(e) => { - log_warn!("Failed to fetch fabric clients: {}", e); - } + } else { + log_warn!("API instance not available. Attempting to load clients from cache."); + Self::load_from_cache("clients.json") } - - for client in &mut clients { - if client.meta.is_new - != (semver::Version::parse(&client.version).unwrap().minor > 6) - { - client.meta = super::client::Meta::new(&client.version, &client.filename); + }); + + let fabric_clients_task = tokio::task::spawn_blocking( + || -> Result, Box> { + let api_option = API.as_ref(); + if let Some(api_instance) = api_option { + match api_instance.json::>("fabric-clients") { + Ok(clients) => Ok(clients), + Err(e) => { + log_warn!("Failed to fetch fabric clients: {}", e); + Ok(Vec::new()) + } + } + } else { + Ok(Vec::new()) } + }, + ); - client.meta.size = client.size; - } + let (clients_res, fabric_res) = tokio::join!(clients_task, fabric_clients_task); - log_debug!("ClientManager initialized with {} clients", clients.len()); + let mut clients = + clients_res.map_err(|e| Box::new(e) as Box)??; + let mut fabric_clients = + fabric_res.map_err(|e| Box::new(e) as Box)??; - Ok(Self { clients }) + if !fabric_clients.is_empty() { + clients.append(&mut fabric_clients); + clients.sort_by(|a, b| b.created_at.cmp(&a.created_at)); } else { - log_warn!("API instance not available. Attempting to load clients from cache."); - let clients_cache_path = DATA.root_dir.join(API_CACHE_DIR).join("clients.json"); - log_debug!( - "Looking for cached clients at {}", - clients_cache_path.display() - ); - - let mut clients: Vec = if clients_cache_path.exists() { - let file = File::open(clients_cache_path)?; - let reader = BufReader::new(file); - let cached_clients = serde_json::from_reader(reader).unwrap_or_else(|e| { - log_warn!( - "Error deserializing cached clients: {}. Returning empty list.", - e - ); - Vec::new() - }); - log_info!( - "Loaded {} clients from cache (API offline)", - cached_clients.len() - ); - cached_clients - } else { - log_warn!("Clients cache not found when API offline. Returning empty list."); - Vec::new() - }; - - for client in &mut clients { - if client.meta.is_new - != (semver::Version::parse(&client.version).unwrap().minor > 6) - { - client.meta = super::client::Meta::new(&client.version, &client.filename); - } + log_debug!("API returned 0 fabric clients or failed to fetch"); + } - client.meta.size = client.size; + for client in &mut clients { + if client.meta.is_new != (semver::Version::parse(&client.version).unwrap().minor > 6) { + client.meta = super::client::Meta::new(&client.version, &client.filename); } - log_info!( - "ClientManager initialized from cache with {} clients — operating offline mode", - clients.len() - ); - Ok(Self { clients }) + client.meta.size = client.size; } + + log_debug!( + "Initialized ClientManager with {} clients ({} fabric, {} vanilla)", + clients.len(), + clients.iter().filter(|c| c.meta.is_fabric).count(), + clients.iter().filter(|c| !c.meta.is_fabric).count() + ); + Ok(clients) } - pub async fn new_async() -> Result> { - tokio::task::spawn_blocking(|| { - Self::new().map_err(|e| -> Box { - Box::new(std::io::Error::other(e.to_string())) - }) - }) - .await? + fn load_from_cache( + filename: &str, + ) -> Result, Box> { + let cache_path = DATA.root_dir.join(API_CACHE_DIR).join(filename); + if cache_path.exists() { + let file = File::open(cache_path) + .map_err(|e| Box::new(e) as Box)?; + let reader = BufReader::new(file); + let cached_clients: Vec = serde_json::from_reader(reader).map_err(|e| { + log_warn!("Failed to deserialize cached clients: {}", e); + Box::new(e) as Box + })?; + log_debug!("Loaded {} clients from cache", cached_clients.len()); + Ok(cached_clients) + } else { + log_warn!("Clients cache not found. Returning empty client list."); + Ok(Vec::new()) + } } pub fn update_status_on_client_exit(&self, app_handle: &AppHandle) -> Result<(), String> { - log_debug!("Updating user status on client exit to 'online' and clearing currentClient"); emit_to_main_window( app_handle, "update-user-status", @@ -154,36 +162,3 @@ impl ClientManager { Ok(()) } } - -pub static CLIENT_MANAGER: LazyLock>> = - LazyLock::new(|| Mutex::new(None)); - -pub async fn initialize_client_manager() -> Result<(), String> { - match ClientManager::new_async().await { - Ok(manager) => { - log_info!("ClientManager async initialization succeeded — setting global manager"); - CLIENT_MANAGER.lock().map_or_else( - |_| { - log_error!("Failed to acquire lock on CLIENT_MANAGER during initialization"); - Err("Failed to acquire lock on CLIENT_MANAGER".to_string()) - }, - |mut client_manager| { - *client_manager = Some(manager); - Ok(()) - }, - ) - } - Err(e) => { - log_error!("Failed to initialize ClientManager: {}", e); - log_warn!("Falling back to empty ClientManager instance"); - - if let Ok(mut client_manager) = CLIENT_MANAGER.lock() { - *client_manager = Some(ClientManager { - clients: Vec::new(), - }); - log_debug!("CLIENT_MANAGER set to empty instance after failure"); - } - Err(format!("Failed to initialize ClientManager: {e}")) - } - } -} diff --git a/src-tauri/src/core/error.rs b/src-tauri/src/core/error.rs index 8999bbd..9e59314 100644 --- a/src-tauri/src/core/error.rs +++ b/src-tauri/src/core/error.rs @@ -17,6 +17,9 @@ pub enum StartupError { #[cfg(target_os = "linux")] #[error("Warning: WEBKIT_DISABLE_DMABUF_RENDERER environment variable is not set to 1.\n\nIf you experience a white screen or rendering issues, please set this variable before launching the application:\n\nWEBKIT_DISABLE_DMABUF_RENDERER=1 collapseloader\n\nOr add it to your shell profile for permanent use.")] LinuxWebKitWarning, + + #[error("Failed to set DPI awareness: {0}")] + DpiAwarenessFailed(String), } impl StartupError { diff --git a/src-tauri/src/core/network/analytics.rs b/src-tauri/src/core/network/analytics.rs index fcc9cb9..ba30460 100644 --- a/src-tauri/src/core/network/analytics.rs +++ b/src-tauri/src/core/network/analytics.rs @@ -53,7 +53,7 @@ impl Analytics { } fn get_server_url(analytics_type: &str) -> Option { - match SERVERS.selected_auth.clone() { + match SERVERS.selected_auth.read().unwrap().clone() { Some(server) => Some(server.url), None => { log_debug!("No Auth server selected for {}", analytics_type); diff --git a/src-tauri/src/core/network/api.rs b/src-tauri/src/core/network/api.rs index 78194f4..ad66957 100644 --- a/src-tauri/src/core/network/api.rs +++ b/src-tauri/src/core/network/api.rs @@ -5,10 +5,10 @@ use std::time::Duration; use crate::core::storage::data::DATA; use crate::{log_debug, log_error, log_warn}; -use super::servers::{Server, SERVERS}; use super::cache; +use super::servers::{Server, SERVERS}; -pub const API_CACHE_DIR: &str = "cache/"; +pub const API_CACHE_DIR: &str = "cache"; pub struct Api { pub api_server: Server, @@ -69,7 +69,11 @@ impl Api { match serde_json::from_str::(&body) { Ok(api_data) => { - cache::write_cache_if_changed(&cache_file_path, &api_data, &cached_data); + cache::write_cache_if_changed( + &cache_file_path, + &api_data, + &cached_data, + ); let result: T = serde_json::from_value(api_data)?; Ok(result) @@ -130,11 +134,10 @@ impl Api { } pub static API: LazyLock> = LazyLock::new(|| { - SERVERS.selected_auth.clone().map_or_else( - || { - log_warn!("Required Auth server or CDN server is not available. API functionality will be disabled."); - None - }, - |auth_s| Some(Api { api_server: auth_s }), - ) + SERVERS + .selected_auth + .read() + .unwrap() + .clone() + .map_or_else(|| None, |auth_s| Some(Api { api_server: auth_s })) }); diff --git a/src-tauri/src/core/network/cache.rs b/src-tauri/src/core/network/cache.rs index 81e42c9..cfbdcab 100644 --- a/src-tauri/src/core/network/cache.rs +++ b/src-tauri/src/core/network/cache.rs @@ -1,6 +1,6 @@ use serde_json::Value; use std::fs::{self, File}; -use std::io::{BufReader, BufWriter}; +use std::io::{BufReader, BufWriter, ErrorKind}; use std::path::{Path, PathBuf}; use crate::log_debug; @@ -11,12 +11,31 @@ pub fn sanitize_path_for_filename(path: &str) -> String { } pub fn ensure_cache_dir(cache_dir: &Path) { - if !cache_dir.exists() { - if let Err(e) = fs::create_dir_all(cache_dir) { - log_warn!("Failed to create API cache directory at {:?}: {}", cache_dir, e); - } else { + match fs::create_dir(cache_dir) { + Ok(()) => { log_debug!("Created cache directory at {:?}", cache_dir); } + Err(e) => match e.kind() { + ErrorKind::AlreadyExists => {} + ErrorKind::NotFound => { + if let Err(e2) = fs::create_dir_all(cache_dir) { + log_warn!( + "Failed to create API cache directory at {:?}: {}", + cache_dir, + e2 + ); + } else { + log_debug!("Created cache directory at {:?}", cache_dir); + } + } + _ => { + log_warn!( + "Failed to create API cache directory at {:?}: {}", + cache_dir, + e + ); + } + }, } } @@ -56,10 +75,21 @@ pub fn read_cached_json(cache_file_path: &Path) -> Option { } } -pub fn write_cache_if_changed(cache_file_path: &Path, api_data: &Value, prev_cached: &Option) { - let should_update_cache = prev_cached.as_ref().is_none_or(|cached| *cached != *api_data); +pub fn write_cache_if_changed( + cache_file_path: &Path, + api_data: &Value, + prev_cached: &Option, +) { + let should_update_cache = prev_cached + .as_ref() + .is_none_or(|cached| *cached != *api_data); - if should_update_cache && cache_file_path.parent().map(|p| p.exists()).unwrap_or(false) { + if should_update_cache + && cache_file_path + .parent() + .map(|p| p.exists()) + .unwrap_or(false) + { match File::create(cache_file_path) { Ok(file) => { let writer = BufWriter::new(file); @@ -69,8 +99,6 @@ pub fn write_cache_if_changed(cache_file_path: &Path, api_data: &Value, prev_cac cache_file_path, e ); - } else { - log_debug!("Cache updated for API path: {}","(see cache file)"); } } Err(e) => { diff --git a/src-tauri/src/core/network/downloader.rs b/src-tauri/src/core/network/downloader.rs new file mode 100644 index 0000000..f62a7b2 --- /dev/null +++ b/src-tauri/src/core/network/downloader.rs @@ -0,0 +1,99 @@ +use crate::core::utils::helpers::emit_to_main_window; +use crate::log_error; +use futures_util::StreamExt; +use std::path::Path; +use std::time::Duration; +use tokio::io::AsyncWriteExt; + +pub async fn download_file( + url: &str, + dest_path: &Path, + emit_name: &str, + app_handle: Option<&tauri::AppHandle>, +) -> Result<(), String> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(600)) + .build() + .map_err(|e| { + log_error!("Failed to create HTTP client: {}", e); + format!("Failed to create HTTP client: {e}") + })?; + + let response = client.get(url).send().await.map_err(|e| { + log_error!("Failed to make HTTP request to {}: {}", url, e); + format!("Failed to download file {emit_name}: {e}") + })?; + + if !response.status().is_success() { + let error_msg = format!( + "Failed to download file {}: HTTP {} - {}", + emit_name, + response.status().as_u16(), + response + .status() + .canonical_reason() + .unwrap_or("Unknown error") + ); + log_error!("{}", error_msg); + return Err(error_msg); + } + + let total_size = response.content_length(); + + let mut dest = tokio::fs::File::create(dest_path).await.map_err(|e| { + log_error!( + "Failed to create destination file {}: {}", + dest_path.display(), + e + ); + format!("Failed to create file: {e}") + })?; + + let mut downloaded: u64 = 0; + let mut last_percentage: u8 = 0; + let mut stream = response.bytes_stream(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| { + log_error!("Failed to read response data for {}: {}", emit_name, e); + format!("Network read error: {e}") + })?; + + dest.write_all(&chunk).await.map_err(|e| { + log_error!( + "Failed to write data to file {}: {}", + dest_path.display(), + e + ); + format!("File write error: {e}") + })?; + + downloaded += chunk.len() as u64; + + let percentage = total_size.map_or_else( + || std::cmp::min(99, (downloaded / 1024 / 1024) as u8), + |total| ((downloaded as f64 / total as f64) * 100.0) as u8, + ); + + if percentage != last_percentage { + last_percentage = percentage; + if let Some(handle) = app_handle { + let progress_data = serde_json::json!({ + "file": emit_name, + "percentage": percentage, + "downloaded": downloaded, + "total": total_size.unwrap_or(0), + "action": "downloading" + }); + emit_to_main_window(handle, "download-progress", progress_data); + } + } + } + + dest.flush().await.map_err(|e| { + log_error!("Failed to flush file {}: {}", dest_path.display(), e); + format!("File flush error: {e}") + })?; + + Ok(()) +} diff --git a/src-tauri/src/core/network/mod.rs b/src-tauri/src/core/network/mod.rs index 868b25d..46741c3 100644 --- a/src-tauri/src/core/network/mod.rs +++ b/src-tauri/src/core/network/mod.rs @@ -1,4 +1,5 @@ pub mod analytics; pub mod api; -pub mod servers; pub mod cache; +pub mod downloader; +pub mod servers; diff --git a/src-tauri/src/core/network/servers.rs b/src-tauri/src/core/network/servers.rs index 0b47359..73d61e4 100644 --- a/src-tauri/src/core/network/servers.rs +++ b/src-tauri/src/core/network/servers.rs @@ -2,13 +2,60 @@ use crate::{ core::utils::globals::{AUTH_SERVERS, CDN_SERVERS}, log_info, log_warn, }; -use reqwest::blocking::Client; -use std::sync::LazyLock; -use std::{sync::Mutex, time::Duration}; +use backoff::{future::retry, ExponentialBackoff}; +use reqwest::Client; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, LazyLock, Mutex, RwLock}; +use std::time::{Duration, Instant}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const CB_MAX_FAILURES: usize = 3; +const CB_RESET_WINDOW: Duration = Duration::from_secs(60); +const BACKOFF_MAX_ELAPSED: Duration = Duration::from_secs(10); + +#[derive(Debug)] +pub struct CircuitBreaker { + failures: AtomicUsize, + last_failure: Mutex>, +} + +impl CircuitBreaker { + fn new() -> Self { + Self { + failures: AtomicUsize::new(0), + last_failure: Mutex::new(None), + } + } + + fn record_failure(&self) { + self.failures.fetch_add(1, Ordering::SeqCst); + let mut last = self.last_failure.lock().unwrap(); + *last = Some(Instant::now()); + } + + fn record_success(&self) { + self.failures.store(0, Ordering::SeqCst); + } + + fn is_open(&self) -> bool { + if self.failures.load(Ordering::SeqCst) < CB_MAX_FAILURES { + return false; + } + let last = self.last_failure.lock().unwrap(); + if let Some(time) = *last { + if time.elapsed() < CB_RESET_WINDOW { + return true; + } + } + false + } +} #[derive(Debug, Clone, serde::Serialize)] pub struct Server { pub url: String, + #[serde(skip)] + pub circuit_breaker: Arc, } #[derive(Debug, Clone, serde::Serialize)] @@ -21,8 +68,8 @@ pub struct ServerConnectivityStatus { pub struct Servers { pub cdns: Vec, pub auths: Vec, - pub selected_cdn: Option, - pub selected_auth: Option, + pub selected_cdn: RwLock>, + pub selected_auth: RwLock>, pub connectivity_status: Mutex, } @@ -30,17 +77,33 @@ impl Server { pub fn new(url: &str) -> Self { Self { url: url.to_string(), + circuit_breaker: Arc::new(CircuitBreaker::new()), } } } impl Servers { pub fn new() -> Self { + let cdns = CDN_SERVERS.to_vec(); + let auths = AUTH_SERVERS.to_vec(); + + let initial_cdn = if std::env::var("FORCE_CDN").is_ok() && !cdns.is_empty() { + Some(cdns[0].clone()) + } else { + None + }; + + let initial_auth = if std::env::var("FORCE_AUTH").is_ok() && !auths.is_empty() { + Some(auths[0].clone()) + } else { + None + }; + Self { - cdns: CDN_SERVERS.to_vec(), - auths: AUTH_SERVERS.to_vec(), - selected_cdn: None, - selected_auth: None, + cdns, + auths, + selected_cdn: RwLock::new(initial_cdn), + selected_auth: RwLock::new(initial_auth), connectivity_status: Mutex::new(ServerConnectivityStatus { cdn_online: false, auth_online: false, @@ -48,60 +111,90 @@ impl Servers { } } - pub fn check_servers(&mut self) { + pub async fn check_servers(&self) { let client = Client::builder() - .timeout(Duration::from_secs(5)) + .timeout(REQUEST_TIMEOUT) .build() .unwrap_or_default(); - for server in &self.cdns { - let response_result = client.head(&server.url).send(); - match response_result { - Ok(response) => { - log_info!( - "CDN Server {} responded with: {}", - server.url, - response.status() + self.check_group(&client, &self.cdns, &self.selected_cdn, "CDN") + .await; + self.check_group(&client, &self.auths, &self.selected_auth, "Auth") + .await; + + self.set_status(); + } + + async fn check_group( + &self, + client: &Client, + servers: &[Server], + selected: &RwLock>, + name: &str, + ) { + for server in servers { + if server.circuit_breaker.is_open() { + log_warn!("Skipping {} Server {}", name, server.url); + continue; + } + + let op = || async { + let resp = client.head(&server.url).send().await.map_err(|e| { + backoff::Error::>::transient(Box::new( + e, + )) + })?; + if !resp.status().is_success() { + return Err( + backoff::Error::>::transient( + format!("Status not success: {}", resp.status()).into(), + ), ); - self.selected_cdn = Some(server.clone()); - break; } - Err(e) => { - log_warn!("Failed to connect to CDN Server {}: {}", server.url, e); - } - } - } + Ok(resp) + }; + + let backoff = ExponentialBackoff { + max_elapsed_time: Some(BACKOFF_MAX_ELAPSED), + ..Default::default() + }; - for server in &self.auths { - let response_result = client.head(&server.url).send(); - match response_result { + match retry(backoff, op).await { Ok(response) => { log_info!( - "Auth Server {} responded with: {}", + "{} Server {} responded with: {}", + name, server.url, response.status() ); - self.selected_auth = Some(server.clone()); - break; + server.circuit_breaker.record_success(); + let mut lock = selected.write().unwrap(); + *lock = Some(server.clone()); + return; } Err(e) => { - log_warn!("Failed to connect to Auth Server {}: {}", server.url, e); + log_warn!("Failed to connect to {} Server {}: {}", name, server.url, e); + server.circuit_breaker.record_failure(); } } } - - self.set_status(); + let mut lock = selected.write().unwrap(); + *lock = None; } pub fn set_status(&self) -> ServerConnectivityStatus { let mut status = self.connectivity_status.lock().unwrap(); - status.cdn_online = self.selected_cdn.is_some(); - status.auth_online = self.selected_auth.is_some(); + status.cdn_online = self.selected_cdn.read().unwrap().is_some(); + status.auth_online = self.selected_auth.read().unwrap().is_some(); status.clone() } pub fn get_auth_server_url(&self) -> Option { - self.selected_auth.as_ref().map(|server| server.url.clone()) + self.selected_auth + .read() + .unwrap() + .as_ref() + .map(|server| server.url.clone()) } } @@ -112,7 +205,6 @@ impl Default for Servers { } pub static SERVERS: LazyLock = LazyLock::new(|| { - let mut servers = Servers::new(); - servers.check_servers(); - servers + // since 0.2.4 server checking moved to lib.rs init + Servers::new() }); diff --git a/src-tauri/src/core/storage/common.rs b/src-tauri/src/core/storage/common.rs index 2feba61..2974272 100644 --- a/src-tauri/src/core/storage/common.rs +++ b/src-tauri/src/core/storage/common.rs @@ -1,5 +1,6 @@ -use crate::{log_debug, log_error, log_warn}; +use crate::{log_debug, log_error, log_info, log_warn}; use serde::{de::DeserializeOwned, Serialize}; +use serde_json::Value; use std::{fs, path::PathBuf}; pub trait JsonStorage: Sized + Serialize + DeserializeOwned { @@ -10,11 +11,6 @@ pub trait JsonStorage: Sized + Serialize + DeserializeOwned { fn create_default() -> Self; fn save_to_disk(&self) { - log_debug!( - "Saving {} to {}", - Self::resource_name(), - self.file_path().display() - ); match serde_json::to_string_pretty(&self) { Ok(data) => { let file_path = self.file_path(); @@ -33,6 +29,15 @@ pub trait JsonStorage: Sized + Serialize + DeserializeOwned { } } + if file_path.exists() { + if let Ok(existing) = fs::read_to_string(file_path) { + if existing == data { + return; + } + } + } + + let was_created = !file_path.exists(); if let Err(e) = fs::write(file_path, data) { log_warn!( "Failed to write {} file to {}: {}", @@ -40,13 +45,9 @@ pub trait JsonStorage: Sized + Serialize + DeserializeOwned { file_path.display(), e ); - return; + } else if !was_created { + log_debug!("Saved {} to {}", Self::resource_name(), file_path.display()); } - log_debug!( - "Successfully saved {} to {}", - Self::resource_name(), - file_path.display() - ); } Err(e) => { log_error!( @@ -110,25 +111,26 @@ pub trait JsonStorage: Sized + Serialize + DeserializeOwned { ); } } - } else { - log_debug!( - "No {} file found at {}, creating a new one with defaults.", - Self::resource_name(), - file_path.display() + + let default = Self::create_default(); + default.save_to_disk(); + log_info!( + "Using default {}.json due to read/parse failure", + Self::resource_name() ); + default + } else { + let default = Self::create_default(); + default.save_to_disk(); + log_info!("Created new {}.json with defaults", Self::resource_name()); + default } - - let default = Self::create_default(); - default.save_to_disk(); - default } fn merge_json_values( default_value: serde_json::Value, partial_value: serde_json::Value, ) -> serde_json::Result { - use serde_json::Value; - match (default_value, partial_value) { (Value::Object(mut default_map), Value::Object(partial_map)) => { for (key, value) in partial_map { diff --git a/src-tauri/src/core/storage/custom_clients.rs b/src-tauri/src/core/storage/custom_clients.rs index 47d76bf..fa156d2 100644 --- a/src-tauri/src/core/storage/custom_clients.rs +++ b/src-tauri/src/core/storage/custom_clients.rs @@ -1,12 +1,13 @@ use std::{fs, path::PathBuf, sync::Mutex}; use crate::core::clients::custom_clients::CustomClient; -use crate::core::clients::custom_clients::Version; use crate::core::storage::data::DATA; use crate::core::storage::settings::SETTINGS; +use crate::core::utils::globals::CUSTOM_CLIENTS_FOLDER; use crate::log_warn; use serde::{Deserialize, Serialize}; use std::sync::LazyLock; +use tauri::async_runtime::block_on; use super::common::JsonStorage; @@ -38,7 +39,7 @@ impl CustomClientManager { )); } - let custom_clients_dir = DATA.get_local("custom_clients"); + let custom_clients_dir = DATA.get_local(CUSTOM_CLIENTS_FOLDER); if !custom_clients_dir.exists() { fs::create_dir_all(&custom_clients_dir) .map_err(|e| format!("Failed to create custom clients directory: {e}"))?; @@ -62,7 +63,7 @@ impl CustomClientManager { .map(|s| s.sync_client_settings.value) .unwrap_or(false) { - if let Err(e) = DATA.ensure_client_synced(&custom_client.name) { + if let Err(e) = block_on(DATA.ensure_client_synced(&custom_client.name)) { log_warn!( "Failed to ensure client sync for custom client {}: {}", custom_client.name, @@ -132,7 +133,7 @@ impl CustomClientManager { #[derive(Debug)] pub struct CustomClientUpdate { pub name: Option, - pub version: Option, + pub version: Option, pub main_class: Option, } @@ -142,7 +143,7 @@ impl JsonStorage for CustomClientManager { } fn resource_name() -> &'static str { - "custom_clients" + CUSTOM_CLIENTS_FOLDER } fn create_default() -> Self { diff --git a/src-tauri/src/core/storage/data.rs b/src-tauri/src/core/storage/data.rs index bbf3c03..a6202d2 100644 --- a/src-tauri/src/core/storage/data.rs +++ b/src-tauri/src/core/storage/data.rs @@ -1,16 +1,19 @@ -use crate::core::clients::client::ClientType; -use crate::core::clients::manager::CLIENT_MANAGER; +use crate::core::network::downloader::download_file; use crate::core::network::servers::SERVERS; use crate::core::storage::settings::SETTINGS; -use crate::core::utils::globals::{JDK_FOLDER, ROOT_DIR}; +use crate::core::utils::archive::unzip; +use crate::core::utils::globals::{ + ASSETS_FABRIC_FOLDER, ASSETS_FOLDER, JDK_FOLDER, LIBRARIES_FABRIC_FOLDER, LIBRARIES_FOLDER, + LIBRARIES_LEGACY_FOLDER, MINECRAFT_VERSIONS_FOLDER, NATIVES_FABRIC_FOLDER, NATIVES_FOLDER, + NATIVES_LEGACY_FOLDER, ROOT_DIR, +}; use crate::core::utils::helpers::emit_to_main_window; use crate::{log_debug, log_error, log_info, log_warn}; -use futures_util::StreamExt; +use std::fs; use std::path::{Path, PathBuf, MAIN_SEPARATOR}; use std::sync::Mutex; -use std::time::Duration; -use std::{fs, io}; -use tokio::io::AsyncWriteExt; +use tokio::fs as tokio_fs; +use tokio::task; pub struct Data { pub root_dir: PathBuf, @@ -19,6 +22,12 @@ pub struct Data { pub static APP_HANDLE: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(None)); +struct FileInfo { + local_file: String, + file_name: String, + is_fabric_client: bool, +} + impl Data { pub fn new(root_dir: PathBuf) -> Self { if !root_dir.exists() { @@ -26,7 +35,10 @@ impl Data { "Root data directory does not exist, creating: {}", root_dir.display() ); - fs::create_dir_all(&root_dir).expect("Failed to create root directory"); + if let Err(e) = fs::create_dir_all(&root_dir) { + log_error!("Failed to create root directory: {}", e); + panic!("Failed to create root directory: {}", e); + } log_info!("Created root data directory: {}", root_dir.display()); } @@ -40,10 +52,18 @@ impl Data { } pub fn get_local(&self, relative_path: &str) -> PathBuf { - self.root_dir.join(relative_path) + let parts: Vec<&str> = relative_path.split(|c| ['/', '\\'].contains(&c)).collect(); + let mut path = self.root_dir.clone(); + for part in parts { + if part.is_empty() { + continue; + } + path = path.join(part); + } + path } - pub fn unzip(&self, file: &str) -> Result<(), String> { + pub async fn unzip(&self, file: &str) -> Result<(), String> { let (emit_name, local_name) = if file.starts_with("http://") || file.starts_with("https://") { ( @@ -54,177 +74,112 @@ impl Data { (file.to_string(), file.to_string()) }; - if let Some(app_handle) = APP_HANDLE.lock().unwrap().as_ref() { - emit_to_main_window(app_handle, "unzip-start", &emit_name); - } - let zip_path = self.get_local(&local_name); let unzip_path = self.get_local(local_name.trim_end_matches(".zip")); - if unzip_path.exists() { - log_debug!( - "Directory {} exists, will overwrite contents", - unzip_path.display() - ); - } else { - log_debug!("Creating unzip directory: {}", unzip_path.display()); - fs::create_dir_all(&unzip_path).map_err(|e| e.to_string())?; - } - - if !zip_path.exists() { - log_error!( - "Zip file not found at expected path: {}", - zip_path.display() - ); - } else { - match fs::metadata(&zip_path) { - Ok(_) => {} - Err(e) => log_warn!("Failed to read metadata for {}: {}", zip_path.display(), e), - } - } - - let mut archive = zip::ZipArchive::new(fs::File::open(&zip_path).map_err(|e| { - log_error!("Failed to open zip file {}: {}", zip_path.display(), e); - e.to_string() - })?) - .map_err(|e| { - log_error!("Failed to read zip archive {}: {}", zip_path.display(), e); - e.to_string() - })?; - - let total_files = archive.len() as u64; - - let mut files_extracted: u64 = 0; - let mut last_percentage: u8 = 0; + let app_handle = APP_HANDLE.lock().unwrap().clone(); - for i in 0..archive.len() { - let mut file_entry = archive.by_index(i).map_err(|e| e.to_string())?; - let outpath = unzip_path.join(file_entry.mangled_name()); - - if file_entry.name().ends_with('/') { - fs::create_dir_all(&outpath).map_err(|e| e.to_string())?; - } else { - if let Some(parent) = outpath.parent() { - if !parent.exists() { - log_debug!( - "Creating parent dir for {} -> {}", - file_entry.name(), - parent.display() - ); - fs::create_dir_all(parent).map_err(|e| { - log_error!("Failed to create parent dir {}: {}", parent.display(), e); - e.to_string() - })?; - } - } - let mut outfile = fs::File::create(&outpath).map_err(|e| { - log_error!("Failed to create output file {}: {}", outpath.display(), e); - e.to_string() - })?; - io::copy(&mut file_entry, &mut outfile).map_err(|e| { - log_error!( - "Failed to write extracted file {}: {}", - outpath.display(), - e - ); - e.to_string() - })?; - } - - files_extracted += 1; - - let percentage = ((files_extracted as f64 / total_files as f64) * 100.0) as u8; - if percentage != last_percentage { - last_percentage = percentage; - - if let Some(app_handle) = APP_HANDLE.lock().unwrap().as_ref() { - let progress_data = serde_json::json!({ - "file": emit_name, - "percentage": percentage, - "action": "extracting", - "files_extracted": files_extracted, - "total_files": total_files - }); - emit_to_main_window(app_handle, "unzip-progress", progress_data); - } - } - } - - if let Err(e) = fs::remove_file(&zip_path) { - log_debug!("Failed to delete zip file {}: {}", zip_path.display(), e); - } - - if let Some(app_handle) = APP_HANDLE.lock().unwrap().as_ref() { - emit_to_main_window(app_handle, "unzip-complete", &emit_name); - } + task::spawn_blocking(move || { + unzip(&zip_path, &unzip_path, &emit_name, app_handle.as_ref()) + }) + .await + .map_err(|e| e.to_string())??; Ok(()) } pub fn get_as_folder(&self, file: &str) -> PathBuf { - let file_name = Path::new(file).file_stem().unwrap().to_str().unwrap(); + let file_name = Path::new(file) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(file); self.root_dir.join(file_name) } pub fn get_as_folder_string(file: &str) -> String { - let file_name = Path::new(file).file_stem().unwrap().to_str().unwrap(); + let file_name = Path::new(file) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(file); format!("{file_name}{MAIN_SEPARATOR}") } pub fn get_filename(file: &str) -> String { - let file_name = Path::new(file).file_stem().unwrap().to_str().unwrap(); - file_name.to_string() + Path::new(file) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(file) + .to_string() } - pub async fn download(&self, file: &str) -> Result<(), String> { + fn resolve_local_file_info(file: &str) -> FileInfo { let is_url = file.starts_with("http://") || file.starts_with("https://"); let local_file = if is_url { - file.rsplit('/').next().unwrap_or(file) + file.rsplit('/').next().unwrap_or(file).to_string() } else { - file + file.to_string() }; - let file_name = Self::get_filename(local_file); + let file_name = Self::get_filename(&local_file); let is_fabric_client = local_file.starts_with("fabric/") && local_file.ends_with(".jar"); - let file_exists = if Self::has_extension(local_file, "zip") { - let zip_path = self.root_dir.join(local_file); + FileInfo { + local_file, + file_name, + is_fabric_client, + } + } + + fn get_destination_path(&self, file: &str, info: &FileInfo) -> PathBuf { + if info.is_fabric_client { + let jar_basename = Path::new(file) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(file); + self.root_dir + .join(&info.file_name) + .join("mods") + .join(jar_basename) + } else if Self::has_extension(&info.local_file, "jar") { + self.root_dir.join(&info.file_name).join(&info.local_file) + } else { + self.root_dir.join(&info.local_file) + } + } + + fn should_skip_download(&self, info: &FileInfo) -> bool { + if Self::has_extension(&info.local_file, "zip") { + let zip_path = self.root_dir.join(&info.local_file); zip_path.exists() - } else if Self::has_extension(local_file, "jar") { - if is_fabric_client { - let jar_basename = Path::new(local_file) + } else if Self::has_extension(&info.local_file, "jar") { + if info.is_fabric_client { + let jar_basename = Path::new(&info.local_file) .file_name() .and_then(|n| n.to_str()) - .unwrap_or(local_file); + .unwrap_or(&info.local_file); let jar_path = self .root_dir - .join(&file_name) + .join(&info.file_name) .join("mods") .join(jar_basename); jar_path.exists() } else { - let jar_path = self.get_local(&format!("{file_name}{MAIN_SEPARATOR}{local_file}")); + let jar_path = self.get_local(&format!( + "{}{MAIN_SEPARATOR}{}", + info.file_name, info.local_file + )); jar_path.exists() } } else { false - }; - - if file_exists { - log_debug!("File {} already exists, skipping download", file); - return Ok(()); - } - - log_debug!("Starting download for file: {}", file); - - if let Some(app_handle) = APP_HANDLE.lock().unwrap().as_ref() { - emit_to_main_window(app_handle, "download-start", &file); } + } - if Self::has_extension(local_file, "jar") { - if is_fabric_client { - let mods_dir = self.root_dir.join(&file_name).join("mods"); - if let Err(e) = fs::create_dir_all(&mods_dir) { + async fn prepare_download_dirs(&self, info: &FileInfo) -> Result<(), String> { + if Self::has_extension(&info.local_file, "jar") { + if info.is_fabric_client { + let mods_dir = self.root_dir.join(&info.file_name).join("mods"); + if let Err(e) = tokio_fs::create_dir_all(&mods_dir).await { log_error!( "Failed to create fabric mods directory {}: {}", mods_dir.display(), @@ -234,8 +189,8 @@ impl Data { } log_debug!("Created fabric mods directory: {}", mods_dir.display()); } else { - let local_path = self.get_as_folder(local_file); - if let Err(e) = fs::create_dir_all(&local_path) { + let local_path = self.get_as_folder(&info.local_file); + if let Err(e) = tokio_fs::create_dir_all(&local_path).await { log_error!("Failed to create directory {}: {}", local_path.display(), e); return Err(format!("Failed to create directory: {e}")); } @@ -245,133 +200,57 @@ impl Data { .map(|s| s.sync_client_settings.value) .unwrap_or(false) { - if let Err(e) = self.ensure_client_synced(&file_name) { - log_warn!("Failed to ensure client sync for {}: {}", file_name, e); + if let Err(e) = self.ensure_client_synced(&info.file_name).await { + log_warn!("Failed to ensure client sync for {}: {}", info.file_name, e); } } } } + Ok(()) + } - let cdn_url = SERVERS.selected_cdn.as_ref().map_or_else( - || { - log_error!("No CDN server available for download"); - Err("No CDN server available for download.".to_string()) - }, - |server| Ok(server.url.clone()), - )?; - - let download_url = if is_url { - file.to_string() + fn get_download_url(file: &str) -> Result { + let is_url = file.starts_with("http://") || file.starts_with("https://"); + if is_url { + Ok(file.to_string()) } else { - format!("{cdn_url}{file}") - }; - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(600)) - .build() - .map_err(|e| { - log_error!("Failed to create HTTP client: {}", e); - format!("Failed to create HTTP client: {e}") - })?; - - let response = client.get(&download_url).send().await.map_err(|e| { - log_error!("Failed to make HTTP request to {}: {}", download_url, e); - format!("Failed to download file {file}: {e}") - })?; - - if !response.status().is_success() { - let error_msg = format!( - "Failed to download file {}: HTTP {} - {}", - file, - response.status().as_u16(), - response - .status() - .canonical_reason() - .unwrap_or("Unknown error") - ); - log_error!("{}", error_msg); - return Err(error_msg); + let cdn_url = SERVERS.selected_cdn.read().unwrap().as_ref().map_or_else( + || { + log_error!("No CDN server available for download"); + Err("No CDN server available for download.".to_string()) + }, + |server| Ok(server.url.clone()), + )?; + Ok(format!("{cdn_url}{file}")) } + } - let total_size = response.content_length(); - let dest_path = if is_fabric_client { - let jar_basename = Path::new(file) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(file); - self.root_dir - .join(&file_name) - .join("mods") - .join(jar_basename) - } else if Self::has_extension(local_file, "jar") { - self.root_dir.join(format!("{file_name}/{local_file}")) - } else { - self.root_dir.join(local_file) - }; - - let mut dest = tokio::fs::File::create(&dest_path).await.map_err(|e| { - log_error!( - "Failed to create destination file {}: {}", - dest_path.display(), - e - ); - format!("Failed to create file: {e}") - })?; - - log_info!("Created destination file: {}", dest_path.display()); + pub async fn download(&self, file: &str) -> Result<(), String> { + let info = Self::resolve_local_file_info(file); - let mut downloaded: u64 = 0; - let mut last_percentage: u8 = 0; - let mut stream = response.bytes_stream(); + if self.should_skip_download(&info) { + log_debug!("File {} already exists, skipping download", file); + return Ok(()); + } - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| { - log_error!("Failed to read response data for {}: {}", file, e); - format!("Network read error: {e}") - })?; + if let Some(app_handle) = APP_HANDLE.lock().unwrap().as_ref() { + emit_to_main_window(app_handle, "download-start", &file); + } - dest.write_all(&chunk).await.map_err(|e| { - log_error!( - "Failed to write data to file {}: {}", - dest_path.display(), - e - ); - format!("File write error: {e}") - })?; + self.prepare_download_dirs(&info).await?; - downloaded += chunk.len() as u64; + let download_url = Self::get_download_url(file)?; + let dest_path = self.get_destination_path(file, &info); - let percentage = total_size.map_or_else( - || std::cmp::min(99, (downloaded / 1024 / 1024) as u8), - |total| ((downloaded as f64 / total as f64) * 100.0) as u8, - ); + let app_handle = APP_HANDLE.lock().unwrap().clone(); + download_file(&download_url, &dest_path, file, app_handle.as_ref()).await?; - if percentage != last_percentage { - last_percentage = percentage; - if let Some(app_handle) = APP_HANDLE.lock().unwrap().as_ref() { - let progress_data = serde_json::json!({ - "file": file, - "percentage": percentage, - "downloaded": downloaded, - "total": total_size.unwrap_or(0), - "action": "downloading" - }); - emit_to_main_window(app_handle, "download-progress", progress_data); - } - } + if let Some(handle) = app_handle.as_ref() { + emit_to_main_window(handle, "download-complete", &file); } - dest.flush().await.map_err(|e| { - log_error!("Failed to flush file {}: {}", dest_path.display(), e); - format!("File flush error: {e}") - })?; - - if let Some(app_handle) = APP_HANDLE.lock().unwrap().as_ref() { - emit_to_main_window(app_handle, "download-complete", &file); - } - - if Self::has_extension(local_file, "zip") { - self.unzip(file).map_err(|e| { + if Self::has_extension(&info.local_file, "zip") { + self.unzip(file).await.map_err(|e| { log_error!("Failed to extract {}: {}", file, e); if let Some(app_handle) = APP_HANDLE.lock().unwrap().as_ref() { let err_data = serde_json::json!({ @@ -384,56 +263,65 @@ impl Data { })?; } - if Self::has_extension(local_file, "jar") { - log_debug!("Verifying MD5 hash for client file: {}", file); - self.verify_client_hash(file, &dest_path).map_err(|e| { - log_error!("Failed to verify client hash for {}: {}", file, e); - e - })?; - } - Ok(()) } - pub fn ensure_client_synced(&self, client_base: &str) -> Result<(), String> { - let folders_to_sync = ["resourcepacks"]; - let files_to_sync = ["options.txt", "optionsof.txt"]; - + pub async fn ensure_client_synced(&self, client_base: &str) -> Result<(), String> { let global_options_dir = self.root_dir.join("synced_options"); if !global_options_dir.exists() { - if let Err(e) = fs::create_dir_all(&global_options_dir) { - return Err(format!("Failed to create global options dir: {e}")); - } + tokio_fs::create_dir_all(&global_options_dir) + .await + .map_err(|e| format!("Failed to create global options dir: {e}"))?; } let client_dir = self.root_dir.join(client_base); if !client_dir.exists() { - if let Err(e) = fs::create_dir_all(&client_dir) { - return Err(format!("Failed to create client dir: {e}")); - } + tokio_fs::create_dir_all(&client_dir) + .await + .map_err(|e| format!("Failed to create client dir: {e}"))?; } - for folder in &folders_to_sync { - let target = global_options_dir.join(folder); + let items = [ + ("resourcepacks", true), + ("options.txt", false), + ("optionsof.txt", false), + ]; + + for (name, is_dir) in items { + let target = global_options_dir.join(name); if !target.exists() { - if let Err(e) = fs::create_dir_all(&target) { - log_warn!( - "Failed to create global {} dir: {}: {}", - folder, - target.display(), - e - ); - continue; + if is_dir { + tokio_fs::create_dir_all(&target).await.map_err(|e| { + format!( + "Failed to create global {} dir: {}: {}", + name, + target.display(), + e + ) + })?; + } else { + tokio_fs::write(&target, "").await.map_err(|e| { + format!( + "Failed to create global {} file at {}: {}", + name, + target.display(), + e + ) + })?; } } - let client_target = client_dir.join(folder); - + let client_target = client_dir.join(name); if client_target.exists() { - if let Err(e) = fs::remove_dir_all(&client_target) { + let res = if client_target.is_dir() { + tokio_fs::remove_dir_all(&client_target).await + } else { + tokio_fs::remove_file(&client_target).await + }; + if let Err(e) = res { log_warn!( "Failed to remove existing client {} at {}: {}", - folder, + name, client_target.display(), e ); @@ -443,7 +331,7 @@ impl Data { if let Err(e) = Self::create_symlink(&target, &client_target) { log_warn!( "Failed to symlink {} for {}: {} -> {}: {}", - folder, + name, client_base, target.display(), client_target.display(), @@ -452,45 +340,6 @@ impl Data { } } - for file in &files_to_sync { - let global_file = global_options_dir.join(file); - if !global_file.exists() { - if let Err(e) = fs::write(&global_file, "") { - log_warn!( - "Failed to create global {} file at {}: {}", - file, - global_file.display(), - e - ); - continue; - } - } - - let client_file = client_dir.join(file); - - if client_file.exists() { - if let Err(e) = fs::remove_file(&client_file) { - log_warn!( - "Failed to remove existing client {} at {}: {}", - file, - client_file.display(), - e - ); - } - } - - if let Err(e) = Self::create_symlink(&global_file, &client_file) { - log_warn!( - "Failed to symlink {} for {}: {} -> {}: {}", - file, - client_base, - global_file.display(), - client_file.display(), - e - ); - } - } - Ok(()) } @@ -518,50 +367,10 @@ impl Data { emit_to_main_window(app_handle, "download-start", &file); } - let is_url = file.starts_with("http://") || file.starts_with("https://"); - let cdn_url = SERVERS.selected_cdn.as_ref().map_or_else( - || { - log_error!("No CDN server available for download"); - Err("No CDN server available for download.".to_string()) - }, - |server| Ok(server.url.clone()), - )?; - - let download_url = if is_url { - file.to_string() - } else { - format!("{cdn_url}{file}") - }; - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(600)) - .build() - .map_err(|e| { - log_error!("Failed to create HTTP client: {}", e); - format!("Failed to create HTTP client: {e}") - })?; - - let response = client.get(&download_url).send().await.map_err(|e| { - log_error!("Failed to make HTTP request to {}: {}", download_url, e); - format!("Failed to download file {file}: {e}") - })?; - - if !response.status().is_success() { - let error_msg = format!( - "Failed to download file {}: HTTP {} - {}", - file, - response.status().as_u16(), - response - .status() - .canonical_reason() - .unwrap_or("Unknown error") - ); - log_error!("{}", error_msg); - return Err(error_msg); - } + let download_url = Self::get_download_url(file)?; let dest_dir = self.root_dir.join(dest_folder); - if let Err(e) = std::fs::create_dir_all(&dest_dir) { + if let Err(e) = tokio_fs::create_dir_all(&dest_dir).await { log_error!( "Failed to create destination folder {}: {}", dest_dir.display(), @@ -573,221 +382,47 @@ impl Data { let dest_filename = file.rsplit('/').next().unwrap_or(file); let dest_path = dest_dir.join(dest_filename); - let mut dest = tokio::fs::File::create(&dest_path).await.map_err(|e| { - log_error!( - "Failed to create destination file {}: {}", - dest_path.display(), - e - ); - format!("Failed to create file: {e}") - })?; - - log_info!( - "Created destination file for folder download: {}", - dest_path.display() - ); - - let total_size = response.content_length(); - let mut downloaded: u64 = 0; - let mut last_percentage: u8 = 0; - let mut stream = response.bytes_stream(); - - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| { - log_error!("Failed to read response data for {}: {}", file, e); - format!("Network read error: {e}") - })?; + let app_handle = APP_HANDLE.lock().unwrap().clone(); + download_file(&download_url, &dest_path, file, app_handle.as_ref()).await?; - dest.write_all(&chunk).await.map_err(|e| { - log_error!( - "Failed to write data to file {}: {}", - dest_path.display(), - e - ); - format!("File write error: {e}") - })?; - - downloaded += chunk.len() as u64; - - let percentage = total_size.map_or_else( - || std::cmp::min(99, (downloaded / 1024 / 1024) as u8), - |total| ((downloaded as f64 / total as f64) * 100.0) as u8, - ); - - if percentage != last_percentage { - last_percentage = percentage; - if let Some(app_handle) = APP_HANDLE.lock().unwrap().as_ref() { - let progress_data = serde_json::json!({ - "file": file, - "percentage": percentage, - "downloaded": downloaded, - "total": total_size.unwrap_or(0), - "action": "downloading" - }); - emit_to_main_window(app_handle, "download-progress", progress_data); - } - } - } - - dest.flush().await.map_err(|e| { - log_error!("Failed to flush file {}: {}", dest_path.display(), e); - format!("File flush error: {e}") - })?; - - if let Some(app_handle) = APP_HANDLE.lock().unwrap().as_ref() { - emit_to_main_window(app_handle, "download-complete", &file); + if let Some(handle) = app_handle.as_ref() { + emit_to_main_window(handle, "download-complete", &file); } Ok(()) } - fn verify_client_hash(&self, filename: &str, file_path: &PathBuf) -> Result<(), String> { - let hash_verify_enabled = { - let settings = SETTINGS - .lock() - .map_err(|_| "Failed to access settings".to_string())?; - settings.hash_verify.value - }; - - let (expected_hash, client_id, client_name, is_fabric) = { - CLIENT_MANAGER - .lock() - .map_err(|_| "Failed to acquire lock on client manager".to_string())? - .as_ref() - .ok_or_else(|| "Client manager not initialized".to_string())? - .clients - .iter() - .find(|c| c.filename == filename) - .map(|c| { - ( - c.md5_hash.clone(), - c.id, - c.name.clone(), - c.client_type == ClientType::Fabric, - ) - }) - .ok_or_else(|| format!("Client not found for filename: {filename}"))? - }; - - if !hash_verify_enabled { - log_info!( - "Hash verification is disabled, skipping check for {}", - filename - ); - return Ok(()); - } - - if let Some(app_handle) = APP_HANDLE.lock().unwrap().as_ref() { - emit_to_main_window( - app_handle, - "client-hash-verification-start", - &serde_json::json!({ - "id": client_id, - "name": client_name - }), - ); - } - - log_info!( - "Verifying MD5 hash for downloaded client file: {}", - filename - ); - let calculated_hash = if is_fabric { - let client_folder = self.root_dir.join(Self::get_filename(filename)); - let jar_basename = std::path::Path::new(filename) - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| "Invalid fabric client filename".to_string())?; - let fabric_jar_path = client_folder.join("mods").join(jar_basename); - Self::calculate_md5_hash(&fabric_jar_path)? - } else { - Self::calculate_md5_hash(file_path)? - }; - - if calculated_hash != expected_hash { - if let Err(e) = fs::remove_file(file_path) { - log_warn!("Failed to remove corrupted file {}: {}", filename, e); - } - - if let Ok(mut manager) = CLIENT_MANAGER.lock() { - if let Some(manager) = manager.as_mut() { - if let Some(client) = manager.clients.iter_mut().find(|c| c.id == client_id) { - client.meta.installed = false; - } - } - } - - if let Some(app_handle) = APP_HANDLE.lock().unwrap().as_ref() { - emit_to_main_window( - app_handle, - "client-hash-verification-failed", - &serde_json::json!({ - "id": client_id, - "name": client_name, - "expected_hash": expected_hash, - "actual_hash": calculated_hash - }), - ); - } - - return Err(format!( - "Hash verification failed for {filename}. Expected: {expected_hash}, Got: {calculated_hash}. The file has been removed and needs to be redownloaded." - )); - } - - log_info!("MD5 hash verification successful for {}", filename); + pub async fn reset_requirements(&self) -> Result<(), String> { + let base_requirements = [ + JDK_FOLDER, + ASSETS_FOLDER, + NATIVES_FOLDER, + LIBRARIES_FOLDER, + NATIVES_LEGACY_FOLDER, + LIBRARIES_LEGACY_FOLDER, + ASSETS_FABRIC_FOLDER, + LIBRARIES_FABRIC_FOLDER, + NATIVES_FABRIC_FOLDER, + ]; - if let Some(app_handle) = APP_HANDLE.lock().unwrap().as_ref() { - emit_to_main_window( - app_handle, - "client-hash-verification-done", - &serde_json::json!({ - "id": client_id, - "name": client_name - }), - ); + let mut requirements = Vec::new(); + for req in base_requirements { + requirements.push(req.to_string()); + requirements.push(format!("{}.zip", req)); } - - Ok(()) - } - - pub fn calculate_md5_hash(path: &PathBuf) -> Result { - let bytes = fs::read(path).map_err(|e| format!("Failed to read file for hashing: {e}"))?; - - let digest = md5::compute(&bytes); - Ok(format!("{digest:x}")) - } - - pub fn reset_requirements(&self) -> Result<(), Box> { - let requirements: Vec = vec![ - JDK_FOLDER.to_string(), - format!("{JDK_FOLDER}.zip"), - "assets".to_string(), - "assets.zip".to_string(), - "natives".to_string(), - "natives.zip".to_string(), - "libraries".to_string(), - "libraries.zip".to_string(), - "natives-1.12".to_string(), - "natives-1.12.zip".to_string(), - "libraries-1.12".to_string(), - "libraries-1.12.zip".to_string(), - "assets_fabric".to_string(), - "assets_fabric.zip".to_string(), - "libraries_fabric".to_string(), - "libraries_fabric.zip".to_string(), - "natives_fabric".to_string(), - "natives_fabric.zip".to_string(), - "minecraft_versions".to_string(), - ]; + requirements.push(MINECRAFT_VERSIONS_FOLDER.to_string()); for requirement in &requirements { let path = self.root_dir.join(requirement); if path.exists() { if path.is_dir() { - fs::remove_dir_all(&path)?; + tokio_fs::remove_dir_all(&path) + .await + .map_err(|e| e.to_string())?; } else { - fs::remove_file(&path)?; + tokio_fs::remove_file(&path) + .await + .map_err(|e| e.to_string())?; } } } @@ -795,10 +430,12 @@ impl Data { Ok(()) } - pub fn reset_cache(&self) -> Result<(), Box> { + pub async fn reset_cache(&self) -> Result<(), String> { let cache_dir = self.root_dir.join("cache"); if cache_dir.exists() { - fs::remove_dir_all(&cache_dir)?; + tokio_fs::remove_dir_all(&cache_dir) + .await + .map_err(|e| e.to_string())?; } Ok(()) } diff --git a/src-tauri/src/core/storage/presets.rs b/src-tauri/src/core/storage/presets.rs index 2d49227..d580a01 100644 --- a/src-tauri/src/core/storage/presets.rs +++ b/src-tauri/src/core/storage/presets.rs @@ -5,6 +5,7 @@ use std::sync::LazyLock; use std::{collections::HashMap, path::PathBuf, sync::Mutex as StdMutex}; #[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ThemePreset { pub id: String, pub name: String, diff --git a/src-tauri/src/core/storage/settings.rs b/src-tauri/src/core/storage/settings.rs index 9d02d49..0460be1 100644 --- a/src-tauri/src/core/storage/settings.rs +++ b/src-tauri/src/core/storage/settings.rs @@ -55,6 +55,7 @@ macro_rules! define_settings { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct $name { $(pub $field: Setting<$field_type>,)* + #[serde(skip)] pub config_path: PathBuf, } diff --git a/src-tauri/src/core/utils/archive.rs b/src-tauri/src/core/utils/archive.rs new file mode 100644 index 0000000..1825f3e --- /dev/null +++ b/src-tauri/src/core/utils/archive.rs @@ -0,0 +1,115 @@ +use crate::core::utils::helpers::emit_to_main_window; +use crate::{log_debug, log_error, log_warn}; +use std::fs; +use std::io; +use std::path::Path; + +pub fn unzip( + zip_path: &Path, + unzip_path: &Path, + emit_name: &str, + app_handle: Option<&tauri::AppHandle>, +) -> Result<(), String> { + if let Some(handle) = app_handle { + emit_to_main_window(handle, "unzip-start", emit_name); + } + + if unzip_path.exists() { + log_debug!( + "Directory {} exists, will overwrite contents", + unzip_path.display() + ); + } else { + log_debug!("Creating unzip directory: {}", unzip_path.display()); + fs::create_dir_all(unzip_path).map_err(|e| e.to_string())?; + } + + if !zip_path.exists() { + log_error!( + "Zip file not found at expected path: {}", + zip_path.display() + ); + } else { + match fs::metadata(zip_path) { + Ok(_) => {} + Err(e) => log_warn!("Failed to read metadata for {}: {}", zip_path.display(), e), + } + } + + let mut archive = zip::ZipArchive::new(fs::File::open(zip_path).map_err(|e| { + log_error!("Failed to open zip file {}: {}", zip_path.display(), e); + e.to_string() + })?) + .map_err(|e| { + log_error!("Failed to read zip archive {}: {}", zip_path.display(), e); + e.to_string() + })?; + + let total_files = archive.len() as u64; + + let mut files_extracted: u64 = 0; + let mut last_percentage: u8 = 0; + + for i in 0..archive.len() { + let mut file_entry = archive.by_index(i).map_err(|e| e.to_string())?; + let outpath = unzip_path.join(file_entry.mangled_name()); + + if file_entry.name().ends_with('/') { + fs::create_dir_all(&outpath).map_err(|e| e.to_string())?; + } else { + if let Some(parent) = outpath.parent() { + if !parent.exists() { + log_debug!( + "Creating parent dir for {} -> {}", + file_entry.name(), + parent.display() + ); + fs::create_dir_all(parent).map_err(|e| { + log_error!("Failed to create parent dir {}: {}", parent.display(), e); + e.to_string() + })?; + } + } + let mut outfile = fs::File::create(&outpath).map_err(|e| { + log_error!("Failed to create output file {}: {}", outpath.display(), e); + e.to_string() + })?; + io::copy(&mut file_entry, &mut outfile).map_err(|e| { + log_error!( + "Failed to write extracted file {}: {}", + outpath.display(), + e + ); + e.to_string() + })?; + } + + files_extracted += 1; + + let percentage = ((files_extracted as f64 / total_files as f64) * 100.0) as u8; + if percentage != last_percentage { + last_percentage = percentage; + + if let Some(handle) = app_handle { + let progress_data = serde_json::json!({ + "file": emit_name, + "percentage": percentage, + "action": "extracting", + "files_extracted": files_extracted, + "total_files": total_files + }); + emit_to_main_window(handle, "unzip-progress", progress_data); + } + } + } + + if let Err(e) = fs::remove_file(zip_path) { + log_debug!("Failed to delete zip file {}: {}", zip_path.display(), e); + } + + if let Some(handle) = app_handle { + emit_to_main_window(handle, "unzip-complete", emit_name); + } + + Ok(()) +} diff --git a/src-tauri/src/core/utils/discord_rpc.rs b/src-tauri/src/core/utils/discord_rpc.rs index f7f38ea..4c205d3 100644 --- a/src-tauri/src/core/utils/discord_rpc.rs +++ b/src-tauri/src/core/utils/discord_rpc.rs @@ -78,12 +78,12 @@ pub fn update_activity(details: String, state: String) -> Result<(), String> { .timestamps(activity::Timestamps::new().start(start_time as i64)); if let Err(e) = discord_client.set_activity(activity.clone()) { - log_error!("Failed to update Discord activity: {}", e); + log_warn!("Failed to update Discord activity: {}", e); if let Err(e) = discord_client.connect() { log_error!("Failed to reconnect to Discord: {}", e); } else if let Err(e) = discord_client.set_activity(activity) { - log_error!( + log_warn!( "Failed to update Discord activity after reconnection: {}", e ); @@ -96,7 +96,7 @@ pub fn update_activity(details: String, state: String) -> Result<(), String> { pub fn update_activity_async(details: String, state: String) { std::thread::spawn(move || { if let Err(e) = update_activity(details, state) { - log_error!("Failed to update Discord activity asynchronously: {}", e); + log_warn!("Failed to update Discord activity asynchronously: {}", e); } }); } diff --git a/src-tauri/src/core/utils/dpi.rs b/src-tauri/src/core/utils/dpi.rs index 4357988..9c2e5ee 100644 --- a/src-tauri/src/core/utils/dpi.rs +++ b/src-tauri/src/core/utils/dpi.rs @@ -51,9 +51,8 @@ pub fn enable_dpi_bypass_async() -> Result<(), String> { pub fn enable_dpi_bypass_async() { log_info!("DPI bypass is only supported on Windows; skipping"); } - #[cfg(target_os = "windows")] -fn enable_dpi_bypass_inner() -> Result<(), String> { +pub fn download_dpi_bypass() -> Result<(), String> { let download_url = match reqwest::blocking::Client::new() .get(DPI_RELEASE_API) .header(reqwest::header::USER_AGENT, "CollapseLoader") @@ -120,8 +119,13 @@ fn enable_dpi_bypass_inner() -> Result<(), String> { rt.block_on(async { DATA.download(&download_url).await }) .map_err(|e| format!("Failed to download DPI package: {}", e))?; - start_winws_background_inner()?; + Ok(()) +} +#[cfg(target_os = "windows")] +fn enable_dpi_bypass_inner() -> Result<(), String> { + download_dpi_bypass()?; + start_winws_background_inner()?; Ok(()) } @@ -149,7 +153,6 @@ pub fn start_winws_background_if_configured() { fn start_winws_background_inner() -> Result<(), String> { let settings = SETTINGS.lock().map_err(|e| e.to_string())?.clone(); if !settings.dpi_bypass.value { - log_info!("DPI bypass disabled; skipping winws start"); return Ok(()); } diff --git a/src-tauri/src/core/utils/globals.rs b/src-tauri/src/core/utils/globals.rs index 7ca66b3..960de93 100644 --- a/src-tauri/src/core/utils/globals.rs +++ b/src-tauri/src/core/utils/globals.rs @@ -2,12 +2,15 @@ use std::{fs, path::PathBuf, sync::LazyLock}; use crate::{core::network::servers::Server, log_debug, log_info}; -pub static CODENAME: &str = "NullPtr"; +pub static CODENAME: &str = "Agent"; pub static GITHUB_REPO_OWNER: &str = "dest4590"; pub static GITHUB_REPO_NAME: &str = "CollapseLoader"; pub static IS_LINUX: bool = cfg!(target_os = "linux"); pub static FILE_EXTENSION: &str = if IS_LINUX { "" } else { ".exe" }; +pub static LINUX_SUFFIX: &str = "-linux"; +pub static LEGACY_SUFFIX: &str = "-1.12"; +pub static PATH_SEPARATOR: &str = if IS_LINUX { ":" } else { ";" }; pub static JDK_FOLDER: &str = if IS_LINUX { "jdk-21.0.2_linux" @@ -15,6 +18,37 @@ pub static JDK_FOLDER: &str = if IS_LINUX { "jdk-21.0.2" }; +// Asset/library file names (without .zip extension) +pub static ASSETS_FOLDER: &str = "assets"; +pub static ASSETS_FABRIC_FOLDER: &str = "assets_fabric"; +pub static LIBRARIES_FOLDER: &str = "libraries"; +pub static LIBRARIES_LEGACY_FOLDER: &str = "libraries-1.12"; +pub static LIBRARIES_FABRIC_FOLDER: &str = "libraries_fabric"; +pub static NATIVES_FOLDER: &str = "natives"; +pub static NATIVES_LINUX_FOLDER: &str = "natives-linux"; +pub static NATIVES_LEGACY_FOLDER: &str = "natives-1.12"; +pub static NATIVES_FABRIC_FOLDER: &str = "natives_fabric"; + +// Zip file names +pub static ASSETS_ZIP: &str = "assets.zip"; +pub static ASSETS_FABRIC_ZIP: &str = "assets_fabric.zip"; +pub static LIBRARIES_ZIP: &str = "libraries.zip"; +pub static LIBRARIES_LEGACY_ZIP: &str = "libraries-1.12.zip"; +pub static LIBRARIES_FABRIC_ZIP: &str = "libraries_fabric.zip"; +pub static NATIVES_ZIP: &str = "natives.zip"; +pub static NATIVES_LINUX_ZIP: &str = "natives-linux.zip"; +pub static NATIVES_LEGACY_ZIP: &str = "natives-1.12.zip"; + +// Folder names +pub static MINECRAFT_VERSIONS_FOLDER: &str = "minecraft_versions"; +pub static AGENT_OVERLAY_FOLDER: &str = "agent_overlay"; +pub static CUSTOM_CLIENTS_FOLDER: &str = "custom_clients"; +pub static MODS_FOLDER: &str = "mods"; + +// IRC Chat Host +pub static IRC_HOST: &str = "irc.collapseloader.org:1338"; +//pub static IRC_HOST: &str = "127.0.0.1:1338"; + fn parse_env_bool(var: &str) -> bool { std::env::var(var).ok().is_some_and(|s| { let s = s.trim().to_ascii_lowercase(); @@ -33,17 +67,31 @@ pub static USE_LOCAL_SERVER: LazyLock = LazyLock::new(|| { pub static SKIP_AGENT_OVERLAY_VERIFICATION: LazyLock = LazyLock::new(|| parse_env_bool("SKIP_AGENT_OVERLAY_VERIFICATION")); +pub static MOCK_CLIENTS: LazyLock = LazyLock::new(|| parse_env_bool("MOCK_CLIENTS")); + pub static CDN_SERVERS: LazyLock> = LazyLock::new(|| { + if let Ok(url) = std::env::var("FORCE_CDN") { + if !url.is_empty() { + log_info!("Using forced CDN server: {}", url); + return vec![Server::new(&url)]; + } + } vec![ - Server::new("https://cdn.collapseloader.org/"), - Server::new("https://collapse.ttfdk.lol/cdn/"), - Server::new( - "https://axkanxneklh7.objectstorage.eu-amsterdam-1.oci.customer-oci.com/n/axkanxneklh7/b/collapse/o/", - ), -] + Server::new("https://cdn.collapseloader.org/"), + Server::new("https://collapse.ttfdk.lol/cdn/"), + Server::new( + "https://axkanxneklh7.objectstorage.eu-amsterdam-1.oci.customer-oci.com/n/axkanxneklh7/b/collapse/o/", + ), + ] }); pub static AUTH_SERVERS: LazyLock> = LazyLock::new(|| { + if let Ok(url) = std::env::var("FORCE_AUTH") { + if !url.is_empty() { + log_info!("Using forced Auth server: {}", url); + return vec![Server::new(&url)]; + } + } if *USE_LOCAL_SERVER { vec![ Server::new("http://localhost:8000/"), diff --git a/src-tauri/src/core/utils/hashing.rs b/src-tauri/src/core/utils/hashing.rs new file mode 100644 index 0000000..2a75df5 --- /dev/null +++ b/src-tauri/src/core/utils/hashing.rs @@ -0,0 +1,23 @@ +use std::fs::File; +use std::io::{BufReader, Read}; +use std::path::Path; + +pub fn calculate_md5_hash(path: &Path) -> Result { + let file = File::open(path).map_err(|e| format!("Failed to open file for hashing: {e}"))?; + let mut reader = BufReader::new(file); + let mut context = md5::Context::new(); + let mut buffer = [0; 8192]; + + loop { + let count = reader + .read(&mut buffer) + .map_err(|e| format!("Failed to read file: {e}"))?; + if count == 0 { + break; + } + context.consume(&buffer[..count]); + } + + let digest = context.finalize(); + Ok(format!("{digest:x}")) +} diff --git a/src-tauri/src/core/utils/helpers.rs b/src-tauri/src/core/utils/helpers.rs index db46133..8a324f8 100644 --- a/src-tauri/src/core/utils/helpers.rs +++ b/src-tauri/src/core/utils/helpers.rs @@ -1,6 +1,6 @@ use crate::log_error; use serde::Serialize; -use tauri::{AppHandle, Emitter, EventTarget, Manager}; +use tauri::{AppHandle, Emitter, Manager}; pub fn emit_to_main_window(app_handle: &AppHandle, event: &str, payload: S) { if let Some(window) = app_handle.get_webview_window("main") { @@ -12,23 +12,6 @@ pub fn emit_to_main_window(app_handle: &AppHandle, event: } } -pub fn emit_to_main_window_filtered( - app_handle: &AppHandle, - event: &str, - payload: S, -) { - if let Err(e) = app_handle.emit_filter(event, payload, |target| match target { - EventTarget::Window { label } => label == "main", - _ => false, - }) { - log_error!( - "Failed to emit filtered event '{}' to main window: {}", - event, - e - ); - } -} - pub fn is_development_enabled() -> bool { env!("DEVELOPMENT").to_lowercase() == "true" -} \ No newline at end of file +} diff --git a/src-tauri/src/core/utils/logging.rs b/src-tauri/src/core/utils/logging.rs index 3976b06..46c9604 100644 --- a/src-tauri/src/core/utils/logging.rs +++ b/src-tauri/src/core/utils/logging.rs @@ -44,8 +44,18 @@ impl Logger { } } - let shorted_tag = tag.rsplit("collapseloader_lib.").next().unwrap_or(tag); - let shorted_tag = shorted_tag.replace("collapse.module.collapseloader_lib", "core.init"); + let mut shorted_tag = { + let slice = if let Some(pos) = tag.rfind("collapseloader_lib.") { + &tag[pos + "collapseloader_lib.".len()..] + } else { + tag + }; + slice.replace("collapse.module.collapseloader_lib", "core.init") + }; + + if shorted_tag.starts_with("commands.") { + shorted_tag.insert_str(0, "tauri."); + } let ts_colored = timestamp.dimmed(); let tag_colored = shorted_tag.white(); @@ -93,7 +103,6 @@ impl Logger { impl Logger { pub fn print_startup_banner( - app_name: &str, version: &str, codename: &str, is_dev: bool, @@ -109,7 +118,7 @@ impl Logger { let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); - let header = format!("{} v{} ({})", app_name, version, codename); + let header = format!("CollapseLoader v{} ({})", version, codename); let dev_info = if is_dev { format!("development build - {} ({})", git_hash, git_branch) } else { diff --git a/src-tauri/src/core/utils/mod.rs b/src-tauri/src/core/utils/mod.rs index 3b29a83..2e0a5ea 100644 --- a/src-tauri/src/core/utils/mod.rs +++ b/src-tauri/src/core/utils/mod.rs @@ -1,6 +1,9 @@ +pub mod archive; pub mod discord_rpc; +pub mod dpi; pub mod globals; +pub mod hashing; pub mod helpers; pub mod logging; +pub mod process; pub mod tags; -pub mod dpi; diff --git a/src-tauri/src/core/utils/process.rs b/src-tauri/src/core/utils/process.rs new file mode 100644 index 0000000..c98fbe7 --- /dev/null +++ b/src-tauri/src/core/utils/process.rs @@ -0,0 +1,135 @@ +use std::process::{Command, Output}; + +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; + +use crate::core::storage::data::DATA; +use crate::core::utils::globals::{FILE_EXTENSION, JDK_FOLDER}; +use crate::{log_debug, log_error, log_info, log_warn}; + +pub fn is_java_installed() -> bool { + let jps_path = get_jps_path(); + jps_path.exists() +} + +pub fn get_jps_path() -> std::path::PathBuf { + DATA.root_dir + .join(JDK_FOLDER) + .join("bin") + .join("jps".to_owned() + FILE_EXTENSION) +} + +pub fn execute_jps() -> Result { + if !is_java_installed() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Java is not installed", + )); + } + + let jps_path = get_jps_path(); + let mut command = Command::new(jps_path); + + #[cfg(target_os = "windows")] + command.creation_flags(0x0800_0000); + + command.arg("-m").output() +} + +pub fn get_jps_output_lines() -> Vec { + match execute_jps() { + Ok(output) => { + let binding = String::from_utf8_lossy(&output.stdout); + binding.lines().map(|s| s.to_string()).collect() + } + Err(e) => { + log_warn!("Failed to execute jps command: {}", e); + Vec::new() + } + } +} + +pub fn find_processes_by_filename(filename: &str) -> Vec { + let lines = get_jps_output_lines(); + lines + .iter() + .filter(|line| line.contains(filename)) + .filter_map(|line| line.split_whitespace().next().map(|s| s.to_string())) + .collect() +} + +pub fn kill_process(pid: &str, client_name: &str) -> Result { + log_debug!( + "Attempting to kill process with PID: {} for '{}'", + pid, + client_name + ); + + let mut kill_command = Command::new("taskkill"); + + #[cfg(target_os = "windows")] + kill_command.creation_flags(0x0800_0000); + + let kill_output = kill_command + .arg("/PID") + .arg(pid) + .arg("/F") + .output() + .map_err(|e| { + log_error!("Failed to execute taskkill for PID {}: {}", pid, e); + format!("Failed to kill process: {e}") + })?; + + if kill_output.status.success() { + log_info!("Successfully killed process {} for '{}'", pid, client_name); + Ok(true) + } else { + log_error!( + "taskkill failed for PID {}: {}", + pid, + String::from_utf8_lossy(&kill_output.stderr) + ); + Ok(false) + } +} + +pub fn stop_process_by_filename(filename: &str, client_name: &str) -> Result<(), String> { + log_info!("Attempting to stop process for '{}'", client_name); + + let lines = match execute_jps() { + Ok(output) => { + let binding = String::from_utf8_lossy(&output.stdout); + binding.lines().map(|s| s.to_string()).collect::>() + } + Err(e) => { + log_error!("Failed to execute jps command for stopping: {}", e); + return Err(format!("Failed to execute jps command: {e}")); + } + }; + + let mut process_found = false; + for line in &lines { + if line.contains(filename) { + process_found = true; + let pid = line.split_whitespace().next().unwrap_or_default(); + kill_process(pid, client_name)?; + } + } + + if !process_found { + log_info!("No process found for: {}", client_name); + } + + Ok(()) +} + +pub fn filter_running(items: Vec, get_filename: F) -> Vec +where + F: Fn(&T) -> &str, +{ + let lines = get_jps_output_lines(); + items + .into_iter() + .filter(|item| lines.iter().any(|line| line.contains(get_filename(item)))) + .collect() +} diff --git a/src-tauri/src/core/utils/tags.rs b/src-tauri/src/core/utils/tags.rs index 8d9bef6..4694954 100644 --- a/src-tauri/src/core/utils/tags.rs +++ b/src-tauri/src/core/utils/tags.rs @@ -1,9 +1,3 @@ -use std::collections::HashMap; -use std::sync::Mutex; - -static TAG_CACHE: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); - fn make_tag_from_module_path(module_path: &str) -> String { let cleaned = module_path.replace("::", "."); let cleaned = cleaned @@ -12,23 +6,27 @@ fn make_tag_from_module_path(module_path: &str) -> String { format!("collapse.module.{cleaned}") } -pub fn collapse_module_tag_cached_from_module_path(module_path: &str) -> &'static str { - let key = make_tag_from_module_path(module_path); - let mut cache = TAG_CACHE.lock().unwrap(); - if let Some(&v) = cache.get(&key) { - return v; - } - let leaked: &'static str = Box::leak(key.into_boxed_str()); - cache.insert(leaked.to_string(), leaked); - leaked +pub fn collapse_module_tag_from_module_path(module_path: &str) -> String { + make_tag_from_module_path(module_path) +} + +pub fn collapse_module_tag_from_file(file_path: &str) -> String { + let mut cleaned = file_path.replace("\\", "/"); + cleaned = cleaned.trim_start_matches("./").to_string(); + cleaned = cleaned + .trim_start_matches("crate/") + .trim_start_matches("/") + .to_string(); + let cleaned = cleaned.replace('/', ".").replace('.', "_"); + format!("collapse.module.{cleaned}") } #[macro_export] macro_rules! collapse_tag { () => { - $crate::core::utils::tags::collapse_module_tag_cached_from_module_path(module_path!()) + $crate::core::utils::tags::collapse_module_tag_from_module_path(module_path!()) }; (file) => { - $crate::core::utils::tags::collapse_module_tag_cached_from_file(file!()) + $crate::core::utils::tags::collapse_module_tag_from_file(file!()) }; } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 326485d..ac2e80e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,9 @@ +use crate::core::clients::manager::ClientManager; #[cfg(target_os = "windows")] use crate::core::platform::messagebox; use crate::core::utils::discord_rpc; use crate::{core::storage::data::APP_HANDLE, logging::Logger}; +use std::sync::{Arc, Mutex}; use tauri::Manager; use crate::core::{ @@ -12,13 +14,14 @@ use crate::core::{ use crate::core::platform::check_webkit_environment; use self::core::network::analytics::Analytics; +use crate::core::network::servers::SERVERS; pub use crate::core::utils::logging; +use tauri::async_runtime::spawn; pub mod commands; pub mod core; pub fn check_dependencies() -> Result<(), StartupError> { - log_info!("Checking platform dependencies..."); check_platform_dependencies() } @@ -73,6 +76,8 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) + .manage(Arc::new(Mutex::new(ClientManager::default()))) + .manage(commands::irc::IrcState::default()) .invoke_handler(tauri::generate_handler![ commands::clients::initialize_api, commands::clients::initialize_rpc, @@ -98,6 +103,7 @@ pub fn run() { commands::clients::launch_custom_client, commands::clients::get_running_custom_client_ids, commands::clients::stop_custom_client, + commands::clients::detect_main_class, commands::presets::get_all_presets, commands::presets::get_preset, commands::presets::create_preset, @@ -124,6 +130,7 @@ pub fn run() { commands::settings::get_favorite_clients, commands::settings::add_favorite_client, commands::settings::remove_favorite_client, + commands::settings::set_all_favorites, commands::settings::is_client_favorite, commands::settings::get_system_memory, commands::utils::get_version, @@ -131,7 +138,6 @@ pub fn run() { commands::utils::get_auth_url, commands::utils::open_data_folder, commands::utils::reset_requirements, - commands::utils::reset_cache, commands::utils::get_data_folder, commands::utils::change_data_folder, commands::utils::decode_base64, @@ -140,13 +146,14 @@ pub fn run() { commands::updater::check_for_updates, commands::updater::download_and_install_update, commands::updater::get_changelog, + commands::irc::connect_irc, + commands::irc::disconnect_irc, + commands::irc::send_irc_message, ]) .setup(|app| { let app_handle = app.handle(); *APP_HANDLE.lock().unwrap() = Some(app_handle.clone()); - log_debug!("Tauri setup: application handle stored"); - let is_dev = env!("DEVELOPMENT") == "true"; let git_hash = env!("GIT_HASH") .to_string() @@ -172,16 +179,12 @@ pub fn run() { } } - Logger::print_startup_banner( - "CollapseLoader", - version, - &codename, - is_dev, - &git_hash, - &git_branch, - ); + Logger::print_startup_banner(version, &codename, is_dev, &git_hash, &git_branch); - Analytics::send_start_analytics(); + spawn(async { + SERVERS.check_servers().await; + Analytics::send_start_analytics(); + }); #[cfg(target_os = "windows")] { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index df486b3..7a0f50f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,7 +1,22 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +#[cfg(target_os = "windows")] +use windows::Win32::UI::HiDpi::{SetProcessDpiAwareness, PROCESS_SYSTEM_DPI_AWARE}; + fn main() { + #[cfg(target_os = "windows")] + unsafe { + if let Err(e) = SetProcessDpiAwareness(PROCESS_SYSTEM_DPI_AWARE) { + use collapseloader_lib::core::error::StartupError; + + collapseloader_lib::log_error!("Failed to set DPI awareness: {}", e); + collapseloader_lib::handle_startup_error(&StartupError::DpiAwarenessFailed( + e.to_string(), + )); + } + } + let _ = dotenvy::dotenv(); if let Err(e) = collapseloader_lib::check_dependencies() { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8e5a624..f3b5ca9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "collapseloader", - "version": "0.2.3", + "version": "0.2.4", "identifier": "org.collapseloader", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.vue b/src/App.vue index ae5c738..c4da9db 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,26 +4,20 @@ import { listen } from '@tauri-apps/api/event'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { router } from './services/router'; import { useI18n } from 'vue-i18n'; -import { Vue3Lottie } from 'vue3-lottie'; -import preloader from './assets/misc/preloader.json'; import GlobalModal from './components/modals/GlobalModal.vue'; import DevMenuModal from './components/core/DevMenuModal.vue'; import InitialSetupModals from './components/core/InitialSetupModals.vue'; import DownloadProgress from './components/features/download/DownloadProgress.vue'; import Sidebar from './components/layout/Sidebar.vue'; -import ClientCrashModal from './components/modals/clients/ClientCrashModal.vue'; import RegisterPromptModal from './components/modals/social/account/RegisterPromptModal.vue'; import ToastContainer from './components/notifications/ToastContainer.vue'; -import BootLogs from './components/core/BootLogs.vue'; -import { globalFriends } from './composables/useFriends'; import { useUser } from './composables/useUser'; import { globalUserStatus } from './composables/useUserStatus'; -import { useModal } from './services/modalService'; import { syncService } from './services/syncService'; +import { settingsService } from './services/settingsService'; import { useToast } from './services/toastService'; import { themeService } from './services/themeService'; import { updaterService } from './services/updaterService'; -import { bootLogService } from './services/bootLogService'; import About from './views/About.vue'; import AccountView from './views/AccountView.vue'; import AdminView from './views/AdminView.vue'; @@ -38,13 +32,11 @@ import UserProfileView from './views/UserProfileView.vue'; import News from './views/News.vue'; import CustomClients from './views/CustomClients.vue'; import Marketplace from './views/Marketplace.vue'; -import { apiGet } from './services/apiClient'; -import { getCurrentLanguage } from './i18n'; -import { fetchSettings, applyLanguageOnStartup, applyThemeOnStartup } from './utils/settings'; +import { fetchSettings } from './utils/settings'; import { getDiscordState } from './utils/discord'; import { VALID_TABS } from './utils/tabs'; import { getIsDevelopment } from './utils/isDevelopment'; -import { isHalloweenEvent, getEventGreeting } from './utils/events'; +import Preloader from './components/core/Preloader.vue'; interface Setting { description: string; @@ -57,55 +49,56 @@ interface Flags { optional_analytics: Setting; } -interface AppSettings { - theme: Setting; - language: Setting; - [key: string]: Setting; -} +import { useAppInit } from './composables/useAppInit'; const { t, locale } = useI18n(); +const { + showPreloader, + loadingState, + currentProgress, + totalSteps, + isOnline, + showFirstRunInfo, + showInitialDisclaimer, + halloweenActive, + currentTheme, + initApp, + initializeUserDataWrapper +} = useAppInit(); + const activeTab = computed(() => router.currentRoute.value as any); -const showPreloader = ref(true); -const contentVisible = ref(false); -const loadingState = ref(t('preloader.initializing')); -const loadingStates = [ - t('preloader.initializing'), - t('preloader.connecting_servers'), -]; -const currentProgress = ref(0); -const totalSteps = ref(4); -const showInitialDisclaimer = ref(false); -const showFirstRunInfo = ref(false); -const initialModalsLoaded = ref(false); const showDevMenu = ref(false); const { addToast } = useToast(); -const { showModal } = useModal(); -const isOnline = ref(true); -const currentTheme = ref('dark'); const isAuthenticated = ref(false); const showRegistrationPrompt = ref(false); const currentUserId = ref(null); -const isNavigatingToProfile = ref(false); const previousTab = ref('home'); const news = ref([]); const unreadNewsCount = ref(0); const isDev = ref(false); -const halloweenActive = ref(isHalloweenEvent()); -const halloweenGreeting = ref(getEventGreeting()); -const { loadUserData, displayName, isAuthenticated: userAuthenticated } = useUser(); -const { - friends, - onlineFriendsCount, - loadFriendsData, - isLoading: friendsLoading -} = globalFriends; +const sidebarPosition = ref(localStorage.getItem('sidebarPosition') as 'left' | 'right' | 'top' | 'bottom' || 'left'); + +const updateSidebarPosition = (newPosition: 'left' | 'right' | 'top' | 'bottom') => { + sidebarPosition.value = newPosition; + localStorage.setItem('sidebarPosition', newPosition); +}; + +const mainClasses = computed(() => { + const base = 'w-full p-6 bg-base-200 overflow-y-auto overflow-x-hidden'; + const pos = sidebarPosition.value; + + if (pos === 'left') return `${base} ml-20 h-screen`; + if (pos === 'right') return `${base} mr-20 h-screen`; + if (pos === 'top') return `${base} mt-20 h-[calc(100vh-5rem)]`; + if (pos === 'bottom') return `${base} h-[calc(100vh-5rem)]`; + + return base; +}); + const { - isOnline: userOnline, - connectionStatus, - initializeStatusSystem, stopStatusSync } = globalUserStatus; @@ -116,7 +109,6 @@ const handleUnreadNewsCountUpdated = (count: number) => { const setActiveTab = (tab: string, opts?: { userId?: number | null }) => { if (!VALID_TABS.includes(tab)) return; previousTab.value = router.currentRoute.value; - isNavigatingToProfile.value = false; if (opts && Object.prototype.hasOwnProperty.call(opts, 'userId')) { currentUserId.value = opts!.userId ?? null; } else { @@ -127,7 +119,6 @@ const setActiveTab = (tab: string, opts?: { userId?: number | null }) => { const showUserProfile = (userId: number) => { previousTab.value = router.currentRoute.value; - isNavigatingToProfile.value = true; setActiveTab('user-profile', { userId }); }; @@ -138,250 +129,6 @@ const checkAuthStatus = () => { isAuthenticated.value = !!token; }; -const fetchNewsAndUpdateUnreadCount = async () => { - try { - const currentLanguage = getCurrentLanguage() || 'en'; - const response = await apiGet('/news/', { - headers: { - 'Accept-Language': currentLanguage, - 'Content-Type': 'application/json', - }, - }); - const allNews = response as any[]; - let filteredNews = allNews.filter( - (article) => article.language === currentLanguage - ); - news.value = filteredNews; - - const getNewsUniqueId = (article: any) => - `news_${article.language}_${article.id}`; - const readNews = JSON.parse(localStorage.getItem('readNews') || '[]'); - unreadNewsCount.value = news.value.filter( - (article) => !readNews.includes(getNewsUniqueId(article)) - ).length; - } catch (e) { - console.error('Failed to fetch news:', e); - unreadNewsCount.value = 0; - } -}; - -const initApp = async () => { - bootLogService.start(); - bootLogService.systemInit(); - - try { - await invoke('initialize_rpc'); - } catch (error) { - console.error('Failed to initialize Discord RPC:', error); - bootLogService.addCustomLog('WARN', 'rpc', `Discord RPC init failed: ${String(error)}`); - } - - await applyThemeOnStartup(); - - bootLogService.themeApplied(currentTheme.value); - - await applyLanguageOnStartup(); - - bootLogService.languageApplied(locale.value || getCurrentLanguage() || 'en'); - - const { getToastPosition } = useToast(); - getToastPosition(); - bootLogService.toastSystemReady(); - - showPreloader.value = true; - currentProgress.value = 0; - totalSteps.value = 4; - - bootLogService.eventListenersInit(); - - listen('client-crashed', (event) => { - const payload = event.payload as { - id: number; - name: string; - error?: string; - }; - addToast( - t('toast.client.crashed', { - name: payload.name, - error: payload.error || '', - }), - 'error' - ); - }); - - listen('client-crash-details', (event) => { - const payload = event.payload as { - id: number; - name: string; - logs: string[]; - error?: string; - }; - showModal( - `client-crash-${payload.id}`, - ClientCrashModal, - { - title: t('modal.client_crash.title', { name: payload.name }), - contentClass: 'wide', - }, - { - clientName: payload.name, - clientId: payload.id, - logs: payload.logs, - error: payload.error, - } - ); - }); - - bootLogService.eventListenersReady(); - - loadingState.value = loadingStates[0]; - currentProgress.value = 1; - await new Promise((resolve) => setTimeout(resolve, 1000)); - - try { - bootLogService.serverConnectivityCheck(); - const connectivity = await invoke<{ - cdn_online: boolean; - auth_online: boolean; - }>('get_server_connectivity_status'); - isOnline.value = connectivity.cdn_online && connectivity.auth_online; - console.log('Server connectivity status:', connectivity); - - if (connectivity.cdn_online) bootLogService.cdnOnline(); - else bootLogService.cdnOffline(); - - if (connectivity.auth_online) bootLogService.webApiOnline(); - else bootLogService.webApiOffline(); - - if (!isOnline.value) { - let offlineMessage = t('toast.server.offline_base'); - if (!connectivity.cdn_online && !connectivity.auth_online) { - offlineMessage += t('toast.server.cdn_and_api_offline'); - } else if (!connectivity.cdn_online) { - offlineMessage += t('toast.server.cdn_offline'); - } else { - offlineMessage += t('toast.server.api_offline'); - } - addToast(offlineMessage, 'warning'); - } - } catch (error) { - console.error('Failed to get server connectivity status:', error); - isOnline.value = false; - addToast(t('toast.server.offline'), 'error'); - bootLogService.addCustomLog('ERROR', 'network', `Connectivity check failed: ${String(error)}`); - } - - loadingState.value = loadingStates[1]; - currentProgress.value = 2; - await new Promise((resolve) => setTimeout(resolve, 1000)); - - try { - bootLogService.apiInit(); - await invoke('initialize_api'); - bootLogService.apiInitSuccess(); - } catch (error) { - console.error('Failed to initialize API:', error); - addToast(t('toast.server.api_init_failed', { error }), 'error'); - bootLogService.apiInitFailed(); - } - - bootLogService.authCheck(); - checkAuthStatus(); - if (isAuthenticated.value) bootLogService.authSuccess(); - else bootLogService.authSkipped(); - - if (isAuthenticated.value && isOnline.value) { - try { - bootLogService.userDataInit(); - await initializeUserData(); - bootLogService.userDataSuccess(); - - globalUserStatus.initializeStatusSystem(); - bootLogService.syncInit(); - bootLogService.syncReady(); - } catch (error) { - console.error('Failed to initialize user data on startup:', error); - bootLogService.userDataFailed(); - } - } - - currentProgress.value = 3; - await new Promise((resolve) => setTimeout(resolve, 1000)); - - try { - const currentFlags = await invoke('get_flags'); - if (currentFlags.first_run.value) { - showFirstRunInfo.value = true; - } else if (!currentFlags.disclaimer_shown.value) { - showInitialDisclaimer.value = true; - } - initialModalsLoaded.value = true; - bootLogService.flagsLoaded(); - } catch (error) { - console.error('Failed to load flags for initial modals:', error); - addToast(t('toast.settings.flags_load_failed', { error }), 'error'); - initialModalsLoaded.value = true; - bootLogService.flagsLoadFailed(); - } - - await new Promise((resolve) => { - const checkInterval = setInterval(() => { - if (initialModalsLoaded.value) { - clearInterval(checkInterval); - resolve(); - } - }, 100); - }); - - try { - await fetchNewsAndUpdateUnreadCount(); - console.log('News loaded successfully on startup'); - } catch (error) { - console.error('Failed to load news on startup:', error); - } - - updaterService.startPeriodicCheck(t); - - currentProgress.value = 4; - loadingState.value = t('preloader.ready'); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const preloaderElement = document.querySelector( - '#preloader' - ) as HTMLElement; - if (preloaderElement) { - preloaderElement.style.opacity = '0'; - preloaderElement.classList.add('animate-out'); - - setTimeout(() => { - showPreloader.value = false; - try { - document.documentElement.classList.add('app-ready'); - } catch (e) { - console.error('Failed to add app-ready class:', e); - } - setTimeout(() => { - contentVisible.value = true; - bootLogService.systemReady(); - try { - bootLogService.clear(); - } catch (e) { - console.error('Failed to clear boot logs:', e); - } - - if (halloweenActive.value && halloweenGreeting.value) { - setTimeout(() => { - addToast(halloweenGreeting.value + ' 🎃', 'info', 5000); - }, 4000); - } - }, 80); - }, 800); - } else { - showPreloader.value = false; - } -}; - const handleFirstRunAccepted = async () => { try { await invoke('mark_first_run_shown'); @@ -424,21 +171,6 @@ const handleDisclaimerAccepted = async () => { } }; -const handleThemeChanged = async (newTheme: string) => { - currentTheme.value = newTheme; - try { - const settings = await invoke('get_settings'); - const newSettings = { - ...settings, - theme: { ...settings.theme, value: newTheme }, - }; - await invoke('save_settings', { inputSettings: newSettings }); - } catch (error) { - console.error('Failed to save theme from initial setup:', error); - addToast(t('toast.settings.theme_save_failed', { error }), 'error'); - } -}; - const handleOpenDevMenu = () => { showDevMenu.value = true; addToast(t('toast.dev.menu_opened'), 'info'); @@ -463,7 +195,7 @@ const handleLoggedIn = async () => { isAuthenticated.value = true; setActiveTab('home'); - await initializeUserData(); + await initializeUserDataWrapper(isAuthenticated.value); syncService.initializeSyncStatus(); @@ -540,29 +272,7 @@ const handleRegisterPrompt = () => { const { clearUserData } = useUser(); -const initializeUserData = async () => { - if (!isAuthenticated.value || !isOnline.value) return; - - try { - await loadUserData(); - console.log(`User loaded: ${displayName.value || 'Unknown'}`); - initializeStatusSystem(); - console.log(`Status system initialized, connection: ${connectionStatus.value}`); - - await loadFriendsData(); - console.log(`Friends loaded: ${friends.value.length} total, ${onlineFriendsCount.value} online`); - - console.log( - 'User data and friends system initialized successfully on startup' - ); - console.log(`Loading state: ${friendsLoading.value ? 'Loading...' : 'Complete'}`); - console.log(`User authentication: ${userAuthenticated.value ? 'Authenticated' : 'Not authenticated'}`); - console.log(`User online status: ${userOnline.value ? 'Online' : 'Offline'}`); - } catch (error) { - console.error('Failed to initialize user data on startup:', error); - } -}; const getTransitionName = () => { const tabOrder = [ @@ -592,7 +302,8 @@ const getTransitionName = () => { }; onMounted(() => { - initApp(); + initApp(isAuthenticated, checkAuthStatus, news, unreadNewsCount); + settingsService.loadSettings(); checkAuthStatus(); (async () => { @@ -609,8 +320,8 @@ onMounted(() => { try { globalUserStatus.setPlayingClient(`${payload.name} (${payload.version || 'unknown version'})`); - - const settings = await invoke('get_settings'); + await settingsService.loadSettings(); + const settings = settingsService.getSettings() as any; if (settings.discord_rpc_enabled?.value) { await invoke('update_presence', { details: t('discord.details.in_game'), @@ -634,8 +345,8 @@ onMounted(() => { try { globalUserStatus.setOnline(); - - const settings = await invoke('get_settings'); + await settingsService.loadSettings(); + const settings = settingsService.getSettings() as any; if (settings.discord_rpc_enabled?.value) { await invoke('update_presence', { details: t('discord.details.in_menu'), @@ -713,59 +424,21 @@ onUnmounted(() => { \ No newline at end of file diff --git a/src/components/common/SyncStatus.vue b/src/components/common/SyncStatus.vue index 09bf8ec..4fae512 100644 --- a/src/components/common/SyncStatus.vue +++ b/src/components/common/SyncStatus.vue @@ -1,5 +1,5 @@ diff --git a/src/composables/useAppInit.ts b/src/composables/useAppInit.ts new file mode 100644 index 0000000..2648363 --- /dev/null +++ b/src/composables/useAppInit.ts @@ -0,0 +1,309 @@ +import { ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { bootLogService } from '../services/bootLogService'; +import { applyThemeOnStartup, applyLanguageOnStartup } from '../utils/settings'; +import { applyCursorForEvent, isHalloweenEvent, getEventGreeting } from '../utils/events'; +import { useToast } from '../services/toastService'; +import { globalUserStatus } from './useUserStatus'; +import { useUser } from './useUser'; +import { globalFriends } from './useFriends'; +import { updaterService } from '../services/updaterService'; +import { getCurrentLanguage } from '../i18n'; +import { useModal } from '../services/modalService'; +import ClientCrashModal from '../components/modals/clients/ClientCrashModal.vue'; +import { apiGet } from '../services/apiClient'; + +interface Flags { + disclaimer_shown: { value: boolean }; + first_run: { value: boolean }; + optional_analytics: { value: boolean }; +} + +export function useAppInit() { + const { t, locale } = useI18n(); + const { addToast } = useToast(); + const { showModal } = useModal(); + const { loadUserData } = useUser(); + const { loadFriendsData } = globalFriends; + const { initializeStatusSystem } = globalUserStatus; + + const showPreloader = ref(true); + const contentVisible = ref(false); + const loadingState = ref(t('preloader.initializing')); + const loadingStates = [ + t('preloader.initializing'), + t('preloader.connecting_servers'), + ]; + const currentProgress = ref(0); + const totalSteps = ref(4); + const isOnline = ref(true); + const initialModalsLoaded = ref(false); + const showFirstRunInfo = ref(false); + const showInitialDisclaimer = ref(false); + const halloweenActive = ref(isHalloweenEvent()); + const halloweenGreeting = ref(getEventGreeting()); + const currentTheme = ref('dark'); + + const initializeUserDataWrapper = async (isAuthenticated: boolean) => { + if (!isAuthenticated || !isOnline.value) return; + + try { + await loadUserData(); + initializeStatusSystem(); + await loadFriendsData(); + } catch (error) { + console.error('Failed to initialize user data on startup:', error); + } + }; + + const fetchNewsAndUpdateUnreadCount = async (news: any, unreadNewsCount: any) => { + try { + const currentLanguage = getCurrentLanguage() || 'en'; + const response = await apiGet('/news/', { + headers: { + 'Accept-Language': currentLanguage, + 'Content-Type': 'application/json', + }, + }); + const allNews = response as any[]; + let filteredNews = allNews.filter( + (article: any) => article.language === currentLanguage + ); + news.value = filteredNews; + + const getNewsUniqueId = (article: any) => + `news_${article.language}_${article.id}`; + const readNews = JSON.parse(localStorage.getItem('readNews') || '[]'); + unreadNewsCount.value = news.value.filter( + (article: any) => !readNews.includes(getNewsUniqueId(article)) + ).length; + } catch (e) { + console.error('Failed to fetch news:', e); + unreadNewsCount.value = 0; + } + }; + + const initApp = async (isAuthenticated: any, checkAuthStatus: () => void, news: any, unreadNewsCount: any) => { + bootLogService.start(); + bootLogService.systemInit(); + + try { + await invoke('initialize_rpc'); + } catch (error) { + console.error('Failed to initialize Discord RPC:', error); + bootLogService.addCustomLog('WARN', 'rpc', `Discord RPC init failed: ${String(error)}`); + } + + await applyThemeOnStartup(); + + bootLogService.themeApplied(currentTheme.value); + + await applyLanguageOnStartup(); + + bootLogService.languageApplied(locale.value || getCurrentLanguage() || 'en'); + + await applyCursorForEvent(); + + bootLogService.cursorApplied(); + + const { getToastPosition } = useToast(); + getToastPosition(); + bootLogService.toastSystemReady(); + + showPreloader.value = true; + currentProgress.value = 0; + totalSteps.value = 4; + + bootLogService.eventListenersInit(); + + listen('client-crashed', (event) => { + const payload = event.payload as { + id: number; + name: string; + error?: string; + }; + addToast( + t('toast.client.crashed', { + name: payload.name, + error: payload.error || '', + }), + 'error' + ); + }); + + listen('client-crash-details', (event) => { + const payload = event.payload as { + id: number; + name: string; + logs: string[]; + error?: string; + }; + showModal( + `client-crash-${payload.id}`, + ClientCrashModal, + { + title: t('modal.client_crash.title', { name: payload.name }), + contentClass: 'wide', + }, + { + clientName: payload.name, + clientId: payload.id, + logs: payload.logs, + error: payload.error, + } + ); + }); + + bootLogService.eventListenersReady(); + + loadingState.value = loadingStates[0]; + currentProgress.value = 1; + + try { + bootLogService.serverConnectivityCheck(); + const connectivity = await invoke<{ + cdn_online: boolean; + auth_online: boolean; + }>('get_server_connectivity_status'); + isOnline.value = connectivity.cdn_online && connectivity.auth_online; + + if (connectivity.cdn_online) bootLogService.cdnOnline(); + else bootLogService.cdnOffline(); + + if (connectivity.auth_online) bootLogService.webApiOnline(); + else bootLogService.webApiOffline(); + + if (!isOnline.value) { + let offlineMessage = t('toast.server.offline_base'); + if (!connectivity.cdn_online && !connectivity.auth_online) { + offlineMessage += t('toast.server.cdn_and_api_offline'); + } else if (!connectivity.cdn_online) { + offlineMessage += t('toast.server.cdn_offline'); + } else { + offlineMessage += t('toast.server.api_offline'); + } + addToast(offlineMessage, 'warning'); + } + } catch (error) { + console.error('Failed to get server connectivity status:', error); + isOnline.value = false; + addToast(t('toast.server.offline'), 'error'); + bootLogService.addCustomLog('ERROR', 'network', `Connectivity check failed: ${String(error)}`); + } + + loadingState.value = loadingStates[1]; + currentProgress.value = 2; + + try { + bootLogService.apiInit(); + await invoke('initialize_api'); + bootLogService.apiInitSuccess(); + } catch (error) { + console.error('Failed to initialize API:', error); + addToast(t('toast.server.api_init_failed', { error }), 'error'); + bootLogService.apiInitFailed(); + } + + bootLogService.authCheck(); + checkAuthStatus(); + if (isAuthenticated.value) bootLogService.authSuccess(); + else bootLogService.authSkipped(); + + if (isAuthenticated.value && isOnline.value) { + try { + bootLogService.userDataInit(); + await initializeUserDataWrapper(isAuthenticated.value); + bootLogService.userDataSuccess(); + + globalUserStatus.initializeStatusSystem(); + bootLogService.syncInit(); + bootLogService.syncReady(); + } catch (error) { + console.error('Failed to initialize user data on startup:', error); + bootLogService.userDataFailed(); + } + } + + currentProgress.value = 3; + + try { + const currentFlags = await invoke('get_flags'); + if (currentFlags.first_run.value) { + showFirstRunInfo.value = true; + } else if (!currentFlags.disclaimer_shown.value) { + showInitialDisclaimer.value = true; + } + initialModalsLoaded.value = true; + bootLogService.flagsLoaded(); + } catch (error) { + console.error('Failed to load flags for initial modals:', error); + addToast(t('toast.settings.flags_load_failed', { error }), 'error'); + initialModalsLoaded.value = true; + bootLogService.flagsLoadFailed(); + } + + try { + await fetchNewsAndUpdateUnreadCount(news, unreadNewsCount); + } catch (error) { + console.error('Failed to load news on startup:', error); + } + + updaterService.startPeriodicCheck(t); + + currentProgress.value = 4; + loadingState.value = t('preloader.ready'); + + const preloaderElement = document.querySelector( + '#preloader' + ) as HTMLElement; + if (preloaderElement) { + preloaderElement.style.opacity = '0'; + preloaderElement.classList.add('animate-out'); + + setTimeout(() => { + showPreloader.value = false; + try { + document.documentElement.classList.add('app-ready'); + } catch (e) { + console.error('Failed to add app-ready class:', e); + } + setTimeout(() => { + contentVisible.value = true; + bootLogService.systemReady(); + try { + bootLogService.clear(); + } catch (e) { + console.error('Failed to clear boot logs:', e); + } + + if (halloweenActive.value && halloweenGreeting.value) { + setTimeout(() => { + addToast(halloweenGreeting.value + ' 🎃', 'info', 5000); + }, 4000); + } + }, 80); + }, 800); + } else { + showPreloader.value = false; + } + }; + + return { + showPreloader, + contentVisible, + loadingState, + currentProgress, + totalSteps, + isOnline, + initialModalsLoaded, + showFirstRunInfo, + showInitialDisclaimer, + halloweenActive, + halloweenGreeting, + currentTheme, + initApp, + initializeUserDataWrapper + }; +} diff --git a/src/composables/useDownloadSpeedMonitor.ts b/src/composables/useDownloadSpeedMonitor.ts new file mode 100644 index 0000000..bda7caa --- /dev/null +++ b/src/composables/useDownloadSpeedMonitor.ts @@ -0,0 +1,174 @@ +import { ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useModal } from '../services/modalService'; +import SlowDownloadWarningModal from '../components/modals/common/SlowDownloadWarningModal.vue'; + +export interface DownloadSpeedConfig { + slowSpeedThreshold?: number; + slowSpeedConsecutiveCount?: number; + stalledTimeout?: number; +} + +export function useDownloadSpeedMonitor(config: DownloadSpeedConfig = {}) { + const { + slowSpeedThreshold = 100 * 1024, + slowSpeedConsecutiveCount = 3, + stalledTimeout = 3000, + } = config; + + const { t } = useI18n(); + const { showModal, hideModal } = useModal(); + + const lastDownloaded = ref(0); + const lastTimestamp = ref(0); + const currentSpeed = ref(0); + const slowSpeedCount = ref(0); + const slowSpeedWarningShown = ref(false); + const stalledWarningShown = ref(false); + const isMonitoring = ref(false); + const stalledCheckTimer = ref | null>(null); + const lastProgressTime = ref(0); + + const calculateSpeed = (downloaded: number): number => { + const now = Date.now(); + lastProgressTime.value = now; + + if (lastTimestamp.value > 0 && now - lastTimestamp.value > 0) { + const timeDiff = (now - lastTimestamp.value) / 1000; + const bytesDiff = downloaded - lastDownloaded.value; + if (timeDiff > 0 && bytesDiff >= 0) { + currentSpeed.value = bytesDiff / timeDiff; + console.log(`[DownloadMonitor] Speed: ${(currentSpeed.value / 1024).toFixed(1)} KB/s`); + } + } + lastDownloaded.value = downloaded; + lastTimestamp.value = now; + + return currentSpeed.value; + }; + + const checkSlowSpeed = () => { + if (currentSpeed.value > 0 && currentSpeed.value < slowSpeedThreshold) { + slowSpeedCount.value++; + console.log(`[DownloadMonitor] Slow speed! Count: ${slowSpeedCount.value}/${slowSpeedConsecutiveCount}`); + } else { + slowSpeedCount.value = 0; + } + + if ( + !slowSpeedWarningShown.value && + slowSpeedCount.value >= slowSpeedConsecutiveCount + ) { + showSlowWarningModal(); + } + }; + + const checkStalled = () => { + if (!isMonitoring.value || stalledWarningShown.value) { + return; + } + + const now = Date.now(); + const timeSinceLastProgress = now - lastProgressTime.value; + + if (lastProgressTime.value > 0 && timeSinceLastProgress >= stalledTimeout) { + console.log(`[DownloadMonitor] Download stalled! No progress for ${(timeSinceLastProgress / 1000).toFixed(0)}s`); + showStalledWarningModal(); + } + }; + + const showSlowWarningModal = () => { + slowSpeedWarningShown.value = true; + showModal( + 'slow-download-warning', + SlowDownloadWarningModal, + { + title: t('modals.slow_download.modal_title'), + }, + { + currentSpeed: currentSpeed.value, + isStalled: false, + }, + { + close: () => hideModal('slow-download-warning'), + } + ); + }; + + const showStalledWarningModal = () => { + stalledWarningShown.value = true; + showModal( + 'slow-download-warning', + SlowDownloadWarningModal, + { + title: t('modals.slow_download.stalled_modal_title'), + }, + { + currentSpeed: 0, + isStalled: true, + }, + { + close: () => hideModal('slow-download-warning'), + } + ); + }; + + const startMonitoring = () => { + isMonitoring.value = true; + lastDownloaded.value = 0; + lastTimestamp.value = 0; + currentSpeed.value = 0; + slowSpeedCount.value = 0; + slowSpeedWarningShown.value = false; + stalledWarningShown.value = false; + lastProgressTime.value = Date.now(); + + if (stalledCheckTimer.value) { + clearInterval(stalledCheckTimer.value); + } + stalledCheckTimer.value = setInterval(checkStalled, 5000); + }; + + const stopMonitoring = () => { + isMonitoring.value = false; + if (stalledCheckTimer.value) { + clearInterval(stalledCheckTimer.value); + stalledCheckTimer.value = null; + } + }; + + const onProgress = (downloaded: number, percentage: number) => { + if (!isMonitoring.value) { + startMonitoring(); + } + + calculateSpeed(downloaded); + + if (percentage > 5 && percentage < 95) { + checkSlowSpeed(); + } + }; + + const reset = () => { + stopMonitoring(); + lastDownloaded.value = 0; + lastTimestamp.value = 0; + currentSpeed.value = 0; + slowSpeedCount.value = 0; + slowSpeedWarningShown.value = false; + stalledWarningShown.value = false; + lastProgressTime.value = 0; + }; + + return { + currentSpeed, + isMonitoring, + slowSpeedWarningShown, + stalledWarningShown, + + startMonitoring, + stopMonitoring, + onProgress, + reset, + }; +} diff --git a/src/composables/useFriends.ts b/src/composables/useFriends.ts index 180cd38..3a4ced7 100644 --- a/src/composables/useFriends.ts +++ b/src/composables/useFriends.ts @@ -22,15 +22,6 @@ interface FriendRequest { updated_at: string; } -interface FriendsMetrics { - totalFriends: number; - onlineFriends: number; - lastBulkUpdate: number; - cacheHitRate: number; - statusUpdateCount: number; - avgStatusResponseTime: number; -} - interface GlobalFriendsState { friends: Friend[]; sentRequests: FriendRequest[]; @@ -39,7 +30,6 @@ interface GlobalFriendsState { isLoaded: boolean; lastUpdated: string | null; lastStatusUpdate: number; - bulkUpdateCount: number; } const globalFriendsState = reactive({ @@ -49,31 +39,12 @@ const globalFriendsState = reactive({ isLoading: false, isLoaded: false, lastUpdated: null, - lastStatusUpdate: 0, - bulkUpdateCount: 0 -}); - -const friendsMetrics = reactive({ - totalFriends: 0, - onlineFriends: 0, - lastBulkUpdate: 0, - cacheHitRate: 0, - statusUpdateCount: 0, - avgStatusResponseTime: 0 + lastStatusUpdate: 0 }); const isStatusLoading = ref(false); -const previousReceivedCount = ref(0); const statusUpdateInterval: { current: NodeJS.Timeout | null } = { current: null }; - -const statusUpdateConfig = { - baseInterval: 45000, - maxInterval: 180000, - currentInterval: 45000, - backoffMultiplier: 1.3, - consecutiveNoChanges: 0, - maxNoChanges: 4 -}; +const STATUS_UPDATE_INTERVAL = 45000; const isAuthenticated = computed(() => !!localStorage.getItem('authToken')); @@ -106,7 +77,6 @@ export function useFriends() { if (globalFriendsState.isLoaded && !forceRefresh && globalFriendsState.lastUpdated && Date.now() - new Date(globalFriendsState.lastUpdated).getTime() < 30000) { - console.log('Friends data is fresh, skipping reload'); return; } @@ -122,13 +92,7 @@ export function useFriends() { console.log(`Batch friends data loaded in ${responseTime}ms:`, { friends: batchData.friends?.length || 0, sentRequests: batchData.requests?.sent?.length || 0, - receivedRequests: batchData.requests?.received?.length || 0, - optimized: batchData.performance_info?.optimized || false - }); - - checkForNewRequests({ - sent: batchData.requests?.sent || [], - received: batchData.requests?.received || [] + receivedRequests: batchData.requests?.received?.length || 0 }); globalFriendsState.friends = batchData.friends || []; @@ -136,18 +100,12 @@ export function useFriends() { globalFriendsState.receivedRequests = batchData.requests?.received || []; globalFriendsState.lastUpdated = new Date().toISOString(); globalFriendsState.isLoaded = true; - globalFriendsState.bulkUpdateCount++; - - friendsMetrics.totalFriends = globalFriendsState.friends.length; - friendsMetrics.onlineFriends = onlineFriendsCount.value; - friendsMetrics.lastBulkUpdate = Date.now(); - friendsMetrics.cacheHitRate = apiClient.getCacheStats().hitRate; if (!statusUpdateInterval.current) { startStatusUpdates(); } - console.log('Friends data loaded successfully via batch endpoint'); + console.log('Friends data loaded successfully'); } catch (error) { console.error('Failed to load friends data via batch endpoint:', error); @@ -173,9 +131,6 @@ export function useFriends() { globalFriendsState.lastUpdated = new Date().toISOString(); globalFriendsState.isLoaded = true; - friendsMetrics.totalFriends = globalFriendsState.friends.length; - friendsMetrics.onlineFriends = onlineFriendsCount.value; - console.log('Friends data loaded successfully via fallback method'); } catch (fallbackError) { console.error('Fallback friends data loading also failed:', fallbackError); @@ -193,7 +148,6 @@ export function useFriends() { try { isStatusLoading.value = true; - friendsMetrics.statusUpdateCount++; const statusesData = await apiClient.get('/auth/friends/status/'); @@ -216,8 +170,6 @@ export function useFriends() { }); const responseTime = Date.now() - startTime; - friendsMetrics.avgStatusResponseTime = friendsMetrics.avgStatusResponseTime * 0.8 + responseTime * 0.2; - friendsMetrics.onlineFriends = onlineFriendsCount.value; globalFriendsState.lastStatusUpdate = Date.now(); console.log(`Friend statuses updated in ${responseTime}ms (${hasChanges ? 'with changes' : 'no changes'})`); @@ -246,13 +198,12 @@ export function useFriends() { return; } - const hasChanges = await updateFriendStatuses(); - adjustStatusUpdateFrequency(hasChanges); + await updateFriendStatuses(); }; runStatusUpdate(); - statusUpdateInterval.current = setInterval(runStatusUpdate, statusUpdateConfig.currentInterval); + statusUpdateInterval.current = setInterval(runStatusUpdate, STATUS_UPDATE_INTERVAL); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { @@ -260,50 +211,7 @@ export function useFriends() { } }); - console.log(`Started status updates with ${statusUpdateConfig.currentInterval}ms interval`); - }; - - - const adjustStatusUpdateFrequency = (hasChanges: boolean): void => { - if (hasChanges) { - statusUpdateConfig.currentInterval = statusUpdateConfig.baseInterval; - statusUpdateConfig.consecutiveNoChanges = 0; - } else { - statusUpdateConfig.consecutiveNoChanges++; - - if (statusUpdateConfig.consecutiveNoChanges >= statusUpdateConfig.maxNoChanges) { - const newInterval = Math.min( - statusUpdateConfig.currentInterval * statusUpdateConfig.backoffMultiplier, - statusUpdateConfig.maxInterval - ); - - if (newInterval !== statusUpdateConfig.currentInterval) { - statusUpdateConfig.currentInterval = newInterval; - console.log(`Increased status update interval to ${newInterval}ms (no changes for ${statusUpdateConfig.consecutiveNoChanges} updates)`); - } - } - } - - if (statusUpdateInterval.current) { - clearInterval(statusUpdateInterval.current); - statusUpdateInterval.current = setInterval(async () => { - if (isAuthenticated.value && globalFriendsState.friends.length > 0) { - const changes = await updateFriendStatuses(); - adjustStatusUpdateFrequency(changes); - } - }, statusUpdateConfig.currentInterval); - } - }; - - - const checkForNewRequests = (currentRequests: { sent: FriendRequest[]; received: FriendRequest[] }): FriendRequest[] => { - if (currentRequests.received.length > previousReceivedCount.value) { - const knownRequestIds = new Set(globalFriendsState.receivedRequests.map(req => req.id)); - const newRequests = currentRequests.received.filter(req => !knownRequestIds.has(req.id)); - previousReceivedCount.value = currentRequests.received.length; - return newRequests; - } - return []; + console.log(`Started status updates with ${STATUS_UPDATE_INTERVAL}ms interval`); }; const searchUsers = async (query: string): Promise => { @@ -373,7 +281,6 @@ export function useFriends() { const index = globalFriendsState.friends.findIndex(friend => friend.id === userId); if (index > -1) { globalFriendsState.friends.splice(index, 1); - friendsMetrics.totalFriends = globalFriendsState.friends.length; } console.log('Friend removed'); @@ -405,15 +312,11 @@ export function useFriends() { globalFriendsState.isLoaded = false; globalFriendsState.lastUpdated = null; globalFriendsState.lastStatusUpdate = 0; - previousReceivedCount.value = 0; if (statusUpdateInterval.current) { clearInterval(statusUpdateInterval.current); statusUpdateInterval.current = null; } - - friendsMetrics.totalFriends = 0; - friendsMetrics.onlineFriends = 0; }; @@ -455,7 +358,6 @@ export function useFriends() { removeFriend, getOnlineFriends, - checkForNewRequests, startStatusUpdates, stopStatusUpdates, diff --git a/src/composables/useIrcChat.ts b/src/composables/useIrcChat.ts new file mode 100644 index 0000000..677d57a --- /dev/null +++ b/src/composables/useIrcChat.ts @@ -0,0 +1,199 @@ +import { ref } from 'vue'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { useToast } from '../services/toastService'; + +interface IrcMessage { + time: string; + content: string; + type?: string; + isHistory?: boolean; +} + +interface IncomingIrcPayload { + type: string; + time?: string; + content?: string; + history?: boolean; +} + +type IrcStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'; + +const RECONNECT_DELAY_MS = 4000; + +const messages = ref([]); +const connected = ref(false); +const isConnecting = ref(false); +const status = ref('disconnected'); + +let connectionPromise: Promise | null = null; +let listenersRegistered = false; +let reconnectTimer: ReturnType | null = null; + +const { addToast } = useToast(); + +const currentTime = (): string => { + const now = new Date(); + return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; +}; + +const formatIsoToTime = (isoString?: string): string => { + if (!isoString) return currentTime(); + const parsed = new Date(isoString); + if (Number.isNaN(parsed.getTime())) return currentTime(); + + const hours = parsed.getHours().toString().padStart(2, '0'); + const minutes = parsed.getMinutes().toString().padStart(2, '0'); + return `${hours}:${minutes}`; +}; + +const parseIrcPayload = (payload: unknown): IrcMessage | null => { + const fallbackTime = currentTime(); + + if (typeof payload === 'string') { + try { + const parsed = JSON.parse(payload) as IncomingIrcPayload; + + if (parsed.type === 'pong') return null; + + return { + time: formatIsoToTime(parsed.time), + content: parsed.content || '', + type: parsed.type, + isHistory: Boolean(parsed.history), + }; + } catch { + return { time: fallbackTime, content: payload, type: 'system' }; + } + } + + return null; +}; + +const clearReconnectTimer = () => { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } +}; + +const registerListeners = async (): Promise => { + if (listenersRegistered) { + return; + } + + listenersRegistered = true; + + try { + await listen('irc-message', (event) => { + const msg = parseIrcPayload(event.payload); + if (msg) { + messages.value.push(msg); + } + }); + + await listen('irc-connected', () => { + console.debug('IRC: connected event'); + connected.value = true; + status.value = 'connected'; + clearReconnectTimer(); + }); + + await listen('irc-disconnected', () => { + console.debug('IRC: disconnected event'); + connected.value = false; + status.value = 'reconnecting'; + messages.value.push({ time: currentTime(), content: 'Disconnected from IRC server.', type: 'error' }); + connectionPromise = null; + scheduleReconnect('Connection lost. Reconnecting...'); + }); + + await listen('irc-error', (event) => { + console.error('IRC: error event', event.payload); + connected.value = false; + status.value = 'error'; + connectionPromise = null; + addToast( + 'IRC Error: ' + event.payload, + 'error', + ); + scheduleReconnect('Error occurred. Attempting to reconnect...'); + }); + } catch (err) { + listenersRegistered = false; + throw err; + } +}; + +const scheduleReconnect = (reason?: string) => { + if (reconnectTimer) return; + + if (reason) { + messages.value.push({ time: currentTime(), content: reason, type: 'system' }); + } + + status.value = 'reconnecting'; + reconnectTimer = setTimeout(async () => { + reconnectTimer = null; + try { + await ensureIrcConnection(true); + } catch (err) { + console.error('IRC: reconnection attempt failed', err); + scheduleReconnect('Reconnection failed. Retrying...'); + } + }, RECONNECT_DELAY_MS); +}; + +export const ensureIrcConnection = async (isReconnect = false): Promise => { + await registerListeners(); + + if (connectionPromise) { + return connectionPromise; + } + + connectionPromise = (async () => { + isConnecting.value = true; + status.value = isReconnect ? 'reconnecting' : 'connecting'; + const token = localStorage.getItem('authToken') || ''; + const tokenPresent = token.length > 0; + console.debug('IRC: attempting connect, token present=', tokenPresent); + + clearReconnectTimer(); + await invoke('connect_irc', { token: tokenPresent ? token : null }); + })() + .catch((err) => { + connectionPromise = null; + status.value = 'error'; + scheduleReconnect('Connection failed. Retrying...'); + throw err; + }) + .finally(() => { + isConnecting.value = false; + }); + + return connectionPromise; +}; + +export function forceReconnect(): void { + console.debug('IRC: forceReconnect called'); + connectionPromise = null; + ensureIrcConnection(true).catch((err) => { + console.error('IRC: forceReconnect failed', err); + }); +} + +export const sendIrcMessage = async (message: string): Promise => { + await invoke('send_irc_message', { message }); +}; + +export function useIrcChat() { + return { + messages, + connected, + isConnecting, + status, + ensureIrcConnection, + forceReconnect, + sendIrcMessage + }; +} \ No newline at end of file diff --git a/src/composables/usePresets.ts b/src/composables/usePresets.ts index 3f55bfc..81f9e6b 100644 --- a/src/composables/usePresets.ts +++ b/src/composables/usePresets.ts @@ -120,30 +120,30 @@ export function usePresets() { const input: CreatePresetInput = { name: presetData.name + ' (Imported)', description: presetData.description, - custom_css: presetData.custom_css || presetData.customCSS || '', - enable_custom_css: presetData.enable_custom_css ?? presetData.enableCustomCSS ?? false, + customCSS: presetData.custom_css || presetData.customCSS || '', + enableCustomCSS: presetData.enable_custom_css ?? presetData.enableCustomCSS ?? false, primary: presetData.primary || presetData.primaryColorOverride, base100: presetData.base100, base200: presetData.base200, base300: presetData.base300, - base_content: presetData.base_content || presetData.baseContent, + baseContent: presetData.base_content || presetData.baseContent, - primary_content: presetData.primary_content || presetData.primaryContent, + primaryContent: presetData.primary_content || presetData.primaryContent, secondary: presetData.secondary, - secondary_content: presetData.secondary_content || presetData.secondaryContent, + secondaryContent: presetData.secondary_content || presetData.secondaryContent, accent: presetData.accent, - accent_content: presetData.accent_content || presetData.accentContent, + accentContent: presetData.accent_content || presetData.accentContent, neutral: presetData.neutral, - neutral_content: presetData.neutral_content || presetData.neutralContent, + neutralContent: presetData.neutral_content || presetData.neutralContent, info: presetData.info, - info_content: presetData.info_content || presetData.infoContent, + infoContent: presetData.info_content || presetData.infoContent, success: presetData.success, - success_content: presetData.success_content || presetData.successContent, + successContent: presetData.success_content || presetData.successContent, warning: presetData.warning, - warning_content: presetData.warning_content || presetData.warningContent, + warningContent: presetData.warning_content || presetData.warningContent, error: presetData.error, - error_content: presetData.error_content || presetData.errorContent, + errorContent: presetData.error_content || presetData.errorContent, }; return await createPreset(input); diff --git a/src/composables/useStreamerMode.ts b/src/composables/useStreamerMode.ts index 0cc0f63..b4526ce 100644 --- a/src/composables/useStreamerMode.ts +++ b/src/composables/useStreamerMode.ts @@ -6,7 +6,6 @@ function readStoredFlag(key: string): boolean { try { return localStorage.getItem(key) === 'true'; } catch (e) { - console.warn('useStreamerMode: failed to read storage', e); return false; } @@ -24,6 +23,7 @@ const isStreamerModeEnabled = ref(readStoredFlag(STORAGE_KEY)); type ChangeListener = (enabled: boolean) => void; const listeners = new Set(); +let storageListenerInstalled = false; function emitChange(enabled: boolean) { for (const l of Array.from(listeners)) { @@ -37,19 +37,32 @@ function emitChange(enabled: boolean) { function maskString(input: string | undefined | null, maskChar = '*'): string { if (!input) return ''; - return Array.from(input).map(() => maskChar).join(''); + return maskChar.repeat([...input].length); } -function maskName(): string { - return '??????'; +function maskName(name?: string): string { + if (!name) return 'User'; + const chars = [...name]; + if (chars.length <= 1) return chars[0] || 'U'; + return maskString(chars.slice(0, -1).join('')) + chars[chars.length - 1]; } -function maskUsername(): string { - return 'unknown'; +function maskUsername(username?: string): string { + if (!username) return 'user'; + const chars = [...username]; + const lead = chars.slice(0, 2).join(''); + return lead + maskString(chars.slice(2).join('')); } -function maskEmail(): string { - return 'unknown@*****.***'; +function maskEmail(email?: string): string { + if (!email) return 'unknown@*****.***'; + const [local, domain] = email.split('@'); + if (!domain) return maskString(email); + const domainParts = domain.split('.'); + const tld = domainParts.pop(); + const maskedLocal = local ? local[0] + maskString(local.slice(1)) : ''; + const maskedDomain = domainParts.join('.') ? domainParts.map(() => '*****').join('.') : '*****'; + return `${maskedLocal}@${maskedDomain}.${tld}`; } export function useStreamerMode() { @@ -75,26 +88,28 @@ export function useStreamerMode() { } function getDisplayName(nickname?: string, username?: string, fallback = 'User'): string { - if (isStreamerModeEnabled.value) return maskName(); - return nickname || username || fallback; + const name = nickname || username || fallback; + return isStreamerModeEnabled.value ? maskName(name) : name; } function getDisplayUsername(username?: string): string { - if (isStreamerModeEnabled.value) return maskUsername(); - return username || 'user'; + const value = username || 'user'; + return isStreamerModeEnabled.value ? maskUsername(value) : value; } function getDisplayEmail(email?: string): string { - if (isStreamerModeEnabled.value) return maskEmail(); - return email || ''; + const value = email || ''; + return isStreamerModeEnabled.value ? maskEmail(value) : value; } function maskIfEnabled(value?: string, masker: (v?: string) => string = maskString): string { return isStreamerModeEnabled.value ? masker(value) : (value || ''); } + return { isStreamerModeEnabled: isEnabled, + enabled: isEnabled, toggleStreamerMode, setStreamerMode, onChange, @@ -107,4 +122,21 @@ export function useStreamerMode() { maskEmail, maskIfEnabled }; +} + +export default useStreamerMode; + +if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + if (!storageListenerInstalled) { + window.addEventListener('storage', (e: StorageEvent) => { + if (e.key === STORAGE_KEY) { + const newVal = e.newValue === 'true'; + if (isStreamerModeEnabled.value !== newVal) { + isStreamerModeEnabled.value = newVal; + emitChange(newVal); + } + } + }); + storageListenerInstalled = true; + } } \ No newline at end of file diff --git a/src/composables/useUser.ts b/src/composables/useUser.ts index db6b69b..024254e 100644 --- a/src/composables/useUser.ts +++ b/src/composables/useUser.ts @@ -1,5 +1,6 @@ -import { reactive, computed } from 'vue'; +import { reactive, computed, ref } from 'vue'; import { userService, type UserProfile, type UserInfo } from '../services/userService'; +import { apiClient } from '../services/apiClient'; interface GlobalUserState { profile: UserProfile | null; @@ -21,8 +22,32 @@ const globalUserState = reactive({ lastUpdated: null }); +const authToken = ref(localStorage.getItem('authToken')); + +window.addEventListener('storage', (event) => { + if (event.key === 'authToken') { + authToken.value = event.newValue; + } +}); + +const originalSetItem = localStorage.setItem; +localStorage.setItem = function (key: string, value: string) { + if (key === 'authToken') { + authToken.value = value; + } + originalSetItem.apply(this, [key, value]); +}; + +const originalRemoveItem = localStorage.removeItem; +localStorage.removeItem = function (key: string) { + if (key === 'authToken') { + authToken.value = null; + } + originalRemoveItem.apply(this, [key]); +}; + export function useUser() { - const isAuthenticated = computed(() => !!localStorage.getItem('authToken')); + const isAuthenticated = computed(() => !!authToken.value); const displayName = computed(() => { if (!globalUserState.info && !globalUserState.profile) return ''; @@ -33,6 +58,9 @@ export function useUser() { const email = computed(() => globalUserState.info?.email || ''); const nickname = computed(() => globalUserState.profile?.nickname || ''); + // race condition fix + let loadPromise: Promise | null = null; + const loadUserData = async (forceRefresh = false): Promise => { if (!isAuthenticated.value) { clearUserData(); @@ -43,45 +71,33 @@ export function useUser() { return; } - if (globalUserState.isLoading) { - return; + if (loadPromise) { + return loadPromise; } globalUserState.isLoading = true; - try { - const initData = await userService.initializeUser(); - - globalUserState.profile = initData.profile; - globalUserState.info = initData.user_info; - globalUserState.adminStatus = initData.admin_status; - globalUserState.syncStatus = initData.sync_status; - globalUserState.lastUpdated = new Date().toISOString(); - globalUserState.isLoaded = true; - - console.log('Global user data loaded successfully via combined endpoint'); - } catch (error) { - console.error('Failed to load global user data:', error); - + loadPromise = (async () => { try { - console.log('Falling back to individual API calls...'); - const [profileResult, infoResult] = await Promise.all([ - userService.loadUserProfile(), - userService.loadUserInfo() - ]); - - globalUserState.profile = profileResult.data; - globalUserState.info = infoResult.data; + const initData = await userService.initializeUser(); + + globalUserState.profile = initData.profile; + globalUserState.info = initData.user_info; + globalUserState.adminStatus = initData.admin_status; + globalUserState.syncStatus = initData.sync_status; globalUserState.lastUpdated = new Date().toISOString(); globalUserState.isLoaded = true; - console.log('Global user data loaded successfully via fallback'); - } catch (fallbackError) { - console.error('Failed to load user data via fallback:', fallbackError); + console.log('Global user data loaded successfully'); + } catch (error) { + console.error('Failed to load global user data:', error); + } finally { + globalUserState.isLoading = false; + loadPromise = null; } - } finally { - globalUserState.isLoading = false; - } + })(); + + return loadPromise; }; const updateUserProfile = async (newNickname: string): Promise => { @@ -114,6 +130,13 @@ export function useUser() { return loadUserData(true); }; + const logout = (): void => { + localStorage.removeItem('authToken'); + userService.clearCache(); + apiClient.clearCache(); + clearUserData(); + }; + return { profile: computed(() => globalUserState.profile), info: computed(() => globalUserState.info), @@ -132,6 +155,7 @@ export function useUser() { loadUserData, updateUserProfile, clearUserData, - refreshUserData + refreshUserData, + logout }; } \ No newline at end of file diff --git a/src/composables/useUserStatus.ts b/src/composables/useUserStatus.ts index 4657ee1..8fefcce 100644 --- a/src/composables/useUserStatus.ts +++ b/src/composables/useUserStatus.ts @@ -1,132 +1,60 @@ -import { ref, computed, reactive } from 'vue'; -import { apiClient, apiHeartbeat } from '../services/apiClient'; +import { ref, computed, reactive, nextTick } from 'vue'; +import { apiClient } from '../services/apiClient'; import { useStreamerMode } from './useStreamerMode'; interface StatusData { isOnline: boolean; currentClient: string | null; invisibleMode: boolean; - streamerMode: boolean; lastSeen: string | null; username: string; nickname: string | null; lastStatusUpdate: string | null; } -interface StatusChangeEvent { - type: 'online' | 'offline' | 'client_change' | 'invisible_toggle'; - timestamp: number; - data: Partial; -} - -interface StatusMetrics { - totalUpdates: number; - skippedUpdates: number; - errorCount: number; - avgResponseTime: number; - lastSuccessfulSync: number; - consecutiveErrors: number; -} const globalStatus = reactive({ isOnline: false, currentClient: null, invisibleMode: false, - streamerMode: false, lastSeen: null, username: '', nickname: null, lastStatusUpdate: null }); -const statusMetrics = reactive({ - totalUpdates: 0, - skippedUpdates: 0, - errorCount: 0, - avgResponseTime: 0, - lastSuccessfulSync: 0, - consecutiveErrors: 0 -}); const isAuthenticated = ref(false); -const connectionStatus = ref<'online' | 'offline' | 'connecting' | 'error'>('offline'); -const lastStatusUpdate = ref(null); -const statusChangeQueue: StatusChangeEvent[] = []; const pollingConfig = { - baseInterval: 30000, - maxInterval: 300000, - backoffMultiplier: 1.5, - currentInterval: 30000, - consecutiveUnchangedPolls: 0, - maxUnchangedPolls: 5, -}; - -const heartbeatConfig = { - interval: 45000, - enabled: true, - maxConsecutiveErrors: 3, - consecutiveErrors: 0 + interval: 30000, }; let statusSyncInterval: ReturnType | null = null; -let heartbeatInterval: ReturnType | null = null; -let changeDetectionTimeout: NodeJS.Timeout | null = null; let pendingStatusUpdate: Promise | null = null; +let lastRequestId = 0; export function useUserStatus() { const streamer = useStreamerMode(); - // subscribe to streamer changes so globalStatus stays in sync - streamer.onChange((enabled) => { - globalStatus.streamerMode = enabled; - }); const checkAuthStatus = (): boolean => { const token = localStorage.getItem('authToken'); const isAuth = !!token; isAuthenticated.value = isAuth; - globalStatus.streamerMode = streamer.isStreamerModeEnabled.value; - return isAuth; }; - const sendHeartbeat = async (): Promise => { - if (!checkAuthStatus()) { - return false; - } - - try { - await apiHeartbeat(); - heartbeatConfig.consecutiveErrors = 0; - console.log('Heartbeat sent successfully'); - return true; - } catch (error) { - heartbeatConfig.consecutiveErrors++; - console.error('Heartbeat failed:', error); - - if (heartbeatConfig.consecutiveErrors >= heartbeatConfig.maxConsecutiveErrors) { - console.warn('Too many heartbeat errors, disabling for this session'); - heartbeatConfig.enabled = false; - } - return false; - } - }; - const syncStatusToServer = async (force = false): Promise => { if (!checkAuthStatus()) { - connectionStatus.value = 'offline'; return null; } if (pendingStatusUpdate && !force) { - console.log('Status update already in progress, skipping'); - statusMetrics.skippedUpdates++; return pendingStatusUpdate; } - connectionStatus.value = 'connecting'; - const startTime = Date.now(); + const currentRequestId = ++lastRequestId; try { const statusPayload = { @@ -138,53 +66,35 @@ export function useUserStatus() { pendingStatusUpdate = apiClient.post('/auth/status/', statusPayload); const response = await pendingStatusUpdate; - const wasChanged = updateLocalStatus(response); - - const responseTime = Date.now() - startTime; - updateResponseTimeMetric(responseTime); - statusMetrics.totalUpdates++; - statusMetrics.lastSuccessfulSync = Date.now(); - statusMetrics.consecutiveErrors = 0; - - adjustPollingFrequency(wasChanged); - - lastStatusUpdate.value = new Date(); - connectionStatus.value = globalStatus.isOnline ? 'online' : 'offline'; - - console.log(`Status synced (${responseTime}ms): ${globalStatus.isOnline ? 'online' : 'offline'}${globalStatus.invisibleMode ? ' (invisible)' : ''}`, - globalStatus.currentClient ? `playing ${globalStatus.currentClient}` : ''); + if (currentRequestId === lastRequestId) { + updateLocalStatus(response); + console.log(`Status synced: ${globalStatus.isOnline ? 'online' : 'offline'}${globalStatus.invisibleMode ? ' (invisible)' : ''}`, + globalStatus.currentClient ? `playing ${globalStatus.currentClient}` : ''); + } else { + console.log('Ignoring stale status response'); + } return response; } catch (error) { console.error('Failed to sync status to server:', error); - statusMetrics.errorCount++; - statusMetrics.consecutiveErrors++; - - if (statusMetrics.consecutiveErrors > 3) { - pollingConfig.currentInterval = Math.min( - pollingConfig.currentInterval * pollingConfig.backoffMultiplier, - pollingConfig.maxInterval - ); - console.log(`Increased polling interval to ${pollingConfig.currentInterval}ms due to errors`); - } - - connectionStatus.value = 'error'; throw error; } finally { - pendingStatusUpdate = null; + if (currentRequestId === lastRequestId) { + pendingStatusUpdate = null; + } } }; - const updateLocalStatus = (serverResponse: any): boolean => { const oldStatus = { ...globalStatus }; + if (typeof serverResponse.is_online !== 'undefined') globalStatus.isOnline = !!serverResponse.is_online; + if ('current_client' in serverResponse) globalStatus.currentClient = serverResponse.current_client || null; if (serverResponse.username) globalStatus.username = serverResponse.username; if (serverResponse.nickname !== undefined) globalStatus.nickname = serverResponse.nickname; if (serverResponse.last_seen) globalStatus.lastSeen = serverResponse.last_seen; if (serverResponse.invisible_mode !== undefined) globalStatus.invisibleMode = serverResponse.invisible_mode; if (serverResponse.last_status_update) globalStatus.lastStatusUpdate = serverResponse.last_status_update; - const hasChanges = ( oldStatus.isOnline !== globalStatus.isOnline || oldStatus.currentClient !== globalStatus.currentClient || @@ -192,72 +102,37 @@ export function useUserStatus() { ); if (hasChanges) { - const changeEvent: StatusChangeEvent = { - type: globalStatus.isOnline ? 'online' : 'offline', - timestamp: Date.now(), - data: { ...globalStatus } - }; - statusChangeQueue.push(changeEvent); - - if (statusChangeQueue.length > 50) { - statusChangeQueue.shift(); - } + console.log('Status change detected:', { ...globalStatus }); } return hasChanges; }; - const adjustPollingFrequency = (hasChanges: boolean) => { - if (hasChanges) { - pollingConfig.currentInterval = pollingConfig.baseInterval; - pollingConfig.consecutiveUnchangedPolls = 0; - console.log('Status changes detected, reset polling to base interval'); - } else { - pollingConfig.consecutiveUnchangedPolls++; - - if (pollingConfig.consecutiveUnchangedPolls >= pollingConfig.maxUnchangedPolls) { - const newInterval = Math.min( - pollingConfig.currentInterval * 1.2, - pollingConfig.maxInterval - ); - - if (newInterval !== pollingConfig.currentInterval) { - pollingConfig.currentInterval = newInterval; - console.log(`Increased polling interval to ${newInterval}ms (no changes for ${pollingConfig.consecutiveUnchangedPolls} polls)`); - } - } - } - - if (statusSyncInterval) { - clearInterval(statusSyncInterval); - startPolling(); - } - }; - - - const updateResponseTimeMetric = (responseTime: number) => { - statusMetrics.avgResponseTime = statusMetrics.avgResponseTime * 0.8 + responseTime * 0.2; - }; - const setOnline = (shouldQueue: boolean = true) => { console.log('Setting user online (no client)'); globalStatus.isOnline = true; globalStatus.currentClient = null; - if (shouldQueue) queueStatusUpdate('online'); + if (shouldQueue) syncStatusToServer(true).catch(error => { + console.error('Immediate status update (online) failed:', error); + }); }; const setOffline = (shouldQueue: boolean = true) => { console.log('Setting user offline'); globalStatus.isOnline = false; globalStatus.currentClient = null; - if (shouldQueue) queueStatusUpdate('offline'); + if (shouldQueue) syncStatusToServer(true).catch(error => { + console.error('Immediate status update (offline) failed:', error); + }); }; const setPlayingClient = (clientName: string, shouldQueue: boolean = true) => { console.log(`Setting user playing client: ${clientName}`); globalStatus.isOnline = true; globalStatus.currentClient = clientName; - if (shouldQueue) queueStatusUpdate('client_change'); + if (shouldQueue) syncStatusToServer(true).catch(error => { + console.error('Immediate status update (client_change) failed:', error); + }); }; const setInvisibleMode = (enable: boolean, shouldQueue: boolean = true) => { @@ -270,41 +145,23 @@ export function useUserStatus() { } else { globalStatus.isOnline = true; } - if (shouldQueue) queueStatusUpdate('invisible_toggle'); + if (shouldQueue) syncStatusToServer(true).catch(error => { + console.error('Immediate status update (invisible_toggle) failed:', error); + }); }; const setStreamerMode = (enable: boolean) => { console.log(`Setting streamer mode: ${enable ? 'enabled' : 'disabled'}`); - try { - streamer.setStreamerMode(enable); - } catch (e) { - console.warn('Failed to set streamer mode through composable, falling back', e); - globalStatus.streamerMode = enable; - try { localStorage.setItem('streamerModeEnabled', enable.toString()); } catch { } - } + streamer.setStreamerMode(enable); }; - const queueStatusUpdate = (_: StatusChangeEvent['type']) => { - if (changeDetectionTimeout) { - clearTimeout(changeDetectionTimeout); - } - - changeDetectionTimeout = setTimeout(() => { - syncStatusToServer(true).catch(error => { - console.error('Queued status update failed:', error); - }); - }, 500); - }; const startPolling = () => { if (statusSyncInterval) { clearInterval(statusSyncInterval); } - if (heartbeatInterval) { - clearInterval(heartbeatInterval); - } const pollWrapper = async () => { if (document && document.visibilityState === 'hidden') { @@ -317,28 +174,17 @@ export function useUserStatus() { }); } else { console.log('Auth check failed in sync interval, stopping sync'); - stopStatusSync(); + await stopStatusSync(); } }; - statusSyncInterval = setInterval(pollWrapper, pollingConfig.currentInterval); + statusSyncInterval = setInterval(pollWrapper, pollingConfig.interval); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { pollWrapper().catch(() => { }); } }); - - if (heartbeatConfig.enabled) { - heartbeatInterval = setInterval(() => { - if (checkAuthStatus() && heartbeatConfig.enabled) { - sendHeartbeat(); - } - }, heartbeatConfig.interval); - console.log(`Started heartbeat with ${heartbeatConfig.interval}ms interval`); - } - - console.log(`Started polling with ${pollingConfig.currentInterval}ms interval`); }; const startStatusSync = () => { @@ -347,19 +193,15 @@ export function useUserStatus() { return; } - console.log('Starting status sync system'); - syncStatusToServer(true).catch(error => { console.error('Failed to sync status on start:', error); }); startPolling(); - - console.log('Status sync system started'); }; - const stopStatusSync = () => { + const stopStatusSync = async () => { console.log('Stopping status sync...'); if (statusSyncInterval) { @@ -367,24 +209,14 @@ export function useUserStatus() { statusSyncInterval = null; } - if (heartbeatInterval) { - clearInterval(heartbeatInterval); - heartbeatInterval = null; - } - - if (changeDetectionTimeout) { - clearTimeout(changeDetectionTimeout); - changeDetectionTimeout = null; - } - if (checkAuthStatus() && globalStatus.isOnline) { - setOffline(); - syncStatusToServer(true).catch(error => { + setOffline(false); + try { + await syncStatusToServer(true); + } catch (error) { console.error('Failed to mark user offline on stop:', error); - }); + } } - - connectionStatus.value = 'offline'; console.log('Status sync stopped'); }; @@ -418,40 +250,31 @@ export function useUserStatus() { const initializeStatusSystem = () => { checkAuthStatus(); if (isAuthenticated.value) { - console.log('Initializing status sync system...'); - - pollingConfig.currentInterval = pollingConfig.baseInterval; - pollingConfig.consecutiveUnchangedPolls = 0; setOnline(false); startStatusSync(); - - console.log('Status sync system initialized'); } else { console.log('User not authenticated, skipping status system initialization'); } }; - const restartStatusSystem = () => { - stopStatusSync(); - setTimeout(() => { - initializeStatusSystem(); - }, 1000); + const restartStatusSystem = async () => { + await stopStatusSync(); + await nextTick(); + initializeStatusSystem(); }; const isOnline = computed(() => globalStatus.isOnline); const currentClient = computed(() => globalStatus.currentClient); const isInvisible = computed(() => globalStatus.invisibleMode); - const isStreamer = computed(() => globalStatus.streamerMode); + const isStreamer = computed(() => streamer.enabled.value); const lastSeen = computed(() => globalStatus.lastSeen); const username = computed(() => globalStatus.username); const nickname = computed(() => globalStatus.nickname); return { isAuthenticated, - connectionStatus, - lastStatusUpdate, globalStatus, isOnline, diff --git a/src/config.ts b/src/config.ts index 0481531..9dbacc4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,27 +2,40 @@ import { invoke } from '@tauri-apps/api/core'; let currentAuthUrl = ""; let isInitialized = false; +let initPromise: Promise | null = null; -export const initializeAuthUrl = async (): Promise => { +export const initializeAuthUrl = (): Promise => { if (isInitialized) { - return currentAuthUrl; + return Promise.resolve(currentAuthUrl); } - try { - const result = await invoke('get_auth_url'); - if (typeof result === 'string' && result.length > 0) { - currentAuthUrl = result.endsWith('/') ? result.slice(0, -1) : result; - console.log('Auth URL from backend:', currentAuthUrl); - } else { - throw new Error('Invalid auth url from backend'); - } - } catch (error) { - console.error('Failed to get Auth URL from backend:', error); - currentAuthUrl = "https://auth.collapseloader.org"; + if (initPromise) { + return initPromise; } - isInitialized = true; - return currentAuthUrl; + initPromise = (async () => { + try { + const result = await invoke('get_auth_url'); + if (typeof result === 'string' && result.length > 0) { + currentAuthUrl = result.endsWith('/') ? result.slice(0, -1) : result; + console.log('Auth URL from backend:', currentAuthUrl); + } else { + throw new Error('Invalid auth url from backend'); + } + } catch (error) { + console.error('Failed to get Auth URL from backend:', error); + currentAuthUrl = "https://auth.collapseloader.org"; + } + + isInitialized = true; + return currentAuthUrl; + })(); + + return initPromise; }; export const getAuthUrl = (): string => currentAuthUrl; +export const ensureAuthUrl = async (): Promise => { + if (isInitialized) return currentAuthUrl; + return initializeAuthUrl(); +}; diff --git a/src/i18n/index.ts b/src/i18n/index.ts index c93f85a..a5afb93 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,5 +1,5 @@ import { createI18n } from 'vue-i18n'; -import { invoke } from '@tauri-apps/api/core'; +import { settingsService } from '../services/settingsService'; import en from './locales/en.json'; import ru from './locales/ru.json'; @@ -26,8 +26,9 @@ interface AppSettings { const getInitialLanguage = async () => { try { - const settings = await invoke('get_settings'); - if (settings?.language?.value && messages[settings.language.value as keyof typeof messages]) { + await settingsService.loadSettings(); + const settings = settingsService.getSettings() as AppSettings | null; + if (settings && settings.language?.value && messages[settings.language.value as keyof typeof messages]) { return settings.language.value; } } catch (error) { @@ -69,17 +70,7 @@ export const changeLanguage = async (locale: string) => { document.documentElement.setAttribute('lang', locale); try { - const currentSettings = await invoke('get_settings'); - if (currentSettings && typeof currentSettings === 'object') { - const updatedSettings = { - ...currentSettings, - language: { - value: locale, - show: currentSettings.language?.show ?? true - } - }; - await invoke('save_settings', { inputSettings: updatedSettings }); - } + await settingsService.editSetting('language', locale); } catch (error) { console.error('Failed to save language to settings:', error); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2af4208..34168ae 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -118,7 +118,7 @@ }, "logout_confirm": { "message": "Are you sure you want to log out of your account?", - "info": "This will sign you out locally and may require you to sign in again to access some features.", + "info": "This will sign you out and may require you to sign in again to access some features.", "yes_logout": "Yes, log out" }, "client_crash": { @@ -172,6 +172,23 @@ "cancel": "Cancel", "close": "I understand" }, + "slow_download": { + "modal_title": "Slow Download Detected", + "title": "Slow Download Speed", + "subtitle": "Your download speed is lower than expected", + "description": "Your current download speed is slower than usual. This may result in longer download times.", + "current_speed": "Current Speed:", + "tip_1": "Check your internet connection", + "tip_2": "Try closing other applications using bandwidth", + "tip_3": "Consider retrying the download later", + "dont_show_again": "Don't show this warning again", + "stalled_modal_title": "Download Stalled", + "stalled_title": "Download Stuck", + "stalled_subtitle": "No progress detected", + "stalled_description": "Your download appears to be stuck. No data has been received for a while.", + "no_progress": "No download progress detected", + "tip_zapret": "If you are from CIS (Russia, Belarus, Kazakhstan, except Ukraine), your provider may block our domains. Try enabling 'DPI Bypass' in settings and restart the loader as administrator." + }, "add_custom_client": "Add custom client", "edit_custom_client": "Edit client", "delete_custom_client": "Delete custom client", @@ -300,7 +317,8 @@ "details": "Details", "send": "Send", "copied_to_clipboard": "Color {color} copied to clipboard", - "done": "Done" + "done": "Done", + "click": "Click" }, "auth": { "logout": { @@ -408,7 +426,8 @@ }, "filters": { "installed": "Installed" - } + }, + "clear_filters": "Clear filters" }, "theme": { "actions": { @@ -596,10 +615,34 @@ "passwords_no_match": "Passwords do not match.", "min_length": "{field} must be at least {length} characters long." }, + "irc": { + "inline": { + "title": "Global Chat", + "latest_activity_connected": "Connected and listening", + "tap_to_connect": "Tap to connect to IRC", + "placeholder": "Type a message...", + "send_failed": "Failed to send message", + "connection_error": "Connection Error: {error}", + "status": { + "connected": "Connected", + "reconnecting": "Reconnecting...", + "connecting": "Connecting...", + "error": "Connection issue", + "disconnected": "Disconnected" + }, + "privateMessage": "Private message", + "copyNickname": "Copy nickname" + } + }, "settings": { - "general": "General Settings", + "general": "Settings", "sync": "Cloud Sync", - "accounts": "Account Management", + "accounts": "Accounts", + "accounts_management": "Accounts Management", + "accounts_description": "Manage your game accounts and switch between them easily", + "search_accounts": "Search accounts...", + "no_matching_accounts": "No matching accounts found", + "try_different_search": "Try adjusting your search or filters", "reset_tooltip": "Reset to defaults", "sync_status": "Cloud Sync Status", "sync_controls": "Sync Controls", @@ -668,8 +711,6 @@ "reset_requirements_success": "Successfully removed requirements, now download any client", "reset_requirements_failed": "Cannot remove requirements, try again or restart loader", "login_required": "You will need to log into your account to use this feature.", - "reset_cache": "Reset client cache", - "reset_cache_success": "Successfully deleted client cache, restart the loader", "reset_cache_failed": "Cannot remove cache, try again", "hash_verify": "Hash verify", "irc_chat": "IRC Chat", @@ -689,7 +730,10 @@ "failed": "Failed to change data folder: {error}", "restart_required": "Please restart the app for changes to take effect" }, - "sync_client_settings": "Sync client settings" + "sync_client_settings": "Sync client settings", + "reset_settings": "Reset settings", + "sync_login_required": "Sign in to enable syncing", + "not_signed_in": "You are not logged in" }, "userProfile": { "back_to_friends": "Back to Friends", @@ -759,7 +803,12 @@ "admin": "Admin Panel", "news": "News", "customization": "Customization", - "custom_clients": "Custom Clients" + "custom_clients": "Custom Clients", + "sidebar_help": { + "title": "Tip:", + "tip": "You can move the sidebar to any side of the screen. Double-click the sidebar to remove margin between icons.", + "got_it": "Got it" + } }, "admin": { "title": "Administration Panel", @@ -896,7 +945,8 @@ "already_in_progress": "Sync already in progress", "syncing": "Syncing to cloud...", "success": "Sync completed successfully!", - "failed": "Sync failed: {error}" + "failed": "Sync failed: {error}", + "login_required": "Please sign in to sync" } }, "discord": { @@ -967,8 +1017,7 @@ } }, "no_changelog_desc": "Updates and changes will appear here." - }, - "insecure_tooltip": "This client is marked as insecure, tap to view details" + } }, "customization": {}, "updater": { @@ -988,7 +1037,10 @@ "show_more": "Show {count} more versions", "show_less": "Show less", "release_types": {}, - "categories": {}, + "categories": { + "feature": "Feature", + "improvement": "Improvement" + }, "no_update": "Updates not found" }, "custom_clients": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 4a8a318..0a40616 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -118,7 +118,7 @@ }, "logout_confirm": { "message": "Вы уверены, что хотите выйти из аккаунта?", - "info": "Это разлогинит вас локально и может потребовать повторного входа для доступа к некоторым функциям.", + "info": "Это разлогинит вас, и может потребовать повторного входа для доступа к некоторым функциям.", "yes_logout": "Да, выйти" }, "client_crash": { @@ -172,6 +172,23 @@ "cancel": "Отмена", "close": "Я понял" }, + "slow_download": { + "modal_title": "Медленная загрузка", + "title": "Низкая скорость загрузки", + "subtitle": "Скорость загрузки ниже ожидаемой", + "description": "Текущая скорость загрузки ниже обычной. Это может привести к увеличению времени загрузки.", + "current_speed": "Текущая скорость:", + "tip_1": "Проверьте подключение к интернету", + "tip_2": "Попробуйте закрыть другие приложения, использующие интернет", + "tip_3": "Попробуйте повторить загрузку позже", + "dont_show_again": "Больше не показывать это предупреждение", + "stalled_modal_title": "Загрузка зависла", + "stalled_title": "Загрузка остановилась", + "stalled_subtitle": "Нет прогресса", + "stalled_description": "Загрузка, похоже, зависла. Данные не поступают уже некоторое время.", + "no_progress": "Нет прогресса загрузки", + "tip_zapret": "Если вы находитесь в странах СНГ (Россия, Беларусь, Казахстан, кроме Украины), ваш провайдер может блокировать наши домены. Попробуйте включить 'DPI Bypass' в настройках и перезапустите загрузчик от имени администратора." + }, "add_custom_client": "Добавить пользовательский клиент", "delete_custom_client": "Удалить свой клиент", "client_added": "Клиент добавлен", @@ -333,7 +350,8 @@ "details": "Подробности", "send": "Отправить", "copied_to_clipboard": "Цвет {color} скопирован в буфер обмена", - "done": "Готово" + "done": "Готово", + "click": "Нажмите" }, "friends": { "addFriend": "Добавить друга", @@ -408,7 +426,8 @@ }, "filters": { "installed": "Установленные" - } + }, + "clear_filters": "Отчистить фильтры" }, "theme": { "actions": { @@ -596,10 +615,29 @@ "passwords_no_match": "Пароли не совпадают.", "min_length": "{field} должно быть не менее {length} символов." }, + "irc": { + "inline": { + "title": "Глобальный чат", + "latest_activity_connected": "Подключено и слушаем", + "tap_to_connect": "Нажмите, чтобы подключиться к IRC", + "placeholder": "Введите сообщение...", + "send_failed": "Не удалось отправить сообщение", + "connection_error": "Ошибка соединения: {error}", + "status": { + "connected": "Подключено", + "reconnecting": "Повторное подключение...", + "connecting": "Подключение...", + "error": "Проблема с подключением", + "disconnected": "Отключено" + }, + "privateMessage": "Личное сообщение", + "copyNickname": "Скопировать никнейм" + } + }, "settings": { - "general": "Основные настройки", + "general": "Настройки", "sync": "Облачная синхронизация", - "accounts": "Управление аккаунтами", + "accounts": "Аккаунты", "reset_tooltip": "Сбросить к значениям по умолчанию", "sync_status": "Статус облачной синхронизации", "sync_controls": "Управление синхронизацией", @@ -668,8 +706,6 @@ "reset_requirements_success": "Успешно удалены файлы, теперь скачайте любой клиент", "reset_requirements_failed": "Не удалось удалить файлы, попробуйте ещё раз, или перезапустите лоадер", "login_required": "Вам нужно войти в свою учетную запись, чтобы использовать эту функцию.", - "reset_cache": "Сбросить кэш клиентов", - "reset_cache_success": "Успешно удален кэш клиентов, перезапустите лоадер", "reset_cache_failed": "Не удалось удалить кэш, попробуйте еще раз", "hash_verify": "Проверка хэша ", "irc_chat": "IRC чат", @@ -689,7 +725,15 @@ "failed": "Не удалось изменить папку данных: {error}", "restart_required": "Перезапустите приложение, чтобы изменения вступили в силу" }, - "sync_client_settings": "Синхронизировать настройки клиента" + "sync_client_settings": "Синхронизировать настройки клиента", + "reset_settings": "Сбросить настройки", + "accounts_description": "Управляйте своими игровыми аккаунтами и легко переключайтесь между ними", + "accounts_management": "Управление учетными записями", + "no_matching_accounts": "Не найдены соответствующие учетные записи", + "search_accounts": "Поиск учетных записей...", + "try_different_search": "Попробуйте изменить параметры поиска или фильтры", + "sync_login_required": "Войдите, чтобы включить синхронизацию", + "not_signed_in": "Вы не зашли в аккаунт" }, "userProfile": { "playing": "Играет в", @@ -759,7 +803,12 @@ "admin": "Панель админа", "news": "Новости", "customization": "Кастомизация", - "custom_clients": "Свои клиенты" + "custom_clients": "Свои клиенты", + "sidebar_help": { + "title": "Совет:", + "tip": "Вы можете переместить боковую панель на любую сторону экрана. Двойной щелчок по панели уберет отступы между иконками.", + "got_it": "Понял" + } }, "toast": { "dev": { @@ -830,7 +879,8 @@ "already_in_progress": "Синхронизация уже выполняется", "syncing": "Синхронизация с облаком...", "success": "Синхронизация завершена успешно!", - "failed": "Синхронизация не удалась: {error}" + "failed": "Синхронизация не удалась: {error}", + "login_required": "Пожалуйста, войдите, чтобы включить синхронизацию" }, "telemetry": { "analytics_enabled": "Аналитика включена. Спасибо за помощь в улучшении приложения!", @@ -967,8 +1017,7 @@ } }, "no_changelog_desc": "Обновления и изменения появятся здесь." - }, - "insecure_tooltip": "Этот клиент помечен как непроверенный, нажмите, чтобы просмотреть детали" + } }, "customization": {}, "updater": { @@ -987,7 +1036,10 @@ "up_to_date": "У вас установлена последняя версия!", "show_more": "Показать еще {count} версий", "show_less": "Показать меньше", - "categories": {}, + "categories": { + "feature": "Функционал", + "improvement": "Улучшение" + }, "no_update": "Обновления не найдены" }, "custom_clients": { diff --git a/src/i18n/locales/ua.json b/src/i18n/locales/ua.json index 5b3fe6e..a2cf47e 100644 --- a/src/i18n/locales/ua.json +++ b/src/i18n/locales/ua.json @@ -118,7 +118,7 @@ }, "logout_confirm": { "message": "Ви впевнені, що хочете вийти з акаунту?", - "info": "Це виконає локальний вихід та може потребувати повторного входу для доступу до деяких функцій.", + "info": "Це виконає вихід, та може потребувати повторного входу для доступу до деяких функцій.", "yes_logout": "Так, вийти" }, "client_crash": { @@ -172,6 +172,23 @@ "cancel": "Скасувати", "close": "Я зрозумів" }, + "slow_download": { + "modal_title": "Повільне завантаження", + "title": "Низька швидкість завантаження", + "subtitle": "Швидкість завантаження нижча за очікувану", + "description": "Поточна швидкість завантаження нижча за звичайну. Це може призвести до збільшення часу завантаження.", + "current_speed": "Поточна швидкість:", + "tip_1": "Перевірте підключення до інтернету", + "tip_2": "Спробуйте закрити інші програми, що використовують інтернет", + "tip_3": "Спробуйте повторити завантаження пізніше", + "dont_show_again": "Більше не показувати це попередження", + "stalled_modal_title": "Завантаження зависло", + "stalled_title": "Завантаження зупинилося", + "stalled_subtitle": "Немає прогресу", + "stalled_description": "Завантаження, схоже, зависло. Дані не надходять вже деякий час.", + "no_progress": "Немає прогресу завантаження", + "tip_zapret": "Якщо ви перебуваєте в країнах СНД (Росія, Білорусь, Казахстан, крім України), ваш провайдер може блокувати наші домени. Спробуйте увімкнути 'DPI Bypass' у налаштуваннях і перезапустіть лоадер від імені адміністратора." + }, "add_custom_client": "Додати власний клієнт", "edit_custom_client": "Редагувати клієнт", "delete_custom_client": "Видалити власний клієнт", @@ -300,7 +317,8 @@ "details": "Деталі", "send": "Надіслати", "copied_to_clipboard": "Колір {color} скопійовано до буфера обміну", - "done": "Готово" + "done": "Готово", + "click": "Натисніть" }, "auth": { "logout": { @@ -408,7 +426,8 @@ }, "filters": { "installed": "Встановлені" - } + }, + "clear_filters": "Очистити фільтри" }, "theme": { "actions": { @@ -596,10 +615,29 @@ "passwords_no_match": "Паролі не збігаються.", "min_length": "{field} повинен містити щонайменше {length} символів." }, + "irc": { + "inline": { + "title": "Глобальний чат", + "latest_activity_connected": "Підключено та слухаємо", + "tap_to_connect": "Торкніться, щоб підключитись до IRC", + "placeholder": "Введіть повідомлення...", + "send_failed": "Не вдалося відправити повідомлення", + "connection_error": "Помилка з'єднання: {error}", + "status": { + "connected": "Підключено", + "reconnecting": "Перепідключення...", + "connecting": "Підключення...", + "error": "Проблеми з підключенням", + "disconnected": "Відключено" + }, + "privateMessage": "Особисте повідомлення", + "copyNickname": "Скопіювати нікнейм" + } + }, "settings": { - "general": "Загальні налаштування", + "general": "Налаштування", "sync": "Хмарна синхронізація", - "accounts": "Керування акаунтами", + "accounts": "Акаунти", "reset_tooltip": "Скинути до стандартних", "sync_status": "Статус хмарної синхронізації", "sync_controls": "Керування синхронізацією", @@ -664,8 +702,6 @@ "reset_requirements_success": "Успішно видалено файли, тепер завантажте будь-який клієнт", "reset_requirements_failed": "Не вдалося видалити файли, спробуйте ще раз або перезапустіть лоадер", "login_required": "Вам потрібно увійти до свого акаунту, щоб використовувати цю функцію.", - "reset_cache": "Скинути кеш клієнтів", - "reset_cache_success": "Успішно видалено кеш клієнтів, перезапустіть лоадер", "reset_cache_failed": "Не вдалося видалити кеш, спробуйте ще раз", "hash_verify": "Перевірка хешу", "irc_chat": "IRC-чат", @@ -688,7 +724,15 @@ "sync_client_settings": "Синхронізувати налаштування клієнта", "ram": { "warning": "Попередження: Ви вибрали {selectedRamMb} МБ оперативної пам'яті. Це перевищує рекомендований ліміт у 6 ГБ і може спричинити проблеми з продуктивністю або збій системи." - } + }, + "reset_settings": "Скинути налаштування", + "accounts_description": "Керуйте своїми ігровими обліковими записами та легко переключайтеся між ними", + "accounts_management": "Управління обліковими записами", + "no_matching_accounts": "Не знайдено відповідних облікових записів", + "search_accounts": "Пошук облікових записів...", + "try_different_search": "Спробуйте змінити параметри пошуку або фільтри", + "sync_login_required": "Увійдіть, щоб увімкнути синхронізацію", + "not_signed_in": "Ви не увійшли в обліковий запис" }, "userProfile": { "back_to_friends": "Назад до друзів", @@ -758,7 +802,12 @@ "admin": "Адмін-панель", "news": "Новини", "customization": "Кастомізація", - "custom_clients": "Власні клієнти" + "custom_clients": "Власні клієнти", + "sidebar_help": { + "title": "Порада:", + "tip": "Ви можете перемістити бічну панель на будь-який бік екрану. Подвійне клацання по бічній панелі прибирає відступи між іконками.", + "got_it": "Зрозумів" + } }, "admin": { "title": "Панель адміністрування", @@ -895,7 +944,8 @@ "already_in_progress": "Синхронізація вже виконується", "syncing": "Синхронізація з хмарою...", "success": "Синхронізацію успішно завершено!", - "failed": "Синхронізація не вдалася: {error}" + "failed": "Синхронізація не вдалася: {error}", + "login_required": "Будь ласка, увійдіть, щоб синхронізувати" } }, "discord": { @@ -966,8 +1016,7 @@ } }, "no_changelog_desc": "Оновлення та зміни з'являться тут." - }, - "insecure_tooltip": "Цей клієнт позначено як небезпечний, натисніть для деталей" + } }, "customization": {}, "updater": { @@ -987,7 +1036,10 @@ "show_more": "Показати ще {count} версій", "show_less": "Показати менше", "release_types": {}, - "categories": {}, + "categories": { + "feature": "Функціонал", + "improvement": "Поліпшення" + }, "no_update": "Оновлень не знайдено" }, "custom_clients": { diff --git a/src/i18n/locales/zh_cn.json b/src/i18n/locales/zh_cn.json index bbf6c5f..f682298 100644 --- a/src/i18n/locales/zh_cn.json +++ b/src/i18n/locales/zh_cn.json @@ -118,7 +118,7 @@ }, "logout_confirm": { "message": "您确定要退出您的账户吗?", - "info": "这将使您在本地登出,并可能需要您再次登录以访问某些功能。", + "info": "这将导致您退出登录,访问某些功能时可能需要重新登录。", "yes_logout": "是的,退出" }, "client_crash": { @@ -172,6 +172,23 @@ "cancel": "取消", "close": "我已了解" }, + "slow_download": { + "modal_title": "下载速度慢", + "title": "下载速度过慢", + "subtitle": "您的下载速度低于预期", + "description": "当前下载速度低于正常水平。这可能导致下载时间延长。", + "current_speed": "当前速度:", + "tip_1": "检查您的网络连接", + "tip_2": "尝试关闭其他占用带宽的应用程序", + "tip_3": "考虑稍后重新下载", + "dont_show_again": "不再显示此警告", + "stalled_modal_title": "下载停滞", + "stalled_title": "下载卡住", + "stalled_subtitle": "未检测到进度", + "stalled_description": "您的下载似乎卡住了。已有一段时间未接收到数据。", + "no_progress": "未检测到下载进度", + "tip_zapret": "如果您位于独联体地区(俄罗斯、白俄罗斯、哈萨克斯坦,乌克兰除外),您的网络提供商可能会屏蔽我们的域名。请尝试在设置中启用“DPI 绕过”并以管理员身份重启加载器。" + }, "add_custom_client": "添加自定义客户端", "edit_custom_client": "编辑客户端", "delete_custom_client": "删除自定义客户端", @@ -300,7 +317,8 @@ "details": "详情", "send": "发送", "copied_to_clipboard": "颜色 {color} 已复制到剪贴板", - "done": "完成" + "done": "完成", + "click": "点击" }, "auth": { "logout": { @@ -408,7 +426,8 @@ }, "filters": { "installed": "已安装" - } + }, + "clear_filters": "清除筛选条件" }, "theme": { "actions": { @@ -585,10 +604,29 @@ "passwords_no_match": "两次输入的密码不一致。", "min_length": "{field} 至少需要 {length} 个字符。" }, + "irc": { + "inline": { + "title": "全局聊天", + "latest_activity_connected": "已连接并在监听", + "tap_to_connect": "点击以连接到 IRC", + "placeholder": "输入消息...", + "send_failed": "发送消息失败", + "connection_error": "连接错误:{error}", + "status": { + "connected": "已连接", + "reconnecting": "正在重连...", + "connecting": "连接中...", + "error": "连接问题", + "disconnected": "已断开" + }, + "privateMessage": "私人消息", + "copyNickname": "复制昵称" + } + }, "settings": { - "general": "通用设置", + "general": "设置", "sync": "云同步", - "accounts": "账户管理", + "accounts": "账户", "reset_tooltip": "重置为默认", "sync_status": "云同步状态", "sync_controls": "同步控制", @@ -653,8 +691,6 @@ "reset_requirements_success": "已成功移除依赖,现在可以下载任意客户端", "reset_requirements_failed": "无法移除依赖,请重试或重启加载器", "login_required": "使用此功能需要登录您的账户。", - "reset_cache": "重置客户端缓存", - "reset_cache_success": "已成功删除客户端缓存,请重启加载器", "reset_cache_failed": "无法删除缓存,请重试", "hash_verify": "哈希校验", "irc_chat": "IRC 聊天", @@ -677,7 +713,15 @@ "sync_client_settings": "同步客户端设置", "ram": { "warning": "警告:您已选择 {selectedRamMb} MB 内存。此配置超过推荐的 6 GB 限制,可能导致性能问题或系统崩溃。" - } + }, + "reset_settings": "重置设置", + "accounts_description": "轻松管理您的游戏账号并自由切换", + "accounts_management": "账户管理", + "no_matching_accounts": "未找到匹配的帐户", + "search_accounts": "搜索账户...", + "try_different_search": "请尝试调整您的搜索条件或筛选器", + "sync_login_required": "登录以启用同步功能", + "not_signed_in": "您尚未登录账户" }, "userProfile": { "back_to_friends": "返回好友", @@ -747,7 +791,12 @@ "admin": "管理面板", "news": "新闻", "customization": "个性化", - "custom_clients": "自定义客户端" + "custom_clients": "自定义客户端", + "sidebar_help": { + "title": "提示:", + "tip": "您可以将侧边栏移动到屏幕的任意一侧。双击侧边栏可移除图标之间的间距。", + "got_it": "知道了" + } }, "admin": { "title": "管理面板", @@ -884,7 +933,8 @@ "already_in_progress": "同步已在进行中", "syncing": "正在同步到云端...", "success": "同步成功完成!", - "failed": "同步失败:{error}" + "failed": "同步失败:{error}", + "login_required": "请登录以同步" } }, "discord": { @@ -955,8 +1005,7 @@ } }, "no_changelog_desc": "更新与改动将显示在此处。" - }, - "insecure_tooltip": "此客户端被标记为不安全,点击查看详情" + } }, "customization": {}, "updater": { @@ -976,7 +1025,10 @@ "show_more": "再显示 {count} 个版本", "show_less": "收起", "release_types": {}, - "categories": {}, + "categories": { + "feature": "功能", + "improvement": "改善" + }, "no_update": "未找到更新" }, "custom_clients": { diff --git a/src/main.ts b/src/main.ts index a31f04f..1ab2a15 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,20 +4,16 @@ import Vue3Lottie from "vue3-lottie"; import i18n from './i18n/index'; import { initializeAuthUrl } from './config'; import { loader } from '@guolao/vue-monaco-editor'; -import { applyCursorForEvent } from "./utils/events"; loader.config({ paths: { - vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs', + vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.55/min/vs', }, }) -initializeAuthUrl() - .finally(() => { - applyCursorForEvent(); +initializeAuthUrl(); - createApp(App) - .use(Vue3Lottie) - .use(i18n) - .mount('#app'); - }); +createApp(App) + .use(Vue3Lottie) + .use(i18n) + .mount('#app'); diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 9e21abe..cf50b95 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -1,5 +1,5 @@ -import axios, { AxiosResponse, AxiosRequestConfig } from 'axios'; -import { getAuthUrl } from '../config'; +import axios, { AxiosRequestConfig } from 'axios'; +import { getAuthUrl, ensureAuthUrl } from '../config'; import { getCurrentLanguage } from '../i18n'; interface CacheEntry { @@ -9,16 +9,6 @@ interface CacheEntry { etag?: string; } -interface RequestMetrics { - totalRequests: number; - cacheHits: number; - cacheMisses: number; - avgResponseTime: number; - errorRate: number; - requestTimes: number[]; - errors: number; -} - interface BatchConfig { maxBatchSize: number; batchTimeout: number; @@ -30,15 +20,6 @@ class ApiClient { private cache = new Map(); private pendingRequests = new Map>(); private requestTimings = new Map(); - private metrics: RequestMetrics = { - totalRequests: 0, - cacheHits: 0, - cacheMisses: 0, - avgResponseTime: 0, - errorRate: 0, - requestTimes: [], - errors: 0 - }; private batchConfig: BatchConfig = { maxBatchSize: 10, @@ -52,7 +33,8 @@ class ApiClient { } private setupInterceptors() { - this.client.interceptors.request.use((config) => { + this.client.interceptors.request.use(async (config) => { + await ensureAuthUrl(); const baseUrl = getAuthUrl(); if (config.url?.startsWith('/')) { @@ -79,42 +61,14 @@ class ApiClient { this.client.interceptors.response.use( (response) => { - this.updateMetrics(response); return response; }, (error) => { - this.updateErrorMetrics(); return Promise.reject(error); } ); } - private updateMetrics(response: AxiosResponse) { - const timingKey = (response.config as any).__timingKey; - if (timingKey && this.requestTimings.has(timingKey)) { - const startTime = this.requestTimings.get(timingKey)!; - const responseTime = Date.now() - startTime; - this.metrics.requestTimes.push(responseTime); - - this.requestTimings.delete(timingKey); - - if (this.metrics.requestTimes.length > 100) { - this.metrics.requestTimes.shift(); - } - - this.metrics.avgResponseTime = - this.metrics.requestTimes.reduce((a, b) => a + b, 0) / this.metrics.requestTimes.length; - } - - this.metrics.totalRequests++; - } - - private updateErrorMetrics() { - this.metrics.errors++; - this.metrics.totalRequests++; - this.metrics.errorRate = this.metrics.errors / this.metrics.totalRequests; - } - private setupCacheCleanup() { setInterval(() => { const now = Date.now(); @@ -182,8 +136,6 @@ class ApiClient { if (this.shouldCache(url, method)) { if (cached && Date.now() - cached.timestamp < cached.ttl) { - this.metrics.cacheHits++; - console.log(`Cache hit for ${url}`); return cached.data; } } @@ -193,7 +145,6 @@ class ApiClient { return this.pendingRequests.get(cacheKey) as Promise; } - // If we have an expired cached entry with an ETag, add conditional header const requestConfig: AxiosRequestConfig = { ...(config || {}), method }; if (cached && cached.etag) { requestConfig.headers = { ...(requestConfig.headers || {}) }; @@ -204,13 +155,10 @@ class ApiClient { try { const response = await this.executeRequest(url, requestConfig); - // axios response handled in executeRequest now returns AxiosResponse const axiosResp: any = response as any; if (axiosResp.status === 304 && cached) { - // server indicates not modified cached.timestamp = Date.now(); - this.metrics.cacheHits++; return cached.data as T; } @@ -224,7 +172,6 @@ class ApiClient { ttl: this.getCacheTTL(url), etag: etag }); - this.metrics.cacheMisses++; } return result; @@ -347,40 +294,11 @@ class ApiClient { return false; } - - getMetrics(): RequestMetrics { - return { ...this.metrics }; - } - - - resetMetrics() { - this.metrics = { - totalRequests: 0, - cacheHits: 0, - cacheMisses: 0, - avgResponseTime: 0, - errorRate: 0, - requestTimes: [], - errors: 0 - }; - } - - clearCache() { this.cache.clear(); console.log('API cache cleared'); } - getCacheStats() { - return { - size: this.cache.size, - hitRate: this.metrics.cacheHits / (this.metrics.cacheHits + this.metrics.cacheMisses) || 0, - avgResponseTime: this.metrics.avgResponseTime, - totalRequests: this.metrics.totalRequests - }; - } - - async preloadCriticalData(): Promise { const criticalEndpoints = [ '/auth/init/', @@ -421,8 +339,6 @@ export const apiDelete = apiClient.delete.bind(apiClient); export const apiBatchGet = apiClient.batchGet.bind(apiClient); export const apiPreload = apiClient.preloadCriticalData.bind(apiClient); -export const apiMetrics = apiClient.getMetrics.bind(apiClient); -export const apiCacheStats = apiClient.getCacheStats.bind(apiClient); export const apiHeartbeat = apiClient.heartbeat.bind(apiClient); export const apiInvalidateProfile = apiClient.invalidateProfileCaches.bind(apiClient); diff --git a/src/services/authClient.ts b/src/services/authClient.ts index 3005450..0bf960e 100644 --- a/src/services/authClient.ts +++ b/src/services/authClient.ts @@ -8,7 +8,5 @@ export const apiDelete = apiClient.delete.bind(apiClient); export const apiBatchGet = apiClient.batchGet.bind(apiClient); export const apiPreload = apiClient.preloadCriticalData.bind(apiClient); -export const apiMetrics = apiClient.getMetrics.bind(apiClient); -export const apiCacheStats = apiClient.getCacheStats.bind(apiClient); export default apiClient; diff --git a/src/services/bootLogService.ts b/src/services/bootLogService.ts index a068d52..c96a815 100644 --- a/src/services/bootLogService.ts +++ b/src/services/bootLogService.ts @@ -46,6 +46,10 @@ class BootLogService { this.addLog('OK', 'i18n', `Language system initialized: ${language} [OK]`); } + cursorApplied() { + this.addLog('OK', 'cursor', `Event cursor applied [OK]`); + } + eventListenersInit() { this.addLog('INFO', 'events', 'Registering event listeners...'); } diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts deleted file mode 100644 index bf61f8c..0000000 --- a/src/services/notificationService.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - isPermissionGranted, - requestPermission, - sendNotification -} from '@tauri-apps/plugin-notification' - -export async function sendNativeNotification( - title: string, - body: string, - icon?: string -): Promise { - try { - let permissionGranted = await isPermissionGranted(); - - if (!permissionGranted) { - const permission = await requestPermission(); - permissionGranted = permission === 'granted'; - } - - if (permissionGranted) { - sendNotification({ - title, - body, - icon - }); - return true; - } - - return false; - } catch (error) { - console.error('Failed to send notification:', error); - return false; - } -} \ No newline at end of file diff --git a/src/services/presetService.ts b/src/services/presetService.ts index 74a4fff..bc3edf2 100644 --- a/src/services/presetService.ts +++ b/src/services/presetService.ts @@ -33,59 +33,59 @@ export class PresetService { return { name, description, - custom_css: settings.customCSS, - enable_custom_css: settings.enableCustomCSS, + customCSS: settings.customCSS, + enableCustomCSS: settings.enableCustomCSS, base100: settings.base100 || undefined, base200: settings.base200 || undefined, base300: settings.base300 || undefined, - base_content: settings.baseContent || undefined, + baseContent: settings.baseContent || undefined, primary: settings.primary || undefined, - primary_content: settings.primaryContent || undefined, + primaryContent: settings.primaryContent || undefined, secondary: settings.secondary || undefined, - secondary_content: settings.secondaryContent || undefined, + secondaryContent: settings.secondaryContent || undefined, accent: settings.accent || undefined, - accent_content: settings.accentContent || undefined, + accentContent: settings.accentContent || undefined, neutral: settings.neutral || undefined, - neutral_content: settings.neutralContent || undefined, + neutralContent: settings.neutralContent || undefined, info: settings.info || undefined, - info_content: settings.infoContent || undefined, + infoContent: settings.infoContent || undefined, success: settings.success || undefined, - success_content: settings.successContent || undefined, + successContent: settings.successContent || undefined, warning: settings.warning || undefined, - warning_content: settings.warningContent || undefined, + warningContent: settings.warningContent || undefined, error: settings.error || undefined, - error_content: settings.errorContent || undefined, + errorContent: settings.errorContent || undefined, }; } applyPresetToTheme(preset: ThemePreset): void { themeService.updatePresetSettings({ - customCSS: preset.custom_css, - enableCustomCSS: preset.enable_custom_css, + customCSS: preset.customCSS, + enableCustomCSS: preset.enableCustomCSS, base100: preset.base100, base200: preset.base200, base300: preset.base300, - baseContent: preset.base_content, + baseContent: preset.baseContent, primary: preset.primary, - primaryContent: preset.primary_content, + primaryContent: preset.primaryContent, secondary: preset.secondary, - secondaryContent: preset.secondary_content, + secondaryContent: preset.secondaryContent, accent: preset.accent, - accentContent: preset.accent_content, + accentContent: preset.accentContent, neutral: preset.neutral, - neutralContent: preset.neutral_content, + neutralContent: preset.neutralContent, info: preset.info, - infoContent: preset.info_content, + infoContent: preset.infoContent, success: preset.success, - successContent: preset.success_content, + successContent: preset.successContent, warning: preset.warning, - warningContent: preset.warning_content, + warningContent: preset.warningContent, error: preset.error, - errorContent: preset.error_content, + errorContent: preset.errorContent, }); } } diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts new file mode 100644 index 0000000..5515964 --- /dev/null +++ b/src/services/settingsService.ts @@ -0,0 +1,131 @@ +import { reactive, ref, watch } from 'vue'; +import { invoke } from '@tauri-apps/api/core'; + +export interface Setting { + value: T; + show: boolean; +} + +export interface SettingsMap { + [key: string]: Setting; +} + +export interface FlagsMap { + [key: string]: any; +} + +class SettingsService { + constructor() { + this.initAutoSaveWatcher(); + } + settings = reactive({}); + flags = reactive({}); + isLoading = ref(false); + isSaving = ref(false); + isEditing = ref(false); + + async loadSettings(): Promise { + this.isLoading.value = true; + try { + const loaded = await invoke('get_settings'); + Object.keys(this.settings).forEach((k) => delete this.settings[k]); + Object.entries(loaded || {}).forEach(([k, v]) => { + this.settings[k] = v; + }); + } catch (err) { + console.error('Failed to load settings:', err); + throw err; + } finally { + this.isLoading.value = false; + } + } + + async saveSettings(settingsArg?: SettingsMap): Promise { + this.isSaving.value = true; + try { + const payload = settingsArg ?? this.settings; + await invoke('save_settings', { inputSettings: payload }); + } catch (err) { + console.error('Failed to save settings:', err); + throw err; + } finally { + this.isSaving.value = false; + } + } + + async loadFlags(): Promise { + try { + const loaded = await invoke('get_flags'); + Object.keys(this.flags).forEach((k) => delete this.flags[k]); + Object.entries(loaded || {}).forEach(([k, v]) => { + this.flags[k] = v; + }); + } catch (err) { + console.error('Failed to load flags:', err); + throw err; + } + } + + setSetting(key: string, value: any) { + if (!this.settings[key]) { + this.settings[key] = { value, show: true } as Setting; + return; + } + this.settings[key].value = value; + } + + async editSetting(key: string, value: any, show: boolean = true): Promise { + this.isEditing.value = true; + try { + if (this.settings[key]) { + this.settings[key].value = value; + this.settings[key].show = show; + } else { + this.settings[key] = { value, show }; + } + + await this.saveSettings(); + } finally { + this.isEditing.value = false; + } + } + + getSetting(key: string): Setting | undefined { + return this.settings[key] as Setting | undefined; + } + + getSettings(): SettingsMap { + return this.settings; + } + + getFlags(): FlagsMap { + return this.flags; + } + + initAutoSaveWatcher() { + let saveTimeout: number | null = null; + watch( + () => this.settings, + () => { + if (this.isLoading.value || this.isEditing.value) return; + + if (saveTimeout) { + clearTimeout(saveTimeout); + } + + saveTimeout = setTimeout(async () => { + try { + await this.saveSettings(); + } catch (e) { + console.error('Auto-save failed', e); + } + }, 500) as unknown as number; + }, + { deep: true } + ); + } +} + +export const settingsService = new SettingsService(); + +export default settingsService; diff --git a/src/services/syncService.ts b/src/services/syncService.ts index 14d4a4c..6105818 100644 --- a/src/services/syncService.ts +++ b/src/services/syncService.ts @@ -1,5 +1,7 @@ import { invoke } from '@tauri-apps/api/core'; import { userService, type SyncData } from './userService'; +import { settingsService } from './settingsService'; +import { globalUserStatus } from '../composables/useUserStatus'; export type ToastFunction = (message: string, type: string) => void; export type TranslateFunction = (key: string, params?: Record) => string; @@ -28,7 +30,7 @@ class SyncService { window.addEventListener('online', () => { this.state.isOnline = true; this.notifyListeners(); - if (this.state.autoSyncEnabled) { + if (this.state.autoSyncEnabled && this.isAuthenticated()) { this.autoSync(); } }); @@ -56,14 +58,24 @@ class SyncService { if (this.autoSyncInterval) return; this.autoSyncInterval = setInterval(() => { - if (this.state.isOnline && this.state.autoSyncEnabled && !this.state.isSyncing) { + if (this.state.isOnline && this.state.autoSyncEnabled && !this.state.isSyncing && this.isAuthenticated()) { this.autoSync(); } }, 5 * 60 * 1000) as unknown as number; } + private isAuthenticated(): boolean { + try { + return !!globalUserStatus?.isAuthenticated?.value; + } catch (e) { + console.error('Error checking authentication status:', e); + return false; + } + } + private async autoSync() { try { + await this.downloadFromCloud(); await this.uploadToCloud(); } catch (error) { console.warn('Auto-sync failed:', error); @@ -72,6 +84,7 @@ class SyncService { async initializeSyncStatus() { if (!this.state.isOnline) return; + if (!this.isAuthenticated()) return; try { const status = await userService.getSyncStatus(); @@ -91,6 +104,11 @@ class SyncService { return; } + if (!this.isAuthenticated()) { + console.log('User not authenticated - skipping startup sync restore'); + return; + } + try { await this.initializeSyncStatus(); @@ -101,63 +119,9 @@ class SyncService { console.log('Checking for startup sync restoration...'); - const cloudData = await userService.downloadFromCloud(); - if (!cloudData) { - console.log('No cloud data to restore'); - return; - } - - let restored = false; - - if (cloudData.favorites_data && Array.isArray(cloudData.favorites_data) && cloudData.favorites_data.length > 0) { - try { - const localFavorites = await invoke('get_favorite_clients'); - - if (localFavorites.length === 0 || - cloudData.favorites_data.some(fav => !localFavorites.includes(fav))) { - - console.log('Restoring favorites from cloud sync'); - - for (const clientId of localFavorites) { - await invoke('remove_favorite_client', { clientId }); - } - for (const clientId of cloudData.favorites_data) { - await invoke('add_favorite_client', { clientId }); - } - restored = true; - } - } catch (error) { - console.warn('Failed to restore favorites from cloud:', error); - } - } - - if (cloudData.accounts_data && Array.isArray(cloudData.accounts_data) && cloudData.accounts_data.length > 0) { - try { - const localAccounts = await invoke('get_accounts'); - const localUsernames = new Set(localAccounts.map((acc: any) => acc.username)); - - for (const cloudAccount of cloudData.accounts_data) { - if (cloudAccount.username && !localUsernames.has(cloudAccount.username)) { - console.log(`Restoring account ${cloudAccount.username} from cloud sync`); - try { - await invoke('add_account', { - username: cloudAccount.username, - tags: cloudAccount.tags || ['cloud-sync'] - }); - restored = true; - } catch (error) { - console.warn('Failed to restore cloud account:', cloudAccount.username, error); - } - } - } - } catch (error) { - console.warn('Failed to restore accounts from cloud:', error); - } - } + await this.downloadFromCloud(); - if (restored) { - console.log('Startup sync restoration completed'); - } + console.log('Startup sync restoration completed'); } catch (error) { console.warn('Failed to check/restore synced data on startup:', error); @@ -166,15 +130,15 @@ class SyncService { async uploadToCloud(): Promise { if (!this.state.isOnline || this.state.isSyncing) return false; + if (!this.isAuthenticated()) return false; this.state.isSyncing = true; this.notifyListeners(); try { - await new Promise(resolve => setTimeout(resolve, 1000)); - - const [settings, favorites, accounts] = await Promise.all([ - invoke('get_settings'), + await settingsService.loadSettings(); + const settings = settingsService.getSettings(); + const [favorites, accounts] = await Promise.all([ invoke('get_favorite_clients'), invoke('get_accounts') ]); @@ -207,28 +171,35 @@ class SyncService { async downloadFromCloud(): Promise { if (!this.state.isOnline || this.state.isSyncing) return false; + if (!this.isAuthenticated()) return false; this.state.isSyncing = true; this.notifyListeners(); try { - await new Promise(resolve => setTimeout(resolve, 1000)); - const cloudData = await userService.downloadFromCloud(); if (!cloudData) return false; if (cloudData.settings_data && Object.keys(cloudData.settings_data).length > 0) { - await invoke('save_settings', { input_settings: cloudData.settings_data }); + await settingsService.loadSettings(); + const currentSettings = settingsService.getSettings(); + const mergedSettings = { ...currentSettings, ...cloudData.settings_data }; + await settingsService.saveSettings(mergedSettings as any); } if (cloudData.favorites_data && Array.isArray(cloudData.favorites_data)) { - const currentFavorites = await invoke('get_favorite_clients'); - for (const clientId of currentFavorites) { - await invoke('remove_favorite_client', { clientId }); - } - for (const clientId of cloudData.favorites_data) { - await invoke('add_favorite_client', { clientId }); + try { + await invoke('set_all_favorites', { clientIds: cloudData.favorites_data }); + } catch (e) { + console.warn('Failed to set all favorites, falling back to loop', e); + const currentFavorites = await invoke('get_favorite_clients'); + for (const clientId of currentFavorites) { + await invoke('remove_favorite_client', { clientId }); + } + for (const clientId of cloudData.favorites_data) { + await invoke('add_favorite_client', { clientId }); + } } } @@ -281,6 +252,11 @@ class SyncService { return; } + if (!this.isAuthenticated()) { + addToast(t('toast.sync.login_required'), 'error'); + return; + } + if (!this.state.isOnline) { addToast(t('toast.sync.offline_error'), 'error'); return; @@ -293,6 +269,8 @@ class SyncService { try { addToast(t('toast.sync.syncing'), 'info'); + + await this.downloadFromCloud(); await this.uploadToCloud(); addToast(t('toast.sync.success'), 'success'); } catch (error) { diff --git a/src/services/themeService.ts b/src/services/themeService.ts index 563c7d9..fe9635d 100644 --- a/src/services/themeService.ts +++ b/src/services/themeService.ts @@ -66,46 +66,39 @@ export const cssVarList = [ const applyPreset = () => { const root = document.documentElement; + const settings = presetSettings as Record; + + const varMap: Record = { + primary: '--color-primary', + base100: '--color-base-100', + base200: '--color-base-200', + base300: '--color-base-300', + baseContent: '--color-base-content', + primaryContent: '--color-primary-content', + secondary: '--color-secondary', + secondaryContent: '--color-secondary-content', + accent: '--color-accent', + accentContent: '--color-accent-content', + neutral: '--color-neutral', + neutralContent: '--color-neutral-content', + info: '--color-info', + infoContent: '--color-info-content', + success: '--color-success', + successContent: '--color-success-content', + warning: '--color-warning', + warningContent: '--color-warning-content', + error: '--color-error', + errorContent: '--color-error-content' + }; - if (presetSettings.primary && presetSettings.primary.trim().length > 0) { - root.style.setProperty('--color-primary', presetSettings.primary); - } else { - root.style.removeProperty('--color-primary'); - } - - const setOrRemove = (varName: string, value?: string | null) => { - if (value && value.trim().length > 0) { - root.style.setProperty(varName, value); + Object.entries(varMap).forEach(([key, cssVar]) => { + const value = settings[key]; + if (value && typeof value === 'string' && value.trim().length > 0) { + root.style.setProperty(cssVar, value); } else { - root.style.removeProperty(varName); + root.style.removeProperty(cssVar); } - }; - - setOrRemove('--color-base-100', presetSettings.base100 ?? null); - setOrRemove('--color-base-200', presetSettings.base200 ?? null); - setOrRemove('--color-base-300', presetSettings.base300 ?? null); - setOrRemove('--color-base-content', presetSettings.baseContent ?? null); - - setOrRemove('--color-primary-content', presetSettings.primaryContent ?? null); - setOrRemove('--color-secondary', presetSettings.secondary ?? null); - setOrRemove('--color-secondary-content', presetSettings.secondaryContent ?? null); - setOrRemove('--color-accent', presetSettings.accent ?? null); - setOrRemove('--color-accent-content', presetSettings.accentContent ?? null); - - setOrRemove('--color-neutral', presetSettings.neutral ?? null); - setOrRemove('--color-neutral-content', presetSettings.neutralContent ?? null); - - setOrRemove('--color-info', presetSettings.info ?? null); - setOrRemove('--color-info-content', presetSettings.infoContent ?? null); - - setOrRemove('--color-success', presetSettings.success ?? null); - setOrRemove('--color-success-content', presetSettings.successContent ?? null); - - setOrRemove('--color-warning', presetSettings.warning ?? null); - setOrRemove('--color-warning-content', presetSettings.warningContent ?? null); - - setOrRemove('--color-error', presetSettings.error ?? null); - setOrRemove('--color-error-content', presetSettings.errorContent ?? null); + }); let styleEl = document.getElementById('custom-theme-styles'); if (!styleEl) { diff --git a/src/services/updaterService.ts b/src/services/updaterService.ts index c2d06f9..a99c0e6 100644 --- a/src/services/updaterService.ts +++ b/src/services/updaterService.ts @@ -66,7 +66,6 @@ class UpdaterService { const merged = deepMerge(existing, translations[locale]); i18n.global.setLocaleMessage(locale, merged); }); - console.log('Merged release translations into i18n:', Object.keys(translations)); } catch (e) { console.warn('Failed to merge release translations:', e); } @@ -147,8 +146,6 @@ class UpdaterService { this.checkInterval = window.setInterval(() => { this.checkForUpdates(false, t); }, this.CHECK_INTERVAL); - - console.log('Started periodic update checking'); } stopPeriodicCheck(): void { diff --git a/src/services/userService.ts b/src/services/userService.ts index 33ad551..ee8c90b 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -11,6 +11,7 @@ interface UserProfile { } interface UserInfo { + id: number; username: string; email: string; } @@ -97,9 +98,21 @@ class UserService { const cached = localStorage.getItem(CACHE_KEY); if (!cached) return null; - const parsedData: CachedUserData = JSON.parse(cached); + const parsedData: any = JSON.parse(cached); + + if (!parsedData || typeof parsedData !== 'object' || !parsedData.lastUpdated) { + localStorage.removeItem(CACHE_KEY); + return null; + } + const now = new Date(); const cacheTime = new Date(parsedData.lastUpdated); + + if (isNaN(cacheTime.getTime())) { + localStorage.removeItem(CACHE_KEY); + return null; + } + const hoursDiff = (now.getTime() - cacheTime.getTime()) / (1000 * 60 * 60); if (hoursDiff > CACHE_EXPIRY_HOURS) { @@ -107,7 +120,7 @@ class UserService { return null; } - return parsedData; + return parsedData as CachedUserData; } catch (error) { console.error('Error reading cached user data:', error); localStorage.removeItem(CACHE_KEY); @@ -151,34 +164,12 @@ class UserService { } } - async loadUserInfo(useCache: boolean = true): Promise<{ data: UserInfo | null; fromCache: boolean }> { - if (useCache) { - const cached = this.getCachedData(); - if (cached?.info) { - console.log('Returning cached user info'); - return { data: cached.info, fromCache: true }; - } - } - - try { - console.log('Fetching user info from server...'); - const info = await apiClient.get('/auth/user/'); - - this.setCachedData({ info }); - console.log('User info loaded and cached'); - return { data: info, fromCache: false }; - } catch (error) { - console.error('Failed to load user info:', error); - return { data: null, fromCache: false }; - } - } - async updateUserProfile(nickname: string): Promise<{ success: boolean; error?: string }> { try { const updatedProfile = await apiClient.patch('/auth/profile/', { nickname }); - this.setCachedData({ profile: updatedProfile }); + apiClient.invalidateProfileCaches(); console.log('User profile updated successfully'); return { success: true }; @@ -194,15 +185,12 @@ class UserService { const form = new FormData(); form.append('avatar', file); - const resp = await apiClient.post('/auth/profile/avatar/', form, { - headers: { - 'Content-Type': 'multipart/form-data' - } - }); + const resp = await apiClient.post('/auth/profile/avatar/', form); const profile = (resp as any).profile as UserProfile; if (profile) { this.setCachedData({ profile }); + apiClient.invalidateProfileCaches(); } return { success: true, profile }; } catch (error: any) { @@ -217,6 +205,7 @@ class UserService { const profile = (resp as any).profile as UserProfile; if (profile) { this.setCachedData({ profile }); + apiClient.invalidateProfileCaches(); } return { success: true, profile }; } catch (error: any) { @@ -225,30 +214,6 @@ class UserService { } } - async syncDataToCloud(data: SyncData): Promise<{ success: boolean; error?: string }> { - try { - await apiClient.post('/auth/sync/', data); - console.log('Data synced to cloud successfully'); - return { success: true }; - } catch (error: any) { - console.error('Failed to sync data to cloud:', error); - const errorMessage = error.response?.data?.error || 'Failed to sync data'; - return { success: false, error: errorMessage }; - } - } - - async loadDataFromCloud(): Promise<{ data: any | null; error?: string }> { - try { - const data = await apiClient.get('/auth/sync/'); - console.log('Data loaded from cloud successfully'); - return { data }; - } catch (error: any) { - console.error('Failed to load data from cloud:', error); - const errorMessage = error.response?.data?.error || 'Failed to load data'; - return { data: null, error: errorMessage }; - } - } - async getSyncStatus(): Promise { try { const status = await apiClient.get('/auth/sync/status/'); @@ -447,7 +412,7 @@ class UserService { async downloadFromCloud(): Promise { try { const response = await apiClient.get('/auth/profile/'); - return response.data; + return response; } catch (error) { console.error('Failed to download from cloud:', error); throw error; @@ -459,10 +424,12 @@ class UserService { try { const response = await apiClient.post('/auth/sync/', data); + const profile = response.data || response; const cachedData = this.getCachedData(); - this.setCachedData({ profile: response.data.data, info: cachedData?.info || null }); + this.setCachedData({ profile: profile, info: cachedData?.info || null }); + apiClient.invalidateProfileCaches(); - return response.data.data; + return profile; } catch (error) { console.error('Failed to sync to cloud:', error); throw error; diff --git a/src/types/presets.ts b/src/types/presets.ts index 02892a5..f068bb4 100644 --- a/src/types/presets.ts +++ b/src/types/presets.ts @@ -2,60 +2,60 @@ export interface ThemePreset { id: string; name: string; description?: string; - created_at: string; - custom_css: string; - enable_custom_css: boolean; + createdAt: string; + customCSS: string; + enableCustomCSS: boolean; primary?: string; base100?: string; base200?: string; base300?: string; - base_content?: string; + baseContent?: string; - primary_content?: string; + primaryContent?: string; secondary?: string; - secondary_content?: string; + secondaryContent?: string; accent?: string; - accent_content?: string; + accentContent?: string; neutral?: string; - neutral_content?: string; + neutralContent?: string; info?: string; - info_content?: string; + infoContent?: string; success?: string; - success_content?: string; + successContent?: string; warning?: string; - warning_content?: string; + warningContent?: string; error?: string; - error_content?: string; + errorContent?: string; } export interface CreatePresetInput { name: string; description?: string; - custom_css: string; - enable_custom_css: boolean; + customCSS: string; + enableCustomCSS: boolean; base100?: string; base200?: string; base300?: string; - base_content?: string; + baseContent?: string; primary?: string; - primary_content?: string; + primaryContent?: string; secondary?: string; - secondary_content?: string; + secondaryContent?: string; accent?: string; - accent_content?: string; + accentContent?: string; neutral?: string; - neutral_content?: string; + neutralContent?: string; info?: string; - info_content?: string; + infoContent?: string; success?: string; - success_content?: string; + successContent?: string; warning?: string; - warning_content?: string; + warningContent?: string; error?: string; - error_content?: string; + errorContent?: string; } export interface UpdatePresetInput extends CreatePresetInput { diff --git a/src/utils/events.ts b/src/utils/events.ts index 5cd25f0..4d96c5b 100644 --- a/src/utils/events.ts +++ b/src/utils/events.ts @@ -1,7 +1,3 @@ -/** - * Event detection utilities for seasonal events - */ - export interface EventInfo { isActive: boolean; name: string; @@ -9,9 +5,7 @@ export interface EventInfo { theme?: string; } -/** - * Check if current date is within Halloween event period (Oct 25 - Nov 5) - */ + export function isHalloweenEvent(): boolean { const now = new Date(); const month = now.getMonth(); @@ -28,9 +22,6 @@ export function isHalloweenEvent(): boolean { return false; } -/** - * Get current active event information - */ export function getCurrentEvent(): EventInfo | null { if (isHalloweenEvent()) { return { @@ -44,9 +35,6 @@ export function getCurrentEvent(): EventInfo | null { return null; } -/** - * Get event-specific greeting message - */ export function getEventGreeting(): string | null { const event = getCurrentEvent(); @@ -64,7 +52,7 @@ export function getEventGreeting(): string | null { } -export function applyCursorForEvent(): void { +export async function applyCursorForEvent(): Promise { const event = getCurrentEvent(); if (event?.name === 'halloween') { diff --git a/src/utils/settings.ts b/src/utils/settings.ts index a34a47b..4acf4cb 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -1,6 +1,6 @@ -import { invoke } from '@tauri-apps/api/core'; import { changeLanguage } from '../i18n'; import { themeService } from '../services/themeService'; +import { settingsService } from '../services/settingsService'; export interface Setting { description: string; @@ -15,7 +15,8 @@ export interface AppSettings { export const fetchSettings = async (): Promise => { try { - return await invoke('get_settings'); + await settingsService.loadSettings(); + return settingsService.getSettings() as unknown as AppSettings; } catch (err) { console.warn('fetchSettings failed', err); return null; diff --git a/src/views/About.vue b/src/views/About.vue index fea1035..d657581 100644 --- a/src/views/About.vue +++ b/src/views/About.vue @@ -10,8 +10,13 @@ import Logo from '../assets/images/logo.svg'; import IconGitHub from '../assets/icons/github.svg'; import IconTelegram from '../assets/icons/telegram.svg'; import IconDiscord from '../assets/icons/discord.svg'; +import { CircleFadingArrowUp } from 'lucide-vue-next'; const { t } = useI18n(); +const LogoUrl = String(Logo); +const IconGitHubUrl = String(IconGitHub); +const IconTelegramUrl = String(IconTelegram); +const IconDiscordUrl = String(IconDiscord); const { addToast } = useToast(); const halloweenActive = ref(isHalloweenEvent()); const halloweenGreeting = ref(getEventGreeting() ?? undefined); @@ -84,12 +89,13 @@ onMounted(async () => {