Compare commits

...
20 changed files with 1202 additions and 47 deletions
+62
View File
@@ -0,0 +1,62 @@
name: Sign Windows artifact
description: Submit one raw EXE or ZIP to SignPath and download the signed result.
inputs:
api-token:
description: SignPath submitter token.
required: true
organization-id:
description: SignPath organization ID.
required: true
signing-policy:
description: test-signing or release-signing.
required: true
artifact-configuration:
description: Artifact configuration slug in the crossdesk project.
required: true
artifact:
description: Absolute path to one EXE or ZIP file.
required: true
output-directory:
description: Absolute directory for the downloaded signed file.
required: true
version:
description: CrossDesk ProductVersion and FileVersion.
required: true
runs:
using: composite
steps:
- name: Upload unsigned artifact
id: unsigned
uses: actions/upload-artifact@v7
with:
path: ${{ inputs.artifact }}
archive: false
if-no-files-found: error
retention-days: 7
- name: Submit signing request and wait for approval
id: sign
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ inputs.api-token }}
organization-id: ${{ inputs.organization-id }}
project-slug: crossdesk
signing-policy-slug: ${{ inputs.signing-policy }}
artifact-configuration-slug: ${{ inputs.artifact-configuration }}
github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
wait-for-completion: true
wait-for-completion-timeout-in-seconds: 3600
output-artifact-directory: ${{ inputs.output-directory }}
skip-decompress: true
parameters: |
version: ${{ toJSON(inputs.version) }}
- name: Record signing request
if: always() && steps.sign.outputs.signing-request-web-url != ''
shell: pwsh
env:
SIGNING_REQUEST_URL: ${{ steps.sign.outputs.signing-request-web-url }}
SIGNING_CONFIGURATION: ${{ inputs.artifact-configuration }}
SIGNING_POLICY: ${{ inputs.signing-policy }}
run: |
"- $env:SIGNING_CONFIGURATION ($env:SIGNING_POLICY): $env:SIGNING_REQUEST_URL" >> $env:GITHUB_STEP_SUMMARY
+114 -41
View File
@@ -14,7 +14,17 @@ on:
required: false
default: "0"
sign_windows_test:
description: "Submit the Windows installer to SignPath using test-signing."
description: "Test-sign Windows binaries and installer (Actions artifacts only)."
required: false
type: boolean
default: false
sign_windows_release:
description: "Test release-signing with the production certificate (Actions artifacts only)."
required: false
type: boolean
default: false
windows_only:
description: "Build only Windows, for signing tests."
required: false
type: boolean
default: false
@@ -28,6 +38,7 @@ env:
jobs:
build-linux:
if: github.event_name != 'workflow_dispatch' || !inputs.windows_only
name: Build Linux (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
strategy:
@@ -176,6 +187,7 @@ jobs:
# macOS
build-macos:
if: github.event_name != 'workflow_dispatch' || !inputs.windows_only
name: Build on macOS
runs-on: ${{ matrix.runner }}
env:
@@ -308,6 +320,7 @@ jobs:
# iOS
build-ios:
if: github.event_name != 'workflow_dispatch' || !inputs.windows_only
name: Build on iOS (arm64)
runs-on: macos-26
timeout-minutes: 120
@@ -504,9 +517,37 @@ jobs:
build-windows-x64:
name: Build on Windows x64
runs-on: windows-2022
timeout-minutes: 240
outputs:
signing-policy: ${{ steps.signing.outputs.policy }}
env:
XMAKE_GLOBALDIR: D:\xmake_global
steps:
- name: Select Windows signing policy
id: signing
shell: pwsh
env:
TEST_SIGNING: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows_test }}
RELEASE_SIGNING: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_windows_release }}
PUBLISH_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
run: |
if ($env:TEST_SIGNING -eq 'true' -and $env:RELEASE_SIGNING -eq 'true') {
throw 'Select only one of sign_windows_test and sign_windows_release.'
}
$policy = ''
if ($env:PUBLISH_RELEASE -eq 'true' -or $env:RELEASE_SIGNING -eq 'true') {
$policy = 'release-signing'
} elseif ($env:TEST_SIGNING -eq 'true') {
$policy = 'test-signing'
}
if ($policy -and (!$env:SIGNPATH_API_TOKEN -or !$env:SIGNPATH_ORGANIZATION_ID)) {
throw 'Signing requires SIGNPATH_API_TOKEN and SIGNPATH_ORGANIZATION_ID.'
}
"policy=$policy" >> $env:GITHUB_OUTPUT
"Windows signing policy: $(if ($policy) { $policy } else { 'unsigned development build' })" >> $env:GITHUB_STEP_SUMMARY
- name: Extract version number
shell: pwsh
run: |
@@ -611,67 +652,99 @@ jobs:
$nsisPluginDir = "C:\Program Files (x86)\NSIS\Plugins\x86-unicode"
copy "${{ github.workspace }}\apps\desktop\scripts\windows\nsProcess.dll" $nsisPluginDir
- name: Test Windows signing helpers
shell: pwsh
run: ./apps/desktop/tests/windows_signing_test.ps1
- name: Build CrossDesk
run: |
xmake f --CROSSDESK_VERSION=${{ env.VERSION_NUM }} --USE_CUDA=true -y
xmake b -vy crossdesk
- name: Prepare installer binaries for signing
if: steps.signing.outputs.policy != ''
shell: pwsh
run: |
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation PrepareBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/unsigned/installer-binaries.zip
- name: Sign installer binaries
if: steps.signing.outputs.policy != ''
uses: ./.github/actions/sign-windows
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
signing-policy: ${{ steps.signing.outputs.policy }}
artifact-configuration: windows-binaries
artifact: ${{ github.workspace }}/signpath/unsigned/installer-binaries.zip
output-directory: ${{ github.workspace }}/signpath/signed/installer-binaries
version: ${{ env.VERSION_NUM }}
- name: Verify and restore signed installer binaries
if: steps.signing.outputs.policy != ''
shell: pwsh
env:
SIGNING_POLICY: ${{ steps.signing.outputs.policy }}
run: |
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation RestoreBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/signed/installer-binaries/installer-binaries.zip -SigningPolicy $env:SIGNING_POLICY
- name: Package
shell: pwsh
run: |
& "${{ github.workspace }}\apps\desktop\scripts\windows\pkg_x64.ps1" $env:VERSION_NUM
- name: Upload unsigned Windows installer for test signing
if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test
id: upload-unsigned-windows-installer
uses: actions/upload-artifact@v7
with:
path: ${{ github.workspace }}/apps/desktop/scripts/windows/crossdesk-win-x64-${{ env.VERSION_NUM }}.exe
archive: false
if-no-files-found: error
retention-days: 1
- name: Sign Windows installer with SignPath test certificate
if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test
uses: signpath/github-action-submit-signing-request@v2
- name: Sign Windows installer
if: steps.signing.outputs.policy != ''
uses: ./.github/actions/sign-windows
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: crossdesk
signing-policy-slug: test-signing
github-artifact-id: ${{ steps.upload-unsigned-windows-installer.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: ${{ github.workspace }}\signpath\signed-installer
skip-decompress: true
parameters: |
version: ${{ toJSON(env.VERSION_NUM) }}
signing-policy: ${{ steps.signing.outputs.policy }}
artifact-configuration: initial
artifact: ${{ github.workspace }}/apps/desktop/scripts/windows/crossdesk-win-x64-${{ env.VERSION_NUM }}.exe
output-directory: ${{ github.workspace }}/signpath/signed/installer
version: ${{ env.VERSION_NUM }}
- name: Verify and use test-signed Windows installer
if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test
- name: Verify and use signed Windows installer
if: steps.signing.outputs.policy != ''
shell: pwsh
env:
SIGNING_POLICY: ${{ steps.signing.outputs.policy }}
run: |
$installerName = "crossdesk-win-x64-${{ env.VERSION_NUM }}.exe"
$signedInstaller = Join-Path "${{ github.workspace }}\signpath\signed-installer" $installerName
$destination = Join-Path "${{ github.workspace }}\apps\desktop\scripts\windows" $installerName
if (!(Test-Path $signedInstaller)) {
throw "SignPath did not return the signed installer: $signedInstaller"
}
$signature = Get-AuthenticodeSignature $signedInstaller
if (!$signature.SignerCertificate -or $signature.Status -eq "NotSigned") {
throw "SignPath returned an installer without an Authenticode signature."
}
Write-Host "Signer: $($signature.SignerCertificate.Subject)"
Write-Host "Signature status: $($signature.Status)"
Copy-Item $signedInstaller $destination -Force
$installerName = "crossdesk-win-x64-$env:VERSION_NUM.exe"
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation VerifyInstaller -Artifact "./signpath/signed/installer/$installerName" -SigningPolicy $env:SIGNING_POLICY
Copy-Item "./signpath/signed/installer/$installerName" ./apps/desktop/scripts/windows/ -Force
- name: Build Portable CrossDesk
run: |
xmake f --CROSSDESK_VERSION=${{ env.VERSION_NUM }} --USE_CUDA=true --CROSSDESK_PORTABLE=true -y
xmake b -vy crossdesk
- name: Prepare portable binaries for signing
if: steps.signing.outputs.policy != ''
shell: pwsh
run: |
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation PrepareBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/unsigned/portable-binaries.zip
- name: Sign portable binaries
if: steps.signing.outputs.policy != ''
uses: ./.github/actions/sign-windows
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
signing-policy: ${{ steps.signing.outputs.policy }}
artifact-configuration: windows-binaries
artifact: ${{ github.workspace }}/signpath/unsigned/portable-binaries.zip
output-directory: ${{ github.workspace }}/signpath/signed/portable-binaries
version: ${{ env.VERSION_NUM }}
- name: Verify and restore signed portable binaries
if: steps.signing.outputs.policy != ''
shell: pwsh
env:
SIGNING_POLICY: ${{ steps.signing.outputs.policy }}
run: |
./apps/desktop/scripts/windows/signpath-artifact.ps1 -Operation RestoreBinaries -Directory ./build/windows/x64/release -Artifact ./signpath/signed/portable-binaries/portable-binaries.zip -SigningPolicy $env:SIGNING_POLICY
- name: Package Portable
shell: pwsh
run: |
@@ -719,7 +792,7 @@ jobs:
release:
name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && needs.build-windows-x64.outputs.signing-policy == 'release-signing'
needs: [build-linux, build-macos, build-ios, build-windows-x64]
runs-on: ubuntu-latest
+3 -2
View File
@@ -92,8 +92,9 @@ jobs:
- name: Generate or update version.json
run: |
# If version.json exists, try to preserve downloads section
if [ -f version.json ] && jq -e '.downloads' version.json > /dev/null 2>&1; then
# Preserve download URLs only for this release, never an older version.
if [ -f version.json ] && jq -e --arg tag "${{ steps.version.outputs.TAG_NAME }}" \
'.tagName == $tag and (.downloads | type == "object")' version.json > /dev/null 2>&1; then
EXISTING_DOWNLOADS=$(jq -c '.downloads' version.json)
if [ "$EXISTING_DOWNLOADS" != "null" ] && [ "$EXISTING_DOWNLOADS" != "{}" ]; then
DOWNLOADS_JSON="$EXISTING_DOWNLOADS"
@@ -0,0 +1,65 @@
param(
[Parameter(Mandatory)]
[ValidateSet('PrepareBinaries', 'RestoreBinaries', 'VerifyInstaller')]
[string]$Operation,
[Parameter(Mandatory)] [string]$Artifact,
[string]$Directory,
[ValidateSet('test-signing', 'release-signing')] [string]$SigningPolicy
)
. "$PSScriptRoot/signpath-common.ps1"
if ($Operation -ne 'PrepareBinaries' -and !$SigningPolicy) {
throw 'SigningPolicy is required when verifying signed artifacts.'
}
if ($Operation -eq 'VerifyInstaller') {
Assert-CrossDeskSignature -Path $Artifact -SigningPolicy $SigningPolicy
return
}
if (!$Directory -or !(Test-Path -LiteralPath $Directory -PathType Container)) {
throw "Binaries directory does not exist: $Directory"
}
$files = @(Get-CrossDeskSigningFiles)
$temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) "crossdesk-signing-$([guid]::NewGuid())"
New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null
try {
if ($Operation -eq 'PrepareBinaries') {
foreach ($name in $files) {
$source = Join-Path $Directory $name
if (!(Test-Path -LiteralPath $source -PathType Leaf)) {
throw "Missing CrossDesk binary: $source"
}
Copy-Item -LiteralPath $source -Destination (Join-Path $temporaryDirectory $name)
}
$artifactPath = [IO.Path]::GetFullPath($Artifact)
New-Item -ItemType Directory -Path (Split-Path -Parent $artifactPath) -Force | Out-Null
Compress-Archive -Path "$temporaryDirectory/*" -DestinationPath $artifactPath -Force
return
}
# Inspect the ZIP before extraction: reject missing, duplicate or unexpected
# entries (including paths) so only the four intended files can be replaced.
$archive = [IO.Compression.ZipFile]::OpenRead([IO.Path]::GetFullPath($Artifact))
try {
$entries = @($archive.Entries | ForEach-Object { $_.FullName })
$uniqueEntries = @($entries | Sort-Object -Unique)
if ($entries.Count -ne $files.Count -or $uniqueEntries.Count -ne $files.Count -or
@(Compare-Object $files $entries).Count -ne 0) {
throw "Signed archive must contain exactly: $($files -join ', ')"
}
} finally {
$archive.Dispose()
}
Expand-Archive -LiteralPath $Artifact -DestinationPath $temporaryDirectory
# Verify every file before replacing any build output.
foreach ($name in $files) {
Assert-CrossDeskSignature -Path (Join-Path $temporaryDirectory $name) -SigningPolicy $SigningPolicy
}
foreach ($name in $files) {
Copy-Item -LiteralPath (Join-Path $temporaryDirectory $name) -Destination (Join-Path $Directory $name) -Force
}
} finally {
Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<parameters>
<parameter name="version" required="true" />
</parameters>
<zip-file>
<pe-file-set product-name="CrossDesk"
product-version="${version}"
file-version="${version}"
company-name="CrossDesk"
copyright="Copyright (C) CrossDesk contributors">
<include path="crossdesk.exe" original-filename="CrossDesk.exe" />
<include path="crossdesk_service.exe" original-filename="crossdesk_service.exe" />
<include path="crossdesk_session_helper.exe" original-filename="crossdesk_session_helper.exe" />
<include path="wgc_plugin.dll" original-filename="wgc_plugin.dll" />
<for-each>
<authenticode-sign />
</for-each>
</pe-file-set>
</zip-file>
</artifact-configuration>
@@ -0,0 +1,57 @@
$ErrorActionPreference = 'Stop'
function Get-CrossDeskSigningFiles {
# Only binaries built from this project's sources. Never include third-party DLLs.
return @('crossdesk.exe', 'crossdesk_service.exe', 'crossdesk_session_helper.exe', 'wgc_plugin.dll')
}
function Find-CrossDeskSignTool {
$command = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
$sdk = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits/10/bin'
$tool = Get-ChildItem "$sdk/*/x64/signtool.exe" |
Sort-Object { [version]$_.Directory.Parent.Name } |
Select-Object -Last 1
if (!$tool) {
throw 'SignTool from the Windows SDK is required to verify release signatures.'
}
return $tool.FullName
}
function Assert-CrossDeskSignature {
param(
[Parameter(Mandatory)] [string]$Path,
[Parameter(Mandatory)] [ValidateSet('test-signing', 'release-signing')] [string]$SigningPolicy
)
if (!(Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "Missing signed file: $Path"
}
$signature = Get-AuthenticodeSignature -LiteralPath $Path
if (!$signature.SignerCertificate) {
throw "File has no Authenticode signing certificate: $Path"
}
Write-Host "Signature: $Path | $($signature.Status) | $($signature.SignerCertificate.Subject)"
if ($SigningPolicy -eq 'test-signing') {
# Self-signed test certificates are not trusted by stock GitHub runners.
# HashMismatch, NotSigned, UnknownError, etc. must still stop the build.
if ($signature.Status -notin @('Valid', 'NotTrusted')) {
throw "Invalid test signature on ${Path}: $($signature.Status)"
}
return
}
if ($signature.Status -ne 'Valid') {
throw "Release signature is not trusted on ${Path}: $($signature.Status)"
}
if (!$signature.TimeStamperCertificate) {
throw "Release signature has no timestamp: $Path"
}
$signTool = Find-CrossDeskSignTool
& $signTool verify /pa /all /v $Path
if ($LASTEXITCODE -ne 0) {
throw "SignTool verification failed for $Path (exit $LASTEXITCODE)."
}
}
@@ -1413,6 +1413,21 @@ void GuiApplication::BindMainCallbacks() {
: slint::SharedString{});
});
main->on_open_download([this] { OpenUrl("https://crossdesk.cn"); });
main->on_start_update([this] {
#if _WIN32
windows_updater_.Start(latest_version_info_);
SyncWindowsUpdate();
#else
OpenUrl("https://crossdesk.cn");
ui_->main->set_update_open(false);
#endif
});
main->on_cancel_update([this] {
#if _WIN32
windows_updater_.Cancel();
#endif
ui_->main->set_update_open(false);
});
main->on_connection_cancel([this] {
const auto props = FindRemoteSession(ui_->connection_dialog_remote_id);
if (!props) {
@@ -2100,6 +2115,9 @@ void GuiApplication::SyncMainWindow() {
ui_->main->set_signal_tls_error(signal_status_ ==
SignalStatus::SignalTlsCertError);
ui_->main->set_update_available(update_available_);
#if _WIN32
SyncWindowsUpdate();
#endif
ui_->main->set_current_version(CROSSDESK_VERSION);
ui_->main->set_latest_version(UiText(latest_version_));
ui_->main->set_release_name(UiText(release_name_));
@@ -3257,7 +3275,59 @@ bool GuiApplication::OpenUrl(const std::string& url) {
return SDL_OpenURL(url.c_str());
}
#if _WIN32
void GuiApplication::SyncWindowsUpdate() {
using State = WindowsUpdater::State;
if (windows_updater_.GetState() == State::Ready) {
windows_updater_.Launch();
if (windows_updater_.GetState() == State::Launched) {
ui_->main->set_update_open(false);
}
}
const State state = windows_updater_.GetState();
const bool downloading = state == State::Downloading;
ui_->main->set_update_downloading(downloading || state == State::Checking ||
state == State::Launching);
ui_->main->set_update_security_blocked(state == State::SecurityBlocked);
ui_->main->set_update_failed(state == State::Failed ||
state == State::LaunchFailed ||
state == State::SecurityBlocked);
const int language = localization_language_index_;
std::string status;
if (downloading) {
status = localization::update_downloading[language];
const uint64_t total = windows_updater_.Total();
const uint64_t received = windows_updater_.Downloaded();
if (total > 0) {
const int percent = static_cast<int>(std::min(100.0, 100.0 * received / total));
status += " " + std::to_string(percent) + "%";
ui_->main->set_update_progress(static_cast<float>(percent) / 100.0f);
} else {
status += " " + std::to_string(received / (1024 * 1024)) + " MB";
ui_->main->set_update_progress(-1.0f);
}
} else if (state == State::Failed) {
status = localization::update_download_failed[language];
} else if (state == State::LaunchFailed) {
status = localization::update_launch_failed[language];
} else if (state == State::Checking) {
status = localization::update_checking[language];
ui_->main->set_update_progress(1.0f);
} else if (state == State::SecurityBlocked) {
status = localization::update_security_blocked[language];
} else if (state == State::Launching) {
status = localization::update_launching[language];
} else if (state == State::Launched) {
status = localization::update_installer_opened[language];
}
ui_->main->set_update_status(UiText(status));
}
#endif
void GuiApplication::Cleanup() {
#if _WIN32
windows_updater_.Cancel();
#endif
if (!ui_) {
SDL_Quit();
return;
@@ -16,6 +16,9 @@
#include "runtime/cursor_state_provider.h"
#include "runtime/gui_runtime.h"
#if _WIN32
#include "platform/windows/windows_updater.h"
#endif
namespace crossdesk {
@@ -52,6 +55,9 @@ private:
void HandlePasswordChangeResult();
void HandleCredentialRecovery();
void SyncMainWindow();
#if _WIN32
void SyncWindowsUpdate();
#endif
void SyncConnectionDialog();
void SyncPlatformDialogs();
void SyncStreamWindow();
@@ -87,6 +93,9 @@ private:
bool OpenUrl(const std::string &url);
std::unique_ptr<SlintUi> ui_;
#if _WIN32
WindowsUpdater windows_updater_;
#endif
CursorStateProvider cursor_state_provider_;
std::unordered_map<std::string, CursorDeliveryState>
cursor_delivery_states_;
@@ -100,4 +109,4 @@ private:
} // namespace crossdesk
#endif
#endif
@@ -184,6 +184,26 @@ struct TranslationRow {
X(notification, u8"通知", "Notification", u8"Уведомление") \
X(new_version_available, u8"新版本可用", "New Version Available", \
u8"Доступна новая версия") \
X(update_downloading, u8"正在下载安装包…", "Downloading installer…", \
u8"Загрузка установщика…") \
X(update_checking, u8"正在进行 Windows 安全检查…", \
"Running Windows security checks…", u8"Проверка безопасности Windows…") \
X(update_security_blocked, \
u8"Windows 安全检查未通过,请查看安全软件日志并核实发布者。", \
"Windows security checks failed. Review security logs and verify the publisher.", \
u8"Проверка Windows не пройдена. Проверьте журнал защиты и издателя.") \
X(update_launching, u8"正在打开安装包,请确认管理员权限请求…", \
"Opening installer. Please confirm the administrator permission request…", \
u8"Запуск установщика. Подтвердите запрос прав администратора…") \
X(update_download_failed, u8"下载失败,请点击更新重试,或访问官网。", \
"Download failed. Click Update to retry, or visit the website.", \
u8"Ошибка загрузки. Повторите обновление или откройте сайт.") \
X(update_launch_failed, u8"安装包未能启动或授权已取消,请点击更新重试。", \
"Installer launch failed or permission was cancelled. Click Update to retry.", \
u8"Установщик не запущен или доступ отменён. Повторите обновление.") \
X(update_installer_opened, u8"安装包已打开,请在安装向导中完成更新。", \
"Installer opened. Follow the setup wizard to finish updating.", \
u8"Установщик открыт. Следуйте указаниям мастера установки.") \
X(release_notes, u8"更新内容", "Release Notes", u8"Содержание обновления") \
X(version, u8"版本", "Version", u8"Версия") \
X(release_date, u8"发布日期: ", "Release Date: ", u8"Дата релиза: ") \
+29 -3
View File
@@ -210,6 +210,11 @@ export component MainWindow inherits Window {
in property <bool> signal-tls-error: false;
in property <[RecentConnection]> recent-connections;
in property <bool> update-available: false;
in property <bool> update-downloading: false;
in property <bool> update-failed: false;
in property <bool> update-security-blocked: false;
in property <float> update-progress: 0;
in property <string> update-status: "";
in property <string> current-version: "";
in property <string> latest-version: "";
in property <string> release-name: "";
@@ -268,6 +273,8 @@ export component MainWindow inherits Window {
callback cancel-self-hosted-settings;
callback browse-save-path;
callback open-download;
callback start-update;
callback cancel-update;
callback connection-cancel;
callback connection-acknowledge;
callback connection-submit-password(string, bool);
@@ -1644,7 +1651,7 @@ export component MainWindow inherits Window {
x: 30px;
y: 105px;
width: 360px;
height: 190px;
height: root.update-status != "" ? 136px : 190px;
background: white;
border-width: 1px;
border-color: #b9bdc3;
@@ -1671,8 +1678,27 @@ export component MainWindow inherits Window {
}
}
}
CompactButton { x: root.compact-language ? 163px : 130px; y: 309px; width: root.compact-language ? 34px : 70px; primary: true; text: UiStrings.download; clicked => { root.open-download(); root.update-open = false; } }
CompactButton { x: root.compact-language ? 204px : 207px; y: 309px; width: root.compact-language ? 34px : 56px; text: UiStrings.later; clicked => { root.update-open = false; } }
if root.update-status != "": Text {
x: 30px; y: 247px; width: 340px; height: 40px;
text: root.update-status;
color: root.update-failed ? #b23b3b : #3d434d;
font-size: ImGuiFontStyle.body;
wrap: word-wrap;
vertical-alignment: center;
}
if root.update-downloading: Rectangle {
x: 30px; y: 291px; width: 340px; height: 4px;
background: #e2e4e7;
clip: true;
Rectangle {
x: root.update-progress < 0 ? (parent.width + self.width) * (mod(animation-tick(), 1500ms) / 1500ms) - self.width : 0px;
width: parent.width * (root.update-progress < 0 ? 0.25 : root.update-progress);
height: parent.height;
background: #334fd1;
}
}
CompactButton { x: root.compact-language ? 163px : 130px; y: 309px; width: root.compact-language ? 34px : 70px; primary: true; text: UiStrings.download; enabled: !root.update-downloading && !root.update-security-blocked; clicked => { root.start-update(); } }
CompactButton { x: root.compact-language ? 204px : 207px; y: 309px; width: root.compact-language ? 34px : 56px; text: UiStrings.later; clicked => { root.cancel-update(); } }
}
}
@@ -0,0 +1,224 @@
#include "windows_installer.h"
#include <windows.h>
#include <shobjidl.h>
#include <winhttp.h>
#include <wrl/client.h>
#include <array>
#include <fstream>
#include <memory>
#include <stdexcept>
#include <system_error>
#include "rd_log.h"
namespace crossdesk {
namespace {
constexpr uint64_t kMaxInstallerSize = 1024ull * 1024 * 1024;
// Stable identity used only for Windows Attachment Services policy/UI.
constexpr GUID kAttachmentClient = {
0x72d17a6b,
0xeac1,
0x4816,
{0x91, 0x40, 0x33, 0x1b, 0xa7, 0x76, 0x22, 0xcd}};
void CheckWin32(BOOL success, const char* operation) {
if (!success) {
throw std::system_error(static_cast<int>(GetLastError()),
std::system_category(), operation);
}
}
struct InternetCloser {
void operator()(HINTERNET handle) const { WinHttpCloseHandle(handle); }
};
using InternetHandle = std::unique_ptr<void, InternetCloser>;
struct PartialFile {
std::filesystem::path path;
~PartialFile() {
std::error_code error;
std::filesystem::remove(path, error);
}
};
bool IsWindowsExecutable(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
std::array<unsigned char, 64> header{};
input.read(reinterpret_cast<char*>(header.data()), header.size());
if (!input || header[0] != 'M' || header[1] != 'Z') return false;
const uint32_t pe_offset =
uint32_t(header[60]) | (uint32_t(header[61]) << 8) |
(uint32_t(header[62]) << 16) | (uint32_t(header[63]) << 24);
if (pe_offset < header.size()) return false;
input.seekg(pe_offset);
std::array<char, 4> signature{};
input.read(signature.data(), signature.size());
return input && signature == std::array<char, 4>{'P', 'E', 0, 0};
}
uint64_t ContentLength(HINTERNET request) {
std::array<wchar_t, 32> value{};
DWORD bytes = static_cast<DWORD>(sizeof(value));
if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_CONTENT_LENGTH,
WINHTTP_HEADER_NAME_BY_INDEX, value.data(), &bytes,
WINHTTP_NO_HEADER_INDEX)) {
if (GetLastError() == ERROR_WINHTTP_HEADER_NOT_FOUND) return 0;
CheckWin32(FALSE, "WinHttpQueryHeaders(Content-Length)");
}
const std::wstring length(value.data());
if (length.empty() ||
length.find_first_not_of(L"0123456789") != std::wstring::npos) {
throw std::runtime_error("Invalid installer content length");
}
return std::stoull(length);
}
class ComApartment {
public:
ComApartment() : status_(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED)) {}
~ComApartment() {
if (SUCCEEDED(status_)) CoUninitialize();
}
bool Ready() const {
return SUCCEEDED(status_) || status_ == RPC_E_CHANGED_MODE;
}
private:
HRESULT status_;
};
bool HandleAttachment(const std::filesystem::path& installer,
const std::string& source, bool execute) {
ComApartment apartment;
if (!apartment.Ready()) {
LOG_WARN("Cannot initialize Windows Attachment Services");
return false;
}
Microsoft::WRL::ComPtr<IAttachmentExecute> attachment;
HRESULT result =
CoCreateInstance(CLSID_AttachmentServices, nullptr, CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(&attachment));
const std::wstring source_url(source.begin(), source.end());
if (SUCCEEDED(result)) result = attachment->SetClientGuid(kAttachmentClient);
if (SUCCEEDED(result))
result = attachment->SetClientTitle(L"CrossDesk Update");
if (SUCCEEDED(result)) result = attachment->SetLocalPath(installer.c_str());
if (SUCCEEDED(result)) result = attachment->SetSource(source_url.c_str());
if (SUCCEEDED(result)) {
if (execute) {
HANDLE process = nullptr;
// Execute applies attachment policy, scanning, and any required prompts.
// The installer's manifest, rather than this process, requests elevation.
result = attachment->Execute(GetActiveWindow(), L"open", &process);
if (process) CloseHandle(process);
} else {
// Save records the source zone and invokes installed trust/AV services.
result = attachment->Save();
}
}
if (FAILED(result)) {
LOG_WARN("Windows Attachment Services {} failed/blocked, HRESULT=0x{:08X}",
execute ? "execute" : "save", static_cast<uint32_t>(result));
return false;
}
return true;
}
} // namespace
bool DownloadWindowsInstaller(
const InstallerDownload& download, const std::filesystem::path& destination,
const std::function<bool(uint64_t, uint64_t)>& progress) {
auto partial_path = destination;
partial_path += ".part";
PartialFile partial{partial_path};
try {
if (!progress(0, 0)) return false;
const std::string agent = "CrossDesk/" CROSSDESK_VERSION " WindowsUpdater";
const std::wstring user_agent(agent.begin(), agent.end());
InternetHandle session(
WinHttpOpen(user_agent.c_str(), WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0));
CheckWin32(session != nullptr, "WinHttpOpen");
CheckWin32(WinHttpSetTimeouts(session.get(), 5000, 5000, 5000, 5000),
"WinHttpSetTimeouts");
InternetHandle connection(WinHttpConnect(session.get(),
L"downloads.crossdesk.cn",
INTERNET_DEFAULT_HTTPS_PORT, 0));
CheckWin32(connection != nullptr, "WinHttpConnect");
const std::wstring path(download.path.begin(), download.path.end());
InternetHandle request(WinHttpOpenRequest(
connection.get(), L"GET", path.c_str(), nullptr, WINHTTP_NO_REFERER,
WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE));
CheckWin32(request != nullptr, "WinHttpOpenRequest");
DWORD disabled = WINHTTP_DISABLE_REDIRECTS;
CheckWin32(WinHttpSetOption(request.get(), WINHTTP_OPTION_DISABLE_FEATURE,
&disabled, sizeof(disabled)),
"Disable redirects");
// Use Windows certificate validation and configured proxies as-is. Do not
// ignore TLS errors, spoof a browser, or retry through alternative hosts.
CheckWin32(WinHttpSendRequest(request.get(), WINHTTP_NO_ADDITIONAL_HEADERS,
0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0),
"WinHttpSendRequest");
CheckWin32(WinHttpReceiveResponse(request.get(), nullptr),
"WinHttpReceiveResponse");
DWORD status = 0;
DWORD status_size = sizeof(status);
CheckWin32(WinHttpQueryHeaders(
request.get(),
WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size,
WINHTTP_NO_HEADER_INDEX),
"WinHttpQueryHeaders(status)");
if (status != 200) {
LOG_WARN("Windows installer download returned HTTP {}", status);
return false;
}
const uint64_t total = ContentLength(request.get());
if (total > kMaxInstallerSize || !progress(0, total)) return false;
std::ofstream output(partial.path, std::ios::binary | std::ios::trunc);
if (!output)
throw std::runtime_error("Cannot create installer download file");
uint64_t received = 0;
std::array<unsigned char, 64 * 1024> buffer{};
while (progress(received, total)) {
DWORD count = 0;
CheckWin32(WinHttpReadData(request.get(), buffer.data(),
static_cast<DWORD>(buffer.size()), &count),
"WinHttpReadData");
if (count == 0) break;
received += count;
if (received > kMaxInstallerSize || (total > 0 && received > total))
return false;
output.write(reinterpret_cast<const char*>(buffer.data()), count);
if (!output)
throw std::runtime_error("Cannot write installer download file");
}
output.close();
if (output.fail() || received == 0 || (total > 0 && received != total) ||
!progress(received, total) || !IsWindowsExecutable(partial.path))
return false;
LOG_INFO("Windows installer downloaded, bytes={}", received);
std::filesystem::rename(partial.path, destination);
return true;
} catch (const std::exception& error) {
LOG_WARN("Windows installer download failed: {}", error.what());
return false;
}
}
bool CheckWindowsInstaller(const std::filesystem::path& installer,
const std::string& source) {
return HandleAttachment(installer, source, false);
}
bool LaunchWindowsInstaller(const std::filesystem::path& installer,
const std::string& source) {
return HandleAttachment(installer, source, true);
}
} // namespace crossdesk
@@ -0,0 +1,33 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-09
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _WINDOWS_INSTALLER_H_
#define _WINDOWS_INSTALLER_H_
#include <cstdint>
#include <filesystem>
#include <functional>
#include <string>
#include "installer_download.h"
namespace crossdesk {
bool DownloadWindowsInstaller(
const InstallerDownload& download, const std::filesystem::path& destination,
const std::function<bool(uint64_t, uint64_t)>& progress);
// Attachment Services records the Internet source and applies Windows policy
// and installed antivirus checks. Failure must never fall back to direct
// launch.
bool CheckWindowsInstaller(const std::filesystem::path& installer,
const std::string& source);
bool LaunchWindowsInstaller(const std::filesystem::path& installer,
const std::string& source);
} // namespace crossdesk
#endif
@@ -0,0 +1,123 @@
#include "windows_updater.h"
#include <shlobj.h>
#include <windows.h>
#include <random>
#include <stdexcept>
#include "installer_download.h"
#include "rd_log.h"
#include "windows_installer.h"
namespace crossdesk {
WindowsUpdater::~WindowsUpdater() {
Cancel();
if (worker_.joinable()) worker_.join();
RemoveDownload();
}
void WindowsUpdater::RemoveDownload() {
// A running NSIS installer still needs its source file. Leave that one in
// the user's update cache; clean up cancelled/failed attempts.
if (installer_.empty() || installer_launched_) return;
std::error_code error;
std::filesystem::remove(installer_, error);
std::filesystem::remove(installer_.parent_path(), error);
}
void WindowsUpdater::Start(const nlohmann::json& metadata) {
const State state = GetState();
if (state == State::Downloading || state == State::Checking ||
state == State::Launching || state == State::SecurityBlocked)
return;
if (state == State::Ready || state == State::LaunchFailed ||
state == State::Launched) {
cancelled_ = false;
Launch();
return;
}
if (worker_.joinable()) worker_.join();
RemoveDownload();
installer_.clear();
installer_launched_ = false;
cancelled_ = false;
downloaded_ = 0;
total_ = 0;
const auto download = GetWindowsInstallerDownload(metadata);
if (!download) {
LOG_WARN("No valid Windows installer for the advertised version");
state_ = State::Failed;
return;
}
try {
std::random_device random;
PWSTR local_app_data = nullptr;
const HRESULT path_result = SHGetKnownFolderPath(
FOLDERID_LocalAppData, KF_FLAG_CREATE, nullptr, &local_app_data);
if (FAILED(path_result))
throw std::runtime_error("Cannot locate LocalAppData");
const std::filesystem::path data_path(local_app_data);
CoTaskMemFree(local_app_data);
const auto updates = data_path / L"CrossDesk" / L"Updates";
std::filesystem::create_directories(updates);
for (int attempt = 0; attempt < 10; ++attempt) {
const auto directory = updates / ("download-" + std::to_string(random()) +
"-" + std::to_string(random()));
if (std::filesystem::create_directory(directory)) {
installer_ = directory / download->filename;
break;
}
}
if (installer_.empty())
throw std::runtime_error("Cannot create update directory");
source_url_ = download->origin + download->path;
state_ = State::Downloading;
worker_ = std::thread([this, download = *download] {
const bool success = DownloadWindowsInstaller(
download, installer_, [this](uint64_t received, uint64_t total) {
downloaded_ = received;
total_ = total;
return !cancelled_.load();
});
if (success && !cancelled_) {
state_ = State::Checking;
state_ = CheckWindowsInstaller(installer_, source_url_)
? State::Ready
: State::SecurityBlocked;
} else {
state_ = State::Failed;
}
if (cancelled_) state_ = State::Idle;
});
} catch (const std::exception& error) {
LOG_WARN("Cannot start Windows update: {}", error.what());
state_ = State::Failed;
}
}
void WindowsUpdater::Cancel() {
cancelled_ = true;
if (GetState() == State::Ready) state_ = State::Idle;
}
void WindowsUpdater::Launch() {
if (cancelled_) return;
const State state = GetState();
if (state != State::Ready && state != State::LaunchFailed &&
state != State::Launched)
return;
if (worker_.joinable()) worker_.join();
state_ = State::Launching;
if (!LaunchWindowsInstaller(installer_, source_url_)) {
state_ = State::LaunchFailed;
return;
}
installer_launched_ = true;
state_ = State::Launched;
// NSIS already prompts to close CrossDesk and stops the installed service.
// Keep this process alive if the user subsequently cancels installation.
}
} // namespace crossdesk
@@ -0,0 +1,55 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-09
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _WINDOWS_UPDATER_H_
#define _WINDOWS_UPDATER_H_
#include <atomic>
#include <filesystem>
#include <nlohmann/json.hpp>
#include <string>
#include <thread>
namespace crossdesk {
class WindowsUpdater {
public:
enum class State {
Idle,
Downloading,
Checking,
Ready,
Launching,
Failed,
SecurityBlocked,
LaunchFailed,
Launched
};
~WindowsUpdater();
void Start(const nlohmann::json& metadata);
void Cancel();
void Launch();
State GetState() const { return state_.load(); }
uint64_t Downloaded() const { return downloaded_.load(); }
uint64_t Total() const { return total_.load(); }
private:
void RemoveDownload();
std::atomic<State> state_{State::Idle};
std::atomic<bool> cancelled_{false};
std::atomic<uint64_t> downloaded_{0};
std::atomic<uint64_t> total_{0};
std::thread worker_;
std::filesystem::path installer_;
std::string source_url_;
bool installer_launched_ = false;
};
} // namespace crossdesk
#endif
@@ -0,0 +1,41 @@
#include "installer_download.h"
namespace crossdesk {
std::optional<InstallerDownload> GetWindowsInstallerDownload(
const nlohmann::json& metadata) {
if (!metadata.is_object()) return std::nullopt;
const auto version_it = metadata.find(
metadata.contains("latest_version") ? "latest_version" : "version");
if (version_it == metadata.end() || !version_it->is_string()) {
return std::nullopt;
}
std::string version = version_it->get<std::string>();
if (!version.empty() && version.front() == 'v') version.erase(0, 1);
if (version.empty() ||
version.find_first_not_of(
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-") !=
std::string::npos) {
return std::nullopt;
}
const auto downloads = metadata.find("downloads");
if (downloads == metadata.end() || !downloads->is_object())
return std::nullopt;
const auto installer = downloads->find("windows-x64");
if (installer == downloads->end() || !installer->is_object())
return std::nullopt;
const auto url = installer->find("url");
const auto filename = installer->find("filename");
InstallerDownload result{"https://downloads.crossdesk.cn", "",
"crossdesk-win-x64-v" + version + ".exe"};
result.path = "/" + result.filename;
if (url == installer->end() || !url->is_string() ||
url->get<std::string>() != result.origin + result.path ||
(filename != installer->end() &&
(!filename->is_string() ||
filename->get<std::string>() != result.filename))) {
return std::nullopt;
}
return result;
}
} // namespace crossdesk
@@ -0,0 +1,28 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-09
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _INSTALLER_DOWNLOAD_H_
#define _INSTALLER_DOWNLOAD_H_
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
namespace crossdesk {
struct InstallerDownload {
std::string origin;
std::string path;
std::string filename;
};
// Only accept the official Windows installer for the advertised version.
std::optional<InstallerDownload> GetWindowsInstallerDownload(
const nlohmann::json& metadata);
} // namespace crossdesk
#endif
+123
View File
@@ -0,0 +1,123 @@
# Portable tests for artifact handling and signature validation decisions.
# Get-AuthenticodeSignature and SignTool are simulated; CI validates real files.
$ErrorActionPreference = 'Stop'
$scripts = Join-Path $PSScriptRoot '../scripts/windows'
. "$scripts/signpath-common.ps1"
$artifactScript = Join-Path $scripts 'signpath-artifact.ps1'
$testDirectory = Join-Path ([IO.Path]::GetTempPath()) "crossdesk-signing-test-$([guid]::NewGuid())"
New-Item -ItemType Directory -Path $testDirectory | Out-Null
$checks = 0
function Assert-True([bool]$Condition, [string]$Message) {
if (!$Condition) { throw $Message }
$script:checks++
}
function Assert-Throws([scriptblock]$Action, [string]$Expected) {
try { & $Action } catch {
if ($_.Exception.Message -notlike "*$Expected*") { throw }
$script:checks++
return
}
throw "Expected an error containing: $Expected"
}
function New-TestZip([string]$Path, [string[]]$Names) {
$zip = [IO.Compression.ZipFile]::Open($Path, [IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($name in $Names) {
$writer = [IO.StreamWriter]::new($zip.CreateEntry($name).Open())
try { $writer.Write("signed $name") } finally { $writer.Dispose() }
}
} finally { $zip.Dispose() }
}
$mockStatus = 'NotTrusted'
$mockBadFile = ''
$mockCertificate = [pscustomobject]@{ Subject = "CN=Test certificate for 'CrossDesk [OSS]'" }
$mockTimestamp = $null
function Get-AuthenticodeSignature {
param([string]$LiteralPath)
$status = if ([IO.Path]::GetFileName($LiteralPath) -eq $mockBadFile) { 'HashMismatch' } else { $mockStatus }
return [pscustomobject]@{
Status = $status
SignerCertificate = $mockCertificate
TimeStamperCertificate = $mockTimestamp
}
}
try {
$files = @(Get-CrossDeskSigningFiles)
$build = Join-Path $testDirectory 'build'
New-Item -ItemType Directory -Path $build | Out-Null
foreach ($name in $files) { Set-Content -LiteralPath (Join-Path $build $name) -Value "original $name" }
Set-Content -LiteralPath (Join-Path $build 'third-party.dll') -Value 'upstream'
$unsigned = Join-Path $testDirectory 'unsigned.zip'
& $artifactScript -Operation PrepareBinaries -Directory $build -Artifact $unsigned
$zip = [IO.Compression.ZipFile]::OpenRead($unsigned)
try {
$entries = @($zip.Entries | ForEach-Object { $_.FullName })
Assert-True ($entries.Count -eq 4 -and @(Compare-Object $files $entries).Count -eq 0) 'Signing input must include only the four project binaries.'
} finally { $zip.Dispose() }
[xml]$config = Get-Content -Raw (Join-Path $scripts 'signpath-binaries.xml')
$configured = @($config.SelectNodes('//*[local-name()="include"]') | ForEach-Object { $_.path })
Assert-True (@(Compare-Object $files $configured).Count -eq 0) 'SignPath XML must cover exactly the files in the submitted ZIP.'
Remove-Item -LiteralPath (Join-Path $build 'wgc_plugin.dll')
Assert-Throws { & $artifactScript -Operation PrepareBinaries -Directory $build -Artifact (Join-Path $testDirectory 'missing.zip') } 'Missing CrossDesk binary'
Set-Content -LiteralPath (Join-Path $build 'wgc_plugin.dll') -Value 'original wgc_plugin.dll'
$signed = Join-Path $testDirectory 'signed.zip'
New-TestZip $signed $files
$mockBadFile = 'wgc_plugin.dll'
Assert-Throws { & $artifactScript -Operation RestoreBinaries -Directory $build -Artifact $signed -SigningPolicy test-signing } 'HashMismatch'
foreach ($name in $files) {
Assert-True ((Get-Content -Raw (Join-Path $build $name)).Trim() -eq "original $name") 'Failed verification must not replace any output.'
}
$mockBadFile = ''
& $artifactScript -Operation RestoreBinaries -Directory $build -Artifact $signed -SigningPolicy test-signing
foreach ($name in $files) {
Assert-True ((Get-Content -Raw (Join-Path $build $name)) -eq "signed $name") 'Verified signed files must replace the unsigned build output.'
}
Assert-True ((Get-Content -Raw (Join-Path $build 'third-party.dll')).Trim() -eq 'upstream') 'Third-party libraries must remain untouched.'
$badArchives = @(
@($files[0], $files[1], $files[2]),
@($files[0], $files[1], $files[2], '../wgc_plugin.dll'),
@($files[0], $files[1], $files[2], $files[0]),
@($files[0], $files[1], $files[2], $files[3], 'third-party.dll')
)
for ($i = 0; $i -lt $badArchives.Count; $i++) {
$badZip = Join-Path $testDirectory "bad-$i.zip"
New-TestZip $badZip $badArchives[$i]
Assert-Throws { & $artifactScript -Operation RestoreBinaries -Directory $build -Artifact $badZip -SigningPolicy test-signing } 'Signed archive must contain exactly'
}
$file = Join-Path $build 'crossdesk.exe'
foreach ($mockStatus in @('NotSigned', 'HashMismatch', 'UnknownError')) {
Assert-Throws { Assert-CrossDeskSignature $file test-signing } 'Invalid test signature'
}
$mockStatus = 'Valid'
$mockCertificate = $null
Assert-Throws { Assert-CrossDeskSignature $file test-signing } 'no Authenticode signing certificate'
$mockCertificate = [pscustomobject]@{ Subject = 'CN=SignPath Foundation' }
$mockStatus = 'NotTrusted'
Assert-Throws { Assert-CrossDeskSignature $file release-signing } 'Release signature is not trusted'
$mockStatus = 'Valid'
Assert-Throws { Assert-CrossDeskSignature $file release-signing } 'no timestamp'
$mockTimestamp = [pscustomobject]@{ Subject = 'CN=Timestamp authority' }
# Only the real SignTool process is replaced; exercise the actual exit-code check.
$fakeSignTool = Join-Path $testDirectory 'signtool.ps1'
function Find-CrossDeskSignTool { return $fakeSignTool }
Set-Content $fakeSignTool '$global:LASTEXITCODE = 1'
Assert-Throws { Assert-CrossDeskSignature $file release-signing } 'SignTool verification failed'
Set-Content $fakeSignTool '$global:LASTEXITCODE = 0'
Assert-CrossDeskSignature $file release-signing
$checks++
Write-Host "Passed $checks Windows signing checks."
} finally {
Remove-Item -LiteralPath $testDirectory -Recurse -Force
}
+5
View File
@@ -285,6 +285,11 @@ function setup_targets()
add_defines("CROSSDESK_VERSION=\"" .. (get_config("CROSSDESK_VERSION") or "Unknown") .. "\"")
add_deps("rd_log")
add_files("apps/desktop/src/version_checker/*.cpp")
if is_os("windows") then
add_files("apps/desktop/src/platform/windows/windows_updater.cpp",
"apps/desktop/src/platform/windows/windows_installer.cpp")
add_links("winhttp", "ole32", "uuid")
end
add_includedirs("apps/desktop/src/version_checker", {public = true})
if is_os("macosx") then
add_defines("CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN")
+71
View File
@@ -0,0 +1,71 @@
# Windows 签名与 Release 测试
GitHub Actions 的 `Build and Release` 工作流构建 CrossDesk,调用 SignPath 签名,再发布已验证的 Windows 文件。
## 触发方式
| 触发方式 | 签名策略 | 输出 |
| --- | --- | --- |
| 普通分支 push,或未勾选签名的手动运行 | 无 | 开发构建工件 |
| 手动运行,勾选 `sign_windows_test` | `test-signing` | 测试证书签名的 Actions 工件 |
| 手动运行,勾选 `sign_windows_release` | `release-signing` | 正式证书签名的 Actions 工件 |
| push `v*` tag | `release-signing` | 验证通过后发布 GitHub Release 和下载站 |
两种手动签名选项不能同时启用。手动运行可勾选 `windows_only` 跳过其他平台;即使选择 tag 手动运行,也不会发布 Release、更新 `latest` 或同步下载站。
## SignPath 配置
GitHub 仓库需要 Secret `SIGNPATH_API_TOKEN` 和 Variable `SIGNPATH_ORGANIZATION_ID`。SignPath 项目 slug 为 `crossdesk`,已关联 GitHub.com Trusted Build System。
该工作流明确使用以下 Artifact Configuration:
| Slug | XML 文件 | 用途 |
| --- | --- | --- |
| `initial` | `apps/desktop/scripts/windows/signpath-installer.xml` | 现有安装包配置,只签外层 NSIS EXE |
| `windows-binaries` | `apps/desktop/scripts/windows/signpath-binaries.xml` | 签 ZIP 内四个 CrossDesk 自有二进制 |
`crossdesk` 后台已添加并验证 `windows-binaries`。新环境需要在项目的 Artifact Configurations 页面创建同名配置,使用仓库内对应的完整 XML。保留 `initial` 的默认配置和现有审批、来源验证要求。XML 文件仅提交到 GitHub 不会自动同步到 SignPath 后台。
两种策略都必须允许提交这些配置。正式策略需要有效的 Release 证书、提交者权限和审批人。若证书仍为 `CSR PENDING`,需要 SignPath 完成签发/导入;切换工作流参数不会使证书生效。后台的来源限制也必须允许当前测试分支,若不允许,应按项目原有发布审批流程处理。
## 签名顺序
1. 构建安装版,将 `crossdesk.exe`、`crossdesk_service.exe`、`crossdesk_session_helper.exe`、`wgc_plugin.dll` 打成 `installer-binaries.zip`。
2. 使用 `windows-binaries` 签名,验证四个文件后替换构建输出。
3. NSIS 打包已签名的文件,使用 `initial` 签名安装器,验证后替换安装器。
4. 构建便携版,将四个文件打成 `portable-binaries.zip`,同样签名并验证,然后生成包含已签名文件的便携 ZIP。
5. 上传最终安装器与便携 ZIP。tag 发布等待 Windows 签名和其他平台构建全部成功。
第三方 DLL 原样打包,不提交签名。外层 NSIS 签名不为内部文件添加独立签名;本流程在打包前签名自有文件。NSIS 生成的 `uninstall.exe` 尚未配置独立签名。
每次运行会按上述顺序提交三个签名请求。正式签名需要在 SignPath 中逐个审批,每个请求最多等待 60 分钟,整个 Windows job 最多 240 分钟。请求链接由官方 Action 输出,并在可用时写入运行摘要。
## 启动正式证书测试
在 GitHub → Actions → Build and Release → Run workflow 中选择测试分支:
- `sign_windows_release`: true
- `sign_windows_test`: false
- `windows_only`: true
也可在分支推送后运行:
```sh
gh workflow run build.yml --ref signpath-release-signing \
-f sign_windows_release=true -f sign_windows_test=false -f windows_only=true
```
正式验证要求 Authenticode 状态为 `Valid`、存在时间戳,并且 `signtool verify /pa /all /v` 返回成功。测试证书允许 `NotTrusted`,但拒绝无签名、哈希不匹配等状态。任一签名或验证失败均停止后续发布;不会回退到未签名发布。
原始签名输入保留为中间 Actions 工件。手动测试成功后,下载名为 `crossdesk-win-x64-...` 和 `crossdesk-win-x64-portable-...` 的最终工件,不要使用 `installer-binaries.zip`、`portable-binaries.zip` 等原始签名输入。
## 本地校验
```sh
pwsh -NoProfile -File apps/desktop/tests/windows_signing_test.ps1
actionlint .github/workflows/build.yml
```
PowerShell 测试使用模拟证书状态检查归档范围、还原顺序及失败行为,不能替代 Windows 上的真实 Authenticode 验证。
参考:[SignPath GitHub 集成](https://docs.signpath.io/trusted-build-systems/github)、[Artifact Configuration](https://docs.signpath.io/artifact-configuration/examples)、[SignTool 验证](https://learn.microsoft.com/en-us/windows/win32/seccrypto/using-signtool-to-verify-a-file-signature)。
+48
View File
@@ -0,0 +1,48 @@
# Windows 更新与安全软件兼容性
Windows 的更新按钮在后台下载发布信息中指定版本的安装包,完成校验及
Windows 附件安全检查后打开安装向导。客户端使用 WinHTTP,保留系统的
HTTPS 证书检查和代理配置,并用 `CrossDesk/<version> WindowsUpdater`
标识请求。
安装包保存在 `%LOCALAPPDATA%\CrossDesk\Updates` 下的独立目录中。
下载过程写入 `.part` 文件;HTTP 响应、文件长度、Windows 可执行文件头
均通过检查后才生成最终安装包。
客户端通过 Windows Attachment Services 的 `Save` 记录下载来源并请求
系统安全检查,再通过 `Execute` 打开安装包。这些 API 会按 Windows 策略
调用扫描、信任检查和必要的提示。安装程序由自身的管理员权限清单请求
UAC 授权。安全检查失败时停止流程,不回退到直接启动,也不删除来源标记。
## 发行版签名
应使用受信任的正式代码签名证书签署发布的主程序、服务、辅助程序、DLL
和最终安装包。只签安装包不能建立已安装主程序的发布者身份。
SignPath 的 `test-signing` 用于测试,不能代替公开发行所需的受信任签名。
当前流水线中的测试签名配置仍保持为可选项;正式策略需在取得证书及
SignPath 授权后配置。
检查实际分发文件的签名:
```powershell
Get-AuthenticodeSignature .\crossdesk.exe
Get-AuthenticodeSignature .\crossdesk-win-x64-<version>.exe
```
## 网络防护告警的处理
2026-09-09 的天擎日志记录了在域名解析阶段对
`downloads.crossdesk.cn` 和 `101.132.143.203` 的阻断。
该记录不能单独证明 HTTP 库、提权方式或文件内容是命中原因。
切换到标准 Windows API 也不能保证域名/IP 信誉告警消失。
出现同类告警时,保留告警时间、策略名称、域名/IP、程序版本及签名状态,
由安全管理员向厂商核查命中原因,同时检查服务器是否存在异常活动。
确认误报后按厂商流程纠正信誉或企业策略。应在策略允许的测试环境中
验证下载、取消、不完整文件、安全检查失败及安装向导启动流程。
相关 API:
- [WinHttpOpen](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpopen)
- [IAttachmentExecute::Save](https://learn.microsoft.com/windows/win32/api/shobjidl_core/nf-shobjidl_core-iattachmentexecute-save)
- [IAttachmentExecute::Execute](https://learn.microsoft.com/windows/win32/api/shobjidl_core/nf-shobjidl_core-iattachmentexecute-execute)