Compare commits

..

48 Commits

Author SHA1 Message Date
Julian Freeman
52cf6736cf change label 2026-04-18 22:36:12 -04:00
Julian Freeman
cfe93144e6 support categories 2026-04-18 20:45:10 -04:00
Julian Freeman
87ffe2e243 no post after update 2026-04-18 20:17:54 -04:00
Julian Freeman
72d878d221 fix fetch bug 2026-04-18 16:40:10 -04:00
Julian Freeman
708df41063 refactor 6 2026-04-18 16:15:52 -04:00
Julian Freeman
73976d1367 refactor 5 2026-04-18 16:08:37 -04:00
Julian Freeman
2625c8b52f refactor 4 2026-04-18 16:05:00 -04:00
Julian Freeman
0fc523e234 refactor 3 2026-04-18 16:01:38 -04:00
Julian Freeman
db377852fc refactor 2 2026-04-18 15:58:19 -04:00
Julian Freeman
2aaa330c9a refactor 1 2026-04-18 15:55:03 -04:00
Julian Freeman
fe86431899 op1 2026-04-18 15:27:00 -04:00
Julian Freeman
bba113e089 fix ui 2026-04-04 19:58:17 -04:00
Julian Freeman
ff238eb534 fix title area 2026-04-04 19:51:06 -04:00
Julian Freeman
886f513b5d support disable config 2026-04-04 19:06:27 -04:00
Julian Freeman
8067cc870f fix cmd 2026-04-04 18:42:23 -04:00
Julian Freeman
fbdfcc8abe support delay 2026-04-04 18:24:34 -04:00
Julian Freeman
86df026091 add details fetch log 2026-04-04 18:08:50 -04:00
Julian Freeman
fd8241fd43 optimize status fetch 2026-04-04 17:20:06 -04:00
Julian Freeman
66b6ac4738 fix env name 2026-04-04 17:02:33 -04:00
Julian Freeman
1d53f42d10 add file_delete and copy 2026-04-04 16:01:04 -04:00
Julian Freeman
c230847cc0 support del reg 2026-04-04 15:54:40 -04:00
Julian Freeman
dac6f6cd62 add scripts 2026-04-04 15:48:22 -04:00
Julian Freeman
04e4a510e5 support config 2026-04-04 13:22:40 -04:00
Julian Freeman
9aa6f9cd1d upgrade 2026-03-31 20:29:10 -04:00
Julian Freeman
46c622fd86 optimize fetch time 2026-03-31 18:51:55 -04:00
Julian Freeman
9cdc371c75 fix bug 2026-03-31 18:38:45 -04:00
Julian Freeman
517ee39707 not sync after every install 2026-03-31 18:28:07 -04:00
Julian Freeman
61caeba242 not log long scripta 2026-03-31 17:16:18 -04:00
Julian Freeman
6dde1ea9a7 enable local manifest 2026-03-31 13:37:23 -04:00
Julian Freeman
d775e049d6 block when checking 2026-03-31 13:30:46 -04:00
Julian Freeman
145dad23a5 fix install logix 2026-03-31 13:15:50 -04:00
Julian Freeman
7e550a8d49 add cmd to log 2026-03-31 11:42:07 -04:00
Julian Freeman
cf740b9e3a fix error bug 2026-03-31 08:45:17 -04:00
Julian Freeman
b6248bec45 fix task bugs 2026-03-31 08:37:10 -04:00
Julian Freeman
50489bb9d4 fix download bug 2026-03-31 01:04:32 -04:00
Julian Freeman
6a360dc14b download file 2026-03-30 23:46:57 -04:00
Julian Freeman
7f2dde8c51 fix bug 2026-03-30 23:06:38 -04:00
Julian Freeman
a7b5955540 support custom manifest 2026-03-30 22:31:49 -04:00
Julian Freeman
5717b94c90 optimize 2026-03-30 22:00:00 -04:00
Julian Freeman
5048ef5e76 change icon to 32 2026-03-30 21:42:24 -04:00
Julian Freeman
11dd161b1c get icon 2026-03-30 21:25:22 -04:00
Julian Freeman
a699df0b1a remove all software 2026-03-30 20:51:01 -04:00
Julian Freeman
f89ff7173b fix bug 2026-03-30 20:36:55 -04:00
Julian Freeman
d33b2ae2a9 reduce essentials keys 2026-03-30 20:33:07 -04:00
Julian Freeman
4f46d745f0 upgrade essentials versions 2026-03-30 20:21:44 -04:00
Julian Freeman
7afdc845fa optimize fetch logic 2026-03-30 19:34:14 -04:00
Julian Freeman
6b64f36cfb update file struct 2026-03-30 19:22:11 -04:00
Julian Freeman
626a9c21b0 fetch essentials from url 2026-03-30 17:50:34 -04:00
38 changed files with 3988 additions and 804 deletions

View File

@@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

118
scripts/convert-reg.ps1 Normal file
View File

@@ -0,0 +1,118 @@
<#
.SYNOPSIS
将 Windows .reg 文件转换为 win-softmgr 所需的 post_install JSON 格式。
.EXAMPLE
.\convert-reg.ps1 -Path .\adobe.reg
#>
param (
[Parameter(Mandatory=$true)]
[string]$Path,
[Parameter(Mandatory=$false)]
[string]$OutputPath
)
if (-not (Test-Path $Path)) {
Write-Error "文件不存在: $Path"
exit 1
}
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$fileInfo = Get-Item $Path
$OutputPath = Join-Path $fileInfo.DirectoryName ($fileInfo.BaseName + ".json")
}
# 使用 -Raw 读取并自动检测编码,然后按行拆分
$content = Get-Content $Path -Raw
$lines = $content -split "\r?\n"
$results = @()
$currentBatch = $null
foreach ($line in $lines) {
$line = $line.Trim()
if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith("Windows Registry Editor")) {
continue
}
# 匹配 [HKEY_...] 路径
if ($line.StartsWith("[") -and $line.EndsWith("]")) {
# 检查是否是删除整个 Key 的语法:[-HKEY_...]
$isDeleteKey = $line.StartsWith("[-")
$fullPath = if ($isDeleteKey) { $line.Substring(2, $line.Length - 3) } else { $line.Substring(1, $line.Length - 2) }
$root = ""
$basePath = ""
if ($fullPath -match "^HKEY_CURRENT_USER(\\.*)?$") {
$root = "HKCU"
$basePath = if ($fullPath.Length -gt 17) { $fullPath.Substring(18) } else { "" }
} elseif ($fullPath -match "^HKEY_LOCAL_MACHINE(\\.*)?$") {
$root = "HKLM"
$basePath = if ($fullPath.Length -gt 18) { $fullPath.Substring(19) } else { "" }
}
if ($root -ne "") {
$currentBatch = [ordered]@{
type = "registry_batch"
root = $root
base_path = $basePath
# 如果是删除整个 Key我们可以在这里记录或者扩展 schema
# 但目前我们先处理 Value 删除
values = [ordered]@{}
}
$results += $currentBatch
}
continue
}
# 匹配 "Name"=Value
if ($line -match '^"(.+)"\s*=\s*(.+)$') {
$name = $Matches[1]
$rawVal = $Matches[2]
$vType = ""
$data = $null
if ($rawVal -eq "-") {
# 处理删除 Value 的逻辑: "Key"=-
$vType = "Delete"
$data = $null
} elseif ($rawVal.StartsWith("dword:")) {
$vType = "Dword"
$hex = $rawVal.Substring(6)
$data = [Convert]::ToInt32($hex, 16)
} elseif ($rawVal.StartsWith('"') -and $rawVal.EndsWith('"')) {
$vType = "String"
$data = $rawVal.Substring(1, $rawVal.Length - 2).Replace("\\", "\")
} elseif ($rawVal.StartsWith("hex(7):")) {
$vType = "MultiString"
$hexBytes = $rawVal.Substring(7).Split(',') | ForEach-Object { [Convert]::ToByte($_, 16) }
$decoded = [System.Text.Encoding]::Unicode.GetString($hexBytes)
$data = $decoded.Split("`0", [System.StringSplitOptions]::RemoveEmptyEntries)
} elseif ($rawVal.StartsWith("hex(b):")) {
$vType = "Qword"
$hexBytes = $rawVal.Substring(7).Split(',') | ForEach-Object { $_ }
if ($hexBytes.Count -ge 8) {
$hexStr = ($hexBytes[7,6,5,4,3,2,1,0] -join "")
$data = [Convert]::ToInt64($hexStr, 16)
}
}
if ($null -ne $currentBatch -and $vType -ne "") {
$currentBatch.values[$name] = [ordered]@{
v_type = $vType
data = $data
}
}
}
}
if ($results.Count -eq 0) {
Write-Warning "未在文件中识别到有效的注册表项。"
}
$jsonOutput = ConvertTo-Json $results -Depth 10
[System.IO.File]::WriteAllText($OutputPath, $jsonOutput, [System.Text.Encoding]::UTF8)
Write-Host "转换成功!结果已保存至: $OutputPath" -ForegroundColor Green

View File

@@ -1,6 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/target*/
# Generated by Tauri
# will have schema files for capabilities auto-completion

540
src-tauri/Cargo.lock generated
View File

