* test * test2 * Fixed selecting drive * working transfer manual +drives * fixed transfer + % button display * Add file transfer speed, ETA, and cancel confirmation, switch from powershell to CMD move * Adds pause/resume controls for game file transfers Introduces handlers and UI elements to allow users to pause and resume game file transfers, improving control over transfer processes. Updates transfer error messages for greater user clarity. Enhances UX by making transfer states and errors more descriptive and actionable. * new branch * fix 1.0 * Delete Future-updates.md * fix 1.1 * BYTE-BASED progress * working on faster moving * steam alike moving files * feat: add native C++ move engine for game transfers * removed pause + resume * moving + cancel 1.0 * commit 1 * removed extra file + res/ps buttons * finall * final 1.1 * final 1.2 * Added `transfer-game-completed` * fixed cancel window * Fixed ETA format * Changing CSS * Done !! * fixed better cancel algorythm * deleted extra files + increased performance * added back transfer % button in the panel * fix security check + if game is in the same directory * fixing annotations * fixed security checks 2.0 * Fix line endings * fix prettier formatting * chore: remove unused Visual Studio files and update translation strings for file transfer feature - Deleted various Visual Studio workspace and project files to clean up the repository. - Updated translation files for English, Spanish, Portuguese, and Russian to include new keys related to the file transfer feature, enhancing user experience. - Enabled logging interceptor in the Hydra API for improved network logging. - Refactored logging in the process watcher to include detailed playtime trace logs for better tracking of game sessions. * chore: update husky scripts and clean up code formatting - Changed husky pre-commit and pre-push scripts to use 'yarn run' for consistency. - Updated the process-watcher to streamline the trackGamePlaytime function call. - Improved code formatting in the GeneralSettingsSection component for better readability. * refactor: improve drive querying and error handling in getAvailableDrives - Refactored the getAvailableDrives function to enhance readability and maintainability. - Introduced helper functions for PowerShell path retrieval and drive CSV parsing. - Replaced console logging with appropriate error handling and logging. - Updated the GeneralSettingsSection component to improve error handling and drive selection logic. - Added a new error display in the UI for better user feedback on drive selection issues. * refactor: enhance drive querying and UI components for game options - Updated drive querying logic to support both Windows and Linux platforms. - Replaced synchronous PowerShell calls with asynchronous file system operations for improved performance. - Added a new "locations" category in the game options modal with relevant UI components. - Enhanced the GeneralSettingsSection to conditionally render sections based on props, improving flexibility. - Updated styles for selected options in the game options modal for better visual feedback. * chore: clean up game options modal - Cleaned up the GeneralSettingsSection in the game options modal for improved readability and maintainability. * chore: remove move_engine.cc - Deleted the move_engine.cc file as part of project cleanup. * refactor: enhance game file transfer validation and update logic - Improved path normalization for game root and target root to ensure consistent comparisons across platforms. - Added checks for existing destination folders and enhanced error handling during database updates after file transfers. - Updated the game options initial category to "locations" in the hero panel actions for better user experience. * chore: format error handling in game file transfer - Reformatted the error handling code in transfer-game-files.ts for improved readability. * chore: update translation strings and remove TO-DO file - Added new translation keys for transfer-related messages in English, Spanish, Portuguese, and Russian to enhance user experience. - Removed the obsolete TO-DO.md file as it is no longer needed. - Updated subproject commit to indicate a dirty state. * fix: streamline error handling in game options modal - Simplified the error handling logic in the GameOptionsModal by removing unnecessary line breaks for better readability. --------- Co-authored-by: yassine <166349232+yassine808@users.noreply.github.com> Co-authored-by: Helio Kroger <me@heliokroger.com>
@@ -1,57 +1,57 @@
|
||||
# Hydra Project Rules
|
||||
|
||||
## Logging
|
||||
|
||||
- **Always use `logger` instead of `console` for logging** in both main and renderer processes
|
||||
- In main process: `import { logger } from "@main/services";`
|
||||
- In renderer process: `import { logger } from "@renderer/logger";`
|
||||
- Replace all instances of:
|
||||
- `console.log()` → `logger.log()`
|
||||
- `console.error()` → `logger.error()`
|
||||
- `console.warn()` → `logger.warn()`
|
||||
- `console.info()` → `logger.info()`
|
||||
- `console.debug()` → `logger.debug()`
|
||||
- Do not use `console` for any logging purposes
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
- All user-facing strings must be translated using i18next
|
||||
- Use the `useTranslation` hook in React components: `const { t } = useTranslation("namespace");`
|
||||
- Add new translation keys to `src/locales/en/translation.json`
|
||||
- Never hardcode English strings in the UI code
|
||||
- Placeholder text in form fields must also be translated
|
||||
|
||||
## Code Style
|
||||
|
||||
- Use ESLint and Prettier for code formatting
|
||||
- Follow TypeScript strict mode conventions
|
||||
- Use async/await instead of promises when possible
|
||||
- Prefer named exports over default exports for utilities and services
|
||||
|
||||
## ESLint Issues
|
||||
|
||||
- **Always try to fix ESLint errors properly before disabling rules**
|
||||
- When encountering ESLint errors, explore these solutions in order:
|
||||
1. **Fix the code to comply with the rule** (e.g., add missing required elements, fix accessibility issues)
|
||||
2. **Use minimal markup to satisfy the rule** (e.g., add empty `<track>` elements for videos without captions, add `role` attributes)
|
||||
3. **Only disable the rule as a last resort** when no reasonable solution exists
|
||||
- When disabling a rule, always include a comment explaining why it's necessary
|
||||
- Examples of proper fixes:
|
||||
- For `jsx-a11y/media-has-caption`: Add `<track kind="captions" />` even if no captions are available
|
||||
- For `jsx-a11y/alt-text`: Add meaningful alt text or `alt=""` for decorative images
|
||||
- For accessibility rules: Add appropriate ARIA attributes rather than disabling
|
||||
|
||||
## TypeScript Array Syntax
|
||||
|
||||
- **Always use `T[]` syntax instead of `Array<T>`** for array types
|
||||
- Prefer: `string[]`, `number[]`, `MyType[]`
|
||||
- Avoid: `Array<string>`, `Array<number>`, `Array<MyType>`
|
||||
- This applies to all type annotations, type assertions, and generic type parameters
|
||||
|
||||
## Comments
|
||||
|
||||
- Keep comments concise and purposeful; avoid verbose explanations.
|
||||
- Focus on the "why" or non-obvious context, not restating the code.
|
||||
- Prefer self-explanatory naming and structure over excessive comments.
|
||||
- Do not comment every line or obvious behavior; remove stale comments.
|
||||
- Use docblocks only where they add value (public APIs, complex logic).
|
||||
# Hydra Project Rules
|
||||
|
||||
## Logging
|
||||
|
||||
- **Always use `logger` instead of `console` for logging** in both main and renderer processes
|
||||
- In main process: `import { logger } from "@main/services";`
|
||||
- In renderer process: `import { logger } from "@renderer/logger";`
|
||||
- Replace all instances of:
|
||||
- `console.log()` → `logger.log()`
|
||||
- `console.error()` → `logger.error()`
|
||||
- `console.warn()` → `logger.warn()`
|
||||
- `console.info()` → `logger.info()`
|
||||
- `console.debug()` → `logger.debug()`
|
||||
- Do not use `console` for any logging purposes
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
- All user-facing strings must be translated using i18next
|
||||
- Use the `useTranslation` hook in React components: `const { t } = useTranslation("namespace");`
|
||||
- Add new translation keys to `src/locales/en/translation.json`
|
||||
- Never hardcode English strings in the UI code
|
||||
- Placeholder text in form fields must also be translated
|
||||
|
||||
## Code Style
|
||||
|
||||
- Use ESLint and Prettier for code formatting
|
||||
- Follow TypeScript strict mode conventions
|
||||
- Use async/await instead of promises when possible
|
||||
- Prefer named exports over default exports for utilities and services
|
||||
|
||||
## ESLint Issues
|
||||
|
||||
- **Always try to fix ESLint errors properly before disabling rules**
|
||||
- When encountering ESLint errors, explore these solutions in order:
|
||||
1. **Fix the code to comply with the rule** (e.g., add missing required elements, fix accessibility issues)
|
||||
2. **Use minimal markup to satisfy the rule** (e.g., add empty `<track>` elements for videos without captions, add `role` attributes)
|
||||
3. **Only disable the rule as a last resort** when no reasonable solution exists
|
||||
- When disabling a rule, always include a comment explaining why it's necessary
|
||||
- Examples of proper fixes:
|
||||
- For `jsx-a11y/media-has-caption`: Add `<track kind="captions" />` even if no captions are available
|
||||
- For `jsx-a11y/alt-text`: Add meaningful alt text or `alt=""` for decorative images
|
||||
- For accessibility rules: Add appropriate ARIA attributes rather than disabling
|
||||
|
||||
## TypeScript Array Syntax
|
||||
|
||||
- **Always use `T[]` syntax instead of `Array<T>`** for array types
|
||||
- Prefer: `string[]`, `number[]`, `MyType[]`
|
||||
- Avoid: `Array<string>`, `Array<number>`, `Array<MyType>`
|
||||
- This applies to all type annotations, type assertions, and generic type parameters
|
||||
|
||||
## Comments
|
||||
|
||||
- Keep comments concise and purposeful; avoid verbose explanations.
|
||||
- Focus on the "why" or non-obvious context, not restating the code.
|
||||
- Prefer self-explanatory naming and structure over excessive comments.
|
||||
- Do not comment every line or obvious behavior; remove stale comments.
|
||||
- Use docblocks only where they add value (public APIs, complex logic).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
MAIN_VITE_API_URL=
|
||||
MAIN_VITE_AUTH_URL=
|
||||
MAIN_VITE_WS_URL=
|
||||
MAIN_VITE_NIMBUS_API_URL=
|
||||
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
|
||||
RENDERER_VITE_TORBOX_REFERRAL_CODE=
|
||||
MAIN_VITE_LAUNCHER_SUBDOMAIN=
|
||||
MAIN_VITE_API_URL=
|
||||
MAIN_VITE_AUTH_URL=
|
||||
MAIN_VITE_WS_URL=
|
||||
MAIN_VITE_NIMBUS_API_URL=
|
||||
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
|
||||
RENDERER_VITE_TORBOX_REFERRAL_CODE=
|
||||
MAIN_VITE_LAUNCHER_SUBDOMAIN=
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.gitignore
|
||||
migration.stub
|
||||
hydra-python-rpc/
|
||||
hydra-native/
|
||||
src/main/generated/
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.gitignore
|
||||
migration.stub
|
||||
hydra-python-rpc/
|
||||
hydra-native/
|
||||
src/main/generated/
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
.vscode/
|
||||
node_modules/
|
||||
__pycache__
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
*.log*
|
||||
.env
|
||||
.vite
|
||||
ludusavi/**
|
||||
!ludusavi/config.yaml
|
||||
hydra-python-rpc/
|
||||
/hydra-native/
|
||||
native/hydra-native/target/
|
||||
.python-version
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
||||
*storybook.log
|
||||
.vscode/
|
||||
node_modules/
|
||||
__pycache__
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
*.log*
|
||||
.env
|
||||
.vite
|
||||
ludusavi/**
|
||||
!ludusavi/config.yaml
|
||||
hydra-python-rpc/
|
||||
/hydra-native/
|
||||
native/hydra-native/target/
|
||||
.python-version
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
||||
*storybook.log
|
||||
Future-updates.md
|
||||
TO-DO.md
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[submodule "proto"]
|
||||
path = proto
|
||||
url = https://github.com/hydralauncher/hydra-protos.git
|
||||
[submodule "proto"]
|
||||
path = proto
|
||||
url = https://github.com/hydralauncher/hydra-protos.git
|
||||
|
||||
@@ -1 +1 @@
|
||||
yarn format
|
||||
yarn run format
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
yarn lint
|
||||
yarn typecheck
|
||||
yarn run lint
|
||||
yarn run typecheck
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
out
|
||||
dist
|
||||
seeds
|
||||
pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
out
|
||||
dist
|
||||
seeds
|
||||
pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Los Broxas
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Los Broxas
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
!macro customUnInstall
|
||||
${ifNot} ${isUpdated}
|
||||
RMDir /r "$LOCALAPPDATA\hydralauncher-updater"
|
||||
${endIf}
|
||||
!macroend
|
||||
!macro customUnInstall
|
||||
${ifNot} ${isUpdated}
|
||||
RMDir /r "$LOCALAPPDATA\hydralauncher-updater"
|
||||
${endIf}
|
||||
!macroend
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
[package]
|
||||
name = "hydra-native"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
image = { version = "0.25.8", default-features = false, features = ["gif", "jpeg", "png", "webp"] }
|
||||
mime_guess = "2.0.5"
|
||||
napi = { version = "3.5.2", default-features = false, features = ["napi8", "tokio_rt"] }
|
||||
napi-derive = "3.3.2"
|
||||
sysinfo = "0.37.2"
|
||||
uuid = { version = "1.11.0", features = ["v4"] }
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.3.1"
|
||||
[package]
|
||||
name = "hydra-native"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
image = { version = "0.25.8", default-features = false, features = ["gif", "jpeg", "png", "webp"] }
|
||||
mime_guess = "2.0.5"
|
||||
napi = { version = "3.5.2", default-features = false, features = ["napi8", "tokio_rt"] }
|
||||
napi-derive = "3.3.2"
|
||||
sysinfo = "0.37.2"
|
||||
uuid = { version = "1.11.0", features = ["v4"] }
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.3.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed=src/lib.rs");
|
||||
}
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed=src/lib.rs");
|
||||
}
|
||||
|
||||
@@ -1,224 +1,224 @@
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{cmp::Ordering, collections::HashMap};
|
||||
|
||||
use image::codecs::gif::GifDecoder;
|
||||
use image::codecs::png::PngDecoder;
|
||||
use image::codecs::webp::WebPDecoder;
|
||||
use image::{AnimationDecoder, ImageFormat, ImageReader};
|
||||
use napi::bindgen_prelude::Error;
|
||||
use napi_derive::napi;
|
||||
use sysinfo::{ProcessesToUpdate, System};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[napi(object)]
|
||||
pub struct ProcessedImageData {
|
||||
pub image_path: String,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct NativeProcessPayload {
|
||||
pub exe: Option<String>,
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub environ: Option<HashMap<String, String>>,
|
||||
pub cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn process_profile_image(
|
||||
image_path: String,
|
||||
target_extension: Option<String>,
|
||||
) -> napi::Result<ProcessedImageData> {
|
||||
let input_path = PathBuf::from(image_path);
|
||||
|
||||
if !input_path.exists() {
|
||||
return Err(Error::from_reason("Image file not found"));
|
||||
}
|
||||
|
||||
let format = detect_image_format(&input_path)?;
|
||||
let animated = is_animated_image(&input_path, format)?;
|
||||
|
||||
if !animated {
|
||||
return Ok(ProcessedImageData {
|
||||
image_path: input_path.to_string_lossy().to_string(),
|
||||
mime_type: mime_type_from_format_or_path(format, &input_path),
|
||||
});
|
||||
}
|
||||
|
||||
let extension = target_extension
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
.unwrap_or_else(|| "webp".to_string());
|
||||
|
||||
let output_format = output_format_from_extension(&extension)?;
|
||||
let output_path = build_temp_output_path(&extension);
|
||||
|
||||
let image = ImageReader::open(&input_path)
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?
|
||||
.with_guessed_format()
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?
|
||||
.decode()
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
image
|
||||
.save_with_format(&output_path, output_format)
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
Ok(ProcessedImageData {
|
||||
image_path: output_path.to_string_lossy().to_string(),
|
||||
mime_type: mime_type_from_format_or_path(Some(output_format), &output_path),
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn list_processes() -> Vec<NativeProcessPayload> {
|
||||
let mut system = System::new_all();
|
||||
system.refresh_processes(ProcessesToUpdate::All, true);
|
||||
|
||||
let mut processes: Vec<NativeProcessPayload> = system
|
||||
.processes()
|
||||
.values()
|
||||
.map(|process| {
|
||||
let include_linux_extras = !cfg!(target_os = "windows");
|
||||
|
||||
NativeProcessPayload {
|
||||
exe: process
|
||||
.exe()
|
||||
.map(|value| value.to_string_lossy().to_string()),
|
||||
pid: process.pid().as_u32(),
|
||||
name: process.name().to_string_lossy().to_string(),
|
||||
cwd: if include_linux_extras {
|
||||
process
|
||||
.cwd()
|
||||
.map(|value| value.to_string_lossy().to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
environ: if include_linux_extras {
|
||||
let env_map: HashMap<String, String> = process
|
||||
.environ()
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let entry_value = entry.to_string_lossy();
|
||||
entry_value.split_once('=').and_then(|(key, value)| {
|
||||
if key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((key.to_string(), value.to_string()))
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if env_map.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(env_map)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
processes.sort_by(|left, right| {
|
||||
let by_pid = left.pid.cmp(&right.pid);
|
||||
if by_pid == Ordering::Equal {
|
||||
left.name.cmp(&right.name)
|
||||
} else {
|
||||
by_pid
|
||||
}
|
||||
});
|
||||
|
||||
processes
|
||||
}
|
||||
|
||||
fn detect_image_format(path: &Path) -> napi::Result<Option<ImageFormat>> {
|
||||
let reader = ImageReader::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
let guessed = reader
|
||||
.with_guessed_format()
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
Ok(guessed.format())
|
||||
}
|
||||
|
||||
fn is_animated_image(path: &Path, format: Option<ImageFormat>) -> napi::Result<bool> {
|
||||
match format {
|
||||
Some(ImageFormat::Gif) => is_gif_animated(path),
|
||||
Some(ImageFormat::WebP) => is_webp_animated(path),
|
||||
Some(ImageFormat::Png) => is_apng(path),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_gif_animated(path: &Path) -> napi::Result<bool> {
|
||||
let file = File::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
let decoder =
|
||||
GifDecoder::new(BufReader::new(file)).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
let mut frames = decoder.into_frames();
|
||||
let _ = frames.next().transpose();
|
||||
Ok(matches!(frames.next().transpose(), Ok(Some(_))))
|
||||
}
|
||||
|
||||
fn is_webp_animated(path: &Path) -> napi::Result<bool> {
|
||||
let file = File::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
let decoder = WebPDecoder::new(BufReader::new(file))
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
Ok(decoder.has_animation())
|
||||
}
|
||||
|
||||
fn is_apng(path: &Path) -> napi::Result<bool> {
|
||||
let file = File::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
let decoder =
|
||||
PngDecoder::new(BufReader::new(file)).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
decoder
|
||||
.is_apng()
|
||||
.map_err(|err| Error::from_reason(err.to_string()))
|
||||
}
|
||||
|
||||
fn output_format_from_extension(extension: &str) -> napi::Result<ImageFormat> {
|
||||
match extension {
|
||||
"png" => Ok(ImageFormat::Png),
|
||||
"jpg" | "jpeg" => Ok(ImageFormat::Jpeg),
|
||||
"webp" => Ok(ImageFormat::WebP),
|
||||
_ => Err(Error::from_reason("Unsupported target extension")),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_temp_output_path(extension: &str) -> PathBuf {
|
||||
let mut output_path = std::env::temp_dir();
|
||||
output_path.push(format!("{}.{}", Uuid::new_v4(), extension));
|
||||
output_path
|
||||
}
|
||||
|
||||
fn mime_type_from_format_or_path(format: Option<ImageFormat>, path: &Path) -> String {
|
||||
if let Some(value) = mime_type_from_image_format(format) {
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
.essence_str()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn mime_type_from_image_format(format: Option<ImageFormat>) -> Option<&'static str> {
|
||||
match format {
|
||||
Some(ImageFormat::Png) => Some("image/png"),
|
||||
Some(ImageFormat::Jpeg) => Some("image/jpeg"),
|
||||
Some(ImageFormat::Gif) => Some("image/gif"),
|
||||
Some(ImageFormat::WebP) => Some("image/webp"),
|
||||
Some(ImageFormat::Bmp) => Some("image/bmp"),
|
||||
Some(ImageFormat::Ico) => Some("image/x-icon"),
|
||||
Some(ImageFormat::Tiff) => Some("image/tiff"),
|
||||
Some(ImageFormat::Avif) => Some("image/avif"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{cmp::Ordering, collections::HashMap};
|
||||
|
||||
use image::codecs::gif::GifDecoder;
|
||||
use image::codecs::png::PngDecoder;
|
||||
use image::codecs::webp::WebPDecoder;
|
||||
use image::{AnimationDecoder, ImageFormat, ImageReader};
|
||||
use napi::bindgen_prelude::Error;
|
||||
use napi_derive::napi;
|
||||
use sysinfo::{ProcessesToUpdate, System};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[napi(object)]
|
||||
pub struct ProcessedImageData {
|
||||
pub image_path: String,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct NativeProcessPayload {
|
||||
pub exe: Option<String>,
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub environ: Option<HashMap<String, String>>,
|
||||
pub cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn process_profile_image(
|
||||
image_path: String,
|
||||
target_extension: Option<String>,
|
||||
) -> napi::Result<ProcessedImageData> {
|
||||
let input_path = PathBuf::from(image_path);
|
||||
|
||||
if !input_path.exists() {
|
||||
return Err(Error::from_reason("Image file not found"));
|
||||
}
|
||||
|
||||
let format = detect_image_format(&input_path)?;
|
||||
let animated = is_animated_image(&input_path, format)?;
|
||||
|
||||
if !animated {
|
||||
return Ok(ProcessedImageData {
|
||||
image_path: input_path.to_string_lossy().to_string(),
|
||||
mime_type: mime_type_from_format_or_path(format, &input_path),
|
||||
});
|
||||
}
|
||||
|
||||
let extension = target_extension
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
.unwrap_or_else(|| "webp".to_string());
|
||||
|
||||
let output_format = output_format_from_extension(&extension)?;
|
||||
let output_path = build_temp_output_path(&extension);
|
||||
|
||||
let image = ImageReader::open(&input_path)
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?
|
||||
.with_guessed_format()
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?
|
||||
.decode()
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
image
|
||||
.save_with_format(&output_path, output_format)
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
Ok(ProcessedImageData {
|
||||
image_path: output_path.to_string_lossy().to_string(),
|
||||
mime_type: mime_type_from_format_or_path(Some(output_format), &output_path),
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn list_processes() -> Vec<NativeProcessPayload> {
|
||||
let mut system = System::new_all();
|
||||
system.refresh_processes(ProcessesToUpdate::All, true);
|
||||
|
||||
let mut processes: Vec<NativeProcessPayload> = system
|
||||
.processes()
|
||||
.values()
|
||||
.map(|process| {
|
||||
let include_linux_extras = !cfg!(target_os = "windows");
|
||||
|
||||
NativeProcessPayload {
|
||||
exe: process
|
||||
.exe()
|
||||
.map(|value| value.to_string_lossy().to_string()),
|
||||
pid: process.pid().as_u32(),
|
||||
name: process.name().to_string_lossy().to_string(),
|
||||
cwd: if include_linux_extras {
|
||||
process
|
||||
.cwd()
|
||||
.map(|value| value.to_string_lossy().to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
environ: if include_linux_extras {
|
||||
let env_map: HashMap<String, String> = process
|
||||
.environ()
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let entry_value = entry.to_string_lossy();
|
||||
entry_value.split_once('=').and_then(|(key, value)| {
|
||||
if key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((key.to_string(), value.to_string()))
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if env_map.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(env_map)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
processes.sort_by(|left, right| {
|
||||
let by_pid = left.pid.cmp(&right.pid);
|
||||
if by_pid == Ordering::Equal {
|
||||
left.name.cmp(&right.name)
|
||||
} else {
|
||||
by_pid
|
||||
}
|
||||
});
|
||||
|
||||
processes
|
||||
}
|
||||
|
||||
fn detect_image_format(path: &Path) -> napi::Result<Option<ImageFormat>> {
|
||||
let reader = ImageReader::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
let guessed = reader
|
||||
.with_guessed_format()
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
Ok(guessed.format())
|
||||
}
|
||||
|
||||
fn is_animated_image(path: &Path, format: Option<ImageFormat>) -> napi::Result<bool> {
|
||||
match format {
|
||||
Some(ImageFormat::Gif) => is_gif_animated(path),
|
||||
Some(ImageFormat::WebP) => is_webp_animated(path),
|
||||
Some(ImageFormat::Png) => is_apng(path),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_gif_animated(path: &Path) -> napi::Result<bool> {
|
||||
let file = File::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
let decoder =
|
||||
GifDecoder::new(BufReader::new(file)).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
let mut frames = decoder.into_frames();
|
||||
let _ = frames.next().transpose();
|
||||
Ok(matches!(frames.next().transpose(), Ok(Some(_))))
|
||||
}
|
||||
|
||||
fn is_webp_animated(path: &Path) -> napi::Result<bool> {
|
||||
let file = File::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
let decoder = WebPDecoder::new(BufReader::new(file))
|
||||
.map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
Ok(decoder.has_animation())
|
||||
}
|
||||
|
||||
fn is_apng(path: &Path) -> napi::Result<bool> {
|
||||
let file = File::open(path).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
let decoder =
|
||||
PngDecoder::new(BufReader::new(file)).map_err(|err| Error::from_reason(err.to_string()))?;
|
||||
|
||||
decoder
|
||||
.is_apng()
|
||||
.map_err(|err| Error::from_reason(err.to_string()))
|
||||
}
|
||||
|
||||
fn output_format_from_extension(extension: &str) -> napi::Result<ImageFormat> {
|
||||
match extension {
|
||||
"png" => Ok(ImageFormat::Png),
|
||||
"jpg" | "jpeg" => Ok(ImageFormat::Jpeg),
|
||||
"webp" => Ok(ImageFormat::WebP),
|
||||
_ => Err(Error::from_reason("Unsupported target extension")),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_temp_output_path(extension: &str) -> PathBuf {
|
||||
let mut output_path = std::env::temp_dir();
|
||||
output_path.push(format!("{}.{}", Uuid::new_v4(), extension));
|
||||
output_path
|
||||
}
|
||||
|
||||
fn mime_type_from_format_or_path(format: Option<ImageFormat>, path: &Path) -> String {
|
||||
if let Some(value) = mime_type_from_image_format(format) {
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
.essence_str()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn mime_type_from_image_format(format: Option<ImageFormat>) -> Option<&'static str> {
|
||||
match format {
|
||||
Some(ImageFormat::Png) => Some("image/png"),
|
||||
Some(ImageFormat::Jpeg) => Some("image/jpeg"),
|
||||
Some(ImageFormat::Gif) => Some("image/gif"),
|
||||
Some(ImageFormat::WebP) => Some("image/webp"),
|
||||
Some(ImageFormat::Bmp) => Some("image/bmp"),
|
||||
Some(ImageFormat::Ico) => Some("image/x-icon"),
|
||||
Some(ImageFormat::Tiff) => Some("image/tiff"),
|
||||
Some(ImageFormat::Avif) => Some("image/avif"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"crc": "^4.3.2",
|
||||
"create-desktop-shortcuts": "^1.11.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"detect-drives": "^1.3.0",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
@@ -74,6 +75,7 @@
|
||||
"lodash-es": "^4.18.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"node-7z": "^3.0.0",
|
||||
"node-disk-info": "^1.3.0",
|
||||
"parse-torrent": "^11.0.18",
|
||||
"png-to-ico": "^3.0.1",
|
||||
"rc-virtual-list": "^3.18.3",
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from cx_Freeze import Executable, setup
|
||||
|
||||
|
||||
def get_windows_openssl_includes():
|
||||
if sys.platform != "win32":
|
||||
return []
|
||||
|
||||
dll_dir = os.path.join(sys.base_prefix, "DLLs")
|
||||
source_by_target = {
|
||||
"libcrypto-1_1.dll": "libcrypto-1_1.dll",
|
||||
"libcrypto-1_1-x64.dll": "libcrypto-1_1.dll",
|
||||
"libssl-1_1.dll": "libssl-1_1.dll",
|
||||
"libssl-1_1-x64.dll": "libssl-1_1.dll",
|
||||
}
|
||||
|
||||
include_files = []
|
||||
for target_name, source_name in source_by_target.items():
|
||||
source_path = os.path.join(dll_dir, source_name)
|
||||
if os.path.exists(source_path):
|
||||
include_files.append((source_path, os.path.join("lib", target_name)))
|
||||
|
||||
return include_files
|
||||
|
||||
|
||||
build_exe_options = {
|
||||
"packages": ["libtorrent"],
|
||||
"build_exe": "hydra-python-rpc",
|
||||
"include_msvcr": True,
|
||||
"include_files": get_windows_openssl_includes(),
|
||||
}
|
||||
|
||||
setup(
|
||||
name="hydra-python-rpc",
|
||||
version="0.1",
|
||||
description="Hydra",
|
||||
options={"build_exe": build_exe_options},
|
||||
executables=[
|
||||
Executable(
|
||||
"python_rpc/main.py",
|
||||
target_name="hydra-python-rpc",
|
||||
icon="build/icon.ico",
|
||||
)
|
||||
],
|
||||
)
|
||||
import os
|
||||
import sys
|
||||
|
||||
from cx_Freeze import Executable, setup
|
||||
|
||||
|
||||
def get_windows_openssl_includes():
|
||||
if sys.platform != "win32":
|
||||
return []
|
||||
|
||||
dll_dir = os.path.join(sys.base_prefix, "DLLs")
|
||||
source_by_target = {
|
||||
"libcrypto-1_1.dll": "libcrypto-1_1.dll",
|
||||
"libcrypto-1_1-x64.dll": "libcrypto-1_1.dll",
|
||||
"libssl-1_1.dll": "libssl-1_1.dll",
|
||||
"libssl-1_1-x64.dll": "libssl-1_1.dll",
|
||||
}
|
||||
|
||||
include_files = []
|
||||
for target_name, source_name in source_by_target.items():
|
||||
source_path = os.path.join(dll_dir, source_name)
|
||||
if os.path.exists(source_path):
|
||||
include_files.append((source_path, os.path.join("lib", target_name)))
|
||||
|
||||
return include_files
|
||||
|
||||
|
||||
build_exe_options = {
|
||||
"packages": ["libtorrent"],
|
||||
"build_exe": "hydra-python-rpc",
|
||||
"include_msvcr": True,
|
||||
"include_files": get_windows_openssl_includes(),
|
||||
}
|
||||
|
||||
setup(
|
||||
name="hydra-python-rpc",
|
||||
version="0.1",
|
||||
description="Hydra",
|
||||
options={"build_exe": build_exe_options},
|
||||
executables=[
|
||||
Executable(
|
||||
"python_rpc/main.py",
|
||||
target_name="hydra-python-rpc",
|
||||
icon="build/icon.ico",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,391 +1,391 @@
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import List, Optional, Set
|
||||
|
||||
import libtorrent as lt
|
||||
|
||||
|
||||
class TorrentDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
torrent_session,
|
||||
flags=lt.torrent_flags.auto_managed,
|
||||
session_lock: Optional[threading.RLock] = None,
|
||||
):
|
||||
self.torrent_handle = None
|
||||
self.session = torrent_session
|
||||
self.flags = flags
|
||||
self.session_lock = session_lock or threading.RLock()
|
||||
self.selected_file_indices = None
|
||||
self.selected_size_bytes = None
|
||||
self.logger = logging.getLogger("hydra.torrent")
|
||||
self.trackers = [
|
||||
"udp://tracker.opentrackr.org:1337/announce",
|
||||
"http://tracker.opentrackr.org:1337/announce",
|
||||
"udp://open.tracker.cl:1337/announce",
|
||||
"udp://open.demonii.com:1337/announce",
|
||||
"udp://open.stealth.si:80/announce",
|
||||
"udp://tracker.torrent.eu.org:451/announce",
|
||||
"udp://exodus.desync.com:6969/announce",
|
||||
"udp://tracker.theoks.net:6969/announce",
|
||||
"udp://tracker-udp.gbitt.info:80/announce",
|
||||
"udp://explodie.org:6969/announce",
|
||||
"https://tracker.tamersunion.org:443/announce",
|
||||
"udp://tracker2.dler.org:80/announce",
|
||||
"udp://tracker1.myporn.club:9337/announce",
|
||||
"udp://tracker.tiny-vps.com:6969/announce",
|
||||
"udp://tracker.dler.org:6969/announce",
|
||||
"udp://tracker.bittor.pw:1337/announce",
|
||||
"udp://tracker.0x7c0.com:6969/announce",
|
||||
"udp://retracker01-msk-virt.corbina.net:80/announce",
|
||||
"udp://opentracker.io:6969/announce",
|
||||
"udp://open.free-tracker.ga:6969/announce",
|
||||
"udp://new-line.net:6969/announce",
|
||||
"udp://moonburrow.club:6969/announce",
|
||||
"udp://leet-tracker.moe:1337/announce",
|
||||
"udp://bt2.archive.org:6969/announce",
|
||||
"udp://bt1.archive.org:6969/announce",
|
||||
"http://tracker2.dler.org:80/announce",
|
||||
"http://tracker1.bt.moack.co.kr:80/announce",
|
||||
"http://tracker.dler.org:6969/announce",
|
||||
"http://tr.kxmp.cf:80/announce",
|
||||
"udp://u.peer-exchange.download:6969/announce",
|
||||
"udp://ttk2.nbaonlineservice.com:6969/announce",
|
||||
"udp://tracker.tryhackx.org:6969/announce",
|
||||
"udp://tracker.srv00.com:6969/announce",
|
||||
"udp://tracker.skynetcloud.site:6969/announce",
|
||||
"udp://tracker.jamesthebard.net:6969/announce",
|
||||
"udp://tracker.fnix.net:6969/announce",
|
||||
"udp://tracker.filemail.com:6969/announce",
|
||||
"udp://tracker.farted.net:6969/announce",
|
||||
"udp://tracker.edkj.club:6969/announce",
|
||||
"udp://tracker.dump.cl:6969/announce",
|
||||
"udp://tracker.deadorbit.nl:6969/announce",
|
||||
"udp://tracker.darkness.services:6969/announce",
|
||||
"udp://tracker.ccp.ovh:6969/announce",
|
||||
"udp://tamas3.ynh.fr:6969/announce",
|
||||
"udp://ryjer.com:6969/announce",
|
||||
"udp://run.publictracker.xyz:6969/announce",
|
||||
"udp://public.tracker.vraphim.com:6969/announce",
|
||||
"udp://p4p.arenabg.com:1337/announce",
|
||||
"udp://p2p.publictracker.xyz:6969/announce",
|
||||
"udp://open.u-p.pw:6969/announce",
|
||||
"udp://open.publictracker.xyz:6969/announce",
|
||||
"udp://open.dstud.io:6969/announce",
|
||||
"udp://open.demonoid.ch:6969/announce",
|
||||
"udp://odd-hd.fr:6969/announce",
|
||||
"udp://martin-gebhardt.eu:25/announce",
|
||||
"udp://jutone.com:6969/announce",
|
||||
"udp://isk.richardsw.club:6969/announce",
|
||||
"udp://evan.im:6969/announce",
|
||||
"udp://epider.me:6969/announce",
|
||||
"udp://d40969.acod.regrucolo.ru:6969/announce",
|
||||
"udp://bt.rer.lol:6969/announce",
|
||||
"udp://amigacity.xyz:6969/announce",
|
||||
"udp://1c.premierzal.ru:6969/announce",
|
||||
"https://trackers.run:443/announce",
|
||||
"https://tracker.yemekyedim.com:443/announce",
|
||||
"https://tracker.renfei.net:443/announce",
|
||||
"https://tracker.pmman.tech:443/announce",
|
||||
"https://tracker.lilithraws.org:443/announce",
|
||||
"https://tracker.imgoingto.icu:443/announce",
|
||||
"https://tracker.cloudit.top:443/announce",
|
||||
"https://tracker-zhuqiy.dgj055.icu:443/announce",
|
||||
"http://tracker.renfei.net:8080/announce",
|
||||
"http://tracker.mywaifu.best:6969/announce",
|
||||
"http://tracker.ipv6tracker.org:80/announce",
|
||||
"http://tracker.files.fm:6969/announce",
|
||||
"http://tracker.edkj.club:6969/announce",
|
||||
"http://tracker.bt4g.com:2095/announce",
|
||||
"http://tracker-zhuqiy.dgj055.icu:80/announce",
|
||||
"http://t1.aag.moe:17715/announce",
|
||||
"http://t.overflow.biz:6969/announce",
|
||||
"http://bittorrent-tracker.e-n-c-r-y-p-t.net:1337/announce",
|
||||
"udp://torrents.artixlinux.org:6969/announce",
|
||||
"udp://mail.artixlinux.org:6969/announce",
|
||||
"udp://ipv4.rer.lol:2710/announce",
|
||||
"udp://concen.org:6969/announce",
|
||||
"udp://bt.rer.lol:2710/announce",
|
||||
"udp://aegir.sexy:6969/announce",
|
||||
"https://www.peckservers.com:9443/announce",
|
||||
"https://tracker.ipfsscan.io:443/announce",
|
||||
"https://tracker.gcrenwp.top:443/announce",
|
||||
"http://www.peckservers.com:9000/announce",
|
||||
"http://tracker1.itzmx.com:8080/announce",
|
||||
"http://ch3oh.ru:6969/announce",
|
||||
"http://bvarf.tracker.sh:2086/announce",
|
||||
]
|
||||
|
||||
def set_download_limit(self, max_download_speed: int = None):
|
||||
download_limit = (
|
||||
max_download_speed if max_download_speed and max_download_speed > 0 else 0
|
||||
)
|
||||
try:
|
||||
self.session.set_download_rate_limit(download_limit)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _wait_for_metadata(self, timeout_seconds: float = 30.0, poll_interval: float = 0.25):
|
||||
if not self.torrent_handle or not self.torrent_handle.is_valid():
|
||||
return False
|
||||
|
||||
deadline = time.monotonic() + max(timeout_seconds, 1.0)
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
status = self.torrent_handle.status()
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
if status.has_metadata:
|
||||
return True
|
||||
|
||||
time.sleep(max(poll_interval, 0.05))
|
||||
|
||||
return False
|
||||
|
||||
def wait_for_metadata(self, timeout_seconds: float = 30.0):
|
||||
return self._wait_for_metadata(timeout_seconds=timeout_seconds)
|
||||
|
||||
def _sanitize_file_indices(self, file_indices: List[int], files_storage):
|
||||
if file_indices is None:
|
||||
return None
|
||||
|
||||
if not isinstance(file_indices, list):
|
||||
raise ValueError("invalid_file_indices")
|
||||
|
||||
max_index = files_storage.num_files() - 1
|
||||
sanitized: Set[int] = set()
|
||||
|
||||
for index in file_indices:
|
||||
if isinstance(index, bool) or not isinstance(index, int):
|
||||
raise ValueError("invalid_file_indices")
|
||||
|
||||
if index < 0 or index > max_index:
|
||||
raise ValueError("invalid_file_indices")
|
||||
|
||||
sanitized.add(index)
|
||||
|
||||
if not sanitized:
|
||||
raise ValueError("empty_selection")
|
||||
|
||||
return sorted(sanitized)
|
||||
|
||||
def _set_selected_file_priorities(self, selected_indices: List[int], files_storage):
|
||||
priorities = [0] * files_storage.num_files()
|
||||
for index in selected_indices:
|
||||
priorities[index] = 1
|
||||
|
||||
self.torrent_handle.prioritize_files(priorities)
|
||||
|
||||
deadline = time.monotonic() + 3.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
current_priorities = [int(priority) for priority in self.torrent_handle.get_file_priorities()]
|
||||
except RuntimeError:
|
||||
break
|
||||
|
||||
if current_priorities == priorities:
|
||||
return
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
self.logger.warning("File priority synchronization timeout")
|
||||
|
||||
def start_download(
|
||||
self,
|
||||
magnet: str,
|
||||
save_path: str,
|
||||
file_indices: Optional[List[int]] = None,
|
||||
wait_timeout_seconds: float = 30.0,
|
||||
):
|
||||
selective_download = file_indices is not None
|
||||
|
||||
with self.session_lock:
|
||||
if self.torrent_handle and self.torrent_handle.is_valid():
|
||||
if not selective_download:
|
||||
self.torrent_handle.set_flags(lt.torrent_flags.auto_managed)
|
||||
self.torrent_handle.resume()
|
||||
return
|
||||
|
||||
self.torrent_handle.pause()
|
||||
self.session.remove_torrent(self.torrent_handle)
|
||||
self.torrent_handle = None
|
||||
|
||||
initial_flags = self.flags | lt.torrent_flags.paused
|
||||
|
||||
if selective_download:
|
||||
initial_flags |= lt.torrent_flags.default_dont_download
|
||||
initial_flags |= lt.torrent_flags.auto_managed
|
||||
else:
|
||||
initial_flags |= lt.torrent_flags.auto_managed
|
||||
|
||||
params = {
|
||||
"url": magnet,
|
||||
"save_path": save_path,
|
||||
"trackers": self.trackers,
|
||||
"flags": initial_flags,
|
||||
}
|
||||
|
||||
if self.torrent_handle is None or not self.torrent_handle.is_valid():
|
||||
self.torrent_handle = self.session.add_torrent(params)
|
||||
|
||||
self.selected_file_indices = None
|
||||
self.selected_size_bytes = None
|
||||
|
||||
if selective_download:
|
||||
try:
|
||||
self.torrent_handle.set_flags(lt.torrent_flags.auto_managed)
|
||||
self.torrent_handle.resume()
|
||||
|
||||
if not self._wait_for_metadata(timeout_seconds=wait_timeout_seconds):
|
||||
raise TimeoutError("metadata_timeout")
|
||||
|
||||
try:
|
||||
info = self.torrent_handle.get_torrent_info()
|
||||
files_storage = info.files()
|
||||
except RuntimeError as error:
|
||||
raise RuntimeError("metadata_incomplete") from error
|
||||
|
||||
self.torrent_handle.pause()
|
||||
self.torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
|
||||
|
||||
sanitized_indices = self._sanitize_file_indices(file_indices, files_storage)
|
||||
self._set_selected_file_priorities(sanitized_indices, files_storage)
|
||||
|
||||
self.selected_file_indices = sanitized_indices
|
||||
self.selected_size_bytes = sum(files_storage.file_size(index) for index in sanitized_indices)
|
||||
except Exception:
|
||||
self.cancel_download()
|
||||
raise
|
||||
|
||||
self.torrent_handle.set_flags(lt.torrent_flags.auto_managed)
|
||||
self.torrent_handle.resume()
|
||||
|
||||
def get_torrent_files(self, timeout_seconds: float = 30.0, max_files: int = 100000):
|
||||
if not self._wait_for_metadata(timeout_seconds=timeout_seconds):
|
||||
raise TimeoutError("metadata_timeout")
|
||||
|
||||
try:
|
||||
info = self.torrent_handle.get_torrent_info()
|
||||
except RuntimeError as error:
|
||||
raise RuntimeError("metadata_incomplete") from error
|
||||
|
||||
files_storage = info.files()
|
||||
file_count = files_storage.num_files()
|
||||
|
||||
if file_count > max_files:
|
||||
raise OverflowError("too_many_files")
|
||||
|
||||
files = []
|
||||
for index in range(file_count):
|
||||
files.append(
|
||||
{
|
||||
"index": index,
|
||||
"path": files_storage.file_path(index),
|
||||
"length": files_storage.file_size(index),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"name": info.name(),
|
||||
"totalSize": info.total_size(),
|
||||
"files": files,
|
||||
}
|
||||
|
||||
def pause_download(self):
|
||||
if self.torrent_handle:
|
||||
self.torrent_handle.pause()
|
||||
self.torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
|
||||
|
||||
def cancel_download(self):
|
||||
with self.session_lock:
|
||||
if self.torrent_handle:
|
||||
if self.torrent_handle.is_valid():
|
||||
self.torrent_handle.pause()
|
||||
self.session.remove_torrent(self.torrent_handle, lt.session.delete_partfile)
|
||||
self.torrent_handle = None
|
||||
self.selected_file_indices = None
|
||||
self.selected_size_bytes = None
|
||||
|
||||
def abort_session(self):
|
||||
self.cancel_download()
|
||||
self.session.abort()
|
||||
self.torrent_handle = None
|
||||
self.selected_file_indices = None
|
||||
self.selected_size_bytes = None
|
||||
|
||||
def _get_handle_status(self):
|
||||
if self.torrent_handle is None:
|
||||
return None
|
||||
|
||||
if not self.torrent_handle.is_valid():
|
||||
return None
|
||||
|
||||
try:
|
||||
return self.torrent_handle.status()
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
def _get_torrent_info_if_available(self, status):
|
||||
if not status.has_metadata:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self.torrent_handle.get_torrent_info()
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
def _get_file_size(self, status, info):
|
||||
total_wanted = getattr(status, "total_wanted", 0)
|
||||
if total_wanted > 0:
|
||||
return total_wanted
|
||||
|
||||
if self.selected_size_bytes is not None:
|
||||
return self.selected_size_bytes
|
||||
|
||||
if info:
|
||||
return info.total_size()
|
||||
|
||||
return 0
|
||||
|
||||
def _get_bytes_downloaded(self, status, file_size):
|
||||
total_wanted_done = getattr(status, "total_wanted_done", -1)
|
||||
if total_wanted_done >= 0:
|
||||
return total_wanted_done
|
||||
|
||||
if file_size > 0:
|
||||
return int(status.progress * file_size)
|
||||
|
||||
return status.all_time_download
|
||||
|
||||
def _get_progress(self, status, file_size, bytes_downloaded):
|
||||
if file_size <= 0:
|
||||
return status.progress
|
||||
|
||||
return min(max(bytes_downloaded / file_size, 0), 1)
|
||||
|
||||
def get_download_status(self):
|
||||
status = self._get_handle_status()
|
||||
if status is None:
|
||||
return None
|
||||
|
||||
info = self._get_torrent_info_if_available(status)
|
||||
file_size = self._get_file_size(status, info)
|
||||
bytes_downloaded = self._get_bytes_downloaded(status, file_size)
|
||||
progress = self._get_progress(status, file_size, bytes_downloaded)
|
||||
|
||||
response = {
|
||||
'folderName': info.name() if info else "",
|
||||
'fileSize': file_size,
|
||||
'progress': progress,
|
||||
'downloadSpeed': status.download_rate,
|
||||
'uploadSpeed': status.upload_rate,
|
||||
'numPeers': status.num_peers,
|
||||
'numSeeds': status.num_seeds,
|
||||
'status': status.state,
|
||||
'bytesDownloaded': bytes_downloaded,
|
||||
}
|
||||
|
||||
return response
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import List, Optional, Set
|
||||
|
||||
import libtorrent as lt
|
||||
|
||||
|
||||
class TorrentDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
torrent_session,
|
||||
flags=lt.torrent_flags.auto_managed,
|
||||
session_lock: Optional[threading.RLock] = None,
|
||||
):
|
||||
self.torrent_handle = None
|
||||
self.session = torrent_session
|
||||
self.flags = flags
|
||||
self.session_lock = session_lock or threading.RLock()
|
||||
self.selected_file_indices = None
|
||||
self.selected_size_bytes = None
|
||||
self.logger = logging.getLogger("hydra.torrent")
|
||||
self.trackers = [
|
||||
"udp://tracker.opentrackr.org:1337/announce",
|
||||
"http://tracker.opentrackr.org:1337/announce",
|
||||
"udp://open.tracker.cl:1337/announce",
|
||||
"udp://open.demonii.com:1337/announce",
|
||||
"udp://open.stealth.si:80/announce",
|
||||
"udp://tracker.torrent.eu.org:451/announce",
|
||||
"udp://exodus.desync.com:6969/announce",
|
||||
"udp://tracker.theoks.net:6969/announce",
|
||||
"udp://tracker-udp.gbitt.info:80/announce",
|
||||
"udp://explodie.org:6969/announce",
|
||||
"https://tracker.tamersunion.org:443/announce",
|
||||
"udp://tracker2.dler.org:80/announce",
|
||||
"udp://tracker1.myporn.club:9337/announce",
|
||||
"udp://tracker.tiny-vps.com:6969/announce",
|
||||
"udp://tracker.dler.org:6969/announce",
|
||||
"udp://tracker.bittor.pw:1337/announce",
|
||||
"udp://tracker.0x7c0.com:6969/announce",
|
||||
"udp://retracker01-msk-virt.corbina.net:80/announce",
|
||||
"udp://opentracker.io:6969/announce",
|
||||
"udp://open.free-tracker.ga:6969/announce",
|
||||
"udp://new-line.net:6969/announce",
|
||||
"udp://moonburrow.club:6969/announce",
|
||||
"udp://leet-tracker.moe:1337/announce",
|
||||
"udp://bt2.archive.org:6969/announce",
|
||||
"udp://bt1.archive.org:6969/announce",
|
||||
"http://tracker2.dler.org:80/announce",
|
||||
"http://tracker1.bt.moack.co.kr:80/announce",
|
||||
"http://tracker.dler.org:6969/announce",
|
||||
"http://tr.kxmp.cf:80/announce",
|
||||
"udp://u.peer-exchange.download:6969/announce",
|
||||
"udp://ttk2.nbaonlineservice.com:6969/announce",
|
||||
"udp://tracker.tryhackx.org:6969/announce",
|
||||
"udp://tracker.srv00.com:6969/announce",
|
||||
"udp://tracker.skynetcloud.site:6969/announce",
|
||||
"udp://tracker.jamesthebard.net:6969/announce",
|
||||
"udp://tracker.fnix.net:6969/announce",
|
||||
"udp://tracker.filemail.com:6969/announce",
|
||||
"udp://tracker.farted.net:6969/announce",
|
||||
"udp://tracker.edkj.club:6969/announce",
|
||||
"udp://tracker.dump.cl:6969/announce",
|
||||
"udp://tracker.deadorbit.nl:6969/announce",
|
||||
"udp://tracker.darkness.services:6969/announce",
|
||||
"udp://tracker.ccp.ovh:6969/announce",
|
||||
"udp://tamas3.ynh.fr:6969/announce",
|
||||
"udp://ryjer.com:6969/announce",
|
||||
"udp://run.publictracker.xyz:6969/announce",
|
||||
"udp://public.tracker.vraphim.com:6969/announce",
|
||||
"udp://p4p.arenabg.com:1337/announce",
|
||||
"udp://p2p.publictracker.xyz:6969/announce",
|
||||
"udp://open.u-p.pw:6969/announce",
|
||||
"udp://open.publictracker.xyz:6969/announce",
|
||||
"udp://open.dstud.io:6969/announce",
|
||||
"udp://open.demonoid.ch:6969/announce",
|
||||
"udp://odd-hd.fr:6969/announce",
|
||||
"udp://martin-gebhardt.eu:25/announce",
|
||||
"udp://jutone.com:6969/announce",
|
||||
"udp://isk.richardsw.club:6969/announce",
|
||||
"udp://evan.im:6969/announce",
|
||||
"udp://epider.me:6969/announce",
|
||||
"udp://d40969.acod.regrucolo.ru:6969/announce",
|
||||
"udp://bt.rer.lol:6969/announce",
|
||||
"udp://amigacity.xyz:6969/announce",
|
||||
"udp://1c.premierzal.ru:6969/announce",
|
||||
"https://trackers.run:443/announce",
|
||||
"https://tracker.yemekyedim.com:443/announce",
|
||||
"https://tracker.renfei.net:443/announce",
|
||||
"https://tracker.pmman.tech:443/announce",
|
||||
"https://tracker.lilithraws.org:443/announce",
|
||||
"https://tracker.imgoingto.icu:443/announce",
|
||||
"https://tracker.cloudit.top:443/announce",
|
||||
"https://tracker-zhuqiy.dgj055.icu:443/announce",
|
||||
"http://tracker.renfei.net:8080/announce",
|
||||
"http://tracker.mywaifu.best:6969/announce",
|
||||
"http://tracker.ipv6tracker.org:80/announce",
|
||||
"http://tracker.files.fm:6969/announce",
|
||||
"http://tracker.edkj.club:6969/announce",
|
||||
"http://tracker.bt4g.com:2095/announce",
|
||||
"http://tracker-zhuqiy.dgj055.icu:80/announce",
|
||||
"http://t1.aag.moe:17715/announce",
|
||||
"http://t.overflow.biz:6969/announce",
|
||||
"http://bittorrent-tracker.e-n-c-r-y-p-t.net:1337/announce",
|
||||
"udp://torrents.artixlinux.org:6969/announce",
|
||||
"udp://mail.artixlinux.org:6969/announce",
|
||||
"udp://ipv4.rer.lol:2710/announce",
|
||||
"udp://concen.org:6969/announce",
|
||||
"udp://bt.rer.lol:2710/announce",
|
||||
"udp://aegir.sexy:6969/announce",
|
||||
"https://www.peckservers.com:9443/announce",
|
||||
"https://tracker.ipfsscan.io:443/announce",
|
||||
"https://tracker.gcrenwp.top:443/announce",
|
||||
"http://www.peckservers.com:9000/announce",
|
||||
"http://tracker1.itzmx.com:8080/announce",
|
||||
"http://ch3oh.ru:6969/announce",
|
||||
"http://bvarf.tracker.sh:2086/announce",
|
||||
]
|
||||
|
||||
def set_download_limit(self, max_download_speed: int = None):
|
||||
download_limit = (
|
||||
max_download_speed if max_download_speed and max_download_speed > 0 else 0
|
||||
)
|
||||
try:
|
||||
self.session.set_download_rate_limit(download_limit)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _wait_for_metadata(self, timeout_seconds: float = 30.0, poll_interval: float = 0.25):
|
||||
if not self.torrent_handle or not self.torrent_handle.is_valid():
|
||||
return False
|
||||
|
||||
deadline = time.monotonic() + max(timeout_seconds, 1.0)
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
status = self.torrent_handle.status()
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
if status.has_metadata:
|
||||
return True
|
||||
|
||||
time.sleep(max(poll_interval, 0.05))
|
||||
|
||||
return False
|
||||
|
||||
def wait_for_metadata(self, timeout_seconds: float = 30.0):
|
||||
return self._wait_for_metadata(timeout_seconds=timeout_seconds)
|
||||
|
||||
def _sanitize_file_indices(self, file_indices: List[int], files_storage):
|
||||
if file_indices is None:
|
||||
return None
|
||||
|
||||
if not isinstance(file_indices, list):
|
||||
raise ValueError("invalid_file_indices")
|
||||
|
||||
max_index = files_storage.num_files() - 1
|
||||
sanitized: Set[int] = set()
|
||||
|
||||
for index in file_indices:
|
||||
if isinstance(index, bool) or not isinstance(index, int):
|
||||
raise ValueError("invalid_file_indices")
|
||||
|
||||
if index < 0 or index > max_index:
|
||||
raise ValueError("invalid_file_indices")
|
||||
|
||||
sanitized.add(index)
|
||||
|
||||
if not sanitized:
|
||||
raise ValueError("empty_selection")
|
||||
|
||||
return sorted(sanitized)
|
||||
|
||||
def _set_selected_file_priorities(self, selected_indices: List[int], files_storage):
|
||||
priorities = [0] * files_storage.num_files()
|
||||
for index in selected_indices:
|
||||
priorities[index] = 1
|
||||
|
||||
self.torrent_handle.prioritize_files(priorities)
|
||||
|
||||
deadline = time.monotonic() + 3.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
current_priorities = [int(priority) for priority in self.torrent_handle.get_file_priorities()]
|
||||
except RuntimeError:
|
||||
break
|
||||
|
||||
if current_priorities == priorities:
|
||||
return
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
self.logger.warning("File priority synchronization timeout")
|
||||
|
||||
def start_download(
|
||||
self,
|
||||
magnet: str,
|
||||
save_path: str,
|
||||
file_indices: Optional[List[int]] = None,
|
||||
wait_timeout_seconds: float = 30.0,
|
||||
):
|
||||
selective_download = file_indices is not None
|
||||
|
||||
with self.session_lock:
|
||||
if self.torrent_handle and self.torrent_handle.is_valid():
|
||||
if not selective_download:
|
||||
self.torrent_handle.set_flags(lt.torrent_flags.auto_managed)
|
||||
self.torrent_handle.resume()
|
||||
return
|
||||
|
||||
self.torrent_handle.pause()
|
||||
self.session.remove_torrent(self.torrent_handle)
|
||||
self.torrent_handle = None
|
||||
|
||||
initial_flags = self.flags | lt.torrent_flags.paused
|
||||
|
||||
if selective_download:
|
||||
initial_flags |= lt.torrent_flags.default_dont_download
|
||||
initial_flags |= lt.torrent_flags.auto_managed
|
||||
else:
|
||||
initial_flags |= lt.torrent_flags.auto_managed
|
||||
|
||||
params = {
|
||||
"url": magnet,
|
||||
"save_path": save_path,
|
||||
"trackers": self.trackers,
|
||||
"flags": initial_flags,
|
||||
}
|
||||
|
||||
if self.torrent_handle is None or not self.torrent_handle.is_valid():
|
||||
self.torrent_handle = self.session.add_torrent(params)
|
||||
|
||||
self.selected_file_indices = None
|
||||
self.selected_size_bytes = None
|
||||
|
||||
if selective_download:
|
||||
try:
|
||||
self.torrent_handle.set_flags(lt.torrent_flags.auto_managed)
|
||||
self.torrent_handle.resume()
|
||||
|
||||
if not self._wait_for_metadata(timeout_seconds=wait_timeout_seconds):
|
||||
raise TimeoutError("metadata_timeout")
|
||||
|
||||
try:
|
||||
info = self.torrent_handle.get_torrent_info()
|
||||
files_storage = info.files()
|
||||
except RuntimeError as error:
|
||||
raise RuntimeError("metadata_incomplete") from error
|
||||
|
||||
self.torrent_handle.pause()
|
||||
self.torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
|
||||
|
||||
sanitized_indices = self._sanitize_file_indices(file_indices, files_storage)
|
||||
self._set_selected_file_priorities(sanitized_indices, files_storage)
|
||||
|
||||
self.selected_file_indices = sanitized_indices
|
||||
self.selected_size_bytes = sum(files_storage.file_size(index) for index in sanitized_indices)
|
||||
except Exception:
|
||||
self.cancel_download()
|
||||
raise
|
||||
|
||||
self.torrent_handle.set_flags(lt.torrent_flags.auto_managed)
|
||||
self.torrent_handle.resume()
|
||||
|
||||
def get_torrent_files(self, timeout_seconds: float = 30.0, max_files: int = 100000):
|
||||
if not self._wait_for_metadata(timeout_seconds=timeout_seconds):
|
||||
raise TimeoutError("metadata_timeout")
|
||||
|
||||
try:
|
||||
info = self.torrent_handle.get_torrent_info()
|
||||
except RuntimeError as error:
|
||||
raise RuntimeError("metadata_incomplete") from error
|
||||
|
||||
files_storage = info.files()
|
||||
file_count = files_storage.num_files()
|
||||
|
||||
if file_count > max_files:
|
||||
raise OverflowError("too_many_files")
|
||||
|
||||
files = []
|
||||
for index in range(file_count):
|
||||
files.append(
|
||||
{
|
||||
"index": index,
|
||||
"path": files_storage.file_path(index),
|
||||
"length": files_storage.file_size(index),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"name": info.name(),
|
||||
"totalSize": info.total_size(),
|
||||
"files": files,
|
||||
}
|
||||
|
||||
def pause_download(self):
|
||||
if self.torrent_handle:
|
||||
self.torrent_handle.pause()
|
||||
self.torrent_handle.unset_flags(lt.torrent_flags.auto_managed)
|
||||
|
||||
def cancel_download(self):
|
||||
with self.session_lock:
|
||||
if self.torrent_handle:
|
||||
if self.torrent_handle.is_valid():
|
||||
self.torrent_handle.pause()
|
||||
self.session.remove_torrent(self.torrent_handle, lt.session.delete_partfile)
|
||||
self.torrent_handle = None
|
||||
self.selected_file_indices = None
|
||||
self.selected_size_bytes = None
|
||||
|
||||
def abort_session(self):
|
||||
self.cancel_download()
|
||||
self.session.abort()
|
||||
self.torrent_handle = None
|
||||
self.selected_file_indices = None
|
||||
self.selected_size_bytes = None
|
||||
|
||||
def _get_handle_status(self):
|
||||
if self.torrent_handle is None:
|
||||
return None
|
||||
|
||||
if not self.torrent_handle.is_valid():
|
||||
return None
|
||||
|
||||
try:
|
||||
return self.torrent_handle.status()
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
def _get_torrent_info_if_available(self, status):
|
||||
if not status.has_metadata:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self.torrent_handle.get_torrent_info()
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
def _get_file_size(self, status, info):
|
||||
total_wanted = getattr(status, "total_wanted", 0)
|
||||
if total_wanted > 0:
|
||||
return total_wanted
|
||||
|
||||
if self.selected_size_bytes is not None:
|
||||
return self.selected_size_bytes
|
||||
|
||||
if info:
|
||||
return info.total_size()
|
||||
|
||||
return 0
|
||||
|
||||
def _get_bytes_downloaded(self, status, file_size):
|
||||
total_wanted_done = getattr(status, "total_wanted_done", -1)
|
||||
if total_wanted_done >= 0:
|
||||
return total_wanted_done
|
||||
|
||||
if file_size > 0:
|
||||
return int(status.progress * file_size)
|
||||
|
||||
return status.all_time_download
|
||||
|
||||
def _get_progress(self, status, file_size, bytes_downloaded):
|
||||
if file_size <= 0:
|
||||
return status.progress
|
||||
|
||||
return min(max(bytes_downloaded / file_size, 0), 1)
|
||||
|
||||
def get_download_status(self):
|
||||
status = self._get_handle_status()
|
||||
if status is None:
|
||||
return None
|
||||
|
||||
info = self._get_torrent_info_if_available(status)
|
||||
file_size = self._get_file_size(status, info)
|
||||
bytes_downloaded = self._get_bytes_downloaded(status, file_size)
|
||||
progress = self._get_progress(status, file_size, bytes_downloaded)
|
||||
|
||||
response = {
|
||||
'folderName': info.name() if info else "",
|
||||
'fileSize': file_size,
|
||||
'progress': progress,
|
||||
'downloadSpeed': status.download_rate,
|
||||
'uploadSpeed': status.upload_rate,
|
||||
'numPeers': status.num_peers,
|
||||
'numSeeds': status.num_seeds,
|
||||
'status': status.state,
|
||||
'bytesDownloaded': bytes_downloaded,
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
libtorrent
|
||||
cx_Freeze == 7.2.3
|
||||
libtorrent
|
||||
cx_Freeze == 7.2.3
|
||||
|
||||
@@ -1 +1 @@
|
||||
sonar.exclusions=src/main/generated/**
|
||||
sonar.exclusions=src/main/generated/**
|
||||
|
||||
@@ -414,6 +414,7 @@
|
||||
"proton_source_steam": "Installed by Steam",
|
||||
"proton_source_compatibility_tools": "Installed in Steam compatibilitytools.d",
|
||||
"settings_category_general": "General",
|
||||
"settings_category_locations": "Locations",
|
||||
"settings_category_general_description": "Executable path, launch options, and shortcuts",
|
||||
"settings_category_assets": "Assets",
|
||||
"settings_category_assets_description": "Customize game title, icon, logo, and hero artwork",
|
||||
@@ -502,6 +503,47 @@
|
||||
"backup_freeze_failed_description": "You must leave at least one free slot for automatic backups",
|
||||
"edit_game_modal_button": "Customize game assets",
|
||||
"game_details": "Game Details",
|
||||
"transferring": "Transferring...",
|
||||
"view_progress": "View progress",
|
||||
"transfer_game": "Transfer Game",
|
||||
"transfer_game_description": "Move {{game}} to a new drive or folder.",
|
||||
"transfer_available_drives": "Available Drives",
|
||||
"transfer_free": "free",
|
||||
"transfer_insufficient_space": "Insufficient Space",
|
||||
"transfer_destination_placeholder": "Choose destination folder (e.g. D:\\Games)",
|
||||
"transfer_browse": "Browse",
|
||||
"transfer_preparing": "Preparing...",
|
||||
"transfer_moving_files": "Moving Files...",
|
||||
"transfer_calculating": "Calculating...",
|
||||
"transfer_speed_unit": "MB/s",
|
||||
"transfer_eta_label": "ETA: {{eta}}",
|
||||
"transfer_cancel_title": "Cancel Transfer?",
|
||||
"transfer_cancel_description": "Files moved so far will be deleted. This cannot be undone.",
|
||||
"transfer_cancel_button": "Cancel",
|
||||
"transfer_continue": "Continue Transfer",
|
||||
"transfer_cancel_confirm": "Yes, Cancel",
|
||||
"game_size": "Game size",
|
||||
"free_of": "free of",
|
||||
"current": "current",
|
||||
"or_enter_path": "Or enter path manually",
|
||||
"destination_folder_placeholder": "e.g. D:\\Games",
|
||||
"start_transfer": "Start Transfer",
|
||||
"transfer_complete": "Transfer complete!",
|
||||
"transfer_complete_description": "{{game}} moved successfully.",
|
||||
"transfer_failed": "Transfer failed.",
|
||||
"transfer_cancelled": "Transfer cancelled.",
|
||||
"transfer_not_enough_space": "Not enough disk space.",
|
||||
"transfer_same_folder": "Already in this location.",
|
||||
"transfer_destination_inside_source": "Destination cannot be inside the current game folder.",
|
||||
"transfer_destination_exists": "Destination folder already exists.",
|
||||
"transfer_destination_unavailable": "Cannot access destination folder.",
|
||||
"transfer_root_not_found": "Could not determine the game root folder.",
|
||||
"transfer_game_not_found": "Game not found or executable path is missing.",
|
||||
"transfer_db_update_failed": "Files were moved, but Hydra could not update metadata. Refresh and verify the executable path.",
|
||||
"transfer_unknown_size": "unknown",
|
||||
"not_enough_space_detail": "Not enough space — need {{needed}}, only {{available}} available.",
|
||||
"detecting_drives": "Detecting drives...",
|
||||
"browse": "Browse",
|
||||
"currency_symbol": "$",
|
||||
"currency_country": "us",
|
||||
"prices": "Prices",
|
||||
|
||||
@@ -392,6 +392,39 @@
|
||||
"backup_freeze_failed_description": "Tenés que tener mínimo un espacio para copias de seguridad automáticas",
|
||||
"edit_game_modal_button": "Personalizar recursos de juego",
|
||||
"game_details": "Detalles del juego",
|
||||
"transfer_game": "Transferir juego",
|
||||
"transfer_game_description": "Mueve {{game}} a una nueva unidad o carpeta.",
|
||||
"transfer_available_drives": "Unidades disponibles",
|
||||
"transfer_free": "libres",
|
||||
"transfer_insufficient_space": "Espacio insuficiente",
|
||||
"transfer_destination_placeholder": "Elige la carpeta de destino (p. ej. D:\\Games)",
|
||||
"transfer_browse": "Examinar",
|
||||
"transfer_preparing": "Preparando...",
|
||||
"transfer_moving_files": "Moviendo archivos...",
|
||||
"transfer_calculating": "Calculando...",
|
||||
"transfer_speed_unit": "MB/s",
|
||||
"transfer_eta_label": "ETA: {{eta}}",
|
||||
"transfer_cancel_title": "¿Cancelar transferencia?",
|
||||
"transfer_cancel_description": "Los archivos movidos hasta ahora se eliminarán. Esta acción no se puede deshacer.",
|
||||
"transfer_cancel_button": "Cancelar",
|
||||
"transfer_continue": "Continuar transferencia",
|
||||
"transfer_cancel_confirm": "Sí, cancelar",
|
||||
"start_transfer": "Iniciar transferencia",
|
||||
"transfer_complete": "¡Transferencia completada!",
|
||||
"transfer_complete_description": "{{game}} se movió correctamente.",
|
||||
"transfer_failed": "La transferencia falló.",
|
||||
"transfer_cancelled": "Transferencia cancelada.",
|
||||
"transfer_not_enough_space": "No hay suficiente espacio en disco.",
|
||||
"transfer_same_folder": "Ya está en esta ubicación.",
|
||||
"transfer_destination_inside_source": "El destino no puede estar dentro de la carpeta actual del juego.",
|
||||
"transfer_destination_exists": "La carpeta de destino ya existe.",
|
||||
"transfer_destination_unavailable": "No se puede acceder a la carpeta de destino.",
|
||||
"transfer_root_not_found": "No se pudo determinar la carpeta raíz del juego.",
|
||||
"transfer_game_not_found": "Juego no encontrado o falta la ruta del ejecutable.",
|
||||
"transfer_db_update_failed": "Los archivos se movieron, pero Hydra no pudo actualizar los metadatos. Actualiza y verifica la ruta del ejecutable.",
|
||||
"transfer_unknown_size": "desconocido",
|
||||
"not_enough_space_detail": "Espacio insuficiente: se necesitan {{needed}} y solo hay {{available}} disponibles.",
|
||||
"select_destination": "Elige un destino.",
|
||||
"currency_symbol": "$",
|
||||
"currency_country": "us",
|
||||
"prices": "Precios",
|
||||
@@ -527,6 +560,7 @@
|
||||
"settings_category_danger_zone": "Zona de Peligro",
|
||||
"settings_category_downloads": "Descargas",
|
||||
"settings_category_general": "General",
|
||||
"settings_category_locations": "Ubicaciones",
|
||||
"settings_category_hydra_cloud": "Hydra Cloud",
|
||||
"settings_not_available_for_custom_games": "Esta categoría no está disponible para juegos personalizados.",
|
||||
"shortcuts_section_description": "Crea accesos directos para ejecutar el juego rápidamente",
|
||||
|
||||
@@ -347,6 +347,39 @@
|
||||
"backup_freeze_failed": "Falha ao fixar backup",
|
||||
"backup_freeze_failed_description": "Você deve deixar pelo menos um espaço livre para backups automáticos",
|
||||
"game_details": "Detalhes do Jogo",
|
||||
"transfer_game": "Transferir jogo",
|
||||
"transfer_game_description": "Mova {{game}} para uma nova unidade ou pasta.",
|
||||
"transfer_available_drives": "Unidades disponíveis",
|
||||
"transfer_free": "livre",
|
||||
"transfer_insufficient_space": "Espaço insuficiente",
|
||||
"transfer_destination_placeholder": "Escolha a pasta de destino (ex.: D:\\Games)",
|
||||
"transfer_browse": "Procurar",
|
||||
"transfer_preparing": "Preparando...",
|
||||
"transfer_moving_files": "Movendo arquivos...",
|
||||
"transfer_calculating": "Calculando...",
|
||||
"transfer_speed_unit": "MB/s",
|
||||
"transfer_eta_label": "ETA: {{eta}}",
|
||||
"transfer_cancel_title": "Cancelar transferência?",
|
||||
"transfer_cancel_description": "Os arquivos movidos até agora serão excluídos. Esta ação não pode ser desfeita.",
|
||||
"transfer_cancel_button": "Cancelar",
|
||||
"transfer_continue": "Continuar transferência",
|
||||
"transfer_cancel_confirm": "Sim, cancelar",
|
||||
"start_transfer": "Iniciar transferência",
|
||||
"transfer_complete": "Transferência concluída!",
|
||||
"transfer_complete_description": "{{game}} foi transferido com sucesso.",
|
||||
"transfer_failed": "Falha na transferência.",
|
||||
"transfer_cancelled": "Transferência cancelada.",
|
||||
"transfer_not_enough_space": "Espaço em disco insuficiente.",
|
||||
"transfer_same_folder": "Já está neste local.",
|
||||
"transfer_destination_inside_source": "O destino não pode ficar dentro da pasta atual do jogo.",
|
||||
"transfer_destination_exists": "A pasta de destino já existe.",
|
||||
"transfer_destination_unavailable": "Não foi possível acessar a pasta de destino.",
|
||||
"transfer_root_not_found": "Não foi possível determinar a pasta raiz do jogo.",
|
||||
"transfer_game_not_found": "Jogo não encontrado ou caminho do executável ausente.",
|
||||
"transfer_db_update_failed": "Os arquivos foram movidos, mas o Hydra não conseguiu atualizar os metadados. Atualize e verifique o caminho do executável.",
|
||||
"transfer_unknown_size": "desconhecido",
|
||||
"not_enough_space_detail": "Espaço insuficiente — precisa de {{needed}}, apenas {{available}} disponíveis.",
|
||||
"select_destination": "Escolha um destino.",
|
||||
"currency_symbol": "R$",
|
||||
"currency_country": "br",
|
||||
"prices": "Preços",
|
||||
@@ -444,6 +477,7 @@
|
||||
"proton_switch_confirmation_title": "Switch Proton version?",
|
||||
"proton_switch_confirmation_description": "To avoid prefix version conflicts, Hydra will delete this game's auto-managed Wine prefix at {{path}} and recreate it on next launch. This may remove data stored inside that prefix.",
|
||||
"proton_source_compatibility_tools": "Installed in Steam compatibilitytools.d",
|
||||
"settings_category_locations": "Localizações",
|
||||
"settings_category_general_description": "Executable path, launch options, and shortcuts",
|
||||
"settings_category_assets_description": "Customize game title, icon, logo, and hero artwork",
|
||||
"settings_category_hydra_cloud_description": "Configure cloud sync behavior for this game",
|
||||
|
||||
@@ -375,6 +375,39 @@
|
||||
"backup_freeze_failed_description": "Deves deixar pelo menos um espaço livre para backups automáticos",
|
||||
"edit_game_modal_button": "Personalizar recursos do jogo",
|
||||
"game_details": "Detalhes do Jogo",
|
||||
"transfer_game": "Transferir jogo",
|
||||
"transfer_game_description": "Move {{game}} para uma nova unidade ou pasta.",
|
||||
"transfer_available_drives": "Unidades disponíveis",
|
||||
"transfer_free": "livre",
|
||||
"transfer_insufficient_space": "Espaço insuficiente",
|
||||
"transfer_destination_placeholder": "Escolhe a pasta de destino (ex.: D:\\Games)",
|
||||
"transfer_browse": "Procurar",
|
||||
"transfer_preparing": "A preparar...",
|
||||
"transfer_moving_files": "A mover ficheiros...",
|
||||
"transfer_calculating": "A calcular...",
|
||||
"transfer_speed_unit": "MB/s",
|
||||
"transfer_eta_label": "ETA: {{eta}}",
|
||||
"transfer_cancel_title": "Cancelar transferência?",
|
||||
"transfer_cancel_description": "Os ficheiros movidos até agora serão eliminados. Esta ação não pode ser desfeita.",
|
||||
"transfer_cancel_button": "Cancelar",
|
||||
"transfer_continue": "Continuar transferência",
|
||||
"transfer_cancel_confirm": "Sim, cancelar",
|
||||
"start_transfer": "Iniciar transferência",
|
||||
"transfer_complete": "Transferência concluída!",
|
||||
"transfer_complete_description": "{{game}} foi transferido com sucesso.",
|
||||
"transfer_failed": "Falha na transferência.",
|
||||
"transfer_cancelled": "Transferência cancelada.",
|
||||
"transfer_not_enough_space": "Espaço em disco insuficiente.",
|
||||
"transfer_same_folder": "Já está nesta localização.",
|
||||
"transfer_destination_inside_source": "O destino não pode estar dentro da pasta atual do jogo.",
|
||||
"transfer_destination_exists": "A pasta de destino já existe.",
|
||||
"transfer_destination_unavailable": "Não foi possível aceder à pasta de destino.",
|
||||
"transfer_root_not_found": "Não foi possível determinar a pasta raiz do jogo.",
|
||||
"transfer_game_not_found": "Jogo não encontrado ou caminho do executável em falta.",
|
||||
"transfer_db_update_failed": "Os ficheiros foram movidos, mas o Hydra não conseguiu atualizar os metadados. Atualiza e verifica o caminho do executável.",
|
||||
"transfer_unknown_size": "desconhecido",
|
||||
"not_enough_space_detail": "Espaço insuficiente — precisa de {{needed}}, só existem {{available}} disponíveis.",
|
||||
"select_destination": "Seleciona um destino.",
|
||||
"currency_symbol": "€",
|
||||
"currency_country": "pt",
|
||||
"prices": "Preços",
|
||||
@@ -428,6 +461,7 @@
|
||||
"proton_switch_confirmation_title": "Switch Proton version?",
|
||||
"proton_switch_confirmation_description": "To avoid prefix version conflicts, Hydra will delete this game's auto-managed Wine prefix at {{path}} and recreate it on next launch. This may remove data stored inside that prefix.",
|
||||
"proton_source_compatibility_tools": "Installed in Steam compatibilitytools.d",
|
||||
"settings_category_locations": "Localizações",
|
||||
"settings_category_general_description": "Executable path, launch options, and shortcuts",
|
||||
"settings_category_assets_description": "Customize game title, icon, logo, and hero artwork",
|
||||
"settings_category_hydra_cloud_description": "Configure cloud sync behavior for this game",
|
||||
|
||||
@@ -414,6 +414,7 @@
|
||||
"proton_source_steam": "Установлено через Steam",
|
||||
"proton_source_compatibility_tools": "Установлено в Steam compatibilitytools.d",
|
||||
"settings_category_general": "Основные",
|
||||
"settings_category_locations": "Расположение",
|
||||
"settings_category_general_description": "Путь к исполняемому файлу, параметры запуска и ярлыки",
|
||||
"settings_category_assets": "Ресурсы",
|
||||
"settings_category_assets_description": "Настройте название игры, иконку, логотип и обложку",
|
||||
@@ -502,6 +503,39 @@
|
||||
"backup_freeze_failed_description": "Вы должны оставить как минимум один свободный слот для автоматических резервных копий",
|
||||
"edit_game_modal_button": "Изменить детали игры",
|
||||
"game_details": "Детали игры",
|
||||
"transfer_game": "Перенести игру",
|
||||
"transfer_game_description": "Переместить {{game}} на другой диск или в другую папку.",
|
||||
"transfer_available_drives": "Доступные диски",
|
||||
"transfer_free": "свободно",
|
||||
"transfer_insufficient_space": "Недостаточно места",
|
||||
"transfer_destination_placeholder": "Выберите папку назначения (например, D:\\Games)",
|
||||
"transfer_browse": "Обзор",
|
||||
"transfer_preparing": "Подготовка...",
|
||||
"transfer_moving_files": "Перемещение файлов...",
|
||||
"transfer_calculating": "Вычисление...",
|
||||
"transfer_speed_unit": "МБ/с",
|
||||
"transfer_eta_label": "Осталось: {{eta}}",
|
||||
"transfer_cancel_title": "Отменить перенос?",
|
||||
"transfer_cancel_description": "Файлы, перенесённые на данный момент, будут удалены. Это действие нельзя отменить.",
|
||||
"transfer_cancel_button": "Отмена",
|
||||
"transfer_continue": "Продолжить перенос",
|
||||
"transfer_cancel_confirm": "Да, отменить",
|
||||
"start_transfer": "Начать перенос",
|
||||
"transfer_complete": "Перенос завершён!",
|
||||
"transfer_complete_description": "{{game}} успешно перенесена.",
|
||||
"transfer_failed": "Не удалось выполнить перенос.",
|
||||
"transfer_cancelled": "Перенос отменён.",
|
||||
"transfer_not_enough_space": "Недостаточно места на диске.",
|
||||
"transfer_same_folder": "Игра уже находится в этом расположении.",
|
||||
"transfer_destination_inside_source": "Папка назначения не может находиться внутри текущей папки игры.",
|
||||
"transfer_destination_exists": "Папка назначения уже существует.",
|
||||
"transfer_destination_unavailable": "Не удалось получить доступ к папке назначения.",
|
||||
"transfer_root_not_found": "Не удалось определить корневую папку игры.",
|
||||
"transfer_game_not_found": "Игра не найдена или отсутствует путь к исполняемому файлу.",
|
||||
"transfer_db_update_failed": "Файлы были перенесены, но Hydra не смогла обновить метаданные. Обновите данные и проверьте путь к исполняемому файлу.",
|
||||
"transfer_unknown_size": "неизвестно",
|
||||
"not_enough_space_detail": "Недостаточно места — требуется {{needed}}, доступно только {{available}}.",
|
||||
"select_destination": "Выберите место назначения.",
|
||||
"currency_symbol": "₽",
|
||||
"currency_country": "ru",
|
||||
"prices": "Цены",
|
||||
|
||||
@@ -16,6 +16,7 @@ import "./themes";
|
||||
import "./torrenting";
|
||||
import "./user";
|
||||
import "./user-preferences";
|
||||
import "./library/transfer-game-files";
|
||||
|
||||
import { isPortableVersion } from "@main/helpers";
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
interface DriveInfo {
|
||||
root: string;
|
||||
label: string;
|
||||
free: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const DRIVE_SEPARATOR = String.fromCharCode(92);
|
||||
const DRIVE_LETTERS = Array.from({ length: 26 }, (_, i) =>
|
||||
String.fromCharCode(65 + i)
|
||||
);
|
||||
const LINUX_IGNORED_FS_TYPES = new Set([
|
||||
"proc",
|
||||
"sysfs",
|
||||
"tmpfs",
|
||||
"devtmpfs",
|
||||
"devpts",
|
||||
"overlay",
|
||||
"squashfs",
|
||||
"nsfs",
|
||||
"cgroup",
|
||||
"cgroup2",
|
||||
"pstore",
|
||||
"bpf",
|
||||
"tracefs",
|
||||
"securityfs",
|
||||
"configfs",
|
||||
"debugfs",
|
||||
"mqueue",
|
||||
"hugetlbfs",
|
||||
"fusectl",
|
||||
"ramfs",
|
||||
"autofs",
|
||||
"binfmt_misc",
|
||||
]);
|
||||
|
||||
function getDriveRoot(letter: string): string {
|
||||
return `${letter}:${DRIVE_SEPARATOR}`;
|
||||
}
|
||||
|
||||
async function getDriveInfo(root: string): Promise<DriveInfo | null> {
|
||||
if (typeof (fs as any).statfs !== "function") return null;
|
||||
|
||||
try {
|
||||
const stats = await (fs as any).statfs(root);
|
||||
const total = stats.blocks * stats.bsize;
|
||||
const free = stats.bavail * stats.bsize;
|
||||
|
||||
if (total <= 0) return null;
|
||||
|
||||
return {
|
||||
root,
|
||||
label: root.slice(0, 2),
|
||||
free: Math.max(0, free),
|
||||
total,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function queryWindowsDrives(): Promise<DriveInfo[]> {
|
||||
const driveChecks = DRIVE_LETTERS.map((letter) =>
|
||||
getDriveInfo(getDriveRoot(letter))
|
||||
);
|
||||
const drives = await Promise.all(driveChecks);
|
||||
|
||||
return drives.filter((drive): drive is DriveInfo => drive !== null);
|
||||
}
|
||||
|
||||
function decodeLinuxMountPath(value: string): string {
|
||||
return value
|
||||
.replaceAll("\\040", " ")
|
||||
.replaceAll("\\011", "\t")
|
||||
.replaceAll("\\012", "\n")
|
||||
.replaceAll("\\134", DRIVE_SEPARATOR);
|
||||
}
|
||||
|
||||
async function getLinuxMountPoints(): Promise<string[]> {
|
||||
const mounts = await fs.readFile("/proc/mounts", "utf8").catch(() => "");
|
||||
if (!mounts.trim()) return ["/"];
|
||||
|
||||
const mountPoints = new Set<string>();
|
||||
|
||||
for (const line of mounts.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const [source = "", target = "", fsType = ""] = line.split(" ");
|
||||
if (!target || LINUX_IGNORED_FS_TYPES.has(fsType)) continue;
|
||||
|
||||
const isDeviceMount = source.startsWith("/dev/");
|
||||
const isUserSpaceFs = fsType.startsWith("fuse.");
|
||||
if (!isDeviceMount && !isUserSpaceFs) continue;
|
||||
|
||||
mountPoints.add(decodeLinuxMountPath(target));
|
||||
}
|
||||
|
||||
if (mountPoints.size === 0) mountPoints.add("/");
|
||||
return Array.from(mountPoints);
|
||||
}
|
||||
|
||||
async function queryLinuxDrives(): Promise<DriveInfo[]> {
|
||||
const mountPoints = await getLinuxMountPoints();
|
||||
const driveChecks = mountPoints.map((mountPoint) => getDriveInfo(mountPoint));
|
||||
const drives = await Promise.all(driveChecks);
|
||||
|
||||
return drives
|
||||
.filter((drive): drive is DriveInfo => drive !== null)
|
||||
.map((drive) => ({ ...drive, label: drive.root }));
|
||||
}
|
||||
|
||||
const getAvailableDrives = async (): Promise<DriveInfo[]> => {
|
||||
console.log("getAvailableDrives called, platform:", process.platform);
|
||||
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
const drives = await queryWindowsDrives();
|
||||
console.log("Parsed drives:", drives.length, "drives found");
|
||||
return drives;
|
||||
}
|
||||
|
||||
if (process.platform === "linux") {
|
||||
const drives = await queryLinuxDrives();
|
||||
console.log("Parsed drives:", drives.length, "drives found");
|
||||
return drives;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch drives:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("getAvailableDrives", getAvailableDrives);
|
||||
@@ -47,3 +47,8 @@ import "./update-launch-options";
|
||||
import "./verify-executable-path";
|
||||
import "./delete-steam-shortcut";
|
||||
import "./check-steam-shortcut";
|
||||
|
||||
//UPDATEDD
|
||||
|
||||
import "./transfer-game-files";
|
||||
import "./get-available-drives";
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel, downloadsSublevel, levelKeys } from "@main/level";
|
||||
import { findGameRootFromExe } from "../helpers/find-game-root";
|
||||
import { getDirectorySize } from "../helpers/get-directory-size";
|
||||
import { WindowManager } from "@main/services/window-manager";
|
||||
import type { GameShop, LibraryGame } from "@types";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function send(
|
||||
event: string,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
...args: unknown[]
|
||||
) {
|
||||
WindowManager.mainWindow?.webContents.send(event, shop, objectId, ...args);
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
const activeTransfers = new Map<
|
||||
string,
|
||||
{
|
||||
cancelled: boolean;
|
||||
currentStreams: Set<{ destroy(): void }>;
|
||||
pendingRejects: Array<(error: Error) => void>;
|
||||
}
|
||||
>();
|
||||
|
||||
// ── Steam‑style copy engine ─────────────────────────────────────────────────
|
||||
class SteamCopyEngine {
|
||||
private bytesCopied = 0;
|
||||
private readonly totalSize: number;
|
||||
private readonly startTime: number;
|
||||
private lastReportTime = 0;
|
||||
private readonly REPORT_INTERVAL = 100;
|
||||
private readonly BLOCK_SIZE = 1024 * 1024;
|
||||
private readonly CONCURRENCY = 8; //4 default - 8 performance/faster
|
||||
|
||||
constructor(
|
||||
private readonly id: string,
|
||||
private readonly shop: GameShop,
|
||||
private readonly objectId: string,
|
||||
totalSize: number
|
||||
) {
|
||||
this.totalSize = totalSize;
|
||||
this.startTime = Date.now();
|
||||
}
|
||||
|
||||
private reportProgress() {
|
||||
const now = Date.now();
|
||||
if (now - this.lastReportTime < this.REPORT_INTERVAL) return;
|
||||
this.lastReportTime = now;
|
||||
|
||||
const elapsedSec = (now - this.startTime) / 1000;
|
||||
const speedMBps =
|
||||
elapsedSec > 0 ? this.bytesCopied / elapsedSec / 1_048_576 : 0;
|
||||
const remaining = this.totalSize - this.bytesCopied;
|
||||
const etaSeconds = speedMBps > 0 ? remaining / (speedMBps * 1_048_576) : 0;
|
||||
const progress = this.bytesCopied / Math.max(this.totalSize, 1);
|
||||
|
||||
send("on-game-transfer-progress", this.shop, this.objectId, progress, {
|
||||
speed: Math.max(0, speedMBps),
|
||||
eta: Math.ceil(etaSeconds),
|
||||
transferred: this.bytesCopied,
|
||||
total: this.totalSize,
|
||||
});
|
||||
}
|
||||
|
||||
private addBytes(bytes: number) {
|
||||
this.bytesCopied += bytes;
|
||||
this.reportProgress();
|
||||
}
|
||||
|
||||
async moveGame(src: string, dest: string): Promise<void> {
|
||||
await fs.mkdir(dest, { recursive: true });
|
||||
await this.copyDirectory(src, dest);
|
||||
await fs.rm(src, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
private async copyDirectory(srcDir: string, destDir: string): Promise<void> {
|
||||
const entries = await fs.readdir(srcDir, { withFileTypes: true });
|
||||
|
||||
const files = entries.filter((e) => e.isFile());
|
||||
const dirs = entries.filter((e) => e.isDirectory());
|
||||
|
||||
for (let i = 0; i < files.length; i += this.CONCURRENCY) {
|
||||
await this.checkCancelled();
|
||||
|
||||
const batch = files.slice(i, i + this.CONCURRENCY);
|
||||
await Promise.all(
|
||||
batch.map((file) =>
|
||||
this.copyFileWithProgress(
|
||||
path.join(srcDir, file.name),
|
||||
path.join(destDir, file.name)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const dir of dirs) {
|
||||
await this.checkCancelled();
|
||||
|
||||
const srcPath = path.join(srcDir, dir.name);
|
||||
const destPath = path.join(destDir, dir.name);
|
||||
await fs.mkdir(destPath, { recursive: true });
|
||||
await this.copyDirectory(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkCancelled(): Promise<void> {
|
||||
const state = activeTransfers.get(this.id);
|
||||
if (state?.cancelled) throw new Error("cancelled");
|
||||
}
|
||||
|
||||
private copyFileWithProgress(
|
||||
srcFile: string,
|
||||
destFile: string
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = activeTransfers.get(this.id);
|
||||
|
||||
if (!state || state.cancelled) {
|
||||
return reject(new Error("cancelled"));
|
||||
}
|
||||
|
||||
state.pendingRejects.push(reject);
|
||||
|
||||
const readStream = createReadStream(srcFile, {
|
||||
highWaterMark: this.BLOCK_SIZE,
|
||||
});
|
||||
const writeStream = createWriteStream(destFile);
|
||||
|
||||
const streamRef = {
|
||||
destroy() {
|
||||
readStream.destroy();
|
||||
writeStream.destroy();
|
||||
},
|
||||
};
|
||||
state.currentStreams.add(streamRef);
|
||||
|
||||
const cleanup = () => {
|
||||
const idx = state.pendingRejects.indexOf(reject);
|
||||
if (idx !== -1) state.pendingRejects.splice(idx, 1);
|
||||
state.currentStreams.delete(streamRef);
|
||||
};
|
||||
|
||||
readStream.on("data", (chunk: string | Buffer) => {
|
||||
this.addBytes(Buffer.byteLength(chunk));
|
||||
});
|
||||
|
||||
readStream.on("close", () => {
|
||||
if (!state.cancelled) return;
|
||||
cleanup();
|
||||
reject(new Error("cancelled"));
|
||||
});
|
||||
|
||||
readStream.on("error", (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
});
|
||||
writeStream.on("error", (err) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
});
|
||||
writeStream.on("finish", () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
});
|
||||
|
||||
readStream.pipe(writeStream);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper functions for transfer validation ────────────────────────────────
|
||||
interface GameWithExecutable extends LibraryGame {
|
||||
executablePath: string;
|
||||
}
|
||||
|
||||
async function validateGameExists(
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
): Promise<GameWithExecutable | null> {
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
try {
|
||||
const game = (await gamesSublevel.get(gameKey)) as LibraryGame | undefined;
|
||||
if (game?.executablePath) {
|
||||
return game as GameWithExecutable;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateGameRoot(
|
||||
game: GameWithExecutable
|
||||
): Promise<
|
||||
{ valid: true; gameRoot: string } | { valid: false; error: string }
|
||||
> {
|
||||
const gameRoot = await findGameRootFromExe(game.executablePath).catch(
|
||||
() => null
|
||||
);
|
||||
if (!gameRoot) {
|
||||
return { valid: false, error: "Cannot determine game root folder" };
|
||||
}
|
||||
|
||||
return { valid: true, gameRoot };
|
||||
}
|
||||
|
||||
async function validateDestination(
|
||||
gameRoot: string,
|
||||
_destParent: string,
|
||||
targetRoot: string
|
||||
) {
|
||||
const normalizePath = (value: string) =>
|
||||
process.platform === "win32" ? value.toLowerCase() : value;
|
||||
|
||||
const resolvedGameRoot = normalizePath(path.resolve(gameRoot));
|
||||
const resolvedTargetRoot = normalizePath(path.resolve(targetRoot));
|
||||
|
||||
if (resolvedGameRoot === resolvedTargetRoot) {
|
||||
return { valid: false, error: "Game is already in this location" };
|
||||
}
|
||||
|
||||
if (resolvedTargetRoot.startsWith(`${resolvedGameRoot}${path.sep}`)) {
|
||||
return { valid: false, error: "Destination is inside source folder" };
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.stat(targetRoot);
|
||||
return {
|
||||
valid: false,
|
||||
error: "Destination folder already exists",
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Cannot access destination folder",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
async function checkDiskSpace(destParent: string, requiredSize: number) {
|
||||
try {
|
||||
if (typeof (fs as any).statfs === "function") {
|
||||
const stats = await (fs as any).statfs(destParent);
|
||||
const available = stats.bfree * stats.bsize;
|
||||
if (available < requiredSize) {
|
||||
return {
|
||||
hasSpace: false,
|
||||
error: "not_enough_space" as const,
|
||||
needed: requiredSize,
|
||||
available,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Proceed without check if unavailable
|
||||
}
|
||||
|
||||
return { hasSpace: true };
|
||||
}
|
||||
|
||||
async function updateDatabaseAfterTransfer(
|
||||
game: GameWithExecutable,
|
||||
gameKey: string,
|
||||
newExePath: string,
|
||||
gameSize: number,
|
||||
targetRoot: string
|
||||
) {
|
||||
const installedSizeInBytes = game.installedSizeInBytes ?? gameSize;
|
||||
|
||||
await gamesSublevel.put(gameKey, {
|
||||
...game,
|
||||
executablePath: newExePath,
|
||||
installedSizeInBytes,
|
||||
});
|
||||
|
||||
const download = await downloadsSublevel.get(gameKey).catch(() => null);
|
||||
if (download) {
|
||||
await downloadsSublevel
|
||||
.put(gameKey, {
|
||||
...download,
|
||||
downloadPath: path.dirname(targetRoot),
|
||||
folderName: path.basename(targetRoot),
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupOnError(id: string, targetRoot: string) {
|
||||
await fs.rm(targetRoot, { recursive: true, force: true }).catch(() => {});
|
||||
activeTransfers.delete(id);
|
||||
}
|
||||
|
||||
// ── MAIN TRANSFER EVENT ─────────────────────────────────────────────────────
|
||||
registerEvent(
|
||||
"transferGameFiles",
|
||||
async (_event, shop: GameShop, objectId: string, destParent: string) => {
|
||||
const id = `${shop}:${objectId}`;
|
||||
activeTransfers.set(id, {
|
||||
cancelled: false,
|
||||
currentStreams: new Set(),
|
||||
pendingRejects: [],
|
||||
});
|
||||
|
||||
// Validate game exists and has executable path
|
||||
const game = await validateGameExists(shop, objectId);
|
||||
if (!game) {
|
||||
activeTransfers.delete(id);
|
||||
return { ok: false, error: "Game not found or has no executable path" };
|
||||
}
|
||||
|
||||
// Validate game root
|
||||
const rootValidation = await validateGameRoot(game);
|
||||
if (!rootValidation.valid) {
|
||||
activeTransfers.delete(id);
|
||||
return { ok: false, error: rootValidation.error };
|
||||
}
|
||||
const gameRoot: string = rootValidation.gameRoot;
|
||||
|
||||
const folderName = path.basename(gameRoot);
|
||||
const targetRoot = path.join(destParent, folderName);
|
||||
|
||||
// Validate destination
|
||||
const destValidation = await validateDestination(
|
||||
gameRoot,
|
||||
destParent,
|
||||
targetRoot
|
||||
);
|
||||
if (!destValidation.valid) {
|
||||
activeTransfers.delete(id);
|
||||
return { ok: false, error: destValidation.error };
|
||||
}
|
||||
|
||||
// Send initial progress
|
||||
send("on-game-transfer-progress", shop, objectId, 0, {
|
||||
speed: 0,
|
||||
eta: 0,
|
||||
transferred: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const gameSize = await getDirectorySize(gameRoot);
|
||||
|
||||
// Check disk space
|
||||
const spaceCheck = await checkDiskSpace(destParent, gameSize);
|
||||
if (!spaceCheck.hasSpace) {
|
||||
activeTransfers.delete(id);
|
||||
return {
|
||||
ok: false,
|
||||
error: spaceCheck.error,
|
||||
needed: spaceCheck.needed,
|
||||
available: spaceCheck.available,
|
||||
};
|
||||
}
|
||||
|
||||
await fs.mkdir(destParent, { recursive: true });
|
||||
|
||||
const engine = new SteamCopyEngine(id, shop, objectId, gameSize);
|
||||
|
||||
try {
|
||||
await engine.moveGame(gameRoot, targetRoot);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
await cleanupOnError(id, targetRoot);
|
||||
|
||||
if (msg === "cancelled") {
|
||||
send("on-game-transfer-cancelled", shop, objectId);
|
||||
return { ok: false, error: "Transfer cancelled" };
|
||||
}
|
||||
|
||||
send("on-game-transfer-error", shop, objectId, msg);
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
|
||||
const relExe = path.relative(gameRoot, game.executablePath);
|
||||
const newExePath = path.join(targetRoot, relExe);
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
try {
|
||||
await updateDatabaseAfterTransfer(
|
||||
game,
|
||||
gameKey,
|
||||
newExePath,
|
||||
gameSize,
|
||||
targetRoot
|
||||
);
|
||||
} catch {
|
||||
activeTransfers.delete(id);
|
||||
send(
|
||||
"on-game-transfer-error",
|
||||
shop,
|
||||
objectId,
|
||||
"Failed to update database"
|
||||
);
|
||||
return { ok: false, error: "Failed to update database" };
|
||||
}
|
||||
|
||||
activeTransfers.delete(id);
|
||||
send("on-game-transfer-complete", shop, objectId, newExePath);
|
||||
return { ok: true, newExePath };
|
||||
}
|
||||
);
|
||||
|
||||
// ── CANCEL ────────────────────────────────────────────────────────────────────
|
||||
registerEvent(
|
||||
"cancelGameTransfer",
|
||||
async (_e, shop: GameShop, objectId: string) => {
|
||||
const s = activeTransfers.get(`${shop}:${objectId}`);
|
||||
if (s) {
|
||||
s.cancelled = true;
|
||||
s.currentStreams.forEach((stream) => stream.destroy());
|
||||
s.currentStreams.clear();
|
||||
s.pendingRejects.forEach((reject) => reject(new Error("cancelled")));
|
||||
s.pendingRejects = [];
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -30,7 +30,7 @@ export class HydraApi {
|
||||
private static instance: AxiosInstance;
|
||||
|
||||
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
|
||||
private static readonly ADD_LOG_INTERCEPTOR = false;
|
||||
private static readonly ADD_LOG_INTERCEPTOR = true;
|
||||
|
||||
private static secondsToMilliseconds(seconds: number) {
|
||||
return seconds * 1000;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Game, GameRunning, UserPreferences } from "@types";
|
||||
import axios from "axios";
|
||||
import { db, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { CloudSync } from "./cloud-sync";
|
||||
import { logger } from "./logger";
|
||||
import { logger, networkLogger } from "./logger";
|
||||
import { PowerSaveBlockerManager } from "./power-save-blocker";
|
||||
import path from "node:path";
|
||||
import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager";
|
||||
@@ -39,6 +39,27 @@ let currentTick = 1;
|
||||
|
||||
const platform = process.platform;
|
||||
|
||||
const logPlaytimeTrace = (
|
||||
event: string,
|
||||
game: Game,
|
||||
payload?: Record<string, unknown>
|
||||
) => {
|
||||
networkLogger.info("[playtime-trace]", event, {
|
||||
gameKey: levelKeys.game(game.shop, game.objectId),
|
||||
shop: game.shop,
|
||||
objectId: game.objectId,
|
||||
remoteId: game.remoteId,
|
||||
localPlayTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds ?? 0),
|
||||
unsyncedDeltaPlayTimeInMilliseconds:
|
||||
game.unsyncedDeltaPlayTimeInMilliseconds ?? 0,
|
||||
lastTimePlayed:
|
||||
game.lastTimePlayed instanceof Date
|
||||
? game.lastTimePlayed.toISOString()
|
||||
: game.lastTimePlayed,
|
||||
...payload,
|
||||
});
|
||||
};
|
||||
|
||||
const getGameExecutables = async () => {
|
||||
const gameExecutables = (
|
||||
await axios
|
||||
@@ -266,13 +287,18 @@ export const watchProcesses = async () => {
|
||||
|
||||
function onOpenGame(game: Game) {
|
||||
const now = performance.now();
|
||||
const gameKey = levelKeys.game(game.shop, game.objectId);
|
||||
|
||||
gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
|
||||
gamesPlaytime.set(gameKey, {
|
||||
lastTick: now,
|
||||
firstTick: now,
|
||||
lastSyncTick: now,
|
||||
});
|
||||
|
||||
logPlaytimeTrace("session-open", game, {
|
||||
performanceNow: now,
|
||||
});
|
||||
|
||||
// On Linux, keep the launcher visible briefly and let it auto-close itself.
|
||||
if (process.platform !== "linux") {
|
||||
WindowManager.closeGameLauncherWindow();
|
||||
@@ -297,18 +323,31 @@ function onOpenGame(game: Game) {
|
||||
);
|
||||
|
||||
if (game.remoteId) {
|
||||
trackGamePlaytime(
|
||||
game,
|
||||
game.unsyncedDeltaPlayTimeInMilliseconds ?? 0,
|
||||
new Date()
|
||||
)
|
||||
const deltaToSync = game.unsyncedDeltaPlayTimeInMilliseconds ?? 0;
|
||||
const syncTimestamp = new Date();
|
||||
|
||||
logPlaytimeTrace("open-sync-track-request", game, {
|
||||
deltaToSync,
|
||||
syncTimestamp: syncTimestamp.toISOString(),
|
||||
});
|
||||
|
||||
trackGamePlaytime(game, deltaToSync, syncTimestamp)
|
||||
.then(() => {
|
||||
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
|
||||
logPlaytimeTrace("open-sync-track-success", game, {
|
||||
deltaToSync,
|
||||
});
|
||||
|
||||
gamesSublevel.put(gameKey, {
|
||||
...game,
|
||||
unsyncedDeltaPlayTimeInMilliseconds: 0,
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((error) => {
|
||||
logPlaytimeTrace("open-sync-track-failed", game, {
|
||||
deltaToSync,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
|
||||
if (game.automaticCloudSync) {
|
||||
CloudSync.uploadSaveGame(
|
||||
@@ -319,7 +358,24 @@ function onOpenGame(game: Game) {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
createGame({ ...game, lastTimePlayed: new Date() }).catch(() => {});
|
||||
const payload = { ...game, lastTimePlayed: new Date() };
|
||||
|
||||
logPlaytimeTrace("open-sync-create-request", payload, {
|
||||
syncTimestamp:
|
||||
payload.lastTimePlayed instanceof Date
|
||||
? payload.lastTimePlayed.toISOString()
|
||||
: payload.lastTimePlayed,
|
||||
});
|
||||
|
||||
createGame(payload)
|
||||
.then(() => {
|
||||
logPlaytimeTrace("open-sync-create-success", payload);
|
||||
})
|
||||
.catch((error) => {
|
||||
logPlaytimeTrace("open-sync-create-failed", payload, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,18 +406,37 @@ function onTickGame(game: Game) {
|
||||
gamePlaytime.lastSyncTick +
|
||||
(game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
|
||||
|
||||
logPlaytimeTrace("periodic-sync-request", game, {
|
||||
method: game.remoteId ? "track" : "create",
|
||||
deltaToSync,
|
||||
performanceNow: now,
|
||||
lastSyncTick: gamePlaytime.lastSyncTick,
|
||||
lastTick: gamePlaytime.lastTick,
|
||||
});
|
||||
|
||||
const gamePromise = game.remoteId
|
||||
? trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
|
||||
: createGame(game);
|
||||
|
||||
gamePromise
|
||||
.then(() => {
|
||||
logPlaytimeTrace("periodic-sync-success", game, {
|
||||
method: game.remoteId ? "track" : "create",
|
||||
deltaToSync,
|
||||
});
|
||||
|
||||
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
|
||||
...updatedGame,
|
||||
unsyncedDeltaPlayTimeInMilliseconds: 0,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
logPlaytimeTrace("periodic-sync-failed", game, {
|
||||
method: game.remoteId ? "track" : "create",
|
||||
deltaToSync,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
|
||||
...updatedGame,
|
||||
unsyncedDeltaPlayTimeInMilliseconds: deltaToSync,
|
||||
@@ -386,6 +461,14 @@ const onCloseGame = (game: Game) => {
|
||||
|
||||
const delta = now - gamePlaytime.lastTick;
|
||||
|
||||
logPlaytimeTrace("session-close", game, {
|
||||
performanceNow: now,
|
||||
delta,
|
||||
firstTick: gamePlaytime.firstTick,
|
||||
lastTick: gamePlaytime.lastTick,
|
||||
lastSyncTick: gamePlaytime.lastSyncTick,
|
||||
});
|
||||
|
||||
const updatedGame: Game = {
|
||||
...game,
|
||||
playTimeInMilliseconds: (game.playTimeInMilliseconds ?? 0) + delta,
|
||||
@@ -411,21 +494,53 @@ const onCloseGame = (game: Game) => {
|
||||
gamePlaytime.lastSyncTick +
|
||||
(game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
|
||||
|
||||
logPlaytimeTrace("close-sync-track-request", game, {
|
||||
deltaToSync,
|
||||
syncTimestamp:
|
||||
game.lastTimePlayed instanceof Date
|
||||
? game.lastTimePlayed.toISOString()
|
||||
: game.lastTimePlayed,
|
||||
});
|
||||
|
||||
return trackGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
|
||||
.then(() => {
|
||||
logPlaytimeTrace("close-sync-track-success", game, {
|
||||
deltaToSync,
|
||||
});
|
||||
|
||||
return gamesSublevel.put(gameKey, {
|
||||
...updatedGame,
|
||||
unsyncedDeltaPlayTimeInMilliseconds: 0,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
logPlaytimeTrace("close-sync-track-failed", game, {
|
||||
deltaToSync,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return gamesSublevel.put(gameKey, {
|
||||
...updatedGame,
|
||||
unsyncedDeltaPlayTimeInMilliseconds: deltaToSync,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return createGame(game).catch(() => {});
|
||||
logPlaytimeTrace("close-sync-create-request", game, {
|
||||
syncTimestamp:
|
||||
game.lastTimePlayed instanceof Date
|
||||
? game.lastTimePlayed.toISOString()
|
||||
: game.lastTimePlayed,
|
||||
});
|
||||
|
||||
return createGame(game)
|
||||
.then(() => {
|
||||
logPlaytimeTrace("close-sync-create-success", game);
|
||||
})
|
||||
.catch((error) => {
|
||||
logPlaytimeTrace("close-sync-create-failed", game, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -799,4 +799,23 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
iterator: (sublevelName: string) =>
|
||||
ipcRenderer.invoke("leveldbIterator", sublevelName),
|
||||
},
|
||||
|
||||
//UPDATEDD
|
||||
pauseGameTransfer: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("pauseGameTransfer", shop, objectId),
|
||||
resumeGameTransfer: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("resumeGameTransfer", shop, objectId),
|
||||
cancelGameTransfer: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("cancelGameTransfer", shop, objectId),
|
||||
|
||||
// Add these to the electron object in contextBridge.exposeInMainWorld
|
||||
on: (channel: string, listener: (...args: any[]) => void) => {
|
||||
ipcRenderer.on(channel, listener);
|
||||
},
|
||||
off: (channel: string, listener: (...args: any[]) => void) => {
|
||||
ipcRenderer.off(channel, listener);
|
||||
},
|
||||
getAvailableDrives: () => ipcRenderer.invoke("getAvailableDrives"),
|
||||
transferGameFiles: (shop: GameShop, objectId: string, destParent: string) =>
|
||||
ipcRenderer.invoke("transferGameFiles", shop, objectId, destParent),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<svg width="55" height="49" viewBox="0 0 55 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.8501 29.1176L19.9196 28.3235L20.6957 25.6764L20.437 24.8823L18.1088 25.9411L14.487 24.3528L10.0891 23.0293L5.69128 23.5587L3.10431 25.6764L2.58691 29.1176L4.1391 33.6177L5.69128 36.7942L8.53695 38.9118L10.8652 38.3824L13.9696 34.9412V31.5L12.9348 29.1176V32.2941L10.8652 34.9412H7.50216L4.91519 32.2941L5.69128 28.3235L9.57174 26.4705L13.9696 27.5294L17.8501 29.1176Z" fill="white"/>
|
||||
<path d="M36.9585 29.1176L34.889 28.3235L34.1129 25.6764L34.3716 24.8823L36.6998 25.9411L40.3216 24.3528L44.7195 23.0293L49.1173 23.5587L51.7043 25.6764L52.2217 29.1176L50.6695 33.6177L49.1173 36.7942L46.2716 38.9118L43.9434 38.3824L40.839 34.9412V31.5L41.8738 29.1176V32.2941L43.9434 34.9412H47.3064L49.8934 32.2941L49.1173 28.3235L45.2369 26.4705L40.839 27.5294L36.9585 29.1176Z" fill="white"/>
|
||||
<path d="M40.3873 19.4005L38.8784 19.9071L38.7685 19.0593L38.5553 17.7049L39.811 14.777L41.6564 11.5721L44.483 9.44001L47.1023 9.23162L49.2244 10.9371L50.7089 14.4032L51.4925 17.1031L50.9665 19.9071L49.3381 20.8916L45.7182 20.6206L43.8957 18.6282L43.2332 16.675L44.9154 18.5142L47.5156 18.8992L49.4627 17.0344L49.5586 14.0673L47.0064 12.1987L43.7784 13.2776L41.7929 16.3293L40.3873 19.4005Z" fill="white"/>
|
||||
<path d="M14.0238 19.4005L15.5327 19.9071L15.6426 19.0593L15.8559 17.7049L14.6001 14.777L12.7548 11.5721L9.92812 9.44001L7.30879 9.23162L5.18676 10.9371L3.70221 14.4032L2.91861 17.1031L3.44468 19.9071L5.07308 20.8916L8.69292 20.6206L10.5154 18.6282L11.178 16.675L9.4957 18.5142L6.89555 18.8992L4.94841 17.0344L4.8525 14.0673L7.4047 12.1987L10.6327 13.2776L12.6182 16.3293L14.0238 19.4005Z" fill="white"/>
|
||||
<path d="M19.9494 36.4343L22.554 34.3372L21.9876 34.0884L20.9528 31.9707L19.9494 32.5001L17.5898 34.0884L15.3904 37.377L14.744 40.4414L15.2615 43.0885L17.0724 45.4709L20.9528 47.3238L23.6932 46.5297L25.435 44.7653L26.0028 42.9192L25.6093 39.0508L25.0919 37.7913L23.6932 37.0002L24.3158 39.105L24.3158 42.0296L22.3597 43.9181L19.9494 43.9181L18.0225 42.0296L17.4949 39.105L19.9494 36.4343Z" fill="white"/>
|
||||
<path d="M35.0955 36.4343L32.4909 34.3372L33.0573 34.0884L34.0921 31.9707L35.0955 32.5001L37.4552 34.0884L39.6545 37.377L40.3009 40.4414L39.7834 43.0885L37.9725 45.4709L34.0921 47.3238L31.3518 46.5297L29.6099 44.7653L29.0421 42.9192L29.4356 39.0508L29.953 37.7913L31.3518 37.0002L30.7291 39.105L30.7291 42.0296L32.6852 43.9181L35.0955 43.9181L37.0224 42.0296L37.55 39.105L35.0955 36.4343Z" fill="white"/>
|
||||
<path d="M18.8447 8.70593V5.79413L20.1382 5H22.9839L27.3817 5.79413L31.7796 5H34.6252L35.9187 5.79413V8.70593L38.7644 11.353L37.2122 15.0589L37.9883 20.8825L35.9187 23.0002L33.3317 23.7943L32.8144 26.1767L33.3317 28.8238L32.8144 30.9415L31.7796 33.0591L30.2274 33.8533H27.3817H24.536L22.9839 33.0591L21.9491 30.9415L21.4317 28.8238L21.9491 26.1767L21.4317 23.7943L18.8447 23.0002L16.7751 20.8825L17.5512 15.0589L15.999 11.353L18.8447 8.70593Z" fill="white"/>
|
||||
<path d="M15.5205 6.88232L16.2966 8.73528L17.5901 7.67645V4.76465L15.5205 6.88232Z" fill="white"/>
|
||||
<path d="M39.2861 6.88232L38.51 8.73528L37.2166 7.67645V4.76465L39.2861 6.88232Z" fill="white"/>
|
||||
<path d="M18.3667 2.11767L18.6254 4.23534L19.4015 3.70593H20.9537L20.4363 2.11767L17.0732 0L18.3667 2.11767Z" fill="white"/>
|
||||
<path d="M35.6997 2.11767L35.441 4.23534L34.6649 3.70593H33.1127L33.6301 2.11767L36.9932 0L35.6997 2.11767Z" fill="white"/>
|
||||
</svg>
|
||||
<svg width="55" height="49" viewBox="0 0 55 49" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.8501 29.1176L19.9196 28.3235L20.6957 25.6764L20.437 24.8823L18.1088 25.9411L14.487 24.3528L10.0891 23.0293L5.69128 23.5587L3.10431 25.6764L2.58691 29.1176L4.1391 33.6177L5.69128 36.7942L8.53695 38.9118L10.8652 38.3824L13.9696 34.9412V31.5L12.9348 29.1176V32.2941L10.8652 34.9412H7.50216L4.91519 32.2941L5.69128 28.3235L9.57174 26.4705L13.9696 27.5294L17.8501 29.1176Z" fill="white"/>
|
||||
<path d="M36.9585 29.1176L34.889 28.3235L34.1129 25.6764L34.3716 24.8823L36.6998 25.9411L40.3216 24.3528L44.7195 23.0293L49.1173 23.5587L51.7043 25.6764L52.2217 29.1176L50.6695 33.6177L49.1173 36.7942L46.2716 38.9118L43.9434 38.3824L40.839 34.9412V31.5L41.8738 29.1176V32.2941L43.9434 34.9412H47.3064L49.8934 32.2941L49.1173 28.3235L45.2369 26.4705L40.839 27.5294L36.9585 29.1176Z" fill="white"/>
|
||||
<path d="M40.3873 19.4005L38.8784 19.9071L38.7685 19.0593L38.5553 17.7049L39.811 14.777L41.6564 11.5721L44.483 9.44001L47.1023 9.23162L49.2244 10.9371L50.7089 14.4032L51.4925 17.1031L50.9665 19.9071L49.3381 20.8916L45.7182 20.6206L43.8957 18.6282L43.2332 16.675L44.9154 18.5142L47.5156 18.8992L49.4627 17.0344L49.5586 14.0673L47.0064 12.1987L43.7784 13.2776L41.7929 16.3293L40.3873 19.4005Z" fill="white"/>
|
||||
<path d="M14.0238 19.4005L15.5327 19.9071L15.6426 19.0593L15.8559 17.7049L14.6001 14.777L12.7548 11.5721L9.92812 9.44001L7.30879 9.23162L5.18676 10.9371L3.70221 14.4032L2.91861 17.1031L3.44468 19.9071L5.07308 20.8916L8.69292 20.6206L10.5154 18.6282L11.178 16.675L9.4957 18.5142L6.89555 18.8992L4.94841 17.0344L4.8525 14.0673L7.4047 12.1987L10.6327 13.2776L12.6182 16.3293L14.0238 19.4005Z" fill="white"/>
|
||||
<path d="M19.9494 36.4343L22.554 34.3372L21.9876 34.0884L20.9528 31.9707L19.9494 32.5001L17.5898 34.0884L15.3904 37.377L14.744 40.4414L15.2615 43.0885L17.0724 45.4709L20.9528 47.3238L23.6932 46.5297L25.435 44.7653L26.0028 42.9192L25.6093 39.0508L25.0919 37.7913L23.6932 37.0002L24.3158 39.105L24.3158 42.0296L22.3597 43.9181L19.9494 43.9181L18.0225 42.0296L17.4949 39.105L19.9494 36.4343Z" fill="white"/>
|
||||
<path d="M35.0955 36.4343L32.4909 34.3372L33.0573 34.0884L34.0921 31.9707L35.0955 32.5001L37.4552 34.0884L39.6545 37.377L40.3009 40.4414L39.7834 43.0885L37.9725 45.4709L34.0921 47.3238L31.3518 46.5297L29.6099 44.7653L29.0421 42.9192L29.4356 39.0508L29.953 37.7913L31.3518 37.0002L30.7291 39.105L30.7291 42.0296L32.6852 43.9181L35.0955 43.9181L37.0224 42.0296L37.55 39.105L35.0955 36.4343Z" fill="white"/>
|
||||
<path d="M18.8447 8.70593V5.79413L20.1382 5H22.9839L27.3817 5.79413L31.7796 5H34.6252L35.9187 5.79413V8.70593L38.7644 11.353L37.2122 15.0589L37.9883 20.8825L35.9187 23.0002L33.3317 23.7943L32.8144 26.1767L33.3317 28.8238L32.8144 30.9415L31.7796 33.0591L30.2274 33.8533H27.3817H24.536L22.9839 33.0591L21.9491 30.9415L21.4317 28.8238L21.9491 26.1767L21.4317 23.7943L18.8447 23.0002L16.7751 20.8825L17.5512 15.0589L15.999 11.353L18.8447 8.70593Z" fill="white"/>
|
||||
<path d="M15.5205 6.88232L16.2966 8.73528L17.5901 7.67645V4.76465L15.5205 6.88232Z" fill="white"/>
|
||||
<path d="M39.2861 6.88232L38.51 8.73528L37.2166 7.67645V4.76465L39.2861 6.88232Z" fill="white"/>
|
||||
<path d="M18.3667 2.11767L18.6254 4.23534L19.4015 3.70593H20.9537L20.4363 2.11767L17.0732 0L18.3667 2.11767Z" fill="white"/>
|
||||
<path d="M35.6997 2.11767L35.441 4.23534L34.6649 3.70593H33.1127L33.6301 2.11767L36.9932 0L35.6997 2.11767Z" fill="white"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.4 KiB |
@@ -1,5 +1,5 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame 933">
|
||||
<path id="Vector" d="M29.3333 10.5H26.8333V8.83333C26.8333 8.61232 26.7455 8.40036 26.5893 8.24408C26.433 8.0878 26.221 8 26 8H11C10.779 8 10.567 8.0878 10.4107 8.24408C10.2545 8.40036 10.1667 8.61232 10.1667 8.83333V10.5H7.66667C7.22464 10.5 6.80072 10.6756 6.48816 10.9882C6.17559 11.3007 6 11.7246 6 12.1667V13.8333C6 14.9384 6.43899 15.9982 7.22039 16.7796C7.6073 17.1665 8.06663 17.4734 8.57215 17.6828C9.07768 17.8922 9.61949 18 10.1667 18H10.5469C11.0378 19.5556 11.9737 20.9333 13.2391 21.9628C14.5044 22.9923 16.0437 23.6285 17.6667 23.7927V26.3333H15.1667C14.9457 26.3333 14.7337 26.4211 14.5774 26.5774C14.4211 26.7337 14.3333 26.9457 14.3333 27.1667C14.3333 27.3877 14.4211 27.5996 14.5774 27.7559C14.7337 27.9122 14.9457 28 15.1667 28H21.8333C22.0543 28 22.2663 27.9122 22.4226 27.7559C22.5789 27.5996 22.6667 27.3877 22.6667 27.1667C22.6667 26.9457 22.5789 26.7337 22.4226 26.5774C22.2663 26.4211 22.0543 26.3333 21.8333 26.3333H19.3333V23.7896C22.6604 23.4531 25.4208 21.1187 26.425 18H26.8333C27.9384 18 28.9982 17.561 29.7796 16.7796C30.561 15.9982 31 14.9384 31 13.8333V12.1667C31 11.7246 30.8244 11.3007 30.5118 10.9882C30.1993 10.6756 29.7754 10.5 29.3333 10.5ZM10.1667 16.3333C9.50363 16.3333 8.86774 16.0699 8.3989 15.6011C7.93006 15.1323 7.66667 14.4964 7.66667 13.8333V12.1667H10.1667V15.5C10.1667 15.7778 10.1802 16.0556 10.2073 16.3333H10.1667ZM29.3333 13.8333C29.3333 14.4964 29.0699 15.1323 28.6011 15.6011C28.1323 16.0699 27.4964 16.3333 26.8333 16.3333H26.7812C26.8154 16.0255 26.8328 15.716 26.8333 15.4062V12.1667H29.3333V13.8333Z" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame 933">
|
||||
<path id="Vector" d="M29.3333 10.5H26.8333V8.83333C26.8333 8.61232 26.7455 8.40036 26.5893 8.24408C26.433 8.0878 26.221 8 26 8H11C10.779 8 10.567 8.0878 10.4107 8.24408C10.2545 8.40036 10.1667 8.61232 10.1667 8.83333V10.5H7.66667C7.22464 10.5 6.80072 10.6756 6.48816 10.9882C6.17559 11.3007 6 11.7246 6 12.1667V13.8333C6 14.9384 6.43899 15.9982 7.22039 16.7796C7.6073 17.1665 8.06663 17.4734 8.57215 17.6828C9.07768 17.8922 9.61949 18 10.1667 18H10.5469C11.0378 19.5556 11.9737 20.9333 13.2391 21.9628C14.5044 22.9923 16.0437 23.6285 17.6667 23.7927V26.3333H15.1667C14.9457 26.3333 14.7337 26.4211 14.5774 26.5774C14.4211 26.7337 14.3333 26.9457 14.3333 27.1667C14.3333 27.3877 14.4211 27.5996 14.5774 27.7559C14.7337 27.9122 14.9457 28 15.1667 28H21.8333C22.0543 28 22.2663 27.9122 22.4226 27.7559C22.5789 27.5996 22.6667 27.3877 22.6667 27.1667C22.6667 26.9457 22.5789 26.7337 22.4226 26.5774C22.2663 26.4211 22.0543 26.3333 21.8333 26.3333H19.3333V23.7896C22.6604 23.4531 25.4208 21.1187 26.425 18H26.8333C27.9384 18 28.9982 17.561 29.7796 16.7796C30.561 15.9982 31 14.9384 31 13.8333V12.1667C31 11.7246 30.8244 11.3007 30.5118 10.9882C30.1993 10.6756 29.7754 10.5 29.3333 10.5ZM10.1667 16.3333C9.50363 16.3333 8.86774 16.0699 8.3989 15.6011C7.93006 15.1323 7.66667 14.4964 7.66667 13.8333V12.1667H10.1667V15.5C10.1667 15.7778 10.1802 16.0556 10.2073 16.3333H10.1667ZM29.3333 13.8333C29.3333 14.4964 29.0699 15.1323 28.6011 15.6011C28.1323 16.0699 27.4964 16.3333 26.8333 16.3333H26.7812C26.8154 16.0255 26.8328 15.716 26.8333 15.4062V12.1667H29.3333V13.8333Z" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,24 +1,24 @@
|
||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Meteor">
|
||||
<g id="Vector">
|
||||
<path d="M10.6242 13.0003C10.6242 13.6184 10.4409 14.2226 10.0975 14.7365C9.75415 15.2504 9.26609 15.6509 8.69507 15.8875C8.12405 16.124 7.49572 16.1859 6.88953 16.0653C6.28334 15.9447 5.72652 15.6471 5.28948 15.2101C4.85244 14.773 4.55481 14.2162 4.43423 13.61C4.31366 13.0038 4.37554 12.3755 4.61206 11.8045C4.84859 11.2334 5.24913 10.7454 5.76303 10.402C6.27693 10.0586 6.88112 9.87535 7.49919 9.87535C8.32799 9.87535 9.12285 10.2046 9.7089 10.7906C10.2949 11.3767 10.6242 12.1715 10.6242 13.0003ZM16.432 10.0582L12.682 13.8082C12.5647 13.9254 12.4988 14.0845 12.4988 14.2503C12.4988 14.4162 12.5647 14.5753 12.682 14.6925C12.7993 14.8098 12.9583 14.8757 13.1242 14.8757C13.29 14.8757 13.4491 14.8098 13.5664 14.6925L17.3164 10.9425C17.3744 10.8845 17.4205 10.8155 17.4519 10.7397C17.4834 10.6638 17.4995 10.5825 17.4995 10.5003C17.4995 10.4182 17.4834 10.3369 17.4519 10.261C17.4205 10.1852 17.3744 10.1162 17.3164 10.0582C17.2583 10.0001 17.1894 9.95403 17.1135 9.9226C17.0376 9.89118 16.9563 9.875 16.8742 9.875C16.7921 9.875 16.7107 9.89118 16.6349 9.9226C16.559 9.95403 16.4901 10.0001 16.432 10.0582ZM14.8164 9.06754C14.8744 9.00947 14.9205 8.94053 14.9519 8.86466C14.9834 8.78879 14.9995 8.70747 14.9995 8.62535C14.9995 8.54323 14.9834 8.46191 14.9519 8.38604C14.9205 8.31017 14.8744 8.24123 14.8164 8.18316C14.7583 8.12509 14.6894 8.07903 14.6135 8.0476C14.5376 8.01617 14.4563 8 14.3742 8C14.2921 8 14.2107 8.01617 14.1349 8.0476C14.059 8.07903 13.9901 8.12509 13.932 8.18316L12.057 10.0582C11.9397 10.1754 11.8738 10.3345 11.8738 10.5003C11.8738 10.6662 11.9397 10.8253 12.057 10.9425C12.1743 11.0598 12.3333 11.1257 12.4992 11.1257C12.665 11.1257 12.8241 11.0598 12.9414 10.9425L14.8164 9.06754ZM17.9414 5.05816C17.8833 5.00005 17.8144 4.95395 17.7385 4.9225C17.6627 4.89105 17.5813 4.87486 17.4992 4.87486C17.4171 4.87486 17.3357 4.89105 17.2599 4.9225C17.184 4.95395 17.115 5.00005 17.057 5.05816L15.807 6.30816C15.6897 6.42544 15.6238 6.5845 15.6238 6.75035C15.6238 6.9162 15.6897 7.07526 15.807 7.19254C15.9243 7.30981 16.0833 7.37569 16.2492 7.3757C16.415 7.3757 16.5741 7.30981 16.6914 7.19254L17.9414 5.94254C17.9995 5.88449 18.0456 5.81556 18.077 5.73969C18.1085 5.66381 18.1247 5.58248 18.1247 5.50035C18.1247 5.41821 18.1085 5.33688 18.077 5.26101C18.0456 5.18514 17.9995 5.11621 17.9414 5.05816ZM9.557 8.44254C9.61505 8.50065 9.68398 8.54674 9.75985 8.5782C9.83572 8.60965 9.91705 8.62584 9.99919 8.62584C10.0813 8.62584 10.1627 8.60965 10.2385 8.5782C10.3144 8.54674 10.3833 8.50065 10.4414 8.44254L16.0664 2.81754C16.1244 2.75947 16.1705 2.69053 16.2019 2.61466C16.2334 2.53879 16.2495 2.45747 16.2495 2.37535C16.2495 2.29323 16.2334 2.21191 16.2019 2.13604C16.1705 2.06017 16.1244 1.99123 16.0664 1.93316C16.0083 1.87509 15.9394 1.82903 15.8635 1.7976C15.7876 1.76618 15.7063 1.75 15.6242 1.75C15.5421 1.75 15.4607 1.76618 15.3849 1.7976C15.309 1.82903 15.2401 1.87509 15.182 1.93316L9.557 7.55816C9.49889 7.61621 9.45279 7.68514 9.42134 7.76101C9.38989 7.83688 9.3737 7.91821 9.3737 8.00035C9.3737 8.08248 9.38989 8.16381 9.42134 8.23969C9.45279 8.31556 9.49889 8.38449 9.557 8.44254ZM10.5929 16.0941C9.77242 16.9146 8.65957 17.3756 7.49919 17.3756C6.33881 17.3756 5.22595 16.9146 4.40544 16.0941C3.58492 15.2736 3.12396 14.1607 3.12396 13.0003C3.12396 11.84 3.58492 10.7271 4.40544 9.9066L10.8703 3.44253C10.9284 3.38447 10.9744 3.31553 11.0058 3.23966C11.0373 3.16379 11.0534 3.08247 11.0534 3.00035C11.0534 2.91823 11.0373 2.83691 11.0058 2.76104C10.9744 2.68517 10.9284 2.61623 10.8703 2.55816C10.8122 2.50009 10.7433 2.45403 10.6674 2.4226C10.5915 2.39118 10.5102 2.375 10.4281 2.375C10.346 2.375 10.2647 2.39118 10.1888 2.4226C10.1129 2.45403 10.044 2.50009 9.98591 2.55816L3.52184 9.023C2.99253 9.54377 2.57156 10.1642 2.28322 10.8484C1.99488 11.5327 1.84487 12.2673 1.84184 13.0098C1.83882 13.7524 1.98284 14.4882 2.2656 15.1747C2.54836 15.8613 2.96427 16.4852 3.48932 17.0102C4.01438 17.5353 4.63819 17.9512 5.32479 18.2339C6.01138 18.5167 6.74717 18.6607 7.4897 18.6577C8.23223 18.6547 8.96682 18.5047 9.65109 18.2163C10.3354 17.928 10.9558 17.507 11.4765 16.9777C11.5888 16.8595 11.6505 16.7022 11.6484 16.5392C11.6463 16.3762 11.5806 16.2205 11.4654 16.1053C11.3501 15.99 11.1944 15.9243 11.0314 15.9223C10.8684 15.9202 10.7111 15.9818 10.5929 16.0941Z" fill="black"/>
|
||||
<path d="M10.6242 13.0003C10.6242 13.6184 10.4409 14.2226 10.0975 14.7365C9.75415 15.2504 9.26609 15.6509 8.69507 15.8875C8.12405 16.124 7.49572 16.1859 6.88953 16.0653C6.28334 15.9447 5.72652 15.6471 5.28948 15.2101C4.85244 14.773 4.55481 14.2162 4.43423 13.61C4.31366 13.0038 4.37554 12.3755 4.61206 11.8045C4.84859 11.2334 5.24913 10.7454 5.76303 10.402C6.27693 10.0586 6.88112 9.87535 7.49919 9.87535C8.32799 9.87535 9.12285 10.2046 9.7089 10.7906C10.2949 11.3767 10.6242 12.1715 10.6242 13.0003ZM16.432 10.0582L12.682 13.8082C12.5647 13.9254 12.4988 14.0845 12.4988 14.2503C12.4988 14.4162 12.5647 14.5753 12.682 14.6925C12.7993 14.8098 12.9583 14.8757 13.1242 14.8757C13.29 14.8757 13.4491 14.8098 13.5664 14.6925L17.3164 10.9425C17.3744 10.8845 17.4205 10.8155 17.4519 10.7397C17.4834 10.6638 17.4995 10.5825 17.4995 10.5003C17.4995 10.4182 17.4834 10.3369 17.4519 10.261C17.4205 10.1852 17.3744 10.1162 17.3164 10.0582C17.2583 10.0001 17.1894 9.95403 17.1135 9.9226C17.0376 9.89118 16.9563 9.875 16.8742 9.875C16.7921 9.875 16.7107 9.89118 16.6349 9.9226C16.559 9.95403 16.4901 10.0001 16.432 10.0582ZM14.8164 9.06754C14.8744 9.00947 14.9205 8.94053 14.9519 8.86466C14.9834 8.78879 14.9995 8.70747 14.9995 8.62535C14.9995 8.54323 14.9834 8.46191 14.9519 8.38604C14.9205 8.31017 14.8744 8.24123 14.8164 8.18316C14.7583 8.12509 14.6894 8.07903 14.6135 8.0476C14.5376 8.01617 14.4563 8 14.3742 8C14.2921 8 14.2107 8.01617 14.1349 8.0476C14.059 8.07903 13.9901 8.12509 13.932 8.18316L12.057 10.0582C11.9397 10.1754 11.8738 10.3345 11.8738 10.5003C11.8738 10.6662 11.9397 10.8253 12.057 10.9425C12.1743 11.0598 12.3333 11.1257 12.4992 11.1257C12.665 11.1257 12.8241 11.0598 12.9414 10.9425L14.8164 9.06754ZM17.9414 5.05816C17.8833 5.00005 17.8144 4.95395 17.7385 4.9225C17.6627 4.89105 17.5813 4.87486 17.4992 4.87486C17.4171 4.87486 17.3357 4.89105 17.2599 4.9225C17.184 4.95395 17.115 5.00005 17.057 5.05816L15.807 6.30816C15.6897 6.42544 15.6238 6.5845 15.6238 6.75035C15.6238 6.9162 15.6897 7.07526 15.807 7.19254C15.9243 7.30981 16.0833 7.37569 16.2492 7.3757C16.415 7.3757 16.5741 7.30981 16.6914 7.19254L17.9414 5.94254C17.9995 5.88449 18.0456 5.81556 18.077 5.73969C18.1085 5.66381 18.1247 5.58248 18.1247 5.50035C18.1247 5.41821 18.1085 5.33688 18.077 5.26101C18.0456 5.18514 17.9995 5.11621 17.9414 5.05816ZM9.557 8.44254C9.61505 8.50065 9.68398 8.54674 9.75985 8.5782C9.83572 8.60965 9.91705 8.62584 9.99919 8.62584C10.0813 8.62584 10.1627 8.60965 10.2385 8.5782C10.3144 8.54674 10.3833 8.50065 10.4414 8.44254L16.0664 2.81754C16.1244 2.75947 16.1705 2.69053 16.2019 2.61466C16.2334 2.53879 16.2495 2.45747 16.2495 2.37535C16.2495 2.29323 16.2334 2.21191 16.2019 2.13604C16.1705 2.06017 16.1244 1.99123 16.0664 1.93316C16.0083 1.87509 15.9394 1.82903 15.8635 1.7976C15.7876 1.76618 15.7063 1.75 15.6242 1.75C15.5421 1.75 15.4607 1.76618 15.3849 1.7976C15.309 1.82903 15.2401 1.87509 15.182 1.93316L9.557 7.55816C9.49889 7.61621 9.45279 7.68514 9.42134 7.76101C9.38989 7.83688 9.3737 7.91821 9.3737 8.00035C9.3737 8.08248 9.38989 8.16381 9.42134 8.23969C9.45279 8.31556 9.49889 8.38449 9.557 8.44254ZM10.5929 16.0941C9.77242 16.9146 8.65957 17.3756 7.49919 17.3756C6.33881 17.3756 5.22595 16.9146 4.40544 16.0941C3.58492 15.2736 3.12396 14.1607 3.12396 13.0003C3.12396 11.84 3.58492 10.7271 4.40544 9.9066L10.8703 3.44253C10.9284 3.38447 10.9744 3.31553 11.0058 3.23966C11.0373 3.16379 11.0534 3.08247 11.0534 3.00035C11.0534 2.91823 11.0373 2.83691 11.0058 2.76104C10.9744 2.68517 10.9284 2.61623 10.8703 2.55816C10.8122 2.50009 10.7433 2.45403 10.6674 2.4226C10.5915 2.39118 10.5102 2.375 10.4281 2.375C10.346 2.375 10.2647 2.39118 10.1888 2.4226C10.1129 2.45403 10.044 2.50009 9.98591 2.55816L3.52184 9.023C2.99253 9.54377 2.57156 10.1642 2.28322 10.8484C1.99488 11.5327 1.84487 12.2673 1.84184 13.0098C1.83882 13.7524 1.98284 14.4882 2.2656 15.1747C2.54836 15.8613 2.96427 16.4852 3.48932 17.0102C4.01438 17.5353 4.63819 17.9512 5.32479 18.2339C6.01138 18.5167 6.74717 18.6607 7.4897 18.6577C8.23223 18.6547 8.96682 18.5047 9.65109 18.2163C10.3354 17.928 10.9558 17.507 11.4765 16.9777C11.5888 16.8595 11.6505 16.7022 11.6484 16.5392C11.6463 16.3762 11.5806 16.2205 11.4654 16.1053C11.3501 15.99 11.1944 15.9243 11.0314 15.9223C10.8684 15.9202 10.7111 15.9818 10.5929 16.0941Z" fill="url(#paint0_linear_2850_16638)"/>
|
||||
<path d="M10.6242 13.0003C10.6242 13.6184 10.4409 14.2226 10.0975 14.7365C9.75415 15.2504 9.26609 15.6509 8.69507 15.8875C8.12405 16.124 7.49572 16.1859 6.88953 16.0653C6.28334 15.9447 5.72652 15.6471 5.28948 15.2101C4.85244 14.773 4.55481 14.2162 4.43423 13.61C4.31366 13.0038 4.37554 12.3755 4.61206 11.8045C4.84859 11.2334 5.24913 10.7454 5.76303 10.402C6.27693 10.0586 6.88112 9.87535 7.49919 9.87535C8.32799 9.87535 9.12285 10.2046 9.7089 10.7906C10.2949 11.3767 10.6242 12.1715 10.6242 13.0003ZM16.432 10.0582L12.682 13.8082C12.5647 13.9254 12.4988 14.0845 12.4988 14.2503C12.4988 14.4162 12.5647 14.5753 12.682 14.6925C12.7993 14.8098 12.9583 14.8757 13.1242 14.8757C13.29 14.8757 13.4491 14.8098 13.5664 14.6925L17.3164 10.9425C17.3744 10.8845 17.4205 10.8155 17.4519 10.7397C17.4834 10.6638 17.4995 10.5825 17.4995 10.5003C17.4995 10.4182 17.4834 10.3369 17.4519 10.261C17.4205 10.1852 17.3744 10.1162 17.3164 10.0582C17.2583 10.0001 17.1894 9.95403 17.1135 9.9226C17.0376 9.89118 16.9563 9.875 16.8742 9.875C16.7921 9.875 16.7107 9.89118 16.6349 9.9226C16.559 9.95403 16.4901 10.0001 16.432 10.0582ZM14.8164 9.06754C14.8744 9.00947 14.9205 8.94053 14.9519 8.86466C14.9834 8.78879 14.9995 8.70747 14.9995 8.62535C14.9995 8.54323 14.9834 8.46191 14.9519 8.38604C14.9205 8.31017 14.8744 8.24123 14.8164 8.18316C14.7583 8.12509 14.6894 8.07903 14.6135 8.0476C14.5376 8.01617 14.4563 8 14.3742 8C14.2921 8 14.2107 8.01617 14.1349 8.0476C14.059 8.07903 13.9901 8.12509 13.932 8.18316L12.057 10.0582C11.9397 10.1754 11.8738 10.3345 11.8738 10.5003C11.8738 10.6662 11.9397 10.8253 12.057 10.9425C12.1743 11.0598 12.3333 11.1257 12.4992 11.1257C12.665 11.1257 12.8241 11.0598 12.9414 10.9425L14.8164 9.06754ZM17.9414 5.05816C17.8833 5.00005 17.8144 4.95395 17.7385 4.9225C17.6627 4.89105 17.5813 4.87486 17.4992 4.87486C17.4171 4.87486 17.3357 4.89105 17.2599 4.9225C17.184 4.95395 17.115 5.00005 17.057 5.05816L15.807 6.30816C15.6897 6.42544 15.6238 6.5845 15.6238 6.75035C15.6238 6.9162 15.6897 7.07526 15.807 7.19254C15.9243 7.30981 16.0833 7.37569 16.2492 7.3757C16.415 7.3757 16.5741 7.30981 16.6914 7.19254L17.9414 5.94254C17.9995 5.88449 18.0456 5.81556 18.077 5.73969C18.1085 5.66381 18.1247 5.58248 18.1247 5.50035C18.1247 5.41821 18.1085 5.33688 18.077 5.26101C18.0456 5.18514 17.9995 5.11621 17.9414 5.05816ZM9.557 8.44254C9.61505 8.50065 9.68398 8.54674 9.75985 8.5782C9.83572 8.60965 9.91705 8.62584 9.99919 8.62584C10.0813 8.62584 10.1627 8.60965 10.2385 8.5782C10.3144 8.54674 10.3833 8.50065 10.4414 8.44254L16.0664 2.81754C16.1244 2.75947 16.1705 2.69053 16.2019 2.61466C16.2334 2.53879 16.2495 2.45747 16.2495 2.37535C16.2495 2.29323 16.2334 2.21191 16.2019 2.13604C16.1705 2.06017 16.1244 1.99123 16.0664 1.93316C16.0083 1.87509 15.9394 1.82903 15.8635 1.7976C15.7876 1.76618 15.7063 1.75 15.6242 1.75C15.5421 1.75 15.4607 1.76618 15.3849 1.7976C15.309 1.82903 15.2401 1.87509 15.182 1.93316L9.557 7.55816C9.49889 7.61621 9.45279 7.68514 9.42134 7.76101C9.38989 7.83688 9.3737 7.91821 9.3737 8.00035C9.3737 8.08248 9.38989 8.16381 9.42134 8.23969C9.45279 8.31556 9.49889 8.38449 9.557 8.44254ZM10.5929 16.0941C9.77242 16.9146 8.65957 17.3756 7.49919 17.3756C6.33881 17.3756 5.22595 16.9146 4.40544 16.0941C3.58492 15.2736 3.12396 14.1607 3.12396 13.0003C3.12396 11.84 3.58492 10.7271 4.40544 9.9066L10.8703 3.44253C10.9284 3.38447 10.9744 3.31553 11.0058 3.23966C11.0373 3.16379 11.0534 3.08247 11.0534 3.00035C11.0534 2.91823 11.0373 2.83691 11.0058 2.76104C10.9744 2.68517 10.9284 2.61623 10.8703 2.55816C10.8122 2.50009 10.7433 2.45403 10.6674 2.4226C10.5915 2.39118 10.5102 2.375 10.4281 2.375C10.346 2.375 10.2647 2.39118 10.1888 2.4226C10.1129 2.45403 10.044 2.50009 9.98591 2.55816L3.52184 9.023C2.99253 9.54377 2.57156 10.1642 2.28322 10.8484C1.99488 11.5327 1.84487 12.2673 1.84184 13.0098C1.83882 13.7524 1.98284 14.4882 2.2656 15.1747C2.54836 15.8613 2.96427 16.4852 3.48932 17.0102C4.01438 17.5353 4.63819 17.9512 5.32479 18.2339C6.01138 18.5167 6.74717 18.6607 7.4897 18.6577C8.23223 18.6547 8.96682 18.5047 9.65109 18.2163C10.3354 17.928 10.9558 17.507 11.4765 16.9777C11.5888 16.8595 11.6505 16.7022 11.6484 16.5392C11.6463 16.3762 11.5806 16.2205 11.4654 16.1053C11.3501 15.99 11.1944 15.9243 11.0314 15.9223C10.8684 15.9202 10.7111 15.9818 10.5929 16.0941Z" stroke="url(#paint1_linear_2850_16638)" stroke-width="0.3"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2850_16638" x1="1.95109" y1="1.75" x2="21.5698" y2="11.5208" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0CF1CA"/>
|
||||
<stop offset="0.264423" stop-color="#0BD2B0"/>
|
||||
<stop offset="0.307692" stop-color="#0CF1CA"/>
|
||||
<stop offset="0.427885" stop-color="#0CF1CA"/>
|
||||
<stop offset="0.466346" stop-color="#0FAF94"/>
|
||||
<stop offset="0.591346" stop-color="#0CA288"/>
|
||||
<stop offset="1" stop-color="#086253"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_2850_16638" x1="1.8418" y1="2.25694" x2="21.3121" y2="11.25" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Meteor">
|
||||
<g id="Vector">
|
||||
<path d="M10.6242 13.0003C10.6242 13.6184 10.4409 14.2226 10.0975 14.7365C9.75415 15.2504 9.26609 15.6509 8.69507 15.8875C8.12405 16.124 7.49572 16.1859 6.88953 16.0653C6.28334 15.9447 5.72652 15.6471 5.28948 15.2101C4.85244 14.773 4.55481 14.2162 4.43423 13.61C4.31366 13.0038 4.37554 12.3755 4.61206 11.8045C4.84859 11.2334 5.24913 10.7454 5.76303 10.402C6.27693 10.0586 6.88112 9.87535 7.49919 9.87535C8.32799 9.87535 9.12285 10.2046 9.7089 10.7906C10.2949 11.3767 10.6242 12.1715 10.6242 13.0003ZM16.432 10.0582L12.682 13.8082C12.5647 13.9254 12.4988 14.0845 12.4988 14.2503C12.4988 14.4162 12.5647 14.5753 12.682 14.6925C12.7993 14.8098 12.9583 14.8757 13.1242 14.8757C13.29 14.8757 13.4491 14.8098 13.5664 14.6925L17.3164 10.9425C17.3744 10.8845 17.4205 10.8155 17.4519 10.7397C17.4834 10.6638 17.4995 10.5825 17.4995 10.5003C17.4995 10.4182 17.4834 10.3369 17.4519 10.261C17.4205 10.1852 17.3744 10.1162 17.3164 10.0582C17.2583 10.0001 17.1894 9.95403 17.1135 9.9226C17.0376 9.89118 16.9563 9.875 16.8742 9.875C16.7921 9.875 16.7107 9.89118 16.6349 9.9226C16.559 9.95403 16.4901 10.0001 16.432 10.0582ZM14.8164 9.06754C14.8744 9.00947 14.9205 8.94053 14.9519 8.86466C14.9834 8.78879 14.9995 8.70747 14.9995 8.62535C14.9995 8.54323 14.9834 8.46191 14.9519 8.38604C14.9205 8.31017 14.8744 8.24123 14.8164 8.18316C14.7583 8.12509 14.6894 8.07903 14.6135 8.0476C14.5376 8.01617 14.4563 8 14.3742 8C14.2921 8 14.2107 8.01617 14.1349 8.0476C14.059 8.07903 13.9901 8.12509 13.932 8.18316L12.057 10.0582C11.9397 10.1754 11.8738 10.3345 11.8738 10.5003C11.8738 10.6662 11.9397 10.8253 12.057 10.9425C12.1743 11.0598 12.3333 11.1257 12.4992 11.1257C12.665 11.1257 12.8241 11.0598 12.9414 10.9425L14.8164 9.06754ZM17.9414 5.05816C17.8833 5.00005 17.8144 4.95395 17.7385 4.9225C17.6627 4.89105 17.5813 4.87486 17.4992 4.87486C17.4171 4.87486 17.3357 4.89105 17.2599 4.9225C17.184 4.95395 17.115 5.00005 17.057 5.05816L15.807 6.30816C15.6897 6.42544 15.6238 6.5845 15.6238 6.75035C15.6238 6.9162 15.6897 7.07526 15.807 7.19254C15.9243 7.30981 16.0833 7.37569 16.2492 7.3757C16.415 7.3757 16.5741 7.30981 16.6914 7.19254L17.9414 5.94254C17.9995 5.88449 18.0456 5.81556 18.077 5.73969C18.1085 5.66381 18.1247 5.58248 18.1247 5.50035C18.1247 5.41821 18.1085 5.33688 18.077 5.26101C18.0456 5.18514 17.9995 5.11621 17.9414 5.05816ZM9.557 8.44254C9.61505 8.50065 9.68398 8.54674 9.75985 8.5782C9.83572 8.60965 9.91705 8.62584 9.99919 8.62584C10.0813 8.62584 10.1627 8.60965 10.2385 8.5782C10.3144 8.54674 10.3833 8.50065 10.4414 8.44254L16.0664 2.81754C16.1244 2.75947 16.1705 2.69053 16.2019 2.61466C16.2334 2.53879 16.2495 2.45747 16.2495 2.37535C16.2495 2.29323 16.2334 2.21191 16.2019 2.13604C16.1705 2.06017 16.1244 1.99123 16.0664 1.93316C16.0083 1.87509 15.9394 1.82903 15.8635 1.7976C15.7876 1.76618 15.7063 1.75 15.6242 1.75C15.5421 1.75 15.4607 1.76618 15.3849 1.7976C15.309 1.82903 15.2401 1.87509 15.182 1.93316L9.557 7.55816C9.49889 7.61621 9.45279 7.68514 9.42134 7.76101C9.38989 7.83688 9.3737 7.91821 9.3737 8.00035C9.3737 8.08248 9.38989 8.16381 9.42134 8.23969C9.45279 8.31556 9.49889 8.38449 9.557 8.44254ZM10.5929 16.0941C9.77242 16.9146 8.65957 17.3756 7.49919 17.3756C6.33881 17.3756 5.22595 16.9146 4.40544 16.0941C3.58492 15.2736 3.12396 14.1607 3.12396 13.0003C3.12396 11.84 3.58492 10.7271 4.40544 9.9066L10.8703 3.44253C10.9284 3.38447 10.9744 3.31553 11.0058 3.23966C11.0373 3.16379 11.0534 3.08247 11.0534 3.00035C11.0534 2.91823 11.0373 2.83691 11.0058 2.76104C10.9744 2.68517 10.9284 2.61623 10.8703 2.55816C10.8122 2.50009 10.7433 2.45403 10.6674 2.4226C10.5915 2.39118 10.5102 2.375 10.4281 2.375C10.346 2.375 10.2647 2.39118 10.1888 2.4226C10.1129 2.45403 10.044 2.50009 9.98591 2.55816L3.52184 9.023C2.99253 9.54377 2.57156 10.1642 2.28322 10.8484C1.99488 11.5327 1.84487 12.2673 1.84184 13.0098C1.83882 13.7524 1.98284 14.4882 2.2656 15.1747C2.54836 15.8613 2.96427 16.4852 3.48932 17.0102C4.01438 17.5353 4.63819 17.9512 5.32479 18.2339C6.01138 18.5167 6.74717 18.6607 7.4897 18.6577C8.23223 18.6547 8.96682 18.5047 9.65109 18.2163C10.3354 17.928 10.9558 17.507 11.4765 16.9777C11.5888 16.8595 11.6505 16.7022 11.6484 16.5392C11.6463 16.3762 11.5806 16.2205 11.4654 16.1053C11.3501 15.99 11.1944 15.9243 11.0314 15.9223C10.8684 15.9202 10.7111 15.9818 10.5929 16.0941Z" fill="black"/>
|
||||
<path d="M10.6242 13.0003C10.6242 13.6184 10.4409 14.2226 10.0975 14.7365C9.75415 15.2504 9.26609 15.6509 8.69507 15.8875C8.12405 16.124 7.49572 16.1859 6.88953 16.0653C6.28334 15.9447 5.72652 15.6471 5.28948 15.2101C4.85244 14.773 4.55481 14.2162 4.43423 13.61C4.31366 13.0038 4.37554 12.3755 4.61206 11.8045C4.84859 11.2334 5.24913 10.7454 5.76303 10.402C6.27693 10.0586 6.88112 9.87535 7.49919 9.87535C8.32799 9.87535 9.12285 10.2046 9.7089 10.7906C10.2949 11.3767 10.6242 12.1715 10.6242 13.0003ZM16.432 10.0582L12.682 13.8082C12.5647 13.9254 12.4988 14.0845 12.4988 14.2503C12.4988 14.4162 12.5647 14.5753 12.682 14.6925C12.7993 14.8098 12.9583 14.8757 13.1242 14.8757C13.29 14.8757 13.4491 14.8098 13.5664 14.6925L17.3164 10.9425C17.3744 10.8845 17.4205 10.8155 17.4519 10.7397C17.4834 10.6638 17.4995 10.5825 17.4995 10.5003C17.4995 10.4182 17.4834 10.3369 17.4519 10.261C17.4205 10.1852 17.3744 10.1162 17.3164 10.0582C17.2583 10.0001 17.1894 9.95403 17.1135 9.9226C17.0376 9.89118 16.9563 9.875 16.8742 9.875C16.7921 9.875 16.7107 9.89118 16.6349 9.9226C16.559 9.95403 16.4901 10.0001 16.432 10.0582ZM14.8164 9.06754C14.8744 9.00947 14.9205 8.94053 14.9519 8.86466C14.9834 8.78879 14.9995 8.70747 14.9995 8.62535C14.9995 8.54323 14.9834 8.46191 14.9519 8.38604C14.9205 8.31017 14.8744 8.24123 14.8164 8.18316C14.7583 8.12509 14.6894 8.07903 14.6135 8.0476C14.5376 8.01617 14.4563 8 14.3742 8C14.2921 8 14.2107 8.01617 14.1349 8.0476C14.059 8.07903 13.9901 8.12509 13.932 8.18316L12.057 10.0582C11.9397 10.1754 11.8738 10.3345 11.8738 10.5003C11.8738 10.6662 11.9397 10.8253 12.057 10.9425C12.1743 11.0598 12.3333 11.1257 12.4992 11.1257C12.665 11.1257 12.8241 11.0598 12.9414 10.9425L14.8164 9.06754ZM17.9414 5.05816C17.8833 5.00005 17.8144 4.95395 17.7385 4.9225C17.6627 4.89105 17.5813 4.87486 17.4992 4.87486C17.4171 4.87486 17.3357 4.89105 17.2599 4.9225C17.184 4.95395 17.115 5.00005 17.057 5.05816L15.807 6.30816C15.6897 6.42544 15.6238 6.5845 15.6238 6.75035C15.6238 6.9162 15.6897 7.07526 15.807 7.19254C15.9243 7.30981 16.0833 7.37569 16.2492 7.3757C16.415 7.3757 16.5741 7.30981 16.6914 7.19254L17.9414 5.94254C17.9995 5.88449 18.0456 5.81556 18.077 5.73969C18.1085 5.66381 18.1247 5.58248 18.1247 5.50035C18.1247 5.41821 18.1085 5.33688 18.077 5.26101C18.0456 5.18514 17.9995 5.11621 17.9414 5.05816ZM9.557 8.44254C9.61505 8.50065 9.68398 8.54674 9.75985 8.5782C9.83572 8.60965 9.91705 8.62584 9.99919 8.62584C10.0813 8.62584 10.1627 8.60965 10.2385 8.5782C10.3144 8.54674 10.3833 8.50065 10.4414 8.44254L16.0664 2.81754C16.1244 2.75947 16.1705 2.69053 16.2019 2.61466C16.2334 2.53879 16.2495 2.45747 16.2495 2.37535C16.2495 2.29323 16.2334 2.21191 16.2019 2.13604C16.1705 2.06017 16.1244 1.99123 16.0664 1.93316C16.0083 1.87509 15.9394 1.82903 15.8635 1.7976C15.7876 1.76618 15.7063 1.75 15.6242 1.75C15.5421 1.75 15.4607 1.76618 15.3849 1.7976C15.309 1.82903 15.2401 1.87509 15.182 1.93316L9.557 7.55816C9.49889 7.61621 9.45279 7.68514 9.42134 7.76101C9.38989 7.83688 9.3737 7.91821 9.3737 8.00035C9.3737 8.08248 9.38989 8.16381 9.42134 8.23969C9.45279 8.31556 9.49889 8.38449 9.557 8.44254ZM10.5929 16.0941C9.77242 16.9146 8.65957 17.3756 7.49919 17.3756C6.33881 17.3756 5.22595 16.9146 4.40544 16.0941C3.58492 15.2736 3.12396 14.1607 3.12396 13.0003C3.12396 11.84 3.58492 10.7271 4.40544 9.9066L10.8703 3.44253C10.9284 3.38447 10.9744 3.31553 11.0058 3.23966C11.0373 3.16379 11.0534 3.08247 11.0534 3.00035C11.0534 2.91823 11.0373 2.83691 11.0058 2.76104C10.9744 2.68517 10.9284 2.61623 10.8703 2.55816C10.8122 2.50009 10.7433 2.45403 10.6674 2.4226C10.5915 2.39118 10.5102 2.375 10.4281 2.375C10.346 2.375 10.2647 2.39118 10.1888 2.4226C10.1129 2.45403 10.044 2.50009 9.98591 2.55816L3.52184 9.023C2.99253 9.54377 2.57156 10.1642 2.28322 10.8484C1.99488 11.5327 1.84487 12.2673 1.84184 13.0098C1.83882 13.7524 1.98284 14.4882 2.2656 15.1747C2.54836 15.8613 2.96427 16.4852 3.48932 17.0102C4.01438 17.5353 4.63819 17.9512 5.32479 18.2339C6.01138 18.5167 6.74717 18.6607 7.4897 18.6577C8.23223 18.6547 8.96682 18.5047 9.65109 18.2163C10.3354 17.928 10.9558 17.507 11.4765 16.9777C11.5888 16.8595 11.6505 16.7022 11.6484 16.5392C11.6463 16.3762 11.5806 16.2205 11.4654 16.1053C11.3501 15.99 11.1944 15.9243 11.0314 15.9223C10.8684 15.9202 10.7111 15.9818 10.5929 16.0941Z" fill="url(#paint0_linear_2850_16638)"/>
|
||||
<path d="M10.6242 13.0003C10.6242 13.6184 10.4409 14.2226 10.0975 14.7365C9.75415 15.2504 9.26609 15.6509 8.69507 15.8875C8.12405 16.124 7.49572 16.1859 6.88953 16.0653C6.28334 15.9447 5.72652 15.6471 5.28948 15.2101C4.85244 14.773 4.55481 14.2162 4.43423 13.61C4.31366 13.0038 4.37554 12.3755 4.61206 11.8045C4.84859 11.2334 5.24913 10.7454 5.76303 10.402C6.27693 10.0586 6.88112 9.87535 7.49919 9.87535C8.32799 9.87535 9.12285 10.2046 9.7089 10.7906C10.2949 11.3767 10.6242 12.1715 10.6242 13.0003ZM16.432 10.0582L12.682 13.8082C12.5647 13.9254 12.4988 14.0845 12.4988 14.2503C12.4988 14.4162 12.5647 14.5753 12.682 14.6925C12.7993 14.8098 12.9583 14.8757 13.1242 14.8757C13.29 14.8757 13.4491 14.8098 13.5664 14.6925L17.3164 10.9425C17.3744 10.8845 17.4205 10.8155 17.4519 10.7397C17.4834 10.6638 17.4995 10.5825 17.4995 10.5003C17.4995 10.4182 17.4834 10.3369 17.4519 10.261C17.4205 10.1852 17.3744 10.1162 17.3164 10.0582C17.2583 10.0001 17.1894 9.95403 17.1135 9.9226C17.0376 9.89118 16.9563 9.875 16.8742 9.875C16.7921 9.875 16.7107 9.89118 16.6349 9.9226C16.559 9.95403 16.4901 10.0001 16.432 10.0582ZM14.8164 9.06754C14.8744 9.00947 14.9205 8.94053 14.9519 8.86466C14.9834 8.78879 14.9995 8.70747 14.9995 8.62535C14.9995 8.54323 14.9834 8.46191 14.9519 8.38604C14.9205 8.31017 14.8744 8.24123 14.8164 8.18316C14.7583 8.12509 14.6894 8.07903 14.6135 8.0476C14.5376 8.01617 14.4563 8 14.3742 8C14.2921 8 14.2107 8.01617 14.1349 8.0476C14.059 8.07903 13.9901 8.12509 13.932 8.18316L12.057 10.0582C11.9397 10.1754 11.8738 10.3345 11.8738 10.5003C11.8738 10.6662 11.9397 10.8253 12.057 10.9425C12.1743 11.0598 12.3333 11.1257 12.4992 11.1257C12.665 11.1257 12.8241 11.0598 12.9414 10.9425L14.8164 9.06754ZM17.9414 5.05816C17.8833 5.00005 17.8144 4.95395 17.7385 4.9225C17.6627 4.89105 17.5813 4.87486 17.4992 4.87486C17.4171 4.87486 17.3357 4.89105 17.2599 4.9225C17.184 4.95395 17.115 5.00005 17.057 5.05816L15.807 6.30816C15.6897 6.42544 15.6238 6.5845 15.6238 6.75035C15.6238 6.9162 15.6897 7.07526 15.807 7.19254C15.9243 7.30981 16.0833 7.37569 16.2492 7.3757C16.415 7.3757 16.5741 7.30981 16.6914 7.19254L17.9414 5.94254C17.9995 5.88449 18.0456 5.81556 18.077 5.73969C18.1085 5.66381 18.1247 5.58248 18.1247 5.50035C18.1247 5.41821 18.1085 5.33688 18.077 5.26101C18.0456 5.18514 17.9995 5.11621 17.9414 5.05816ZM9.557 8.44254C9.61505 8.50065 9.68398 8.54674 9.75985 8.5782C9.83572 8.60965 9.91705 8.62584 9.99919 8.62584C10.0813 8.62584 10.1627 8.60965 10.2385 8.5782C10.3144 8.54674 10.3833 8.50065 10.4414 8.44254L16.0664 2.81754C16.1244 2.75947 16.1705 2.69053 16.2019 2.61466C16.2334 2.53879 16.2495 2.45747 16.2495 2.37535C16.2495 2.29323 16.2334 2.21191 16.2019 2.13604C16.1705 2.06017 16.1244 1.99123 16.0664 1.93316C16.0083 1.87509 15.9394 1.82903 15.8635 1.7976C15.7876 1.76618 15.7063 1.75 15.6242 1.75C15.5421 1.75 15.4607 1.76618 15.3849 1.7976C15.309 1.82903 15.2401 1.87509 15.182 1.93316L9.557 7.55816C9.49889 7.61621 9.45279 7.68514 9.42134 7.76101C9.38989 7.83688 9.3737 7.91821 9.3737 8.00035C9.3737 8.08248 9.38989 8.16381 9.42134 8.23969C9.45279 8.31556 9.49889 8.38449 9.557 8.44254ZM10.5929 16.0941C9.77242 16.9146 8.65957 17.3756 7.49919 17.3756C6.33881 17.3756 5.22595 16.9146 4.40544 16.0941C3.58492 15.2736 3.12396 14.1607 3.12396 13.0003C3.12396 11.84 3.58492 10.7271 4.40544 9.9066L10.8703 3.44253C10.9284 3.38447 10.9744 3.31553 11.0058 3.23966C11.0373 3.16379 11.0534 3.08247 11.0534 3.00035C11.0534 2.91823 11.0373 2.83691 11.0058 2.76104C10.9744 2.68517 10.9284 2.61623 10.8703 2.55816C10.8122 2.50009 10.7433 2.45403 10.6674 2.4226C10.5915 2.39118 10.5102 2.375 10.4281 2.375C10.346 2.375 10.2647 2.39118 10.1888 2.4226C10.1129 2.45403 10.044 2.50009 9.98591 2.55816L3.52184 9.023C2.99253 9.54377 2.57156 10.1642 2.28322 10.8484C1.99488 11.5327 1.84487 12.2673 1.84184 13.0098C1.83882 13.7524 1.98284 14.4882 2.2656 15.1747C2.54836 15.8613 2.96427 16.4852 3.48932 17.0102C4.01438 17.5353 4.63819 17.9512 5.32479 18.2339C6.01138 18.5167 6.74717 18.6607 7.4897 18.6577C8.23223 18.6547 8.96682 18.5047 9.65109 18.2163C10.3354 17.928 10.9558 17.507 11.4765 16.9777C11.5888 16.8595 11.6505 16.7022 11.6484 16.5392C11.6463 16.3762 11.5806 16.2205 11.4654 16.1053C11.3501 15.99 11.1944 15.9243 11.0314 15.9223C10.8684 15.9202 10.7111 15.9818 10.5929 16.0941Z" stroke="url(#paint1_linear_2850_16638)" stroke-width="0.3"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2850_16638" x1="1.95109" y1="1.75" x2="21.5698" y2="11.5208" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0CF1CA"/>
|
||||
<stop offset="0.264423" stop-color="#0BD2B0"/>
|
||||
<stop offset="0.307692" stop-color="#0CF1CA"/>
|
||||
<stop offset="0.427885" stop-color="#0CF1CA"/>
|
||||
<stop offset="0.466346" stop-color="#0FAF94"/>
|
||||
<stop offset="0.591346" stop-color="#0CA288"/>
|
||||
<stop offset="1" stop-color="#086253"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_2850_16638" x1="1.8418" y1="2.25694" x2="21.3121" y2="11.25" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="white" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 409 B After Width: | Height: | Size: 413 B |
@@ -1,42 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_2_1_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 491 491" style="enable-background:new 0 0 491 491;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#1A2233;}
|
||||
.st1{fill:#F50057;}
|
||||
.st2{fill:#FFFFFF;}
|
||||
</style>
|
||||
<!-- <path class="st0" d="M455.3,491H35.7C16,491,0,475,0,455.3V35.7C0,16,16,0,35.7,0h419.7C475,0,491,16,491,35.7v419.7
|
||||
C491,475,475,491,455.3,491z"/> -->
|
||||
<g>
|
||||
<path class="st1" d="M470.5,245.5c0-29.8-37.3-58.1-94.6-75.6c13.2-58.3,7.3-104.7-18.5-119.6c-6-3.5-12.9-5.1-20.5-5.1v20.5
|
||||
c4.2,0,7.6,0.8,10.5,2.4c12.5,7.2,17.9,34.4,13.7,69.4c-1,8.6-2.7,17.7-4.7,27c-18-4.4-37.6-7.8-58.2-10
|
||||
c-12.4-17-25.2-32.4-38.2-45.9c29.9-27.8,58-43,77-43V45.1l0,0c-25.2,0-58.2,18-91.6,49.2c-33.4-31-66.4-48.8-91.6-48.8v20.5
|
||||
c19,0,47.1,15.1,77,42.7c-12.8,13.5-25.7,28.8-37.9,45.8c-20.7,2.2-40.4,5.6-58.3,10.1c-2.1-9.2-3.7-18.1-4.8-26.6
|
||||
c-4.3-35,1-62.3,13.4-69.5c2.8-1.7,6.3-2.4,10.5-2.4V45.6l0,0c-7.7,0-14.7,1.7-20.7,5.1c-25.8,14.9-31.6,61.2-18.3,119.3
|
||||
c-57.1,17.6-94.2,45.8-94.2,75.5c0,29.8,37.3,58.1,94.6,75.6c-13.2,58.3-7.3,104.7,18.5,119.6c6,3.5,12.9,5.1,20.6,5.1
|
||||
c25.2,0,58.2-18,91.6-49.2c33.4,31,66.4,48.8,91.6,48.8c7.7,0,14.7-1.7,20.7-5.1c25.8-14.9,31.6-61.2,18.3-119.3
|
||||
C433.4,303.5,470.5,275.3,470.5,245.5z M351.1,184.4c-3.4,11.8-7.6,24-12.4,36.2c-3.8-7.3-7.7-14.7-12-22
|
||||
c-4.2-7.3-8.7-14.5-13.2-21.5C326.5,179,339.1,181.4,351.1,184.4z M309.1,282.1c-7.2,12.4-14.5,24.1-22.1,35
|
||||
c-13.7,1.2-27.5,1.8-41.5,1.8c-13.9,0-27.7-0.6-41.3-1.7c-7.6-10.9-15-22.6-22.2-34.9c-7-12-13.3-24.2-19.1-36.5
|
||||
c5.7-12.3,12.1-24.6,19-36.6c7.2-12.4,14.5-24.1,22.1-35c13.7-1.2,27.5-1.8,41.5-1.8c13.9,0,27.7,0.6,41.3,1.7
|
||||
c7.6,10.9,15,22.6,22.2,34.9c7,12,13.3,24.2,19.1,36.5C322.3,257.7,315.9,270,309.1,282.1z M338.7,270.1c5,12.3,9.2,24.6,12.7,36.5
|
||||
c-12,2.9-24.7,5.4-37.8,7.3c4.5-7.1,9-14.3,13.2-21.7C331,284.9,334.9,277.5,338.7,270.1z M245.7,368c-8.5-8.8-17.1-18.6-25.5-29.4
|
||||
c8.3,0.4,16.7,0.6,25.2,0.6c8.6,0,17.2-0.2,25.5-0.6C262.7,349.4,254.1,359.2,245.7,368z M177.4,314c-13-1.9-25.6-4.3-37.6-7.2
|
||||
c3.4-11.8,7.6-24,12.4-36.2c3.8,7.3,7.7,14.7,12,22C168.5,299.8,172.9,307,177.4,314z M245.2,123.1c8.5,8.8,17.1,18.6,25.5,29.4
|
||||
c-8.3-0.4-16.7-0.6-25.2-0.6c-8.6,0-17.2,0.2-25.5,0.6C228.3,141.7,236.8,131.9,245.2,123.1z M177.3,177.1
|
||||
c-4.5,7.1-9,14.3-13.2,21.7c-4.2,7.3-8.2,14.7-11.9,22c-5-12.3-9.2-24.6-12.7-36.5C151.6,181.5,164.2,179,177.3,177.1z M94.3,292
|
||||
c-32.5-13.9-53.5-32-53.5-46.4c0-14.4,21-32.7,53.5-46.4c7.9-3.4,16.5-6.4,25.4-9.3c5.2,18,12.1,36.7,20.6,55.9
|
||||
c-8.4,19.1-15.2,37.7-20.4,55.6C110.9,298.5,102.3,295.4,94.3,292z M143.7,423c-12.5-7.2-17.9-34.4-13.7-69.4
|
||||
c1-8.6,2.7-17.7,4.7-27c18,4.4,37.6,7.8,58.2,10c12.4,17,25.2,32.4,38.2,45.9c-29.9,27.8-58,43-77,43
|
||||
C149.9,425.4,146.4,424.6,143.7,423z M361.3,353.1c4.3,35-1,62.3-13.4,69.5c-2.8,1.7-6.3,2.4-10.5,2.4c-19,0-47.1-15.1-77-42.7
|
||||
c12.8-13.5,25.7-28.8,37.9-45.8c20.7-2.2,40.4-5.6,58.3-10.1C358.6,335.7,360.2,344.6,361.3,353.1z M396.6,292
|
||||
c-7.9,3.4-16.5,6.4-25.4,9.3c-5.2-18-12.1-36.7-20.6-55.9c8.4-19.1,15.2-37.7,20.4-55.6c9.1,2.8,17.7,6,25.8,9.4
|
||||
c32.5,13.9,53.5,32,53.5,46.4C450,259.9,429,278.2,396.6,292z"/>
|
||||
<path class="st1" d="M153.6,45.5L153.6,45.5L153.6,45.5z"/>
|
||||
<circle class="st2" cx="245.4" cy="245.5" r="41.9"/>
|
||||
<path class="st1" d="M336.8,45.2L336.8,45.2L336.8,45.2z"/>
|
||||
</g>
|
||||
</svg>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_2_1_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 491 491" style="enable-background:new 0 0 491 491;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#1A2233;}
|
||||
.st1{fill:#F50057;}
|
||||
.st2{fill:#FFFFFF;}
|
||||
</style>
|
||||
<!-- <path class="st0" d="M455.3,491H35.7C16,491,0,475,0,455.3V35.7C0,16,16,0,35.7,0h419.7C475,0,491,16,491,35.7v419.7
|
||||
C491,475,475,491,455.3,491z"/> -->
|
||||
<g>
|
||||
<path class="st1" d="M470.5,245.5c0-29.8-37.3-58.1-94.6-75.6c13.2-58.3,7.3-104.7-18.5-119.6c-6-3.5-12.9-5.1-20.5-5.1v20.5
|
||||
c4.2,0,7.6,0.8,10.5,2.4c12.5,7.2,17.9,34.4,13.7,69.4c-1,8.6-2.7,17.7-4.7,27c-18-4.4-37.6-7.8-58.2-10
|
||||
c-12.4-17-25.2-32.4-38.2-45.9c29.9-27.8,58-43,77-43V45.1l0,0c-25.2,0-58.2,18-91.6,49.2c-33.4-31-66.4-48.8-91.6-48.8v20.5
|
||||
c19,0,47.1,15.1,77,42.7c-12.8,13.5-25.7,28.8-37.9,45.8c-20.7,2.2-40.4,5.6-58.3,10.1c-2.1-9.2-3.7-18.1-4.8-26.6
|
||||
c-4.3-35,1-62.3,13.4-69.5c2.8-1.7,6.3-2.4,10.5-2.4V45.6l0,0c-7.7,0-14.7,1.7-20.7,5.1c-25.8,14.9-31.6,61.2-18.3,119.3
|
||||
c-57.1,17.6-94.2,45.8-94.2,75.5c0,29.8,37.3,58.1,94.6,75.6c-13.2,58.3-7.3,104.7,18.5,119.6c6,3.5,12.9,5.1,20.6,5.1
|
||||
c25.2,0,58.2-18,91.6-49.2c33.4,31,66.4,48.8,91.6,48.8c7.7,0,14.7-1.7,20.7-5.1c25.8-14.9,31.6-61.2,18.3-119.3
|
||||
C433.4,303.5,470.5,275.3,470.5,245.5z M351.1,184.4c-3.4,11.8-7.6,24-12.4,36.2c-3.8-7.3-7.7-14.7-12-22
|
||||
c-4.2-7.3-8.7-14.5-13.2-21.5C326.5,179,339.1,181.4,351.1,184.4z M309.1,282.1c-7.2,12.4-14.5,24.1-22.1,35
|
||||
c-13.7,1.2-27.5,1.8-41.5,1.8c-13.9,0-27.7-0.6-41.3-1.7c-7.6-10.9-15-22.6-22.2-34.9c-7-12-13.3-24.2-19.1-36.5
|
||||
c5.7-12.3,12.1-24.6,19-36.6c7.2-12.4,14.5-24.1,22.1-35c13.7-1.2,27.5-1.8,41.5-1.8c13.9,0,27.7,0.6,41.3,1.7
|
||||
c7.6,10.9,15,22.6,22.2,34.9c7,12,13.3,24.2,19.1,36.5C322.3,257.7,315.9,270,309.1,282.1z M338.7,270.1c5,12.3,9.2,24.6,12.7,36.5
|
||||
c-12,2.9-24.7,5.4-37.8,7.3c4.5-7.1,9-14.3,13.2-21.7C331,284.9,334.9,277.5,338.7,270.1z M245.7,368c-8.5-8.8-17.1-18.6-25.5-29.4
|
||||
c8.3,0.4,16.7,0.6,25.2,0.6c8.6,0,17.2-0.2,25.5-0.6C262.7,349.4,254.1,359.2,245.7,368z M177.4,314c-13-1.9-25.6-4.3-37.6-7.2
|
||||
c3.4-11.8,7.6-24,12.4-36.2c3.8,7.3,7.7,14.7,12,22C168.5,299.8,172.9,307,177.4,314z M245.2,123.1c8.5,8.8,17.1,18.6,25.5,29.4
|
||||
c-8.3-0.4-16.7-0.6-25.2-0.6c-8.6,0-17.2,0.2-25.5,0.6C228.3,141.7,236.8,131.9,245.2,123.1z M177.3,177.1
|
||||
c-4.5,7.1-9,14.3-13.2,21.7c-4.2,7.3-8.2,14.7-11.9,22c-5-12.3-9.2-24.6-12.7-36.5C151.6,181.5,164.2,179,177.3,177.1z M94.3,292
|
||||
c-32.5-13.9-53.5-32-53.5-46.4c0-14.4,21-32.7,53.5-46.4c7.9-3.4,16.5-6.4,25.4-9.3c5.2,18,12.1,36.7,20.6,55.9
|
||||
c-8.4,19.1-15.2,37.7-20.4,55.6C110.9,298.5,102.3,295.4,94.3,292z M143.7,423c-12.5-7.2-17.9-34.4-13.7-69.4
|
||||
c1-8.6,2.7-17.7,4.7-27c18,4.4,37.6,7.8,58.2,10c12.4,17,25.2,32.4,38.2,45.9c-29.9,27.8-58,43-77,43
|
||||
C149.9,425.4,146.4,424.6,143.7,423z M361.3,353.1c4.3,35-1,62.3-13.4,69.5c-2.8,1.7-6.3,2.4-10.5,2.4c-19,0-47.1-15.1-77-42.7
|
||||
c12.8-13.5,25.7-28.8,37.9-45.8c20.7-2.2,40.4-5.6,58.3-10.1C358.6,335.7,360.2,344.6,361.3,353.1z M396.6,292
|
||||
c-7.9,3.4-16.5,6.4-25.4,9.3c-5.2-18-12.1-36.7-20.6-55.9c8.4-19.1,15.2-37.7,20.4-55.6c9.1,2.8,17.7,6,25.8,9.4
|
||||
c32.5,13.9,53.5,32,53.5,46.4C450,259.9,429,278.2,396.6,292z"/>
|
||||
<path class="st1" d="M153.6,45.5L153.6,45.5L153.6,45.5z"/>
|
||||
<circle class="st2" cx="245.4" cy="245.5" r="41.9"/>
|
||||
<path class="st1" d="M336.8,45.2L336.8,45.2L336.8,45.2z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -1 +1 @@
|
||||
<svg viewBox="0 0 600.75 799.79" xmlns="http://www.w3.org/2000/svg" width="1878" height="2500"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="-154.087" x2="316.283" y1="274.041" y2="501.116"><stop offset=".107" stop-color="#c957e6"/><stop offset="1" stop-color="#1a9fff"/></linearGradient><path d="M200.25 600.27C89.51 600.27 0 510.89 0 400.32s89.51-199.95 200.25-199.95 200.26 89.38 200.26 199.95-89.52 199.95-200.26 199.95z" fill="url(#a)"/><path d="M456.98 399.89c0-141.57-114.95-256.34-256.74-256.34V0c221.2 0 400.51 179.04 400.51 399.89 0 220.86-179.31 399.9-400.51 399.9V656.24c141.79 0 256.74-114.77 256.74-256.35z" fill="#1b1b1b"/></svg>
|
||||
<svg viewBox="0 0 600.75 799.79" xmlns="http://www.w3.org/2000/svg" width="1878" height="2500"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="-154.087" x2="316.283" y1="274.041" y2="501.116"><stop offset=".107" stop-color="#c957e6"/><stop offset="1" stop-color="#1a9fff"/></linearGradient><path d="M200.25 600.27C89.51 600.27 0 510.89 0 400.32s89.51-199.95 200.25-199.95 200.26 89.38 200.26 199.95-89.52 199.95-200.26 199.95z" fill="url(#a)"/><path d="M456.98 399.89c0-141.57-114.95-256.34-256.74-256.34V0c221.2 0 400.51 179.04 400.51 399.89 0 220.86-179.31 399.9-400.51 399.9V656.24c141.79 0 256.74-114.77 256.74-256.35z" fill="#1b1b1b"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 657 B After Width: | Height: | Size: 658 B |
@@ -45,12 +45,15 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||
achievements: null,
|
||||
hasNSFWContentBlocked: false,
|
||||
lastDownloadedOption: null,
|
||||
isTransferring: false,
|
||||
transferProgress: 0,
|
||||
selectGameExecutable: async () => null,
|
||||
updateGame: async () => {},
|
||||
setShowGameOptionsModal: () => {},
|
||||
setGameOptionsInitialCategory: () => {},
|
||||
setShowRepacksModal: () => {},
|
||||
setHasNSFWContentBlocked: () => {},
|
||||
cancelTransfer: () => {},
|
||||
});
|
||||
|
||||
const { Provider } = gameDetailsContext;
|
||||
@@ -78,6 +81,8 @@ export function GameDetailsContextProvider({
|
||||
const [game, setGame] = useState<LibraryGame | null>(null);
|
||||
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const [isTransferring, setIsTransferring] = useState(false);
|
||||
const [transferProgress, setTransferProgress] = useState(0);
|
||||
|
||||
const [stats, setStats] = useState<GameStats | null>(null);
|
||||
|
||||
@@ -114,6 +119,59 @@ export function GameDetailsContextProvider({
|
||||
updateGame();
|
||||
}, [updateGame, isGameDownloading, lastPacket?.gameId]);
|
||||
|
||||
// Listen for transfer events
|
||||
useEffect(() => {
|
||||
const onTransferProgress = (
|
||||
_: unknown,
|
||||
shop: string,
|
||||
objectId: string,
|
||||
progress: number
|
||||
) => {
|
||||
if (shop === game?.shop && objectId === game?.objectId) {
|
||||
setIsTransferring(progress >= 0 && progress < 1);
|
||||
setTransferProgress(progress);
|
||||
}
|
||||
};
|
||||
|
||||
const onTransferComplete = (_: unknown, shop: string, objectId: string) => {
|
||||
if (shop === game?.shop && objectId === game?.objectId) {
|
||||
setIsTransferring(false);
|
||||
setTransferProgress(0);
|
||||
updateGame();
|
||||
}
|
||||
};
|
||||
|
||||
const onTransferCancelled = (
|
||||
_: unknown,
|
||||
shop: string,
|
||||
objectId: string
|
||||
) => {
|
||||
if (shop === game?.shop && objectId === game?.objectId) {
|
||||
setIsTransferring(false);
|
||||
setTransferProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const onTransferError = (_: unknown, shop: string, objectId: string) => {
|
||||
if (shop === game?.shop && objectId === game?.objectId) {
|
||||
setIsTransferring(false);
|
||||
setTransferProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
window.electron.on("on-game-transfer-progress", onTransferProgress);
|
||||
window.electron.on("on-game-transfer-complete", onTransferComplete);
|
||||
window.electron.on("on-game-transfer-cancelled", onTransferCancelled);
|
||||
window.electron.on("on-game-transfer-error", onTransferError);
|
||||
|
||||
return () => {
|
||||
window.electron.off("on-game-transfer-progress", onTransferProgress);
|
||||
window.electron.off("on-game-transfer-complete", onTransferComplete);
|
||||
window.electron.off("on-game-transfer-cancelled", onTransferCancelled);
|
||||
window.electron.off("on-game-transfer-error", onTransferError);
|
||||
};
|
||||
}, [game]);
|
||||
|
||||
useEffect(() => {
|
||||
if (abortControllerRef.current) abortControllerRef.current.abort();
|
||||
const abortController = new AbortController();
|
||||
@@ -376,6 +434,13 @@ export function GameDetailsContextProvider({
|
||||
});
|
||||
};
|
||||
|
||||
// Handlers for cancel
|
||||
const cancelTransfer = () => {
|
||||
window.electron.cancelGameTransfer?.(shop, objectId);
|
||||
setIsTransferring(false);
|
||||
setTransferProgress(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<Provider
|
||||
value={{
|
||||
@@ -394,12 +459,15 @@ export function GameDetailsContextProvider({
|
||||
achievements,
|
||||
hasNSFWContentBlocked,
|
||||
lastDownloadedOption: null,
|
||||
isTransferring,
|
||||
transferProgress,
|
||||
setHasNSFWContentBlocked,
|
||||
selectGameExecutable,
|
||||
updateGame,
|
||||
setShowRepacksModal,
|
||||
setShowGameOptionsModal,
|
||||
setGameOptionsInitialCategory,
|
||||
cancelTransfer,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
|
||||
export type GameOptionsCategoryId =
|
||||
| "general"
|
||||
| "locations"
|
||||
| "assets"
|
||||
| "hydra_cloud"
|
||||
| "compatibility"
|
||||
@@ -31,6 +32,8 @@ export interface GameDetailsContext {
|
||||
achievements: UserAchievement[] | null;
|
||||
hasNSFWContentBlocked: boolean;
|
||||
lastDownloadedOption: GameRepack | null;
|
||||
isTransferring: boolean;
|
||||
transferProgress: number;
|
||||
selectGameExecutable: () => Promise<string | null>;
|
||||
updateGame: () => Promise<void>;
|
||||
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -39,4 +42,5 @@ export interface GameDetailsContext {
|
||||
React.SetStateAction<GameOptionsCategoryId>
|
||||
>;
|
||||
setHasNSFWContentBlocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
cancelTransfer: () => void;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@ import type {
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
|
||||
export interface DriveInfo {
|
||||
root: string;
|
||||
label: string;
|
||||
free: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
declare module "*.svg" {
|
||||
const content: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
|
||||
@@ -563,6 +570,27 @@ declare global {
|
||||
values: (sublevelName: string) => Promise<unknown[]>;
|
||||
iterator: (sublevelName: string) => Promise<[string, unknown][]>;
|
||||
};
|
||||
|
||||
/* Transfer Game */
|
||||
getAvailableDrives: () => Promise<DriveInfo[]>;
|
||||
transferGameFiles: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
destParent: string
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
needed?: number;
|
||||
available?: number;
|
||||
newExePath?: string;
|
||||
}>;
|
||||
|
||||
// Cancel for game transfers
|
||||
cancelGameTransfer: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
|
||||
/* Event listeners for transfer progress */
|
||||
on: (channel: string, listener: (...args: any[]) => void) => void;
|
||||
off: (channel: string, listener: (...args: any[]) => void) => void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function GameDetails() {
|
||||
updateGame,
|
||||
setShowRepacksModal,
|
||||
setShowGameOptionsModal,
|
||||
setGameOptionsInitialCategory,
|
||||
setGameOptionsInitialCategory, // ADD THIS
|
||||
}) => {
|
||||
const handleStartDownload = async (
|
||||
repack: GameRepack,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PlusCircleIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { Button } from "@renderer/components";
|
||||
import { XCircle } from "lucide-react";
|
||||
import {
|
||||
useDownload,
|
||||
useLibrary,
|
||||
@@ -41,6 +42,8 @@ export function HeroPanelActions() {
|
||||
setShowRepacksModal,
|
||||
updateGame,
|
||||
selectGameExecutable,
|
||||
isTransferring,
|
||||
transferProgress,
|
||||
} = useContext(gameDetailsContext);
|
||||
|
||||
const { lastPacket } = useDownload();
|
||||
@@ -213,6 +216,22 @@ export function HeroPanelActions() {
|
||||
);
|
||||
|
||||
const gameActionButton = () => {
|
||||
if (isTransferring) {
|
||||
const percent = Math.round(transferProgress * 100);
|
||||
return (
|
||||
<Button
|
||||
theme="outline"
|
||||
className="hero-panel-actions__action"
|
||||
onClick={() => {
|
||||
setGameOptionsInitialCategory("locations");
|
||||
setShowGameOptionsModal(true);
|
||||
}}
|
||||
>
|
||||
Transferring {percent}%
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGameRunning) {
|
||||
return (
|
||||
<Button
|
||||
@@ -221,6 +240,7 @@ export function HeroPanelActions() {
|
||||
disabled={deleting}
|
||||
className="hero-panel-actions__action"
|
||||
>
|
||||
<XCircle size={18} />
|
||||
{t("close")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
flex-wrap: wrap;
|
||||
margin-top: calc(globals.$spacing-unit / 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,3 +244,286 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drive-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: globals.$spacing-unit;
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
margin-bottom: calc(globals.$spacing-unit / 2);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
&__list-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__custom {
|
||||
margin-top: calc(globals.$spacing-unit / 2);
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin: calc(globals.$spacing-unit / 2) 0 0;
|
||||
color: globals.$error-color;
|
||||
font-size: globals.$small-font-size;
|
||||
}
|
||||
|
||||
&__path-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
> *:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: calc(globals.$spacing-unit / 2);
|
||||
}
|
||||
}
|
||||
|
||||
.drive-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: globals.$dark-background-color;
|
||||
border: 1px solid globals.$border-color;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: rgba(255, 255, 255, 0.35) !important;
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
&--nospace {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
opacity: 0.9;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__space {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&--nospace &__space {
|
||||
color: #f87171;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__bar-used {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(168, 162, 158, 0.6);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&__bar-game {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg, #22c55e, #4ade80);
|
||||
opacity: 0.9;
|
||||
border-radius: 0 2px 2px 0;
|
||||
box-shadow: 0 0 6px rgba(74, 222, 128, 0.4);
|
||||
}
|
||||
|
||||
&__tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
color: #f87171;
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
border: 1px solid rgba(248, 113, 113, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.transfer-progress {
|
||||
margin-top: 6px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__pct {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&__track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
&__fill {
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.cancel-confirm-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.cancel-confirm-modal {
|
||||
background: #1e1e1e;
|
||||
border-radius: 16px;
|
||||
padding: 24px 32px;
|
||||
min-width: 420px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 20px;
|
||||
font-size: 15px;
|
||||
opacity: 0.75;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.cancel-confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
|
||||
button {
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal } from "@renderer/components";
|
||||
import { formatBytes } from "@shared";
|
||||
|
||||
import type {
|
||||
CreateSteamShortcutOptions,
|
||||
Game,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
AlertIcon,
|
||||
CloudIcon,
|
||||
DownloadIcon,
|
||||
FileDirectoryIcon,
|
||||
GearIcon,
|
||||
ImageIcon,
|
||||
} from "@primer/octicons-react";
|
||||
@@ -74,8 +77,15 @@ export function GameOptionsModal({
|
||||
selectGameExecutable,
|
||||
achievements,
|
||||
shopDetails,
|
||||
isTransferring,
|
||||
} = useContext(gameDetailsContext);
|
||||
|
||||
const [transferProgress, setTransferProgress] = useState(0);
|
||||
const [drives, setDrives] = useState<any[]>([]);
|
||||
const [transferSpeed, setTransferSpeed] = useState(0);
|
||||
const [transferETA, setTransferETA] = useState(0);
|
||||
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showRemoveGameModal, setShowRemoveGameModal] = useState(false);
|
||||
const [gameTitle, setGameTitle] = useState(game.title ?? "");
|
||||
@@ -118,7 +128,6 @@ export function GameOptionsModal({
|
||||
isGameDeleting,
|
||||
cancelDownload,
|
||||
} = useDownload();
|
||||
|
||||
const { userDetails } = useUserDetails();
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
@@ -126,18 +135,22 @@ export function GameOptionsModal({
|
||||
|
||||
const globalAutoRunGamemode = userPreferences?.autoRunGamemode === true;
|
||||
const globalAutoRunMangohud = userPreferences?.autoRunMangohud === true;
|
||||
|
||||
const hasAchievements =
|
||||
(achievements?.filter((achievement) => achievement.unlocked).length ?? 0) >
|
||||
0;
|
||||
|
||||
(achievements?.filter((a) => a.unlocked).length ?? 0) > 0;
|
||||
const deleting = isGameDeleting(game.id);
|
||||
|
||||
const { lastPacket } = useDownload();
|
||||
|
||||
const isGameDownloading =
|
||||
game.download?.status === "active" && lastPacket?.gameId === game.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
window.electron
|
||||
.getAvailableDrives?.()
|
||||
.then(setDrives)
|
||||
.catch((err) => console.error("Failed to fetch drives:", err));
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
visible &&
|
||||
@@ -157,22 +170,18 @@ export function GameOptionsModal({
|
||||
useEffect(() => {
|
||||
setGameTitle(game.title ?? "");
|
||||
}, [game.title]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedProtonPath(game.protonPath ?? "");
|
||||
}, [game.protonPath]);
|
||||
|
||||
useEffect(() => {
|
||||
setAutoRunMangohud(game.autoRunMangohud === true);
|
||||
}, [game.autoRunMangohud]);
|
||||
|
||||
useEffect(() => {
|
||||
setAutoRunGamemode(game.autoRunGamemode === true);
|
||||
}, [game.autoRunGamemode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || window.electron.platform !== "linux") return;
|
||||
|
||||
window.electron
|
||||
.getInstalledProtonVersions()
|
||||
.then(setProtonVersions)
|
||||
@@ -184,10 +193,9 @@ export function GameOptionsModal({
|
||||
setDefaultWinePrefixPath(null);
|
||||
return;
|
||||
}
|
||||
|
||||
window.electron
|
||||
.getDefaultWinePrefixSelectionPath()
|
||||
.then((defaultPath) => setDefaultWinePrefixPath(defaultPath))
|
||||
.then(setDefaultWinePrefixPath)
|
||||
.catch(() => setDefaultWinePrefixPath(null));
|
||||
}, [visible]);
|
||||
|
||||
@@ -196,7 +204,6 @@ export function GameOptionsModal({
|
||||
setGamemodeAvailable(false);
|
||||
return;
|
||||
}
|
||||
|
||||
window.electron
|
||||
.isGamemodeAvailable()
|
||||
.then(setGamemodeAvailable)
|
||||
@@ -208,7 +215,6 @@ export function GameOptionsModal({
|
||||
setMangohudAvailable(false);
|
||||
return;
|
||||
}
|
||||
|
||||
window.electron
|
||||
.isMangohudAvailable()
|
||||
.then(setMangohudAvailable)
|
||||
@@ -220,7 +226,6 @@ export function GameOptionsModal({
|
||||
setWinetricksAvailable(false);
|
||||
return;
|
||||
}
|
||||
|
||||
window.electron
|
||||
.isWinetricksAvailable()
|
||||
.then(setWinetricksAvailable)
|
||||
@@ -229,10 +234,6 @@ export function GameOptionsModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (game.shop !== "custom") {
|
||||
console.log(
|
||||
"Checking Steam shortcut existence for",
|
||||
window.electron.checkSteamShortcut(game.shop, game.objectId)
|
||||
);
|
||||
window.electron
|
||||
.checkSteamShortcut(game.shop, game.objectId)
|
||||
.then(setSteamShortcutExists)
|
||||
@@ -240,6 +241,27 @@ export function GameOptionsModal({
|
||||
}
|
||||
}, [game.shop, game.objectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onProgress = (
|
||||
_: unknown,
|
||||
shop: string,
|
||||
oid: string,
|
||||
progress: number,
|
||||
details?: any
|
||||
) => {
|
||||
if (shop === game.shop && oid === game.objectId) {
|
||||
setTransferProgress(progress);
|
||||
if (details) {
|
||||
setTransferSpeed(details.speed);
|
||||
setTransferETA(details.eta);
|
||||
}
|
||||
}
|
||||
};
|
||||
(window.electron as any).on("on-game-transfer-progress", onProgress);
|
||||
return () =>
|
||||
(window.electron as any).off("on-game-transfer-progress", onProgress);
|
||||
}, [game]);
|
||||
|
||||
const debounceUpdateLaunchOptions = useRef(
|
||||
debounce(async (value: string) => {
|
||||
const gameKey = getGameKey(game.shop, game.objectId);
|
||||
@@ -248,46 +270,122 @@ export function GameOptionsModal({
|
||||
"games"
|
||||
)) as Game | null;
|
||||
if (gameData) {
|
||||
const trimmedValue = value.trim();
|
||||
const updated = {
|
||||
...gameData,
|
||||
launchOptions: trimmedValue ? trimmedValue : null,
|
||||
};
|
||||
await levelDBService.put(gameKey, updated, "games");
|
||||
const trimmed = value.trim();
|
||||
await levelDBService.put(
|
||||
gameKey,
|
||||
{ ...gameData, launchOptions: trimmed || null },
|
||||
"games"
|
||||
);
|
||||
}
|
||||
updateGame();
|
||||
}, 1000)
|
||||
).current;
|
||||
|
||||
const handleRemoveGameFromLibrary = async () => {
|
||||
if (isGameDownloading) {
|
||||
await cancelDownload(game.shop, game.objectId);
|
||||
}
|
||||
|
||||
if (isGameDownloading) await cancelDownload(game.shop, game.objectId);
|
||||
await removeGameFromLibrary(game.shop, game.objectId);
|
||||
await Promise.all([updateGame(), updateLibrary(), loadCollections()]);
|
||||
onClose();
|
||||
if (game.shop === "custom" && onNavigateHome) onNavigateHome();
|
||||
};
|
||||
|
||||
// Redirect to home page if it's a custom game
|
||||
if (game.shop === "custom" && onNavigateHome) {
|
||||
onNavigateHome();
|
||||
const handleCancelTransfer = () => {
|
||||
window.electron.cancelGameTransfer?.(game.shop, game.objectId);
|
||||
setTransferProgress(0);
|
||||
setTransferSpeed(0);
|
||||
setTransferETA(0);
|
||||
setShowCancelConfirm(false);
|
||||
};
|
||||
|
||||
const getTransferErrorToast = (
|
||||
result: Awaited<ReturnType<typeof window.electron.transferGameFiles>>
|
||||
) => {
|
||||
const neededSpace =
|
||||
typeof result.needed === "number"
|
||||
? formatBytes(result.needed)
|
||||
: t("transfer_unknown_size");
|
||||
const availableSpace =
|
||||
typeof result.available === "number"
|
||||
? formatBytes(result.available)
|
||||
: t("transfer_unknown_size");
|
||||
|
||||
switch (result.error) {
|
||||
case "Transfer cancelled":
|
||||
return {
|
||||
title: t("transfer_cancelled"),
|
||||
};
|
||||
case "not_enough_space":
|
||||
return {
|
||||
title: t("transfer_not_enough_space"),
|
||||
message: t("not_enough_space_detail", {
|
||||
needed: neededSpace,
|
||||
available: availableSpace,
|
||||
}),
|
||||
};
|
||||
case "Game is already in this location":
|
||||
return {
|
||||
title: t("transfer_same_folder"),
|
||||
};
|
||||
case "Destination is inside source folder":
|
||||
return {
|
||||
title: t("transfer_destination_inside_source"),
|
||||
};
|
||||
case "Destination folder already exists":
|
||||
return {
|
||||
title: t("transfer_destination_exists"),
|
||||
};
|
||||
case "Cannot access destination folder":
|
||||
return {
|
||||
title: t("transfer_destination_unavailable"),
|
||||
};
|
||||
case "Cannot determine game root folder":
|
||||
return {
|
||||
title: t("transfer_root_not_found"),
|
||||
};
|
||||
case "Game not found or has no executable path":
|
||||
return {
|
||||
title: t("transfer_game_not_found"),
|
||||
};
|
||||
case "Failed to update database":
|
||||
return {
|
||||
title: t("transfer_failed"),
|
||||
message: t("transfer_db_update_failed"),
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: t("transfer_failed"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartTransfer = async (destPath: string) => {
|
||||
const result = await window.electron.transferGameFiles(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
destPath
|
||||
);
|
||||
if (!result.ok) {
|
||||
const transferErrorToast = getTransferErrorToast(result);
|
||||
showErrorToast(transferErrorToast.title, transferErrorToast.message);
|
||||
throw new Error(transferErrorToast.message ?? transferErrorToast.title);
|
||||
}
|
||||
showSuccessToast(
|
||||
t("transfer_complete"),
|
||||
t("transfer_complete_description", { game: game.title })
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeExecutableLocation = async () => {
|
||||
const path = await selectGameExecutable();
|
||||
|
||||
if (path) {
|
||||
const gameUsingPath =
|
||||
await window.electron.verifyExecutablePathInUse(path);
|
||||
|
||||
if (gameUsingPath) {
|
||||
showErrorToast(
|
||||
t("executable_path_in_use", { game: gameUsingPath.title })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
window.electron
|
||||
.updateExecutablePath(game.shop, game.objectId, path)
|
||||
.then(updateGame);
|
||||
@@ -299,24 +397,20 @@ export function GameOptionsModal({
|
||||
) => {
|
||||
try {
|
||||
setCreatingSteamShortcut(true);
|
||||
|
||||
await window.electron.createSteamShortcut(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
options ?? {}
|
||||
);
|
||||
|
||||
showSuccessToast(
|
||||
t("create_shortcut_success"),
|
||||
t("you_might_need_to_restart_steam")
|
||||
);
|
||||
|
||||
const exists = await window.electron.checkSteamShortcut(
|
||||
game.shop,
|
||||
game.objectId
|
||||
);
|
||||
setSteamShortcutExists(exists);
|
||||
|
||||
updateGame();
|
||||
} catch (error: unknown) {
|
||||
logger.error("Failed to create Steam shortcut", error);
|
||||
@@ -331,18 +425,15 @@ export function GameOptionsModal({
|
||||
try {
|
||||
setCreatingSteamShortcut(true);
|
||||
await window.electron.deleteSteamShortcut(game.shop, game.objectId);
|
||||
|
||||
showSuccessToast(
|
||||
t("delete_shortcut_success"),
|
||||
t("you_might_need_to_restart_steam")
|
||||
);
|
||||
|
||||
const exists = await window.electron.checkSteamShortcut(
|
||||
game.shop,
|
||||
game.objectId
|
||||
);
|
||||
setSteamShortcutExists(exists);
|
||||
|
||||
updateGame();
|
||||
} catch (error: unknown) {
|
||||
logger.error("Failed to delete Steam shortcut", error);
|
||||
@@ -355,57 +446,43 @@ export function GameOptionsModal({
|
||||
const handleCreateShortcut = async (location: ShortcutLocation) => {
|
||||
window.electron
|
||||
.createGameShortcut(game.shop, game.objectId, location)
|
||||
.then((success) => {
|
||||
if (success) {
|
||||
showSuccessToast(t("create_shortcut_success"));
|
||||
} else {
|
||||
showErrorToast(t("create_shortcut_error"));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showErrorToast(t("create_shortcut_error"));
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenDownloadFolder = async () => {
|
||||
await window.electron.openGameInstallerPath(game.shop, game.objectId);
|
||||
.then((success: boolean) =>
|
||||
success
|
||||
? showSuccessToast(t("create_shortcut_success"))
|
||||
: showErrorToast(t("create_shortcut_error"))
|
||||
)
|
||||
.catch(() => showErrorToast(t("create_shortcut_error")));
|
||||
};
|
||||
|
||||
const handleOpenDownloadFolder = () =>
|
||||
window.electron.openGameInstallerPath(game.shop, game.objectId);
|
||||
const handleDeleteGame = async () => {
|
||||
await removeGameInstaller(game.shop, game.objectId);
|
||||
updateGame();
|
||||
};
|
||||
|
||||
const handleOpenGameExecutablePath = async () => {
|
||||
await window.electron.openGameExecutablePath(game.shop, game.objectId);
|
||||
};
|
||||
|
||||
const handleOpenGameExecutablePath = () =>
|
||||
window.electron.openGameExecutablePath(game.shop, game.objectId);
|
||||
const handleOpenSaveFolder = async () => {
|
||||
if (saveFolderPath) {
|
||||
if (saveFolderPath)
|
||||
await window.electron.openGameSaveFolder(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
saveFolderPath
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearExecutablePath = async () => {
|
||||
await window.electron.updateExecutablePath(game.shop, game.objectId, null);
|
||||
|
||||
updateGame();
|
||||
};
|
||||
|
||||
const handleChangeWinePrefixPath = async () => {
|
||||
const defaultPath =
|
||||
await window.electron.getDefaultWinePrefixSelectionPath();
|
||||
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openDirectory"],
|
||||
defaultPath: game?.winePrefixPath ?? defaultPath ?? "",
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
if (filePaths?.length) {
|
||||
try {
|
||||
await window.electron.selectGameWinePrefix(
|
||||
game.shop,
|
||||
@@ -413,7 +490,7 @@ export function GameOptionsModal({
|
||||
filePaths[0]
|
||||
);
|
||||
await updateGame();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
showErrorToast(
|
||||
t("invalid_wine_prefix_path"),
|
||||
t("invalid_wine_prefix_path_description")
|
||||
@@ -426,18 +503,14 @@ export function GameOptionsModal({
|
||||
await window.electron.selectGameWinePrefix(game.shop, game.objectId, null);
|
||||
updateGame();
|
||||
};
|
||||
|
||||
const handleOpenWinetricks = async () => {
|
||||
const success = await window.electron.openGameWinetricks(
|
||||
game.shop,
|
||||
game.objectId
|
||||
);
|
||||
|
||||
if (success) {
|
||||
showSuccessToast(t("winetricks_opened"));
|
||||
} else {
|
||||
showErrorToast(t("winetricks_open_error"));
|
||||
}
|
||||
success
|
||||
? showSuccessToast(t("winetricks_opened"))
|
||||
: showErrorToast(t("winetricks_open_error"));
|
||||
};
|
||||
|
||||
const handleChangeMangohudState = async (value: boolean) => {
|
||||
@@ -445,7 +518,6 @@ export function GameOptionsModal({
|
||||
await window.electron.toggleGameMangohud(game.shop, game.objectId, value);
|
||||
updateGame();
|
||||
};
|
||||
|
||||
const handleChangeGamemodeState = async (value: boolean) => {
|
||||
setAutoRunGamemode(value);
|
||||
await window.electron.toggleGameGamemode(game.shop, game.objectId, value);
|
||||
@@ -469,43 +541,32 @@ export function GameOptionsModal({
|
||||
const handleChangeLaunchOptions = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const value = event.target.value;
|
||||
|
||||
setLaunchOptions(value);
|
||||
debounceUpdateLaunchOptions(value);
|
||||
const v = event.target.value;
|
||||
setLaunchOptions(v);
|
||||
debounceUpdateLaunchOptions(v);
|
||||
};
|
||||
|
||||
const handleChangeGameTitle = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const handleChangeGameTitle = (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setGameTitle(event.target.value);
|
||||
};
|
||||
|
||||
const handleBlurGameTitle = async () => {
|
||||
if (updatingGameTitle) return;
|
||||
|
||||
const trimmedTitle = gameTitle.trim();
|
||||
const currentTitle = (game.title ?? "").trim();
|
||||
|
||||
if (!trimmedTitle) {
|
||||
const trimmed = gameTitle.trim();
|
||||
if (!trimmed) {
|
||||
setGameTitle(game.title ?? "");
|
||||
showErrorToast(t("edit_game_modal_fill_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedTitle === currentTitle) {
|
||||
if (trimmed === (game.title ?? "").trim()) {
|
||||
setGameTitle(game.title ?? "");
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdatingGameTitle(true);
|
||||
|
||||
try {
|
||||
if (game.shop === "custom") {
|
||||
await window.electron.updateCustomGame({
|
||||
shop: game.shop,
|
||||
objectId: game.objectId,
|
||||
title: trimmedTitle,
|
||||
title: trimmed,
|
||||
iconUrl: game.iconUrl || undefined,
|
||||
logoImageUrl: game.logoImageUrl || undefined,
|
||||
libraryHeroImageUrl: game.libraryHeroImageUrl || undefined,
|
||||
@@ -514,12 +575,11 @@ export function GameOptionsModal({
|
||||
await window.electron.updateGameCustomAssets({
|
||||
shop: game.shop,
|
||||
objectId: game.objectId,
|
||||
title: trimmedTitle,
|
||||
title: trimmed,
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all([updateGame(), updateLibrary()]);
|
||||
setGameTitle(trimmedTitle);
|
||||
setGameTitle(trimmed);
|
||||
} catch (error) {
|
||||
setGameTitle(game.title ?? "");
|
||||
showErrorToast(
|
||||
@@ -532,27 +592,22 @@ export function GameOptionsModal({
|
||||
|
||||
const handleChangeProtonVersion = (value: string) => {
|
||||
setSelectedProtonPath(value);
|
||||
|
||||
const currentProtonPath = game.protonPath ?? "";
|
||||
if (value === currentProtonPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
void applyProtonPathChange(value);
|
||||
if (value !== (game.protonPath ?? "")) applyProtonPathChange(value);
|
||||
};
|
||||
|
||||
const handleClearLaunchOptions = async () => {
|
||||
setLaunchOptions("");
|
||||
|
||||
const gameKey = getGameKey(game.shop, game.objectId);
|
||||
const gameData = (await levelDBService.get(
|
||||
gameKey,
|
||||
"games"
|
||||
)) as Game | null;
|
||||
if (gameData) {
|
||||
const updated = { ...gameData, launchOptions: null };
|
||||
await levelDBService.put(gameKey, updated, "games");
|
||||
}
|
||||
if (gameData)
|
||||
await levelDBService.put(
|
||||
gameKey,
|
||||
{ ...gameData, launchOptions: null },
|
||||
"games"
|
||||
);
|
||||
updateGame();
|
||||
};
|
||||
|
||||
@@ -571,6 +626,11 @@ export function GameOptionsModal({
|
||||
label: t("settings_category_general"),
|
||||
icon: <GearIcon size={16} />,
|
||||
},
|
||||
{
|
||||
id: "locations" as const,
|
||||
label: t("settings_category_locations"),
|
||||
icon: <FileDirectoryIcon size={16} />,
|
||||
},
|
||||
{
|
||||
id: "assets" as const,
|
||||
label: t("settings_category_assets"),
|
||||
@@ -605,36 +665,30 @@ export function GameOptionsModal({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedCategory(initialCategory ?? "general");
|
||||
}
|
||||
if (visible) setSelectedCategory(initialCategory ?? "general");
|
||||
}, [initialCategory, visible]);
|
||||
|
||||
const shouldShowCreateStartMenuShortcut =
|
||||
window.electron.platform === "win32";
|
||||
|
||||
const handleResetAchievements = async () => {
|
||||
setIsDeletingAchievements(true);
|
||||
try {
|
||||
await window.electron.resetGameAchievements(game.shop, game.objectId);
|
||||
await updateGame();
|
||||
showSuccessToast(t("reset_achievements_success"));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
showErrorToast(t("reset_achievements_error"));
|
||||
} finally {
|
||||
setIsDeletingAchievements(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePlaytime = async (playtimeInSeconds: number) => {
|
||||
const handleChangePlaytime = async (sec: number) => {
|
||||
try {
|
||||
await window.electron.changeGamePlayTime(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
playtimeInSeconds
|
||||
);
|
||||
await window.electron.changeGamePlayTime(game.shop, game.objectId, sec);
|
||||
await updateGame();
|
||||
showSuccessToast(t("update_playtime_success"));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
showErrorToast(t("update_playtime_error"));
|
||||
}
|
||||
};
|
||||
@@ -643,17 +697,17 @@ export function GameOptionsModal({
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
setAutomaticCloudSync(event.target.checked);
|
||||
|
||||
const gameKey = getGameKey(game.shop, game.objectId);
|
||||
const gameData = (await levelDBService.get(
|
||||
gameKey,
|
||||
"games"
|
||||
)) as Game | null;
|
||||
if (gameData) {
|
||||
const updated = { ...gameData, automaticCloudSync: event.target.checked };
|
||||
await levelDBService.put(gameKey, updated, "games");
|
||||
}
|
||||
|
||||
if (gameData)
|
||||
await levelDBService.put(
|
||||
gameKey,
|
||||
{ ...gameData, automaticCloudSync: event.target.checked },
|
||||
"games"
|
||||
);
|
||||
updateGame();
|
||||
};
|
||||
|
||||
@@ -664,28 +718,24 @@ export function GameOptionsModal({
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
deleteGame={handleDeleteGame}
|
||||
/>
|
||||
|
||||
<RemoveGameFromLibraryModal
|
||||
visible={showRemoveGameModal}
|
||||
onClose={() => setShowRemoveGameModal(false)}
|
||||
removeGameFromLibrary={handleRemoveGameFromLibrary}
|
||||
game={game}
|
||||
/>
|
||||
|
||||
<ResetAchievementsModal
|
||||
visible={showResetAchievementsModal}
|
||||
onClose={() => setShowResetAchievementsModal(false)}
|
||||
resetAchievements={handleResetAchievements}
|
||||
game={game}
|
||||
/>
|
||||
|
||||
<ChangeGamePlaytimeModal
|
||||
visible={showChangePlaytimeModal}
|
||||
onClose={() => setShowChangePlaytimeModal(false)}
|
||||
changePlaytime={handleChangePlaytime}
|
||||
game={game}
|
||||
/>
|
||||
|
||||
<CreateSteamShortcutModal
|
||||
visible={showSteamShortcutModal}
|
||||
creating={creatingSteamShortcut}
|
||||
@@ -706,7 +756,6 @@ export function GameOptionsModal({
|
||||
selectedCategory={selectedCategory}
|
||||
onSelectCategory={setSelectedCategory}
|
||||
/>
|
||||
|
||||
<div className="game-options-modal__panel">
|
||||
{selectedCategory === "general" && (
|
||||
<GeneralSettingsSection
|
||||
@@ -735,9 +784,64 @@ export function GameOptionsModal({
|
||||
onBlurGameTitle={handleBlurGameTitle}
|
||||
onChangeLaunchOptions={handleChangeLaunchOptions}
|
||||
onClearLaunchOptions={handleClearLaunchOptions}
|
||||
isTransferring={isTransferring}
|
||||
transferProgress={transferProgress}
|
||||
drives={drives}
|
||||
onStartTransfer={handleStartTransfer}
|
||||
onCancelDriveSelection={() => {}}
|
||||
transferSpeed={transferSpeed}
|
||||
transferETA={transferETA}
|
||||
showCancelConfirm={showCancelConfirm}
|
||||
onShowCancelConfirm={() => setShowCancelConfirm(true)}
|
||||
onHideCancelConfirm={() => setShowCancelConfirm(false)}
|
||||
onConfirmCancelTransfer={handleCancelTransfer}
|
||||
showExecutableSection={false}
|
||||
showTransferSection={false}
|
||||
/>
|
||||
)}
|
||||
{selectedCategory === "locations" && (
|
||||
<GeneralSettingsSection
|
||||
game={game}
|
||||
gameTitle={gameTitle}
|
||||
launchOptions={launchOptions}
|
||||
updatingGameTitle={updatingGameTitle}
|
||||
creatingSteamShortcut={creatingSteamShortcut}
|
||||
shouldShowCreateStartMenuShortcut={
|
||||
shouldShowCreateStartMenuShortcut
|
||||
}
|
||||
shouldShowWinePrefixConfiguration={
|
||||
shouldShowWinePrefixConfiguration
|
||||
}
|
||||
loadingSaveFolder={loadingSaveFolder}
|
||||
saveFolderPath={saveFolderPath}
|
||||
steamShortcutExists={steamShortcutExists}
|
||||
onChangeExecutableLocation={handleChangeExecutableLocation}
|
||||
onClearExecutablePath={handleClearExecutablePath}
|
||||
onOpenGameExecutablePath={handleOpenGameExecutablePath}
|
||||
onOpenSaveFolder={handleOpenSaveFolder}
|
||||
onCreateShortcut={handleCreateShortcut}
|
||||
onCreateSteamShortcut={() => setShowSteamShortcutModal(true)}
|
||||
onDeleteSteamShortcut={handleDeleteSteamShortcut}
|
||||
onChangeGameTitle={handleChangeGameTitle}
|
||||
onBlurGameTitle={handleBlurGameTitle}
|
||||
onChangeLaunchOptions={handleChangeLaunchOptions}
|
||||
onClearLaunchOptions={handleClearLaunchOptions}
|
||||
isTransferring={isTransferring}
|
||||
transferProgress={transferProgress}
|
||||
drives={drives}
|
||||
onStartTransfer={handleStartTransfer}
|
||||
onCancelDriveSelection={() => {}}
|
||||
transferSpeed={transferSpeed}
|
||||
transferETA={transferETA}
|
||||
showCancelConfirm={showCancelConfirm}
|
||||
onShowCancelConfirm={() => setShowCancelConfirm(true)}
|
||||
onHideCancelConfirm={() => setShowCancelConfirm(false)}
|
||||
onConfirmCancelTransfer={handleCancelTransfer}
|
||||
showTitleSection={false}
|
||||
showShortcutsSection={false}
|
||||
showLaunchOptionsSection={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedCategory === "assets" && (
|
||||
<GameAssetsSettings
|
||||
game={game}
|
||||
@@ -745,7 +849,6 @@ export function GameOptionsModal({
|
||||
onGameUpdated={updateGame}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedCategory === "hydra_cloud" && (
|
||||
<HydraCloudSettingsSection
|
||||
game={game}
|
||||
@@ -753,7 +856,6 @@ export function GameOptionsModal({
|
||||
onToggleAutomaticCloudSync={handleToggleAutomaticCloudSync}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedCategory === "compatibility" &&
|
||||
shouldShowWinePrefixConfiguration && (
|
||||
<CompatibilitySettingsSection
|
||||
@@ -778,7 +880,6 @@ export function GameOptionsModal({
|
||||
onChangeProtonVersion={handleChangeProtonVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedCategory === "downloads" && (
|
||||
<DownloadsSettingsSection
|
||||
game={game}
|
||||
@@ -789,7 +890,6 @@ export function GameOptionsModal({
|
||||
onOpenDownloadFolder={handleOpenDownloadFolder}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedCategory === "danger_zone" && (
|
||||
<DangerZoneSection
|
||||
game={game}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, TextField } from "@renderer/components";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import type { LibraryGame, ShortcutLocation } from "@types";
|
||||
import { FileIcon } from "@primer/octicons-react";
|
||||
import { HardDrive, X, FolderOpen } from "lucide-react";
|
||||
|
||||
interface DriveInfo {
|
||||
root: string;
|
||||
label: string;
|
||||
free: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface GeneralSettingsSectionProps {
|
||||
game: LibraryGame;
|
||||
@@ -27,6 +35,44 @@ interface GeneralSettingsSectionProps {
|
||||
onBlurGameTitle: () => Promise<void>;
|
||||
onChangeLaunchOptions: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onClearLaunchOptions: () => Promise<void>;
|
||||
isTransferring: boolean;
|
||||
transferProgress: number;
|
||||
drives: DriveInfo[];
|
||||
onStartTransfer: (destPath: string) => Promise<void>;
|
||||
onCancelDriveSelection: () => void;
|
||||
transferSpeed?: number;
|
||||
transferETA?: number;
|
||||
showCancelConfirm?: boolean;
|
||||
onShowCancelConfirm?: () => void;
|
||||
onHideCancelConfirm?: () => void;
|
||||
onConfirmCancelTransfer?: () => void;
|
||||
showTitleSection?: boolean;
|
||||
showExecutableSection?: boolean;
|
||||
showTransferSection?: boolean;
|
||||
showShortcutsSection?: boolean;
|
||||
showLaunchOptionsSection?: boolean;
|
||||
}
|
||||
|
||||
//
|
||||
function formatETA(seconds: number) {
|
||||
if (seconds <= 0) return "";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
if (seconds < 86400) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
return `${d}d ${h}h`;
|
||||
}
|
||||
|
||||
function fmt(b: number) {
|
||||
if (b >= 1e12) return (b / 1e12).toFixed(1) + " TB";
|
||||
if (b >= 1e9) return (b / 1e9).toFixed(1) + " GB";
|
||||
if (b >= 1e6) return (b / 1e6).toFixed(1) + " MB";
|
||||
return (b / 1e3).toFixed(0) + " KB";
|
||||
}
|
||||
|
||||
export function GeneralSettingsSection({
|
||||
@@ -51,86 +97,377 @@ export function GeneralSettingsSection({
|
||||
onBlurGameTitle,
|
||||
onChangeLaunchOptions,
|
||||
onClearLaunchOptions,
|
||||
isTransferring,
|
||||
transferProgress,
|
||||
drives,
|
||||
onStartTransfer,
|
||||
onCancelDriveSelection,
|
||||
transferSpeed = 0,
|
||||
transferETA = 0,
|
||||
showCancelConfirm = false,
|
||||
onShowCancelConfirm = () => {},
|
||||
onHideCancelConfirm = () => {},
|
||||
onConfirmCancelTransfer = () => {},
|
||||
showTitleSection = true,
|
||||
showExecutableSection = true,
|
||||
showTransferSection = true,
|
||||
showShortcutsSection = true,
|
||||
showLaunchOptionsSection = true,
|
||||
}: Readonly<GeneralSettingsSectionProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const [selectedDrive, setSelectedDrive] = useState<string | null>(null);
|
||||
const [customPath, setCustomPath] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPreparing, setIsPreparing] = useState(false);
|
||||
|
||||
const gameSize = game.installedSizeInBytes ?? 0;
|
||||
const progressPercent = Math.round(transferProgress * 100);
|
||||
const transferredBytes = gameSize * transferProgress;
|
||||
const transferGameLabel =
|
||||
gameSize > 0 ? `${game.title} (${fmt(gameSize)})` : game.title;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTransferring) return;
|
||||
console.log(
|
||||
"Transfer progress update:",
|
||||
`${Math.round(transferProgress * 100)}%`
|
||||
);
|
||||
}, [isTransferring, transferProgress]);
|
||||
|
||||
const handleStartTransfer = async () => {
|
||||
let fullDest: string;
|
||||
const platform = window.electron.platform;
|
||||
const pathSeparator = platform === "win32" ? String.fromCharCode(92) : "/";
|
||||
|
||||
if (selectedDrive !== null) {
|
||||
const normalizedDrive =
|
||||
selectedDrive.length > 1 && selectedDrive.endsWith(pathSeparator)
|
||||
? selectedDrive.slice(0, -1)
|
||||
: selectedDrive;
|
||||
fullDest = `${normalizedDrive}${pathSeparator}Hydra Games`;
|
||||
} else if (customPath.trim()) {
|
||||
fullDest = customPath.trim();
|
||||
} else {
|
||||
setError(t("select_destination"));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsPreparing(true);
|
||||
|
||||
try {
|
||||
await onStartTransfer(fullDest);
|
||||
setSelectedDrive(null);
|
||||
setCustomPath("");
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t("transfer_failed"));
|
||||
} finally {
|
||||
setIsPreparing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowse = async () => {
|
||||
const res = await window.electron.showOpenDialog({
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
if (!res.canceled && res.filePaths[0]) {
|
||||
setCustomPath(res.filePaths[0]);
|
||||
setSelectedDrive(null);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelSelector = () => {
|
||||
setSelectedDrive(null);
|
||||
setCustomPath("");
|
||||
setError(null);
|
||||
onCancelDriveSelection();
|
||||
};
|
||||
|
||||
const effectiveDest = selectedDrive || customPath.trim();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="game-options-modal__section">
|
||||
<TextField
|
||||
label={t("edit_game_modal_title")}
|
||||
placeholder={t("edit_game_modal_enter_title")}
|
||||
value={gameTitle}
|
||||
onChange={onChangeGameTitle}
|
||||
onBlur={() => void onBlurGameTitle()}
|
||||
theme="dark"
|
||||
disabled={updatingGameTitle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="game-options-modal__section">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("executable_section_title")}</h2>
|
||||
<h4 className="game-options-modal__header-description">
|
||||
{t("executable_section_description")}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="game-options-modal__executable-field">
|
||||
{/* Title */}
|
||||
{showTitleSection && (
|
||||
<div className="game-options-modal__section">
|
||||
<TextField
|
||||
value={game.executablePath || ""}
|
||||
readOnly
|
||||
label={t("edit_game_modal_title")}
|
||||
placeholder={t("edit_game_modal_enter_title")}
|
||||
value={gameTitle}
|
||||
onChange={onChangeGameTitle}
|
||||
onBlur={() => void onBlurGameTitle()}
|
||||
theme="dark"
|
||||
disabled
|
||||
placeholder={t("no_executable_selected")}
|
||||
rightContent={
|
||||
<>
|
||||
disabled={updatingGameTitle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Executable */}
|
||||
{showExecutableSection && (
|
||||
<div className="game-options-modal__section">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("executable_section_title")}</h2>
|
||||
<h4 className="game-options-modal__header-description">
|
||||
{t("executable_section_description")}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="game-options-modal__executable-field">
|
||||
<TextField
|
||||
value={game.executablePath || ""}
|
||||
readOnly
|
||||
theme="dark"
|
||||
disabled
|
||||
placeholder={t("no_executable_selected")}
|
||||
rightContent={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={onChangeExecutableLocation}
|
||||
>
|
||||
<FileIcon />
|
||||
{t("select_executable")}
|
||||
</Button>
|
||||
{game.executablePath && (
|
||||
<Button onClick={onClearExecutablePath} theme="outline">
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="game-options-modal__executable-field-buttons">
|
||||
{game.executablePath && (
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={onChangeExecutableLocation}
|
||||
onClick={onOpenGameExecutablePath}
|
||||
>
|
||||
<FileIcon />
|
||||
{t("select_executable")}
|
||||
<FolderOpen size={14} />
|
||||
{t("open_folder")}
|
||||
</Button>
|
||||
{game.executablePath && (
|
||||
<Button onClick={onClearExecutablePath} theme="outline">
|
||||
{t("clear")}
|
||||
)}
|
||||
{game.shop !== "custom" &&
|
||||
window.electron.platform === "win32" && (
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={onOpenSaveFolder}
|
||||
disabled={loadingSaveFolder || !saveFolderPath}
|
||||
>
|
||||
<HardDrive size={14} />
|
||||
{loadingSaveFolder
|
||||
? t("searching_save_folder")
|
||||
: saveFolderPath
|
||||
? t("open_save_folder")
|
||||
: t("no_save_folder_found")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="game-options-modal__executable-field-buttons">
|
||||
{game.executablePath && (
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={onOpenGameExecutablePath}
|
||||
>
|
||||
{t("open_folder")}
|
||||
</Button>
|
||||
)}
|
||||
{game.shop !== "custom" && window.electron.platform === "win32" && (
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={onOpenSaveFolder}
|
||||
disabled={loadingSaveFolder || !saveFolderPath}
|
||||
>
|
||||
{loadingSaveFolder
|
||||
? t("searching_save_folder")
|
||||
: saveFolderPath
|
||||
? t("open_save_folder")
|
||||
: t("no_save_folder_found")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{game.executablePath && (
|
||||
{/* Drive Selector */}
|
||||
{showTransferSection && game.executablePath && !isTransferring && (
|
||||
<div className="game-options-modal__section">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("transfer_game")}</h2>
|
||||
<h4 className="game-options-modal__header-description">
|
||||
{t("transfer_game_description", { game: transferGameLabel })}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="drive-selector">
|
||||
{drives.length > 0 && (
|
||||
<div className="drive-selector__list">
|
||||
<div className="drive-selector__list-title">
|
||||
{t("transfer_available_drives")}
|
||||
</div>
|
||||
{drives.map((drive) => {
|
||||
const hasSufficientSpace = drive.free >= gameSize;
|
||||
const hasInsufficientSpace = !hasSufficientSpace;
|
||||
const isSelected =
|
||||
selectedDrive === drive.root && !customPath;
|
||||
const usedPct = Math.round(
|
||||
((drive.total - drive.free) / drive.total) * 100
|
||||
);
|
||||
const gamePct =
|
||||
gameSize > 0
|
||||
? Math.min((gameSize / drive.total) * 100, 100 - usedPct)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={drive.root}
|
||||
type="button"
|
||||
className={`drive-card ${isSelected ? "drive-card--selected" : ""} ${hasInsufficientSpace ? "drive-card--nospace" : ""}`}
|
||||
onClick={() => {
|
||||
if (hasInsufficientSpace) return;
|
||||
setSelectedDrive(drive.root);
|
||||
setCustomPath("");
|
||||
setError(null);
|
||||
}}
|
||||
disabled={hasInsufficientSpace}
|
||||
>
|
||||
<HardDrive size={18} className="drive-card__icon" />
|
||||
<div className="drive-card__body">
|
||||
<div className="drive-card__top">
|
||||
<span className="drive-card__label">
|
||||
{drive.label || drive.root}
|
||||
</span>
|
||||
<span
|
||||
className={`drive-card__space ${hasInsufficientSpace ? "drive-card__space--error" : ""}`}
|
||||
>
|
||||
{fmt(drive.free)} {t("transfer_free")} /{" "}
|
||||
{fmt(drive.total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="drive-card__bar">
|
||||
<div
|
||||
className="drive-card__bar-used"
|
||||
style={{ width: `${usedPct}%` }}
|
||||
/>
|
||||
{gamePct > 0 && (
|
||||
<div
|
||||
className="drive-card__bar-game"
|
||||
style={{
|
||||
width: `${gamePct}%`,
|
||||
left: `${usedPct}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasInsufficientSpace && (
|
||||
<span className="drive-card__tag">
|
||||
{t("transfer_insufficient_space")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="drive-selector__custom">
|
||||
<div className="drive-selector__path-row">
|
||||
<TextField
|
||||
value={customPath}
|
||||
onChange={(e) => {
|
||||
setCustomPath(e.target.value);
|
||||
setSelectedDrive(null);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("transfer_destination_placeholder")}
|
||||
theme="dark"
|
||||
/>
|
||||
<Button type="button" theme="outline" onClick={handleBrowse}>
|
||||
<FolderOpen size={14} />
|
||||
{t("transfer_browse")}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="drive-selector__error">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="drive-selector__actions">
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={handleCancelSelector}
|
||||
>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
theme="danger"
|
||||
onClick={handleStartTransfer}
|
||||
disabled={!effectiveDest || isPreparing}
|
||||
>
|
||||
{isPreparing ? t("transfer_preparing") : t("start_transfer")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transfer Progress */}
|
||||
{showTransferSection && isTransferring && (
|
||||
<div className="game-options-modal__section">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("transfer_game")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="transfer-progress">
|
||||
<div className="transfer-progress__header">
|
||||
<div className="transfer-progress__title">
|
||||
<span>{t("transfer_moving_files")}</span>
|
||||
</div>
|
||||
<span className="transfer-progress__pct">{progressPercent}%</span>
|
||||
</div>
|
||||
|
||||
<div className="transfer-progress__track">
|
||||
<div
|
||||
className="transfer-progress__fill"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="transfer-progress__stats">
|
||||
<span className="transfer-progress__size">
|
||||
{fmt(transferredBytes)} / {fmt(gameSize)}
|
||||
</span>
|
||||
<span className="transfer-progress__speed">
|
||||
{transferSpeed > 0 ? (
|
||||
<>
|
||||
{transferSpeed.toFixed(1)} {t("transfer_speed_unit")}
|
||||
{transferETA > 0 &&
|
||||
` • ${t("transfer_eta_label", { eta: formatETA(transferETA) })}`}
|
||||
</>
|
||||
) : (
|
||||
t("transfer_calculating")
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="transfer-progress__actions">
|
||||
<Button
|
||||
type="button"
|
||||
theme="danger"
|
||||
onClick={onShowCancelConfirm}
|
||||
>
|
||||
<X size={12} />
|
||||
{t("transfer_cancel_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancel Confirmation Modal */}
|
||||
{showTransferSection && showCancelConfirm && (
|
||||
<div className="cancel-confirm-overlay">
|
||||
<div className="cancel-confirm-modal">
|
||||
<h4>{t("transfer_cancel_title")}</h4>
|
||||
<p>{t("transfer_cancel_description")}</p>
|
||||
<div className="cancel-confirm-actions">
|
||||
<Button theme="outline" onClick={onHideCancelConfirm}>
|
||||
{t("transfer_continue")}
|
||||
</Button>
|
||||
<Button theme="danger" onClick={onConfirmCancelTransfer}>
|
||||
{t("transfer_cancel_confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shortcuts */}
|
||||
{showShortcutsSection && game.executablePath && (
|
||||
<div className="game-options-modal__section">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("shortcuts_section_title")}</h2>
|
||||
@@ -138,7 +475,6 @@ export function GeneralSettingsSection({
|
||||
{t("shortcuts_section_description")}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="game-options-modal__row">
|
||||
<Button onClick={() => onCreateShortcut("desktop")} theme="outline">
|
||||
{t("create_shortcut")}
|
||||
@@ -175,39 +511,41 @@ export function GeneralSettingsSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="game-options-modal__launch-options">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("launch_options")}</h2>
|
||||
<h4 className="game-options-modal__header-description">
|
||||
{shouldShowWinePrefixConfiguration ? (
|
||||
<Trans
|
||||
i18nKey="launch_options_description_linux"
|
||||
ns="game_details"
|
||||
defaults="Add game launch arguments, or use <code>%command%</code> to wrap the launch command."
|
||||
components={{
|
||||
code: <code className="game-options-modal__inline-code" />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t("launch_options_description")
|
||||
)}
|
||||
</h4>
|
||||
{/* Launch options */}
|
||||
{showLaunchOptionsSection && (
|
||||
<div className="game-options-modal__launch-options">
|
||||
<div className="game-options-modal__header">
|
||||
<h2>{t("launch_options")}</h2>
|
||||
<h4 className="game-options-modal__header-description">
|
||||
{shouldShowWinePrefixConfiguration ? (
|
||||
<Trans
|
||||
i18nKey="launch_options_description_linux"
|
||||
ns="game_details"
|
||||
defaults="Add game launch arguments, or use <code>%command%</code> to wrap the launch command."
|
||||
components={{
|
||||
code: <code className="game-options-modal__inline-code" />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t("launch_options_description")
|
||||
)}
|
||||
</h4>
|
||||
</div>
|
||||
<TextField
|
||||
value={launchOptions}
|
||||
theme="dark"
|
||||
placeholder={t("launch_options_placeholder")}
|
||||
onChange={onChangeLaunchOptions}
|
||||
rightContent={
|
||||
game.launchOptions && (
|
||||
<Button onClick={onClearLaunchOptions} theme="outline">
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
value={launchOptions}
|
||||
theme="dark"
|
||||
placeholder={t("launch_options_placeholder")}
|
||||
onChange={onChangeLaunchOptions}
|
||||
rightContent={
|
||||
game.launchOptions && (
|
||||
<Button onClick={onClearLaunchOptions} theme="outline">
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type GameSettingsCategoryId =
|
||||
| "general"
|
||||
| "locations"
|
||||
| "assets"
|
||||
| "hydra_cloud"
|
||||
| "compatibility"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
}
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/locales/index.ts", "src/shared/**/*", "src/types/**/*"],
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"composite": true,
|
||||
"types": ["electron-vite/node"],
|
||||
"baseUrl": ".",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"paths": {
|
||||
"@main/*": ["src/main/*"],
|
||||
"@renderer/*": ["src/renderer/*"],
|
||||
"@types": ["src/types/index.ts"],
|
||||
"@locales": ["src/locales/index.ts"],
|
||||
"@resources": ["src/resources/index.ts"],
|
||||
"@shared": ["src/shared/index.ts"]
|
||||
}
|
||||
}
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/locales/index.ts", "src/shared/**/*", "src/types/**/*"],
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"composite": true,
|
||||
"types": ["electron-vite/node"],
|
||||
"baseUrl": ".",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"paths": {
|
||||
"@main/*": ["src/main/*"],
|
||||
"@renderer/*": ["src/renderer/*"],
|
||||
"@types": ["src/types/index.ts"],
|
||||
"@locales": ["src/locales/index.ts"],
|
||||
"@resources": ["src/resources/index.ts"],
|
||||
"@shared": ["src/shared/index.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": [
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.tsx",
|
||||
"src/preload/*.d.ts",
|
||||
"src/locales/index.ts",
|
||||
"src/shared/**/*",
|
||||
"src/stories/**/*",
|
||||
"src/types/**/*",
|
||||
".storybook/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@renderer/*": [
|
||||
"src/renderer/src/*"
|
||||
],
|
||||
"@types": ["src/types/index.ts"],
|
||||
"@locales": ["src/locales/index.ts"],
|
||||
"@shared": ["src/shared/index.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": [
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.tsx",
|
||||
"src/preload/*.d.ts",
|
||||
"src/locales/index.ts",
|
||||
"src/shared/**/*",
|
||||
"src/stories/**/*",
|
||||
"src/types/**/*",
|
||||
".storybook/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@renderer/*": [
|
||||
"src/renderer/src/*"
|
||||
],
|
||||
"@types": ["src/types/index.ts"],
|
||||
"@locales": ["src/locales/index.ts"],
|
||||
"@shared": ["src/shared/index.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||