Skip to content

Commit 99b3e48

Browse files
HanFengRuYueclaude
andcommitted
feat: v2.1 — 修复自动更新功能并增强更新体验
- 修复 Updater 参数传递因路径末尾反斜杠导致引号转义错误(改用 ArgumentList) - 修复 GitHub API 限速(403)和网络错误被误判为"已是最新版本" - 修复 Process 对象未释放的句柄泄漏(UpdateService 和 Updater) - 新增侧边栏设置项更新提示小红点 - 新增 Windows 系统通知推送更新提醒 - 前端 pollForRestart 新增版本校验,防止回滚后误判更新成功 - 将程序版本提升至 2.1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 021db9c commit 99b3e48

9 files changed

Lines changed: 100 additions & 27 deletions

File tree

.github/workflows/build.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ jobs:
5757
shell: pwsh
5858
run: |
5959
$v = '${{ inputs.build_version }}'
60-
if (-not $v) { $v = "2.0.$(Get-Date -Format 'yyyyMMddHHmm')" }
60+
if (-not $v) { $v = "2.1.$(Get-Date -Format 'yyyyMMddHHmm')" }
6161
$prefix = $v.Split('.')[0..1] -join '.'
6262
"build_version=$v" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
6363
"version_prefix=$prefix" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
@@ -580,7 +580,7 @@ jobs:
580580
$installerProject = "Installer/Installer.wixproj"
581581
582582
# Generate MSI version from build version
583-
# Build version format: 2.0.YYYYMMDDHHmm
583+
# Build version format: 2.1.YYYYMMDDHHmm
584584
# MSI constraints: major < 256, minor < 256, build < 65536
585585
$parts = "${{ steps.ver.outputs.build_version }}".Split('.')
586586
$timestamp = $parts[2] # YYYYMMDDHHmm

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ cd XUnityToolkit-Vue && npx vue-tsc --noEmit
142142

143143
- `dotnet build` auto-runs frontend; skip with `-p:SkipFrontendBuild=true`
144144
- `build.ps1`: downloads bundled assets → extracts XUnity reference DLLs → updates classdata.tpk (requires `gh` CLI) → frontend → TranslatorEndpoint → publish to `Release/win-x64/`; `-SkipDownload` skips all download/extraction steps; cleanup: remove `web.config`, `*.pdb`, `*.staticwebassets.endpoints.json`
145-
- **Versioning:** `build.ps1` auto-generates `2.0.{YYYYMMDDHHmm}` (CI uses `2.0.` prefix) via `-p:InformationalVersion`; **must use `InformationalVersion` not `Version`**`Version` sets `AssemblyVersion` (UInt16 max 65535) which overflows with timestamp
145+
- **Versioning:** `build.ps1` auto-generates `2.1.{YYYYMMDDHHmm}` (CI uses `2.1.` prefix) via `-p:InformationalVersion`; **must use `InformationalVersion` not `Version`**`Version` sets `AssemblyVersion` (UInt16 max 65535) which overflows with timestamp
146146
- **Multi-file publishing:** `PublishSingleFile` removed; `ExcludeFromSingleFile` target removed; LibCpp2IL.dll works naturally in multi-file mode
147147
- **Satellite assemblies:** `SatelliteResourceLanguages=en` strips all language folders (cs/de/fr/ja/ko/etc.) from publish output; WinForms satellite resources are unused (UI is Vue, native dialogs use OS localization)
148148
- **Updater:** `Updater/Updater.csproj` (net10.0, PublishAot); win-x64 only; `--data-dir` CLI arg directs log/backup paths to `paths.Root`