@@ -444,6 +444,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.44"
@@ -493,6 +499,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -516,9 +532,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -529,7 +545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.11.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -843,7 +859,7 @@ dependencies = [
"rustc_version",
"toml 0.9.12+spec-1.1.0",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@@ -852,6 +868,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -986,6 +1011,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -993,7 +1027,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1007,6 +1041,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1252,8 +1292,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1263,9 +1305,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1429,6 +1473,25 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1545,6 +1608,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1556,6 +1620,39 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -1574,9 +1671,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1982,6 +2081,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -2093,6 +2198,23 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2313,6 +2435,50 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.112"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2768,6 +2934,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -2814,6 +3035,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
@@ -2834,6 +3065,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -2852,6 +3093,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -2945,6 +3195,50 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.2"
@@ -2979,6 +3273,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -3007,12 +3315,53 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3022,6 +3371,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3079,6 +3437,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -3221,6 +3602,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.18.0"
@@ -3469,6 +3862,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3522,6 +3921,27 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -3543,7 +3963,7 @@ checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb"
dependencies = [
"bitflags 2.11.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dispatch2",
@@ -3620,7 +4040,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@@ -3948,6 +4368,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.50.0"
@@ -3976,6 +4411,26 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -4280,6 +4735,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -4329,6 +4790,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -4526,6 +4993,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.3"
@@ -4582,6 +5059,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -4622,14 +5108,17 @@ dependencies = [
name = "win-softmgr"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"regex",
"reqwest 0.12.28",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-opener",
"tokio",
"winreg 0.56.0",
]
[[package]]
@@ -4781,6 +5270,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -4826,6 +5326,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -5085,6 +5594,17 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"serde",
"windows-sys 0.61.2",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -5369,6 +5889,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"

View File

@@ -25,4 +25,6 @@ serde_json = "1"
tokio = { version = "1.50.0", features = ["full"] }
chrono = "0.4.44"
regex = "1.12.3"
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
winreg = { version = "0.56.0", features = ["serde"] }
base64 = "0.22.1"

View File

@@ -0,0 +1,57 @@
use tauri::AppHandle;
use crate::domain::models::{
AppSettings, DashboardSnapshot, EssentialsRepo, EssentialsStatusItem, LogPayload, SyncEssentialsResult,
UpdateCandidate,
};
use crate::services::{essentials_service, settings_service, software_state_service};
#[tauri::command]
pub fn get_settings(app: AppHandle) -> AppSettings {
settings_service::get_settings(&app)
}
#[tauri::command]
pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
settings_service::save_settings(&app, &settings)
}
#[tauri::command]
pub async fn sync_essentials(app: AppHandle) -> Result<SyncEssentialsResult, String> {
essentials_service::sync_essentials(&app).await
}
#[tauri::command]
pub fn get_essentials(app: AppHandle) -> Option<EssentialsRepo> {
essentials_service::get_essentials(&app)
}
#[tauri::command]
pub async fn initialize_app(app: AppHandle) -> Result<bool, String> {
software_state_service::initialize_app(app).await
}
#[tauri::command]
pub async fn get_dashboard_snapshot(app: AppHandle) -> DashboardSnapshot {
software_state_service::get_dashboard_snapshot(app).await
}
#[tauri::command]
pub async fn get_essentials_status(app: AppHandle) -> (String, Vec<EssentialsStatusItem>) {
software_state_service::get_essentials_status(app).await
}
#[tauri::command]
pub async fn get_update_candidates(app: AppHandle) -> Vec<UpdateCandidate> {
software_state_service::get_update_candidates(app).await
}
#[tauri::command]
pub async fn get_software_icon(app: AppHandle, id: String, name: String) -> Option<String> {
software_state_service::get_software_icon(app, id, name).await
}
#[tauri::command]
pub fn get_logs_history() -> Vec<LogPayload> {
vec![]
}

View File

@@ -0,0 +1 @@
pub mod app_commands;

View File

@@ -0,0 +1 @@
pub mod models;

View File

@@ -0,0 +1,114 @@
use serde::{Deserialize, Serialize};
use crate::winget::{PostInstallStep, Software};
#[derive(Clone, Serialize, Deserialize)]
pub struct AppSettings {
pub repo_url: String,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
repo_url: "https://karlblue.github.io/winget-repo".to_string(),
}
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct EssentialsRepo {
pub version: String,
pub essentials: Vec<Software>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct InstallTask {
pub id: String,
pub version: Option<String>,
#[serde(default)]
pub use_manifest: bool,
pub manifest_url: Option<String>,
#[serde(default = "default_true")]
pub enable_post_install: bool,
}
fn default_true() -> bool {
true
}
#[derive(Clone, Serialize)]
pub struct LogPayload {
pub id: String,
pub timestamp: String,
pub command: String,
pub output: String,
pub status: String,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct SyncEssentialsResult {
pub status: String,
pub message: String,
}
#[derive(Clone)]
pub struct ResolvedPostInstall {
pub software: Software,
pub steps: Vec<PostInstallStep>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct TaskEventPayload {
pub task_id: String,
pub software_id: String,
pub task_type: String,
pub status: String,
pub stage: String,
pub progress: f32,
pub target_version: Option<String>,
pub message: Option<String>,
pub software_info: Option<Software>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct EssentialsStatusItem {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub version: Option<String>,
pub recommended_version: Option<String>,
pub available_version: Option<String>,
pub icon_url: Option<String>,
pub use_manifest: bool,
pub manifest_url: Option<String>,
pub post_install: Option<Vec<PostInstallStep>>,
pub post_install_url: Option<String>,
pub action_label: String,
pub target_version: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct UpdateCandidate {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub version: Option<String>,
pub available_version: Option<String>,
pub icon_url: Option<String>,
pub use_manifest: bool,
pub manifest_url: Option<String>,
pub post_install: Option<Vec<PostInstallStep>>,
pub post_install_url: Option<String>,
pub action_label: String,
pub target_version: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct DashboardSnapshot {
pub essentials_version: String,
pub essentials: Vec<EssentialsStatusItem>,
pub updates: Vec<UpdateCandidate>,
pub installed_software: Vec<Software>,
}

View File

@@ -1,220 +1,33 @@
use tauri::Manager;
pub mod commands;
pub mod domain;
pub mod providers;
pub mod services;
pub mod storage;
pub mod tasks;
pub mod winget;
use std::fs;
use std::process::{Command, Stdio};
use std::os::windows::process::CommandExt;
use std::io::{BufRead, BufReader};
use tokio::sync::mpsc;
use tauri::{AppHandle, Manager, State, Emitter};
use serde::Serialize;
use winget::{Software, list_all_software, list_updates, ensure_winget_dependencies};
use regex::Regex;
struct AppState {
install_tx: mpsc::Sender<String>,
}
#[derive(Clone, Serialize)]
pub struct LogPayload {
pub id: String,
pub timestamp: String,
pub command: String,
pub output: String,
pub status: String,
}
pub fn emit_log(handle: &AppHandle, id: &str, command: &str, output: &str, status: &str) {
let now = chrono::Local::now().format("%H:%M:%S").to_string();
let _ = handle.emit("log-event", LogPayload {
id: id.to_string(),
timestamp: now,
command: command.to_string(),
output: output.to_string(),
status: status.to_string(),
});
}
#[tauri::command]
async fn initialize_app(app: AppHandle) -> Result<bool, String> {
tokio::task::spawn_blocking(move || {
ensure_winget_dependencies(&app).map(|_| true)
}).await.unwrap_or(Err("Initialization Task Panicked".to_string()))
}
#[tauri::command]
fn get_essentials(app: AppHandle) -> Vec<Software> {
let app_data_dir = app.path().app_data_dir().unwrap_or_default();
if !app_data_dir.exists() {
let _ = fs::create_dir_all(&app_data_dir);
}
let file_path = app_data_dir.join("setup-essentials.json");
if !file_path.exists() {
let default_essentials = vec![
Software {
id: "Microsoft.PowerToys".to_string(),
name: "PowerToys".to_string(),
description: Some("Microsoft PowerToys 是一组实用程序,供高级用户调整和简化其 Windows 10 和 11 体验。".to_string()),
version: None,
available_version: None,
icon_url: Some("https://raw.githubusercontent.com/microsoft/PowerToys/master/doc/images/logo.png".to_string()),
status: "idle".to_string(),
progress: 0.0,
},
Software {
id: "Google.Chrome".to_string(),
name: "Google Chrome".to_string(),
description: Some("Google Chrome 是一款快速、安全且免费的浏览器。".to_string()),
version: None,
available_version: None,
icon_url: Some("https://www.google.com/chrome/static/images/chrome-logo.svg".to_string()),
status: "idle".to_string(),
progress: 0.0,
}
];
let _ = fs::write(&file_path, serde_json::to_string_pretty(&default_essentials).unwrap());
return default_essentials;
}
let content = fs::read_to_string(file_path).unwrap_or_else(|_| "[]".to_string());
serde_json::from_str(&content).unwrap_or_default()
}
#[tauri::command]
async fn get_all_software(app: AppHandle) -> Vec<Software> {
tokio::task::spawn_blocking(move || list_all_software(&app)).await.unwrap_or_default()
}
#[tauri::command]
async fn get_updates(app: AppHandle) -> Vec<Software> {
tokio::task::spawn_blocking(move || list_updates(&app)).await.unwrap_or_default()
}
#[tauri::command]
async fn install_software(id: String, state: State<'_, AppState>) -> Result<(), String> {
state.install_tx.send(id).await.map_err(|e| e.to_string())
}
#[tauri::command]
fn get_logs_history() -> Vec<LogPayload> {
vec![]
}
#[derive(Clone, Serialize)]
struct InstallProgress {
id: String,
status: String,
progress: f32,
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.setup(move |app| {
let handle = app.handle().clone();
let (tx, mut rx) = mpsc::channel::<String>(100);
app.manage(AppState { install_tx: tx });
tauri::async_runtime::spawn(async move {
let perc_re = Regex::new(r"(\d+)\s*%").unwrap();
let size_re = Regex::new(r"([\d\.]+)\s*[a-zA-Z]+\s*/\s*([\d\.]+)\s*[a-zA-Z]+").unwrap();
while let Some(id) = rx.recv().await {
let log_id = format!("install-{}", id);
let _ = handle.emit("install-status", InstallProgress {
id: id.clone(),
status: "installing".to_string(),
progress: 0.0,
});
emit_log(&handle, &log_id, &format!("Winget Install: {}", id), "Starting...", "info");
let id_for_cmd = id.clone();
let h = handle.clone();
let child = Command::new("winget")
.args([
"install", "--id", &id_for_cmd, "-e", "--silent",
"--accept-package-agreements", "--accept-source-agreements",
"--disable-interactivity"
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.creation_flags(0x08000000)
.spawn();
let status_result = match child {
Ok(mut child_proc) => {
if let Some(stdout) = child_proc.stdout.take() {
let reader = BufReader::new(stdout);
for line_res in reader.split(b'\r') {
if let Ok(line_bytes) = line_res {
let line_str = String::from_utf8_lossy(&line_bytes).to_string();
let clean_line = line_str.trim();
if clean_line.is_empty() { continue; }
let mut is_progress = false;
if let Some(caps) = perc_re.captures(clean_line) {
if let Ok(p_val) = caps[1].parse::<f32>() {
let _ = h.emit("install-status", InstallProgress {
id: id_for_cmd.clone(),
status: "installing".to_string(),
progress: p_val / 100.0,
});
is_progress = true;
}
}
else if let Some(caps) = size_re.captures(clean_line) {
let current = caps[1].parse::<f32>().unwrap_or(0.0);
let total = caps[2].parse::<f32>().unwrap_or(1.0);
if total > 0.0 {
let _ = h.emit("install-status", InstallProgress {
id: id_for_cmd.clone(),
status: "installing".to_string(),
progress: (current / total).min(1.0),
});
is_progress = true;
}
}
// 净化日志:过滤进度行、单字符动画行以及退格符
if !is_progress && clean_line.chars().count() > 1 {
emit_log(&h, &log_id, "", clean_line, "info");
}
}
}
}
let exit_status = child_proc.wait().map(|s| s.success()).unwrap_or(false);
if exit_status { "success" } else { "error" }
},
Err(e) => {
emit_log(&h, &log_id, "Fatal Error", &e.to_string(), "error");
"error"
}
};
let _ = handle.emit("install-status", InstallProgress {
id: id.clone(),
status: status_result.to_string(),
progress: 1.0,
});
emit_log(&handle, &log_id, "Result", &format!("Execution finished: {}", status_result), if status_result == "success" { "success" } else { "error" });
}
});
let install_state = tasks::install_queue::create_install_state(app.handle().clone());
app.manage(install_state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
initialize_app,
get_essentials,
get_all_software,
get_updates,
install_software,
get_logs_history
commands::app_commands::initialize_app,
commands::app_commands::get_settings,
commands::app_commands::save_settings,
commands::app_commands::sync_essentials,
commands::app_commands::get_essentials,
commands::app_commands::get_dashboard_snapshot,
commands::app_commands::get_essentials_status,
commands::app_commands::get_update_candidates,
commands::app_commands::get_software_icon,
tasks::install_queue::install_software,
commands::app_commands::get_logs_history
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -0,0 +1 @@
pub mod winget_client;

View File

@@ -0,0 +1,23 @@
use tauri::AppHandle;
use crate::winget::{self, Software};
pub fn ensure_environment_ready(handle: &AppHandle) -> Result<(), String> {
winget::ensure_winget_dependencies(handle)
}
pub fn list_installed_packages(handle: &AppHandle) -> Vec<Software> {
winget::list_installed_software(handle)
}
pub fn list_upgrade_candidates(handle: &AppHandle) -> Vec<Software> {
winget::list_updates(handle)
}
pub fn get_package_by_id(handle: &AppHandle, id: &str) -> Option<Software> {
winget::get_software_info(handle, id)
}
pub fn resolve_icon(handle: &AppHandle, id: &str, name: &str) -> Option<String> {
winget::get_cached_or_extract_icon(handle, id, name)
}

View File

@@ -0,0 +1,163 @@
use reqwest::Client;
use tauri::AppHandle;
use crate::domain::models::{EssentialsRepo, SyncEssentialsResult};
use crate::services::log_service::emit_log;
use crate::services::settings_service;
use crate::services::task_event_service::emit_task_event;
use crate::storage::{essentials_store, paths};
pub async fn sync_essentials(app: &AppHandle) -> Result<SyncEssentialsResult, String> {
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"running",
"starting",
0.0,
None,
Some("Starting essentials sync".to_string()),
None,
);
let settings = settings_service::get_settings(app);
let url = format!("{}/setup-essentials.json", settings.repo_url.trim_end_matches('/'));
let cache_path = paths::get_essentials_path(app);
emit_log(
app,
"sync-essentials",
"Syncing Essentials",
&format!("Downloading from {}...", url),
"info",
);
let client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
match client.get(&url).send().await {
Ok(response) => {
if response.status().is_success() {
let content = response.text().await.map_err(|e| e.to_string())?;
let validation: Result<EssentialsRepo, _> = serde_json::from_str(&content);
if validation.is_ok() {
essentials_store::save_essentials(app, &content)?;
emit_log(
app,
"sync-essentials",
"Result",
"Essentials list updated successfully.",
"success",
);
Ok(SyncEssentialsResult {
status: "updated".to_string(),
message: "清单同步成功".to_string(),
})
.inspect(|_| {
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"completed",
"updated",
1.0,
None,
Some("Essentials list updated successfully".to_string()),
None,
);
})
} else {
emit_log(
app,
"sync-essentials",
"Error",
"Invalid JSON format from repository. Expected { version, essentials }.",
"error",
);
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"failed",
"invalid_json",
1.0,
None,
Some("Invalid JSON format".to_string()),
None,
);
Err("Invalid JSON format".to_string())
}
} else {
let err_msg = format!("HTTP Error: {}", response.status());
emit_log(app, "sync-essentials", "Error", &err_msg, "error");
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"failed",
"http_error",
1.0,
None,
Some(err_msg.clone()),
None,
);
Err(err_msg)
}
}
Err(e) => {
if cache_path.exists() {
emit_log(
app,
"sync-essentials",
"Skipped",
&format!("Network issue: {}. Using local cache.", e),
"info",
);
Ok(SyncEssentialsResult {
status: "cache_used".to_string(),
message: "网络不可用,已继续使用本地缓存".to_string(),
})
.inspect(|_| {
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"completed",
"cache_used",
1.0,
None,
Some("Network unavailable, used local cache".to_string()),
None,
);
})
} else {
let err_msg = format!("Network issue: {}", e);
emit_log(app, "sync-essentials", "Error", &err_msg, "error");
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"failed",
"network_error",
1.0,
None,
Some(err_msg.clone()),
None,
);
Err(err_msg)
}
}
}
}
pub fn get_essentials(app: &AppHandle) -> Option<EssentialsRepo> {
essentials_store::load_essentials(app)
}

View File

@@ -0,0 +1,17 @@
use tauri::{AppHandle, Emitter};
use crate::domain::models::LogPayload;
pub fn emit_log(handle: &AppHandle, id: &str, command: &str, output: &str, status: &str) {
let now = chrono::Local::now().format("%H:%M:%S").to_string();
let _ = handle.emit(
"log-event",
LogPayload {
id: id.to_string(),
timestamp: now,
command: command.to_string(),
output: output.to_string(),
status: status.to_string(),
},
);
}

View File

@@ -0,0 +1,6 @@
pub mod essentials_service;
pub mod log_service;
pub mod reconcile_service;
pub mod settings_service;
pub mod software_state_service;
pub mod task_event_service;

View File

@@ -0,0 +1,160 @@
use std::collections::HashMap;
use crate::domain::models::{DashboardSnapshot, EssentialsRepo, EssentialsStatusItem, UpdateCandidate};
use crate::winget::Software;
pub fn build_dashboard_snapshot(
repo: Option<EssentialsRepo>,
installed_software: Vec<Software>,
updates: Vec<Software>,
) -> DashboardSnapshot {
let essentials_version = repo
.as_ref()
.map(|item| item.version.clone())
.unwrap_or_default();
let definitions = repo.map(|item| item.essentials).unwrap_or_default();
let essentials = build_essentials_status(&definitions, &installed_software, &updates);
let update_candidates = build_update_candidates(&definitions, updates);
DashboardSnapshot {
essentials_version,
essentials,
updates: update_candidates,
installed_software,
}
}
pub fn build_essentials_status(
definitions: &[Software],
installed_software: &[Software],
updates: &[Software],
) -> Vec<EssentialsStatusItem> {
definitions
.iter()
.map(|definition| {
let installed = installed_software
.iter()
.find(|item| item.id.eq_ignore_ascii_case(&definition.id));
let update = updates
.iter()
.find(|item| item.id.eq_ignore_ascii_case(&definition.id));
let current_version = installed.and_then(|item| item.version.clone());
let recommended_version = definition.version.clone();
let available_version = update.and_then(|item| item.available_version.clone());
let (action_label, target_version) = if installed.is_some() {
match compare_versions(current_version.as_deref(), recommended_version.as_deref()) {
Some(std::cmp::Ordering::Less) => (
"更新".to_string(),
recommended_version.clone().or(available_version.clone()),
),
Some(_) => ("已安装".to_string(), None),
None => ("已安装".to_string(), None),
}
} else {
(
"安装".to_string(),
recommended_version.clone().or(available_version.clone()),
)
};
EssentialsStatusItem {
id: definition.id.clone(),
name: definition.name.clone(),
description: definition.description.clone(),
category: definition.category.clone(),
version: current_version,
recommended_version,
available_version,
icon_url: definition.icon_url.clone(),
use_manifest: definition.use_manifest,
manifest_url: definition.manifest_url.clone(),
post_install: definition.post_install.clone(),
post_install_url: definition.post_install_url.clone(),
action_label,
target_version,
}
})
.collect()
}
pub fn build_update_candidates(
definitions: &[Software],
updates: Vec<Software>,
) -> Vec<UpdateCandidate> {
let definition_map: HashMap<String, &Software> = definitions
.iter()
.map(|item| (item.id.to_ascii_lowercase(), item))
.collect();
let mut result: Vec<UpdateCandidate> = updates
.into_iter()
.map(|update| {
let definition = definition_map.get(&update.id.to_ascii_lowercase()).copied();
UpdateCandidate {
id: update.id.clone(),
name: update.name.clone(),
description: definition.and_then(|item| item.description.clone()),
category: definition.and_then(|item| item.category.clone()),
version: update.version.clone(),
available_version: update.available_version.clone(),
icon_url: update.icon_url.clone().or_else(|| definition.and_then(|item| item.icon_url.clone())),
use_manifest: definition.map(|item| item.use_manifest).unwrap_or(false),
manifest_url: definition.and_then(|item| item.manifest_url.clone()),
post_install: None,
post_install_url: None,
action_label: "更新".to_string(),
target_version: update.available_version.clone(),
}
})
.collect();
result.sort_by(|left, right| left.name.locale_compare(&right.name));
result
}
trait LocaleCompare {
fn locale_compare(&self, other: &str) -> std::cmp::Ordering;
}
impl LocaleCompare for String {
fn locale_compare(&self, other: &str) -> std::cmp::Ordering {
self.to_lowercase().cmp(&other.to_lowercase())
}
}
fn compare_versions(left: Option<&str>, right: Option<&str>) -> Option<std::cmp::Ordering> {
let left = left?;
let right = right?;
if left == right {
return Some(std::cmp::Ordering::Equal);
}
let clean = |value: &str| {
value
.trim_start_matches('v')
.trim_start_matches('V')
.split(['-', '+'])
.next()
.unwrap_or(value)
.split('.')
.map(|item| item.parse::<u32>().unwrap_or(0))
.collect::<Vec<_>>()
};
let left_parts = clean(left);
let right_parts = clean(right);
let max_len = left_parts.len().max(right_parts.len());
for index in 0..max_len {
let left_value = *left_parts.get(index).unwrap_or(&0);
let right_value = *right_parts.get(index).unwrap_or(&0);
match left_value.cmp(&right_value) {
std::cmp::Ordering::Equal => continue,
ordering => return Some(ordering),
}
}
Some(std::cmp::Ordering::Equal)
}

View File

@@ -0,0 +1,12 @@
use tauri::AppHandle;
use crate::domain::models::AppSettings;
use crate::storage::settings_store;
pub fn get_settings(app: &AppHandle) -> AppSettings {
settings_store::get_settings(app)
}
pub fn save_settings(app: &AppHandle, settings: &AppSettings) -> Result<(), String> {
settings_store::save_settings(app, settings)
}

View File

@@ -0,0 +1,85 @@
use tauri::AppHandle;
use crate::domain::models::{DashboardSnapshot, EssentialsStatusItem, UpdateCandidate};
use crate::providers::winget_client;
use crate::services::{essentials_service, reconcile_service};
use crate::services::task_event_service::emit_task_event;
pub async fn initialize_app(app: AppHandle) -> Result<bool, String> {
emit_task_event(
&app,
"env-check",
"env-check",
"initialize_app",
"running",
"checking_environment",
0.0,
None,
Some("Checking WinGet environment".to_string()),
None,
);
let app_clone = app.clone();
let result = tokio::task::spawn_blocking(move || winget_client::ensure_environment_ready(&app_clone).map(|_| true))
.await
.unwrap_or(Err("Initialization Task Panicked".to_string()));
match &result {
Ok(_) => emit_task_event(
&app,
"env-check",
"env-check",
"initialize_app",
"completed",
"ready",
1.0,
None,
Some("WinGet environment ready".to_string()),
None,
),
Err(err) => emit_task_event(
&app,
"env-check",
"env-check",
"initialize_app",
"failed",
"error",
1.0,
None,
Some(err.clone()),
None,
),
}
result
}
pub async fn get_software_icon(app: AppHandle, id: String, name: String) -> Option<String> {
tokio::task::spawn_blocking(move || winget_client::resolve_icon(&app, &id, &name))
.await
.unwrap_or(None)
}
pub async fn get_dashboard_snapshot(app: AppHandle) -> DashboardSnapshot {
let repo = essentials_service::get_essentials(&app);
let app_for_installed = app.clone();
let app_for_updates = app.clone();
let installed_handle =
tokio::task::spawn_blocking(move || winget_client::list_installed_packages(&app_for_installed));
let updates_handle =
tokio::task::spawn_blocking(move || winget_client::list_upgrade_candidates(&app_for_updates));
let installed_software = installed_handle.await.unwrap_or_default();
let updates = updates_handle.await.unwrap_or_default();
reconcile_service::build_dashboard_snapshot(repo, installed_software, updates)
}
pub async fn get_essentials_status(app: AppHandle) -> (String, Vec<EssentialsStatusItem>) {
let snapshot = get_dashboard_snapshot(app).await;
(snapshot.essentials_version, snapshot.essentials)
}
pub async fn get_update_candidates(app: AppHandle) -> Vec<UpdateCandidate> {
get_dashboard_snapshot(app).await.updates
}

View File

@@ -0,0 +1,32 @@
use tauri::{AppHandle, Emitter};
use crate::domain::models::TaskEventPayload;
use crate::winget::Software;
pub fn emit_task_event(
handle: &AppHandle,
task_id: &str,
software_id: &str,
task_type: &str,
status: &str,
stage: &str,
progress: f32,
target_version: Option<String>,
message: Option<String>,
software_info: Option<Software>,
) {
let _ = handle.emit(
"task-event",
TaskEventPayload {
task_id: task_id.to_string(),
software_id: software_id.to_string(),
task_type: task_type.to_string(),
status: status.to_string(),
stage: stage.to_string(),
progress,
target_version,
message,
software_info,
},
);
}

View File

@@ -0,0 +1,21 @@
use std::fs;
use tauri::AppHandle;
use crate::domain::models::EssentialsRepo;
use crate::storage::paths::get_essentials_path;
pub fn load_essentials(app: &AppHandle) -> Option<EssentialsRepo> {
let file_path = get_essentials_path(app);
if !file_path.exists() {
return None;
}
let content = fs::read_to_string(file_path).unwrap_or_default();
serde_json::from_str(&content).ok()
}
pub fn save_essentials(app: &AppHandle, content: &str) -> Result<(), String> {
let path = get_essentials_path(app);
fs::write(path, content).map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,3 @@
pub mod essentials_store;
pub mod paths;
pub mod settings_store;

View File

@@ -0,0 +1,20 @@
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
pub fn get_app_data_dir(app: &AppHandle) -> PathBuf {
let app_data_dir = app.path().app_data_dir().unwrap_or_default();
if !app_data_dir.exists() {
let _ = fs::create_dir_all(&app_data_dir);
}
app_data_dir
}
pub fn get_settings_path(app: &AppHandle) -> PathBuf {
get_app_data_dir(app).join("settings.json")
}
pub fn get_essentials_path(app: &AppHandle) -> PathBuf {
get_app_data_dir(app).join("setup-essentials.json")
}

View File

@@ -0,0 +1,24 @@
use std::fs;
use tauri::AppHandle;
use crate::domain::models::AppSettings;
use crate::storage::paths::get_settings_path;
pub fn get_settings(app: &AppHandle) -> AppSettings {
let path = get_settings_path(app);
if !path.exists() {
let default_settings = AppSettings::default();
let _ = fs::write(&path, serde_json::to_string_pretty(&default_settings).unwrap());
return default_settings;
}
let content = fs::read_to_string(path).unwrap_or_default();
serde_json::from_str(&content).unwrap_or_default()
}
pub fn save_settings(app: &AppHandle, settings: &AppSettings) -> Result<(), String> {
let path = get_settings_path(app);
let content = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?;
fs::write(path, content).map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,753 @@
use std::fs;
use std::io::{BufRead, BufReader, Read};
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use regex::Regex;
use tauri::{AppHandle, Emitter, State};
use tokio::sync::mpsc;
use winreg::enums::*;
use winreg::RegKey;
use crate::domain::models::{InstallTask, TaskEventPayload};
use crate::providers::winget_client;
use crate::services::essentials_service;
use crate::services::log_service::emit_log;
use crate::services::task_event_service;
use crate::winget::{PostInstallStep, Software};
pub struct AppState {
pub install_tx: mpsc::Sender<InstallTask>,
pub app_handle: AppHandle,
}
pub fn create_install_state(handle: AppHandle) -> AppState {
let (tx, mut rx) = mpsc::channel::<InstallTask>(100);
let runtime_handle = handle.clone();
tauri::async_runtime::spawn(async move {
let perc_re = Regex::new(r"(\d+)\s*%").unwrap();
let size_re = Regex::new(r"([\d\.]+)\s*[a-zA-Z]+\s*/\s*([\d\.]+)\s*[a-zA-Z]+").unwrap();
while let Some(task) = rx.recv().await {
let task_id = task.id.clone();
let task_version = task.version.clone();
let use_manifest = task.use_manifest;
let manifest_url = task.manifest_url.clone();
let enable_post_install_flag = task.enable_post_install;
let log_id = format!("install-{}", task_id);
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"running",
"installing",
0.0,
task_version.clone(),
None,
None,
);
let mut args = vec!["install".to_string()];
let display_cmd: String;
let mut temp_manifest_path: Option<PathBuf> = None;
if use_manifest && manifest_url.is_some() {
let url = manifest_url.unwrap();
display_cmd = format!("Winget Install (Manifest): {} from {}", task_id, url);
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"running",
"downloading_manifest",
0.0,
task_version.clone(),
Some("Downloading remote manifest".to_string()),
None,
);
emit_log(
&runtime_handle,
&log_id,
&display_cmd,
"Downloading remote manifest...",
"info",
);
let client = reqwest::Client::new();
match client
.get(&url)
.timeout(std::time::Duration::from_secs(15))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
if let Ok(content) = resp.text().await {
let temp_dir = std::env::temp_dir();
let file_name =
format!("winget_tmp_{}.yaml", chrono::Local::now().timestamp_millis());
let local_path = temp_dir.join(file_name);
if fs::write(&local_path, content).is_ok() {
args.push("--manifest".to_string());
args.push(local_path.to_string_lossy().to_string());
temp_manifest_path = Some(local_path);
}
}
}
_ => {}
}
if temp_manifest_path.is_none() {
emit_log(
&runtime_handle,
&log_id,
"Error",
"Failed to download or save manifest.",
"error",
);
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"failed",
"manifest_error",
0.0,
task_version.clone(),
Some("Failed to download or save manifest".to_string()),
None,
);
continue;
}
} else {
args.push("--id".to_string());
args.push(task_id.clone());
args.push("-e".to_string());
if let Some(v) = &task_version {
if !v.is_empty() {
args.push("--version".to_string());
args.push(v.clone());
}
}
display_cmd = match &task_version {
Some(v) if !v.is_empty() => format!("Winget Install: {} (v{})", task_id, v),
_ => format!("Winget Install: {}", task_id),
};
}
args.extend([
"--silent".to_string(),
"--accept-package-agreements".to_string(),
"--accept-source-agreements".to_string(),
"--disable-interactivity".to_string(),
]);
let full_command = format!("winget {}", args.join(" "));
emit_log(
&runtime_handle,
&log_id,
&display_cmd,
&format!("Executing: {}\n---", full_command),
"info",
);
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"running",
"invoking_winget",
0.0,
task_version.clone(),
None,
None,
);
let child = Command::new("winget")
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.creation_flags(0x08000000)
.spawn();
let status_result = match child {
Ok(mut child_proc) => {
let stdout_handle = child_proc.stdout.take().map(|stdout| {
spawn_install_stream_reader(
stdout,
runtime_handle.clone(),
log_id.clone(),
task_id.clone(),
"stdout",
perc_re.clone(),
size_re.clone(),
)
});
let stderr_handle = child_proc.stderr.take().map(|stderr| {
spawn_install_stream_reader(
stderr,
runtime_handle.clone(),
log_id.clone(),
task_id.clone(),
"stderr",
perc_re.clone(),
size_re.clone(),
)
});
let exit_status = child_proc.wait().map(|s| s.success()).unwrap_or(false);
if let Some(join_handle) = stdout_handle {
let _ = join_handle.join();
}
if let Some(join_handle) = stderr_handle {
let _ = join_handle.join();
}
let status_result = if exit_status { "success" } else { "error" };
if status_result == "success" && enable_post_install_flag {
let software_info = essentials_service::get_essentials(&runtime_handle)
.and_then(|repo| repo.essentials.into_iter().find(|s| s.id == task_id));
if let Some(sw) = software_info {
let mut final_steps = None;
if let Some(steps) = sw.post_install {
if !steps.is_empty() {
final_steps = Some(steps);
}
} else if let Some(url) = sw.post_install_url {
emit_log(
&runtime_handle,
&log_id,
"Post-Install",
"Local config not found, fetching remote config...",
"info",
);
let client = reqwest::Client::new();
if let Ok(resp) = client
.get(&url)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
{
if resp.status().is_success() {
if let Ok(text) = resp.text().await {
match serde_json::from_str::<Vec<PostInstallStep>>(&text) {
Ok(steps) => {
emit_log(
&runtime_handle,
&log_id,
"Post-Install",
&format!(
"Successfully fetched remote config with {} steps.",
steps.len()
),
"info",
);
final_steps = Some(steps);
}
Err(e) => {
emit_log(
&runtime_handle,
&log_id,
"Post-Install Error",
&format!("JSON Parse Error: {}. Raw Content: {}", e, text),
"error",
);
}
}
}
} else {
emit_log(
&runtime_handle,
&log_id,
"Post-Install Error",
&format!("Remote config HTTP Error: {}", resp.status()),
"error",
);
}
}
}
if let Some(steps) = final_steps {
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"running",
"configuring",
1.0,
task_version.clone(),
Some("Starting post-installation configuration".to_string()),
None,
);
emit_log(
&runtime_handle,
&log_id,
"Post-Install",
"Starting post-installation configuration...",
"info",
);
if let Err(e) = execute_post_install(&runtime_handle, &log_id, steps).await {
emit_log(&runtime_handle, &log_id, "Post-Install Error", &e, "error");
} else {
emit_log(
&runtime_handle,
&log_id,
"Post-Install",
"Post-installation configuration completed.",
"success",
);
}
}
}
}
status_result
}
Err(e) => {
emit_log(&runtime_handle, &log_id, "Fatal Error", &e.to_string(), "error");
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"failed",
"spawn_error",
0.0,
task_version.clone(),
Some(e.to_string()),
None,
);
"error"
}
};
let resolved_software_info = if status_result == "success" {
winget_client::get_package_by_id(&runtime_handle, &task_id)
} else {
None
};
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
if status_result == "success" { "completed" } else { "failed" },
status_result,
1.0,
task_version.clone(),
Some(format!("Execution finished: {}", status_result)),
resolved_software_info,
);
emit_log(
&runtime_handle,
&log_id,
"Result",
&format!("Execution finished: {}", status_result),
if status_result == "success" {
"success"
} else {
"error"
},
);
if let Some(path) = temp_manifest_path {
let _ = fs::remove_file(path);
}
}
});
AppState { install_tx: tx, app_handle: handle }
}
#[tauri::command]
pub async fn install_software(
task: InstallTask,
state: State<'_, AppState>,
) -> Result<(), String> {
let log_id = format!("install-{}", task.id);
emit_task_event(
&state.app_handle,
&log_id,
&task.id,
"install",
"queued",
"queued",
0.0,
task.version.clone(),
None,
None,
);
state.install_tx.send(task).await.map_err(|e| e.to_string())
}
fn spawn_install_stream_reader<R: Read + Send + 'static>(
reader: R,
handle: AppHandle,
log_id: String,
task_id: String,
stream_name: &'static str,
perc_re: Regex,
size_re: Regex,
) -> thread::JoinHandle<()> {
thread::spawn(move || {
let reader = BufReader::new(reader);
for line_res in reader.split(b'\r') {
if let Ok(line_bytes) = line_res {
let line_str = String::from_utf8_lossy(&line_bytes).to_string();
let clean_line = line_str.trim();
if clean_line.is_empty() {
continue;
}
if stream_name == "stdout" {
let mut is_progress = false;
if let Some(caps) = perc_re.captures(clean_line) {
if let Ok(p_val) = caps[1].parse::<f32>() {
let _ = handle.emit(
"task-event",
TaskEventPayload {
task_id: log_id.clone(),
software_id: task_id.clone(),
task_type: "install".to_string(),
status: "running".to_string(),
stage: "installing".to_string(),
progress: p_val / 100.0,
target_version: None,
message: None,
software_info: None,
},
);
is_progress = true;
}
} else if let Some(caps) = size_re.captures(clean_line) {
let current = caps[1].parse::<f32>().unwrap_or(0.0);
let total = caps[2].parse::<f32>().unwrap_or(1.0);
if total > 0.0 {
let _ = handle.emit(
"task-event",
TaskEventPayload {
task_id: log_id.clone(),
software_id: task_id.clone(),
task_type: "install".to_string(),
status: "running".to_string(),
stage: "installing".to_string(),
progress: (current / total).min(1.0),
target_version: None,
message: None,
software_info: None,
},
);
is_progress = true;
}
}
if !is_progress && clean_line.chars().count() > 1 {
emit_log(&handle, &log_id, "", clean_line, "info");
}
} else {
emit_log(&handle, &log_id, stream_name, clean_line, "error");
}
}
}
})
}
fn emit_task_event(
handle: &AppHandle,
task_id: &str,
software_id: &str,
task_type: &str,
status: &str,
stage: &str,
progress: f32,
target_version: Option<String>,
message: Option<String>,
software_info: Option<Software>,
) {
task_event_service::emit_task_event(
handle,
task_id,
software_id,
task_type,
status,
stage,
progress,
target_version.clone(),
message,
software_info,
);
}
fn expand_win_path(path: &str) -> PathBuf {
let mut expanded = path.to_string();
let env_vars = [
"AppData",
"LocalAppData",
"ProgramData",
"SystemRoot",
"SystemDrive",
"TEMP",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
];
for var in env_vars {
let re = Regex::new(&format!(r"(?i)%{}%", var)).unwrap();
if re.is_match(&expanded) {
if let Ok(val) = std::env::var(var) {
expanded = re.replace_all(&expanded, val.as_str()).to_string();
}
}
}
PathBuf::from(expanded)
}
async fn execute_post_install(
handle: &AppHandle,
log_id: &str,
steps: Vec<PostInstallStep>,
) -> Result<(), String> {
let steps_len = steps.len();
for (i, step) in steps.into_iter().enumerate() {
let step_prefix = format!("Step {}/{}: ", i + 1, steps_len);
let delay = match &step {
PostInstallStep::RegistryBatch { delay_ms, .. } => *delay_ms,
PostInstallStep::FileCopy { delay_ms, .. } => *delay_ms,
PostInstallStep::FileDelete { delay_ms, .. } => *delay_ms,
PostInstallStep::Command { delay_ms, .. } => *delay_ms,
};
match step {
PostInstallStep::RegistryBatch {
root,
base_path,
values,
..
} => {
emit_log(
handle,
log_id,
"Registry Update",
&format!("{}Applying batch registry settings to {}...", step_prefix, base_path),
"info",
);
let hive = match root.as_str() {
"HKCU" => RegKey::predef(HKEY_CURRENT_USER),
"HKLM" => RegKey::predef(HKEY_LOCAL_MACHINE),
_ => {
emit_log(
handle,
log_id,
"Registry Error",
&format!("Unknown root hive: {}", root),
"error",
);
continue;
}
};
match hive.create_subkey(&base_path) {
Ok((key, _)) => {
for (name, val) in values {
let res = match val.v_type.as_str() {
"String" => key.set_value(&name, &val.data.as_str().unwrap_or_default()),
"Dword" => key.set_value(&name, &(val.data.as_u64().unwrap_or(0) as u32)),
"Qword" => key.set_value(&name, &(val.data.as_u64().unwrap_or(0))),
"MultiString" => {
let strings: Vec<String> = val
.data
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
key.set_value(&name, &strings)
}
"ExpandString" => {
key.set_value(&name, &val.data.as_str().unwrap_or_default())
}
"Delete" => key.delete_value(&name),
_ => Err(std::io::Error::other("Unsupported type")),
};
if let Err(e) = res {
emit_log(
handle,
log_id,
"Registry Error",
&format!("Failed to apply {}: {}", name, e),
"error",
);
}
}
}
Err(e) => {
emit_log(
handle,
log_id,
"Registry Error",
&format!("Failed to create/open key {}: {}", base_path, e),
"error",
);
}
}
}
PostInstallStep::FileCopy { src, dest, .. } => {
let dest_path = expand_win_path(&dest);
let src_is_url = src.starts_with("http://") || src.starts_with("https://");
if src_is_url {
emit_log(
handle,
log_id,
"File Download",
&format!("{}Downloading {:?} to {:?}...", step_prefix, src, dest_path),
"info",
);
let client = reqwest::Client::new();
match client
.get(&src)
.timeout(std::time::Duration::from_secs(60))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
if let Ok(bytes) = resp.bytes().await {
if let Some(parent) = dest_path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = fs::write(&dest_path, bytes) {
emit_log(
handle,
log_id,
"File Error",
&format!("Failed to write to {:?}: {}", dest_path, e),
"error",
);
} else {
emit_log(
handle,
log_id,
"Success",
"File downloaded and saved successfully.",
"success",
);
}
}
}
Ok(resp) => emit_log(
handle,
log_id,
"Download Error",
&format!("HTTP Status: {}", resp.status()),
"error",
),
Err(e) => emit_log(handle, log_id, "Download Error", &e.to_string(), "error"),
}
} else {
let src_path = expand_win_path(&src);
emit_log(
handle,
log_id,
"File Copy",
&format!("{}Copying {:?} to {:?}...", step_prefix, src_path, dest_path),
"info",
);
if let Some(parent) = dest_path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = fs::copy(&src_path, &dest_path) {
emit_log(
handle,
log_id,
"File Error",
&format!("Failed to copy file: {}", e),
"error",
);
} else {
emit_log(handle, log_id, "Success", "File copied successfully.", "success");
}
}
}
PostInstallStep::FileDelete { path, .. } => {
let full_path = expand_win_path(&path);
emit_log(
handle,
log_id,
"File Delete",
&format!("{}Deleting {:?}...", step_prefix, full_path),
"info",
);
if full_path.exists() {
if let Err(e) = fs::remove_file(&full_path) {
emit_log(
handle,
log_id,
"File Error",
&format!("Failed to delete file: {}", e),
"error",
);
} else {
emit_log(handle, log_id, "Success", "File deleted successfully.", "success");
}
} else {
emit_log(handle, log_id, "File Info", "File does not exist, skipping.", "info");
}
}
PostInstallStep::Command { run, .. } => {
emit_log(
handle,
log_id,
"Command Execution",
&format!("{}Executing: {}", step_prefix, run),
"info",
);
let output = Command::new("cmd")
.arg("/C")
.raw_arg(&run)
.creation_flags(0x08000000)
.output();
match output {
Ok(out) => {
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
emit_log(handle, log_id, "Command Failed", &err, "error");
} else {
emit_log(handle, log_id, "Success", "Command executed successfully.", "success");
}
}
Err(e) => {
emit_log(handle, log_id, "Execution Error", &e.to_string(), "error");
}
}
}
}
if let Some(ms) = delay {
if ms > 0 {
emit_log(
handle,
log_id,
"Post-Install",
&format!("Waiting for {}ms...", ms),
"info",
);
tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
}
}
}
Ok(())
}

