97 Commits

Author SHA1 Message Date
renovate[bot]
b533bf43b4 Update dependency vue to v3.5.25 2025-11-24 09:41:09 +00:00
Copilot
0dae544d54 fix(mount): mount button disabled incorrectly on macOS when directory exists (#109) (#110)
Fix macOS mount button disabled issue (#109) - correct mount status logic and add directory creation.
2025-11-13 21:29:26 +08:00
GitHub Action
f3a5e2556f chore: bump version to 0.8.0 [skip ci] 2025-11-08 04:29:05 +00:00
Suyunmeng
a3a6ef03a5 feat(ui): persist log filter preferences in settings file
This commit improves the log viewer by persisting user filter preferences
across page navigation and application restarts.

Changes:
- Add log_filter_level and log_filter_source fields to AppConfig
- Store filter preferences in settings.json instead of localStorage
- Ensure filter preferences survive app restarts and page switches
- Fix clippy warning: use &Path instead of &PathBuf in normalize_path

The filter level and source selections are now saved to the persistent
settings file, providing a more reliable storage mechanism for Tauri
applications.
2025-11-08 11:19:18 +08:00
Suyunmeng
e4ab2184eb fix(data): fix data directory errors and log display issues 2025-11-08 09:53:57 +08:00
Suyunjing
4c76f31885 chore(ci): update ci configurations 2025-10-30 15:48:58 +08:00
Yinan Qin
68411aaaf3 fix!: update log and data dirs for linux and windows (#108)
* fix: update log dir for linux and windows

Signed-off-by: Yinan Qin <39023210+elysia-best@users.noreply.github.com>

* fix: update log dirs for linux and windows

Signed-off-by: Yinan Qin <39023210+elysia-best@users.noreply.github.com>

* chore: refactor APPDATA variable retrieval for clarity

Signed-off-by: Yinan Qin <39023210+elysia-best@users.noreply.github.com>

* fix: update version to 0.7.0 and improve data directory handling

* fix: fix clippy error

---------

Signed-off-by: Yinan Qin <39023210+elysia-best@users.noreply.github.com>
2025-10-27 11:06:31 +08:00
GitHub Action
e00c0d5ffd chore: bump version to 0.7.0 [skip ci] 2025-08-28 07:18:22 +00:00
Kuingsmile
ca0b9251d3 Feature(custom): change default value of show window on startup to true 2025-08-28 15:11:34 +08:00
Kuingsmile
94027733fc Feature(custom): add visible false in macos configuration 2025-08-28 13:23:49 +08:00
Kuingsmile
674c9b0041 Feature(custom): add option to show/hide main window on startup 2025-08-27 17:55:29 +08:00
Kuingsmile
a81f0ed9eb 📦 Chore(custom): add prettier rules 2025-08-27 14:13:30 +08:00
Kuingsmile
d4a42de814 Feature(custom): optimize some setting description 2025-08-26 10:08:53 +08:00
Kuingsmile
bf0f481086 🐛 Fix(custom): update padding for dashboard container and grid for improved layout 2025-08-26 09:53:39 +08:00
Kuingsmile
6d6c38eab9 feat: use task scheduler to start service 2025-08-25 17:56:04 +08:00
Kuingsmile
d2c834d936 Feature(custom): add rclone api port setting
ISSUES CLOSED: #73
2025-08-25 15:28:00 +08:00
Kuingsmile
31980949a3 Feature(custom): add show_window_on_startup option
ISSUES CLOSED: #77
2025-08-25 14:43:56 +08:00
Kuingsmile
58b7128e4e Feature(custom): add show_window_on_startup option
ISSUES CLOSED: #77
2025-08-25 14:43:10 +08:00
Kuingsmile
62f2710a7c 🐛 Fix(custom): fix dir cache time flag
ISSUES CLOSED: #80
2025-08-20 11:54:54 +08:00
Kuingsmile
15d789ef3a chore: bump version to 0.6.1 2025-07-23 13:46:31 +08:00
Kuingsmile
d6ff164b0c feat: add GitHub Actions workflow for cross-platform build testing 2025-07-23 13:43:38 +08:00
Kuingsmile
7f7cc8c8ce refactor: remove unnecessary logging in Rclone status check 2025-07-23 11:44:06 +08:00
Kuingsmile
9277c9380c feat: optimize rclone backend status check method 2025-07-23 11:38:06 +08:00
Kuingsmile
a6c83fe289 fix: fix reset admin passwd error on windows close #69 2025-07-23 10:35:12 +08:00
Kuingsmile
c47fc1443b feat: update macOS link opening behavior to allow opening in browser #71 2025-07-23 09:59:16 +08:00
GitHub Action
954ee010c1 chore: bump version to 0.6.0 [skip ci] 2025-07-22 09:49:13 +00:00
Kuingsmile
c219afa54e fix: update service log path for macOS 2025-07-22 17:30:29 +08:00
Kuingsmile
06e54d1b01 feat: add service log display in log page 2025-07-22 17:07:44 +08:00
Kuingsmile
386147d5ff feat: add functionality to open various directories and configuration files, change default path on macos close #63 2025-07-22 15:50:28 +08:00
Kuingsmile
2eeba5f428 fix: fix an issue that url can't be opened on macos #63 2025-07-22 13:26:43 +08:00
Kuingsmile
dc1cb41e61 feat: enhance version update notifications 2025-07-22 11:41:20 +08:00
Kuingsmile
99c426c15c fix: fix a bug makes log filter not working #63 2025-07-21 17:51:27 +08:00
Kuingsmile
77f9f81dea feat: add loading indicators and processing state for core and rclone actions close #66 2025-07-21 17:40:01 +08:00
Kuingsmile
5cc2c1640c feat: improve service stop status check #66 2025-07-21 17:19:42 +08:00
Kuingsmile
24b45446cc fix: set current directory for command execution and manage admin password state #66 2025-07-21 16:44:30 +08:00
Kuingsmile
f3cc4a021b fix: fix an issue processes can't be started when accessing from RDP, #68 2025-07-21 15:41:33 +08:00
Kuingsmile
b6cfda7648 feat: add SHA-256 hash calculation for binary files for better debug trace #63 2025-07-20 18:38:18 +08:00
Kuingsmile
3b9910da0a fix: fix vfs-dir-cache-time to dir-cache-time #63 2025-07-20 17:51:34 +08:00
Kuingsmile
a88b17c92f fix: update mountPointPlaceholder in localization files 2025-07-18 17:38:17 +08:00
Kuingsmile
20aeb6a796 fix: add creation flags to netsh command in rule_stdout function close #62 2025-07-18 17:23:03 +08:00
Kuingsmile
13efd8a629 docs: update readme 2025-07-18 14:56:06 +08:00
GitHub Action
9998563110 chore: bump version to 0.5.0 [skip ci] 2025-07-18 06:19:09 +00:00
Kuingsmile
6f41bd708c fix: update GitHub token usage in release workflow 2025-07-18 13:44:18 +08:00
Kuingsmile
4837ee592f fix: fix an issue service and processes can't be stopped on macos, #60 2025-07-18 13:33:27 +08:00
Kuingsmile
8d25feefe0 fix: update vue-i18n 2025-07-17 17:47:27 +08:00
Kuingsmile
6628c7936b fix: update rclone download URL to use the official downloads site #60 2025-07-17 17:43:10 +08:00
Kuingsmile
9c53267589 fix: remove redundant confirmation message for admin password reset 2025-07-17 17:36:01 +08:00
Kuingsmile
e0d3250823 feat: optimize admin password management and add reset 2025-07-17 17:32:57 +08:00
Kuingsmile
c9ccf6d1ce feat: add data directory configuration for OpenList core close #58 2025-07-17 15:22:29 +08:00
Kuingsmile
a19e74ce1f feat: add Windows firewall management for openlist port #49 2025-07-17 14:03:40 +08:00
Kuingsmile
0231fa20d7 fix: update rclone configuration tips for clarity and set textarea to readonly #60 2025-07-17 10:48:02 +08:00
Kuingsmile
f69bfa6fd5 feat: disable modal close when click at blank space #60 2025-07-17 10:12:07 +08:00
Kuingsmile
bb0f091849 refactor: streamline exit status handling 2025-07-16 17:19:13 +08:00
Kuingsmile
9d95b6b46c refactor: simplify conditional checks in macOS service management 2025-07-16 17:10:03 +08:00
Kuingsmile
249612344e chore: update Rust toolchain to nightly in CI and release workflows 2025-07-16 16:59:03 +08:00
Kuingsmile
3c5f64b1b4 refactor: update open link handling 2025-07-16 16:53:01 +08:00
Kuingsmile
2911922403 refactor: remove unused RcloneRemoteParameters and TransferStats structs 2025-07-16 16:45:30 +08:00
Kuingsmile
0093f15524 feat: update package version to 0.4.0 and add winget manifests 2025-07-15 17:49:48 +08:00
GitHub Action
704d06ebe1 chore: bump version to 0.4.0 [skip ci] 2025-07-15 09:20:34 +00:00
Kuingsmile
7038a1a255 chore: cargo.toml 2025-07-15 16:57:35 +08:00
Kuingsmile
7476e29e2b feat: add macOS private API support to Tauri dependencies 2025-07-15 16:53:56 +08:00
Kuingsmile
08a9eb38cc fix: remove unused i18n 2025-07-15 16:46:41 +08:00
Kuingsmile
d0917ee550 feat: optimize front-end performance 2025-07-15 15:42:29 +08:00
Kuingsmile
ccb8b12f1e feat: remove tutorial functionality and related UI components 2025-07-15 14:24:15 +08:00
Kuingsmile
1d74daf8a5 refactor: optimize string creation in get_current_platform function 2025-07-14 21:51:45 +08:00
Kuingsmile
ffdf996cd0 refactor: simplify platform string formatting in get_current_platform function 2025-07-14 17:21:25 +08:00
Kuingsmile
eb82a49270 feat: removing service config file if select delete app data 2025-07-14 17:19:57 +08:00
Kuingsmile
0c09addb31 feat: termination rclone.exe, clean all related files after uninstall 2025-07-14 16:47:44 +08:00
Kuingsmile
731ff435a5 feat: update application configuration and monitoring intervals, remove monitor_interval setting
Fixes #48
2025-07-13 15:52:29 +08:00
Kuingsmile
3e1a5f121d fix: update parameter type for install_windows_update function to Path 2025-07-13 11:55:35 +08:00
Kuingsmile
9263ad9810 fix: fix a bug make auto update not woking on windows 2025-07-13 11:44:21 +08:00
Kuingsmile
7b0565f210 fix: run child process as user other than SYSTEM on windows, close #52 2025-07-13 10:57:51 +08:00
Kuingsmile
90f935e6bb fix: add WebDAV and WinFSP tips css 2025-07-12 14:56:51 +08:00
Kuingsmile
8aa4f93f6f docs: add installation instructions for Winget 2025-07-12 14:22:08 +08:00
Kuingsmile
4b08aeb4d3 fix: add missing scope and install modes to winget manifest 2025-07-11 15:34:20 +08:00
Kuingsmile
97be081c47 fix: change installer type to nullsoft in winget manifest 2025-07-11 11:54:48 +08:00
Kuingsmile
c68102fa20 feat: add version 0.3.0 winget manifest file 2025-07-10 16:30:18 +08:00
GitHub Action
5c978d43f6 chore: bump version to 0.3.0 [skip ci] 2025-07-10 07:45:49 +00:00
Kuingsmile
cb3a4cb995 fix: update WinGet package submission logic 2025-07-10 15:43:50 +08:00
Kuingsmile
340b117956 feat: add Windows certificate configuration for signing and notarization 2025-07-10 15:11:06 +08:00
Kuingsmile
a70b576e1e fix: fix clippy error 2025-07-10 13:40:21 +08:00
Kuingsmile
23095dba6b refactor: increase timeout for rclone API requests and refactor some files 2025-07-10 10:51:10 +08:00
Kuingsmile
64b12e6c3b refactor: improve binary version retrieval and command execution 2025-07-09 22:56:55 +08:00
Kuingsmile
f9cf0b59ed refactor: remove unused composables 2025-07-09 17:28:43 +08:00
Kuingsmile
522e1f8059 refactor: refactor rcloneStore and appStore usage 2025-07-09 17:11:07 +08:00
renovate[bot]
c83b63eb03 chore(deps): update dependency @types/node to v22.16.2 (#46)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-09 15:50:10 +08:00
renovate[bot]
84ef6de7db chore(deps): update dependency @types/node to v22.16.0 (#29)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-09 15:36:25 +08:00
Kuingsmile
96e995051e feat: add option to open links in external browser and fix app setting css error 2025-07-09 15:30:32 +08:00
Kuingsmile
83b3caf521 feat: update dependencies and GitHub Actions 2025-07-08 18:00:13 +08:00
Kuingsmile
bff5a44bfc feat: update Rclone flag syntax in documentation and localization files 2025-07-08 17:37:37 +08:00
Kuingsmile
cdb0f608e0 feat: allow users to quickly select common flags for rclone mount 2025-07-08 17:32:29 +08:00
Kuingsmile
3685013ca6 feat: add argument splitting utility for better compatibility 2025-07-08 15:28:38 +08:00
Kuingsmile
f00f1da349 feat: add GitHub proxy support, optimize toll update logic 2025-07-05 21:34:03 +08:00
Kuingsmile
08c860c02a feat: add WinGet submission workflow and metadata for package management 2025-07-05 17:17:30 +08:00
Kuingsmile
a8360bf228 feat: add Windows certificate import step in release workflow 2025-07-05 16:39:51 +08:00
Kuingsmile
4f38ebb85d feat: change default install path and set directory permissions (#39)
* feat: change default install path and set directory permissions in installer close #38

* feat: check D: drive existence and fallback to PROGRAMFILES
2025-07-04 17:47:19 +08:00
Kuingsmile
9b85453ed0 feat: add WinFSP installation tips for Windows users close #37 2025-07-04 13:46:35 +08:00
89 changed files with 7477 additions and 4704 deletions

View File

@@ -0,0 +1,230 @@
# Connect-SimplySign-Enhanced.ps1
# Registry-Enhanced TOTP Authentication for SimplySign Desktop
# Uses registry pre-configuration + TOTP credential injection approach
param(
[string]$OtpUri = $env:CERTUM_OTP_URI,
[string]$UserId = $env:CERTUM_USERNAME,
[string]$ExePath = $env:CERTUM_EXE_PATH
)
# Validate required parameters
if (-not $OtpUri) {
Write-Host "ERROR: CERTUM_OTP_URI environment variable not provided"
exit 1
}
if (-not $UserId) {
Write-Host "ERROR: CERTUM_USERNAME environment variable not provided"
exit 1
}
if (-not $ExePath) {
$ExePath = "C:\Program Files\Certum\SimplySign Desktop\SimplySignDesktop.exe"
}
Write-Host "=== REGISTRY-ENHANCED TOTP AUTHENTICATION ==="
Write-Host "Using registry pre-configuration + credential injection"
Write-Host "OTP URI provided (length: $($OtpUri.Length))"
Write-Host "User ID: $UserId"
Write-Host "Executable: $ExePath"
Write-Host ""
# Verify SimplySign Desktop exists
if (-not (Test-Path $ExePath)) {
Write-Host "ERROR: SimplySign Desktop not found at: $ExePath"
exit 1
}
# Parse the otpauth:// URI
$uri = [Uri]$OtpUri
# Parse query parameters (compatible with both PowerShell 5.1 and 7+)
try {
$q = [System.Web.HttpUtility]::ParseQueryString($uri.Query)
} catch {
$q = @{}
foreach ($part in $uri.Query.TrimStart('?') -split '&') {
$kv = $part -split '=', 2
if ($kv.Count -eq 2) {
$q[$kv[0]] = [Uri]::UnescapeDataString($kv[1])
}
}
}
$Base32 = $q['secret']
$Digits = if ($q['digits']) { [int]$q['digits'] } else { 6 }
$Period = if ($q['period']) { [int]$q['period'] } else { 30 }
$Algorithm = if ($q['algorithm']) { $q['algorithm'].ToUpper() } else { 'SHA256' }
# Validate supported algorithms
$SupportedAlgorithms = @('SHA1', 'SHA256', 'SHA512')
if ($Algorithm -notin $SupportedAlgorithms) {
Write-Host "ERROR: Unsupported algorithm: $Algorithm. Supported: $($SupportedAlgorithms -join ', ')"
exit 1
}
# TOTP Generator (inline C# implementation)
Add-Type -Language CSharp @"
using System;
using System.Security.Cryptography;
public static class Totp
{
private const string B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private static byte[] Base32Decode(string s)
{
s = s.TrimEnd('=').ToUpperInvariant();
int byteCount = s.Length * 5 / 8;
byte[] bytes = new byte[byteCount];
int bitBuffer = 0, bitsLeft = 0, idx = 0;
foreach (char c in s)
{
int val = B32.IndexOf(c);
if (val < 0) throw new ArgumentException("Invalid Base32 char: " + c);
bitBuffer = (bitBuffer << 5) | val;
bitsLeft += 5;
if (bitsLeft >= 8)
{
bytes[idx++] = (byte)(bitBuffer >> (bitsLeft - 8));
bitsLeft -= 8;
}
}
return bytes;
}
private static HMAC GetHmacAlgorithm(string algorithm, byte[] key)
{
switch (algorithm.ToUpper())
{
case "SHA1":
return new HMACSHA1(key);
case "SHA256":
return new HMACSHA256(key);
case "SHA512":
return new HMACSHA512(key);
default:
throw new ArgumentException("Unsupported algorithm: " + algorithm);
}
}
public static string Now(string secret, int digits, int period, string algorithm = "SHA256")
{
byte[] key = Base32Decode(secret);
long counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / period;
byte[] cnt = BitConverter.GetBytes(counter);
if (BitConverter.IsLittleEndian) Array.Reverse(cnt);
byte[] hash;
using (var hmac = GetHmacAlgorithm(algorithm, key))
{
hash = hmac.ComputeHash(cnt);
}
int offset = hash[hash.Length - 1] & 0x0F;
int binary =
((hash[offset] & 0x7F) << 24) |
((hash[offset + 1] & 0xFF) << 16) |
((hash[offset + 2] & 0xFF) << 8) |
(hash[offset + 3] & 0xFF);
int otp = binary % (int)Math.Pow(10, digits);
return otp.ToString(new string('0', digits));
}
}
"@
function Get-TotpCode {
param([string]$Secret, [int]$Digits = 6, [int]$Period = 30, [string]$Algorithm = 'SHA256')
[Totp]::Now($Secret, $Digits, $Period, $Algorithm)
}
# Generate current TOTP code
$otp = Get-TotpCode -Secret $Base32 -Digits $Digits -Period $Period -Algorithm $Algorithm
Write-Host "Generated TOTP: $otp (using $Algorithm algorithm)"
Write-Host ""
# Launch SimplySign Desktop (registry should auto-open login dialog)
Write-Host "Launching SimplySign Desktop..."
Write-Host "Registry pre-configuration should auto-open login dialog"
$proc = Start-Process -FilePath $ExePath -PassThru
Write-Host "Process started with ID: $($proc.Id)"
Write-Host ""
# Wait for the application to initialize
Write-Host "Waiting for SimplySign Desktop to initialize..."
Start-Sleep -Seconds 3
# Create WScript.Shell for window interaction
$wshell = New-Object -ComObject WScript.Shell
# Try to focus the SimplySign Desktop window
Write-Host "Attempting to focus SimplySign Desktop window..."
$focused = $false
# Method 1: Focus by process ID (most reliable)
$focused = $wshell.AppActivate($proc.Id)
# Method 2: Focus by window title (fallback)
if (-not $focused) {
$focused = $wshell.AppActivate('SimplySign Desktop')
}
# Method 3: Multiple attempts with slight delays
for ($i = 0; (-not $focused) -and ($i -lt 10); $i++) {
Start-Sleep -Milliseconds 500
$focused = $wshell.AppActivate($proc.Id) -or $wshell.AppActivate('SimplySign Desktop')
Write-Host "Focus attempt $($i + 1): $focused"
}
if (-not $focused) {
Write-Host "ERROR: Could not bring SimplySign Desktop to foreground"
Write-Host "Login dialog may not be visible for credential injection"
exit 1
}
Write-Host "Successfully focused SimplySign Desktop window"
Write-Host ""
# Small delay to ensure window is ready for input
Start-Sleep -Milliseconds 400
# Inject credentials: Username + TAB + TOTP + ENTER
Write-Host "Injecting credentials into login dialog..."
Write-Host "Sending: Username -> TAB -> TOTP -> ENTER"
# Send the credential sequence
$wshell.SendKeys($UserId)
Start-Sleep -Milliseconds 200
$wshell.SendKeys("{TAB}")
Start-Sleep -Milliseconds 200
$wshell.SendKeys($otp)
Start-Sleep -Milliseconds 200
$wshell.SendKeys("{ENTER}")
Write-Host "Credentials injected successfully"
Write-Host ""
# Wait for authentication to process
Write-Host "Waiting for authentication to complete..."
Start-Sleep -Seconds 5
# Verify SimplySign Desktop is still running
$stillRunning = Get-Process -Id $proc.Id -ErrorAction SilentlyContinue
if ($stillRunning) {
Write-Host "SUCCESS: SimplySign Desktop is running"
Write-Host "Authentication should be complete"
Write-Host "Cloud certificate should now be available"
} else {
Write-Host "WARNING: SimplySign Desktop process has exited"
Write-Host "This may indicate authentication failure"
}
Write-Host ""
Write-Host "=== TOTP AUTHENTICATION COMPLETE ==="
Write-Host "Registry pre-configuration + credential injection finished"

View File

@@ -0,0 +1,252 @@
param(
[switch]$DebugMode = $false,
[switch]$VerifyOnly = $false
)
# SimplySign Desktop Registry Configuration Script
# Pre-configures optimal registry settings for automated login dialog display
Write-Host "=== SimplySign Desktop Registry Configuration ==="
if ($DebugMode) {
Write-Host "Debug mode enabled - verbose logging active"
}
# Registry path for SimplySign Desktop settings
$RegistryPath = "HKCU:\Software\Certum\SimplySign"
# Optimal configuration values for automation
$OptimalSettings = @{
"ShowLoginDialogOnStart" = 1
"ShowLoginDialogOnAppRequest" = 1
"RememberLastUserName" = 1
"Autostart" = 0
"UnregisterCertificatesOnDisconnect" = 0
"RememberPINinCSP" = 1
"ForgetPINinCSPonDisconnect" = 1
"LangID" = 9
}
# Function to check if registry path exists
function Test-RegistryPath {
param([string]$Path)
try {
$null = Get-Item -Path $Path -ErrorAction Stop
return $true
} catch {
return $false
}
}
# Function to get current registry value
function Get-RegistryValue {
param(
[string]$Path,
[string]$Name
)
try {
$value = Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop
return $value.$Name
} catch {
return $null
}
}
# Function to set registry value safely
function Set-RegistryValue {
param(
[string]$Path,
[string]$Name,
[int]$Value
)
try {
Set-ItemProperty -Path $Path -Name $Name -Value $Value -Type DWord -ErrorAction Stop
if ($DebugMode) {
Write-Host " Set $Name = $Value"
}
return $true
} catch {
Write-Host " ERROR: Failed to set $Name = $Value - $($_.Exception.Message)"
return $false
}
}
# Function to display current settings
function Show-CurrentSettings {
Write-Host "Current SimplySign Desktop registry settings:"
Write-Host "============================================="
if (-not (Test-RegistryPath $RegistryPath)) {
Write-Host "Registry path does not exist: $RegistryPath"
return
}
foreach ($setting in $OptimalSettings.Keys) {
$currentValue = Get-RegistryValue -Path $RegistryPath -Name $setting
if ($null -eq $currentValue) {
Write-Host " $setting : NOT SET"
} else {
Write-Host " $setting : $currentValue"
}
}
Write-Host ""
}
# Function to create registry structure
function Initialize-RegistryStructure {
Write-Host "Initializing registry structure..."
# Create parent keys if they don't exist
$ParentPaths = @(
"HKCU:\Software\Certum",
$RegistryPath
)
$allCreated = $true
foreach ($path in $ParentPaths) {
if (-not (Test-RegistryPath $path)) {
try {
New-Item -Path $path -Force -ErrorAction Stop | Out-Null
if ($DebugMode) {
Write-Host " Created registry path: $path"
}
} catch {
Write-Host " ERROR: Failed to create registry path: $path - $($_.Exception.Message)"
$allCreated = $false
}
} else {
if ($DebugMode) {
Write-Host " Registry path exists: $path"
}
}
}
return $allCreated
}
# Function to apply optimal configuration
function Set-OptimalConfiguration {
Write-Host "Applying optimal configuration for automation..."
$successCount = 0
$totalSettings = $OptimalSettings.Count
foreach ($setting in $OptimalSettings.Keys) {
$value = $OptimalSettings[$setting]
if (Set-RegistryValue -Path $RegistryPath -Name $setting -Value $value) {
$successCount++
}
}
Write-Host "Applied $successCount of $totalSettings settings successfully"
return ($successCount -eq $totalSettings)
}
# Function to verify configuration
function Test-Configuration {
Write-Host "Verifying configuration..."
$verificationResults = @{}
$allCorrect = $true
foreach ($setting in $OptimalSettings.Keys) {
$expectedValue = $OptimalSettings[$setting]
$actualValue = Get-RegistryValue -Path $RegistryPath -Name $setting
$isCorrect = ($actualValue -eq $expectedValue)
$verificationResults[$setting] = @{
Expected = $expectedValue
Actual = $actualValue
Correct = $isCorrect
}
if (-not $isCorrect) {
$allCorrect = $false
}
if ($DebugMode -or -not $isCorrect) {
$status = if ($isCorrect) { "OK" } else { "MISMATCH" }
Write-Host " $setting : Expected=$expectedValue, Actual=$actualValue [$status]"
}
}
return $verificationResults, $allCorrect
}
# Main execution
try {
Write-Host "Starting registry configuration process..."
Write-Host ""
# Show current state
Write-Host "BEFORE CONFIGURATION:"
Show-CurrentSettings
if ($VerifyOnly) {
Write-Host "Verification-only mode - no changes will be made"
$verificationResults, $allCorrect = Test-Configuration
if ($allCorrect) {
Write-Host "SUCCESS: All settings are correctly configured"
exit 0
} else {
Write-Host "CONFIGURATION NEEDED: Some settings require adjustment"
exit 1
}
}
# Initialize registry structure
if (-not (Initialize-RegistryStructure)) {
Write-Host "FATAL ERROR: Failed to initialize registry structure"
exit 1
}
# Apply optimal configuration
if (-not (Set-OptimalConfiguration)) {
Write-Host "ERROR: Failed to apply complete configuration"
exit 1
}
Write-Host ""
Write-Host "AFTER CONFIGURATION:"
Show-CurrentSettings
# Verify the configuration was applied correctly
$verificationResults, $allCorrect = Test-Configuration
if ($allCorrect) {
Write-Host "SUCCESS: Registry configuration completed successfully"
Write-Host ""
Write-Host "Key automation settings enabled:"
Write-Host " ShowLoginDialogOnStart = 1 (Login dialog will appear automatically)"
Write-Host " ShowLoginDialogOnAppRequest = 1 (Dialog appears when apps request access)"
Write-Host " RememberLastUserName = 1 (Username persistence for efficiency)"
Write-Host ""
Write-Host "Next steps:"
Write-Host "1. Launch SimplySign Desktop"
Write-Host "2. Login dialog should appear automatically"
Write-Host "3. Complete authentication process"
# Create a status file for the workflow to check
"REGISTRY_CONFIGURATION_SUCCESS" | Out-File -FilePath "registry_config_status.log" -Encoding UTF8
exit 0
} else {
Write-Host "ERROR: Configuration verification failed"
Write-Host "Some settings were not applied correctly"
"REGISTRY_CONFIGURATION_PARTIAL" | Out-File -FilePath "registry_config_status.log" -Encoding UTF8
exit 1
}
} catch {
Write-Host "FATAL ERROR: Registry configuration failed - $($_.Exception.Message)"
"REGISTRY_CONFIGURATION_FAILED" | Out-File -FilePath "registry_config_status.log" -Encoding UTF8
exit 1
}

112
.github/scripts/install-simplysign.sh vendored Normal file
View File

@@ -0,0 +1,112 @@
#!/bin/bash
# Install SimplySign Desktop - Clean MSI Installation
set -euo pipefail
echo "=== INSTALLING SIMPLYSIGN DESKTOP ==="
echo "Using proven installation method from successful testing..."
# Download SimplySign Desktop MSI
CERTUM_INSTALLER="SimplySignDesktop.msi"
echo "Downloading SimplySign Desktop MSI..."
if curl -L "https://files.certum.eu/software/SimplySignDesktop/Windows/9.3.2.67/SimplySignDesktop-9.3.2.67-64-bit-en.msi" -o "$CERTUM_INSTALLER" --fail --max-time 60; then
echo "✅ Downloaded SimplySign Desktop MSI ($(ls -lh "$CERTUM_INSTALLER" | awk '{print $5}'))"
else
echo "❌ Failed to download SimplySign Desktop"
exit 1
fi
# Install with proven method (matching successful test)
echo "Installing SimplySign Desktop..."
echo "Full command: msiexec /i \"$CERTUM_INSTALLER\" /quiet /norestart /l*v install.log ALLUSERS=1 REBOOT=ReallySuppress"
# Check for administrative privileges (like the successful test)
ADMIN_RIGHTS=false
if powershell -Command "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)" 2>/dev/null; then
echo "✅ Running with administrative privileges"
ADMIN_RIGHTS=true
else
echo "⚠️ No explicit administrative privileges detected"
fi
# Use the exact method that worked: PowerShell with admin privileges
if [ "$ADMIN_RIGHTS" = true ]; then
echo "Running MSI installation with administrator privileges..."
powershell -Command "Start-Process -FilePath 'msiexec.exe' -ArgumentList '/i', '\"$CERTUM_INSTALLER\"', '/quiet', '/norestart', '/l*v', 'install.log', 'ALLUSERS=1', 'REBOOT=ReallySuppress' -Wait -NoNewWindow -PassThru" &
INSTALL_PID=$!
else
echo "Running MSI installation without explicit admin elevation..."
timeout 300 msiexec /i "$CERTUM_INSTALLER" /quiet /norestart /l*v install.log ALLUSERS=1 REBOOT=ReallySuppress &
INSTALL_PID=$!
fi
# Monitor with the same logic as successful test
echo "Monitoring installation progress..."
INSTALL_START_TIME=$(date +%s)
sleep 10
# Check if msiexec process is actually running (like successful test)
if kill -0 $INSTALL_PID 2>/dev/null; then
echo "MSI installation process is running (PID: $INSTALL_PID)"
# Monitor for up to 3 minutes with status updates
for i in {1..18}; do
sleep 10
CURRENT_TIME=$(date +%s)
ELAPSED=$((CURRENT_TIME - INSTALL_START_TIME))
if kill -0 $INSTALL_PID 2>/dev/null; then
echo "Installation still running after ${ELAPSED} seconds..."
# Check log file growth
if [ -f "install.log" ]; then
LOG_SIZE=$(stat -c%s "install.log" 2>/dev/null || stat -f%z "install.log" 2>/dev/null || echo 0)
echo " Log file size: $LOG_SIZE bytes"
fi
else
echo "MSI installation completed after ${ELAPSED} seconds"
break
fi
done
# Final wait if still running
if kill -0 $INSTALL_PID 2>/dev/null; then
echo "Installation taking longer, waiting for completion..."
wait $INSTALL_PID 2>/dev/null || echo "Installation process ended"
fi
else
echo "MSI installation process ended quickly"
fi
# Quick success check using proven patterns
INSTALLATION_SUCCESSFUL=false
if [ -f "install.log" ]; then
if grep -qi "Installation.*operation.*completed.*successfully\|Installation.*success.*or.*error.*status.*0\|MainEngineThread.*is.*returning.*0\|Windows.*Installer.*installed.*the.*product" install.log 2>/dev/null; then
echo "✅ Installation successful (confirmed by log patterns)"
INSTALLATION_SUCCESSFUL=true
fi
fi
# Verify installation directory
INSTALL_PATH="/c/Program Files/Certum/SimplySign Desktop"
if [ -d "$INSTALL_PATH" ]; then
echo "✅ SimplySign Desktop installed successfully"
echo "✅ Virtual card emulation now active for code signing"
INSTALLATION_SUCCESSFUL=true
# Set output for GitHub Actions
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "SIMPLYSIGN_PATH=$INSTALL_PATH" >> "$GITHUB_OUTPUT"
fi
fi
if [ "$INSTALLATION_SUCCESSFUL" = false ]; then
echo "❌ Installation verification failed"
echo "Last 10 lines of install log:"
tail -10 install.log 2>/dev/null || echo "No install log available"
exit 1
fi
echo "🎉 SimplySign Desktop installation completed successfully!"

450
.github/workflows/build-test.yml vendored Normal file
View File

@@ -0,0 +1,450 @@
name: 'Build Test - All Platforms'
on:
workflow_dispatch:
inputs:
test_version:
description: 'Test version (e.g., 1.0.0-test). Leave empty to use package.json version with -test suffix'
required: false
type: string
permissions: write-all
env:
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
# macOS signing and notarization (optional for testing)
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Certum cloud code signing for Windows
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
CERTUM_USERNAME: ${{ secrets.CERTUM_USERNAME }}
CERTUM_CERTIFICATE_SHA1: ${{ secrets.CERTUM_CERTIFICATE_SHA1 }}
concurrency:
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
cancel-in-progress: true
jobs:
prepare:
name: Prepare Build Information
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
build-date: ${{ steps.version.outputs.build-date }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Calculate test version
id: version
run: |
# If manual test version is provided, use it
if [ -n "${{ inputs.test_version }}" ]; then
TEST_VERSION="${{ inputs.test_version }}"
echo "Using manual test version: $TEST_VERSION"
echo "version=$TEST_VERSION" >> $GITHUB_OUTPUT
else
# Use package.json version with test suffix
CURRENT_VERSION=$(node -p "require('./package.json').version")
TEST_VERSION="$CURRENT_VERSION-test"
echo "Using auto test version: $TEST_VERSION"
echo "version=$TEST_VERSION" >> $GITHUB_OUTPUT
fi
# Add build date for reference
BUILD_DATE=$(date -u +"%Y-%m-%d %H:%M:%S UTC")
echo "build-date=$BUILD_DATE" >> $GITHUB_OUTPUT
build:
name: Build
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
platform: windows
arch: x64
- os: windows-latest
target: aarch64-pc-windows-msvc
platform: windows
arch: arm64
- os: macos-latest
target: aarch64-apple-darwin
platform: macos
arch: arm64
- os: macos-latest
target: x86_64-apple-darwin
platform: macos
arch: x64
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
platform: linux
arch: x64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@nightly
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
save-if: false
- name: Install dependencies (ubuntu only)
if: matrix.os == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
uses: borales/actions-yarn@v5
with:
cmd: install
- name: Prebuild and check
run: |
yarn install
yarn run prebuild:dev --target=${{ matrix.target }}
- name: Update version for test build (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
# Update package.json
yarn version --new-version ${{ needs.prepare.outputs.version }} --no-git-tag-version
# Update Cargo.toml
(Get-Content src-tauri/Cargo.toml) -replace '^version = ".*"', 'version = "${{ needs.prepare.outputs.version }}"' | Set-Content src-tauri/Cargo.toml
# Update tauri.conf.json
(Get-Content src-tauri/tauri.conf.json) -replace '"version": ".*"', '"version": "${{ needs.prepare.outputs.version }}"' | Set-Content src-tauri/tauri.conf.json
- name: Update version for test build (macOS)
if: runner.os == 'macOS'
shell: bash
run: |
# Update package.json
yarn version --new-version ${{ needs.prepare.outputs.version }} --no-git-tag-version
# Update Cargo.toml
sed -i '' 's/^version = "[^"]*"/version = "${{ needs.prepare.outputs.version }}"/' src-tauri/Cargo.toml
# Update tauri.conf.json
sed -i '' 's/"version": "[^"]*"/"version": "${{ needs.prepare.outputs.version }}"/' src-tauri/tauri.conf.json
- name: Update version for test build (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
# Update package.json
yarn version --new-version ${{ needs.prepare.outputs.version }} --no-git-tag-version
# Update Cargo.toml
sed -i 's/^version = "[^"]*"/version = "${{ needs.prepare.outputs.version }}"/' src-tauri/Cargo.toml
# Update tauri.conf.json
sed -i 's/"version": "[^"]*"/"version": "${{ needs.prepare.outputs.version }}"/' src-tauri/tauri.conf.json
- name: Import Apple Developer Certificate (macOS only)
if: matrix.os == 'macos-latest' && env.APPLE_CERTIFICATE != ''
uses: apple-actions/import-codesign-certs@v5
with:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Setup Certum Code Signing (Windows)
if: matrix.platform == 'windows'
run: |
echo "=== SETTING UP CERTUM CODE SIGNING FOR WINDOWS ==="
echo "Installing SimplySign Desktop and configuring for automatic authentication"
# Install SimplySign Desktop
chmod +x ./.github/scripts/install-simplysign.sh
./.github/scripts/install-simplysign.sh
# Configure registry for auto-login dialog
echo "Configuring registry for automatic login dialog..."
powershell -ExecutionPolicy Bypass -File "./.github/scripts/configure-simplysign-registry.ps1"
echo "Certum signing environment ready"
shell: bash
- name: Authenticate Certum (Windows)
if: matrix.platform == 'windows'
env:
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
CERTUM_USERNAME: ${{ secrets.CERTUM_USERNAME }}
run: |
echo "=== CERTUM AUTHENTICATION ==="
echo "Authenticating with Certum cloud certificate using TOTP"
# Authenticate with Certum using our enhanced script
powershell -ExecutionPolicy Bypass -File "./.github/scripts/Connect-SimplySign-Enhanced.ps1"
echo "Authentication completed"
shell: bash
- name: Configure Certum Certificate Thumbprint (Windows)
if: matrix.platform == 'windows'
shell: bash
run: |
echo "=== CONFIGURING CERTUM CERTIFICATE THUMBPRINT ==="
CONFIG_PATH="src-tauri/tauri.windows.conf.json"
THUMBPRINT="${{ secrets.CERTUM_CERTIFICATE_SHA1 }}"
# Update the certificateThumbprint field using jq
jq --arg thumbprint "$THUMBPRINT" '.bundle.windows.certificateThumbprint = $thumbprint' "$CONFIG_PATH" > tmp.$$ && mv tmp.$$ "$CONFIG_PATH"
echo "Certificate thumbprint configured: $THUMBPRINT"
- name: Build the app
if: matrix.platform == 'windows' || matrix.platform == 'linux'
uses: tauri-apps/tauri-action@v0
env:
NODE_OPTIONS: "--max_old_space_size=4096"
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
with:
args: --target ${{ matrix.target }}
- name: Build the app (macOS)
uses: tauri-apps/tauri-action@v0
if: matrix.platform == 'macos'
env:
NODE_OPTIONS: "--max_old_space_size=4096"
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
# macOS signing and notarization environment variables
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
TAURI_SKIP_SIDECAR_SIGNATURE_CHECK: "true"
with:
args: --target ${{ matrix.target }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.platform }}-${{ matrix.arch }}-build
path: |
src-tauri/target/${{ matrix.target }}/release/bundle/**/*
retention-days: 30
build-linux-arm:
name: Build Linux ARM
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
target: aarch64-unknown-linux-gnu
arch: arm64
- os: ubuntu-22.04
target: armv7-unknown-linux-gnueabihf
arch: armhf
runs-on: ${{ matrix.os }}
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@nightly
- name: Add Rust Target
run: rustup target add ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
save-if: false
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
uses: borales/actions-yarn@v5
with:
cmd: install
- name: Prebuild and check
run: |
yarn install
yarn run prebuild:dev --target=${{ matrix.target }}
- name: Update version for test build
run: |
# Update package.json
yarn version --new-version ${{ needs.prepare.outputs.version }} --no-git-tag-version
# Update Cargo.toml
sed -i 's/^version = "[^"]*"/version = "${{ needs.prepare.outputs.version }}"/' src-tauri/Cargo.toml
# Update tauri.conf.json
sed -i 's/"version": "[^"]*"/"version": "${{ needs.prepare.outputs.version }}"/' src-tauri/tauri.conf.json
- name: Setup for Linux ARM cross-compilation
run: |-
sudo ls -lR /etc/apt/
cat > /tmp/sources.list << EOF
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-security main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-updates main multiverse universe restricted
deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu jammy-backports main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main multiverse universe restricted
deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main multiverse universe restricted
EOF
sudo mv /etc/apt/sources.list /etc/apt/sources.list.default
sudo mv /tmp/sources.list /etc/apt/sources.list
sudo dpkg --add-architecture ${{ matrix.arch }}
sudo apt update
sudo apt install -y \
libxslt1.1:${{ matrix.arch }} \
libwebkit2gtk-4.1-dev:${{ matrix.arch }} \
libayatana-appindicator3-dev:${{ matrix.arch }} \
libssl-dev:${{ matrix.arch }} \
patchelf:${{ matrix.arch }} \
librsvg2-dev:${{ matrix.arch }}
- name: Install aarch64 tools
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt install -y \
gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu
- name: Install armv7 tools
if: matrix.target == 'armv7-unknown-linux-gnueabihf'
run: |
sudo apt install -y \
gcc-arm-linux-gnueabihf \
g++-arm-linux-gnueabihf
- name: Build for Linux ARM
run: |
export PKG_CONFIG_ALLOW_CROSS=1
if [ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]; then
export PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig/:$PKG_CONFIG_PATH
export PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu/
elif [ "${{ matrix.target }}" == "armv7-unknown-linux-gnueabihf" ]; then
export PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig/:$PKG_CONFIG_PATH
export PKG_CONFIG_SYSROOT_DIR=/usr/arm-linux-gnueabihf/
fi
yarn build --target ${{ matrix.target }}
env:
NODE_OPTIONS: "--max_old_space_size=4096"
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: Upload Linux ARM artifacts
uses: actions/upload-artifact@v4
with:
name: linux-${{ matrix.target }}-build
path: |
src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm
retention-days: 30
summary:
name: Build Summary
needs: [prepare, build, build-linux-arm]
runs-on: ubuntu-latest
if: always()
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create build summary
run: |
echo "# Build Test Summary" > build-summary.md
echo "" >> build-summary.md
echo "**Version:** ${{ needs.prepare.outputs.version }}" >> build-summary.md
echo "**Build Date:** ${{ needs.prepare.outputs.build-date }}" >> build-summary.md
echo "**Commit:** ${{ github.sha }}" >> build-summary.md
echo "**Branch:** ${{ github.ref_name }}" >> build-summary.md
echo "" >> build-summary.md
echo "## Build Status" >> build-summary.md
echo "" >> build-summary.md
# Check build status
if [ "${{ needs.build.result }}" == "success" ]; then
echo "✅ **Main platforms build:** Success" >> build-summary.md
else
echo "❌ **Main platforms build:** Failed" >> build-summary.md
fi
if [ "${{ needs.build-linux-arm.result }}" == "success" ]; then
echo "✅ **Linux ARM build:** Success" >> build-summary.md
else
echo "❌ **Linux ARM build:** Failed" >> build-summary.md
fi
echo "" >> build-summary.md
echo "## Available Artifacts" >> build-summary.md
echo "" >> build-summary.md
echo "The following build artifacts are available for download from the Actions tab:" >> build-summary.md
echo "" >> build-summary.md
# List artifacts
if [ -d "artifacts" ]; then
find artifacts -name "*.exe" -o -name "*.msi" -o -name "*.dmg" -o -name "*.pkg" -o -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" | while read file; do
echo "- $(basename "$file")" >> build-summary.md
done
fi
echo "" >> build-summary.md
echo "## Testing Instructions" >> build-summary.md
echo "" >> build-summary.md
echo "1. Download the appropriate artifact for your platform from the GitHub Actions page" >> build-summary.md
echo "2. Extract and install the application" >> build-summary.md
echo "3. Test the functionality" >> build-summary.md
echo "4. Report any issues found" >> build-summary.md
- name: Upload build summary
uses: actions/upload-artifact@v4
with:
name: build-summary
path: build-summary.md
retention-days: 30

View File

@@ -66,7 +66,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@nightly
with: with:
targets: ${{ matrix.platform.target }} targets: ${{ matrix.platform.target }}
components: rustfmt, clippy components: rustfmt, clippy
@@ -150,7 +150,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@nightly
with: with:
targets: ${{ matrix.platform.target }} targets: ${{ matrix.platform.target }}

View File

@@ -26,6 +26,11 @@ env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Certum cloud code signing for Windows
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
CERTUM_USERNAME: ${{ secrets.CERTUM_USERNAME }}
CERTUM_CERTIFICATE_SHA1: ${{ secrets.CERTUM_CERTIFICATE_SHA1 }}
PERSONAL_GITHUB_TOKEN: ${{ secrets.PERSONAL_GITHUB_TOKEN }}
concurrency: concurrency:
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}" group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
@@ -317,8 +322,8 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install Rust Stable - name: Install Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@nightly
- name: Add Rust Target - name: Add Rust Target
run: rustup target add ${{ matrix.target }} run: rustup target add ${{ matrix.target }}
@@ -341,7 +346,7 @@ jobs:
node-version: "22" node-version: "22"
- name: Run install - name: Run install
uses: borales/actions-yarn@v4 uses: borales/actions-yarn@v5
with: with:
cmd: install cmd: install
- name: install and check - name: install and check
@@ -390,11 +395,56 @@ jobs:
- name: Import Apple Developer Certificate (macOS only) - name: Import Apple Developer Certificate (macOS only)
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-latest'
uses: apple-actions/import-codesign-certs@v2 uses: apple-actions/import-codesign-certs@v5
with: with:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Setup Certum Code Signing (Windows)
if: matrix.os == 'windows-latest'
run: |
echo "=== SETTING UP CERTUM CODE SIGNING FOR WINDOWS ==="
echo "Installing SimplySign Desktop and configuring for automatic authentication"
# Install SimplySign Desktop
chmod +x ./.github/scripts/install-simplysign.sh
./.github/scripts/install-simplysign.sh
# Configure registry for auto-login dialog
echo "Configuring registry for automatic login dialog..."
powershell -ExecutionPolicy Bypass -File "./.github/scripts/configure-simplysign-registry.ps1"
echo "Certum signing environment ready"
shell: bash
- name: Authenticate Certum (Windows)
if: matrix.os == 'windows-latest'
env:
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
CERTUM_USERNAME: ${{ secrets.CERTUM_USERNAME }}
run: |
echo "=== CERTUM AUTHENTICATION ==="
echo "Authenticating with Certum cloud certificate using TOTP"
# Authenticate with Certum using our enhanced script
powershell -ExecutionPolicy Bypass -File "./.github/scripts/Connect-SimplySign-Enhanced.ps1"
echo "Authentication completed"
shell: bash
- name: Configure Certum Certificate Thumbprint (Windows)
if: matrix.os == 'windows-latest'
shell: bash
run: |
echo "=== CONFIGURING CERTUM CERTIFICATE THUMBPRINT ==="
CONFIG_PATH="src-tauri/tauri.windows.conf.json"
THUMBPRINT="${{ secrets.CERTUM_CERTIFICATE_SHA1 }}"
# Update the certificateThumbprint field using jq
jq --arg thumbprint "$THUMBPRINT" '.bundle.windows.certificateThumbprint = $thumbprint' "$CONFIG_PATH" > tmp.$$ && mv tmp.$$ "$CONFIG_PATH"
echo "Certificate thumbprint configured: $THUMBPRINT"
- name: Build the app - name: Build the app
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
@@ -437,8 +487,8 @@ jobs:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install Rust Stable - name: Install Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@nightly
- name: Add Rust Target - name: Add Rust Target
run: rustup target add ${{ matrix.target }} run: rustup target add ${{ matrix.target }}
@@ -455,7 +505,7 @@ jobs:
node-version: "22" node-version: "22"
- name: Run install - name: Run install
uses: borales/actions-yarn@v4 uses: borales/actions-yarn@v5
with: with:
cmd: install cmd: install
@@ -573,7 +623,7 @@ jobs:
path: arm-artifacts path: arm-artifacts
- name: Update release with ARM artifacts - name: Update release with ARM artifacts
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ needs.changelog.outputs.tag }} tag_name: ${{ needs.changelog.outputs.tag }}
name: 'OpenList Desktop ${{ needs.changelog.outputs.tag }}' name: 'OpenList Desktop ${{ needs.changelog.outputs.tag }}'
@@ -585,3 +635,51 @@ jobs:
arm-artifacts/**/*.rpm arm-artifacts/**/*.rpm
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
winget-submit:
name: Submit to WinGet
needs: [publish, changelog, auto-version]
runs-on: windows-latest
if: always() && needs.publish.result == 'success'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get release version
id: version
run: |
$version = "${{ needs.auto-version.outputs.version }}"
echo "version=$version" >> $env:GITHUB_OUTPUT
- name: Download WinGet Create CLI
run: |
Write-Host "Downloading wingetcreate CLI..."
$url = "https://aka.ms/wingetcreate/latest"
Invoke-WebRequest -Uri $url -OutFile "wingetcreate.exe"
Write-Host "Downloaded wingetcreate.exe"
- name: Update WinGet package manifest
env:
GITHUB_TOKEN: ${{ secrets.PERSONAL_GITHUB_TOKEN }}
run: |
$version = "${{ steps.version.outputs.version }}"
# URLs for both x64 and arm64 installers
$x64InstallerUrl = "https://github.com/${{ github.repository }}/releases/download/v$version/OpenList.Desktop_$version`_x64-setup.exe"
$arm64InstallerUrl = "https://github.com/${{ github.repository }}/releases/download/v$version/OpenList.Desktop_$version`_arm64-setup.exe"
Write-Host "Updating WinGet package for version: $version"
Write-Host "x64 Installer URL: $x64InstallerUrl"
Write-Host "arm64 Installer URL: $arm64InstallerUrl"
Write-Host "Attempting to update existing package..."
./wingetcreate.exe update OpenListTeam.OpenListDesktop `
--version $version `
--urls $x64InstallerUrl $arm64InstallerUrl `
--token $env:GITHUB_TOKEN `
--submit
if ($LASTEXITCODE -ne 0) {
Write-Host "First submit, will do manually..."
} else {
Write-Host "Successfully updated existing WinGet package"
}

View File

@@ -31,6 +31,11 @@
"source.fixAll.eslint": "explicit" "source.fixAll.eslint": "explicit"
}, },
"typescript.tsdk": "node_modules\\typescript\\lib", "typescript.tsdk": "node_modules\\typescript\\lib",
"i18n-ally.localesPaths": [
"src/i18n",
"src/i18n/locales"
],
"i18n-ally.keystyle": "nested",
// "rust-analyzer.cargo.target": "x86_64-unknown-linux-gnu" // "rust-analyzer.cargo.target": "x86_64-unknown-linux-gnu"
//"rust-analyzer.cargo.target": "x86_64-pc-windows-msvc" //"rust-analyzer.cargo.target": "x86_64-pc-windows-msvc"
// "rust-analyzer.cargo.target": "x86_64-apple-darwin", // "rust-analyzer.cargo.target": "x86_64-apple-darwin",

View File

@@ -0,0 +1,18 @@
# Created using wingetcreate 1.9.14.0
# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.9.0.schema.json
PackageIdentifier: OpenListTeam.OpenListDesktop
PackageVersion: 0.3.0
InstallerType: nullsoft
Scope: machine
InstallModes:
- interactive
Installers:
- Architecture: x64
InstallerUrl: https://github.com/OpenListTeam/OpenList-Desktop/releases/download/v0.3.0/OpenList.Desktop_0.3.0_x64-setup.exe
InstallerSha256: 43CC59B5E557F67A7D2F66ADBEF517FBB7CD4FD7E9032FA274FEF5373E38B885
- Architecture: arm64
InstallerUrl: https://github.com/OpenListTeam/OpenList-Desktop/releases/download/v0.3.0/OpenList.Desktop_0.3.0_arm64-setup.exe
InstallerSha256: 3F99A8F566242EE749A3463E3DCB7D6D1CE75C51D3E31970AE1A39C46287035F
ManifestType: installer
ManifestVersion: 1.9.0

View File

@@ -0,0 +1,30 @@
# Created using wingetcreate 1.9.14.0
# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.9.0.schema.json
PackageIdentifier: OpenListTeam.OpenListDesktop
PackageVersion: 0.3.0
PackageLocale: en-US
Publisher: OpenList Team
PublisherUrl: https://github.com/OpenListTeam
PublisherSupportUrl: https://github.com/OpenListTeam/OpenList-Desktop/issues
Author: Kuingsmile
PackageName: OpenList Desktop
PackageUrl: https://github.com/OpenListTeam/OpenList-Desktop
License: GPL-3.0
LicenseUrl: https://github.com/OpenListTeam/OpenList-Desktop/blob/main/LICENSE
Copyright: Copyright (c) 2025 OpenList Team
ShortDescription: A cross-platform desktop application for OpenList with local disk mounting
Description: |
OpenList Desktop is a modern desktop application that provides a seamless interface for managing OpenList.
Features include local disk mounting, service management, real-time monitoring, and multi-language support.
Key Features:
- Cross-platform support (Windows, macOS, Linux)
- Local disk mounting
- Service management and monitoring
- Real-time log viewing
- Multi-language support
Tags:
- openlist
ManifestType: defaultLocale
ManifestVersion: 1.9.0

View File

@@ -0,0 +1,8 @@
# Created using wingetcreate 1.9.14.0
# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.9.0.schema.json
PackageIdentifier: OpenListTeam.OpenListDesktop
PackageVersion: 0.3.0
DefaultLocale: en-US
ManifestType: version
ManifestVersion: 1.9.0

View File

@@ -0,0 +1,18 @@
# Created using wingetcreate 1.9.14.0
# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.9.0.schema.json
PackageIdentifier: OpenListTeam.OpenListDesktop
PackageVersion: 0.4.0
InstallerType: nullsoft
Scope: machine
InstallModes:
- interactive
Installers:
- Architecture: x64
InstallerUrl: https://github.com/OpenListTeam/OpenList-Desktop/releases/download/v0.4.0/OpenList.Desktop_0.4.0_x64-setup.exe
InstallerSha256: 500DDBC34C73A663C1CA5A82F55DF56321AE4E2E8B727BE26D6EBF4F9F19F881
- Architecture: arm64
InstallerUrl: https://github.com/OpenListTeam/OpenList-Desktop/releases/download/v0.4.0/OpenList.Desktop_0.4.0_arm64-setup.exe
InstallerSha256: 4D60544E1684AE3A90220DA6A044D57C144E9F566272D2D43A481DEC8ED573EA
ManifestType: installer
ManifestVersion: 1.9.0

View File

@@ -0,0 +1,30 @@
# Created using wingetcreate 1.9.14.0
# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.9.0.schema.json
PackageIdentifier: OpenListTeam.OpenListDesktop
PackageVersion: 0.4.0
PackageLocale: en-US
Publisher: OpenList Team
PublisherUrl: https://github.com/OpenListTeam
PublisherSupportUrl: https://github.com/OpenListTeam/OpenList-Desktop/issues
Author: Kuingsmile
PackageName: OpenList Desktop
PackageUrl: https://github.com/OpenListTeam/OpenList-Desktop
License: GPL-3.0
LicenseUrl: https://github.com/OpenListTeam/OpenList-Desktop/blob/main/LICENSE
Copyright: Copyright (c) 2025 OpenList Team
ShortDescription: A cross-platform desktop application for OpenList with local disk mounting
Description: |
OpenList Desktop is a modern desktop application that provides a seamless interface for managing OpenList.
Features include local disk mounting, service management, real-time monitoring, and multi-language support.
Key Features:
- Cross-platform support (Windows, macOS, Linux)
- Local disk mounting
- Service management and monitoring
- Real-time log viewing
- Multi-language support
Tags:
- openlist
ManifestType: defaultLocale
ManifestVersion: 1.9.0

View File

@@ -0,0 +1,8 @@
# Created using wingetcreate 1.9.14.0
# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.9.0.schema.json
PackageIdentifier: OpenListTeam.OpenListDesktop
PackageVersion: 0.4.0
DefaultLocale: en-US
ManifestType: version
ManifestVersion: 1.9.0

18
.winget/metadata.yml Normal file
View File

@@ -0,0 +1,18 @@
PackageIdentifier: OpenListTeam.OpenListDesktop
PackageName: OpenList Desktop
Publisher: OpenList Team
PackageUrl: https://github.com/OpenListTeam/OpenList-Desktop
Copyright: Copyright (c) 2025 OpenList Team
License: GPL-3.0
LicenseUrl: https://github.com/OpenListTeam/OpenList-Desktop/blob/main/LICENSE
ShortDescription: A cross-platform desktop application for OpenList with local disk mounting
Description: |
OpenList Desktop is a modern desktop application that provides a seamless interface for managing OpenList.
Features include local disk mounting, service management, real-time monitoring, and multi-language support.
Key Features:
- Cross-platform support (Windows, macOS, Linux)
- Local disk mounting
- Service management and monitoring
- Real-time log viewing
- Multi-language support

View File

@@ -138,11 +138,19 @@ yarn run tauri build
#### Windows #### Windows
##### 使用安装程序
1. 下载 `.exe` 安装程序 1. 下载 `.exe` 安装程序
2. 以管理员身份运行安装程序 2. 以管理员身份运行安装程序
3. 按照安装向导进行操作 3. 按照安装向导进行操作
4. 从开始菜单或桌面快捷方式启动 4. 从开始菜单或桌面快捷方式启动
##### 使用Winget
```bash
winget install OpenListTeam.OpenListDesktop
```
#### macOS #### macOS
1. 下载 `.dmg` 文件 1. 下载 `.dmg` 文件
@@ -170,7 +178,6 @@ yarn run tauri build
1. **初始设置**:首次启动时,应用程序将指导您完成初始配置 1. **初始设置**:首次启动时,应用程序将指导您完成初始配置
2. **服务安装**:在提示时安装 OpenList 服务 2. **服务安装**:在提示时安装 OpenList 服务
3. **存储配置**:配置您的第一个云存储连接 3. **存储配置**:配置您的第一个云存储连接
4. **教程**:完成交互式教程以学习关键功能
### 基本操作 ### 基本操作
@@ -216,9 +223,9 @@ yarn run tauri build
添加自定义 Rclone 标志以获得最佳性能: 添加自定义 Rclone 标志以获得最佳性能:
- `--vfs-cache-mode full`:启用完整 VFS 缓存 - `--vfs-cache-mode=full`:启用完整 VFS 缓存
- `--buffer-size 256M`:增加缓冲区大小 - `--buffer-size=256M`:增加缓冲区大小
- `--transfers 10`:并发传输限制 - `--transfers=10`:并发传输限制
#### 系统托盘操作 #### 系统托盘操作
@@ -235,9 +242,9 @@ yarn run tauri build
{ {
"openlist": { "openlist": {
"port": 5244, "port": 5244,
"api_token": "your-secure-token", "data_dir": "",
"auto_launch": true, "auto_launch": true,
"ssl_enabled": false "ssl_enabled": false,
} }
} }
``` ```
@@ -258,6 +265,7 @@ yarn run tauri build
"extraFlags": ["--vfs-cache-mode", "full"] "extraFlags": ["--vfs-cache-mode", "full"]
} }
}, },
"api_port": 45572
} }
} }
``` ```
@@ -269,18 +277,15 @@ yarn run tauri build
"app": { "app": {
"theme": "auto", "theme": "auto",
"auto_update_enabled": true, "auto_update_enabled": true,
"monitor_interval": 30000 "gh_proxy": "https://ghproxy.com/",
"gh_proxy_api": false,
"open_links_in_browser": true,
"admin_password": "",
"show_window_on_startup": true
} }
} }
``` ```
### 环境变量
- `OPENLIST_API_TOKEN`:覆盖默认 API 令牌
- `OPENLIST_PORT`覆盖默认端口5244
- `RCLONE_CONFIG_DIR`:自定义 Rclone 配置目录
- `LOG_LEVEL`设置日志级别debug、info、warn、error
## 🔧 开发 ## 🔧 开发
### 开发环境设置 ### 开发环境设置
@@ -288,7 +293,7 @@ yarn run tauri build
#### 先决条件 #### 先决条件
- **Node.js**v22+ 和 yarn - **Node.js**v22+ 和 yarn
- **Rust**:最新稳定版本 - **Rust**:最新nightly版本
- **Git**:版本控制 - **Git**:版本控制
#### 设置步骤 #### 设置步骤
@@ -301,35 +306,11 @@ cd openlist-desktop
# 安装 Node.js 依赖 # 安装 Node.js 依赖
yarn install yarn install
# 安装 Rust 依赖
cd src-tauri
cargo fetch
# 准备开发环境 # 准备开发环境
cd ..
yarn run prebuild:dev yarn run prebuild:dev
# 启动开发服务器 # 启动开发服务器
yarn run dev yarn tauri dev
```
#### 开发命令
```bash
# 启动带热重载的开发服务器
yarn run dev
# 启动不带文件监视的开发
yarn run nowatch
# 运行代码检查
yarn run lint
# 修复代码检查问题
yarn run lint:fix
# 类型检查
yarn run build --dry-run
``` ```
#### 提交PR #### 提交PR

View File

@@ -138,11 +138,19 @@ yarn run tauri build
#### Windows #### Windows
##### Installation via Installer
1. Download the `.exe` installer 1. Download the `.exe` installer
2. Run the installer as Administrator 2. Run the installer as Administrator
3. Follow the installation wizard 3. Follow the installation wizard
4. Launch from Start Menu or Desktop shortcut 4. Launch from Start Menu or Desktop shortcut
##### Winget
```bash
winget install OpenListTeam.OpenListDesktop
```
#### macOS #### macOS
1. Download the `.dmg` file 1. Download the `.dmg` file
@@ -170,7 +178,6 @@ yarn run tauri build
1. **Initial Setup**: On first launch, the application will guide you through initial configuration 1. **Initial Setup**: On first launch, the application will guide you through initial configuration
2. **Service Installation**: Install the OpenList service when prompted 2. **Service Installation**: Install the OpenList service when prompted
3. **Storage Configuration**: Configure your first cloud storage connection 3. **Storage Configuration**: Configure your first cloud storage connection
4. **Tutorial**: Complete the interactive tutorial to learn key features
### Basic Operations ### Basic Operations
@@ -216,9 +223,9 @@ Dashboard → Quick Actions → Start Rclone Backend
Add custom Rclone flags for optimal performance: Add custom Rclone flags for optimal performance:
- `--vfs-cache-mode full`: Enable full VFS caching - `--vfs-cache-mode=full`: Enable full VFS caching
- `--buffer-size 256M`: Increase buffer size - `--buffer-size=256M`: Increase buffer size
- `--transfers 10`: Concurrent transfer limit - `--transfers=10`: Concurrent transfer limit
#### System Tray Operations #### System Tray Operations
@@ -235,7 +242,7 @@ Add custom Rclone flags for optimal performance:
{ {
"openlist": { "openlist": {
"port": 5244, "port": 5244,
"api_token": "your-secure-token", "data_dir": "",
"auto_launch": true, "auto_launch": true,
"ssl_enabled": false "ssl_enabled": false
} }
@@ -258,6 +265,7 @@ Add custom Rclone flags for optimal performance:
"extraFlags": ["--vfs-cache-mode", "full"] "extraFlags": ["--vfs-cache-mode", "full"]
} }
}, },
"api_port": 45572
} }
} }
``` ```
@@ -269,18 +277,15 @@ Add custom Rclone flags for optimal performance:
"app": { "app": {
"theme": "auto", "theme": "auto",
"auto_update_enabled": true, "auto_update_enabled": true,
"monitor_interval": 30000 "gh_proxy": "https://ghproxy.com/",
"gh_proxy_api": false,
"open_links_in_browser": true,
"admin_password": "",
"show_window_on_startup": true
} }
} }
``` ```
### Environment Variables
- `OPENLIST_API_TOKEN`: Override default API token
- `OPENLIST_PORT`: Override default port (5244)
- `RCLONE_CONFIG_DIR`: Custom Rclone configuration directory
- `LOG_LEVEL`: Set logging level (debug, info, warn, error)
## 🔧 Development ## 🔧 Development
### Development Environment Setup ### Development Environment Setup
@@ -288,7 +293,7 @@ Add custom Rclone flags for optimal performance:
#### Prerequisites #### Prerequisites
- **Node.js**: v22+ with yarn - **Node.js**: v22+ with yarn
- **Rust**: Latest stable version - **Rust**: Latest nightly version
- **Git**: Version control - **Git**: Version control
#### Setup Steps #### Setup Steps
@@ -301,35 +306,11 @@ cd openlist-desktop
# Install Node.js dependencies # Install Node.js dependencies
yarn install yarn install
# Install Rust dependencies
cd src-tauri
cargo fetch
# Prepare development environment # Prepare development environment
cd ..
yarn run prebuild:dev yarn run prebuild:dev
# Start development server # Start development server
yarn run dev yarn tauri dev
```
#### Development Commands
```bash
# Start development server with hot reload
yarn run dev
# Start development without file watching
yarn run nowatch
# Run linting
yarn run lint
# Fix linting issues
yarn run lint:fix
# Type checking
yarn run build --dry-run
``` ```
## 🤝 Contributing ## 🤝 Contributing

View File

@@ -1,7 +1,8 @@
// @ts-check
import eslint from '@eslint/js' import eslint from '@eslint/js'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import simpleImportSort from 'eslint-plugin-simple-import-sort' import simpleImportSort from 'eslint-plugin-simple-import-sort'
import eslintPluginUnicorn from 'eslint-plugin-unicorn' import eslintPluginUnicorn from 'eslint-plugin-unicorn'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals' import globals from 'globals'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
@@ -24,6 +25,8 @@ export default tseslint.config(
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
...tseslint.configs.stylistic, ...tseslint.configs.stylistic,
...pluginVue.configs['flat/recommended'],
eslintPluginPrettierRecommended,
{ {
plugins: { plugins: {
'simple-import-sort': simpleImportSort, 'simple-import-sort': simpleImportSort,
@@ -39,7 +42,10 @@ export default tseslint.config(
parserOptions: { parserOptions: {
warnOnUnsupportedTypeScriptVersion: false warnOnUnsupportedTypeScriptVersion: false
}, },
globals: globals.node globals: {
...globals.node,
...globals.browser
}
} }
}, },
{ {
@@ -102,5 +108,19 @@ export default tseslint.config(
{ name: 'exports' } { name: 'exports' }
] ]
} }
},
{
files: ['*.vue', '**/*.vue'],
rules: {
'no-undef': 'off'
},
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: globals.browser,
parserOptions: {
parser: tseslint.parser
}
}
} }
) )

View File

@@ -9,7 +9,7 @@
"tauri" "tauri"
], ],
"private": true, "private": true,
"version": "0.2.0", "version": "0.8.0",
"author": { "author": {
"name": "OpenList Team", "name": "OpenList Team",
"email": "96409857+Kuingsmile@users.noreply.github.com" "email": "96409857+Kuingsmile@users.noreply.github.com"
@@ -26,8 +26,9 @@
"tauri:dev": "cross-env RUST_BACKTRACE=1 tauri dev", "tauri:dev": "cross-env RUST_BACKTRACE=1 tauri dev",
"tauri": "tauri", "tauri": "tauri",
"nowatch": "tauri dev --no-watch", "nowatch": "tauri dev --no-watch",
"lint": "eslint src/**/*.ts", "lint": "eslint --ext .js,.jsx,.ts,.tsx,.vue src/ scripts/",
"lint:fix": "eslint src/**/*.ts --fix", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx,.vue src/ scripts/ --fix",
"lint:dpdm": "dpdm -T --tsconfig ./tsconfig.json --no-tree --no-warning --exit-code circular:1 src/main.ts",
"i18n:check": "node scripts/find-unused-i18n.js", "i18n:check": "node scripts/find-unused-i18n.js",
"i18n:check:verbose": "node scripts/find-unused-i18n.js --verbose", "i18n:check:verbose": "node scripts/find-unused-i18n.js --verbose",
"cz": "git-cz", "cz": "git-cz",
@@ -41,7 +42,7 @@
"check:all": "yarn check:frontend && yarn check:rust:all", "check:all": "yarn check:frontend && yarn check:rust:all",
"fix:rust": "cd src-tauri && cargo fmt --all && cargo clippy --all-targets --all-features --fix --allow-dirty", "fix:rust": "cd src-tauri && cargo fmt --all && cargo clippy --all-targets --all-features --fix --allow-dirty",
"fix:frontend": "yarn lint:fix", "fix:frontend": "yarn lint:fix",
"fix:all": "yarn fix:frontend && yarn fix:rust" "fix:all": "yarn fix:frontend && yarn fix:rust && yarn i18n:check"
}, },
"config": { "config": {
"commitizen": { "commitizen": {
@@ -57,43 +58,51 @@
] ]
}, },
"dependencies": { "dependencies": {
"@headlessui/vue": "^1.7.23", "@tauri-apps/api": "^2.8.0",
"@tauri-apps/api": "^2.6.0",
"@tauri-apps/plugin-autostart": "^2.5.0", "@tauri-apps/plugin-autostart": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.3.0", "@tauri-apps/plugin-dialog": "^2.3.3",
"@tauri-apps/plugin-fs": "^2.4.0", "@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-opener": "^2.4.0", "@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-process": "^2.3.0", "@tauri-apps/plugin-process": "^2.3.0",
"@tauri-apps/plugin-shell": "^2.3.0", "@tauri-apps/plugin-shell": "^2.3.1",
"@tauri-apps/plugin-store": "^2.3.0", "@tauri-apps/plugin-store": "^2.4.0",
"chrono-node": "^2.8.3", "chrono-node": "^2.8.4",
"lucide-vue-next": "^0.525.0", "lucide-vue-next": "^0.542.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"vue": "^3.5.17", "vue": "^3.5.19",
"vue-i18n": "11.1.8", "vue-i18n": "11.1.11",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.6.2", "@tauri-apps/cli": "^2.8.3",
"@types/node": "^22.9.3", "@types/node": "^24.3.0",
"@typescript-eslint/eslint-plugin": "^8.35.1", "@typescript-eslint/eslint-plugin": "^8.41.0",
"@typescript-eslint/parser": "^8.35.1", "@typescript-eslint/parser": "^8.41.0",
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.1",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^9.30.1", "dpdm": "^3.14.0",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unicorn": "^59.0.1", "eslint-plugin-unicorn": "^60.0.0",
"fs-extra": "^11.3.0", "eslint-plugin-vue": "^10.4.0",
"fs-extra": "^11.3.1",
"https-proxy-agent": "^7.0.6", "https-proxy-agent": "^7.0.6",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.1.2", "lint-staged": "^16.1.5",
"node-bump-version": "^2.0.0", "node-bump-version": "^2.0.0",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"prettier": "^3.6.2",
"tar": "^7.4.3", "tar": "^7.4.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.35.1", "typescript-eslint": "^8.41.0",
"vite": "^7.0.0", "vite": "^7.1.11",
"vue-tsc": "^3.0.1" "vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.6"
},
"overrides": {
"tmp": "^0.2.4"
} }
} }

View File

@@ -1,288 +1,404 @@
#!/usr/bin/env node #!/usr/bin/env node
import { readdirSync, readFileSync } from 'node:fs' import { readdirSync, readFileSync } from 'node:fs'
import { basename, dirname, extname, join, relative } from 'node:path' import { basename, dirname, extname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename) const __dirname = dirname(__filename)
const LOCALE_DIR = join(__dirname, '../src/i18n/locales') const LOCALE_DIR = join(__dirname, '../src/i18n/locales')
const SRC_DIR = join(__dirname, '../src') const SRC_DIR = join(__dirname, '../src')
console.log(`\n🔍 Analyzing i18n keys in ${LOCALE_DIR} and source files in ${SRC_DIR}\n`) console.log(`\n🔍 Analyzing i18n keys in ${LOCALE_DIR} and source files in ${SRC_DIR}\n`)
const colors = { const colors = {
reset: '\x1b[0m', reset: '\x1b[0m',
bright: '\x1b[1m', bright: '\x1b[1m',
red: '\x1b[31m', red: '\x1b[31m',
green: '\x1b[32m', green: '\x1b[32m',
yellow: '\x1b[33m', yellow: '\x1b[33m',
blue: '\x1b[34m', blue: '\x1b[34m',
magenta: '\x1b[35m', magenta: '\x1b[35m',
cyan: '\x1b[36m' cyan: '\x1b[36m'
} }
function colorize(text, color) { function colorize(text, color) {
return `${colors[color]}${text}${colors.reset}` return `${colors[color]}${text}${colors.reset}`
} }
function flattenKeys(obj, prefix = '') { function flattenKeys(obj, prefix = '') {
const keys = [] const keys = []
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key const fullKey = prefix ? `${prefix}.${key}` : key
if (typeof value === 'object' && value !== null && !Array.isArray(value)) { if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
keys.push(...flattenKeys(value, fullKey)) keys.push(...flattenKeys(value, fullKey))
} else { } else {
keys.push(fullKey) keys.push(fullKey)
} }
} }
return keys return keys
} }
function readLocaleFile(filePath) { function readLocaleFile(filePath) {
try { try {
const content = readFileSync(filePath, 'utf8') const content = readFileSync(filePath, 'utf8')
return JSON.parse(content) return JSON.parse(content)
} catch (error) { } catch (error) {
console.error(colorize(`Error reading ${filePath}: ${error.message}`, 'red')) console.error(colorize(`Error reading ${filePath}: ${error.message}`, 'red'))
return {} return {}
} }
} }
function getAllI18nKeys() { function getAllI18nKeys() {
const localeFiles = readdirSync(LOCALE_DIR).filter(file => file.endsWith('.json')) const localeFiles = readdirSync(LOCALE_DIR).filter(file => file.endsWith('.json'))
const allKeys = new Set() const allKeys = new Set()
const localeData = {} const localeData = {}
console.log(colorize('\n📁 Found locale files:', 'blue')) console.log(colorize('\n📁 Found locale files:', 'blue'))
for (const file of localeFiles) { for (const file of localeFiles) {
const filePath = join(LOCALE_DIR, file) const filePath = join(LOCALE_DIR, file)
const locale = basename(file, '.json') const locale = basename(file, '.json')
const data = readLocaleFile(filePath) const data = readLocaleFile(filePath)
const keys = flattenKeys(data) const keys = flattenKeys(data)
localeData[locale] = { localeData[locale] = {
file: filePath, file: filePath,
keys, keys,
data data
} }
keys.forEach(key => allKeys.add(key)) keys.forEach(key => allKeys.add(key))
console.log(` ${colorize('✓', 'green')} ${file} (${keys.length} keys)`) console.log(` ${colorize('✓', 'green')} ${file} (${keys.length} keys)`)
} }
return { return {
allKeys: Array.from(allKeys).sort(), allKeys: Array.from(allKeys).sort(),
localeData localeData
} }
} }
function findFiles(dir, extensions = ['.vue', '.ts', '.js']) { function findFiles(dir, extensions = ['.vue', '.ts', '.js']) {
const files = [] const files = []
function walk(currentDir) { function walk(currentDir) {
const entries = readdirSync(currentDir, { withFileTypes: true }) const entries = readdirSync(currentDir, { withFileTypes: true })
for (const entry of entries) { for (const entry of entries) {
const fullPath = join(currentDir, entry.name) const fullPath = join(currentDir, entry.name)
if (entry.isDirectory()) { if (entry.isDirectory()) {
if (!['node_modules', '.git', 'dist', 'build', 'target'].includes(entry.name)) { if (!['node_modules', '.git', 'dist', 'build', 'target'].includes(entry.name)) {
walk(fullPath) walk(fullPath)
} }
} else if (entry.isFile()) { } else if (entry.isFile()) {
const ext = extname(entry.name) const ext = extname(entry.name)
if (extensions.includes(ext)) { if (extensions.includes(ext)) {
files.push(fullPath) files.push(fullPath)
} }
} }
} }
} }
walk(dir) walk(dir)
return files return files
} }
function findKeyUsage(keys) { function findKeyUsage(keys) {
const usage = {} const usage = {}
const dynamicPatterns = []
keys.forEach(key => {
usage[key] = { keys.forEach(key => {
used: false, usage[key] = {
files: [], used: false,
patterns: [] files: [],
} patterns: [],
}) dynamicMatch: false
}
console.log(colorize('\n🔍 Searching for key usage in source files...', 'blue')) })
const sourceFiles = findFiles(SRC_DIR) console.log(colorize('\n🔍 Searching for key usage in source files...', 'blue'))
console.log(` Found ${sourceFiles.length} source files to analyze`) const sourceFiles = findFiles(SRC_DIR)
const searchPatterns = [ console.log(` Found ${sourceFiles.length} source files to analyze`)
/\$?t\s*\(\s*['"`]([^'"`]+)['"`]/g,
/(?:^|[^a-zA-Z])t\s*\(\s*['"`]([^'"`]+)['"`]/g, const searchPatterns = [
/\{\{\s*\$?t\s*\(\s*['"`]([^'"`]+)['"`]/g /\$?t\s*\(\s*['"`]([^'"`]+)['"`]/g,
] /(?:^|[^a-zA-Z])t\s*\(\s*['"`]([^'"`]+)['"`]/g,
/\{\{\s*\$?t\s*\(\s*['"`]([^'"`]+)['"`]/g
sourceFiles.forEach(filePath => { ]
try {
const content = readFileSync(filePath, 'utf8') const dynamicPattern = /\$?t\s*\(\s*`([^`]*\$\{[^}]+\}[^`]*)`/g
const relativePath = relative(join(__dirname, '..'), filePath)
sourceFiles.forEach(filePath => {
searchPatterns.forEach((pattern, patternIndex) => { try {
let match const content = readFileSync(filePath, 'utf8')
while ((match = pattern.exec(content)) !== null) { const relativePath = relative(join(__dirname, '..'), filePath)
const key = match[1]
if (usage[key]) { searchPatterns.forEach((pattern, patternIndex) => {
usage[key].used = true let match
if (!usage[key].files.includes(relativePath)) { while ((match = pattern.exec(content)) !== null) {
usage[key].files.push(relativePath) const key = match[1]
} if (usage[key]) {
if (!usage[key].patterns.includes(patternIndex)) { usage[key].used = true
usage[key].patterns.push(patternIndex) if (!usage[key].files.includes(relativePath)) {
} usage[key].files.push(relativePath)
} }
} if (!usage[key].patterns.includes(patternIndex)) {
}) usage[key].patterns.push(patternIndex)
} catch (error) { }
console.error(colorize(`Error reading ${filePath}: ${error.message}`, 'red')) }
} }
}) })
return usage let dynamicMatch
} while ((dynamicMatch = dynamicPattern.exec(content)) !== null) {
const templateString = dynamicMatch[1]
function findLocaleInconsistencies(localeData) {
const locales = Object.keys(localeData) const staticParts = templateString.split(/\$\{[^}]+\}/)
const inconsistencies = {}
const patternInfo = {
if (locales.length < 2) { template: templateString,
return inconsistencies file: relativePath,
} staticParts
}
locales.forEach(locale => {
const currentKeys = new Set(localeData[locale].keys) if (!dynamicPatterns.some(p => p.template === templateString && p.file === relativePath)) {
inconsistencies[locale] = { dynamicPatterns.push(patternInfo)
missing: [], }
extra: []
} keys.forEach(key => {
if (matchesDynamicPattern(key, staticParts)) {
locales.forEach(otherLocale => { if (usage[key]) {
if (locale !== otherLocale) { usage[key].used = true
localeData[otherLocale].keys.forEach(key => { usage[key].dynamicMatch = true
if (!currentKeys.has(key) && !inconsistencies[locale].missing.includes(key)) { if (!usage[key].files.includes(relativePath)) {
inconsistencies[locale].missing.push(key) usage[key].files.push(relativePath)
} }
}) if (!usage[key].patterns.includes('dynamic')) {
} usage[key].patterns.push('dynamic')
}) }
}
localeData[locale].keys.forEach(key => { }
const existsInOthers = locales.some( })
otherLocale => locale !== otherLocale && localeData[otherLocale].keys.includes(key) }
) } catch (error) {
if (!existsInOthers) { console.error(colorize(`Error reading ${filePath}: ${error.message}`, 'red'))
inconsistencies[locale].extra.push(key) }
} })
})
}) usage._dynamicPatterns = dynamicPatterns
return inconsistencies return usage
} }
function main() { function matchesDynamicPattern(key, staticParts) {
console.log(colorize('🌐 OpenList Desktop - I18n Usage Analyzer', 'cyan')) if (staticParts.length === 0) return false
console.log(colorize('==========================================', 'cyan'))
let keyIndex = 0
const { allKeys, localeData } = getAllI18nKeys()
for (let i = 0; i < staticParts.length; i++) {
console.log(colorize(`\n📊 Total unique keys found: ${allKeys.length}`, 'yellow')) const part = staticParts[i]
const usage = findKeyUsage(allKeys)
const usedKeys = allKeys.filter(key => usage[key].used) if (part === '') {
const unusedKeys = allKeys.filter(key => !usage[key].used) if (i < staticParts.length - 1) {
const nextPart = staticParts[i + 1]
const inconsistencies = findLocaleInconsistencies(localeData) if (nextPart) {
const nextIndex = key.indexOf(nextPart, keyIndex)
console.log(colorize('\n📈 Usage Summary:', 'blue')) if (nextIndex === -1) return false
console.log(` ${colorize('✓', 'green')} Used keys: ${usedKeys.length}`) keyIndex = nextIndex
console.log(` ${colorize('✗', 'red')} Unused keys: ${unusedKeys.length}`) }
console.log(` ${colorize('📊', 'yellow')} Usage rate: ${((usedKeys.length / allKeys.length) * 100).toFixed(1)}%`) }
continue
if (unusedKeys.length > 0) { }
console.log(colorize('\n🗑 Unused I18n Keys:', 'red'))
console.log(colorize('====================', 'red')) if (i === 0) {
if (!key.startsWith(part)) return false
const groupedUnused = {} keyIndex = part.length
unusedKeys.forEach(key => { } else if (i === staticParts.length - 1) {
const namespace = key.split('.')[0] if (part && !key.endsWith(part)) return false
if (!groupedUnused[namespace]) { } else {
groupedUnused[namespace] = [] const index = key.indexOf(part, keyIndex)
} if (index === -1) return false
groupedUnused[namespace].push(key) keyIndex = index + part.length
}) }
}
Object.entries(groupedUnused).forEach(([namespace, keys]) => {
console.log(colorize(`\n[${namespace}] - ${keys.length} unused keys:`, 'yellow')) return true
keys.forEach(key => { }
console.log(` ${colorize('✗', 'red')} ${key}`)
}) function findLocaleInconsistencies(localeData) {
}) const locales = Object.keys(localeData)
} else { const inconsistencies = {}
console.log(colorize('\n🎉 No unused keys found! All i18n keys are being used.', 'green'))
} if (locales.length < 2) {
return inconsistencies
const hasInconsistencies = Object.values(inconsistencies).some(inc => inc.missing.length > 0 || inc.extra.length > 0) }
if (hasInconsistencies) { locales.forEach(locale => {
console.log(colorize('\n⚠ Locale Inconsistencies:', 'yellow')) const currentKeys = new Set(localeData[locale].keys)
console.log(colorize('=========================', 'yellow')) inconsistencies[locale] = {
missing: [],
Object.entries(inconsistencies).forEach(([locale, data]) => { extra: []
if (data.missing.length > 0 || data.extra.length > 0) { }
console.log(colorize(`\n[${locale}.json]:`, 'cyan'))
locales.forEach(otherLocale => {
if (data.missing.length > 0) { if (locale !== otherLocale) {
console.log(colorize(` Missing ${data.missing.length} keys:`, 'red')) localeData[otherLocale].keys.forEach(key => {
data.missing.forEach(key => { if (!currentKeys.has(key) && !inconsistencies[locale].missing.includes(key)) {
console.log(` ${colorize('✗', 'red')} ${key}`) inconsistencies[locale].missing.push(key)
}) }
} })
}
if (data.extra.length > 0) { })
console.log(colorize(` Extra ${data.extra.length} keys:`, 'blue'))
data.extra.forEach(key => { localeData[locale].keys.forEach(key => {
console.log(` ${colorize('!', 'blue')} ${key}`) const existsInOthers = locales.some(
}) otherLocale => locale !== otherLocale && localeData[otherLocale].keys.includes(key)
} )
} if (!existsInOthers) {
}) inconsistencies[locale].extra.push(key)
} }
})
if (process.argv.includes('--verbose') || process.argv.includes('-v')) { })
console.log(colorize('\n📋 Sample Used Keys (first 10):', 'blue'))
console.log(colorize('=================================', 'blue')) return inconsistencies
}
usedKeys.slice(0, 10).forEach(key => {
const files = usage[key].files.slice(0, 3) // Show first 3 files function main() {
const moreFiles = usage[key].files.length > 3 ? ` (+${usage[key].files.length - 3} more)` : '' console.log(colorize('🌐 OpenList Desktop - I18n Usage Analyzer', 'cyan'))
console.log(` ${colorize('', 'green')} ${key}`) console.log(colorize('==========================================', 'cyan'))
console.log(` Used in: ${files.join(', ')}${moreFiles}`)
}) const { allKeys, localeData } = getAllI18nKeys()
}
console.log(colorize(`\n📊 Total unique keys found: ${allKeys.length}`, 'yellow'))
console.log(colorize('\n✨ Analysis complete!', 'cyan')) const usage = findKeyUsage(allKeys)
const dynamicPatterns = usage._dynamicPatterns || []
if (unusedKeys.length > 0) { delete usage._dynamicPatterns
console.log(colorize('\n💡 Tip: Run with --verbose (-v) flag to see usage details of used keys', 'blue'))
} const usedKeys = allKeys.filter(key => usage[key].used)
} const unusedKeys = allKeys.filter(key => !usage[key].used)
const dynamicallyUsedKeys = usedKeys.filter(key => usage[key].dynamicMatch)
main() const staticUsedKeys = usedKeys.filter(key => !usage[key].dynamicMatch)
const inconsistencies = findLocaleInconsistencies(localeData)
console.log(colorize('\n📈 Usage Summary:', 'blue'))
console.log(` ${colorize('✓', 'green')} Used keys: ${usedKeys.length}`)
console.log(` ${colorize('→', 'cyan')} Static usage: ${staticUsedKeys.length}`)
console.log(` ${colorize('→', 'magenta')} Dynamic usage: ${dynamicallyUsedKeys.length}`)
console.log(` ${colorize('✗', 'red')} Unused keys: ${unusedKeys.length}`)
console.log(` ${colorize('📊', 'yellow')} Usage rate: ${((usedKeys.length / allKeys.length) * 100).toFixed(1)}%`)
if (dynamicPatterns.length > 0) {
console.log(colorize('\n🔮 Dynamic I18n Patterns Detected:', 'magenta'))
console.log(colorize('===================================', 'magenta'))
dynamicPatterns.forEach((pattern, index) => {
console.log(colorize(`\n${index + 1}. Template: \`${pattern.template}\``, 'cyan'))
console.log(` File: ${pattern.file}`)
console.log(` Static parts: [${pattern.staticParts.map(p => `"${p}"`).join(', ')}]`)
const matchingKeys = allKeys.filter(key => matchesDynamicPattern(key, pattern.staticParts))
if (matchingKeys.length > 0) {
console.log(
` ${colorize('Matches', 'green')} (${matchingKeys.length}): ${matchingKeys.slice(0, 5).join(', ')}${
matchingKeys.length > 5 ? '...' : ''
}`
)
}
})
}
if (unusedKeys.length > 0) {
console.log(colorize('\n🗑 Unused I18n Keys:', 'red'))
console.log(colorize('====================', 'red'))
const groupedUnused = {}
unusedKeys.forEach(key => {
const namespace = key.split('.')[0]
if (!groupedUnused[namespace]) {
groupedUnused[namespace] = []
}
groupedUnused[namespace].push(key)
})
Object.entries(groupedUnused).forEach(([namespace, keys]) => {
console.log(colorize(`\n[${namespace}] - ${keys.length} unused keys:`, 'yellow'))
keys.forEach(key => {
console.log(` ${colorize('✗', 'red')} ${key}`)
})
})
} else {
console.log(colorize('\n🎉 No unused keys found! All i18n keys are being used.', 'green'))
}
const hasInconsistencies = Object.values(inconsistencies).some(inc => inc.missing.length > 0 || inc.extra.length > 0)
if (hasInconsistencies) {
console.log(colorize('\n⚠ Locale Inconsistencies:', 'yellow'))
console.log(colorize('=========================', 'yellow'))
Object.entries(inconsistencies).forEach(([locale, data]) => {
if (data.missing.length > 0 || data.extra.length > 0) {
console.log(colorize(`\n[${locale}.json]:`, 'cyan'))
if (data.missing.length > 0) {
console.log(colorize(` Missing ${data.missing.length} keys:`, 'red'))
data.missing.forEach(key => {
console.log(` ${colorize('✗', 'red')} ${key}`)
})
}
if (data.extra.length > 0) {
console.log(colorize(` Extra ${data.extra.length} keys:`, 'blue'))
data.extra.forEach(key => {
console.log(` ${colorize('!', 'blue')} ${key}`)
})
}
}
})
}
if (process.argv.includes('--verbose') || process.argv.includes('-v')) {
console.log(colorize('\n📋 Sample Used Keys (first 10):', 'blue'))
console.log(colorize('=================================', 'blue'))
usedKeys.slice(0, 10).forEach(key => {
const files = usage[key].files.slice(0, 3) // Show first 3 files
const moreFiles = usage[key].files.length > 3 ? ` (+${usage[key].files.length - 3} more)` : ''
const usageType = usage[key].dynamicMatch ? colorize('(dynamic)', 'magenta') : colorize('(static)', 'cyan')
console.log(` ${colorize('✓', 'green')} ${key} ${usageType}`)
console.log(` Used in: ${files.join(', ')}${moreFiles}`)
})
if (dynamicallyUsedKeys.length > 0) {
console.log(colorize('\n🔮 Dynamic Key Usage Details:', 'magenta'))
console.log(colorize('=============================', 'magenta'))
dynamicallyUsedKeys.slice(0, 5).forEach(key => {
const files = usage[key].files.slice(0, 2)
console.log(` ${colorize('✨', 'magenta')} ${key}`)
console.log(` Files: ${files.join(', ')}`)
})
if (dynamicallyUsedKeys.length > 5) {
console.log(` ... and ${dynamicallyUsedKeys.length - 5} more dynamic keys`)
}
}
}
console.log(colorize('\n✨ Analysis complete!', 'cyan'))
if (unusedKeys.length > 0) {
console.log(colorize('\n💡 Tip: Run with --verbose (-v) flag to see usage details of used keys', 'blue'))
}
}
main()

View File

@@ -1,4 +1,5 @@
import { execSync } from 'node:child_process' import { execSync } from 'node:child_process'
import crypto from 'node:crypto'
import fsp from 'node:fs/promises' import fsp from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
@@ -28,7 +29,7 @@ if (!getOpenlistArchMap[platformArch]) {
} }
// Rclone version management // Rclone version management
let rcloneVersion = 'v1.70.1' let rcloneVersion = 'v1.70.3'
const rcloneVersionUrl = 'https://github.com/rclone/rclone/releases/latest/download/version.txt' const rcloneVersionUrl = 'https://github.com/rclone/rclone/releases/latest/download/version.txt'
async function getLatestRcloneVersion() { async function getLatestRcloneVersion() {
@@ -42,7 +43,7 @@ async function getLatestRcloneVersion() {
} }
// openlist version management // openlist version management
let openlistVersion = 'v4.0.3' let openlistVersion = 'v4.0.8'
async function getLatestOpenlistVersion() { async function getLatestOpenlistVersion() {
try { try {
@@ -51,7 +52,7 @@ async function getLatestOpenlistVersion() {
getFetchOptions() getFetchOptions()
) )
const data = await response.json() const data = await response.json()
openlistVersion = data.tag_name || 'v4.0.3' openlistVersion = data.tag_name || 'v4.0.8'
console.log(`Latest OpenList version: ${openlistVersion}`) console.log(`Latest OpenList version: ${openlistVersion}`)
} catch (error) { } catch (error) {
console.log('Error fetching latest OpenList version:', error.message) console.log('Error fetching latest OpenList version:', error.message)
@@ -81,24 +82,29 @@ const getServiceInfo = exeName => {
} }
} }
// SimpleSC.dll const resolvePlugins = async () => {
const resolvePlugin = async () => { const pluginDir = path.join(process.env.APPDATA, 'Local/NSIS')
await fs.mkdir(pluginDir, { recursive: true })
await resolveSimpleServicePlugin(pluginDir)
await resolveAccessControlPlugin(pluginDir)
}
const resolveSimpleServicePlugin = async pluginDir => {
const url = 'https://nsis.sourceforge.io/mediawiki/images/e/ef/NSIS_Simple_Service_Plugin_Unicode_1.30.zip' const url = 'https://nsis.sourceforge.io/mediawiki/images/e/ef/NSIS_Simple_Service_Plugin_Unicode_1.30.zip'
const TEMP_DIR = path.join(cwd, 'temp') const TEMP_DIR = path.join(cwd, 'temp')
const tempDir = path.join(TEMP_DIR, 'SimpleSC') const tempDir = path.join(TEMP_DIR, 'SimpleSC')
const tempZip = path.join(tempDir, 'NSIS_Simple_Service_Plugin_Unicode_1.30.zip') const tempZip = path.join(tempDir, 'NSIS_Simple_Service_Plugin_Unicode_1.30.zip')
const tempDll = path.join(tempDir, 'SimpleSC.dll') const tempDll = path.join(tempDir, 'SimpleSC.dll')
const pluginDir = path.join(process.env.APPDATA, 'Local/NSIS')
const pluginPath = path.join(pluginDir, 'SimpleSC.dll') const pluginPath = path.join(pluginDir, 'SimpleSC.dll')
await fs.mkdir(pluginDir, { recursive: true })
await fs.mkdir(tempDir, { recursive: true }) await fs.mkdir(tempDir, { recursive: true })
if (fs.existsSync(pluginPath)) return if (fs.existsSync(pluginPath)) return
try { try {
if (!fs.existsSync(tempZip)) { if (!fs.existsSync(tempZip)) {
await downloadFile(url, tempZip) await downloadFile(url, tempZip)
} }
const zip = new AdmZip(tempZip) const zip = new AdmZip(tempZip)
zip.extractAllTo(tempDir, true) zip.extractAllTo(tempDir, true)
await fsp.cp(tempDll, pluginPath, { recursive: true, force: true }) await fsp.cp(tempDll, pluginPath, { recursive: true, force: true })
console.log(`SimpleSC.dll copied to ${pluginPath}`) console.log(`SimpleSC.dll copied to ${pluginPath}`)
@@ -107,6 +113,60 @@ const resolvePlugin = async () => {
} }
} }
const calculateSha256 = async filePath => {
const hash = crypto.createHash('sha256')
const fileStream = fs.createReadStream(filePath)
fileStream.on('data', chunk => hash.update(chunk))
fileStream.on('end', () => {
const digest = hash.digest('hex')
console.log(`SHA-256 hash of ${filePath}: ${digest}`)
})
}
const resolveAccessControlPlugin = async pluginDir => {
const url = 'https://nsis.sourceforge.io/mediawiki/images/4/4a/AccessControl.zip'
const TEMP_DIR = path.join(cwd, 'temp')
const tempDir = path.join(TEMP_DIR, 'AccessControl')
const tempZip = path.join(tempDir, 'AccessControl.zip')
const tempDll = path.join(tempDir, 'Plugins', 'AccessControl.dll')
const pluginPath = path.join(pluginDir, 'Plugins', 'x86-unicode', 'AccessControl.dll')
const pluginPathB = path.join(pluginDir, 'AccessControl.dll')
await fs.mkdir(tempDir, { recursive: true })
if (fs.existsSync(pluginPath)) return
try {
if (!fs.existsSync(tempZip)) {
await downloadFile(url, tempZip)
}
const zip = new AdmZip(tempZip)
zip.extractAllTo(tempDir, true)
let sourcePath = tempDll
if (!fs.existsSync(sourcePath)) {
const altPaths = [
path.join(tempDir, 'AccessControl.dll'),
path.join(tempDir, 'Plugins', 'i386-unicode', 'AccessControl.dll')
]
for (const altPath of altPaths) {
if (fs.existsSync(altPath)) {
sourcePath = altPath
break
}
}
}
if (fs.existsSync(sourcePath)) {
await fsp.cp(sourcePath, pluginPath, { recursive: true, force: true })
await fsp.cp(sourcePath, pluginPathB, { recursive: true, force: true })
console.log(`AccessControl.dll copied to ${pluginPath}`)
} else {
console.warn('AccessControl.dll not found in the extracted archive')
}
} finally {
await fsp.rm(tempDir, { recursive: true, force: true })
}
}
async function resolveSidecar(binInfo) { async function resolveSidecar(binInfo) {
const { name, targetFile, zipFile, exeFile, downloadURL } = binInfo const { name, targetFile, zipFile, exeFile, downloadURL } = binInfo
const binaryDir = path.join(cwd, 'src-tauri', 'binary') const binaryDir = path.join(cwd, 'src-tauri', 'binary')
@@ -135,6 +195,7 @@ async function resolveSidecar(binInfo) {
} }
await fs.remove(zipPath) await fs.remove(zipPath)
await fs.chmod(binaryPath, 0o755) await fs.chmod(binaryPath, 0o755)
await calculateSha256(binaryPath)
} catch (err) { } catch (err) {
console.error(`Error preparing "${name}":`, err.message) console.error(`Error preparing "${name}":`, err.message)
await fs.rm(binaryPath, { recursive: true, force: true }) await fs.rm(binaryPath, { recursive: true, force: true })
@@ -191,16 +252,11 @@ async function main() {
await retryTask('rclone', async () => { await retryTask('rclone', async () => {
await getLatestRcloneVersion() await getLatestRcloneVersion()
await resolveSidecar( await resolveSidecar(
createBinaryInfo( createBinaryInfo('rclone', getRcloneArchMap(rcloneVersion), `https://downloads.rclone.org`, rcloneVersion)
'rclone',
getRcloneArchMap(rcloneVersion),
'https://github.com/rclone/rclone/releases/download',
rcloneVersion
)
) )
}) })
if (isWin) { if (isWin) {
await resolvePlugin() await resolvePlugins()
} }
await resolveService(getServiceInfo('install-openlist-service')) await resolveService(getServiceInfo('install-openlist-service'))
await resolveService(getServiceInfo('openlist-desktop-service')) await resolveService(getServiceInfo('openlist-desktop-service'))

275
src-tauri/Cargo.lock generated
View File

@@ -69,9 +69,9 @@ dependencies = [
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.98" version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]] [[package]]
name = "arbitrary" name = "arbitrary"
@@ -513,7 +513,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02260d489095346e5cafd04dea8e8cb54d1d74fcd759022a9b72986ebe9a1257" checksum = "02260d489095346e5cafd04dea8e8cb54d1d74fcd759022a9b72986ebe9a1257"
dependencies = [ dependencies = [
"serde", "serde",
"toml", "toml 0.8.23",
] ]
[[package]] [[package]]
@@ -989,9 +989,9 @@ dependencies = [
[[package]] [[package]]
name = "dlopen2" name = "dlopen2"
version = "0.7.0" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1297103d2bbaea85724fcee6294c2d50b1081f9ad47d0f6f6f61eda65315a6" checksum = "b54f373ccf864bf587a89e880fb7610f8d73f3045f13580948ccbcaff26febff"
dependencies = [ dependencies = [
"dlopen2_derive", "dlopen2_derive",
"libc", "libc",
@@ -1070,7 +1070,7 @@ dependencies = [
"cc", "cc",
"memchr", "memchr",
"rustc_version", "rustc_version",
"toml", "toml 0.8.23",
"vswhom", "vswhom",
"winreg 0.55.0", "winreg 0.55.0",
] ]
@@ -1887,7 +1887,7 @@ dependencies = [
"libc", "libc",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2 0.5.10",
"system-configuration", "system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
@@ -2082,6 +2082,17 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "io-uring"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.11.0" version = "2.11.0"
@@ -2590,6 +2601,15 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "ntapi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@@ -2779,6 +2799,16 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-io-kit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a"
dependencies = [
"libc",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "objc2-io-surface" name = "objc2-io-surface"
version = "0.3.1" version = "0.3.1"
@@ -2790,6 +2820,16 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-javascript-core"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9052cb1bb50a4c161d934befcf879526fb87ae9a68858f241e693ca46225cf5a"
dependencies = [
"objc2 0.6.1",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "objc2-metal" name = "objc2-metal"
version = "0.2.2" version = "0.2.2"
@@ -2826,6 +2866,17 @@ dependencies = [
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
] ]
[[package]]
name = "objc2-security"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1f8e0ef3ab66b08c42644dcb34dba6ec0a574bbd8adbb8bdbdc7a2779731a44"
dependencies = [
"bitflags 2.9.1",
"objc2 0.6.1",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "objc2-ui-kit" name = "objc2-ui-kit"
version = "0.3.1" version = "0.3.1"
@@ -2850,6 +2901,8 @@ dependencies = [
"objc2-app-kit", "objc2-app-kit",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"objc2-javascript-core",
"objc2-security",
] ]
[[package]] [[package]]
@@ -2887,7 +2940,7 @@ dependencies = [
[[package]] [[package]]
name = "openlist-desktop" name = "openlist-desktop"
version = "0.1.0" version = "0.7.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
@@ -2906,6 +2959,7 @@ dependencies = [
"runas", "runas",
"serde", "serde",
"serde_json", "serde_json",
"sysinfo",
"tar", "tar",
"tauri", "tauri",
"tauri-build", "tauri-build",
@@ -3439,7 +3493,7 @@ dependencies = [
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2", "socket2 0.5.10",
"thiserror 2.0.12", "thiserror 2.0.12",
"tokio", "tokio",
"tracing", "tracing",
@@ -3476,7 +3530,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
"once_cell", "once_cell",
"socket2", "socket2 0.5.10",
"tracing", "tracing",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
@@ -3665,9 +3719,9 @@ dependencies = [
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.11.1" version = "1.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@@ -3694,9 +3748,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.20" version = "0.12.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
@@ -4071,9 +4125,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.140" version = "1.0.143"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@@ -4101,6 +4155,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_spanned"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -4159,9 +4222,9 @@ dependencies = [
[[package]] [[package]]
name = "serialize-to-javascript" name = "serialize-to-javascript"
version = "0.1.1" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9823f2d3b6a81d98228151fdeaf848206a7855a7a042bbf9bf870449a66cafb" checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -4170,13 +4233,13 @@ dependencies = [
[[package]] [[package]]
name = "serialize-to-javascript-impl" name = "serialize-to-javascript-impl"
version = "0.1.1" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74064874e9f6a15f04c1f3cb627902d0e6b410abbf36668afa873c61889f1763" checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 1.0.109", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -4331,6 +4394,16 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "socket2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "softbuffer" name = "softbuffer"
version = "0.4.6" version = "0.4.6"
@@ -4481,6 +4554,20 @@ dependencies = [
"syn 2.0.104", "syn 2.0.104",
] ]
[[package]]
name = "sysinfo"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07cec4dc2d2e357ca1e610cfb07de2fa7a10fc3e9fe89f72545f3d244ea87753"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows",
]
[[package]] [[package]]
name = "system-configuration" name = "system-configuration"
version = "0.6.1" version = "0.6.1"
@@ -4511,17 +4598,18 @@ dependencies = [
"cfg-expr", "cfg-expr",
"heck 0.5.0", "heck 0.5.0",
"pkg-config", "pkg-config",
"toml", "toml 0.8.23",
"version-compare", "version-compare",
] ]
[[package]] [[package]]
name = "tao" name = "tao"
version = "0.34.0" version = "0.34.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49c380ca75a231b87b6c9dd86948f035012e7171d1a7c40a9c2890489a7ffd8a" checksum = "4daa814018fecdfb977b59a094df4bd43b42e8e21f88fddfc05807e6f46efaaf"
dependencies = [ dependencies = [
"bitflags 2.9.1", "bitflags 2.9.1",
"block2 0.6.1",
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
@@ -4584,12 +4672,13 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]] [[package]]
name = "tauri" name = "tauri"
version = "2.6.2" version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "124e129c9c0faa6bec792c5948c89e86c90094133b0b9044df0ce5f0a8efaa0d" checksum = "dcead52ec80df0e9e4be671c0f2596a1f3bd7b6b2c9418c1eb7dd737499ff4bd"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
"cookie",
"dirs 6.0.0", "dirs 6.0.0",
"dunce", "dunce",
"embed_plist", "embed_plist",
@@ -4607,6 +4696,7 @@ dependencies = [
"objc2-app-kit", "objc2-app-kit",
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit",
"percent-encoding", "percent-encoding",
"plist", "plist",
"raw-window-handle", "raw-window-handle",
@@ -4634,9 +4724,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-build" name = "tauri-build"
version = "2.3.0" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f025c389d3adb83114bec704da973142e82fc6ec799c7c750c5e21cefaec83" checksum = "67945dbaf8920dbe3a1e56721a419a0c3d085254ab24cff5b9ad55e2b0016e0b"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
@@ -4650,15 +4740,15 @@ dependencies = [
"serde_json", "serde_json",
"tauri-utils", "tauri-utils",
"tauri-winres", "tauri-winres",
"toml", "toml 0.9.5",
"walkdir", "walkdir",
] ]
[[package]] [[package]]
name = "tauri-codegen" name = "tauri-codegen"
version = "2.3.0" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5df493a1075a241065bc865ed5ef8d0fbc1e76c7afdc0bf0eccfaa7d4f0e406" checksum = "1ab3a62cf2e6253936a8b267c2e95839674e7439f104fa96ad0025e149d54d8a"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"brotli", "brotli",
@@ -4683,9 +4773,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-macros" name = "tauri-macros"
version = "2.3.1" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f237fbea5866fa5f2a60a21bea807a2d6e0379db070d89c3a10ac0f2d4649bbc" checksum = "4368ea8094e7045217edb690f493b55b30caf9f3e61f79b4c24b6db91f07995e"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@@ -4697,9 +4787,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin" name = "tauri-plugin"
version = "2.3.0" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d9a0bd00bf1930ad1a604d08b0eb6b2a9c1822686d65d7f4731a7723b8901d3" checksum = "9946a3cede302eac0c6eb6c6070ac47b1768e326092d32efbb91f21ed58d978f"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"glob", "glob",
@@ -4708,7 +4798,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tauri-utils", "tauri-utils",
"toml", "toml 0.9.5",
"walkdir", "walkdir",
] ]
@@ -4728,9 +4818,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-dialog" name = "tauri-plugin-dialog"
version = "2.3.0" version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aefb14219b492afb30b12647b5b1247cadd2c0603467310c36e0f7ae1698c28" checksum = "0ee5a3c416dc59d7d9aa0de5490a82d6e201c67ffe97388979d77b69b08cda40"
dependencies = [ dependencies = [
"log", "log",
"raw-window-handle", "raw-window-handle",
@@ -4746,9 +4836,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-fs" name = "tauri-plugin-fs"
version = "2.4.0" version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c341290d31991dbca38b31d412c73dfbdb070bb11536784f19dd2211d13b778f" checksum = "315784ec4be45e90a987687bae7235e6be3d6e9e350d2b75c16b8a4bf22c1db7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dunce", "dunce",
@@ -4762,15 +4852,15 @@ dependencies = [
"tauri-plugin", "tauri-plugin",
"tauri-utils", "tauri-utils",
"thiserror 2.0.12", "thiserror 2.0.12",
"toml", "toml 0.9.5",
"url", "url",
] ]
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.4.0" version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecee219f11cdac713ab32959db5d0cceec4810ba4f4458da992292ecf9660321" checksum = "786156aa8e89e03d271fbd3fe642207da8e65f3c961baa9e2930f332bf80a1f5"
dependencies = [ dependencies = [
"dunce", "dunce",
"glob", "glob",
@@ -4821,9 +4911,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-plugin-single-instance" name = "tauri-plugin-single-instance"
version = "2.3.0" version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b441b6d5d1a194e9fee0b358fe0d602ded845d0f580e1f8c8ef78ebc3c8b225d" checksum = "236043404a4d1502ed7cce11a8ec88ea1e85597eec9887b4701bb10b66b13b6e"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -4836,9 +4926,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.7.0" version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e7bb73d1bceac06c20b3f755b2c8a2cb13b20b50083084a8cf3700daf397ba4" checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846"
dependencies = [ dependencies = [
"cookie", "cookie",
"dpi", "dpi",
@@ -4847,20 +4937,23 @@ dependencies = [
"jni", "jni",
"objc2 0.6.1", "objc2 0.6.1",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit",
"raw-window-handle", "raw-window-handle",
"serde", "serde",
"serde_json", "serde_json",
"tauri-utils", "tauri-utils",
"thiserror 2.0.12", "thiserror 2.0.12",
"url", "url",
"webkit2gtk",
"webview2-com",
"windows", "windows",
] ]
[[package]] [[package]]
name = "tauri-runtime-wry" name = "tauri-runtime-wry"
version = "2.7.1" version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "902b5aa9035e16f342eb64f8bf06ccdc2808e411a2525ed1d07672fa4e780bad" checksum = "5bb0f10f831f75832ac74d14d98f701868f9a8adccef2c249b466cf70b607db9"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
@@ -4885,9 +4978,9 @@ dependencies = [
[[package]] [[package]]
name = "tauri-utils" name = "tauri-utils"
version = "2.5.0" version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41743bbbeb96c3a100d234e5a0b60a46d5aa068f266160862c7afdbf828ca02e" checksum = "41a3852fdf9a4f8fbeaa63dc3e9a85284dd6ef7200751f0bd66ceee30c93f212"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"brotli", "brotli",
@@ -4914,7 +5007,7 @@ dependencies = [
"serde_with", "serde_with",
"swift-rs", "swift-rs",
"thiserror 2.0.12", "thiserror 2.0.12",
"toml", "toml 0.9.5",
"url", "url",
"urlpattern", "urlpattern",
"uuid", "uuid",
@@ -4929,7 +5022,7 @@ checksum = "e8d321dbc6f998d825ab3f0d62673e810c861aac2d0de2cc2c395328f1d113b4"
dependencies = [ dependencies = [
"embed-resource", "embed-resource",
"indexmap 2.10.0", "indexmap 2.10.0",
"toml", "toml 0.8.23",
] ]
[[package]] [[package]]
@@ -5118,21 +5211,23 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.45.1" version = "1.47.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"bytes", "bytes",
"io-uring",
"libc", "libc",
"mio", "mio",
"parking_lot", "parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
"socket2", "slab",
"socket2 0.6.0",
"tokio-macros", "tokio-macros",
"tracing", "tracing",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -5186,11 +5281,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [ dependencies = [
"serde", "serde",
"serde_spanned", "serde_spanned 0.6.9",
"toml_datetime", "toml_datetime 0.6.11",
"toml_edit 0.22.27", "toml_edit 0.22.27",
] ]
[[package]]
name = "toml"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8"
dependencies = [
"indexmap 2.10.0",
"serde",
"serde_spanned 1.0.0",
"toml_datetime 0.7.0",
"toml_parser",
"toml_writer",
"winnow 0.7.11",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.6.11" version = "0.6.11"
@@ -5200,6 +5310,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "toml_datetime"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.19.15" version = "0.19.15"
@@ -5207,7 +5326,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [ dependencies = [
"indexmap 2.10.0", "indexmap 2.10.0",
"toml_datetime", "toml_datetime 0.6.11",
"winnow 0.5.40", "winnow 0.5.40",
] ]
@@ -5218,7 +5337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81"
dependencies = [ dependencies = [
"indexmap 2.10.0", "indexmap 2.10.0",
"toml_datetime", "toml_datetime 0.6.11",
"winnow 0.5.40", "winnow 0.5.40",
] ]
@@ -5230,18 +5349,33 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [ dependencies = [
"indexmap 2.10.0", "indexmap 2.10.0",
"serde", "serde",
"serde_spanned", "serde_spanned 0.6.9",
"toml_datetime", "toml_datetime 0.6.11",
"toml_write", "toml_write",
"winnow 0.7.11", "winnow 0.7.11",
] ]
[[package]]
name = "toml_parser"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10"
dependencies = [
"winnow 0.7.11",
]
[[package]] [[package]]
name = "toml_write" name = "toml_write"
version = "0.1.2" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64"
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.2" version = "0.5.2"
@@ -6349,14 +6483,15 @@ checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
[[package]] [[package]]
name = "wry" name = "wry"
version = "0.52.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12a714d9ba7075aae04a6e50229d6109e3d584774b99a6a8c60de1698ca111b9" checksum = "5698e50a589268aec06d2219f48b143222f7b5ad9aa690118b8dce0a8dcac574"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"block2 0.6.1", "block2 0.6.1",
"cookie", "cookie",
"crossbeam-channel", "crossbeam-channel",
"dirs 6.0.0",
"dpi", "dpi",
"dunce", "dunce",
"gdkx11", "gdkx11",
@@ -6448,9 +6583,9 @@ dependencies = [
[[package]] [[package]]
name = "zbus" name = "zbus"
version = "5.7.1" version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3a7c7cee313d044fca3f48fa782cb750c79e4ca76ba7bc7718cd4024cdf6f68" checksum = "67a073be99ace1adc48af593701c8015cd9817df372e14a1a6b0ee8f8bf043be"
dependencies = [ dependencies = [
"async-broadcast", "async-broadcast",
"async-executor", "async-executor",
@@ -6473,7 +6608,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"uds_windows", "uds_windows",
"windows-sys 0.59.0", "windows-sys 0.60.2",
"winnow 0.7.11", "winnow 0.7.11",
"zbus_macros", "zbus_macros",
"zbus_names", "zbus_names",
@@ -6482,9 +6617,9 @@ dependencies = [
[[package]] [[package]]
name = "zbus_macros" name = "zbus_macros"
version = "5.7.1" version = "5.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a17e7e5eec1550f747e71a058df81a9a83813ba0f6a95f39c4e218bdc7ba366a" checksum = "0e80cd713a45a49859dcb648053f63265f4f2851b6420d47a958e5697c68b131"
dependencies = [ dependencies = [
"proc-macro-crate 3.3.0", "proc-macro-crate 3.3.0",
"proc-macro2", "proc-macro2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "openlist-desktop" name = "openlist-desktop"
version = "0.2.0" version = "0.8.0"
description = "A Tauri App" description = "A Tauri App"
authors = ["Kuingsmile"] authors = ["Kuingsmile"]
edition = "2024" edition = "2024"
@@ -15,27 +15,27 @@ name = "openlist_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.3.0", features = [] } tauri-build = { version = "2.4.0", features = [] }
[dependencies] [dependencies]
tauri = { version = "2.6.2", features = ["tray-icon", "devtools"] } tauri = { version = "2.8.3", features = ["tray-icon", "devtools"] }
tauri-plugin-opener = "2.4.0" tauri-plugin-opener = "2.5.0"
tauri-plugin-process = "2.3.0" tauri-plugin-process = "2.3.0"
tauri-plugin-fs = "2.4.0" tauri-plugin-fs = "2.4.2"
tauri-plugin-dialog = "2.3.0" tauri-plugin-dialog = "2.3.3"
tauri-plugin-shell = "2.3.0" tauri-plugin-shell = "2.3.0"
tauri-plugin-autostart = "2.5.0" tauri-plugin-autostart = "2.5.0"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140" serde_json = "1.0.143"
tokio = { version = "1.45.1", features = ["full"] } tokio = { version = "1.47.1", features = ["full"] }
anyhow = "1.0.98" anyhow = "1.0.99"
thiserror = "2.0.12" thiserror = "2.0.12"
chrono = { version = "0.4.41", features = ["serde"] } chrono = { version = "0.4.41", features = ["serde"] }
log = "0.4.27" log = "0.4.27"
log4rs = "1.3.0" log4rs = "1.3.0"
dirs = "6.0.0" dirs = "6.0.0"
open = "5.3.2" open = "5.3.2"
reqwest = { version = "0.12.20", features = ["json", "rustls-tls", "cookies"] } reqwest = { version = "0.12.23", features = ["json", "rustls-tls", "cookies"] }
once_cell = "1.21.3" once_cell = "1.21.3"
parking_lot = "0.12.4" parking_lot = "0.12.4"
url = "2.5.4" url = "2.5.4"
@@ -44,7 +44,8 @@ base64 = "0.22.1"
zip = "4.2.0" zip = "4.2.0"
tar = "0.4.44" tar = "0.4.44"
flate2 = "1.1.2" flate2 = "1.1.2"
regex = "1.11.1" regex = "1.11.2"
sysinfo = "0.37.0"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
runas = "=1.2.0" runas = "=1.2.0"
@@ -55,7 +56,7 @@ windows-service = "0.8.0"
uzers = "0.12.1" uzers = "0.12.1"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2.3.0" tauri-plugin-single-instance = "2.3.3"
[profile.release] [profile.release]
panic = "abort" panic = "abort"

View File

@@ -401,17 +401,11 @@ Function .onInit
${If} $INSTDIR == "" ${If} $INSTDIR == ""
; Set default install location ; Set default install location
!if "${INSTALLMODE}" == "perMachine" !if "${INSTALLMODE}" == "perMachine"
${If} ${RunningX64} IfFileExists "D:\" 0 +3
!if "${ARCH}" == "x64" StrCpy $INSTDIR "D:\Program\${PRODUCTNAME}"
StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" Goto instdir_set
!else if "${ARCH}" == "arm64" StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}"
StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" instdir_set:
!else
StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}"
!endif
${Else}
StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}"
${EndIf}
!else if "${INSTALLMODE}" == "currentUser" !else if "${INSTALLMODE}" == "currentUser"
StrCpy $INSTDIR "$LOCALAPPDATA\${PRODUCTNAME}" StrCpy $INSTDIR "$LOCALAPPDATA\${PRODUCTNAME}"
!endif !endif
@@ -473,6 +467,22 @@ FunctionEnd
nsis_tauri_utils::KillProcess "openlist.exe" nsis_tauri_utils::KillProcess "openlist.exe"
!endif !endif
${EndIf} ${EndIf}
; Check if rclone.exe is running
!if "${INSTALLMODE}" == "currentUser"
nsis_tauri_utils::FindProcessCurrentUser "rclone.exe"
!else
nsis_tauri_utils::FindProcess "rclone.exe"
!endif
Pop $R0
${If} $R0 = 0
DetailPrint "Kill rclone.exe..."
!if "${INSTALLMODE}" == "currentUser"
nsis_tauri_utils::KillProcessCurrentUser "rclone.exe"
!else
nsis_tauri_utils::KillProcess "rclone.exe"
!endif
${EndIf}
!macroend !macroend
!macro StartOpenListDesktopService !macro StartOpenListDesktopService
@@ -501,6 +511,34 @@ FunctionEnd
${EndIf} ${EndIf}
!macroend !macroend
!macro SetDirectoryPermissions
DetailPrint "Setting permissions for installation directory..."
!if "${INSTALLMODE}" == "currentUser"
AccessControl::GrantOnFile "$INSTDIR" "(S-1-5-32-545)" "FullAccess"
Pop $R0
${If} $R0 == "ok"
DetailPrint "Successfully granted permissions to Users group"
${Else}
DetailPrint "Warning: Failed to set permissions - $R0"
${EndIf}
!else
AccessControl::GrantOnFile "$INSTDIR" "(S-1-5-32-545)" "FullAccess"
Pop $R0
${If} $R0 == "ok"
DetailPrint "Successfully granted permissions to Users group"
${Else}
DetailPrint "Warning: Failed to set permissions - $R0"
${EndIf}
AccessControl::GrantOnFile "$INSTDIR" "(S-1-5-11)" "FullAccess"
Pop $R0
${If} $R0 == "ok"
DetailPrint "Successfully granted permissions to Authenticated Users"
${Else}
DetailPrint "Warning: Failed to set permissions for Authenticated Users - $R0"
${EndIf}
!endif
!macroend
!macro RemoveOpenListService !macro RemoveOpenListService
; Check if the service exists ; Check if the service exists
SimpleSC::ExistsService "openlist_desktop_service" SimpleSC::ExistsService "openlist_desktop_service"
@@ -731,6 +769,8 @@ Section Install
!insertmacro CheckIfAppIsRunning !insertmacro CheckIfAppIsRunning
!insertmacro CheckAllOpenListProcesses !insertmacro CheckAllOpenListProcesses
!insertmacro SetDirectoryPermissions
DetailPrint "Cleaning auto-launch registry entries..." DetailPrint "Cleaning auto-launch registry entries..."
StrCpy $R1 "Software\Microsoft\Windows\CurrentVersion\Run" StrCpy $R1 "Software\Microsoft\Windows\CurrentVersion\Run"
@@ -943,6 +983,13 @@ Section Uninstall
SetShellVarContext current SetShellVarContext current
RmDir /r "$APPDATA\${BUNDLEID}" RmDir /r "$APPDATA\${BUNDLEID}"
RmDir /r "$LOCALAPPDATA\${BUNDLEID}" RmDir /r "$LOCALAPPDATA\${BUNDLEID}"
RmDir /r "$INSTDIR\data"
RmDir /r "$INSTDIR\logs"
Delete "$INSTDIR\settings.json"
Delete "$INSTDIR\openlist-desktop-service.log"
Delete "$INSTDIR\rclone.conf"
RMDir "$INSTDIR"
RMDir /r "C:\ProgramData\openlist-service-config"
${EndIf} ${EndIf}
SetShellVarContext current SetShellVarContext current

View File

@@ -1,18 +1,22 @@
use std::env; use std::env;
use std::path::PathBuf;
use std::process::Command; use std::process::Command;
#[tauri::command] #[tauri::command]
pub async fn get_binary_version(binary_name: Option<String>) -> Result<String, String> { pub async fn get_binary_version(binary_name: Option<String>) -> Result<String, String> {
let app_dir = env::current_exe().unwrap().parent().unwrap().to_path_buf(); let bin = binary_name.as_deref().unwrap_or("openlist");
let binary_path = if cfg!(windows) { let mut binary_path: PathBuf =
app_dir.join(format!( env::current_exe().map_err(|e| format!("Failed to get current exe path: {e}"))?;
"{}.exe", binary_path.pop();
binary_name.unwrap_or("openlist".to_string())
)) #[cfg(windows)]
} else { let file_name = format!("{bin}.exe");
app_dir.join(binary_name.unwrap_or("openlist".to_string())) #[cfg(not(windows))]
}; let file_name = bin.to_string();
let mut cmd = Command::new(binary_path);
binary_path.push(file_name);
let mut cmd = Command::new(&binary_path);
cmd.arg("version"); cmd.arg("version");
#[cfg(windows)] #[cfg(windows)]
@@ -21,19 +25,25 @@ pub async fn get_binary_version(binary_name: Option<String>) -> Result<String, S
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
} }
let output = cmd.output().map_err(|e| e.to_string())?; let output = cmd
if output.status.success() { .output()
let version_output = String::from_utf8_lossy(&output.stdout); .map_err(|e| format!("Failed to spawn {:?}: {}", &binary_path, e))?;
let version_line = version_output
.lines() if !output.status.success() {
.find(|line| line.starts_with("Version:") || line.starts_with("rclone")) return Err(format!(
.ok_or("Version not found in output")?; "{:?} exited with status: {}",
let version = version_line &binary_path, output.status
.split_whitespace() ));
.nth(1)
.ok_or("Failed to parse version")?;
Ok(version.to_string())
} else {
Err("Failed to get OpenList binary version".to_string())
} }
let stdout = String::from_utf8_lossy(&output.stdout);
let version = stdout
.lines()
.filter(|l| l.starts_with("Version:") || l.starts_with("rclone"))
.filter_map(|l| l.split_whitespace().nth(1))
.next()
.ok_or_else(|| "Version not found in output".to_string())?;
Ok(version.to_string())
} }

View File

@@ -1,90 +1,109 @@
use std::fs; use std::fs;
use std::path::PathBuf;
use tauri::State; use tauri::State;
use tokio::time::{Duration, sleep};
use crate::cmd::http_api::{get_process_list, start_process, stop_process}; use crate::cmd::http_api::{delete_process, get_process_list, start_process, stop_process};
use crate::cmd::openlist_core::create_openlist_core_process;
use crate::cmd::rclone_core::create_rclone_backend_process;
use crate::conf::config::MergedSettings; use crate::conf::config::MergedSettings;
use crate::object::structs::AppState; use crate::object::structs::AppState;
use crate::utils::path::app_config_file_path; use crate::utils::path::{app_config_file_path, get_default_openlist_data_dir};
#[tauri::command] fn write_json_to_file<T: serde::Serialize>(path: PathBuf, value: &T) -> Result<(), String> {
pub async fn save_settings( let json = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
settings: MergedSettings, fs::write(path, json).map_err(|e| e.to_string())
state: State<'_, AppState>,
) -> Result<bool, String> {
state.update_settings(settings.clone());
let settings_path = app_config_file_path().map_err(|e| e.to_string())?;
let settings_json = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
fs::write(settings_path, settings_json).map_err(|e| e.to_string())?;
log::info!("Settings saved successfully");
Ok(true)
} }
#[tauri::command] fn persist_app_settings(settings: &MergedSettings) -> Result<(), String> {
pub async fn save_settings_with_update_port( let path = app_config_file_path().map_err(|e| e.to_string())?;
settings: MergedSettings, write_json_to_file(path, settings)
state: State<'_, AppState>, }
) -> Result<bool, String> {
save_settings(settings.clone(), state.clone()).await?; fn update_data_config(port: u16, data_dir: Option<&str>) -> Result<(), String> {
let app_dir = std::env::current_exe() let data_config_path = if let Some(dir) = data_dir.filter(|d| !d.is_empty()) {
.map_err(|e| format!("Failed to get current exe path: {e}"))? PathBuf::from(dir).join("config.json")
.parent()
.ok_or("Failed to get parent directory")?
.to_path_buf();
let data_config_path = app_dir.join("data").join("config.json");
if let Some(parent) = data_config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let mut config = if data_config_path.exists() {
let content =
std::fs::read_to_string(data_config_path.clone()).map_err(|e| e.to_string())?;
serde_json::from_str(&content).map_err(|e| e.to_string())?
} else { } else {
serde_json::json!({ get_default_openlist_data_dir()?.join("config.json")
"scheme": {
"http_port": settings.openlist.port,
}
})
}; };
if let Some(scheme) = config.get_mut("scheme") {
if let Some(scheme_obj) = scheme.as_object_mut() { if let Some(parent) = data_config_path.parent() {
scheme_obj.insert( fs::create_dir_all(parent).map_err(|e| e.to_string())?;
"http_port".to_string(),
serde_json::Value::Number(serde_json::Number::from(settings.openlist.port)),
);
}
} else {
config["scheme"] = serde_json::json!({
"http_port": settings.openlist.port
});
}
let content = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?;
std::fs::write(data_config_path, content).map_err(|e| e.to_string())?;
// Stop the OpenList core process
let process_list = get_process_list(state.clone()).await?;
if let Some(existing_process) = process_list
.iter()
.find(|p| p.config.name == "single_openlist_core_process")
{
match stop_process(existing_process.config.id.clone(), state.clone()).await {
Ok(_) => log::info!("OpenList core process stopped successfully"),
Err(e) => log::warn!("Failed to stop OpenList core process: {e}"),
}
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
match start_process(existing_process.config.id.clone(), state.clone()).await {
Ok(_) => log::info!("OpenList core process started successfully with new port"),
Err(e) => {
log::error!("Failed to start OpenList core process: {e}");
return Err(format!(
"Failed to restart OpenList core with new port: {e}"
));
}
}
} }
log::info!("Settings saved and OpenList core restarted with new port successfully"); let mut cfg_value = if data_config_path.exists() {
Ok(true) let s = fs::read_to_string(&data_config_path).map_err(|e| e.to_string())?;
serde_json::from_str::<serde_json::Value>(&s).map_err(|e| e.to_string())?
} else {
serde_json::json!({ "scheme": { "http_port": port } })
};
let scheme = cfg_value.get_mut("scheme").and_then(|v| v.as_object_mut());
if let Some(obj) = scheme {
obj.insert("http_port".into(), serde_json::json!(port));
} else {
cfg_value["scheme"] = serde_json::json!({ "http_port": port });
}
write_json_to_file(data_config_path, &cfg_value)
}
async fn restart_openlist_core(state: State<'_, AppState>) -> Result<(), String> {
let procs = get_process_list(state.clone()).await?;
if let Some(proc) = procs
.into_iter()
.find(|p| p.config.name == "single_openlist_core_process")
{
let id = proc.config.id.clone();
let _ = stop_process(id.clone(), state.clone()).await;
sleep(Duration::from_millis(1_000)).await;
start_process(id, state)
.await
.map_err(|e| format!("Failed to restart OpenList core: {e}"))?;
}
Ok(())
}
async fn recreate_openlist_core_process(state: State<'_, AppState>) -> Result<(), String> {
let procs = get_process_list(state.clone()).await?;
if let Some(proc) = procs
.into_iter()
.find(|p| p.config.name == "single_openlist_core_process")
{
let id = proc.config.id.clone();
let _ = stop_process(id.clone(), state.clone()).await;
sleep(Duration::from_millis(1000)).await;
let _ = delete_process(id, state.clone()).await;
sleep(Duration::from_millis(1000)).await;
let auto_launch = state
.app_settings
.read()
.clone()
.map(|settings| settings.openlist.auto_launch)
.unwrap_or(false);
create_openlist_core_process(auto_launch, state.clone()).await?;
}
Ok(())
}
async fn recreate_rclone_backend_process(state: State<'_, AppState>) -> Result<(), String> {
let procs = get_process_list(state.clone()).await?;
if let Some(proc) = procs
.into_iter()
.find(|p| p.config.name == "single_rclone_backend_process")
{
let id = proc.config.id.clone();
let _ = stop_process(id.clone(), state.clone()).await;
sleep(Duration::from_millis(1000)).await;
let _ = delete_process(id, state.clone()).await;
sleep(Duration::from_millis(1000)).await;
create_rclone_backend_process(state.clone()).await?;
}
Ok(())
} }
#[tauri::command] #[tauri::command]
@@ -94,15 +113,77 @@ pub async fn load_settings(state: State<'_, AppState>) -> Result<Option<MergedSe
} }
#[tauri::command] #[tauri::command]
pub async fn reset_settings(state: State<'_, AppState>) -> Result<Option<MergedSettings>, String> { pub async fn save_settings(
let default_settings = MergedSettings::default(); settings: MergedSettings,
state.update_settings(default_settings.clone()); state: State<'_, AppState>,
) -> Result<bool, String> {
let settings_path = app_config_file_path().map_err(|e| e.to_string())?; state.update_settings(settings.clone());
let settings_json = persist_app_settings(&settings)?;
serde_json::to_string_pretty(&default_settings).map_err(|e| e.to_string())?; log::info!("Settings saved successfully");
fs::write(settings_path, settings_json).map_err(|e| e.to_string())?; Ok(true)
}
log::info!("Settings reset to default");
Ok(Some(default_settings)) #[tauri::command]
pub async fn save_settings_with_update_port(
settings: MergedSettings,
state: State<'_, AppState>,
) -> Result<bool, String> {
let old_settings = state.get_settings();
let needs_openlist_recreation = if let Some(old) = &old_settings {
old.openlist.data_dir != settings.openlist.data_dir
} else {
false
};
let needs_rclone_recreation = if let Some(old) = &old_settings {
old.rclone.api_port != settings.rclone.api_port
} else {
false
};
state.update_settings(settings.clone());
persist_app_settings(&settings)?;
let data_dir = if settings.openlist.data_dir.is_empty() {
None
} else {
Some(settings.openlist.data_dir.as_str())
};
update_data_config(settings.openlist.port, data_dir)?;
if needs_openlist_recreation {
if let Err(e) = recreate_openlist_core_process(state.clone()).await {
log::error!("{e}");
return Err(e);
}
log::info!(
"Settings saved and OpenList core recreated with new data directory successfully"
);
} else {
if let Err(e) = restart_openlist_core(state.clone()).await {
log::error!("{e}");
return Err(e);
}
log::info!("Settings saved and OpenList core restarted with new port successfully");
}
if needs_rclone_recreation {
if let Err(e) = recreate_rclone_backend_process(state.clone()).await {
log::error!("Failed to recreate rclone backend process: {e}");
return Err(format!("Failed to recreate rclone backend process: {e}"));
}
log::info!("Rclone backend process recreated with new API port successfully");
} else {
log::info!("Settings saved successfully (no rclone port change detected)");
}
Ok(true)
}
#[tauri::command]
pub async fn reset_settings(state: State<'_, AppState>) -> Result<Option<MergedSettings>, String> {
let base_settings = MergedSettings::default();
state.update_settings(base_settings.clone());
persist_app_settings(&base_settings)?;
log::info!("Settings reset to default");
Ok(Some(base_settings))
} }

View File

@@ -1,15 +1,16 @@
use std::env; use std::env;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::time::Duration; use std::time::Duration;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State}; use tauri::{AppHandle, Emitter, Manager, State};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use crate::cmd::config::save_settings; use crate::cmd::config::save_settings;
use crate::object::structs::AppState; use crate::object::structs::AppState;
use crate::utils::github_proxy::apply_github_proxy;
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct GitHubRelease { pub struct GitHubRelease {
@@ -67,13 +68,12 @@ pub struct DownloadProgress {
fn get_current_platform() -> String { fn get_current_platform() -> String {
let os = env::consts::OS; let os = env::consts::OS;
let arch = env::consts::ARCH;
match os { match os {
"windows" => format!("{arch}-pc-windows-msvc"), "windows" => "pc-windows-msvc".to_string(),
"macos" => format!("{arch}-apple-darwin"), "macos" => "apple-darwin".to_string(),
"linux" => format!("{arch}-unknown-linux-gnu"), "linux" => "unknown-linux-gnu".to_string(),
_ => format!("{arch}-{os}"), _ => os.to_string(),
} }
} }
@@ -168,14 +168,25 @@ fn compare_versions(current: &str, latest: &str) -> bool {
} }
#[tauri::command] #[tauri::command]
pub async fn check_for_updates() -> Result<UpdateCheck, String> { pub async fn check_for_updates(state: State<'_, AppState>) -> Result<UpdateCheck, String> {
log::info!("Checking for updates..."); log::info!("Checking for updates...");
let gh_proxy = state
.get_settings()
.and_then(|settings| settings.app.gh_proxy.clone());
let gh_proxy_api = state
.get_settings()
.and_then(|settings| settings.app.gh_proxy_api);
let client = Client::new(); let client = Client::new();
let url = "https://api.github.com/repos/OpenListTeam/openlist-desktop/releases/latest"; let url = "https://api.github.com/repos/OpenListTeam/openlist-desktop/releases/latest";
let proxied_url = apply_github_proxy(url, &gh_proxy, &gh_proxy_api);
log::info!("Fetching updates from: {proxied_url}");
let response = client let response = client
.get(url) .get(&proxied_url)
.header("User-Agent", "OpenList-Desktop") .header("User-Agent", "OpenList-Desktop")
.timeout(Duration::from_secs(30)) .timeout(Duration::from_secs(30))
.send() .send()
@@ -212,7 +223,14 @@ pub async fn check_for_updates() -> Result<UpdateCheck, String> {
let latest_version = release.tag_name.as_str(); let latest_version = release.tag_name.as_str();
let has_update = compare_versions(current_version, latest_version); let has_update = compare_versions(current_version, latest_version);
let assets = filter_assets_for_platform(&release.assets); let mut assets = filter_assets_for_platform(&release.assets);
// Apply GitHub proxy to asset URLs if proxy is configured
if gh_proxy.is_some() {
for asset in &mut assets {
asset.url = apply_github_proxy(&asset.url, &gh_proxy, &gh_proxy_api);
}
}
log::info!( log::info!(
"Update check result: current={}, latest={}, has_update={}, assets_count={}", "Update check result: current={}, latest={}, has_update={}, assets_count={}",
@@ -237,9 +255,21 @@ pub async fn download_update(
app: AppHandle, app: AppHandle,
asset_url: String, asset_url: String,
asset_name: String, asset_name: String,
state: State<'_, AppState>,
) -> Result<String, String> { ) -> Result<String, String> {
log::info!("Starting download of update: {asset_name}"); log::info!("Starting download of update: {asset_name}");
let gh_proxy = state
.get_settings()
.and_then(|settings| settings.app.gh_proxy.clone());
let gh_proxy_api = state
.get_settings()
.and_then(|settings| settings.app.gh_proxy_api);
let proxied_url = apply_github_proxy(&asset_url, &gh_proxy, &gh_proxy_api);
log::info!("Downloading from: {proxied_url}");
let client = Client::new(); let client = Client::new();
let temp_dir = std::env::temp_dir(); let temp_dir = std::env::temp_dir();
@@ -248,7 +278,7 @@ pub async fn download_update(
log::info!("Downloading to: {file_path:?}"); log::info!("Downloading to: {file_path:?}");
let mut response = client let mut response = client
.get(&asset_url) .get(&proxied_url)
.header("User-Agent", "OpenList-Desktop") .header("User-Agent", "OpenList-Desktop")
.timeout(Duration::from_secs(9000)) .timeout(Duration::from_secs(9000))
.send() .send()
@@ -367,6 +397,7 @@ pub async fn install_update_and_restart(
"linux" => install_linux_update(&path).await, "linux" => install_linux_update(&path).await,
_ => Err("Unsupported platform for auto-update".to_string()), _ => Err("Unsupported platform for auto-update".to_string()),
}; };
log::info!("Update installation result: {result:?}");
match result { match result {
Ok(_) => { Ok(_) => {
@@ -376,8 +407,8 @@ pub async fn install_update_and_restart(
log::error!("Failed to emit install completed event: {e}"); log::error!("Failed to emit install completed event: {e}");
} }
if let Err(e) = app.emit("app-restarting", ()) { if let Err(e) = app.emit("quit-app", ()) {
log::error!("Failed to emit app restarting event: {e}"); log::error!("Failed to emit app quit event: {e}");
} }
tokio::time::sleep(Duration::from_millis(1000)).await; tokio::time::sleep(Duration::from_millis(1000)).await;
@@ -392,29 +423,47 @@ pub async fn install_update_and_restart(
} }
} }
} }
async fn install_windows_update(installer_path: &Path) -> Result<(), String> {
async fn install_windows_update(installer_path: &PathBuf) -> Result<(), String> {
log::info!("Installing Windows update..."); log::info!("Installing Windows update...");
let mut cmd = Command::new(installer_path); let mut cmd = Command::new("powershell");
cmd.arg("/SILENT"); cmd.args([
"-Command",
#[cfg(windows)] &format!(
{ "Start-Process -FilePath '{}' -Verb runAs",
use std::os::windows::process::CommandExt; installer_path.display()
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW ),
} ]);
log::info!("Running command: {cmd:?}");
let _ = tokio::task::spawn_blocking(move || { let _ = tokio::task::spawn_blocking(move || {
cmd.spawn() let child = cmd
.map_err(|e| format!("Failed to start Windows installer: {e}")) .spawn()
.map_err(|e| format!("Failed to start Windows installer: {e}"))?;
log::info!("Started installer process with PID: {}", child.id());
let output = child
.wait_with_output()
.map_err(|e| format!("Failed to wait for installer: {e}"))?;
log::info!("Installer output: {output:?}");
if output.status.success() {
log::info!(
"Installer completed successfully. Output: {}",
String::from_utf8_lossy(&output.stdout)
);
Ok(())
} else {
log::error!(
"Installer failed. Error: {}",
String::from_utf8_lossy(&output.stderr)
);
Err(format!("Installer exited with status: {:?}", output.status))
}
}) })
.await .await
.map_err(|e| format!("Task error: {e}"))?; .map_err(|e| format!("Task error: {e}"))?;
Ok(()) Ok(())
} }
async fn install_macos_update(installer_path: &PathBuf) -> Result<(), String> { async fn install_macos_update(installer_path: &PathBuf) -> Result<(), String> {
log::info!("Installing macOS update..."); log::info!("Installing macOS update...");
@@ -422,8 +471,25 @@ async fn install_macos_update(installer_path: &PathBuf) -> Result<(), String> {
cmd.arg(installer_path); cmd.arg(installer_path);
let _ = tokio::task::spawn_blocking(move || { let _ = tokio::task::spawn_blocking(move || {
cmd.spawn() let child = cmd
.map_err(|e| format!("Failed to start macOS installer: {e}")) .spawn()
.map_err(|e| format!("Failed to start macOS installer: {e}"))?;
let output = child
.wait_with_output()
.map_err(|e| format!("Failed to wait for installer: {e}"))?;
if output.status.success() {
log::info!(
"Installer completed successfully. Output: {}",
String::from_utf8_lossy(&output.stdout)
);
Ok(())
} else {
log::error!(
"Installer failed. Error: {}",
String::from_utf8_lossy(&output.stderr)
);
Err(format!("Installer exited with status: {:?}", output.status))
}
}) })
.await .await
.map_err(|e| format!("Task error: {e}"))?; .map_err(|e| format!("Task error: {e}"))?;
@@ -458,8 +524,25 @@ async fn install_linux_update(installer_path: &PathBuf) -> Result<(), String> {
}; };
let _ = tokio::task::spawn_blocking(move || { let _ = tokio::task::spawn_blocking(move || {
cmd.spawn() let child = cmd
.map_err(|e| format!("Failed to start Linux installer: {e}")) .spawn()
.map_err(|e| format!("Failed to start Linux installer: {e}"))?;
let output = child
.wait_with_output()
.map_err(|e| format!("Failed to wait for installer: {e}"))?;
if output.status.success() {
log::info!(
"Installer completed successfully. Output: {}",
String::from_utf8_lossy(&output.stdout)
);
Ok(())
} else {
log::error!(
"Installer failed. Error: {}",
String::from_utf8_lossy(&output.stderr)
);
Err(format!("Installer exited with status: {:?}", output.status))
}
}) })
.await .await
.map_err(|e| format!("Task error: {e}"))?; .map_err(|e| format!("Task error: {e}"))?;
@@ -502,10 +585,92 @@ pub async fn is_auto_check_enabled(state: State<'_, AppState>) -> Result<bool, S
Ok(settings.app.auto_update_enabled.unwrap_or(true)) Ok(settings.app.auto_update_enabled.unwrap_or(true))
} }
async fn check_for_updates_internal(app: &AppHandle) -> Result<UpdateCheck, String> {
log::info!("Checking for updates (background check)...");
let app_state = app.state::<AppState>();
let gh_proxy = app_state
.get_settings()
.and_then(|settings| settings.app.gh_proxy.clone());
let gh_proxy_api = app_state
.get_settings()
.and_then(|settings| settings.app.gh_proxy_api);
let client = Client::new();
let url = "https://api.github.com/repos/OpenListTeam/openlist-desktop/releases/latest";
let proxied_url = apply_github_proxy(url, &gh_proxy, &gh_proxy_api);
log::info!("Fetching updates from: {proxied_url}");
let response = client
.get(&proxied_url)
.header("User-Agent", "OpenList-Desktop")
.timeout(Duration::from_secs(30))
.send()
.await
.map_err(|e| {
let error_msg = format!("Network error while checking for updates: {e}");
log::error!("{error_msg}");
error_msg
})?;
if !response.status().is_success() {
let status = response.status();
let error_msg = if status.as_u16() == 404 {
"Repository not found. Please check the repository URL.".to_string()
} else if status.as_u16() == 403 {
"API rate limit exceeded. Please try again later.".to_string()
} else {
format!(
"GitHub API returned status: {} ({})",
status.as_u16(),
status.canonical_reason().unwrap_or("Unknown")
)
};
log::error!("{error_msg}");
return Err(error_msg);
}
let release: GitHubRelease = response.json().await.map_err(|e| {
log::error!("Failed to parse GitHub response: {e}");
format!("Failed to parse update information: {e}")
})?;
let current_version = env!("CARGO_PKG_VERSION");
let latest_version = release.tag_name.as_str();
let has_update = compare_versions(current_version, latest_version);
let mut assets = filter_assets_for_platform(&release.assets);
if gh_proxy.is_some() {
for asset in &mut assets {
asset.url = apply_github_proxy(&asset.url, &gh_proxy, &gh_proxy_api);
}
}
log::info!(
"Update check result: current={}, latest={}, has_update={}, assets_count={}",
current_version,
latest_version,
has_update,
assets.len()
);
Ok(UpdateCheck {
has_update,
current_version: current_version.to_string(),
latest_version: latest_version.to_string(),
release_date: release.published_at,
release_notes: release.body,
assets,
})
}
pub async fn perform_background_update_check(app: AppHandle) -> Result<(), String> { pub async fn perform_background_update_check(app: AppHandle) -> Result<(), String> {
log::debug!("Performing background update check..."); log::debug!("Performing background update check...");
match check_for_updates().await { match check_for_updates_internal(&app).await {
Ok(update_check) => { Ok(update_check) => {
if update_check.has_update { if update_check.has_update {
log::info!( log::info!(
@@ -528,16 +693,3 @@ pub async fn perform_background_update_check(app: AppHandle) -> Result<(), Strin
} }
} }
} }
#[tauri::command]
pub async fn restart_app(app: AppHandle) {
log::info!("Restarting application...");
if let Err(e) = app.emit("app-restarting", ()) {
log::error!("Failed to emit app-restarting event: {e}");
}
tokio::time::sleep(Duration::from_millis(500)).await;
app.restart();
}

View File

@@ -0,0 +1,122 @@
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
use std::process::{Command, ExitStatus};
#[cfg(target_os = "windows")]
use deelevate::{PrivilegeLevel, Token};
#[cfg(target_os = "windows")]
use runas::Command as RunasCommand;
use tauri::State;
use crate::object::structs::AppState;
#[cfg(target_os = "windows")]
const RULE: &str = "OpenList Core";
#[cfg(target_os = "windows")]
fn netsh_with_elevation(args: &[String]) -> Result<ExitStatus, String> {
let token = Token::with_current_process().map_err(|e| format!("token: {e}"))?;
let elevated = !matches!(
token
.privilege_level()
.map_err(|e| format!("privilege: {e}"))?,
PrivilegeLevel::NotPrivileged
);
if elevated {
Command::new("netsh")
.args(args)
.creation_flags(0x08000000)
.status()
} else {
RunasCommand::new("netsh").args(args).show(false).status()
}
.map_err(|e| format!("netsh: {e}"))
}
#[cfg(target_os = "windows")]
fn firewall_rule(verb: &str, port: Option<u16>) -> Result<bool, String> {
let mut args: Vec<String> = vec![
"advfirewall".into(),
"firewall".into(),
verb.into(),
"rule".into(),
format!("name={RULE}"),
];
if let Some(p) = port {
args.extend([
"dir=in".into(),
"action=allow".into(),
"protocol=TCP".into(),
format!("localport={p}"),
"description=Allow OpenList Core web interface access".into(),
]);
}
Ok(netsh_with_elevation(&args)?.success())
}
#[cfg(target_os = "windows")]
fn rule_stdout() -> Result<Option<String>, String> {
let out = Command::new("netsh")
.args([
"advfirewall",
"firewall",
"show",
"rule",
&format!("name={RULE}"),
])
.creation_flags(0x08000000)
.output()
.map_err(|e| format!("netsh: {e}"))?;
if out.status.success() {
Ok(Some(String::from_utf8_lossy(&out.stdout).into()))
} else {
Ok(None)
}
}
#[cfg(not(target_os = "windows"))]
fn firewall_rule(_: &str, _: Option<u16>) -> Result<bool, String> {
Ok(true)
}
#[cfg(not(target_os = "windows"))]
fn rule_stdout() -> Result<Option<String>, String> {
Ok(None)
}
#[tauri::command]
pub async fn check_firewall_rule(state: State<'_, AppState>) -> Result<bool, String> {
let port = state
.app_settings
.read()
.clone()
.ok_or("read settings")?
.openlist
.port;
if let Some(out) = rule_stdout()? {
Ok(out.contains(&port.to_string()))
} else {
Ok(false)
}
}
#[tauri::command]
pub async fn add_firewall_rule(state: State<'_, AppState>) -> Result<bool, String> {
let port = state
.app_settings
.read()
.clone()
.ok_or("read settings")?
.openlist
.port;
let _ = firewall_rule("delete", None);
firewall_rule("add", Some(port))
}
#[tauri::command]
pub async fn remove_firewall_rule(_state: State<'_, AppState>) -> Result<bool, String> {
firewall_rule("delete", None)
}

View File

@@ -1,120 +1,90 @@
use std::str::FromStr; use reqwest::Client;
use reqwest;
use tauri::State; use tauri::State;
use crate::object::structs::AppState; use crate::object::structs::AppState;
use crate::utils::api::{ListProcessResponse, ProcessStatus, get_api_key, get_server_port}; use crate::utils::api::{ListProcessResponse, ProcessStatus, get_api_key, get_server_port};
use crate::utils::args::split_args_vec;
fn create_client() -> (Client, String, u16) {
let client = Client::new();
let api_key = get_api_key();
let port = get_server_port();
(client, api_key, port)
}
async fn process_operation(id: &str, operation: &str) -> Result<bool, String> {
let (client, api_key, port) = create_client();
let url = match operation {
"start" => format!("http://127.0.0.1:{port}/api/v1/processes/{id}/start"),
"stop" => format!("http://127.0.0.1:{port}/api/v1/processes/{id}/stop"),
"delete" => format!("http://127.0.0.1:{port}/api/v1/processes/{id}"),
_ => return Err("Invalid operation".to_string()),
};
let request = match operation {
"delete" => client.delete(&url),
_ => client.post(&url),
};
let response = request
.header("Authorization", format!("Bearer {api_key}"))
.send()
.await
.map_err(|e| format!("Failed to send request: {e}"))?;
if response.status().is_success() {
Ok(true)
} else {
Err(format!(
"Failed to {operation} process: {}",
response.status()
))
}
}
#[tauri::command] #[tauri::command]
pub async fn get_process_list(_state: State<'_, AppState>) -> Result<Vec<ProcessStatus>, String> { pub async fn get_process_list(_state: State<'_, AppState>) -> Result<Vec<ProcessStatus>, String> {
let api_key = get_api_key(); let (client, api_key, port) = create_client();
let port = get_server_port();
let client = reqwest::Client::new();
let response = client let response = client
.get(format!("http://127.0.0.1:{port}/api/v1/processes")) .get(format!("http://127.0.0.1:{port}/api/v1/processes"))
.header("Authorization", format!("Bearer {api_key}")) .header("Authorization", format!("Bearer {api_key}"))
.send() .send()
.await .await
.map_err(|e| format!("Failed to send request: {e}"))?; .map_err(|e| format!("Failed to send request: {e}"))?;
if response.status().is_success() {
let response_text = response if !response.status().is_success() {
.text() return Err(format!("Failed to get process list: {}", response.status()));
.await
.map_err(|e| format!("Failed to read response text: {e}"))?;
let process_list = match serde_json::from_str::<ListProcessResponse>(&response_text) {
Ok(process_list) => process_list,
Err(e) => {
return Err(format!(
"Failed to parse response: {e}, response: {response_text}"
));
}
};
Ok(process_list.data)
} else {
Err(format!("Failed to get process list: {}", response.status()))
} }
let response_text = response
.text()
.await
.map_err(|e| format!("Failed to read response text: {e}"))?;
serde_json::from_str::<ListProcessResponse>(&response_text)
.map(|process_list| process_list.data)
.map_err(|e| format!("Failed to parse response: {e}, response: {response_text}"))
} }
#[tauri::command] #[tauri::command]
pub async fn start_process(id: String, _state: State<'_, AppState>) -> Result<bool, String> { pub async fn start_process(id: String, _state: State<'_, AppState>) -> Result<bool, String> {
let api_key = get_api_key(); process_operation(&id, "start").await
let port = get_server_port();
let client = reqwest::Client::new();
let response = client
.post(format!(
"http://127.0.0.1:{port}/api/v1/processes/{id}/start"
))
.header("Authorization", format!("Bearer {api_key}"))
.send()
.await
.map_err(|e| format!("Failed to send request: {e}"))?;
if response.status().is_success() {
Ok(true)
} else {
Err(format!("Failed to start process: {}", response.status()))
}
} }
#[tauri::command] #[tauri::command]
pub async fn stop_process(id: String, _state: State<'_, AppState>) -> Result<bool, String> { pub async fn stop_process(id: String, _state: State<'_, AppState>) -> Result<bool, String> {
let api_key = get_api_key(); process_operation(&id, "stop").await
let port = get_server_port();
let client = reqwest::Client::new();
let response = client
.post(format!(
"http://127.0.0.1:{port}/api/v1/processes/{id}/stop"
))
.header("Authorization", format!("Bearer {api_key}"))
.send()
.await
.map_err(|e| format!("Failed to send request: {e}"))?;
if response.status().is_success() {
Ok(true)
} else {
Err(format!("Failed to stop process: {}", response.status()))
}
} }
#[tauri::command] #[tauri::command]
pub async fn restart_process(id: String, _state: State<'_, AppState>) -> Result<bool, String> { pub async fn restart_process(id: String, _state: State<'_, AppState>) -> Result<bool, String> {
let api_key = get_api_key(); process_operation(&id, "stop")
let port = get_server_port();
let client = reqwest::Client::new();
let stop_response = client
.post(format!(
"http://127.0.0.1:{port}/api/v1/processes/{id}/stop"
))
.header("Authorization", format!("Bearer {api_key}"))
.send()
.await .await
.map_err(|e| format!("Failed to send request: {e}"))?; .map_err(|e| format!("Failed to stop OpenList Core process: {e}"))?;
if stop_response.status().is_success() {
let start_response = client process_operation(&id, "start")
.post( .await
url::Url::from_str(&format!( .map_err(|e| format!("Failed to start OpenList Core process: {e}"))
"http://127.0.0.1:{port}/api/v1/processes/{id}/start"
))
.unwrap(),
)
.header("Authorization", format!("Bearer {api_key}"))
.send()
.await
.map_err(|e| format!("Failed to send request: {e}"))?;
if start_response.status().is_success() {
Ok(true)
} else {
Err(format!(
"Failed to start OpenList Core process: {}",
start_response.status()
))
}
} else {
Err(format!(
"Failed to stop OpenList Core process: {}",
stop_response.status()
))
}
} }
#[tauri::command] #[tauri::command]
@@ -123,16 +93,26 @@ pub async fn update_process(
update_config: serde_json::Value, update_config: serde_json::Value,
_state: State<'_, AppState>, _state: State<'_, AppState>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let api_key = get_api_key(); let (client, api_key, port) = create_client();
let port = get_server_port();
let client = reqwest::Client::new(); let mut processed_config = update_config;
if let Some(args) = processed_config.get("args").and_then(|v| v.as_array()) {
let args_strings: Vec<String> = args
.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect();
processed_config["args"] = serde_json::json!(split_args_vec(args_strings));
}
let response = client let response = client
.put(format!("http://127.0.0.1:{port}/api/v1/processes/{id}")) .put(format!("http://127.0.0.1:{port}/api/v1/processes/{id}"))
.header("Authorization", format!("Bearer {api_key}")) .header("Authorization", format!("Bearer {api_key}"))
.json(&update_config) .json(&processed_config)
.send() .send()
.await .await
.map_err(|e| format!("Failed to send request: {e}"))?; .map_err(|e| format!("Failed to send request: {e}"))?;
if response.status().is_success() { if response.status().is_success() {
Ok(true) Ok(true)
} else { } else {
@@ -142,18 +122,5 @@ pub async fn update_process(
#[tauri::command] #[tauri::command]
pub async fn delete_process(id: String, _state: State<'_, AppState>) -> Result<bool, String> { pub async fn delete_process(id: String, _state: State<'_, AppState>) -> Result<bool, String> {
let api_key = get_api_key(); process_operation(&id, "delete").await
let port = get_server_port();
let client = reqwest::Client::new();
let response = client
.delete(format!("http://127.0.0.1:{port}/api/v1/processes/{id}"))
.header("Authorization", format!("Bearer {api_key}"))
.send()
.await
.map_err(|e| format!("Failed to send request: {e}"))?;
if response.status().is_success() {
Ok(true)
} else {
Err(format!("Failed to delete process: {}", response.status()))
}
} }

View File

@@ -1,122 +1,272 @@
use std::env; use std::env;
use std::path::PathBuf;
use std::process::Command;
use regex::Regex; use tauri::State;
#[tauri::command] use crate::object::structs::AppState;
pub async fn get_admin_password() -> Result<String, String> { use crate::utils::path::{get_app_logs_dir, get_default_openlist_data_dir, get_service_log_path};
let app_dir = env::current_exe().unwrap().parent().unwrap().to_path_buf();
let logs_dir = app_dir.join("logs/process_openlist_core.log");
let logs_content = fn generate_random_password() -> String {
std::fs::read_to_string(logs_dir).map_err(|e| format!("Failed to read log file: {e}"))?; use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::time::{SystemTime, UNIX_EPOCH};
let re = Regex::new(r"Successfully created the admin user and the initial password is: (\w+)") let mut hasher = DefaultHasher::new();
.map_err(|e| format!("Failed to create regex: {e}"))?;
let mut last_password = None; if let Ok(duration) = SystemTime::now().duration_since(UNIX_EPOCH) {
for line in logs_content.lines() { duration.as_nanos().hash(&mut hasher);
if let Some(captures) = re.captures(line) }
&& let Some(password) = captures.get(1)
{ std::process::id().hash(&mut hasher);
last_password = Some(password.as_str().to_string());
let dummy = [1, 2, 3];
(dummy.as_ptr() as usize).hash(&mut hasher);
let hash = hasher.finish();
let chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let mut password = String::new();
let mut current_hash = hash;
for _ in 0..16 {
let index = (current_hash % chars.len() as u64) as usize;
password.push(chars.chars().nth(index).unwrap());
current_hash = current_hash.wrapping_mul(1103515245).wrapping_add(12345);
}
password
}
async fn execute_openlist_admin_set(
password: &str,
state: &State<'_, AppState>,
) -> Result<(), String> {
let exe_path =
env::current_exe().map_err(|e| format!("Failed to determine executable path: {e}"))?;
let app_dir = exe_path
.parent()
.ok_or("Executable has no parent directory")?;
let possible_names = ["openlist", "openlist.exe"];
let mut openlist_exe = None;
for name in &possible_names {
let exe_path = app_dir.join(name);
if exe_path.exists() {
openlist_exe = Some(exe_path);
break;
} }
} }
last_password.ok_or("No admin password found in logs".to_string()) let openlist_exe = openlist_exe.ok_or_else(|| {
} format!(
"OpenList executable not found. Searched for: {:?} in {}",
possible_names,
app_dir.display()
)
})?;
#[tauri::command] log::info!(
pub async fn get_logs(source: Option<String>) -> Result<Vec<String>, String> { "Setting new admin password using: {}",
match source.as_deref() { openlist_exe.display()
Some("openlist") => { );
let app_dir = env::current_exe().unwrap().parent().unwrap().to_path_buf();
let logs_dir = app_dir.join("data/log/log.log");
let logs = std::fs::read_to_string(logs_dir)
.map_err(|e| e.to_string())?
.lines()
.map(|line| line.to_string())
.collect();
Ok(logs)
}
Some("app") => {
let app_dir = env::current_exe().unwrap().parent().unwrap().to_path_buf();
let logs_dir = app_dir.join("logs/app.log");
let logs = std::fs::read_to_string(logs_dir)
.map_err(|e| e.to_string())?
.lines()
.map(|line| line.to_string())
.collect();
Ok(logs)
}
Some("rclone") => {
let app_dir = env::current_exe().unwrap().parent().unwrap().to_path_buf();
let logs_dir = app_dir.join("logs/process_rclone.log");
let logs = std::fs::read_to_string(logs_dir)
.map_err(|e| e.to_string())?
.lines()
.map(|line| line.to_string())
.collect();
Ok(logs)
}
Some("openlist_core") => {
let app_dir = env::current_exe().unwrap().parent().unwrap().to_path_buf();
let logs_dir = app_dir.join("logs/process_openlist_core.log");
let logs = std::fs::read_to_string(logs_dir)
.map_err(|e| e.to_string())?
.lines()
.map(|line| line.to_string())
.collect();
Ok(logs)
}
_ => Err("Invalid log source".to_string()),
}
}
#[tauri::command] let mut cmd = Command::new(&openlist_exe);
pub async fn clear_logs(source: Option<String>) -> Result<bool, String> { cmd.args(["admin", "set", password]);
let app_dir = env::current_exe().unwrap().parent().unwrap().to_path_buf(); cmd.current_dir(app_dir);
let log_files = match source.as_deref() { let effective_data_dir = if let Some(settings) = state.get_settings()
Some("openlist") => vec![app_dir.join("data/log/log.log")], && !settings.openlist.data_dir.is_empty()
Some("app") => vec![app_dir.join("logs/app.log")], {
Some("rclone") => vec![app_dir.join("logs/process_rclone.log")], settings.openlist.data_dir
Some("openlist_core") => vec![app_dir.join("logs/process_openlist_core.log")], } else {
None => vec![ get_default_openlist_data_dir()
app_dir.join("data/log/log.log"), .map_err(|e| format!("Failed to get default data directory: {e}"))?
app_dir.join("logs/app.log"), .to_string_lossy()
app_dir.join("logs/process_rclone.log"), .to_string()
app_dir.join("logs/process_openlist_core.log"),
],
_ => return Err("Invalid log source".to_string()),
}; };
let mut cleared_count = 0; cmd.arg("--data");
let mut errors = Vec::new(); cmd.arg(&effective_data_dir);
log::info!("Using data directory: {effective_data_dir}");
log::info!("Executing command: {cmd:?}");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
}
let output = cmd
.output()
.map_err(|e| format!("Failed to execute openlist command: {e}"))?;
for log_file in log_files { if !output.status.success() {
if log_file.exists() { let stderr = String::from_utf8_lossy(&output.stderr);
match std::fs::write(&log_file, "") { let stdout = String::from_utf8_lossy(&output.stdout);
Ok(_) => { log::error!("OpenList admin set command failed. stdout: {stdout}, stderr: {stderr}");
cleared_count += 1; return Err(format!("OpenList admin set command failed: {stderr}"));
} }
Err(e) => {
let error_msg = format!("Failed to clear {log_file:?}: {e}"); let stdout = String::from_utf8_lossy(&output.stdout);
errors.push(error_msg); log::info!("Successfully set admin password. Output: {stdout}");
}
} Ok(())
}
fn resolve_log_paths(source: Option<&str>, data_dir: Option<&str>) -> Result<Vec<PathBuf>, String> {
let logs_dir = get_app_logs_dir()?;
let service_path = get_service_log_path()?;
let openlist_log_base = if let Some(dir) = data_dir.filter(|d| !d.is_empty()) {
PathBuf::from(dir)
} else {
get_default_openlist_data_dir()
.map_err(|e| format!("Failed to get default data directory: {e}"))?
};
let mut paths = Vec::new();
match source {
Some("openlist") => paths.push(openlist_log_base.join("log/log.log")),
Some("app") => paths.push(logs_dir.join("app.log")),
Some("rclone") => paths.push(logs_dir.join("process_rclone.log")),
Some("openlist_core") => paths.push(logs_dir.join("process_openlist_core.log")),
Some("service") => paths.push(service_path),
Some("all") => {
paths.push(openlist_log_base.join("log/log.log"));
paths.push(logs_dir.join("app.log"));
paths.push(logs_dir.join("process_rclone.log"));
paths.push(logs_dir.join("process_openlist_core.log"));
paths.push(service_path);
}
_ => return Err("Invalid log source".into()),
}
Ok(paths)
}
#[tauri::command]
pub async fn get_admin_password(state: State<'_, AppState>) -> Result<String, String> {
if let Some(settings) = state.get_settings()
&& let Some(ref stored_password) = settings.app.admin_password
&& !stored_password.is_empty()
{
log::info!("Found admin password in local settings");
return Ok(stored_password.clone());
}
let new_password = generate_random_password();
if let Err(e) = execute_openlist_admin_set(&new_password, &state).await {
return Err(format!("Failed to set new admin password: {e}"));
}
log::info!("Successfully generated and set new admin password");
if let Some(mut settings) = state.get_settings() {
settings.app.admin_password = Some(new_password.clone());
state.update_settings(settings.clone());
if let Err(e) = settings.save() {
log::warn!("Failed to save new admin password to settings: {e}");
} }
} }
if !errors.is_empty() { Ok(new_password)
return Err(format!( }
"Some log files could not be cleared: {}",
errors.join(", ") #[tauri::command]
)); pub async fn reset_admin_password(state: State<'_, AppState>) -> Result<String, String> {
log::info!("Forcing admin password reset");
let new_password = generate_random_password();
if let Err(e) = execute_openlist_admin_set(&new_password, &state).await {
return Err(format!("Failed to set new admin password: {e}"));
}
log::info!("Successfully generated and set new admin password via force reset");
if let Some(mut settings) = state.get_settings() {
settings.app.admin_password = Some(new_password.clone());
state.update_settings(settings.clone());
if let Err(e) = settings.save() {
log::warn!("Failed to save new admin password to settings: {e}");
}
}
Ok(new_password)
}
#[tauri::command]
pub async fn set_admin_password(
password: String,
state: State<'_, AppState>,
) -> Result<String, String> {
log::info!("Setting custom admin password");
if let Err(e) = execute_openlist_admin_set(&password, &state).await {
return Err(format!("Failed to set admin password: {e}"));
}
log::info!("Successfully set custom admin password");
if let Some(mut settings) = state.get_settings() {
settings.app.admin_password = Some(password.clone());
state.update_settings(settings.clone());
if let Err(e) = settings.save() {
log::warn!("Failed to save admin password to settings: {e}");
}
}
Ok(password)
}
#[tauri::command]
pub async fn get_logs(
source: Option<String>,
state: State<'_, AppState>,
) -> Result<Vec<String>, String> {
let data_dir = state
.get_settings()
.map(|s| s.openlist.data_dir)
.filter(|d| !d.is_empty());
let paths = resolve_log_paths(source.as_deref(), data_dir.as_deref())?;
let mut logs = Vec::new();
for path in paths {
if path.exists() {
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read {path:?}: {e}"))?;
logs.extend(content.lines().map(str::to_string));
} else {
log::info!("Log file does not exist: {path:?}");
}
}
Ok(logs)
}
#[tauri::command]
pub async fn clear_logs(
source: Option<String>,
state: State<'_, AppState>,
) -> Result<bool, String> {
let data_dir = state
.get_settings()
.map(|s| s.openlist.data_dir)
.filter(|d| !d.is_empty());
let paths = resolve_log_paths(source.as_deref(), data_dir.as_deref())?;
let mut cleared_count = 0;
for path in paths {
if path.exists() {
std::fs::write(&path, "").map_err(|e| format!("Failed to clear {path:?}: {e}"))?;
cleared_count += 1;
}
} }
if cleared_count == 0 { if cleared_count == 0 {
return Err("No log files found to clear".to_string()); Err("No log files found to clear".into())
} else {
Ok(true)
} }
Ok(true)
} }

View File

@@ -1,6 +1,7 @@
pub mod binary; pub mod binary;
pub mod config; pub mod config;
pub mod custom_updater; pub mod custom_updater;
pub mod firewall;
pub mod http_api; pub mod http_api;
pub mod logs; pub mod logs;
pub mod openlist_core; pub mod openlist_core;

View File

@@ -4,13 +4,22 @@ use url::Url;
use crate::object::structs::{AppState, ServiceStatus}; use crate::object::structs::{AppState, ServiceStatus};
use crate::utils::api::{CreateProcessResponse, ProcessConfig, get_api_key, get_server_port}; use crate::utils::api::{CreateProcessResponse, ProcessConfig, get_api_key, get_server_port};
use crate::utils::path::{get_app_logs_dir, get_openlist_binary_path}; use crate::utils::path::{
get_app_logs_dir, get_default_openlist_data_dir, get_openlist_binary_path,
};
#[tauri::command] #[tauri::command]
pub async fn create_openlist_core_process( pub async fn create_openlist_core_process(
auto_start: bool, auto_start: bool,
_state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<ProcessConfig, String> { ) -> Result<ProcessConfig, String> {
let data_dir = state
.app_settings
.read()
.clone()
.ok_or("Failed to read app settings")?
.openlist
.data_dir;
let binary_path = get_openlist_binary_path() let binary_path = get_openlist_binary_path()
.map_err(|e| format!("Failed to get OpenList binary path: {e}"))?; .map_err(|e| format!("Failed to get OpenList binary path: {e}"))?;
let log_file_path = let log_file_path =
@@ -19,12 +28,25 @@ pub async fn create_openlist_core_process(
let api_key = get_api_key(); let api_key = get_api_key();
let port = get_server_port(); let port = get_server_port();
let mut args = vec!["server".into()];
// Use custom data dir if set, otherwise use platform-specific default
let effective_data_dir = if !data_dir.is_empty() {
data_dir
} else {
get_default_openlist_data_dir()
.map_err(|e| format!("Failed to get default data directory: {e}"))?
.to_string_lossy()
.to_string()
};
args.push("--data".into());
args.push(effective_data_dir);
let config = ProcessConfig { let config = ProcessConfig {
id: "openlist_core".into(), id: "openlist_core".into(),
name: "single_openlist_core_process".into(), name: "single_openlist_core_process".into(),
bin_path: binary_path.to_string_lossy().into_owned(), bin_path: binary_path.to_string_lossy().into_owned(),
args: vec!["server".into()], args,
log_file: log_file_path.to_string_lossy().into_owned(), log_file: log_file_path.to_string_lossy().into_owned(),
working_dir: binary_path working_dir: binary_path
.parent() .parent()

View File

@@ -5,7 +5,11 @@ use tauri::{AppHandle, State};
use crate::cmd::http_api::{get_process_list, start_process, stop_process}; use crate::cmd::http_api::{get_process_list, start_process, stop_process};
use crate::object::structs::{AppState, FileItem}; use crate::object::structs::{AppState, FileItem};
use crate::utils::path::{get_openlist_binary_path, get_rclone_binary_path}; use crate::utils::github_proxy::apply_github_proxy;
use crate::utils::path::{
app_config_file_path, get_app_logs_dir, get_default_openlist_data_dir,
get_openlist_binary_path, get_rclone_binary_path, get_rclone_config_path,
};
fn normalize_path(path: &str) -> String { fn normalize_path(path: &str) -> String {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -60,6 +64,17 @@ pub async fn open_url(url: String) -> Result<bool, String> {
Ok(true) Ok(true)
} }
#[tauri::command]
pub async fn open_url_in_browser(url: String, app_handle: AppHandle) -> Result<bool, String> {
use tauri_plugin_opener::OpenerExt;
app_handle
.opener()
.open_url(url, None::<&str>)
.map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command] #[tauri::command]
pub fn select_directory(title: String, app_handle: AppHandle) -> Result<Option<String>, String> { pub fn select_directory(title: String, app_handle: AppHandle) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt; use tauri_plugin_dialog::DialogExt;
@@ -127,19 +142,37 @@ pub async fn list_files(
} }
#[tauri::command] #[tauri::command]
pub async fn get_available_versions(tool: String) -> Result<Vec<String>, String> { pub async fn get_available_versions(
tool: String,
state: State<'_, AppState>,
) -> Result<Vec<String>, String> {
let url = match tool.as_str() { let url = match tool.as_str() {
"openlist" => "https://api.github.com/repos/OpenListTeam/OpenList/releases", "openlist" => "https://api.github.com/repos/OpenListTeam/OpenList/releases",
"rclone" => "https://api.github.com/repos/rclone/rclone/releases", "rclone" => "https://api.github.com/repos/rclone/rclone/releases",
_ => return Err("Unsupported tool".to_string()), _ => return Err("Unsupported tool".to_string()),
}; };
let gh_proxy = state
.get_settings()
.and_then(|settings| settings.app.gh_proxy.clone());
let gh_proxy_api = state
.get_settings()
.and_then(|settings| settings.app.gh_proxy_api);
let proxied_url = apply_github_proxy(url, &gh_proxy, &gh_proxy_api);
log::info!("Fetching {tool} versions from: {proxied_url}");
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.user_agent("OpenList Desktop/1.0") .user_agent("OpenList Desktop/1.0")
.build() .build()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let response = client.get(url).send().await.map_err(|e| e.to_string())?; let response = client
.get(&proxied_url)
.send()
.await
.map_err(|e| e.to_string())?;
let releases: serde_json::Value = response.json().await.map_err(|e| e.to_string())?; let releases: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
let versions = releases let versions = releases
@@ -162,21 +195,29 @@ pub async fn update_tool_version(
) -> Result<String, String> { ) -> Result<String, String> {
log::info!("Updating {tool} to version {version}"); log::info!("Updating {tool} to version {version}");
let process_list = get_process_list(state.clone()) let process_list_result = get_process_list(state.clone()).await;
.await
.map_err(|e| format!("Failed to get process list: {e}"))?;
let process_name = match tool.as_str() { let (was_running, process_id) = match process_list_result {
"openlist" => "single_openlist_core_process", Ok(process_list) => {
"rclone" => "single_rclone_backend_process", let process_name = match tool.as_str() {
_ => return Err("Unsupported tool".to_string()), "openlist" => "single_openlist_core_process",
"rclone" => "single_rclone_backend_process",
_ => return Err("Unsupported tool".to_string()),
};
let running_process = process_list.iter().find(|p| p.config.name == process_name);
let was_running = running_process.map(|p| p.is_running).unwrap_or(false);
let process_id = running_process.map(|p| p.config.id.clone());
(was_running, process_id)
}
Err(e) => {
log::warn!("Failed to get process list (service may not be installed): {e}");
log::info!("Proceeding with update without stopping processes");
(false, None)
}
}; };
let running_process = process_list.iter().find(|p| p.config.name == process_name);
let was_running = running_process.map(|p| p.is_running).unwrap_or(false);
let process_id = running_process.map(|p| p.config.id.clone());
if was_running && let Some(pid) = &process_id { if was_running && let Some(pid) = &process_id {
log::info!("Stopping {tool} process with ID: {pid}"); log::info!("Stopping {tool} process with ID: {pid}");
match tool.as_str() { match tool.as_str() {
@@ -190,7 +231,15 @@ pub async fn update_tool_version(
log::info!("Successfully stopped {tool} process"); log::info!("Successfully stopped {tool} process");
} }
let result = download_and_replace_binary(&tool, &version).await; let gh_proxy = state
.get_settings()
.and_then(|settings| settings.app.gh_proxy.clone());
let gh_proxy_api = state
.get_settings()
.and_then(|settings| settings.app.gh_proxy_api);
let result = download_and_replace_binary(&tool, &version, &gh_proxy, &gh_proxy_api).await;
match result { match result {
Ok(_) => { Ok(_) => {
@@ -207,6 +256,10 @@ pub async fn update_tool_version(
_ => return Err("Unsupported tool".to_string()), _ => return Err("Unsupported tool".to_string()),
} }
log::info!("Successfully restarted {tool} process"); log::info!("Successfully restarted {tool} process");
} else if process_id.is_none() {
log::info!(
"Update completed successfully. Service is not currently installed or running."
);
} }
Ok(format!("Successfully updated {tool} to {version}")) Ok(format!("Successfully updated {tool} to {version}"))
@@ -231,7 +284,12 @@ pub async fn update_tool_version(
} }
} }
async fn download_and_replace_binary(tool: &str, version: &str) -> Result<(), String> { async fn download_and_replace_binary(
tool: &str,
version: &str,
gh_proxy: &Option<String>,
gh_proxy_api: &Option<bool>,
) -> Result<(), String> {
let platform = std::env::consts::OS; let platform = std::env::consts::OS;
let arch = std::env::consts::ARCH; let arch = std::env::consts::ARCH;
@@ -258,13 +316,13 @@ async fn download_and_replace_binary(tool: &str, version: &str) -> Result<(), St
"openlist" => { "openlist" => {
let path = get_openlist_binary_path() let path = get_openlist_binary_path()
.map_err(|e| format!("Failed to get OpenList binary path: {e}"))?; .map_err(|e| format!("Failed to get OpenList binary path: {e}"))?;
let info = get_openlist_download_info(&platform_arch, version)?; let info = get_openlist_download_info(&platform_arch, version, gh_proxy, gh_proxy_api)?;
(path, info) (path, info)
} }
"rclone" => { "rclone" => {
let path = get_rclone_binary_path() let path = get_rclone_binary_path()
.map_err(|e| format!("Failed to get Rclone binary path: {e}"))?; .map_err(|e| format!("Failed to get Rclone binary path: {e}"))?;
let info = get_rclone_download_info(&platform_arch, version)?; let info = get_rclone_download_info(&platform_arch, version, gh_proxy, gh_proxy_api)?;
(path, info) (path, info)
} }
_ => return Err("Unsupported tool".to_string()), _ => return Err("Unsupported tool".to_string()),
@@ -335,7 +393,12 @@ struct DownloadInfo {
executable_name: String, executable_name: String,
} }
fn get_openlist_download_info(platform_arch: &str, version: &str) -> Result<DownloadInfo, String> { fn get_openlist_download_info(
platform_arch: &str,
version: &str,
gh_proxy: &Option<String>,
gh_proxy_api: &Option<bool>,
) -> Result<DownloadInfo, String> {
let arch_map = get_openlist_arch_mapping(platform_arch)?; let arch_map = get_openlist_arch_mapping(platform_arch)?;
let is_windows = platform_arch.starts_with("win32"); let is_windows = platform_arch.starts_with("win32");
let is_unix = platform_arch.starts_with("darwin") || platform_arch.starts_with("linux"); let is_unix = platform_arch.starts_with("darwin") || platform_arch.starts_with("linux");
@@ -348,15 +411,21 @@ fn get_openlist_download_info(platform_arch: &str, version: &str) -> Result<Down
let download_url = format!( let download_url = format!(
"https://github.com/OpenListTeam/OpenList/releases/download/{version}/{archive_name}" "https://github.com/OpenListTeam/OpenList/releases/download/{version}/{archive_name}"
); );
let proxied_url = apply_github_proxy(&download_url, gh_proxy, gh_proxy_api);
Ok(DownloadInfo { Ok(DownloadInfo {
download_url, download_url: proxied_url,
archive_name, archive_name,
executable_name, executable_name,
}) })
} }
fn get_rclone_download_info(platform_arch: &str, version: &str) -> Result<DownloadInfo, String> { fn get_rclone_download_info(
platform_arch: &str,
version: &str,
gh_proxy: &Option<String>,
gh_proxy_api: &Option<bool>,
) -> Result<DownloadInfo, String> {
let arch_map = get_rclone_arch_mapping(platform_arch)?; let arch_map = get_rclone_arch_mapping(platform_arch)?;
let is_windows = platform_arch.starts_with("win32"); let is_windows = platform_arch.starts_with("win32");
@@ -365,9 +434,10 @@ fn get_rclone_download_info(platform_arch: &str, version: &str) -> Result<Downlo
let executable_name = format!("rclone{exe_ext}"); let executable_name = format!("rclone{exe_ext}");
let download_url = let download_url =
format!("https://github.com/rclone/rclone/releases/download/{version}/{archive_name}"); format!("https://github.com/rclone/rclone/releases/download/{version}/{archive_name}");
let proxied_url = apply_github_proxy(&download_url, gh_proxy, gh_proxy_api);
Ok(DownloadInfo { Ok(DownloadInfo {
download_url, download_url: proxied_url,
archive_name, archive_name,
executable_name, executable_name,
}) })
@@ -548,3 +618,45 @@ fn extract_tar_gz(
executable_path executable_path
.ok_or_else(|| format!("Executable '{executable_name}' not found in tar.gz archive")) .ok_or_else(|| format!("Executable '{executable_name}' not found in tar.gz archive"))
} }
#[tauri::command]
pub async fn open_logs_directory() -> Result<bool, String> {
let logs_dir = get_app_logs_dir()?;
if !logs_dir.exists() {
fs::create_dir_all(&logs_dir)
.map_err(|e| format!("Failed to create logs directory: {e}"))?;
}
open::that(logs_dir.as_os_str()).map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub async fn open_openlist_data_dir() -> Result<bool, String> {
let config_path = get_default_openlist_data_dir()?;
if !config_path.exists() {
fs::create_dir_all(&config_path)
.map_err(|e| format!("Failed to create config directory: {e}"))?;
}
open::that(config_path.as_os_str()).map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub async fn open_rclone_config_file() -> Result<bool, String> {
let config_path = get_rclone_config_path()?;
if !config_path.exists() {
fs::File::create(&config_path).map_err(|e| format!("Failed to create config file: {e}"))?;
}
open::that_detached(config_path.as_os_str()).map_err(|e| e.to_string())?;
Ok(true)
}
#[tauri::command]
pub async fn open_settings_file() -> Result<bool, String> {
let settings_path = app_config_file_path()?;
if !settings_path.exists() {
return Err("Settings file does not exist".to_string());
}
open::that_detached(settings_path.as_os_str()).map_err(|e| e.to_string())?;
Ok(true)
}

View File

@@ -1,15 +1,12 @@
use std::time::Duration; use reqwest;
use sysinfo::System;
use reqwest::{self, Client};
use tauri::State; use tauri::State;
use crate::cmd::http_api::{get_process_list, start_process}; use crate::cmd::http_api::{get_process_list, start_process};
use crate::object::structs::AppState; use crate::object::structs::AppState;
use crate::utils::api::{CreateProcessResponse, ProcessConfig, get_api_key, get_server_port}; use crate::utils::api::{CreateProcessResponse, ProcessConfig, get_api_key, get_server_port};
use crate::utils::path::{get_app_logs_dir, get_rclone_binary_path}; use crate::utils::path::{get_app_logs_dir, get_rclone_binary_path, get_rclone_config_path};
// use 45572 due to the reserved port on Windows
pub const RCLONE_API_BASE: &str = "http://127.0.0.1:45572";
// admin:admin base64 encoded // admin:admin base64 encoded
pub const RCLONE_AUTH: &str = "Basic YWRtaW46YWRtaW4="; pub const RCLONE_AUTH: &str = "Basic YWRtaW46YWRtaW4=";
@@ -34,19 +31,23 @@ pub async fn create_and_start_rclone_backend(
#[tauri::command] #[tauri::command]
pub async fn create_rclone_backend_process( pub async fn create_rclone_backend_process(
_state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<ProcessConfig, String> { ) -> Result<ProcessConfig, String> {
let binary_path = let binary_path =
get_rclone_binary_path().map_err(|e| format!("Failed to get rclone binary path: {e}"))?; get_rclone_binary_path().map_err(|e| format!("Failed to get rclone binary path: {e}"))?;
let log_file_path = let log_file_path =
get_app_logs_dir().map_err(|e| format!("Failed to get app logs directory: {e}"))?; get_app_logs_dir().map_err(|e| format!("Failed to get app logs directory: {e}"))?;
let rclone_conf_path = binary_path let rclone_conf_path =
.parent() get_rclone_config_path().map_err(|e| format!("Failed to get rclone config path: {e}"))?;
.map(|p| p.join("rclone.conf"))
.ok_or_else(|| "Failed to determine rclone.conf path".to_string())?;
let log_file_path = log_file_path.join("process_rclone.log"); let log_file_path = log_file_path.join("process_rclone.log");
let api_key = get_api_key(); let api_key = get_api_key();
let port = get_server_port(); let port = get_server_port();
let rclone_port = state
.get_settings()
.map(|settings| settings.rclone.api_port)
.unwrap_or(45572);
let config = ProcessConfig { let config = ProcessConfig {
id: "rclone_backend".into(), id: "rclone_backend".into(),
name: "single_rclone_backend_process".into(), name: "single_rclone_backend_process".into(),
@@ -60,7 +61,7 @@ pub async fn create_rclone_backend_process(
"--rc-pass".into(), "--rc-pass".into(),
"admin".into(), "admin".into(),
"--rc-addr".into(), "--rc-addr".into(),
format!("127.0.0.1:45572"), format!("127.0.0.1:{}", rclone_port),
"--rc-web-gui-no-open-browser".into(), "--rc-web-gui-no-open-browser".into(),
], ],
log_file: log_file_path.to_string_lossy().into_owned(), log_file: log_file_path.to_string_lossy().into_owned(),
@@ -110,13 +111,19 @@ pub async fn get_rclone_backend_status(_state: State<'_, AppState>) -> Result<bo
} }
async fn is_rclone_running() -> bool { async fn is_rclone_running() -> bool {
let client = Client::new(); let mut system = System::new_all();
system.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let response = client for process in system.processes().values() {
.get(format!("{RCLONE_API_BASE}/")) let process_name = process.name().to_string_lossy().to_lowercase();
.timeout(Duration::from_secs(1))
.send()
.await;
response.is_ok() if process_name.contains("rclone") {
let cmd_args = process.cmd();
if cmd_args.iter().any(|arg| arg == "rcd") {
return true;
}
}
}
false
} }

View File

@@ -2,114 +2,127 @@ use std::fs;
use std::path::Path; use std::path::Path;
use reqwest::Client; use reqwest::Client;
use serde_json::json; use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use tauri::State; use tauri::State;
use super::http_api::get_process_list; use super::http_api::get_process_list;
use super::rclone_core::{RCLONE_API_BASE, RCLONE_AUTH}; use super::rclone_core::RCLONE_AUTH;
use crate::conf::rclone::{RcloneCreateRemoteRequest, RcloneMountRequest, RcloneWebdavConfig}; use crate::conf::rclone::{RcloneCreateRemoteRequest, RcloneMountRequest, RcloneWebdavConfig};
use crate::object::structs::{ use crate::object::structs::{
AppState, RcloneMountInfo, RcloneMountListResponse, RcloneRemoteListResponse, AppState, RcloneMountInfo, RcloneMountListResponse, RcloneRemoteListResponse,
}; };
use crate::utils::api::{CreateProcessResponse, ProcessConfig, get_api_key, get_server_port}; use crate::utils::api::{CreateProcessResponse, ProcessConfig, get_api_key, get_server_port};
use crate::utils::path::{get_app_logs_dir, get_rclone_binary_path}; use crate::utils::args::split_args_vec;
use crate::utils::path::{get_app_logs_dir, get_rclone_binary_path, get_rclone_config_path};
fn get_rclone_api_base_url(state: &State<AppState>) -> String {
let port = state
.get_settings()
.map(|settings| settings.rclone.api_port)
.unwrap_or(45572);
format!("http://127.0.0.1:{}", port)
}
struct RcloneApi {
client: Client,
api_base: String,
}
impl RcloneApi {
fn new(api_base: String) -> Self {
Self {
client: Client::new(),
api_base,
}
}
async fn post_json<T: DeserializeOwned>(
&self,
endpoint: &str,
body: Option<Value>,
) -> Result<T, String> {
let url = format!("{}/{endpoint}", self.api_base);
let mut req = self.client.post(&url).header("Authorization", RCLONE_AUTH);
if let Some(b) = body {
req = req.json(&b).header("Content-Type", "application/json");
}
let resp = req
.send()
.await
.map_err(|e| format!("Request failed: {e}"))?;
let status = resp.status();
if status.is_success() {
resp.json::<T>()
.await
.map_err(|e| format!("Failed to parse JSON: {e}"))
} else {
let txt = resp.text().await.unwrap_or_default();
Err(format!("API error {status}: {txt}"))
}
}
async fn post_text(&self, endpoint: &str) -> Result<String, String> {
let url = format!("{}/{endpoint}", self.api_base);
let resp = self
.client
.post(&url)
.header("Authorization", RCLONE_AUTH)
.send()
.await
.map_err(|e| format!("Request failed: {e}"))?;
let status = resp.status();
if status.is_success() {
resp.text()
.await
.map_err(|e| format!("Failed to read text: {e}"))
} else {
let txt = resp.text().await.unwrap_or_default();
Err(format!("API error {status}: {txt}"))
}
}
}
#[tauri::command] #[tauri::command]
pub async fn rclone_list_config( pub async fn rclone_list_config(
remote_type: String, remote_type: String,
_state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<serde_json::Value, String> { ) -> Result<Value, String> {
let client = Client::new(); let api = RcloneApi::new(get_rclone_api_base_url(&state));
let response = client let text = api.post_text("config/dump").await?;
.post(format!("{RCLONE_API_BASE}/config/dump")) let all: Value = serde_json::from_str(&text).map_err(|e| format!("Invalid JSON: {e}"))?;
.header("Authorization", RCLONE_AUTH) let remotes = match (remote_type.as_str(), all.as_object()) {
.send() ("", _) => all.clone(),
.await (t, Some(map)) => {
.map_err(|e| format!("Failed to send request: {e}"))?; let filtered = map
if response.status().is_success() { .iter()
let response_text = response .filter_map(|(name, cfg)| {
.text() cfg.get("type")
.await .and_then(Value::as_str)
.map_err(|e| format!("Failed to read response text: {e}"))?; .filter(|&ty| ty == t)
let json: serde_json::Value = serde_json::from_str(&response_text) .map(|_| (name.clone(), cfg.clone()))
.map_err(|e| format!("Failed to parse JSON: {e}"))?; })
let remotes = if remote_type.is_empty() { .collect();
json.clone() Value::Object(filtered)
} else if let Some(obj) = json.as_object() { }
let mut filtered_map = serde_json::Map::new(); _ => Value::Object(Default::default()),
for (remote_name, remote_config) in obj { };
if let Some(config_obj) = remote_config.as_object() Ok(remotes)
&& let Some(remote_type_value) = config_obj.get("type")
&& let Some(type_str) = remote_type_value.as_str()
&& type_str == remote_type
{
filtered_map.insert(remote_name.clone(), remote_config.clone());
}
}
serde_json::Value::Object(filtered_map)
} else {
serde_json::Value::Object(serde_json::Map::new())
};
Ok(remotes)
} else {
Err(format!(
"Failed to list Rclone config: {}",
response.status()
))
}
} }
#[tauri::command] #[tauri::command]
pub async fn rclone_list_remotes() -> Result<Vec<String>, String> { pub async fn rclone_list_remotes(state: State<'_, AppState>) -> Result<Vec<String>, String> {
let client = Client::new(); let api = RcloneApi::new(get_rclone_api_base_url(&state));
let resp: RcloneRemoteListResponse = api.post_json("config/listremotes", None).await?;
let response = client Ok(resp.remotes)
.post(format!("{RCLONE_API_BASE}/config/listremotes"))
.header("Authorization", RCLONE_AUTH)
.send()
.await
.map_err(|e| format!("Failed to list remotes: {e}"))?;
if response.status().is_success() {
let remote_list: RcloneRemoteListResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse remote list response: {e}"))?;
Ok(remote_list.remotes)
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
Err(format!("Failed to list remotes: {error_text}"))
}
} }
#[tauri::command] #[tauri::command]
pub async fn rclone_list_mounts() -> Result<RcloneMountListResponse, String> { pub async fn rclone_list_mounts(
let client = Client::new(); state: State<'_, AppState>,
) -> Result<RcloneMountListResponse, String> {
let response = client let api = RcloneApi::new(get_rclone_api_base_url(&state));
.post(format!("{RCLONE_API_BASE}/mount/listmounts")) api.post_json("mount/listmounts", None).await
.header("Authorization", RCLONE_AUTH)
.send()
.await
.map_err(|e| format!("Failed to list mounts: {e}"))?;
if response.status().is_success() {
let mount_list: RcloneMountListResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse mount list response: {e}"))?;
Ok(mount_list)
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
Err(format!("Failed to list mounts: {error_text}"))
}
} }
#[tauri::command] #[tauri::command]
@@ -117,39 +130,17 @@ pub async fn rclone_create_remote(
name: String, name: String,
r#type: String, r#type: String,
config: RcloneWebdavConfig, config: RcloneWebdavConfig,
_state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let client = Client::new(); let api = RcloneApi::new(get_rclone_api_base_url(&state));
let req = RcloneCreateRemoteRequest {
let create_request = RcloneCreateRemoteRequest { name,
name: name.clone(), r#type,
r#type: r#type.clone(), parameters: config,
parameters: crate::conf::rclone::RcloneWebdavConfig {
url: config.url.clone(),
vendor: config.vendor.clone(),
user: config.user.clone(),
pass: config.pass.clone(),
},
}; };
api.post_json::<Value>("config/create", Some(json!(req)))
let response = client
.post(format!("{RCLONE_API_BASE}/config/create"))
.header("Authorization", RCLONE_AUTH)
.header("Content-Type", "application/json")
.json(&create_request)
.send()
.await .await
.map_err(|e| format!("Failed to create remote config: {e}"))?; .map(|_| true)
if response.status().is_success() {
Ok(true)
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
Err(format!("Failed to create remote config: {error_text}"))
}
} }
#[tauri::command] #[tauri::command]
@@ -157,109 +148,47 @@ pub async fn rclone_update_remote(
name: String, name: String,
r#type: String, r#type: String,
config: RcloneWebdavConfig, config: RcloneWebdavConfig,
_state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let client = Client::new(); let api = RcloneApi::new(get_rclone_api_base_url(&state));
let body = json!({ "name": name, "type": r#type, "parameters": config });
let response = client api.post_json::<Value>("config/update", Some(body))
.post(format!("{RCLONE_API_BASE}/config/update"))
.header("Authorization", RCLONE_AUTH)
.header("Content-Type", "application/json")
.json(&json!({ "name": name, "type": r#type, "parameters": config }))
.send()
.await .await
.map_err(|e| format!("Failed to update remote config: {e}"))?; .map(|_| true)
if response.status().is_success() {
Ok(true)
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
Err(format!("Failed to update remote config: {error_text}"))
}
} }
#[tauri::command] #[tauri::command]
pub async fn rclone_delete_remote( pub async fn rclone_delete_remote(
name: String, name: String,
_state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let client = Client::new(); let api = RcloneApi::new(get_rclone_api_base_url(&state));
let body = json!({ "name": name });
let response = client api.post_json::<Value>("config/delete", Some(body))
.post(format!("{RCLONE_API_BASE}/config/delete"))
.header("Authorization", RCLONE_AUTH)
.header("Content-Type", "application/json")
.json(&json!({ "name": name }))
.send()
.await .await
.map_err(|e| format!("Failed to delete remote config: {e}"))?; .map(|_| true)
if response.status().is_success() {
Ok(true)
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
Err(format!("Failed to delete remote config: {error_text}"))
}
} }
#[tauri::command] #[tauri::command]
pub async fn rclone_mount_remote( pub async fn rclone_mount_remote(
mount_request: RcloneMountRequest, mount_request: RcloneMountRequest,
_state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let client = Client::new(); let api = RcloneApi::new(get_rclone_api_base_url(&state));
api.post_json::<Value>("mount/mount", Some(json!(mount_request)))
let response = client
.post(format!("{RCLONE_API_BASE}/mount/mount"))
.header("Authorization", RCLONE_AUTH)
.header("Content-Type", "application/json")
.json(&mount_request)
.send()
.await .await
.map_err(|e| format!("Failed to mount remote: {e}"))?; .map(|_| true)
if response.status().is_success() {
Ok(true)
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
Err(format!("Failed to mount remote: {error_text}"))
}
} }
#[tauri::command] #[tauri::command]
pub async fn rclone_unmount_remote( pub async fn rclone_unmount_remote(
mount_point: String, mount_point: String,
_state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let client = Client::new(); let api = RcloneApi::new(get_rclone_api_base_url(&state));
api.post_json::<Value>("mount/unmount", Some(json!({ "mountPoint": mount_point })))
let response = client
.post(format!("{RCLONE_API_BASE}/mount/unmount"))
.header("Authorization", RCLONE_AUTH)
.header("Content-Type", "application/json")
.json(&json!({ "mountPoint": mount_point }))
.send()
.await .await
.map_err(|e| format!("Failed to unmount remote: {e}"))?; .map(|_| true)
if response.status().is_success() {
Ok(true)
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
Err(format!("Failed to unmount remote: {error_text}"))
}
} }
#[tauri::command] #[tauri::command]
@@ -272,10 +201,25 @@ pub async fn create_rclone_mount_remote_process(
let log_file_path = let log_file_path =
get_app_logs_dir().map_err(|e| format!("Failed to get app logs directory: {e}"))?; get_app_logs_dir().map_err(|e| format!("Failed to get app logs directory: {e}"))?;
let log_file_path = log_file_path.join("process_rclone.log"); let log_file_path = log_file_path.join("process_rclone.log");
let rclone_conf_path = binary_path let rclone_conf_path =
.parent() get_rclone_config_path().map_err(|e| format!("Failed to get rclone config path: {e}"))?;
.map(|p| p.join("rclone.conf"))
.ok_or_else(|| "Failed to determine rclone.conf path".to_string())?; // Extract mount point from args and create directory if it doesn't exist.
// The mount point is the second non-flag argument (first is remote:path).
let args_vec = split_args_vec(config.args.clone());
let mount_point_opt = args_vec.iter().filter(|arg| !arg.starts_with('-')).nth(1); // 0th is remote:path, 1st is mount_point
if let Some(mount_point) = mount_point_opt {
let mount_path = Path::new(mount_point);
if !mount_path.exists()
&& let Err(e) = fs::create_dir_all(mount_path)
{
return Err(format!(
"Failed to create mount point directory '{}': {}",
mount_point, e
));
}
}
let api_key = get_api_key(); let api_key = get_api_key();
let port = get_server_port(); let port = get_server_port();
@@ -284,7 +228,7 @@ pub async fn create_rclone_mount_remote_process(
"--config".into(), "--config".into(),
rclone_conf_path.to_string_lossy().into_owned(), rclone_conf_path.to_string_lossy().into_owned(),
]; ];
args.extend(config.args.clone()); args.extend(args_vec);
let config = ProcessConfig { let config = ProcessConfig {
id: config.id.clone(), id: config.id.clone(),
@@ -384,9 +328,10 @@ pub async fn get_mount_info_list(
Ok(is_mounted) => { Ok(is_mounted) => {
if process.is_running { if process.is_running {
if is_mounted { "mounted" } else { "mounting" } if is_mounted { "mounted" } else { "mounting" }
} else if is_mounted {
"unmounting"
} else { } else {
// If process is not running, the mount point should be considered
// unmounted regardless of whether
// the directory exists or not
"unmounted" "unmounted"
} }
} }

View File

@@ -3,16 +3,28 @@ use serde::{Deserialize, Serialize};
#[derive(Default, Debug, Serialize, Deserialize, Clone)] #[derive(Default, Debug, Serialize, Deserialize, Clone)]
pub struct AppConfig { pub struct AppConfig {
pub theme: Option<String>, pub theme: Option<String>,
pub monitor_interval: Option<u64>,
pub auto_update_enabled: Option<bool>, pub auto_update_enabled: Option<bool>,
pub gh_proxy: Option<String>,
pub gh_proxy_api: Option<bool>,
pub open_links_in_browser: Option<bool>,
pub admin_password: Option<String>,
pub show_window_on_startup: Option<bool>,
pub log_filter_level: Option<String>,
pub log_filter_source: Option<String>,
} }
impl AppConfig { impl AppConfig {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
theme: Some("light".to_string()), theme: Some("light".to_string()),
monitor_interval: Some(5),
auto_update_enabled: Some(true), auto_update_enabled: Some(true),
gh_proxy: None,
gh_proxy_api: Some(false),
open_links_in_browser: Some(false),
admin_password: None,
show_window_on_startup: Some(true),
log_filter_level: Some("all".to_string()),
log_filter_source: Some("openlist".to_string()),
} }
} }
} }

View File

@@ -5,12 +5,7 @@ use serde::{Deserialize, Serialize};
use super::app::AppConfig; use super::app::AppConfig;
use crate::conf::core::OpenListCoreConfig; use crate::conf::core::OpenListCoreConfig;
use crate::conf::rclone::RcloneConfig; use crate::conf::rclone::RcloneConfig;
use crate::utils::path::app_config_file_path; use crate::utils::path::{app_config_file_path, get_default_openlist_data_dir};
#[allow(unused)]
pub static OPENLIST_CORE_CONFIG: &str = "data/config.json";
#[allow(unused)]
pub static OPENLIST_DESKTOP_SETTINGS_FILE_NAME: &str = "settings.json";
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MergedSettings { pub struct MergedSettings {
@@ -19,6 +14,12 @@ pub struct MergedSettings {
pub app: AppConfig, pub app: AppConfig,
} }
impl Default for MergedSettings {
fn default() -> Self {
Self::new()
}
}
impl MergedSettings { impl MergedSettings {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -28,69 +29,66 @@ impl MergedSettings {
} }
} }
pub fn get_data_config_path() -> Result<PathBuf, String> { pub fn get_data_config_path_for_dir(data_dir: Option<&str>) -> Result<PathBuf, String> {
let app_dir = std::env::current_exe() if let Some(dir) = data_dir.filter(|d| !d.is_empty()) {
.map_err(|e| format!("Failed to get current exe path: {e}"))? Ok(PathBuf::from(dir).join("config.json"))
.parent() } else {
.ok_or("Failed to get parent directory")? Ok(get_default_openlist_data_dir()?.join("config.json"))
.to_path_buf(); }
Ok(app_dir.join("data").join("config.json"))
} }
pub fn read_data_config() -> Result<serde_json::Value, String> { pub fn read_data_config_for_dir(data_dir: Option<&str>) -> Result<serde_json::Value, String> {
let path = Self::get_data_config_path()?; let path = Self::get_data_config_path_for_dir(data_dir)?;
if !path.exists() {
return Err("data/config.json does not exist".to_string());
}
let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?; let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
serde_json::from_str(&content).map_err(|e| e.to_string()) serde_json::from_str(&content).map_err(|e| e.to_string())
} }
fn get_port_from_data_config() -> Result<Option<u16>, String> { fn get_port_from_data_config_for_dir(data_dir: Option<&str>) -> Result<Option<u16>, String> {
let config = Self::read_data_config()?; let config = Self::read_data_config_for_dir(data_dir)?;
Ok(config Ok(config
.get("scheme") .get("scheme")
.and_then(|scheme| scheme.get("http_port")) .and_then(|s| s.get("http_port"))
.and_then(|port| port.as_u64()) .and_then(|p| p.as_u64())
.map(|port| port as u16)) .map(|p| p as u16))
} }
pub fn save(&self) -> Result<(), String> { pub fn save(&self) -> Result<(), String> {
let path = app_config_file_path().map_err(|e| e.to_string())?; let path = app_config_file_path().map_err(|e| e.to_string())?;
std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?; if let Some(dir) = path.parent() {
let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
std::fs::write(&path, json).map_err(|e| e.to_string())?; }
Ok(()) let file = std::fs::File::create(&path).map_err(|e| e.to_string())?;
serde_json::to_writer_pretty(file, &self).map_err(|e| e.to_string())
} }
pub fn load() -> Result<Self, String> { pub fn load() -> Result<Self, String> {
let path = app_config_file_path().map_err(|e| e.to_string())?; let path = app_config_file_path().map_err(|e| e.to_string())?;
let mut merged_settings = if !path.exists() {
std::fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?; let mut settings = if path.exists() {
let new_settings = Self::new(); let data = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
std::fs::write( serde_json::from_str(&data).map_err(|e| e.to_string())?
&path,
serde_json::to_string_pretty(&new_settings).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
new_settings
} else { } else {
let config = std::fs::read_to_string(path).map_err(|e| e.to_string())?; let default = Self::new();
serde_json::from_str(&config).map_err(|e| e.to_string())? if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
default.save()?;
default
}; };
if let Ok(Some(port)) = Self::get_port_from_data_config() let data_dir = if settings.openlist.data_dir.is_empty() {
&& merged_settings.openlist.port != port None
} else {
Some(settings.openlist.data_dir.as_str())
};
if let Ok(Some(port)) = Self::get_port_from_data_config_for_dir(data_dir)
&& settings.openlist.port != port
{ {
merged_settings.openlist.port = port; settings.openlist.port = port;
merged_settings.save()?; settings.save()?;
} }
Ok(merged_settings) Ok(settings)
}
pub fn default() -> Self {
Self::new()
} }
} }

View File

@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OpenListCoreConfig { pub struct OpenListCoreConfig {
pub port: u16, pub port: u16,
pub api_token: String, pub data_dir: String,
pub auto_launch: bool, pub auto_launch: bool,
pub ssl_enabled: bool, pub ssl_enabled: bool,
} }
@@ -12,7 +12,7 @@ impl OpenListCoreConfig {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
port: 5244, port: 5244,
api_token: "".to_string(), data_dir: "".to_string(),
auto_launch: false, auto_launch: false,
ssl_enabled: false, ssl_enabled: false,
} }

View File

@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RcloneConfig { pub struct RcloneConfig {
pub config: serde_json::Value, pub config: serde_json::Value,
pub api_port: u16,
} }
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
@@ -22,14 +23,6 @@ pub struct RcloneCreateRemoteRequest {
pub parameters: RcloneWebdavConfig, pub parameters: RcloneWebdavConfig,
} }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RcloneRemoteParameters {
pub url: String,
pub vendor: Option<String>,
pub user: String,
pub pass: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RcloneMountRequest { pub struct RcloneMountRequest {
pub fs: String, pub fs: String,
@@ -49,17 +42,11 @@ pub struct RcloneMountOptions {
pub volume_name: Option<String>, pub volume_name: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize)]
pub struct RcloneApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub error: Option<String>,
}
impl RcloneConfig { impl RcloneConfig {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
config: serde_json::Value::Object(Default::default()), config: serde_json::Value::Object(Default::default()),
api_port: 45572,
} }
} }
} }

View File

@@ -732,28 +732,27 @@ pub async fn start_service() -> Result<bool, Box<dyn std::error::Error>> {
if let Some(pid_value) = extract_plist_value(&output_str, "PID") { if let Some(pid_value) = extract_plist_value(&output_str, "PID") {
log::info!("Extracted PID value: {pid_value}"); log::info!("Extracted PID value: {pid_value}");
if let Ok(pid) = pid_value.parse::<i32>() { if let Ok(pid) = pid_value.parse::<i32>()
if pid > 0 { && pid > 0
log::info!("Service is running with PID: {pid}"); {
return Ok(true); log::info!("Service is running with PID: {pid}");
} return Ok(true);
} }
} }
if let Some(exit_status) = extract_plist_value(&output_str, "LastExitStatus") { if let Some(exit_status) = extract_plist_value(&output_str, "LastExitStatus")
if let Ok(status) = exit_status.parse::<i32>() { && let Ok(status) = exit_status.parse::<i32>()
if status == 0 { {
log::info!( if status == 0 {
"Service is loaded but not running (clean exit), attempting to \ log::info!(
start" "Service is loaded but not running (clean exit), attempting to start"
); );
return start_macos_service(SERVICE_IDENTIFIER).await; return start_macos_service(SERVICE_IDENTIFIER).await;
} else { } else {
log::warn!( log::warn!(
"Service has non-zero exit status: {status}, attempting to restart" "Service has non-zero exit status: {status}, attempting to restart"
); );
return start_macos_service(SERVICE_IDENTIFIER).await; return start_macos_service(SERVICE_IDENTIFIER).await;
}
} }
} }
@@ -795,23 +794,23 @@ pub async fn check_service_status() -> Result<String, Box<dyn std::error::Error>
if let Some(pid_value) = extract_plist_value(&output_str, "PID") { if let Some(pid_value) = extract_plist_value(&output_str, "PID") {
log::info!("Extracted PID value: {pid_value}"); log::info!("Extracted PID value: {pid_value}");
if let Ok(pid) = pid_value.parse::<i32>() { if let Ok(pid) = pid_value.parse::<i32>()
if pid > 0 { && pid > 0
log::info!("Service is running with PID: {pid}"); {
return Ok("running".to_string()); log::info!("Service is running with PID: {pid}");
} return Ok("running".to_string());
} }
} }
if let Some(exit_status) = extract_plist_value(&output_str, "LastExitStatus") { if let Some(exit_status) = extract_plist_value(&output_str, "LastExitStatus")
if let Ok(status) = exit_status.parse::<i32>() { && let Ok(status) = exit_status.parse::<i32>()
if status == 0 { {
log::info!("Service is loaded but not running (clean exit)"); if status == 0 {
return Ok("stopped".to_string()); log::info!("Service is loaded but not running (clean exit)");
} else { return Ok("stopped".to_string());
log::warn!("Service has non-zero exit status: {status}"); } else {
return Ok("stopped".to_string()); log::warn!("Service has non-zero exit status: {status}");
} return Ok("stopped".to_string());
} }
} }
@@ -855,15 +854,15 @@ async fn start_macos_service(service_identifier: &str) -> Result<bool, Box<dyn s
let output_str = String::from_utf8_lossy(&verify_output.stdout); let output_str = String::from_utf8_lossy(&verify_output.stdout);
log::info!("Verification output: {output_str}"); log::info!("Verification output: {output_str}");
if let Some(pid_value) = extract_plist_value(&output_str, "PID") { if let Some(pid_value) = extract_plist_value(&output_str, "PID")
if let Ok(pid) = pid_value.parse::<i32>() { && let Ok(pid) = pid_value.parse::<i32>()
if pid > 0 { {
log::info!("Service verified as running with PID: {pid}"); if pid > 0 {
return Ok(true); log::info!("Service verified as running with PID: {pid}");
} else { return Ok(true);
log::warn!("Service has invalid PID: {pid}"); } else {
return Ok(false); log::warn!("Service has invalid PID: {pid}");
} return Ok(false);
} }
} }
@@ -887,19 +886,19 @@ fn extract_plist_value(plist_output: &str, key: &str) -> Option<String> {
for line in plist_output.lines() { for line in plist_output.lines() {
let trimmed = line.trim(); let trimmed = line.trim();
if trimmed.starts_with(&pattern) { if trimmed.starts_with(&pattern)
if let Some(equals_pos) = trimmed.find('=') { && let Some(equals_pos) = trimmed.find('=')
let value_part = &trimmed[equals_pos + 1..]; {
let value_trimmed = value_part.trim(); let value_part = &trimmed[equals_pos + 1..];
let value_trimmed = value_part.trim();
let value_clean = if let Some(stripped) = value_trimmed.strip_suffix(';') { let value_clean = if let Some(stripped) = value_trimmed.strip_suffix(';') {
stripped stripped
} else { } else {
value_trimmed value_trimmed
}; };
return Some(value_clean.trim().to_string()); return Some(value_clean.trim().to_string());
}
} }
} }

View File

@@ -11,16 +11,20 @@ use cmd::binary::get_binary_version;
use cmd::config::{load_settings, reset_settings, save_settings, save_settings_with_update_port}; use cmd::config::{load_settings, reset_settings, save_settings, save_settings_with_update_port};
use cmd::custom_updater::{ use cmd::custom_updater::{
check_for_updates, download_update, get_current_version, install_update_and_restart, check_for_updates, download_update, get_current_version, install_update_and_restart,
is_auto_check_enabled, restart_app, set_auto_check_enabled, is_auto_check_enabled, set_auto_check_enabled,
}; };
use cmd::firewall::{add_firewall_rule, check_firewall_rule, remove_firewall_rule};
use cmd::http_api::{ use cmd::http_api::{
delete_process, get_process_list, restart_process, start_process, stop_process, update_process, delete_process, get_process_list, restart_process, start_process, stop_process, update_process,
}; };
use cmd::logs::{clear_logs, get_admin_password, get_logs}; use cmd::logs::{
clear_logs, get_admin_password, get_logs, reset_admin_password, set_admin_password,
};
use cmd::openlist_core::{create_openlist_core_process, get_openlist_core_status}; use cmd::openlist_core::{create_openlist_core_process, get_openlist_core_status};
use cmd::os_operate::{ use cmd::os_operate::{
get_available_versions, list_files, open_file, open_folder, open_url, select_directory, get_available_versions, list_files, open_file, open_folder, open_logs_directory,
update_tool_version, open_openlist_data_dir, open_rclone_config_file, open_settings_file, open_url,
open_url_in_browser, select_directory, update_tool_version,
}; };
use cmd::rclone_core::{ use cmd::rclone_core::{
create_and_start_rclone_backend, create_rclone_backend_process, get_rclone_backend_status, create_and_start_rclone_backend, create_rclone_backend_process, get_rclone_backend_status,
@@ -65,7 +69,7 @@ async fn force_update_tray_menu(
fn setup_background_update_checker(app_handle: &tauri::AppHandle) { fn setup_background_update_checker(app_handle: &tauri::AppHandle) {
let app_handle_initial = app_handle.clone(); let app_handle_initial = app_handle.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(10)).await; tokio::time::sleep(std::time::Duration::from_secs(300)).await;
let app_state = app_handle_initial.state::<AppState>(); let app_state = app_handle_initial.state::<AppState>();
match is_auto_check_enabled(app_state).await { match is_auto_check_enabled(app_state).await {
@@ -138,7 +142,12 @@ pub fn run() {
list_files, list_files,
open_file, open_file,
open_folder, open_folder,
open_logs_directory,
open_openlist_data_dir,
open_rclone_config_file,
open_settings_file,
open_url, open_url,
open_url_in_browser,
save_settings, save_settings,
save_settings_with_update_port, save_settings_with_update_port,
load_settings, load_settings,
@@ -146,6 +155,8 @@ pub fn run() {
get_logs, get_logs,
clear_logs, clear_logs,
get_admin_password, get_admin_password,
reset_admin_password,
set_admin_password,
get_binary_version, get_binary_version,
select_directory, select_directory,
get_available_versions, get_available_versions,
@@ -158,13 +169,15 @@ pub fn run() {
check_service_status, check_service_status,
stop_service, stop_service,
start_service, start_service,
check_firewall_rule,
add_firewall_rule,
remove_firewall_rule,
check_for_updates, check_for_updates,
download_update, download_update,
install_update_and_restart, install_update_and_restart,
get_current_version, get_current_version,
set_auto_check_enabled, set_auto_check_enabled,
is_auto_check_enabled, is_auto_check_enabled
restart_app,
]) ])
.setup(|app| { .setup(|app| {
let app_handle = app.app_handle(); let app_handle = app.app_handle();
@@ -172,6 +185,9 @@ pub fn run() {
utils::path::get_app_logs_dir()?; utils::path::get_app_logs_dir()?;
utils::init_log::init_log()?; utils::init_log::init_log()?;
utils::path::get_app_config_dir()?; utils::path::get_app_config_dir()?;
let settings = conf::config::MergedSettings::load().unwrap_or_default();
let show_window = settings.app.show_window_on_startup.unwrap_or(true);
let app_state = app.state::<AppState>(); let app_state = app.state::<AppState>();
if let Err(e) = app_state.init(app_handle) { if let Err(e) = app_state.init(app_handle) {
log::error!("Failed to initialize app state: {e}"); log::error!("Failed to initialize app state: {e}");
@@ -188,6 +204,13 @@ pub fn run() {
setup_background_update_checker(app_handle); setup_background_update_checker(app_handle);
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
if show_window {
let _ = window.show();
log::info!("Main window shown on startup based on user preference");
} else {
log::info!("Main window hidden on startup based on user preference");
}
let app_handle_clone = app_handle.clone(); let app_handle_clone = app_handle.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { if let tauri::WindowEvent::CloseRequested { api, .. } = event {

View File

@@ -31,13 +31,6 @@ pub struct RcloneMountInfo {
pub status: String, pub status: String,
} }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TransferStats {
pub read: u64,
pub write: u64,
pub errors: u32,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct RcloneRemoteListResponse { pub struct RcloneRemoteListResponse {
pub remotes: Vec<String>, pub remotes: Vec<String>,

View File

@@ -6,7 +6,7 @@ use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
static LAST_MENU_UPDATE: Mutex<Option<Instant>> = Mutex::new(None); static LAST_MENU_UPDATE: Mutex<Option<Instant>> = Mutex::new(None);
const MIN_UPDATE_INTERVAL: Duration = Duration::from_millis(5000); const MIN_UPDATE_INTERVAL: Duration = Duration::from_millis(30000);
pub fn create_tray(app_handle: &AppHandle) -> tauri::Result<()> { pub fn create_tray(app_handle: &AppHandle) -> tauri::Result<()> {
let quit_i = MenuItem::with_id(app_handle, "quit", "退出", true, None::<&str>)?; let quit_i = MenuItem::with_id(app_handle, "quit", "退出", true, None::<&str>)?;

View File

@@ -0,0 +1,67 @@
pub fn split_args(input: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current_arg = String::new();
let mut in_quotes = false;
let mut quote_char = '"';
let mut escape_next = false;
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if escape_next {
current_arg.push(ch);
escape_next = false;
continue;
}
match ch {
'\\' => {
if let Some(&next_ch) = chars.peek() {
if next_ch == '"' || next_ch == '\'' || next_ch == '\\' {
escape_next = true;
} else {
current_arg.push(ch);
}
} else {
current_arg.push(ch);
}
}
'"' | '\'' if !in_quotes => {
in_quotes = true;
quote_char = ch;
}
ch if in_quotes && ch == quote_char => {
in_quotes = false;
}
' ' | '\t' if !in_quotes => {
if !current_arg.is_empty() {
args.push(current_arg.clone());
current_arg.clear();
}
while let Some(&next_ch) = chars.peek() {
if next_ch == ' ' || next_ch == '\t' {
chars.next();
} else {
break;
}
}
}
_ => {
current_arg.push(ch);
}
}
}
if !current_arg.is_empty() {
args.push(current_arg);
}
args
}
pub fn split_args_vec(args: Vec<String>) -> Vec<String> {
let mut result = Vec::new();
for arg in args {
result.extend(split_args(&arg));
}
result
}

View File

@@ -0,0 +1,26 @@
pub fn apply_github_proxy(
url: &str,
gh_proxy: &Option<String>,
gh_proxy_api: &Option<bool>,
) -> String {
if let Some(proxy) = gh_proxy
&& !proxy.is_empty()
&& should_proxy_url(url, gh_proxy_api)
{
let proxy_clean = proxy.trim_end_matches('/');
return format!("{proxy_clean}/{url}");
}
url.to_string()
}
fn should_proxy_url(url: &str, gh_proxy_api: &Option<bool>) -> bool {
if url.starts_with("https://github.com/") {
return true;
}
if url.starts_with("https://api.github.com/") {
return gh_proxy_api.unwrap_or(false);
}
false
}

View File

@@ -1,3 +1,5 @@
pub mod api; pub mod api;
pub mod args;
pub mod github_proxy;
pub mod init_log; pub mod init_log;
pub mod path; pub mod path;

View File

@@ -1,8 +1,33 @@
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::{env, fs}; use std::{env, fs};
pub static APP_ID: &str = "io.github.openlistteam.openlist.desktop"; pub static APP_ID: &str = "io.github.openlistteam.openlist.desktop";
// Normalize path without Windows long path prefix (\\?\)
// The \\?\ prefix breaks compatibility with some applications like SQLite
fn normalize_path(path: &Path) -> Result<PathBuf, String> {
#[cfg(target_os = "windows")]
{
// On Windows, use canonicalize but strip the \\?\ prefix if present
let canonical = path
.canonicalize()
.map_err(|e| format!("Failed to canonicalize path: {e}"))?;
let path_str = canonical.to_string_lossy();
if let Some(stripped) = path_str.strip_prefix(r"\\?\") {
Ok(PathBuf::from(stripped))
} else {
Ok(canonical)
}
}
#[cfg(not(target_os = "windows"))]
{
path.canonicalize()
.map_err(|e| format!("Failed to canonicalize path: {e}"))
}
}
fn get_app_dir() -> Result<PathBuf, String> { fn get_app_dir() -> Result<PathBuf, String> {
let app_dir = env::current_exe() let app_dir = env::current_exe()
.map_err(|e| format!("Failed to get current exe path: {e}"))? .map_err(|e| format!("Failed to get current exe path: {e}"))?
@@ -16,48 +41,85 @@ fn get_app_dir() -> Result<PathBuf, String> {
Ok(app_dir) Ok(app_dir)
} }
pub fn get_openlist_binary_path() -> Result<PathBuf, String> { fn get_user_data_dir() -> Result<PathBuf, String> {
let app_dir = get_app_dir()?; let data_dir = {
#[cfg(target_os = "macos")]
{
let home = env::var("HOME").map_err(|_| "Failed to get HOME environment variable")?;
PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("OpenList Desktop")
}
let binary_name = if cfg!(target_os = "windows") { #[cfg(target_os = "linux")]
"openlist.exe" {
} else { let home = env::var("HOME").map_err(|_| "Failed to get HOME environment variable")?;
"openlist" PathBuf::from(home)
.join(".local")
.join("share")
.join("OpenList Desktop")
}
#[cfg(target_os = "windows")]
{
let appdata =
env::var("APPDATA").map_err(|_| "Failed to get APPDATA environment variable")?;
PathBuf::from(appdata).join("OpenList Desktop")
}
}; };
let binary_path = app_dir.join(binary_name);
if !binary_path.exists() { fs::create_dir_all(&data_dir).map_err(|e| format!("Failed to create data directory: {e}"))?;
return Err(format!(
"OpenList service binary not found at: {binary_path:?}" normalize_path(&data_dir)
)); }
fn get_user_logs_dir() -> Result<PathBuf, String> {
let logs_dir = {
#[cfg(target_os = "macos")]
{
let home = env::var("HOME").map_err(|_| "Failed to get HOME environment variable")?;
PathBuf::from(home)
.join("Library")
.join("Logs")
.join("OpenList Desktop")
}
#[cfg(not(target_os = "macos"))]
{
get_user_data_dir()?.join("logs")
}
};
fs::create_dir_all(&logs_dir).map_err(|e| format!("Failed to create logs directory: {e}"))?;
normalize_path(&logs_dir)
}
fn get_binary_path(binary: &str, service_name: &str) -> Result<PathBuf, String> {
let mut name = binary.to_string();
if cfg!(target_os = "windows") {
name.push_str(".exe");
} }
Ok(binary_path) let path = get_app_dir()?.join(&name);
if !path.exists() {
return Err(format!(
"{service_name} service binary not found at: {path:?}"
));
}
Ok(path)
}
pub fn get_openlist_binary_path() -> Result<PathBuf, String> {
get_binary_path("openlist", "OpenList")
} }
pub fn get_rclone_binary_path() -> Result<PathBuf, String> { pub fn get_rclone_binary_path() -> Result<PathBuf, String> {
let app_dir = get_app_dir()?; get_binary_path("rclone", "Rclone")
let binary_name = if cfg!(target_os = "windows") {
"rclone.exe"
} else {
"rclone"
};
let binary_path = app_dir.join(binary_name);
if !binary_path.exists() {
return Err(format!(
"Rclone service binary not found at: {binary_path:?}"
));
}
Ok(binary_path)
} }
pub fn get_app_config_dir() -> Result<PathBuf, String> { pub fn get_app_config_dir() -> Result<PathBuf, String> {
let app_dir = get_app_dir()?; get_user_data_dir()
fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?;
Ok(app_dir)
} }
pub fn app_config_file_path() -> Result<PathBuf, String> { pub fn app_config_file_path() -> Result<PathBuf, String> {
@@ -65,8 +127,31 @@ pub fn app_config_file_path() -> Result<PathBuf, String> {
} }
pub fn get_app_logs_dir() -> Result<PathBuf, String> { pub fn get_app_logs_dir() -> Result<PathBuf, String> {
let app_dir = get_app_dir()?; get_user_logs_dir()
let logs_dir = app_dir.join("logs"); }
fs::create_dir_all(&logs_dir).map_err(|e| e.to_string())?;
Ok(logs_dir) pub fn get_rclone_config_path() -> Result<PathBuf, String> {
Ok(get_user_data_dir()?.join("rclone.conf"))
}
pub fn get_default_openlist_data_dir() -> Result<PathBuf, String> {
Ok(get_user_data_dir()?.join("data"))
}
pub fn get_service_log_path() -> Result<PathBuf, String> {
#[cfg(target_os = "macos")]
{
let home = env::var("HOME").map_err(|_| "Failed to get HOME environment variable")?;
let logs = PathBuf::from(home)
.join("Library")
.join("Logs")
.join("OpenList Desktop")
.join("openlist-desktop-service.log");
Ok(logs)
}
#[cfg(not(target_os = "macos"))]
{
Ok(get_app_logs_dir()?.join("openlist-desktop-service.log"))
}
} }

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "OpenList Desktop", "productName": "OpenList Desktop",
"version": "0.2.0", "version": "0.8.0",
"identifier": "io.github.openlistteam.openlist.desktop", "identifier": "io.github.openlistteam.openlist.desktop",
"build": { "build": {
"beforeDevCommand": "yarn run dev", "beforeDevCommand": "yarn run dev",
@@ -19,7 +19,8 @@
"minHeight": 400, "minHeight": 400,
"resizable": true, "resizable": true,
"center": true, "center": true,
"decorations": false "decorations": false,
"visible": false
} }
], ],
"security": { "security": {

View File

@@ -2,6 +2,25 @@
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"identifier": "io.github.openlistteam.openlist.desktop", "identifier": "io.github.openlistteam.openlist.desktop",
"productName": "OpenList Desktop", "productName": "OpenList Desktop",
"app": {
"windows": [
{
"title": "OpenList Desktop",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 400,
"resizable": true,
"center": true,
"decorations": true,
"titleBarStyle": "Transparent",
"visible": false
}
],
"security": {
"csp": null
}
},
"bundle": { "bundle": {
"targets": ["app", "dmg"], "targets": ["app", "dmg"],
"macOS": { "macOS": {

View File

@@ -4,9 +4,9 @@
"bundle": { "bundle": {
"targets": ["nsis"], "targets": ["nsis"],
"windows": { "windows": {
"certificateThumbprint": null, "certificateThumbprint": "",
"digestAlgorithm": "sha256", "digestAlgorithm": "sha256",
"timestampUrl": "", "timestampUrl": "http://time.certum.pl",
"webviewInstallMode": { "webviewInstallMode": {
"type": "embedBootstrapper", "type": "embedBootstrapper",
"silent": true "silent": true

View File

@@ -2,15 +2,14 @@
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAppStore } from './stores/app' import { TauriAPI } from './api/tauri'
import Navigation from './components/NavigationPage.vue'
import TitleBar from './components/ui/TitleBar.vue'
import { useTranslation } from './composables/useI18n' import { useTranslation } from './composables/useI18n'
import { useTray } from './composables/useTray' import { useTray } from './composables/useTray'
import { TauriAPI } from './api/tauri' import { useAppStore } from './stores/app'
import Navigation from './components/Navigation.vue'
import TitleBar from './components/ui/TitleBar.vue'
import TutorialOverlay from './components/ui/TutorialOverlay.vue'
const store = useAppStore() const appStore = useAppStore()
const { t } = useTranslation() const { t } = useTranslation()
const { updateTrayMenu } = useTray() const { updateTrayMenu } = useTray()
const router = useRouter() const router = useRouter()
@@ -49,25 +48,21 @@ const handleKeydown = (event: KeyboardEvent) => {
onMounted(async () => { onMounted(async () => {
try { try {
store.init() appStore.init()
store.applyTheme(store.settings.app.theme || 'light') appStore.applyTheme(appStore.settings.app.theme || 'light')
await updateTrayMenu(store.openlistCoreStatus.running) await updateTrayMenu(appStore.openlistCoreStatus.running)
try { try {
updateUnlisten = await TauriAPI.updater.onBackgroundUpdate(updateInfo => { updateUnlisten = await TauriAPI.updater.onBackgroundUpdate(updateInfo => {
console.log('Global update listener: Update available', updateInfo) console.log('Global update listener: Update available', updateInfo)
store.setUpdateAvailable(true, updateInfo) appStore.setUpdateAvailable(true, updateInfo)
}) })
console.log('Global update listener set up successfully')
} catch (err) { } catch (err) {
console.warn('Failed to set up global update listener:', err) console.warn('Failed to set up global update listener:', err)
} }
document.addEventListener('keydown', handleKeydown) document.addEventListener('keydown', handleKeydown)
} finally { } finally {
setTimeout(() => { isLoading.value = false
isLoading.value = false
}, 1000)
} }
}) })
@@ -147,8 +142,6 @@ onUnmounted(() => {
</router-view> </router-view>
</div> </div>
</main> </main>
<TutorialOverlay />
</div> </div>
</template> </template>
@@ -372,9 +365,9 @@ body {
.loading-backdrop { .loading-backdrop {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: radial-gradient(circle at 25% 25%, rgba(120, 119, 198, 0.3) 0%, transparent 50%), background:
radial-gradient(circle at 25% 25%, rgba(120, 119, 198, 0.3) 0%, transparent 50%),
radial-gradient(circle at 75% 75%, rgba(255, 255, 255, 0.1) 0%, transparent 50%); radial-gradient(circle at 75% 75%, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
animation: float 20s ease-in-out infinite;
} }
.loading-content { .loading-content {
@@ -402,7 +395,6 @@ body {
z-index: 2; z-index: 2;
color: white; color: white;
filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.2)); filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.2));
animation: logoFloat 3s ease-in-out infinite;
} }
.logo-shimmer { .logo-shimmer {
@@ -410,7 +402,6 @@ body {
inset: -20px; inset: -20px;
background: conic-gradient(from 0deg, transparent, rgba(255, 255, 255, 0.2), transparent); background: conic-gradient(from 0deg, transparent, rgba(255, 255, 255, 0.2), transparent);
border-radius: 50%; border-radius: 50%;
animation: shimmer 2s linear infinite;
} }
.loading-title { .loading-title {
@@ -462,57 +453,6 @@ body {
.progress-fill { .progress-fill {
height: 100%; height: 100%;
border-radius: 1px; border-radius: 1px;
animation: progressFill 2s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
33% {
transform: translateY(-10px) rotate(1deg);
}
66% {
transform: translateY(-5px) rotate(-1deg);
}
}
@keyframes logoFloat {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-8px);
}
}
@keyframes shimmer {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes progressFill {
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(-50%);
}
100% {
transform: translateX(100%);
}
} }
.app-container { .app-container {
@@ -539,7 +479,6 @@ body {
height: 80%; height: 80%;
background: radial-gradient(circle, rgba(0, 122, 255, 0.05) 0%, transparent 70%); background: radial-gradient(circle, rgba(0, 122, 255, 0.05) 0%, transparent 70%);
border-radius: 50%; border-radius: 50%;
animation: gradientFloat 20s ease-in-out infinite;
} }
.bg-gradient-secondary { .bg-gradient-secondary {
@@ -550,22 +489,6 @@ body {
height: 60%; height: 60%;
background: radial-gradient(circle, rgba(175, 82, 222, 0.03) 0%, transparent 70%); background: radial-gradient(circle, rgba(175, 82, 222, 0.03) 0%, transparent 70%);
border-radius: 50%; border-radius: 50%;
animation: gradientFloat 25s ease-in-out infinite reverse;
}
@keyframes gradientFloat {
0%,
100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(-10px, -15px) scale(1.05);
}
66% {
transform: translate(10px, -10px) scale(0.95);
}
} }
.main-content { .main-content {
@@ -590,14 +513,6 @@ body {
margin: 0; margin: 0;
} }
.page-enter-active {
transition: all var(--transition-medium);
}
.page-leave-active {
transition: all 0.15s cubic-bezier(0.4, 0, 1, 1);
}
.page-enter-from { .page-enter-from {
opacity: 0; opacity: 0;
transform: translateY(24px) scale(0.98); transform: translateY(24px) scale(0.98);

View File

@@ -60,7 +60,12 @@ export class TauriAPI {
list: (path: string): Promise<FileItem[]> => call('list_files', { path }), list: (path: string): Promise<FileItem[]> => call('list_files', { path }),
open: (path: string): Promise<boolean> => call('open_file', { path }), open: (path: string): Promise<boolean> => call('open_file', { path }),
folder: (path: string): Promise<boolean> => call('open_folder', { path }), folder: (path: string): Promise<boolean> => call('open_folder', { path }),
url: (path: string): Promise<boolean> => call('open_url', { path }) url: (url: string): Promise<boolean> => call('open_url', { url }),
urlInBrowser: (url: string): Promise<boolean> => call('open_url_in_browser', { url }),
openOpenListDataDir: (): Promise<boolean> => call('open_openlist_data_dir'),
openLogsDirectory: (): Promise<boolean> => call('open_logs_directory'),
openRcloneConfigFile: (): Promise<boolean> => call('open_rclone_config_file'),
openSettingsFile: (): Promise<boolean> => call('open_settings_file')
} }
// --- Settings management --- // --- Settings management ---
@@ -74,11 +79,13 @@ export class TauriAPI {
// --- Logs management --- // --- Logs management ---
static logs = { static logs = {
get: (src?: 'openlist' | 'rclone' | 'app' | 'openlist_core'): Promise<string[]> => get: (src?: 'openlist' | 'rclone' | 'app' | 'openlist_core' | 'service' | 'all'): Promise<string[]> =>
call('get_logs', { source: src }), call('get_logs', { source: src }),
clear: (src?: 'openlist' | 'rclone' | 'app' | 'openlist_core'): Promise<boolean> => clear: (src?: 'openlist' | 'rclone' | 'app' | 'openlist_core' | 'service' | 'all'): Promise<boolean> =>
call('clear_logs', { source: src }), call('clear_logs', { source: src }),
adminPassword: (): Promise<string> => call('get_admin_password') adminPassword: (): Promise<string> => call('get_admin_password'),
resetAdminPassword: (): Promise<string> => call('reset_admin_password'),
setAdminPassword: (password: string): Promise<string> => call('set_admin_password', { password })
} }
// --- Binary management --- // --- Binary management ---
@@ -103,6 +110,13 @@ export class TauriAPI {
listen: (cb: (action: string) => void) => listen('tray-core-action', e => cb(e.payload as string)) listen: (cb: (action: string) => void) => listen('tray-core-action', e => cb(e.payload as string))
} }
// --- Firewall management ---
static firewall = {
check: (): Promise<boolean> => call('check_firewall_rule'),
add: (): Promise<boolean> => call('add_firewall_rule'),
remove: (): Promise<boolean> => call('remove_firewall_rule')
}
// --- Update management --- // --- Update management ---
static updater = { static updater = {
check: (): Promise<UpdateCheck> => call('check_for_updates'), check: (): Promise<UpdateCheck> => call('check_for_updates'),
@@ -118,6 +132,6 @@ export class TauriAPI {
listen('download-progress', e => cb(e.payload as DownloadProgress)), listen('download-progress', e => cb(e.payload as DownloadProgress)),
onInstallStarted: (cb: () => void) => listen('update-install-started', () => cb()), onInstallStarted: (cb: () => void) => listen('update-install-started', () => cb()),
onInstallError: (cb: (err: string) => void) => listen('update-install-error', e => cb(e.payload as string)), onInstallError: (cb: (err: string) => void) => listen('update-install-error', e => cb(e.payload as string)),
onAppRestarting: (cb: () => void) => listen('app-restarting', () => cb()) onAppQuit: (cb: () => void) => listen('quit-app', () => cb())
} }
} }

View File

@@ -1,40 +1,3 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTranslation } from '../composables/useI18n'
import { useAppStore } from '../stores/app'
import LanguageSwitcher from './ui/LanguageSwitcher.vue'
import ThemeSwitcher from './ui/ThemeSwitcher.vue'
import { Home, HardDrive, FileText, Settings, Download, DownloadCloud, Github } from 'lucide-vue-next'
import { TauriAPI } from '@/api/tauri'
const { t } = useTranslation()
const appStore = useAppStore()
const navigationItems = computed(() => [
{ name: t('navigation.dashboard'), path: '/', icon: Home, shortcut: 'Ctrl+H' },
{ name: t('navigation.mount'), path: '/mount', icon: HardDrive, shortcut: 'Ctrl+M' },
{ name: t('navigation.logs'), path: '/logs', icon: FileText, shortcut: 'Ctrl+L' },
{ name: t('navigation.settings'), path: '/settings', icon: Settings, shortcut: 'Ctrl+,' },
{
name: t('navigation.update'),
path: '/update',
icon: appStore.updateAvailable ? DownloadCloud : Download,
shortcut: 'Ctrl+U',
hasNotification: appStore.updateAvailable
}
])
const openLink = async (url: string) => {
try {
await TauriAPI.files.url(url)
} catch (error) {
console.error('Failed to open link:', error)
window.open(url, '_blank')
}
}
</script>
<template> <template>
<nav class="navigation"> <nav class="navigation">
<div class="title-bar"> <div class="title-bar">
@@ -73,9 +36,9 @@ const openLink = async (url: string) => {
<div class="github-section"> <div class="github-section">
<a <a
@click.prevent="openLink('https://github.com/OpenListTeam/openlist-desktop')"
class="github-link" class="github-link"
title="View on GitHub" title="View on GitHub"
@click.prevent="openLink('https://github.com/OpenListTeam/openlist-desktop')"
> >
<Github :size="20" /> <Github :size="20" />
</a> </a>
@@ -83,6 +46,53 @@ const openLink = async (url: string) => {
</nav> </nav>
</template> </template>
<script setup lang="ts">
import { Download, DownloadCloud, FileText, Github, HardDrive, Home, Settings } from 'lucide-vue-next'
import { computed } from 'vue'
import { TauriAPI } from '@/api/tauri'
import { useTranslation } from '../composables/useI18n'
import { useAppStore } from '../stores/app'
import LanguageSwitcher from './ui/LanguageSwitcher.vue'
import ThemeSwitcher from './ui/ThemeSwitcher.vue'
const { t } = useTranslation()
const appStore = useAppStore()
const navigationItems = computed(() => [
{ name: t('navigation.dashboard'), path: '/', icon: Home, shortcut: 'Ctrl+H' },
{ name: t('navigation.mount'), path: '/mount', icon: HardDrive, shortcut: 'Ctrl+M' },
{ name: t('navigation.logs'), path: '/logs', icon: FileText, shortcut: 'Ctrl+L' },
{ name: t('navigation.settings'), path: '/settings', icon: Settings, shortcut: 'Ctrl+,' },
{
name: t('navigation.update'),
path: '/update',
icon: appStore.updateAvailable ? DownloadCloud : Download,
shortcut: 'Ctrl+U',
hasNotification: appStore.updateAvailable
}
])
const isMacOs = computed(() => {
return typeof OS_PLATFORM !== 'undefined' && OS_PLATFORM === 'darwin'
})
const openLink = async (url: string) => {
try {
if (appStore.settings.app.open_links_in_browser || isMacOs.value) {
await TauriAPI.files.urlInBrowser(url)
return
}
} catch (error) {
console.error('Failed to open link:', error)
}
setTimeout(() => {
window.open(url, '_blank')
})
}
</script>
<style scoped> <style scoped>
.navigation { .navigation {
display: flex; display: flex;
@@ -189,7 +199,6 @@ const openLink = async (url: string) => {
text-decoration: none; text-decoration: none;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
transition: all 0.2s ease-in-out;
} }
:root.dark .nav-item, :root.dark .nav-item,
@@ -237,7 +246,6 @@ const openLink = async (url: string) => {
background: rgb(220 38 38); background: rgb(220 38 38);
border-radius: 50%; border-radius: 50%;
border: 2px solid var(--color-background-secondary); border: 2px solid var(--color-background-secondary);
animation: pulse 2s infinite;
} }
.nav-item.has-notification .nav-icon-container { .nav-item.has-notification .nav-icon-container {
@@ -272,7 +280,6 @@ const openLink = async (url: string) => {
color: rgb(75 85 99); color: rgb(75 85 99);
text-decoration: none; text-decoration: none;
border-radius: 0.5rem; border-radius: 0.5rem;
transition: all 0.2s ease-in-out;
} }
:root.dark .github-link, :root.dark .github-link,
@@ -283,7 +290,6 @@ const openLink = async (url: string) => {
.github-link:hover { .github-link:hover {
background: rgb(243 244 246); background: rgb(243 244 246);
color: rgb(17 24 39); color: rgb(17 24 39);
transform: scale(1.1);
} }
:root.dark .github-link:hover, :root.dark .github-link:hover,
@@ -291,14 +297,4 @@ const openLink = async (url: string) => {
background: rgb(55 65 81); background: rgb(55 65 81);
color: rgb(243 244 246); color: rgb(243 244 246);
} }
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
</style> </style>

View File

@@ -10,7 +10,7 @@
<div class="heartbeat-section"> <div class="heartbeat-section">
<div class="heartbeat-header"> <div class="heartbeat-header">
<h4></h4> <h4></h4>
<div class="metrics" v-if="isCoreRunning"> <div v-if="isCoreRunning" class="metrics">
<span class="metric info"> <span class="metric info">
<Globe :size="14" /> <Globe :size="14" />
Port: {{ openlistCoreStatus.port || 5244 }} Port: {{ openlistCoreStatus.port || 5244 }}
@@ -33,7 +33,7 @@
</div> </div>
</div> </div>
<div class="heartbeat-chart" ref="chartContainer"> <div ref="chartContainer" class="heartbeat-chart">
<svg :width="chartWidth" :height="chartHeight" class="heartbeat-svg"> <svg :width="chartWidth" :height="chartHeight" class="heartbeat-svg">
<defs> <defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse"> <pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
@@ -70,19 +70,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue' import { Activity, Globe } from 'lucide-vue-next'
import { useAppStore } from '../../stores/app' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useTranslation } from '../../composables/useI18n' import { useTranslation } from '../../composables/useI18n'
import Card from '../ui/Card.vue' import { useAppStore } from '../../stores/app'
import { Globe, Activity } from 'lucide-vue-next' import Card from '../ui/CardPage.vue'
const { t } = useTranslation() const { t } = useTranslation()
const store = useAppStore() const appStore = useAppStore()
const chartContainer = ref<HTMLElement>() const chartContainer = ref<HTMLElement>()
const chartWidth = ref(400) const chartWidth = ref(400)
const chartHeight = ref(120) const chartHeight = ref(120)
const dataPoints = ref<Array<{ timestamp: number; responseTime: number; isHealthy: boolean }>>([]) const dataPoints = ref<{ timestamp: number; responseTime: number; isHealthy: boolean }[]>([])
const responseTime = ref(0) const responseTime = ref(0)
const startTime = ref(Date.now()) const startTime = ref(Date.now())
const monitoringInterval = ref<number>() const monitoringInterval = ref<number>()
@@ -97,8 +98,8 @@ const tooltip = ref({
statusText: '' statusText: ''
}) })
const isCoreRunning = computed(() => store.isCoreRunning) const isCoreRunning = computed(() => appStore.isCoreRunning)
const openlistCoreStatus = computed(() => store.openlistCoreStatus) const openlistCoreStatus = computed(() => appStore.openlistCoreStatus)
const avgResponseTime = computed(() => { const avgResponseTime = computed(() => {
if (dataPoints.value.length === 0) return 0 if (dataPoints.value.length === 0) return 0
@@ -158,7 +159,7 @@ const gridColor = computed(() => {
}) })
const checkCoreHealth = async () => { const checkCoreHealth = async () => {
await store.refreshOpenListCoreStatus() await appStore.refreshOpenListCoreStatus()
if (!isCoreRunning.value) { if (!isCoreRunning.value) {
dataPoints.value.push({ dataPoints.value.push({
timestamp: Date.now(), timestamp: Date.now(),
@@ -172,7 +173,7 @@ const checkCoreHealth = async () => {
const startTime = Date.now() const startTime = Date.now()
try { try {
await store.refreshOpenListCoreStatus() await appStore.refreshOpenListCoreStatus()
const endTime = Date.now() const endTime = Date.now()
const responseTimeMs = endTime - startTime const responseTimeMs = endTime - startTime
@@ -223,12 +224,13 @@ const updateChartSize = () => {
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
updateChartSize() updateChartSize()
await appStore.refreshOpenListCoreStatus()
if (isCoreRunning.value) { if (isCoreRunning.value) {
startTime.value = Date.now() startTime.value = Date.now()
} }
monitoringInterval.value = window.setInterval(checkCoreHealth, (store.settings.app.monitor_interval || 5) * 1000) monitoringInterval.value = window.setInterval(checkCoreHealth, 15 * 1000)
window.addEventListener('resize', updateChartSize) window.addEventListener('resize', updateChartSize)
}) })
@@ -256,9 +258,10 @@ watch(isCoreRunning, (newValue: boolean, oldValue: boolean) => {
padding: 0.5rem 0.875rem; padding: 0.5rem 0.875rem;
border-radius: 20px; border-radius: 20px;
background: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
border: 1px solid rgba(226, 232, 240, 0.6); border: 1px solid rgba(226, 232, 240, 0.6);
transition: all 0.2s ease; transition:
background-color 0.15s ease,
border-color 0.15s ease;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -287,20 +290,6 @@ watch(isCoreRunning, (newValue: boolean, oldValue: boolean) => {
background: currentColor; background: currentColor;
} }
.status-indicator.online .pulse-dot {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.heartbeat-section { .heartbeat-section {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
@@ -348,9 +337,7 @@ watch(isCoreRunning, (newValue: boolean, oldValue: boolean) => {
padding: 0.5rem 0.875rem; padding: 0.5rem 0.875rem;
border-radius: 20px; border-radius: 20px;
font-weight: 600; font-weight: 600;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
transition: all 0.2s ease;
} }
.metric:hover { .metric:hover {
@@ -422,7 +409,6 @@ watch(isCoreRunning, (newValue: boolean, oldValue: boolean) => {
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(226, 232, 240, 0.6); border: 1px solid rgba(226, 232, 240, 0.6);
backdrop-filter: blur(10px);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {

View File

@@ -13,11 +13,11 @@
</div> </div>
</div> </div>
<div class="doc-actions"> <div class="doc-actions">
<button @click="openOpenListDocs" class="doc-btn primary"> <button class="doc-btn primary" @click="openOpenListDocs">
<ExternalLink :size="14" /> <ExternalLink :size="14" />
<span>{{ t('dashboard.documentation.openDocs') }}</span> <span>{{ t('dashboard.documentation.openDocs') }}</span>
</button> </button>
<button @click="openOpenListGitHub" class="doc-btn secondary"> <button class="doc-btn secondary" @click="openOpenListGitHub">
<Github :size="14" /> <Github :size="14" />
<span>{{ t('dashboard.documentation.github') }}</span> <span>{{ t('dashboard.documentation.github') }}</span>
</button> </button>
@@ -35,11 +35,11 @@
</div> </div>
</div> </div>
<div class="doc-actions"> <div class="doc-actions">
<button @click="openRcloneDocs" class="doc-btn primary"> <button class="doc-btn primary" @click="openRcloneDocs">
<ExternalLink :size="14" /> <ExternalLink :size="14" />
<span>{{ t('dashboard.documentation.openDocs') }}</span> <span>{{ t('dashboard.documentation.openDocs') }}</span>
</button> </button>
<button @click="openRcloneGitHub" class="doc-btn secondary"> <button class="doc-btn secondary" @click="openRcloneGitHub">
<Github :size="14" /> <Github :size="14" />
<span>{{ t('dashboard.documentation.github') }}</span> <span>{{ t('dashboard.documentation.github') }}</span>
</button> </button>
@@ -52,19 +52,19 @@
<h4>{{ t('dashboard.documentation.quickLinks') }}</h4> <h4>{{ t('dashboard.documentation.quickLinks') }}</h4>
</div> </div>
<div class="links-grid"> <div class="links-grid">
<button @click="openLink('https://docs.oplist.org/guide/api')" class="link-btn"> <button class="link-btn" @click="openLink('https://docs.oplist.org/guide/api')">
<Code :size="16" /> <Code :size="16" />
<span>{{ t('dashboard.documentation.apiDocs') }}</span> <span>{{ t('dashboard.documentation.apiDocs') }}</span>
</button> </button>
<button @click="openLink('https://rclone.org/commands/')" class="link-btn"> <button class="link-btn" @click="openLink('https://rclone.org/commands/')">
<Terminal :size="16" /> <Terminal :size="16" />
<span>{{ t('dashboard.documentation.commands') }}</span> <span>{{ t('dashboard.documentation.commands') }}</span>
</button> </button>
<button @click="openLink('https://github.com/OpenListTeam/OpenList-desktop/issues')" class="link-btn"> <button class="link-btn" @click="openLink('https://github.com/OpenListTeam/OpenList-desktop/issues')">
<HelpCircle :size="16" /> <HelpCircle :size="16" />
<span>{{ t('dashboard.documentation.issues') }}</span> <span>{{ t('dashboard.documentation.issues') }}</span>
</button> </button>
<button @click="openLink('https://docs.oplist.org/faq/')" class="link-btn"> <button class="link-btn" @click="openLink('https://docs.oplist.org/faq/')">
<MessageCircle :size="16" /> <MessageCircle :size="16" />
<span>{{ t('dashboard.documentation.faq') }}</span> <span>{{ t('dashboard.documentation.faq') }}</span>
</button> </button>
@@ -75,12 +75,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useTranslation } from '../../composables/useI18n' import { BookOpen, Cloud, Code, ExternalLink, Github, HelpCircle, MessageCircle, Terminal } from 'lucide-vue-next'
import { ExternalLink, Github, BookOpen, Cloud, Code, Terminal, HelpCircle, MessageCircle } from 'lucide-vue-next' import { computed } from 'vue'
import Card from '../ui/Card.vue'
import { TauriAPI } from '../../api/tauri' import { TauriAPI } from '../../api/tauri'
import { useTranslation } from '../../composables/useI18n'
import { useAppStore } from '../../stores/app'
import Card from '../ui/CardPage.vue'
const { t } = useTranslation() const { t } = useTranslation()
const appStore = useAppStore()
const openOpenListDocs = () => { const openOpenListDocs = () => {
openLink('https://docs.oplist.org/') openLink('https://docs.oplist.org/')
@@ -98,13 +102,22 @@ const openRcloneGitHub = () => {
openLink('https://github.com/rclone/rclone') openLink('https://github.com/rclone/rclone')
} }
const isMacOs = computed(() => {
return typeof OS_PLATFORM !== 'undefined' && OS_PLATFORM === 'darwin'
})
const openLink = async (url: string) => { const openLink = async (url: string) => {
try { try {
await TauriAPI.files.url(url) if (appStore.settings.app.open_links_in_browser || isMacOs.value) {
await TauriAPI.files.urlInBrowser(url)
return
}
} catch (error) { } catch (error) {
console.error('Failed to open link:', error) console.error('Failed to open link:', error)
window.open(url, '_blank')
} }
setTimeout(() => {
window.open(url, '_blank')
})
} }
</script> </script>
@@ -126,12 +139,11 @@ const openLink = async (url: string) => {
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 1rem; padding: 1rem;
background: rgb(249 250 251); background: rgb(249 250 251);
transition: all 0.2s;
} }
.doc-section:hover { .doc-section:hover {
border-color: rgb(209 213 219); border-color: rgb(209 213 219);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); background: rgb(243 244 246);
} }
:root.dark .doc-section, :root.dark .doc-section,
@@ -143,7 +155,7 @@ const openLink = async (url: string) => {
:root.dark .doc-section:hover, :root.dark .doc-section:hover,
:root.auto.dark .doc-section:hover { :root.auto.dark .doc-section:hover {
border-color: rgb(75 85 99); border-color: rgb(75 85 99);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); background: rgb(55 65 81);
} }
.doc-header { .doc-header {
@@ -218,7 +230,6 @@ const openLink = async (url: string) => {
border: none; border: none;
border-radius: 0.5rem; border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s;
text-decoration: none; text-decoration: none;
flex: 1; flex: 1;
justify-content: center; justify-content: center;
@@ -296,7 +307,6 @@ const openLink = async (url: string) => {
border: 1px solid rgb(209 213 219); border: 1px solid rgb(209 213 219);
border-radius: 0.5rem; border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s;
text-align: center; text-align: center;
} }

View File

@@ -4,66 +4,93 @@
<div class="action-section"> <div class="action-section">
<div class="section-header"> <div class="section-header">
<h4>{{ t('dashboard.quickActions.openlistService') }}</h4> <h4>{{ t('dashboard.quickActions.openlistService') }}</h4>
<div v-if="isCoreLoading" class="section-loading-indicator">
<Loader :size="12" class="loading-icon" />
</div>
</div> </div>
<div class="action-buttons"> <div class="action-buttons">
<button <button
:disabled="isCoreLoading"
:class="['action-btn', 'service-btn', { running: isCoreRunning, loading: isCoreLoading }]"
@click="toggleCore" @click="toggleCore"
:class="['action-btn', 'service-btn', { running: isCoreRunning }]"
:disabled="loading"
> >
<component :is="serviceButtonIcon" :size="20" /> <component :is="serviceButtonIcon" v-if="!isCoreLoading" :size="20" />
<span>{{ serviceButtonText }}</span> <Loader v-else :size="20" class="loading-icon" />
<span>{{ isCoreLoading ? t('dashboard.quickActions.processing') : serviceButtonText }}</span>
</button> </button>
<button @click="restartCore" :disabled="!isCoreRunning || loading" class="action-btn restart-btn"> <button
<RotateCcw :size="18" /> :disabled="!isCoreRunning || isCoreLoading"
:class="['action-btn', 'restart-btn', { loading: isCoreLoading }]"
@click="restartCore"
>
<RotateCcw v-if="!isCoreLoading" :size="18" />
<Loader v-else :size="18" class="loading-icon" />
<span>{{ t('dashboard.quickActions.restart') }}</span> <span>{{ t('dashboard.quickActions.restart') }}</span>
</button> </button>
<button <button
@click="openWebUI" :disabled="!isCoreRunning || isCoreLoading"
:disabled="!isCoreRunning"
class="action-btn web-btn" class="action-btn web-btn"
:title="store.openListCoreUrl" :title="appStore.openListCoreUrl"
@click="openWebUI"
> >
<ExternalLink :size="18" /> <ExternalLink :size="18" />
<span>{{ t('dashboard.quickActions.openWeb') }}</span> <span>{{ t('dashboard.quickActions.openWeb') }}</span>
</button> </button>
<button <button
@click="showAdminPassword"
class="action-btn password-btn icon-only-btn" class="action-btn password-btn icon-only-btn"
:title="t('dashboard.quickActions.showAdminPassword')" :title="t('dashboard.quickActions.copyAdminPassword')"
@click="copyAdminPassword"
> >
<Key :size="16" /> <Key :size="16" />
</button> </button>
<button
class="action-btn reset-password-btn icon-only-btn"
:title="t('dashboard.quickActions.resetAdminPassword')"
@click="resetAdminPassword"
>
<RotateCcw :size="16" />
</button>
</div> </div>
</div> </div>
<div class="action-section"> <div class="action-section">
<div class="section-header"> <div class="section-header">
<h4>{{ t('dashboard.quickActions.rclone') }}</h4> <h4>{{ t('dashboard.quickActions.rclone') }}</h4>
<div v-if="isRcloneLoading" class="section-loading-indicator">
<Loader :size="12" class="loading-icon" />
</div>
</div> </div>
<div class="action-buttons"> <div class="action-buttons">
<button <button
@click="rcloneStore.serviceRunning ? rcloneStore.stopRcloneBackend() : rcloneStore.startRcloneBackend()" :disabled="isRcloneLoading"
:disabled="loading || rcloneStore.loading" :class="[
:class="['action-btn', 'service-indicator-btn', { active: rcloneStore.serviceRunning }]" 'action-btn',
'service-indicator-btn',
{ active: rcloneStore.serviceRunning, loading: isRcloneLoading }
]"
@click="rcloneStore.serviceRunning ? stopBackend() : startBackend()"
> >
<component :is="rcloneStore.serviceRunning ? Square : Play" :size="18" /> <component :is="rcloneStore.serviceRunning ? Square : Play" v-if="!isRcloneLoading" :size="18" />
<Loader v-else :size="18" class="loading-icon" />
<span>{{ <span>{{
rcloneStore.serviceRunning isRcloneLoading
? t('dashboard.quickActions.stopRclone') ? t('dashboard.quickActions.processing')
: t('dashboard.quickActions.startRclone') : rcloneStore.serviceRunning
? t('dashboard.quickActions.stopRclone')
: t('dashboard.quickActions.startRclone')
}}</span> }}</span>
</button> </button>
<button @click="openRcloneConfig" class="action-btn config-btn"> <button class="action-btn config-btn" @click="openRcloneConfig">
<Settings :size="18" /> <Settings :size="18" />
<span>{{ t('dashboard.quickActions.configRclone') }}</span> <span>{{ t('dashboard.quickActions.configRclone') }}</span>
</button> </button>
<button @click="viewMounts" class="action-btn mount-btn"> <button class="action-btn mount-btn" @click="viewMounts">
<HardDrive :size="18" /> <HardDrive :size="18" />
<span>{{ t('dashboard.quickActions.manageMounts') }}</span> <span>{{ t('dashboard.quickActions.manageMounts') }}</span>
</button> </button>
@@ -77,9 +104,31 @@
</div> </div>
<div class="settings-toggles"> <div class="settings-toggles">
<label class="toggle-item"> <label class="toggle-item">
<input type="checkbox" v-model="settings.openlist.auto_launch" @change="handleAutoLaunchToggle" /> <input v-model="settings.openlist.auto_launch" type="checkbox" @change="handleAutoLaunchToggle" />
<span class="toggle-text">{{ t('dashboard.quickActions.autoLaunch') }}</span> <span class="toggle-text">{{ t('dashboard.quickActions.autoLaunch') }}</span>
</label> </label>
<!-- Windows Firewall Management-->
<button
v-if="isWindows"
:class="['firewall-toggle-btn', { active: firewallEnabled }]"
:disabled="firewallLoading"
:title="
firewallEnabled
? t('dashboard.quickActions.firewall.disable')
: t('dashboard.quickActions.firewall.enable')
"
@click="toggleFirewallRule"
>
<Shield :size="18" />
<span>
{{
firewallEnabled
? t('dashboard.quickActions.firewall.disable')
: t('dashboard.quickActions.firewall.enable')
}}
</span>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -87,31 +136,39 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue' import { ExternalLink, HardDrive, Key, Loader, Play, RotateCcw, Settings, Shield, Square } from 'lucide-vue-next'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { TauriAPI } from '@/api/tauri'
import { useTranslation } from '../../composables/useI18n'
import { useAppStore } from '../../stores/app' import { useAppStore } from '../../stores/app'
import { useRcloneStore } from '../../stores/rclone' import { useRcloneStore } from '../../stores/rclone'
import { useTranslation } from '../../composables/useI18n' import Card from '../ui/CardPage.vue'
import Card from '../ui/Card.vue'
import { Play, Square, RotateCcw, ExternalLink, Settings, HardDrive, Loader, Key } from 'lucide-vue-next'
const { t } = useTranslation() const { t } = useTranslation()
const router = useRouter() const router = useRouter()
const store = useAppStore() const appStore = useAppStore()
const rcloneStore = useRcloneStore() const rcloneStore = useRcloneStore()
const isCoreRunning = computed(() => store.isCoreRunning) const isCoreRunning = computed(() => appStore.isCoreRunning)
const loading = computed(() => store.loading) const isCoreLoading = computed(() => appStore.loading)
const settings = computed(() => store.settings) const isRcloneLoading = computed(() => rcloneStore.loading)
const settings = computed(() => appStore.settings)
let statusCheckInterval: number | null = null let statusCheckInterval: number | null = null
const firewallEnabled = ref(false)
const firewallLoading = ref(false)
const isWindows = computed(() => {
return typeof OS_PLATFORM !== 'undefined' && OS_PLATFORM === 'win32'
})
const serviceButtonIcon = computed(() => { const serviceButtonIcon = computed(() => {
if (loading.value) return Loader
return isCoreRunning.value ? Square : Play return isCoreRunning.value ? Square : Play
}) })
const serviceButtonText = computed(() => { const serviceButtonText = computed(() => {
if (loading.value) return t('dashboard.quickActions.processing')
return isCoreRunning.value return isCoreRunning.value
? t('dashboard.quickActions.stopOpenListCore') ? t('dashboard.quickActions.stopOpenListCore')
: t('dashboard.quickActions.startOpenListCore') : t('dashboard.quickActions.startOpenListCore')
@@ -119,19 +176,19 @@ const serviceButtonText = computed(() => {
const toggleCore = async () => { const toggleCore = async () => {
if (isCoreRunning.value) { if (isCoreRunning.value) {
await store.stopOpenListCore() await appStore.stopOpenListCore()
} else { } else {
await store.startOpenListCore() await appStore.startOpenListCore()
} }
} }
const restartCore = async () => { const restartCore = async () => {
await store.restartOpenListCore() await appStore.restartOpenListCore()
} }
const openWebUI = () => { const openWebUI = () => {
if (store.openListCoreUrl) { if (appStore.openListCoreUrl) {
window.open(store.openListCoreUrl, '_blank') openLink(appStore.openListCoreUrl)
} }
} }
@@ -143,9 +200,9 @@ const viewMounts = () => {
router.push({ name: 'Mount' }) router.push({ name: 'Mount' })
} }
const showAdminPassword = async () => { const copyAdminPassword = async () => {
try { try {
const password = await store.getAdminPassword() const password = await appStore.getAdminPassword()
if (password) { if (password) {
await navigator.clipboard.writeText(password) await navigator.clipboard.writeText(password)
@@ -216,56 +273,184 @@ const showAdminPassword = async () => {
} }
} catch (error) { } catch (error) {
console.error('Failed to get admin password:', error) console.error('Failed to get admin password:', error)
showNotification('error', 'Failed to get admin password. Please check the logs.')
}
}
const notification = document.createElement('div') const resetAdminPassword = async () => {
notification.innerHTML = ` try {
<div style=" const newPassword = await appStore.resetAdminPassword()
position: fixed; if (newPassword) {
top: 20px; await navigator.clipboard.writeText(newPassword)
right: 20px;
background: linear-gradient(135deg, rgb(239, 68, 68), rgb(220, 38, 38)); const notification = document.createElement('div')
color: white; notification.innerHTML = `
padding: 12px 20px; <div style="
border-radius: 8px; position: fixed;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); top: 20px;
z-index: 10000; right: 20px;
font-weight: 500; background: linear-gradient(135deg, rgb(16, 185, 129), rgb(5, 150, 105));
max-width: 300px; color: white;
"> padding: 12px 20px;
<div style="display: flex; align-items: center; gap: 8px;"> border-radius: 8px;
<div style="font-size: 18px;">✗</div> box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
<div> z-index: 10000;
<div style="font-size: 14px; margin-bottom: 4px;">Failed to get admin password</div> font-weight: 500;
<div style="font-size: 12px; opacity: 0.9;">Please check the logs.</div> max-width: 300px;
word-break: break-all;
">
<div style="display: flex; align-items: center; gap: 8px;">
<div style="font-size: 18px;">✓</div>
<div>
<div style="font-size: 14px; margin-bottom: 4px;">Admin password reset and copied!</div>
<div style="font-size: 12px; opacity: 0.9; font-family: monospace;">${newPassword}</div>
</div>
</div> </div>
</div> </div>
</div> `
` document.body.appendChild(notification)
document.body.appendChild(notification)
setTimeout(() => { setTimeout(() => {
if (notification.parentNode) { if (notification.parentNode) {
notification.parentNode.removeChild(notification) notification.parentNode.removeChild(notification)
} }
}, 4000) }, 4000)
} else {
showNotification('error', 'Failed to reset admin password. Please check the logs.')
}
} catch (error) {
console.error('Failed to reset admin password:', error)
showNotification('error', 'Failed to reset admin password. Please check the logs.')
} }
} }
const handleAutoLaunchToggle = () => { const handleAutoLaunchToggle = () => {
store.enableAutoLaunch(settings.value.openlist.auto_launch) appStore.enableAutoLaunch(settings.value.openlist.auto_launch)
saveSettings() saveSettings()
} }
const saveSettings = async () => { const saveSettings = async () => {
await store.saveSettings() await appStore.saveSettings()
}
const startBackend = async () => {
try {
await rcloneStore.startRcloneBackend()
await new Promise(resolve => setTimeout(resolve, 1000))
await rcloneStore.checkRcloneBackendStatus()
} catch (error: any) {
console.error(error.message || t('mount.messages.failedToStartService'))
}
}
const stopBackend = async () => {
try {
const stopped = await rcloneStore.stopRcloneBackend()
if (!stopped) {
throw new Error(t('mount.messages.failedToStopService'))
}
} catch (error: any) {
console.error(error.message || t('mount.messages.failedToStopService'))
}
}
const checkFirewallStatus = async () => {
if (!isWindows.value) return
try {
firewallEnabled.value = await TauriAPI.firewall.check()
} catch (error) {
console.error('Failed to check firewall status:', error)
}
}
const toggleFirewallRule = async () => {
if (!isWindows.value) return
try {
firewallLoading.value = true
if (firewallEnabled.value) {
await TauriAPI.firewall.remove()
firewallEnabled.value = false
showNotification('success', t('dashboard.quickActions.firewall.removed'))
} else {
await TauriAPI.firewall.add()
firewallEnabled.value = true
showNotification('success', t('dashboard.quickActions.firewall.added'))
}
} catch (error: any) {
console.error('Failed to toggle firewall rule:', error)
const message = firewallEnabled.value
? t('dashboard.quickActions.firewall.failedToRemove')
: t('dashboard.quickActions.firewall.failedToAdd')
showNotification('error', message + ': ' + (error.message || error))
} finally {
firewallLoading.value = false
}
}
const showNotification = (type: 'success' | 'error', message: string) => {
const notification = document.createElement('div')
const bgColor =
type === 'success'
? 'linear-gradient(135deg, rgb(16, 185, 129), rgb(5, 150, 105))'
: 'linear-gradient(135deg, rgb(239, 68, 68), rgb(220, 38, 38))'
const icon = type === 'success' ? '✓' : '⚠'
notification.innerHTML = `
<div style="
position: fixed;
top: 20px;
right: 20px;
background: ${bgColor};
color: white;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
font-weight: 500;
max-width: 300px;
word-break: break-word;
">
<div style="display: flex; align-items: center; gap: 8px;">
<div style="font-size: 18px;">${icon}</div>
<div style="font-size: 14px;">${message}</div>
</div>
</div>
`
document.body.appendChild(notification)
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification)
}
}, 4000)
}
const isMacOs = computed(() => {
return typeof OS_PLATFORM !== 'undefined' && OS_PLATFORM === 'darwin'
})
const openLink = async (url: string) => {
try {
if (appStore.settings.app.open_links_in_browser || isMacOs.value) {
await TauriAPI.files.urlInBrowser(url)
return
}
} catch (error) {
console.error('Failed to open link:', error)
}
setTimeout(() => {
window.open(url, '_blank')
})
} }
onMounted(async () => { onMounted(async () => {
await rcloneStore.checkRcloneBackendStatus() await rcloneStore.checkRcloneBackendStatus()
statusCheckInterval = window.setInterval( statusCheckInterval = window.setInterval(rcloneStore.checkRcloneBackendStatus, 15 * 1000)
rcloneStore.checkRcloneBackendStatus,
(store.settings.app.monitor_interval || 5) * 1000 await checkFirewallStatus()
)
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -301,6 +486,16 @@ onUnmounted(() => {
letter-spacing: -0.025em; letter-spacing: -0.025em;
} }
.section-loading-indicator {
display: flex;
align-items: center;
}
.loading-icon {
opacity: 0.7;
color: var(--color-text-secondary);
}
.icon-only-btn { .icon-only-btn {
flex: 0 0 auto; flex: 0 0 auto;
min-width: auto; min-width: auto;
@@ -329,13 +524,10 @@ onUnmounted(() => {
border: 1px solid var(--color-border-secondary); border: 1px solid var(--color-border-secondary);
border-radius: 10px; border-radius: 10px;
background: var(--color-surface); background: var(--color-surface);
backdrop-filter: blur(10px);
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-sm);
flex: 1; flex: 1;
min-width: 0; min-width: 0;
text-align: center; text-align: center;
@@ -349,122 +541,126 @@ onUnmounted(() => {
} }
.action-btn:hover:not(:disabled) { .action-btn:hover:not(:disabled) {
transform: translateY(-1px);
background: var(--color-surface-elevated); background: var(--color-surface-elevated);
border-color: rgba(59, 130, 246, 0.3); border-color: rgba(59, 130, 246, 0.3);
box-shadow: var(--shadow-md);
} }
.action-btn:active { .action-btn:active {
transform: translateY(0); opacity: 0.8;
} }
.action-btn:disabled { .action-btn:disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
transform: none; }
.action-btn.loading {
opacity: 0.8;
cursor: wait !important;
pointer-events: none;
}
.action-btn.loading .loading-icon {
opacity: 1;
} }
.service-btn.running { .service-btn.running {
background: linear-gradient(135deg, rgb(239, 68, 68) 0%, rgb(220, 38, 38) 100%); background: rgb(239, 68, 68);
color: white; color: white;
border-color: rgba(220, 38, 38, 0.3); border-color: rgba(220, 38, 38, 0.3);
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.2);
} }
.service-btn.running:hover:not(:disabled) { .service-btn.running:hover:not(:disabled) {
background: linear-gradient(135deg, rgb(220, 38, 38) 0%, rgb(185, 28, 28) 100%); background: rgb(220, 38, 38);
box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2);
} }
.service-btn:not(.running) { .service-btn:not(.running) {
background: linear-gradient(135deg, rgb(16, 185, 129) 0%, rgb(5, 150, 105) 100%); background: rgb(16, 185, 129);
color: white; color: white;
border-color: rgba(5, 150, 105, 0.3); border-color: rgba(5, 150, 105, 0.3);
box-shadow: 0 2px 4px rgba(16, 185, 129, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.2);
} }
.service-btn:not(.running):hover:not(:disabled) { .service-btn:not(.running):hover:not(:disabled) {
background: linear-gradient(135deg, rgb(5, 150, 105) 0%, rgb(4, 120, 87) 100%); background: rgb(5, 150, 105);
box-shadow: 0 4px 8px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2);
} }
.restart-btn:hover:not(:disabled) { .restart-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgb(251, 191, 36) 0%, rgb(245, 158, 11) 100%); background: rgb(251, 191, 36);
color: white; color: white;
border-color: rgba(245, 158, 11, 0.3); border-color: rgba(245, 158, 11, 0.3);
} }
.web-btn:hover:not(:disabled) { .web-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgb(59, 130, 246) 0%, rgb(37, 99, 235) 100%); background: rgb(59, 130, 246);
color: white; color: white;
border-color: rgba(37, 99, 235, 0.3); border-color: rgba(37, 99, 235, 0.3);
} }
.config-btn:hover:not(:disabled) { .config-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgb(139, 92, 246) 0%, rgb(124, 58, 237) 100%); background: rgb(139, 92, 246);
color: white; color: white;
border-color: rgba(124, 58, 237, 0.3); border-color: rgba(124, 58, 237, 0.3);
} }
.test-btn:hover:not(:disabled) { .test-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgb(6, 182, 212) 0%, rgb(8, 145, 178) 100%); background: rgb(6, 182, 212);
color: white; color: white;
border-color: rgba(8, 145, 178, 0.3); border-color: rgba(8, 145, 178, 0.3);
} }
.mount-btn:hover:not(:disabled) { .mount-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgb(249, 115, 22) 0%, rgb(234, 88, 12) 100%); background: rgb(249, 115, 22);
color: white; color: white;
border-color: rgba(234, 88, 12, 0.3); border-color: rgba(234, 88, 12, 0.3);
} }
.password-btn:hover:not(:disabled) { .password-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgb(168, 85, 247) 0%, rgb(147, 51, 234) 100%); background: rgb(168, 85, 247);
color: white; color: white;
border-color: rgba(147, 51, 234, 0.3); border-color: rgba(147, 51, 234, 0.3);
} }
.reset-password-btn:hover:not(:disabled) {
background: rgb(239, 68, 68);
color: white;
border-color: rgba(220, 38, 38, 0.3);
}
.service-indicator-btn { .service-indicator-btn {
background: var(--color-surface); background: var(--color-surface);
border-color: var(--color-border-secondary); border-color: var(--color-border-secondary);
} }
.service-indicator-btn.active { .service-indicator-btn.active {
background: linear-gradient(135deg, rgb(239, 68, 68) 0%, rgb(220, 38, 38) 100%); background: rgb(239, 68, 68);
color: white; color: white;
border-color: rgba(5, 150, 105, 0.3); border-color: rgba(220, 38, 38, 0.3);
box-shadow: 0 2px 4px rgba(16, 185, 129, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.2);
} }
.service-indicator-btn.active:hover:not(:disabled) { .service-indicator-btn.active:hover:not(:disabled) {
background: linear-gradient(135deg, rgb(220, 38, 38) 0%, rgb(185, 28, 28) 100%); background: rgb(220, 38, 38);
border-color: rgba(220, 38, 38, 0.3); border-color: rgba(220, 38, 38, 0.3);
box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2);
} }
.service-indicator-btn:not(.active):not(:disabled) { .service-indicator-btn:not(.active):not(:disabled) {
background: linear-gradient(135deg, rgb(16, 185, 129) 0%, rgb(5, 150, 105) 100%); background: rgb(16, 185, 129);
color: white; color: white;
border-color: rgba(5, 150, 105, 0.3); border-color: rgba(5, 150, 105, 0.3);
box-shadow: 0 4px 8px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2);
} }
.service-indicator-btn:not(.active):hover:not(:disabled) { .service-indicator-btn:not(.active):hover:not(:disabled) {
background: linear-gradient(135deg, rgb(5, 150, 105) 0%, rgb(4, 120, 87) 100%); background: rgb(5, 150, 105);
color: white; color: white;
border-color: rgba(5, 150, 105, 0.3); border-color: rgba(5, 150, 105, 0.3);
box-shadow: 0 4px 8px rgba(16, 185, 129, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2);
} }
.settings-btn:hover:not(:disabled) { .settings-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgb(100, 116, 139) 0%, rgb(71, 85, 105) 100%); background: rgb(100, 116, 139);
color: white; color: white;
border-color: rgba(71, 85, 105, 0.3); border-color: rgba(71, 85, 105, 0.3);
} }
.custom-services-btn:hover:not(:disabled) { .custom-services-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgb(139, 92, 246) 0%, rgb(124, 58, 237) 100%); background: rgb(139, 92, 246);
color: white; color: white;
border-color: rgba(124, 58, 237, 0.3); border-color: rgba(124, 58, 237, 0.3);
} }
@@ -487,7 +683,6 @@ onUnmounted(() => {
cursor: pointer; cursor: pointer;
padding: 0.375rem; padding: 0.375rem;
border-radius: 8px; border-radius: 8px;
transition: background-color 0.2s ease;
flex: 1; flex: 1;
white-space: nowrap; white-space: nowrap;
} }
@@ -511,6 +706,41 @@ onUnmounted(() => {
user-select: none; user-select: none;
} }
.firewall-toggle-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-secondary);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text-primary);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.firewall-toggle-btn:hover:not(:disabled) {
background: var(--color-surface-elevated);
border-color: rgba(59, 130, 246, 0.3);
}
.firewall-toggle-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.firewall-toggle-btn.active {
background: rgb(16, 185, 129);
color: white;
border-color: rgba(5, 150, 105, 0.3);
}
.firewall-toggle-btn.active:hover:not(:disabled) {
background: rgb(5, 150, 105);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.actions-grid { .actions-grid {
gap: 1.5rem; gap: 1.5rem;

View File

@@ -19,10 +19,10 @@
<div class="actions-section"> <div class="actions-section">
<div class="action-buttons"> <div class="action-buttons">
<button <button
@click="installService" v-if="serviceStatus !== 'running' && serviceStatus !== 'stopped'"
:disabled="actionLoading || serviceStatus === 'installed'" :disabled="actionLoading || serviceStatus === 'installed'"
class="action-btn install-btn" class="action-btn install-btn"
v-if="serviceStatus !== 'running' && serviceStatus !== 'stopped'" @click="installService"
> >
<component :is="actionLoading && currentAction === 'install' ? LoaderIcon : Download" :size="16" /> <component :is="actionLoading && currentAction === 'install' ? LoaderIcon : Download" :size="16" />
<span>{{ <span>{{
@@ -33,10 +33,10 @@
</button> </button>
<button <button
@click="startService" v-if="serviceStatus === 'installed' || serviceStatus === 'stopped'"
:disabled="actionLoading || (serviceStatus !== 'installed' && serviceStatus !== 'stopped')" :disabled="actionLoading || (serviceStatus !== 'installed' && serviceStatus !== 'stopped')"
class="action-btn start-btn" class="action-btn start-btn"
v-if="serviceStatus === 'installed' || serviceStatus === 'stopped'" @click="startService"
> >
<component :is="actionLoading && currentAction === 'start' ? LoaderIcon : Play" :size="16" /> <component :is="actionLoading && currentAction === 'start' ? LoaderIcon : Play" :size="16" />
<span>{{ <span>{{
@@ -45,10 +45,10 @@
</button> </button>
<button <button
@click="stopService" v-if="serviceStatus === 'running'"
:disabled="actionLoading" :disabled="actionLoading"
class="action-btn stop-btn" class="action-btn stop-btn"
v-if="serviceStatus === 'running'" @click="stopService"
> >
<component :is="actionLoading && currentAction === 'stop' ? LoaderIcon : Stop" :size="16" /> <component :is="actionLoading && currentAction === 'stop' ? LoaderIcon : Stop" :size="16" />
<span>{{ <span>{{
@@ -57,10 +57,10 @@
</button> </button>
<button <button
@click="showUninstallDialog = true" v-if="serviceStatus !== 'not-installed'"
:disabled="actionLoading" :disabled="actionLoading"
class="action-btn uninstall-btn" class="action-btn uninstall-btn"
v-if="serviceStatus !== 'not-installed'" @click="showUninstallDialog = true"
> >
<component :is="actionLoading && currentAction === 'uninstall' ? LoaderIcon : Trash2" :size="16" /> <component :is="actionLoading && currentAction === 'uninstall' ? LoaderIcon : Trash2" :size="16" />
<span>{{ <span>{{
@@ -87,36 +87,36 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useTranslation } from '../../composables/useI18n'
import { import {
CheckCircle2,
Circle,
Download, Download,
Loader2 as LoaderIcon,
Play, Play,
Server,
Square as Stop, Square as Stop,
Trash2, Trash2,
Loader2 as LoaderIcon, XCircle
CheckCircle2,
XCircle,
Circle,
Server
} from 'lucide-vue-next' } from 'lucide-vue-next'
import Card from '../ui/Card.vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import ConfirmDialog from '../ui/ConfirmDialog.vue'
import { TauriAPI } from '../../api/tauri'
import { useRcloneStore } from '@/stores/rclone'
import { useAppStore } from '../../stores/app'
const store = useAppStore() import { useRcloneStore } from '@/stores/rclone'
import { TauriAPI } from '../../api/tauri'
import { useTranslation } from '../../composables/useI18n'
import Card from '../ui/CardPage.vue'
import ConfirmDialog from '../ui/ConfirmDialog.vue'
const rcloneStore = useRcloneStore()
const { t } = useTranslation() const { t } = useTranslation()
const rcloneStore = useRcloneStore()
const serviceStatus = ref<'not-installed' | 'installed' | 'running' | 'error' | 'stopped'>('not-installed') const serviceStatus = ref<'not-installed' | 'installed' | 'running' | 'error' | 'stopped'>('not-installed')
const actionLoading = ref(false) const actionLoading = ref(false)
const currentAction = ref('') const currentAction = ref('')
const showUninstallDialog = ref(false) const showUninstallDialog = ref(false)
let statusCheckInterval: number | null = null const statusCheckInterval: number | null = null
const statusClass = computed(() => { const statusClass = computed(() => {
switch (serviceStatus.value) { switch (serviceStatus.value) {
@@ -227,7 +227,17 @@ const stopService = async () => {
if (!result) { if (!result) {
throw new Error('Service stop failed') throw new Error('Service stop failed')
} }
await checkServiceStatus() let attempts = 0
const maxAttempts = 5
for (let i = 0; i < maxAttempts; i++) {
const status = await checkServiceStatus()
if (status === 'stopped' || status === 'not-installed' || status === 'error') {
serviceStatus.value = status
break
}
attempts++
await new Promise(resolve => setTimeout(resolve, 3000))
}
} catch (error) { } catch (error) {
console.error('Failed to stop service:', error) console.error('Failed to stop service:', error)
serviceStatus.value = 'error' serviceStatus.value = 'error'
@@ -267,7 +277,6 @@ const cancelUninstall = () => {
onMounted(async () => { onMounted(async () => {
await checkServiceStatus() await checkServiceStatus()
statusCheckInterval = window.setInterval(checkServiceStatus, (store.settings.app.monitor_interval || 5) * 1000)
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -438,7 +447,6 @@ onUnmounted(() => {
border: none; border: none;
border-radius: 0.5rem; border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s;
flex: 1; flex: 1;
justify-content: center; justify-content: center;
min-width: 7rem; min-width: 7rem;
@@ -494,21 +502,6 @@ onUnmounted(() => {
cursor: not-allowed; cursor: not-allowed;
} }
/* Loading animation */
.action-btn [data-lucide='loader-2'],
.logs-refresh-btn [data-lucide='loader-2'] {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Responsive design */ /* Responsive design */
@media (max-width: 768px) { @media (max-width: 768px) {
.action-buttons { .action-buttons {

View File

@@ -1,3 +1,4 @@
<!-- eslint-disable vue/no-v-html -->
<template> <template>
<Card <Card
:title="t('update.title')" :title="t('update.title')"
@@ -12,8 +13,8 @@
<h4>{{ t('update.currentVersion') }}</h4> <h4>{{ t('update.currentVersion') }}</h4>
<span class="version-tag">v{{ currentVersion }}</span> <span class="version-tag">v{{ currentVersion }}</span>
</div> </div>
<button @click="checkForUpdates" :disabled="checking || downloading || installing" class="check-update-btn"> <button :disabled="checking || downloading || installing" class="check-update-btn" @click="checkForUpdates">
<RefreshCw :class="{ 'animate-spin': checking }" :size="16" /> <RefreshCw :size="16" />
{{ checking ? t('update.checking') : t('update.checkForUpdates') }} {{ checking ? t('update.checking') : t('update.checkForUpdates') }}
</button> </button>
</div> </div>
@@ -21,7 +22,7 @@
<div class="settings-row"> <div class="settings-row">
<div class="auto-check-setting"> <div class="auto-check-setting">
<label class="checkbox-container"> <label class="checkbox-container">
<input type="checkbox" v-model="autoCheckEnabled" @change="toggleAutoCheck" :disabled="settingsLoading" /> <input v-model="autoCheckEnabled" type="checkbox" :disabled="settingsLoading" @change="toggleAutoCheck" />
<span class="label-text">{{ t('update.autoCheck') }}</span> <span class="label-text">{{ t('update.autoCheck') }}</span>
</label> </label>
</div> </div>
@@ -32,7 +33,7 @@
<AlertCircle :size="16" /> <AlertCircle :size="16" />
<span>{{ error }}</span> <span>{{ error }}</span>
</div> </div>
<button @click="clearError" class="clear-error-btn">×</button> <button class="clear-error-btn" @click="clearError">×</button>
</div> </div>
<div v-if="!updateCheck?.hasUpdate && lastChecked && !checking && !error" class="no-updates"> <div v-if="!updateCheck?.hasUpdate && lastChecked && !checking && !error" class="no-updates">
@@ -100,11 +101,11 @@
</div> </div>
</div> </div>
<div class="update-actions" v-if="!downloading"> <div v-if="!downloading" class="update-actions">
<button <button
@click="downloadAndInstall"
:disabled="!selectedAsset || checking || downloading || installing" :disabled="!selectedAsset || checking || downloading || installing"
class="install-btn" class="install-btn"
@click="downloadAndInstall"
> >
<Download :size="16" /> <Download :size="16" />
{{ t('update.downloadAndInstall') }} {{ t('update.downloadAndInstall') }}
@@ -124,7 +125,7 @@
<Info :size="20" class="notification-icon" /> <Info :size="20" class="notification-icon" />
<div class="notification-text"> <div class="notification-text">
<span>{{ t('update.backgroundUpdateAvailable') }}</span> <span>{{ t('update.backgroundUpdateAvailable') }}</span>
<button @click="showBackgroundUpdate" class="show-update-btn"> <button class="show-update-btn" @click="showBackgroundUpdate">
{{ t('update.showUpdate') }} {{ t('update.showUpdate') }}
</button> </button>
</div> </div>
@@ -135,13 +136,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue' import { AlertCircle, ArrowRight, CheckCircle, CheckCircle2, Download, Info, RefreshCw } from 'lucide-vue-next'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { formatBytes } from '@/utils/formatters'
import { TauriAPI } from '../../api/tauri'
import { useTranslation } from '../../composables/useI18n' import { useTranslation } from '../../composables/useI18n'
import { useAppStore } from '../../stores/app' import { useAppStore } from '../../stores/app'
import { TauriAPI } from '../../api/tauri' import Card from '../ui/CardPage.vue'
import Card from '../ui/Card.vue'
import { formatBytes } from '@/utils/formatters'
import { RefreshCw, Download, ArrowRight, CheckCircle, AlertCircle, Info, CheckCircle2 } from 'lucide-vue-next'
interface Props { interface Props {
isStandalone?: boolean isStandalone?: boolean
@@ -177,7 +180,7 @@ let backgroundUpdateUnlisten: (() => void) | null = null
let downloadProgressUnlisten: (() => void) | null = null let downloadProgressUnlisten: (() => void) | null = null
let installStartedUnlisten: (() => void) | null = null let installStartedUnlisten: (() => void) | null = null
let installErrorUnlisten: (() => void) | null = null let installErrorUnlisten: (() => void) | null = null
let appRestartingUnlisten: (() => void) | null = null let appQuitEventUnsubscriber: (() => void) | null = null
const checkForUpdates = async () => { const checkForUpdates = async () => {
if (checking.value || downloading.value || installing.value) return if (checking.value || downloading.value || installing.value) return
@@ -204,7 +207,7 @@ const checkForUpdates = async () => {
} }
} catch (err: any) { } catch (err: any) {
console.error('Failed to check for updates:', err) console.error('Failed to check for updates:', err)
error.value = err.message || t('update.checkError') error.value = t('update.checkError') + String(err ? `: ${err}` : '')
} finally { } finally {
checking.value = false checking.value = false
} }
@@ -358,13 +361,13 @@ onMounted(async () => {
} }
try { try {
appRestartingUnlisten = await TauriAPI.updater.onAppRestarting(() => { appQuitEventUnsubscriber = await TauriAPI.updater.onAppQuit(() => {
installationStatus.value = t('update.restartingApp') installationStatus.value = t('update.quitApp')
installationStatusType.value = 'success' installationStatusType.value = 'success'
}) })
} catch (err) { } catch (err) {
console.warn('App restarting listener not available:', err) console.warn('App restarting listener not available:', err)
appRestartingUnlisten = null appQuitEventUnsubscriber = null
} }
if (autoCheckEnabled.value) { if (autoCheckEnabled.value) {
await checkForUpdates() await checkForUpdates()
@@ -400,7 +403,7 @@ onUnmounted(() => {
} }
try { try {
appRestartingUnlisten?.() appQuitEventUnsubscriber?.()
} catch (err) { } catch (err) {
console.warn('Error unregistering app restarting listener:', err) console.warn('Error unregistering app restarting listener:', err)
} }
@@ -456,7 +459,6 @@ onUnmounted(() => {
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s;
} }
.check-update-btn:hover:not(:disabled) { .check-update-btn:hover:not(:disabled) {
@@ -468,19 +470,6 @@ onUnmounted(() => {
cursor: not-allowed; cursor: not-allowed;
} }
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.settings-row { .settings-row {
padding: 1rem; padding: 1rem;
background: var(--color-surface); background: var(--color-surface);
@@ -673,7 +662,6 @@ onUnmounted(() => {
border: 2px solid transparent; border: 2px solid transparent;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: all 0.2s;
} }
.asset-item:hover { .asset-item:hover {
@@ -773,7 +761,6 @@ onUnmounted(() => {
.progress-fill { .progress-fill {
height: 100%; height: 100%;
background: var(--color-success); background: var(--color-success);
transition: width 0.3s ease;
} }
.progress-details { .progress-details {
@@ -799,7 +786,6 @@ onUnmounted(() => {
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
transition: background-color 0.2s;
} }
.install-btn:hover:not(:disabled) { .install-btn:hover:not(:disabled) {
@@ -902,7 +888,6 @@ onUnmounted(() => {
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.8rem; font-size: 0.8rem;
transition: background-color 0.2s;
} }
.show-update-btn:hover { .show-update-btn:hover {

View File

@@ -8,8 +8,12 @@
<h4>{{ t('dashboard.versionManager.openlist') }}</h4> <h4>{{ t('dashboard.versionManager.openlist') }}</h4>
<span class="current-version">{{ currentVersions.openlist }}</span> <span class="current-version">{{ currentVersions.openlist }}</span>
</div> </div>
<button @click="refreshVersions" :disabled="refreshing" class="refresh-icon-btn"> <button :disabled="refreshing" class="refresh-icon-btn" @click="refreshVersions">
<component :is="refreshing ? LoaderIcon : RefreshCw" :size="16" /> <component
:is="refreshing ? Loader : RefreshCw"
:size="16"
:class="{ 'rotate-animation': refreshing && !loading.openlist }"
/>
</button> </button>
</div> </div>
<div class="version-controls"> <div class="version-controls">
@@ -20,14 +24,16 @@
</option> </option>
</select> </select>
<button <button
@click="updateVersion('openlist')"
:disabled=" :disabled="
!selectedVersions.openlist || loading.openlist || selectedVersions.openlist === currentVersions.openlist !selectedVersions.openlist || loading.openlist || selectedVersions.openlist === currentVersions.openlist
" "
class="update-btn" class="update-btn"
@click="updateVersion('openlist')"
> >
<component :is="loading.openlist ? LoaderIcon : Download" :size="14" /> <component :is="loading.openlist ? Loader : Download" :size="14" />
<span>{{ loading.openlist ? t('common.loading') : t('dashboard.versionManager.update') }}</span> <span>{{
loading.openlist ? t('dashboard.versionManager.updating') : t('dashboard.versionManager.update')
}}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -37,8 +43,12 @@
<h4>{{ t('dashboard.versionManager.rclone') }}</h4> <h4>{{ t('dashboard.versionManager.rclone') }}</h4>
<span class="current-version">{{ currentVersions.rclone }}</span> <span class="current-version">{{ currentVersions.rclone }}</span>
</div> </div>
<button @click="refreshVersions" :disabled="refreshing" class="refresh-icon-btn"> <button :disabled="refreshing" class="refresh-icon-btn" @click="refreshVersions">
<component :is="refreshing ? LoaderIcon : RefreshCw" :size="16" /> <component
:is="refreshing ? Loader : RefreshCw"
:size="16"
:class="{ 'rotate-animation': refreshing && !loading.rclone }"
/>
</button> </button>
</div> </div>
<div class="version-controls"> <div class="version-controls">
@@ -49,14 +59,16 @@
</option> </option>
</select> </select>
<button <button
@click="updateVersion('rclone')"
:disabled=" :disabled="
!selectedVersions.rclone || loading.rclone || selectedVersions.rclone === currentVersions.rclone !selectedVersions.rclone || loading.rclone || selectedVersions.rclone === currentVersions.rclone
" "
class="update-btn" class="update-btn"
@click="updateVersion('rclone')"
> >
<component :is="loading.rclone ? LoaderIcon : Download" :size="14" /> <component :is="loading.rclone ? Loader : Download" :size="14" />
<span>{{ loading.rclone ? t('common.loading') : t('dashboard.versionManager.update') }}</span> <span>{{
loading.rclone ? t('dashboard.versionManager.updating') : t('dashboard.versionManager.update')
}}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -66,11 +78,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { Download, Loader, RefreshCw } from 'lucide-vue-next'
import { useTranslation } from '../../composables/useI18n' import { onMounted, ref } from 'vue'
import { Download, RefreshCw, Loader2 as LoaderIcon } from 'lucide-vue-next'
import Card from '../ui/Card.vue'
import { TauriAPI } from '../../api/tauri' import { TauriAPI } from '../../api/tauri'
import { useTranslation } from '../../composables/useI18n'
import Card from '../ui/CardPage.vue'
const { t } = useTranslation() const { t } = useTranslation()
@@ -144,20 +157,95 @@ const refreshVersions = async () => {
const updateVersion = async (type: 'openlist' | 'rclone') => { const updateVersion = async (type: 'openlist' | 'rclone') => {
loading.value[type] = true loading.value[type] = true
try { try {
const result = await TauriAPI.bin.updateVersion(type, selectedVersions.value[type]) const result = await TauriAPI.bin.updateVersion(type, selectedVersions.value[type])
currentVersions.value[type] = selectedVersions.value[type] currentVersions.value[type] = selectedVersions.value[type]
selectedVersions.value[type] = '' selectedVersions.value[type] = ''
showNotification(
'success',
t('dashboard.versionManager.updateSuccess', { type: type.charAt(0).toUpperCase() + type.slice(1) })
)
console.log(`Updated ${type}:`, result) console.log(`Updated ${type}:`, result)
} catch (error) { } catch (error) {
console.error(`Failed to update ${type}:`, error) console.error(`Failed to update ${type}:`, error)
const errorMessage = error instanceof Error ? error.message : String(error)
showNotification(
'error',
t('dashboard.versionManager.updateError', {
type: type.charAt(0).toUpperCase() + type.slice(1),
error: errorMessage
})
)
} finally { } finally {
loading.value[type] = false loading.value[type] = false
} }
} }
const showNotification = (type: 'success' | 'error', message: string) => {
const notification = document.createElement('div')
const bgColor =
type === 'success'
? 'linear-gradient(135deg, rgb(16, 185, 129), rgb(5, 150, 105))'
: 'linear-gradient(135deg, rgb(239, 68, 68), rgb(220, 38, 38))'
const icon = type === 'success' ? '✓' : '⚠'
notification.innerHTML = `
<div style="
position: fixed;
top: 20px;
right: 20px;
background: ${bgColor};
color: white;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
font-weight: 500;
max-width: 350px;
word-break: break-word;
animation: slideInRight 0.3s ease-out;
">
<div style="display: flex; align-items: center; gap: 8px;">
<div style="font-size: 18px;">${icon}</div>
<div style="font-size: 14px;">${message}</div>
</div>
</div>
`
if (!document.querySelector('#notification-styles')) {
const style = document.createElement('style')
style.id = 'notification-styles'
style.innerHTML = `
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
`
document.head.appendChild(style)
}
document.body.appendChild(notification)
setTimeout(() => {
if (notification.parentNode) {
notification.style.animation = 'slideInRight 0.3s ease-in reverse'
setTimeout(() => {
notification.parentNode?.removeChild(notification)
}, 300)
}
}, 4000)
}
onMounted(() => { onMounted(() => {
refreshVersions() refreshVersions()
}) })
@@ -184,7 +272,6 @@ onMounted(() => {
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 0.875rem; padding: 0.875rem;
background: var(--color-background-tertiary, rgb(249 250 251)); background: var(--color-background-tertiary, rgb(249 250 251));
transition: all 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
@@ -192,7 +279,7 @@ onMounted(() => {
.version-item:hover { .version-item:hover {
border-color: var(--color-border, rgb(209 213 219)); border-color: var(--color-border, rgb(209 213 219));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); background: var(--color-background-secondary, rgb(243 244 246));
} }
:root.dark .version-item, :root.dark .version-item,
@@ -204,7 +291,7 @@ onMounted(() => {
:root.dark .version-item:hover, :root.dark .version-item:hover,
:root.auto.dark .version-item:hover { :root.auto.dark .version-item:hover {
border-color: var(--color-border, rgb(75 85 99)); border-color: var(--color-border, rgb(75 85 99));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); background: var(--color-background-primary, rgb(55 65 81));
} }
.version-header { .version-header {
@@ -234,7 +321,6 @@ onMounted(() => {
border: 1px solid var(--color-border-secondary, rgb(209 213 219)); border: 1px solid var(--color-border-secondary, rgb(209 213 219));
border-radius: 0.5rem; border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -308,7 +394,6 @@ onMounted(() => {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-text-primary, rgb(17 24 39)); color: var(--color-text-primary, rgb(17 24 39));
width: 100%; width: 100%;
transition: border-color 0.2s ease;
} }
:root.dark .version-select, :root.dark .version-select,
@@ -337,7 +422,6 @@ onMounted(() => {
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap; white-space: nowrap;
width: 100%; width: 100%;
} }
@@ -365,18 +449,4 @@ onMounted(() => {
padding: 0.75rem; padding: 0.75rem;
} }
} }
.refresh-icon-btn [data-lucide='loader-2'],
.update-btn [data-lucide='loader-2'] {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style> </style>

View File

@@ -28,6 +28,7 @@ interface Props {
} }
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
title: '',
variant: 'default', variant: 'default',
hover: false, hover: false,
interactive: false interactive: false
@@ -37,29 +38,22 @@ withDefaults(defineProps<Props>(), {
<style scoped> <style scoped>
.card { .card {
background: var(--color-surface); background: var(--color-surface);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 16px; border-radius: 16px;
border: 1px solid var(--color-border-secondary); border: 1px solid var(--color-border-secondary);
box-shadow: var(--shadow-md);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.card--elevated { .card--elevated {
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-sm);
} }
.card--outlined { .card--outlined {
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
box-shadow: var(--shadow-sm);
} }
.card--glass { .card--glass {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
} }
@@ -73,16 +67,15 @@ withDefaults(defineProps<Props>(), {
/* Interactive states */ /* Interactive states */
.card--hover:hover, .card--hover:hover,
.card--interactive:hover { .card--interactive:hover {
transform: translateY(-4px); background: var(--color-surface-elevated);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04), border-color: rgba(59, 130, 246, 0.2);
inset 0 1px 0 rgba(255, 255, 255, 0.3);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.card--hover:hover, .card--hover:hover,
.card--interactive:hover { .card--interactive:hover {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3), background: var(--color-surface-elevated);
inset 0 1px 0 rgba(255, 255, 255, 0.08); border-color: rgba(59, 130, 246, 0.2);
} }
} }
@@ -91,7 +84,7 @@ withDefaults(defineProps<Props>(), {
} }
.card--interactive:active { .card--interactive:active {
transform: translateY(-2px); opacity: 0.9;
} }
/* Card structure */ /* Card structure */

View File

@@ -8,10 +8,10 @@
<p class="dialog-message">{{ message }}</p> <p class="dialog-message">{{ message }}</p>
</div> </div>
<div class="dialog-actions"> <div class="dialog-actions">
<button @click="onCancel" class="dialog-btn cancel-btn"> <button class="dialog-btn cancel-btn" @click="onCancel">
{{ cancelText }} {{ cancelText }}
</button> </button>
<button @click="onConfirm" class="dialog-btn confirm-btn" :class="confirmButtonClass"> <button class="dialog-btn confirm-btn" :class="confirmButtonClass" @click="onConfirm">
{{ confirmText }} {{ confirmText }}
</button> </button>
</div> </div>
@@ -74,13 +74,14 @@ export default {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
backdrop-filter: blur(4px);
} }
.dialog-container { .dialog-container {
background: white; background: white;
border-radius: 0.75rem; border-radius: 0.75rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 28rem; max-width: 28rem;
width: 90%; width: 90%;
max-height: 80vh; max-height: 80vh;
@@ -138,7 +139,6 @@ export default {
font-weight: 500; font-weight: 500;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: all 0.2s;
min-width: 4rem; min-width: 4rem;
} }

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useTranslation } from '../../composables/useI18n'
import { ChevronDown } from 'lucide-vue-next' import { ChevronDown } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { useTranslation } from '../../composables/useI18n'
const { currentLocale, switchLanguage } = useTranslation() const { currentLocale, switchLanguage } = useTranslation()
const isOpen = ref(false) const isOpen = ref(false)
@@ -35,18 +36,18 @@ onMounted(() => {
<template> <template>
<div ref="dropdownRef" class="language-switcher relative"> <div ref="dropdownRef" class="language-switcher relative">
<button @click="toggleDropdown" class="language-button"> <button class="language-button" @click="toggleDropdown">
<span class="language-label">{{ currentLanguage?.name }}</span> <span class="language-label">{{ currentLanguage?.name }}</span>
<ChevronDown :size="12" :class="{ 'rotate-180': isOpen }" class="transition-transform" /> <ChevronDown :size="12" :class="{ flipped: isOpen }" />
</button> </button>
<div v-if="isOpen" class="language-dropdown"> <div v-if="isOpen" class="language-dropdown">
<div <div
v-for="language in languages" v-for="language in languages"
:key="language.code" :key="language.code"
@click="handleLanguageChange(language.code)"
class="language-option" class="language-option"
:class="{ active: language.code === currentLocale }" :class="{ active: language.code === currentLocale }"
@click="handleLanguageChange(language.code)"
> >
<span class="language-flag">{{ language.flag }}</span> <span class="language-flag">{{ language.flag }}</span>
<span class="language-name">{{ language.name }}</span> <span class="language-name">{{ language.name }}</span>
@@ -72,7 +73,6 @@ onMounted(() => {
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 0.875rem; font-size: 0.875rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease;
min-width: 120px; min-width: 120px;
} }
@@ -86,6 +86,10 @@ onMounted(() => {
text-align: center; text-align: center;
} }
.flipped {
opacity: 0.7;
}
.language-dropdown { .language-dropdown {
position: absolute; position: absolute;
top: 100%; top: 100%;
@@ -107,7 +111,6 @@ onMounted(() => {
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem; padding: 0.75rem;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease;
font-size: 0.875rem; font-size: 0.875rem;
} }

View File

@@ -56,7 +56,6 @@ defineProps<Props>()
.status-indicator.active .status-dot { .status-indicator.active .status-dot {
background-color: rgb(34 197 94); background-color: rgb(34 197 94);
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
} }
.status-text { .status-text {

View File

@@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { Monitor, Moon, Sun } from 'lucide-vue-next'
import { computed } from 'vue' import { computed } from 'vue'
import { useAppStore } from '../../stores/app'
import { useTranslation } from '../../composables/useI18n'
import { Sun, Moon, Monitor } from 'lucide-vue-next'
const store = useAppStore() import { useTranslation } from '../../composables/useI18n'
import { useAppStore } from '../../stores/app'
const appStore = useAppStore()
const { t } = useTranslation() const { t } = useTranslation()
const currentTheme = computed(() => store.settings.app.theme || 'light') const currentTheme = computed(() => appStore.settings.app.theme || 'light')
const themeOptions = computed(() => [ const themeOptions = computed(() => [
{ {
@@ -35,13 +36,13 @@ const currentThemeOption = computed(
) )
const toggleTheme = () => { const toggleTheme = () => {
store.toggleTheme() appStore.toggleTheme()
} }
</script> </script>
<template> <template>
<div class="theme-switcher"> <div class="theme-switcher">
<button @click="toggleTheme" class="theme-toggle-btn" :title="t('settings.theme.toggle')"> <button class="theme-toggle-btn" :title="t('settings.theme.toggle')" @click="toggleTheme">
<component :is="currentThemeOption.icon" :size="18" /> <component :is="currentThemeOption.icon" :size="18" />
<span class="theme-label">{{ currentThemeOption.label }}</span> <span class="theme-label">{{ currentThemeOption.label }}</span>
</button> </button>
@@ -65,14 +66,12 @@ const toggleTheme = () => {
color: var(--color-text-secondary); color: var(--color-text-secondary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
font-size: 0.875rem; font-size: 0.875rem;
} }
.theme-toggle-btn:hover { .theme-toggle-btn:hover {
background: var(--color-surface-elevated); background: var(--color-surface-elevated);
color: var(--color-text-primary); color: var(--color-text-primary);
transform: translateY(-1px);
} }
.theme-label { .theme-label {

View File

@@ -18,7 +18,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { getCurrentWindow } from '@tauri-apps/api/window' import { getCurrentWindow } from '@tauri-apps/api/window'
import { ref, onMounted, onUnmounted } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import WindowControls from './WindowControls.vue' import WindowControls from './WindowControls.vue'
interface Props { interface Props {
appTitle?: string appTitle?: string

View File

@@ -1,636 +0,0 @@
<script setup lang="ts">
import { computed, ref, onMounted, nextTick, watch } from 'vue'
import { useAppStore } from '../../stores/app'
import { useTranslation } from '../../composables/useI18n'
import { ChevronLeft, ChevronRight, X, Check, Play, FileText, Settings, HardDrive, Home } from 'lucide-vue-next'
const store = useAppStore()
const { t } = useTranslation()
const tutorialSteps = computed(() => [
{
title: t('tutorial.welcome.title'),
content: t('tutorial.welcome.content'),
target: '.app-title',
position: 'center',
showNext: true,
showSkip: true,
icon: Home
},
{
title: t('tutorial.navigation.title'),
content: t('tutorial.navigation.content'),
target: '.nav-menu',
position: 'right',
showNext: true,
showPrev: true,
showSkip: true,
icon: HardDrive
},
{
title: t('tutorial.service.title'),
content: t('tutorial.service.content'),
target: '.service-management-card',
position: 'top',
showNext: true,
showPrev: true,
showSkip: true,
icon: Play
},
{
title: t('tutorial.openlist.title'),
content: t('tutorial.openlist.content'),
target: '.quick-actions-card',
position: 'top',
showNext: true,
showPrev: true,
showSkip: true,
icon: HardDrive
},
{
title: t('tutorial.documentation.title'),
content: t('tutorial.documentation.content'),
target: '.documentation-card',
position: 'bottom',
showNext: true,
showPrev: true,
showSkip: true,
icon: FileText
},
{
title: t('tutorial.settings.title'),
content: t('tutorial.settings.content'),
target: '.nav-item[href="/settings"]',
position: 'right',
showPrev: true,
showComplete: true,
icon: Settings
}
])
const currentStep = computed(() => tutorialSteps.value[store.tutorialStep] || tutorialSteps.value[0])
const highlightStyle = ref({})
const updateHighlight = async () => {
await nextTick()
if (!currentStep.value.target) {
highlightStyle.value = {}
return
}
const targetElement = document.querySelector(currentStep.value.target) as HTMLElement
if (!targetElement) {
highlightStyle.value = {}
return
}
const rect = targetElement.getBoundingClientRect()
const padding = 8
highlightStyle.value = {
top: `${rect.top - padding}px`,
left: `${rect.left - padding}px`,
width: `${rect.width + padding * 2}px`,
height: `${rect.height + padding * 2}px`
}
}
const getTooltipStyle = () => {
if (!currentStep.value.target)
return {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}
const targetElement = document.querySelector(currentStep.value.target) as HTMLElement
if (!targetElement)
return {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}
const rect = targetElement.getBoundingClientRect()
const position = currentStep.value.position || 'bottom'
const offset = 16
const tooltipWidth = 320
const tooltipHeight = 200
let style: any = {}
let adjustedPosition = position
if (position === 'left' && rect.left < tooltipWidth + offset) {
adjustedPosition = 'right'
} else if (position === 'right' && rect.right + tooltipWidth + offset > window.innerWidth) {
adjustedPosition = 'left'
} else if (position === 'top' && rect.top < tooltipHeight + offset) {
adjustedPosition = 'bottom'
} else if (position === 'bottom' && rect.bottom + tooltipHeight + offset > window.innerHeight) {
adjustedPosition = 'top'
}
switch (adjustedPosition) {
case 'center':
style = {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}
break
case 'top':
style = {
bottom: `${window.innerHeight - rect.top + offset}px`,
left: `${rect.left + rect.width / 2}px`,
transform: 'translateX(-50%)'
}
break
case 'bottom':
style = {
top: `${rect.bottom + offset}px`,
left: `${rect.left + rect.width / 2}px`,
transform: 'translateX(-50%)'
}
break
case 'left':
style = {
top: `${rect.top + rect.height / 2}px`,
right: `${window.innerWidth - rect.left + offset}px`,
transform: 'translateY(-50%)'
}
break
case 'right':
style = {
top: `${rect.top + rect.height / 2}px`,
left: `${rect.right + offset}px`,
transform: 'translateY(-50%)'
}
break
case 'bottom-right':
style = {
top: `${rect.bottom + offset}px`,
left: `${Math.max(16, rect.right - tooltipWidth)}px`
}
break
default:
style = {
top: `${rect.bottom + offset}px`,
left: `${rect.left + rect.width / 2}px`,
transform: 'translateX(-50%)'
}
}
if (style.left && !style.transform?.includes('translateX')) {
const leftPos = parseInt(style.left)
if (leftPos + tooltipWidth > window.innerWidth) {
style.left = `${window.innerWidth - tooltipWidth - 16}px`
}
if (leftPos < 16) {
style.left = '16px'
}
}
if (style.top && !style.transform?.includes('translateY')) {
const topPos = parseInt(style.top)
if (topPos + tooltipHeight > window.innerHeight) {
style.top = `${window.innerHeight - tooltipHeight - 16}px`
}
if (topPos < 16) {
style.top = '16px'
}
}
if (style.bottom) {
const bottomPos = parseInt(style.bottom)
if (window.innerHeight - bottomPos - tooltipHeight < 16) {
delete style.bottom
style.top = '16px'
}
}
if (style.right) {
const rightPos = parseInt(style.right)
if (window.innerWidth - rightPos - tooltipWidth < 16) {
delete style.right
style.left = '16px'
delete style.transform
}
}
if (style.transform?.includes('translate(-50%, -50%)')) {
return style
}
if (style.transform?.includes('translateX(-50%)') && style.left) {
const leftPos = parseInt(style.left)
const halfWidth = tooltipWidth / 2
if (leftPos - halfWidth < 16) {
style.left = `${halfWidth + 16}px`
}
if (leftPos + halfWidth > window.innerWidth - 16) {
style.left = `${window.innerWidth - halfWidth - 16}px`
}
}
if (style.transform?.includes('translateY(-50%)') && style.top) {
const topPos = parseInt(style.top)
const halfHeight = tooltipHeight / 2
if (topPos - halfHeight < 16) {
style.top = `${halfHeight + 16}px`
}
if (topPos + halfHeight > window.innerHeight - 16) {
style.top = `${window.innerHeight - halfHeight - 16}px`
}
}
return style
}
const handleNext = () => {
if (store.tutorialStep < tutorialSteps.value.length - 1) {
store.nextTutorialStep()
updateHighlight()
}
}
const handlePrev = () => {
if (store.tutorialStep > 0) {
store.prevTutorialStep()
updateHighlight()
}
}
const handleSkip = () => {
store.skipTutorial()
}
const handleComplete = () => {
store.completeTutorial()
}
const handleClose = () => {
store.closeTutorial()
}
onMounted(() => {
updateHighlight()
watch(
() => store.tutorialStep,
() => {
setTimeout(() => {
updateHighlight()
}, 100)
}
)
const handleResize = () => {
updateHighlight()
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})
</script>
<template>
<Teleport to="body">
<div v-if="store.showTutorial" class="tutorial-overlay">
<div class="tutorial-backdrop" @click="handleClose" />
<div
v-if="currentStep.target && currentStep.position !== 'center'"
class="tutorial-highlight"
:style="highlightStyle"
/>
<div class="tutorial-tooltip" :style="getTooltipStyle()">
<div class="tooltip-header">
<div class="tooltip-icon">
<component :is="currentStep.icon" :size="20" />
</div>
<h3 class="tooltip-title">{{ currentStep.title }}</h3>
<button class="tooltip-close" @click="handleClose" :title="t('common.close')">
<X :size="18" />
</button>
</div>
<div class="tooltip-content">
<p>{{ currentStep.content }}</p>
</div>
<div class="tooltip-footer">
<div class="step-indicator">
<span class="step-current">{{ store.tutorialStep + 1 }}</span>
<span class="step-divider">/</span>
<span class="step-total">{{ tutorialSteps.length }}</span>
</div>
<div class="tutorial-actions">
<button v-if="currentStep.showSkip" class="btn-skip" @click="handleSkip">
{{ t('tutorial.skip') }}
</button>
<button v-if="currentStep.showPrev" class="btn-prev" @click="handlePrev">
<ChevronLeft :size="16" />
{{ t('tutorial.previous') }}
</button>
<button v-if="currentStep.showNext" class="btn-next" @click="handleNext">
{{ t('tutorial.next') }}
<ChevronRight :size="16" />
</button>
<button v-if="currentStep.showComplete" class="btn-complete" @click="handleComplete">
<Check :size="16" />
{{ t('tutorial.complete') }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.tutorial-overlay {
position: fixed;
inset: 0;
z-index: 10000;
pointer-events: none;
}
.tutorial-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
pointer-events: all;
}
.tutorial-highlight {
position: absolute;
border: 2px solid var(--color-accent);
border-radius: var(--radius-md);
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.2), var(--shadow-lg);
background: rgba(255, 255, 255, 0.05);
pointer-events: none;
transition: all var(--transition-medium);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.2), var(--shadow-lg);
}
50% {
box-shadow: 0 0 0 8px rgba(0, 122, 255, 0.1), var(--shadow-xl);
}
}
.tutorial-tooltip {
position: absolute;
width: 320px;
max-width: calc(100vw - 32px);
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
backdrop-filter: blur(20px);
pointer-events: all;
animation: tooltipEnter 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
z-index: 10001;
}
@keyframes tooltipEnter {
0% {
opacity: 0;
transform: translateY(16px) scale(0.9);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.tooltip-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px 12px 20px;
border-bottom: 1px solid var(--color-border-secondary);
}
.tooltip-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--color-accent);
color: white;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.tooltip-title {
flex: 1;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0;
}
.tooltip-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
color: var(--color-text-tertiary);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.tooltip-close:hover {
background: var(--color-background-secondary);
color: var(--color-text-primary);
}
.tooltip-content {
padding: 16px 20px;
}
.tooltip-content p {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-text-secondary);
}
.tooltip-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px 16px 20px;
border-top: 1px solid var(--color-border-secondary);
}
.step-indicator {
display: flex;
align-items: center;
font-size: 0.75rem;
color: var(--color-text-tertiary);
}
.step-current {
font-weight: 600;
color: var(--color-accent);
}
.step-divider {
margin: 0 4px;
}
.tutorial-actions {
display: flex;
align-items: center;
gap: 8px;
}
.btn-skip {
padding: 6px 12px;
font-size: 0.75rem;
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-tertiary);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-skip:hover {
background: var(--color-background-secondary);
color: var(--color-text-secondary);
}
.btn-prev,
.btn-next,
.btn-complete {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
font-size: 0.75rem;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
font-weight: 500;
}
.btn-prev {
background: var(--color-background-secondary);
color: var(--color-text-primary);
}
.btn-prev:hover {
background: var(--color-background-tertiary);
}
.btn-next,
.btn-complete {
background: var(--color-accent);
color: white;
}
.btn-next:hover,
.btn-complete:hover {
background: var(--color-accent-hover);
}
.btn-complete {
background: var(--color-success);
}
.btn-complete:hover {
background: #2fb344;
}
:root.dark .tutorial-backdrop {
background: rgba(0, 0, 0, 0.8);
}
:root.dark .tutorial-highlight {
border-color: var(--color-accent);
box-shadow: 0 0 0 4px rgba(10, 132, 255, 0.3), var(--shadow-lg);
}
:root.dark .tutorial-tooltip {
background: var(--color-surface-elevated);
border-color: var(--color-border);
}
@media (max-width: 768px) {
.tutorial-tooltip {
width: 280px;
max-width: calc(100vw - 24px);
position: fixed !important;
top: auto !important;
bottom: 20px !important;
left: 50% !important;
right: auto !important;
transform: translateX(-50%) !important;
}
.tooltip-header {
padding: 12px 16px 8px 16px;
}
.tooltip-content {
padding: 12px 16px;
}
.tooltip-footer {
padding: 8px 16px 12px 16px;
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.tutorial-actions {
justify-content: space-between;
width: 100%;
}
.step-indicator {
align-self: center;
}
}
@media (max-width: 480px) {
.tutorial-tooltip {
width: calc(100vw - 32px);
}
.tutorial-actions {
flex-direction: column;
gap: 8px;
}
.btn-prev,
.btn-next,
.btn-complete,
.btn-skip {
width: 100%;
justify-content: center;
}
}
</style>

View File

@@ -10,10 +10,10 @@
<div v-if="message" class="notification-message">{{ message }}</div> <div v-if="message" class="notification-message">{{ message }}</div>
</div> </div>
<div class="notification-actions"> <div class="notification-actions">
<button v-if="showAction" @click="$emit('action')" class="action-btn"> <button v-if="showAction" class="action-btn" @click="$emit('action')">
{{ actionText }} {{ actionText }}
</button> </button>
<button @click="$emit('dismiss')" class="dismiss-btn"> <button class="dismiss-btn" @click="$emit('dismiss')">
<X :size="16" /> <X :size="16" />
</button> </button>
</div> </div>
@@ -23,13 +23,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Download, CheckCircle, AlertCircle, X } from 'lucide-vue-next' import { AlertCircle, CheckCircle, Download, X } from 'lucide-vue-next'
interface Props { interface Props {
visible: boolean visible: boolean
type: 'info' | 'success' | 'warning' | 'error' type?: 'info' | 'success' | 'warning' | 'error'
title: string title: string
message?: string message: string
showAction?: boolean showAction?: boolean
actionText?: string actionText?: string
} }
@@ -133,7 +133,6 @@ const getIcon = () => {
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
transition: background-color 0.2s;
} }
.action-btn:hover { .action-btn:hover {
@@ -147,7 +146,6 @@ const getIcon = () => {
padding: 0.25rem; padding: 0.25rem;
border-radius: 4px; border-radius: 4px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
transition: color 0.2s, background-color 0.2s;
} }
.dismiss-btn:hover { .dismiss-btn:hover {
@@ -155,19 +153,11 @@ const getIcon = () => {
background: var(--color-surface); background: var(--color-surface);
} }
/* Transitions */
.notification-enter-active,
.notification-leave-active {
transition: all 0.3s ease;
}
.notification-enter-from { .notification-enter-from {
opacity: 0; opacity: 0;
transform: translateX(100%) scale(0.95);
} }
.notification-leave-to { .notification-leave-to {
opacity: 0; opacity: 0;
transform: translateX(100%) scale(0.95);
} }
</style> </style>

View File

@@ -1,30 +1,20 @@
<template> <template>
<div class="window-controls"> <button <div class="window-controls">
class="control-btn minimize" <button class="control-btn minimize" :title="t('common.minimize')" @click="$emit('minimize')">
@click="$emit('minimize')"
:title="t('common.minimize')"
>
<Minimize2 :size="12" /> <Minimize2 :size="12" />
</button> </button>
<button <button class="control-btn maximize" :title="t('common.maximize')" @click="$emit('maximize')">
class="control-btn maximize"
@click="$emit('maximize')"
:title="t('common.maximize')"
>
<Maximize2 :size="12" /> <Maximize2 :size="12" />
</button> </button>
<button <button class="control-btn close" :title="t('common.close')" @click="$emit('close')">
class="control-btn close"
@click="$emit('close')"
:title="t('common.close')"
>
<X :size="12" /> <X :size="12" />
</button> </button>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Minimize2, Maximize2, X } from 'lucide-vue-next' import { Maximize2, Minimize2, X } from 'lucide-vue-next'
import { useTranslation } from '../../composables/useI18n' import { useTranslation } from '../../composables/useI18n'
const { t } = useTranslation() const { t } = useTranslation()
@@ -53,7 +43,6 @@ defineEmits<{
color: rgb(107 114 128); color: rgb(107 114 128);
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease-in-out;
} }
.control-btn:hover { .control-btn:hover {
@@ -65,7 +54,7 @@ defineEmits<{
.control-btn { .control-btn {
color: rgb(156 163 175); color: rgb(156 163 175);
} }
.control-btn:hover { .control-btn:hover {
background: rgb(55 65 81); background: rgb(55 65 81);
color: rgb(209 213 219); color: rgb(209 213 219);

View File

@@ -1,11 +1,11 @@
import { useAppStore } from '../stores/app' import { useAppStore } from '../stores/app'
export const useCoreActions = () => { export const useCoreActions = () => {
const store = useAppStore() const appStore = useAppStore()
const startOpenListCore = async () => { const startOpenListCore = async () => {
try { try {
await store.startOpenListCore() await appStore.startOpenListCore()
} catch (error) { } catch (error) {
console.error('Failed to start service:', error) console.error('Failed to start service:', error)
throw error throw error
@@ -14,7 +14,7 @@ export const useCoreActions = () => {
const stopOpenListCore = async () => { const stopOpenListCore = async () => {
try { try {
await store.stopOpenListCore() await appStore.stopOpenListCore()
} catch (error) { } catch (error) {
console.error('Failed to stop service:', error) console.error('Failed to stop service:', error)
throw error throw error
@@ -23,7 +23,7 @@ export const useCoreActions = () => {
const restartOpenListCore = async () => { const restartOpenListCore = async () => {
try { try {
await store.restartOpenListCore() await appStore.restartOpenListCore()
} catch (error) { } catch (error) {
console.error('Failed to restart service:', error) console.error('Failed to restart service:', error)
throw error throw error

View File

@@ -1,35 +0,0 @@
export function useKeyboardShortcuts() {
const handleKeydown = (event: KeyboardEvent, callbacks: Record<string, () => void>) => {
const { metaKey, ctrlKey, shiftKey, key } = event
const modifier = metaKey || ctrlKey
if (modifier && key === 'k' && callbacks.search) {
event.preventDefault()
callbacks.search()
}
if (modifier && shiftKey && key === 'L' && callbacks.logs) {
event.preventDefault()
callbacks.logs()
}
if (modifier && shiftKey && key === 'M' && callbacks.metrics) {
event.preventDefault()
callbacks.metrics()
}
if (modifier && key === '1' && callbacks.dashboard) {
event.preventDefault()
callbacks.dashboard()
}
if (key === 'F11' && callbacks.fullscreen) {
event.preventDefault()
callbacks.fullscreen()
}
}
return {
handleKeydown
}
}

View File

@@ -1,147 +0,0 @@
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useAppStore } from '../stores/app'
export function useLogs() {
const store = useAppStore()
const logContainer = ref<HTMLElement>()
const autoScroll = ref(true)
const filterLevel = ref<string>('all')
const filterSource = ref<string>('all')
const searchQuery = ref('')
const selectedLogEntry = ref<any>(null)
let logRefreshInterval: NodeJS.Timeout | null = null
const filteredLogs = computed(() => {
let logs = store.logs || []
if (filterLevel.value !== 'all') {
logs = logs.filter((log: any) => log.level === filterLevel.value)
}
if (filterSource.value !== 'all') {
logs = logs.filter((log: any) => log.source === filterSource.value)
}
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase()
logs = logs.filter(
(log: any) => log.message.toLowerCase().includes(query) || log.source.toLowerCase().includes(query)
)
}
return logs.slice(-500)
})
const logLevelClass = (level: string) => {
switch (level) {
case 'error':
return 'log-error'
case 'warn':
return 'log-warning'
case 'info':
return 'log-info'
case 'debug':
return 'log-debug'
default:
return 'log-info'
}
}
const formatTimestamp = (timestamp: string) => {
return new Date(timestamp).toLocaleTimeString()
}
const scrollToBottom = async () => {
if (autoScroll.value && logContainer.value) {
await nextTick()
logContainer.value.scrollTop = logContainer.value.scrollHeight
}
}
const clearLogs = async (source?: 'openlist' | 'rclone' | 'app') => {
try {
await store.clearLogs(source)
} catch (error) {
console.error('Failed to clear logs:', error)
throw error
}
}
const copyLogsToClipboard = async () => {
const logsText = filteredLogs.value
.map((log: any) => `[${log.timestamp}] [${log.level.toUpperCase()}] [${log.source}] ${log.message}`)
.join('\n')
try {
await navigator.clipboard.writeText(logsText)
} catch (error) {
console.error('Failed to copy logs:', error)
}
}
const exportLogs = () => {
const logsText = filteredLogs.value
.map((log: any) => `[${log.timestamp}] [${log.level.toUpperCase()}] [${log.source}] ${log.message}`)
.join('\n')
const blob = new Blob([logsText], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `openlist-logs-${new Date().toISOString().split('T')[0]}.txt`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const startLogRefresh = (interval = 2000) => {
if (logRefreshInterval) return
logRefreshInterval = setInterval(async () => {
const oldLength = store.logs?.length || 0
await store.loadLogs()
if (store.logs?.length > oldLength) {
await scrollToBottom()
}
}, interval)
}
const stopLogRefresh = () => {
if (logRefreshInterval) {
clearInterval(logRefreshInterval)
logRefreshInterval = null
}
}
onMounted(async () => {
await store.loadLogs()
await scrollToBottom()
startLogRefresh()
})
onUnmounted(() => {
stopLogRefresh()
})
return {
logContainer,
autoScroll,
filterLevel,
filterSource,
searchQuery,
selectedLogEntry,
filteredLogs,
logLevelClass,
formatTimestamp,
scrollToBottom,
clearLogs,
copyLogsToClipboard,
exportLogs,
startLogRefresh,
stopLogRefresh
}
}

View File

@@ -1,40 +0,0 @@
import { ref } from 'vue'
export interface MenuItem {
label?: string
shortcut?: string
action?: () => void
enabled?: boolean
type?: 'separator'
}
export interface MenuSection {
label: string
items: MenuItem[]
}
export function useMenu() {
const showMenuDropdown = ref<string | false>(false)
const showUserMenu = ref(false)
const closeAllMenus = () => {
showMenuDropdown.value = false
showUserMenu.value = false
}
const toggleMenu = (menuName: string) => {
if (showMenuDropdown.value === menuName) {
showMenuDropdown.value = false
} else {
showMenuDropdown.value = menuName
showUserMenu.value = false
}
}
return {
showMenuDropdown,
showUserMenu,
closeAllMenus,
toggleMenu
}
}

View File

@@ -1,38 +0,0 @@
import { ref } from 'vue'
export function useResizable() {
const isResizing = ref(false)
const startResize = (
event: MouseEvent,
initialValue: number,
onResize: (newValue: number) => void,
options: { min?: number; max?: number; direction?: 'horizontal' | 'vertical' } = {}
) => {
const { min = 100, max = 1000, direction = 'vertical' } = options
isResizing.value = true
const startPos = direction === 'vertical' ? event.clientY : event.clientX
const startValue = initialValue
const handleMouseMove = (e: MouseEvent) => {
const delta = (direction === 'vertical' ? e.clientY : e.clientX) - startPos
const newValue = Math.max(min, Math.min(max, startValue + delta))
onResize(newValue)
}
const handleMouseUp = () => {
isResizing.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
return {
isResizing,
startResize
}
}

View File

@@ -6,7 +6,7 @@ import { useCoreActions } from './useCoreActions'
export const useTray = () => { export const useTray = () => {
const { startOpenListCore, stopOpenListCore, restartOpenListCore } = useCoreActions() const { startOpenListCore, stopOpenListCore, restartOpenListCore } = useCoreActions()
const store = useAppStore() const appStore = useAppStore()
let unlistenTrayActions: (() => void) | null = null let unlistenTrayActions: (() => void) | null = null
const updateTrayMenu = async (serviceRunning: boolean) => { const updateTrayMenu = async (serviceRunning: boolean) => {
@@ -23,19 +23,19 @@ export const useTray = () => {
case 'start': case 'start':
await startOpenListCore() await startOpenListCore()
setTimeout(async () => { setTimeout(async () => {
await updateTrayMenu(store.openlistCoreStatus.running) await updateTrayMenu(appStore.openlistCoreStatus.running)
}, 5000) }, 5000)
break break
case 'stop': case 'stop':
await stopOpenListCore() await stopOpenListCore()
setTimeout(async () => { setTimeout(async () => {
await updateTrayMenu(store.openlistCoreStatus.running) await updateTrayMenu(appStore.openlistCoreStatus.running)
}, 5000) }, 5000)
break break
case 'restart': case 'restart':
await restartOpenListCore() await restartOpenListCore()
setTimeout(async () => { setTimeout(async () => {
await updateTrayMenu(store.openlistCoreStatus.running) await updateTrayMenu(appStore.openlistCoreStatus.running)
}, 5000) }, 5000)
break break
default: default:
@@ -44,7 +44,7 @@ export const useTray = () => {
} catch (error) { } catch (error) {
console.error(`Failed to execute tray action '${action}':`, error) console.error(`Failed to execute tray action '${action}':`, error)
setTimeout(async () => { setTimeout(async () => {
await updateTrayMenu(store.openlistCoreStatus.running) await updateTrayMenu(appStore.openlistCoreStatus.running)
}, 3000) }, 3000)
} }
} }
@@ -52,7 +52,7 @@ export const useTray = () => {
try { try {
unlistenTrayActions = await TauriAPI.tray.listen(handleTrayServiceAction) unlistenTrayActions = await TauriAPI.tray.listen(handleTrayServiceAction)
await TauriAPI.tray.forceUpdate(store.openlistCoreStatus.running) await TauriAPI.tray.forceUpdate(appStore.openlistCoreStatus.running)
console.log('Tray listeners initialized and menu updated') console.log('Tray listeners initialized and menu updated')
} catch (error) { } catch (error) {
console.error('Failed to initialize tray listeners:', error) console.error('Failed to initialize tray listeners:', error)

View File

@@ -2,6 +2,7 @@
"common": { "common": {
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm",
"reset": "Reset", "reset": "Reset",
"close": "Close", "close": "Close",
"minimize": "Minimize", "minimize": "Minimize",
@@ -23,17 +24,26 @@
"openlistService": "OpenList Core", "openlistService": "OpenList Core",
"rclone": "RClone", "rclone": "RClone",
"quickSettings": "Quick Settings", "quickSettings": "Quick Settings",
"startOpenListCore": "Start Core", "startOpenListCore": "Start",
"stopOpenListCore": "Stop Core", "stopOpenListCore": "Stop",
"processing": "Processing...",
"restart": "Restart", "restart": "Restart",
"openWeb": "Web UI", "openWeb": "Web",
"configRclone": "Configure RClone", "configRclone": "Configure RClone",
"startRclone": "Start RClone", "startRclone": "Start RClone",
"stopRclone": "Stop RClone", "stopRclone": "Stop RClone",
"manageMounts": "Manage Mounts", "manageMounts": "Manage Mounts",
"autoLaunch": "Auto Launch Core(not app)", "autoLaunch": "Auto Launch Core(not app)",
"processing": "Processing...", "copyAdminPassword": "Copy admin password",
"showAdminPassword": "Show/Copy admin password from logs" "resetAdminPassword": "Reset admin password",
"firewall": {
"enable": "Allow Port",
"disable": "Remove Port Allow",
"added": "Port allowed successfully",
"removed": "Port removed successfully",
"failedToAdd": "Failed to allow port",
"failedToRemove": "Failed to remove port allow"
}
}, },
"coreMonitor": { "coreMonitor": {
"title": "Core Monitor", "title": "Core Monitor",
@@ -48,7 +58,10 @@
"openlist": "OpenList", "openlist": "OpenList",
"rclone": "Rclone", "rclone": "Rclone",
"selectVersion": "Select Version", "selectVersion": "Select Version",
"update": "Update" "update": "Update",
"updating": "Updating...",
"updateSuccess": "{type} updated successfully!",
"updateError": "Failed to update {type}: {error}"
}, },
"documentation": { "documentation": {
"title": "Documentation", "title": "Documentation",
@@ -90,7 +103,10 @@
"subtitle": "Configure your OpenList Desktop application", "subtitle": "Configure your OpenList Desktop application",
"saveChanges": "Save Changes", "saveChanges": "Save Changes",
"resetToDefaults": "Reset to defaults", "resetToDefaults": "Reset to defaults",
"confirmReset": "Are you sure you want to reset all settings to defaults? This action cannot be undone.", "confirmReset": {
"title": "Reset Settings",
"message": "Are you sure you want to reset all settings to defaults? This action cannot be undone."
},
"saved": "Settings saved successfully!", "saved": "Settings saved successfully!",
"saveFailed": "Failed to save settings. Please try again.", "saveFailed": "Failed to save settings. Please try again.",
"resetSuccess": "Settings reset to defaults successfully!", "resetSuccess": "Settings reset to defaults successfully!",
@@ -126,10 +142,15 @@
"placeholder": "5244", "placeholder": "5244",
"help": "Port number for the web interface (1-65535)" "help": "Port number for the web interface (1-65535)"
}, },
"apiToken": { "dataDir": {
"label": "API Token", "label": "Data Directory",
"placeholder": "Optional. Secure API access with authentication", "placeholder": "Optional. Custom data directory path",
"help": "Optional. Secure API access with authentication" "help": "Optional. Specify a custom directory for OpenList data storage",
"selectTitle": "Select Data Directory",
"selectError": "Failed to select directory. Please try again or enter path manually.",
"openTitle": "Open Data Directory",
"openSuccess": "Data directory opened successfully",
"openError": "Failed to open data directory"
}, },
"ssl": { "ssl": {
"title": "Enable SSL/HTTPS", "title": "Enable SSL/HTTPS",
@@ -139,18 +160,42 @@
"startup": { "startup": {
"autoLaunch": { "autoLaunch": {
"title": "Auto-launch on startup", "title": "Auto-launch on startup",
"description": "Automatically start OpenList service when the application launches" "description": "Automatically start OpenList core when the computer starts"
} }
},
"admin": {
"title": "Admin Password",
"subtitle": "Manage the admin password for OpenList Core web interface",
"currentPassword": "Admin Password",
"passwordPlaceholder": "Enter admin password or click reset to generate",
"resetTitle": "Reset admin password to a new random value",
"resetSuccess": "Admin password reset successfully! New password has been generated and saved.",
"resetFailed": "Failed to reset admin password. Please check the logs for more details.",
"passwordUpdated": "Admin password updated successfully!",
"passwordUpdateFailed": "Failed to update admin password. Please check the logs for more details.",
"help": "Enter a custom admin password or click the reset button to generate a new random password. Click 'Save Changes' to apply the password to OpenList Core."
} }
}, },
"rclone": { "rclone": {
"subtitle": "Configure remote storage connections", "subtitle": "Configure remote storage connections",
"api": {
"title": "API Configuration",
"subtitle": "Configure the Rclone API server settings",
"port": {
"label": "API Port",
"placeholder": "45572",
"help": "Port number for the Rclone API server (1-65535). Default: 45572"
}
},
"config": { "config": {
"title": "Remote Storage", "title": "Remote Storage",
"subtitle": "Configure rclone for remote storage access", "subtitle": "Configure rclone for remote storage access",
"label": "Rclone Configuration (JSON)", "label": "Rclone Configuration (JSON)",
"tips": "Enter your rclone configuration as JSON. This will be used to configure rclone remotes.", "tips": "View your rclone configuration as JSON. This will be used to configure rclone remotes.",
"invalidJson": "Invalid JSON configuration. Please check your syntax." "invalidJson": "Invalid JSON configuration. Please check your syntax.",
"openFile": "Open rclone.conf",
"openSuccess": "Rclone config file opened successfully",
"openError": "Failed to open rclone config file"
} }
}, },
"app": { "app": {
@@ -163,20 +208,31 @@
"auto": "Auto", "auto": "Auto",
"autoDesc": "Follow system" "autoDesc": "Follow system"
}, },
"monitor": { "config": {
"title": "Monitoring", "title": "Configuration Files",
"subtitle": "System monitoring and refresh settings", "subtitle": "Access application configuration files",
"interval": { "openFile": "Open settings.json",
"label": "Monitor Interval (seconds)", "openSuccess": "Settings file opened successfully",
"placeholder": "5", "openError": "Failed to open settings file"
"help": "How often to refresh system metrics and status" },
"ghProxy": {
"title": "GitHub Proxy",
"subtitle": "Accelerate GitHub with proxy service",
"label": "GitHub Proxy URL",
"placeholder": "https://ghfast.top",
"help": "Optional. Enter a proxy URL to accelerate GitHub. Example: https://ghfast.top",
"api": {
"title": "Apply proxy to API URLs",
"description": "Also use proxy for api.github.com URLs "
} }
}, },
"tutorial": { "links": {
"title": "Tutorial", "title": "Link Handling",
"subtitle": "Learn how to use OpenList Desktop", "subtitle": "Configure how links are opened",
"restart": "Start Tutorial", "openInBrowser": {
"help": "Restart the tutorial to learn about app features and navigation" "title": "Open links in external browser",
"description": "Use system default browser instead of built-in window"
}
}, },
"updates": { "updates": {
"title": "Updates", "title": "Updates",
@@ -190,6 +246,10 @@
"title": "Auto-launch on startup(Immediate Effect)", "title": "Auto-launch on startup(Immediate Effect)",
"subtitle": "Automatically start OpenList Desktop application when the system starts", "subtitle": "Automatically start OpenList Desktop application when the system starts",
"description": "Automatically start OpenList service when the application launches" "description": "Automatically start OpenList service when the application launches"
},
"showWindowOnStartup": {
"title": "Show main window on startup",
"description": "Show the main application window when OpenList Desktop starts"
} }
} }
}, },
@@ -202,7 +262,9 @@
"copyFailed": "Failed to copy logs to clipboard", "copyFailed": "Failed to copy logs to clipboard",
"exportSuccess": "Successfully exported {count} logs entries to file", "exportSuccess": "Successfully exported {count} logs entries to file",
"clearSuccess": "Logs cleared successfully", "clearSuccess": "Logs cleared successfully",
"clearFailed": "Failed to clear logs" "clearFailed": "Failed to clear logs",
"openDirectorySuccess": "Logs directory opened successfully",
"openDirectoryFailed": "Failed to open logs directory"
}, },
"toolbar": { "toolbar": {
"pause": "Pause (Space)", "pause": "Pause (Space)",
@@ -213,6 +275,7 @@
"copyToClipboard": "Copy to Clipboard (Ctrl+C)", "copyToClipboard": "Copy to Clipboard (Ctrl+C)",
"exportLogs": "Export Logs", "exportLogs": "Export Logs",
"clearLogs": "Clear Logs (Ctrl+Delete)", "clearLogs": "Clear Logs (Ctrl+Delete)",
"openLogsDirectory": "Open Logs Directory",
"toggleFullscreen": "Toggle Fullscreen (F11)", "toggleFullscreen": "Toggle Fullscreen (F11)",
"scrollToTop": "Scroll to Top (Home)", "scrollToTop": "Scroll to Top (Home)",
"scrollToBottom": "Scroll to Bottom (End)" "scrollToBottom": "Scroll to Bottom (End)"
@@ -237,7 +300,8 @@
"sources": { "sources": {
"all": "All Sources", "all": "All Sources",
"rclone": "Rclone", "rclone": "Rclone",
"openlist": "OpenList" "openlist": "OpenList",
"service": "Service"
}, },
"actions": { "actions": {
"selectAll": "Select All (Ctrl+A)", "selectAll": "Select All (Ctrl+A)",
@@ -257,7 +321,8 @@
"stripAnsiColors": "Strip ANSI Colors" "stripAnsiColors": "Strip ANSI Colors"
}, },
"messages": { "messages": {
"confirmClear": "Are you sure you want to clear all logs?" "confirmClear": "Are you sure you want to clear all logs?",
"confirmTitle": "Clear Logs"
}, },
"headers": { "headers": {
"timestamp": "Timestamp", "timestamp": "Timestamp",
@@ -311,7 +376,7 @@
"authentication": "Authentication", "authentication": "Authentication",
"mountSettings": "Mount Settings", "mountSettings": "Mount Settings",
"name": "Name", "name": "Name",
"namePlaceholder": "e.g., my-webdav-remote", "namePlaceholder": "e.g.mount1",
"type": "Type", "type": "Type",
"url": "URL", "url": "URL",
"urlPlaceholder": "e.g., http://localhost:5264/dav/189", "urlPlaceholder": "e.g., http://localhost:5264/dav/189",
@@ -322,15 +387,58 @@
"password": "Password", "password": "Password",
"passwordPlaceholder": "Password", "passwordPlaceholder": "Password",
"mountPoint": "Mount Path", "mountPoint": "Mount Path",
"mountPointPlaceholder": "e.g., T: (Windows) or /mnt/remote (Linux)", "mountPointPlaceholder": "e.g., T: or /mnt/remote",
"volumeName": "Remote Path", "volumeName": "Remote Path",
"volumeNamePlaceholder": "e.g., /", "volumeNamePlaceholder": "e.g., /",
"autoMount": "Auto-mount on startup", "autoMount": "Auto-mount on startup",
"advancedSettings": "Advanced Settings", "advancedSettings": "Advanced Settings",
"extraFlags": "Extra Flags", "extraFlags": "Extra Flags",
"flagPlaceholder": "e.g., --vfs-cache-mode", "flagPlaceholder": "e.g., --vfs-cache-mode=full",
"addFlag": "Add Flag", "addFlag": "Add Flag",
"removeFlag": "Remove Flag", "removeFlag": "Remove Flag",
"quickFlags": "Common Used Flags",
"quickFlagsTooltip": "Quick select common rclone flags",
"selectCommonFlags": "Select Common Flags",
"clickToToggleFlags": "Click on flags to instantly add or remove them from your configuration",
"flagCategories": {
"Performance": "Performance",
"Caching": "Caching",
"Bandwidth": "Bandwidth",
"Network": "Network",
"Security": "Security",
"WebDAV Specific": "WebDAV Specific",
"Debugging": "Debugging"
},
"flagDescriptions": {
"vfs-cache-mode-full": "Cache full file contents",
"vfs-cache-mode-writes": "Cache local writes, upload file contents after writing completes",
"vfs-cache-mode-minimal": "Cache only file metadata",
"buffer-size-16M": "Buffer size for reading files (default 16M)",
"buffer-size-32M": "Larger buffer for better performance",
"vfs-read-chunk-size": "Read chunk size (default 128M)",
"transfers": "Number of parallel transfers (default 4)",
"checkers": "Number of checkers to run in parallel (default 8)",
"vfs-cache-max-age": "Max age of objects in cache (default 24h)",
"vfs-cache-max-size": "Max total size of cache (default 10G)",
"dir-cache-time": "How long to cache directory listings (default 5m)",
"bwlimit-10M": "Bandwidth limit (e.g. 10M)",
"bwlimit-10M:100M": "Set separate upload and download bandwidth limits",
"bwlimit-schedule": "Time-based bandwidth limits",
"timeout": "IO idle timeout (default 5m)",
"contimeout": "Connection timeout (default 1m)",
"low-level-retries": "Number of low level retries (default 10)",
"retries": "Retry operations this many times (default 3)",
"read-only": "Mount read-only",
"allow-other": "Allow other users to access the mount",
"allow-root": "Allow root to access the mount",
"umask": "Override file permissions (default 022)",
"webdav-headers": "Set custom HTTP headers",
"webdav-bearer-token": "Custom bearer token",
"log-level": "Log level: ERROR, NOTICE, INFO, DEBUG",
"verbose": "Print lots more stuff",
"use-json-log": "Use JSON format for logging",
"progress": "Show progress during transfer"
},
"types": { "types": {
"webdav": "WebDAV" "webdav": "WebDAV"
} }
@@ -358,6 +466,8 @@
"tip": { "tip": {
"webdavTitle": "Enable WebDAV Management Required", "webdavTitle": "Enable WebDAV Management Required",
"webdavMessage": "Before mounting remotes, please ensure WebDAV management for specific user is enabled in OpenList Core.", "webdavMessage": "Before mounting remotes, please ensure WebDAV management for specific user is enabled in OpenList Core.",
"winfspTitle": "WinFSP Installation Required",
"winfspMessage": "On Windows, you need to install WinFSP first to use mount functionality. Please download and install it from GitHub: https://github.com/winfsp/winfsp/releases",
"dismissForever": "Dismiss forever" "dismissForever": "Dismiss forever"
} }
}, },
@@ -365,36 +475,6 @@
"title": "OpenList", "title": "OpenList",
"loading": "Initializing OpenList Desktop..." "loading": "Initializing OpenList Desktop..."
}, },
"tutorial": {
"welcome": {
"title": "Welcome to OpenList Desktop",
"content": "Welcome to OpenList Desktop! This tutorial will guide you through the key features and help you get started quickly."
},
"navigation": {
"title": "Navigation Panel",
"content": "Use the navigation panel to access different sections: Dashboard for monitoring, Mount for storage management, Logs for troubleshooting, and Settings for configuration."
},
"service": {
"title": "Install & Start Service",
"content": "First, you need to install and start the OpenList service. This is the core component that manages your cloud storage connections."
},
"openlist": {
"title": "OpenList Core Access",
"content": "Once the service is running, you can access the OpenList web interface to manage your files and configurations."
},
"documentation": {
"title": "Read Documentation",
"content": "For detailed information and advanced configurations, check out the documentation section. You'll find guides, API docs, and troubleshooting tips."
},
"settings": {
"title": "Settings & Configuration",
"content": "Customize your OpenList experience in the Settings section. Configure themes, service options, and storage preferences."
},
"skip": "Skip Tutorial",
"next": "Next",
"previous": "Previous",
"complete": "Complete Tutorial"
},
"update": { "update": {
"title": "App Updates", "title": "App Updates",
"subtitle": "Keep your application up to date with the latest features and security improvements", "subtitle": "Keep your application up to date with the latest features and security improvements",
@@ -416,7 +496,7 @@
"startingDownload": "Starting download...", "startingDownload": "Starting download...",
"downloading": "Downloading", "downloading": "Downloading",
"installingUpdate": "Installing update...", "installingUpdate": "Installing update...",
"restartingApp": "Restarting application...", "quitApp": "Quitting application...",
"noUpdatesFound": "No updates available", "noUpdatesFound": "No updates available",
"aboutUpdates": "About Updates", "aboutUpdates": "About Updates",
"autoCheckInfo": "Automatic update checks keep your app secure and up-to-date", "autoCheckInfo": "Automatic update checks keep your app secure and up-to-date",

View File

@@ -2,11 +2,12 @@
"common": { "common": {
"save": "保存", "save": "保存",
"cancel": "取消", "cancel": "取消",
"confirm": "确认",
"reset": "重置", "reset": "重置",
"close": "关闭", "close": "关闭",
"minimize": "最小化", "minimize": "最小化",
"maximize": "最大化", "maximize": "最大化",
"loading": "加载中...", "loading": "处理中...",
"saving": "保存中...", "saving": "保存中...",
"add": "添加" "add": "添加"
}, },
@@ -23,17 +24,26 @@
"openlistService": "OpenList 核心", "openlistService": "OpenList 核心",
"rclone": "RClone", "rclone": "RClone",
"quickSettings": "快速设置", "quickSettings": "快速设置",
"startOpenListCore": "启动核心", "startOpenListCore": "启动",
"stopOpenListCore": "停止核心", "stopOpenListCore": "停止",
"processing": "处理中...",
"restart": "重启", "restart": "重启",
"openWeb": "网页界面", "openWeb": "网页",
"configRclone": "配置 RClone", "configRclone": "配置 RClone",
"startRclone": "启动 RClone", "startRclone": "启动 RClone",
"stopRclone": "停止 RClone", "stopRclone": "停止 RClone",
"manageMounts": "管理挂载", "manageMounts": "管理挂载",
"autoLaunch": "自动启动核心(非桌面app)", "autoLaunch": "自动启动核心(非桌面app)",
"processing": "处理中...", "copyAdminPassword": "复制管理员密码",
"showAdminPassword": "显示/复制日志中的管理员密码" "resetAdminPassword": "重置管理员密码",
"firewall": {
"enable": "放行端口",
"disable": "移除端口放行",
"added": "端口放行成功",
"removed": "端口移除成功",
"failedToAdd": "添加端口放行失败",
"failedToRemove": "移除端口放行失败"
}
}, },
"coreMonitor": { "coreMonitor": {
"title": "核心监控", "title": "核心监控",
@@ -48,7 +58,10 @@
"openlist": "OpenList", "openlist": "OpenList",
"rclone": "Rclone", "rclone": "Rclone",
"selectVersion": "选择版本", "selectVersion": "选择版本",
"update": "更新" "update": "更新",
"updating": "更新中...",
"updateSuccess": "{type} 更新成功!",
"updateError": "更新 {type} 失败:{error}"
}, },
"documentation": { "documentation": {
"title": "文档", "title": "文档",
@@ -90,7 +103,10 @@
"subtitle": "配置您的 OpenList 桌面应用程序", "subtitle": "配置您的 OpenList 桌面应用程序",
"saveChanges": "保存更改", "saveChanges": "保存更改",
"resetToDefaults": "重置为默认值", "resetToDefaults": "重置为默认值",
"confirmReset": "您确定要将所有设置重置为默认值吗?此操作无法撤消。", "confirmReset": {
"title": "重置设置",
"message": "您确定要将所有设置重置为默认值吗?此操作无法撤消。"
},
"saved": "设置保存成功!", "saved": "设置保存成功!",
"saveFailed": "保存设置失败,请重试。", "saveFailed": "保存设置失败,请重试。",
"resetSuccess": "设置已重置为默认值!", "resetSuccess": "设置已重置为默认值!",
@@ -126,10 +142,15 @@
"placeholder": "5244", "placeholder": "5244",
"help": "Web 界面的端口号 (1-65535)" "help": "Web 界面的端口号 (1-65535)"
}, },
"apiToken": { "dataDir": {
"label": "API 令牌", "label": "数据目录",
"placeholder": "可选。用于 API 访问的身份验证", "placeholder": "可选。自定义数据目录路径",
"help": "可选。用于 API 访问的身份验证" "help": "可选。为 OpenList 数据存储指定自定义目录",
"selectTitle": "选择数据目录",
"selectError": "选择目录失败。请重试或手动输入路径。",
"openTitle": "打开数据目录",
"openSuccess": "数据目录打开成功",
"openError": "打开数据目录失败"
}, },
"ssl": { "ssl": {
"title": "启用 SSL/HTTPS", "title": "启用 SSL/HTTPS",
@@ -139,18 +160,42 @@
"startup": { "startup": {
"autoLaunch": { "autoLaunch": {
"title": "开机自启", "title": "开机自启",
"description": "应用程序启动时自动启动 OpenList 服务" "description": "开机自动启动 OpenList 核心"
} }
},
"admin": {
"title": "管理员密码",
"subtitle": "管理 OpenList 核心网页界面的管理员密码",
"currentPassword": "管理员密码",
"passwordPlaceholder": "输入管理员密码或点击重置生成",
"resetTitle": "重置管理员密码为新的随机值",
"resetSuccess": "管理员密码重置成功!已生成新密码并保存。",
"resetFailed": "重置管理员密码失败。请查看日志了解详细信息。",
"passwordUpdated": "管理员密码更新成功!",
"passwordUpdateFailed": "更新管理员密码失败。请查看日志了解详细信息。",
"help": "输入自定义管理员密码或点击重置按钮生成新的随机密码。点击'保存更改'将密码应用到 OpenList 核心。"
} }
}, },
"rclone": { "rclone": {
"subtitle": "配置远程存储连接", "subtitle": "配置远程存储连接",
"api": {
"title": "API 配置",
"subtitle": "配置 Rclone API 服务器设置",
"port": {
"label": "API 端口",
"placeholder": "45572",
"help": "Rclone API 服务器的端口号 (1-65535)。默认45572"
}
},
"config": { "config": {
"title": "远程存储", "title": "远程存储",
"subtitle": "配置 rclone 远程存储访问", "subtitle": "配置 rclone 远程存储访问",
"label": "Rclone 配置 (JSON)", "label": "Rclone 配置 (JSON)",
"invalidJson": "无效的 JSON 配置。请检查您的语法。", "invalidJson": "无效的 JSON 配置。请检查您的语法。",
"tips": "输入你的JSON配置" "tips": "查看你的JSON配置",
"openFile": "打开 rclone.conf",
"openSuccess": "Rclone 配置文件打开成功",
"openError": "打开 Rclone 配置文件失败"
} }
}, },
"app": { "app": {
@@ -163,20 +208,31 @@
"auto": "自动", "auto": "自动",
"autoDesc": "跟随系统" "autoDesc": "跟随系统"
}, },
"monitor": { "config": {
"title": "监控", "title": "配置文件",
"subtitle": "系统监控和刷新设置", "subtitle": "访问应用程序配置文件",
"interval": { "openFile": "打开 settings.json",
"label": "监控间隔(秒)", "openSuccess": "设置文件打开成功",
"placeholder": "5", "openError": "打开设置文件失败"
"help": "刷新系统指标和状态的频率" },
"ghProxy": {
"title": "GitHub 代理",
"subtitle": "使用代理服务加速 GitHub",
"label": "GitHub 代理地址",
"placeholder": "https://ghfast.top",
"help": "可选。输入代理地址以加速 GitHub。例如https://ghfast.top",
"api": {
"title": "代理 API 地址",
"description": "同时为 api.github.com 地址使用代理"
} }
}, },
"tutorial": { "links": {
"title": "教程", "title": "链接处理",
"subtitle": "学习如何使用 OpenList 桌面版", "subtitle": "配置链接的打开方式",
"restart": "开始教程", "openInBrowser": {
"help": "重新启动教程以了解应用功能和导航" "title": "在外部浏览器中打开链接",
"description": "使用系统默认浏览器而不是内置窗口"
}
}, },
"updates": { "updates": {
"title": "更新", "title": "更新",
@@ -190,6 +246,10 @@
"title": "开机自动启动应用(立即生效)", "title": "开机自动启动应用(立即生效)",
"subtitle": "在系统启动时自动启动 OpenList 桌面应用", "subtitle": "在系统启动时自动启动 OpenList 桌面应用",
"description": "在系统启动时自动启动 OpenList 桌面应用" "description": "在系统启动时自动启动 OpenList 桌面应用"
},
"showWindowOnStartup": {
"title": "启动时显示主窗口",
"description": "在 OpenList 桌面应用启动时显示主应用窗口"
} }
} }
}, },
@@ -202,7 +262,9 @@
"copyFailed": "复制日志到剪贴板失败", "copyFailed": "复制日志到剪贴板失败",
"exportSuccess": "成功导出 {count} 条日志到文件", "exportSuccess": "成功导出 {count} 条日志到文件",
"clearSuccess": "日志清理成功", "clearSuccess": "日志清理成功",
"clearFailed": "清理日志失败" "clearFailed": "清理日志失败",
"openDirectorySuccess": "日志目录打开成功",
"openDirectoryFailed": "打开日志目录失败"
}, },
"toolbar": { "toolbar": {
"pause": "暂停 (Space)", "pause": "暂停 (Space)",
@@ -213,6 +275,7 @@
"copyToClipboard": "复制到剪贴板 (Ctrl+C)", "copyToClipboard": "复制到剪贴板 (Ctrl+C)",
"exportLogs": "导出日志", "exportLogs": "导出日志",
"clearLogs": "清除日志 (Ctrl+Delete)", "clearLogs": "清除日志 (Ctrl+Delete)",
"openLogsDirectory": "打开日志目录",
"toggleFullscreen": "切换全屏 (F11)", "toggleFullscreen": "切换全屏 (F11)",
"scrollToTop": "滚动到顶部 (Home)", "scrollToTop": "滚动到顶部 (Home)",
"scrollToBottom": "滚动到底部 (End)" "scrollToBottom": "滚动到底部 (End)"
@@ -237,7 +300,8 @@
"sources": { "sources": {
"all": "所有来源", "all": "所有来源",
"rclone": "Rclone", "rclone": "Rclone",
"openlist": "OpenList" "openlist": "OpenList",
"service": "服务"
}, },
"actions": { "actions": {
"selectAll": "全选 (Ctrl+A)", "selectAll": "全选 (Ctrl+A)",
@@ -257,7 +321,8 @@
"stripAnsiColors": "去除 ANSI 颜色" "stripAnsiColors": "去除 ANSI 颜色"
}, },
"messages": { "messages": {
"confirmClear": "您确定要清除所有日志吗?" "confirmClear": "您确定要清除所有日志吗?",
"confirmTitle": "清除日志"
}, },
"headers": { "headers": {
"timestamp": "时间", "timestamp": "时间",
@@ -311,7 +376,7 @@
"authentication": "身份认证", "authentication": "身份认证",
"mountSettings": "挂载设置", "mountSettings": "挂载设置",
"name": "名称", "name": "名称",
"namePlaceholder": "例如:我的webdav远程", "namePlaceholder": "例如:mount1",
"type": "类型", "type": "类型",
"url": "URL", "url": "URL",
"urlPlaceholder": "例如http://localhost:5264/dav/189", "urlPlaceholder": "例如http://localhost:5264/dav/189",
@@ -322,15 +387,58 @@
"password": "密码", "password": "密码",
"passwordPlaceholder": "密码", "passwordPlaceholder": "密码",
"mountPoint": "挂载点", "mountPoint": "挂载点",
"mountPointPlaceholder": "例如T: (Windows) 或 /mnt/remote (Linux)", "mountPointPlaceholder": "例如T: 或 /mnt/remote",
"volumeName": "远程路径", "volumeName": "远程路径",
"volumeNamePlaceholder": "例如:/", "volumeNamePlaceholder": "例如:/",
"autoMount": "开机自动挂载", "autoMount": "开机自动挂载",
"advancedSettings": "高级设置", "advancedSettings": "高级设置",
"extraFlags": "额外标志", "extraFlags": "额外标志",
"flagPlaceholder": "例如:--vfs-cache-mode", "flagPlaceholder": "例如:--vfs-cache-mode=full",
"addFlag": "添加标志", "addFlag": "添加标志",
"removeFlag": "移除标志", "removeFlag": "移除标志",
"quickFlags": "常用标志",
"quickFlagsTooltip": "快速选择常用 rclone 标志",
"selectCommonFlags": "选择常用标志",
"clickToToggleFlags": "点击即可立即添加或移除",
"flagCategories": {
"Performance": "性能",
"Caching": "缓存",
"Bandwidth": "宽带",
"Network": "网络",
"Security": "安全",
"WebDAV Specific": "WebDAV 专用",
"Debugging": "调试"
},
"flagDescriptions": {
"vfs-cache-mode-full": "缓存文件的完整内容",
"vfs-cache-mode-writes": "缓存本地写入,文件内容写入完成后上传",
"vfs-cache-mode-minimal": "只缓存文件的元信息",
"buffer-size-16M": "读取文件的缓冲区大小(默认 16M",
"buffer-size-32M": "更大的缓冲区以获得更好的性能",
"vfs-read-chunk-size": "读取块大小(默认 128M",
"transfers": "并行传输数量(默认 4",
"checkers": "并行运行的检查器数量(默认 8",
"vfs-cache-max-age": "缓存的最大生命周期(默认 24h",
"vfs-cache-max-size": "缓存文件的最大大小(默认 10G",
"dir-cache-time": "缓存目录列表的时间(默认 5m",
"bwlimit-10M": "带宽限制(例如 10M",
"bwlimit-10M:100M": "分别设置上传和下载宽带限制",
"bwlimit-schedule": "基于时间的带宽限制",
"timeout": "IO 空闲超时(默认 5m",
"contimeout": "连接超时(默认 1m",
"low-level-retries": "低级重试次数(默认 10",
"retries": "重试操作次数(默认 3",
"read-only": "以只读方式挂载",
"allow-other": "允许其他用户访问挂载点",
"allow-root": "允许 root 用户访问挂载点",
"umask": "覆盖文件权限(默认 022",
"webdav-headers": "设置自定义 HTTP 标头",
"webdav-bearer-token": "自定义token",
"log-level": "日志级别ERROR、NOTICE、INFO、DEBUG",
"verbose": "打印更多详细信息",
"use-json-log": "使用 JSON 格式记录日志",
"progress": "传输期间显示进度"
},
"types": { "types": {
"webdav": "WebDAV" "webdav": "WebDAV"
} }
@@ -358,6 +466,8 @@
"tip": { "tip": {
"webdavTitle": "需要启用 WebDAV 管理功能", "webdavTitle": "需要启用 WebDAV 管理功能",
"webdavMessage": "在挂载远程存储之前,请确保在 OpenList 核心中为用户启用了 WebDAV 管理功能", "webdavMessage": "在挂载远程存储之前,请确保在 OpenList 核心中为用户启用了 WebDAV 管理功能",
"winfspTitle": "需要安装 WinFSP",
"winfspMessage": "在 Windows 系统上,您需要先安装 WinFSP 才能使用挂载功能。请从 GitHub 下载并安装https://github.com/winfsp/winfsp/releases",
"dismissForever": "永久关闭" "dismissForever": "永久关闭"
} }
}, },
@@ -365,36 +475,6 @@
"title": "OpenList", "title": "OpenList",
"loading": "正在初始化" "loading": "正在初始化"
}, },
"tutorial": {
"welcome": {
"title": "欢迎使用 OpenList 桌面版",
"content": "欢迎使用 OpenList 桌面版!本教程将引导您了解主要功能,帮助您快速上手。"
},
"navigation": {
"title": "导航面板",
"content": "使用导航面板访问不同部分:仪表板用于监控,挂载用于存储管理,日志用于故障排除,设置用于配置。"
},
"service": {
"title": "安装并启动服务",
"content": "首先,您需要安装并启动 OpenList 服务。这是管理云存储连接的核心组件。"
},
"openlist": {
"title": "OpenList 核心访问",
"content": "服务运行后,您可以访问 OpenList 网页界面来管理文件和配置。"
},
"documentation": {
"title": "阅读文档",
"content": "有关详细信息和高级配置请查看文档部分。您会找到指南、API 文档和故障排除提示。"
},
"settings": {
"title": "设置和配置",
"content": "在设置部分自定义您的 OpenList 体验。配置主题、服务选项和存储首选项。"
},
"skip": "跳过教程",
"next": "下一步",
"previous": "上一步",
"complete": "完成教程"
},
"update": { "update": {
"title": "应用更新", "title": "应用更新",
"subtitle": "保持应用程序最新,获取最新功能和安全改进", "subtitle": "保持应用程序最新,获取最新功能和安全改进",
@@ -416,7 +496,7 @@
"startingDownload": "开始下载...", "startingDownload": "开始下载...",
"downloading": "下载中...", "downloading": "下载中...",
"installingUpdate": "安装更新中...", "installingUpdate": "安装更新中...",
"restartingApp": "重启应用中...", "quitApp": "退出应用中...",
"noUpdatesFound": "没有可用更新", "noUpdatesFound": "没有可用更新",
"aboutUpdates": "关于更新", "aboutUpdates": "关于更新",
"autoCheckInfo": "自动更新检查让您的应用保持安全和最新状态", "autoCheckInfo": "自动更新检查让您的应用保持安全和最新状态",

View File

@@ -7,9 +7,17 @@ type ActionFn<T = any> = () => Promise<T>
export const useAppStore = defineStore('app', () => { export const useAppStore = defineStore('app', () => {
const settings = ref<MergedSettings>({ const settings = ref<MergedSettings>({
openlist: { port: 5244, api_token: '', auto_launch: false, ssl_enabled: false }, openlist: { port: 5244, data_dir: '', auto_launch: false, ssl_enabled: false },
rclone: { config: {} }, rclone: { config: {}, api_port: 45572 },
app: { theme: 'light', monitor_interval: 5000, auto_update_enabled: true } app: {
theme: 'light',
auto_update_enabled: true,
gh_proxy: '',
gh_proxy_api: false,
open_links_in_browser: false,
admin_password: undefined,
show_window_on_startup: true
}
}) })
const openlistCoreStatus = ref<OpenListCoreStatus>({ running: false }) const openlistCoreStatus = ref<OpenListCoreStatus>({ running: false })
const remoteConfigs = ref<IRemoteConfig>({}) const remoteConfigs = ref<IRemoteConfig>({})
@@ -97,7 +105,7 @@ export const useAppStore = defineStore('app', () => {
const saveSettings = () => withLoading(() => TauriAPI.settings.save(settings.value), 'Failed to save settings') const saveSettings = () => withLoading(() => TauriAPI.settings.save(settings.value), 'Failed to save settings')
async function saveSettingsWithUpdatePort(): Promise<boolean> { async function saveSettingsWithCoreUpdate(): Promise<boolean> {
try { try {
await TauriAPI.settings.saveWithUpdatePort(settings.value) await TauriAPI.settings.saveWithUpdatePort(settings.value)
return true return true
@@ -273,13 +281,10 @@ export const useAppStore = defineStore('app', () => {
async function loadRemoteConfigs() { async function loadRemoteConfigs() {
try { try {
loading.value = true
remoteConfigs.value = await TauriAPI.rclone.remotes.listConfig('webdav') remoteConfigs.value = await TauriAPI.rclone.remotes.listConfig('webdav')
} catch (err: any) { } catch (err: any) {
error.value = 'Failed to load remote configurations' error.value = 'Failed to load remote configurations'
console.error('Failed to load remote configs:', err) console.error('Failed to load remote configs:', err)
} finally {
loading.value = false
} }
} }
@@ -375,10 +380,6 @@ export const useAppStore = defineStore('app', () => {
const openlistProcessId = ref<string | undefined>(undefined) const openlistProcessId = ref<string | undefined>(undefined)
const showTutorial = ref(false)
const tutorialStep = ref(0)
const tutorialSkipped = ref(false)
async function getRcloneMountProcessId(name: string): Promise<string | undefined> { async function getRcloneMountProcessId(name: string): Promise<string | undefined> {
try { try {
const processList = await TauriAPI.process.list() const processList = await TauriAPI.process.list()
@@ -553,7 +554,7 @@ export const useAppStore = defineStore('app', () => {
} }
} }
async function loadLogs(source?: 'openlist' | 'rclone' | 'app') { async function loadLogs(source?: 'openlist' | 'rclone' | 'app' | 'service' | 'all') {
try { try {
source = source || 'openlist' source = source || 'openlist'
const logEntries = await TauriAPI.logs.get(source) const logEntries = await TauriAPI.logs.get(source)
@@ -563,9 +564,10 @@ export const useAppStore = defineStore('app', () => {
} }
} }
async function clearLogs(source?: 'openlist' | 'rclone' | 'app') { async function clearLogs(source?: 'openlist' | 'rclone' | 'app' | 'service' | 'all') {
try { try {
loading.value = true loading.value = true
source = source || 'openlist'
const result = await TauriAPI.logs.clear(source) const result = await TauriAPI.logs.clear(source)
if (result) { if (result) {
logs.value = [] logs.value = []
@@ -604,6 +606,46 @@ export const useAppStore = defineStore('app', () => {
} }
} }
async function openLogsDirectory() {
try {
await TauriAPI.files.openLogsDirectory()
} catch (err) {
error.value = 'Failed to open logs directory'
console.error('Failed to open logs directory:', err)
throw err
}
}
async function openOpenListDataDir() {
try {
await TauriAPI.files.openOpenListDataDir()
} catch (err) {
error.value = 'Failed to open openlist data directory'
console.error('Failed to open openlist data directory:', err)
throw err
}
}
async function openRcloneConfigFile() {
try {
await TauriAPI.files.openRcloneConfigFile()
} catch (err) {
error.value = 'Failed to open rclone config file'
console.error('Failed to open rclone config file:', err)
throw err
}
}
async function openSettingsFile() {
try {
await TauriAPI.files.openSettingsFile()
} catch (err) {
error.value = 'Failed to open settings file'
console.error('Failed to open settings file:', err)
throw err
}
}
async function selectDirectory(title: string): Promise<string | null> { async function selectDirectory(title: string): Promise<string | null> {
try { try {
return await TauriAPI.util.selectDirectory(title) return await TauriAPI.util.selectDirectory(title)
@@ -683,7 +725,6 @@ export const useAppStore = defineStore('app', () => {
async function init() { async function init() {
try { try {
initTutorial()
await loadSettings() await loadSettings()
await refreshOpenListCoreStatus() await refreshOpenListCoreStatus()
await TauriAPI.tray.updateDelayed(openlistCoreStatus.value.running) await TauriAPI.tray.updateDelayed(openlistCoreStatus.value.running)
@@ -697,43 +738,6 @@ export const useAppStore = defineStore('app', () => {
} }
} }
function initTutorial() {
const hasSeenTutorial = localStorage.getItem('openlist-tutorial-completed')
const tutorialDisabled = localStorage.getItem('openlist-tutorial-disabled')
if (!hasSeenTutorial && tutorialDisabled !== 'true') {
showTutorial.value = true
tutorialStep.value = 0
}
}
function startTutorial() {
showTutorial.value = true
tutorialStep.value = 0
localStorage.removeItem('openlist-tutorial-disabled')
}
function nextTutorialStep() {
tutorialStep.value++
}
function prevTutorialStep() {
if (tutorialStep.value > 0) {
tutorialStep.value--
}
}
function skipTutorial() {
showTutorial.value = false
tutorialSkipped.value = true
localStorage.setItem('openlist-tutorial-disabled', 'true')
}
function completeTutorial() {
showTutorial.value = false
localStorage.setItem('openlist-tutorial-completed', 'true')
}
async function getAdminPassword(): Promise<string | null> { async function getAdminPassword(): Promise<string | null> {
try { try {
return await TauriAPI.logs.adminPassword() return await TauriAPI.logs.adminPassword()
@@ -743,8 +747,34 @@ export const useAppStore = defineStore('app', () => {
} }
} }
function closeTutorial() { async function resetAdminPassword(): Promise<string | null> {
showTutorial.value = false try {
const newPassword = await TauriAPI.logs.resetAdminPassword()
if (newPassword) {
settings.value.app.admin_password = newPassword
await saveSettings()
}
return newPassword
} catch (err) {
console.error('Failed to reset admin password:', err)
return null
}
}
async function setAdminPassword(password: string): Promise<boolean> {
try {
await TauriAPI.logs.setAdminPassword(password)
settings.value.app.admin_password = password
await saveSettings()
return true
} catch (err) {
console.error('Failed to set admin password:', err)
return false
}
} }
// Update management functions // Update management functions
@@ -783,16 +813,12 @@ export const useAppStore = defineStore('app', () => {
updateAvailable, updateAvailable,
updateCheck, updateCheck,
showTutorial,
tutorialStep,
tutorialSkipped,
isCoreRunning, isCoreRunning,
openListCoreUrl, openListCoreUrl,
loadSettings, loadSettings,
saveSettings, saveSettings,
saveSettingsWithUpdatePort, saveSettingsWithCoreUpdate,
resetSettings, resetSettings,
startOpenListCore, startOpenListCore,
@@ -805,22 +831,21 @@ export const useAppStore = defineStore('app', () => {
listFiles, listFiles,
openFile, openFile,
openFolder, openFolder,
openLogsDirectory,
openOpenListDataDir,
openRcloneConfigFile,
openSettingsFile,
selectDirectory, selectDirectory,
clearError, clearError,
init, init,
getAdminPassword, getAdminPassword,
resetAdminPassword,
setAdminPassword,
setTheme, setTheme,
toggleTheme, toggleTheme,
applyTheme, applyTheme,
initTutorial,
startTutorial,
nextTutorialStep,
prevTutorialStep,
skipTutorial,
completeTutorial,
closeTutorial,
setUpdateAvailable, setUpdateAvailable,
clearUpdateStatus clearUpdateStatus
} }

View File

@@ -8,82 +8,66 @@ export const useRcloneStore = defineStore('rclone', () => {
const error = ref<string | undefined>() const error = ref<string | undefined>()
const serviceRunning = ref(false) const serviceRunning = ref(false)
function clearError() { const setError = (msg?: string) => (error.value = msg)
error.value = undefined
const runWithLoading = async <T>(fn: () => Promise<T>): Promise<T> => {
loading.value = true
try {
return await fn()
} finally {
loading.value = false
}
} }
async function startRcloneBackend() { async function getRcloneProcessId(): Promise<string | undefined> {
try { try {
loading.value = true const processList = await TauriAPI.process.list()
const isRunning = await TauriAPI.rclone.backend.isRunning() return processList.find(p => p.config?.name === 'single_rclone_backend_process')?.id
if (isRunning) { } catch (err) {
console.error('Failed to get Rclone process ID:', err)
}
}
const clearError = () => setError()
const startRcloneBackend = () =>
runWithLoading(async () => {
if (await TauriAPI.rclone.backend.isRunning()) {
serviceRunning.value = true serviceRunning.value = true
return true
} }
const result = await TauriAPI.rclone.backend.createAndStart() const result = await TauriAPI.rclone.backend.createAndStart()
if (result) { if (result) {
serviceRunning.value = true serviceRunning.value = true
} }
return result }).catch(err => {
} catch (err: any) { setError('Failed to start rclone service')
error.value = 'Failed to start rclone service'
throw err throw err
} finally { })
loading.value = false
}
}
async function getRcloneProcessId() { const stopRcloneBackend = () =>
try { runWithLoading(async () => {
const processList = await TauriAPI.process.list()
const findRcloneBackend = processList.find(p => p.config?.name === 'single_rclone_backend_process')
if (findRcloneBackend) {
return findRcloneBackend.id
}
} catch (err) {
console.error('Failed to get Rclone process ID from database:', err)
return undefined
}
}
async function stopRcloneBackend() {
try {
loading.value = true
const id = await getRcloneProcessId() const id = await getRcloneProcessId()
if (!id) { if (!id) {
serviceRunning.value = false serviceRunning.value = false
return return true
} }
const result = await TauriAPI.process.stop(id) const ok = await TauriAPI.process.stop(id)
if (result) { if (ok) serviceRunning.value = false
serviceRunning.value = false return ok
} }).catch(err => {
return result setError('Failed to stop rclone service')
} catch (err: any) {
error.value = 'Failed to stop rclone service'
throw err throw err
} finally { })
loading.value = false
} const checkRcloneBackendStatus = async () => {
const running = await TauriAPI.rclone.backend.isRunning().catch(() => false)
serviceRunning.value = running
return running
} }
async function checkRcloneBackendStatus() { const init = () => console.log('Initializing Rclone store...')
try {
const isRunning = await TauriAPI.rclone.backend.isRunning()
serviceRunning.value = isRunning
return isRunning
} catch (err: any) {
serviceRunning.value = false
return false
}
}
async function init() {
console.log('Initializing Rclone store...')
}
return { return {
// State
loading, loading,
error, error,
serviceRunning, serviceRunning,

11
src/types/types.d.ts vendored
View File

@@ -6,13 +6,14 @@ interface IRemoteConfig {
interface OpenListCoreConfig { interface OpenListCoreConfig {
port: number port: number
api_token: string data_dir: string
auto_launch: boolean auto_launch: boolean
ssl_enabled: boolean ssl_enabled: boolean
} }
interface RcloneConfig { interface RcloneConfig {
config?: any // Flexible JSON object for rclone configuration config?: any // Flexible JSON object for rclone configuration
api_port: number // Port for the Rclone API server
} }
interface RcloneWebdavConfig { interface RcloneWebdavConfig {
@@ -45,8 +46,14 @@ interface RcloneMountInfo {
interface AppConfig { interface AppConfig {
theme?: 'light' | 'dark' | 'auto' theme?: 'light' | 'dark' | 'auto'
monitor_interval?: number
auto_update_enabled?: boolean auto_update_enabled?: boolean
gh_proxy?: string
gh_proxy_api?: boolean
open_links_in_browser?: boolean
admin_password?: string
show_window_on_startup?: boolean
log_filter_level?: string
log_filter_source?: string
} }
interface MergedSettings { interface MergedSettings {

View File

@@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed } from 'vue' import { computed, onMounted, ref } from 'vue'
import CoreMonitorCard from '../components/dashboard/CoreMonitorCard.vue'
import DocumentationCard from '../components/dashboard/DocumentationCard.vue'
import QuickActionsCard from '../components/dashboard/QuickActionsCard.vue'
import ServiceManagementCard from '../components/dashboard/ServiceManagementCard.vue'
import VersionManagerCard from '../components/dashboard/VersionManagerCard.vue'
import { useAppStore } from '../stores/app' import { useAppStore } from '../stores/app'
import QuickActionsCard from '../components/dashboard/QuickActionsCard.vue' const appStore = useAppStore()
import CoreMonitorCard from '../components/dashboard/CoreMonitorCard.vue'
import VersionManagerCard from '../components/dashboard/VersionManagerCard.vue'
import DocumentationCard from '../components/dashboard/DocumentationCard.vue'
import ServiceManagementCard from '../components/dashboard/ServiceManagementCard.vue'
const store = useAppStore()
const isLoading = ref(true) const isLoading = ref(true)
@@ -23,7 +23,7 @@ const layoutClass = computed(() => ({
})) }))
onMounted(async () => { onMounted(async () => {
serviceStatus.value.isRunning = store.isCoreRunning serviceStatus.value.isRunning = appStore.isCoreRunning
isLoading.value = false isLoading.value = false
}) })
</script> </script>

View File

@@ -1,38 +1,43 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue' import * as chrono from 'chrono-node'
import { useAppStore } from '../stores/app'
import { useTranslation } from '../composables/useI18n'
import { import {
Search, AlertCircle,
Filter, AlertTriangle,
Download,
Copy,
Trash2,
Play,
Pause,
RotateCcw,
Settings,
ArrowUp,
ArrowDown, ArrowDown,
ArrowUp,
Copy,
Download,
Filter,
FolderOpen,
Info,
Maximize2, Maximize2,
Minimize2, Minimize2,
AlertCircle, Pause,
Info, Play,
AlertTriangle RotateCcw,
Search,
Settings,
Trash2
} from 'lucide-vue-next' } from 'lucide-vue-next'
import * as chrono from 'chrono-node' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const store = useAppStore() import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
import { useTranslation } from '../composables/useI18n'
import { useAppStore } from '../stores/app'
type filterSourceType = 'openlist' | 'rclone' | 'app' | 'service' | 'all'
const appStore = useAppStore()
const { t } = useTranslation() const { t } = useTranslation()
const logContainer = ref<HTMLElement>() const logContainer = ref<HTMLElement>()
const searchInputRef = ref<HTMLInputElement>() const searchInputRef = ref<HTMLInputElement>()
const autoScroll = ref(true) const autoScroll = ref(true)
const isPaused = ref(false) const isPaused = ref(false)
const filterLevel = ref<string>('all') const filterLevel = ref<string>(appStore.settings.app.log_filter_level || 'all')
const filterSource = ref<string>(localStorage.getItem('logFilterSource') || 'all') const filterSource = ref<string>(appStore.settings.app.log_filter_source || 'openlist')
const searchQuery = ref('') const searchQuery = ref('')
const selectedEntries = ref<Set<number>>(new Set()) const selectedEntries = ref<Set<number>>(new Set())
const showFilters = ref(false) const showFilters = ref(true)
const showSettings = ref(false) const showSettings = ref(false)
const fontSize = ref(13) const fontSize = ref(13)
const maxLines = ref(1000) const maxLines = ref(1000)
@@ -42,14 +47,25 @@ const stripAnsiColors = ref(true)
const showNotification = ref(false) const showNotification = ref(false)
const notificationMessage = ref('') const notificationMessage = ref('')
const notificationType = ref<'success' | 'info' | 'warning' | 'error'>('success') const notificationType = ref<'success' | 'info' | 'warning' | 'error'>('success')
const showConfirmDialog = ref(false)
const confirmDialogConfig = ref({
title: '',
message: '',
onConfirm: () => {},
onCancel: () => {}
})
watch( watch(filterLevel, async newValue => {
filterSource, appStore.settings.app.log_filter_level = newValue
newValue => { await appStore.saveSettings()
localStorage.setItem('logFilterSource', newValue) })
},
{ immediate: true } watch(filterSource, async newValue => {
) appStore.settings.app.log_filter_source = newValue
await appStore.saveSettings()
await appStore.loadLogs((newValue !== 'gin' ? newValue : 'openlist') as filterSourceType)
await scrollToBottom()
})
let logRefreshInterval: NodeJS.Timeout | null = null let logRefreshInterval: NodeJS.Timeout | null = null
@@ -63,6 +79,16 @@ const showNotificationMessage = (message: string, type: 'success' | 'info' | 'wa
}, 3000) }, 3000)
} }
const openLogsDirectory = async () => {
try {
await appStore.openLogsDirectory()
showNotificationMessage(t('logs.notifications.openDirectorySuccess'), 'success')
} catch (error) {
console.error('Failed to open logs directory:', error)
showNotificationMessage(t('logs.notifications.openDirectoryFailed'), 'error')
}
}
const stripAnsiCodes = (text: string): string => { const stripAnsiCodes = (text: string): string => {
return text.replace(/\u001b\[[0-9;]*[mGKHF]/g, '') return text.replace(/\u001b\[[0-9;]*[mGKHF]/g, '')
} }
@@ -104,11 +130,8 @@ const parseLogEntry = (logText: string) => {
} }
} }
} }
} else if (cleanText.includes('openlist_desktop') || cleanText.includes('tao::')) { } else {
source = 'app' source = filterSource.value
level = 'info'
} else if (cleanText.toLowerCase().includes('rclone')) {
source = 'rclone'
} }
message = message message = message
@@ -132,7 +155,7 @@ const parseLogEntry = (logText: string) => {
} }
const filteredLogs = computed(() => { const filteredLogs = computed(() => {
let logs = store.logs let logs = appStore.logs
.slice(-maxLines.value) .slice(-maxLines.value)
.filter((log: string | string[]) => !log.includes('/ping')) .filter((log: string | string[]) => !log.includes('/ping'))
.map(parseLogEntry) .map(parseLogEntry)
@@ -187,21 +210,30 @@ const scrollToTop = () => {
} }
const clearLogs = async () => { const clearLogs = async () => {
if (confirm(t('logs.messages.confirmClear'))) { confirmDialogConfig.value = {
try { title: t('logs.messages.confirmTitle') || t('common.confirm'),
await store.clearLogs( message: t('logs.messages.confirmClear'),
(filterSource.value !== 'all' && filterSource.value !== 'gin' ? filterSource.value : 'openlist') as onConfirm: async () => {
| 'openlist' showConfirmDialog.value = false
| 'rclone' try {
| 'app' await appStore.clearLogs(
) (filterSource.value !== 'all' && filterSource.value !== 'gin'
selectedEntries.value.clear() ? filterSource.value
showNotificationMessage(t('logs.notifications.clearSuccess'), 'success') : 'openlist') as filterSourceType
} catch (error) { )
console.error('Failed to clear logs:', error) selectedEntries.value.clear()
showNotificationMessage(t('logs.notifications.clearFailed'), 'error') showNotificationMessage(t('logs.notifications.clearSuccess'), 'success')
} catch (error) {
console.error('Failed to clear logs:', error)
showNotificationMessage(t('logs.notifications.clearFailed'), 'error')
}
},
onCancel: () => {
showConfirmDialog.value = false
} }
} }
showConfirmDialog.value = true
} }
const copyLogsToClipboard = async () => { const copyLogsToClipboard = async () => {
@@ -273,7 +305,9 @@ const togglePause = () => {
} }
const refreshLogs = async () => { const refreshLogs = async () => {
await store.loadLogs() await appStore.loadLogs(
(filterSource.value !== 'all' && filterSource.value !== 'gin' ? filterSource.value : 'openlist') as filterSourceType
)
await scrollToBottom() await scrollToBottom()
if (isPaused.value) { if (isPaused.value) {
isPaused.value = false isPaused.value = false
@@ -347,31 +381,22 @@ const handleKeydown = (event: KeyboardEvent) => {
} }
onMounted(async () => { onMounted(async () => {
await store.loadLogs( appStore.loadLogs((filterSource.value !== 'gin' ? filterSource.value : 'openlist') as filterSourceType).then(() => {
(filterSource.value !== 'all' && filterSource.value !== 'gin' ? filterSource.value : 'openlist') as scrollToBottom()
| 'openlist' })
| 'rclone'
| 'app'
)
await scrollToBottom()
document.addEventListener('keydown', handleKeydown) document.addEventListener('keydown', handleKeydown)
logRefreshInterval = setInterval(async () => { logRefreshInterval = setInterval(async () => {
if (!isPaused.value) { if (!isPaused.value) {
const oldLength = store.logs.length const oldLength = appStore.logs.length
await store.loadLogs( await appStore.loadLogs((filterSource.value !== 'gin' ? filterSource.value : 'openlist') as filterSourceType)
(filterSource.value !== 'all' && filterSource.value !== 'gin' ? filterSource.value : 'openlist') as
| 'openlist'
| 'rclone'
| 'app'
)
if (store.logs.length > oldLength) { if (appStore.logs.length > oldLength) {
await scrollToBottom() await scrollToBottom()
} }
} }
}, (store.settings.app.monitor_interval || 5) * 1000) }, 30 * 1000)
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -381,7 +406,7 @@ onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown) document.removeEventListener('keydown', handleKeydown)
}) })
const unwatchLogs = store.$subscribe(mutation => { const unwatchLogs = appStore.$subscribe(mutation => {
if (mutation.storeId === 'app') { if (mutation.storeId === 'app') {
const events = Array.isArray(mutation.events) ? mutation.events : [mutation.events] const events = Array.isArray(mutation.events) ? mutation.events : [mutation.events]
if (events.some((event: any) => event.key === 'logs')) { if (events.some((event: any) => event.key === 'logs')) {
@@ -402,14 +427,14 @@ onUnmounted(() => {
<button <button
class="toolbar-btn" class="toolbar-btn"
:class="{ active: isPaused }" :class="{ active: isPaused }"
@click="togglePause"
:title="isPaused ? t('logs.toolbar.resume') : t('logs.toolbar.pause')" :title="isPaused ? t('logs.toolbar.resume') : t('logs.toolbar.pause')"
@click="togglePause"
> >
<Pause v-if="!isPaused" :size="16" /> <Pause v-if="!isPaused" :size="16" />
<Play v-else :size="16" /> <Play v-else :size="16" />
</button> </button>
<button class="toolbar-btn" @click="refreshLogs" :title="t('logs.toolbar.refresh')"> <button class="toolbar-btn" :title="t('logs.toolbar.refresh')" @click="refreshLogs">
<RotateCcw :size="16" /> <RotateCcw :size="16" />
</button> </button>
@@ -418,8 +443,8 @@ onUnmounted(() => {
<button <button
class="toolbar-btn" class="toolbar-btn"
:class="{ active: showFilters }" :class="{ active: showFilters }"
@click="showFilters = !showFilters"
:title="t('logs.toolbar.showFilters')" :title="t('logs.toolbar.showFilters')"
@click="showFilters = !showFilters"
> >
<Filter :size="16" /> <Filter :size="16" />
</button> </button>
@@ -427,8 +452,8 @@ onUnmounted(() => {
<button <button
class="toolbar-btn" class="toolbar-btn"
:class="{ active: showSettings }" :class="{ active: showSettings }"
@click="showSettings = !showSettings"
:title="t('logs.toolbar.settings')" :title="t('logs.toolbar.settings')"
@click="showSettings = !showSettings"
> >
<Settings :size="16" /> <Settings :size="16" />
</button> </button>
@@ -451,7 +476,7 @@ onUnmounted(() => {
<div class="toolbar-section right"> <div class="toolbar-section right">
<div class="log-stats"> <div class="log-stats">
<span class="stat">{{ <span class="stat">{{
t('logs.stats.logsCount', { filtered: filteredLogs.length, total: store.logs.length }) t('logs.stats.logsCount', { filtered: filteredLogs.length, total: appStore.logs.length })
}}</span> }}</span>
<span v-if="selectedEntries.size > 0" class="stat selected"> <span v-if="selectedEntries.size > 0" class="stat selected">
{{ t('logs.stats.selected', { count: selectedEntries.size }) }} {{ t('logs.stats.selected', { count: selectedEntries.size }) }}
@@ -462,29 +487,38 @@ onUnmounted(() => {
<button <button
class="toolbar-btn" class="toolbar-btn"
@click="copyLogsToClipboard"
:title="t('logs.toolbar.copyToClipboard')" :title="t('logs.toolbar.copyToClipboard')"
:disabled="filteredLogs.length === 0" :disabled="filteredLogs.length === 0"
@click="copyLogsToClipboard"
> >
<Copy :size="16" /> <Copy :size="16" />
</button> </button>
<button <button
class="toolbar-btn" class="toolbar-btn"
@click="exportLogs"
:title="t('logs.toolbar.exportLogs')" :title="t('logs.toolbar.exportLogs')"
:disabled="filteredLogs.length === 0" :disabled="filteredLogs.length === 0"
@click="exportLogs"
> >
<Download :size="16" /> <Download :size="16" />
</button> </button>
<button class="toolbar-btn danger" @click="clearLogs" :title="t('logs.toolbar.clearLogs')"> <button
class="toolbar-btn danger"
:disabled="filteredLogs.length === 0 || filterSource === 'gin' || filterSource === 'all'"
:title="t('logs.toolbar.clearLogs')"
@click="clearLogs"
>
<Trash2 :size="16" /> <Trash2 :size="16" />
</button> </button>
<button class="toolbar-btn" :title="t('logs.toolbar.openLogsDirectory')" @click="openLogsDirectory">
<FolderOpen :size="16" />
</button>
<div class="toolbar-separator"></div> <div class="toolbar-separator"></div>
<button class="toolbar-btn" @click="toggleFullscreen" :title="t('logs.toolbar.toggleFullscreen')"> <button class="toolbar-btn" :title="t('logs.toolbar.toggleFullscreen')" @click="toggleFullscreen">
<Maximize2 v-if="!isFullscreen" :size="16" /> <Maximize2 v-if="!isFullscreen" :size="16" />
<Minimize2 v-else :size="16" /> <Minimize2 v-else :size="16" />
</button> </button>
@@ -509,16 +543,17 @@ onUnmounted(() => {
<option value="openlist">{{ t('logs.filters.sources.openlist') }}</option> <option value="openlist">{{ t('logs.filters.sources.openlist') }}</option>
<option value="gin">GIN Server</option> <option value="gin">GIN Server</option>
<option value="rclone">{{ t('logs.filters.sources.rclone') }}</option> <option value="rclone">{{ t('logs.filters.sources.rclone') }}</option>
<option value="service">{{ t('logs.filters.sources.service') }}</option>
<option value="app">{{ t('logs.filters.app') }}</option> <option value="app">{{ t('logs.filters.app') }}</option>
</select> </select>
</div> </div>
<div class="filter-actions"> <div class="filter-actions">
<button class="filter-btn" @click="selectAllVisible" :disabled="filteredLogs.length === 0"> <button class="filter-btn" :disabled="filteredLogs.length === 0" @click="selectAllVisible">
{{ t('logs.filters.actions.selectAll') }} {{ t('logs.filters.actions.selectAll') }}
</button> </button>
<button class="filter-btn" @click="clearSelection" :disabled="selectedEntries.size === 0"> <button class="filter-btn" :disabled="selectedEntries.size === 0" @click="clearSelection">
{{ t('logs.filters.actions.clearSelection') }} {{ t('logs.filters.actions.clearSelection') }}
</button> </button>
@@ -559,10 +594,10 @@ onUnmounted(() => {
<div class="log-col source">{{ t('logs.headers.source') }}</div> <div class="log-col source">{{ t('logs.headers.source') }}</div>
<div class="log-col message">{{ t('logs.headers.message') }}</div> <div class="log-col message">{{ t('logs.headers.message') }}</div>
<div class="log-col actions"> <div class="log-col actions">
<button class="scroll-btn" @click="scrollToTop" :title="t('logs.toolbar.scrollToTop')"> <button class="scroll-btn" :title="t('logs.toolbar.scrollToTop')" @click="scrollToTop">
<ArrowUp :size="14" /> <ArrowUp :size="14" />
</button> </button>
<button class="scroll-btn" @click="scrollToBottom" :title="t('logs.toolbar.scrollToBottom')"> <button class="scroll-btn" :title="t('logs.toolbar.scrollToBottom')" @click="scrollToBottom">
<ArrowDown :size="14" /> <ArrowDown :size="14" />
</button> </button>
</div> </div>
@@ -618,7 +653,7 @@ onUnmounted(() => {
<div class="status-right"> <div class="status-right">
<span class="status-item"> <span class="status-item">
{{ t('logs.status.showing', { filtered: filteredLogs.length, total: store.logs.length }) }} {{ t('logs.status.showing', { filtered: filteredLogs.length, total: appStore.logs.length }) }}
</span> </span>
</div> </div>
</div> </div>
@@ -636,6 +671,17 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</Transition> </Transition>
<ConfirmDialog
:is-open="showConfirmDialog"
:title="confirmDialogConfig.title"
:message="confirmDialogConfig.message"
:confirm-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
variant="danger"
@confirm="confirmDialogConfig.onConfirm"
@cancel="confirmDialogConfig.onCancel"
/>
</div> </div>
</template> </template>

View File

@@ -1,31 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, ComputedRef, Ref } from 'vue'
import { useTranslation } from '../composables/useI18n'
import { useRcloneStore } from '../stores/rclone'
import { import {
HardDrive,
Plus,
Edit,
Trash2,
Play,
Square,
CheckCircle, CheckCircle,
XCircle,
Loader,
Cloud, Cloud,
Search, Edit,
FolderOpen,
HardDrive,
Loader,
Play,
Plus,
RefreshCw, RefreshCw,
Save, Save,
X, Search,
Settings, Settings,
FolderOpen Square,
Trash2,
X,
XCircle
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { useAppStore } from '@/stores/app' import { computed, ComputedRef, onMounted, onUnmounted, Ref, ref } from 'vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue' import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
import { useAppStore } from '@/stores/app'
import { useTranslation } from '../composables/useI18n'
import { useRcloneStore } from '../stores/rclone'
const { t } = useTranslation() const { t } = useTranslation()
const rcloneStore = useRcloneStore() const rcloneStore = useRcloneStore()
const store = useAppStore() const appStore = useAppStore()
const showAddForm = ref(false) const showAddForm = ref(false)
const editingConfig = ref<RcloneFormConfig | null>(null) const editingConfig = ref<RcloneFormConfig | null>(null)
@@ -58,9 +60,77 @@ const configForm = ref({
} }
}) as Ref<RcloneFormConfig> }) as Ref<RcloneFormConfig>
const commonFlags = ref([
{
category: 'Caching',
flags: [
{ flag: '--vfs-cache-mode', value: 'full', descriptionKey: 'vfs-cache-mode-full' },
{ flag: '--vfs-cache-mode', value: 'writes', descriptionKey: 'vfs-cache-mode-writes' },
{ flag: '--vfs-cache-mode', value: 'minimal', descriptionKey: 'vfs-cache-mode-minimal' },
{ flag: '--vfs-cache-max-age', value: '24h', descriptionKey: 'vfs-cache-max-age' },
{ flag: '--vfs-cache-max-size', value: '10G', descriptionKey: 'vfs-cache-max-size' },
{ flag: '--dir-cache-time', value: '5m', descriptionKey: 'dir-cache-time' }
]
},
{
category: 'Performance',
flags: [
{ flag: '--buffer-size', value: '16M', descriptionKey: 'buffer-size-16M' },
{ flag: '--buffer-size', value: '32M', descriptionKey: 'buffer-size-32M' },
{ flag: '--vfs-read-chunk-size', value: '128M', descriptionKey: 'vfs-read-chunk-size' },
{ flag: '--transfers', value: '4', descriptionKey: 'transfers' },
{ flag: '--checkers', value: '8', descriptionKey: 'checkers' }
]
},
{
category: 'Bandwidth',
flags: [
{ flag: '--bwlimit', value: '10M', descriptionKey: 'bwlimit-10M' },
{ flag: '--bwlimit', value: '10M:100M', descriptionKey: 'bwlimit-10M:100M' },
{ flag: '--bwlimit', value: '08:00,512k 18:00,10M 23:00,off', descriptionKey: 'bwlimit-schedule' }
]
},
{
category: 'Network',
flags: [
{ flag: '--timeout', value: '5m', descriptionKey: 'timeout' },
{ flag: '--contimeout', value: '60s', descriptionKey: 'contimeout' },
{ flag: '--low-level-retries', value: '10', descriptionKey: 'low-level-retries' },
{ flag: '--retries', value: '3', descriptionKey: 'retries' }
]
},
{
category: 'Security',
flags: [
{ flag: '--read-only', value: '', descriptionKey: 'read-only' },
{ flag: '--allow-other', value: '', descriptionKey: 'allow-other' },
{ flag: '--allow-root', value: '', descriptionKey: 'allow-root' },
{ flag: '--umask', value: '022', descriptionKey: 'umask' }
]
},
{
category: 'WebDAV Specific',
flags: [
{ flag: '--webdav-headers', value: 'User-Agent,rclone/1.0', descriptionKey: 'webdav-headers' },
{ flag: '--webdav-bearer-token', value: '', descriptionKey: 'webdav-bearer-token' }
]
},
{
category: 'Debugging',
flags: [
{ flag: '--log-level', value: 'INFO', descriptionKey: 'log-level' },
{ flag: '--verbose', value: '', descriptionKey: 'verbose' },
{ flag: '--use-json-log', value: '', descriptionKey: 'use-json-log' },
{ flag: '--progress', value: '', descriptionKey: 'progress' }
]
}
])
const showFlagSelector = ref(false)
const filteredConfigs: ComputedRef<RcloneFormConfig[]> = computed(() => { const filteredConfigs: ComputedRef<RcloneFormConfig[]> = computed(() => {
let filtered: RcloneFormConfig[] = [] const filtered: RcloneFormConfig[] = []
const fullRemoteConfigs = store.fullRcloneConfigs const fullRemoteConfigs = appStore.fullRcloneConfigs
for (const config of fullRemoteConfigs) { for (const config of fullRemoteConfigs) {
if (!config) continue if (!config) continue
@@ -71,7 +141,7 @@ const filteredConfigs: ComputedRef<RcloneFormConfig[]> = computed(() => {
: true : true
if (!matchesSearch) continue if (!matchesSearch) continue
const mountInfo = store.mountInfos.find(mount => mount.name === config.name) const mountInfo = appStore.mountInfos.find(mount => mount.name === config.name)
const status = mountInfo?.status || 'unmounted' const status = mountInfo?.status || 'unmounted'
const matchesStatus = statusFilter.value === 'all' || status === statusFilter.value const matchesStatus = statusFilter.value === 'all' || status === statusFilter.value
@@ -83,12 +153,12 @@ const filteredConfigs: ComputedRef<RcloneFormConfig[]> = computed(() => {
}) })
const configCounts = computed(() => { const configCounts = computed(() => {
const fullConfigs = store.fullRcloneConfigs const fullConfigs = appStore.fullRcloneConfigs
return { return {
total: fullConfigs.length, total: fullConfigs.length,
mounted: store.mountedConfigs.length, mounted: appStore.mountedConfigs.length,
unmounted: fullConfigs.length - store.mountedConfigs.length, unmounted: fullConfigs.length - appStore.mountedConfigs.length,
error: store.mountInfos.filter(m => m.status === 'error').length error: appStore.mountInfos.filter(m => m.status === 'error').length
} }
}) })
@@ -122,7 +192,7 @@ const saveConfig = async () => {
try { try {
if (editingConfig.value && editingConfig.value.name) { if (editingConfig.value && editingConfig.value.name) {
await store.updateRemoteConfig(editingConfig.value.name, configForm.value.type, { await appStore.updateRemoteConfig(editingConfig.value.name, configForm.value.type, {
name: configForm.value.name, name: configForm.value.name,
type: configForm.value.type, type: configForm.value.type,
url: configForm.value.url, url: configForm.value.url,
@@ -135,7 +205,7 @@ const saveConfig = async () => {
extraFlags: configForm.value.extraFlags extraFlags: configForm.value.extraFlags
}) })
} else { } else {
await store.createRemoteConfig(configForm.value.name, configForm.value.type, { await appStore.createRemoteConfig(configForm.value.name, configForm.value.type, {
name: configForm.value.name, name: configForm.value.name,
type: configForm.value.type, type: configForm.value.type,
url: configForm.value.url, url: configForm.value.url,
@@ -178,7 +248,7 @@ const resetForm = () => {
const mountConfig = async (config: RcloneFormConfig) => { const mountConfig = async (config: RcloneFormConfig) => {
try { try {
await store.mountRemote(config.name) await appStore.mountRemote(config.name)
} catch (error: any) { } catch (error: any) {
console.error(error.message || t('mount.messages.failedToMount')) console.error(error.message || t('mount.messages.failedToMount'))
} }
@@ -187,7 +257,7 @@ const mountConfig = async (config: RcloneFormConfig) => {
const unmountConfig = async (config: RcloneFormConfig) => { const unmountConfig = async (config: RcloneFormConfig) => {
if (!config.name) return if (!config.name) return
try { try {
await store.unmountRemote(config.name) await appStore.unmountRemote(config.name)
} catch (error: any) { } catch (error: any) {
console.error(error.message || t('mount.messages.failedToUnmount')) console.error(error.message || t('mount.messages.failedToUnmount'))
} }
@@ -209,7 +279,7 @@ const confirmDelete = async () => {
if (!config || !config.name) return if (!config || !config.name) return
try { try {
await store.deleteRemoteConfig(config.name) await appStore.deleteRemoteConfig(config.name)
} catch (error: any) { } catch (error: any) {
console.error(error.message || t('mount.messages.failedToDelete')) console.error(error.message || t('mount.messages.failedToDelete'))
} finally { } finally {
@@ -228,8 +298,8 @@ const startBackend = async () => {
await rcloneStore.startRcloneBackend() await rcloneStore.startRcloneBackend()
await new Promise(resolve => setTimeout(resolve, 1000)) await new Promise(resolve => setTimeout(resolve, 1000))
await rcloneStore.checkRcloneBackendStatus() await rcloneStore.checkRcloneBackendStatus()
await store.loadRemoteConfigs() await appStore.loadRemoteConfigs()
await store.loadMountInfos() await appStore.loadMountInfos()
} catch (error: any) { } catch (error: any) {
console.error(error.message || t('mount.messages.failedToStartService')) console.error(error.message || t('mount.messages.failedToStartService'))
} }
@@ -237,14 +307,17 @@ const startBackend = async () => {
const stopBackend = async () => { const stopBackend = async () => {
try { try {
await rcloneStore.stopRcloneBackend() const stopped = await rcloneStore.stopRcloneBackend()
if (!stopped) {
throw new Error(t('mount.messages.failedToStopService'))
}
} catch (error: any) { } catch (error: any) {
console.error(error.message || t('mount.messages.failedToStopService')) console.error(error.message || t('mount.messages.failedToStopService'))
} }
} }
const getConfigStatus = (config: RcloneFormConfig) => { const getConfigStatus = (config: RcloneFormConfig) => {
const mountInfo = store.mountInfos.find(mount => mount.name === config.name) const mountInfo = appStore.mountInfos.find(mount => mount.name === config.name)
return mountInfo?.status || 'unmounted' return mountInfo?.status || 'unmounted'
} }
@@ -285,6 +358,58 @@ const removeFlag = (index: number) => {
} }
} }
const addFlagToConfig = (flag: { flag: string; value: string; descriptionKey: string }) => {
if (!configForm.value.extraFlags) {
configForm.value.extraFlags = []
}
const flagKey = `${flag.flag}${flag.value ? `=${flag.value}` : ''}`
if (flag.flag === '--vfs-cache-mode' || flag.flag === '--buffer-size' || flag.flag === '--log-level') {
const existingIndex = configForm.value.extraFlags.findIndex(existingFlag => existingFlag.startsWith(flag.flag))
if (existingIndex !== -1) {
configForm.value.extraFlags.splice(existingIndex, 1)
}
}
if (!configForm.value.extraFlags.includes(flagKey)) {
configForm.value.extraFlags.push(flagKey)
}
}
const removeFlagFromConfig = (flag: { flag: string; value: string; descriptionKey: string }) => {
if (!configForm.value.extraFlags) return
const flagKey = `${flag.flag}${flag.value ? `=${flag.value}` : ''}`
const index = configForm.value.extraFlags.indexOf(flagKey)
if (index !== -1) {
configForm.value.extraFlags.splice(index, 1)
}
}
const isFlagInConfig = (flag: { flag: string; value: string; descriptionKey: string }) => {
if (!configForm.value.extraFlags) return false
const flagKey = `${flag.flag}${flag.value ? `=${flag.value}` : ''}`
return configForm.value.extraFlags.includes(flagKey)
}
const toggleFlag = (flag: { flag: string; value: string; descriptionKey: string }) => {
if (isFlagInConfig(flag)) {
removeFlagFromConfig(flag)
} else {
addFlagToConfig(flag)
}
}
const closeFlagSelector = () => {
showFlagSelector.value = false
}
const getFlagDescription = (flag: { flag: string; value: string; descriptionKey: string }) => {
return t(`mount.config.flagDescriptions.${flag.descriptionKey}`)
}
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {
const key = event.key const key = event.key
const ctrl = event.ctrlKey const ctrl = event.ctrlKey
@@ -294,8 +419,8 @@ const handleKeydown = (event: KeyboardEvent) => {
addNewConfig() addNewConfig()
} else if (ctrl && key === 'r') { } else if (ctrl && key === 'r') {
event.preventDefault() event.preventDefault()
store.loadRemoteConfigs() appStore.loadRemoteConfigs()
store.loadMountInfos() appStore.loadMountInfos()
} else if (key === 'Escape') { } else if (key === 'Escape') {
event.preventDefault() event.preventDefault()
if (showAddForm.value) { if (showAddForm.value) {
@@ -311,7 +436,7 @@ const openInFileExplorer = async (path?: string) => {
} }
const normalizedPath = path.trim() const normalizedPath = path.trim()
try { try {
await store.openFolder(normalizedPath) await appStore.openFolder(normalizedPath)
} catch (error: any) { } catch (error: any) {
console.error('Failed to open mount point in file explorer:', error) console.error('Failed to open mount point in file explorer:', error)
const errorMessage = error.message || error.toString() || 'Unknown error' const errorMessage = error.message || error.toString() || 'Unknown error'
@@ -330,16 +455,33 @@ const dismissWebdavTip = () => {
localStorage.setItem('webdav_tip_dismissed', 'true') localStorage.setItem('webdav_tip_dismissed', 'true')
} }
const isWindows = computed(() => {
return typeof OS_PLATFORM !== 'undefined' && OS_PLATFORM === 'win32'
})
const showWinfspTip = ref(isWindows.value && !localStorage.getItem('winfsp_tip_dismissed'))
const dismissWinfspTip = () => {
showWinfspTip.value = false
localStorage.setItem('winfsp_tip_dismissed', 'true')
}
const shouldShowWebdavTip = computed(() => {
if (isWindows.value) {
return !showWinfspTip.value && showWebdavTip.value
}
return showWebdavTip.value
})
onMounted(async () => { onMounted(async () => {
document.addEventListener('keydown', handleKeydown) document.addEventListener('keydown', handleKeydown)
await rcloneStore.checkRcloneBackendStatus() rcloneStore.checkRcloneBackendStatus()
await store.loadRemoteConfigs() appStore.loadRemoteConfigs()
await store.loadMountInfos() appStore.loadMountInfos()
mountRefreshInterval = setInterval(store.loadMountInfos, (store.settings.app.monitor_interval || 5) * 1000) mountRefreshInterval = setInterval(appStore.loadMountInfos, 15 * 1000)
backendStatusCheckInterval = setInterval(() => { backendStatusCheckInterval = setInterval(() => {
rcloneStore.checkRcloneBackendStatus() rcloneStore.checkRcloneBackendStatus()
}, (store.settings.app.monitor_interval || 5) * 1000) }, 15 * 1000)
await rcloneStore.init() rcloneStore.init()
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -393,14 +535,14 @@ onUnmounted(() => {
{{ rcloneStore.serviceRunning ? t('mount.service.running') : t('mount.service.stopped') }} {{ rcloneStore.serviceRunning ? t('mount.service.running') : t('mount.service.stopped') }}
</span> </span>
<button <button
@click="rcloneStore.serviceRunning ? stopBackend() : startBackend()"
:class="['service-toggle', { active: rcloneStore.serviceRunning }]" :class="['service-toggle', { active: rcloneStore.serviceRunning }]"
:disabled="rcloneStore.loading" :disabled="rcloneStore.loading"
@click="rcloneStore.serviceRunning ? stopBackend() : startBackend()"
> >
<component :is="rcloneStore.serviceRunning ? Square : Play" class="btn-icon" /> <component :is="rcloneStore.serviceRunning ? Square : Play" class="btn-icon" />
</button> </button>
</div> </div>
<button @click="addNewConfig" class="primary-btn"> <button class="primary-btn" @click="addNewConfig">
<Plus class="btn-icon" /> <Plus class="btn-icon" />
<span>{{ t('mount.actions.addRemote') }}</span> <span>{{ t('mount.actions.addRemote') }}</span>
</button> </button>
@@ -408,7 +550,7 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<div v-if="showWebdavTip" class="webdav-tip"> <div v-if="shouldShowWebdavTip" class="webdav-tip">
<div class="tip-content"> <div class="tip-content">
<div class="tip-icon"> <div class="tip-icon">
<Settings class="icon" /> <Settings class="icon" />
@@ -417,7 +559,22 @@ onUnmounted(() => {
<h4 class="tip-title">{{ t('mount.tip.webdavTitle') }}</h4> <h4 class="tip-title">{{ t('mount.tip.webdavTitle') }}</h4>
<p class="tip-description">{{ t('mount.tip.webdavMessage') }}</p> <p class="tip-description">{{ t('mount.tip.webdavMessage') }}</p>
</div> </div>
<button @click="dismissWebdavTip" class="tip-close" :title="t('mount.tip.dismissForever')"> <button class="tip-close" :title="t('mount.tip.dismissForever')" @click="dismissWebdavTip">
<X class="close-icon" />
</button>
</div>
</div>
<div v-if="showWinfspTip" class="winfsp-tip">
<div class="tip-content">
<div class="tip-icon">
<HardDrive class="icon" />
</div>
<div class="tip-message">
<h4 class="tip-title">{{ t('mount.tip.winfspTitle') }}</h4>
<p class="tip-description">{{ t('mount.tip.winfspMessage') }}</p>
</div>
<button class="tip-close" :title="t('mount.tip.dismissForever')" @click="dismissWinfspTip">
<X class="close-icon" /> <X class="close-icon" />
</button> </button>
</div> </div>
@@ -441,7 +598,7 @@ onUnmounted(() => {
<option value="unmounted">{{ t('mount.status.unmounted') }}</option> <option value="unmounted">{{ t('mount.status.unmounted') }}</option>
<option value="error">{{ t('mount.status.error') }}</option> <option value="error">{{ t('mount.status.error') }}</option>
</select> </select>
<button @click="store.loadMountInfos" class="refresh-btn" :disabled="rcloneStore.loading"> <button class="refresh-btn" :disabled="rcloneStore.loading" @click="appStore.loadMountInfos">
<RefreshCw class="refresh-icon" :class="{ spinning: rcloneStore.loading }" /> <RefreshCw class="refresh-icon" :class="{ spinning: rcloneStore.loading }" />
</button> </button>
</div> </div>
@@ -450,7 +607,7 @@ onUnmounted(() => {
<div v-if="rcloneStore.error" class="error-alert"> <div v-if="rcloneStore.error" class="error-alert">
<XCircle class="alert-icon" /> <XCircle class="alert-icon" />
<span class="alert-message">{{ rcloneStore.error }}</span> <span class="alert-message">{{ rcloneStore.error }}</span>
<button @click="rcloneStore.clearError" class="alert-close"> <button class="alert-close" @click="rcloneStore.clearError">
<X class="close-icon" /> <X class="close-icon" />
</button> </button>
</div> </div>
@@ -462,7 +619,7 @@ onUnmounted(() => {
<Cloud class="empty-icon" /> <Cloud class="empty-icon" />
<h3 class="empty-title">{{ t('mount.empty.title') }}</h3> <h3 class="empty-title">{{ t('mount.empty.title') }}</h3>
<p class="empty-description">{{ t('mount.empty.description') }}</p> <p class="empty-description">{{ t('mount.empty.description') }}</p>
<button @click="addNewConfig" class="empty-action-btn"> <button class="empty-action-btn" @click="addNewConfig">
<Plus class="btn-icon" /> <Plus class="btn-icon" />
<span>{{ t('mount.actions.addRemote') }}</span> <span>{{ t('mount.actions.addRemote') }}</span>
</button> </button>
@@ -495,7 +652,7 @@ onUnmounted(() => {
:is="getStatusIcon(getConfigStatus(config))" :is="getStatusIcon(getConfigStatus(config))"
class="status-icon" class="status-icon"
:class="{ :class="{
spinning: isConfigMounting(config) || store.loading, spinning: isConfigMounting(config) || appStore.loading,
success: getConfigStatus(config) === 'mounted', success: getConfigStatus(config) === 'mounted',
error: getConfigStatus(config) === 'error' error: getConfigStatus(config) === 'error'
}" }"
@@ -509,8 +666,8 @@ onUnmounted(() => {
<span <span
v-if="config.mountPoint" v-if="config.mountPoint"
class="meta-tag clickable-mount-point" class="meta-tag clickable-mount-point"
@click="openInFileExplorer(config.mountPoint)"
:title="t('mount.meta.openInExplorer')" :title="t('mount.meta.openInExplorer')"
@click="openInFileExplorer(config.mountPoint)"
> >
<FolderOpen class="mount-point-icon" /> <FolderOpen class="mount-point-icon" />
{{ config.mountPoint }} {{ config.mountPoint }}
@@ -524,19 +681,19 @@ onUnmounted(() => {
<div class="action-group"> <div class="action-group">
<button <button
v-if="!isConfigMounted(config)" v-if="!isConfigMounted(config)"
@click="mountConfig(config)"
class="action-btn primary" class="action-btn primary"
:disabled="isConfigMounting(config) || !config.mountPoint" :disabled="isConfigMounting(config) || !config.mountPoint"
:title="!config.mountPoint ? t('mount.messages.mountPointRequired') : ''" :title="!config.mountPoint ? t('mount.messages.mountPointRequired') : ''"
@click="mountConfig(config)"
> >
<Play class="btn-icon" /> <Play class="btn-icon" />
<span>{{ t('mount.actions.mount') }}</span> <span>{{ t('mount.actions.mount') }}</span>
</button> </button>
<button <button
v-else v-else
@click="unmountConfig(config)"
class="action-btn warning" class="action-btn warning"
:disabled="isConfigMounting(config)" :disabled="isConfigMounting(config)"
@click="unmountConfig(config)"
> >
<Square class="btn-icon" /> <Square class="btn-icon" />
<span>{{ t('mount.actions.unmount') }}</span> <span>{{ t('mount.actions.unmount') }}</span>
@@ -544,22 +701,22 @@ onUnmounted(() => {
</div> </div>
<div class="secondary-actions"> <div class="secondary-actions">
<button @click="editConfig(config)" class="secondary-btn" :title="t('mount.actions.edit')"> <button class="secondary-btn" :title="t('mount.actions.edit')" @click="editConfig(config)">
<Edit class="btn-icon" /> <Edit class="btn-icon" />
</button> </button>
<button <button
@click="deleteConfig(config)"
class="secondary-btn danger" class="secondary-btn danger"
:disabled="isConfigMounted(config)" :disabled="isConfigMounted(config)"
:title="t('mount.actions.delete')" :title="t('mount.actions.delete')"
@click="deleteConfig(config)"
> >
<Trash2 class="btn-icon" /> <Trash2 class="btn-icon" />
</button> </button>
<button <button
v-if="isConfigMounted(config)" v-if="isConfigMounted(config)"
@click="openInFileExplorer(config.mountPoint)"
class="secondary-btn" class="secondary-btn"
:title="t('mount.actions.openInExplorer')" :title="t('mount.actions.openInExplorer')"
@click="openInFileExplorer(config.mountPoint)"
> >
<FolderOpen class="btn-icon" /> <FolderOpen class="btn-icon" />
</button> </button>
@@ -569,7 +726,7 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- Configuration Modal --> <!-- Configuration Modal -->
<div v-if="showAddForm" class="modal-backdrop" @click="cancelForm"> <div v-if="showAddForm" class="modal-backdrop">
<div class="config-modal" @click.stop> <div class="config-modal" @click.stop>
<div class="modal-header"> <div class="modal-header">
<div class="modal-title-section"> <div class="modal-title-section">
@@ -578,7 +735,7 @@ onUnmounted(() => {
{{ editingConfig ? t('mount.config.editTitle') : t('mount.config.addTitle') }} {{ editingConfig ? t('mount.config.editTitle') : t('mount.config.addTitle') }}
</h2> </h2>
</div> </div>
<button @click="cancelForm" class="modal-close"> <button class="modal-close" @click="cancelForm">
<X class="close-icon" /> <X class="close-icon" />
</button> </button>
</div> </div>
@@ -689,6 +846,68 @@ onUnmounted(() => {
<h3 class="section-title">{{ t('mount.config.advancedSettings') }}</h3> <h3 class="section-title">{{ t('mount.config.advancedSettings') }}</h3>
<div class="form-field"> <div class="form-field">
<label class="field-label">{{ t('mount.config.extraFlags') }}</label> <label class="field-label">{{ t('mount.config.extraFlags') }}</label>
<div class="flags-header">
<button
type="button"
class="quick-flags-btn"
:title="t('mount.config.quickFlagsTooltip')"
@click="showFlagSelector = !showFlagSelector"
>
<Settings class="btn-icon" />
<span>{{ t('mount.config.quickFlags') }}</span>
</button>
</div>
<div v-if="showFlagSelector" class="flag-selector-backdrop" @click="closeFlagSelector">
<div class="flag-selector-popup" @click.stop>
<div class="flag-selector-header">
<h4>{{ t('mount.config.selectCommonFlags') }}</h4>
<button class="close-selector-btn" @click="closeFlagSelector">
<X class="btn-icon" />
</button>
</div>
<div class="flag-selector-content">
<div class="flag-selector-help">
<p>{{ t('mount.config.clickToToggleFlags') }}</p>
</div>
<div class="flag-categories">
<div v-for="category in commonFlags" :key="category.category" class="flag-category">
<div class="category-header">
<h5>{{ t(`mount.config.flagCategories.${category.category}`) }}</h5>
</div>
<div class="category-flags">
<div
v-for="flag in category.flags"
:key="`${flag.flag}-${flag.value}`"
class="flag-option"
:class="{
selected: isFlagInConfig(flag),
'in-config': isFlagInConfig(flag)
}"
:title="getFlagDescription(flag)"
@click="toggleFlag(flag)"
>
<div class="flag-checkbox">
<div class="custom-checkbox" :class="{ checked: isFlagInConfig(flag) }">
<CheckCircle v-if="isFlagInConfig(flag)" class="check-icon" />
</div>
</div>
<div class="flag-content">
<code class="flag-code">{{ flag.flag }}{{ flag.value ? `=${flag.value}` : '' }}</code>
<span class="flag-description">{{ getFlagDescription(flag) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Manual Flags Input -->
<div class="flags-container"> <div class="flags-container">
<div v-for="(_, index) in configForm.extraFlags || []" :key="index" class="flag-item"> <div v-for="(_, index) in configForm.extraFlags || []" :key="index" class="flag-item">
<input <input
@@ -698,15 +917,15 @@ onUnmounted(() => {
:placeholder="t('mount.config.flagPlaceholder')" :placeholder="t('mount.config.flagPlaceholder')"
/> />
<button <button
@click="removeFlag(index)"
type="button" type="button"
class="remove-flag-btn" class="remove-flag-btn"
:title="t('mount.config.removeFlag')" :title="t('mount.config.removeFlag')"
@click="removeFlag(index)"
> >
<X class="btn-icon" /> <X class="btn-icon" />
</button> </button>
</div> </div>
<button @click="addFlag" type="button" class="add-flag-btn"> <button type="button" class="add-flag-btn" @click="addFlag">
<Plus class="btn-icon" /> <Plus class="btn-icon" />
<span>{{ t('mount.config.addFlag') }}</span> <span>{{ t('mount.config.addFlag') }}</span>
</button> </button>
@@ -717,11 +936,11 @@ onUnmounted(() => {
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button @click="cancelForm" class="cancel-btn"> <button class="cancel-btn" @click="cancelForm">
<X class="btn-icon" /> <X class="btn-icon" />
<span>{{ t('common.cancel') }}</span> <span>{{ t('common.cancel') }}</span>
</button> </button>
<button @click="saveConfig" class="save-btn" :disabled="store.loading"> <button class="save-btn" :disabled="appStore.loading" @click="saveConfig">
<Save class="btn-icon" /> <Save class="btn-icon" />
<span>{{ editingConfig ? t('common.save') : t('common.add') }}</span> <span>{{ editingConfig ? t('common.save') : t('common.add') }}</span>
</button> </button>

View File

@@ -1,14 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue' import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart'
import { useRoute, useRouter } from 'vue-router' import { open } from '@tauri-apps/plugin-dialog'
import { useAppStore } from '../stores/app' import {
import { useTranslation } from '../composables/useI18n' AlertCircle,
import { Settings, Server, HardDrive, Save, RotateCcw, AlertCircle, CheckCircle, Play } from 'lucide-vue-next' CheckCircle,
import { enable, isEnabled, disable } from '@tauri-apps/plugin-autostart' ExternalLink,
FolderOpen,
HardDrive,
RotateCcw,
Save,
Server,
Settings
} from 'lucide-vue-next'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const store = useAppStore() import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
import { useTranslation } from '../composables/useI18n'
import { useAppStore } from '../stores/app'
const appStore = useAppStore()
const route = useRoute() const route = useRoute()
const router = useRouter()
const { t } = useTranslation() const { t } = useTranslation()
const isSaving = ref(false) const isSaving = ref(false)
const message = ref('') const message = ref('')
@@ -16,11 +28,22 @@ const messageType = ref<'success' | 'error' | 'info'>('info')
const activeTab = ref('openlist') const activeTab = ref('openlist')
const rcloneConfigJson = ref('') const rcloneConfigJson = ref('')
const autoStartApp = ref(false) const autoStartApp = ref(false)
const isResettingPassword = ref(false)
const showConfirmDialog = ref(false)
const confirmDialogConfig = ref({
title: '',
message: '',
onConfirm: () => {},
onCancel: () => {}
})
const openlistCoreSettings = reactive({ ...store.settings.openlist }) const openlistCoreSettings = reactive({ ...appStore.settings.openlist })
const rcloneSettings = reactive({ ...store.settings.rclone }) const rcloneSettings = reactive({ ...appStore.settings.rclone })
const appSettings = reactive({ ...store.settings.app }) const appSettings = reactive({ ...appStore.settings.app })
let originalOpenlistPort = openlistCoreSettings.port || 5244 let originalOpenlistPort = openlistCoreSettings.port || 5244
let originalDataDir = openlistCoreSettings.data_dir
let originalRcloneApiPort = rcloneSettings.api_port || 45572
let originalAdminPassword = appStore.settings.app.admin_password || ''
watch(autoStartApp, async newValue => { watch(autoStartApp, async newValue => {
if (newValue) { if (newValue) {
@@ -59,33 +82,43 @@ onMounted(async () => {
} }
if (!openlistCoreSettings.port) openlistCoreSettings.port = 5244 if (!openlistCoreSettings.port) openlistCoreSettings.port = 5244
if (!openlistCoreSettings.api_token) openlistCoreSettings.api_token = '' if (!openlistCoreSettings.data_dir) openlistCoreSettings.data_dir = ''
if (openlistCoreSettings.auto_launch === undefined) openlistCoreSettings.auto_launch = false if (openlistCoreSettings.auto_launch === undefined) openlistCoreSettings.auto_launch = false
if (openlistCoreSettings.ssl_enabled === undefined) openlistCoreSettings.ssl_enabled = false if (openlistCoreSettings.ssl_enabled === undefined) openlistCoreSettings.ssl_enabled = false
if (!rcloneSettings.config) rcloneSettings.config = {} if (!rcloneSettings.config) rcloneSettings.config = {}
if (!rcloneSettings.api_port) rcloneSettings.api_port = 45572
rcloneConfigJson.value = JSON.stringify(rcloneSettings.config, null, 2) rcloneConfigJson.value = JSON.stringify(rcloneSettings.config, null, 2)
if (!appSettings.theme) appSettings.theme = 'light' if (!appSettings.theme) appSettings.theme = 'light'
if (!appSettings.monitor_interval) appSettings.monitor_interval = 5
if (appSettings.auto_update_enabled === undefined) appSettings.auto_update_enabled = true if (appSettings.auto_update_enabled === undefined) appSettings.auto_update_enabled = true
if (!appSettings.gh_proxy) appSettings.gh_proxy = ''
if (appSettings.gh_proxy_api === undefined) appSettings.gh_proxy_api = false
if (appSettings.open_links_in_browser === undefined) appSettings.open_links_in_browser = false
if (appSettings.show_window_on_startup === undefined) appSettings.show_window_on_startup = true
if (!appSettings.admin_password) appSettings.admin_password = ''
originalOpenlistPort = openlistCoreSettings.port || 5244 originalOpenlistPort = openlistCoreSettings.port || 5244
originalDataDir = openlistCoreSettings.data_dir
originalRcloneApiPort = rcloneSettings.api_port || 45572
// Load current admin password
await loadCurrentAdminPassword()
}) })
const hasUnsavedChanges = computed(() => { const hasUnsavedChanges = computed(() => {
let rcloneConfigChanged = false let rcloneConfigChanged = false
try { try {
const parsedConfig = JSON.parse(rcloneConfigJson.value) const parsedConfig = JSON.parse(rcloneConfigJson.value)
rcloneConfigChanged = JSON.stringify(parsedConfig) !== JSON.stringify(store.settings.rclone.config) rcloneConfigChanged = JSON.stringify(parsedConfig) !== JSON.stringify(appStore.settings.rclone.config)
} catch { } catch {
rcloneConfigChanged = rcloneConfigJson.value !== JSON.stringify(store.settings.rclone.config, null, 2) rcloneConfigChanged = rcloneConfigJson.value !== JSON.stringify(appStore.settings.rclone.config, null, 2)
} }
return ( return (
JSON.stringify(openlistCoreSettings) !== JSON.stringify(store.settings.openlist) || JSON.stringify(openlistCoreSettings) !== JSON.stringify(appStore.settings.openlist) ||
JSON.stringify(rcloneSettings) !== JSON.stringify(store.settings.rclone) || JSON.stringify(rcloneSettings) !== JSON.stringify(appStore.settings.rclone) ||
JSON.stringify(appSettings) !== JSON.stringify(store.settings.app) || JSON.stringify(appSettings) !== JSON.stringify(appStore.settings.app) ||
rcloneConfigChanged rcloneConfigChanged
) )
}) })
@@ -104,16 +137,40 @@ const handleSave = async () => {
return return
} }
store.settings.openlist = { ...openlistCoreSettings } appStore.settings.openlist = { ...openlistCoreSettings }
store.settings.rclone = { ...rcloneSettings } appStore.settings.rclone = { ...rcloneSettings }
store.settings.app = { ...appSettings } appStore.settings.app = { ...appSettings }
if (originalOpenlistPort !== openlistCoreSettings.port) {
await store.saveSettingsWithUpdatePort() const needsPasswordUpdate = originalAdminPassword !== appSettings.admin_password && appSettings.admin_password
if (
originalOpenlistPort !== openlistCoreSettings.port ||
originalDataDir !== openlistCoreSettings.data_dir ||
originalRcloneApiPort !== rcloneSettings.api_port
) {
await appStore.saveSettingsWithCoreUpdate()
} else { } else {
await store.saveSettings() await appStore.saveSettings()
} }
message.value = t('settings.saved')
messageType.value = 'success' if (needsPasswordUpdate) {
try {
await appStore.setAdminPassword(appSettings.admin_password!)
message.value = t('settings.service.admin.passwordUpdated')
messageType.value = 'success'
} catch (error) {
console.error('Failed to update admin password:', error)
message.value = t('settings.service.admin.passwordUpdateFailed')
messageType.value = 'error'
}
} else {
message.value = t('settings.saved')
messageType.value = 'success'
}
originalOpenlistPort = openlistCoreSettings.port || 5244
originalRcloneApiPort = rcloneSettings.api_port || 45572
originalDataDir = openlistCoreSettings.data_dir
} catch (error) { } catch (error) {
message.value = t('settings.saveFailed') message.value = t('settings.saveFailed')
messageType.value = 'error' messageType.value = 'error'
@@ -127,29 +184,143 @@ const handleSave = async () => {
} }
} }
async function startTutorial() {
router.push({ name: 'Dashboard' })
store.startTutorial()
}
const handleReset = async () => { const handleReset = async () => {
if (!confirm(t('settings.confirmReset'))) { confirmDialogConfig.value = {
return title: t('settings.confirmReset.title'),
message: t('settings.confirmReset.message'),
onConfirm: async () => {
showConfirmDialog.value = false
try {
await appStore.resetSettings()
Object.assign(openlistCoreSettings, appStore.settings.openlist)
Object.assign(rcloneSettings, appStore.settings.rclone)
Object.assign(appSettings, appStore.settings.app)
rcloneConfigJson.value = JSON.stringify(rcloneSettings.config, null, 2)
message.value = t('settings.resetSuccess')
messageType.value = 'info'
} catch (error) {
message.value = t('settings.resetFailed')
messageType.value = 'error'
}
},
onCancel: () => {
showConfirmDialog.value = false
}
} }
showConfirmDialog.value = true
}
const handleSelectDataDir = async () => {
try { try {
await store.resetSettings() const selected = await open({
Object.assign(openlistCoreSettings, store.settings.openlist) directory: true,
Object.assign(rcloneSettings, store.settings.rclone) multiple: false,
Object.assign(appSettings, store.settings.app) title: t('settings.service.network.dataDir.selectTitle'),
defaultPath: openlistCoreSettings.data_dir || undefined
})
rcloneConfigJson.value = JSON.stringify(rcloneSettings.config, null, 2) if (selected && typeof selected === 'string') {
openlistCoreSettings.data_dir = selected
message.value = t('settings.resetSuccess') }
messageType.value = 'info'
} catch (error) { } catch (error) {
message.value = t('settings.resetFailed') console.error('Failed to select directory:', error)
message.value = t('settings.service.network.dataDir.selectError')
messageType.value = 'error' messageType.value = 'error'
setTimeout(() => {
message.value = ''
}, 3000)
}
}
const handleOpenDataDir = async () => {
try {
if (openlistCoreSettings.data_dir) {
await appStore.openFolder(openlistCoreSettings.data_dir)
} else {
await appStore.openOpenListDataDir()
}
message.value = t('settings.service.network.dataDir.openSuccess')
messageType.value = 'success'
} catch (error) {
console.error('Failed to open data directory:', error)
message.value = t('settings.service.network.dataDir.openError')
messageType.value = 'error'
} finally {
setTimeout(() => {
message.value = ''
}, 3000)
}
}
const handleResetAdminPassword = async () => {
isResettingPassword.value = true
try {
const newPassword = await appStore.resetAdminPassword()
if (newPassword) {
appSettings.admin_password = newPassword
message.value = t('settings.service.admin.resetSuccess')
messageType.value = 'success'
} else {
message.value = t('settings.service.admin.resetFailed')
messageType.value = 'error'
}
} catch (error) {
console.error('Failed to reset admin password:', error)
message.value = t('settings.service.admin.resetFailed')
messageType.value = 'error'
} finally {
isResettingPassword.value = false
setTimeout(() => {
message.value = ''
}, 3000)
}
}
const handleOpenRcloneConfig = async () => {
try {
await appStore.openRcloneConfigFile()
message.value = t('settings.rclone.config.openSuccess')
messageType.value = 'success'
} catch (error) {
console.error('Failed to open rclone config file:', error)
message.value = t('settings.rclone.config.openError')
messageType.value = 'error'
} finally {
setTimeout(() => {
message.value = ''
}, 3000)
}
}
const handleOpenSettingsFile = async () => {
try {
await appStore.openSettingsFile()
message.value = t('settings.app.config.openSuccess')
messageType.value = 'success'
} catch (error) {
console.error('Failed to open settings file:', error)
message.value = t('settings.app.config.openError')
messageType.value = 'error'
} finally {
setTimeout(() => {
message.value = ''
}, 3000)
}
}
const loadCurrentAdminPassword = async () => {
try {
const password = await appStore.getAdminPassword()
if (password) {
appSettings.admin_password = password
originalAdminPassword = password
}
} catch (error) {
console.error('Failed to load admin password:', error)
} }
} }
</script> </script>
@@ -165,11 +336,11 @@ const handleReset = async () => {
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button @click="handleReset" class="btn btn-secondary" :title="t('settings.resetToDefaults')"> <button class="btn btn-secondary" :title="t('settings.resetToDefaults')" @click="handleReset">
<RotateCcw :size="16" /> <RotateCcw :size="16" />
{{ t('common.reset') }} {{ t('common.reset') }}
</button> </button>
<button @click="handleSave" :disabled="!hasUnsavedChanges || isSaving" class="btn btn-primary"> <button :disabled="!hasUnsavedChanges || isSaving" class="btn btn-primary" @click="handleSave">
<Save :size="16" /> <Save :size="16" />
{{ isSaving ? t('common.saving') : t('settings.saveChanges') }} {{ isSaving ? t('common.saving') : t('settings.saveChanges') }}
</button> </button>
@@ -179,16 +350,16 @@ const handleReset = async () => {
<div v-if="message" class="message-banner" :class="messageType"> <div v-if="message" class="message-banner" :class="messageType">
<component :is="messageType === 'success' ? CheckCircle : AlertCircle" :size="16" /> <component :is="messageType === 'success' ? CheckCircle : AlertCircle" :size="16" />
<span>{{ message }}</span> <span>{{ message }}</span>
<button @click="message = ''" class="message-close">×</button> <button class="message-close" @click="message = ''">×</button>
</div> </div>
<div class="tab-navigation"> <div class="tab-navigation">
<button <button
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.id" :key="tab.id"
@click="activeTab = tab.id"
class="tab-button" class="tab-button"
:class="{ active: activeTab === tab.id }" :class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
> >
<component :is="tab.icon" :size="18" /> <component :is="tab.icon" :size="18" />
<span>{{ tab.label }}</span> <span>{{ tab.label }}</span>
@@ -215,14 +386,32 @@ const handleReset = async () => {
<small>{{ t('settings.service.network.port.help') }}</small> <small>{{ t('settings.service.network.port.help') }}</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>{{ t('settings.service.network.apiToken.label') }}</label> <label>{{ t('settings.service.network.dataDir.label') }}</label>
<input <div class="input-group">
v-model="openlistCoreSettings.api_token" <input
type="password" v-model="openlistCoreSettings.data_dir"
class="form-input" type="text"
:placeholder="t('settings.service.network.apiToken.placeholder')" class="form-input"
/> :placeholder="t('settings.service.network.dataDir.placeholder')"
<small>{{ t('settings.service.network.apiToken.help') }}</small> />
<button
type="button"
class="input-addon-btn"
:title="t('settings.service.network.dataDir.selectTitle')"
@click="handleSelectDataDir"
>
<FolderOpen :size="16" />
</button>
<button
type="button"
class="input-addon-btn"
:title="t('settings.service.network.dataDir.openTitle')"
@click="handleOpenDataDir"
>
<ExternalLink :size="16" />
</button>
</div>
<small>{{ t('settings.service.network.dataDir.help') }}</small>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -253,20 +442,79 @@ const handleReset = async () => {
</label> </label>
</div> </div>
</div> </div>
<div class="settings-section">
<h2>{{ t('settings.service.admin.title') }}</h2>
<p>{{ t('settings.service.admin.subtitle') }}</p>
<div class="form-group">
<label>{{ t('settings.service.admin.currentPassword') }}</label>
<div class="input-group">
<input
v-model="appSettings.admin_password"
type="text"
class="form-input"
:placeholder="t('settings.service.admin.passwordPlaceholder')"
/>
<button
type="button"
:disabled="isResettingPassword"
class="input-addon-btn reset-password-btn"
:title="t('settings.service.admin.resetTitle')"
@click="handleResetAdminPassword"
>
<RotateCcw :size="16" />
</button>
</div>
<small>{{ t('settings.service.admin.help') }}</small>
</div>
</div>
</div> </div>
<div v-if="activeTab === 'rclone'" class="tab-content"> <div v-if="activeTab === 'rclone'" class="tab-content">
<div class="settings-section">
<h2>{{ t('settings.rclone.api.title') }}</h2>
<p>{{ t('settings.rclone.api.subtitle') }}</p>
<div class="form-grid">
<div class="form-group">
<label>{{ t('settings.rclone.api.port.label') }}</label>
<input
v-model.number="rcloneSettings.api_port"
type="number"
class="form-input"
:placeholder="t('settings.rclone.api.port.placeholder')"
min="1"
max="65535"
/>
<small>{{ t('settings.rclone.api.port.help') }}</small>
</div>
</div>
</div>
<div class="settings-section"> <div class="settings-section">
<h2>{{ t('settings.rclone.config.title') }}</h2> <h2>{{ t('settings.rclone.config.title') }}</h2>
<p>{{ t('settings.rclone.config.subtitle') }}</p> <p>{{ t('settings.rclone.config.subtitle') }}</p>
<div class="form-group"> <div class="form-group">
<label>{{ t('settings.rclone.config.label') }}</label> <label>{{ t('settings.rclone.config.label') }}</label>
<div class="settings-section-actions">
<button
type="button"
class="btn btn-secondary"
:title="t('settings.rclone.config.openFile')"
@click="handleOpenRcloneConfig"
>
<ExternalLink :size="16" />
{{ t('settings.rclone.config.openFile') }}
</button>
</div>
<textarea <textarea
v-model="rcloneConfigJson" v-model="rcloneConfigJson"
class="form-textarea" class="form-textarea"
placeholder='{ "remote1": { "type": "s3", "provider": "AWS" } }' placeholder='{ "remote1": { "type": "s3", "provider": "AWS" } }'
rows="10" rows="10"
readonly
></textarea> ></textarea>
<small>{{ t('settings.rclone.config.tips') }}</small> <small>{{ t('settings.rclone.config.tips') }}</small>
</div> </div>
@@ -283,8 +531,8 @@ const handleReset = async () => {
<label>{{ t('settings.theme.title') }}</label> <label>{{ t('settings.theme.title') }}</label>
<select <select
v-model="appSettings.theme" v-model="appSettings.theme"
@change="store.setTheme(appSettings.theme || 'light')"
class="form-input" class="form-input"
@change="appStore.setTheme(appSettings.theme || 'light')"
> >
<option value="light">{{ t('settings.app.theme.light') }}</option> <option value="light">{{ t('settings.app.theme.light') }}</option>
<option value="dark">{{ t('settings.app.theme.dark') }}</option> <option value="dark">{{ t('settings.app.theme.dark') }}</option>
@@ -296,23 +544,51 @@ const handleReset = async () => {
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h2>{{ t('settings.app.monitor.title') }}</h2> <h2>{{ t('settings.app.config.title') }}</h2>
<p>{{ t('settings.app.monitor.subtitle') }}</p> <p>{{ t('settings.app.config.subtitle') }}</p>
<div class="form-group">
<div class="settings-section-actions">
<button
type="button"
class="btn btn-secondary"
:title="t('settings.app.config.openFile')"
@click="handleOpenSettingsFile"
>
<ExternalLink :size="16" />
{{ t('settings.app.config.openFile') }}
</button>
</div>
</div>
</div>
<div class="settings-section">
<h2>{{ t('settings.app.ghProxy.title') }}</h2>
<p>{{ t('settings.app.ghProxy.subtitle') }}</p>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label>{{ t('settings.app.monitor.interval.label') }}</label> <label>{{ t('settings.app.ghProxy.label') }}</label>
<input <input
v-model.number="appSettings.monitor_interval" v-model="appSettings.gh_proxy"
type="number" type="text"
class="form-input" class="form-input"
:placeholder="t('settings.app.monitor.interval.placeholder')" :placeholder="t('settings.app.ghProxy.placeholder')"
min="1"
max="60"
/> />
<small>{{ t('settings.app.monitor.interval.help') }}</small> <small>{{ t('settings.app.ghProxy.help') }}</small>
</div> </div>
</div> </div>
<div class="form-group">
<label class="switch-label">
<input v-model="appSettings.gh_proxy_api" type="checkbox" class="switch-input" />
<span class="switch-slider"></span>
<div class="switch-content">
<span class="switch-title">{{ t('settings.app.ghProxy.api.title') }}</span>
<span class="switch-description">{{ t('settings.app.ghProxy.api.description') }}</span>
</div>
</label>
</div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
@@ -331,6 +607,21 @@ const handleReset = async () => {
</div> </div>
</div> </div>
<div class="settings-section">
<h2>{{ t('settings.app.showWindowOnStartup.title') }}</h2>
<div class="form-group">
<label class="switch-label">
<input v-model="appSettings.show_window_on_startup" type="checkbox" class="switch-input" />
<span class="switch-slider"></span>
<div class="switch-content">
<span class="switch-title">{{ t('settings.app.showWindowOnStartup.title') }}</span>
<span class="switch-description">{{ t('settings.app.showWindowOnStartup.description') }}</span>
</div>
</label>
</div>
</div>
<div class="settings-section"> <div class="settings-section">
<h2>{{ t('settings.app.updates.title') }}</h2> <h2>{{ t('settings.app.updates.title') }}</h2>
<p>{{ t('settings.app.updates.subtitle') }}</p> <p>{{ t('settings.app.updates.subtitle') }}</p>
@@ -348,21 +639,33 @@ const handleReset = async () => {
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h2>{{ t('settings.app.tutorial.title') }}</h2> <h2>{{ t('settings.app.links.title') }}</h2>
<p>{{ t('settings.app.tutorial.subtitle') }}</p> <p>{{ t('settings.app.links.subtitle') }}</p>
<div class="form-grid"> <div class="form-group">
<div class="form-group"> <label class="switch-label">
<button @click="startTutorial" class="tutorial-btn" type="button"> <input v-model="appSettings.open_links_in_browser" type="checkbox" class="switch-input" />
<Play :size="16" /> <span class="switch-slider"></span>
{{ t('settings.app.tutorial.restart') }} <div class="switch-content">
</button> <span class="switch-title">{{ t('settings.app.links.openInBrowser.title') }}</span>
<small>{{ t('settings.app.tutorial.help') }}</small> <span class="switch-description">{{ t('settings.app.links.openInBrowser.description') }}</span>
</div> </div>
</label>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<ConfirmDialog
:is-open="showConfirmDialog"
:title="confirmDialogConfig.title"
:message="confirmDialogConfig.message"
:confirm-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
variant="danger"
@confirm="confirmDialogConfig.onConfirm"
@cancel="confirmDialogConfig.onCancel"
/>
</div> </div>
</template> </template>

View File

@@ -6,7 +6,7 @@
<p class="view-subtitle">{{ t('update.subtitle') }}</p> <p class="view-subtitle">{{ t('update.subtitle') }}</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button @click="goToSettings" class="settings-link"> <button class="settings-link" @click="goToSettings">
<Settings :size="16" /> <Settings :size="16" />
{{ t('navigation.settings') }} {{ t('navigation.settings') }}
</button> </button>
@@ -36,10 +36,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router'
import { useTranslation } from '../composables/useI18n'
import { Settings } from 'lucide-vue-next' import { Settings } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import UpdateManagerCard from '../components/dashboard/UpdateManagerCard.vue' import UpdateManagerCard from '../components/dashboard/UpdateManagerCard.vue'
import { useTranslation } from '../composables/useI18n'
const { t } = useTranslation() const { t } = useTranslation()
const router = useRouter() const router = useRouter()
@@ -100,7 +101,6 @@ const goToSettings = () => {
text-decoration: none; text-decoration: none;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem; font-size: 0.9rem;
} }
@@ -128,7 +128,6 @@ const goToSettings = () => {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 12px; border-radius: 12px;
transition: all 0.2s ease;
} }
.info-card:hover { .info-card:hover {

View File

@@ -1,5 +1,5 @@
.dashboard-container { .dashboard-container {
padding: 0.25rem 0.5rem 0.25rem; padding: 0.25rem 0.5rem 1rem;
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, var(--color-background-secondary) 0%, var(--color-background-tertiary) 100%); background: linear-gradient(135deg, var(--color-background-secondary) 0%, var(--color-background-tertiary) 100%);
overflow-y: auto; overflow-y: auto;
@@ -56,20 +56,9 @@
rgba(139, 92, 246, 0.3) 80%, rgba(139, 92, 246, 0.3) 80%,
transparent 100% transparent 100%
); );
animation: shimmer 8s ease-in-out infinite;
z-index: 0; z-index: 0;
} }
@keyframes shimmer {
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 0.8;
}
}
.metrics-overview { .metrics-overview {
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -104,13 +93,12 @@
.dashboard-grid.three-column { .dashboard-grid.three-column {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1.2fr; grid-template-columns: 1fr 1fr 1.2fr;
grid-template-rows: minmax(320px, 1fr) minmax(320px, 1fr); grid-template-rows: minmax(320px, auto) minmax(320px, auto);
gap: 0.5rem; gap: 0.5rem;
min-height: min-content; min-height: min-content;
align-items: stretch; align-items: stretch;
padding: 0.25rem; padding: 0.25rem 0.25rem 1rem;
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
@@ -119,6 +107,7 @@
grid-template-rows: repeat(3, minmax(280px, auto)); grid-template-rows: repeat(3, minmax(280px, auto));
gap: 0.5rem; gap: 0.5rem;
min-height: auto; min-height: auto;
padding-bottom: 1rem;
} }
.dashboard-grid.three-column .dashboard-card-wrapper:nth-child(1) { .dashboard-grid.three-column .dashboard-card-wrapper:nth-child(1) {
@@ -150,7 +139,7 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.dashboard-container { .dashboard-container {
padding: 0.25rem; padding: 0.25rem 0.25rem 1rem;
} }
.dashboard-grid, .dashboard-grid,
@@ -158,7 +147,7 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto; grid-template-rows: auto;
gap: 0.375rem; gap: 0.375rem;
padding: 0; padding: 0 0 1rem 0;
} }
.dashboard-grid.three-column .dashboard-card-wrapper:nth-child(1), .dashboard-grid.three-column .dashboard-card-wrapper:nth-child(1),
@@ -196,22 +185,17 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform-origin: center;
position: relative; position: relative;
border-radius: 16px; border-radius: 16px;
background: transparent; background: transparent;
will-change: transform;
} }
.dashboard-card-wrapper:hover { .dashboard-card-wrapper:hover {
transform: translateY(-2px) scale(1.01); background: rgba(255, 255, 255, 0.5);
z-index: 10;
} }
.dashboard-card-wrapper:hover > * { .dashboard-card-wrapper:hover > * {
box-shadow: 0 20px 40px -12px rgba(0, 0, 0, 0.15), 0 8px 16px -5px rgba(0, 0, 0, 0.1), border-color: rgba(59, 130, 246, 0.3);
inset 0 1px 0 rgba(255, 255, 255, 0.2);
} }
:root.dark .dashboard-card-wrapper:hover > *, :root.dark .dashboard-card-wrapper:hover > *,
@@ -232,7 +216,6 @@
.dashboard-grid:focus-within > *:not(:focus-within) { .dashboard-grid:focus-within > *:not(:focus-within) {
opacity: 0.7; opacity: 0.7;
transform: scale(0.98);
} }
.dashboard-grid > *:focus-within { .dashboard-grid > *:focus-within {
@@ -241,49 +224,6 @@
border-radius: 20px; border-radius: 20px;
} }
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.dashboard-loading {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.dashboard-ready .metrics-overview {
animation: slideInDown 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.dashboard-ready .dashboard-grid {
animation: fadeIn 0.8s cubic-bezier(0.4, 0, 0.2, 1) 0.2s forwards;
opacity: 0;
}
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (max-width: 1400px) { @media (max-width: 1400px) {
.dashboard-grid { .dashboard-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@@ -300,7 +240,7 @@
@media (max-width: 1200px) { @media (max-width: 1200px) {
.dashboard-container { .dashboard-container {
padding: 0.75rem 1rem; padding: 0.75rem 1rem 1.5rem;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -342,7 +282,7 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.dashboard-container { .dashboard-container {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem 1.5rem;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -370,15 +310,11 @@
.dashboard-subtitle { .dashboard-subtitle {
font-size: 0.875rem; font-size: 0.875rem;
} }
.dashboard-grid > *:hover {
transform: translateY(-2px) scale(1.001);
}
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.dashboard-container { .dashboard-container {
padding: 0.5rem; padding: 0.5rem 0.5rem 1.5rem;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -421,51 +357,6 @@
} }
} }
.dashboard-grid > *:nth-child(1) {
animation: slideInLeft 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.dashboard-grid > *:nth-child(2) {
animation: slideInRight 0.6s cubic-bezier(0.4, 0, 0.2, 1) 0.1s forwards;
}
.dashboard-grid > *:nth-child(n + 3) {
animation: slideInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) 0.2s forwards;
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dashboard-header { .dashboard-header {
text-align: center; text-align: center;
margin-bottom: 1rem; margin-bottom: 1rem;

View File

@@ -22,7 +22,6 @@
padding: 12px 20px; padding: 12px 20px;
background: var(--color-surface-elevated); background: var(--color-surface-elevated);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
backdrop-filter: blur(20px) saturate(180%);
gap: 20px; gap: 20px;
flex-shrink: 0; flex-shrink: 0;
min-height: 56px; min-height: 56px;
@@ -58,7 +57,6 @@
background: transparent; background: transparent;
color: var(--color-text-secondary); color: var(--color-text-secondary);
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
} }
.toolbar-btn:hover:not(:disabled) { .toolbar-btn:hover:not(:disabled) {
@@ -111,7 +109,6 @@
background: var(--color-background-secondary); background: var(--color-background-secondary);
font-size: 13px; font-size: 13px;
color: var(--color-text-primary); color: var(--color-text-primary);
transition: all var(--transition-medium);
} }
.search-input:focus { .search-input:focus {
@@ -154,19 +151,6 @@
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
gap: 20px; gap: 20px;
flex-wrap: wrap; flex-wrap: wrap;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
transform: translateY(-10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
} }
.filter-group, .filter-group,
@@ -220,7 +204,6 @@
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
} }
.filter-btn:hover:not(:disabled) { .filter-btn:hover:not(:disabled) {
@@ -318,7 +301,6 @@
background: transparent; background: transparent;
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
} }
.scroll-btn:hover { .scroll-btn:hover {
@@ -370,7 +352,6 @@
padding: 8px 16px; padding: 8px 16px;
border-bottom: 1px solid var(--color-border-secondary); border-bottom: 1px solid var(--color-border-secondary);
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
} }
.log-entry:hover { .log-entry:hover {
@@ -684,7 +665,6 @@
padding: 16px 20px; padding: 16px 20px;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
@@ -747,21 +727,11 @@
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
} }
.notification-enter-active {
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.notification-leave-active {
transition: all 0.3s ease-in;
}
.notification-enter-from { .notification-enter-from {
transform: translateX(100%) scale(0.8);
opacity: 0; opacity: 0;
} }
.notification-leave-to { .notification-leave-to {
transform: translateX(100%) scale(0.8);
opacity: 0; opacity: 0;
} }

View File

@@ -1,4 +1,3 @@
.mount-view { .mount-view {
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, var(--color-background-secondary) 0%, var(--color-background-tertiary) 100%); background: linear-gradient(135deg, var(--color-background-secondary) 0%, var(--color-background-tertiary) 100%);
@@ -9,7 +8,6 @@
flex-direction: column; flex-direction: column;
} }
.mount-view::before { .mount-view::before {
content: ''; content: '';
position: absolute; position: absolute;
@@ -40,7 +38,6 @@
z-index: 1; z-index: 1;
padding: 24px 28px 20px; padding: 24px 28px 20px;
background: var(--color-surface-elevated); background: var(--color-surface-elevated);
backdrop-filter: blur(20px) saturate(180%);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
@@ -148,7 +145,6 @@
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--color-danger); background: var(--color-danger);
transition: background-color var(--transition-fast);
} }
.service-indicator.active .indicator-dot { .service-indicator.active .indicator-dot {
@@ -172,7 +168,6 @@
background: var(--color-background-secondary); background: var(--color-background-secondary);
color: var(--color-text-secondary); color: var(--color-text-secondary);
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
} }
.service-toggle:hover { .service-toggle:hover {
@@ -202,7 +197,6 @@
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
@@ -221,7 +215,6 @@
z-index: 1; z-index: 1;
padding: 20px 28px; padding: 20px 28px;
background: var(--color-surface); background: var(--color-surface);
backdrop-filter: blur(20px) saturate(180%);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -257,7 +250,6 @@
background: var(--color-background-primary); background: var(--color-background-primary);
font-size: 14px; font-size: 14px;
color: var(--color-text-primary); color: var(--color-text-primary);
transition: all var(--transition-medium);
} }
.search-input:focus { .search-input:focus {
@@ -284,7 +276,6 @@
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
} }
.status-filter:focus { .status-filter:focus {
@@ -303,7 +294,6 @@
background: var(--color-background-primary); background: var(--color-background-primary);
color: var(--color-text-secondary); color: var(--color-text-secondary);
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
} }
.refresh-btn:hover:not(:disabled) { .refresh-btn:hover:not(:disabled) {
@@ -320,20 +310,6 @@
.refresh-icon { .refresh-icon {
width: 16px; width: 16px;
height: 16px; height: 16px;
transition: transform var(--transition-medium);
}
.refresh-icon.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
} }
.error-alert { .error-alert {
@@ -371,7 +347,6 @@
background: transparent; background: transparent;
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
transition: background-color var(--transition-fast);
} }
.alert-close:hover { .alert-close:hover {
@@ -440,7 +415,6 @@
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
@@ -461,10 +435,8 @@
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
padding: 24px; padding: 24px;
transition: all var(--transition-medium);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
backdrop-filter: blur(20px) saturate(180%);
} }
.config-card:hover { .config-card:hover {
@@ -564,10 +536,6 @@
color: var(--color-danger); color: var(--color-danger);
} }
.status-icon.spinning {
animation: spin 1s linear infinite;
}
.card-meta { .card-meta {
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -598,7 +566,6 @@
.meta-tag.clickable-mount-point { .meta-tag.clickable-mount-point {
cursor: pointer; cursor: pointer;
transition: all 0.2s ease;
background: var(--color-primary-50); background: var(--color-primary-50);
color: var(--color-primary-600); color: var(--color-primary-600);
border: 1px solid var(--color-primary-200); border: 1px solid var(--color-primary-200);
@@ -657,7 +624,6 @@
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
width: 100%; width: 100%;
justify-content: center; justify-content: center;
} }
@@ -703,7 +669,6 @@
background: var(--color-background-primary); background: var(--color-background-primary);
color: var(--color-text-secondary); color: var(--color-text-secondary);
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
} }
.secondary-btn:hover:not(:disabled) { .secondary-btn:hover:not(:disabled) {
@@ -736,7 +701,6 @@
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -754,7 +718,6 @@
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
backdrop-filter: blur(20px) saturate(180%);
} }
.modal-header { .modal-header {
@@ -795,7 +758,6 @@
background: var(--color-background-secondary); background: var(--color-background-secondary);
color: var(--color-text-secondary); color: var(--color-text-secondary);
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
} }
.modal-close:hover { .modal-close:hover {
@@ -856,7 +818,6 @@
background: var(--color-background-primary); background: var(--color-background-primary);
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 14px; font-size: 14px;
transition: all var(--transition-fast);
} }
.field-input:focus, .field-input:focus,
@@ -910,7 +871,6 @@
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
} }
.cancel-btn:hover { .cancel-btn:hover {
@@ -930,7 +890,6 @@
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
} }
.save-btn:hover:not(:disabled) { .save-btn:hover:not(:disabled) {
@@ -962,7 +921,6 @@
background: var(--color-background-primary); background: var(--color-background-primary);
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 13px; font-size: 13px;
transition: all var(--transition-fast);
} }
.flag-input:focus { .flag-input:focus {
@@ -986,7 +944,6 @@
background: var(--color-background-primary); background: var(--color-background-primary);
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
} }
.remove-flag-btn:hover { .remove-flag-btn:hover {
@@ -1012,7 +969,6 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast);
margin-top: 4px; margin-top: 4px;
} }
@@ -1027,91 +983,521 @@
height: 14px; height: 14px;
} }
/* Quick Flag Selector Styles */
.flags-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.quick-flags-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border: 1px solid var(--color-border);
border-radius: 10px;
background: var(--color-background-primary);
color: var(--color-text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.quick-flags-btn:hover {
border-color: var(--color-accent);
color: var(--color-accent);
background: rgba(0, 122, 255, 0.05);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.15);
}
.quick-flags-btn .btn-icon {
width: 16px;
height: 16px;
}
.flag-selector-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.flag-selector-popup {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
width: 100%;
max-width: 1000px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.flag-selector-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px;
border-bottom: 1px solid var(--color-border);
background: var(--color-background-secondary);
}
.flag-selector-header h4 {
margin: 0;
font-size: 20px;
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: -0.02em;
}
.close-selector-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
background: var(--color-background-primary);
color: var(--color-text-secondary);
cursor: pointer;
}
.close-selector-btn:hover {
background: var(--color-background-tertiary);
color: var(--color-text-primary);
}
.close-selector-btn .btn-icon {
width: 18px;
height: 18px;
}
.flag-selector-content {
padding: 24px;
overflow-y: auto;
flex: 1;
scrollbar-width: none;
-ms-overflow-style: none;
}
.flag-selector-content::-webkit-scrollbar {
display: none;
}
.flag-selector-help {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.06), rgba(99, 102, 241, 0.04));
border: 1px solid rgba(59, 130, 246, 0.12);
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 24px;
position: relative;
overflow: hidden;
}
.flag-selector-help::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, #3b82f6, #6366f1);
}
.flag-selector-help p {
margin: 0;
font-size: 14px;
font-weight: 500;
color: var(--color-text-secondary);
text-align: center;
line-height: 1.5;
}
.flag-categories {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
max-height: 65vh;
overflow-y: auto;
padding: 4px;
scroll-behavior: smooth;
}
.flag-categories::-webkit-scrollbar {
width: 8px;
}
.flag-categories::-webkit-scrollbar-track {
background: var(--color-background-secondary);
border-radius: 4px;
}
.flag-categories::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
border: 2px solid var(--color-background-secondary);
}
.flag-categories::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
.flag-category {
border: 1px solid var(--color-border);
border-radius: 14px;
overflow: hidden;
background: var(--color-background-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.flag-category:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
.category-header {
background: var(--color-background-secondary);
padding: 14px 20px;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
}
.category-header h5 {
margin: 0;
font-size: 12px;
font-weight: 700;
color: var(--color-text-primary);
text-transform: uppercase;
letter-spacing: 0.8px;
text-align: center;
}
.category-flags {
display: flex;
flex-direction: column;
}
.flag-option {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 20px;
border: none;
background: var(--color-background-primary);
color: var(--color-text-primary);
text-align: left;
cursor: pointer;
border-left: 3px solid transparent;
border-bottom: 1px solid var(--color-border);
position: relative;
}
.flag-option:last-child {
border-bottom: none;
}
.flag-option:hover {
background: var(--color-background-secondary);
transform: translateX(2px);
}
.flag-option.selected,
.flag-option.in-config {
background: rgba(34, 197, 94, 0.06);
border-left-color: #22c55e;
color: var(--color-text-primary);
}
.flag-option.selected:hover,
.flag-option.in-config:hover {
background: rgba(34, 197, 94, 0.1);
transform: translateX(2px);
}
.flag-checkbox {
display: flex;
align-items: center;
flex-shrink: 0;
}
.custom-checkbox {
width: 22px;
height: 22px;
border: 2px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
}
.custom-checkbox:hover {
border-color: #22c55e;
}
.custom-checkbox.checked {
background: #22c55e;
border-color: #22c55e;
}
.check-icon {
width: 14px;
height: 14px;
color: white;
stroke-width: 3px;
}
.flag-checkbox input[type='checkbox'] {
width: 16px;
height: 16px;
margin: 0;
cursor: pointer;
accent-color: #22c55e;
}
.flag-selector-help {
background: rgba(59, 130, 246, 0.05);
border: 1px solid rgba(59, 130, 246, 0.1);
border-radius: var(--radius-md);
padding: 12px 16px;
margin-bottom: 16px;
}
.flag-selector-help p {
margin: 0;
font-size: 13px;
color: var(--color-text-secondary);
text-align: center;
}
.flag-content {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 0;
}
.flag-code {
font-family: 'SF Mono', 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
font-weight: 600;
padding: 6px 10px;
background: var(--color-background-tertiary);
border: 1px solid var(--color-border);
border-radius: 8px;
color: var(--color-accent);
display: inline-block;
max-width: fit-content;
line-height: 1.2;
letter-spacing: -0.02em;
}
.flag-description {
font-size: 13px;
font-weight: 500;
color: var(--color-text-secondary);
line-height: 1.5;
margin: 0;
}
:root.dark .flag-selector-popup,
:root.auto.dark .flag-selector-popup {
background: var(--color-background-tertiary);
border-color: var(--color-border);
}
:root.dark .flag-selector-header,
:root.auto.dark .flag-selector-header {
background: var(--color-background-secondary);
border-color: var(--color-border);
}
:root.dark .flag-category,
:root.auto.dark .flag-category {
background: var(--color-background-primary);
border-color: var(--color-border);
}
:root.dark .flag-category:hover,
:root.auto.dark .flag-category:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
:root.dark .flag-option,
:root.auto.dark .flag-option {
background: var(--color-background-primary);
border-color: var(--color-border);
}
:root.dark .flag-option:hover,
:root.auto.dark .flag-option:hover {
background: var(--color-background-secondary);
}
:root.dark .flag-code,
:root.auto.dark .flag-code {
background: var(--color-background-secondary);
border-color: var(--color-border);
}
:root.dark .custom-checkbox,
:root.auto.dark .custom-checkbox {
background: var(--color-background-primary);
border-color: var(--color-border);
}
:root.dark .quick-flags-btn,
:root.auto.dark .quick-flags-btn {
background: var(--color-background-primary);
border-color: var(--color-border);
}
.webdav-tip { .webdav-tip {
position: relative; position: relative;
z-index: 1; z-index: 1;
margin: 0 28px 20px; margin: 0 28px 12px;
background: linear-gradient(135deg, #fef3cd 0%, #fff3cd 100%); background: linear-gradient(135deg, #fef3cd 0%, #fff3cd 100%);
border: 1px solid #f9cc33; border: 1px solid #f9cc33;
border-radius: 12px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(249, 204, 51, 0.1); box-shadow: 0 1px 4px rgba(249, 204, 51, 0.1);
overflow: hidden;
}
.winfsp-tip {
position: relative;
z-index: 1;
margin: 0 28px 12px;
background: linear-gradient(135deg, #dbeafe 0%, #e0f2fe 100%);
border: 1px solid #3b82f6;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(59, 130, 246, 0.1);
overflow: hidden; overflow: hidden;
} }
.tip-content { .tip-content {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 16px; gap: 12px;
padding: 16px 20px; padding: 12px 16px;
} }
.tip-icon { .tip-icon {
flex-shrink: 0; flex-shrink: 0;
width: 40px; width: 32px;
height: 40px; height: 32px;
background: rgba(249, 204, 51, 0.1); background: rgba(249, 204, 51, 0.1);
border-radius: 10px; border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.winfsp-tip .tip-icon {
background: rgba(59, 130, 246, 0.1);
}
.tip-icon .icon { .tip-icon .icon {
width: 20px; width: 16px;
height: 20px; height: 16px;
color: #b45309; color: #b45309;
} }
.winfsp-tip .tip-icon .icon {
color: #1d4ed8;
}
.tip-message { .tip-message {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.tip-title { .tip-title {
margin: 0 0 8px 0; margin: 0 0 4px 0;
font-size: 15px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #92400e; color: #92400e;
line-height: 1.3; line-height: 1.3;
} }
.winfsp-tip .tip-title {
color: #1e40af;
}
.tip-description { .tip-description {
margin: 0; margin: 0;
font-size: 14px; font-size: 12px;
color: #a16207; color: #a16207;
line-height: 1.5; line-height: 1.4;
}
.winfsp-tip .tip-description {
color: #1d4ed8;
} }
.tip-close { .tip-close {
flex-shrink: 0; flex-shrink: 0;
width: 32px; width: 28px;
height: 32px; height: 28px;
background: rgba(249, 204, 51, 0.1); background: rgba(249, 204, 51, 0.1);
border: none; border: none;
border-radius: 8px; border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; }
.winfsp-tip .tip-close {
background: rgba(59, 130, 246, 0.1);
} }
.tip-close:hover { .tip-close:hover {
background: rgba(249, 204, 51, 0.2); background: rgba(249, 204, 51, 0.2);
transform: scale(1.05); }
.winfsp-tip .tip-close:hover {
background: rgba(59, 130, 246, 0.2);
} }
.tip-close .close-icon { .tip-close .close-icon {
width: 16px; width: 14px;
height: 16px; height: 14px;
color: #a16207; color: #a16207;
} }
.winfsp-tip .tip-close .close-icon {
color: #1d4ed8;
}
:root.dark .webdav-tip, :root.dark .webdav-tip,
:root.auto.dark .webdav-tip { :root.auto.dark .webdav-tip {
background: linear-gradient(135deg, #451a03 0%, #541c15 100%); background: linear-gradient(135deg, #451a03 0%, #541c15 100%);
border-color: #a16207; border-color: #a16207;
box-shadow: 0 2px 8px rgba(161, 98, 7, 0.1); box-shadow: 0 1px 4px rgba(161, 98, 7, 0.1);
}
:root.dark .winfsp-tip,
:root.auto.dark .winfsp-tip {
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
border-color: #3b82f6;
box-shadow: 0 1px 4px rgba(59, 130, 246, 0.1);
} }
:root.dark .tip-icon, :root.dark .tip-icon,
@@ -1119,36 +1505,71 @@
background: rgba(161, 98, 7, 0.1); background: rgba(161, 98, 7, 0.1);
} }
:root.dark .winfsp-tip .tip-icon,
:root.auto.dark .winfsp-tip .tip-icon {
background: rgba(59, 130, 246, 0.1);
}
:root.dark .tip-icon .icon, :root.dark .tip-icon .icon,
:root.auto.dark .tip-icon .icon { :root.auto.dark .tip-icon .icon {
color: #f59e0b; color: #f59e0b;
} }
:root.dark .winfsp-tip .tip-icon .icon,
:root.auto.dark .winfsp-tip .tip-icon .icon {
color: #60a5fa;
}
:root.dark .tip-title, :root.dark .tip-title,
:root.auto.dark .tip-title { :root.auto.dark .tip-title {
color: #fbbf24; color: #fbbf24;
} }
:root.dark .winfsp-tip .tip-title,
:root.auto.dark .winfsp-tip .tip-title {
color: #93c5fd;
}
:root.dark .tip-description, :root.dark .tip-description,
:root.auto.dark .tip-description { :root.auto.dark .tip-description {
color: #d97706; color: #d97706;
} }
:root.dark .winfsp-tip .tip-description,
:root.auto.dark .winfsp-tip .tip-description {
color: #60a5fa;
}
:root.dark .tip-close, :root.dark .tip-close,
:root.auto.dark .tip-close { :root.auto.dark .tip-close {
background: rgba(161, 98, 7, 0.1); background: rgba(161, 98, 7, 0.1);
} }
:root.dark .winfsp-tip .tip-close,
:root.auto.dark .winfsp-tip .tip-close {
background: rgba(59, 130, 246, 0.1);
}
:root.dark .tip-close:hover, :root.dark .tip-close:hover,
:root.auto.dark .tip-close:hover { :root.auto.dark .tip-close:hover {
background: rgba(161, 98, 7, 0.2); background: rgba(161, 98, 7, 0.2);
} }
:root.dark .winfsp-tip .tip-close:hover,
:root.auto.dark .winfsp-tip .tip-close:hover {
background: rgba(59, 130, 246, 0.2);
}
:root.dark .tip-close .close-icon, :root.dark .tip-close .close-icon,
:root.auto.dark .tip-close .close-icon { :root.auto.dark .tip-close .close-icon {
color: #d97706; color: #d97706;
} }
:root.dark .winfsp-tip .tip-close .close-icon,
:root.auto.dark .winfsp-tip .tip-close .close-icon {
color: #60a5fa;
}
@media (max-width: 1024px) { @media (max-width: 1024px) {
.header-content { .header-content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -1197,6 +1618,45 @@
gap: 12px; gap: 12px;
} }
.webdav-tip,
.winfsp-tip {
margin: 0 16px 8px;
}
.tip-content {
padding: 10px 12px;
gap: 10px;
}
.tip-icon {
width: 28px;
height: 28px;
}
.tip-icon .icon {
width: 14px;
height: 14px;
}
.tip-title {
font-size: 12px;
margin-bottom: 2px;
}
.tip-description {
font-size: 11px;
}
.tip-close {
width: 24px;
height: 24px;
}
.tip-close .close-icon {
width: 12px;
height: 12px;
}
.config-grid { .config-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1265,30 +1725,6 @@
border-bottom-color: rgba(255, 59, 48, 0.3); border-bottom-color: rgba(255, 59, 48, 0.3);
} }
/* Animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.config-card {
animation: fadeIn 0.5s ease-out;
}
.config-card:nth-child(even) {
animation-delay: 0.1s;
}
.config-card:nth-child(3n) {
animation-delay: 0.2s;
}
/* Accessibility */ /* Accessibility */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.config-card, .config-card,

View File

@@ -1,20 +1,17 @@
/* Use CSS variables for theme support */
.settings-container { .settings-container {
padding: 2rem; padding: 2rem;
height: 100vh; min-height: 100vh;
background: var(--color-background-secondary); background: var(--color-background-secondary);
color: var(--color-text-primary); color: var(--color-text-primary);
overflow-y: auto; overflow-y: auto;
/* Hide scrollbar for webkit browsers */ scrollbar-width: none;
scrollbar-width: none; /* Firefox */ -ms-overflow-style: none;
-ms-overflow-style: none; /* Internet Explorer 10+ */
} }
.settings-container::-webkit-scrollbar { .settings-container::-webkit-scrollbar {
display: none; /* Safari and Chrome */ display: none;
} }
/* Override specific colors for settings */
:root.dark .settings-container, :root.dark .settings-container,
:root.auto.dark .settings-container { :root.auto.dark .settings-container {
background: var(--color-background-primary); background: var(--color-background-primary);
@@ -120,7 +117,6 @@
font-weight: 500; font-weight: 500;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease;
} }
.btn:disabled { .btn:disabled {
@@ -211,7 +207,6 @@
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease;
flex: 1; flex: 1;
justify-content: center; justify-content: center;
} }
@@ -232,6 +227,7 @@
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
min-height: fit-content;
} }
:root.dark .settings-content, :root.dark .settings-content,
@@ -242,6 +238,7 @@
.tab-content { .tab-content {
padding: 2rem; padding: 2rem;
min-height: fit-content;
} }
.settings-section { .settings-section {
@@ -299,7 +296,6 @@
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
font-size: 0.875rem; font-size: 0.875rem;
transition: all 0.2s ease;
background: var(--color-surface); background: var(--color-surface);
color: var(--color-text-primary); color: var(--color-text-primary);
} }
@@ -315,7 +311,6 @@
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
font-size: 0.875rem; font-size: 0.875rem;
transition: all 0.2s ease;
background: var(--color-surface); background: var(--color-surface);
color: var(--color-text-primary); color: var(--color-text-primary);
resize: vertical; resize: vertical;
@@ -382,7 +377,6 @@
background: var(--color-background-tertiary); background: var(--color-background-tertiary);
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease;
} }
.input-addon-btn:hover { .input-addon-btn:hover {
@@ -390,33 +384,50 @@
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.input-addon-btn.reset-password-btn {
background: var(--color-error-background, #fef2f2);
color: var(--color-error, #dc2626);
border-color: var(--color-error-border, #fecaca);
}
.input-addon-btn.reset-password-btn:hover:not(:disabled) {
background: var(--color-error, #dc2626);
color: white;
}
.input-addon-btn.reset-password-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Switch */ /* Switch */
.switch-label { .switch-label {
display: flex; display: flex;
align-items: flex-start; align-items: center;
gap: 0.75rem; gap: 0.75rem;
cursor: pointer; cursor: pointer;
padding: 1rem; padding: 1rem;
border: 1px solid var(--color-border-secondary); border: 1px solid var(--color-border-secondary);
border-radius: 8px; border-radius: 8px;
background: var(--color-background-tertiary); background: var(--color-background-tertiary);
transition: all 0.2s ease; min-height: auto;
} }
.switch-label:hover { .switch-label:hover {
background: rgb(243 244 246); background: var(--color-background-secondary);
border-color: rgb(209 213 219); border-color: var(--color-border);
} }
@media (prefers-color-scheme: dark) { :root.dark .switch-label,
.switch-label { :root.auto.dark .switch-label {
background: rgb(55 65 81); background: var(--color-background-tertiary);
border-color: rgb(75 85 99); border-color: var(--color-border);
} }
.switch-label:hover { :root.dark .switch-label:hover,
background: rgb(75 85 99); :root.auto.dark .switch-label:hover {
} background: var(--color-background-secondary);
border-color: var(--color-border);
} }
.switch-input { .switch-input {
@@ -429,11 +440,9 @@
position: relative; position: relative;
width: 44px; width: 44px;
height: 24px; height: 24px;
background: rgb(209 213 219); background: var(--color-border);
border-radius: 12px; border-radius: 12px;
transition: all 0.2s ease;
flex-shrink: 0; flex-shrink: 0;
margin-top: 2px;
} }
.switch-slider::before { .switch-slider::before {
@@ -445,12 +454,11 @@
height: 20px; height: 20px;
background: var(--color-surface); background: var(--color-surface);
border-radius: 50%; border-radius: 50%;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.switch-input:checked + .switch-slider { .switch-input:checked + .switch-slider {
background: rgb(59 130 246); background: var(--color-accent);
} }
.switch-input:checked + .switch-slider::before { .switch-input:checked + .switch-slider::before {
@@ -461,108 +469,30 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.125rem; gap: 0.125rem;
flex: 1;
} }
.switch-title { .switch-title {
font-weight: 500; font-weight: 500;
color: rgb(55 65 81); color: var(--color-text-primary);
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.25;
} }
.switch-description { .switch-description {
font-size: 0.75rem; font-size: 0.75rem;
color: rgb(107 114 128); color: var(--color-text-secondary);
line-height: 1.25;
} }
@media (prefers-color-scheme: dark) { :root.dark .switch-title,
.switch-title { :root.auto.dark .switch-title {
color: rgb(209 213 219); color: var(--color-text-primary);
}
} }
/* Flags */ :root.dark .switch-description,
.flags-container { :root.auto.dark .switch-description {
display: flex; color: var(--color-text-secondary);
flex-direction: column;
gap: 0.75rem;
}
.flag-item {
display: flex;
gap: 0.5rem;
}
.flag-item .form-input {
flex: 1;
}
.remove-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid rgb(239 68 68);
border-radius: 6px;
background: rgb(254 242 242);
color: rgb(239 68 68);
cursor: pointer;
transition: all 0.2s ease;
}
.remove-btn:hover {
background: rgb(254 226 226);
}
.add-flag-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
border: 1px dashed rgb(209 213 219);
border-radius: 6px;
background: transparent;
color: rgb(107 114 128);
cursor: pointer;
transition: all 0.2s ease;
align-self: flex-start;
}
.add-flag-btn:hover {
border-color: rgb(156 163 175);
color: rgb(55 65 81);
}
@media (prefers-color-scheme: dark) {
.add-flag-btn {
border-color: rgb(75 85 99);
color: rgb(156 163 175);
}
.add-flag-btn:hover {
border-color: rgb(107 114 128);
color: rgb(209 213 219);
}
}
/* Info Message */
.info-message {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: rgba(0, 122, 255, 0.1);
color: var(--color-accent);
border: 1px solid rgba(0, 122, 255, 0.2);
border-radius: 8px;
font-size: 0.875rem;
}
:root.dark .info-message,
:root.auto.dark .info-message {
background: rgba(59, 130, 246, 0.1);
border-color: rgba(59, 130, 246, 0.2);
color: rgb(147, 197, 253);
} }
/* Responsive Design */ /* Responsive Design */
@@ -598,28 +528,32 @@
} }
} }
.tutorial-btn { /* Settings Section Actions */
.settings-section-actions {
display: flex;
justify-content: flex-start;
margin: 0;
gap: 0.5rem;
}
.settings-section-actions .btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 0.5rem;
padding: 12px 20px; white-space: nowrap;
background: var(--color-accent);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
} }
.tutorial-btn:hover { .input-group .input-addon-btn:last-child:not(:only-child) {
background: var(--color-accent-hover); border-radius: 0 8px 8px 0;
transform: translateY(-1px); border-left: none;
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
} }
.tutorial-btn:active { .input-group .input-addon-btn:not(:first-child):not(:last-child) {
transform: translateY(0); border-radius: 0;
box-shadow: 0 2px 6px rgba(0, 122, 255, 0.3); border-left: none;
border-right: none;
}
.input-group .input-addon-btn:first-child:not(:only-child) {
border-radius: 0;
} }

14
src/vite-env.d.ts vendored
View File

@@ -1,12 +1,12 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare module "*.vue" { declare module '*.vue' {
import type { DefineComponent } from "vue"; import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>; const component: DefineComponent<{}, {}, any>
export default component; export default component
} }
declare module "*.json" { declare module '*.json' {
const value: any; const value: any
export default value; export default value
} }

2412
yarn.lock

File diff suppressed because it is too large Load Diff