Updater/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ void Log(string message)
6161
{
6262
try
6363
{
64-
var p = Process.GetProcessById(pid);
64+
using var p = Process.GetProcessById(pid);
6565
if (p.HasExited)
6666
{
6767
exited = true;

XUnityToolkit-Vue/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Vue 3 frontend for XUnityToolkit-WebUI. See root `CLAUDE.md` for project overvie
4141
- **ConfigPanel** auto-saves internally (2s), no `save` event
4242
- Naive UI: light theme pass `null`; `NDrawer` width numbers only; `NForm` label-placement via computed (not CSS); `NInput` `string?` use `:value` + `@update:value`; `NInput` blur+enter double-fire → flag guard; `NDialogOptions.onPositiveClick`: returning a `Promise` keeps dialog open until resolved — fire-and-forget long async work (e.g., `() => { doWork() }`) to close immediately
4343
- `NDataTable`: `virtual-scroll` and `pagination` mutually exclusive; empty state guard with `filteredEntries.length > 0`; `row-key` must be globally unique — if ID can collide across categories, use composite key like `` `${category}:${id}` ``; columns without explicit `width`/`minWidth` get squeezed to 0px when fixed-width columns sum exceeds container — always set `minWidth` on flexible columns
44+
- `NColorPicker`: `#trigger` slot replaces entire trigger element; `#label` only customizes text inside default rectangular trigger — use `#trigger` for custom trigger buttons; always set `:modes="['hex']"` when consuming hex values (default allows rgb/hsl/hsv switching, which breaks `hexToRgb()`); manage visibility manually via `:show`/`@update:show` — do NOT also call slot's provided `onClick` (it only opens, never toggles)
4445
- `v-show` + `loading="lazy"` deadlock: use `opacity: 0` + `position: absolute`
4546
- `onBeforeRouteLeave` with async: must `return new Promise<boolean>()` — NOT `next()` callback
4647
- **RouterView key:** `:key="route.path"` ensures transitions fire for same-component different-route navigations

XUnityToolkit-Vue/src/components/layout/AppShell.vue

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { ref, watch, onMounted, onUnmounted } from 'vue'
2+
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
33
import { RouterView, useRouter, useRoute } from 'vue-router'
44
import { NIcon } from 'naive-ui'
55
import { GamepadFilled, SettingsOutlined, SmartToyOutlined, ArticleOutlined, FontDownloadOutlined } from '@vicons/material'
@@ -13,6 +13,28 @@ const sidebarOpen = ref(false)
1313
const appVersion = ref('')
1414
const updateStore = useUpdateStore()
1515
16+
const showUpdateBadge = computed(() =>
17+
updateStore.isUpdateAvailable || updateStore.isReady || updateStore.isDownloading
18+
)
19+
20+
// Watch for update availability — push Windows system notification
21+
let notifiedVersion: string | undefined
22+
watch(() => updateStore.availableInfo, (info) => {
23+
if (!info || !updateStore.isUpdateAvailable) return
24+
if (notifiedVersion === info.version) return
25+
notifiedVersion = info.version
26+
27+
if ('Notification' in window && Notification.permission === 'granted') {
28+
new Notification('XUnity Toolkit', {
29+
body: `发现新版本 v${info.version},点击前往设置更新`,
30+
icon: '/logo.png',
31+
}).onclick = () => {
32+
window.focus()
33+
router.push('/settings')
34+
}
35+
}
36+
})
37+
1638
onMounted(async () => {
1739
try {
1840
const info = await settingsApi.getVersion()
@@ -21,6 +43,10 @@ onMounted(async () => {
2143
} catch {
2244
appVersion.value = '1.0.0'
2345
}
46+
// Request notification permission early
47+
if ('Notification' in window && Notification.permission === 'default') {
48+
Notification.requestPermission()
49+
}
2450
// Initialize update system early so we receive SignalR broadcasts from startup auto-check
2551
updateStore.init()
2652
})
@@ -116,6 +142,7 @@ watch(() => route.path, () => {
116142
<component :is="item.icon" />
117143
</NIcon>
118144
<span>{{ item.label }}</span>
145+
<span v-if="item.key === '/settings' && showUpdateBadge" class="update-dot" />
119146
</a>
120147
</nav>
121148

@@ -274,6 +301,21 @@ watch(() => route.path, () => {
274301
transition: filter 0.3s ease, color 0.3s ease;
275302
}
276303
304+
.update-dot {
305+
width: 8px;
306+
height: 8px;
307+
border-radius: 50%;
308+
background: var(--accent);
309+
box-shadow: 0 0 6px var(--accent-glow);
310+
margin-left: auto;
311+
animation: pulse-dot 2s ease-in-out infinite;
312+
}
313+
314+
@keyframes pulse-dot {
315+
0%, 100% { opacity: 1; }
316+
50% { opacity: 0.4; }
317+
}
318+
277319
/* ===== Sidebar Footer ===== */
278320
.sidebar-spacer {
279321
flex: 1;

XUnityToolkit-Vue/src/stores/update.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ export const useUpdateStore = defineStore('update', () => {
164164

165165
function pollForRestart() {
166166
if (restartPollTimer) return
167+
const expectedVersion = availableInfo.value?.version
167168
let elapsed = 0
168169
restartPollTimer = setInterval(async () => {
169170
elapsed += 2000
@@ -178,6 +179,17 @@ export const useUpdateStore = defineStore('update', () => {
178179
try {
179180
const resp = await fetch('/api/settings/version')
180181
if (resp.ok) {
182+
// Verify the new version is actually running (not a rollback to old version)
183+
const result = await resp.json()
184+
const runningVersion = (result?.data?.version as string)?.split('+')[0]
185+
if (expectedVersion && runningVersion && runningVersion !== expectedVersion) {
186+
// Server restarted but with old version — update failed (rollback)
187+
if (restartPollTimer) clearInterval(restartPollTimer)
188+
restartPollTimer = null
189+
state.value = 'Error'
190+
error.value = '更新失败,已回滚到旧版本'
191+
return
192+
}
181193
if (restartPollTimer) clearInterval(restartPollTimer)
182194
restartPollTimer = null
183195
window.location.reload()

XUnityToolkit-WebUI/Endpoints/UpdateEndpoints.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,15 @@ public static void MapUpdateEndpoints(this WebApplication app)
1111

1212
group.MapGet("/check", async (UpdateService updateService, CancellationToken ct) =>
1313
{
14-
var result = await updateService.CheckForUpdateAsync(ct);
15-
return Results.Ok(ApiResult<UpdateCheckResult>.Ok(result));
14+
try
15+
{
16+
var result = await updateService.CheckForUpdateAsync(ct);
17+
return Results.Ok(ApiResult<UpdateCheckResult>.Ok(result));
18+
}
19+
catch (InvalidOperationException ex)
20+
{
21+
return Results.Ok(ApiResult.Fail(ex.Message));
22+
}
1623
});
1724

1825
group.MapGet("/status", (UpdateService updateService) =>

XUnityToolkit-WebUI/Services/UpdateService.cs

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,9 @@ public async Task<UpdateCheckResult> CheckForUpdateAsync(CancellationToken ct =
159159
if ((int)response.StatusCode == 403)
160160
{
161161
logger.LogWarning("GitHub API 速率限制,跳过检查");
162-
_status = new UpdateStatusInfo { State = UpdateState.None };
162+
_status = new UpdateStatusInfo { State = UpdateState.Error, Error = "GitHub API 请求频率超限,请稍后再试" };
163163
await BroadcastStatus();
164-
return _lastCheckResult ?? new UpdateCheckResult();
164+
throw new InvalidOperationException("GitHub API 请求频率超限,请稍后再试");
165165
}
166166

167167
response.EnsureSuccessStatusCode();
@@ -320,12 +320,12 @@ await File.WriteAllTextAsync(cachePath,
320320

321321
return _lastCheckResult;
322322
}
323-
catch (Exception ex)
323+
catch (Exception ex) when (ex is not InvalidOperationException)
324324
{
325325
logger.LogError(ex, "检查更新失败");
326326
_status = new UpdateStatusInfo { State = UpdateState.Error, Error = "检查更新失败,请检查网络连接" };
327327
await BroadcastStatus();
328-
return _lastCheckResult ?? new UpdateCheckResult();
328+
throw new InvalidOperationException("检查更新失败,请检查网络连接", ex);
329329
}
330330
finally
331331
{
@@ -538,26 +538,37 @@ public async Task<string> ApplyUpdateAsync(CancellationToken ct = default)
538538
var updaterDst = Path.Combine(tempDir, "Updater.exe");
539539
File.Copy(updaterSrc, updaterDst, overwrite: true);
540540

541-
// Build arguments
541+
// Build arguments (use ArgumentList to avoid backslash-quote escaping issues —
542+
// AppContext.BaseDirectory ends with '\', and "path\" is parsed as escaped quote by CommandLineToArgvW)
542543
var pid = Environment.ProcessId;
543544
var exeName = Path.GetFileName(Environment.ProcessPath ?? "XUnityToolkit-WebUI.exe");
544545
var deleteListPath = Path.Combine(stagingDir, "delete-list.txt");
545-
var args = $"--pid {pid} --app-dir \"{appDir}\" --staging-dir \"{filesDir}\" --exe-name \"{exeName}\" --data-dir \"{paths.Root}\"";
546-
if (File.Exists(deleteListPath))
547-
args += $" --delete-list \"{deleteListPath}\"";
548546

549-
// Launch Updater
550-
logger.LogInformation("启动 Updater.exe: {Args}", args);
551-
var process = new System.Diagnostics.Process
547+
var startInfo = new System.Diagnostics.ProcessStartInfo
552548
{
553-
StartInfo = new System.Diagnostics.ProcessStartInfo
554-
{
555-
FileName = updaterDst,
556-
Arguments = args,
557-
UseShellExecute = false,
558-
CreateNoWindow = true
559-
}
549+
FileName = updaterDst,
550+
UseShellExecute = false,
551+
CreateNoWindow = true
560552
};
553+
startInfo.ArgumentList.Add("--pid");
554+
startInfo.ArgumentList.Add(pid.ToString());
555+
startInfo.ArgumentList.Add("--app-dir");
556+
startInfo.ArgumentList.Add(appDir);
557+
startInfo.ArgumentList.Add("--staging-dir");
558+
startInfo.ArgumentList.Add(filesDir);
559+
startInfo.ArgumentList.Add("--exe-name");
560+
startInfo.ArgumentList.Add(exeName);
561+
startInfo.ArgumentList.Add("--data-dir");
562+
startInfo.ArgumentList.Add(paths.Root);
563+
if (File.Exists(deleteListPath))
564+
{
565+
startInfo.ArgumentList.Add("--delete-list");
566+
startInfo.ArgumentList.Add(deleteListPath);
567+
}
568+
569+
// Launch Updater
570+
logger.LogInformation("启动 Updater.exe: {Args}", string.Join(" ", startInfo.ArgumentList));
571+
using var process = new System.Diagnostics.Process { StartInfo = startInfo };
561572
process.Start();
562573

563574
_status = new UpdateStatusInfo { State = UpdateState.Applying, Message = "正在应用更新..." };

build.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,8 @@ $EndpointProject = Join-Path $ProjectRoot 'TranslatorEndpoint\TranslatorEndpoint
163163
$rid = 'win-x64'
164164
$hasEndpoint = Test-Path $EndpointProject
165165

166-
# Generate version: 2.0.{YYYYMMDDHHmm}
167-
$BuildVersion = "2.0.$(Get-Date -Format 'yyyyMMddHHmm')"
166+
# Generate version: 2.1.{YYYYMMDDHHmm}
167+
$BuildVersion = "2.1.$(Get-Date -Format 'yyyyMMddHHmm')"
168168
$VersionPrefix = ($BuildVersion -split '\.')[0..1] -join '.'
169169

170170
# Generate MSI-compatible version: {(YYYY-2024)*12+MM}.{DD}.{HH*60+mm}

0 commit comments

Comments
 (0)