View File

@@ -0,0 +1 @@
pub mod install_queue;

View File

@@ -1,21 +1,71 @@
use serde::{Deserialize, Serialize};
use base64::Engine;
use std::fs;
use std::process::Command;
use std::os::windows::process::CommandExt;
use tauri::AppHandle;
use crate::emit_log;
use std::collections::HashMap;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
use crate::services::log_service::emit_log;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RegistryValue {
pub v_type: String, // "String", "Dword", "Qword", "MultiString", "ExpandString"
pub data: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum PostInstallStep {
#[serde(rename = "registry_batch")]
RegistryBatch {
root: String,
base_path: String,
values: HashMap<String, RegistryValue>,
delay_ms: Option<u64>,
},
#[serde(rename = "file_copy")]
FileCopy {
src: String,
dest: String,
delay_ms: Option<u64>,
},
#[serde(rename = "file_delete")]
FileDelete {
path: String,
delay_ms: Option<u64>,
},
#[serde(rename = "command")]
Command {
run: String,
delay_ms: Option<u64>,
},
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Software {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub version: Option<String>,
pub available_version: Option<String>,
pub icon_url: Option<String>,
pub status: String, // "idle", "pending", "installing", "success", "error"
#[serde(default = "default_status")]
pub status: String, // "idle", "pending", "installing", "configuring", "success", "error"
#[serde(default = "default_progress")]
pub progress: f32,
#[serde(default = "default_false")]
pub use_manifest: bool,
pub manifest_url: Option<String>,
pub post_install: Option<Vec<PostInstallStep>>,
pub post_install_url: Option<String>,
}
fn default_status() -> String { "idle".to_string() }
fn default_progress() -> f32 { 0.0 }
fn default_false() -> bool { false }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct WingetPackage {
@@ -23,6 +73,7 @@ struct WingetPackage {
pub name: String,
pub installed_version: Option<String>,
pub available_versions: Option<Vec<String>>,
pub icon_url: Option<String>,
}
pub fn ensure_winget_dependencies(handle: &AppHandle) -> Result<(), String> {
@@ -58,6 +109,7 @@ pub fn ensure_winget_dependencies(handle: &AppHandle) -> Result<(), String> {
Install-Module -Name Microsoft.WinGet.Client -Force -AllowClobber -Scope CurrentUser -Confirm:$false
winget source update --accept-source-agreements
winget settings --enable LocalManifestFiles
"#;
let output = Command::new("powershell")
@@ -99,8 +151,8 @@ pub fn ensure_winget_dependencies(handle: &AppHandle) -> Result<(), String> {
}
}
pub fn list_all_software(handle: &AppHandle) -> Vec<Software> {
let log_id = format!("list-all-{}", chrono::Local::now().timestamp_millis());
pub fn list_installed_software(handle: &AppHandle) -> Vec<Software> {
let log_id = format!("list-installed-{}", chrono::Local::now().timestamp_millis());
let script = r#"
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = 'SilentlyContinue'
@@ -120,7 +172,7 @@ pub fn list_all_software(handle: &AppHandle) -> Vec<Software> {
}
"#;
execute_powershell(handle, &log_id, "Fetch All Software", script)
execute_powershell(handle, &log_id, "Fetch Installed Software", script)
}
pub fn list_updates(handle: &AppHandle) -> Vec<Software> {
@@ -129,14 +181,17 @@ pub fn list_updates(handle: &AppHandle) -> Vec<Software> {
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = 'SilentlyContinue'
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
$pkgs = Get-WinGetPackage -ErrorAction SilentlyContinue | Where-Object { $_.IsUpdateAvailable }
if ($pkgs) {
$pkgs | ForEach-Object {
$pkgs | ForEach-Object {
[PSCustomObject]@{
Name = [string]$_.Name;
Id = [string]$_.Id;
InstalledVersion = [string]$_.InstalledVersion;
AvailableVersions = $_.AvailableVersions
AvailableVersions = $_.AvailableVersions;
IconUrl = $null
}
} | ConvertTo-Json -Compress
} else {
@@ -147,8 +202,134 @@ pub fn list_updates(handle: &AppHandle) -> Vec<Software> {
execute_powershell(handle, &log_id, "Fetch Updates", script)
}
pub fn get_software_info(handle: &AppHandle, id: &str) -> Option<Software> {
let log_id = format!("get-info-{}", chrono::Local::now().timestamp_millis());
let script = format!(r#"
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = 'SilentlyContinue'
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
$pkg = Get-WinGetPackage -Id "{}" -ErrorAction SilentlyContinue
if ($pkg) {{
[PSCustomObject]@{{
Name = [string]$pkg.Name;
Id = [string]$pkg.Id;
InstalledVersion = [string]$pkg.InstalledVersion;
AvailableVersions = @()
}} | ConvertTo-Json -Compress
}}
"#, id);
let res = execute_powershell(handle, &log_id, "Fetch Single Software Info", &script);
res.into_iter().next()
}
pub fn get_cached_or_extract_icon(handle: &AppHandle, id: &str, name: &str) -> Option<String> {
let cache_key = sanitize_cache_key(id);
let icon_dir = get_icon_cache_dir(handle);
let icon_path = icon_dir.join(format!("{}.png", cache_key));
if let Ok(bytes) = fs::read(&icon_path) {
return Some(format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
));
}
let log_id = format!("icon-{}", cache_key);
emit_log(handle, &log_id, "Icon Lookup", &format!("Resolving icon for {}...", id), "info");
let script = format!(r#"
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = 'SilentlyContinue'
Add-Type -AssemblyName System.Drawing
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
$packageId = @'
{id}
'@
$packageName = @'
{name}
'@
$foundPath = ""
$wshShell = New-Object -ComObject WScript.Shell
$startMenuPaths = @(
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs",
"$env:AppData\Microsoft\Windows\Start Menu\Programs"
)
$lnkFiles = Get-ChildItem -Path $startMenuPaths -Filter "*.lnk" -Recurse -File
$matchedLnk = $lnkFiles | Where-Object {{ $_.BaseName -eq $packageName -or $packageName -like "*$($_.BaseName)*" }} | Select-Object -First 1
if ($matchedLnk) {{
try {{
$target = $wshShell.CreateShortcut($matchedLnk.FullName).TargetPath
if ($target -and (Test-Path $target) -and $target.EndsWith(".exe")) {{
$foundPath = $target
}} else {{
$foundPath = $matchedLnk.FullName
}}
}} catch {{
$foundPath = $matchedLnk.FullName
}}
}}
if (-not $foundPath) {{
$registryPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
$regItems = Get-ItemProperty $registryPaths
$matchedReg = $regItems | Where-Object {{ $_.DisplayName -eq $packageName -or $_.PSChildName -eq $packageId }} | Select-Object -First 1
if ($matchedReg.DisplayIcon) {{
$foundPath = $matchedReg.DisplayIcon.Split(',')[0].Trim('"')
}} elseif ($matchedReg.InstallLocation) {{
$loc = $matchedReg.InstallLocation.Trim('"')
if (Test-Path $loc) {{
$exe = Get-ChildItem -Path $loc -Filter "*.exe" -File | Select-Object -First 1
if ($exe) {{ $foundPath = $exe.FullName }}
}}
}}
}}
if ($foundPath -and (Test-Path $foundPath)) {{
try {{
$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($foundPath)
if ($icon) {{
$bitmap = $icon.ToBitmap()
$ms = New-Object System.IO.MemoryStream
$bitmap.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
[Convert]::ToBase64String($ms.ToArray())
$ms.Dispose()
$bitmap.Dispose()
$icon.Dispose()
}}
}} catch {{}}
}}
"#, id = id, name = name);
let output = Command::new("powershell")
.args(["-NoProfile", "-Command", &script])
.creation_flags(0x08000000)
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let encoded = stdout.trim_start_matches('\u{feff}').trim();
if encoded.is_empty() {
return None;
}
let bytes = base64::engine::general_purpose::STANDARD.decode(encoded).ok()?;
if fs::create_dir_all(&icon_dir).is_err() {
return Some(format!("data:image/png;base64,{}", encoded));
}
let _ = fs::write(&icon_path, &bytes);
Some(format!("data:image/png;base64,{}", encoded))
}
fn execute_powershell(handle: &AppHandle, log_id: &str, cmd_title: &str, script: &str) -> Vec<Software> {
emit_log(handle, log_id, cmd_title, "Executing PowerShell...", "info");
emit_log(handle, log_id, cmd_title, "Fetching data from Winget...", "info");
let output = Command::new("powershell")
.args(["-NoProfile", "-Command", script])
@@ -191,15 +372,31 @@ fn parse_json_output(json_str: String) -> Vec<Software> {
vec![]
}
fn get_icon_cache_dir(handle: &AppHandle) -> PathBuf {
let app_data_dir = handle.path().app_data_dir().unwrap_or_default();
app_data_dir.join("icons")
}
fn sanitize_cache_key(id: &str) -> String {
id.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
fn map_package(p: WingetPackage) -> Software {
Software {
id: p.id,
name: p.name,
description: None,
category: None,
version: p.installed_version,
available_version: p.available_versions.and_then(|v| v.first().cloned()),
icon_url: None,
icon_url: p.icon_url,
status: "idle".to_string(),
progress: 0.0,
use_manifest: false,
manifest_url: None,
post_install: None,
post_install_url: None,
}
}

View File

@@ -10,11 +10,22 @@
<path d="M12 3l1.912 5.885h6.19l-5.007 3.638 1.912 5.885L12 14.77l-5.007 3.638 1.912-5.885-5.007-3.638h6.19z"></path>
</svg>
</span>
装机必备
装机常用
</router-link>
<router-link to="/other-software" class="nav-item">
<span class="nav-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="6" rx="1.5"></rect>
<rect x="3" y="14" width="18" height="6" rx="1.5"></rect>
<path d="M7 7h.01"></path>
<path d="M7 17h.01"></path>
</svg>
</span>
网传有关
</router-link>
<router-link to="/updates" class="nav-item">
<span class="nav-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
@@ -23,30 +34,31 @@
</span>
软件更新
</router-link>
<router-link to="/all" class="nav-item">
<span class="nav-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"></path>
<path d="m3.3 7 8.7 5 8.7-5"></path>
<path d="M12 22V12"></path>
</svg>
</span>
全部软件
</router-link>
<!-- 底部日志选项 -->
<router-link to="/logs" class="nav-item nav-logs">
<span class="nav-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
</span>
运行日志
</router-link>
<!-- 底部选项 -->
<div class="sidebar-footer">
<router-link to="/logs" class="nav-item nav-logs">
<span class="nav-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
</span>
运行日志
</router-link>
<router-link to="/settings" class="nav-item nav-settings">
<span class="nav-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1Z"></path>
</svg>
</span>
软件设置
</router-link>
</div>
</nav>
</div>
</template>
@@ -56,7 +68,7 @@
width: 240px;
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
padding: 40px 20px;
padding: 40px 20px 5px 20px;
display: flex;
flex-direction: column;
}
@@ -65,7 +77,7 @@
font-size: 20px;
font-weight: 700;
margin-bottom: 40px;
padding-left: 20px;
padding-left: 10px;
color: var(--text-main);
}
@@ -106,8 +118,15 @@
justify-content: center;
}
.nav-logs {
margin-top: auto; /* 将日志推到底部 */
margin-bottom: 20px;
.sidebar-footer {
margin-top: auto;
padding-bottom: 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.nav-logs, .nav-settings {
margin-top: 0;
}
</style>

View File

@@ -4,7 +4,8 @@
:class="{
'installed-mode': software.status === 'installed',
'is-selected': isSelected && software.status !== 'installed',
'is-busy': software.status === 'pending' || software.status === 'installing'
'is-busy': software.status === 'pending' || software.status === 'installing',
'is-disabled': disabled && software.status === 'idle'
}"
@click="handleCardClick"
>
@@ -14,7 +15,7 @@
class="checkbox"
:class="{
'checked': isSelected,
'disabled': software.status === 'installed' || software.status === 'pending' || software.status === 'installing'
'disabled': disabled || software.status === 'installed' || software.status === 'pending' || software.status === 'installing'
}"
>
<span v-if="isSelected"></span>
@@ -30,25 +31,55 @@
</div>
<div class="info">
<div class="title-row">
<h3 class="name">{{ software.name }}</h3>
<h3 class="name" :title="software.description">{{ software.name }}</h3>
<span class="id-badge">{{ software.id }}</span>
</div>
<p class="description" v-if="software.description">{{ software.description }}</p>
<div class="version-info">
<span class="version-tag">当前: {{ software.version || '--' }}</span>
<span class="version-tag available" v-if="software.available_version">
最新: {{ software.available_version }}
</span>
<template v-if="software.version">
<span class="version-tag">当前: {{ software.version }}</span>
<span v-if="software.recommended_version && software.actionLabel === '更新'" class="version-tag recommended">
推荐: {{ software.recommended_version }}
</span>
<span v-if="software.available_version && !software.recommended_version" class="version-tag available">
最新: {{ software.available_version }}
</span>
</template>
<template v-else>
<span v-if="software.recommended_version" class="version-tag recommended">
推荐: {{ software.recommended_version }}
</span>
<span v-else class="version-tag recommended">
推荐: 最新版
</span>
<span v-if="software.available_version" class="version-tag available">
最新: {{ software.available_version }}
</span>
</template>
</div>
</div>
</div>
<div class="card-right" v-if="actionLabel || software.status !== 'idle'">
<div class="action-wrapper">
<!-- 后安装配置开关 -->
<div
v-if="software.status === 'idle' && (software.post_install || software.post_install_url)"
class="post-install-toggle"
@click.stop="$emit('togglePostInstall', software.id)"
:title="software.enablePostInstall ? '已开启安装后自动配置' : '已关闭安装后自动配置'"
>
<span class="toggle-label">自动配置</span>
<div class="toggle-switch" :class="{ 'is-active': software.enablePostInstall }">
<div class="toggle-dot"></div>
</div>
</div>
<button
v-if="software.status === 'idle'"
@click.stop="$emit('install', software.id)"
@click.stop="$emit('install', software.id, (software as any).targetVersion)"
class="action-btn install-btn"
:disabled="disabled"
>
{{ actionLabel }}
</button>
@@ -61,12 +92,15 @@
已安装
</button>
<!-- 等待中状态 -->
<div v-else-if="software.status === 'pending'" class="status-pending">
<span class="wait-text">等待中</span>
</div>
<!-- 安装中状态显示进度环和百分比 -->
<div v-else-if="software.status === 'configuring'" class="status-configuring">
<div class="mini-spinner"></div>
<span class="config-text">正在配置...</span>
</div>
<div v-else-if="software.status === 'installing'" class="progress-status">
<div class="progress-ring-container">
<svg viewBox="0 0 32 32" class="ring-svg">
@@ -84,9 +118,14 @@
<span class="check-icon"></span> 已完成
</div>
<div v-else-if="software.status === 'error'" class="status-error">
失败
</div>
<button
v-else-if="software.status === 'error'"
@click.stop="$emit('install', software.id, (software as any).targetVersion)"
class="action-btn retry-btn"
:disabled="disabled"
>
重试
</button>
</div>
</div>
</div>
@@ -101,17 +140,24 @@ const props = defineProps<{
name: string;
description?: string;
version?: string;
recommended_version?: string;
available_version?: string;
icon_url?: string;
status: string;
progress: number;
actionLabel?: string;
targetVersion?: string;
post_install?: any;
post_install_url?: string;
enablePostInstall?: boolean;
},
actionLabel?: string,
selectable?: boolean,
isSelected?: boolean
isSelected?: boolean,
disabled?: boolean
}>();
const emit = defineEmits(['install', 'toggleSelect']);
const emit = defineEmits(['install', 'toggleSelect', 'togglePostInstall']);
const displayProgress = computed(() => {
if (!props.software.progress) return '准备中';
@@ -164,6 +210,11 @@ const handleCardClick = () => {
opacity: 0.9;
}
.software-card.is-disabled {
opacity: 0.6;
pointer-events: none;
}
.selection-area {
margin-right: 16px;
flex-shrink: 0;
@@ -202,9 +253,9 @@ const handleCardClick = () => {
}
.icon-container {
width: 48px;
height: 48px;
border-radius: 12px;
width: 32px;
height: 32px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
@@ -223,7 +274,7 @@ const handleCardClick = () => {
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
font-size: 14px;
font-weight: 700;
}
@@ -254,17 +305,6 @@ const handleCardClick = () => {
font-family: monospace;
}
.description {
font-size: 13px;
color: var(--text-sec);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
max-width: 500px;
}
.version-info {
display: flex;
gap: 8px;
@@ -276,7 +316,7 @@ const handleCardClick = () => {
font-weight: 500;
}
.version-tag.available {
.version-tag.available, .version-tag.recommended {
color: var(--primary-color);
background: rgba(0, 122, 255, 0.08);
padding: 0 6px;
@@ -286,10 +326,64 @@ const handleCardClick = () => {
.card-right {
margin-left: 20px;
min-width: 100px;
}
.action-wrapper {
display: flex;
align-items: center;
gap: 16px;
justify-content: flex-end;
}
.post-install-toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 8px;
transition: background-color 0.2s;
}
.post-install-toggle:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.toggle-label {
font-size: 12px;
color: var(--text-sec);
font-weight: 500;
}
.toggle-switch {
width: 32px;
height: 18px;
background-color: #E5E5EA;
border-radius: 9px;
position: relative;
transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toggle-switch.is-active {
background-color: #34C759;
}
.toggle-dot {
width: 14px;
height: 14px;
background-color: white;
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toggle-switch.is-active .toggle-dot {
transform: translateX(14px);
}
.action-btn {
width: 90px;
height: 34px;
@@ -314,6 +408,16 @@ const handleCardClick = () => {
color: white;
}
.retry-btn {
background-color: rgba(255, 59, 48, 0.05);
color: #FF3B30;
}
.retry-btn:hover {
background-color: #FF3B30;
color: white;
}
.installed-btn {
background-color: #F2F2F7;
color: #AEAEB2;
@@ -329,6 +433,30 @@ const handleCardClick = () => {
color: #AEAEB2;
}
.status-configuring {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 90px;
justify-content: center;
color: var(--primary-color);
}
.mini-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0, 122, 255, 0.1);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.config-text {
font-size: 11px;
font-weight: 600;
}
.wait-text {
font-size: 12px;
font-weight: 600;

View File

@@ -11,17 +11,21 @@ const router = createRouter({
path: '/essentials',
component: () => import('../views/Essentials.vue')
},
{
path: '/other-software',
component: () => import('../views/OtherSoftware.vue')
},
{
path: '/updates',
component: () => import('../views/Updates.vue')
},
{
path: '/all',
component: () => import('../views/AllSoftware.vue')
},
{
path: '/logs',
component: () => import('../views/Logs.vue')
},
{
path: '/settings',
component: () => import('../views/Settings.vue')
}
]
})

137
src/store/catalog.ts Normal file
View File

@@ -0,0 +1,137 @@
import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/core'
import type { AppSettings, DashboardSnapshot, SoftwareListItem, SyncEssentialsResult, UpdateCandidate } from './types'
type EssentialsStatusResponse = [string, SoftwareListItem[]]
export const useCatalogStore = defineStore('catalog', {
state: () => ({
essentials: [] as SoftwareListItem[],
essentialsVersion: '',
updates: [] as UpdateCandidate[],
allSoftware: [] as SoftwareListItem[],
settings: {
repo_url: 'https://karlblue.github.io/winget-repo'
} as AppSettings,
loading: false,
isInitialized: false,
initStatus: '正在检查系统环境...',
lastFetched: 0
}),
actions: {
async ensureEssentialsAvailable() {
const cachedRepo = await invoke('get_essentials') as unknown
if (cachedRepo) return true
try {
await invoke('sync_essentials')
return true
} catch (err) {
console.error('Initial sync failed:', err)
return false
}
},
async initializeApp() {
if (this.isInitialized) return
this.initStatus = '正在加载应用配置...'
try {
this.settings = await invoke('get_settings')
this.initStatus = '正在同步 Winget 模块...'
await invoke('initialize_app')
this.isInitialized = true
} catch {
this.initStatus = '环境配置失败,请检查运行日志'
setTimeout(() => { this.isInitialized = true }, 2000)
}
},
async saveSettings(newSettings: AppSettings) {
await invoke('save_settings', { settings: newSettings })
this.settings = newSettings
},
async syncEssentials() {
this.loading = true
try {
const result = await invoke('sync_essentials') as SyncEssentialsResult
await this.fetchEssentials()
return result
} finally {
this.loading = false
}
},
async fetchEssentials() {
await this.ensureEssentialsAvailable()
let response = await invoke('get_essentials_status') as EssentialsStatusResponse
if (response && Array.isArray(response[1])) {
this.essentialsVersion = response[0] || ''
this.essentials = response[1]
} else {
this.essentials = []
this.essentialsVersion = ''
}
},
async fetchUpdates() {
this.loading = true
try {
const res = await invoke('get_update_candidates')
this.updates = res as UpdateCandidate[]
await this.loadIconsForUpdates()
} finally {
this.loading = false
}
},
async syncDataIfNeeded(force = false) {
const now = Date.now()
const cacheTimeout = 5 * 60 * 1000
if (!force && this.allSoftware.length > 0 && (now - this.lastFetched < cacheTimeout)) {
if (this.essentials.length === 0) await this.fetchEssentials()
return
}
await this.fetchAllData()
},
async fetchAllData() {
this.loading = true
try {
await this.ensureEssentialsAvailable()
const snapshot = await invoke('get_dashboard_snapshot') as DashboardSnapshot
this.applyDashboardSnapshot(snapshot)
await this.loadIconsForUpdates()
this.lastFetched = Date.now()
} finally {
this.loading = false
}
},
applyDashboardSnapshot(snapshot: DashboardSnapshot) {
this.essentialsVersion = snapshot.essentials_version
this.essentials = snapshot.essentials
this.updates = snapshot.updates
this.allSoftware = snapshot.installed_software
},
async loadIconsForUpdates() {
const targets = this.updates.filter(item => !item.icon_url && item.id && item.name)
await Promise.allSettled(targets.map(async (item) => {
const iconUrl = await invoke('get_software_icon', { id: item.id, name: item.name }) as string | null
if (!iconUrl) return
const target = this.updates.find(update => update.id === item.id)
if (target) {
target.icon_url = iconUrl
}
}))
},
findSoftware(id: string) {
return this.essentials.find(s => s.id === id)
|| this.updates.find(s => s.id === id)
|| this.allSoftware.find(s => s.id === id)
}
}
})

View File

@@ -1,196 +1,191 @@
import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { defineStore, storeToRefs } from 'pinia'
import { computed } from 'vue'
const sortByName = (a: any, b: any) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' });
import { useCatalogStore } from './catalog'
import { useTaskRuntimeStore } from './taskRuntime'
export interface LogEntry {
id: string; // 日志唯一标识
timestamp: string;
command: string;
output: string;
status: 'info' | 'success' | 'error';
}
export const useSoftwareStore = defineStore('software', () => {
const catalog = useCatalogStore()
const taskRuntime = useTaskRuntimeStore()
export const useSoftwareStore = defineStore('software', {
state: () => ({
essentials: [] as any[],
updates: [] as any[],
allSoftware: [] as any[],
selectedEssentialIds: [] as string[],
selectedUpdateIds: [] as string[],
logs: [] as LogEntry[],
loading: false,
isInitialized: false,
initStatus: '正在检查系统环境...',
lastFetched: 0
}),
getters: {
mergedEssentials: (state) => {
return state.essentials.map(item => {
const isInstalled = state.allSoftware.some(s => s.id.toLowerCase() === item.id.toLowerCase());
const hasUpdate = state.updates.some(s => s.id.toLowerCase() === item.id.toLowerCase());
let displayStatus = item.status;
let actionLabel = '安装';
if (isInstalled) {
if (hasUpdate) {
actionLabel = '更新';
} else if (displayStatus === 'idle') {
displayStatus = 'installed';
actionLabel = '已安装';
}
}
return { ...item, status: displayStatus, actionLabel };
});
},
sortedUpdates: (state) => [...state.updates].sort(sortByName),
sortedAllSoftware: (state) => [...state.allSoftware].sort(sortByName),
isBusy: (state) => {
const allItems = [...state.essentials, ...state.updates, ...state.allSoftware];
return allItems.some(item => item.status === 'pending' || item.status === 'installing');
}
},
actions: {
async initializeApp() {
if (this.isInitialized) return;
this.initStatus = '正在同步 Winget 模块...';
try {
await invoke('initialize_app');
this.isInitialized = true;
} catch (err) {
this.initStatus = '环境配置失败,请检查运行日志';
setTimeout(() => { this.isInitialized = true; }, 2000);
}
},
toggleSelection(id: string, type: 'essential' | 'update') {
if (this.isBusy) return;
const list = type === 'essential' ? this.selectedEssentialIds : this.selectedUpdateIds;
const index = list.indexOf(id);
if (index === -1) list.push(id);
else list.splice(index, 1);
},
selectAll(type: 'essential' | 'update') {
if (type === 'essential') {
const selectable = this.mergedEssentials.filter(s => s.status !== 'installed');
this.selectedEssentialIds = selectable.map(s => s.id);
} else {
this.selectedUpdateIds = this.updates.map(s => s.id);
}
},
deselectAll(type: 'essential' | 'update') {
if (type === 'essential') this.selectedEssentialIds = [];
else this.selectedUpdateIds = [];
},
invertSelection(type: 'essential' | 'update') {
if (type === 'essential') {
const selectable = this.mergedEssentials.filter(s => s.status !== 'installed').map(s => s.id);
this.selectedEssentialIds = selectable.filter(id => !this.selectedEssentialIds.includes(id));
} else {
const selectable = this.updates.map(s => s.id);
this.selectedUpdateIds = selectable.filter(id => !this.selectedUpdateIds.includes(id));
}
},
const {
essentials,
essentialsVersion,
updates,
allSoftware,
settings,
loading,
isInitialized,
initStatus
} = storeToRefs(catalog)
async fetchEssentials() {
this.essentials = await invoke('get_essentials')
},
async fetchUpdates() {
if (this.isBusy) return;
this.loading = true
try {
const res = await invoke('get_updates')
this.updates = res as any[]
if (this.selectedUpdateIds.length === 0) this.selectAll('update');
} finally {
this.loading = false
}
},
async fetchAll() {
if (this.isBusy) return;
this.loading = true
try {
const res = await invoke('get_all_software')
this.allSoftware = res as any[]
} finally {
this.loading = false
}
},
async syncDataIfNeeded(force = false) {
if (this.isBusy) return;
const now = Date.now();
const CACHE_TIMEOUT = 5 * 60 * 1000;
if (!force && this.allSoftware.length > 0 && (now - this.lastFetched < CACHE_TIMEOUT)) {
if (this.essentials.length === 0) await this.fetchEssentials();
return;
}
await this.fetchAllData();
},
async fetchAllData() {
this.loading = true;
try {
const [essentials, all, updates] = await Promise.all([
invoke('get_essentials'),
invoke('get_all_software'),
invoke('get_updates')
]);
this.essentials = essentials as any[];
this.allSoftware = all as any[];
this.updates = updates as any[];
this.lastFetched = Date.now();
if (this.selectedEssentialIds.length === 0) this.selectAll('essential');
} finally {
this.loading = false;
}
},
async install(id: string) {
const software = this.findSoftware(id)
if (software) software.status = 'pending';
await invoke('install_software', { id })
},
findSoftware(id: string) {
return this.essentials.find(s => s.id === id) ||
this.updates.find(s => s.id === id) ||
this.allSoftware.find(s => s.id === id)
},
initListener() {
if ((window as any).__tauri_listener_init) return;
(window as any).__tauri_listener_init = true;
const {
activeTasks,
selectedEssentialIds,
selectedSpecialIds,
selectedUpdateIds,
logs,
postInstallPrefs
} = storeToRefs(taskRuntime)
listen('install-status', (event: any) => {
const { id, status, progress } = event.payload
const software = this.findSoftware(id)
if (software) {
software.status = status
software.progress = progress
}
if (status === 'success') {
this.lastFetched = 0;
this.selectedEssentialIds = this.selectedEssentialIds.filter(i => i !== id);
this.selectedUpdateIds = this.selectedUpdateIds.filter(i => i !== id);
}
})
const mergeSoftwareItem = (item: typeof essentials.value[number]) => {
const task = activeTasks.value[item.id]
const enablePostInstall = postInstallPrefs.value[item.id] !== false
const baseStatus = item.action_label === '已安装' ? 'installed' : 'idle'
// 日志监听:根据 ID 追加内容
listen('log-event', (event: any) => {
const payload = event.payload as LogEntry;
const existingLog = this.logs.find(l => l.id === payload.id);
if (existingLog) {
// 如果是增量更新
if (payload.output) {
existingLog.output += '\n' + payload.output;
}
if (payload.status !== 'info') {
existingLog.status = payload.status;
}
} else {
// 如果是新日志
this.logs.unshift(payload);
if (this.logs.length > 100) this.logs.pop();
}
})
return {
...item,
version: item.version ?? undefined,
recommended_version: item.recommended_version ?? undefined,
available_version: item.available_version ?? undefined,
icon_url: item.icon_url ?? undefined,
manifest_url: item.manifest_url ?? undefined,
post_install_url: item.post_install_url ?? undefined,
category: item.category ?? 'general',
actionLabel: item.action_label,
targetVersion: item.target_version ?? undefined,
status: task ? task.status : baseStatus,
progress: task ? task.progress : 0,
enablePostInstall
}
}
const mergedEssentials = computed(() => essentials.value.map(mergeSoftwareItem))
const categorizedEssentials = computed(() => {
return mergedEssentials.value.reduce((acc, item) => {
const category = item.category === 'special' ? 'special' : 'general'
acc[category].push(item)
return acc
}, { general: [] as typeof mergedEssentials.value, special: [] as typeof mergedEssentials.value })
})
const generalEssentials = computed(() => categorizedEssentials.value.general)
const specialEssentials = computed(() => categorizedEssentials.value.special)
const sortedUpdates = computed(() => [...updates.value].map(item => {
const task = activeTasks.value[item.id]
const enablePostInstall = postInstallPrefs.value[item.id] !== false
return {
...item,
version: item.version ?? undefined,
recommended_version: item.recommended_version ?? undefined,
available_version: item.available_version ?? undefined,
icon_url: item.icon_url ?? undefined,
manifest_url: item.manifest_url ?? undefined,
post_install_url: item.post_install_url ?? undefined,
actionLabel: item.action_label,
targetVersion: item.target_version ?? undefined,
status: task ? task.status : 'idle',
progress: task ? task.progress : 0,
enablePostInstall
}
}).sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' })))
const isBusy = computed(() => loading.value || taskRuntime.isTaskBusy)
const toggleSelection = (id: string, type: 'essential' | 'special' | 'update') => {
if (isBusy.value) return
taskRuntime.toggleSelection(id, type)
}
const selectAll = (type: 'essential' | 'special' | 'update') => {
if (type === 'essential') {
const selectable = generalEssentials.value.filter(item => item.actionLabel !== '已安装')
taskRuntime.setSelection('essential', selectable.map(item => item.id))
} else if (type === 'special') {
const selectable = specialEssentials.value.filter(item => item.actionLabel !== '已安装')
taskRuntime.setSelection('special', selectable.map(item => item.id))
} else {
taskRuntime.setSelection('update', updates.value.map(item => item.id))
}
}
const deselectAll = (type: 'essential' | 'special' | 'update') => {
taskRuntime.setSelection(type, [])
}
const invertSelection = (type: 'essential' | 'special' | 'update') => {
if (type === 'essential') {
const selectable = generalEssentials.value
.filter(item => item.actionLabel !== '已安装')
.map(item => item.id)
taskRuntime.setSelection(
'essential',
selectable.filter(id => !selectedEssentialIds.value.includes(id))
)
} else if (type === 'special') {
const selectable = specialEssentials.value
.filter(item => item.actionLabel !== '已安装')
.map(item => item.id)
taskRuntime.setSelection(
'special',
selectable.filter(id => !selectedSpecialIds.value.includes(id))
)
} else {
const selectable = updates.value.map(item => item.id)
taskRuntime.setSelection(
'update',
selectable.filter(id => !selectedUpdateIds.value.includes(id))
)
}
}
const fetchUpdates = async () => {
if (isBusy.value) return
await catalog.fetchUpdates()
if (selectedUpdateIds.value.length === 0) selectAll('update')
}
const syncDataIfNeeded = async (force = false) => {
if (isBusy.value) return
await catalog.syncDataIfNeeded(force)
if (selectedEssentialIds.value.length === 0) selectAll('essential')
if (selectedSpecialIds.value.length === 0) selectAll('special')
}
const fetchAllData = async () => {
await catalog.fetchAllData()
if (selectedEssentialIds.value.length === 0) selectAll('essential')
if (selectedSpecialIds.value.length === 0) selectAll('special')
}
return {
essentials,
essentialsVersion,
updates,
allSoftware,
selectedEssentialIds,
selectedSpecialIds,
selectedUpdateIds,
logs,
settings,
activeTasks,
loading,
isInitialized,
initStatus,
mergedEssentials,
generalEssentials,
specialEssentials,
sortedUpdates,
isBusy,
initializeApp: catalog.initializeApp,
saveSettings: catalog.saveSettings,
syncEssentials: catalog.syncEssentials,
fetchEssentials: catalog.fetchEssentials,
fetchUpdates,
syncDataIfNeeded,
fetchAllData,
install: taskRuntime.install,
togglePostInstallPref: taskRuntime.togglePostInstallPref,
startBatch: taskRuntime.startBatch,
scheduleDataRefresh: taskRuntime.scheduleDataRefresh,
findSoftware: catalog.findSoftware,
initListener: taskRuntime.initListener,
toggleSelection,
selectAll,
deselectAll,
invertSelection
}
})

206
src/store/taskRuntime.ts Normal file
View File

@@ -0,0 +1,206 @@
import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { useCatalogStore } from './catalog'
import type { ActiveTaskState, LogEntry, TaskEventPayload, TaskRecord } from './types'
export const useTaskRuntimeStore = defineStore('task-runtime', {
state: () => ({
taskRecords: {} as Record<string, TaskRecord>,
selectedEssentialIds: [] as string[],
selectedSpecialIds: [] as string[],
selectedUpdateIds: [] as string[],
logs: [] as LogEntry[],
refreshTimer: null as ReturnType<typeof setTimeout> | null,
batchQueue: [] as string[],
postInstallPrefs: {} as Record<string, boolean>
}),
getters: {
activeTasks: (state): Record<string, ActiveTaskState> => {
return Object.values(state.taskRecords).reduce<Record<string, ActiveTaskState>>((acc, task) => {
acc[task.softwareId] = {
status: mapTaskToLegacyStatus(task),
progress: task.progress,
targetVersion: task.targetVersion
}
return acc
}, {})
},
isTaskBusy(): boolean {
return Object.values(this.taskRecords).some(task =>
task.status === 'queued' || task.status === 'running'
)
}
},
actions: {
toggleSelection(id: string, type: 'essential' | 'special' | 'update') {
const list = type === 'essential'
? this.selectedEssentialIds
: type === 'special'
? this.selectedSpecialIds
: this.selectedUpdateIds
const index = list.indexOf(id)
if (index === -1) list.push(id)
else list.splice(index, 1)
},
setSelection(type: 'essential' | 'special' | 'update', ids: string[]) {
if (type === 'essential') this.selectedEssentialIds = ids
else if (type === 'special') this.selectedSpecialIds = ids
else this.selectedUpdateIds = ids
},
togglePostInstallPref(id: string) {
const current = this.postInstallPrefs[id] !== false
this.postInstallPrefs[id] = !current
},
startBatch(ids: string[]) {
this.batchQueue = [...ids]
},
async install(id: string, targetVersion?: string) {
const catalog = useCatalogStore()
const updateSoftware = catalog.updates.find(item => item.id.toLowerCase() === id.toLowerCase())
const software = updateSoftware ?? catalog.findSoftware(id)
if (!software) return
const enablePostInstall = updateSoftware ? false : this.postInstallPrefs[id] !== false
try {
await invoke('install_software', {
task: {
id,
version: targetVersion,
use_manifest: software.use_manifest || false,
manifest_url: software.manifest_url || null,
enable_post_install: enablePostInstall
}
})
} catch (err) {
console.error('Invoke install failed:', err)
this.taskRecords[`install-${id}`] = {
taskId: `install-${id}`,
softwareId: id,
taskType: 'install',
status: 'failed',
stage: 'invoke_error',
progress: 0,
targetVersion,
message: String(err)
}
}
},
scheduleDataRefresh() {
const catalog = useCatalogStore()
if (this.refreshTimer) clearTimeout(this.refreshTimer)
this.refreshTimer = setTimeout(async () => {
await catalog.fetchAllData()
Object.keys(this.taskRecords).forEach(taskId => {
const status = this.taskRecords[taskId].status
if (status === 'completed' || status === 'failed') {
delete this.taskRecords[taskId]
}
})
this.refreshTimer = null
}, 2000)
},
initListener() {
if ((window as Window & { __tauri_listener_init?: boolean }).__tauri_listener_init) return
;(window as Window & { __tauri_listener_init?: boolean }).__tauri_listener_init = true
listen('task-event', async (event: { payload: TaskEventPayload }) => {
const catalog = useCatalogStore()
const payload = event.payload
const taskRecord: TaskRecord = {
taskId: payload.task_id,
softwareId: payload.software_id,
taskType: payload.task_type,
status: payload.status,
stage: payload.stage,
progress: payload.progress,
targetVersion: payload.target_version ?? undefined,
message: payload.message ?? undefined
}
this.taskRecords[payload.task_id] = taskRecord
if (payload.status === 'completed' || payload.status === 'failed') {
if (payload.task_type === 'install' && payload.status === 'completed') {
const latestInfo = payload.software_info
if (latestInfo) {
const installedIndex = catalog.allSoftware.findIndex(s => s.id.toLowerCase() === payload.software_id.toLowerCase())
if (installedIndex !== -1) {
catalog.allSoftware[installedIndex] = { ...catalog.allSoftware[installedIndex], ...latestInfo }
} else {
catalog.allSoftware.push(latestInfo)
}
const essentialIndex = catalog.essentials.findIndex(s => s.id.toLowerCase() === payload.software_id.toLowerCase())
if (essentialIndex !== -1) {
catalog.essentials[essentialIndex] = {
...catalog.essentials[essentialIndex],
version: latestInfo.version,
available_version: undefined,
action_label: '已安装',
target_version: undefined
}
}
catalog.updates = catalog.updates.filter(item => item.id.toLowerCase() !== payload.software_id.toLowerCase())
}
this.selectedEssentialIds = this.selectedEssentialIds.filter(item => item !== payload.software_id)
this.selectedSpecialIds = this.selectedSpecialIds.filter(item => item !== payload.software_id)
this.selectedUpdateIds = this.selectedUpdateIds.filter(item => item !== payload.software_id)
setTimeout(() => {
if (this.taskRecords[payload.task_id]?.status === 'completed') {
delete this.taskRecords[payload.task_id]
}
}, 3000)
} else if (payload.task_type !== 'install') {
setTimeout(() => {
if (this.taskRecords[payload.task_id]?.status === payload.status) {
delete this.taskRecords[payload.task_id]
}
}, 1500)
}
if (payload.task_type === 'install') {
const index = this.batchQueue.indexOf(payload.software_id)
if (index !== -1) {
this.batchQueue.splice(index, 1)
if (this.batchQueue.length === 0) {
this.scheduleDataRefresh()
}
}
}
}
})
listen('log-event', (event: { payload: LogEntry }) => {
const payload = event.payload
const existingLog = this.logs.find(item => item.id === payload.id)
if (existingLog) {
if (payload.output) existingLog.output += '\n' + payload.output
if (payload.status !== 'info') existingLog.status = payload.status
} else {
this.logs.unshift(payload)
if (this.logs.length > 100) this.logs.pop()
}
})
}
}
})
function mapTaskToLegacyStatus(task: TaskRecord): string {
if (task.status === 'queued') return 'pending'
if (task.status === 'completed') return 'success'
if (task.status === 'failed') return 'error'
if (task.stage === 'configuring') return 'configuring'
return 'installing'
}

76
src/store/types.ts Normal file
View File

@@ -0,0 +1,76 @@
export interface LogEntry {
id: string
timestamp: string
command: string
output: string
status: 'info' | 'success' | 'error'
}
export interface SyncEssentialsResult {
status: 'updated' | 'cache_used'
message: string
}
export interface DashboardSnapshot {
essentials_version: string
essentials: SoftwareListItem[]
updates: UpdateCandidate[]
installed_software: SoftwareListItem[]
}
export interface SoftwareListItem {
id: string
name: string
description?: string
category?: string | null
version?: string | null
recommended_version?: string | null
available_version?: string | null
icon_url?: string | null
use_manifest?: boolean
manifest_url?: string | null
post_install?: unknown
post_install_url?: string | null
actionLabel?: string
action_label?: string
targetVersion?: string | null
target_version?: string | null
}
export interface UpdateCandidate extends SoftwareListItem {
action_label: string
target_version?: string | null
}
export interface ActiveTaskState {
status: string
progress: number
targetVersion?: string
}
export interface TaskRecord {
taskId: string
softwareId: string
taskType: string
status: string
stage: string
progress: number
targetVersion?: string
message?: string
}
export interface TaskEventPayload {
task_id: string
software_id: string
task_type: string
status: string
stage: string
progress: number
target_version?: string | null
message?: string | null
software_info?: SoftwareListItem | null
}
export interface AppSettings {
repo_url: string
}

View File

@@ -1,190 +0,0 @@
<template>
<main class="content">
<header class="content-header">
<div class="header-left">
<h1>全部软件</h1>
<p class="count"> {{ filteredSoftware.length }} 个项目</p>
</div>
<div class="header-actions">
<button
@click="store.fetchAll"
class="secondary-btn action-btn"
:disabled="store.loading || store.isBusy"
>
<span class="icon" :class="{ 'spinning': store.loading }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
</span>
{{ store.loading ? '正在扫描...' : '重新扫描' }}
</button>
<div class="search-box">
<input
type="text"
v-model="searchQuery"
placeholder="搜索已安装的软件..."
class="search-input"
:disabled="store.isBusy"
/>
</div>
</div>
</header>
<div v-if="store.loading && store.sortedAllSoftware.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在读取已安装软件列表...</p>
</div>
<div v-else class="software-list">
<SoftwareCard
v-for="item in filteredSoftware"
:key="item.id"
:software="item"
/>
</div>
</main>
</template>
<script setup lang="ts">
import SoftwareCard from '../components/SoftwareCard.vue';
import { useSoftwareStore } from '../store/software';
import { onMounted, ref, computed } from 'vue';
const store = useSoftwareStore();
const searchQuery = ref('');
onMounted(() => {
if (store.allSoftware.length === 0) {
store.fetchAll();
}
});
const filteredSoftware = computed(() => {
const data = store.sortedAllSoftware;
if (!searchQuery.value) return data;
const q = searchQuery.value.toLowerCase();
return data.filter(s =>
s.name.toLowerCase().includes(q) || s.id.toLowerCase().includes(q)
);
});
</script>
<style scoped>
.content {
flex: 1;
padding: 40px 60px;
overflow-y: auto;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 30px;
}
.header-left h1 {
font-size: 32px;
font-weight: 700;
}
.header-left .count {
font-size: 14px;
color: var(--text-sec);
margin-top: 4px;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 10px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid var(--border-color);
background-color: var(--bg-light);
color: var(--text-main);
white-space: nowrap;
}
.action-btn:hover:not(:disabled) {
background-color: white;
border-color: var(--primary-color);
color: var(--primary-color);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon {
display: flex;
align-items: center;
}
.icon.spinning {
animation: spin 1s linear infinite;
}
.search-input {
width: 240px;
padding: 8px 16px;
border-radius: 10px;
border: 1px solid var(--border-color);
background-color: white;
font-size: 13px;
outline: none;
transition: all 0.2s ease;
}
.search-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
}
.search-input:disabled {
background-color: #F2F2F7;
cursor: not-allowed;
}
.software-list {
display: flex;
flex-direction: column;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: var(--text-sec);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(0, 122, 255, 0.1);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -1,65 +1,74 @@
<template>
<main class="content">
<header class="content-header">
<div class="header-left">
<h1>装机必备</h1>
</div>
<div class="header-actions">
<button
@click="store.syncDataIfNeeded(true)"
class="secondary-btn action-btn"
:disabled="store.loading || store.isBusy"
>
<span class="icon" :class="{ 'spinning': store.loading }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
</span>
{{ store.loading ? '正在同步...' : '同步状态' }}
</button>
<button
@click="installSelected"
class="primary-btn action-btn"
:disabled="store.loading || store.isBusy || store.selectedEssentialIds.length === 0"
>
安装/更新所选 ({{ store.selectedEssentialIds.length }})
</button>
</div>
</header>
<!-- 固定标头区域 -->
<div class="sticky-header">
<header class="content-header">
<div class="header-left">
<h1>装机常用</h1>
<span v-if="store.essentialsVersion" class="version-badge">版本: {{ store.essentialsVersion }}</span>
</div>
<div class="header-actions">
<button
@click="store.syncDataIfNeeded(true)"
class="secondary-btn action-btn"
:disabled="store.loading || store.isBusy"
>
<span class="icon" :class="{ 'spinning': store.loading }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
</span>
{{ store.loading ? '正在刷新...' : '刷新状态' }}
</button>
<button
@click="installSelected"
class="primary-btn action-btn"
:disabled="store.loading || store.isBusy || store.selectedEssentialIds.length === 0"
>
安装所选 ({{ store.selectedEssentialIds.length }})
</button>
</div>
</header>
<!-- 批量选择控制栏 -->
<div class="selection-toolbar" v-if="selectableItems.length > 0">
<div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedEssentialIds.length }} / {{ selectableItems.length }} </span>
</div>
<div class="toolbar-actions">
<button @click="store.selectAll('essential')" class="text-btn" :disabled="store.isBusy">全选</button>
<div class="divider"></div>
<button @click="store.deselectAll('essential')" class="text-btn" :disabled="store.isBusy">取消</button>
<div class="divider"></div>
<button @click="store.invertSelection('essential')" class="text-btn" :disabled="store.isBusy">反选</button>
<!-- 批量选择控制栏 -->
<div class="selection-toolbar" v-if="selectableItems.length > 0">
<div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedEssentialIds.length }} / {{ selectableItems.length }} </span>
</div>
<div class="toolbar-actions">
<button @click="store.selectAll('essential')" class="text-btn" :disabled="store.isBusy">全选</button>
<div class="divider"></div>
<button @click="store.deselectAll('essential')" class="text-btn" :disabled="store.isBusy">取消</button>
<div class="divider"></div>
<button @click="store.invertSelection('essential')" class="text-btn" :disabled="store.isBusy">反选</button>
</div>
</div>
</div>
<div v-if="store.loading && store.mergedEssentials.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在读取必备软件列表...</p>
</div>
<!-- 可滚动内容区域 -->
<div class="scroll-content">
<div v-if="store.loading && store.generalEssentials.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在读取软件列表...</p>
</div>
<div v-else class="software-list">
<SoftwareCard
v-for="item in store.mergedEssentials"
:key="item.id"
:software="item"
:action-label="item.actionLabel"
:selectable="true"
:is-selected="store.selectedEssentialIds.includes(item.id)"
@install="store.install"
@toggle-select="id => store.toggleSelection(id, 'essential')"
/>
<div v-else class="software-list">
<SoftwareCard
v-for="item in store.generalEssentials"
:key="item.id"
:software="item"
:action-label="item.actionLabel"
:selectable="true"
:is-selected="store.selectedEssentialIds.includes(item.id)"
:disabled="store.isBusy"
@install="store.install"
@toggle-select="store.toggleSelection($event, 'essential')"
@toggle-post-install="store.togglePostInstallPref"
/>
</div>
</div>
</main>
</template>
@@ -72,12 +81,17 @@ import { onMounted, computed } from 'vue';
const store = useSoftwareStore();
const selectableItems = computed(() => {
return store.mergedEssentials.filter(s => s.status !== 'installed');
return store.generalEssentials.filter(s => s.status !== 'installed');
});
const installSelected = () => {
store.selectedEssentialIds.forEach(id => {
store.install(id);
const ids = [...store.selectedEssentialIds];
store.startBatch(ids);
ids.forEach(id => {
const item = store.generalEssentials.find(s => s.id === id);
if (item) {
store.install(id, item.targetVersion);
}
});
};
@@ -90,15 +104,45 @@ onMounted(() => {
<style scoped>
.content {
flex: 1;
padding: 40px 60px;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden; /* 关键:禁止最外层滚动 */
}
.sticky-header {
padding: 24px 60px 8px 60px;
background-color: #F5F5F7; /* 与 App.vue 背景色保持一致 */
z-index: 10;
flex-shrink: 0;
}
.scroll-content {
flex: 1;
overflow-y: auto;
padding: 8px 60px 32px 60px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.version-badge {
font-size: 13px;
font-weight: 500;
color: var(--text-sec);
background-color: rgba(0, 0, 0, 0.05);
padding: 4px 10px;
border-radius: 20px;
}
.content-header h1 {
@@ -114,16 +158,16 @@ onMounted(() => {
align-items: center;
}
/* 批量选择工具栏 */
.selection-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
background-color: rgba(0, 0, 0, 0.02);
background-color: white; /* 这里的工具栏背景设为白色更清晰 */
border-radius: 12px;
margin-bottom: 20px;
margin-bottom: 10px;
border: 1px solid rgba(0, 0, 0, 0.03);
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
}
.selection-count {

293
src/views/OtherSoftware.vue Normal file
View File

@@ -0,0 +1,293 @@
<template>
<main class="content">
<div class="sticky-header">
<header class="content-header">
<div class="header-left">
<h1>网传有关</h1>
<span v-if="store.essentialsVersion" class="version-badge">版本: {{ store.essentialsVersion }}</span>
</div>
<div class="header-actions">
<button
@click="store.syncDataIfNeeded(true)"
class="secondary-btn action-btn"
:disabled="store.loading || store.isBusy"
>
<span class="icon" :class="{ spinning: store.loading }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
</span>
{{ store.loading ? '正在刷新...' : '刷新状态' }}
</button>
<button
@click="installSelected"
class="primary-btn action-btn"
:disabled="store.loading || store.isBusy || store.selectedSpecialIds.length === 0"
>
安装所选 ({{ store.selectedSpecialIds.length }})
</button>
</div>
</header>
<div class="selection-toolbar" v-if="selectableItems.length > 0">
<div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedSpecialIds.length }} / {{ selectableItems.length }} </span>
</div>
<div class="toolbar-actions">
<button @click="store.selectAll('special')" class="text-btn" :disabled="store.isBusy">全选</button>
<div class="divider"></div>
<button @click="store.deselectAll('special')" class="text-btn" :disabled="store.isBusy">取消</button>
<div class="divider"></div>
<button @click="store.invertSelection('special')" class="text-btn" :disabled="store.isBusy">反选</button>
</div>
</div>
</div>
<div class="scroll-content">
<div v-if="store.loading && store.specialEssentials.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在读取软件列表...</p>
</div>
<div v-else-if="store.specialEssentials.length === 0" class="empty-state">
<p>当前清单没有标记为网传有关的项目</p>
</div>
<div v-else class="software-list">
<SoftwareCard
v-for="item in store.specialEssentials"
:key="item.id"
:software="item"
:action-label="item.actionLabel"
:selectable="true"
:is-selected="store.selectedSpecialIds.includes(item.id)"
:disabled="store.isBusy"
@install="store.install"
@toggle-select="store.toggleSelection($event, 'special')"
@toggle-post-install="store.togglePostInstallPref"
/>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import SoftwareCard from '../components/SoftwareCard.vue'
import { useSoftwareStore } from '../store/software'
const store = useSoftwareStore()
const selectableItems = computed(() => {
return store.specialEssentials.filter(item => item.status !== 'installed')
})
const installSelected = () => {
const ids = [...store.selectedSpecialIds]
store.startBatch(ids)
ids.forEach(id => {
const item = store.specialEssentials.find(software => software.id === id)
if (item) {
store.install(id, item.targetVersion)
}
})
}
onMounted(() => {
store.syncDataIfNeeded()
store.initListener()
})
</script>
<style scoped>
.content {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.sticky-header {
padding: 24px 60px 8px 60px;
background-color: #F5F5F7;
z-index: 10;
flex-shrink: 0;
}
.scroll-content {
flex: 1;
overflow-y: auto;
padding: 8px 60px 32px 60px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.version-badge {
font-size: 13px;
font-weight: 500;
color: var(--text-sec);
background-color: rgba(0, 0, 0, 0.05);
padding: 4px 10px;
border-radius: 20px;
}
.content-header h1 {
font-size: 32px;
font-weight: 700;
letter-spacing: -0.5px;
color: var(--text-main);
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
}
.selection-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
background-color: white;
border-radius: 12px;
margin-bottom: 10px;
border: 1px solid rgba(0, 0, 0, 0.03);
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
}
.selection-count {
font-size: 13px;
color: var(--text-sec);
font-weight: 500;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.text-btn {
background: none;
border: none;
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: all 0.2s ease;
}
.text-btn:hover:not(:disabled) {
background-color: rgba(0, 122, 255, 0.08);
}
.text-btn:disabled {
color: #AEAEB2;
cursor: not-allowed;
}
.divider {
width: 1px;
height: 12px;
background-color: var(--border-color);
}
.action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 12px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
border: none;
white-space: nowrap;
}
.primary-btn {
background-color: var(--primary-color);
color: white;
box-shadow: var(--btn-shadow);
}
.primary-btn:hover:not(:disabled) {
background-color: var(--primary-hover);
}
.secondary-btn {
background-color: var(--bg-light);
color: var(--text-main);
border: 1px solid var(--border-color);
}
.secondary-btn:hover:not(:disabled) {
background-color: white;
border-color: var(--primary-color);
color: var(--primary-color);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
.icon {
display: flex;
align-items: center;
}
.icon.spinning {
animation: spin 1s linear infinite;
}
.software-list {
display: flex;
flex-direction: column;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: var(--text-sec);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(0, 122, 255, 0.1);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

299
src/views/Settings.vue Normal file
View File

@@ -0,0 +1,299 @@
<template>
<div class="settings-view">
<header class="view-header">
<h1>软件设置</h1>
<p class="subtitle">管理仓库地址与应用配置</p>
</header>
<div class="settings-content">
<section class="settings-section">
<h3 class="section-title">仓库配置</h3>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<label>仓库地址</label>
<p>应用将从该地址同步软件清单</p>
</div>
<div class="setting-action">
<input
type="text"
v-model="tempRepoUrl"
placeholder="https://..."
class="settings-input"
/>
</div>
</div>
<div class="setting-footer">
<button
@click="handleSave"
class="btn-primary"
:disabled="isSaving || !isValidUrl"
>
{{ isSaving ? '正在保存...' : '保存配置' }}
</button>
<button
@click="handleSync"
class="btn-secondary"
:disabled="store.loading"
>
<span v-if="store.loading" class="spinner"></span>
{{ store.loading ? '正在同步...' : '立即同步清单' }}
</button>
</div>
</div>
</section>
<section class="settings-section">
<h3 class="section-title">关于</h3>
<div class="settings-card about-card">
<p>Windows 软件管理 v{{ version }}</p>
<p class="hint">基于 WinGet 构建的 Windows 软件管理工具</p>
</div>
</section>
</div>
<!-- 通知提示 -->
<transition name="toast">
<div v-if="toast" class="toast" :class="toast.type">
{{ toast.message }}
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useSoftwareStore } from '../store/software'
import { version } from "../../package.json";
const store = useSoftwareStore()
const tempRepoUrl = ref('')
const isSaving = ref(false)
const toast = ref<{ message: string, type: 'success' | 'error' } | null>(null)
const isValidUrl = computed(() => {
try {
new URL(tempRepoUrl.value)
return true
} catch {
return false
}
})
onMounted(() => {
tempRepoUrl.value = store.settings.repo_url
})
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
toast.value = { message, type }
setTimeout(() => {
toast.value = null
}, 3000)
}
const handleSave = async () => {
if (!isValidUrl.value) return
isSaving.value = true
try {
await store.saveSettings({ repo_url: tempRepoUrl.value })
showToast('配置已保存')
} catch (err) {
showToast('保存失败: ' + err, 'error')
} finally {
isSaving.value = false
}
}
const handleSync = async () => {
try {
const result = await store.syncEssentials()
showToast(result.message, result.status === 'updated' ? 'success' : 'error')
} catch (err) {
showToast('同步失败,请检查网络或地址', 'error')
}
}
</script>
<style scoped>
.settings-view {
padding: 60px;
height: 100%;
overflow-y: auto;
background-color: var(--bg-light);
}
.view-header {
margin-bottom: 40px;
}
.view-header h1 {
font-size: 34px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 8px;
}
.subtitle {
color: var(--text-sec);
font-size: 17px;
}
.settings-content {
max-width: 800px;
}
.settings-section {
margin-bottom: 40px;
}
.section-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-sec);
margin-bottom: 12px;
padding-left: 4px;
}
.settings-card {
background: white;
border-radius: var(--radius-card);
padding: 24px;
box-shadow: var(--card-shadow);
border: 1px solid var(--border-color);
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.setting-info label {
display: block;
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
}
.setting-info p {
font-size: 14px;
color: var(--text-sec);
}
.settings-input {
width: 320px;
padding: 12px 16px;
border-radius: 10px;
border: 1px solid var(--border-color);
font-size: 14px;
transition: all 0.2s ease;
background-color: #f5f5f7;
}
.settings-input:focus {
outline: none;
border-color: var(--primary-color);
background-color: white;
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.1);
}
.setting-footer {
display: flex;
gap: 12px;
border-top: 1px solid var(--border-color);
padding-top: 24px;
}
.btn-primary, .btn-secondary {
padding: 10px 20px;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
border: none;
display: flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-hover);
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background-color: #f5f5f7;
color: var(--text-main);
}
.btn-secondary:hover:not(:disabled) {
background-color: #e5e5e7;
}
.about-card p {
margin-bottom: 4px;
font-weight: 600;
}
.hint {
font-size: 14px;
color: var(--text-sec);
font-weight: 400 !important;
}
/* Toast */
.toast {
position: fixed;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
border-radius: 12px;
background: #323232;
color: white;
font-size: 14px;
font-weight: 500;
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
z-index: 1000;
}
.toast.error {
background: #ff3b30;
}
.toast-enter-active, .toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from, .toast-leave-to {
opacity: 0;
transform: translate(-50%, 20px);
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(0,0,0,0.1);
border-top-color: var(--text-main);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -1,70 +1,78 @@
<template>
<main class="content">
<header class="content-header">
<div class="header-left">
<h1>软件更新</h1>
</div>
<div class="header-actions">
<button
@click="store.fetchUpdates"
class="secondary-btn action-btn"
:disabled="store.loading || store.isBusy"
>
<span class="icon" :class="{ 'spinning': store.loading }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
</span>
{{ store.loading ? '正在检查...' : '检查更新' }}
</button>
<button
@click="updateSelected"
class="primary-btn action-btn"
:disabled="store.selectedUpdateIds.length === 0 || store.loading || store.isBusy"
>
更新所选 ({{ store.selectedUpdateIds.length }})
</button>
</div>
</header>
<!-- 固定标头区域 -->
<div class="sticky-header">
<header class="content-header">
<div class="header-left">
<h1>软件更新</h1>
</div>
<div class="header-actions">
<button
@click="store.fetchUpdates"
class="secondary-btn action-btn"
:disabled="store.loading || store.isBusy"
>
<span class="icon" :class="{ 'spinning': store.loading }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
</span>
{{ store.loading ? '正在检查...' : '检查更新' }}
</button>
<button
@click="updateSelected"
class="primary-btn action-btn"
:disabled="store.selectedUpdateIds.length === 0 || store.loading || store.isBusy"
>
更新所选 ({{ store.selectedUpdateIds.length }})
</button>
</div>
</header>
<!-- 批量选择控制栏 -->
<div class="selection-toolbar" v-if="store.sortedUpdates.length > 0">
<div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedUpdateIds.length }} / {{ store.sortedUpdates.length }} </span>
</div>
<div class="toolbar-actions">
<button @click="store.selectAll('update')" class="text-btn" :disabled="store.isBusy">全选</button>
<div class="divider"></div>
<button @click="store.deselectAll('update')" class="text-btn" :disabled="store.isBusy">取消</button>
<div class="divider"></div>
<button @click="store.invertSelection('update')" class="text-btn" :disabled="store.isBusy">反选</button>
<!-- 批量选择控制栏 -->
<div class="selection-toolbar" v-if="store.sortedUpdates.length > 0">
<div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedUpdateIds.length }} / {{ store.sortedUpdates.length }} </span>
</div>
<div class="toolbar-actions">
<button @click="store.selectAll('update')" class="text-btn" :disabled="store.isBusy">全选</button>
<div class="divider"></div>
<button @click="store.deselectAll('update')" class="text-btn" :disabled="store.isBusy">取消</button>
<div class="divider"></div>
<button @click="store.invertSelection('update')" class="text-btn" :disabled="store.isBusy">反选</button>
</div>
</div>
</div>
<div v-if="store.loading && store.updates.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在使用 Winget 扫描可用的更新...</p>
</div>
<!-- 可滚动内容区域 -->
<div class="scroll-content">
<div v-if="store.loading && store.updates.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在使用 Winget 扫描可用的更新...</p>
</div>
<div v-else-if="store.updates.length === 0 && !store.loading" class="empty-state">
<span class="empty-icon"></span>
<p>所有软件已是最新版本</p>
</div>
<div v-else-if="store.updates.length === 0 && !store.loading" class="empty-state">
<span class="empty-icon"></span>
<p>所有软件已是最新版本</p>
</div>
<div v-else class="software-list">
<SoftwareCard
v-for="item in store.sortedUpdates"
:key="item.id"
:software="item"
action-label="更新"
:selectable="true"
:is-selected="store.selectedUpdateIds.includes(item.id)"
@install="store.install"
@toggle-select="id => store.toggleSelection(id, 'update')"
/>
<div v-else class="software-list">
<SoftwareCard
v-for="item in store.sortedUpdates"
:key="item.id"
:software="item"
:action-label="item.actionLabel"
:selectable="true"
:is-selected="store.selectedUpdateIds.includes(item.id)"
:disabled="store.isBusy"
@install="store.install"
@toggle-select="store.toggleSelection($event, 'update')"
@toggle-post-install="store.togglePostInstallPref"
/>
</div>
</div>
</main>
</template>
@@ -77,8 +85,13 @@ import { onMounted } from 'vue';
const store = useSoftwareStore();
const updateSelected = () => {
store.selectedUpdateIds.forEach(id => {
store.install(id);
const ids = [...store.selectedUpdateIds];
store.startBatch(ids);
ids.forEach(id => {
const item = store.sortedUpdates.find(s => s.id === id);
if (item && item.targetVersion) {
store.install(id, item.targetVersion);
}
});
};
@@ -92,15 +105,30 @@ onMounted(() => {
<style scoped>
.content {
flex: 1;
padding: 40px 60px;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden; /* 关键:禁止最外层滚动 */
}
.sticky-header {
padding: 24px 60px 8px 60px;
background-color: #F5F5F7;
z-index: 10;
flex-shrink: 0;
}
.scroll-content {
flex: 1;
overflow-y: auto;
padding: 8px 60px 32px 60px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
margin-bottom: 16px;
}
.content-header h1 {
@@ -115,16 +143,16 @@ onMounted(() => {
align-items: center;
}
/* 批量选择工具栏 */
.selection-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
background-color: rgba(0, 0, 0, 0.02);
background-color: white;
border-radius: 12px;
margin-bottom: 20px;
margin-bottom: 10px;
border: 1px solid rgba(0, 0, 0, 0.03);
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
}
.selection-count {