Compare commits

...
10 Commits
27 changed files with 1134 additions and 291 deletions
+66 -14
View File
@@ -50,7 +50,7 @@ def sign_update(payload, private_key, expected_public_key):
return {"payload": payload, "signature": signature.hex()} return {"payload": payload, "signature": signature.hex()}
def version_info(version, fallback_date=None): def version_fields(version):
version = version.removeprefix("v") version = version.removeprefix("v")
base = r"[0-9]+(?:\.[0-9]+){1,3}" base = r"[0-9]+(?:\.[0-9]+){1,3}"
for pattern in ( for pattern in (
@@ -60,18 +60,41 @@ def version_info(version, fallback_date=None):
): ):
match = re.fullmatch(pattern, version) match = re.fullmatch(pattern, version)
if match: if match:
fields = match.groupdict() return match.groupdict()
date = fields.get("date")
release_date = (datetime.datetime.strptime(date, "%Y%m%d").date().isoformat()
if date else fallback_date)
if not release_date:
raise ValueError(f"Release date is missing for {version}")
datetime.date.fromisoformat(release_date)
return {"latest_version": version, "version": version,
"releaseDate": release_date, "patch": int(fields.get("patch") or 0)}
raise ValueError(f"Invalid release version: {version}") raise ValueError(f"Invalid release version: {version}")
def version_info(version, fallback_date=None):
fields = version_fields(version)
version = version.removeprefix("v")
date = fields.get("date")
release_date = (datetime.datetime.strptime(date, "%Y%m%d").date().isoformat()
if date else fallback_date)
if not release_date:
raise ValueError(f"Release date is missing for {version}")
datetime.date.fromisoformat(release_date)
return {"latest_version": version, "version": version,
"releaseDate": release_date, "patch": int(fields.get("patch") or 0)}
def release_order(metadata):
"""Match client ordering: zero-padded numeric components, then patch, never date."""
version = metadata.get("latest_version") or metadata.get("version")
if not version:
return None
fields = version_fields(version)
numbers = tuple(int(part) for part in fields["base"].split("."))
inline_patch = fields.get("patch") or "0"
patch = int(inline_patch) if len(inline_patch) <= 4 else 0
# Legacy manifests may store the effective patch outside the version string.
explicit_patch = metadata.get("patch")
if isinstance(explicit_patch, str) and re.fullmatch(r"[0-9]+", explicit_patch):
explicit_patch = int(explicit_patch)
if type(explicit_patch) is int and 0 <= explicit_patch <= 2147483647:
patch = explicit_patch
return numbers + (0,) * (4 - len(numbers)) + (patch,)
def artifact_downloads(directory): def artifact_downloads(directory):
downloads = {} downloads = {}
private_key = os.environ.get("CROSSDESK_UPDATE_PRIVATE_KEY", "") private_key = os.environ.get("CROSSDESK_UPDATE_PRIVATE_KEY", "")
@@ -129,10 +152,34 @@ def merge_release(current, tag, name, notes, downloads, published_date=None):
if not tag.startswith("v"): if not tag.startswith("v"):
raise ValueError(f"Expected a version tag, got {tag!r}") raise ValueError(f"Expected a version tag, got {tag!r}")
metadata = version_info(tag, published_date) metadata = version_info(tag, published_date)
# Clients require the full artifact version to match the advertised
# desktop version, including its date and patch spelling.
for platform, download in downloads.items():
if (platform.startswith(("windows-", "macos-", "linux-"))
and download["version"] != metadata["version"]):
raise ValueError(
f"Desktop release {tag} expects version {metadata['version']}, "
f"but {platform} artifact {download['filename']} "
f"has version {download['version']}")
current_order = release_order(current)
if current_order is not None and release_order(metadata) < current_order:
current_version = current.get("latest_version") or current.get("version")
print(f"Skipping {tag}: older than current desktop release {current_version}")
return dict(current)
accepted_downloads = {}
for platform, download in downloads.items():
previous_order = release_order(current["downloads"].get(platform, {}))
if previous_order is not None and release_order(download) < previous_order:
print(f"Skipping {tag} download for {platform}: a newer version is already published")
continue
accepted_downloads[platform] = download
if mobile and not accepted_downloads:
return dict(current)
result = dict(current) result = dict(current)
result.pop("platforms", None) result.pop("platforms", None)
result["downloads"] = {**current["downloads"], **downloads} result["downloads"] = {**current["downloads"], **accepted_downloads}
if not mobile: if not mobile:
result.update({**metadata, "releaseName": name, "releaseNotes": notes, "tagName": tag}) result.update({**metadata, "releaseName": name, "releaseNotes": notes, "tagName": tag})
return result return result
@@ -159,11 +206,16 @@ def main():
parser.error("Supply --release-json or --tag, --name and --notes") parser.error("Supply --release-json or --tag, --name and --notes")
tag, name, notes = args.tag, args.name, args.notes.read_text() tag, name, notes = args.tag, args.name, args.notes.read_text()
published_date = None published_date = None
current = json.loads(args.current.read_text()) current_text = args.current.read_text()
current = json.loads(current_text)
downloads = artifact_downloads(args.artifacts) downloads = artifact_downloads(args.artifacts)
result = merge_release(current, tag, name, notes, downloads, published_date) result = merge_release(current, tag, name, notes, downloads, published_date)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n") if result == current:
print(f"Updated {tag}: {', '.join(downloads)}") args.output.write_text(current_text)
print(f"No changes to version.json for {tag}")
else:
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
print(f"Updated version.json for {tag}")
if __name__ == "__main__": if __name__ == "__main__":
+73 -219
View File
@@ -36,9 +36,68 @@ env:
CROSSDESK_DESKTOP_VERSION: ${{ inputs.desktop_version }} CROSSDESK_DESKTOP_VERSION: ${{ inputs.desktop_version }}
jobs: jobs:
prepare-version:
name: Resolve desktop version
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.version.outputs.version }}
# Resolve the date once for every desktop build and the release job.
steps:
- name: Resolve desktop version
id: version
shell: bash
env:
PATCH_NUMBER: ${{ inputs.patch || '0' }}
run: |
VERSION_REF="${GITHUB_REF##*/}"
if [[ "${GITHUB_REF}" == refs/heads/* ]]; then
VERSION_REF="${CROSSDESK_DESKTOP_VERSION:-0.0.0}"
if [[ ! "${VERSION_REF}" =~ ^v?[0-9]+(\.[0-9]+){1,3}$ ]]; then
echo "::error::desktop_version must be a numeric version such as 1.7.2"
exit 1
fi
elif [[ -n "${CROSSDESK_DESKTOP_VERSION}" ]]; then
echo "::error::desktop_version is only supported for branch builds; tags determine release versions"
exit 1
fi
VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
if [[ "${PATCH_NUMBER}" != "0" ]]; then
VERSION_NUM="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
VERSION_NUM="v${VERSION_BASE}-${BUILD_DATE}"
fi
echo "version=${VERSION_NUM}" >> "$GITHUB_OUTPUT"
echo "Desktop version: ${VERSION_NUM}"
build-linux: build-linux:
name: Build Linux (${{ matrix.arch }}) name: Build Linux (${{ matrix.arch }})
needs: prepare-version
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
env:
LEGAL_VERSION: ${{ needs.prepare-version.outputs.version }}
strategy: strategy:
matrix: matrix:
include: include:
@@ -58,69 +117,6 @@ jobs:
options: --user root options: --user root
steps: steps:
- name: Extract version number
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
if [[ "${GITHUB_REF}" == refs/heads/* ]]; then
VERSION_REF="${CROSSDESK_DESKTOP_VERSION:-0.0.0}"
if [[ ! "${VERSION_REF}" =~ ^v?[0-9]+(\.[0-9]+){1,3}$ ]]; then
echo "::error::desktop_version must be a numeric version such as 1.7.2"
exit 1
fi
elif [[ -n "${CROSSDESK_DESKTOP_VERSION}" ]]; then
echo "::error::desktop_version is only supported for branch builds; tags determine release versions"
exit 1
fi
VERSION_BASE="${VERSION_REF#v}"
PATCH_NUMBER="${{ github.event.inputs.patch }}"
BUILD_DATE_OVERRIDE=""
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE_OVERRIDE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE_OVERRIDE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE_OVERRIDE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
echo "VERSION_BASE=${VERSION_BASE}" >> $GITHUB_ENV
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_ENV
echo "BUILD_DATE_OVERRIDE=${BUILD_DATE_OVERRIDE}" >> $GITHUB_ENV
- name: Set legal Debian version
shell: bash
run: |
BUILD_DATE="${BUILD_DATE_OVERRIDE}"
if [[ -z "${BUILD_DATE}" ]]; then
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
fi
if [[ ! "${VERSION_BASE}" =~ ^[0-9] ]]; then
VERSION_BASE="0.0.0-${VERSION_BASE}"
fi
if [[ "${PATCH_NUMBER}" != "0" ]]; then
LEGAL_VERSION="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
LEGAL_VERSION="v${VERSION_BASE}-${BUILD_DATE}"
fi
echo "LEGAL_VERSION=${LEGAL_VERSION}" >> $GITHUB_ENV
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
@@ -218,8 +214,10 @@ jobs:
# macOS # macOS
build-macos: build-macos:
name: Build on macOS name: Build on macOS
needs: prepare-version
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
env: env:
VERSION_NUM: ${{ needs.prepare-version.outputs.version }}
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "14.0"
EXPECTED_MACOS_SDK: "26.5" EXPECTED_MACOS_SDK: "26.5"
strategy: strategy:
@@ -239,56 +237,6 @@ jobs:
package_script: ./apps/desktop/scripts/macos/pkg_arm64.sh package_script: ./apps/desktop/scripts/macos/pkg_arm64.sh
steps: steps:
- name: Extract version number
id: version
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
if [[ "${GITHUB_REF}" == refs/heads/* ]]; then
VERSION_REF="${CROSSDESK_DESKTOP_VERSION:-0.0.0}"
if [[ ! "${VERSION_REF}" =~ ^v?[0-9]+(\.[0-9]+){1,3}$ ]]; then
echo "::error::desktop_version must be a numeric version such as 1.7.2"
exit 1
fi
elif [[ -n "${CROSSDESK_DESKTOP_VERSION}" ]]; then
echo "::error::desktop_version is only supported for branch builds; tags determine release versions"
exit 1
fi
VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
PATCH_NUMBER="${{ github.event.inputs.patch }}"
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
if [[ "${PATCH_NUMBER}" != "0" ]]; then
VERSION_NUM="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
VERSION_NUM="v${VERSION_BASE}-${BUILD_DATE}"
fi
echo "VERSION_NUM=${VERSION_NUM}" >> $GITHUB_ENV
echo "VERSION_NUM=${VERSION_NUM}"
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_ENV
- name: Select Xcode 26.6 - name: Select Xcode 26.6
shell: bash shell: bash
run: | run: |
@@ -380,62 +328,12 @@ jobs:
# Windows # Windows
build-windows-x64: build-windows-x64:
name: Build on Windows x64 name: Build on Windows x64
needs: prepare-version
runs-on: windows-2022 runs-on: windows-2022
env: env:
VERSION_NUM: ${{ needs.prepare-version.outputs.version }}
XMAKE_GLOBALDIR: D:\xmake_global XMAKE_GLOBALDIR: D:\xmake_global
steps: steps:
- name: Extract version number
shell: pwsh
run: |
$ref = $env:GITHUB_REF
$version = $ref -replace '^refs/(tags|heads)/', ''
if ($ref.StartsWith("refs/heads/")) {
$version = if ([string]::IsNullOrEmpty($env:CROSSDESK_DESKTOP_VERSION)) {
"0.0.0"
} else {
$env:CROSSDESK_DESKTOP_VERSION
}
if ($version -notmatch '^v?[0-9]+(\.[0-9]+){1,3}\z') {
throw "desktop_version must be a numeric version such as 1.7.2"
}
} elseif (![string]::IsNullOrEmpty($env:CROSSDESK_DESKTOP_VERSION)) {
throw "desktop_version is only supported for branch builds; tags determine release versions"
}
$version = $version -replace '^v', ''
$version = $version -replace '/', '-'
$BUILD_DATE = ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId((Get-Date), "China Standard Time")).ToString("yyyyMMdd")
$PATCH_NUMBER = "${{ github.event.inputs.patch }}"
if ($PATCH_NUMBER -notmatch '^[0-9]+$') {
$PATCH_NUMBER = "0"
}
if ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$') {
$version = $Matches[1]
$PATCH_NUMBER = $Matches[3]
$BUILD_DATE = $Matches[4]
} elseif ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$') {
$version = $Matches[1]
$BUILD_DATE = $Matches[3]
$PATCH_NUMBER = $Matches[4]
} elseif ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$') {
$version = $Matches[1]
$BUILD_DATE = $Matches[3]
} elseif ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$' -and $PATCH_NUMBER -eq "0") {
$version = $Matches[1]
$PATCH_NUMBER = $Matches[3]
}
if ($PATCH_NUMBER -ne "0") {
$VERSION_NUM = "v$version-$PATCH_NUMBER-$BUILD_DATE"
} else {
$VERSION_NUM = "v$version-$BUILD_DATE"
}
echo "VERSION_NUM=$VERSION_NUM" >> $env:GITHUB_ENV
echo "BUILD_DATE=$BUILD_DATE" >> $env:GITHUB_ENV
echo "PATCH_NUMBER=$PATCH_NUMBER" >> $env:GITHUB_ENV
- name: Cache xmake dependencies - name: Cache xmake dependencies
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
@@ -636,7 +534,7 @@ jobs:
release: release:
name: Publish Release name: Publish Release
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
needs: [build-linux, build-macos, build-ios, build-android, sign-android, build-windows-x64] needs: [prepare-version, build-linux, build-macos, build-ios, build-android, sign-android, build-windows-x64]
runs-on: ubuntu-latest runs-on: ubuntu-latest
concurrency: concurrency:
group: publish-release-metadata group: publish-release-metadata
@@ -651,50 +549,6 @@ jobs:
with: with:
path: artifacts path: artifacts
- name: Extract version number
id: version
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
PATCH_NUMBER="${{ github.event.inputs.patch }}"
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
BUILD_DATE_ISO="${BUILD_DATE:0:4}-${BUILD_DATE:4:2}-${BUILD_DATE:6:2}"
if [[ "${PATCH_NUMBER}" != "0" ]]; then
VERSION_NUM="${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
VERSION_NUM="${VERSION_BASE}-${BUILD_DATE}"
fi
VERSION_WITH_V="v${VERSION_NUM}"
echo "VERSION_NUM=${VERSION_NUM}" >> $GITHUB_OUTPUT
echo "VERSION_WITH_V=${VERSION_WITH_V}" >> $GITHUB_OUTPUT
echo "VERSION_BASE=${VERSION_BASE}" >> $GITHUB_OUTPUT
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_OUTPUT
echo "BUILD_DATE_ISO=${BUILD_DATE_ISO}" >> $GITHUB_OUTPUT
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_OUTPUT
- name: Prepare release notes - name: Prepare release notes
run: | run: |
cat > "$RUNNER_TEMP/release-notes.md" << EOF cat > "$RUNNER_TEMP/release-notes.md" << EOF
@@ -724,18 +578,18 @@ jobs:
- name: Rename artifacts - name: Rename artifacts
run: | run: |
mkdir -p release mkdir -p release
cp artifacts/crossdesk-macos-x64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-macos-x64-${{ steps.version.outputs.VERSION_WITH_V }}.pkg cp artifacts/crossdesk-macos-x64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-macos-x64-${{ needs.prepare-version.outputs.version }}.pkg
cp artifacts/crossdesk-macos-arm64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-macos-arm64-${{ steps.version.outputs.VERSION_WITH_V }}.pkg cp artifacts/crossdesk-macos-arm64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-macos-arm64-${{ needs.prepare-version.outputs.version }}.pkg
for ARCH in amd64 arm64; do for ARCH in amd64 arm64; do
for PACKAGE in crossdesk crossdesk-virtual-desktop-xfce; do for PACKAGE in crossdesk crossdesk-virtual-desktop-xfce; do
FILE="${PACKAGE}-linux-${ARCH}-${{ steps.version.outputs.VERSION_WITH_V }}.deb" FILE="${PACKAGE}-linux-${ARCH}-${{ needs.prepare-version.outputs.version }}.deb"
cp "artifacts/crossdesk-linux-${ARCH}-${{ steps.version.outputs.VERSION_WITH_V }}/${FILE}" "release/${FILE}" cp "artifacts/crossdesk-linux-${ARCH}-${{ needs.prepare-version.outputs.version }}/${FILE}" "release/${FILE}"
done done
done done
cp artifacts/crossdesk-ios-arm64-unsigned-${{ needs.build-ios.outputs.version }}/* release/crossdesk-ios-arm64-unsigned-${{ needs.build-ios.outputs.version }}.zip cp artifacts/crossdesk-ios-arm64-unsigned-${{ needs.build-ios.outputs.version }}/* release/crossdesk-ios-arm64-unsigned-${{ needs.build-ios.outputs.version }}.zip
cp artifacts/crossdesk-android-arm64-${{ needs.build-android.outputs.version }}/*.apk release/crossdesk-android-arm64-${{ needs.build-android.outputs.version }}.apk cp artifacts/crossdesk-android-arm64-${{ needs.build-android.outputs.version }}/*.apk release/crossdesk-android-arm64-${{ needs.build-android.outputs.version }}.apk
cp artifacts/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}.exe cp artifacts/crossdesk-win-x64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-win-x64-${{ needs.prepare-version.outputs.version }}.exe
cp artifacts/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}.zip cp artifacts/crossdesk-win-x64-portable-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-win-x64-portable-${{ needs.prepare-version.outputs.version }}.zip
- name: List release files - name: List release files
run: ls -lh release/ run: ls -lh release/
@@ -743,10 +597,10 @@ jobs:
- name: Upload to Versioned GitHub Release - name: Upload to Versioned GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ steps.version.outputs.VERSION_WITH_V }} tag_name: ${{ needs.prepare-version.outputs.version }}
# The pushed tag already determines the release source commit. # The pushed tag already determines the release source commit.
target_commitish: ${{ github.event.repository.default_branch }} target_commitish: ${{ github.event.repository.default_branch }}
name: Release ${{ steps.version.outputs.VERSION_WITH_V }} name: Release ${{ needs.prepare-version.outputs.version }}
draft: false draft: false
prerelease: false prerelease: false
files: release/* files: release/*
@@ -789,7 +643,7 @@ jobs:
- name: Generate version.json for all platforms - name: Generate version.json for all platforms
env: env:
RELEASE_TAG: ${{ steps.version.outputs.VERSION_WITH_V }} RELEASE_TAG: ${{ needs.prepare-version.outputs.version }}
CROSSDESK_UPDATE_PRIVATE_KEY: ${{ secrets.CROSSDESK_UPDATE_PRIVATE_KEY }} CROSSDESK_UPDATE_PRIVATE_KEY: ${{ secrets.CROSSDESK_UPDATE_PRIVATE_KEY }}
run: | run: |
python3 .github/scripts/update_version_manifest.py \ python3 .github/scripts/update_version_manifest.py \
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity version="1.0.0.0" name="CrossDesk.SessionHelper" type="win32" />
<description>CrossDesk Session Helper</description>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>
@@ -4,13 +4,10 @@ IDI_ICON1 ICON "crossdesk.ico"
#define CREATEPROCESS_MANIFEST_RESOURCE_ID 1 #define CREATEPROCESS_MANIFEST_RESOURCE_ID 1
#define RT_MANIFEST 24 #define RT_MANIFEST 24
#ifdef CROSSDESK_PORTABLE // The service supplies either the user's token (desktop observer) or a SYSTEM
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_portable.manifest" // token (secure capture/input). Requiring elevation here prevents the observer
#elif defined(CROSSDESK_DEBUG) // from starting with CreateProcessAsUserW in installed release builds (740).
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_debug.manifest" CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_session_helper.manifest"
#else
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk.manifest"
#endif
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Session Helper" #define CROSSDESK_FILE_DESCRIPTION "CrossDesk Session Helper"
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_session_helper.exe" #define CROSSDESK_ORIGINAL_FILENAME "crossdesk_session_helper.exe"
+11
View File
@@ -20,6 +20,7 @@
#include "platform/windows/gui/slint_backend.h" #include "platform/windows/gui/slint_backend.h"
#include "service_host.h" #include "service_host.h"
#include "service_lifetime.h" #include "service_lifetime.h"
#include "shutdown_order.h"
#include "unattended_config.h" #include "unattended_config.h"
#endif #endif
@@ -406,6 +407,16 @@ static int RunApplication(
} }
int main(int argc, char* argv[]) { int main(int argc, char* argv[]) {
#ifdef _WIN32
// Apply in every process, including the daemon and --child. Windows does
// not inherit shutdown priority from a parent process.
const DWORD shutdown_error = crossdesk::ConfigureWindowsShutdownOrder(
crossdesk::WindowsShutdownRole::host);
if (shutdown_error != ERROR_SUCCESS) {
std::cerr << "Could not configure Windows host shutdown order, error="
<< shutdown_error << '\n';
}
#endif
#ifdef __linux__ #ifdef __linux__
// Run before GUI, configuration, logging or display initialization. // Run before GUI, configuration, logging or display initialization.
if (argc == 2 && if (argc == 2 &&
@@ -426,9 +426,8 @@ ConfigCenter::VIDEO_ENCODE_FORMAT ConfigCenter::GetVideoEncodeFormat() const {
} }
bool ConfigCenter::IsHardwareVideoCodecAvailable() { bool ConfigCenter::IsHardwareVideoCodecAvailable() {
#if (((defined(_WIN32) || defined(__linux__)) && !defined(__aarch64__) && \ #if defined(_WIN32) || defined(__APPLE__) || \
!defined(__arm__) && USE_CUDA) || \ (defined(__linux__) && !defined(__aarch64__) && !defined(__arm__) && USE_CUDA)
defined(__APPLE__))
return true; return true;
#else #else
return false; return false;
@@ -36,6 +36,7 @@
#include "crossdesk_ui.h" #include "crossdesk_ui.h"
#include "fa_solid_900.h" #include "fa_solid_900.h"
#include "features/about/about_catalog.h"
#include "features/file_transfer/transfer_notification_layout.h" #include "features/file_transfer/transfer_notification_layout.h"
#include "features/gallery/gallery_fullscreen_transition.h" #include "features/gallery/gallery_fullscreen_transition.h"
#include "features/gallery/gallery_window_state.h" #include "features/gallery/gallery_window_state.h"
@@ -55,6 +56,7 @@
#include <windows.h> #include <windows.h>
#include "platform/windows/gui/slint_backend.h" #include "platform/windows/gui/slint_backend.h"
#include "platform/windows/gui/shutdown_session_guard.h"
#include "platform/windows/gui/tray/win_tray.h" #include "platform/windows/gui/tray/win_tray.h"
#include "platform/windows/gui/window_icons.h" #include "platform/windows/gui/window_icons.h"
#elif defined(__APPLE__) #elif defined(__APPLE__)
@@ -922,6 +924,10 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
} // namespace } // namespace
struct GuiApplication::SlintUi { struct GuiApplication::SlintUi {
#if _WIN32
std::unique_ptr<ShutdownSessionGuard> shutdown_guard;
std::wstring_view shutdown_reason;
#endif
gallery::Workspace gallery; gallery::Workspace gallery;
std::filesystem::path gallery_path; std::filesystem::path gallery_path;
std::vector<std::string> gallery_ids; std::vector<std::string> gallery_ids;
@@ -1283,6 +1289,10 @@ void GuiApplication::InitializeUi() {
slint::set_xdg_app_id("crossdesk"); slint::set_xdg_app_id("crossdesk");
#endif #endif
ui_ = std::make_unique<SlintUi>(); ui_ = std::make_unique<SlintUi>();
#if _WIN32
ui_->shutdown_guard =
std::make_unique<ShutdownSessionGuard>(windows_rdp_handoff_connected_);
#endif
ui_->sponsorship_prompt_pending = ui_->sponsorship_prompt_pending =
config_center_->GetSponsorshipPromptDismissedVersion() != CROSSDESK_VERSION; config_center_->GetSponsorshipPromptDismissedVersion() != CROSSDESK_VERSION;
ui_->video_presenter = ui_->video_presenter =
@@ -1785,6 +1795,11 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
window->set_update_install_busy(updater_->busy()); window->set_update_install_busy(updater_->busy());
#endif #endif
}); });
about::Populate(ui_->Window(gallery_window));
main->on_open_about_link([this](slint::SharedString url) {
const std::string link(url);
if (link.starts_with("https://")) OpenUrl(link);
});
main->on_open_website([this] { OpenUrl("https://crossdesk.cn"); }); main->on_open_website([this] { OpenUrl("https://crossdesk.cn"); });
main->on_open_afdian_sponsor([this] { main->on_open_afdian_sponsor([this] {
OpenUrl("https://afdian.com/a/kunkundi"); OpenUrl("https://afdian.com/a/kunkundi");
@@ -2252,6 +2267,11 @@ void GuiApplication::Tick() {
slint::quit_event_loop(); slint::quit_event_loop();
return; return;
} }
#if _WIN32
if (ui_->shutdown_guard) {
ui_->shutdown_guard->Update(ui_->shutdown_reason);
}
#endif
PollUpdateCheck(); PollUpdateCheck();
HandleSessionCleanup(); HandleSessionCleanup();
HandlePasswordChangeResult(); HandlePasswordChangeResult();
@@ -2809,6 +2829,15 @@ void GuiApplication::UpdateLocalization() {
if (!ui_ || ui_->localized_language == language) { if (!ui_ || ui_->localized_language == language) {
return; return;
} }
#if _WIN32
// All views refer to static literals; selecting the text needs no allocation
// and only runs when the language changes. Tick still checks connection state.
ui_->shutdown_reason = language == 0
? L"远程控制连接中,保留连接以确认或取消关机。"
: language == 2
? L"Удалённый сеанс активен. Соединение сохранено для подтверждения или отмены выключения."
: L"Remote session active. Keeping the connection available to confirm or cancel shutdown.";
#endif
ui_localization::ApplyMainWindowStrings(ui_->main, language); ui_localization::ApplyMainWindowStrings(ui_->main, language);
ui_localization::ApplyStreamWindowStrings(ui_->main, language); ui_localization::ApplyStreamWindowStrings(ui_->main, language);
ui_localization::ApplyMainWindowStrings(ui_->wall, language); ui_localization::ApplyMainWindowStrings(ui_->wall, language);
@@ -6185,6 +6214,9 @@ void GuiApplication::Cleanup() {
return; return;
} }
ui_->file_dialog_lifetime.reset(); ui_->file_dialog_lifetime.reset();
#if _WIN32
ui_->shutdown_guard.reset();
#endif
#if defined(__APPLE__) #if defined(__APPLE__)
CancelNativeFileDialog(); CancelNativeFileDialog();
#endif #endif
@@ -0,0 +1,115 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-21
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#include "about_catalog.h"
#include <nlohmann/json.hpp>
#include <iterator>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
#include "about_resources.h"
namespace crossdesk::about {
namespace {
slint::SharedString Text(const nlohmann::json& object, const char* key) {
return slint::SharedString(object.value(key, std::string{}));
}
std::string_view TrimLayoutWhitespace(std::string_view line) {
const auto first = line.find_first_not_of(" \t\r");
if (first == std::string_view::npos) return {};
return line.substr(first, line.find_last_not_of(" \t\r") - first + 1);
}
slint::SharedString DocumentText(const nlohmann::json& document) {
const auto title = document.value("title", std::string{});
const auto text = document.value("text", std::string{});
if (title != "GNU GPL v3" && title != "GNU LGPL v3") {
return slint::SharedString(text);
}
// GNU's plain-text copies use fixed-width wrapping and space-aligned headings.
// Reflow prose for the reader, retaining paragraph boundaries, notice examples,
// and every non-whitespace character. The embedded source stays untouched.
std::string formatted;
std::vector<std::string_view> paragraph;
auto append_paragraph = [&] {
if (paragraph.empty()) return;
if (!formatted.empty()) formatted += "\n\n";
const bool preserve_lines = paragraph.front().starts_with("GNU ") ||
paragraph.front().starts_with("<");
for (size_t index = 0; index < paragraph.size(); ++index) {
if (index > 0) {
const bool after_copyright =
paragraph[index - 1].starts_with("Copyright (C)");
formatted += preserve_lines || after_copyright ? '\n' : ' ';
}
formatted += paragraph[index];
}
paragraph.clear();
};
std::string_view remaining(text);
while (!remaining.empty()) {
const auto end = remaining.find('\n');
const auto line = TrimLayoutWhitespace(remaining.substr(0, end));
if (line.empty()) {
append_paragraph();
} else {
paragraph.push_back(line);
}
if (end == std::string_view::npos) break;
remaining.remove_prefix(end + 1);
}
append_paragraph();
return slint::SharedString(formatted);
}
} // namespace
void Populate(const slint::ComponentHandle<ui::MainWindow>& window) {
// Both the main window and DeskHub share the immutable, embedded catalog.
static const auto catalog = nlohmann::json::parse(
std::begin(kAboutResources), std::end(kAboutResources));
static const auto model = [] {
std::vector<ui::AboutComponent> components;
for (const auto& item : catalog.at("components")) {
ui::AboutComponent component;
component.id = Text(item, "id");
component.name = Text(item, "name");
component.version = Text(item, "version");
component.license = Text(item, "license");
component.source_url = Text(item, "sourceURL");
std::vector<ui::AboutDocument> documents;
for (const auto& document : item.at("documents")) {
ui::AboutDocument entry;
entry.title = Text(document, "title");
entry.text = DocumentText(document);
documents.push_back(std::move(entry));
}
component.documents =
std::make_shared<slint::VectorModel<ui::AboutDocument>>(documents);
components.push_back(std::move(component));
}
return std::make_shared<slint::VectorModel<ui::AboutComponent>>(components);
}();
window->set_about_components(model);
for (size_t index = 0; index < model->row_count(); ++index) {
if (model->row_data(index)->id == "openfec") {
window->set_about_openfec_index(static_cast<int>(index));
break;
}
}
window->set_about_revision(Text(catalog, "revision"));
window->set_about_source_url(Text(catalog, "sourceURL"));
window->set_about_build_url(Text(catalog, "buildURL"));
}
} // namespace crossdesk::about
@@ -0,0 +1,18 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-21
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _ABOUT_CATALOG_H_
#define _ABOUT_CATALOG_H_
#include "crossdesk_ui.h"
namespace crossdesk::about {
void Populate(const slint::ComponentHandle<ui::MainWindow>& window);
} // namespace crossdesk::about
#endif
+235
View File
@@ -0,0 +1,235 @@
import { ScrollView, TextEdit } from "std-widgets.slint";
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
import { WorkbenchStyle } from "workbench_controls.slint";
import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint";
import { FontAwesomeIcons } from "common.slint";
import "../assets/fonts/message_spacing.ttf";
export enum AboutPage { overview, components, component, document, source }
export struct AboutDocument { title: string, text: string }
export struct AboutComponent {
id: string,
name: string,
version: string,
license: string,
source-url: string,
documents: [AboutDocument],
}
export global AboutStrings {
out property <string> software-license: GalleryStrings.tr("软件许可", "Software license", "Лицензия программы");
out property <string> components: GalleryStrings.tr("开源组件", "Open-source components", "Компоненты с открытым кодом");
out property <string> source: GalleryStrings.tr("源码与构建说明", "Source code and build instructions", "Исходный код и сборка");
out property <string> notices: GalleryStrings.tr("许可与版权声明", "Licenses and copyright notices", "Лицензии и авторские права");
out property <string> view-source: GalleryStrings.tr("查看源码", "View source code", "Открыть исходный код");
out property <string> back: GalleryStrings.tr("返回", "Back", "Назад");
}
export component AboutNavigationGroup inherits Rectangle {
height: rows.preferred-height + 2px;
background: WorkbenchStyle.surface;
border-radius: UiRadius.control;
border-width: 1px; border-color: WorkbenchStyle.separator;
clip: true;
rows := VerticalLayout {
x: 1px; y: 1px; width: parent.width - 2px;
height: self.preferred-height; padding: 0px; spacing: 0px;
@children
}
}
export component AboutRowSeparator inherits Rectangle {
height: 1px;
Rectangle {
x: 14px; width: parent.width - 30px; height: 1px;
background: WorkbenchStyle.separator;
}
}
export component AboutNavigationRow inherits Rectangle {
in property <string> title;
in property <string> subtitle;
in property <string> icon;
in property <string> detail;
in property <bool> grouped: false;
in property <bool> external: false;
callback clicked;
min-width: 0px;
min-height: max(44px, labels.preferred-height + 20px);
background: touch.pressed ? WorkbenchStyle.pressed-surface
: touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
border-radius: root.grouped ? 0px : UiRadius.control;
border-width: root.grouped ? 0px : 1px; border-color: WorkbenchStyle.separator;
accessible-role: button;
accessible-label: root.title + (root.subtitle != "" ? ", " + root.subtitle : "")
+ (root.detail != "" ? ", " + root.detail : "");
accessible-action-default => { root.clicked(); }
if root.icon != "": Rectangle {
x: 12px; y: (parent.height - self.height) / 2;
width: 28px; height: 28px;
border-radius: UiRadius.control;
background: WorkbenchStyle.field-surface;
Text {
text: root.icon; font-family: "Font Awesome 6 Free"; font-weight: 900;
font-size: 13px; color: WorkbenchStyle.muted;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
}
labels := VerticalLayout {
x: root.icon != "" ? 52px : 14px;
y: (parent.height - self.height) / 2;
width: parent.width - self.x - 36px - (root.detail != "" ? detail-label.width + 12px : 0px);
height: self.preferred-height; spacing: 4px;
Text { text: root.title; color: WorkbenchStyle.text; font-size: 13px; wrap: word-wrap; }
if root.subtitle != "": Text {
text: root.subtitle; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap;
}
}
detail-label := Text {
x: parent.width - self.width - 36px; height: parent.height;
width: min(100px, self.preferred-width);
text: root.detail; color: WorkbenchStyle.muted; font-size: 11px;
vertical-alignment: center; overflow: elide;
accessible-role: none;
}
Text {
x: parent.width - 30px; width: 18px; height: parent.height;
text: root.external ? FontAwesomeIcons.arrow-up-right-from-square : FontAwesomeIcons.angle-right;
font-family: "Font Awesome 6 Free"; font-weight: 900;
color: touch.has-hover ? WorkbenchStyle.accent : WorkbenchStyle.muted;
font-size: 11px; vertical-alignment: center; horizontal-alignment: center;
accessible-role: none;
}
touch := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } }
}
export component AboutPanel inherits SettingsDialog {
show-close-button: false;
in-out property <AboutPage> page: AboutPage.components;
in-out property <int> component-index: 0;
private property <int> document-index: 0;
in property <[AboutComponent]> components;
in property <int> openfec-index: -1;
in property <string> revision;
in property <string> source-url;
in property <string> build-url;
private property <AboutComponent> selected: root.components[root.component-index];
callback back;
callback open-link(string);
title: root.page == AboutPage.components ? AboutStrings.components
: root.page == AboutPage.source ? AboutStrings.source
: root.page == AboutPage.document ? AboutStrings.notices
: root.selected.name;
if root.page != AboutPage.document: ScrollView {
x: 24px; y: root.content-top; width: parent.width - 48px + 22px;
height: footer.y - self.y - 16px;
viewport-width: self.visible-width;
viewport-height: content.preferred-height;
horizontal-scrollbar-policy: always-off;
vertical-scrollbar-policy: as-needed;
content := VerticalLayout {
width: parent.width; height: self.preferred-height;
padding: 0px; padding-right: 22px; spacing: 10px;
if root.page == AboutPage.components: VerticalLayout {
padding: 0px; spacing: 10px;
Text {
text: GalleryStrings.tr("以下列出本版本的主要开源组件。各组件遵循各自的开源许可。", "Main open-source components used in this build. Each component is covered by its own license.", "Основные открытые компоненты этой сборки. Каждый распространяется по своей лицензии.");
color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap;
}
Rectangle {
height: notice.preferred-height + 32px;
background: WorkbenchStyle.field-surface; border-radius: UiRadius.control;
notice := VerticalLayout {
x: 16px; y: 16px; width: parent.width - 32px;
height: self.preferred-height; spacing: 12px;
Text { text: GalleryStrings.tr("OpenFEC 告知", "OpenFEC notice", "Уведомление OpenFEC"); color: WorkbenchStyle.text; font-size: 14px; font-weight: 600; }
Text {
text: GalleryStrings.tr("本应用使用 OpenFEC 提供前向纠错功能,遵循 CeCILL-C 1.0 许可。该许可规定了有限保证及责任限制,详情见许可全文。", "This application uses OpenFEC for forward error correction under CeCILL-C 1.0, which provides limited warranties and limits liability. See the full license for details.", "Приложение использует OpenFEC для прямого исправления ошибок по лицензии CeCILL-C 1.0 с ограниченной гарантией и ответственностью. Подробности — в тексте лицензии.");
color: WorkbenchStyle.muted; font-size: 13px; wrap: word-wrap;
font-family: "CrossDesk Message Spacing";
}
Text {
text: "(c) Copyright 2009 - 2012 INRIA - All rights reserved";
color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap;
font-family: "CrossDesk Message Spacing";
}
if root.openfec-index >= 0: AboutNavigationRow {
title: GalleryStrings.tr("OpenFEC 许可与源码", "OpenFEC license and source", "Лицензия и исходный код OpenFEC");
clicked => { root.component-index = root.openfec-index; root.page = AboutPage.component; }
}
}
}
for component[index] in root.components: VerticalLayout {
padding: 0px; spacing: 0px;
if component.id != "crossdesk" && component.id != "openfec": AboutNavigationRow {
title: component.name;
subtitle: component.version + " · " + component.license;
clicked => { root.component-index = index; root.page = AboutPage.component; }
}
}
}
if root.page == AboutPage.component: VerticalLayout {
padding: 0px; spacing: 12px;
Text { text: root.selected.version + " · " + root.selected.license; color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap; }
AboutNavigationRow {
title: AboutStrings.view-source; external: true;
clicked => { root.open-link(root.selected.source-url); }
}
Text { text: AboutStrings.notices; color: WorkbenchStyle.text; font-size: 13px; font-weight: 600; }
for document[index] in root.selected.documents: AboutNavigationRow {
title: document.title;
clicked => { root.document-index = index; root.page = AboutPage.document; }
}
if root.selected.documents.length == 0: Text {
text: GalleryStrings.tr("本组件未内置许可文本,请通过“查看源码”查阅许可与版权声明。", "License text is not bundled for this component. Use View source code to read its license and copyright notices.", "Текст лицензии не включён. Откройте исходный код для просмотра лицензии и авторских прав.");
color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap;
}
}
if root.page == AboutPage.source: VerticalLayout {
padding: 0px; spacing: 12px;
Text {
text: root.revision != "" ? GalleryStrings.tr("源码版本:", "Source revision: ", "Ревизия исходного кода: ") + root.revision
: GalleryStrings.tr("本版本源码信息暂不可用。", "Source revision is unavailable for this build.", "Ревизия исходного кода этой сборки недоступна.");
color: WorkbenchStyle.text; font-size: 13px; wrap: word-wrap;
}
AboutNavigationRow { title: AboutStrings.view-source; external: true; clicked => { root.open-link(root.source-url); } }
AboutNavigationRow {
title: GalleryStrings.tr("构建说明", "Build instructions", "Инструкции по сборке"); external: true;
clicked => { root.open-link(root.build-url); }
}
AboutNavigationRow {
title: GalleryStrings.tr("项目主页", "Project website", "Сайт проекта"); external: true;
clicked => { root.open-link("https://crossdesk.cn"); }
}
}
}
}
if root.page == AboutPage.document: VerticalLayout {
x: 24px; y: root.content-top; width: parent.width - 48px;
height: footer.y - self.y - 16px; spacing: 12px;
Text { text: root.selected.documents[root.document-index].title; color: WorkbenchStyle.text; font-size: 13px; font-weight: 600; wrap: word-wrap; }
TextEdit {
text: root.selected.documents[root.document-index].text;
read-only: true; wrap: word-wrap; font-size: 14px;
// Use the same 1.5em line spacing as other long-form reading views.
font-family: "CrossDesk Message Spacing";
vertical-stretch: 1;
key-pressed(event) => {
if event.text == Key.Escape { root.back(); return accept; }
return reject;
}
}
}
footer := VerticalLayout {
x: 24px; y: parent.height - self.height - 24px;
width: parent.width - 48px; height: self.preferred-height; spacing: 12px;
Rectangle { height: 1px; background: WorkbenchStyle.separator; }
HorizontalLayout {
alignment: end;
SettingsButton { text: AboutStrings.back; clicked => { root.back(); } }
}
}
}
+4
View File
@@ -32,9 +32,11 @@ export global FontAwesomeIcons {
out property <string> angle-right: "\u{f105}"; out property <string> angle-right: "\u{f105}";
out property <string> angle-up: "\u{f106}"; out property <string> angle-up: "\u{f106}";
out property <string> arrow-right-long: "\u{f178}"; out property <string> arrow-right-long: "\u{f178}";
out property <string> arrow-up-right-from-square: "\u{f35d}";
out property <string> bars: "\u{f0c9}"; out property <string> bars: "\u{f0c9}";
out property <string> check: "\u{f00c}"; out property <string> check: "\u{f00c}";
out property <string> circle-arrow-up: "\u{f0aa}"; out property <string> circle-arrow-up: "\u{f0aa}";
out property <string> code: "\u{f121}";
out property <string> compress: "\u{f066}"; out property <string> compress: "\u{f066}";
out property <string> computer-mouse: "\u{f8cc}"; out property <string> computer-mouse: "\u{f8cc}";
out property <string> copy: "\u{f0c5}"; out property <string> copy: "\u{f0c5}";
@@ -42,8 +44,10 @@ export global FontAwesomeIcons {
out property <string> expand: "\u{f065}"; out property <string> expand: "\u{f065}";
out property <string> eye: "\u{f06e}"; out property <string> eye: "\u{f06e}";
out property <string> eye-slash: "\u{f070}"; out property <string> eye-slash: "\u{f070}";
out property <string> file-lines: "\u{f15c}";
out property <string> folder-open: "\u{f07c}"; out property <string> folder-open: "\u{f07c}";
out property <string> keyboard: "\u{f11c}"; out property <string> keyboard: "\u{f11c}";
out property <string> layer-group: "\u{f5fd}";
out property <string> minus: "\u{f068}"; out property <string> minus: "\u{f068}";
out property <string> pen: "\u{f304}"; out property <string> pen: "\u{f304}";
out property <string> rotate: "\u{f2f1}"; out property <string> rotate: "\u{f2f1}";
@@ -1,6 +1,7 @@
// Single AOT entry point. Generating one header for the entire UI avoids // Single AOT entry point. Generating one header for the entire UI avoids
// duplicate imported-component definitions when C++ uses all three windows. // duplicate imported-component definitions when C++ uses all three windows.
export { AnnouncementItem, MessageStrings } from "message_panel.slint"; export { AnnouncementItem, MessageStrings } from "message_panel.slint";
export { AboutComponent, AboutDocument, AboutPage } from "about_panel.slint";
export { MainWindow, RecentConnection, UpdateCheckStatus, AutostartStatus, UiStrings } from "main_window.slint"; export { MainWindow, RecentConnection, UpdateCheckStatus, AutostartStatus, UiStrings } from "main_window.slint";
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint"; export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint"; export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint";
+96 -32
View File
@@ -1,3 +1,4 @@
import { AboutPanel, AboutPage, AboutComponent, AboutStrings, AboutNavigationRow, AboutNavigationGroup, AboutRowSeparator } from "about_panel.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { WorkbenchStyle, WorkbenchButton, WorkbenchCard, WorkbenchNotice } from "workbench_controls.slint"; import { WorkbenchStyle, WorkbenchButton, WorkbenchCard, WorkbenchNotice } from "workbench_controls.slint";
import { GalleryStrings } from "device_strings.slint"; import { GalleryStrings } from "device_strings.slint";
@@ -536,6 +537,20 @@ export component MainWindow inherits Window {
if !root.self-host-settings-open { root.self-host-settings-error = ""; } if !root.self-host-settings-open { root.self-host-settings-error = ""; }
} }
in-out property <bool> about-open: false; in-out property <bool> about-open: false;
in-out property <AboutPage> about-page: overview;
private property <length> about-scroll-y: 0px;
in-out property <int> about-component-index: 0;
in property <[AboutComponent]> about-components;
in property <int> about-openfec-index: -1;
in property <string> about-revision;
in property <string> about-source-url;
in property <string> about-build-url;
callback open-about-link(string);
function about-back() {
if root.about-page == AboutPage.document { root.about-page = AboutPage.component; }
else if root.about-page == AboutPage.component && root.about-component-index != 0 { root.about-page = AboutPage.components; }
else { root.about-page = AboutPage.overview; }
}
in-out property <bool> sponsorship-open: false; in-out property <bool> sponsorship-open: false;
// Automatic prompts must not interrupt another dialog or menu. // Automatic prompts must not interrupt another dialog or menu.
private property <bool> sponsorship-unobstructed: !root.gallery-mode private property <bool> sponsorship-unobstructed: !root.gallery-mode
@@ -627,6 +642,15 @@ export component MainWindow inherits Window {
changed about-open => { changed about-open => {
if root.about-open { root.dialog-focus-pending = true; } if root.about-open { root.dialog-focus-pending = true; }
else {
root.about-page = AboutPage.overview;
root.about-component-index = 0;
root.about-scroll-y = 0px;
}
}
changed about-page => {
// Restore dialog shortcuts after leaving the selectable license text.
if root.about-open { root.dialog-focus-pending = true; }
} }
changed sponsorship-open => { changed sponsorship-open => {
@@ -855,7 +879,10 @@ export component MainWindow inherits Window {
return accept; return accept;
} }
if root.about-open && !root.update-open { if root.about-open && !root.update-open {
if event.text == Key.Escape { root.about-open = false; } if event.text == Key.Escape {
if root.about-page != AboutPage.overview { root.about-back(); }
else { root.about-open = false; }
}
return accept; return accept;
} }
if root.sponsorship-open && root.sponsorship-unobstructed { if root.sponsorship-open && root.sponsorship-unobstructed {
@@ -1459,25 +1486,24 @@ export component MainWindow inherits Window {
text: root.current-version; text: root.current-version;
font-size: 11px; color: WorkbenchStyle.muted; vertical-alignment: center; font-size: 11px; color: WorkbenchStyle.muted; vertical-alignment: center;
} }
if !root.signal-connected && !root.signal-tls-error: Rectangle { if !root.signal-connected && !root.signal-tls-error: WorkbenchButton {
x: parent.width - self.width - 20px; x: parent.width - self.width - 20px;
y: 0px; y: (parent.height - self.height) / 2;
height: parent.height; width: max(64px, reconnect-label-metrics.preferred-width + 22px);
width: reconnect-label.preferred-width; height: 22px;
reconnect-label := Text { text: UiStrings.signal-reconnect;
text: UiStrings.signal-reconnect; text-size: 11px;
color: WorkbenchStyle.accent; text-color: WorkbenchStyle.accent;
font-size: 11px; clicked => {
horizontal-alignment: center; root.copy-feedback = CopyFeedback.idle;
vertical-alignment: center; root.signal-reconnect-requested();
} }
TouchArea { reconnect-label-metrics := Text {
mouse-cursor: pointer; visible: false;
clicked => { text: UiStrings.signal-reconnect;
root.copy-feedback = CopyFeedback.idle; font-size: 11px;
root.signal-reconnect-requested(); font-weight: 500;
} }
}
} }
} }
} }
@@ -2365,17 +2391,36 @@ export component MainWindow inherits Window {
} }
if root.about-open: Rectangle { if root.about-open: Rectangle {
// Use the same bounds throughout the About navigation stack.
private property <length> dialog-width: min(480px, self.width - 40px);
private property <length> dialog-height: min(520px, self.height - 32px);
background: #15223850; background: #15223850;
z: 46; z: 46;
TouchArea { clicked => { focus-sink.focus(); } } TouchArea { clicked => { focus-sink.focus(); } }
SettingsDialog { if root.about-page != AboutPage.overview: AboutPanel {
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
width: min(480px, parent.width - 40px); width: parent.dialog-width;
min-height: 0px; max-height: parent.height - 32px;
height: parent.dialog-height;
page <=> root.about-page;
component-index <=> root.about-component-index;
components: root.about-components;
openfec-index: root.about-openfec-index;
revision: root.about-revision;
source-url: root.about-source-url;
build-url: root.about-build-url;
back => { root.about-back(); }
open-link(url) => { root.open-about-link(url); }
}
if root.about-page == AboutPage.overview: SettingsDialog {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: parent.dialog-width;
// The overlay excludes any custom titlebar on every platform. // The overlay excludes any custom titlebar on every platform.
min-height: 0px; max-height: parent.height - 32px; min-height: 0px; max-height: parent.height - 32px;
// Keep the dialog stable as update status and release details change. // Keep the dialog stable as update status and release details change.
height: min(488px, parent.height - 32px); height: parent.dialog-height;
title: UiStrings.about; title: UiStrings.about;
close-label: MessageStrings.close; close-label: MessageStrings.close;
close => { root.about-open = false; } close => { root.about-open = false; }
@@ -2388,6 +2433,8 @@ export component MainWindow inherits Window {
height: about-footer.y - self.y - 16px; height: about-footer.y - self.y - 16px;
viewport-width: self.visible-width; viewport-width: self.visible-width;
viewport-height: about-content.preferred-height; viewport-height: about-content.preferred-height;
// Keep the position when navigation recreates the overview.
viewport-y <=> root.about-scroll-y;
horizontal-scrollbar-policy: always-off; horizontal-scrollbar-policy: always-off;
vertical-scrollbar-policy: as-needed; vertical-scrollbar-policy: as-needed;
about-content := VerticalLayout { about-content := VerticalLayout {
@@ -2465,6 +2512,26 @@ export component MainWindow inherits Window {
Text { text: UiStrings.copyright; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap; } Text { text: UiStrings.copyright; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap; }
Text { text: UiStrings.license; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap; } Text { text: UiStrings.license; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap; }
} }
AboutNavigationGroup {
AboutNavigationRow {
grouped: true;
title: AboutStrings.software-license;
detail: "GPL v3";
clicked => { root.about-component-index = 0; root.about-page = AboutPage.component; }
}
AboutRowSeparator { }
AboutNavigationRow {
grouped: true;
title: AboutStrings.components;
clicked => { root.about-page = AboutPage.components; }
}
AboutRowSeparator { }
AboutNavigationRow {
grouped: true;
title: AboutStrings.source;
clicked => { root.about-page = AboutPage.source; }
}
}
} }
} }
about-footer := VerticalLayout { about-footer := VerticalLayout {
@@ -2472,17 +2539,14 @@ export component MainWindow inherits Window {
width: parent.width - 48px; height: self.preferred-height; width: parent.width - 48px; height: self.preferred-height;
spacing: 16px; spacing: 16px;
Rectangle { height: 1px; background: WorkbenchStyle.separator; } Rectangle { height: 1px; background: WorkbenchStyle.separator; }
if root.update-status-text != "": Text {
text: root.update-status-text;
color: root.update-check-status == UpdateCheckStatus.failed ? #b3261e
: root.update-check-status == UpdateCheckStatus.up-to-date ? WorkbenchStyle.success : WorkbenchStyle.muted;
font-size: 12px; wrap: word-wrap;
}
HorizontalLayout { HorizontalLayout {
alignment: end; spacing: 8px; alignment: end; spacing: 8px;
// Status updates use the spare footer space without reflowing the content.
Text {
min-width: 0px; height: 32px; horizontal-stretch: 1;
text: root.update-status-text;
color: root.update-check-status == UpdateCheckStatus.failed ? #b3261e
: root.update-check-status == UpdateCheckStatus.up-to-date ? WorkbenchStyle.success : WorkbenchStyle.muted;
font-size: 12px; wrap: word-wrap; overflow: elide;
vertical-alignment: center;
}
if root.update-available: WorkbenchButton { if root.update-available: WorkbenchButton {
width: max(80px, about-update-button-metrics.preferred-width + 24px); width: max(80px, about-update-button-metrics.preferred-width + 24px);
height: 32px; height: 32px;
@@ -0,0 +1,121 @@
#include "shutdown_session_guard.h"
#include <utility>
#include "rd_log.h"
namespace crossdesk {
namespace {
constexpr wchar_t kShutdownWindowClass[] = L"CrossDesk.ShutdownSessionGuard";
}
ShutdownSessionGuard::ShutdownSessionGuard(
std::shared_ptr<std::atomic<bool>> connected)
: connected_(std::move(connected)) {}
ShutdownSessionGuard::~ShutdownSessionGuard() {
ClearReason();
if (window_) DestroyWindow(window_);
}
bool ShutdownSessionGuard::ReportError(const char* operation) {
const DWORD error = GetLastError();
const ULONGLONG now = GetTickCount64();
if (last_error_tick_ == 0 || now - last_error_tick_ >= 5000) {
LOG_WARN("Windows shutdown session guard {} failed, error={}", operation, error);
last_error_tick_ = now;
}
SetLastError(error);
return false;
}
bool ShutdownSessionGuard::Start() {
if (window_) return true;
WNDCLASSW klass{};
klass.lpfnWndProc = WindowProc;
klass.hInstance = GetModuleHandleW(nullptr);
klass.lpszClassName = kShutdownWindowClass;
if (!RegisterClassW(&klass) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS)
return ReportError("register window");
// A top-level window receives session-end broadcasts even when the Slint
// windows are hidden in the tray. HWND_MESSAGE does not receive them.
window_ = CreateWindowExW(WS_EX_TOOLWINDOW, kShutdownWindowClass, L"CrossDesk",
WS_OVERLAPPED, 0, 0, 0, 0, nullptr, nullptr, klass.hInstance, this);
if (!window_) return ReportError("create window");
return true;
}
bool ShutdownSessionGuard::ClearReason() {
if (!reason_registered_) return true;
if (window_ && !ShutdownBlockReasonDestroy(window_))
return ReportError("clear reason");
reason_registered_ = false;
LOG_INFO("Windows shutdown session guard released");
return true;
}
bool ShutdownSessionGuard::SyncReason() {
if (shutdown_allowed_ || !connected_ ||
!connected_->load(std::memory_order_relaxed)) {
return ClearReason();
}
if (reason_registered_) return true;
if (!ShutdownBlockReasonCreate(window_, reason_.c_str()))
return ReportError("register reason");
reason_registered_ = true;
LOG_INFO("Windows shutdown session guard armed for connected controller");
return true;
}
bool ShutdownSessionGuard::Update(std::wstring_view reason) {
if (!Start()) return false;
if (std::wstring_view(reason_) != reason) {
// Keep the old registration and text on failure so the next tick retries
// removal before registering the newly selected language.
if (!ClearReason()) return false;
reason_.assign(reason);
}
return SyncReason();
}
LRESULT CALLBACK ShutdownSessionGuard::WindowProc(
HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
if (message == WM_NCCREATE) {
const auto* create = reinterpret_cast<CREATESTRUCTW*>(lparam);
auto* guard = static_cast<ShutdownSessionGuard*>(create->lpCreateParams);
guard->window_ = window;
SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(guard));
}
auto* guard = reinterpret_cast<ShutdownSessionGuard*>(
GetWindowLongPtrW(window, GWLP_USERDATA));
if (guard && message == WM_QUERYENDSESSION) {
const auto flags = static_cast<DWORD>(lparam);
// "Shut down anyway", logoff and Restart Manager must remain available.
if ((flags & (ENDSESSION_CRITICAL | ENDSESSION_LOGOFF | ENDSESSION_CLOSEAPP)) != 0)
guard->shutdown_allowed_ = true;
guard->SyncReason();
const bool retain = !guard->shutdown_allowed_ && guard->reason_registered_ &&
guard->connected_ && guard->connected_->load(std::memory_order_relaxed);
LOG_INFO("Windows shutdown query: flags=0x{:x}, retain_remote_session={}", flags, retain);
return retain ? FALSE : TRUE;
}
if (guard && message == WM_ENDSESSION) {
LOG_INFO("Windows session end: confirmed={}, flags=0x{:x}",
wparam != FALSE, static_cast<DWORD>(lparam));
guard->shutdown_allowed_ = wparam != FALSE;
guard->SyncReason();
// A cancelled shutdown must leave transport, capture and input running.
return 0;
}
if (guard && message == WM_NCDESTROY) {
guard->ClearReason();
// Destruction removes the native registration even if explicit removal
// failed. A later window must not inherit the destroyed window's state.
guard->reason_registered_ = false;
guard->window_ = nullptr;
SetWindowLongPtrW(window, GWLP_USERDATA, 0);
}
return DefWindowProcW(window, message, wparam, lparam);
}
} // namespace crossdesk
@@ -0,0 +1,48 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SHUTDOWN_SESSION_GUARD_H_
#define _SHUTDOWN_SESSION_GUARD_H_
#include <Windows.h>
#include <atomic>
#include <memory>
#include <string>
#include <string_view>
namespace crossdesk {
// Owned and pumped by the UI thread. The transport may revoke the shared
// connection flag from another thread, including between UI ticks.
class ShutdownSessionGuard {
public:
explicit ShutdownSessionGuard(std::shared_ptr<std::atomic<bool>> connected);
~ShutdownSessionGuard();
ShutdownSessionGuard(const ShutdownSessionGuard&) = delete;
ShutdownSessionGuard& operator=(const ShutdownSessionGuard&) = delete;
bool Update(std::wstring_view reason);
private:
bool Start();
bool SyncReason();
bool ClearReason();
bool ReportError(const char* operation);
static LRESULT CALLBACK WindowProc(HWND window, UINT message,
WPARAM wparam, LPARAM lparam);
std::shared_ptr<std::atomic<bool>> connected_;
HWND window_ = nullptr;
std::wstring reason_;
bool reason_registered_ = false;
bool shutdown_allowed_ = false;
ULONGLONG last_error_tick_ = 0;
};
} // namespace crossdesk
#endif
@@ -1,6 +1,6 @@
/* /*
* @Author: DI JUNKUN * @Author: DI JUNKUN
* @Date: 2026-09-07 * @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/ */
@@ -19,6 +19,15 @@ inline bool IsDesktopTransitionInputError(DWORD error) {
error == ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION; error == ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION;
} }
// Only call with an observation from the interactive session, never Session 0.
// Process presence (including LogonUI) cannot prove that Default is accessible:
// shutdown blocking UI may be active while WTS still reports an unlocked user.
inline bool IsSecureInputDesktop(bool available, const std::string& name,
DWORD error) {
if (!available) return error == ERROR_ACCESS_DENIED;
return !name.empty() && _stricmp(name.c_str(), "Default") != 0;
}
inline std::wstring GetDesktopNameW(HDESK desktop) { inline std::wstring GetDesktopNameW(HDESK desktop) {
DWORD bytes_needed = 0; DWORD bytes_needed = 0;
if (desktop == nullptr) { if (desktop == nullptr) {
@@ -32,6 +32,7 @@
#include "secure_desktop_frame_pacer.h" #include "secure_desktop_frame_pacer.h"
#include "secure_desktop_nv12_cache.h" #include "secure_desktop_nv12_cache.h"
#include "service_lifetime.h" #include "service_lifetime.h"
#include "shutdown_order.h"
#include "shared_capture_lifecycle.h" #include "shared_capture_lifecycle.h"
#include "shared_capture_configuration.h" #include "shared_capture_configuration.h"
#include "usbmmidd_policy.h" #include "usbmmidd_policy.h"
@@ -398,13 +399,8 @@ void UpdateHelperState(HelperState* helper_state) {
// Process enumeration can straddle an unlock. Observe the input desktop // Process enumeration can straddle an unlock. Observe the input desktop
// afterwards, and expose the sample's start time for service-side rejection. // afterwards, and expose the sample's start time for service-side rejection.
InputDesktopInfo desktop_info = GetInputDesktopInfo(); InputDesktopInfo desktop_info = GetInputDesktopInfo();
const bool input_desktop_is_winlogon = const bool secure_desktop_active = crossdesk::IsSecureInputDesktop(
_stricmp(desktop_info.name.c_str(), "Winlogon") == 0; desktop_info.available, desktop_info.name, desktop_info.error_code);
const bool inaccessible_secure_input_desktop =
!desktop_info.available &&
desktop_info.error_code == ERROR_ACCESS_DENIED && !logon_ui_visible;
const bool secure_desktop_active = input_desktop_is_winlogon ||
inaccessible_secure_input_desktop;
bool session_locked = false; bool session_locked = false;
if (!QuerySessionLockState(helper_state->session_id, &session_locked)) { if (!QuerySessionLockState(helper_state->session_id, &session_locked)) {
session_locked = session_locked =
@@ -2043,9 +2039,15 @@ void PrintUsage() {
} // namespace } // namespace
int main(int argc, char* argv[]) { int main(int argc, char* argv[]) {
const DWORD shutdown_error = crossdesk::ConfigureWindowsShutdownOrder(
crossdesk::WindowsShutdownRole::helper);
EnablePerMonitorDpiAwareness(); EnablePerMonitorDpiAwareness();
InitializeHelperLogger(); InitializeHelperLogger();
if (shutdown_error != ERROR_SUCCESS) {
LOG_WARN("Could not configure Windows helper shutdown order, error={}",
shutdown_error);
}
bool run_helper = false; bool run_helper = false;
bool run_secure_input_helper = false; bool run_secure_input_helper = false;
@@ -2158,6 +2160,8 @@ int main(int argc, char* argv[]) {
bool last_consent_ui = false; bool last_consent_ui = false;
bool last_secure_desktop = false; bool last_secure_desktop = false;
bool last_session_locked = false; bool last_session_locked = false;
bool last_input_desktop_available = false;
DWORD last_input_desktop_error = ERROR_SUCCESS;
std::string last_stage; std::string last_stage;
while (true) { while (true) {
UpdateHelperState(&helper_state); UpdateHelperState(&helper_state);
@@ -2165,6 +2169,7 @@ int main(int argc, char* argv[]) {
std::string desktop_name; std::string desktop_name;
bool session_locked = false; bool session_locked = false;
bool input_desktop_available = false; bool input_desktop_available = false;
DWORD input_desktop_error = ERROR_SUCCESS;
bool lock_app_visible = false; bool lock_app_visible = false;
bool logon_ui_running = false; bool logon_ui_running = false;
bool consent_ui_visible = false; bool consent_ui_visible = false;
@@ -2174,6 +2179,7 @@ int main(int argc, char* argv[]) {
desktop_name = helper_state.input_desktop_name; desktop_name = helper_state.input_desktop_name;
session_locked = helper_state.session_locked; session_locked = helper_state.session_locked;
input_desktop_available = helper_state.input_desktop_available; input_desktop_available = helper_state.input_desktop_available;
input_desktop_error = helper_state.input_desktop_error_code;
lock_app_visible = helper_state.lock_app_visible; lock_app_visible = helper_state.lock_app_visible;
logon_ui_running = helper_state.logon_ui_visible; logon_ui_running = helper_state.logon_ui_visible;
consent_ui_visible = helper_state.consent_ui_visible; consent_ui_visible = helper_state.consent_ui_visible;
@@ -2188,6 +2194,8 @@ int main(int argc, char* argv[]) {
lock_app_visible, credential_ui_visible, secure_desktop_active); lock_app_visible, credential_ui_visible, secure_desktop_active);
if (desktop_name != last_desktop_name || if (desktop_name != last_desktop_name ||
input_desktop_available != last_input_desktop_available ||
input_desktop_error != last_input_desktop_error ||
session_locked != last_session_locked || session_locked != last_session_locked ||
lock_app_visible != last_lock_app || lock_app_visible != last_lock_app ||
logon_ui_running != last_logon_ui || logon_ui_running != last_logon_ui ||
@@ -2195,11 +2203,15 @@ int main(int argc, char* argv[]) {
secure_desktop_active != last_secure_desktop || stage != last_stage) { secure_desktop_active != last_secure_desktop || stage != last_stage) {
LOG_INFO( LOG_INFO(
"Session helper state: session_id={}, input_desktop='{}', " "Session helper state: session_id={}, input_desktop='{}', "
"input_desktop_available={}, input_desktop_error={}, "
"session_locked={}, lock_app_visible={}, logon_ui_running={}, " "session_locked={}, lock_app_visible={}, logon_ui_running={}, "
"consent_ui_visible={}, secure_desktop_active={}, stage={}", "consent_ui_visible={}, secure_desktop_active={}, stage={}",
current_session_id, desktop_name, session_locked, lock_app_visible, current_session_id, desktop_name, input_desktop_available,
input_desktop_error, session_locked, lock_app_visible,
logon_ui_running, consent_ui_visible, secure_desktop_active, stage); logon_ui_running, consent_ui_visible, secure_desktop_active, stage);
last_desktop_name = desktop_name; last_desktop_name = desktop_name;
last_input_desktop_available = input_desktop_available;
last_input_desktop_error = input_desktop_error;
last_session_locked = session_locked; last_session_locked = session_locked;
last_lock_app = lock_app_visible; last_lock_app = lock_app_visible;
last_logon_ui = logon_ui_running; last_logon_ui = logon_ui_running;
@@ -0,0 +1,35 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SHUTDOWN_ORDER_H_
#define _SHUTDOWN_ORDER_H_
#include <Windows.h>
namespace crossdesk {
enum class WindowsShutdownRole { host, helper };
// Windows shuts processes down from high to low (the default is 0x280).
// Keep the remote host alive while ordinary applications ask the user to
// resolve a blocked shutdown. The helpers must outlive the host. Stay inside
// the application range; 0x000-0x0ff is reserved for the operating system.
inline constexpr DWORD WindowsShutdownLevel(WindowsShutdownRole role) {
return role == WindowsShutdownRole::host ? 0x101 : 0x100;
}
inline DWORD ConfigureWindowsShutdownOrder(WindowsShutdownRole role) {
// Change only the order. The GUI's session guard separately defers shutdown
// while a controller is connected; a confirmed shutdown still ends CrossDesk.
if (SetProcessShutdownParameters(WindowsShutdownLevel(role), 0))
return ERROR_SUCCESS;
const DWORD error = GetLastError();
return error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE;
}
} // namespace crossdesk
#endif
+3
View File
@@ -49,6 +49,9 @@ function setup_options_and_dependencies()
end end
add_requireconfs("**.python", {version = "3.12", override = true, configs = {pgo = false}}) add_requireconfs("**.python", {version = "3.12", override = true, configs = {pgo = false}})
-- Keep application and build-tool dependencies on the same OpenSSL release.
add_requireconfs("**.openssl", "**.openssl3",
{version = "3.3.2", system = false, override = true})
add_requires("spdlog 1.14.1", {system = false}) add_requires("spdlog 1.14.1", {system = false})
add_requires("slint 1.17.1", {configs = {shared = true}}) add_requires("slint 1.17.1", {configs = {shared = true}})
add_requires("libsdl3 3.2.26", {configs = {shared = false}}) add_requires("libsdl3 3.2.26", {configs = {shared = false}})
@@ -0,0 +1,134 @@
-- Embed notices so the About pages also work in offline/portable installations.
rule("crossdesk.about_resources")
after_load(function(target)
target:add("includedirs", path.join(target:autogendir(), "about"))
end)
before_build(function(target)
import("core.base.json")
import("core.project.config")
local root = os.projectdir()
local function read(relative)
return assert(io.readfile(path.join(root, relative)))
end
local function git(directory, args)
local result
try {function() result = os.iorunv("git", table.join({"-C", directory}, args)) end}
return result and result:trim() or ""
end
local repository = "https://github.com/kunkundi/crossdesk"
local revision = git(root, {"rev-parse", "HEAD"})
local source = repository .. (revision ~= "" and "/tree/" .. revision or "")
local mini_root = path.join(root, "deps/submodules/minirtc")
local mini_revision = git(mini_root, {"rev-parse", "HEAD"})
local mini_source = "https://github.com/kunkundi/minirtc" ..
(mini_revision ~= "" and "/tree/" .. mini_revision or "")
local catalog = json.decode(read("apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json"))
local known = {}
for _, component in ipairs(catalog.components) do known[component.id] = component end
-- These desktop dependencies are not part of the shared mobile catalog.
local desktop = {
slint = {"Slint", "https://github.com/slint-ui/slint/tree/v"},
libsdl3 = {"SDL", "https://github.com/libsdl-org/SDL/tree/release-"},
tinyfiledialogs = {"tinyfiledialogs", "https://sourceforge.net/projects/tinyfiledialogs/"},
["cpp-httplib"] = {"cpp-httplib", "https://github.com/yhirose/cpp-httplib/tree/v"},
openh264 = {"OpenH264", "https://github.com/cisco/openh264/tree/v"},
miniaudio = {"miniaudio", "https://github.com/mackron/miniaudio/tree/"},
aom = {"libaom", "https://aomedia.googlesource.com/aom/+/v"}
}
local components = {{
id = "crossdesk", name = "CrossDesk", version = config.get("CROSSDESK_VERSION") or "0.0.0",
license = "GPL-3.0-only", sourceURL = source,
documents = {{title = "GNU GPL v3", text = read("LICENSE")}}
}, {
id = "minirtc", name = "MiniRTC", version = mini_revision:sub(1, 12),
license = "LGPL-3.0-only", sourceURL = mini_source,
documents = {{title = "GNU LGPL v3", text = read("deps/submodules/minirtc/LICENSE")},
{title = "GNU GPL v3", text = read("LICENSE")}}
}}
local seen = {}
for _, dependency in ipairs(table.join({target}, target:orderdeps())) do
for _, package in ipairs(dependency:orderpkgs()) do
local name = package:name()
if not seen[name] then
seen[name] = true
local version = package:get("version") or ""
local shared = known[name]
local extra = desktop[name]
local item = {id = name, name = shared and shared.name or extra and extra[1] or name,
version = version, license = package:license() or "",
sourceURL = "https://github.com/xmake-io/xmake-repo/tree/master/packages/" ..
name:sub(1, 1) .. "/" .. name,
documents = json.mark_as_array({})}
if shared and shared.version:gsub("^v", "") == version:gsub("^v", "") then
item.license = shared.license
item.sourceURL = shared.sourceURL
item.documents = shared.documents
elseif extra then
item.sourceURL = extra[2] .. (name == "tinyfiledialogs" and "" or version:gsub("^v", ""))
end
-- Prefer installed notices when a package is absent from the shared catalog.
if #item.documents == 0 then
local install = package:installdir()
if install then
for _, file in ipairs(os.files(path.join(install, "share/licenses/**"))) do
local text = io.readfile(file)
if text and not text:find("\0", 1, true) then
table.insert(item.documents, {title = path.relative(file, install), text = text})
end
end
local notice_header = name == "tinyfiledialogs" and "include/tinyfiledialogs.h"
or name == "openh264" and "include/wels/codec_api.h"
if notice_header then
local header_path = path.join(install, notice_header)
local header = os.isfile(header_path) and io.readfile(header_path)
local notice = header and header:match("^/%*.-%*/")
if notice then
table.insert(item.documents, {title = notice_header, text = notice})
end
end
end
end
if name == "slint" then
table.insert(item.documents, {title = "GNU GPL v3", text = read("LICENSE")})
end
table.insert(components, item)
end
end
end
table.sort(components, function(a, b)
if a.id == b.id then return false end
if a.id == "crossdesk" then return true end
if b.id == "crossdesk" then return false end
return a.name:lower() < b.name:lower()
end)
local data = json.encode({components = components, revision = revision:sub(1, 12),
sourceURL = source,
buildURL = repository .. "/blob/" .. (revision ~= "" and revision or "main") .. "/docs/BUILD_EN.md"},
{pure = true, orderkeys = true})
-- A byte array avoids MSVC's limit on individual/concatenated string literals.
local bytes = {}
for index = 1, #data do
bytes[index] = tostring(data:byte(index)) .. (index % 32 == 0 and "\n" or "")
end
local output = [[/*
* @Author: DI JUNKUN
* @Date: 2026-10-21
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _ABOUT_RESOURCES_H_
#define _ABOUT_RESOURCES_H_
// Generated by the desktop build. Do not edit.
inline constexpr unsigned char kAboutResources[] = {
]] .. table.concat(bytes, ",") .. "\n};\n\n#endif\n"
local destination = path.join(target:autogendir(), "about", "about_resources.h")
if not os.isfile(destination) or io.readfile(destination) ~= output then
os.mkdir(path.directory(destination))
io.writefile(destination, output)
end
end)
rule_end()
+4 -5
View File
@@ -20,10 +20,6 @@ function setup_targets()
end end
table.insert(windows_resource_defines, table.insert(windows_resource_defines,
"CROSSDESK_VERSION_NUMERIC=" .. table.concat(version_parts, ",")) "CROSSDESK_VERSION_NUMERIC=" .. table.concat(version_parts, ","))
local windows_helper_resource_defines = table.join(windows_resource_defines)
if is_config("CROSSDESK_PORTABLE", true) then
table.insert(windows_helper_resource_defines, "CROSSDESK_PORTABLE=1")
end
local function copy_slint_runtime(target) local function copy_slint_runtime(target)
if not target:is_plat("windows") then if not target:is_plat("windows") then
@@ -560,6 +556,7 @@ function setup_targets()
target("gui") target("gui")
set_kind("object") set_kind("object")
add_rules("crossdesk.about_resources")
set_languages("c++20") set_languages("c++20")
-- spdlog 1.14 bundles fmt 10, whose consteval parser is rejected by -- spdlog 1.14 bundles fmt 10, whose consteval parser is rejected by
-- current Apple Clang in C++20 mode. Keep the established dependency -- current Apple Clang in C++20 mode. Keep the established dependency
@@ -585,6 +582,7 @@ function setup_targets()
add_files("apps/desktop/src/gui/render.cpp", "apps/desktop/src/gui/application/gui_application.cpp", add_files("apps/desktop/src/gui/render.cpp", "apps/desktop/src/gui/application/gui_application.cpp",
"apps/desktop/src/gui/rendering/*.cpp", "apps/desktop/src/gui/rendering/*.cpp",
"apps/desktop/src/gui/runtime/*.cpp", "apps/desktop/src/gui/runtime/*.cpp",
"apps/desktop/src/gui/features/about/*.cpp",
"apps/desktop/src/platform/common/gui/service_status_runtime.cpp", "apps/desktop/src/platform/common/gui/service_status_runtime.cpp",
"apps/desktop/src/gui/features/devices/*.cpp", "apps/desktop/src/gui/features/input/*.cpp", "apps/desktop/src/gui/features/devices/*.cpp", "apps/desktop/src/gui/features/input/*.cpp",
"apps/desktop/src/gui/features/clipboard/*.cpp", "apps/desktop/src/gui/features/file_transfer/*.cpp", "apps/desktop/src/gui/features/clipboard/*.cpp", "apps/desktop/src/gui/features/file_transfer/*.cpp",
@@ -605,6 +603,7 @@ function setup_targets()
add_files("apps/desktop/src/platform/windows/gui/tray/win_tray.cpp", add_files("apps/desktop/src/platform/windows/gui/tray/win_tray.cpp",
"apps/desktop/src/platform/windows/gui/slint_backend.cpp", "apps/desktop/src/platform/windows/gui/slint_backend.cpp",
"apps/desktop/src/platform/windows/gui/window_icons.cpp", "apps/desktop/src/platform/windows/gui/window_icons.cpp",
"apps/desktop/src/platform/windows/gui/shutdown_session_guard.cpp",
"apps/desktop/src/platform/windows/gui/slint_renderer_probe.cpp", "apps/desktop/src/platform/windows/gui/slint_renderer_probe.cpp",
"apps/desktop/src/platform/common/gui/opengl_video_renderer.cpp", "apps/desktop/src/platform/common/gui/opengl_video_renderer.cpp",
"apps/desktop/src/platform/common/gui/cuda_driver.cpp", "apps/desktop/src/platform/common/gui/cuda_driver.cpp",
@@ -679,7 +678,7 @@ function setup_targets()
add_files("apps/desktop/src/platform/windows/service/session_helper_main.cpp") add_files("apps/desktop/src/platform/windows/service/session_helper_main.cpp")
add_files("apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp") add_files("apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp")
add_files("apps/desktop/resources/windows/crossdesk_session_helper.rc", add_files("apps/desktop/resources/windows/crossdesk_session_helper.rc",
{defines = windows_helper_resource_defines}) {defines = windows_resource_defines})
add_includedirs("apps/desktop/src/common", add_includedirs("apps/desktop/src/common",
"apps/desktop/src/platform/windows/service", "apps/desktop/src/platform/windows/service",
"apps/desktop/src/platform/windows/screen_capturer", "apps/desktop/src/platform/windows/screen_capturer",
+53
View File
@@ -43,6 +43,58 @@ def write_json(path, value):
write(path, json.dumps(value, ensure_ascii=False, indent=2) + '\n') write(path, json.dumps(value, ensure_ascii=False, indent=2) + '\n')
def write_about_resources(output, version):
"""Supply the embedded catalog required by the shared desktop About UI."""
def git(directory, *args):
try:
return subprocess.check_output(['git', '-C', str(directory), *args],
text=True, stderr=subprocess.DEVNULL).strip()
except (OSError, subprocess.CalledProcessError):
return ''
revision = git(ROOT, 'rev-parse', 'HEAD')
mini = git(ROOT / 'deps/submodules/minirtc', 'rev-parse', 'HEAD')
repository = 'https://github.com/kunkundi/crossdesk'
source = repository + (f'/tree/{revision}' if revision else '')
gpl = {'title': 'GNU GPL v3', 'text': (ROOT / 'LICENSE').read_text()}
components = [
{'id': 'crossdesk', 'name': 'CrossDesk', 'version': version,
'license': 'GPL-3.0-only', 'sourceURL': source, 'documents': [gpl]},
{'id': 'minirtc', 'name': 'MiniRTC', 'version': mini[:12],
'license': 'LGPL-3.0-only',
'sourceURL': 'https://github.com/kunkundi/minirtc' + (f'/tree/{mini}' if mini else ''),
'documents': [{'title': 'GNU LGPL v3',
'text': (ROOT / 'deps/submodules/minirtc/LICENSE').read_text()}, gpl]},
{'id': 'slint', 'name': 'Slint', 'version': '1.17.1', 'license': 'GPL-3.0-only',
'sourceURL': 'https://github.com/slint-ui/slint/tree/v1.17.1',
'documents': [{'title': 'Slint license', 'text': (SOURCE / 'LICENSE.md').read_text()}, gpl]},
{'id': 'cpp-httplib', 'name': 'cpp-httplib', 'version': 'v0.26.0', 'license': 'MIT',
'sourceURL': 'https://github.com/yhirose/cpp-httplib/tree/v0.26.0', 'documents': []}]
shared = json.loads((ROOT / 'apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json').read_text())['components']
shared += json.loads((ROOT / 'apps/android/licenses/additional-notices.json').read_text())
seen = {item['id'] for item in components}
for item in shared:
name = item['id']
package = APP / '.native/packages' / name[0] / name
if name not in seen and any((package / tag).is_dir() for tag in
[item['version'], item['version'].removeprefix('v'),
'v' + item['version'].removeprefix('v')]):
components.append(item)
seen.add(name)
components.sort(key=lambda item: (item['id'] != 'crossdesk', item['name'].lower()))
data = json.dumps({'components': components, 'revision': revision[:12],
'sourceURL': source,
'buildURL': f'{repository}/blob/{revision or "main"}/apps/harmony/README.md'},
ensure_ascii=False, sort_keys=True).encode()
body = '\n'.join(','.join(str(byte) for byte in data[i:i + 32]) + ','
for i in range(0, len(data), 32))
write(output, '/*\n * @Author: DI JUNKUN\n * @Date: 2026-10-21\n'
' * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.\n */\n\n'
'#ifndef _ABOUT_RESOURCES_H_\n#define _ABOUT_RESOURCES_H_\n\n'
'// Generated by the Harmony desktop build. Do not edit.\n'
'inline constexpr unsigned char kAboutResources[] = {\n' + body + '\n};\n\n#endif\n')
def main(): def main():
bundle = json.loads((APP / 'AppScope/app.json5').read_text())['app']['bundleName'] bundle = json.loads((APP / 'AppScope/app.json5').read_text())['app']['bundleName']
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
@@ -157,6 +209,7 @@ def main():
urllib.request.urlretrieve('https://raw.githubusercontent.com/yhirose/cpp-httplib/v0.26.0/httplib.h', header) urllib.request.urlretrieve('https://raw.githubusercontent.com/yhirose/cpp-httplib/v0.26.0/httplib.h', header)
if hashlib.sha256(header.read_bytes()).hexdigest() != '4a42da28ce477d06f53942c172197d3b83b4fe42b7e37b8af78c70e063c06da7': if hashlib.sha256(header.read_bytes()).hexdigest() != '4a42da28ce477d06f53942c172197d3b83b4fe42b7e37b8af78c70e063c06da7':
raise SystemExit('cpp-httplib source checksum mismatch') raise SystemExit('cpp-httplib source checksum mismatch')
write_about_resources(generated / 'about_resources.h', args.version)
legacy_types = PROJECT / 'entry/src/main/cpp/types/libslint_probe' legacy_types = PROJECT / 'entry/src/main/cpp/types/libslint_probe'
if legacy_types.exists(): if legacy_types.exists():
run([studio / 'tools/hvigor/bin/hvigorw', 'clean', '--no-daemon'], cwd=PROJECT, env=env) run([studio / 'tools/hvigor/bin/hvigorw', 'clean', '--no-daemon'], cwd=PROJECT, env=env)
+7
View File
@@ -0,0 +1,7 @@
-- Python's recipe looks up its dependency as "openssl". Keep that name while
-- using the OpenSSL 3 recipe, including its sources, patches and build scripts.
package("openssl")
set_base("openssl3")
-- Rebuild consumers cached against the old OpenSSL 1.x ABI.
set_policy("package.strict_compatibility", true)
package_end()
+1 -1
View File
@@ -1 +1 @@
includes("cpp-httplib", "rust", "slint") includes("cpp-httplib", "openssl", "rust", "slint")
+16
View File
@@ -54,11 +54,27 @@ GitHub Actions 从分支手动打包时,在 `Build and Release` 的
构建使用 `0.0.0-日期`;版本标签构建继续使用标签中的版本号。 构建使用 `0.0.0-日期`;版本标签构建继续使用标签中的版本号。
`desktop_version` 只影响桌面安装包,不改变移动端版本。 `desktop_version` 只影响桌面安装包,不改变移动端版本。
`prepare-version` 前置任务统一生成完整桌面版本,通过 job outputs 传给
Linux、macOS、Windows 和发布任务。无日期标签使用前置任务运行当天的
北京时间日期,显式日期标签使用标签中的日期;后续任务不再重新计算。
构建跨午夜或单独重跑发布任务时,继续使用前置任务确定的版本。
不要使用 `vauto-update-20261020` 这类分支名作为版本号验证升级。 不要使用 `vauto-update-20261020` 这类分支名作为版本号验证升级。
新版本必须增加数字版本或 patch;只改变日期不视为升级。例如先手动安装 新版本必须增加数字版本或 patch;只改变日期不视为升级。例如先手动安装
`v1.7.2-20261020`,再验证到 `v1.7.3-20261020` 的升级。 `v1.7.2-20261020`,再验证到 `v1.7.3-20261020` 的升级。
分支手动构建只上传 Actions 构建产物,不会自动发布版本清单。 分支手动构建只上传 Actions 构建产物,不会自动发布版本清单。
发布脚本按数字版本和 patch 比较版本,忽略构建日期。编辑或重新发布旧的
桌面 Release 时,保留服务器现有清单并在 Actions 日志中说明跳过原因;
同版本仍可更新发布说明,新版本正常发布。各平台下载记录也不会被较低
版本覆盖,包括独立移动端发布和桌面 Release 附带的移动端安装包。
桌面 Release 中所有桌面安装包及便携包的完整版本必须与标签去掉 `v`
前缀后的版本一致,包括日期和 patch 的位置;不能只按数字版本比较。
任一包不匹配时,脚本报告标签、平台、文件名及双方版本,终止清单生成,
不写入输出文件。手动发布时应使用与安装包一致的完整版本标签,例如
`v1.7.4-20261021`。iOS、Android 安装包保持独立版本。
本地构建可以设置同名环境变量,或使用: 本地构建可以设置同名环境变量,或使用:
```sh ```sh
+1
View File
@@ -15,6 +15,7 @@ if desktop_platform then
includes(path.join(source_dir, "apps/desktop/xmake/options.lua")) includes(path.join(source_dir, "apps/desktop/xmake/options.lua"))
includes(path.join(source_dir, "apps/desktop/xmake/platform.lua")) includes(path.join(source_dir, "apps/desktop/xmake/platform.lua"))
includes(path.join(source_dir, "apps/desktop/xmake/rules/slint.lua")) includes(path.join(source_dir, "apps/desktop/xmake/rules/slint.lua"))
includes(path.join(source_dir, "apps/desktop/xmake/rules/about_resources.lua"))
includes(path.join(source_dir, "apps/desktop/xmake/targets.lua")) includes(path.join(source_dir, "apps/desktop/xmake/targets.lua"))
setup_options_and_dependencies() setup_options_and_dependencies()