diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index 643cb9a5..f3903fbe 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -2,11 +2,11 @@
## 文件总览
-| 文件 | 触发时机 | 职责 |
-|---|---|---|
-| `pre-publish-branch-ci-check.yml` | push / PR → dev · release · bugfix | 构建 + 单元测试 |
-| `branch-protection.yml` | PR → main | 校验源分支合法性 |
-| `auto-version-release.yml` | PR 合并到 main | 版本递增 · 打包 · 冒烟测试 · 发布 |
+| 文件 | 触发时机 | 职责 |
+| ----------------------------------- | ------------------------------------- | ------------------------------------ |
+| `pre-publish-branch-ci-check.yml` | push / PR → dev · release · bugfix | 构建 + 单元测试 |
+| `branch-protection.yml` | PR → main | 校验源分支合法性 |
+| `auto-version-release.yml` | PR 合并到 main | 版本递增 · 打包 · 冒烟测试 · 发布 |
---
@@ -35,6 +35,7 @@
对预发布分支进行持续集成,确保代码在合并到 main 之前质量达标。
**执行内容:**
+
1. 构建整个解决方案(Release 配置)
2. 并行运行 4 项单元测试:Core · UI · ML · Shader
3. 汇总测试结果,写入 commit status `ci-check/pre-publish-tests`
@@ -50,18 +51,27 @@
只允许 `dev`、`release`、`bugfix` 三个分支向 main 发起 PR,其他分支一律拦截。
-| 源分支 | 版本变化 | 说明 |
-|---|---|---|
-| `release` | Minor +1 | 0.4.x.x → 0.5.0.0 |
-| `dev` | Build +1 | 0.5.2.x → 0.5.3.0 |
-| `bugfix` | Revision +1 | 0.5.3.0 → 0.5.3.1 |
+| 源分支 | 版本变化 | 说明 |
+| ----------- | ----------- | ------------------ |
+| `release` | Minor +1 | 0.4.x.x → 0.5.0.0 |
+| `dev` | Build +1 | 0.5.2.x → 0.5.3.0 |
+| `bugfix` | Revision +1 | 0.5.3.0 → 0.5.3.1 |
---
## 工作流三:自动发布
> **文件:** `auto-version-release.yml`
-> **触发:** PR 成功合并到 `main`
+> **触发:** PR 成功合并到 `main`(标题不含 `[skip ci]`)
+
+### 更新类型判定
+
+工作流根据 PR 标签自动区分两种更新类型:
+
+| 类型 | 判定条件 | 版本号变化 |
+| ------------------------ | --------------------------- | ------------------------------------------------------------ |
+| **常规安装式更新** | 无 `update-plugin-*` 标签 | 按分支递增(feature→minor, daily→build, bugfix→revision) |
+| **重启式更新** | 含 `update-plugin-*` 标签 | 版本号不变,打包插件 |
### 执行流程
@@ -75,15 +85,23 @@ build
package
│ InnoSetup 编译安装包,上传为 Artifact(保留 30 天)
▼
-smoke_test ← 失败则终止并回滚
- │ 静默安装 → 启动主程序 → 等待 UI 被拉起(最多 20s)
+smoke_test
+ │ 静默安装 → 启动主程序 → 等待 UI 被拉起(最多 10s)
│ 若未自动拉起:兜底手动启动 UI
│ 再次尝试启动 UI → 断言只有 1 个 UI 进程(单例验证)
+ │ 验证 UI 无法在无主进程时独立运行(MessageBox 守卫)
+ ▼
+ ┌─────────────────────────────────────────────────────────────┐
+ │ 以下阶段根据更新类型和测试结果有条件执行(见下表) │
+ └─────────────────────────────────────────────────────────────┘
+ ▼
+package_restart_update (仅重启式更新)
+ │ 打包插件 zip + manifest,上传为 Artifact
▼
-bump
+bump (仅常规安装式更新)
│ 更新 csproj / InnoSetup / README badge 版本号并提交到 main
▼
-sync
+sync (仅常规安装式更新)
│ 将版本文件同步到 release / dev / bugfix 分支
▼
summary
@@ -93,11 +111,34 @@ release(草稿)
创建 Draft GitHub Release
- Tag 和标题:v{版本号}
- 正文:本次 PR 的描述
- - 附件:安装包 exe
+ - 附件:安装包 exe(+ 插件包,如有)
- 状态:草稿,不公开,等待人工验证后发布
```
-> 任意步骤失败时,`rollback` job 自动触发,对 main 及三个预发布分支执行 `git revert`。
+### 各阶段执行条件
+
+| 阶段 | 常规安装式更新 | 重启式更新 |
+| -------------------------- | ------------------------ | ------------------------- |
+| `prepare` | ✅ 始终执行 | ✅ 始终执行 |
+| `build` | ✅ 始终执行 | ✅ 始终执行 |
+| `package` | ✅ 始终执行 | ✅ 始终执行 |
+| `smoke_test` | ✅ 始终执行 | ✅ 始终执行 |
+| `package_restart_update` | ❌ 始终跳过 | ✅ smoke_test 通过时执行 |
+| `bump` | ✅ smoke_test 通过时执行 | ❌ 始终跳过 |
+| `sync` | ✅ bump 成功时执行 | ❌ 始终跳过 |
+| `summary` | ✅ 全部成功时执行 | ✅ 全部成功时执行 |
+| `release` | ✅ summary 成功时执行 | ✅ summary 成功时执行 |
+| `rollback` | ✅ sync 阶段失败时触发 | ❌ 始终跳过(无版本同步) |
+
+### 失败处理
+
+| 失败阶段 | 常规安装式更新 | 重启式更新 |
+| -------------------------- | --------------------------------------------------------------- | -------------------------------- |
+| `build` / `package` | 流程终止,无后续操作 | 流程终止,无后续操作 |
+| `smoke_test` | 流程终止,不触发版本同步和 draft | 流程终止,不触发插件打包和 draft |
+| `bump` | 流程终止,不触发 sync | — |
+| `sync` | **触发 rollback**,回滚 main + 三个预发布分支到 CI 前 SHA | — |
+| `package_restart_update` | — | 流程终止,不触发 draft |
---
diff --git a/.github/workflows/auto-version-release.yml b/.github/workflows/auto-version-release.yml
index 5d50124c..be50323b 100644
--- a/.github/workflows/auto-version-release.yml
+++ b/.github/workflows/auto-version-release.yml
@@ -22,7 +22,7 @@ jobs:
# Job 1: 准备阶段 - 记录 SHA、确定分支、计算新版本号
# ─────────────────────────────────────────────────────────────
prepare:
- name: Prepare - Calculate Version & Record SHAs
+ name: Prepare - Version & Metadata
runs-on: windows-latest
if: |
github.event.pull_request.merged == true &&
@@ -40,6 +40,12 @@ jobs:
release_sha: ${{ steps.shas.outputs.release_sha }}
pr_title: ${{ steps.pr_info.outputs.pr_title }}
pr_body: ${{ steps.pr_info.outputs.pr_body }}
+ has_plugin_update: ${{ steps.version.outputs.has_plugin_update }}
+ update_plugins: ${{ steps.version.outputs.update_plugins }}
+ build_number: ${{ steps.version.outputs.build_number }}
+ release_tag: ${{ steps.version.outputs.release_tag }}
+ previous_tag: ${{ steps.prev_tag.outputs.previous_tag }}
+ app_comp_manifest: ${{ steps.app_info.outputs.app_comp_manifest }}
steps:
- name: Checkout with full history
@@ -50,27 +56,28 @@ jobs:
- name: Record original branch SHAs
id: shas
- shell: powershell
+ shell: pwsh
run: |
$mainSha = git rev-parse HEAD
- Write-Output "main_sha=$mainSha" >> $env:GITHUB_OUTPUT
+ $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "main_sha=$mainSha`n", $utf8NoBom)
Write-Host "main SHA: $mainSha"
$devSha = git rev-parse origin/dev
- Write-Output "dev_sha=$devSha" >> $env:GITHUB_OUTPUT
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "dev_sha=$devSha`n", $utf8NoBom)
Write-Host "dev SHA: $devSha"
$bugfixSha = git rev-parse origin/bugfix
- Write-Output "bugfix_sha=$bugfixSha" >> $env:GITHUB_OUTPUT
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "bugfix_sha=$bugfixSha`n", $utf8NoBom)
Write-Host "bugfix SHA: $bugfixSha"
$releaseSha = git rev-parse origin/release
- Write-Output "release_sha=$releaseSha" >> $env:GITHUB_OUTPUT
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "release_sha=$releaseSha`n", $utf8NoBom)
Write-Host "release SHA: $releaseSha"
- name: Determine source branch and version increment
id: branch_info
- shell: powershell
+ shell: pwsh
env:
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
@@ -90,12 +97,30 @@ jobs:
Write-Host "Unknown source branch: $sourceBranch - defaulting to patch"
}
- Write-Output "source_branch=$sourceBranch" >> $env:GITHUB_OUTPUT
- Write-Output "increment_type=$incrementType" >> $env:GITHUB_OUTPUT
+ $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "source_branch=$sourceBranch`n", $utf8NoBom)
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "increment_type=$incrementType`n", $utf8NoBom)
+
+ - name: Get previous tag
+ id: prev_tag
+ shell: pwsh
+ run: |
+ # Use for-each-ref sorted by creatordate — not limited to ancestor chain
+ $prevTag = git for-each-ref --sort=-creatordate --count=1 --format="%(refname:short)" refs/tags/
+ if (-not $prevTag) {
+ Write-Host "No previous tag found, this is the first release"
+ $prevTag = ""
+ }
+ Write-Host "Previous tag: $prevTag"
+ $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "previous_tag=$prevTag`n", $utf8NoBom)
- - name: Calculate new version
+ - name: Prepare release metadata
id: version
- shell: powershell
+ shell: pwsh
+ env:
+ PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
+ PREV_TAG: ${{ steps.prev_tag.outputs.previous_tag }}
run: |
$csprojPath = "src/VirtualPaper/VirtualPaper.csproj"
$content = Get-Content $csprojPath -Raw
@@ -112,32 +137,103 @@ jobs:
$revision = [int]$versionMatch.Groups[4].Value
$currentVersion = "$major.$minor.$build.$revision"
+ $currentVersionText = "${major}_${minor}_${build}_${revision}"
+
+ # ── Detect plugin update labels ──────────────────────────────
+ $labels = "$env:PR_LABELS" -split ',' | ForEach-Object { $_.Trim() }
+ $isPluginUpdate = $false
+ $plugins = @()
- $incrementType = "${{ steps.branch_info.outputs.increment_type }}"
- if ($incrementType -eq "feature") {
- $minor = $minor + 1 # 第2位 +1
- $build = 0 # 第3位 清零
- $revision = 0 # 第4位 清零
- } elseif ($incrementType -eq "minor") {
- $build = $build + 1 # 第3位 +1
- $revision = 0 # 第4位 清零
+ if ($labels -contains "update-plugins") {
+ $isPluginUpdate = $true
+ $plugins = @("UI", "PlayerWeb", "ScrSaver", "ML", "Shaders")
} else {
- $revision = $revision + 1 # 第4位 +1
+ if ($labels -contains "update-plugin-ui") { $isPluginUpdate = $true; $plugins += "UI" }
+ if ($labels -contains "update-plugin-playerweb") { $isPluginUpdate = $true; $plugins += "PlayerWeb" }
+ if ($labels -contains "update-plugin-scrsaver") { $isPluginUpdate = $true; $plugins += "ScrSaver" }
+ if ($labels -contains "update-plugin-ml") { $isPluginUpdate = $true; $plugins += "ML" }
+ if ($labels -contains "update-plugin-shaders") { $isPluginUpdate = $true; $plugins += "Shaders" }
+ }
+
+ # ── Calculate version ────────────────────────────────────────
+ if ($isPluginUpdate) {
+ Write-Host "Plugin update ($($plugins -join ', ')) - version unchanged: $currentVersion"
+ } else {
+ Write-Host "Installer-only release - no plugin updates"
+ $incrementType = "${{ steps.branch_info.outputs.increment_type }}"
+ if ($incrementType -eq "feature") {
+ $minor = $minor + 1 # 第2位 +1
+ $build = 0 # 第3位 清零
+ $revision = 0 # 第4位 清零
+ } elseif ($incrementType -eq "minor") {
+ $build = $build + 1 # 第3位 +1
+ $revision = 0 # 第4位 清零
+ } else {
+ $revision = $revision + 1 # 第4位 +1
+ }
}
$newVersion = "$major.$minor.$build.$revision"
# 使用下划线分隔,避免各组件 >= 10 时产生歧义(如 0.10.0.0 → 01000 与 0.1.0.0.0 冲突)
$newVersionText = "${major}_${minor}_${build}_${revision}"
-
Write-Host "Version: $currentVersion -> $newVersion"
- Write-Output "current_version=$currentVersion" >> $env:GITHUB_OUTPUT
- Write-Output "new_version=$newVersion" >> $env:GITHUB_OUTPUT
- Write-Output "new_version_text=$newVersionText" >> $env:GITHUB_OUTPUT
+ # ── Generate build number: YYMM + R + xx (每月重置) ─────────
+ # 从 previous tag 解码上一次 build number,同月 +1,跨月重置
+ $now = Get-Date
+ $yearMonth = $now.ToString("yyMM")
+
+ $prevBuildNum = ""
+ $prevTag = "$env:PREV_TAG"
+ if ($prevTag -match 'plugin([A-Z]+)$') {
+ $encoded = $Matches[1]
+ $prevBuildNum = ($encoded.ToCharArray() | ForEach-Object {
+ if ($_ -ge 'A' -and $_ -le 'J') { [char]([int][char]'0' + ([int]$_ - [int][char]'A')) } else { $_ }
+ }) -join ''
+ }
+
+ $buildSeq = 1
+ if ($prevBuildNum -match "^(\d{4})R(\d{2})$") {
+ $prevYM = $Matches[1]
+ $prevSeq = [int]$Matches[2]
+ if ($prevYM -eq $yearMonth) {
+ $buildSeq = $prevSeq + 1
+ }
+ }
+
+ $buildNumber = "${yearMonth}R{0:D2}" -f $buildSeq
+ Write-Host "Build number: $buildNumber (prev: $prevBuildNum, seq: $buildSeq)"
+
+ # ── Generate release tag ─────────────────────────────────────
+ # Plugin 更新:tag 后缀用字母编码 build number,保证旧客户端正则兼容
+ # 数字 0-9 → A-J,字母原样保留
+ # 例:2607R01 → CGAHRAAB
+ # tag:v0.4.1.0pluginCGAHRAAB → 正则后 0.4.1.0
+ # 常规更新:tag 为 v{version}
+ if ($isPluginUpdate) {
+ $encoded = ($buildNumber.ToCharArray() | ForEach-Object {
+ if ($_ -ge '0' -and $_ -le '9') { [char]([int][char]'A' + ([int]$_ - [int][char]'0')) } else { $_ }
+ }) -join ''
+ $releaseTag = "v${newVersion}plugin${encoded}"
+ } else {
+ $releaseTag = "v${newVersion}"
+ }
+ Write-Host "Release tag: $releaseTag"
+
+ # ── Outputs ──────────────────────────────────────────────────
+ $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "current_version=$currentVersion`n", $utf8NoBom)
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "current_version_text=$currentVersionText`n", $utf8NoBom)
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "new_version=$newVersion`n", $utf8NoBom)
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "new_version_text=$newVersionText`n", $utf8NoBom)
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "has_plugin_update=$($isPluginUpdate.ToString().ToLower())`n", $utf8NoBom)
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "update_plugins=$($plugins -join ';')`n", $utf8NoBom)
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "build_number=$buildNumber`n", $utf8NoBom)
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "release_tag=$releaseTag`n", $utf8NoBom)
- name: Capture PR title and body
id: pr_info
- shell: powershell
+ shell: pwsh
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
@@ -158,6 +254,77 @@ jobs:
[System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "$delimiter`n", $utf8NoBom)
Write-Host "PR body captured ($($body.Length) chars)"
+ - name: Build app_comp_manifest (merge with previous release manifest)
+ id: app_info
+ shell: pwsh
+ env:
+ PREV_TAG: ${{ steps.prev_tag.outputs.previous_tag }}
+ BUILD_NUMBER: ${{ steps.version.outputs.build_number }}
+ HAS_PLUGIN_UPDATE: ${{ steps.version.outputs.has_plugin_update }}
+ UPDATE_PLUGINS: ${{ steps.version.outputs.update_plugins }}
+ run: |
+ $buildNumber = $env:BUILD_NUMBER
+ $hasPluginUpdate = $env:HAS_PLUGIN_UPDATE -eq "true"
+ $updatePlugins = $env:UPDATE_PLUGINS -split ';' | Where-Object { $_ -ne "" }
+ $prevTag = $env:PREV_TAG
+ $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+
+ $prevPlugins = @{}
+
+ # 从上一个 release 下载 manifest
+ if ($prevTag) {
+ try {
+ gh release download $prevTag --pattern "app_comp_manifest.json" --output "prev_manifest" 2>$null
+ $global:LASTEXITCODE = 0
+ if (Test-Path "prev_manifest") {
+ $manifest = Get-Content "prev_manifest" -Raw | ConvertFrom-Json
+ foreach ($prop in $manifest.plugins.PSObject.Properties) {
+ $prevPlugins[$prop.Name] = $prop.Value
+ }
+ Write-Host "[OK] Downloaded previous manifest from release $prevTag"
+ }
+ }
+ catch {
+ $global:LASTEXITCODE = 0
+ }
+ }
+
+ # 构建所有插件列表
+ $allPlugins = @("UI", "PlayerWeb", "ScrSaver", "ML", "Shaders")
+ $pluginsInfo = @{}
+
+ foreach ($plugin in $allPlugins) {
+ $pluginBuild = $buildNumber
+ if ($prevPlugins.ContainsKey($plugin)) {
+ $pluginBuild = $prevPlugins[$plugin]
+ }
+ # 如果是本次更新的插件,使用新的 buildNumber
+ if ($hasPluginUpdate -and $updatePlugins -contains $plugin) {
+ $pluginBuild = $buildNumber
+ }
+ $pluginsInfo[$plugin] = $pluginBuild
+ }
+
+ # 输出 app_comp_manifest JSON
+ $ms = [System.IO.MemoryStream]::new()
+ $writer = [System.Text.Json.Utf8JsonWriter]::new($ms, [System.Text.Json.JsonWriterOptions]@{ Indented = $false })
+ $writer.WriteStartObject()
+ $writer.WriteString("app_build_number", $buildNumber)
+ $writer.WritePropertyName("plugins")
+ $writer.WriteStartObject()
+ foreach ($kv in $pluginsInfo.GetEnumerator() | Sort-Object Name) {
+ $writer.WriteString($kv.Key, $kv.Value)
+ }
+ $writer.WriteEndObject()
+ $writer.WriteEndObject()
+ $writer.Flush()
+ $json = [System.Text.Encoding]::UTF8.GetString($ms.ToArray())
+ $writer.Dispose()
+ $ms.Dispose()
+
+ [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "app_comp_manifest=$json`n", $utf8NoBom)
+ Write-Host "app_comp_manifest: $json"
+
# ─────────────────────────────────────────────────────────────
# Job 2: 编译 .NET Release
# ─────────────────────────────────────────────────────────────
@@ -185,7 +352,7 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Patch AssemblyVersion in csproj (local only, not committed)
- shell: powershell
+ shell: pwsh
run: |
$csprojPath = "src/VirtualPaper/VirtualPaper.csproj"
$content = Get-Content $csprojPath -Raw
@@ -200,6 +367,39 @@ jobs:
- name: Rebuild solution
run: msbuild src/VirtualPaper.sln /t:Rebuild /p:Configuration=Release /p:Platform="Any CPU" /restore /m
+ - name: Generate app_comp_manifest.json
+ shell: pwsh
+ env:
+ APP_COMP_MANIFEST: ${{ needs.prepare.outputs.app_comp_manifest }}
+ run: |
+ $manifestJson = $env:APP_COMP_MANIFEST
+ $outDir = "src/VirtualPaper/bin/Release/net8.0-windows10.0.19041.0"
+ $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+
+ # 格式化 JSON(Indented)
+ $parsed = $manifestJson | ConvertFrom-Json
+ $ms = [System.IO.MemoryStream]::new()
+ $writer = [System.Text.Json.Utf8JsonWriter]::new($ms, [System.Text.Json.JsonWriterOptions]@{ Indented = $true })
+ $writer.WriteStartObject()
+ $writer.WriteString("app_build_number", $parsed.app_build_number)
+ $writer.WritePropertyName("plugins")
+ $writer.WriteStartObject()
+ foreach ($prop in $parsed.plugins.PSObject.Properties | Sort-Object Name) {
+ $writer.WriteString($prop.Name, $prop.Value)
+ }
+ $writer.WriteEndObject()
+ $writer.WriteEndObject()
+ $writer.Flush()
+ $indentedJson = [System.Text.Encoding]::UTF8.GetString($ms.ToArray())
+ $writer.Dispose()
+ $ms.Dispose()
+
+ [System.IO.File]::WriteAllText("$outDir/app_comp_manifest.json", $indentedJson, $utf8NoBom)
+ [System.IO.File]::WriteAllText("app_comp_manifest.json", $indentedJson, $utf8NoBom)
+
+ Write-Host "[OK] Generated app_comp_manifest.json"
+ Write-Host $indentedJson
+
- name: Validate Build Artifacts
# 在产物就地校验:主 exe / Plugin exe / 核心 dll 存在且非空。
# 通过后才上传 artifact,确保 package / smoke_test job 拿到的是健康的构建产物。
@@ -223,6 +423,13 @@ jobs:
path: src/
retention-days: 1
+ - name: Upload app_comp_manifest
+ uses: actions/upload-artifact@v4
+ with:
+ name: app-comp-manifest
+ path: app_comp_manifest.json
+ retention-days: 1
+
# ─────────────────────────────────────────────────────────────
# Job 3: 制作安装包(依赖 build 完成)
# ─────────────────────────────────────────────────────────────
@@ -251,11 +458,11 @@ jobs:
path: src/
- name: Install Inno Setup 6.7.1
- shell: powershell
+ shell: pwsh
run: choco install innosetup --version=6.7.1 --no-progress -y
- name: Patch version in InnoSetup script (local only, not committed)
- shell: powershell
+ shell: pwsh
run: |
$setupPath = "InnoSetup/setup.iss"
$content = Get-Content $setupPath -Raw
@@ -267,7 +474,7 @@ jobs:
Write-Host "Patched InnoSetup version to $newVersion (build-time only)"
- name: Download InnoDependencyInstaller
- shell: powershell
+ shell: pwsh
run: |
$url = "https://raw.githubusercontent.com/DomGries/InnoDependencyInstaller/master/CodeDependencies.iss"
$dest = "InnoSetup\CodeDependencies.iss"
@@ -276,7 +483,7 @@ jobs:
- name: Compile installer
id: build_installer
- shell: powershell
+ shell: pwsh
run: |
$iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (-not (Test-Path $iscc)) {
@@ -292,7 +499,7 @@ jobs:
- name: Record installer info
id: installer_info
- shell: powershell
+ shell: pwsh
run: |
$outputDir = "InnoSetup\Output"
$installerFiles = Get-ChildItem -Path $outputDir -Filter "*.exe" -ErrorAction SilentlyContinue
@@ -306,7 +513,7 @@ jobs:
Write-Output "installer_name=$($installerFiles[0].Name)" >> $env:GITHUB_OUTPUT
- name: Generate SHA256 for installer
- shell: powershell
+ shell: pwsh
run: |
$hash = (Get-FileHash -Path "${{ steps.installer_info.outputs.installer_path }}" -Algorithm SHA256).Hash.ToLower()
Set-Content -Path "${{ github.workspace }}\InnoSetup\Output\SHA256.txt" -Value $hash
@@ -346,7 +553,7 @@ jobs:
- name: Silent install
id: install
- shell: powershell
+ shell: pwsh
run: |
$installer = Get-ChildItem "dist\" -Filter "*.exe" | Select-Object -First 1
if (-not $installer) {
@@ -367,7 +574,7 @@ jobs:
Write-Host "[OK] Installation completed"
- name: Verify key files exist
- shell: powershell
+ shell: pwsh
run: |
$base = "${{ steps.install.outputs.install_dir }}"
$required = @(
@@ -389,7 +596,7 @@ jobs:
}
- name: Launch and verify UI process
- shell: powershell
+ shell: pwsh
run: |
$installDir = "${{ steps.install.outputs.install_dir }}"
$mainExe = "$installDir\VirtualPaper.exe"
@@ -450,7 +657,7 @@ jobs:
Write-Host "[OK] Smoke test passed"
- name: Verify UI cannot start without main process
- shell: powershell
+ shell: pwsh
run: |
$installDir = "${{ steps.install.outputs.install_dir }}"
$uiExe = "$installDir\Plugins\UI\VirtualPaper.UI.exe"
@@ -523,13 +730,156 @@ jobs:
}
Write-Host "[OK] Standalone-UI guard test passed"
+ # ─────────────────────────────────────────────────────────────
+ # Job 4.5: 制作插件更新包(plugin zips + manifest)
+ # 仅在冒烟测试通过且 PR 有 plugin-packages 标签时执行
+ # ─────────────────────────────────────────────────────────────
+ package_plugin_update:
+ name: Create Plugin Packages
+ runs-on: windows-latest
+ needs: [prepare, build, smoke_test]
+ if: needs.prepare.outputs.has_plugin_update == 'true'
+
+ steps:
+ - name: Download build output
+ uses: actions/download-artifact@v4
+ with:
+ name: build-release-output
+ path: src/
+
+ - name: Download app_comp_manifest
+ uses: actions/download-artifact@v4
+ with:
+ name: app-comp-manifest
+ path: plugin-packages/
+
+ - name: Create plugin patch package
+ shell: pwsh
+ run: |
+ $buildDir = "src/VirtualPaper/bin/Release/net8.0-windows10.0.19041.0"
+ $pluginsDir = "$buildDir/Plugins"
+ $workDir = "plugin-packages"
+ $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
+
+ $buildNumber = "${{ needs.prepare.outputs.build_number }}"
+ $plugins = "${{ needs.prepare.outputs.update_plugins }}" -split ';' | Where-Object { $_ -ne "" }
+
+ # 读取 app_comp_manifest.json
+ $appCompManifest = Get-Content "$workDir/app_comp_manifest.json" -Raw | ConvertFrom-Json
+
+ # 创建每个插件的 zip 并计算 sha256
+ $pendingManifest = @{ plugins = @{} }
+ $tempPluginZips = @()
+
+ foreach ($plugin in $plugins) {
+ $pluginDir = "$pluginsDir/$plugin"
+ if (Test-Path $pluginDir) {
+ $pluginLower = $plugin.ToLower()
+ $zipName = "plugin-$pluginLower-$buildNumber.zip"
+ $zipPath = "$workDir/$zipName"
+ Compress-Archive -Path $pluginDir -DestinationPath $zipPath -Force
+ $tempPluginZips += $zipPath
+
+ $hash = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash.ToLower()
+ $pluginBuild = $appCompManifest.plugins.$plugin
+
+ $pendingManifest.plugins[$plugin] = @{
+ build_number = $pluginBuild
+ asset = $zipName
+ sha256 = $hash
+ }
+ Write-Host "[OK] Created $zipName (sha256: $hash)"
+ } else {
+ Write-Warning "Plugin directory not found: $pluginDir"
+ }
+ }
+
+ # 生成 pending_update_plugins_manifest.json
+ $ms = [System.IO.MemoryStream]::new()
+ $writer = [System.Text.Json.Utf8JsonWriter]::new($ms, [System.Text.Json.JsonWriterOptions]@{ Indented = $true })
+ $writer.WriteStartObject()
+ $writer.WritePropertyName("plugins")
+ $writer.WriteStartObject()
+ foreach ($kv in $pendingManifest.plugins.GetEnumerator() | Sort-Object Name) {
+ $writer.WritePropertyName($kv.Key)
+ $writer.WriteStartObject()
+ $writer.WriteString("build_number", $kv.Value.build_number)
+ $writer.WriteString("asset", $kv.Value.asset)
+ $writer.WriteString("sha256", $kv.Value.sha256)
+ $writer.WriteEndObject()
+ }
+ $writer.WriteEndObject()
+ $writer.WriteEndObject()
+ $writer.Flush()
+ $pendingJson = [System.Text.Encoding]::UTF8.GetString($ms.ToArray())
+ $writer.Dispose()
+ $ms.Dispose()
+
+ [System.IO.File]::WriteAllText("$workDir/pending_update_plugins_manifest.json", $pendingJson, $utf8NoBom)
+ Write-Host "[OK] Generated pending_update_plugins_manifest.json"
+ Write-Host $pendingJson
+
+ # 生成 app_comp_manifest.json(打包到 zip 里的版本)
+ $ms2 = [System.IO.MemoryStream]::new()
+ $writer2 = [System.Text.Json.Utf8JsonWriter]::new($ms2, [System.Text.Json.JsonWriterOptions]@{ Indented = $true })
+ $writer2.WriteStartObject()
+ $writer2.WriteString("app_build_number", $appCompManifest.app_build_number)
+ $writer2.WritePropertyName("plugins")
+ $writer2.WriteStartObject()
+ foreach ($prop in $appCompManifest.plugins.PSObject.Properties | Sort-Object Name) {
+ $writer2.WriteString($prop.Name, $prop.Value)
+ }
+ $writer2.WriteEndObject()
+ $writer2.WriteEndObject()
+ $writer2.Flush()
+ $appCompJson = [System.Text.Encoding]::UTF8.GetString($ms2.ToArray())
+ $writer2.Dispose()
+ $ms2.Dispose()
+
+ [System.IO.File]::WriteAllText("$workDir/app_comp_manifest.json", $appCompJson, $utf8NoBom)
+ Write-Host "[OK] Generated app_comp_manifest.json for zip"
+
+ # 打包 plugins_patch.zip(包含所有插件 zip + 两个 manifest)
+ $patchZipPath = "plugins_patch.zip"
+ $filesToZip = @()
+ $filesToZip += "$workDir/pending_update_plugins_manifest.json"
+ $filesToZip += "$workDir/app_comp_manifest.json"
+ $filesToZip += $tempPluginZips
+
+ Compress-Archive -Path $filesToZip -DestinationPath $patchZipPath -Force
+ Write-Host "[OK] Created plugins_patch.zip"
+
+ # 生成 PLUGINS_PATCH_SHS256.txt(plugins_patch.zip 的 sha256)
+ $patchHash = (Get-FileHash -Path $patchZipPath -Algorithm SHA256).Hash.ToLower()
+ [System.IO.File]::WriteAllText("PLUGINS_PATCH_SHS256.txt", $patchHash, $utf8NoBom)
+ Write-Host "[OK] Generated PLUGINS_PATCH_SHS256.txt: $patchHash"
+
+ # 输出产物信息
+ Write-Host ""
+ Write-Host "=== Plugin Patch Package ==="
+ Write-Host "plugins_patch.zip: $((Get-Item $patchZipPath).Length) bytes"
+ Write-Host "PLUGINS_PATCH_SHS256.txt: $patchHash"
+
+ - name: Upload plugin patch package
+ uses: actions/upload-artifact@v4
+ with:
+ name: VirtualPaper-Plugins-v${{ needs.prepare.outputs.new_version }}
+ path: |
+ plugins_patch.zip
+ PLUGINS_PATCH_SHS256.txt
+ retention-days: 30
+
# ─────────────────────────────────────────────────────────────
# Job 5: 将版本号递增变更提交到 main(冒烟测试通过后才提交)
# ─────────────────────────────────────────────────────────────
bump:
name: Bump Version to main
runs-on: windows-latest
- needs: [prepare, package, smoke_test]
+ needs: [prepare, package, smoke_test, package_plugin_update]
+ if: |
+ always() &&
+ needs.prepare.outputs.has_plugin_update != 'true' &&
+ needs.smoke_test.result == 'success'
steps:
- name: Checkout main with full history
@@ -539,7 +889,7 @@ jobs:
fetch-depth: 0
- name: Update version in csproj
- shell: powershell
+ shell: pwsh
run: |
$csprojPath = "src/VirtualPaper/VirtualPaper.csproj"
$content = Get-Content $csprojPath -Raw
@@ -549,7 +899,7 @@ jobs:
Write-Host "Updated csproj version to: $newVersion"
- name: Update version in InnoSetup script
- shell: powershell
+ shell: pwsh
run: |
$setupPath = "InnoSetup/setup.iss"
$content = Get-Content $setupPath -Raw
@@ -561,7 +911,7 @@ jobs:
Write-Host "Updated InnoSetup version to: $newVersion"
- name: Update version badge in README
- shell: powershell
+ shell: pwsh
run: |
$readmePath = "README.md"
$content = Get-Content $readmePath -Raw
@@ -571,7 +921,7 @@ jobs:
Write-Host "Updated README badge version to: $newVersion"
- name: Commit and push to main
- shell: powershell
+ shell: pwsh
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
@@ -589,6 +939,9 @@ jobs:
name: Sync Version to Pre-publish Branches
runs-on: windows-latest
needs: [prepare, bump]
+ if: |
+ always() &&
+ needs.bump.result == 'success'
steps:
- name: Checkout with full history
@@ -598,7 +951,7 @@ jobs:
fetch-depth: 0
- name: Sync main -> pre-publish branches (merge with main override on conflict)
- shell: powershell
+ shell: pwsh
run: |
$newVersion = "${{ needs.prepare.outputs.new_version }}"
@@ -632,12 +985,16 @@ jobs:
Write-Host "[OK]" -ForegroundColor Green -NoNewline; Write-Host " $branch fully synced with main"
}
else {
- # 策略 2(fallback):merge 无法自动解决 → 放弃,直接 reset --hard 到 main
- # 使用 reset --hard 直接移动分支指针,不产生额外 commit,
- # 历史看起来如同这次 merge 从未发生,比 git revert 更干净彻底。
- Write-Host " [WARN]" -ForegroundColor Yellow -NoNewline; Write-Host " merge has unresolvable conflicts, force-resetting to origin/main"
+ # 策略 2(fallback):merge 无法自动解决 → 备份后 reset --hard 到 main
+ Write-Host " [WARN]" -ForegroundColor Yellow -NoNewline; Write-Host " merge has unresolvable conflicts, backing up and force-resetting to origin/main"
git merge --abort
+ # 备份当前分支状态到 ${branch}_backup,再 reset
+ $backupBranch = "${branch}_backup"
+ git branch -f $backupBranch HEAD
+ git push --force origin $backupBranch
+ Write-Host " [BACKUP]" -ForegroundColor Cyan -NoNewline; Write-Host " $branch saved to $backupBranch"
+
git reset --hard origin/main
git push --force-with-lease origin $branch
Write-Host "[WARN]" -ForegroundColor Yellow -NoNewline; Write-Host " $branch force-reset to origin/main (unresolvable conflicts overridden)"
@@ -650,15 +1007,15 @@ jobs:
summary:
name: Summary
runs-on: ubuntu-latest
- needs: [prepare, build, package, smoke_test, bump, sync]
- if: >-
+ needs: [prepare, build, package, smoke_test, package_plugin_update, bump, sync]
+ if: |
!cancelled() &&
needs.prepare.result == 'success' &&
needs.build.result == 'success' &&
needs.package.result == 'success' &&
needs.smoke_test.result == 'success' &&
- needs.bump.result == 'success' &&
- needs.sync.result == 'success'
+ (needs.prepare.outputs.has_plugin_update == 'true' || (needs.bump.result == 'success' && needs.sync.result == 'success')) &&
+ (needs.prepare.outputs.has_plugin_update != 'true' || needs.package_plugin_update.result == 'success')
steps:
- name: Print summary
@@ -675,10 +1032,25 @@ jobs:
echo "Source : ${{ needs.prepare.outputs.source_branch }} -> main"
echo "Type : $TYPE"
echo -e "\033[0;36mPackage : ${{ needs.package.outputs.installer_name }}\033[0m"
+
+ if [ "${{ needs.prepare.outputs.has_plugin_update }}" = "true" ]; then
+ echo -e "\033[0;36mPlugins Update: YES (plugins: ${{ needs.prepare.outputs.update_plugins }}, build: ${{ needs.prepare.outputs.build_number }})\033[0m"
+ else
+ echo "Plugins Update: NO (installer only)"
+ fi
+
echo ""
- echo -e "\033[0;32m[OK]\033[0m Version updated and committed to main"
- echo -e "\033[0;32m[OK]\033[0m Version synced to pre-publish branches (release / dev / bugfix)"
+ if [ "${{ needs.prepare.outputs.has_plugin_update }}" = "true" ]; then
+ echo -e "\033[0;33m[SKIP]\033[0m Version bump skipped (plugin update - version unchanged)"
+ echo -e "\033[0;33m[SKIP]\033[0m Branch sync skipped (plugin update)"
+ else
+ echo -e "\033[0;32m[OK]\033[0m Version updated and committed to main"
+ echo -e "\033[0;32m[OK]\033[0m Version synced to pre-publish branches (release / dev / bugfix)"
+ fi
echo -e "\033[0;32m[OK]\033[0m Installer uploaded as Artifact"
+ if [ "${{ needs.prepare.outputs.has_plugin_update }}" = "true" ]; then
+ echo -e "\033[0;32m[OK]\033[0m Plugins update packages uploaded as Artifact"
+ fi
echo -e "\033[0;32m[OK]\033[0m Smoke test passed (install + launch + singleton)"
echo -e "\033[0;32m[OK]\033[0m Draft GitHub Release will be created: v${{ needs.prepare.outputs.new_version }}"
echo -e "\033[0;32m==========================================\033[0m"
@@ -689,45 +1061,90 @@ jobs:
# - 以 PR body 作为 Release 正文
# - Tag / Release 标题均为 "v版本号"
# - 将 Installer exe 作为 Release 附件上传
+ # - 如有重启式更新包,一并上传
# - 创建为草稿(draft),需人工验证后手动点击发布
# ─────────────────────────────────────────────────────────────
release:
name: Create Draft GitHub Release
runs-on: ubuntu-latest
- needs: [prepare, package, summary]
- if: needs.summary.result == 'success'
+ needs: [prepare, build, package, smoke_test, package_plugin_update, bump, sync, summary]
+ if: |
+ !cancelled() &&
+ needs.prepare.result == 'success' &&
+ needs.build.result == 'success' &&
+ needs.package.result == 'success' &&
+ needs.smoke_test.result == 'success' &&
+ (needs.prepare.outputs.has_plugin_update == 'true' || (needs.bump.result == 'success' && needs.sync.result == 'success')) &&
+ (needs.prepare.outputs.has_plugin_update != 'true' || needs.package_plugin_update.result == 'success')
steps:
+ - name: Checkout (for tag verification)
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
- name: Download installer artifact
uses: actions/download-artifact@v4
with:
name: VirtualPaper-Installer-v${{ needs.prepare.outputs.new_version }}
path: dist/
+ - name: Download plugin patch artifact
+ if: needs.prepare.outputs.has_plugin_update == 'true'
+ uses: actions/download-artifact@v4
+ with:
+ name: VirtualPaper-Plugins-v${{ needs.prepare.outputs.new_version }}
+ path: dist/
+
+ - name: Download app_comp_manifest
+ uses: actions/download-artifact@v4
+ with:
+ name: app-comp-manifest
+ path: dist/
+
+ - name: Verify tag does not exist
+ id: tag_check
+ shell: pwsh
+ run: |
+ $tag = "${{ needs.prepare.outputs.release_tag }}"
+ $existing = git tag -l $tag 2>$null
+ if ($existing) {
+ Write-Error "Tag '$tag' already exists! Possible concurrent build race. Aborting release."
+ exit 1
+ }
+ Write-Host "[OK] Tag '$tag' is unique, safe to create"
+
- name: Create Draft Release & Tag
uses: softprops/action-gh-release@v2
with:
- tag_name: v${{ needs.prepare.outputs.new_version }}
- name: v${{ needs.prepare.outputs.new_version }}
+ tag_name: ${{ needs.prepare.outputs.release_tag }}
+ name: ${{ needs.prepare.outputs.has_plugin_update == 'true' && format('v{0} Plugin Update (Build {1})', needs.prepare.outputs.new_version, needs.prepare.outputs.build_number) || format('v{0} (Build {1})', needs.prepare.outputs.new_version, needs.prepare.outputs.build_number) }}
body: ${{ needs.prepare.outputs.pr_body }}
+ previous_tag: ${{ needs.prepare.outputs.previous_tag }}
files: |
dist/*.exe
dist/SHA256.txt
+ dist/app_comp_manifest.json
+ dist/plugins_patch.zip
+ dist/PLUGINS_PATCH_SHS256.txt
draft: true # 草稿状态,不对外公开,等待人工验证后发布
make_latest: false # 草稿阶段不标记为最新,发布时再生效
token: ${{ secrets.GITHUB_TOKEN }}
# ─────────────────────────────────────────────────────────────
- # Job 9: 失败回滚(任意 job 失败时执行)
+ # Job 9: 失败回滚(bump 或 sync 执行后失败时)
# ─────────────────────────────────────────────────────────────
rollback:
name: Rollback on Failure
runs-on: windows-latest
- needs: [prepare, build, package, smoke_test, bump, sync]
- # 触发条件:bump 或 sync 已经开始执行(分支内容已被修改),且流程出现失败或取消。
- # build 失败时 bump/sync 均为 skipped(分支未动),不触发,避免无意义的回滚操作。
+ needs: [prepare, build, package, smoke_test, package_plugin_update, bump, sync]
+ # 触发条件:
+ # 1. 仅针对常规安装式更新(重启式更新不涉及版本同步,无需回滚)
+ # 2. bump 或 sync 已经开始执行(分支内容已被修改),且流程出现失败或取消
+ # 3. bump 依赖 smoke_test 成功,因此 smoke_test 失败时 bump/sync 均为 skipped,不触发回滚
if: |
always() &&
+ needs.prepare.outputs.has_plugin_update != 'true' &&
needs.prepare.result == 'success' &&
(needs.bump.result != 'skipped' || needs.sync.result != 'skipped') &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
@@ -740,7 +1157,7 @@ jobs:
fetch-depth: 0
- name: Rollback changes
- shell: powershell
+ shell: pwsh
run: |
Write-Host ""
Write-Host "[WARN]" -ForegroundColor Yellow -NoNewline; Write-Host " Pipeline failure detected, starting rollback..."
diff --git a/src/StaticImg/StaticImg.csproj b/src/StaticImg/StaticImg.csproj
index ee03a769..a0586899 100644
--- a/src/StaticImg/StaticImg.csproj
+++ b/src/StaticImg/StaticImg.csproj
@@ -3,7 +3,7 @@
net8.0-windows10.0.19041.0
10.0.17763.0
Workloads.Creation.StaticImg
- win-x86;win-x64;win-arm64
+ false
true
true
enable
diff --git a/src/VirtualPaper.AppSettingsPanel/ViewModels/GeneralSettingViewModel.cs b/src/VirtualPaper.AppSettingsPanel/ViewModels/GeneralSettingViewModel.cs
index 82d7294b..c42dd861 100644
--- a/src/VirtualPaper.AppSettingsPanel/ViewModels/GeneralSettingViewModel.cs
+++ b/src/VirtualPaper.AppSettingsPanel/ViewModels/GeneralSettingViewModel.cs
@@ -2,19 +2,22 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text.Json;
using System.Threading.Tasks;
using System.Windows.Input;
using Grpc.Core;
using VirtualPaper.Common;
-using VirtualPaper.Common.Events;
using VirtualPaper.Common.Logging;
using VirtualPaper.Common.Utils.Files;
using VirtualPaper.Common.Utils.Localization;
using VirtualPaper.Common.Utils.Storage;
using VirtualPaper.Common.Utils.ThreadContext;
+using VirtualPaper.Cores.AppUpdate.Models;
using VirtualPaper.Grpc.Client.Interfaces;
+using VirtualPaper.Models.AppUpdate;
using VirtualPaper.Models.Cores;
using VirtualPaper.Models.Cores.Interfaces;
+using VirtualPaper.Models.Events;
using VirtualPaper.Models.Mvvm;
using VirtualPaper.UIComponent;
using VirtualPaper.UIComponent.Utils;
@@ -34,10 +37,61 @@ public string AppVersionText {
ver += "b";
else if (Constants.ApplicationType.IsMSIX)
ver += $" {LanguageUtil.GetI18n(Constants.I18n.Settings_General_Version_MsStore)}";
+
+ var appBuild = LoadAppBuildInfo()?.AppBuildNumber;
+ if (!string.IsNullOrEmpty(appBuild))
+ ver += $" (Build {appBuild})";
+
return ver;
}
}
+ public List PluginVersionTexts {
+ get {
+ var plugins = LoadAppBuildInfo()?.Plugins;
+ if (plugins == null) return [];
+ return plugins
+ .OrderBy(kv => Enum.TryParse(kv.Key, true, out var p) ? (int)p : int.MaxValue)
+ .Select(kv => $"{kv.Key}: {kv.Value}")
+ .ToList();
+ }
+ }
+
+ public bool HasPluginVersions => PluginVersionTexts.Count > 0;
+
+ public Microsoft.UI.Xaml.Visibility PluginVersionsVisibility =>
+ HasPluginVersions ? Microsoft.UI.Xaml.Visibility.Visible : Microsoft.UI.Xaml.Visibility.Collapsed;
+
+ private AppBuildInfo? LoadAppBuildInfo() {
+ if (_buildInfo != null) return _buildInfo;
+
+ // 从 AppData 目录读取 app_comp_manifest.json
+ var path = Path.Combine(Constants.CommonPaths.AppDataDir, "app_comp_manifest.json");
+ if (!File.Exists(path)) {
+ _buildInfo = null;
+ return null;
+ }
+
+ try {
+ var json = File.ReadAllText(path);
+ var manifest = JsonSerializer.Deserialize(json, UpdateManifestContext.Default.AppCompManifest);
+ if (manifest?.Plugins == null) {
+ _buildInfo = null;
+ return null;
+ }
+
+ _buildInfo = new AppBuildInfo { AppBuildNumber = manifest.AppBuildNumber };
+ foreach (var (pluginName, buildNumber) in manifest.Plugins) {
+ _buildInfo.Plugins[pluginName] = buildNumber;
+ }
+ }
+ catch {
+ _buildInfo = null;
+ }
+
+ return _buildInfo;
+ }
+
public List SystemBackdrops { get; set; } = [];
public List Languages { get; set; } = [];
@@ -71,6 +125,12 @@ public bool IsUpdateBtnEnable {
set { _isUpdateBtnEnable = value; OnPropertyChanged(); }
}
+ private bool _isInstallBtnEnable = true;
+ public bool IsInstallBtnEnable {
+ get => _isInstallBtnEnable;
+ set { _isInstallBtnEnable = value; OnPropertyChanged(); }
+ }
+
private bool _isUpdateRingActive = false;
public bool IsUpdateRingActive {
get => _isUpdateRingActive;
@@ -154,6 +214,18 @@ public bool IsWallpaperDirectoryChangeEnable {
set { _isWallpaperDirectoryChangeEnable = value; OnPropertyChanged(); }
}
+ private string? _text_UpdateReady;
+ public string? Text_UpdateReady {
+ get { return _text_UpdateReady; }
+ set { _text_UpdateReady = value; OnPropertyChanged(); }
+ }
+
+ private ICommand? _installBtnComand;
+ public ICommand? InstallBtnComand {
+ get { return _installBtnComand; }
+ set { _installBtnComand = value; OnPropertyChanged(); }
+ }
+
public ICommand? ChangeFileStorageCommand { get; private set; }
public ICommand? OpenFileStorageCommand { get; private set; }
public ICommand? CheckUpdateCommand { get; private set; }
@@ -162,10 +234,12 @@ public bool IsWallpaperDirectoryChangeEnable {
public GeneralSettingViewModel(
IAppUpdaterClient appUpdater,
IUserSettingsClient userSettingsClient,
- IWallpaperControlClient wallpaperControlClient) {
+ IWallpaperControlClient wallpaperControlClient,
+ ICommandsClient commandsClient) {
_appUpdater = appUpdater;
_userSettingsClient = userSettingsClient;
_wpControlClient = wallpaperControlClient;
+ _commandsClient = commandsClient;
InitText();
InitCollections();
@@ -195,6 +269,8 @@ private void InitContent() {
IsAutoStart = _userSettingsClient.Settings.IsAutoStart;
WallpaperDir = _userSettingsClient.Settings.WallpaperDir;
+
+ LoadAppBuildInfo();
}
private void InitText() {
@@ -220,14 +296,15 @@ private void ChangeAutoShartStatu(bool isAutoStart) {
}
private async Task CheckUpdateAsync() {
+ IsInstallBtnEnable = false;
IsUpdateBtnEnable = false;
IsUpdateRingActive = true;
InfoBarVisibilityRestore();
await _appUpdater.CheckUpdateAsync();
- IsUpdateBtnEnable = true;
IsUpdateRingActive = false;
+ IsUpdateBtnEnable = true;
}
private void InfoBarVisibilityRestore() {
@@ -236,32 +313,45 @@ private void InfoBarVisibilityRestore() {
private void AppUpdater_UpdateChecked(object? sender, AppUpdaterEventArgs e) {
CrossThreadInvoker.InvokeOnUIThread(() => {
- MenuUpdate(e.UpdateStatus, e.UpdateDate, e.UpdateVersion);
+ MenuUpdate(e.UpdateStatus, e.Release);
});
}
- private void MenuUpdate(AppUpdateStatus status, DateTime date, Version version) {
- Version = $"v{version}";
-//#if DEBUG
-// CurrentVersionState = VersionState.FindNew;
-//#else
+ private void MenuUpdate(AppUpdateStatus status, ReleaseInfo? release) {
+ //CurrentVersionState = VersionState.FindNew;
+
switch (status) {
case AppUpdateStatus.Uptodate:
CurrentVersionState = VersionState.UptoNewest;
break;
case AppUpdateStatus.Available:
- Version = $"v{version}";
+ Version = $"v{release?.Version} Build ({release?.AppBuild})";
CurrentVersionState = VersionState.FindNew;
break;
+ case AppUpdateStatus.InstallerReady:
+ Text_UpdateReady = LanguageUtil.GetI18n(nameof(Constants.I18n.Settings_General_Version_InstallerReady));
+ InstallBtnComand = new RelayCommand(async () => {
+
+ });
+ CurrentVersionState = VersionState.InstallReady;
+ IsInstallBtnEnable = true;
+ break;
+ case AppUpdateStatus.PluginsReady:
+ Text_UpdateReady = LanguageUtil.GetI18n(nameof(Constants.I18n.Settings_General_Version_PluginsReady));
+ InstallBtnComand = new RelayCommand(async () => {
+ await _commandsClient.CloseUI();
+ });
+ CurrentVersionState = VersionState.InstallReady;
+ IsInstallBtnEnable = true;
+ break;
case AppUpdateStatus.Invalid or AppUpdateStatus.Error:
CurrentVersionState = VersionState.UpdateErr;
break;
default:
break;
}
-//#endif
Version_LastCheckDate = LanguageUtil.GetI18n(Constants.I18n.Settings_General_Version_LastCheckDate);
- Version_LastCheckDate += status == AppUpdateStatus.Notchecked ? "" : $" {date}";
+ Version_LastCheckDate += status == AppUpdateStatus.Notchecked ? "" : $" {release?.CheckedTime}";
}
private async Task StartDownloadAsync() {
@@ -335,7 +425,7 @@ internal async Task WallpaperDirectoryChangeAsync(string destRootFolderPath) {
GlobalMessageUtil.ShowException(ex);
ArcLog.GetLogger().Error(ex.Message);
if (destFolderPath != string.Empty) {
- FileUtil.EmptyDirectory(destFolderPath);
+ FileUtil.RemoveDirectory(destFolderPath);
}
}
finally {
@@ -376,9 +466,11 @@ internal static async IAsyncEnumerable GetWpBasicDataByInstallFolders
WpLibData libData = new();
foreach (string file in files) {
if (Path.GetFileName(file) == Constants.Field.WpBasicDataFileName) {
- libData.BasicData = await JsonSaver.LoadAsync(file, WpBasicDataContext.Default);
+ var basicData = await JsonSaver.LoadAsync(file, WpBasicDataContext.Default);
+ if (basicData == null) continue;
- if (libData.BasicData.IsAvailable()) {
+ libData.BasicData = basicData;
+ if (libData.BasicData != null && libData.BasicData.IsAvailable()) {
libData.Idx = idx++;
yield return libData;
break;
@@ -414,6 +506,8 @@ protected virtual void Dispose(bool disposing) {
private readonly IAppUpdaterClient _appUpdater;
private readonly IUserSettingsClient _userSettingsClient;
private readonly IWallpaperControlClient _wpControlClient;
+ private readonly ICommandsClient _commandsClient;
+ private AppBuildInfo? _buildInfo;
}
public enum VersionState {
@@ -424,6 +518,7 @@ public enum VersionState {
DownloadFailed, // 下载失败
VerifyFailed, // 校验失败
Downloaded, // 下载完成
- UpdateErr // 网络或更新错误
+ UpdateErr, // 网络或更新错误
+ InstallReady
}
}
diff --git a/src/VirtualPaper.AppSettingsPanel/Views/GeneralSetting.xaml b/src/VirtualPaper.AppSettingsPanel/Views/GeneralSetting.xaml
index be98836b..e1c6a127 100644
--- a/src/VirtualPaper.AppSettingsPanel/Views/GeneralSetting.xaml
+++ b/src/VirtualPaper.AppSettingsPanel/Views/GeneralSetting.xaml
@@ -18,59 +18,87 @@
Text="{markup:I18n Key=Settings_General_Text_Version}"
FontWeight="Bold"/>
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
net8.0-windows10.0.19041.0
VirtualPaper.AppSettingsPanel
- win-x86;win-x64;win-arm64
- win10-x86;win10-x64;win10-arm64
+ false
true
enable
10.0.19041.0
diff --git a/src/VirtualPaper.Common/Constants.cs b/src/VirtualPaper.Common/Constants.cs
index 18c3509f..d2d1dbb3 100644
--- a/src/VirtualPaper.Common/Constants.cs
+++ b/src/VirtualPaper.Common/Constants.cs
@@ -64,6 +64,13 @@ public static class CommonPaths {
public static string TempWebView2Dir => Path.Combine(AppDataDir, "WebView2");
public static string TempScrWebView2Dir => Path.Combine(AppDataDir, "ScrWebView2");
+ public static string InstallerCacheDir => Path.Combine(AppDataDir, "installer_cache");
+ public static string PendingUpdatesDir => Path.Combine(AppDataDir, "pending_updates");
+ public static string UpdateFlagPath => Path.Combine(PendingUpdatesDir, "update.flag");
+ public static string UpdateBackupDir => Path.Combine(PendingUpdatesDir, "_backup");
+ public static string PluginPatchExtractDir => Path.Combine(PendingUpdatesDir, "extracted");
+ public static string UpdateFailedNoticePath => Path.Combine(AppDataDir, "update_failed_notice.json");
+
private static class Legacy {
public static string AppRulesPath => Path.Combine(AppDataDir, "AppRules.json");
public static string WallpaperLayoutPath => Path.Combine(AppDataDir, "WallpaperLayout.json");
@@ -129,6 +136,7 @@ public static class CoreField {
public static string PipeServerName => UniqueAppUid + Environment.UserName;
public static string UniqueAppUid => "Virtual:WALLPAPERSYSTEM";
public static string UniqueAppUIUid => "Virtual:UI:WALLPAPERSYSTEM";
+ public static string AppBuildFile => "app_build.json";
}
public static class EnviromentVarKey {
@@ -154,6 +162,20 @@ public static class I18n {
public static string InfobarMsg_Err => "InfobarMsg_Err";
public static string InfobarMsg_ImportErr => "InfobarMsg_ImportErr";
public static string InfobarMsg_Success => "InfobarMsg_Success";
+ public static string AppUpdater_UpdateFailedMessage => "AppUpdater_UpdateFailedMessage";
+ public static string Settings_General_Version_Plugins => "Settings_General_Version_Plugins";
+ public static string PluginsUpdate_InvalidInfo => "PluginsUpdate_InvalidInfo";
+ public static string PluginsUpdate_Starting => "PluginsUpdate_Starting";
+ public static string PluginsUpdate_Completed => "PluginsUpdate_Completed";
+ public static string PluginsUpdate_Failed => "PluginsUpdate_Failed";
+ public static string PluginsUpdate_Stage_Downloading => "PluginsUpdate_Stage_Downloading";
+ public static string PluginsUpdate_Stage_BackingUp => "PluginsUpdate_Stage_BackingUp";
+ public static string PluginsUpdate_Stage_Replacing => "PluginsUpdate_Stage_Replacing";
+ public static string PluginsUpdate_Stage_Completed => "PluginsUpdate_Stage_Completed";
+ public static string PluginsUpdate_Stage_Failed => "PluginsUpdate_Stage_Failed";
+ public static string PluginsUpdate_Close => "PluginsUpdate_Close";
+ public static string PluginsUpdate_PostponeTip => "PluginsUpdate_PostponeTip";
+ public static string Find_New_Version_Restart => "Find_New_Version_Restart";
public static string ScreenSaver__effectBubble => "ScreenSaver__effectBubble";
public static string ScreenSaver__effectNone => "ScreenSaver__effectNone";
public static string Settings_General_AppearanceAndAction__sysbdAcrylic => "Settings_General_AppearanceAndAction__sysbdAcrylic";
@@ -295,6 +317,15 @@ public static class I18n {
public static string? Project_StaticImg_Text_EffectDesc_Screen { get; }
public static string? Project_StaticImg_Text_EffectDesc_Overlay { get; }
public static string? Project_StaticImg_Text_EffectDesc_SoftLight { get; }
+ public static string? Settings_General_Version_FindNew { get; }
+ public static string? AppUpdater_SpeedText_Ready { get; }
+ public static string? Settings_General_Version_DownloadStart { get; }
+ public static string? Settings_General_Version_Install { get; }
+ public static string? Settings_General_Version_InstallerReady { get; }
+ public static string? Settings_General_Version_PluginsReady { get; }
+ public static string? PluginUpdate_ReplacedPlugin { get; }
+ public static string? PluginUpdate_Title_Completed { get; }
+ public static string? PluginUpdate_Title_Failed { get; }
}
public static class Field {
diff --git a/src/VirtualPaper.Common/Enums.cs b/src/VirtualPaper.Common/Enums.cs
index 5caa6ef7..77af8355 100644
--- a/src/VirtualPaper.Common/Enums.cs
+++ b/src/VirtualPaper.Common/Enums.cs
@@ -89,7 +89,8 @@ public enum WallpaperArrangement {
}
#endregion
- #region type
+ #region type
+ [JsonConverter(typeof(FileTypeJsonConverter))]
public enum FileType {
FUnknown,
FImage,
@@ -100,6 +101,20 @@ public enum FileType {
FWebZip,
}
+ public sealed class FileTypeJsonConverter : JsonConverter {
+ public override FileType Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) {
+ var str = reader.GetString();
+ if (str != null && Enum.TryParse(str, ignoreCase: true, out var result)) {
+ return result;
+ }
+ return FileType.FUnknown;
+ }
+
+ public override void Write(System.Text.Json.Utf8JsonWriter writer, FileType value, System.Text.Json.JsonSerializerOptions options) {
+ writer.WriteStringValue(value.ToString());
+ }
+ }
+
public enum RuntimeType {
RUnknown,
RImage,
@@ -142,6 +157,14 @@ public enum DialogResult {
public static class FileExtension {
public const string FE_Design = ".vpd";
}
+
+ public enum PluginName {
+ UI,
+ PlayerWeb,
+ ScrSaver,
+ ML,
+ Shaders
+ }
#endregion
#region common
diff --git a/src/VirtualPaper.Common/Events/AppUpdaterEventArgs.cs b/src/VirtualPaper.Common/Events/AppUpdaterEventArgs.cs
deleted file mode 100644
index 8b924e6a..00000000
--- a/src/VirtualPaper.Common/Events/AppUpdaterEventArgs.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using System.ComponentModel;
-
-namespace VirtualPaper.Common.Events {
- public class AppUpdaterEventArgs : EventArgs {
- public AppUpdaterEventArgs(AppUpdateStatus updateStatus, Version updateVersion, DateTime updateDate, Uri updateUri, Uri updateSHAUri, string changeLog) {
- UpdateStatus = updateStatus;
- UpdateVersion = updateVersion;
- UpdateUri = updateUri;
- UpdateSHAUri = updateSHAUri;
- UpdateDate = updateDate;
- ChangeLog = changeLog;
- }
-
- public AppUpdateStatus UpdateStatus { get; }
- public Version UpdateVersion { get; }
- public Uri UpdateUri { get; }
- public Uri UpdateSHAUri { get; }
- public DateTime UpdateDate { get; }
- public string ChangeLog { get; }
- }
-
- public enum AppUpdateStatus {
- [Description("Software is up-to-date.")]
- Uptodate,
- [Description("Update available.")]
- Available,
- [Description("Installed software version higher than whats available online.")]
- Invalid,
- [Description("Update not checked yet.")]
- Notchecked,
- [Description("Update check failed.")]
- Error,
- }
-}
diff --git a/src/VirtualPaper.Common/Events/EffectValueChanged.cs b/src/VirtualPaper.Common/Events/EffectValueChanged.cs
index 9ea40355..bcea445b 100644
--- a/src/VirtualPaper.Common/Events/EffectValueChanged.cs
+++ b/src/VirtualPaper.Common/Events/EffectValueChanged.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace VirtualPaper.Common.Events.EffectValue.Base {
+namespace VirtualPaper.Common.Events {
[JsonSerializable(typeof(EffectValueChangedBase))]
public partial class EffectValueChangedBaseContext : JsonSerializerContext { }
diff --git a/src/VirtualPaper.Common/Events/HardwareUsageEventArgs.cs b/src/VirtualPaper.Common/Events/HardwareUsageEventArgs.cs
index 901ecd04..5d413471 100644
--- a/src/VirtualPaper.Common/Events/HardwareUsageEventArgs.cs
+++ b/src/VirtualPaper.Common/Events/HardwareUsageEventArgs.cs
@@ -1,4 +1,4 @@
-namespace VirtualPaper.Common.Events {
+namespace VirtualPaper.Common.Events {
public class HardwareUsageEventArgs : EventArgs {
///
/// Primary cpu name.
diff --git a/src/VirtualPaper.Common/Utils/Files/FileUtil.cs b/src/VirtualPaper.Common/Utils/Files/FileUtil.cs
index 2ef38b0c..474e4c85 100644
--- a/src/VirtualPaper.Common/Utils/Files/FileUtil.cs
+++ b/src/VirtualPaper.Common/Utils/Files/FileUtil.cs
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Security.Cryptography;
+using System.Text;
using Windows.Graphics.Imaging;
using Windows.Storage;
@@ -52,10 +53,11 @@ public static void OpenFolderByExplorer(string path) {
FileName = "explorer.exe"
};
if (File.Exists(path)) {
- startInfo.Arguments = "/select, \"" + path + "\"";
+ startInfo.ArgumentList.Add("/select,");
+ startInfo.ArgumentList.Add(path);
}
else if (Directory.Exists(path)) {
- startInfo.Arguments = "\"" + path + "\"";
+ startInfo.ArgumentList.Add(path);
}
else {
throw new FileNotFoundException();
@@ -117,10 +119,10 @@ private static string GetNextFilename(string pattern) {
}
///
- /// 计算文件校验和
+ /// 计算文件 SHA256 校验和
///
- ///
- /// SHA256 checksum.
+ /// 文件路径
+ /// SHA256 checksum (小写 hex)
public static string GetChecksumSHA256(string filePath) {
using SHA256 sha256 = SHA256.Create();
using var stream = File.OpenRead(filePath);
@@ -129,11 +131,80 @@ public static string GetChecksumSHA256(string filePath) {
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
- public static void EmptyDirectory(string directory) {
+ ///
+ /// 验证文件 SHA256 是否与预期值匹配
+ ///
+ public static async Task VerifyFileIntegrityAsync(string filePath, string expectedSha256, CancellationToken token = default) {
+ if (!File.Exists(filePath) || !IsValidSHA256(expectedSha256))
+ return false;
+ var actual = await CalculateFileSHA256Async(filePath, token);
+ return string.Equals(actual, expectedSha256, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static async Task CalculateFileSHA256Async(string filePath, CancellationToken token) {
+ using var sha256 = SHA256.Create();
+ using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 8192, true);
+ var hashBytes = await sha256.ComputeHashAsync(stream, token);
+ return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
+ }
+
+ private static bool IsValidSHA256(string sha256) {
+ if (string.IsNullOrEmpty(sha256) || sha256.Length != 64)
+ return false;
+ return System.Text.RegularExpressions.Regex.IsMatch(sha256, @"^[a-fA-F0-9]{64}$");
+ }
+
+ ///
+ /// 计算字符串内容的 SHA256 校验和(UTF-8 编码)
+ /// 自动处理 BOM:如果内容以 UTF-8 BOM 开头,会先移除再计算
+ ///
+ /// 字符串内容
+ /// SHA256 checksum (小写 hex)
+ public static string GetChecksumSHA256FromContent(string content) {
+ // 移除 UTF-8 BOM(如果存在)
+ if (content.Length > 0 && content[0] == '\uFEFF') {
+ content = content.Substring(1);
+ }
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
+ return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
+ }
+
+ public static void RemoveDirectory(string directory) {
+ if (!Directory.Exists(directory)) return;
+
DirectoryInfo di = new(directory);
di.Delete(true);
}
+ ///
+ /// 清空目录内容(删除所有文件和子目录),但保留目录本身。
+ /// 使用 Enumerate 延迟求值,适合大目录。
+ /// 跳过符号链接和联接点,避免意外删除外部文件。
+ ///
+ /// 要清空的目录路径
+ public static void DeleteDirectoryContents(string dirPath) {
+ if (!Directory.Exists(dirPath)) return;
+
+ var dir = new DirectoryInfo(dirPath);
+ var options = new EnumerationOptions {
+ AttributesToSkip = System.IO.FileAttributes.ReparsePoint // 跳过 symlink/junction
+ };
+
+ // 先删文件(延迟枚举,不一次性加载全部路径)
+ foreach (var file in dir.EnumerateFiles("*", options)) {
+ // 清除只读属性
+ if (file.Attributes.HasFlag(System.IO.FileAttributes.ReadOnly)) {
+ file.Attributes &= ~System.IO.FileAttributes.ReadOnly;
+ }
+ file.Delete();
+ }
+
+ // 再删子目录(递归删除,跳过 reparse point)
+ foreach (var subDir in dir.EnumerateDirectories("*", options)) {
+ subDir.Delete(true);
+ }
+ }
+
public static bool IsFileGreaterThanThreshold(string filePath, long bytes) {
bool result = false;
try {
@@ -356,7 +427,7 @@ public static string SizeSuffix(long value, int decimalPlaces = 1) {
ArgumentOutOfRangeException.ThrowIfNegative(decimalPlaces);
if (value < 0) { return "-" + SizeSuffix(-value, decimalPlaces); }
- string fmt = "0." + new string('#', decimalPlaces);
+ string fmt = "0." + new string('0', decimalPlaces);
if (value == 0) { return string.Format("{0:" + fmt + "} bytes", 0m); }
diff --git a/src/VirtualPaper.Common/Utils/GithubUtil.cs b/src/VirtualPaper.Common/Utils/GithubUtil.cs
index c6959ac0..859b14c1 100644
--- a/src/VirtualPaper.Common/Utils/GithubUtil.cs
+++ b/src/VirtualPaper.Common/Utils/GithubUtil.cs
@@ -1,9 +1,16 @@
-using System.Text.RegularExpressions;
+using System.Collections.Concurrent;
+using System.Text.RegularExpressions;
using Octokit;
using ProductHeaderValue = Octokit.ProductHeaderValue;
namespace VirtualPaper.Common.Utils {
public static class GithubUtil {
+ private static readonly ConcurrentDictionary _clients = new();
+
+ private static GitHubClient GetClient(string repositoryName) {
+ return _clients.GetOrAdd(repositoryName, name => new GitHubClient(new ProductHeaderValue(name)));
+ }
+
///
/// After given delay retrieve github release asset download url.
/// Returns first asset matching substring (make sure asset name is unique to get correct file.), case is ignored.
@@ -29,7 +36,7 @@ public static async Task GetLatestAssetUrl(string assetNameSubstring, st
public static async Task GetLatestRelease(string repositoryName, string userName, int startDelay = 45000) {
//wait for computer startup.. so that user and network is ready.
await Task.Delay(startDelay);
- GitHubClient client = new(new ProductHeaderValue(repositoryName));
+ var client = GetClient(repositoryName);
var releases = await client.Repository.Release.GetAll(userName, repositoryName);
var latest = releases[0];
@@ -46,7 +53,7 @@ public static async Task GetLatestRelease(string repositoryName, string
///
///
public static async Task GetAssetUrl(string assetNameSubstring, Release release, string repositoryName, string userName) {
- GitHubClient client = new(new ProductHeaderValue(repositoryName));
+ var client = GetClient(repositoryName);
var allAssets = await client.Repository.Release.GetAllAssets(userName, repositoryName, release.Id);
//var requiredAssets = allAssets.Single(x => x.Name.Equals(assetName, StringComparison.OrdinalIgnoreCase));
var requiredAsset = allAssets.First(x => Contains(x.Name, assetNameSubstring, StringComparison.OrdinalIgnoreCase));
@@ -54,7 +61,7 @@ public static async Task GetAssetUrl(string assetNameSubstring, Release
}
public static async Task> GetAllAssetUrl(string assetNameSubstring, Release release, string repositoryName, string userName) {
- GitHubClient client = new(new ProductHeaderValue(repositoryName));
+ var client = GetClient(repositoryName);
var allAssets = await client.Repository.Release.GetAllAssets(userName, repositoryName, release.Id);
//var requiredAssets = allAssets.Single(x => x.Name.Equals(assetName, StringComparison.OrdinalIgnoreCase));
var downloadUrls = allAssets
@@ -63,6 +70,16 @@ public static async Task> GetAllAssetUrl(string assetNameSub
return downloadUrls;
}
+ public static ReleaseAsset? FindAsset(Release release, string assetName) {
+ return release.Assets.FirstOrDefault(x => x.Name.Equals(assetName, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public static async Task DownloadAssetContent(ReleaseAsset asset) {
+ using var httpClient = new HttpClient();
+ httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("VirtualPaper-UpdateChecker");
+ return await httpClient.GetStringAsync(asset.BrowserDownloadUrl);
+ }
+
public static Version GetVersion(Release release) {
return new Version(Regex.Replace(release.TagName, "[A-Za-z ]", ""));
}
@@ -81,7 +98,9 @@ public static int CompareAssemblyVersion(Release release) {
return result;
}
- public static int CompareAssemblyVersion(Version version) {
+ public static int CompareAssemblyVersion(Version? version) {
+ if (version == null) return -1;
+
var appVersion = new Version(System.Reflection.Assembly.GetEntryAssembly().GetName().Version.ToString());
return version.CompareTo(appVersion);
}
diff --git a/src/VirtualPaper.Common/Utils/IPC/IpcMessage.cs b/src/VirtualPaper.Common/Utils/IPC/IpcMessage.cs
index e9beea53..b7c4d1e3 100644
--- a/src/VirtualPaper.Common/Utils/IPC/IpcMessage.cs
+++ b/src/VirtualPaper.Common/Utils/IPC/IpcMessage.cs
@@ -1,6 +1,5 @@
using System.Text.Json.Serialization;
using VirtualPaper.Common.Events;
-using VirtualPaper.Common.Events.EffectValue.Base;
namespace VirtualPaper.Common.Utils.IPC {
[JsonSerializable(typeof(IpcMessage))]
diff --git a/src/VirtualPaper.Common/Utils/IPC/PipeClient.cs b/src/VirtualPaper.Common/Utils/IPC/PipeClient.cs
deleted file mode 100644
index a87eb514..00000000
--- a/src/VirtualPaper.Common/Utils/IPC/PipeClient.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using System.IO.Pipes;
-
-namespace VirtualPaper.Common.Utils.IPC {
- public class PipeClient {
- public static void SendMessage(string channelName, string msg) {
- using var pipeClient = new NamedPipeClientStream(".", channelName, PipeDirection.Out);
- pipeClient.Connect(0);
- var writer = new StreamWriter(pipeClient) { AutoFlush = true };
- writer.Write(msg);
- writer.Flush();
- writer.Close();
- pipeClient.Dispose();
- }
- }
-}
diff --git a/src/VirtualPaper.Common/Utils/LogUtil.cs b/src/VirtualPaper.Common/Utils/LogUtil.cs
index 7f2f9ed9..3c2722fc 100644
--- a/src/VirtualPaper.Common/Utils/LogUtil.cs
+++ b/src/VirtualPaper.Common/Utils/LogUtil.cs
@@ -49,12 +49,12 @@ public static void ExportLogFiles(string savePath) {
}
var logFolderCore = Constants.CommonPaths.LogDirCore;
- if (Directory.Exists(logFolder)) {
+ if (Directory.Exists(logFolderCore)) {
files.AddRange(Directory.GetFiles(logFolderCore, "*.*", SearchOption.TopDirectoryOnly));
}
var logFolderUI = Constants.CommonPaths.LogDirUI;
- if (Directory.Exists(logFolder)) {
+ if (Directory.Exists(logFolderUI)) {
files.AddRange(Directory.GetFiles(logFolderUI, "*.*", SearchOption.TopDirectoryOnly));
}
diff --git a/src/VirtualPaper.Common/Utils/PInvoke/Native.cs b/src/VirtualPaper.Common/Utils/PInvoke/Native.cs
index 3513f0d2..ca17e418 100644
--- a/src/VirtualPaper.Common/Utils/PInvoke/Native.cs
+++ b/src/VirtualPaper.Common/Utils/PInvoke/Native.cs
@@ -1692,6 +1692,25 @@ public static extern bool SystemParametersInfo(
[DllImport("User32.dll")]
public static extern int SetForegroundWindow(IntPtr point);
+ [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
+ public static extern bool FlashWindowEx(ref FLASHWINFO pwfi);
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct FLASHWINFO {
+ public uint cbSize;
+ public IntPtr hwnd;
+ public uint dwFlags;
+ public uint uCount;
+ public uint dwTimeout;
+ }
+
+ public const uint FLASHW_STOP = 0;
+ public const uint FLASHW_CAPTION = 1;
+ public const uint FLASHW_TRAY = 2;
+ public const uint FLASHW_ALL = FLASHW_CAPTION | FLASHW_TRAY;
+ public const uint FLASHW_TIMER = 4;
+ public const uint FLASHW_TIMERNOFG = 12;
+
#endregion keyboard
#region Pause
diff --git a/src/VirtualPaper.Common/Utils/Storage/JsonSaver.cs b/src/VirtualPaper.Common/Utils/Storage/JsonSaver.cs
index a6eb15c9..f32f5012 100644
--- a/src/VirtualPaper.Common/Utils/Storage/JsonSaver.cs
+++ b/src/VirtualPaper.Common/Utils/Storage/JsonSaver.cs
@@ -11,14 +11,32 @@ static JsonSaver() {
}
public static T Load(string filePath, JsonSerializerContext context) {
- return LoadAsync(filePath, context).Result;
+ try {
+ JsonSerializerOptions combinedLoadOptions = new(_optionsLoad) { TypeInfoResolver = JsonTypeInfoResolver.Combine(context) };
+ using FileStream stream = File.OpenRead(filePath);
+ return JsonSerializer.Deserialize(stream, combinedLoadOptions)!;
+ }
+ catch (Exception ex) {
+ throw new FileAccessException(filePath, "read json", ex);
+ }
}
public static void Save(string filePath, T data, JsonSerializerContext context) {
- SaveAsync(filePath, data, context).Wait();
+ try {
+ string? directoryPath = Path.GetDirectoryName(filePath);
+ if (!string.IsNullOrEmpty(directoryPath)) {
+ Directory.CreateDirectory(directoryPath);
+ }
+ JsonSerializerOptions combinedStoreOptions = new(_optionsStore) { TypeInfoResolver = JsonTypeInfoResolver.Combine(context) };
+ using FileStream stream = File.Create(filePath);
+ JsonSerializer.Serialize(stream, data, combinedStoreOptions);
+ }
+ catch (Exception ex) {
+ throw new FileAccessException(filePath, "write json", ex);
+ }
}
- public static async Task LoadAsync(string filePath, JsonSerializerContext context, params JsonConverter[]? converters) {
+ public static async Task LoadAsync(string filePath, JsonSerializerContext context, params JsonConverter[]? converters) {
try {
JsonSerializerOptions combinedLoadOptions = new(_optionsLoad) { TypeInfoResolver = JsonTypeInfoResolver.Combine(context) };
diff --git a/src/VirtualPaper.Common/VirtualPaper.Common.csproj b/src/VirtualPaper.Common/VirtualPaper.Common.csproj
index 50983d8a..2e469109 100644
--- a/src/VirtualPaper.Common/VirtualPaper.Common.csproj
+++ b/src/VirtualPaper.Common/VirtualPaper.Common.csproj
@@ -9,11 +9,8 @@
-
-
-
diff --git a/src/VirtualPaper.Core.Test/T_AppUpdate/GithubUpdaterServiceTests.cs b/src/VirtualPaper.Core.Test/T_AppUpdate/GithubUpdaterServiceTests.cs
index 563b8d26..80a26ad4 100644
--- a/src/VirtualPaper.Core.Test/T_AppUpdate/GithubUpdaterServiceTests.cs
+++ b/src/VirtualPaper.Core.Test/T_AppUpdate/GithubUpdaterServiceTests.cs
@@ -1,6 +1,7 @@
using Moq;
-using VirtualPaper.Common.Events;
using VirtualPaper.Cores.AppUpdate;
+using VirtualPaper.Models.AppUpdate;
+using VirtualPaper.Models.Events;
using VirtualPaper.Utils.Interfcaes;
namespace VirtualPaper.Core.Test.T_AppUpdate {
@@ -8,6 +9,7 @@ namespace VirtualPaper.Core.Test.T_AppUpdate {
public class GithubUpdaterServiceTests {
private Mock _mockClient = null!;
private Mock _mockComparer = null!;
+ private Mock _mockBuildService = null!;
private GithubUpdaterService _service = null!;
private static readonly Uri FakeUri = new("https://fake/setup.exe");
@@ -15,27 +17,35 @@ public class GithubUpdaterServiceTests {
private static readonly Version FakeVersion = new(0, 0, 0, 0);
private const string FakeChangelog = "- bug fix";
+ private static ReleaseInfo CreateFakeReleaseInfo() => new() {
+ InstallerUri = FakeUri,
+ InstallerShaUri = FakeShaUri,
+ Version = FakeVersion,
+ Changelog = FakeChangelog
+ };
+
[TestInitialize]
public void TestInitialize() {
_mockClient = new Mock();
_mockComparer = new Mock();
+ _mockBuildService = new Mock();
_mockClient
.Setup(c => c.GetLatestRelease(It.IsAny()))
- .ReturnsAsync((FakeUri, FakeShaUri, FakeVersion, FakeChangelog));
+ .ReturnsAsync(CreateFakeReleaseInfo());
- _service = new GithubUpdaterService(_mockClient.Object, _mockComparer.Object);
+ _service = new GithubUpdaterService(_mockClient.Object, _mockComparer.Object, _mockBuildService.Object);
}
// -------------------------------------------------------
- // CheckUpdate - 版本比较分支
+ // CheckUpdateAsync - 版本比较分支
// -------------------------------------------------------
[TestMethod]
public async Task CheckUpdate_WhenNewerVersion_ShouldReturnAvailable() {
_mockComparer.Setup(c => c.CompareAssemblyVersion(FakeVersion)).Returns(1);
- var result = await _service.CheckUpdate(fetchDelay: 0);
+ var result = await _service.CheckUpdateAsync(fetchDelay: 0);
Assert.AreEqual(AppUpdateStatus.Available, result);
Assert.AreEqual(AppUpdateStatus.Available, _service.Status);
@@ -45,7 +55,7 @@ public async Task CheckUpdate_WhenNewerVersion_ShouldReturnAvailable() {
public async Task CheckUpdate_WhenSameVersion_ShouldReturnUptodate() {
_mockComparer.Setup(c => c.CompareAssemblyVersion(FakeVersion)).Returns(0);
- var result = await _service.CheckUpdate(fetchDelay: 0);
+ var result = await _service.CheckUpdateAsync(fetchDelay: 0);
Assert.AreEqual(AppUpdateStatus.Uptodate, result);
Assert.AreEqual(AppUpdateStatus.Uptodate, _service.Status);
@@ -55,7 +65,7 @@ public async Task CheckUpdate_WhenSameVersion_ShouldReturnUptodate() {
public async Task CheckUpdate_WhenOlderVersion_ShouldReturnInvalid() {
_mockComparer.Setup(c => c.CompareAssemblyVersion(FakeVersion)).Returns(-1);
- var result = await _service.CheckUpdate(fetchDelay: 0);
+ var result = await _service.CheckUpdateAsync(fetchDelay: 0);
Assert.AreEqual(AppUpdateStatus.Invalid, result);
Assert.AreEqual(AppUpdateStatus.Invalid, _service.Status);
@@ -67,14 +77,14 @@ public async Task CheckUpdate_WhenExceptionThrown_ShouldReturnError() {
.Setup(c => c.GetLatestRelease(It.IsAny()))
.ThrowsAsync(new HttpRequestException("network error"));
- var result = await _service.CheckUpdate(fetchDelay: 0);
+ var result = await _service.CheckUpdateAsync(fetchDelay: 0);
Assert.AreEqual(AppUpdateStatus.Error, result);
Assert.AreEqual(AppUpdateStatus.Error, _service.Status);
}
// -------------------------------------------------------
- // CheckUpdate - LastCheckTime 更新
+ // CheckUpdateAsync - LastCheckTime 更新
// -------------------------------------------------------
[TestMethod]
@@ -82,9 +92,9 @@ public async Task CheckUpdate_AfterSuccess_ShouldUpdateLastCheckTime() {
_mockComparer.Setup(c => c.CompareAssemblyVersion(FakeVersion)).Returns(0);
var before = DateTime.Now;
- await _service.CheckUpdate(fetchDelay: 0);
+ await _service.CheckUpdateAsync(fetchDelay: 0);
- Assert.IsTrue(_service.LastCheckTime >= before);
+ Assert.IsTrue(_service.LastReleaseInfo?.CheckedTime >= before);
}
[TestMethod]
@@ -94,13 +104,13 @@ public async Task CheckUpdate_WhenExceptionThrown_ShouldStillUpdateLastCheckTime
.ThrowsAsync(new Exception());
var before = DateTime.Now;
- await _service.CheckUpdate(fetchDelay: 0);
+ await _service.CheckUpdateAsync(fetchDelay: 0);
- Assert.IsTrue(_service.LastCheckTime >= before);
+ Assert.IsTrue(_service.LastReleaseInfo?.CheckedTime >= before);
}
// -------------------------------------------------------
- // CheckUpdate - UpdateChecked 事件触发
+ // CheckUpdateAsync - UpdateChecked 事件触发
// -------------------------------------------------------
[TestMethod]
@@ -109,14 +119,14 @@ public async Task CheckUpdate_AfterSuccess_ShouldFireUpdateCheckedEvent() {
AppUpdaterEventArgs? received = null;
_service.UpdateChecked += (_, args) => received = args;
- await _service.CheckUpdate(fetchDelay: 0);
+ await _service.CheckUpdateAsync(fetchDelay: 0);
Assert.IsNotNull(received);
Assert.AreEqual(AppUpdateStatus.Available, received.UpdateStatus);
- Assert.AreEqual(FakeVersion, received.UpdateVersion);
- Assert.AreEqual(FakeUri, received.UpdateUri);
- Assert.AreEqual(FakeShaUri, received.UpdateSHAUri);
- Assert.AreEqual(FakeChangelog, received.ChangeLog);
+ Assert.AreEqual(FakeVersion, received.Release?.Version);
+ Assert.AreEqual(FakeUri, received.Release?.InstallerUri);
+ Assert.AreEqual(FakeShaUri, received.Release?.InstallerShaUri);
+ Assert.AreEqual(FakeChangelog, received.Release?.Changelog);
}
[TestMethod]
@@ -127,14 +137,14 @@ public async Task CheckUpdate_WhenExceptionThrown_ShouldStillFireUpdateCheckedEv
AppUpdaterEventArgs? received = null;
_service.UpdateChecked += (_, args) => received = args;
- await _service.CheckUpdate(fetchDelay: 0);
+ await _service.CheckUpdateAsync(fetchDelay: 0);
Assert.IsNotNull(received);
Assert.AreEqual(AppUpdateStatus.Error, received.UpdateStatus);
}
// -------------------------------------------------------
- // Start / Stop
+ // StartAsync / Stop
// -------------------------------------------------------
[TestMethod]
diff --git a/src/VirtualPaper.Core.Test/T_Common/FileUtilTests.cs b/src/VirtualPaper.Core.Test/T_Common/FileUtilTests.cs
index 1c96673a..7c5e66fb 100644
--- a/src/VirtualPaper.Core.Test/T_Common/FileUtilTests.cs
+++ b/src/VirtualPaper.Core.Test/T_Common/FileUtilTests.cs
@@ -53,12 +53,12 @@ public void IsValidFilePath_InvalidChars_ReturnsFalse() {
// ── SizeSuffix ────────────────────────────────────────────────
[TestMethod]
- [DataRow(0, "0 bytes")]
- [DataRow(500, "500 bytes")]
- [DataRow(1024, "1 KB")]
+ [DataRow(0, "0.0 bytes")]
+ [DataRow(500, "500.0 bytes")]
+ [DataRow(1024, "1.0 KB")]
[DataRow(1536, "1.5 KB")]
- [DataRow(1048576, "1 MB")]
- [DataRow(1073741824, "1 GB")]
+ [DataRow(1048576, "1.0 MB")]
+ [DataRow(1073741824, "1.0 GB")]
public void SizeSuffix_CorrectFormatting(long value, string expected) {
Assert.AreEqual(expected, FileUtil.SizeSuffix(value));
}
@@ -73,7 +73,7 @@ public void SizeSuffix_NegativeValue_HasMinusPrefix() {
public void SizeSuffix_LargeValue_RoundsUp() {
// 1024 MB → 应显示为 1 GB 而非 1024.0 MB
var result = FileUtil.SizeSuffix(1024L * 1024 * 1024);
- Assert.AreEqual("1 GB", result);
+ Assert.AreEqual("1.0 GB", result);
}
// ── NextAvailableFilename ─────────────────────────────────────
diff --git a/src/VirtualPaper.Core.Test/T_ScrSreen/ScrControlTests.cs b/src/VirtualPaper.Core.Test/T_ScrSreen/ScrControlTests.cs
index 709eea86..0b173548 100644
--- a/src/VirtualPaper.Core.Test/T_ScrSreen/ScrControlTests.cs
+++ b/src/VirtualPaper.Core.Test/T_ScrSreen/ScrControlTests.cs
@@ -97,38 +97,38 @@ private void SimulateProcessExited() {
}
// ----------------------------------------------------------------
- // Start / Stop 生命周期
+ // StartAsync / Stop 生命周期
// ----------------------------------------------------------------
[TestMethod]
- [Description("Start should configure timer interval from settings and start it")]
+ [Description("StartAsync should configure timer interval from settings and start it")]
public void Start_ShouldConfigureAndStartTimer() {
_settings.Object.Settings.WaitingTime = 5;
- _sut.Start();
+ _sut.StartAsync();
_timer.VerifySet(t => t.Interval = TimeSpan.FromMinutes(5), Times.Once);
_timer.Verify(t => t.Start(), Times.Once);
}
[TestMethod]
- [Description("Start should not start the timer if it is already timing")]
+ [Description("StartAsync should not start the timer if it is already timing")]
public void Start_WhenAlreadyTiming_ShouldNotStartAgain() {
- _sut.Start();
- _sut.Start();
+ _sut.StartAsync();
+ _sut.StartAsync();
_timer.Verify(t => t.Start(), Times.Once,
"Timer should only be started once");
}
[TestMethod]
- [Description("Start should not start the timer if screensaver is already running")]
+ [Description("StartAsync should not start the timer if screensaver is already running")]
public void Start_WhenAlreadyRunning_ShouldNotStartTimer() {
- _sut.Start();
+ _sut.StartAsync();
FireTick();
SimulateWpLoaded(); // IsRunning = true
- _sut.Start();
+ _sut.StartAsync();
_timer.Verify(t => t.Start(), Times.Once);
}
@@ -136,7 +136,7 @@ public void Start_WhenAlreadyRunning_ShouldNotStartTimer() {
[TestMethod]
[Description("Stop should stop the timer")]
public void Stop_ShouldStopTimer() {
- _sut.Start();
+ _sut.StartAsync();
_sut.Stop();
@@ -150,7 +150,7 @@ public void Stop_ShouldStopTimer() {
[TestMethod]
[Description("Tick should stop the timer before evaluating launch conditions")]
public void Tick_ShouldStopTimerFirst() {
- _sut.Start();
+ _sut.StartAsync();
FireTick();
@@ -164,7 +164,7 @@ public void Tick_WhenNoPrimaryWallpaper_ShouldNotLaunch() {
.Setup(w => w.GetPrimaryWpFilePathRType())
.Returns((null, RuntimeType.RUnknown));
_settings.Object.Settings.IsScreenSaverOn = true;
- _sut.Start();
+ _sut.StartAsync();
FireTick();
@@ -179,7 +179,7 @@ public void Tick_WhenSystemIsBusy_ShouldNotLaunch() {
.Setup(n => n.SHQueryUserNotificationState(out busyState))
.Returns(0);
_settings.Object.Settings.IsScreenSaverOn = true;
- _sut.Start();
+ _sut.StartAsync();
FireTick();
@@ -192,7 +192,7 @@ public void Tick_WhenForegroundProcInWhitelist_ShouldNotLaunch() {
_native.Setup(n => n.GetProcessNameById(It.IsAny())).Returns("chrome");
_sut.AddToWhiteList("chrome");
_settings.Object.Settings.IsScreenSaverOn = true;
- _sut.Start();
+ _sut.StartAsync();
FireTick();
@@ -202,7 +202,7 @@ public void Tick_WhenForegroundProcInWhitelist_ShouldNotLaunch() {
[TestMethod]
[Description("Tick should launch screensaver when all conditions are met")]
public void Tick_WhenAllConditionsMet_ShouldLaunchProcess() {
- _sut.Start();
+ _sut.StartAsync();
FireTick();
@@ -212,7 +212,7 @@ public void Tick_WhenAllConditionsMet_ShouldLaunchProcess() {
[TestMethod]
[Description("Tick should call BeginOutputReadLine after launch to start reading stdout")]
public void Tick_AfterLaunch_ShouldBeginOutputReadLine() {
- _sut.Start();
+ _sut.StartAsync();
FireTick();
@@ -232,7 +232,7 @@ public void Tick_ShouldPassCorrectArgsToProcess() {
.Setup(l => l.Launch(It.IsAny()))
.Callback(info => capturedInfo = info);
- _sut.Start();
+ _sut.StartAsync();
FireTick();
Assert.IsNotNull(capturedInfo);
@@ -247,7 +247,7 @@ public void Tick_ShouldLaunchCorrectExecutable() {
_launcher
.Setup(l => l.Launch(It.IsAny()))
.Callback(psi => captured = psi);
- _sut.Start();
+ _sut.StartAsync();
FireTick();
@@ -266,7 +266,7 @@ public void Tick_WhenLaunchThrows_ShouldNotCrashAndRestartTimer() {
_launcher
.Setup(l => l.Launch(It.IsAny()))
.Throws(new InvalidOperationException("Process start failed"));
- _sut.Start();
+ _sut.StartAsync();
try {
FireTick();
@@ -286,7 +286,7 @@ public void Tick_WhenLaunchThrows_IsRunningShouldRemainFalse() {
_launcher
.Setup(l => l.Launch(It.IsAny()))
.Throws(new InvalidOperationException("Process start failed"));
- _sut.Start();
+ _sut.StartAsync();
try { FireTick(); } catch { /* ignored */ }
@@ -300,7 +300,7 @@ public void Tick_WhenLaunchThrows_IsRunningShouldRemainFalse() {
[TestMethod]
[Description("After WpLoaded signal received, IsRunning should become true")]
public void OutputDataReceived_WpLoaded_IsRunningShouldBecomeTrue() {
- _sut.Start();
+ _sut.StartAsync();
Assert.IsNotNull(_capturedTick, "Tick未被注册");
@@ -317,7 +317,7 @@ public void OutputDataReceived_WpLoaded_IsRunningShouldBecomeTrue() {
[TestMethod]
[Description("After WpLoaded, timer should not restart (screensaver is active)")]
public void OutputDataReceived_WpLoaded_TimerShouldNotRestart() {
- _sut.Start();
+ _sut.StartAsync();
FireTick();
int startCountAfterTick = _timer.Invocations
.Count(i => i.Method.Name == nameof(IDispatcherTimer.Start));
@@ -334,7 +334,7 @@ public void OutputDataReceived_WpLoaded_TimerShouldNotRestart() {
[TestMethod]
[Description("OutputDataReceived with null data should not throw")]
public void OutputDataReceived_NullData_ShouldNotThrow() {
- _sut.Start();
+ _sut.StartAsync();
FireTick();
try {
@@ -350,7 +350,7 @@ public void OutputDataReceived_NullData_ShouldNotThrow() {
[TestMethod]
[Description("OutputDataReceived with unrecognized data should not affect IsRunning")]
public void OutputDataReceived_UnrecognizedData_IsRunningShouldRemainFalse() {
- _sut.Start();
+ _sut.StartAsync();
FireTick();
_launcher.Raise(
@@ -367,7 +367,7 @@ public void OutputDataReceived_UnrecognizedData_IsRunningShouldRemainFalse() {
[TestMethod]
[Description("When process exits normally, IsRunning should become false")]
public void ProcessExited_Normal_IsRunningShouldBecomeFalse() {
- _sut.Start();
+ _sut.StartAsync();
FireTick();
SimulateWpLoaded(); // IsRunning = true
@@ -380,7 +380,7 @@ public void ProcessExited_Normal_IsRunningShouldBecomeFalse() {
[TestMethod]
[Description("When process exits normally, timer should restart to allow re-trigger")]
public void ProcessExited_Normal_TimerShouldRestart() {
- _sut.Start();
+ _sut.StartAsync();
FireTick();
SimulateWpLoaded();
@@ -393,7 +393,7 @@ public void ProcessExited_Normal_TimerShouldRestart() {
[TestMethod]
[Description("When process exits unexpectedly (before WpLoaded), state should still reset")]
public void ProcessExited_BeforeWpLoaded_ShouldResetState() {
- _sut.Start();
+ _sut.StartAsync();
FireTick();
// 不调用 SimulateWpLoaded,直接退出
@@ -406,7 +406,7 @@ public void ProcessExited_BeforeWpLoaded_ShouldResetState() {
[TestMethod]
[Description("When process exits, it should be disposed")]
public void ProcessExited_ShouldDisposeProcess() {
- _sut.Start();
+ _sut.StartAsync();
FireTick();
SimulateWpLoaded();
@@ -426,7 +426,7 @@ public void AddToWhiteList_ShouldBlockLaunch() {
_native.Setup(n => n.GetProcessNameById(It.IsAny())).Returns("notepad");
_sut.AddToWhiteList("notepad");
- _sut.Start();
+ _sut.StartAsync();
FireTick();
_launcher.Verify(l => l.Launch(It.IsAny()), Times.Never);
@@ -439,7 +439,7 @@ public void RemoveFromWhiteList_ShouldAllowLaunchAfterRemoval() {
_sut.AddToWhiteList("notepad");
_sut.RemoveFromWhiteList("notepad");
- _sut.Start();
+ _sut.StartAsync();
FireTick();
_launcher.Verify(l => l.Launch(It.IsAny()), Times.Once);
@@ -451,7 +451,7 @@ public void AddToWhiteList_CaseInsensitive_ShouldBlockLaunch() {
_native.Setup(n => n.GetProcessNameById(It.IsAny())).Returns("Notepad");
_sut.AddToWhiteList("notepad"); // 小写加入
- _sut.Start();
+ _sut.StartAsync();
FireTick();
_launcher.Verify(l => l.Launch(It.IsAny()), Times.Never,
@@ -479,7 +479,7 @@ public void AddToWhiteList_Duplicate_ShouldNotThrow() {
public void Tick_WhenScreenSaverOff_ShouldNotLaunch() {
_settings.Object.Settings.IsScreenSaverOn = false;
_sut = BuildSut();
- _sut.Start();
+ _sut.StartAsync();
FireTick();
@@ -493,7 +493,7 @@ public void Tick_WhenScreenSaverOff_AllOtherConditionsMet_ShouldNotLaunch() {
// All other conditions are met (from base Setup), just flip the switch
_settings.Object.Settings.IsScreenSaverOn = false;
_sut = BuildSut();
- _sut.Start();
+ _sut.StartAsync();
FireTick();
@@ -507,7 +507,7 @@ public void Tick_WhenScreenSaverOff_AllOtherConditionsMet_ShouldNotLaunch() {
[TestMethod]
[Description("Tick should not launch screensaver again if it is already running")]
public void Tick_WhenAlreadyRunning_ShouldNotLaunchAgain() {
- _sut.Start();
+ _sut.StartAsync();
FireTick();
SimulateWpLoaded(); // IsRunning = true
@@ -524,7 +524,7 @@ public void Tick_WhenAlreadyRunning_ShouldNotLaunchAgain() {
[TestMethod]
[Description("After screensaver process exits, the next Tick should be allowed to launch again")]
public void Tick_AfterProcessExited_NextTickCanLaunchAgain() {
- _sut.Start();
+ _sut.StartAsync();
FireTick();
SimulateWpLoaded(); // IsRunning = true
SimulateProcessExited(); // IsRunning = false
diff --git a/src/VirtualPaper.Core.Test/VirtualPaper.Core.Test.csproj b/src/VirtualPaper.Core.Test/VirtualPaper.Core.Test.csproj
index e6ee48a0..46cea615 100644
--- a/src/VirtualPaper.Core.Test/VirtualPaper.Core.Test.csproj
+++ b/src/VirtualPaper.Core.Test/VirtualPaper.Core.Test.csproj
@@ -6,6 +6,7 @@
enable
enable
true
+ x64
diff --git a/src/VirtualPaper.DraftPanel/VirtualPaper.DraftPanel.csproj b/src/VirtualPaper.DraftPanel/VirtualPaper.DraftPanel.csproj
index fc5020dd..ee8d8743 100644
--- a/src/VirtualPaper.DraftPanel/VirtualPaper.DraftPanel.csproj
+++ b/src/VirtualPaper.DraftPanel/VirtualPaper.DraftPanel.csproj
@@ -3,8 +3,7 @@
net8.0-windows10.0.19041.0
10.0.19041.0
VirtualPaper.DraftPanel
- win-x86;win-x64;win-arm64
- win10-x86;win10-x64;win10-arm64
+ false
true
true
enable
diff --git a/src/VirtualPaper.Grpc.Client/AppUpdaterClient.cs b/src/VirtualPaper.Grpc.Client/AppUpdaterClient.cs
index f9df02e9..c5b97962 100644
--- a/src/VirtualPaper.Grpc.Client/AppUpdaterClient.cs
+++ b/src/VirtualPaper.Grpc.Client/AppUpdaterClient.cs
@@ -2,21 +2,23 @@
using Grpc.Core;
using GrpcDotNetNamedPipes;
using VirtualPaper.Common;
-using VirtualPaper.Common.Events;
using VirtualPaper.Common.Logging;
using VirtualPaper.Grpc.Client.Interfaces;
using VirtualPaper.Grpc.Service.Update;
+using VirtualPaper.Models.AppUpdate;
+using VirtualPaper.Models.Events;
namespace VirtualPaper.Grpc.Client {
public partial class AppUpdaterClient : IAppUpdaterClient {
public event EventHandler? UpdateChecked;
public AppUpdateStatus Status { get; private set; } = AppUpdateStatus.Notchecked;
- public DateTime LastCheckTime { get; private set; } = DateTime.MinValue;
- public Version LastCheckVersion { get; private set; } = new Version(0, 0, 0, 0);
- public string LastCheckChangelog { get; private set; } = string.Empty;
- public Uri LastCheckUri { get; private set; }
- public Uri LastCheckShaUri { get; private set; }
+ //public DateTime LastCheckTime { get; private set; } = DateTime.MinValue;
+ //public Version LastCheckVersion { get; private set; } = new Version(0, 0, 0, 0);
+ //public string LastCheckChangelog { get; private set; } = string.Empty;
+ //public Uri LastCheckUri { get; private set; }
+ //public Uri LastCheckShaUri { get; private set; }
+ public ReleaseInfo Release { get; private set; } = new ReleaseInfo();
public AppUpdaterClient() {
_client = new Grpc_UpdateService.Grpc_UpdateServiceClient(new NamedPipeChannel(".", Constants.CoreField.GrpcPipeServerName));
@@ -40,12 +42,13 @@ public async Task StartDownloadAsync() {
private async Task UpdateStatusRefresh() {
var resp = await _client.GetUpdateStatusAsync(new Empty());
Status = (AppUpdateStatus)((int)resp.Status);
- LastCheckTime = resp.Time.ToDateTime().ToLocalTime();
- LastCheckChangelog = resp.Changelog;
+ Release.CheckedTime = resp.Time.ToDateTime().ToLocalTime();
+ Release.Changelog = resp.Changelog;
+ Release.AppBuild = resp.AppBuild;
try {
- LastCheckVersion = string.IsNullOrEmpty(resp.Version) ? null : new Version(resp.Version);
- LastCheckUri = string.IsNullOrEmpty(resp.Uri) ? null : new Uri(resp.Uri);
- LastCheckShaUri = string.IsNullOrEmpty(resp.ShaUri) ? null : new Uri(resp.ShaUri);
+ Release.Version = string.IsNullOrEmpty(resp.Version) ? null : new Version(resp.Version);
+ Release.InstallerUri = string.IsNullOrEmpty(resp.Uri) ? null : new Uri(resp.Uri);
+ Release.InstallerShaUri = string.IsNullOrEmpty(resp.ShaUri) ? null : new Uri(resp.ShaUri);
}
catch { /* TODO */ }
}
@@ -58,7 +61,7 @@ private async Task SubscribeUpdateCheckedStream(CancellationToken token) {
try {
var resp = call.ResponseStream.Current;
await UpdateStatusRefresh();
- UpdateChecked?.Invoke(this, new AppUpdaterEventArgs(Status, LastCheckVersion, LastCheckTime, LastCheckUri, LastCheckShaUri, LastCheckChangelog));
+ UpdateChecked?.Invoke(this, new AppUpdaterEventArgs(Status, Release));
}
finally {
_updateCheckedLock.Release();
diff --git a/src/VirtualPaper.Grpc.Client/CommandsClient.cs b/src/VirtualPaper.Grpc.Client/CommandsClient.cs
index 6eaf62fa..af4f98bd 100644
--- a/src/VirtualPaper.Grpc.Client/CommandsClient.cs
+++ b/src/VirtualPaper.Grpc.Client/CommandsClient.cs
@@ -17,7 +17,7 @@ public CommandsClient() {
_uiRecievedCmdTask = Task.Run(() => SubscribeUIRecievedCmdTaskStream(_ctsUIRecievedCmd.Token));
}
- public async Task ShowUI() {
+ public async Task ShowUIAsync() {
await _client.ShowUIAsync(new Empty());
}
diff --git a/src/VirtualPaper.Grpc.Client/Interfaces/IAppUpdaterClient.cs b/src/VirtualPaper.Grpc.Client/Interfaces/IAppUpdaterClient.cs
index 4f0f015a..758e0a5b 100644
--- a/src/VirtualPaper.Grpc.Client/Interfaces/IAppUpdaterClient.cs
+++ b/src/VirtualPaper.Grpc.Client/Interfaces/IAppUpdaterClient.cs
@@ -1,11 +1,13 @@
-using VirtualPaper.Common.Events;
+using VirtualPaper.Models.AppUpdate;
+using VirtualPaper.Models.Events;
namespace VirtualPaper.Grpc.Client.Interfaces {
public interface IAppUpdaterClient : IDisposable {
- string LastCheckChangelog { get; }
- DateTime LastCheckTime { get; }
- Uri LastCheckUri { get; }
- Version LastCheckVersion { get; }
+ //string LastCheckChangelog { get; }
+ //DateTime LastCheckTime { get; }
+ //Uri LastCheckUri { get; }
+ //Version LastCheckVersion { get; }
+ ReleaseInfo Release { get; }
AppUpdateStatus Status { get; }
event EventHandler? UpdateChecked;
diff --git a/src/VirtualPaper.Grpc.Client/Interfaces/ICommandsClient.cs b/src/VirtualPaper.Grpc.Client/Interfaces/ICommandsClient.cs
index 504b3cb4..da6cabd2 100644
--- a/src/VirtualPaper.Grpc.Client/Interfaces/ICommandsClient.cs
+++ b/src/VirtualPaper.Grpc.Client/Interfaces/ICommandsClient.cs
@@ -2,7 +2,7 @@ namespace VirtualPaper.Grpc.Client.Interfaces {
public interface ICommandsClient : IDisposable {
event EventHandler? UIRecieveCmd;
- Task ShowUI();
+ Task ShowUIAsync();
Task CloseUI();
Task RestartUI();
Task ShowDebugView();
diff --git a/src/VirtualPaper.Grpc.Service/Protos/common_models.proto b/src/VirtualPaper.Grpc.Service/Protos/common_models.proto
index 09f550ee..cb1dee2d 100644
--- a/src/VirtualPaper.Grpc.Service/Protos/common_models.proto
+++ b/src/VirtualPaper.Grpc.Service/Protos/common_models.proto
@@ -83,7 +83,8 @@ message Grpc_UpdateResponse {
string uri = 3;
string sha_uri = 4;
string changelog = 5;
- google.protobuf.Timestamp time = 6;
+ string app_build = 6;
+ google.protobuf.Timestamp time = 7;
}
message Grpc_WallpaperLayoutsSettings {
diff --git a/src/VirtualPaper.Models/AppUpdate/AppBuildInfo.cs b/src/VirtualPaper.Models/AppUpdate/AppBuildInfo.cs
new file mode 100644
index 00000000..e239301f
--- /dev/null
+++ b/src/VirtualPaper.Models/AppUpdate/AppBuildInfo.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+
+namespace VirtualPaper.Models.AppUpdate {
+ [JsonSerializable(typeof(AppBuildInfo))]
+ public partial class AppBuildInfoContext : JsonSerializerContext { }
+
+ public class AppBuildInfo {
+ [JsonPropertyName("app_build_number")]
+ public string AppBuildNumber { get; set; } = string.Empty;
+
+ [JsonPropertyName("plugins")]
+ public Dictionary Plugins { get; set; } = new();
+ }
+}
diff --git a/src/VirtualPaper.Models/AppUpdate/ReleaseInfo.cs b/src/VirtualPaper.Models/AppUpdate/ReleaseInfo.cs
new file mode 100644
index 00000000..4b9b11d1
--- /dev/null
+++ b/src/VirtualPaper.Models/AppUpdate/ReleaseInfo.cs
@@ -0,0 +1,22 @@
+using VirtualPaper.Cores.AppUpdate.Models;
+
+namespace VirtualPaper.Models.AppUpdate {
+ public class ReleaseInfo {
+ public Version? Version { get; set; }
+ public string? AppBuild { get; set; }
+ public string Changelog { get; set; } = string.Empty;
+ public DateTime CheckedTime { get; set; }
+
+ // For plugin update (plugins_patch.zip)
+ public Uri? PluginPatchUri { get; set; }
+ public Uri? PluginPatchSha256Uri { get; set; }
+ public AppCompManifest? AppCompManifest { get; set; }
+
+ // For installer update
+ public Uri? InstallerUri { get; set; }
+ public Uri? InstallerShaUri { get; set; }
+
+ public bool IsPluginsUpdate => PluginPatchUri != null;
+ public bool IsInstallerUpdate => !IsPluginsUpdate;
+ }
+}
diff --git a/src/VirtualPaper.Models/AppUpdate/UpdateFailedNotice.cs b/src/VirtualPaper.Models/AppUpdate/UpdateFailedNotice.cs
new file mode 100644
index 00000000..473b9c25
--- /dev/null
+++ b/src/VirtualPaper.Models/AppUpdate/UpdateFailedNotice.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+
+namespace VirtualPaper.Models.AppUpdate {
+ [JsonSerializable(typeof(UpdateFailedNotice))]
+ public partial class UpdateFailedNoticeContext : JsonSerializerContext { }
+
+ public class UpdateFailedNotice {
+ [JsonPropertyName("message_key")]
+ public string MessageKey { get; set; } = string.Empty;
+
+ [JsonPropertyName("exception_message")]
+ public string ExceptionMessage { get; set; } = string.Empty;
+ }
+}
diff --git a/src/VirtualPaper.Models/AppUpdate/UpdateFlag.cs b/src/VirtualPaper.Models/AppUpdate/UpdateFlag.cs
new file mode 100644
index 00000000..a8d5f592
--- /dev/null
+++ b/src/VirtualPaper.Models/AppUpdate/UpdateFlag.cs
@@ -0,0 +1,45 @@
+using System.Text.Json.Serialization;
+
+namespace VirtualPaper.Models.AppUpdate {
+ [JsonSerializable(typeof(UpdateFlag))]
+ [JsonSerializable(typeof(PluginFlagInfo))]
+ [JsonSerializable(typeof(FileHashInfo))]
+ public partial class UpdateFlagContext : JsonSerializerContext { }
+
+ public class UpdateFlag {
+ [JsonPropertyName("status")]
+ public string Status { get; set; } = UpdateStatusPending;
+
+ [JsonPropertyName("app_build_number")]
+ public string AppBuildNumber { get; set; } = string.Empty;
+
+ [JsonPropertyName("plugins")]
+ public Dictionary Plugins { get; set; } = new();
+
+ [JsonPropertyName("removed_plugins")]
+ public List RemovedPlugins { get; set; } = new();
+
+ public const string UpdateStatusPending = "pending";
+ public const string UpdateStatusInProgress = "in_progress";
+ public const string UpdateStatusCompleted = "completed";
+ }
+
+ public class PluginFlagInfo {
+ [JsonPropertyName("target")]
+ public string Target { get; set; } = string.Empty;
+
+ [JsonPropertyName("build")]
+ public string Build { get; set; } = string.Empty;
+
+ [JsonPropertyName("files")]
+ public List Files { get; set; } = new();
+ }
+
+ public class FileHashInfo {
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonPropertyName("sha256")]
+ public string Sha256 { get; set; } = string.Empty;
+ }
+}
diff --git a/src/VirtualPaper.Models/AppUpdate/UpdateManifest.cs b/src/VirtualPaper.Models/AppUpdate/UpdateManifest.cs
new file mode 100644
index 00000000..6c957eb4
--- /dev/null
+++ b/src/VirtualPaper.Models/AppUpdate/UpdateManifest.cs
@@ -0,0 +1,38 @@
+using System.Text.Json.Serialization;
+
+namespace VirtualPaper.Cores.AppUpdate.Models {
+ [JsonSerializable(typeof(AppCompManifest))]
+ [JsonSerializable(typeof(PendingUpdateManifest))]
+ [JsonSerializable(typeof(PendingPluginInfo))]
+ public partial class UpdateManifestContext : JsonSerializerContext { }
+
+ ///
+ /// app_comp_manifest.json — 记录 app build number 和所有插件的 build number
+ ///
+ public class AppCompManifest {
+ [JsonPropertyName("app_build_number")]
+ public string AppBuildNumber { get; set; } = string.Empty;
+
+ [JsonPropertyName("plugins")]
+ public Dictionary Plugins { get; set; } = new();
+ }
+
+ ///
+ /// pending_update_plugins_manifest.json — 记录需要更新的插件信息
+ ///
+ public class PendingUpdateManifest {
+ [JsonPropertyName("plugins")]
+ public Dictionary Plugins { get; set; } = new();
+ }
+
+ public class PendingPluginInfo {
+ [JsonPropertyName("build_number")]
+ public string BuildNumber { get; set; } = string.Empty;
+
+ [JsonPropertyName("asset")]
+ public string Asset { get; set; } = string.Empty;
+
+ [JsonPropertyName("sha256")]
+ public string Sha256 { get; set; } = string.Empty;
+ }
+}
diff --git a/src/VirtualPaper.Models/Events/AppUpdaterEventArgs.cs b/src/VirtualPaper.Models/Events/AppUpdaterEventArgs.cs
new file mode 100644
index 00000000..8fbbbf9f
--- /dev/null
+++ b/src/VirtualPaper.Models/Events/AppUpdaterEventArgs.cs
@@ -0,0 +1,26 @@
+using System.ComponentModel;
+using VirtualPaper.Models.AppUpdate;
+
+namespace VirtualPaper.Models.Events {
+ public class AppUpdaterEventArgs(AppUpdateStatus updateStatus, ReleaseInfo? release) : EventArgs {
+ public AppUpdateStatus UpdateStatus { get; } = updateStatus;
+ public ReleaseInfo? Release { get; } = release;
+ }
+
+ public enum AppUpdateStatus {
+ [Description("Software is up-to-date.")]
+ Uptodate,
+ [Description("Update available.")]
+ Available,
+ [Description("Installed software version higher than whats available online.")]
+ Invalid,
+ [Description("Update not checked yet.")]
+ Notchecked,
+ [Description("Update check failed.")]
+ Error,
+ [Description("Installer ready.")]
+ InstallerReady,
+ [Description("Plugins ready.")]
+ PluginsReady,
+ }
+}
diff --git a/src/VirtualPaper.PlayerWeb.Core/Utils/Interfaces/IEffectService.cs b/src/VirtualPaper.PlayerWeb.Core/Utils/Interfaces/IEffectService.cs
index f8440b24..5518ac66 100644
--- a/src/VirtualPaper.PlayerWeb.Core/Utils/Interfaces/IEffectService.cs
+++ b/src/VirtualPaper.PlayerWeb.Core/Utils/Interfaces/IEffectService.cs
@@ -1,4 +1,4 @@
-using VirtualPaper.Common.Events.EffectValue.Base;
+using VirtualPaper.Common.Events;
namespace VirtualPaper.PlayerWeb.Core.Utils.Interfaces {
public interface IEffectService {
diff --git a/src/VirtualPaper.PlayerWeb.Core/VirtualPaper.PlayerWeb.Core.csproj b/src/VirtualPaper.PlayerWeb.Core/VirtualPaper.PlayerWeb.Core.csproj
index c65cfd9f..b90beffe 100644
--- a/src/VirtualPaper.PlayerWeb.Core/VirtualPaper.PlayerWeb.Core.csproj
+++ b/src/VirtualPaper.PlayerWeb.Core/VirtualPaper.PlayerWeb.Core.csproj
@@ -3,7 +3,7 @@
net8.0-windows10.0.19041.0
true
VirtualPaper.PlayerWeb.Core
- win-x86;win-x64;win-arm64
+ false
true
10.0.19041.0
enable
diff --git a/src/VirtualPaper.PlayerWeb.Core/WebView/Components/General/GeneralEffect.xaml.cs b/src/VirtualPaper.PlayerWeb.Core/WebView/Components/General/GeneralEffect.xaml.cs
index b4b4a0f7..ccd7b4bb 100644
--- a/src/VirtualPaper.PlayerWeb.Core/WebView/Components/General/GeneralEffect.xaml.cs
+++ b/src/VirtualPaper.PlayerWeb.Core/WebView/Components/General/GeneralEffect.xaml.cs
@@ -2,20 +2,17 @@
using System.Collections.Generic;
using System.IO;
using System.Text.Json.Nodes;
-using System.Threading.Tasks;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Navigation;
using VirtualPaper.Common;
-using VirtualPaper.Common.Events.EffectValue;
-using VirtualPaper.Common.Events.EffectValue.Base;
+using VirtualPaper.Common.Events;
using VirtualPaper.Common.Runtime.PlayerWeb;
using VirtualPaper.Common.Utils.Storage;
using VirtualPaper.PlayerWeb.Core.Interfaces;
using VirtualPaper.PlayerWeb.Core.Utils.Interfaces;
using VirtualPaper.UIComponent.Utils;
-using VirtualPaper.UIComponent.Utils.Extensions;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
diff --git a/src/VirtualPaper.PlayerWeb.Core/WebView/Pages/PageWithPlaying.xaml.cs b/src/VirtualPaper.PlayerWeb.Core/WebView/Pages/PageWithPlaying.xaml.cs
index 52a67ab1..5e7caa4d 100644
--- a/src/VirtualPaper.PlayerWeb.Core/WebView/Pages/PageWithPlaying.xaml.cs
+++ b/src/VirtualPaper.PlayerWeb.Core/WebView/Pages/PageWithPlaying.xaml.cs
@@ -6,7 +6,7 @@
using Microsoft.UI.Xaml.Controls;
using Microsoft.Web.WebView2.Core;
using VirtualPaper.Common;
-using VirtualPaper.Common.Events.EffectValue.Base;
+using VirtualPaper.Common.Events;
using VirtualPaper.Common.Logging;
using VirtualPaper.Common.Runtime.PlayerWeb;
using VirtualPaper.Common.Utils;
@@ -79,7 +79,7 @@ private void InputLayer_PointerEntered(object sender, Microsoft.UI.Xaml.Input.Po
}
private void InputLayer_PointerMoved(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e) {
- if (_isParallaxRunning != 1) return;
+ //if (_isParallaxRunning != 1) return;
var point = e.GetCurrentPoint(this);
_mousePos = point.Position;
@@ -378,11 +378,11 @@ private void OnUnloaded() {
private ArcWindow? _arcWindow;
private Point _mousePos;
private Rect _pageRegion;
- private volatile int _isParallaxRunning = 0; // 0 = stopped, 1 = running
+ //private volatile int _isParallaxRunning = 0; // 0 = stopped, 1 = running
private bool _isPointerInsidePage;
private readonly TaskCompletionSource _loadedTcs = new();
private static readonly CoreWebView2EnvironmentOptions _environmentOptions = new() {
- AdditionalBrowserArguments = "--disable-web-security --allow-file-access --allow-file-access-from-files --disk-cache-size=1 --autoplay-policy=no-user-gesture-required "
- }; // workaround: avoid cache
+ AdditionalBrowserArguments = "--disk-cache-size=1 --autoplay-policy=no-user-gesture-required"
+ };
}
}
diff --git a/src/VirtualPaper.PlayerWeb.Core/WebView/Windows/AdjustConfig.xaml.cs b/src/VirtualPaper.PlayerWeb.Core/WebView/Windows/AdjustConfig.xaml.cs
index e920fc26..fd832809 100644
--- a/src/VirtualPaper.PlayerWeb.Core/WebView/Windows/AdjustConfig.xaml.cs
+++ b/src/VirtualPaper.PlayerWeb.Core/WebView/Windows/AdjustConfig.xaml.cs
@@ -2,7 +2,7 @@
using System.IO;
using System.Text.Json;
using Microsoft.UI.Xaml;
-using VirtualPaper.Common.Events.EffectValue.Base;
+using VirtualPaper.Common.Events;
using VirtualPaper.Common.Logging;
using VirtualPaper.Common.Runtime.PlayerWeb;
using VirtualPaper.Common.Utils.Storage;
@@ -55,7 +55,7 @@ private void NaviContent_Loaded(object sender, RoutedEventArgs e) {
private async void AfterFeReady() {
_wpBasicData ??= await JsonSaver.LoadAsync(_startArgs.WpBasicDataFilePath, WpBasicDataContext.Default);
- string windowTitle = !string.IsNullOrEmpty(_wpBasicData.Title) ? $"{_wpBasicData.Title} (Adjust)" :
+ string windowTitle = !string.IsNullOrEmpty(_wpBasicData?.Title) ? $"{_wpBasicData.Title} (Adjust)" :
(!string.IsNullOrEmpty(_startArgs.FilePath) ? $"{Path.GetFileName(_startArgs.FilePath)} (Adjust)" : "Virtual Paper PlayerWeb (Adjust)");
this.Title = this.MainHost.Title = windowTitle;
}
@@ -68,8 +68,8 @@ public void UpdateEffectValue(EffectValueChanged e) {
EffectChanged?.Invoke(this, e);
}
- private readonly StartArgsWeb? _startArgs;
+ private readonly StartArgsWeb _startArgs = null!;
private readonly ArcWindowManagerKey _windowKey;
- private WpBasicData _wpBasicData;
+ private WpBasicData? _wpBasicData;
}
}
diff --git a/src/VirtualPaper.PlayerWeb/Extensions/CoreWebView2Extensions.cs b/src/VirtualPaper.PlayerWeb/Extensions/CoreWebView2Extensions.cs
index 1681e4d0..925bf6f5 100644
--- a/src/VirtualPaper.PlayerWeb/Extensions/CoreWebView2Extensions.cs
+++ b/src/VirtualPaper.PlayerWeb/Extensions/CoreWebView2Extensions.cs
@@ -15,7 +15,7 @@ public static void MapWallpaperVirtualHost(this WebView webView) {
webView.CoreWebView2.SetVirtualHostNameToFolderMapping(
hostName,
folder,
- CoreWebView2HostResourceAccessKind.Allow
+ CoreWebView2HostResourceAccessKind.DenyCors
);
}
@@ -32,11 +32,11 @@ public static void NavigateToLocalPath(this WebView webView, string filePath) {
webView.CoreWebView2.SetVirtualHostNameToFolderMapping(
hostName,
directoryPath,
- CoreWebView2HostResourceAccessKind.Allow);
+ CoreWebView2HostResourceAccessKind.DenyCors);
webView.CoreWebView2.SetVirtualHostNameToFolderMapping(
WallpaperHost,
Path.Combine(Constants.CommonPaths.LibraryDir, Constants.FolderName.WpStoreFolderName),
- CoreWebView2HostResourceAccessKind.Allow);
+ CoreWebView2HostResourceAccessKind.DenyCors);
webView.CoreWebView2.Navigate($"https://{hostName}/{fileName}");
}
diff --git a/src/VirtualPaper.PlayerWeb/Form1.cs b/src/VirtualPaper.PlayerWeb/Form1.cs
index e05511b8..1d058296 100644
--- a/src/VirtualPaper.PlayerWeb/Form1.cs
+++ b/src/VirtualPaper.PlayerWeb/Form1.cs
@@ -1,7 +1,7 @@
using System.Text.Json;
using Microsoft.Web.WebView2.Core;
using VirtualPaper.Common;
-using VirtualPaper.Common.Events.EffectValue.Base;
+using VirtualPaper.Common.Events;
using VirtualPaper.Common.Extensions;
using VirtualPaper.Common.Runtime.PlayerWeb;
using VirtualPaper.Common.Utils;
@@ -607,7 +607,7 @@ private void RunParallax() {
private int _isParallaxOnFromIpc = 1; // 0=IPC 挂起(全屏/焦点应用遮盖), 1=IPC 允许(默认允许)
private readonly CancellationTokenSource _ctsConsoleIn = new();
private static readonly CoreWebView2EnvironmentOptions _environmentOptions = new() {
- AdditionalBrowserArguments = "--disable-web-security --allow-file-access --allow-file-access-from-files --disk-cache-size=1 --autoplay-policy=no-user-gesture-required "
- }; // workaround: avoid cache
+ AdditionalBrowserArguments = "--disk-cache-size=1 --autoplay-policy=no-user-gesture-required"
+ };
}
}
diff --git a/src/VirtualPaper.Sandbox.WPF.Preview/App.xaml b/src/VirtualPaper.Sandbox.WPF.Preview/App.xaml
new file mode 100644
index 00000000..96e69523
--- /dev/null
+++ b/src/VirtualPaper.Sandbox.WPF.Preview/App.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VirtualPaper.Sandbox.WPF.Preview/App.xaml.cs b/src/VirtualPaper.Sandbox.WPF.Preview/App.xaml.cs
new file mode 100644
index 00000000..c116ea8b
--- /dev/null
+++ b/src/VirtualPaper.Sandbox.WPF.Preview/App.xaml.cs
@@ -0,0 +1,12 @@
+using System.Configuration;
+using System.Data;
+using System.Windows;
+
+namespace VirtualPaper.Sandbox.WPF.Preview {
+ ///
+ /// Interaction logic for App.xaml
+ ///
+ public partial class App : Application {
+ }
+
+}
diff --git a/src/VirtualPaper.Sandbox.WPF.Preview/AssemblyInfo.cs b/src/VirtualPaper.Sandbox.WPF.Preview/AssemblyInfo.cs
new file mode 100644
index 00000000..b0ec8275
--- /dev/null
+++ b/src/VirtualPaper.Sandbox.WPF.Preview/AssemblyInfo.cs
@@ -0,0 +1,10 @@
+using System.Windows;
+
+[assembly: ThemeInfo(
+ ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
+ //(used if a resource is not found in the page,
+ // or application resource dictionaries)
+ ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
+ //(used if a resource is not found in the page,
+ // app, or any theme specific resource dictionaries)
+)]
diff --git a/src/VirtualPaper.Sandbox.WPF.Preview/MainWindow.xaml b/src/VirtualPaper.Sandbox.WPF.Preview/MainWindow.xaml
new file mode 100644
index 00000000..a94e0158
--- /dev/null
+++ b/src/VirtualPaper.Sandbox.WPF.Preview/MainWindow.xaml
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VirtualPaper.Sandbox.WPF.Preview/MainWindow.xaml.cs b/src/VirtualPaper.Sandbox.WPF.Preview/MainWindow.xaml.cs
new file mode 100644
index 00000000..08c548db
--- /dev/null
+++ b/src/VirtualPaper.Sandbox.WPF.Preview/MainWindow.xaml.cs
@@ -0,0 +1,77 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using VirtualPaper.lang;
+using VirtualPaper.Views;
+using Wpf.Ui.Appearance;
+
+namespace VirtualPaper.Sandbox.WPF.Preview {
+ public partial class MainWindow {
+ public MainWindow() {
+ InitializeComponent();
+ WindowList.ItemsSource = _openedWindows;
+ Closed += (_, _) => CloseAllTrackedWindows();
+ }
+
+ private void ThemeCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) {
+ if (ThemeCombo.SelectedItem is ComboBoxItem item) {
+ var theme = item.Content?.ToString() == "Light"
+ ? ApplicationTheme.Light
+ : ApplicationTheme.Dark;
+ ApplicationThemeManager.Apply(theme, updateAccent: false);
+ }
+ }
+
+ private void LangCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) {
+ if (LangCombo.SelectedItem is ComboBoxItem item) {
+ var lang = item.Content?.ToString() ?? "zh-CN";
+ LanguageManager.Instance.ChangeLanguage(new CultureInfo(lang));
+ }
+ }
+
+ private void OpenPluginUpdateWindow(object sender, RoutedEventArgs e) {
+ TrackAndShow(new PluginUpdateWindow(), "PluginUpdateWindow");
+ }
+
+ private void OpenSplashWindow(object sender, RoutedEventArgs e) {
+ TrackAndShow(new SplashWindow(), "SplashWindow");
+ }
+
+ private void OpenIdentifyWindow(object sender, RoutedEventArgs e) {
+ TrackAndShow(new IdentifyWindow(index: 1), "IdentifyWindow");
+ }
+
+ private void OpenDebugLog(object sender, RoutedEventArgs e) {
+ TrackAndShow(new DebugLog(), "DebugLog");
+ }
+
+ private void TrackAndShow(Window w, string label) {
+ w.Title = string.IsNullOrEmpty(w.Title) ? label : w.Title;
+ w.Closed += (_, _) => _openedWindows.Remove(w);
+ _openedWindows.Add(w);
+ w.Show();
+ RefreshList();
+ }
+
+ private void CloseWindow_Click(object sender, RoutedEventArgs e) {
+ if (WindowList.SelectedItem is Window w) {
+ w.Close();
+ }
+ }
+
+ private void RefreshList() {
+ WindowList.ItemsSource = null;
+ WindowList.ItemsSource = _openedWindows;
+ }
+
+ private void CloseAllTrackedWindows() {
+ foreach (var w in _openedWindows.ToList()) {
+ w.Close();
+ }
+ _openedWindows.Clear();
+ }
+
+ private readonly ObservableCollection _openedWindows = [];
+ }
+}
diff --git a/src/VirtualPaper.Sandbox.WPF.Preview/VirtualPaper.Sandbox.WPF.Preview.csproj b/src/VirtualPaper.Sandbox.WPF.Preview/VirtualPaper.Sandbox.WPF.Preview.csproj
new file mode 100644
index 00000000..30c73513
--- /dev/null
+++ b/src/VirtualPaper.Sandbox.WPF.Preview/VirtualPaper.Sandbox.WPF.Preview.csproj
@@ -0,0 +1,22 @@
+
+
+
+ WinExe
+ net8.0-windows10.0.19041.0
+ enable
+ enable
+ true
+ x64
+ win-x64
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/App.xaml b/src/VirtualPaper.Sandbox.WinUI.Preview/App.xaml
new file mode 100644
index 00000000..96d5aee9
--- /dev/null
+++ b/src/VirtualPaper.Sandbox.WinUI.Preview/App.xaml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/App.xaml.cs b/src/VirtualPaper.Sandbox.WinUI.Preview/App.xaml.cs
new file mode 100644
index 00000000..4c8d5940
--- /dev/null
+++ b/src/VirtualPaper.Sandbox.WinUI.Preview/App.xaml.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices.WindowsRuntime;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+using Microsoft.UI.Xaml.Shapes;
+using Windows.ApplicationModel;
+using Windows.ApplicationModel.Activation;
+using Windows.Foundation;
+using Windows.Foundation.Collections;
+
+// To learn more about WinUI, the WinUI project structure,
+// and more about our project templates, see: http://aka.ms/winui-project-info.
+
+namespace VirtualPaper.Sandbox.WinUI.Preview {
+ ///
+ /// Provides application-specific behavior to supplement the default Application class.
+ ///
+ public partial class App : Application {
+ private Window? _window;
+
+ ///
+ /// Initializes the singleton application object. This is the first line of authored code
+ /// executed, and as such is the logical equivalent of main() or WinMain().
+ ///
+ public App() {
+ InitializeComponent();
+ }
+
+ ///
+ /// Invoked when the application is launched.
+ ///
+ /// Details about the launch request and process.
+ protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) {
+ _window = new MainWindow();
+ _window.Activate();
+ }
+ }
+}
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/LockScreenLogo.scale-200.png b/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/LockScreenLogo.scale-200.png
new file mode 100644
index 00000000..7440f0d4
Binary files /dev/null and b/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/LockScreenLogo.scale-200.png differ
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/SplashScreen.scale-200.png b/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/SplashScreen.scale-200.png
new file mode 100644
index 00000000..32f486a8
Binary files /dev/null and b/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/SplashScreen.scale-200.png differ
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/Square150x150Logo.scale-200.png b/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/Square150x150Logo.scale-200.png
new file mode 100644
index 00000000..53ee3777
Binary files /dev/null and b/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/Square150x150Logo.scale-200.png differ
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/Square44x44Logo.scale-200.png b/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/Square44x44Logo.scale-200.png
new file mode 100644
index 00000000..f713bba6
Binary files /dev/null and b/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/Square44x44Logo.scale-200.png differ
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/Square44x44Logo.targetsize-24_altform-unplated.png
new file mode 100644
index 00000000..dc9f5bea
Binary files /dev/null and b/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/StoreLogo.png b/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/StoreLogo.png
new file mode 100644
index 00000000..a4586f26
Binary files /dev/null and b/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/StoreLogo.png differ
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/Wide310x150Logo.scale-200.png b/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/Wide310x150Logo.scale-200.png
new file mode 100644
index 00000000..8b4a5d0d
Binary files /dev/null and b/src/VirtualPaper.Sandbox.WinUI.Preview/Assets/Wide310x150Logo.scale-200.png differ
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/MainWindow.xaml b/src/VirtualPaper.Sandbox.WinUI.Preview/MainWindow.xaml
new file mode 100644
index 00000000..20a8fef3
--- /dev/null
+++ b/src/VirtualPaper.Sandbox.WinUI.Preview/MainWindow.xaml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/MainWindow.xaml.cs b/src/VirtualPaper.Sandbox.WinUI.Preview/MainWindow.xaml.cs
new file mode 100644
index 00000000..5a5a87ac
--- /dev/null
+++ b/src/VirtualPaper.Sandbox.WinUI.Preview/MainWindow.xaml.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices.WindowsRuntime;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+using Windows.Foundation;
+using Windows.Foundation.Collections;
+
+// To learn more about WinUI, the WinUI project structure,
+// and more about our project templates, see: http://aka.ms/winui-project-info.
+
+namespace VirtualPaper.Sandbox.WinUI.Preview {
+ ///
+ /// An empty window that can be used on its own or navigated to within a Frame.
+ ///
+ public sealed partial class MainWindow : Window {
+ public MainWindow() {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/Package.appxmanifest b/src/VirtualPaper.Sandbox.WinUI.Preview/Package.appxmanifest
new file mode 100644
index 00000000..785aaf55
--- /dev/null
+++ b/src/VirtualPaper.Sandbox.WinUI.Preview/Package.appxmanifest
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+ VirtualPaper.Sandbox.WinUI.Preview
+ liurui18
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/Properties/launchSettings.json b/src/VirtualPaper.Sandbox.WinUI.Preview/Properties/launchSettings.json
new file mode 100644
index 00000000..dc9fe32d
--- /dev/null
+++ b/src/VirtualPaper.Sandbox.WinUI.Preview/Properties/launchSettings.json
@@ -0,0 +1,10 @@
+{
+ "profiles": {
+ "VirtualPaper.Sandbox.WinUI.Preview (Package)": {
+ "commandName": "MsixPackage"
+ },
+ "VirtualPaper.Sandbox.WinUI.Preview (Unpackaged)": {
+ "commandName": "Project"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/VirtualPaper.Sandbox.WinUI.Preview.csproj b/src/VirtualPaper.Sandbox.WinUI.Preview/VirtualPaper.Sandbox.WinUI.Preview.csproj
new file mode 100644
index 00000000..f0f8b27a
--- /dev/null
+++ b/src/VirtualPaper.Sandbox.WinUI.Preview/VirtualPaper.Sandbox.WinUI.Preview.csproj
@@ -0,0 +1,93 @@
+
+
+ WinExe
+ net8.0-windows10.0.19041.0
+ 10.0.17763.0
+ VirtualPaper.Sandbox.WinUI.Preview
+ app.manifest
+ x64
+ win-x64
+ win-$(Platform).pubxml
+ false
+ true
+ false
+ true
+ true
+ enable
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+ False
+ True
+ False
+ True
+ Always
+ x64
+ True
+ 0
+
+ win-$(Platform).pubxml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+ False
+ True
+ False
+ True
+
+
\ No newline at end of file
diff --git a/src/VirtualPaper.Sandbox.WinUI.Preview/app.manifest b/src/VirtualPaper.Sandbox.WinUI.Preview/app.manifest
new file mode 100644
index 00000000..5a7db0d5
--- /dev/null
+++ b/src/VirtualPaper.Sandbox.WinUI.Preview/app.manifest
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true/PM
+ PerMonitorV2
+
+
+
\ No newline at end of file
diff --git a/src/VirtualPaper.UI.Test/T_AppSettings/GeneralSettingViewModelTests.cs b/src/VirtualPaper.UI.Test/T_AppSettings/GeneralSettingViewModelTests.cs
index 02f01917..80ac1983 100644
--- a/src/VirtualPaper.UI.Test/T_AppSettings/GeneralSettingViewModelTests.cs
+++ b/src/VirtualPaper.UI.Test/T_AppSettings/GeneralSettingViewModelTests.cs
@@ -2,14 +2,15 @@
using Moq;
using VirtualPaper.AppSettingsPanel.ViewModels;
using VirtualPaper.Common;
-using VirtualPaper.Common.Events;
using VirtualPaper.Common.Utils.Localization;
using VirtualPaper.Common.Utils.Storage;
using VirtualPaper.Common.Utils.ThreadContext;
using VirtualPaper.Grpc.Client.Interfaces;
using VirtualPaper.Grpc.Service.CommonModels;
+using VirtualPaper.Models.AppUpdate;
using VirtualPaper.Models.Cores;
using VirtualPaper.Models.Cores.Interfaces;
+using VirtualPaper.Models.Events;
using VirtualPaper.UI.Test.Utils;
namespace VirtualPaper.UI.Test.T_AppSettings {
@@ -19,6 +20,7 @@ public class GeneralSettingViewModelTests {
private Mock _userSettingsClient = null!;
private Mock _wpControlClient = null!;
private Mock _settings = null!;
+ private Mock _commandsClient;
private GeneralSettingViewModel _vm = null!;
[TestInitialize]
@@ -29,6 +31,7 @@ public void Setup() {
_userSettingsClient = new Mock();
_wpControlClient = new Mock();
_settings = new Mock();
+ _commandsClient = new Mock();
_settings.SetupProperty(s => s.IsAutoStart, false);
_settings.SetupProperty(s => s.SystemBackdrop, AppSystemBackdrop.Default);
@@ -43,7 +46,8 @@ public void Setup() {
_vm = new GeneralSettingViewModel(
_appUpdater.Object,
_userSettingsClient.Object,
- _wpControlClient.Object);
+ _wpControlClient.Object,
+ _commandsClient.Object);
}
[TestCleanup]
@@ -55,14 +59,16 @@ public void Cleanup() {
[TestMethod]
public void MenuUpdate_WhenUptodate_SetsUptoNewest() {
+ var releaseInfo = new ReleaseInfo {
+ Version = new Version(1, 0),
+ CheckedTime = DateTime.Now,
+ InstallerUri = new Uri("https://example.com/update"),
+ InstallerShaUri = new Uri("https://example.com/update.sha"),
+ };
_appUpdater.Raise(a => a.UpdateChecked += null,
new AppUpdaterEventArgs(
AppUpdateStatus.Uptodate,
- new Version(1, 0),
- DateTime.Now,
- new Uri("https://example.com/update"),
- new Uri("https://example.com/update.sha"),
- string.Empty));
+ releaseInfo));
Assert.AreEqual(VersionState.UptoNewest, _vm.CurrentVersionState);
}
@@ -70,31 +76,36 @@ public void MenuUpdate_WhenUptodate_SetsUptoNewest() {
[TestMethod]
public void MenuUpdate_WhenAvailable_SetsVersionAndFindNew() {
var version = new Version(2, 0);
+ var releaseInfo = new ReleaseInfo {
+ Version = version,
+ CheckedTime = DateTime.Now,
+ InstallerUri = new Uri("https://example.com/update"),
+ InstallerShaUri = new Uri("https://example.com/update.sha"),
+ };
_appUpdater.Raise(a => a.UpdateChecked += null,
new AppUpdaterEventArgs(
AppUpdateStatus.Available,
- version,
- DateTime.Now,
- new Uri("https://example.com/update"),
- new Uri("https://example.com/update.sha"),
- string.Empty));
+ releaseInfo));
+
Assert.AreEqual(VersionState.FindNew, _vm.CurrentVersionState);
- Assert.AreEqual("v2.0", _vm.Version);
+ Assert.AreEqual("v2.0 Build ()", _vm.Version);
}
[TestMethod]
[DataRow(AppUpdateStatus.Invalid)]
[DataRow(AppUpdateStatus.Error)]
public void MenuUpdate_WhenInvalidOrError_SetsUpdateErr(AppUpdateStatus status) {
+ var releaseInfo = new ReleaseInfo {
+ Version = new Version(1, 0),
+ CheckedTime = DateTime.Now,
+ InstallerUri = new Uri("https://example.com/update"),
+ InstallerShaUri = new Uri("https://example.com/update.sha"),
+ };
_appUpdater.Raise(a => a.UpdateChecked += null,
new AppUpdaterEventArgs(
status,
- new Version(1, 0),
- DateTime.Now,
- new Uri("https://example.com/update"),
- new Uri("https://example.com/update.sha"),
- string.Empty));
+ releaseInfo));
Assert.AreEqual(VersionState.UpdateErr, _vm.CurrentVersionState);
}
diff --git a/src/VirtualPaper.UI/App.xaml.cs b/src/VirtualPaper.UI/App.xaml.cs
index 15046eaf..3d47095c 100644
--- a/src/VirtualPaper.UI/App.xaml.cs
+++ b/src/VirtualPaper.UI/App.xaml.cs
@@ -1,5 +1,6 @@
using System;
using System.Diagnostics;
+using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
@@ -22,6 +23,7 @@
using VirtualPaper.ML.StyleTransfer.Interfaces;
using VirtualPaper.ML.SuperResolution;
using VirtualPaper.ML.SuperResolution.Interfaces;
+using VirtualPaper.Models.AppUpdate;
using VirtualPaper.UIComponent.Converters;
using VirtualPaper.UIComponent.Utils;
using VirtualPaper.UIComponent.Utils.Adapter;
@@ -160,6 +162,36 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) {
var m_window = AppServiceLocator.Services.GetRequiredService();
m_window.Show();
+
+ // Check for update failed notice
+ CheckUpdateFailedNotice();
+ }
+
+ private static void CheckUpdateFailedNotice() {
+ try {
+ var noticePath = Constants.CommonPaths.UpdateFailedNoticePath;
+ if (!File.Exists(noticePath)) return;
+
+ var json = File.ReadAllText(noticePath);
+ if (string.IsNullOrWhiteSpace(json)) {
+ File.Delete(noticePath);
+ return;
+ }
+
+ var notice = System.Text.Json.JsonSerializer.Deserialize(json, UpdateFailedNoticeContext.Default.UpdateFailedNotice);
+ File.Delete(noticePath);
+
+ if (notice != null && !string.IsNullOrEmpty(notice.MessageKey)) {
+ VirtualPaper.UIComponent.Utils.GlobalMessageUtil.ShowError(
+ notice.MessageKey,
+ isNeedLocalizer: true,
+ key: "UpdateFailedNotice",
+ extraMsg: string.IsNullOrEmpty(notice.ExceptionMessage) ? null : notice.ExceptionMessage);
+ }
+ }
+ catch (Exception ex) {
+ ArcLog.GetLogger().Warn($"Failed to read update failed notice: {ex.Message}");
+ }
}
private static void LogUnhandledException(Exception exception) => ArcLog.GetLogger().Error(exception);
diff --git a/src/VirtualPaper.UI/MainWindow.xaml.cs b/src/VirtualPaper.UI/MainWindow.xaml.cs
index a7d03dc9..bceb2f39 100644
--- a/src/VirtualPaper.UI/MainWindow.xaml.cs
+++ b/src/VirtualPaper.UI/MainWindow.xaml.cs
@@ -5,6 +5,7 @@
using VirtualPaper.AppSettingsPanel;
using VirtualPaper.Common;
using VirtualPaper.Common.Logging;
+using VirtualPaper.Common.Utils.PInvoke;
using VirtualPaper.Common.Utils.IPC;
using VirtualPaper.Common.Utils.ThreadContext;
using VirtualPaper.DraftPanel;
@@ -86,7 +87,9 @@ private void HandleIpcMessage(int type) {
switch (messageType) {
case MessageType.cmd_active:
CrossThreadInvoker.InvokeOnUIThread(() => {
- this.Activate();
+ var hwnd = WindowConsts.WindowHandle;
+ Native.ShowWindow(hwnd, (uint)Native.SHOWWINDOW.SW_RESTORE);
+ _ = Native.SetForegroundWindow(hwnd);
});
break;
default:
diff --git a/src/VirtualPaper.UI/VirtualPaper.UI.csproj b/src/VirtualPaper.UI/VirtualPaper.UI.csproj
index ac485ff9..7ae86b1c 100644
--- a/src/VirtualPaper.UI/VirtualPaper.UI.csproj
+++ b/src/VirtualPaper.UI/VirtualPaper.UI.csproj
@@ -4,9 +4,9 @@
net8.0-windows10.0.19041.0
10.0.19041.0
app.manifest
- x86;x64;ARM64
- win-x86;win-x64;win-arm64
- win10-x86;win10-x64;win10-arm64
+ x64
+ win-x64
+ false
true
true
true
@@ -20,6 +20,14 @@
zh-CN
+
+
+
+
+
+
+
true
diff --git a/src/VirtualPaper.UIComponent/Converters/VisibilityByValueConverter.cs b/src/VirtualPaper.UIComponent/Converters/VisibilityByValueConverter.cs
index 6fc95b75..5926d228 100644
--- a/src/VirtualPaper.UIComponent/Converters/VisibilityByValueConverter.cs
+++ b/src/VirtualPaper.UIComponent/Converters/VisibilityByValueConverter.cs
@@ -51,17 +51,31 @@ public object Convert(object value, Type targetType, object parameter, string la
}
// --- 3️ Enum 匹配 ---
else if (value is Enum enumValue && !string.IsNullOrEmpty(param)) {
- try {
- var targetValue = Enum.Parse(value.GetType(), param, ignoreCase: true);
- result = enumValue.Equals(targetValue);
- }
- catch {
- result = false;
+ var candidates = param.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ result = false;
+ foreach (var candidate in candidates) {
+ try {
+ var targetValue = Enum.Parse(value.GetType(), candidate, ignoreCase: true);
+ if (enumValue.Equals(targetValue)) {
+ result = true;
+ break;
+ }
+ }
+ catch {
+ // ignore invalid enum value
+ }
}
}
// --- 4️ 字符串匹配 ---
else if (value is string str && !string.IsNullOrEmpty(param)) {
- result = str.Equals(param, StringComparison.OrdinalIgnoreCase);
+ var candidates = param.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ result = false;
+ foreach (var candidate in candidates) {
+ if (str.Equals(candidate, StringComparison.OrdinalIgnoreCase)) {
+ result = true;
+ break;
+ }
+ }
}
// --- 5️ 默认行为(非空即显示) ---
else {
diff --git a/src/VirtualPaper.UIComponent/Strings/en-US/Resources.resw b/src/VirtualPaper.UIComponent/Strings/en-US/Resources.resw
index 036a063b..5aa670f7 100644
--- a/src/VirtualPaper.UIComponent/Strings/en-US/Resources.resw
+++ b/src/VirtualPaper.UIComponent/Strings/en-US/Resources.resw
@@ -195,6 +195,42 @@ The file cannot be imported:
Accomplished
+
+ Update failed. Please retry or download the full installer to update.
+
+
+ Plugins Version
+
+
+ Invalid update info
+
+
+ Starting update...
+
+
+ Update completed. UI will restart.
+
+
+ Update failed: {0}
+
+
+ Downloading plugins...
+
+
+ Backing up current plugins...
+
+
+ Replacing plugin files...
+
+
+ Completed
+
+
+ Failed
+
+
+ Close
+
Apply
@@ -1547,7 +1583,6 @@ The following words cannot be used as usernames:
SoftLight
-
Remove color, convert to grayscale
@@ -1617,4 +1652,10 @@ The following words cannot be used as usernames:
Soft light blend, gentle contrast
+
+ Update ready. Click to install. The app must be fully closed, please save your data.
+
+
+ Update ready. Click to install. The main window must be closed, please save your data.
+
\ No newline at end of file
diff --git a/src/VirtualPaper.UIComponent/Strings/zh-CN/Resources.resw b/src/VirtualPaper.UIComponent/Strings/zh-CN/Resources.resw
index 48f8a575..5116dda2 100644
--- a/src/VirtualPaper.UIComponent/Strings/zh-CN/Resources.resw
+++ b/src/VirtualPaper.UIComponent/Strings/zh-CN/Resources.resw
@@ -195,6 +195,42 @@ Scale-down:尺寸与 None 或 Contain 中的一中方式相同,取决于哪
已完成
+
+ 更新发生错误,请重试或下载完整安装包进行更新
+
+
+ 组件版本
+
+
+ 更新信息无效
+
+
+ 正在开始更新...
+
+
+ 更新完成,UI 即将重启
+
+
+ 更新失败:{0}
+
+
+ 正在下载组件...
+
+
+ 正在备份当前组件...
+
+
+ 正在替换组件文件...
+
+
+ 已完成
+
+
+ 失败
+
+
+ 关闭
+
应用
@@ -1547,7 +1583,6 @@ Virtual Paper 是一款免费、运行在 Windows 上的轻量级壁纸软件,
柔光
-
去除颜色信息,转为灰度图
@@ -1617,4 +1652,10 @@ Virtual Paper 是一款免费、运行在 Windows 上的轻量级壁纸软件,
柔光模式,产生柔和对比
+
+ 更新已就绪,点击安装。安装需完全退出应用,请注意保存数据
+
+
+ 更新已就绪,点击安装。安装需关闭主窗口,请注意保存数据
+
\ No newline at end of file
diff --git a/src/VirtualPaper.UIComponent/VirtualPaper.UIComponent.csproj b/src/VirtualPaper.UIComponent/VirtualPaper.UIComponent.csproj
index e2d6a0f8..ecf7d937 100644
--- a/src/VirtualPaper.UIComponent/VirtualPaper.UIComponent.csproj
+++ b/src/VirtualPaper.UIComponent/VirtualPaper.UIComponent.csproj
@@ -2,8 +2,7 @@
net8.0-windows10.0.19041.0
VirtualPaper.UIComponent
- win-x86;win-x64;win-arm64
- win10-x86;win10-x64;win10-arm64
+ false
true
true
10.0.19041.0
diff --git a/src/VirtualPaper.WpSettingsPanel/Utils/WallpaperIndexFile.cs b/src/VirtualPaper.WpSettingsPanel/Utils/WallpaperIndexFile.cs
index 8b08802d..1eb13c4f 100644
--- a/src/VirtualPaper.WpSettingsPanel/Utils/WallpaperIndexFile.cs
+++ b/src/VirtualPaper.WpSettingsPanel/Utils/WallpaperIndexFile.cs
@@ -33,7 +33,13 @@ await Parallel.ForEachAsync(folders, parallelOptions, async (folder, ct) => {
var jsonPath = Path.Combine(folder.FullName, Constants.Field.WpBasicDataFileName);
if (!File.Exists(jsonPath)) return;
- var data = await JsonSaver.LoadAsync(jsonPath, WpBasicDataContext.Default);
+ WpBasicData data;
+ try {
+ data = await JsonSaver.LoadAsync(jsonPath, WpBasicDataContext.Default);
+ }
+ catch (Exception) {
+ return;
+ }
if (!data.IsAvailable()) return;
diff --git a/src/VirtualPaper.WpSettingsPanel/ViewModels/ScreenSaverViewModel.cs b/src/VirtualPaper.WpSettingsPanel/ViewModels/ScreenSaverViewModel.cs
index 953e36d8..bef00de5 100644
--- a/src/VirtualPaper.WpSettingsPanel/ViewModels/ScreenSaverViewModel.cs
+++ b/src/VirtualPaper.WpSettingsPanel/ViewModels/ScreenSaverViewModel.cs
@@ -3,6 +3,8 @@
using System.Collections.ObjectModel;
using System.IO;
using System.IO.Pipes;
+using System.Security.AccessControl;
+using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;
@@ -123,10 +125,24 @@ private void InitCollections() {
internal async Task ListenForClients() {
ArcLog.GetLogger().Info("[PipeServer] Pipe Server is running...");
+ var pipeSecurity = new PipeSecurity();
+ var currentUserId = WindowsIdentity.GetCurrent().User;
+ if (currentUserId != null) {
+ pipeSecurity.AddAccessRule(new PipeAccessRule(currentUserId, PipeAccessRights.ReadWrite, AccessControlType.Allow));
+ }
+
try {
await Task.Run(async () => {
while (!_ctsListen.IsCancellationRequested) {
- using var server = new NamedPipeServerStream("TRAY_CMD", PipeDirection.InOut, 1, PipeTransmissionMode.Message, PipeOptions.Asynchronous);
+ using var server = NamedPipeServerStreamAcl.Create(
+ "TRAY_CMD",
+ PipeDirection.InOut,
+ 1,
+ PipeTransmissionMode.Message,
+ PipeOptions.Asynchronous,
+ 0,
+ 0,
+ pipeSecurity);
await server.WaitForConnectionAsync(_ctsListen.Token);
using var reader = new StreamReader(server);
string? cmd = await reader.ReadLineAsync(_ctsListen.Token);
diff --git a/src/VirtualPaper.WpSettingsPanel/VirtualPaper.WpSettingsPanel.csproj b/src/VirtualPaper.WpSettingsPanel/VirtualPaper.WpSettingsPanel.csproj
index 5adac93e..c085e11a 100644
--- a/src/VirtualPaper.WpSettingsPanel/VirtualPaper.WpSettingsPanel.csproj
+++ b/src/VirtualPaper.WpSettingsPanel/VirtualPaper.WpSettingsPanel.csproj
@@ -3,8 +3,7 @@
net8.0-windows10.0.19041.0
10.0.19041.0
VirtualPaper.WpSettingsPanel
- win-x86;win-x64;win-arm64
- win10-x86;win10-x64;win10-arm64
+ false
true
true
enable
diff --git a/src/VirtualPaper.sln b/src/VirtualPaper.sln
index 893de834..6c0e7917 100644
--- a/src/VirtualPaper.sln
+++ b/src/VirtualPaper.sln
@@ -1,6 +1,6 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
-VisualStudioVersion = 18.2.11415.280
+VisualStudioVersion = 18.7.11925.98 stable
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VirtualPaper", "VirtualPaper\VirtualPaper.csproj", "{A118069A-BB34-499B-BF87-652F405C1E29}"
EndProject
@@ -62,6 +62,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Grpc", "Grpc", "{C474D1FA-C
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualPaper.ReleaseBuildData.Test", "VirtualPaper.ReleaseBuildData.Test\VirtualPaper.ReleaseBuildData.Test.csproj", "{2E56353E-AC5F-4796-A7D9-E01652FFA45A}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebBackdrop", "WebBackdrop\WebBackdrop.csproj", "{E6144434-C2DC-4211-8EA3-44B60F66F3A1}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sandbox", "Sandbox", "{05C90E96-B949-4D4A-8608-A4F51942B8F7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualPaper.Sandbox.WPF.Preview", "VirtualPaper.Sandbox.WPF.Preview\VirtualPaper.Sandbox.WPF.Preview.csproj", "{C4BE8CF1-7BA3-4759-A883-577837EE118B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualPaper.Sandbox.WinUI.Preview", "VirtualPaper.Sandbox.WinUI.Preview\VirtualPaper.Sandbox.WinUI.Preview.csproj", "{828BA5CC-0B45-42C7-8E71-C461CF08C117}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -562,6 +570,61 @@ Global
{2E56353E-AC5F-4796-A7D9-E01652FFA45A}.Release|x64.Build.0 = Release|Any CPU
{2E56353E-AC5F-4796-A7D9-E01652FFA45A}.Release|x86.ActiveCfg = Release|Any CPU
{2E56353E-AC5F-4796-A7D9-E01652FFA45A}.Release|x86.Build.0 = Release|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Debug|ARM.ActiveCfg = Debug|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Debug|ARM.Build.0 = Debug|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Debug|x64.Build.0 = Debug|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Debug|x86.Build.0 = Debug|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Release|ARM.ActiveCfg = Release|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Release|ARM.Build.0 = Release|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Release|ARM64.Build.0 = Release|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Release|x64.ActiveCfg = Release|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Release|x64.Build.0 = Release|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Release|x86.ActiveCfg = Release|Any CPU
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1}.Release|x86.Build.0 = Release|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Debug|ARM.ActiveCfg = Debug|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Debug|ARM.Build.0 = Debug|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Debug|x64.Build.0 = Debug|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Debug|x86.Build.0 = Debug|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Release|ARM.ActiveCfg = Release|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Release|x64.ActiveCfg = Release|Any CPU
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B}.Release|x86.ActiveCfg = Release|Any CPU
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|Any CPU.Build.0 = Debug|x64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|Any CPU.Deploy.0 = Debug|x64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|ARM.ActiveCfg = Debug|x64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|ARM.Build.0 = Debug|x64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|ARM.Deploy.0 = Debug|x64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|ARM64.ActiveCfg = Debug|ARM64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|ARM64.Build.0 = Debug|ARM64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|ARM64.Deploy.0 = Debug|ARM64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|x64.ActiveCfg = Debug|x64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|x64.Build.0 = Debug|x64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|x64.Deploy.0 = Debug|x64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|x86.ActiveCfg = Debug|x86
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|x86.Build.0 = Debug|x86
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Debug|x86.Deploy.0 = Debug|x86
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Release|Any CPU.ActiveCfg = Release|x64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Release|ARM.ActiveCfg = Release|x64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Release|ARM64.ActiveCfg = Release|ARM64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Release|x64.ActiveCfg = Release|x64
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117}.Release|x86.ActiveCfg = Release|x86
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -583,6 +646,9 @@ Global
{68E40740-CB2B-4F34-BAFB-25AD79BDC1D1} = {C9E354C8-7B8A-43D2-82E9-4D7653F54906}
{6643885C-DF9A-43C1-8104-F7ECBF70A3C8} = {C9E354C8-7B8A-43D2-82E9-4D7653F54906}
{2E56353E-AC5F-4796-A7D9-E01652FFA45A} = {C9E354C8-7B8A-43D2-82E9-4D7653F54906}
+ {E6144434-C2DC-4211-8EA3-44B60F66F3A1} = {46DB997B-6F32-40E9-A639-28DE474DB5C1}
+ {C4BE8CF1-7BA3-4759-A883-577837EE118B} = {05C90E96-B949-4D4A-8608-A4F51942B8F7}
+ {828BA5CC-0B45-42C7-8E71-C461CF08C117} = {05C90E96-B949-4D4A-8608-A4F51942B8F7}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
RESX_NeutralResourcesLanguage = zh-CN
diff --git a/src/VirtualPaper/App.xaml.cs b/src/VirtualPaper/App.xaml.cs
index 3a51a8ca..3a974ce2 100644
--- a/src/VirtualPaper/App.xaml.cs
+++ b/src/VirtualPaper/App.xaml.cs
@@ -32,6 +32,7 @@
using VirtualPaper.lang;
using VirtualPaper.Models;
using VirtualPaper.Models.Cores.Interfaces;
+using VirtualPaper.Models.Events;
using VirtualPaper.Services;
using VirtualPaper.Services.Download;
using VirtualPaper.Services.Interfaces;
@@ -86,7 +87,7 @@ public App() {
#region 必要路径处理
try {
// 清空缓存
- FileUtil.EmptyDirectory(Constants.CommonPaths.TempDir);
+ FileUtil.RemoveDirectory(Constants.CommonPaths.TempDir);
}
catch { }
@@ -132,9 +133,12 @@ public App() {
UserSettings.Settings.WallpaperDir = Path.Combine(Constants.CommonPaths.LibraryDir, Constants.FolderName.WpStoreFolderName);
Directory.CreateDirectory(UserSettings.Settings.WallpaperDir);
}
- // 初始化语言包
+ // 初始化语言包(需在插件更新前,确保更新进度窗口文字有 i18n)
ChangeLanguage(UserSettings.Settings.Language);
+ // 检查是否有未完成的插件更新(异步,不阻塞 UI 线程)
+ _ = ((IPluginsUpdateServiceInit)Services.GetRequiredService()).InitAsync();
+
UserSettings.Save();
#endregion
@@ -162,17 +166,17 @@ public App() {
//restore wallpaper(s) from previous run.
var wpControl = Services.GetRequiredService();
- wpControl.RestoreWallpaper();
+ _ = wpControl.RestoreWallpaperAsync();
// 启动屏保服务(需要在"还原壁纸"后进行)
bool isScrOn = UserSettings.Settings.IsScreenSaverOn;
if (isScrOn) {
- Services.GetRequiredService().Start();
+ _ = Services.GetRequiredService().StartAsync();
}
//first run Setup-Wizard show..
if (UserSettings.Settings.IsFirstRun) {
- Services.GetRequiredService().ShowUI();
+ _ = Services.GetRequiredService().ShowUIAsync();
}
}
catch (Exception ex) {
@@ -229,6 +233,8 @@ private ServiceProvider ConfigureServices() {
.AddSingleton()
.AddSingleton()
.AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
.AddSingleton()
.AddSingleton()
.AddSingleton()
@@ -252,6 +258,7 @@ private ServiceProvider ConfigureServices() {
.AddTransient()
.AddTransient()
.AddTransient()
+ .AddTransient()
.AddTransient()
@@ -319,8 +326,9 @@ public static void ChangeLanguage(string lang) {
public static void AppUpdateDialog(AppUpdaterEventArgs e) {
_updateNotify = false;
var windowService = Services.GetRequiredService();
- var info = new AppUpdateInfo(e.UpdateUri, e.UpdateSHAUri, e.UpdateVersion.ToString(), e.ChangeLog);
- windowService.Show(info);
+
+ // Both installer-style and restart-style updates use AppUpdaterWindow
+ windowService.Show(e.Release, bringToFront: true);
}
private static int _updateNotifyAmt = 1;
@@ -331,8 +339,15 @@ private void AppUpdateChecked(object sender, AppUpdaterEventArgs e) {
if (_updateNotifyAmt > 0) {
_updateNotifyAmt--;
_updateNotify = true;
+
+ var updater = Services.GetRequiredService();
+ var isRestart = updater.LastReleaseInfo?.IsPluginsUpdate == true;
+ var toastKey = isRestart
+ ? Constants.I18n.Find_New_Version_Restart
+ : nameof(Constants.I18n.Settings_General_Version_FindNew);
+
new ToastContentBuilder()
- .AddText(LanguageManager.Instance["Find_New_Verison"])
+ .AddText(LanguageManager.Instance[toastKey])
.Show();
}
@@ -345,7 +360,10 @@ private void AppUpdateChecked(object sender, AppUpdaterEventArgs e) {
}));
}
+ public static volatile bool IsShuttingDown;
+
public static void ShutDown() {
+ IsShuttingDown = true;
try {
_ctsPlayback.Cancel();
Jobs?.Close();
@@ -365,9 +383,9 @@ public static void ShutDown() {
Application.Current.Dispatcher.Invoke(Application.Current.Shutdown);
}
- private readonly IServiceProvider _serviceProvider;
+ private readonly IServiceProvider _serviceProvider = null!;
private readonly Mutex _mutex = new(false, Constants.CoreField.UniqueAppUid);
- private readonly NamedPipeServer _grpcServer;
+ private readonly NamedPipeServer _grpcServer = null!;
private static readonly CancellationTokenSource _ctsPlayback = new();
private static readonly Logger _log = LogManager.GetCurrentClassLogger();
}
diff --git a/src/VirtualPaper/Cores/AppUpdate/AppBuildService.cs b/src/VirtualPaper/Cores/AppUpdate/AppBuildService.cs
new file mode 100644
index 00000000..22afdae7
--- /dev/null
+++ b/src/VirtualPaper/Cores/AppUpdate/AppBuildService.cs
@@ -0,0 +1,73 @@
+using System.IO;
+using System.Text.Json;
+using VirtualPaper.Common;
+using VirtualPaper.Common.Utils.Files;
+using VirtualPaper.Cores.AppUpdate.Models;
+using VirtualPaper.Models.AppUpdate;
+
+namespace VirtualPaper.Cores.AppUpdate {
+ public interface IAppBuildService {
+ AppBuildInfo BuildInfo { get; }
+ string AppBuild { get; }
+ string GetPluginBuild(string pluginName);
+ void Refresh();
+ }
+
+ public class AppBuildService : IAppBuildService {
+ private const string AppCompManifestFile = "app_comp_manifest.json";
+
+ public AppBuildInfo BuildInfo { get; private set; } = new();
+
+ public string AppBuild => BuildInfo.AppBuildNumber;
+
+ public AppBuildService() {
+ Refresh();
+ }
+
+ public string GetPluginBuild(string pluginName) {
+ return BuildInfo.Plugins.TryGetValue(pluginName, out var build) ? build : string.Empty;
+ }
+
+ public void Refresh() {
+ SyncManifestToAppData();
+ BuildInfo = LoadFromManifest() ?? new AppBuildInfo();
+ }
+
+ ///
+ /// 将工作目录的 app_comp_manifest.json 同步到 AppData 目录。
+ /// 若工作目录不存在该文件,则删除 AppData 目录下的副本。
+ ///
+ private static void SyncManifestToAppData() {
+ var sourcePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, AppCompManifestFile);
+ var destPath = Path.Combine(Constants.CommonPaths.AppDataDir, AppCompManifestFile);
+ try {
+ if (!File.Exists(sourcePath)) {
+ if (File.Exists(destPath)) File.Delete(destPath);
+ return;
+ }
+ Directory.CreateDirectory(Constants.CommonPaths.AppDataDir);
+ File.Copy(sourcePath, destPath, true);
+ }
+ catch { }
+ }
+
+ private static AppBuildInfo? LoadFromManifest() {
+ var path = Path.Combine(Constants.CommonPaths.AppDataDir, AppCompManifestFile);
+ if (!File.Exists(path)) return null;
+ try {
+ var json = File.ReadAllText(path);
+ var manifest = JsonSerializer.Deserialize(json, UpdateManifestContext.Default.AppCompManifest);
+ if (manifest?.Plugins == null) return null;
+
+ var info = new AppBuildInfo { AppBuildNumber = manifest.AppBuildNumber };
+ foreach (var (pluginName, buildNumber) in manifest.Plugins) {
+ info.Plugins[pluginName] = buildNumber;
+ }
+ return info;
+ }
+ catch {
+ return null;
+ }
+ }
+ }
+}
diff --git a/src/VirtualPaper/Cores/AppUpdate/GithubUpdaterService.cs b/src/VirtualPaper/Cores/AppUpdate/GithubUpdaterService.cs
index e22dca29..ca0e057f 100644
--- a/src/VirtualPaper/Cores/AppUpdate/GithubUpdaterService.cs
+++ b/src/VirtualPaper/Cores/AppUpdate/GithubUpdaterService.cs
@@ -1,5 +1,11 @@
+using System.IO;
+using System.Text.Json;
+using Octokit;
using VirtualPaper.Common;
-using VirtualPaper.Common.Events;
+using VirtualPaper.Common.Logging;
+using VirtualPaper.Common.Utils.Files;
+using VirtualPaper.Models.AppUpdate;
+using VirtualPaper.Models.Events;
using VirtualPaper.Utils.Interfcaes;
using Timer = System.Timers.Timer;
@@ -7,39 +13,54 @@ namespace VirtualPaper.Cores.AppUpdate {
public sealed class GithubUpdaterService : IAppUpdaterService {
public event EventHandler? UpdateChecked;
- public string LastCheckChangelog { get; private set; } = string.Empty;
- public DateTime LastCheckTime { get; private set; } = DateTime.MinValue;
- public Uri LastCheckUri { get; private set; } = null!;
- public Uri LastCheckShaUri { get; private set; } = null!;
- public Version LastCheckVersion { get; private set; } = new Version(0, 0, 0, 0);
public AppUpdateStatus Status { get; private set; } = AppUpdateStatus.Notchecked;
+ public ReleaseInfo? LastReleaseInfo { get; private set; }
public GithubUpdaterService(
IGithubReleaseClient githubReleaseClient,
- IVersionComparer versionComparer) {
+ IVersionComparer versionComparer,
+ IAppBuildService appBuildService) {
_githubReleaseClient = githubReleaseClient;
_versionComparer = versionComparer;
+ _appBuildService = appBuildService;
_retryTimer.Elapsed += RetryTimer_Elapsed;
//giving the retry delay is not reliable since it will reset if system sleeps/suspends.
_retryTimer.Interval = 5 * 60 * 1000;
}
- public async Task CheckUpdate(int fetchDelay = 45000) {
+ public async Task CheckUpdateAsync(int fetchDelay = 45000) {
if (Constants.ApplicationType.IsMSIX) {
//msix already has built-in _updater.
return AppUpdateStatus.Notchecked;
}
+ var localStatus = ProbeLocalUpdate();
+ if (localStatus != null) {
+ Status = localStatus.Value;
+ LastReleaseInfo = new() {
+ CheckedTime = DateTime.Now
+ };
+ UpdateChecked?.Invoke(this, new AppUpdaterEventArgs(Status, LastReleaseInfo));
+ return Status;
+ }
+
try {
await Task.Delay(fetchDelay);
- (Uri exeUri, Uri shaUri, Version verison, string changelog) = await _githubReleaseClient.GetLatestRelease(Constants.ApplicationType.IsTestBuild);
- int verCompare = _versionComparer.CompareAssemblyVersion(verison);
+ var releaseInfo = await _githubReleaseClient.GetLatestRelease(Constants.ApplicationType.IsTestBuild);
+ LastReleaseInfo = releaseInfo;
+ LastReleaseInfo.CheckedTime = DateTime.Now;
+
+ int verCompare = _versionComparer.CompareAssemblyVersion(releaseInfo.Version);
if (verCompare > 0) {
//update Available.
Status = AppUpdateStatus.Available;
}
- else if (verCompare < 0 || exeUri == null || shaUri == null) {
+ else if (releaseInfo.IsPluginsUpdate && releaseInfo.AppCompManifest != null && HasPluginUpdate(releaseInfo)) {
+ //version unchanged, but plugin updates available.
+ Status = AppUpdateStatus.Available;
+ }
+ else if (verCompare < 0 || releaseInfo.InstallerUri == null) {
//beta release.
Status = AppUpdateStatus.Invalid;
}
@@ -47,61 +68,108 @@ public async Task CheckUpdate(int fetchDelay = 45000) {
//up-to-date.
Status = AppUpdateStatus.Uptodate;
}
- LastCheckUri = exeUri;
- LastCheckShaUri = shaUri;
- LastCheckVersion = verison;
- LastCheckChangelog = changelog;
+ }
+ catch (RateLimitExceededException e) {
+ ArcLog.GetLogger().Warn("Github rate limit exceeded, retry after reset");
+ LastReleaseInfo ??= new ReleaseInfo();
+ LastReleaseInfo.CheckedTime = DateTime.Now;
+ Status = AppUpdateStatus.Error;
+ if (e.HttpResponse?.Headers.TryGetValue("X-RateLimit-Reset", out var resetStr) == true
+ && long.TryParse(resetStr, out var resetUnix)) {
+ var resetTime = DateTimeOffset.FromUnixTimeSeconds(resetUnix);
+ var delay = resetTime - DateTimeOffset.UtcNow;
+ if (delay > TimeSpan.Zero) {
+ _retryTimer.Interval = delay.TotalMilliseconds + 60_000;
+ }
+ }
}
catch (Exception e) {
- App.Log.Error("Github update fetch failed", e);
+ ArcLog.GetLogger().Error("Github update fetch failed", e);
+ LastReleaseInfo ??= new ReleaseInfo();
+ LastReleaseInfo.CheckedTime = DateTime.Now;
Status = AppUpdateStatus.Error;
}
- LastCheckTime = DateTime.Now;
- UpdateChecked?.Invoke(this, new AppUpdaterEventArgs(Status, LastCheckVersion, LastCheckTime, LastCheckUri, LastCheckShaUri, LastCheckChangelog));
+ UpdateChecked?.Invoke(this, new AppUpdaterEventArgs(Status, LastReleaseInfo));
return Status;
}
- //private async Task> GetModulesLatestRelease(bool isBeta) {
- // var userName = "PaperHammer";
- // var repositoryName = isBeta ? "VirtualPaper-beta" : "VirtualPaper";
- // var gitRelease = await GithubUtil.GetLatestRelease(repositoryName, userName, 0);
- // Version version = GithubUtil.GetVersion(gitRelease);
-
- // //download asset format: virtualpaper_x64_module_YYY_vXXXX.dll, YYY - module-name, XXXX - 4 digit version no.
- // var gitUrls = await GithubUtil.GetAllAssetUrl(
- // "virtualpaper_x64_module",
- // gitRelease, repositoryName, userName);
- // List<(Uri, Version, string)> res = [];
- // foreach (var url in gitUrls) {
- // Uri uri = new(url);
- // string changelog = gitRelease.Body;
- // res.Add((uri, version, changelog));
- // }
-
- // return res;
- //}
-
- //public async Task<(Uri exeUri, Uri shaUri, Version version, string changelog)> GetLatestRelease(bool isBeta) {
- // var userName = "PaperHammer";
- // var repositoryName = isBeta ? "VirtualPaper-beta" : "VirtualPaper";
- // var gitRelease = await GithubUtil.GetLatestRelease(repositoryName, userName, 0);
- // Version version = GithubUtil.GetVersion(gitRelease);
-
- // //download asset format: virtualpaper_setup_x64_full_vXXXX.exe, XXXX - 4 digit version no.
- // var gitUrl = await GithubUtil.GetAssetUrl(
- // "virtualpaper_setup_x64_full",
- // gitRelease, repositoryName, userName);
- // Uri exeUri = new(gitUrl);
- // string changelog = gitRelease.Body;
-
- // gitUrl = await GithubUtil.GetAssetUrl(
- // "SHA256",
- // gitRelease, repositoryName, userName);
- // Uri shaUri = new(gitUrl);
-
- // return (exeUri, shaUri, version, changelog);
- //}
+ ///
+ /// 本地探测:检查 pending_updates 和 installer_cache 是否有已就绪的更新。
+ /// 验证失败则清理对应目录。
+ ///
+ private AppUpdateStatus? ProbeLocalUpdate() {
+ // 1. 检查 restart-style: pending_updates + update.flag (pending status)
+ if (ProbePluginsReady())
+ return AppUpdateStatus.PluginsReady;
+
+ // 2. 检查 install-style: installer_cache 有文件 + sha256 验证通过
+ if (ProbeInstallerReady())
+ return AppUpdateStatus.InstallerReady;
+
+ return null;
+ }
+
+ private bool ProbePluginsReady() {
+ try {
+ var flagPath = Constants.CommonPaths.UpdateFlagPath;
+ if (!File.Exists(flagPath)) return false;
+
+ var json = File.ReadAllText(flagPath);
+ var flag = JsonSerializer.Deserialize(json, UpdateFlagContext.Default.UpdateFlag);
+ if (flag == null || flag.Status != UpdateFlag.UpdateStatusPending) {
+ FileUtil.RemoveDirectory(Constants.CommonPaths.PendingUpdatesDir);
+ return false;
+ }
+
+ var extractDir = Constants.CommonPaths.PluginPatchExtractDir;
+ if (!Directory.Exists(extractDir)) {
+ FileUtil.RemoveDirectory(Constants.CommonPaths.PendingUpdatesDir);
+ return false;
+ }
+
+ foreach (var kv in flag.Plugins) {
+ foreach (var fileHash in kv.Value.Files) {
+ var filePath = Path.Combine(extractDir, fileHash.Name);
+ if (!FileUtil.VerifyFileIntegrityAsync(filePath, fileHash.Sha256).GetAwaiter().GetResult()) {
+ FileUtil.RemoveDirectory(Constants.CommonPaths.PendingUpdatesDir);
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+ catch {
+ FileUtil.RemoveDirectory(Constants.CommonPaths.PendingUpdatesDir);
+ return false;
+ }
+ }
+
+ private bool ProbeInstallerReady() {
+ try {
+ var cacheDir = Constants.CommonPaths.InstallerCacheDir;
+ if (!Directory.Exists(cacheDir)) return false;
+
+ foreach (var file in Directory.GetFiles(cacheDir)) {
+ if (file.EndsWith(".sha256", StringComparison.OrdinalIgnoreCase)) continue;
+
+ var shaPath = file + ".sha256";
+ if (!File.Exists(shaPath)) continue;
+
+ var expectedSha = File.ReadAllText(shaPath).Trim();
+ if (FileUtil.VerifyFileIntegrityAsync(file, expectedSha).GetAwaiter().GetResult())
+ return true;
+ }
+
+ // 有文件但无有效配对 → 清理
+ FileUtil.RemoveDirectory(Constants.CommonPaths.InstallerCacheDir);
+ return false;
+ }
+ catch {
+ FileUtil.RemoveDirectory(Constants.CommonPaths.InstallerCacheDir);
+ return false;
+ }
+ }
///
/// Check for updates periodically.
@@ -121,9 +189,21 @@ public void Stop() {
#region private
private void RetryTimer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) {
- if ((DateTime.Now - LastCheckTime).TotalMilliseconds > (Status != AppUpdateStatus.Error ? _fetchDelayRepeat : _fetchDelayError)) {
- _ = CheckUpdate(0);
+ if (LastReleaseInfo == null || (DateTime.Now - LastReleaseInfo.CheckedTime).TotalMilliseconds > (Status != AppUpdateStatus.Error ? _fetchDelayRepeat : _fetchDelayError)) {
+ _ = CheckUpdateAsync(0);
+ }
+ }
+
+ private bool HasPluginUpdate(ReleaseInfo releaseInfo) {
+ foreach (var (pluginName, remoteBuild) in releaseInfo.AppCompManifest!.Plugins) {
+ var localBuild = _appBuildService.GetPluginBuild(pluginName);
+ if (!string.IsNullOrEmpty(localBuild) &&
+ string.Compare(remoteBuild, localBuild, StringComparison.Ordinal) > 0) {
+ ArcLog.GetLogger().Info($"Plugin update available: {pluginName} ({localBuild} -> {remoteBuild})");
+ return true;
+ }
}
+ return false;
}
#endregion
@@ -132,5 +212,6 @@ private void RetryTimer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e
private readonly Timer _retryTimer = new();
private readonly IGithubReleaseClient _githubReleaseClient;
private readonly IVersionComparer _versionComparer;
+ private readonly IAppBuildService _appBuildService;
}
}
diff --git a/src/VirtualPaper/Cores/AppUpdate/IAppUpdaterService.cs b/src/VirtualPaper/Cores/AppUpdate/IAppUpdaterService.cs
index e11b0bb5..de234074 100644
--- a/src/VirtualPaper/Cores/AppUpdate/IAppUpdaterService.cs
+++ b/src/VirtualPaper/Cores/AppUpdate/IAppUpdaterService.cs
@@ -1,18 +1,14 @@
-using VirtualPaper.Common.Events;
+using VirtualPaper.Models.AppUpdate;
+using VirtualPaper.Models.Events;
namespace VirtualPaper.Cores.AppUpdate {
public interface IAppUpdaterService {
event EventHandler UpdateChecked;
- string LastCheckChangelog { get; }
- DateTime LastCheckTime { get; }
- Uri LastCheckUri { get; }
- Uri? LastCheckShaUri { get; }
- Version LastCheckVersion { get; }
AppUpdateStatus Status { get; }
+ ReleaseInfo? LastReleaseInfo { get; }
- Task CheckUpdate(int fetchDelay = 45000);
- //Task<(Uri exeUri, Uri shaUri, Version version, string changelog)> GetLatestRelease(bool isBeta);
+ Task CheckUpdateAsync(int fetchDelay = 45000);
void Start();
void Stop();
}
diff --git a/src/VirtualPaper/Cores/AppUpdate/PluginsUpdateService.cs b/src/VirtualPaper/Cores/AppUpdate/PluginsUpdateService.cs
new file mode 100644
index 00000000..3dea3081
--- /dev/null
+++ b/src/VirtualPaper/Cores/AppUpdate/PluginsUpdateService.cs
@@ -0,0 +1,580 @@
+using System.IO;
+using System.IO.Compression;
+using System.Text.Json;
+using VirtualPaper.Common;
+using VirtualPaper.Common.Logging;
+using VirtualPaper.Common.Utils.Files;
+using VirtualPaper.Cores.AppUpdate.Models;
+using VirtualPaper.lang;
+using VirtualPaper.Models.AppUpdate;
+using VirtualPaper.Services.Interfaces;
+using VirtualPaper.Views;
+
+namespace VirtualPaper.Cores.AppUpdate {
+ public interface IPluginsUpdateService {
+ Task DownloadPendingAsync(ReleaseInfo releaseInfo, IProgress? progress = null, CancellationToken token = default);
+ Task VerifyAndSavePendingAsync(ReleaseInfo releaseInfo, CancellationToken token = default);
+ Task ExecutePendingUpdateAsync(IProgress? progress = null, CancellationToken token = default);
+ Task CheckAndRecoverAsync(CancellationToken token = default);
+
+ ///
+ /// 执行待处理的插件更新,显示进度窗口
+ ///
+ Task ExecutePendingPluginUpdateWithWindowAsync();
+ }
+
+ public interface IPluginsUpdateServiceInit {
+ Task InitAsync();
+ }
+
+ public class PluginsUpdateService : IPluginsUpdateService, IPluginsUpdateServiceInit {
+ public PluginsUpdateService(
+ IDownloadService downloadService,
+ IJobService jobService,
+ IAppBuildService appBuildService,
+ IWindowService windowService) {
+ _downloadService = downloadService;
+ _jobService = jobService;
+ _appBuildService = appBuildService;
+ _windowService = windowService;
+ }
+
+ public async Task InitAsync() {
+ UpdateLock.RegisterAll();
+
+ var hasPending = await CheckAndRecoverAsync();
+ if (hasPending) {
+ await ExecutePendingPluginUpdateWithWindowAsync();
+ }
+ else {
+ UpdateLock.ReleaseAll();
+ }
+ _appBuildService.Refresh();
+ }
+
+ public async Task DownloadPendingAsync(ReleaseInfo releaseInfo, IProgress? progress = null, CancellationToken token = default) {
+ var result = new PluginsUpdateResult();
+
+ if (releaseInfo.PluginPatchUri == null) {
+ result.Success = false;
+ result.ErrorMessage = "No plugin patch available";
+ return result;
+ }
+
+ var pendingDir = Constants.CommonPaths.PendingUpdatesDir;
+
+ try {
+ FileUtil.RemoveDirectory(pendingDir);
+ Directory.CreateDirectory(pendingDir);
+
+ var patchZipPath = Path.Combine(pendingDir, "plugins_patch.zip");
+ await foreach (var p in _downloadService.DownloadAsync(releaseInfo.PluginPatchUri, patchZipPath, token)) {
+ progress?.Report(p);
+ }
+
+ result.Success = true;
+ }
+ catch (Exception ex) {
+ ArcLog.GetLogger().Error("Plugin patch download failed", ex);
+ result.Success = false;
+ result.ErrorMessage = ex.Message;
+ FileUtil.RemoveDirectory(pendingDir);
+ }
+
+ return result;
+ }
+
+ public async Task VerifyAndSavePendingAsync(ReleaseInfo releaseInfo, CancellationToken token = default) {
+ var result = new PluginsUpdateResult();
+
+ if (releaseInfo.PluginPatchSha256Uri == null) {
+ result.Success = false;
+ result.ErrorMessage = "No plugin patch SHA256 available";
+ return result;
+ }
+
+ var pendingDir = Constants.CommonPaths.PendingUpdatesDir;
+ var patchZipPath = Path.Combine(pendingDir, "plugins_patch.zip");
+
+ try {
+ // Download and verify SHA256 of plugins_patch.zip
+ var expectedHash = await _downloadService.DownloadShaTxtAsync(releaseInfo.PluginPatchSha256Uri, token);
+ bool verified = await _downloadService.VerifyFileIntegrityAsync(patchZipPath, expectedHash, token);
+ if (!verified) {
+ throw new InvalidDataException("SHA256 verification failed for plugins_patch.zip");
+ }
+
+ // Extract plugins_patch.zip
+ var extractDir = Constants.CommonPaths.PluginPatchExtractDir;
+ if (Directory.Exists(extractDir)) {
+ Directory.Delete(extractDir, true);
+ }
+ Directory.CreateDirectory(extractDir);
+ ZipFile.ExtractToDirectory(patchZipPath, extractDir, true);
+
+ // Parse pending_update_plugins_manifest.json
+ var pendingManifestPath = Path.Combine(extractDir, "pending_update_plugins_manifest.json");
+ if (!File.Exists(pendingManifestPath)) {
+ throw new FileNotFoundException("pending_update_plugins_manifest.json not found in patch");
+ }
+ var pendingJson = await File.ReadAllTextAsync(pendingManifestPath, token);
+ var pendingManifest = JsonSerializer.Deserialize(pendingJson, UpdateManifestContext.Default.PendingUpdateManifest);
+ if (pendingManifest == null || pendingManifest.Plugins.Count == 0) {
+ throw new InvalidOperationException("No plugins to update in pending manifest");
+ }
+
+ // Parse app_comp_manifest.json
+ var appCompManifestPath = Path.Combine(extractDir, "app_comp_manifest.json");
+ AppCompManifest? appCompManifest = null;
+ if (File.Exists(appCompManifestPath)) {
+ var appCompJson = await File.ReadAllTextAsync(appCompManifestPath, token);
+ appCompManifest = JsonSerializer.Deserialize(appCompJson, UpdateManifestContext.Default.AppCompManifest);
+ }
+
+ // Build update flag
+ var updateFlag = new UpdateFlag {
+ Status = UpdateFlag.UpdateStatusPending,
+ AppBuildNumber = appCompManifest?.AppBuildNumber ?? string.Empty,
+ Plugins = new Dictionary()
+ };
+
+ foreach (var (pluginName, pluginInfo) in pendingManifest.Plugins) {
+ // Verify individual plugin zip exists in extracted content
+ var pluginZipPath = Path.Combine(extractDir, pluginInfo.Asset);
+ if (!File.Exists(pluginZipPath)) {
+ throw new FileNotFoundException($"Plugin zip not found in patch: {pluginInfo.Asset}");
+ }
+
+ // Cross-verify: compute actual SHA256 of plugin zip and compare with manifest declaration.
+ // This catches tampering with the manifest's sha256 field after the outer zip was extracted.
+ bool pluginZipVerified = await _downloadService.VerifyFileIntegrityAsync(pluginZipPath, pluginInfo.Sha256, token);
+ if (!pluginZipVerified) {
+ throw new InvalidDataException($"SHA256 verification failed for plugin zip: {pluginInfo.Asset}");
+ }
+
+ updateFlag.Plugins[pluginName] = new PluginFlagInfo {
+ Target = Path.Combine("Plugins", pluginName),
+ Build = pluginInfo.BuildNumber,
+ Files = new List {
+ new FileHashInfo {
+ Name = pluginInfo.Asset,
+ Sha256 = pluginInfo.Sha256
+ }
+ }
+ };
+ }
+
+ await SaveUpdateFlagAsync(updateFlag, token);
+
+ // Store release info for later use
+ releaseInfo.AppCompManifest = appCompManifest;
+ releaseInfo.AppBuild = appCompManifest?.AppBuildNumber;
+
+ result.Success = true;
+ }
+ catch (Exception ex) {
+ ArcLog.GetLogger().Error("Plugin patch verify failed", ex);
+ result.Success = false;
+ result.ErrorMessage = ex.Message;
+ FileUtil.RemoveDirectory(pendingDir);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Execute a pending update: close UI, backup, replace, cleanup.
+ /// Can be called immediately after download, or later on UI close / core start.
+ ///
+ public async Task ExecutePendingUpdateAsync(IProgress? progress = null, CancellationToken token = default) {
+ var result = new PluginsUpdateResult();
+ var pendingDir = Constants.CommonPaths.PendingUpdatesDir;
+ var flagPath = Constants.CommonPaths.UpdateFlagPath;
+
+ if (!File.Exists(flagPath)) {
+ result.Success = false;
+ result.ErrorMessage = "No pending update found";
+ return result;
+ }
+
+ var flag = await LoadUpdateFlagAsync(token);
+ if (flag == null || flag.Status != UpdateFlag.UpdateStatusPending) {
+ result.Success = false;
+ result.ErrorMessage = "No pending update found or invalid state";
+ return result;
+ }
+
+ try {
+ var extractDir = Constants.CommonPaths.PluginPatchExtractDir;
+
+ // Verify extracted plugin zips against hashes in flag
+ foreach (var (pluginName, pluginInfo) in flag.Plugins) {
+ foreach (var fileHash in pluginInfo.Files) {
+ var filePath = Path.Combine(extractDir, fileHash.Name);
+ if (!File.Exists(filePath)) {
+ throw new FileNotFoundException($"File missing: {Path.GetFileName(filePath)}");
+ }
+ bool verified = await _downloadService.VerifyFileIntegrityAsync(filePath, fileHash.Sha256, token);
+ if (!verified) {
+ throw new InvalidDataException($"File verification failed: {Path.GetFileName(filePath)}");
+ }
+ }
+ }
+
+ // Lock first (prevent restart), then stop processes
+ var updatingPlugins = ParsePluginNames(flag.Plugins.Keys);
+ UpdateLock.LockAll(updatingPlugins);
+ StopPlugins(updatingPlugins);
+
+ // Step: Backup current plugins
+ progress?.Report(new PluginsUpdateProgress(PluginsUpdateStage.BackingUp, 0, LanguageManager.Instance[nameof(Constants.I18n.PluginsUpdate_Stage_BackingUp)]));
+ var backupDir = Constants.CommonPaths.UpdateBackupDir;
+ FileUtil.RemoveDirectory(backupDir);
+ Directory.CreateDirectory(backupDir);
+
+ foreach (var (pluginName, pluginInfo) in flag.Plugins) {
+ var sourceDir = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, pluginInfo.Target));
+ if (Directory.Exists(sourceDir)) {
+ var backupPath = Path.Combine(backupDir, pluginName);
+ FileUtil.CopyDirectory(sourceDir, backupPath, true);
+ }
+ }
+
+ // Backup app_comp_manifest.json from WorkSpace root
+ var appCompManifestPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "app_comp_manifest.json");
+ if (File.Exists(appCompManifestPath)) {
+ var appManifestBackup = Path.Combine(backupDir, "app_comp_manifest.json");
+ File.Copy(appCompManifestPath, appManifestBackup, true);
+ }
+
+ // Step: Update flag to in_progress
+ flag.Status = UpdateFlag.UpdateStatusInProgress;
+ await SaveUpdateFlagAsync(flag, token);
+
+ // Step: Replace plugins sequentially (avoid IO contention on Windows)
+ progress?.Report(new PluginsUpdateProgress(PluginsUpdateStage.Replacing, 0, LanguageManager.Instance[nameof(Constants.I18n.PluginsUpdate_Stage_Replacing)]));
+ int totalPlugins = flag.Plugins.Count;
+ int replacedCount = 0;
+
+ foreach (var (pluginName, pluginInfo) in flag.Plugins) {
+ token.ThrowIfCancellationRequested();
+
+ var targetDir = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, pluginInfo.Target));
+ var zipPath = Path.Combine(extractDir, pluginInfo.Files[0].Name);
+
+ // Clear target directory
+ if (Directory.Exists(targetDir)) {
+ FileUtil.DeleteDirectoryContents(targetDir);
+ }
+ else {
+ Directory.CreateDirectory(targetDir);
+ }
+
+ // Extract plugin zip - the zip contains a single folder with the plugin name
+ var pluginExtractDir = Path.Combine(pendingDir, pluginName, "extracted");
+ if (Directory.Exists(pluginExtractDir)) {
+ Directory.Delete(pluginExtractDir, true);
+ }
+ Directory.CreateDirectory(pluginExtractDir);
+ ZipFile.ExtractToDirectory(zipPath, pluginExtractDir, true);
+
+ // Find the plugin folder inside the extracted content
+ var folders = Directory.GetDirectories(pluginExtractDir);
+ if (folders.Length != 1) {
+ throw new InvalidOperationException($"Expected exactly one folder in plugin zip, found {folders.Length}");
+ }
+ var pluginFolder = folders[0];
+
+ // Move contents from plugin folder to target
+ foreach (var item in Directory.GetFileSystemEntries(pluginFolder)) {
+ var destPath = Path.Combine(targetDir, Path.GetFileName(item));
+ if (Directory.Exists(item)) {
+ FileUtil.CopyDirectory(item, destPath, true);
+ Directory.Delete(item, true);
+ }
+ else {
+ File.Move(item, destPath);
+ }
+ }
+
+ replacedCount++;
+ progress?.Report(new PluginsUpdateProgress(PluginsUpdateStage.Replacing, (float)replacedCount / totalPlugins * 100, string.Format(LanguageManager.Instance[nameof(Constants.I18n.PluginUpdate_ReplacedPlugin)], pluginName)));
+ }
+
+ // Step: Copy app_comp_manifest.json from extracted patch to both BaseDirectory and AppDataDir
+ var extractedAppCompManifest = Path.Combine(extractDir, "app_comp_manifest.json");
+ if (File.Exists(extractedAppCompManifest)) {
+ var baseManifest = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "app_comp_manifest.json");
+ var appDataManifest = Path.Combine(Constants.CommonPaths.AppDataDir, "app_comp_manifest.json");
+ File.Copy(extractedAppCompManifest, baseManifest, true);
+ File.Copy(extractedAppCompManifest, appDataManifest, true);
+ }
+
+ // Step: Process removed plugins
+ foreach (var pluginName in flag.RemovedPlugins) {
+ var pluginDir = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Plugins", pluginName));
+ if (Directory.Exists(pluginDir)) {
+ Directory.Delete(pluginDir, true);
+ }
+ }
+
+ // Refresh build info from manifest
+ _appBuildService.Refresh();
+
+ // Step: Update flag to completed, then cleanup
+ flag.Status = UpdateFlag.UpdateStatusCompleted;
+ await SaveUpdateFlagAsync(flag, token);
+ FileUtil.RemoveDirectory(pendingDir);
+ FileUtil.RemoveDirectory(backupDir);
+
+ result.Success = true;
+ progress?.Report(new PluginsUpdateProgress(PluginsUpdateStage.Completed, 100, LanguageManager.Instance[nameof(Constants.I18n.PluginsUpdate_Stage_Completed)]));
+ }
+ catch (Exception ex) {
+ ArcLog.GetLogger().Error("Plugins update failed", ex);
+ progress?.Report(new PluginsUpdateProgress(PluginsUpdateStage.Failed, 100, LanguageManager.Instance[nameof(Constants.I18n.PluginsUpdate_Stage_Failed)]));
+ result.Success = false;
+ result.ErrorMessage = ex.Message;
+
+ // Only rollback if backup exists (i.e. installation had started)
+ var backupDir = Constants.CommonPaths.UpdateBackupDir;
+ if (Directory.Exists(backupDir)) {
+ await RollbackAsync(flag);
+ }
+ else {
+ // No backup = verification failed before installation, just clean up
+ FileUtil.RemoveDirectory(Constants.CommonPaths.PendingUpdatesDir);
+ }
+ await WriteUpdateFailedNoticeAsync(ex, token);
+ }
+ finally {
+ UpdateLock.ReleaseAll();
+ _jobService.PluginsUpdateFinished();
+ }
+
+ return result;
+ }
+
+ public async Task CheckAndRecoverAsync(CancellationToken token = default) {
+ var pendingDir = Constants.CommonPaths.PendingUpdatesDir;
+ if (!Directory.Exists(pendingDir)) return false;
+
+ var flagPath = Constants.CommonPaths.UpdateFlagPath;
+ if (!File.Exists(flagPath)) {
+ // Flag missing but pending dir exists - cleanup
+ FileUtil.RemoveDirectory(pendingDir);
+ return false;
+ }
+
+ try {
+ var flag = await LoadUpdateFlagAsync(token);
+ if (flag == null) {
+ await RollbackAsync(null);
+ return false;
+ }
+
+ switch (flag.Status) {
+ case UpdateFlag.UpdateStatusPending:
+ return true;
+
+ case UpdateFlag.UpdateStatusInProgress:
+ await RollbackAsync(flag);
+ return false;
+
+ case UpdateFlag.UpdateStatusCompleted:
+ FileUtil.RemoveDirectory(pendingDir);
+ FileUtil.RemoveDirectory(Constants.CommonPaths.UpdateBackupDir);
+ return false;
+ }
+ }
+ catch (Exception ex) {
+ ArcLog.GetLogger().Error("Recovery check failed", ex);
+ await RollbackAsync(null);
+ }
+ return false;
+ }
+
+ private void StopPlugins(IEnumerable plugins) {
+ foreach (var plugin in plugins) {
+ try { _jobService.StopPlugin(plugin); }
+ catch (Exception ex) { ArcLog.GetLogger().Warn($"Failed to stop {plugin}: {ex.Message}"); }
+ }
+ }
+
+ private static List ParsePluginNames(IEnumerable names) =>
+ names
+ .Select(n => Enum.TryParse(n, true, out var p) ? (PluginName?)p : null)
+ .Where(p => p.HasValue)
+ .Select(p => p!.Value)
+ .ToList();
+
+ private async Task RollbackAsync(UpdateFlag? flag) {
+ ArcLog.GetLogger().Warn("Start plugins rolling back");
+ var backupDir = Constants.CommonPaths.UpdateBackupDir;
+ var pendingDir = Constants.CommonPaths.PendingUpdatesDir;
+ if (!Directory.Exists(backupDir)) {
+ ArcLog.GetLogger().Warn("No backup found for rollback");
+ FileUtil.RemoveDirectory(pendingDir);
+ return;
+ }
+
+ try {
+ // Restore each backed up plugin using target path from flag if available
+ foreach (var backupPluginDir in Directory.GetDirectories(backupDir)) {
+ var pluginName = Path.GetFileName(backupPluginDir);
+ string targetDir;
+
+ if (flag != null && flag.Plugins.TryGetValue(pluginName, out var pluginInfo)) {
+ targetDir = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, pluginInfo.Target));
+ }
+ else {
+ targetDir = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Plugins", pluginName));
+ }
+
+ // Clear current
+ if (Directory.Exists(targetDir)) {
+ FileUtil.DeleteDirectoryContents(targetDir);
+ }
+ else {
+ Directory.CreateDirectory(targetDir);
+ }
+
+ FileUtil.CopyDirectory(backupPluginDir, targetDir, true);
+ }
+
+ // Restore app_comp_manifest.json from backup to both BaseDirectory and AppDataDir
+ var appCompManifestBackup = Path.Combine(backupDir, "app_comp_manifest.json");
+ if (File.Exists(appCompManifestBackup)) {
+ var baseManifest = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "app_comp_manifest.json");
+ var appDataManifest = Path.Combine(Constants.CommonPaths.AppDataDir, "app_comp_manifest.json");
+ File.Copy(appCompManifestBackup, baseManifest, true);
+ File.Copy(appCompManifestBackup, appDataManifest, true);
+ }
+
+ // Refresh build info from manifest
+ _appBuildService.Refresh();
+
+ ArcLog.GetLogger().Info("Rollback completed");
+ }
+ catch (Exception ex) {
+ ArcLog.GetLogger().Error("Rollback failed", ex);
+ }
+ finally {
+ FileUtil.RemoveDirectory(pendingDir);
+ FileUtil.RemoveDirectory(backupDir);
+ }
+ }
+
+ private async Task WriteUpdateFailedNoticeAsync(Exception ex, CancellationToken token) {
+ var notice = new UpdateFailedNotice {
+ MessageKey = Constants.I18n.AppUpdater_UpdateFailedMessage,
+ ExceptionMessage = ex.ToString()
+ };
+ var json = JsonSerializer.Serialize(notice, UpdateFailedNoticeContext.Default.UpdateFailedNotice);
+ await File.WriteAllTextAsync(Constants.CommonPaths.UpdateFailedNoticePath, json, token);
+ }
+
+ private async Task LoadUpdateFlagAsync(CancellationToken token) {
+ var flagPath = Constants.CommonPaths.UpdateFlagPath;
+ if (!File.Exists(flagPath)) return null;
+
+ var json = await File.ReadAllTextAsync(flagPath, token);
+ return JsonSerializer.Deserialize(json, UpdateFlagContext.Default.UpdateFlag);
+ }
+
+ private async Task SaveUpdateFlagAsync(UpdateFlag flag, CancellationToken token) {
+ var flagPath = Constants.CommonPaths.UpdateFlagPath;
+ Directory.CreateDirectory(Path.GetDirectoryName(flagPath)!);
+ var json = JsonSerializer.Serialize(flag, UpdateFlagContext.Default.UpdateFlag);
+ await File.WriteAllTextAsync(flagPath, json, token);
+ }
+
+ public async Task ExecutePendingPluginUpdateWithWindowAsync() {
+ // Prevent concurrent execution
+ lock (_pendingLock) {
+ if (_pendingUpdateTask != null && !_pendingUpdateTask.IsCompleted) return;
+ _updateCts = new CancellationTokenSource();
+ _pendingUpdateTask = ExecutePendingPluginUpdateCoreAsync(_updateCts.Token);
+ }
+ await _pendingUpdateTask;
+ }
+
+ private async Task ExecutePendingPluginUpdateCoreAsync(CancellationToken token) {
+ try {
+ var flagPath = Constants.CommonPaths.UpdateFlagPath;
+ if (!File.Exists(flagPath)) return;
+
+ // Create window on UI thread
+ PluginUpdateWindow? progressWindow = null;
+ System.Windows.Application.Current.Dispatcher.Invoke(() => {
+ _windowService.Show(bringToFront: true);
+ _windowService.TryGet(out progressWindow);
+ });
+
+ // Progress callback marshals to UI thread (non-blocking)
+ var progress = new Progress(p => {
+ System.Windows.Application.Current.Dispatcher.BeginInvoke(() => {
+ progressWindow?.ReportProgress(p);
+ });
+ });
+
+ var result = await ExecutePendingUpdateAsync(progress, token);
+ if (!result.Success) {
+ System.Windows.Application.Current.Dispatcher.Invoke(() => {
+ progressWindow?.ShowError(result.ErrorMessage ?? "Unknown error");
+ });
+ }
+ else {
+ // Auto-close on success
+ System.Windows.Application.Current.Dispatcher.Invoke(() => {
+ progressWindow?.Close();
+ });
+ }
+ }
+ catch (OperationCanceledException) {
+ ArcLog.GetLogger().Info("Plugin update cancelled");
+ }
+ catch (Exception ex) {
+ ArcLog.GetLogger().Error("Failed to execute pending plugin update", ex);
+ }
+ }
+
+ private readonly IDownloadService _downloadService;
+ private readonly IJobService _jobService;
+ private readonly IAppBuildService _appBuildService;
+ private readonly IWindowService _windowService;
+
+ private Task? _pendingUpdateTask;
+ private readonly object _pendingLock = new();
+ private CancellationTokenSource? _updateCts;
+ }
+
+ public class PluginsUpdateResult {
+ public bool Success { get; set; }
+ public string? ErrorMessage { get; set; }
+ }
+
+ public record PluginsUpdateProgress(
+ PluginsUpdateStage Stage,
+ float Percent,
+ string Message,
+ float Speed = 0,
+ IReadOnlyList? PluginDetails = null);
+
+ public record PluginDownloadProgress(
+ string PluginName,
+ float Percent,
+ long ReceivedBytes,
+ long TotalBytes,
+ float Speed) {
+ public string SizeText => $"{FileUtil.SizeSuffix(ReceivedBytes)} / {FileUtil.SizeSuffix(TotalBytes)}";
+ }
+
+ public enum PluginsUpdateStage {
+ Downloading,
+ BackingUp,
+ Replacing,
+ Completed,
+ Failed
+ }
+}
diff --git a/src/VirtualPaper/Cores/AppUpdate/README.md b/src/VirtualPaper/Cores/AppUpdate/README.md
new file mode 100644
index 00000000..b17c101c
--- /dev/null
+++ b/src/VirtualPaper/Cores/AppUpdate/README.md
@@ -0,0 +1,274 @@
+# Hot Update Mechanism
+
+## Supported Plugins
+
+| Plugin | Type | Loaded By | Hot-Updatable |
+|--------|------|-----------|---------------|
+| **UI** | Separate process | Self | ✅ |
+| **PlayerWeb** | Separate process | Self | ✅ |
+| **ScrSaver** | Separate process | Self | ✅ |
+| **ML** | DLL + model files | UI process | ✅ (UI restart required) |
+| **Shaders** | Data files (.cso) | PlayerWeb process | ✅ (PlayerWeb restart required) |
+
+**Update behavior:**
+- Restart-style update always stops and restarts UI
+- If PlayerWeb is being updated, it's also stopped and restarted with UI
+- ML and Shaders don't need explicit stop — they're loaded automatically when UI/PlayerWeb starts
+- UI startup is blocked if UI, ML, or Shaders are being updated
+
+## Update Type Detection
+
+When a new release is found, determine update type:
+
+```
+Fetch release → Check if "update_manifest.json" exists in release assets
+├── Exists → Download and parse manifest
+│ ├── type = "restart" → Restart-style: download plugin files per manifest
+│ ├── type = "install" → Installer-style: download setup exe (current flow)
+│ └── Parse failure → Fallback to installer-style
+└── Not exists → Installer-style (backward compatible, current behavior)
+```
+
+### update_manifest.json Format
+
+```json
+{
+ "type": "restart",
+ "app_build": "2606R23T1430",
+ "min_app_build": "2606R01T1200",
+ "plugins": {
+ "UI": {
+ "build": "2606R23T1430",
+ "asset": "plugin_UI_2606R23T1430.zip",
+ "sha256": "abc..."
+ },
+ "PlayerWeb": {
+ "build": "2606R20T1000",
+ "asset": "plugin_PlayerWeb_2606R20T1000.zip",
+ "sha256": "def..."
+ }
+ },
+ "removed_plugins": ["ScrSaver"]
+}
+```
+
+- `type`: `"restart"` (hot-update) or `"install"` (installer update)
+- `app_build`: current app build number (format: `YYMM` + `R` + `DD` + `T` + `HHMM`, e.g., `2606R23T1430`)
+- `min_app_build`: minimum app build required for this hot-update to be compatible
+- `plugins`: map of plugin name to update info
+ - `build`: plugin build number (only changes when this plugin is updated)
+ - `asset`: release asset filename to download
+ - `sha256`: expected hash for verification
+- `removed_plugins`: list of plugin names to be removed (optional)
+ - Client deletes local `Plugins/{name}/` directory for each entry
+ - Supports complete plugin removal via hot-update
+
+### Plugin Zip Structure
+
+Each plugin zip (`plugin_{name}_{build}.zip`) contains the plugin folder contents directly:
+```
+plugin_UI_2606R23T1430.zip
+├── VirtualPaper.UI.exe
+├── VirtualPaper.UI.dll
+├── ... (all plugin files)
+```
+
+The zip-level SHA256 is stored in `update_manifest.json` for download verification.
+
+### Client Update Flow (Parallel Execution)
+
+```
+Phase 1: Download (UI still running)
+1. Download all plugin zips in parallel
+ └── Verify each zip SHA256 against manifest (parallel)
+2. Write update flag (status="pending") with file hashes
+
+Phase 2: Execute (triggered by UI close or core start)
+3. Verify downloaded files against hashes in flag
+4. Lock plugins (prevent startup)
+5. Stop plugins (always stops UI)
+6. Backup current plugins
+7. Update flag to "in_progress"
+8. Extract and replace all plugins in parallel
+9. Update app_build.json
+10. Process removed_plugins
+11. Update flag to "completed", cleanup
+12. Restart UI
+```
+
+### Pending Update Mechanism
+
+If the user cancels UI close (e.g., unsaved work), the update stays in "pending" state:
+- Downloaded files remain in `pending_updates/`
+- Flag file indicates pending state
+- On next UI close or core start, `CheckAndRecoverAsync` detects pending update and executes it
+
+**Trigger points:**
+- `UIRunnerService.Proc_UI_Exited` — when UI process exits normally
+- `App.xaml.cs` startup — `CheckAndRecoverAsync` called on core start
+
+**Safety:**
+- Files are verified against stored hashes before execution
+- If files are missing or corrupted, pending update is cleaned up
+- `UpdateLock` prevents re-entry during active update
+
+## Version Management
+
+Each component has an independent build number (format: `YYMM` + `R` + `DD` + `T` + `HHMM`):
+
+| Component | Build Number Rule | Example |
+|-----------|-------------------|---------|
+| App | Changes on **any** update | `2606R23T1430` → `2606R24T1000` (any update) |
+| UI Plugin | Changes only when **UI** is updated | `2606R23T1430` → stays `2606R23T1430` if not updated |
+| PlayerWeb Plugin | Changes only when **PlayerWeb** is updated | `2606R20T1000` → stays `2606R20T1000` if not updated |
+
+### Local Version Tracking
+
+`app_build.json` stores all build numbers:
+```json
+{
+ "app_build": "2606R23T1430",
+ "plugins": {
+ "UI": "2606R23T1430",
+ "PlayerWeb": "2606R20T1000"
+ }
+}
+```
+
+**File locations:**
+- **Installation directory** (`BaseDirectory/app_build.json`): source of truth. Generated at build time, updated by hot-update.
+- **AppData directory** (`AppDataDir/app_build.json`): synced copy. Force-overwritten from installation directory on every main process startup.
+
+**Flow:**
+1. Build time → MSBuild generates `app_build.json` in output directory
+2. Installer → includes file, installs to installation directory
+3. Main process startup → `AppBuildService.Refresh()` reads from installation dir → force-overwrites to AppData
+4. Hot-update → `PluginsUpdateService` updates installation directory's `app_build.json`
+5. UI reads from AppData (always up-to-date after sync)
+
+### Update Decision Logic
+
+1. Read manifest
+2. Read local `app_build.json`
+3. Process `removed_plugins` (if any):
+ - Delete local `Plugins/{name}/` directory for each entry
+ - Remove from local `app_build.json`
+4. For each plugin in manifest:
+ - Compare local plugin build vs manifest build
+ - Different or missing → needs update
+ - Same → skip
+5. Only download and replace plugins that need update
+6. After successful update, update `app_build.json`
+
+## Storage Structure
+
+```
+AppDataDir/
+├── pending_updates/
+│ ├── update.flag # Flag file with update metadata
+│ ├── UI/ # New files for Plugins/UI/
+│ ├── PlayerWeb/ # New files for Plugins/PlayerWeb/
+│ ├── ScrSaver/ # New files for Plugins/ScrSaver/
+│ └── _backup/ # Backup of current plugins (created during update)
+│ ├── UI/
+│ ├── PlayerWeb/
+│ └── ScrSaver/
+```
+
+## update.flag Format
+
+```json
+{
+ "status": "pending",
+ "plugins": {
+ "UI": {
+ "target": "Plugins/UI",
+ "files": [
+ { "name": "VirtualPaper.UI.exe", "sha256": "abc..." },
+ { "name": "VirtualPaper.UI.dll", "sha256": "def..." }
+ ]
+ }
+ }
+}
+```
+
+Status values: `pending` | `in_progress` | `completed`
+
+## Update Flow
+
+### Download Phase
+
+1. Download new files to `pending_updates/{plugin}/`
+2. Calculate SHA256 for each downloaded file
+3. Write `update.flag` with `status="pending"`, file list + hashes
+
+### Execution Phase (UI closed, triggered by main process)
+
+1. Read `update.flag`
+2. **Backup** current `plugins/` → `pending_updates/_backup/{plugin}/` (first step)
+3. Verify all downloaded files against SHA256
+ - Any failure → delete `pending_updates/` (no restore needed, originals untouched)
+ - All pass → continue
+4. Write `update.flag` with `status="in_progress"`
+5. For each plugin:
+ - Clear target folder
+ - Copy new files from `pending_updates/{plugin}/`
+ - Verify copied files match SHA256
+6. All successful:
+ - Delete `pending_updates/` (including backups)
+ - Start UI
+7. Any failure:
+ - Full rollback from `_backup/`
+ - Delete `pending_updates/`
+ - Start UI
+
+### Crash Recovery (main process startup)
+
+1. `pending_updates/` does not exist → normal startup
+2. `update.flag` missing or corrupted → rollback from `_backup/`, delete `pending_updates/`
+3. `status="pending"` → re-verify files, proceed with execution if pass, else delete
+4. `status="in_progress"` → rollback from `_backup/`, delete `pending_updates/`
+5. `status="completed"` → delete `pending_updates/`
+
+## Key Principles
+
+- **No intermediate state**: either full success or full rollback
+- **Backup first**: before any modifications, backup current plugins
+- **Verify twice**: verify downloaded files before replacement, verify copied files after
+- **Always cleanup**: all paths (success, failure, crash) end with `pending_updates/` deleted
+- **Granularity**: per-plugin folder (each plugin is a separate process)
+
+## Fallback Cases
+
+- `pending_updates/` directory missing → rollback if `_backup/` exists
+- `update.flag` corrupted → rollback from `_backup/`
+- Files manually deleted → rollback from `_backup/` if available
+- Backup also missing → log error, start UI with current state (may be broken)
+
+## Rollback Notification
+
+When a restart-style update is rolled back, write a notification file. UI displays a global message on next startup.
+
+### File Location
+
+`AppDataDir/update_rollback_notice.json`
+
+### Format
+
+```json
+{
+ "rollback": true
+}
+```
+
+### Flow
+
+1. **On rollback** (any path): write `update_rollback_notice.json` before cleaning up `pending_updates/`
+2. **UI startup**: check if `update_rollback_notice.json` exists
+ - Exists → display global message, then **delete the file**
+ - Not exists → normal startup
+
+### Message (based on current language setting)
+
+- **中文**: "更新发生错误,请重试或执行全量更新"
+- **English**: "Update failed. Please retry or perform a full update."
diff --git a/src/VirtualPaper/Cores/AppUpdate/UpdateLock.cs b/src/VirtualPaper/Cores/AppUpdate/UpdateLock.cs
new file mode 100644
index 00000000..59b85cab
--- /dev/null
+++ b/src/VirtualPaper/Cores/AppUpdate/UpdateLock.cs
@@ -0,0 +1,98 @@
+using System.Collections.Concurrent;
+using VirtualPaper.Common;
+
+namespace VirtualPaper.Cores.AppUpdate {
+ ///
+ /// Per-plugin async gate for coordinating plugin startup with updates.
+ /// All plugins start in a locked state. Core releases after check/update completes.
+ /// Uses ManualResetEventSlim (state-based, not consumed) so multiple waiters work.
+ ///
+ public static class UpdateLock {
+ private static readonly ConcurrentDictionary _gates = new();
+
+ private static ManualResetEventSlim GetGate(PluginName plugin) =>
+ _gates.GetOrAdd(plugin, _ => new ManualResetEventSlim(false));
+
+ ///
+ /// Pre-create gates for all known plugins. All start locked (unset).
+ /// Call once at startup before any plugin attempts to start.
+ ///
+ public static void RegisterAll() {
+ foreach (var plugin in Enum.GetValues()) {
+ _ = GetGate(plugin);
+ }
+ }
+
+ ///
+ /// Async wait until the plugin is free to start. Blocks while locked.
+ ///
+ public static Task WaitAsync(PluginName plugin, CancellationToken token = default) {
+ var gate = GetGate(plugin);
+ if (gate.IsSet) return Task.CompletedTask;
+ return Task.Run(() => gate.Wait(token), token);
+ }
+
+ ///
+ /// Release the lock for a plugin, allowing pending startups to proceed.
+ ///
+ public static void Release(PluginName plugin) {
+ if (_gates.TryGetValue(plugin, out var gate)) {
+ gate.Set();
+ }
+ }
+
+ ///
+ /// Release locks for multiple plugins at once.
+ ///
+ public static void ReleaseAll(IEnumerable plugins) {
+ foreach (var plugin in plugins) Release(plugin);
+ }
+
+ ///
+ /// Release all registered plugin locks.
+ ///
+ public static void ReleaseAll() {
+ foreach (var kv in _gates) {
+ kv.Value.Set();
+ }
+ }
+
+ ///
+ /// Re-lock a plugin, blocking its startup until released again.
+ ///
+ public static void Lock(PluginName plugin) {
+ if (_gates.TryGetValue(plugin, out var gate)) {
+ gate.Reset();
+ }
+ }
+
+ ///
+ /// Re-lock multiple plugins.
+ ///
+ public static void LockAll(IEnumerable plugins) {
+ foreach (var plugin in plugins) Lock(plugin);
+ }
+
+ ///
+ /// Check if a plugin is currently locked.
+ ///
+ public static bool IsLocked(PluginName plugin) =>
+ _gates.TryGetValue(plugin, out var gate) && !gate.IsSet;
+
+ ///
+ /// Async wait until all registered plugins are unlocked.
+ /// Only waits on gates that are currently locked.
+ ///
+ public static async Task WaitAllAsync(CancellationToken token = default) {
+ var lockedGates = _gates.Values.Where(g => !g.IsSet).ToList();
+ if (lockedGates.Count == 0) return;
+ await Task.WhenAll(lockedGates.Select(g => Task.Run(() => g.Wait(token), token)));
+ }
+
+ ///
+ /// Check if any registered plugin is currently locked.
+ ///
+ public static bool IsAnyLocked =>
+ _gates.Any(kv => !kv.Value.IsSet);
+ }
+}
diff --git a/src/VirtualPaper/Cores/Players/Web/WpPlayerWeb.cs b/src/VirtualPaper/Cores/Players/Web/WpPlayerWeb.cs
index 860a064e..063fc6b8 100644
--- a/src/VirtualPaper/Cores/Players/Web/WpPlayerWeb.cs
+++ b/src/VirtualPaper/Cores/Players/Web/WpPlayerWeb.cs
@@ -107,7 +107,7 @@ public async Task ShowAsync(CancellationToken token) {
Proc.Exited += Proc_Exited;
Proc.OutputDataReceived += Proc_OutputDataReceived;
Proc.Start();
- App.Jobs.AddProcess(Proc.Id);
+ App.Jobs.AddProcess(Proc.Id, PluginName.PlayerWeb);
Proc.BeginOutputReadLine();
StartArgs = new PlayerWebSrartArgs(Data, IsPreview).ToJson();
@@ -234,10 +234,14 @@ private void Proc_OutputDataReceived(object sender, DataReceivedEventArgs e) {
}
private void Proc_Exited(object? sender, EventArgs e) {
+ var pid = Proc.Id;
_tcsProcessWait.TrySetResult(null);
Proc.OutputDataReceived -= Proc_OutputDataReceived;
Terminate();
IsExited = true;
+ if (!App.IsShuttingDown) {
+ App.Jobs.StopPlugin(pid);
+ }
}
public bool Equals(IWpPlayer? other) {
diff --git a/src/VirtualPaper/Cores/ScreenSaver/IScrControl.cs b/src/VirtualPaper/Cores/ScreenSaver/IScrControl.cs
index 86ac1b27..a2944780 100644
--- a/src/VirtualPaper/Cores/ScreenSaver/IScrControl.cs
+++ b/src/VirtualPaper/Cores/ScreenSaver/IScrControl.cs
@@ -4,7 +4,7 @@ public interface IScrControl : IDisposable {
void AddToWhiteList(string procName);
void ChangeLockStatu(bool isLock);
void RemoveFromWhiteList(string procName);
- void Start();
+ Task StartAsync();
void Stop();
}
}
diff --git a/src/VirtualPaper/Cores/ScreenSaver/ScrControl.cs b/src/VirtualPaper/Cores/ScreenSaver/ScrControl.cs
index ac1711a7..1da68bcd 100644
--- a/src/VirtualPaper/Cores/ScreenSaver/ScrControl.cs
+++ b/src/VirtualPaper/Cores/ScreenSaver/ScrControl.cs
@@ -7,6 +7,7 @@
using VirtualPaper.Common.Logging;
using VirtualPaper.Common.Utils.IPC;
using VirtualPaper.Common.Utils.PInvoke;
+using VirtualPaper.Cores.AppUpdate;
using VirtualPaper.Cores.WpControl;
using VirtualPaper.Services.Interfaces;
using VirtualPaper.Utils.Interfcaes;
@@ -44,7 +45,10 @@ public ScrControl(
}
}
- public void Start() {
+ public async Task StartAsync() {
+ // Wait for any pending plugin update to complete
+ await UpdateLock.WaitAllAsync();
+
if (!_userSettings.Settings.IsScreenSaverOn || _isTiming || IsRunning) return;
try {
@@ -115,7 +119,7 @@ private void ResetTimer(string reason) {
}
// -------------------------------------------------------------------------
- // Process: Start
+ // Process: StartAsync
// -------------------------------------------------------------------------
private void DispatcherTimer_Tick(object? sender, EventArgs e) {
@@ -176,7 +180,7 @@ private void LaunchProc(string filePath, string rtype) {
_processLauncher.Exited += Proc_Exited;
_processLauncher.OutputDataReceived += Proc_OutputDataReceived;
_processLauncher.Launch(startInfo);
- _jobService.AddProcess(_processLauncher.ProcessId);
+ _jobService.AddProcess(_processLauncher.ProcessId, PluginName.ScrSaver);
_processLauncher.BeginOutputReadLine();
ArcLog.GetLogger().Info("ScreenSaver launched.");
@@ -208,11 +212,15 @@ private void StopProc() {
/// 无论是主动 Stop 还是意外退出,都走这里。
///
private void Proc_Exited(object? sender, EventArgs e) {
+ var pid = _processLauncher.ProcessId;
_processLauncher.OutputDataReceived -= Proc_OutputDataReceived;
_processLauncher.Exited -= Proc_Exited;
CleanupProc();
- RestartTimerAfterExit();
+ if (!App.IsShuttingDown) {
+ _jobService.StopPlugin(pid);
+ RestartTimerAfterExit();
+ }
}
///
diff --git a/src/VirtualPaper/Cores/WpControl/IWallpaperControl.cs b/src/VirtualPaper/Cores/WpControl/IWallpaperControl.cs
index dfae775b..120156e4 100644
--- a/src/VirtualPaper/Cores/WpControl/IWallpaperControl.cs
+++ b/src/VirtualPaper/Cores/WpControl/IWallpaperControl.cs
@@ -43,7 +43,7 @@ public interface IWallpaperControl : IDisposable {
string GetPlayerStartArgsInRunning(string monitorId);
string? GetPlayerStartArgs(IWpPlayerData wpPlayingData, CancellationToken toke = default);
Task ResetWallpaperAsync();
- Grpc_RestartWallpaperResponse RestoreWallpaper();
+ Task RestoreWallpaperAsync();
Task SetWallpaperAsync(IWpPlayerData data, IMonitor monitor, bool isFromPreview = false, CancellationToken token = default);
void SeekWallpaper(IWpPlayerData data, float seek, PlaybackPosType type);
void SeekWallpaper(IMonitor monitor, float seek, PlaybackPosType type);
diff --git a/src/VirtualPaper/Cores/WpControl/WallpaperControl.cs b/src/VirtualPaper/Cores/WpControl/WallpaperControl.cs
index db003ab4..5e06a2e2 100644
--- a/src/VirtualPaper/Cores/WpControl/WallpaperControl.cs
+++ b/src/VirtualPaper/Cores/WpControl/WallpaperControl.cs
@@ -11,6 +11,7 @@
using VirtualPaper.Common.Utils.PInvoke;
using VirtualPaper.Common.Utils.Shell;
using VirtualPaper.Common.Utils.Storage;
+using VirtualPaper.Cores.AppUpdate;
using VirtualPaper.Cores.Monitor;
using VirtualPaper.DataAssistor;
using VirtualPaper.Factories.Interfaces;
@@ -52,6 +53,10 @@ public WallpaperControl(
this._monitorManager.MonitorUpdated += MonitorSettingsChanged_Hwnd;
this.WallpaperChanged += SetupDesktop_WallpaperChanged;
+ _jobService.PluginsUpdateFinishedEvent += (s, e) => {
+ _ = RestoreWallpaperAsync();
+ };
+
SystemEvents.SessionSwitch += (s, e) => {
if (e.Reason == SessionSwitchReason.SessionUnlock) {
if (!(DesktopWorkerW == IntPtr.Zero || Native.IsWindow(DesktopWorkerW))) {
@@ -193,9 +198,12 @@ public async Task ResetWallpaperAsync() {
}
}
- public Grpc_RestartWallpaperResponse RestoreWallpaper() {
+ public async Task RestoreWallpaperAsync() {
Grpc_RestartWallpaperResponse response = new();
+ // Wait for any pending plugin update to complete
+ await UpdateLock.WaitAllAsync();
+
try {
ArcLog.GetLogger().Info("Restore wallpapers...");
var wallpaperLayouts = _userSettings.WallpaperLayouts.ToList().AsReadOnly();
@@ -204,7 +212,7 @@ public Grpc_RestartWallpaperResponse RestoreWallpaper() {
if (wallpaperLayouts.Count > 0) {
var layout = wallpaperLayouts.FirstOrDefault(x => x.MonitorDeviceId == _monitorManager.PrimaryMonitor.DeviceId);
var data = WallpaperUtil.GetWallpaperByFolder(layout.FolderPath, _monitorManager.PrimaryMonitor.Content, layout.RType);
- SetWallpaperAsync(data.GetPlayerData(), _monitorManager.PrimaryMonitor);
+ _ = SetWallpaperAsync(data.GetPlayerData(), _monitorManager.PrimaryMonitor);
}
}
else {
@@ -310,7 +318,7 @@ public async Task SetWallpaperAsync(
}
else {
instance.Closing += ClosingEvent;
- _jobService.AddProcess(instance.Proc.Id);
+ _jobService.AddProcess(instance.Proc.Id, PluginName.PlayerWeb);
_monitorManager.UpdateTargetMonitorThu(monitorIdx, data.ThumbnailPath);
_wallpapers.Add(instance);
}
@@ -339,7 +347,7 @@ public async Task SetWallpaperAsync(
}
else {
instance.Closing += ClosingEvent;
- _jobService.AddProcess(instance.Proc.Id);
+ _jobService.AddProcess(instance.Proc.Id, PluginName.PlayerWeb);
_monitorManager.UpdateTargetMonitorThu(monitorIdx, data.ThumbnailPath);
_wallpapers.Add(instance);
}
@@ -369,7 +377,7 @@ public async Task SetWallpaperAsync(
}
else {
instance.Closing += ClosingEvent;
- _jobService.AddProcess(instance.Proc.Id);
+ _jobService.AddProcess(instance.Proc.Id, PluginName.PlayerWeb);
_monitorManager.UpdateTargetMonitorThu(monitorIdx, data.ThumbnailPath);
_wallpapers.Add(instance);
}
@@ -855,9 +863,11 @@ private void Restore(ReadOnlyCollection wallpaperLayouts) {
private void ClosingEvent(object? s, EventArgs e) {
if (s is not IWpPlayer instance) return;
+ var pid = instance.Proc.Id;
instance.Closing -= ClosingEvent;
instance.Closing = null;
_wallpapers.RemoveAll(x => x.Monitor!.DeviceId == instance.Monitor!.DeviceId);
+ _jobService.StopPlugin(pid);
}
private void SetupDesktop_WallpaperChanged(object? sender, EventArgs e) {
diff --git a/src/VirtualPaper/GrpcServers/AppUpdateServer.cs b/src/VirtualPaper/GrpcServers/AppUpdateServer.cs
index aee20665..bab2ed21 100644
--- a/src/VirtualPaper/GrpcServers/AppUpdateServer.cs
+++ b/src/VirtualPaper/GrpcServers/AppUpdateServer.cs
@@ -1,24 +1,24 @@
using System.Windows.Threading;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
-using VirtualPaper.Common.Events;
using VirtualPaper.Cores.AppUpdate;
using VirtualPaper.Grpc.Service.CommonModels;
using VirtualPaper.Grpc.Service.Update;
+using VirtualPaper.Models.Events;
namespace VirtualPaper.GrpcServers {
public class AppUpdateServer(
IAppUpdaterService updater) : Grpc_UpdateService.Grpc_UpdateServiceBase {
public override async Task CheckUpdate(Empty _, ServerCallContext context) {
- await _updater.CheckUpdate(0);
+ await _updater.CheckUpdateAsync(0);
return await Task.FromResult(new Empty());
}
public override Task StartDownload(Empty _, ServerCallContext context) {
- if (true || _updater.Status == AppUpdateStatus.Available) {
+ if (_updater.Status == AppUpdateStatus.Available) {
System.Windows.Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.Normal, new ThreadStart(delegate {
- App.AppUpdateDialog(new AppUpdaterEventArgs(_updater.Status, _updater.LastCheckVersion, _updater.LastCheckTime, _updater.LastCheckUri, _updater.LastCheckShaUri, _updater.LastCheckChangelog));
+ App.AppUpdateDialog(new AppUpdaterEventArgs(_updater.Status, _updater.LastReleaseInfo));
}));
}
@@ -27,12 +27,13 @@ public override Task StartDownload(Empty _, ServerCallContext context) {
public override Task GetUpdateStatus(Empty _, ServerCallContext context) {
return Task.FromResult(new Grpc_UpdateResponse() {
- Status = (Grpc_UpdateStatus)((int)_updater.Status),
- Changelog = _updater.LastCheckChangelog ?? string.Empty,
- Uri = _updater.LastCheckUri?.OriginalString ?? string.Empty,
- ShaUri = _updater.LastCheckShaUri?.OriginalString ?? string.Empty,
- Version = _updater.LastCheckVersion.ToString() ?? string.Empty,
- Time = Timestamp.FromDateTime(_updater.LastCheckTime.ToUniversalTime()),
+ Status = (Grpc_UpdateStatus)((int)_updater.Status),
+ Changelog = _updater.LastReleaseInfo?.Changelog ?? string.Empty,
+ Uri = _updater.LastReleaseInfo?.InstallerUri?.OriginalString ?? string.Empty,
+ ShaUri = _updater.LastReleaseInfo?.InstallerShaUri?.OriginalString ?? string.Empty,
+ Version = _updater.LastReleaseInfo?.Version?.ToString() ?? string.Empty,
+ AppBuild = _updater.LastReleaseInfo?.AppBuild ?? string.Empty,
+ Time = Timestamp.FromDateTime(_updater.LastReleaseInfo?.CheckedTime.ToUniversalTime() ?? DateTime.UtcNow),
});
}
diff --git a/src/VirtualPaper/GrpcServers/CommandsServer.cs b/src/VirtualPaper/GrpcServers/CommandsServer.cs
index 993962c2..0ec78a1b 100644
--- a/src/VirtualPaper/GrpcServers/CommandsServer.cs
+++ b/src/VirtualPaper/GrpcServers/CommandsServer.cs
@@ -12,9 +12,9 @@
namespace VirtualPaper.GrpcServers {
public class CommandsServer(
IUIRunnerService runner) : Grpc_CommandsService.Grpc_CommandsServiceBase {
- public override Task ShowUI(Empty _, ServerCallContext context) {
- _runner.ShowUI();
- return Task.FromResult(new Empty());
+ public override async Task ShowUI(Empty _, ServerCallContext context) {
+ await _runner.ShowUIAsync();
+ return new Empty();
}
public override Task CloseUI(Empty _, ServerCallContext context) {
diff --git a/src/VirtualPaper/GrpcServers/ScrCommandsServer.cs b/src/VirtualPaper/GrpcServers/ScrCommandsServer.cs
index d1fadddd..b2348851 100644
--- a/src/VirtualPaper/GrpcServers/ScrCommandsServer.cs
+++ b/src/VirtualPaper/GrpcServers/ScrCommandsServer.cs
@@ -13,10 +13,10 @@ public override Task ChangeLockStatu(Grpc_LockData request, ServerCallCon
return Task.FromResult(new Empty());
}
- public override Task Start(Empty request, ServerCallContext context) {
- _scrControl.Start();
+ public override async Task Start(Empty request, ServerCallContext context) {
+ await _scrControl.StartAsync();
- return Task.FromResult(new Empty());
+ return new Empty();
}
public override Task Stop(Empty request, ServerCallContext context) {
diff --git a/src/VirtualPaper/GrpcServers/WallpaperControlServer.cs b/src/VirtualPaper/GrpcServers/WallpaperControlServer.cs
index 4e7f20ed..b1588a9d 100644
--- a/src/VirtualPaper/GrpcServers/WallpaperControlServer.cs
+++ b/src/VirtualPaper/GrpcServers/WallpaperControlServer.cs
@@ -62,9 +62,9 @@ public override async Task GetPlayerSt
}
public override async Task RestartAllWallpapers(Empty request, ServerCallContext context) {
- Grpc_RestartWallpaperResponse response = _wpControl.RestoreWallpaper();
+ Grpc_RestartWallpaperResponse response = await _wpControl.RestoreWallpaperAsync();
- return await Task.FromResult(response);
+ return response;
}
public override async Task SetWallpaper(Grpc_SetWallpaperRequest request, ServerCallContext context) {
diff --git a/src/VirtualPaper/MainWindow.xaml.cs b/src/VirtualPaper/MainWindow.xaml.cs
index 95bc2835..d9d7bd94 100644
--- a/src/VirtualPaper/MainWindow.xaml.cs
+++ b/src/VirtualPaper/MainWindow.xaml.cs
@@ -52,13 +52,13 @@ private void Window_SourceInitialized(object sender, EventArgs e) {
.Show();
}
- private void NotifyIcon_LeftDoubleClick(Wpf.Ui.Tray.Controls.NotifyIcon sender, RoutedEventArgs e) {
- _uiRunnerService.ShowUI();
+ private async void NotifyIcon_LeftDoubleClick(Wpf.Ui.Tray.Controls.NotifyIcon sender, RoutedEventArgs e) {
+ await _uiRunnerService.ShowUIAsync();
e.Handled = true;
}
- private void OpenAppMenuItem_Click(object sender, RoutedEventArgs e) {
- _uiRunnerService.ShowUI();
+ private async void OpenAppMenuItem_Click(object sender, RoutedEventArgs e) {
+ await _uiRunnerService.ShowUIAsync();
}
private void CloseAllWpMenuItem_Click(object sender, RoutedEventArgs e) {
diff --git a/src/VirtualPaper/Models/AppUpdateInfo.cs b/src/VirtualPaper/Models/AppUpdateInfo.cs
index 18911ed8..2fa0a870 100644
--- a/src/VirtualPaper/Models/AppUpdateInfo.cs
+++ b/src/VirtualPaper/Models/AppUpdateInfo.cs
@@ -1,3 +1,3 @@
namespace VirtualPaper.Models {
- public record AppUpdateInfo(Uri DownloadUri, Uri SHAUri, string Version, string ChangeLog);
+ //public record AppUpdateInfo(Uri DownloadUri, Uri SHAUri, string Version, string ChangeLog);
}
diff --git a/src/VirtualPaper/Properties/lang.Designer.cs b/src/VirtualPaper/Properties/lang.Designer.cs
index b3893ff7..2dd7a463 100644
--- a/src/VirtualPaper/Properties/lang.Designer.cs
+++ b/src/VirtualPaper/Properties/lang.Designer.cs
@@ -19,7 +19,7 @@ namespace VirtualPaper.Properties {
// 类通过类似于 ResGen 或 Visual Studio 的工具自动生成的。
// 若要添加或移除成员,请编辑 .ResX 文件,然后重新运行 ResGen
// (以 /str 作为命令选项),或重新生成 VS 项目。
- [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class lang {
@@ -88,7 +88,7 @@ public static string AppUpdater_ActionButtonText_Installing {
}
///
- /// 查找类似 继续下载 的本地化字符串。
+ /// 查找类似 继续 的本地化字符串。
///
public static string AppUpdater_ActionButtonText_Paused {
get {
@@ -286,6 +286,114 @@ public static string Find_New_Verison {
}
}
+ ///
+ /// 查找类似 发现组件更新! 的本地化字符串。
+ ///
+ public static string Find_New_Version_Restart {
+ get {
+ return ResourceManager.GetString("Find_New_Version_Restart", resourceCulture);
+ }
+ }
+
+ ///
+ /// 查找类似 关闭 的本地化字符串。
+ ///
+ public static string PluginsUpdate_Close {
+ get {
+ return ResourceManager.GetString("PluginsUpdate_Close", resourceCulture);
+ }
+ }
+
+ ///
+ /// 查找类似 更新完成,UI 即将重启 的本地化字符串。
+ ///
+ public static string PluginsUpdate_Completed {
+ get {
+ return ResourceManager.GetString("PluginsUpdate_Completed", resourceCulture);
+ }
+ }
+
+ ///
+ /// 查找类似 更新失败:{0} 的本地化字符串。
+ ///
+ public static string PluginsUpdate_Failed {
+ get {
+ return ResourceManager.GetString("PluginsUpdate_Failed", resourceCulture);
+ }
+ }
+
+ ///
+ /// 查找类似 更新信息无效 的本地化字符串。
+ ///
+ public static string PluginsUpdate_InvalidInfo {
+ get {
+ return ResourceManager.GetString("PluginsUpdate_InvalidInfo", resourceCulture);
+ }
+ }
+
+ ///
+ /// 查找类似 更新已就绪,关闭主窗口以触发更新 的本地化字符串。
+ ///
+ public static string PluginsUpdate_PostponeTip {
+ get {
+ return ResourceManager.GetString("PluginsUpdate_PostponeTip", resourceCulture);
+ }
+ }
+
+ ///
+ /// 查找类似 正在备份当前组件... 的本地化字符串。
+ ///
+ public static string PluginsUpdate_Stage_BackingUp {
+ get {
+ return ResourceManager.GetString("PluginsUpdate_Stage_BackingUp", resourceCulture);
+ }
+ }
+
+ ///
+ /// 查找类似 已完成 的本地化字符串。
+ ///
+ public static string PluginsUpdate_Stage_Completed {
+ get {
+ return ResourceManager.GetString("PluginsUpdate_Stage_Completed", resourceCulture);
+ }
+ }
+
+ ///
+ /// 查找类似 正在下载组件... 的本地化字符串。
+ ///
+ public static string PluginsUpdate_Stage_Downloading {
+ get {
+ return ResourceManager.GetString("PluginsUpdate_Stage_Downloading", resourceCulture);
+ }
+ }
+
+ ///
+ /// 查找类似 失败 的本地化字符串。
+ ///
+ public static string PluginsUpdate_Stage_Failed {
+ get {
+ return ResourceManager.GetString("PluginsUpdate_Stage_Failed", resourceCulture);
+ }
+ }
+
+ ///
+ /// 查找类似 正在替换组件文件... 的本地化字符串。
+ ///
+ public static string PluginsUpdate_Stage_Replacing {
+ get {
+ return ResourceManager.GetString("PluginsUpdate_Stage_Replacing", resourceCulture);
+ }
+ }
+
+ ///
+ /// 查找类似 正在开始更新... 的本地化字符串。
+ ///
+ public static string PluginsUpdate_Starting {
+ get {
+ return ResourceManager.GetString("PluginsUpdate_Starting", resourceCulture);
+ }
+ }
+
///
/// 查找类似 关闭所有壁纸 的本地化字符串。
///
diff --git a/src/VirtualPaper/Properties/lang.en-US.resx b/src/VirtualPaper/Properties/lang.en-US.resx
index 9e90c609..92b94697 100644
--- a/src/VirtualPaper/Properties/lang.en-US.resx
+++ b/src/VirtualPaper/Properties/lang.en-US.resx
@@ -127,7 +127,7 @@
Installing...
- Resume downloading
+ Resume
Download
@@ -139,7 +139,7 @@
Download complete!
- Unable to access the resource, please check your network and try again.
+ Unable to access, please check network or try again.
Downloading...
@@ -193,6 +193,9 @@ https://github.com/PaperHammer/VirtualPaper/releases
Find a newer version!
+
+ Component update available!
+
Close all wallpapers
@@ -253,4 +256,52 @@ Windows Explorer restarted twice in the last 30 seconds!This could be a conflict
4. Close the window by clicking Apply and then OK.
If it still doesn't work, shut down and restart or reboot the system.(Doesn't work in some Windows 10 preview versions)
+
+ Invalid update info
+
+
+ Starting update...
+
+
+ Update completed. UI will restart.
+
+
+ Update failed: {0}
+
+
+ Downloading plugins...
+
+
+ Backing up current plugins...
+
+
+ Replacing plugin files...
+
+
+ Completed
+
+
+ Failed
+
+
+ Close
+
+
+ Update ready. Close the main window to apply.
+
+
+ Updating components...
+
+
+ Update completed
+
+
+ Update failed
+
+
+ Preparing...
+
+
+ Replaced {0}
+
\ No newline at end of file
diff --git a/src/VirtualPaper/Properties/lang.resx b/src/VirtualPaper/Properties/lang.resx
index 5ddd8edf..7b5b5be6 100644
--- a/src/VirtualPaper/Properties/lang.resx
+++ b/src/VirtualPaper/Properties/lang.resx
@@ -127,7 +127,7 @@
安装中...
- 继续下载
+ 继续
下载
@@ -193,6 +193,9 @@ https://github.com/PaperHammer/VirtualPaper/releases
发现新版本!
+
+ 发现组件更新!
+
关闭所有壁纸
@@ -253,4 +256,52 @@ Windows 资源管理器在过去 30 秒内重新启动两次!这可能是 Virt
4. 点击 "应用",然后按 "确定",关闭该窗口。
如果还是不行,关闭并重新启动或重启系统。(在某些 Windows 10 预览版本中不工作)
+
+ 更新信息无效
+
+
+ 正在开始更新...
+
+
+ 更新完成,UI 即将重启
+
+
+ 更新失败:{0}
+
+
+ 正在下载组件...
+
+
+ 正在备份当前组件...
+
+
+ 正在替换组件文件...
+
+
+ 已完成
+
+
+ 失败
+
+
+ 关闭
+
+
+ 更新已就绪,关闭主窗口以触发更新
+
+
+ 正在更新组件...
+
+
+ 更新完成
+
+
+ 更新失败
+
+
+ 准备中...
+
+
+ 已替换 {0}
+
\ No newline at end of file
diff --git a/src/VirtualPaper/Services/Download/MultiDownloadService.cs b/src/VirtualPaper/Services/Download/MultiDownloadService.cs
index dab4dead..00cb0f7b 100644
--- a/src/VirtualPaper/Services/Download/MultiDownloadService.cs
+++ b/src/VirtualPaper/Services/Download/MultiDownloadService.cs
@@ -3,6 +3,7 @@
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Downloader;
+using VirtualPaper.Common.Utils.Files;
using VirtualPaper.Services.Interfaces;
using IDownloadService = VirtualPaper.Services.Interfaces.IDownloadService;
@@ -24,6 +25,7 @@ public MultiDownloadService() {
};
_downloader = new DownloadService(downloadOpt);
+ _parallelDownloaders = new List();
}
@@ -52,7 +54,7 @@ void OnProgressChanged(object? sender, DownloadProgressChangedEventArgs e) {
: 0);
// 尝试异步写入通道
- channel.Writer.TryWrite(new DownloadProgress(percent, speed, remaining));
+ channel.Writer.TryWrite(new DownloadProgress(percent, speed, remaining, e.ReceivedBytesSize, e.TotalBytesToReceive));
}
void OnCompleted(object? sender, AsyncCompletedEventArgs e) {
@@ -89,6 +91,104 @@ void OnCompleted(object? sender, AsyncCompletedEventArgs e) {
}
}
+ ///
+ /// 并行下载多个文件,返回聚合后的总进度
+ ///
+ public async IAsyncEnumerable DownloadMultipleAsync(
+ IEnumerable<(Uri uri, string saveFilePath)> downloads,
+ [EnumeratorCancellation] CancellationToken token) {
+
+ var downloadList = downloads.ToList();
+ if (downloadList.Count == 0) yield break;
+
+ if (downloadList.Count == 1) {
+ await foreach (var p in DownloadAsync(downloadList[0].uri, downloadList[0].saveFilePath, token))
+ yield return p;
+ yield break;
+ }
+
+ var channel = Channel.CreateUnbounded(
+ new UnboundedChannelOptions { SingleReader = true });
+
+ var perPlugin = new (long received, long total, float speed)[downloadList.Count];
+ var lockObj = new object();
+
+ void ReportAggregate() {
+ long totalReceived = 0, totalAll = 0;
+ float totalSpeed = 0;
+ lock (lockObj) {
+ for (int i = 0; i < perPlugin.Length; i++) {
+ totalReceived += perPlugin[i].received;
+ totalAll += perPlugin[i].total;
+ totalSpeed += perPlugin[i].speed;
+ }
+ }
+ float percent = totalAll > 0 ? (float)totalReceived / totalAll * 100 : 0;
+ TimeSpan remaining = totalSpeed > 0
+ ? TimeSpan.FromSeconds((totalAll - totalReceived) / (totalSpeed * 1024.0 * 1024.0))
+ : TimeSpan.Zero;
+ channel.Writer.TryWrite(new DownloadProgress(percent, totalSpeed, remaining, totalReceived, totalAll));
+ }
+
+ var tasks = downloadList.Select((item, index) => Task.Run(async () => {
+ var downloadOpt = new DownloadConfiguration() {
+ BufferBlockSize = 8000,
+ ChunkCount = 4,
+ MaxTryAgainOnFailover = 5,
+ Timeout = 10000,
+ ClearPackageOnCompletionWithFailure = false,
+ ReserveStorageSpaceBeforeStartingDownload = false,
+ };
+ var downloader = new DownloadService(downloadOpt);
+
+ lock (_parallelDownloaders) {
+ _parallelDownloaders.Add(downloader);
+ }
+
+ try {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ downloader.DownloadProgressChanged += (s, e) => {
+ if (token.IsCancellationRequested) return;
+ lock (lockObj) {
+ perPlugin[index] = (e.ReceivedBytesSize, e.TotalBytesToReceive, (float)(e.BytesPerSecondSpeed / 1024.0 / 1024.0));
+ }
+ ReportAggregate();
+ };
+ downloader.DownloadFileCompleted += (s, e) => {
+ if (e.Error != null) tcs.TrySetException(e.Error);
+ else if (e.Cancelled) tcs.TrySetCanceled(token);
+ else tcs.TrySetResult();
+ };
+
+ var downloadTask = downloader.DownloadFileTaskAsync(item.uri.AbsoluteUri, item.saveFilePath, token)
+ .ContinueWith(t => { if (t.IsFaulted) _ = t.Exception; }, TaskContinuationOptions.ExecuteSynchronously);
+
+ await Task.WhenAll(downloadTask, tcs.Task);
+ }
+ finally {
+ lock (_parallelDownloaders) {
+ _parallelDownloaders.Remove(downloader);
+ }
+ }
+ }, token)).ToArray();
+
+ // 在后台等待所有下载完成,然后关闭 channel
+ _ = Task.Run(async () => {
+ try {
+ await Task.WhenAll(tasks);
+ }
+ catch { }
+ channel.Writer.TryComplete();
+ });
+
+ await foreach (var p in channel.Reader.ReadAllAsync(token))
+ yield return p;
+
+ // 等待所有下载任务完成(传播异常)
+ await Task.WhenAll(tasks);
+ }
+
///
/// 下载 SHA256.txt 并缓存其内容
///
@@ -103,7 +203,7 @@ public async Task DownloadShaTxtAsync(Uri shaUri, CancellationToken toke
string sha256Content = await File.ReadAllTextAsync(tempFilePath, token);
sha256Content = sha256Content.Trim();
- if (!IsValidSHA256(sha256Content))
+ if (sha256Content.Length != 64 || !System.Text.RegularExpressions.Regex.IsMatch(sha256Content, @"^[a-fA-F0-9]{64}$"))
throw new InvalidDataException("The downloaded SHA256 file format is invalid");
return sha256Content;
@@ -127,81 +227,51 @@ public async Task DownloadShaTxtAsync(Uri shaUri, CancellationToken toke
/// 取消令牌
/// 校验结果
public async Task VerifyFileIntegrityAsync(string filePath, string expectedSha256, CancellationToken token = default) {
- if (!File.Exists(filePath) || !IsValidSHA256(expectedSha256))
- return false;
-
- string actualSha256 = await CalculateFileSHA256Async(filePath, token);
-
- return string.Equals(actualSha256, expectedSha256, StringComparison.OrdinalIgnoreCase);
+ return await FileUtil.VerifyFileIntegrityAsync(filePath, expectedSha256, token);
}
public void Pause() {
if (_downloader.Status == DownloadStatus.Running)
_downloader.Pause();
+
+ List snapshot;
+ lock (_parallelDownloaders) {
+ snapshot = _parallelDownloaders.ToList();
+ }
+
+ Parallel.ForEach(snapshot, downloader => {
+ if (downloader.Status == DownloadStatus.Running)
+ downloader.Pause();
+ });
}
public void Resume() {
if (_downloader.Status == DownloadStatus.Paused)
_downloader.Resume();
+
+ List snapshot;
+ lock (_parallelDownloaders) {
+ snapshot = _parallelDownloaders.ToList();
+ }
+
+ Parallel.ForEach(snapshot, downloader => {
+ if (downloader.Status == DownloadStatus.Paused)
+ downloader.Resume();
+ });
}
public void Dispose() {
_downloader?.Dispose();
+ lock (_parallelDownloaders) {
+ foreach (var downloader in _parallelDownloaders) {
+ downloader.Dispose();
+ }
+ _parallelDownloaders.Clear();
+ }
}
- #region Private Methods
- ///
- /// 计算文件的SHA256哈希值
- ///
- private static async Task CalculateFileSHA256Async(string filePath, CancellationToken token) {
- using var sha256 = System.Security.Cryptography.SHA256.Create();
- using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 8192, true);
-
- var hashBytes = await sha256.ComputeHashAsync(fileStream, token);
- return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
- }
-
- ///
- /// 验证SHA256字符串格式
- ///
- private static bool IsValidSHA256(string sha256) {
- if (string.IsNullOrEmpty(sha256) || sha256.Length != 64)
- return false;
- return SHA256Regex().IsMatch(sha256);
- }
- #endregion
-
- /////
- ///// 内部轻量级异步通道(单消费者)
- /////
- //private sealed class Channel {
- // private readonly Queue _queue = new();
- // private readonly SemaphoreSlim _signal = new(0);
-
- // public void TryWrite(T value) {
- // lock (_queue) {
- // _queue.Enqueue(value);
- // }
- // _signal.Release();
- // }
-
- // public async IAsyncEnumerable ReadAllAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token) {
- // while (!token.IsCancellationRequested) {
- // await _signal.WaitAsync(token);
- // T item;
- // lock (_queue) {
- // if (_queue.Count == 0) continue;
- // item = _queue.Dequeue();
- // }
- // yield return item;
- // }
- // }
- //}
-
private readonly DownloadService _downloader;
-
- [System.Text.RegularExpressions.GeneratedRegex(@"^[a-fA-F0-9]{64}$")]
- private static partial System.Text.RegularExpressions.Regex SHA256Regex();
+ private readonly List _parallelDownloaders;
}
}
diff --git a/src/VirtualPaper/Services/Interfaces/IDownloadService.cs b/src/VirtualPaper/Services/Interfaces/IDownloadService.cs
index 0a8f264d..61baea4f 100644
--- a/src/VirtualPaper/Services/Interfaces/IDownloadService.cs
+++ b/src/VirtualPaper/Services/Interfaces/IDownloadService.cs
@@ -1,11 +1,12 @@
namespace VirtualPaper.Services.Interfaces {
public interface IDownloadService {
IAsyncEnumerable DownloadAsync(Uri uri, string saveFilePath, CancellationToken token);
+ IAsyncEnumerable DownloadMultipleAsync(IEnumerable<(Uri uri, string saveFilePath)> downloads, CancellationToken token);
Task DownloadShaTxtAsync(Uri shaUri, CancellationToken token);
Task VerifyFileIntegrityAsync(string filePath, string expectedSha256, CancellationToken token = default);
void Pause();
void Resume();
}
- public record DownloadProgress(float Percent, float Speed, TimeSpan Remaining);
+ public record DownloadProgress(float Percent, float Speed, TimeSpan Remaining, long ReceivedBytes = 0, long TotalBytes = 0);
}
diff --git a/src/VirtualPaper/Services/Interfaces/IJobService.cs b/src/VirtualPaper/Services/Interfaces/IJobService.cs
index ce364aa5..ff31fae2 100644
--- a/src/VirtualPaper/Services/Interfaces/IJobService.cs
+++ b/src/VirtualPaper/Services/Interfaces/IJobService.cs
@@ -1,8 +1,21 @@
+using VirtualPaper.Common;
+
namespace VirtualPaper.Services.Interfaces {
public interface IJobService {
+ event EventHandler? PluginsUpdateFinishedEvent;
+
bool AddProcess(IntPtr processHandle);
bool AddProcess(int processId);
+ bool AddProcess(int processId, PluginName pluginName);
+ void StopPlugin(PluginName pluginName);
+ void StopPlugin(int pid);
void Close();
void Dispose();
+
+ ///
+ /// Start a plugin, waiting asynchronously if the plugin is being updated.
+ ///
+ Task StartPluginAsync(PluginName pluginName, Func startAction, CancellationToken token = default);
+ void PluginsUpdateFinished();
}
}
diff --git a/src/VirtualPaper/Services/Interfaces/IUIRunnerService.cs b/src/VirtualPaper/Services/Interfaces/IUIRunnerService.cs
index 113102ff..3b151853 100644
--- a/src/VirtualPaper/Services/Interfaces/IUIRunnerService.cs
+++ b/src/VirtualPaper/Services/Interfaces/IUIRunnerService.cs
@@ -9,7 +9,7 @@ public interface IUIRunnerService : IDisposable {
bool IsVisibleUI { get; }
- void ShowUI();
+ Task ShowUIAsync();
void CloseUI();
void RestartUI();
nint GetUIHwnd();
diff --git a/src/VirtualPaper/Services/Interfaces/IWindowService.cs b/src/VirtualPaper/Services/Interfaces/IWindowService.cs
index bf5481c2..cd7a2a7c 100644
--- a/src/VirtualPaper/Services/Interfaces/IWindowService.cs
+++ b/src/VirtualPaper/Services/Interfaces/IWindowService.cs
@@ -5,7 +5,8 @@ public interface IWindowService {
///
/// 窗口类型
/// 传递给 ViewModel 的初始化参数
- void Show(object? parameter = null) where TWindow : class;
+ /// 是否将窗口带到前台(跨进程生效)
+ void Show(object? parameter = null, bool bringToFront = false) where TWindow : class;
///
/// 打开一个模态窗口
diff --git a/src/VirtualPaper/Services/JobService.cs b/src/VirtualPaper/Services/JobService.cs
index c2ea105b..004a3cfb 100644
--- a/src/VirtualPaper/Services/JobService.cs
+++ b/src/VirtualPaper/Services/JobService.cs
@@ -1,11 +1,16 @@
+using System.Collections.Concurrent;
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
+using VirtualPaper.Common;
using VirtualPaper.Common.Logging;
+using VirtualPaper.Cores.AppUpdate;
using VirtualPaper.Services.Interfaces;
namespace VirtualPaper.Services {
internal partial class JobService : IJobService, IDisposable {
+ public event EventHandler? PluginsUpdateFinishedEvent;
+
#region Helper classes
///
/// 作业对象,主要用于子进程管理。
@@ -86,6 +91,71 @@ public bool AddProcess(int processId) {
return false;
}
+ ///
+ /// 进程加入到作业对象中,并记录所属插件名
+ ///
+ public bool AddProcess(int processId, PluginName pluginName) {
+ if (!AddProcess(processId)) return false;
+ _pluginProcesses.AddOrUpdate(
+ pluginName,
+ new ConcurrentHashSet { processId },
+ (_, set) => { set.Add(processId); return set; });
+ return true;
+ }
+
+ ///
+ /// 停止指定插件的所有已注册进程
+ ///
+ public void StopPlugin(PluginName pluginName) {
+ if (!_pluginProcesses.TryRemove(pluginName, out var pids)) return;
+
+ foreach (var pid in pids) {
+ try {
+ var proc = Process.GetProcessById(pid);
+ if (!proc.HasExited) {
+ if (!proc.CloseMainWindow() || !proc.WaitForExit(3000)) {
+ proc.Kill();
+ }
+ }
+ }
+ catch (ArgumentException) { }
+ catch (Exception ex) {
+ ArcLog.GetLogger().Warn($"Failed to stop process {pid} for plugin {pluginName}: {ex.Message}");
+ }
+ }
+ }
+
+ ///
+ /// 按 PID 停止单个进程,并从跟踪集合中移除
+ ///
+ public void StopPlugin(int pid) {
+ try {
+ var proc = Process.GetProcessById(pid);
+ if (!proc.HasExited) {
+ if (!proc.CloseMainWindow() || !proc.WaitForExit(3000)) {
+ proc.Kill();
+ }
+ }
+ }
+ catch (ArgumentException) { }
+ catch (Exception ex) {
+ ArcLog.GetLogger().Warn($"Failed to stop process {pid}: {ex.Message}");
+ }
+
+ foreach (var kv in _pluginProcesses) {
+ kv.Value.Remove(pid);
+ }
+ }
+
+ public async Task StartPluginAsync(PluginName pluginName, Func startAction, CancellationToken token = default) {
+ await UpdateLock.WaitAsync(pluginName, token);
+ await startAction();
+ }
+
+ public void PluginsUpdateFinished() {
+ PluginsUpdateFinishedEvent?.Invoke(this, EventArgs.Empty);
+ }
+
///
/// 销毁作业对象,手动调用则其拥有的所有进程都会退出
///
@@ -111,6 +181,16 @@ private void Dispose(bool disposing) {
private IntPtr _handle;
private bool _disposed;
+ private readonly ConcurrentDictionary> _pluginProcesses = new();
+
+ private class ConcurrentHashSet : IEnumerable {
+ private readonly HashSet _set = new();
+ private readonly object _lock = new();
+ public bool Add(T item) { lock (_lock) return _set.Add(item); }
+ public bool Remove(T item) { lock (_lock) return _set.Remove(item); }
+ public IEnumerator GetEnumerator() { lock (_lock) return _set.ToList().GetEnumerator(); }
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
+ }
}
[StructLayout(LayoutKind.Sequential)]
diff --git a/src/VirtualPaper/Services/UIRunnerService.cs b/src/VirtualPaper/Services/UIRunnerService.cs
index 94bbf08f..f6623b91 100644
--- a/src/VirtualPaper/Services/UIRunnerService.cs
+++ b/src/VirtualPaper/Services/UIRunnerService.cs
@@ -1,9 +1,12 @@
using System.Diagnostics;
using System.IO;
using System.Windows;
+using Microsoft.Extensions.DependencyInjection;
using VirtualPaper.Common;
+using VirtualPaper.Common.Logging;
using VirtualPaper.Common.Utils.IPC;
using VirtualPaper.Common.Utils.PInvoke;
+using VirtualPaper.Cores.AppUpdate;
using VirtualPaper.lang;
using VirtualPaper.Services.Interfaces;
using MessageBox = System.Windows.MessageBox;
@@ -13,9 +16,11 @@ namespace VirtualPaper.Services {
public partial class UIRunnerService : IUIRunnerService {
public event EventHandler? UISendCmd;
- public UIRunnerService() {
+ public UIRunnerService(IJobService jobService) {
+ _jobService = jobService;
+
if (UAC.IsElevated) {
- App.Log.Warn("Process is running elevated, UI may not function properly.");
+ ArcLog.GetLogger().Warn("Process is running elevated, UI may not work properly.");
}
if (Constants.ApplicationType.IsMSIX) {
@@ -29,15 +34,17 @@ public UIRunnerService() {
}
}
- public void ShowUI() {
+ public async Task ShowUIAsync() {
+ await UpdateLock.WaitAllAsync();
+
if (_processUI != null) {
try {
- App.Log.Warn("UI is already running");
+ ArcLog.GetLogger().Warn("UI is already running");
UISendCmd?.Invoke(this, MessageType.cmd_active);
//_processUI.StandardInput.WriteLine(JsonSerializer.Serialize(new VirtualPaperActiveCmd(), IpcMessageContext.Default.IpcMessage));
}
catch (Exception e) {
- App.Log.Error(e);
+ ArcLog.GetLogger().Error(e);
}
}
else {
@@ -56,14 +63,14 @@ public void ShowUI() {
_processUI.Exited += Proc_UI_Exited;
_processUI.OutputDataReceived += Proc_OutputDataReceived;
_processUI.Start();
- App.Jobs.AddProcess(_processUI.Id);
+ App.Jobs.AddProcess(_processUI.Id, PluginName.UI);
//winui writing debug information into output stream :/
//_processUI.BeginOutputReadLine();
//_processUI.BeginErrorReadLine();
}
catch (Exception e) {
- App.Log.Error(e);
+ ArcLog.GetLogger().Error(e);
_processUI = null;
_ = MessageBox.Show(
$"{LanguageManager.Instance["UIRunnerService_VirtualPaperExceptionGeneral"]}\nEXCEPTION:\n{e.Message}",
@@ -85,13 +92,13 @@ public void RestartUI() {
_processUI.Dispose();
}
catch (Exception e) {
- App.Log.Error(e);
+ ArcLog.GetLogger().Error(e);
}
finally {
_processUI = null;
}
}
- ShowUI();
+ _ = ShowUIAsync();
}
public void CloseUI() {
@@ -104,7 +111,7 @@ public void CloseUI() {
}
}
catch (Exception e) {
- App.Log.Error(e);
+ ArcLog.GetLogger().Error(e);
}
}
@@ -122,17 +129,32 @@ private void Proc_OutputDataReceived(object sender, DataReceivedEventArgs e) {
//When the redirected stream is closed, a null line is sent to the event handler.
if (!string.IsNullOrEmpty(e.Data)) {
//Ref: https://github.com/cyanfish/grpc-dotnet-namedpipes/issues/8
- App.Log.Info($"UI: {e.Data}");
+ ArcLog.GetLogger().Info($"UI: {e.Data}");
}
}
private void Proc_UI_Exited(object? sender, EventArgs e) {
if (_processUI == null) return;
+ var pid = _processUI.Id;
_processUI.Exited -= Proc_UI_Exited;
_processUI.OutputDataReceived -= Proc_OutputDataReceived;
_processUI.Dispose();
_processUI = null;
+
+ if (App.IsShuttingDown) return;
+
+ App.Jobs.StopPlugin(pid);
+
+ // Check for pending restart update when UI exits normally
+ // (not during an update - UpdateLock would be set in that case)
+ if (!UpdateLock.IsAnyLocked) {
+ _jobService.PluginsUpdateFinishedEvent += (s, e) => {
+ _ = ShowUIAsync();
+ };
+ var restartService = App.Services.GetRequiredService();
+ _ = restartService.ExecutePendingPluginUpdateWithWindowAsync();
+ }
}
#region dispose
@@ -141,7 +163,17 @@ protected virtual void Dispose(bool disposing) {
if (!_isDisposed) {
if (disposing) {
try {
- _processUI?.Kill();
+ if (_processUI != null) {
+ // If a pending restart update exists, close UI gracefully so
+ // Proc_UI_Exited fires and triggers ExecutePendingUpdateAsync.
+ var flagPath = VirtualPaper.Common.Constants.CommonPaths.UpdateFlagPath;
+ if (File.Exists(flagPath)) {
+ CloseUI();
+ }
+ else {
+ _processUI.Kill();
+ }
+ }
}
catch { }
}
@@ -157,5 +189,6 @@ public void Dispose() {
private Process? _processUI;
private readonly string _fileName, _workingDir;
+ private readonly IJobService _jobService;
}
}
diff --git a/src/VirtualPaper/Services/WindowService.cs b/src/VirtualPaper/Services/WindowService.cs
index 15b5846d..de0ca986 100644
--- a/src/VirtualPaper/Services/WindowService.cs
+++ b/src/VirtualPaper/Services/WindowService.cs
@@ -1,4 +1,6 @@
using System.Windows;
+using System.Windows.Interop;
+using VirtualPaper.Common.Utils.PInvoke;
using VirtualPaper.Services.Interfaces;
namespace VirtualPaper.Services {
@@ -7,9 +9,13 @@ public WindowService(IServiceProvider serviceProvider) {
_serviceProvider = serviceProvider;
}
- public void Show(object? parameter = null) where TWindow : class {
+ public void Show(object? parameter = null, bool bringToFront = false) where TWindow : class {
if (_openWindows.TryGetValue(typeof(TWindow), out var existing)) {
- existing.Activate();
+ if (bringToFront) {
+ BringToFront(existing);
+ } else {
+ existing.Activate();
+ }
return;
}
@@ -19,6 +25,16 @@ public void Show(object? parameter = null) where TWindow : class {
window.Closed += (_, _) => _openWindows.Remove(typeof(TWindow));
_openWindows[typeof(TWindow)] = window;
window.Show();
+ if (bringToFront) {
+ BringToFront(window);
+ } else {
+ window.Activate();
+ }
+ }
+
+ private static void BringToFront(Window window) {
+ var hwnd = new WindowInteropHelper(window).EnsureHandle();
+ Native.SetForegroundWindow(hwnd);
}
public Task ShowDialogAsync(object? parameter = null) where TWindow : class {
diff --git a/src/VirtualPaper/Utils/Interfcaes/IAppUpdate.cs b/src/VirtualPaper/Utils/Interfcaes/IAppUpdate.cs
index 8231bcd7..322d5a68 100644
--- a/src/VirtualPaper/Utils/Interfcaes/IAppUpdate.cs
+++ b/src/VirtualPaper/Utils/Interfcaes/IAppUpdate.cs
@@ -1,10 +1,11 @@
+using VirtualPaper.Models.AppUpdate;
+
namespace VirtualPaper.Utils.Interfcaes {
public interface IGithubReleaseClient {
- Task<(Uri exeUri, Uri shaUri, Version version, string changelog)>
- GetLatestRelease(bool isBeta);
+ Task GetLatestRelease(bool isBeta);
}
public interface IVersionComparer {
- int CompareAssemblyVersion(Version version);
+ int CompareAssemblyVersion(Version? version);
}
}
diff --git a/src/VirtualPaper/Utils/Services/AppUpdate.cs b/src/VirtualPaper/Utils/Services/AppUpdate.cs
index fa887b27..8530ac1e 100644
--- a/src/VirtualPaper/Utils/Services/AppUpdate.cs
+++ b/src/VirtualPaper/Utils/Services/AppUpdate.cs
@@ -1,31 +1,74 @@
+using System.Text.Json;
+using VirtualPaper.Common.Logging;
using VirtualPaper.Common.Utils;
+using VirtualPaper.Cores.AppUpdate.Models;
+using VirtualPaper.Models.AppUpdate;
using VirtualPaper.Utils.Interfcaes;
namespace VirtualPaper.Utils.Services {
public class GithubReleaseClient : IGithubReleaseClient {
- public async Task<(Uri exeUri, Uri shaUri, Version version, string changelog)> GetLatestRelease(bool isBeta) {
+ private const string PLUGINS_PATCH_ASSET_NAME = "plugins_patch.zip";
+ private const string PLUGINS_PATCH_SHA256_ASSET_NAME = "PLUGINS_PATCH_SHS256.txt";
+ private const string APP_COMP_MANIFEST_ASSET_NAME = "app_comp_manifest.json";
+
+ public async Task GetLatestRelease(bool isBeta) {
var userName = "PaperHammer";
+ //var repositoryName = isBeta ? "VirtualPaper-beta" : "VirtualPaper_Mirror_Test";
var repositoryName = isBeta ? "VirtualPaper-beta" : "VirtualPaper";
var gitRelease = await GithubUtil.GetLatestRelease(repositoryName, userName, 0);
Version version = GithubUtil.GetVersion(gitRelease);
-
- var gitUrl = await GithubUtil.GetAssetUrl(
- "virtualpaper_setup_x64_full",
- gitRelease, repositoryName, userName);
- Uri exeUri = new(gitUrl);
string changelog = gitRelease.Body;
- gitUrl = await GithubUtil.GetAssetUrl(
- "SHA256",
- gitRelease, repositoryName, userName);
- Uri shaUri = new(gitUrl);
+ var result = new ReleaseInfo {
+ Version = version,
+ Changelog = changelog
+ };
- return (exeUri, shaUri, version, changelog);
- }
+ // Check for plugin update: plugins_patch.zip + PLUGINS_PATCH_SHS256.txt
+ var patchAsset = GithubUtil.FindAsset(gitRelease, PLUGINS_PATCH_ASSET_NAME);
+ var patchSha256Asset = GithubUtil.FindAsset(gitRelease, PLUGINS_PATCH_SHA256_ASSET_NAME);
+ if (patchAsset != null && patchSha256Asset != null) {
+ result.PluginPatchUri = new Uri(patchAsset.BrowserDownloadUrl);
+ result.PluginPatchSha256Uri = new Uri(patchSha256Asset.BrowserDownloadUrl);
+
+ // Download app_comp_manifest.json from release assets for build info.
+ // Note: app_comp_manifest.json also exists inside plugins_patch.zip (which is SHA256 verified),
+ // so this standalone copy is not separately verified — it's only used for displaying target version.
+ var appCompManifestAsset = GithubUtil.FindAsset(gitRelease, APP_COMP_MANIFEST_ASSET_NAME);
+ if (appCompManifestAsset != null) {
+ try {
+ var manifestContent = await GithubUtil.DownloadAssetContent(appCompManifestAsset);
+ var appCompManifest = JsonSerializer.Deserialize(manifestContent, UpdateManifestContext.Default.AppCompManifest);
+ if (appCompManifest != null) {
+ result.AppCompManifest = appCompManifest;
+ result.AppBuild = appCompManifest.AppBuildNumber;
+ }
+ }
+ catch (Exception ex) {
+ ArcLog.GetLogger().Error("Failed to parse app_comp_manifest", ex);
+ }
+ }
+
+ return result;
+ }
+
+ // Install-style update: gather installer info
+ var installerAsset = GithubUtil.FindAsset(gitRelease, "virtualpaper_setup_x64_full");
+ if (installerAsset != null) {
+ result.InstallerUri = new Uri(installerAsset.BrowserDownloadUrl);
+ }
+
+ var shaAsset = GithubUtil.FindAsset(gitRelease, "SHA256");
+ if (shaAsset != null) {
+ result.InstallerShaUri = new Uri(shaAsset.BrowserDownloadUrl);
+ }
+
+ return result;
+ }
}
public class AssemblyVersionComparer : IVersionComparer {
- public int CompareAssemblyVersion(Version version) {
+ public int CompareAssemblyVersion(Version? version) {
return GithubUtil.CompareAssemblyVersion(version);
}
}
diff --git a/src/VirtualPaper/ViewModels/AppUpdaterWindowViewModel.cs b/src/VirtualPaper/ViewModels/AppUpdaterWindowViewModel.cs
index 56d3a2db..837e9faf 100644
--- a/src/VirtualPaper/ViewModels/AppUpdaterWindowViewModel.cs
+++ b/src/VirtualPaper/ViewModels/AppUpdaterWindowViewModel.cs
@@ -1,8 +1,10 @@
using System.Diagnostics;
using System.IO;
-using System.Windows.Input;
+using VirtualPaper.Common;
+using VirtualPaper.Common.Utils.Files;
+using VirtualPaper.Cores.AppUpdate;
using VirtualPaper.lang;
-using VirtualPaper.Models;
+using VirtualPaper.Models.AppUpdate;
using VirtualPaper.Models.Mvvm;
using VirtualPaper.Services;
using VirtualPaper.Services.Interfaces;
@@ -36,10 +38,22 @@ public string StatusText {
set { _statusText = value; OnPropertyChanged(); }
}
- private string _speedText = string.Empty;
- public string SpeedText {
- get => _speedText;
- set { _speedText = value; OnPropertyChanged(); }
+ private string _speedValue = string.Empty;
+ public string SpeedValue {
+ get => _speedValue;
+ set { _speedValue = value; OnPropertyChanged(); }
+ }
+
+ private string _sizeText = string.Empty;
+ public string SizeText {
+ get => _sizeText;
+ set { _sizeText = value; OnPropertyChanged(); }
+ }
+
+ private string _remainingText = string.Empty;
+ public string RemainingText {
+ get => _remainingText;
+ set { _remainingText = value; OnPropertyChanged(); }
}
private string _actionButtonText = string.Empty;
@@ -60,36 +74,56 @@ public bool IsIndeterminate {
set { _isIndeterminate = value; OnPropertyChanged(); }
}
- public ICommand? ActionCommand { get; }
+ public bool IsPluginsUpdate { get; private set; }
+
+ public Action? RequestFlashTaskbar { get; set; }
private DownloadState _currentState;
public DownloadState CurrentState {
get { return _currentState; }
set {
+ if (value == _currentState) return;
+
_currentState = value;
ActionButtonEnable = _currentState != DownloadState.Verifying;
IsIndeterminate = _currentState == DownloadState.Verifying;
UpdateUIByState();
+
+ OnPropertyChanged();
}
}
public AppUpdaterWindowViewModel(
IDownloadService downloadService,
- IContentDialogService contentDialogService) {
+ IContentDialogService contentDialogService,
+ IPluginsUpdateService pluginsUpdateService,
+ IAppUpdaterService appUpdaterService) {
_downloadService = downloadService;
_contentDialogService = contentDialogService;
-
- ActionCommand = new RelayCommand(OnActionCommand);
+ _pluginsUpdateService = pluginsUpdateService;
+ _appUpdaterService = appUpdaterService;
}
public void ReceiveParameter(object? parameter) {
- if (parameter is AppUpdateInfo info) {
- _downloadUri = info.DownloadUri;
- _shaUri = info.SHAUri;
- Version = info.Version;
- ChangeLog = info.ChangeLog;
+ if (parameter is ReleaseInfo info) {
+ if (info.IsPluginsUpdate) {
+ IsPluginsUpdate = true;
+ _releaseInfo = info;
+ }
+ else {
+ _downloadUri = info.InstallerUri!;
+ _shaUri = info.InstallerShaUri!;
+ _savePath = Path.Combine(Constants.CommonPaths.InstallerCacheDir, Path.GetFileName(_downloadUri.LocalPath));
+ }
+ Version = $"{info.Version?.ToString()} (Build {info.AppBuild?.ToString()})";
+ ChangeLog = info.Changelog ?? string.Empty;
CurrentState = DownloadState.Ready;
- _savePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(_downloadUri.LocalPath));
+ }
+ }
+
+ public void AutoStartDownload() {
+ if (CurrentState == DownloadState.Ready) {
+ _ = StartDownloadAsync();
}
}
@@ -115,7 +149,7 @@ public async Task ShowCancelDialogAsync() {
}
#region Command Handlers
- private async void OnActionCommand() {
+ internal async void OnActionCommand() {
switch (CurrentState) {
case DownloadState.Ready:
case DownloadState.DownloadFailed:
@@ -140,24 +174,75 @@ private async void OnActionCommand() {
#region Download Logic
private async Task StartDownloadAsync() {
+ if (IsPluginsUpdate) {
+ await StartPluginsDownloadAsync();
+ return;
+ }
+
if (_downloadUri == null)
return;
- DeleteFile();
+ FileUtil.DeleteDirectoryContents(Constants.CommonPaths.InstallerCacheDir);
_cts = new CancellationTokenSource();
CurrentState = DownloadState.Downloading;
try {
_sha256 = await _downloadService.DownloadShaTxtAsync(_shaUri, _cts.Token);
+ File.WriteAllText(_savePath + ".sha256", _sha256);
await foreach (var progress in _downloadService.DownloadAsync(_downloadUri, _savePath, _cts.Token)) {
Progress = progress.Percent;
- SpeedText = $"{progress.Speed:F2} MB/s | 剩余时间:{progress.Remaining:hh\\:mm\\:ss}";
+ UpdateSpeedInfo(progress.Speed, progress.ReceivedBytes, progress.TotalBytes, progress.Remaining);
}
await VerifyAsync();
}
catch (OperationCanceledException) {
+ FileUtil.DeleteDirectoryContents(Constants.CommonPaths.InstallerCacheDir);
+ if (CurrentState != DownloadState.Paused)
+ CurrentState = DownloadState.Paused;
+ }
+ catch (Exception ex) {
+ App.Log.Error(ex);
+ CurrentState = DownloadState.DownloadFailed;
+ }
+ }
+
+ private async Task StartPluginsDownloadAsync() {
+ if (_releaseInfo == null)
+ return;
+
+ _cts = new CancellationTokenSource();
+ CurrentState = DownloadState.Downloading;
+
+ try {
+ var progress = new Progress(p => {
+ Progress = p.Percent;
+ UpdateSpeedInfo(p.Speed, p.ReceivedBytes, p.TotalBytes, p.Remaining);
+ });
+
+ var result = await _pluginsUpdateService.DownloadPendingAsync(_releaseInfo, progress, _cts.Token);
+
+ if (!result.Success) {
+ CurrentState = DownloadState.DownloadFailed;
+ return;
+ }
+
+ CurrentState = DownloadState.Verifying;
+ var verifyResult = await _pluginsUpdateService.VerifyAndSavePendingAsync(_releaseInfo, _cts.Token);
+
+ if (!verifyResult.Success) {
+ CurrentState = DownloadState.VerifyFailed;
+ return;
+ }
+
+ CurrentState = DownloadState.Completed;
+
+ // Trigger a new update check to refresh the status in GeneralSettingViewModel
+ _ = Task.Run(() => _appUpdaterService.CheckUpdateAsync());
+ }
+ catch (OperationCanceledException) {
+ FileUtil.RemoveDirectory(Constants.CommonPaths.PendingUpdatesDir);
if (CurrentState != DownloadState.Paused)
CurrentState = DownloadState.Paused;
}
@@ -173,7 +258,7 @@ private async Task VerifyAsync() {
if (!verified) {
CurrentState = DownloadState.VerifyFailed;
- DeleteFile();
+ FileUtil.DeleteDirectoryContents(Constants.CommonPaths.InstallerCacheDir);
return;
}
@@ -190,7 +275,23 @@ private void ResumeDownload() {
CurrentState = DownloadState.Downloading;
}
+ private void UpdateSpeedInfo(float speed, long receivedBytes, long totalBytes, TimeSpan remaining) {
+ SpeedValue = $"{speed:F2} MB/s";
+ SizeText = totalBytes > 0 ? $"{FileUtil.SizeSuffix(receivedBytes)} / {FileUtil.SizeSuffix(totalBytes)}" : string.Empty;
+ RemainingText = $"{LanguageManager.Instance[nameof(Constants.I18n.AppUpdater_SpeedText_Ready)]}:{remaining:hh\\:mm\\:ss}";
+ }
+
+ private void ClearSpeedInfo() {
+ SpeedValue = string.Empty;
+ SizeText = string.Empty;
+ RemainingText = string.Empty;
+ }
+
private async void InstallUpdate() {
+ if (IsPluginsUpdate) {
+ return;
+ }
+
CurrentState = DownloadState.Installing;
try {
//run setup in silent mode.
@@ -219,12 +320,11 @@ private void UpdateUIByState() {
case DownloadState.Ready:
ActionButtonText = LanguageManager.Instance["AppUpdater_ActionButtonText_Ready"];
StatusText = LanguageManager.Instance["AppUpdater_StatusText_Ready"];
- SpeedText = $"-- MB/s | {LanguageManager.Instance["AppUpdater_SpeedText_Ready"]}:--:--";
Progress = 0;
break;
case DownloadState.Downloading:
- ActionButtonText = LanguageManager.Instance["AppUpdater_ActionButtonText_Downloading"]; ;
+ ActionButtonText = LanguageManager.Instance["AppUpdater_ActionButtonText_Downloading"];
StatusText = LanguageManager.Instance["AppUpdater_StatusText_Downloading"];
break;
@@ -235,13 +335,25 @@ private void UpdateUIByState() {
case DownloadState.Verifying:
StatusText = LanguageManager.Instance["AppUpdater_StatusText_Verifying"];
- SpeedText = string.Empty;
+ ClearSpeedInfo();
break;
case DownloadState.Completed:
- ActionButtonText = LanguageManager.Instance["AppUpdater_ActionButtonText_Completed"];
+ ActionButtonText = IsPluginsUpdate
+ ? LanguageManager.Instance["Common_TextConfirm"]
+ : LanguageManager.Instance["AppUpdater_ActionButtonText_Completed"];
StatusText = LanguageManager.Instance["AppUpdater_StatusText_Completed"];
- SpeedText = string.Empty;
+ ClearSpeedInfo();
+ if (IsPluginsUpdate) {
+ _ = _contentDialogService.ShowSimpleDialogAsync(
+ new SimpleContentDialogCreateOptions() {
+ Title = LanguageManager.Instance["PluginsUpdate_Close"],
+ Content = LanguageManager.Instance["PluginsUpdate_PostponeTip"],
+ CloseButtonText = LanguageManager.Instance["Common_TextConfirm"],
+ }
+ );
+ }
+ RequestFlashTaskbar?.Invoke();
break;
case DownloadState.DownloadFailed:
@@ -257,7 +369,7 @@ private void UpdateUIByState() {
case DownloadState.Installing:
ActionButtonText = LanguageManager.Instance["AppUpdater_ActionButtonText_Installing"];
StatusText = LanguageManager.Instance["AppUpdater_StatusText_Installing"];
- SpeedText = string.Empty;
+ ClearSpeedInfo();
break;
case DownloadState.Installed:
@@ -271,20 +383,13 @@ private void UpdateUIByState() {
public void Dispose() {
_cts?.Cancel();
_cts?.Dispose();
- DeleteFile();
- }
-
- private void DeleteFile() {
- try {
- if (File.Exists(_savePath))
- File.Delete(_savePath);
- }
- catch {
- }
}
private readonly IDownloadService _downloadService;
private readonly IContentDialogService _contentDialogService;
+ private readonly IPluginsUpdateService _pluginsUpdateService;
+ private readonly IAppUpdaterService _appUpdaterService;
+ private ReleaseInfo? _releaseInfo;
private Uri _downloadUri = null!;
private Uri _shaUri = null!;
private CancellationTokenSource? _cts;
diff --git a/src/VirtualPaper/Views/AppUpdaterWindow.xaml b/src/VirtualPaper/Views/AppUpdaterWindow.xaml
index 3cf5bd61..0a7411df 100644
--- a/src/VirtualPaper/Views/AppUpdaterWindow.xaml
+++ b/src/VirtualPaper/Views/AppUpdaterWindow.xaml
@@ -17,7 +17,7 @@
Height="600" Width="800"
ResizeMode="CanMinimize"
Title="Virtual Paper Updater"
- Icon="pack://application:,,,/Resources/appicon_download.png"
+ Icon="pack://application:,,,/VirtualPaper;component/Resources/appicon_download.png"
ExtendsContentIntoTitleBar="True"
Closing="FluentWindow_Closing"
Closed="FluentWindow_Closed"
@@ -39,7 +39,7 @@
ShowMaximize="False"
Height="48">
-
+
@@ -109,21 +109,29 @@
Value="{Binding Progress}"
Minimum="0" Maximum="100"/>
-
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/src/VirtualPaper/Views/AppUpdaterWindow.xaml.cs b/src/VirtualPaper/Views/AppUpdaterWindow.xaml.cs
index 7b8b9209..b6ec073c 100644
--- a/src/VirtualPaper/Views/AppUpdaterWindow.xaml.cs
+++ b/src/VirtualPaper/Views/AppUpdaterWindow.xaml.cs
@@ -1,6 +1,9 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Input;
+using System.Windows.Interop;
+using System.Windows.Shell;
+using VirtualPaper.Common.Utils.PInvoke;
using VirtualPaper.ViewModels;
using Wpf.Ui;
using Wpf.Ui.Controls;
@@ -16,6 +19,57 @@ public AppUpdaterWindow(
InitializeComponent();
contentDialogService.SetDialogHost(RootContentDialog);
DataContext = _viewModel = viewModel;
+ Loaded += AppUpdaterWindow_Loaded;
+ _viewModel.RequestFlashTaskbar = FlashTaskbar;
+ _viewModel.PropertyChanged += ViewModel_PropertyChanged;
+
+ TaskbarItemInfo = new TaskbarItemInfo();
+ }
+
+ private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) {
+ if (e.PropertyName == nameof(AppUpdaterWindowViewModel.Progress)
+ || e.PropertyName == nameof(AppUpdaterWindowViewModel.CurrentState)) {
+ UpdateTaskbarProgress();
+ }
+ }
+
+ private void UpdateTaskbarProgress() {
+ if (TaskbarItemInfo == null) return;
+
+ switch (_viewModel.CurrentState) {
+ case DownloadState.Downloading:
+ TaskbarItemInfo.ProgressState = TaskbarItemProgressState.Normal;
+ TaskbarItemInfo.ProgressValue = _viewModel.Progress / 100.0;
+ break;
+ case DownloadState.Verifying:
+ TaskbarItemInfo.ProgressState = TaskbarItemProgressState.Indeterminate;
+ TaskbarItemInfo.ProgressValue = 0;
+ break;
+ case DownloadState.Completed:
+ TaskbarItemInfo.ProgressState = TaskbarItemProgressState.None;
+ TaskbarItemInfo.ProgressValue = 0;
+ break;
+ default:
+ TaskbarItemInfo.ProgressState = TaskbarItemProgressState.None;
+ TaskbarItemInfo.ProgressValue = 0;
+ break;
+ }
+ }
+
+ private void AppUpdaterWindow_Loaded(object sender, RoutedEventArgs e) {
+ _viewModel?.AutoStartDownload();
+ }
+
+ private void FlashTaskbar() {
+ var hwnd = new WindowInteropHelper(this).EnsureHandle();
+ var fi = new Native.FLASHWINFO {
+ cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf(),
+ hwnd = hwnd,
+ dwFlags = Native.FLASHW_TRAY | Native.FLASHW_TIMERNOFG,
+ uCount = uint.MaxValue,
+ dwTimeout = 0
+ };
+ Native.FlashWindowEx(ref fi);
}
private void FluentWindow_Closed(object? sender, EventArgs e) {
@@ -32,7 +86,6 @@ private async void FluentWindow_Closing(object? sender, CancelEventArgs e) {
case DownloadState.DownloadFailed:
case DownloadState.VerifyFailed:
case DownloadState.Verifying:
- case DownloadState.Completed:
e.Cancel = true;
var confirmClose = await _viewModel.ShowCancelDialogAsync();
@@ -61,6 +114,15 @@ private void FluentWindow_PreviewKeyDown(object sender, System.Windows.Input.Key
}
}
+ private void ActionBtn_Click(object sender, RoutedEventArgs e) {
+ if (_viewModel.IsPluginsUpdate && _viewModel.CurrentState == DownloadState.Completed) {
+ this.Close();
+ return;
+ }
+
+ _viewModel.OnActionCommand();
+ }
+
private readonly AppUpdaterWindowViewModel _viewModel;
}
}
diff --git a/src/VirtualPaper/Views/PluginUpdateWindow.xaml b/src/VirtualPaper/Views/PluginUpdateWindow.xaml
new file mode 100644
index 00000000..7f01589d
--- /dev/null
+++ b/src/VirtualPaper/Views/PluginUpdateWindow.xaml
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VirtualPaper/Views/PluginUpdateWindow.xaml.cs b/src/VirtualPaper/Views/PluginUpdateWindow.xaml.cs
new file mode 100644
index 00000000..655b1c9d
--- /dev/null
+++ b/src/VirtualPaper/Views/PluginUpdateWindow.xaml.cs
@@ -0,0 +1,79 @@
+using System.Windows;
+using System.Windows.Input;
+using System.Windows.Interop;
+using System.Windows.Shell;
+using Microsoft.Extensions.DependencyInjection;
+using VirtualPaper.Common;
+using VirtualPaper.Common.Utils.PInvoke;
+using VirtualPaper.Cores.AppUpdate;
+using VirtualPaper.lang;
+using VirtualPaper.Services.Interfaces;
+
+namespace VirtualPaper.Views {
+ public partial class PluginUpdateWindow : Window {
+ public PluginUpdateWindow() {
+ InitializeComponent();
+ this.Loaded += PluginUpdateWindow_Loaded;
+
+ TaskbarItemInfo = new TaskbarItemInfo();
+ }
+
+ private void PluginUpdateWindow_Loaded(object sender, RoutedEventArgs e) {
+ TaskbarItemInfo.ProgressState = TaskbarItemProgressState.Indeterminate;
+ }
+
+ private void Window_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {
+ if (e.ChangedButton == MouseButton.Left)
+ DragMove();
+ }
+
+ private void MinimizeButton_Click(object sender, RoutedEventArgs e) {
+ WindowState = WindowState.Minimized;
+ }
+
+ private void CloseButton_Click(object sender, RoutedEventArgs e) {
+ Close();
+ _ = App.Services.GetRequiredService().ShowUIAsync();
+ }
+
+ private void FlashTaskbar() {
+ var hwnd = new WindowInteropHelper(this).EnsureHandle();
+ var fi = new Native.FLASHWINFO {
+ cbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf(),
+ hwnd = hwnd,
+ dwFlags = Native.FLASHW_TRAY | Native.FLASHW_TIMERNOFG,
+ uCount = uint.MaxValue,
+ dwTimeout = 0
+ };
+ Native.FlashWindowEx(ref fi);
+ }
+
+ public void ReportProgress(PluginsUpdateProgress progress) {
+ Dispatcher.Invoke(() => {
+ UpdateProgressBar.Value = progress.Percent;
+ StatusText.Text = progress.Message;
+
+ if (progress.Stage == PluginsUpdateStage.Completed) {
+ TaskbarItemInfo.ProgressState = TaskbarItemProgressState.None;
+ TitleText.Text = LanguageManager.Instance[nameof(Constants.I18n.PluginUpdate_Title_Completed)];
+ }
+ else if (progress.Stage == PluginsUpdateStage.Failed) {
+ TaskbarItemInfo.ProgressState = TaskbarItemProgressState.None;
+ TitleText.Text = LanguageManager.Instance[nameof(Constants.I18n.PluginUpdate_Title_Failed)];
+ }
+ });
+ }
+
+ public void ShowError(string message) {
+ Dispatcher.Invoke(() => {
+ TitleText.Text = LanguageManager.Instance[nameof(Constants.I18n.PluginUpdate_Title_Failed)];
+ StatusText.Text = message;
+ UpdateProgressBar.IsIndeterminate = false;
+ UpdateProgressBar.Value = 100;
+ UpdateProgressBar.Foreground = System.Windows.Media.Brushes.Red;
+ CloseButton.IsEnabled = true;
+ FlashTaskbar();
+ });
+ }
+ }
+}
diff --git a/src/VirtualPaper/Views/SplashWindow.xaml b/src/VirtualPaper/Views/SplashWindow.xaml
index 5a9547e9..ac6ce15e 100644
--- a/src/VirtualPaper/Views/SplashWindow.xaml
+++ b/src/VirtualPaper/Views/SplashWindow.xaml
@@ -21,7 +21,7 @@
-
+
diff --git a/src/VirtualPaper/VirtualPaper.csproj b/src/VirtualPaper/VirtualPaper.csproj
index d75cdc8c..aa78613a 100644
--- a/src/VirtualPaper/VirtualPaper.csproj
+++ b/src/VirtualPaper/VirtualPaper.csproj
@@ -11,9 +11,12 @@
0.5.2.0
virtualpaper.ico
true
+ win-x64
+ false
+
@@ -51,7 +54,7 @@
<_PluginFiles Include="$(MSBuildProjectDirectory)\Plugins\**\*" Exclude="$(MSBuildProjectDirectory)\Plugins\**\*.pdb;
$(MSBuildProjectDirectory)\Plugins\**\*.xml" />
-
+
diff --git a/src/WebBackdrop/Core/Utils/WebProjectSession.cs b/src/WebBackdrop/Core/Utils/WebProjectSession.cs
new file mode 100644
index 00000000..49f20dcc
--- /dev/null
+++ b/src/WebBackdrop/Core/Utils/WebProjectSession.cs
@@ -0,0 +1,33 @@
+using System;
+using VirtualPaper.Common.Utils.UndoRedo.Events;
+
+namespace Workloads.Creation.WebBackdrop.Core.Utils {
+ public partial class WebProjectSession : IDisposable {
+ public event EventHandler? SessionDisposed;
+ public event EventHandler? IsSavedChanged;
+
+ public string SessionId { get; } = Guid.NewGuid().ToString();
+
+ private void UnReUtil_IsSavedChanged(object? sender, IsSavedChangedEventArgs e) {
+ IsSavedChanged?.Invoke(this, e);
+ }
+
+ #region dispose
+ private bool _isDisposed;
+ protected virtual void Dispose(bool disposing) {
+ if (!_isDisposed) {
+ if (disposing) {
+ //UnReUtil?.Dispose();
+ SessionDisposed?.Invoke(this, EventArgs.Empty);
+ }
+ _isDisposed = true;
+ }
+ }
+
+ public void Dispose() {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+ #endregion
+ }
+}
diff --git a/src/WebBackdrop/MainPage.xaml b/src/WebBackdrop/MainPage.xaml
new file mode 100644
index 00000000..2237c597
--- /dev/null
+++ b/src/WebBackdrop/MainPage.xaml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/src/WebBackdrop/MainPage.xaml.cs b/src/WebBackdrop/MainPage.xaml.cs
new file mode 100644
index 00000000..1fa9ef90
--- /dev/null
+++ b/src/WebBackdrop/MainPage.xaml.cs
@@ -0,0 +1,112 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices.WindowsRuntime;
+using System.Threading.Tasks;
+using VirtualPaper.Common.Logging;
+using VirtualPaper.Common.Utils.Storage;
+using VirtualPaper.Common.Utils.UndoRedo.Events;
+using VirtualPaper.UIComponent;
+using VirtualPaper.UIComponent.Templates;
+using VirtualPaper.UIComponent.Utils;
+using Windows.Foundation;
+using Windows.Foundation.Collections;
+using Workloads.Creation.WebBackdrop.Core.Utils;
+using Workloads.Utils.DraftUtils.Interfaces;
+using Workloads.Utils.DraftUtils.Models;
+
+// To learn more about WinUI, the WinUI project structure,
+// and more about our project templates, see: http://aka.ms/winui-project-info.
+
+namespace Workloads.Creation.WebBackdrop {
+ ///
+ /// An empty page that can be used on its own or navigated to within a Frame.
+ ///
+ public sealed partial class MainPage : ArcPage {
+ public override Type ArcType => typeof(MainPage);
+
+ public MainPage() {
+ InitializeComponent();
+ }
+ }
+ //public sealed partial class MainPage : ArcPage, IRuntime {
+ // public event EventHandler? IsSavedChanged;
+ // public string FileName => Session.DesignFileUtil.FileName;
+ // public string FileNameWithoutEx => Session.DesignFileUtil.FileNameWithoutEx;
+ // public string Id => Session.SessionId;
+ // public override Type ArcType => typeof(MainPage);
+ // protected override bool IsMultiInstance => true;
+ // public WebProjectSession Session { get; private set; }
+ // public bool IsSavedFromInit => Session.DesignFileUtil.IsSaveFromInit;
+
+ // public MainPage() {
+ // InitializeComponent();
+ // }
+
+ // #region workSpace events
+ // public async Task SaveAsync() {
+ // try {
+ // //var res = await inkCanvas.SaveAsync();
+ // //return res;
+ // throw new NotImplementedException();
+ // }
+ // catch (Exception ex) {
+ // ArcLog.GetLogger().Error(ex);
+ // GlobalMessageUtil.ShowException(ex);
+ // }
+ // return false;
+ // }
+
+ // public async Task SaveAsAsync() {
+ // try {
+ // var format = Session.DesignFileUtil.ExportFormatDefult;
+ // var path = await ExportAsync(format);
+ // if (path != null) {
+ // Session.DesignFileUtil.SetFilePath(path);
+ // //await inkCanvas.UpdateRecentUsedAsync(path);
+ // throw new NotImplementedException();
+ // }
+
+ // return path;
+ // }
+ // catch (Exception ex) {
+ // ArcLog.GetLogger().Error(ex);
+ // GlobalMessageUtil.ShowException(ex);
+ // }
+ // return null;
+ // }
+
+ // public async Task UndoAsync() {
+ // try {
+ // await Session.UnReUtil.UndoAsync();
+ // }
+ // catch (Exception ex) {
+ // ArcLog.GetLogger().Error(ex);
+ // GlobalMessageUtil.ShowException(ex);
+ // }
+ // }
+
+ // public async Task RedoAsync() {
+ // try {
+ // await Session.UnReUtil.RedoAsync();
+ // }
+ // catch (Exception ex) {
+ // ArcLog.GetLogger().Error(ex);
+ // GlobalMessageUtil.ShowException(ex);
+ // }
+ // }
+
+ // public async Task ExportAsync(ExportImageFormat format) {
+ // throw new NotImplementedException();
+ // }
+ // #endregion
+ //}
+}
diff --git a/src/WebBackdrop/WebBackdrop.csproj b/src/WebBackdrop/WebBackdrop.csproj
new file mode 100644
index 00000000..a7179529
--- /dev/null
+++ b/src/WebBackdrop/WebBackdrop.csproj
@@ -0,0 +1,27 @@
+
+
+ net8.0-windows10.0.19041.0
+ 10.0.17763.0
+ Workloads.Creation.WebBackdrop
+ false
+ true
+ true
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+ MSBuild:Compile
+
+
+
+
\ No newline at end of file
diff --git a/src/Workloads.Utils/Workloads.Utils.csproj b/src/Workloads.Utils/Workloads.Utils.csproj
index 53c3ab39..6f333e14 100644
--- a/src/Workloads.Utils/Workloads.Utils.csproj
+++ b/src/Workloads.Utils/Workloads.Utils.csproj
@@ -3,7 +3,7 @@
net8.0-windows10.0.19041.0
10.0.19041.0
Workloads.Utils
- win-x86;win-x64;win-arm64
+ false
true
false
true