Initial commit: rdgen with GitHub/Gitea dual build backend

This commit is contained in:
naeeo
2026-09-29 19:56:02 +08:00
commit a3686a49d4
103 changed files with 20007 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
docker-compose.yml
# Docs
README.md
setup.md
LICENSE
# Git stuff
.git
.github
.gitignore
# Python caches / venv
*.pyc
__pycache__/
.venv/
# Runtime data (容器内通过卷挂载)
db.sqlite3
data/
exe/
png/
temp_zips/
static/
# 与 Web 服务无关的内容
printer-adapter/
+48
View File
@@ -0,0 +1,48 @@
name: 'Decrypt and Mask Secrets'
description: 'Decrypts a zip and masks the JSON contents as env vars'
inputs:
zip_password:
description: 'Password for the Zip'
required: true
zip_path:
description: 'Path to the encrypted zip'
required: false
default: 'secrets.zip'
runs:
using: "composite"
steps:
- name: install python deps
shell: bash
run: |
pip install pyzipper
- name: Decrypt and Mask
shell: python
env:
PYTHONUTF8: "1"
PYTHONIOENCODING: "utf-8"
run: |
import sys
import io
import pyzipper
import json
import os
# Force UTF-8 stdout/stderr so secrets with characters outside
# the runner codepage (CJK, emoji, etc.) do not crash this step
# on Windows runners (default cp1251) with UnicodeEncodeError
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
with pyzipper.AESZipFile('${{ inputs.zip_path }}') as zf:
zf.setpassword('${{ inputs.zip_password }}'.encode())
with zf.open('secrets.json') as f:
secrets = json.load(f)
with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as env_file:
for key, value in secrets.items():
if value:
print(f"::add-mask::{value}")
env_file.write(f"{key}={value}\n")
print(f"Successfully masked {len(secrets)} secrets.")
+134
View File
@@ -0,0 +1,134 @@
# Gitea Actions 适配副本:由 .github/workflows 同名文件适配(本地路径/cache/artifact 差异),修改时请同步两边。差异说明见 setup.md「Gitea 部署附录」。
# This yaml shares the build bridge steps with ci and nightly.
name: Build flutter-rust-bridge
# 2023-11-23 18:00:00+00:00
on:
workflow_call:
inputs:
version:
description: 'Rustdesk Version'
required: true
default: '1.3.1'
type: string
env:
CARGO_EXPAND_VERSION: "1.0.95"
FLUTTER_VERSION: "3.22.3"
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
jobs:
generate_bridge:
runs-on: ${{ matrix.job.os }}
strategy:
fail-fast: false
matrix:
job:
- {
target: x86_64-unknown-linux-gnu,
os: ubuntu-24.04,
extra-build-args: "",
}
steps:
- name: Checkout source code
if: ${{ inputs.version != 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
ref: refs/tags/${{ inputs.version }}
submodules: recursive
- name: Checkout source code
if: ${{ inputs.version == 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
submodules: recursive
- name: Install prerequisites
run: |
sudo apt-get install ca-certificates -y
sudo apt-get update -y
sudo apt-get install -y \
clang \
cmake \
curl \
gcc \
git \
g++ \
libclang-dev \
libgtk-3-dev \
llvm-dev \
nasm \
ninja-build \
pkg-config \
wget
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@v2
with:
prefix-key: bridge-${{ matrix.job.os }}
- name: Cache cargo git
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: bridge-cargo-git-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
bridge-cargo-git-
- name: Cache Bridge
id: cache-bridge
uses: actions/cache@v4
with:
path: /tmp/flutter_rust_bridge
key: vcpkg-${{ matrix.job.arch }}
- name: Install flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install flutter rust bridge deps
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 15
shell: bash
command: |
cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked
cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked
pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd
- name: Run flutter rust bridge
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 15
shell: bash
command: |
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h
cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: bridge-artifact
path: |
./src/bridge_generated.rs
./src/bridge_generated.io.rs
./flutter/lib/generated_bridge.dart
./flutter/lib/generated_bridge.freezed.dart
./flutter/macos/Runner/bridge_generated.h
./flutter/ios/Runner/bridge_generated.h
@@ -0,0 +1,48 @@
# Gitea Actions 适配副本:由 .github/workflows 同名文件适配(本地路径/cache/artifact 差异),修改时请同步两边。差异说明见 setup.md「Gitea 部署附录」。
name: Fetch Encrypted Secrets
on:
workflow_call:
inputs:
zip_url_json:
required: true
type: string
jobs:
download-zip:
runs-on: ubuntu-latest
steps:
- name: Download with Retry
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
shell: python
command: |
import requests
import json
import time
input_data = json.loads('${{ inputs.zip_url_json }}')
url = f"{input_data['url']}/get_zip?filename={input_data['file']}"
for attempt in range(5):
try:
print(f"Downloading (Attempt {attempt + 1})...")
r = requests.get(url, timeout=20)
r.raise_for_status()
with open('secrets.zip', 'wb') as f:
f.write(r.content)
break
except Exception as e:
if attempt < 4:
time.sleep(5 * (2 ** attempt))
else: raise e
- name: Upload Encrypted Artifact
uses: actions/upload-artifact@v4
with:
name: encrypted-secrets-zip
path: secrets.zip
retention-days: 1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+775
View File
@@ -0,0 +1,775 @@
# Gitea Actions 适配副本:由 .github/workflows 同名文件适配(本地路径/cache/artifact 差异),修改时请同步两边。差异说明见 setup.md「Gitea 部署附录」。
name: Custom macOS Client Generator
run-name: Custom macOS Client Generator
on:
workflow_dispatch:
inputs:
version:
description: 'version to buld'
required: true
default: ''
type: string
zip_url:
description: 'url to zip of json'
required: true
default: ''
type: string
env:
SCITER_RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503, also 1.78 has ABI change which causes our sciter version not working, https://blog.rust-lang.org/2024/03/30/i128-layout-update.html
RUST_VERSION: "1.75" # sciter failed on m1 with 1.78 because of https://blog.rust-lang.org/2024/03/30/i128-layout-update.html
MAC_RUST_VERSION: "1.81"
CARGO_NDK_VERSION: "3.1.2"
SCITER_ARMV7_CMAKE_VERSION: "3.29.7"
SCITER_NASM_DEBVERSION: "2.14-1"
LLVM_VERSION: "15.0.6"
FLUTTER_VERSION: "3.24.5"
ANDROID_FLUTTER_VERSION: "3.24.5" # >= 3.16 is very slow on my android phone, but work well on most of others. We may switch to new flutter after changing to texture rendering (I believe it can solve my problem).
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1" # for arm64 linux because official Dart SDK does not work
FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "${{ inputs.upload-tag }}"
VCPKG_BINARY_SOURCES: "clear;files,/Users/runner/vcpkg-cache,readwrite"
# vcpkg version: 2024.07.12
VCPKG_COMMIT_ID: "${{ inputs.version == 'master' && '9e593bb18ea69cc5095e012465dcd675a822ed0d' || '120deac3062162151622ca4860575a33844ba10b' }}"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836"
VERSION: "${{ inputs.version }}"
NDK_VERSION: "r28c"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
UPLOAD_ARTIFACT: 'true'
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}"
jobs:
setup:
uses: ./.gitea/workflows/fetch-encrypted-secrets.yml
with:
zip_url_json: ${{ inputs.zip_url }}
generate-bridge:
uses: ./.gitea/workflows/bridge.yml
with:
version: ${{ inputs.version }}
build-for-macos:
name: ${{ matrix.job.target }}
runs-on: ${{ matrix.job.os }}
needs: [generate-bridge, setup]
strategy:
fail-fast: false
matrix:
job:
- {
target: x86_64-apple-darwin,
os: macos-15-intel, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel
extra-build-args: "",
arch: x86_64,
vcpkg-triplet: x64-osx,
}
- {
target: aarch64-apple-darwin,
os: macos-14,
# extra-build-args: "--disable-flutter-texture-render", # disable this for mac, because we see a lot of users reporting flickering both on arm and x64, and we can not confirm if texture rendering has better performance if htere is no vram, https://github.com/rustdesk/rustdesk/issues/6296
extra-build-args: "--screencapturekit",
arch: aarch64,
vcpkg-triplet: arm64-osx,
}
env:
STATUS_URL: "${{ secrets.GENURL }}/updategh"
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
path: .rdgen-src
- uses: actions/download-artifact@v4
with:
name: encrypted-secrets-zip
- name: Load Secrets
uses: ./.gitea/actions/decrypt-secrets
with:
zip_password: ${{ secrets.ZIP_PASSWORD }}
- name: Finalize and Cleanup zip/json
if: always() # Run even if previous steps fail
continue-on-error: true
uses: fjogeleit/http-request-action@v1
with:
url: "${{ secrets.GENURL }}/cleanzip"
method: 'POST'
customHeaders: '{"Content-Type": "application/json"}'
data: '{"uuid": "${{ env.uuid }}"}'
- name: Checkout source code
if: ${{ env.VERSION != 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
ref: refs/tags/${{ env.VERSION }}
submodules: recursive
- name: Checkout source code
if: ${{ env.VERSION == 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
submodules: recursive
- name: Install imagemagick and potrace and nasm and and
shell: bash
run: |
brew install imagemagick potrace nasm cmake gcc wget ninja
echo "$(brew --prefix imagemagick)/bin" >> $GITHUB_PATH
- name: Update macOS Info.plist and settings
continue-on-error: false
shell: bash
run: |
# MACSTUFF Update Info.plist
sed -i '' -e 's|<key>CFBundleName</key>.*<string>.*</string>|<key>CFBundleName</key>\n\t<string>${{ env.appname }}</string>|' ./flutter/macos/Runner/Info.plist
sed -i '' -e 's|<key>CFBundleDisplayName</key>.*<string>.*</string>|<key>CFBundleDisplayName</key>\n\t<string>${{ env.appname }}</string>|' ./flutter/macos/Runner/Info.plist
sed -i '' -e 's|<key>CFBundleIdentifier</key>.*<string>.*</string>|<key>CFBundleIdentifier</key>\n\t<string>com.${{ env.appname }}.app</string>|' ./flutter/macos/Runner/Info.plist
# MACSTUFF Update AppInfo.xcconfig
sed -i '' -e 's|PRODUCT_NAME = .*|PRODUCT_NAME = ${{ env.appname }}|' ./flutter/macos/Runner/Configs/AppInfo.xcconfig
sed -i '' -e 's|PRODUCT_BUNDLE_IDENTIFIER = .*|PRODUCT_BUNDLE_IDENTIFIER = com.${{ env.appname }}.app|' ./flutter/macos/Runner/Configs/AppInfo.xcconfig
sed -i '' -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./flutter/macos/Runner/Configs/AppInfo.xcconfig
sed -i '' -e 's|Purslane Ltd.|${{ env.compname }}|' ./flutter/macos/Runner/Configs/AppInfo.xcconfig
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./Cargo.toml
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./libs/portable/Cargo.toml
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./src/main.rs
sed -i -e 's|Purslane Ltd.|${{ env.compname }}|' ./Cargo.toml
sed -i -e 's|Purslane Ltd.|${{ env.compname }}|' ./libs/portable/Cargo.toml
sed -i -e 's|Purslane Ltd.|${{ env.compname }}|' ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./src/main.rs
# Update Xcode project settings
sed -i '' -e 's/PRODUCT_NAME = "RustDesk"/PRODUCT_NAME = "${{ env.appname }}"/' ./flutter/macos/Runner.xcodeproj/project.pbxproj
sed -i '' -e 's/PRODUCT_BUNDLE_IDENTIFIER = ".*"/PRODUCT_BUNDLE_IDENTIFIER = "com.${{ env.appname }}.app"/' ./flutter/macos/Runner.xcodeproj/project.pbxproj
# Update CMake settings
if [ -f "./flutter/macos/CMakeLists.txt" ]; then
sed -i '' -e 's/set(BINARY_NAME ".*")/set(BINARY_NAME "${{ env.appname }}")/' ./flutter/macos/CMakeLists.txt
fi
# Update Podfile - keep the target as 'Runner'
sed -i '' -e 's/target '"'"'Runner'"'"' do/target '"'"'Runner'"'"' do/' ./flutter/macos/Podfile
find ./src/lang -name "*.rs" -exec sed -i '' -e 's|RustDesk|${{ env.appname }}|' {} \;
sed -i '' -e 's|RustDesk|${{ env.appname }}|' ./src/lang/nl.rs
sed -i '' -e 's|https://rustdesk.com|${{ env.urlLink }}|' ./build.py
sed -i '' -e "s|launchUrl(Uri.parse('https://rustdesk.com'));|launchUrl(Uri.parse('${{ env.urlLink }}'));|" ./flutter/lib/common.dart
sed -i '' -e "s|launchUrlString('https://rustdesk.com');|launchUrlString('${{ env.urlLink }}');|" ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i '' -e "s|launchUrlString('https://rustdesk.com/privacy.html')|launchUrlString('${{ env.urlLink }}/privacy.html')|" ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i '' -e "s|const url = 'https://rustdesk.com/';|const url = '${{ env.urlLink }}';|" ./flutter/lib/mobile/pages/settings_page.dart
sed -i '' -e "s|launchUrlString('https://rustdesk.com/privacy.html')|launchUrlString('${{ env.urlLink }}/privacy.html')|" ./flutter/lib/mobile/pages/settings_page.dart
sed -i '' -e "s|https://rustdesk.com/privacy.html|${{ env.urlLink }}/privacy.html|" ./flutter/lib/desktop/pages/install_page.dart
- name: change download link to custom
if: env.downloadLink != 'https://rustdesk.com/download'
continue-on-error: true
shell: bash
run: |
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./flutter/lib/desktop/pages/desktop_home_page.dart
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./flutter/lib/mobile/pages/connection_page.dart
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./src/ui/index.tis
sed -i -e 's|&download_url|"${{ env.downloadLink }}"|' ./src/updater.rs
sed -i -e 's|$downloadUrl/$downloadFile|${{ env.downloadLink }}|' ./flutter/lib/desktop/widgets/update_progress.dart
# Update slogan - About in en.rs
sed -i '' -e 's/("Slogan_tip", "Made with heart in this chaotic world!")/("Slogan_tip", "Powered by ${{ env.appname }}")/' ./src/lang/en.rs
sed -i '' -e 's/("About RustDesk", "")/("About RustDesk", "About ${{ env.appname }}")/' ./src/lang/en.rs
# Update slogan - About in nl.rs
sed -i '' -e 's/("Slogan_tip", "Ontwikkeld met het hart voor deze chaotische wereld!")/("Slogan_tip", "Powered by ${{ env.appname }}")/' ./src/lang/nl.rs
sed -i '' -e 's/("Your Desktop", "Uw Bureaublad")/("Your Desktop", "Uw ${{ env.appname }}")/' ./src/lang/nl.rs
sed -i '' -e 's/("About RustDesk", "Over RustDesk")/("About RustDesk", "Over ${{ env.appname }}")/' ./src/lang/nl.rs
sed -i '' -e 's/("About", "Over")/("About", "Over ${{ env.appname }}")/' ./src/lang/nl.rs
# Update pubspec.yaml with proper YAML formatting
cp ./flutter/pubspec.yaml ./flutter/pubspec.yaml.bak
echo " archive: ^3.6.1" > ./flutter/temp_dependency.txt
awk '/intl:/{print;system("cat ./flutter/temp_dependency.txt");next}1' ./flutter/pubspec.yaml > ./flutter/pubspec.yaml.tmp
mv ./flutter/pubspec.yaml.tmp ./flutter/pubspec.yaml
rm ./flutter/temp_dependency.txt
- name: set server, serverPort, key, and apiserver
continue-on-error: true
shell: bash
env:
SERVER_DOMAIN: ${{ env.server }}
SERVER_PORT_INPUT: ${{ env.serverPort }}
SERVER_KEY: ${{ env.key }}
API_SERVER: ${{ env.apiServer }}
run: |
# Pass secrets via bash env vars to avoid quoting issues
if [ ! -f "./libs/hbb_common/src/config.rs" ]; then
echo "Error: config.rs not found!"
exit 1
fi
if [ ! -f "./src/common.rs" ]; then
echo "Error: common.rs not found!"
exit 1
fi
# Port must be a number; views.py defaults it to 21116
if ! [[ "$SERVER_PORT_INPUT" =~ ^[0-9]+$ ]]; then
echo "Error: serverPort must be a number, got: '$SERVER_PORT_INPUT'"
exit 1
fi
# Derive the port block from the rendezvous port
# (defaults 21116/21117/21118/21119)
SERVER_PORT=$SERVER_PORT_INPUT
RELAY_PORT=$((SERVER_PORT + 1))
WS_RENDEZVOUS_PORT=$((RELAY_PORT + 1))
WS_RELAY_PORT=$((WS_RENDEZVOUS_PORT + 1))
echo "Setting server: $SERVER_DOMAIN"
echo "Setting ports: $SERVER_PORT, $RELAY_PORT, $WS_RENDEZVOUS_PORT, $WS_RELAY_PORT"
sed -i -e "s|rs-ny.rustdesk.com|$SERVER_DOMAIN|" ./libs/hbb_common/src/config.rs
# Match on numeric value so the step is independent of defaults
sed -i -e "s|pub const RENDEZVOUS_PORT: i32 = [0-9][0-9]*;|pub const RENDEZVOUS_PORT: i32 = $SERVER_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const RELAY_PORT: i32 = [0-9][0-9]*;|pub const RELAY_PORT: i32 = $RELAY_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const WS_RENDEZVOUS_PORT: i32 = [0-9][0-9]*;|pub const WS_RENDEZVOUS_PORT: i32 = $WS_RENDEZVOUS_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const WS_RELAY_PORT: i32 = [0-9][0-9]*;|pub const WS_RELAY_PORT: i32 = $WS_RELAY_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw=|$SERVER_KEY|" ./libs/hbb_common/src/config.rs
sed -i -e "s|https://admin.rustdesk.com|$API_SERVER|" ./src/common.rs
echo "=== Verification ==="
grep -nE "RENDEZVOUS_PORT|RELAY_PORT|WS_" ./libs/hbb_common/src/config.rs
- name: allow custom.txt
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
shell: bash
command: |
cp .rdgen-src/.github/patches/allowCustom.py .
python allowCustom.py
cp .rdgen-src/.github/patches/removeSetupServerTip.diff .
git apply removeSetupServerTip.diff
# sed -i '/intl:/a \ \ archive: ^3.6.1' ./flutter/pubspec.yaml
- name: Install build runtime
run: |
brew install llvm create-dmg
# pkg-config is handled in a separate step, because it may be already installed by `macos-latest`(14.7.1) runner
if command -v pkg-config &>/dev/null; then
echo "pkg-config is already installed"
else
brew install pkg-config
fi
- name: Install NASM
run: |
# Install NASM 2.16.x from official release.
# Do NOT use `brew install nasm` which installs NASM 3.x.
# NASM 3.x is a complete rewrite with incompatible CLI options and removed features.
# aom and other multimedia libraries require NASM 2.x for x86/x86_64 assembly.
wget https://www.nasm.us/pub/nasm/releasebuilds/2.16.03/macosx/nasm-2.16.03-macosx.zip
unzip nasm-2.16.03-macosx.zip
sudo cp nasm-2.16.03/nasm /usr/local/bin/nasm
nasm --version
- name: Install flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Patch flutter
continue-on-error: true
run: |
cd $(dirname $(dirname $(which flutter)))
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
- name: Workaround for flutter issue
shell: bash
run: |
cd "$(dirname "$(which flutter)")"
# https://github.com/flutter/flutter/issues/133533
sed -i -e 's/_setFramesEnabledState(false);/\/\/_setFramesEnabledState(false);/g' ../packages/flutter/lib/src/scheduler/binding.dart
grep -n '_setFramesEnabledState(false);' ../packages/flutter/lib/src/scheduler/binding.dart
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
with:
toolchain: ${{ env.MAC_RUST_VERSION }}
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@v2
with:
prefix-key: ${{ matrix.job.os }}
- name: Magick stuff for macOS
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: false
shell: bash
run: |
# Create all necessary directories first
mkdir -p ./res
mkdir -p ./flutter/assets
mkdir -p ./flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset
mkdir -p ./macos/Runner/Assets.xcassets/AppIcon.appiconset
mkdir -p ./rustdesk/data/flutter_assets/assets
# Download icon using curl with additional SSL options
curl -k -L --tlsv1.2 --proto =https --ssl-reqd \
-H "User-Agent: Mozilla/5.0" \
"${{ env.iconlink_url }}/get_png?filename=${{ env.iconlink_file }}&uuid=${{ env.iconlink_uuid }}" \
-o ./res/icon.png || wget --no-check-certificate -O ./res/icon.png "${{ env.iconlink_url }}/get_png?filename=${{ env.iconlink_file }}&uuid=${{ env.iconlink_uuid }}"
# Backup existing files (if they exist)
[ -f "./res/32x32.png" ] && mv ./res/32x32.png ./res/32x32.png.bak
[ -f "./res/64x64.png" ] && mv ./res/64x64.png ./res/64x64.png.bak
[ -f "./res/128x128.png" ] && mv ./res/128x128.png ./res/128x128.png.bak
[ -f "./res/mac-icon.png" ] && mv ./res/mac-icon.png ./res/mac-icon.png.bak
[ -f "./flutter/assets/icon.png" ] && mv ./flutter/assets/icon.png ./flutter/assets/icon.png.bak
[ -f "./flutter/assets/icon.svg" ] && mv ./flutter/assets/icon.svg ./flutter/assets/icon.svg.bak
[ -f "./rustdesk/data/flutter_assets/assets/icon.svg" ] && mv ./rustdesk/data/flutter_assets/assets/icon.svg ./rustdesk/data/flutter_assets/assets/icon.svg.bak
# Create standard app icons
magick ./res/icon.png -resize 32x32 ./res/32x32.png
magick ./res/icon.png -resize 64x64 ./res/64x64.png
magick ./res/icon.png -resize 128x128 ./res/128x128.png
# Copy icon to Flutter assets
cp ./res/icon.png ./flutter/assets/icon.png
cp ./res/icon.png ./rustdesk/data/flutter_assets/assets/icon.png
# Convert PNG to SVG using potrace
magick ./res/icon.png -flatten ./temp_icon.pbm
potrace --svg -o ./flutter/assets/icon.svg ./temp_icon.pbm
cp ./flutter/assets/icon.svg ./rustdesk/data/flutter_assets/assets/icon.svg
rm ./temp_icon.pbm
# Create macOS app icons
magick ./res/icon.png -resize 16x16 "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png"
magick ./res/icon.png -resize 32x32 "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png"
magick ./res/icon.png -resize 64x64 "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png"
magick ./res/icon.png -resize 128x128 "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png"
magick ./res/icon.png -resize 256x256 "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png"
magick ./res/icon.png -resize 512x512 "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png"
magick ./res/icon.png -resize 1024x1024 "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png"
# Create macOS specific icons
magick ./res/icon.png -resize 128x128 ./res/mac-icon.png
# Create dark mode tray icon (optimized for macOS menu bar)
magick ./res/icon.png -resize 22x22 -colorspace gray -alpha set -background none -channel A -evaluate set 100% ./res/mac-tray-dark-x2.png
# Create light mode tray icon (optimized for macOS menu bar)
magick ./res/icon.png -resize 22x22 -negate -colorspace gray -alpha set -background none -channel A -evaluate set 100% ./res/mac-tray-light-x2.png
# Create AppIcon.icns (macOS native icon format)
mkdir -p ./iconset.iconset
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png" "./iconset.iconset/icon_16x16.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png" "./iconset.iconset/icon_16x16@2x.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png" "./iconset.iconset/icon_32x32.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png" "./iconset.iconset/icon_32x32@2x.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png" "./iconset.iconset/icon_128x128.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png" "./iconset.iconset/icon_128x128@2x.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png" "./iconset.iconset/icon_256x256.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png" "./iconset.iconset/icon_256x256@2x.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png" "./iconset.iconset/icon_512x512.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png" "./iconset.iconset/icon_512x512@2x.png"
iconutil -c icns ./iconset.iconset -o ./flutter/macos/Runner/AppIcon.icns
rm -rf ./iconset.iconset
# Create Contents.json for macOS app icon
echo '{
"images": [
{"size":"16x16","idiom":"mac","filename":"app_icon_16.png","scale":"1x"},
{"size":"16x16","idiom":"mac","filename":"app_icon_32.png","scale":"2x"},
{"size":"32x32","idiom":"mac","filename":"app_icon_32.png","scale":"1x"},
{"size":"32x32","idiom":"mac","filename":"app_icon_64.png","scale":"2x"},
{"size":"128x128","idiom":"mac","filename":"app_icon_128.png","scale":"1x"},
{"size":"128x128","idiom":"mac","filename":"app_icon_256.png","scale":"2x"},
{"size":"256x256","idiom":"mac","filename":"app_icon_256.png","scale":"1x"},
{"size":"256x256","idiom":"mac","filename":"app_icon_512.png","scale":"2x"},
{"size":"512x512","idiom":"mac","filename":"app_icon_512.png","scale":"1x"},
{"size":"512x512","idiom":"mac","filename":"app_icon_1024.png","scale":"2x"}
],
"info": {
"version": 1,
"author": "xcode"
}
}' > "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json"
# Copy icons and Contents.json to both locations
cp -r flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/* macos/Runner/Assets.xcassets/AppIcon.appiconset/
# Verify files exist and show their sizes
echo "Verifying generated files:"
ls -lh ./res/mac-tray-dark-x2.png
ls -lh ./res/mac-tray-light-x2.png
ls -lh ./res/mac-icon.png
echo "Flutter macOS app icons:"
ls -lh flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/
echo "Flutter assets:"
ls -lh flutter/assets/icon.*
echo "RustDesk Flutter assets:"
ls -lh rustdesk/data/flutter_assets/assets/
- name: replace flutter icons
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: false
shell: bash
run: |
cd ./flutter
# Create required directories and files
mkdir -p web
mkdir -p assets
echo '{"name":"${{ env.appname }}","short_name":"${{ env.appname }}","start_url":"/","display":"standalone","background_color":"#ffffff","theme_color":"#ffffff","description":"A remote desktop software."}' > web/manifest.json
echo '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${{ env.appname }}</title></head><body></body></html>' > web/index.html
# Ensure the AppIcon.appiconset directory exists
mkdir -p macos/Runner/Assets.xcassets/AppIcon.appiconset
# Copy the processed icons to Flutter locations
cp ../res/mac-icon.png ./assets/icon.png
cp ../flutter/assets/icon.svg ./assets/icon.svg || true
flutter pub upgrade win32
flutter pub get
flutter pub run flutter_launcher_icons
cd ..
- name: ui.rs
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: true
shell: bash
run: |
cp ./src/ui.rs ./src/ui.rs.bak
if [ -f "./res/icon.png" ]; then
SEARCH_STR="iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAEiuAABIrgHwmhA7AAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAEx9JREFUeJztnXmYHMV5h9"
b64=$(base64 < ./res/icon.png)
sed -i '' -e "s~$SEARCH_STR.*\"~$b64\"~" ./src/ui.rs
fi
- name: fix connection delay
continue-on-error: false
if: ${{ env.delayFix == 'true' }}
shell: bash
run: |
# Precise fix: only extend the direct-connection condition with the
# key check, instead of globally replacing !key.is_empty() with
# false (which would also disable TCP encryption and UDP logic).
sed -i -e 's#if is_local || peer_nat_type == NatType::SYMMETRIC {#if is_local || peer_nat_type == NatType::SYMMETRIC || !key.is_empty() {#' ./src/client.rs
grep -n "key.is_empty()" ./src/client.rs || true
- name: use X for offline display instead of orange circle
continue-on-error: true
if: env.xOffline == 'true'
run: |
cp .rdgen-src/.github/patches/xoffline.diff .
git apply xoffline.diff
- name: hide-cm
continue-on-error: true
if: env.hidecm == 'true'
run: |
cp .rdgen-src/.github/patches/hidecm.diff .
git apply hidecm.diff
- name: removeNewVersionNotif
continue-on-error: true
if: env.removeNewVersionNotif == 'true'
run: |
sed -i -e 's|updateUrl.isNotEmpty|false|' ./flutter/lib/desktop/pages/desktop_home_page.dart
sed -i '/let (request, url) =/,/Ok(())/{/Ok(())/!d}' ./src/common.rs
- name: Restore bridge files
uses: actions/download-artifact@v4
with:
name: bridge-artifact
path: ./
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11
with:
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
doNotCache: false
- name: Install vcpkg dependencies
run: |
if ! $VCPKG_ROOT/vcpkg \
install \
--x-install-root="$VCPKG_ROOT/installed"; then
find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do
echo "$_1:"
echo "======"
cat "$_1"
echo "======"
echo ""
done
exit 1
fi
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true
- name: Create MacOS directory structure
run: |
mkdir -p ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS
- name: Build rustdesk
run: |
if [ "${{ matrix.job.target }}" = "aarch64-apple-darwin" ]; then
MIN_MACOS_VERSION="12.3"
sed -i -e "s/MACOSX_DEPLOYMENT_TARGET\=[0-9]*.[0-9]*/MACOSX_DEPLOYMENT_TARGET=${MIN_MACOS_VERSION}/" build.py
sed -i -e "s/platform :osx, '.*'/platform :osx, '${MIN_MACOS_VERSION}'/" flutter/macos/Podfile
sed -i -e "s/osx_minimum_system_version = \"[0-9]*.[0-9]*\"/osx_minimum_system_version = \"${MIN_MACOS_VERSION}\"/" Cargo.toml
sed -i -e "s/MACOSX_DEPLOYMENT_TARGET = [0-9]*.[0-9]*;/MACOSX_DEPLOYMENT_TARGET = ${MIN_MACOS_VERSION};/" flutter/macos/Runner.xcodeproj/project.pbxproj
fi
sed -i -e "s/RustDesk.app/\"${{ env.appname }}.app\"/" build.py
./build.py --flutter --hwcodec --unix-file-copy-paste ${{ matrix.job.extra-build-args }}
# - name: Copy service file
# run: |
# cp -rf ../target/release/service ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/
- name: Embed custom config into the .app bundle
shell: bash
run: |
APP=$(find ./flutter/build/macos/Build/Products/Release -maxdepth 1 -name "*.app" | head -n 1)
echo "App bundle: $APP"
echo -n '${{ env.custom }}' > "$APP/Contents/MacOS/custom_.txt"
ls -l "$APP/Contents/MacOS/custom_.txt"
- name: Install rcodesign tool
if: env.MACOS_P12_BASE64 != null
shell: bash
run: |
pushd /tmp
wget https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F0.22.0/apple-codesign-0.22.0-macos-universal.tar.gz
tar -zxvf apple-codesign-0.22.0-macos-universal.tar.gz
mv apple-codesign-0.22.0-macos-universal/rcodesign /usr/local/bin
popd
- name: Install build runtime
run: |
brew install llvm create-dmg nasm cmake gcc wget ninja
# pkg-config is handled in a separate step, because it may be already installed by `macos-latest`(14.7.1) runner
if command -v pkg-config &>/dev/null; then
echo "pkg-config is already installed"
else
brew install pkg-config
fi
- name: Show version information (Rust, cargo, Clang)
shell: bash
run: |
clang --version || true
rustup -V
rustup toolchain list
rustup default
cargo -V
rustc -V
- name: icon svg handling
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: false
shell: bash
run: |
ASSETS_DIR="build/macos/Build/Products/Release/RustDesk.app/Contents/Frameworks/App.framework/Versions/Current/Resources/flutter_assets/assets"
mkdir -p "$ASSETS_DIR"
if [ -f "$ASSETS_DIR/icon.svg" ]; then
mv "$ASSETS_DIR/icon.svg" "$ASSETS_DIR/icon.svg.bak"
fi
# First convert PNG to PBM (bitmap)
magick convert ./res/icon.png ./temp_icon.pbm
# Then use potrace to convert to SVG
potrace --svg -o "$ASSETS_DIR/icon.svg" ./temp_icon.pbm
rm ./temp_icon.pbm
- name: logo handling
if: ${{ env.logolink_url != 'false' }}
continue-on-error: false
shell: bash
run: |
ASSETS_DIR="build/macos/Build/Products/Release/RustDesk.app/Contents/Frameworks/App.framework/Versions/Current/Resources/flutter_assets/assets"
mkdir -p "$ASSETS_DIR"
curl -k -L --tlsv1.2 --proto =https --ssl-reqd \
-H "User-Agent: Mozilla/5.0" \
"${{ env.logolink_url }}/get_png?filename=${{ env.logolink_file }}&uuid=${{ env.logolink_uuid }}" \
-o "$ASSETS_DIR/logo.png" || \
wget --no-check-certificate \
-O "$ASSETS_DIR/logo.png" \
"${{ env.logolink_url }}/get_png?filename=${{ env.logolink_file }}&uuid=${{ env.logolink_uuid }}"
- name: Sign macOS app bundle
if: env.MACOS_P12_BASE64 != ''
run: |
cd flutter/build/macos/Build/Products/Release
# Debug info
echo "Current directory contents:"
ls -la
# Rename RustDesk.app to the custom app name first
if [ -d "RustDesk.app" ]; then
# First rename the app if it's still called RustDesk.app
mv "RustDesk.app" "${{ env.appname }}.app"
echo "Renamed RustDesk.app to ${{ env.appname }}.app"
fi
echo "App bundle contents after rename:"
ls -la "${{ env.appname }}.app" || echo "App not found"
ls -la "${{ env.appname }}.app/Contents" || echo "Contents not found"
# Decode the certificate
echo "${{ secrets.MACOS_P12_BASE64 }}" | base64 --decode > certificate.p12
# Sign the app bundle and its contents
if [ -d "${{ env.appname }}.app/Contents/MacOS" ]; then
echo "Signing main executable..."
MAIN_EXECUTABLE="${{ env.appname }}.app/Contents/MacOS/${{ env.appname }}"
if [ -f "$MAIN_EXECUTABLE" ]; then
rcodesign sign --p12-file certificate.p12 --p12-password "${{ secrets.MACOS_P12_PASSWORD }}" \
--code-signature-flags runtime "$MAIN_EXECUTABLE"
else
echo "Main executable not found at expected path: $MAIN_EXECUTABLE"
# Try to find the actual executable
echo "Available executables in MacOS directory:"
ls -la "${{ env.appname }}.app/Contents/MacOS/"
ACTUAL_EXECUTABLE=$(ls "${{ env.appname }}.app/Contents/MacOS/" | head -n 1)
if [ -n "$ACTUAL_EXECUTABLE" ]; then
echo "Found executable: $ACTUAL_EXECUTABLE"
rcodesign sign --p12-file certificate.p12 --p12-password "${{ secrets.MACOS_P12_PASSWORD }}" \
--code-signature-flags runtime "${{ env.appname }}.app/Contents/MacOS/$ACTUAL_EXECUTABLE"
fi
fi
echo "Signing frameworks..."
find "${{ env.appname }}.app/Contents/Frameworks" -type f -not -name ".*" -exec \
rcodesign sign --p12-file certificate.p12 --p12-password "${{ secrets.MACOS_P12_PASSWORD }}" \
--code-signature-flags runtime {} \;
echo "Signing main bundle..."
rcodesign sign --p12-file certificate.p12 --p12-password "${{ secrets.MACOS_P12_PASSWORD }}" \
--code-signature-flags runtime "${{ env.appname }}.app"
else
echo "Error: Invalid app bundle structure"
exit 1
fi
# Clean up
rm certificate.p12
- name: Ad-hoc Sign macOS app bundle (Fallback)
if: env.MACOS_P12_BASE64 == '' || env.MACOS_P12_BASE64 == null
shell: bash
run: |
cd flutter/build/macos/Build/Products/Release
# Renombrar RustDesk.app al nombre de la marca blanca si aplica
if [ -d "RustDesk.app" ]; then
mv "RustDesk.app" "${{ env.appname }}.app"
echo "RustDesk.app renombrado a ${{ env.appname }}.app"
fi
TARGET_APP="${{ env.appname }}.app"
if [ -d "$TARGET_APP" ]; then
echo "Limpiando atributos extendidos..."
xattr -cr "$TARGET_APP" || true
echo "Re-firmando $TARGET_APP de forma Ad-Hoc y recursiva..."
codesign --force --deep --sign - "$TARGET_APP"
echo "Firma Ad-Hoc completada con exito."
else
echo "Error: No se encontro la app bundle en $TARGET_APP para firmar."
exit 1
fi
- name: Create DMG
run: |
cd /Users/runner/work/${{ github.event.repository.name }}/${{ github.event.repository.name }}/flutter/build/macos/Build/Products/Release
# Print directory contents for debugging
echo "Directory contents:"
ls -la
# Find the actual .app bundle
if [ -d "RustDesk.app" ]; then
# First rename the app if it's still called RustDesk.app
mv "RustDesk.app" "${{ env.appname }}.app"
fi
if [ ! -d "${{ env.appname }}.app" ]; then
echo "Could not find .app bundle!"
exit 1
fi
echo "Creating DMG for ${{ env.appname }}.app"
create-dmg \
--volname "${{ env.appname }}" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "${{ env.appname }}.app" 200 190 \
--hide-extension "${{ env.appname }}.app" \
--app-drop-link 600 185 \
"${{ env.appname }}-${{ matrix.job.arch }}.dmg" \
"${{ env.appname }}.app"
mv "${{ env.appname }}-${{ matrix.job.arch }}.dmg" $GITHUB_WORKSPACE/
- name: Rename rustdesk
if: env.UPLOAD_ARTIFACT == 'true'
run: |
cd $GITHUB_WORKSPACE
echo "Directory contents:"
ls -la
# Find the DMG file dynamically
DMG_FILE=$(find . -name "${{ env.appname }}-${{ matrix.job.arch }}.dmg")
if [ -n "$DMG_FILE" ]; then
echo "Found DMG file: $DMG_FILE"
mv "$DMG_FILE" "${{ env.filename }}-${{ matrix.job.arch }}.dmg"
echo "Renamed to ${{ env.filename }}-${{ matrix.job.arch }}.dmg"
else
echo "No DMG file found matching the pattern"
exit 1
fi
- name: send file to rdgen server
if: ${{ env.rdgen == 'true' }}
shell: bash
run: |
curl -i -X POST \
-H "Content-Type: multipart/form-data" \
-H "Authorization: Bearer ${{ env.token }}" \
-F "file=@$GITHUB_WORKSPACE/${{ env.filename }}-${{ matrix.job.arch }}.dmg" \
-F "uuid=${{ env.uuid }}" \
"${{ secrets.GENURL }}/save_custom_client"
- name: send file to api server
if: ${{ env.rdgen == 'false' }}
shell: bash
run: |
curl -i -X POST \
-H "Content-Type: multipart/form-data" \
-H "Authorization: Bearer ${{ env.token }}" \
-F "file=@$GITHUB_WORKSPACE/${{ env.filename }}-${{ matrix.job.arch }}.dmg" \
"${{ env.apiServer }}/api/save_custom_client"
cleanup:
needs: [build-for-macos]
runs-on: ubuntu-latest
continue-on-error: true
if: always()
steps:
- name: Delete secrets artifact
uses: geekyeggo/delete-artifact@v4
with:
name: encrypted-secrets-zip
File diff suppressed because one or more lines are too long
+750
View File
@@ -0,0 +1,750 @@
# Gitea Actions 适配副本:由 .github/workflows 同名文件适配(本地路径/cache/artifact 差异),修改时请同步两边。差异说明见 setup.md「Gitea 部署附录」。
name: Custom Windows Client Generator
run-name: Custom Windows Client Generator
on:
workflow_dispatch:
inputs:
version:
description: 'version to buld'
required: true
default: ''
type: string
zip_url:
description: 'url to zip of json'
required: true
default: ''
type: string
env:
SCITER_RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503, also 1.78 has ABI change which causes our sciter version not working, https://blog.rust-lang.org/2024/03/30/i128-layout-update.html
RUST_VERSION: "1.75" # sciter failed on m1 with 1.78 because of https://blog.rust-lang.org/2024/03/30/i128-layout-update.html
CARGO_NDK_VERSION: "3.1.2"
SCITER_ARMV7_CMAKE_VERSION: "3.29.7"
SCITER_NASM_DEBVERSION: "2.15.05-1"
LLVM_VERSION: "15.0.6"
FLUTTER_VERSION: "3.24.5"
ANDROID_FLUTTER_VERSION: "3.24.5"
# for arm64 linux because official Dart SDK does not work
FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "${{ inputs.upload-tag }}"
VCPKG_BINARY_SOURCES: "clear;files,D:\vcpkg-cache,readwrite"
# vcpkg version: 2024.07.12
VCPKG_COMMIT_ID: "${{ inputs.version == 'master' && '9e593bb18ea69cc5095e012465dcd675a822ed0d' || '120deac3062162151622ca4860575a33844ba10b' }}"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836"
VERSION: "${{ inputs.version }}"
NDK_VERSION: "r28c"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
UPLOAD_ARTIFACT: 'true'
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}"
STATUS_URL: "${{ secrets.GENURL }}/updategh"
jobs:
setup:
uses: ./.gitea/workflows/fetch-encrypted-secrets.yml
with:
zip_url_json: ${{ inputs.zip_url }}
generate-bridge:
uses: ./.gitea/workflows/bridge.yml
with:
version: ${{ inputs.version }}
build-RustDeskTempTopMostWindow:
needs: setup
uses: ./.gitea/workflows/third-party-RustDeskTempTopMostWindow.yml
with:
upload-artifact: true
target: windows-2022
configuration: Release
platform: x64
target_version: Windows10
secrets: inherit
strategy:
fail-fast: false
build-for-windows-flutter:
name: Build Windows
needs: [build-RustDeskTempTopMostWindow, generate-bridge, setup]
runs-on: ${{ matrix.job.os }}
strategy:
fail-fast: false
matrix:
job:
# - { target: i686-pc-windows-msvc , os: windows-2022 }
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
- {
target: x86_64-pc-windows-msvc,
os: windows-2022,
arch: x86_64,
vcpkg-triplet: x64-windows-static,
}
# - { target: aarch64-pc-windows-msvc, os: windows-2022, arch: aarch64 }
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
path: .rdgen-src
- uses: actions/download-artifact@v4
with:
name: encrypted-secrets-zip
- name: Load Secrets
uses: ./.gitea/actions/decrypt-secrets
with:
zip_password: ${{ secrets.ZIP_PASSWORD }}
- name: Finalize and Cleanup zip/json
if: always() # Run even if previous steps fail
continue-on-error: true
uses: fjogeleit/http-request-action@v1
with:
url: "${{ secrets.GENURL }}/cleanzip"
method: 'POST'
customHeaders: '{"Content-Type": "application/json"}'
data: '{"uuid": "${{ env.uuid }}"}'
- name: Set rdgen value
if: ${{ env.rdgen == 'true' }}
run: |
echo "STATUS_URL=${{ secrets.GENURL }}/updategh" >> $env:GITHUB_ENV
- name: Set rdgen value
if: ${{ env.rdgen == 'false' }}
run: |
echo "STATUS_URL=${{ env.apiServer }}/api/updategh" >> $env:GITHUB_ENV
- name: Checkout source code
if: ${{ env.VERSION != 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
ref: refs/tags/${{ env.VERSION }}
submodules: recursive
- name: Checkout source code
if: ${{ env.VERSION == 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
submodules: recursive
- name: Restore bridge files
uses: actions/download-artifact@v4
with:
name: bridge-artifact
path: ./
- name: Install ImageMagick on Windows
run: |
choco install -y imagemagick.app --no-progress
Get-ChildItem -Path "${env:ProgramFiles}" | % { $_.FullName } | Select-String -Pattern "[\/\\]ImageMagick[^\/\\]*$" | Out-File -Append -FilePath $env:GITHUB_PATH -Encoding utf8
- name: fix flutter_gpu_texture_renderer (fixed in master rustdesk)
shell: bash
run: |
sed -i -e 's|2ded7f146437a761ffe6981e2f742038f85ca68d|08a471bb8ceccdd50483c81cdfa8b81b07b14b87|' ./flutter/pubspec.lock
sed -i -e 's|2ded7f146437a761ffe6981e2f742038f85ca68d|08a471bb8ceccdd50483c81cdfa8b81b07b14b87|' ./flutter/pubspec.yaml
- name: change appname to custom
if: env.appname != 'rustdesk'
continue-on-error: true
shell: bash
run: |
# ./Cargo.toml
sed -i -e 's|description = "RustDesk Remote Desktop"|description = "${{ env.appname }}"|' ./Cargo.toml
sed -i -e 's|ProductName = "RustDesk"|ProductName = "${{ env.appname }}"|' ./Cargo.toml
sed -i -e 's|FileDescription = "RustDesk Remote Desktop"|FileDescription = "${{ env.appname }}"|' ./Cargo.toml
sed -i -e 's|OriginalFilename = "rustdesk.exe"|OriginalFilename = "${{ env.appname }}.exe"|' ./Cargo.toml
# ./libs/portable/Cargo.toml
sed -i -e 's|description = "RustDesk Remote Desktop"|description = "${{ env.appname }}"|' ./libs/portable/Cargo.toml
sed -i -e 's|ProductName = "RustDesk"|ProductName = "${{ env.appname }}"|' ./libs/portable/Cargo.toml
sed -i -e 's|FileDescription = "RustDesk Remote Desktop"|FileDescription = "${{ env.appname }}"|' ./libs/portable/Cargo.toml
sed -i -e 's|OriginalFilename = "rustdesk.exe"|OriginalFilename = "${{ env.appname }}.exe"|' ./libs/portable/Cargo.toml
# ./libs/portable/src/main.rs
sed -i -e 's|const APP_PREFIX: \&str = "rustdesk";|const APP_PREFIX: \&str = "${{ env.appname }}";|' ./libs/portable/src/main.rs
# ./flutter/windows/runner/Runner.rc
sed -i -e 's|"RustDesk Remote Desktop"|"${{ env.appname }}"|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|VALUE "InternalName", "rustdesk" "\0"|VALUE "InternalName", "${{ env.appname }}" "\0"|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|"rustdesk.exe"|"${{ env.filename }}"|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|"RustDesk"|"${{ env.appname }}"|' ./flutter/windows/runner/Runner.rc
# ./src/lang/en.rs
# change powered by rustdek to powered by compname
if [ ! -z "${{ env.compname }}" ]; then
find ./src/lang -name "*.rs" -exec sed -i '/powered_by_me/s|RustDesk|${{ env.compname }}|g' {} \;
fi
find ./src/lang -name "*.rs" -exec sed -i -e 's|RustDesk|${{ env.appname }}|' {} \;
sed -i -e 's|RustDesk|${{ env.appname }}|' ./res/msi/Package/License.rtf
- name: fix registry if appname has a space
if: contains(env.appname, ' ')
continue-on-error: true
shell: bash
run: |
#./src/platform/windows.rs
sed -i -e 's|reg add {}|reg add \\\"{}\\\"|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext} /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\DefaultIcon /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\DefaultIcon\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open\\\\command|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open\\\\command\\\"|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext} /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext}\\\\shell /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open\\\\command /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open\\\\command\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|{subkey}|\\\"{subkey}\\\"|' ./src/platform/windows.rs
sed -i -e 's|reg delete HKEY_CLASSES_ROOT\\\\.{ext} /f|reg delete \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg delete HKEY_CLASSES_ROOT\\\\{ext} /f|reg delete \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\" /f|' ./src/platform/windows.rs
- name: change company name
if: env.compname != 'Purslane Ltd'
continue-on-error: true
shell: bash
run: |
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./res/msi/preprocess.py
sed -i -e 's|r"Purslane(?: Tech Pte\\.)? Ltd"|"${{ env.compname }}"|' ./res/msi/preprocess.py
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./Cargo.toml
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./libs/portable/Cargo.toml
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./res/msi/Package/License.rtf
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./src/main.rs
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e 's|PURSLANE|${{ env.compname }}|' ./res/msi/preprocess.py
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./res/msi/preprocess.py
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./Cargo.toml
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./libs/portable/Cargo.toml
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./res/msi/Package/License.rtf
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./src/main.rs
- name: change url to custom
if: env.urlLink != 'https://rustdesk.com'
continue-on-error: true
shell: bash
run: |
sed -i -e 's|Homepage: https://rustdesk.com|Homepage: ${{ env.urlLink }}|' ./build.py
sed -i -e "s|launchUrl(Uri.parse('https://rustdesk.com'));|launchUrl(Uri.parse('${{ env.urlLink }}'));|" ./flutter/lib/common.dart
sed -i -e "s|launchUrlString('https://rustdesk.com');|launchUrlString('${{ env.urlLink }}');|" ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e "s|launchUrlString('https://rustdesk.com/privacy.html')|launchUrlString('${{ env.urlLink }}/privacy.html')|" ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e "s|const url = 'https://rustdesk.com/';|const url = '${{ env.urlLink }}';|" ./flutter/lib/mobile/pages/settings_page.dart
sed -i -e "s|launchUrlString('https://rustdesk.com/privacy.html')|launchUrlString('${{ env.urlLink }}/privacy.html')|" ./flutter/lib/mobile/pages/settings_page.dart
sed -i -e "s|https://rustdesk.com/privacy.html|${{ env.urlLink }}/privacy.html|" ./flutter/lib/desktop/pages/install_page.dart
sed -i -e "s|rustdesk.com|${{ env.urlLink }}|" ./res/msi/Package/License.rtf
- name: change download link to custom
if: env.downloadLink != 'https://rustdesk.com/download'
continue-on-error: true
shell: bash
run: |
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./flutter/lib/desktop/pages/desktop_home_page.dart
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./flutter/lib/mobile/pages/connection_page.dart
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./src/ui/index.tis
sed -i -e 's|&download_url|"${{ env.downloadLink }}"|' ./src/updater.rs
sed -i -e 's|$downloadUrl/$downloadFile|${{ env.downloadLink }}|' ./flutter/lib/desktop/widgets/update_progress.dart
- name: set server, serverPort, key, and apiserver
continue-on-error: true
shell: bash
env:
SERVER_DOMAIN: ${{ env.server }}
SERVER_PORT_INPUT: ${{ env.serverPort }}
SERVER_KEY: ${{ env.key }}
API_SERVER: ${{ env.apiServer }}
run: |
# Pass secrets via bash env vars to avoid quoting issues
if [ ! -f "./libs/hbb_common/src/config.rs" ]; then
echo "Error: config.rs not found!"
exit 1
fi
if [ ! -f "./src/common.rs" ]; then
echo "Error: common.rs not found!"
exit 1
fi
# Port must be a number; views.py defaults it to 21116
if ! [[ "$SERVER_PORT_INPUT" =~ ^[0-9]+$ ]]; then
echo "Error: serverPort must be a number, got: '$SERVER_PORT_INPUT'"
exit 1
fi
# Derive the port block from the rendezvous port
# (defaults 21116/21117/21118/21119)
SERVER_PORT=$SERVER_PORT_INPUT
RELAY_PORT=$((SERVER_PORT + 1))
WS_RENDEZVOUS_PORT=$((RELAY_PORT + 1))
WS_RELAY_PORT=$((WS_RENDEZVOUS_PORT + 1))
echo "Setting server: $SERVER_DOMAIN"
echo "Setting ports: $SERVER_PORT, $RELAY_PORT, $WS_RENDEZVOUS_PORT, $WS_RELAY_PORT"
sed -i -e "s|rs-ny.rustdesk.com|$SERVER_DOMAIN|" ./libs/hbb_common/src/config.rs
# Match on numeric value so the step is independent of defaults
sed -i -e "s|pub const RENDEZVOUS_PORT: i32 = [0-9][0-9]*;|pub const RENDEZVOUS_PORT: i32 = $SERVER_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const RELAY_PORT: i32 = [0-9][0-9]*;|pub const RELAY_PORT: i32 = $RELAY_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const WS_RENDEZVOUS_PORT: i32 = [0-9][0-9]*;|pub const WS_RENDEZVOUS_PORT: i32 = $WS_RENDEZVOUS_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const WS_RELAY_PORT: i32 = [0-9][0-9]*;|pub const WS_RELAY_PORT: i32 = $WS_RELAY_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw=|$SERVER_KEY|" ./libs/hbb_common/src/config.rs
sed -i -e "s|https://admin.rustdesk.com|$API_SERVER|" ./src/common.rs
echo "=== Verification ==="
grep -nE "RENDEZVOUS_PORT|RELAY_PORT|WS_" ./libs/hbb_common/src/config.rs
# ./flutter/pubspec.yaml
#sed -i '/intl:/a \ \ archive: ^3.6.1' ./flutter/pubspec.yaml
- name: allow custom.txt
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
shell: pwsh
command: |
Copy-Item .rdgen-src/.github/patches/allowCustom.py allowCustom.py
python allowCustom.py
# Remove Setup Server Tip
Copy-Item .rdgen-src/.github/patches/removeSetupServerTip.diff removeSetupServerTip.diff
git apply removeSetupServerTip.diff
- name: Patch remote printer port to spool file
shell: python
run: |
import os, re
src = "."
app = os.environ.get("appname") or "RustDesk"
literal = "C:\\\\ProgramData\\\\%s\\\\printer-spool\\\\job.prn" % app
# atch Rust remote_printer lib.rs
rs = os.path.join(src, "libs", "remote_printer", "src", "lib.rs")
if os.path.isfile(rs):
with open(rs, "r", encoding="utf-8") as f:
t = f.read()
if "printer-spool" in t:
print("Rust printer port already points to spool file")
else:
old = ('fn get_port_name(app_name: &str) -> Vec<u16> {\n'
' format!("{} Printer", app_name)')
new = ('fn get_port_name(app_name: &str) -> Vec<u16> {\n'
' let base = std::env::var("ProgramData")\n'
' .unwrap_or_else(|_| "C:\\\\ProgramData".to_string());\n'
' let dir = format!("{}\\\\{}\\\\printer-spool", base, app_name);\n'
' let _ = std::fs::create_dir_all(&dir);\n'
' format!("{}\\\\job.prn", dir)')
if old in t:
with open(rs, "w", encoding="utf-8") as f:
f.write(t.replace(old, new, 1))
print("Successfully patched Rust printer port")
else:
print("Warning: get_port_name() signature not matched in Rust source")
# patch C++ MSI Custom Action
cpp = os.path.join(src, "res", "msi", "CustomActions", "RemotePrinter.cpp")
if os.path.isfile(cpp):
with open(cpp, "r", encoding="utf-8") as f:
t = f.read()
if "printer-spool" in t:
print("MSI printer port already points to spool file")
else:
pattern = r'(RD_PRINTER_PORT\s*=\s*)L"[^"]*"'
new_t, n = re.subn(pattern, lambda m: m.group(1) + 'L"%s"' % literal, t, count=1)
if n == 1:
with open(cpp, "w", encoding="utf-8") as f:
f.write(new_t)
print("Successfully patched MSI CustomAction printer port")
else:
print("Warning: RD_PRINTER_PORT not matched in RemotePrinter.cpp")
- name: Install LLVM and Clang
uses: KyleMayes/install-llvm-action@v1
with:
version: ${{ env.LLVM_VERSION }}
- name: Install flutter
uses: subosito/flutter-action@v2.12.0 #https://github.com/subosito/flutter-action/issues/277
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
# https://github.com/flutter/flutter/issues/155685
- name: Replace engine with rustdesk custom flutter engine
run: |
flutter doctor -v
flutter precache --windows
Invoke-WebRequest -Uri https://github.com/rustdesk/engine/releases/download/main/windows-x64-release.zip -OutFile windows-x64-release.zip
Expand-Archive -Path windows-x64-release.zip -DestinationPath windows-x64-release
mv -Force windows-x64-release/* C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/
- name: Patch flutter
shell: bash
run: |
cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter)))
cd $(dirname $(dirname $(which flutter)))
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
with:
toolchain: ${{ env.SCITER_RUST_VERSION }}
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@v2
with:
prefix-key: ${{ matrix.job.os }}
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11
with:
vcpkgDirectory: C:\vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
doNotCache: false
- name: Install vcpkg dependencies
env:
VCPKG_DEFAULT_HOST_TRIPLET: ${{ matrix.job.vcpkg-triplet }}
run: |
if ! $VCPKG_ROOT/vcpkg \
install \
--triplet ${{ matrix.job.vcpkg-triplet }} \
--x-install-root="$VCPKG_ROOT/installed"; then
find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do
echo "$_1:"
echo "======"
cat "$_1"
echo "======"
echo ""
done
exit 1
fi
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true
shell: bash
- name: magick stuff
if: ${{ env.iconlink_url != 'false' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
shell: pwsh
command: |
Invoke-WebRequest -Uri ${{ env.iconlink_url }}/get_png?filename=${{ env.iconlink_file }}"&"uuid=${{ env.iconlink_uuid }} -OutFile ./res/iconx.png
mv ./res/icon.ico ./res/icon.ico.bak
mv ./res/icon.png ./res/icon.png.bak
mv ./res/tray-icon.ico ./res/tray-icon.ico.bak
mv ./res/iconx.png ./res/icon.png
mv ./res/32x32.png ./res/32x32.png.bak
mv ./res/64x64.png ./res/64x64.png.bak
mv ./res/128x128.png ./res/128x128.png.bak
mv ./res/128x128@2x.png ./res/128x128@2x.png.bak
magick ./res/icon.png -define icon:auto-resize=256,64,48,32,16 ./res/icon.ico
cp ./res/icon.ico ./res/tray-icon.ico
magick ./res/icon.png -resize 32x32 ./res/32x32.png
magick ./res/icon.png -resize 64x64 ./res/64x64.png
magick ./res/icon.png -resize 128x128 ./res/128x128.png
magick ./res/128x128.png -resize 200% ./res/128x128@2x.png
- name: ui.rs icon
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: true
shell: bash
run: |
cp ./src/ui.rs ./src/ui.rs.bak
b64=$(base64 -w0 < ./res/icon.png)
perl -0777 -pe "s|iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHL[A-Za-z0-9+/=]+|$b64|g" -i.bak ./src/ui.rs
b64=""
- name: fix connection delay
continue-on-error: true
if: ${{ env.delayFix == 'true' }}
shell: bash
run: |
# Precise fix: only extend the direct-connection condition with the
# key check, instead of globally replacing !key.is_empty() with
# false (which would also disable TCP encryption and UDP logic).
sed -i -e 's#if is_local || peer_nat_type == NatType::SYMMETRIC {#if is_local || peer_nat_type == NatType::SYMMETRIC || !key.is_empty() {#' ./src/client.rs
grep -n "key.is_empty()" ./src/client.rs || true
- name: use X for offline display instead of orange circle
if: env.xOffline == 'true'
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
shell: pwsh
continue_on_error: true
command: |
Copy-Item .rdgen-src/.github/patches/xoffline.diff xoffline.diff
git apply xoffline.diff
- name: removeNewVersionNotif
continue-on-error: true
if: env.removeNewVersionNotif == 'true'
shell: bash
run: |
sed -i -e 's|updateUrl.isNotEmpty|false|' ./flutter/lib/desktop/pages/desktop_home_page.dart
sed -i '/let (request, url) =/,/Ok(())/{/Ok(())/!d}' ./src/common.rs
# - name: run as admin
# continue-on-error: true
# if: ${{ env.runasadmin == 'true' }}
# shell: bash
# run: |
# sed -i '/<\/compatibility>/a \
# <security> \
# <requestedExecutionLevel level="requireAdministrator" uiAccess="false"/> \
# </security>' ./flutter/windows/runner/runner.exe.manifest
- name: replace flutter icons
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: true
run: |
cd ./flutter
#flutter pub upgrade win32
flutter pub get
flutter pub run flutter_launcher_icons
cd ..
- name: Checkout printer-adapter crate
uses: actions/checkout@v4
with:
repository: ${{ github.repository }}
ref: ${{ github.ref_name }}
path: ./temp-repo
- name: Move printer-adapter into place
shell: pwsh
run: |
if (Test-Path "./temp-repo/printer-adapter") {
Move-Item -Path "./temp-repo/printer-adapter" -Destination "./printer-adapter" -Force
Remove-Item -Recurse -Force "./temp-repo"
Write-Host "Successfully placed printer-adapter into workspace."
} else {
Write-Host "Error: printer-adapter folder not found in repository."
}
- name: Build rustdesk
run: |
# Windows: build RustDesk
python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack
mv ./flutter/build/windows/x64/runner/Release ./rustdesk
# Download usbmmidd_v2.zip and extract it to ./rustdesk
Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip
Expand-Archive usbmmidd_v2.zip -DestinationPath . -Force
Remove-Item -Path usbmmidd_v2\Win32 -Recurse
Remove-Item -Path "usbmmidd_v2\deviceinstaller64.exe", "usbmmidd_v2\deviceinstaller.exe", "usbmmidd_v2\usbmmidd.bat"
mv -Force .\usbmmidd_v2 ./rustdesk
# Download and install driver package
try {
Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/rustdesk_printer_driver_v4-1.4.zip -OutFile rustdesk_printer_driver_v4-1.4.zip
Expand-Archive rustdesk_printer_driver_v4-1.4.zip -DestinationPath .
mkdir ./rustdesk/drivers -ErrorAction SilentlyContinue
mv -Force .\rustdesk_printer_driver_v4-1.4 ./rustdesk/drivers/RustDeskPrinterDriver
} catch {
Write-Host "Warning: Printer driver download failed."
}
# Build and install open printer adapter
if (Test-Path "./printer-adapter/Cargo.toml") {
Write-Host "Building open printer-adapter..."
pushd ./printer-adapter
cargo build --release --locked
popd
if (Test-Path "./printer-adapter/target/release/printer_driver_adapter.dll") {
cp -Force "./printer-adapter/target/release/printer_driver_adapter.dll" "./rustdesk/printer_driver_adapter.dll"
Write-Host "Replaced printer_driver_adapter.dll with open implementation."
} else {
Write-Host "Error: printer-adapter build succeeded but DLL was not found."
}
} else {
Write-Host "Warning: ./printer-adapter/Cargo.toml not found, remote printing will remain disabled."
}
- name: icon stuff
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: true
run: |
mv ./rustdesk/data/flutter_assets/assets/icon.svg ./rustdesk/data/flutter_assets/assets/icon.svg.bak
magick ./res/icon.png ./rustdesk/data/flutter_assets/assets/icon.svg
- name: logo stuff
if: ${{ env.logolink_url != 'false' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
shell: pwsh
continue_on_error: true
command: |
Invoke-WebRequest -Uri ${{ env.logolink_url }}/get_png?filename=${{ env.logolink_file }}"&"uuid=${{ env.logolink_uuid }} -OutFile ./rustdesk/data/flutter_assets/assets/logo.png
- name: find Runner.res
# Windows: find Runner.res (compiled from ./flutter/windows/runner/Runner.rc), copy to ./Runner.res
# Runner.rc does not contain actual version, but Runner.res does
continue-on-error: true
shell: bash
run: |
runner_res=$(find . -name "Runner.res");
if [ "$runner_res" == "" ]; then
echo "Runner.res: not found";
else
echo "Runner.res: $runner_res";
cp $runner_res ./libs/portable/Runner.res;
echo "list ./libs/portable/Runner.res";
ls -l ./libs/portable/Runner.res;
fi
- name: Download RustDeskTempTopMostWindow artifacts
uses: actions/download-artifact@v4
if: env.UPLOAD_ARTIFACT == 'true'
with:
name: topmostwindow-artifacts
path: "./rustdesk"
- name: zip dlls
continue-on-error: true
shell: pwsh
run: |
Compress-Archive -Path ./rustdesk/*.dll, ./rustdesk/*.exe -DestinationPath ./rustdesk/unsigned_files.zip -CompressionLevel Fastest
- name: sign dlls
continue-on-error: true
shell: bash
run: |
if [ ! -z "${{ secrets.SIGN_BASE_URL }}" ] && [ ! -z "${{ secrets.SIGN_API_KEY }}" ]; then
curl -X POST -F "file=@./rustdesk/unsigned_files.zip" \
-H "X-API-KEY: ${{ secrets.SIGN_API_KEY }}" \
-m 900 \
"${{ secrets.SIGN_BASE_URL }}/sign/" -o ./rustdesk/signed_files.zip
else
echo "Signing skipped - signing URL or API key not configured"
cp ./rustdesk/unsigned_files.zip ./rustdesk/signed_files.zip
fi
- name: unzip dlls
continue-on-error: true
shell: pwsh
run: |
Expand-Archive -Path ./rustdesk/signed_files.zip -DestinationPath ./rustdesk/ -Force
Remove-Item ./rustdesk/unsigned_files.zip
Remove-Item ./rustdesk/signed_files.zip
- name: Create custom.txt file
shell: bash
run: |
echo -n "${{ env.custom }}" | cat > ./rustdesk/custom_.txt
- name: Build self-extracted executable
shell: bash
if: env.UPLOAD_ARTIFACT == 'true'
run: |
mv "./rustdesk/rustdesk.exe" "./rustdesk/${{ env.appname }}.exe" || echo "rustdesk.exe"
sed -i '/dpiAware/d' res/manifest.xml
pushd ./libs/portable
pip3 install -r requirements.txt
python3 ./generate.py -f ../../rustdesk/ -o . -e "../../rustdesk/${{ env.appname }}.exe"
popd
mkdir -p ./SignOutput
mv ./target/release/rustdesk-portable-packer.exe "./SignOutput/rustdesk.exe"
- name: Add MSBuild to PATH
uses: microsoft/setup-msbuild@v2
- name: Build msi
continue-on-error: true
if: env.UPLOAD_ARTIFACT == 'true'
run: |
$myappname = "${{ env.appname }}" -replace '\s','_'
cp "rustdesk/${{ env.appname }}.exe" "rustdesk/${myappname}.exe" -ErrorAction SilentlyContinue
pushd ./res/msi
python preprocess.py --app-name "$myappname" --arp -d ../../rustdesk
nuget restore msi.sln
msbuild msi.sln -p:Configuration=Release -p:Platform=x64 /p:TargetVersion=Windows10
cp ./Package/bin/x64/Release/en-us/Package.msi ../../SignOutput/rustdesk-latest.msi
mv ./Package/bin/x64/Release/en-us/Package.msi ../../SignOutput/rustdesk.msi
sha256sum ../../SignOutput/rustdesk.msi
- name: zip exe and msi
continue-on-error: true
shell: pwsh
run: |
Compress-Archive -Path ./SignOutput/*.exe, ./SignOutput/*.msi -DestinationPath ./SignOutput/unsigned_files.zip -CompressionLevel Fastest
- name: sign exe and msi
continue-on-error: true
shell: bash
run: |
if [ ! -z "${{ secrets.SIGN_BASE_URL }}" ] && [ ! -z "${{ secrets.SIGN_API_KEY }}" ]; then
curl -X POST -F "file=@./SignOutput/unsigned_files.zip" \
-H "X-API-KEY: ${{ secrets.SIGN_API_KEY }}" \
-m 900 \
"${{ secrets.SIGN_BASE_URL }}/sign/" -o ./SignOutput/signed_files.zip
else
echo "Signing skipped - signing URL or API key not configured"
cp ./SignOutput/unsigned_files.zip ./SignOutput/signed_files.zip
fi
- name: unzip exe and msi
continue-on-error: true
shell: pwsh
run: |
Expand-Archive -Path ./SignOutput/signed_files.zip -DestinationPath ./SignOutput/ -Force
Remove-Item ./SignOutput/unsigned_files.zip
Remove-Item ./SignOutput/signed_files.zip
- name: rename rustdesk.exe to filename.exe
run: |
mv ./SignOutput/rustdesk.exe "./SignOutput/${{ env.filename }}.exe" || echo "rustdesk"
- name: rename rustdesk.msi to filename.msi
continue-on-error: true
run: |
mv ./SignOutput/rustdesk.msi "./SignOutput/${{ env.filename }}.msi" || echo "rustdesk"
- name: send file to rdgen server
if: ${{ env.rdgen == 'true' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 15
shell: bash
command: |
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.exe" -F "uuid=${{ env.uuid }}" ${{ secrets.GENURL }}/save_custom_client
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.msi" -F "uuid=${{ env.uuid }}" ${{ secrets.GENURL }}/save_custom_client || true
- name: send file to api server
if: ${{ env.rdgen == 'false' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 15
shell: bash
command: |
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.exe" ${{ env.apiServer }}/api/save_custom_client
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.msi" ${{ env.apiServer }}/api/save_custom_client || true
cleanup:
needs: [build-for-windows-flutter]
runs-on: ubuntu-latest
continue-on-error: true
if: always()
steps:
- name: Delete secrets artifact
uses: geekyeggo/delete-artifact@v4
with:
name: encrypted-secrets-zip
+668
View File
@@ -0,0 +1,668 @@
# Gitea Actions 适配副本:由 .github/workflows 同名文件适配(本地路径/cache/artifact 差异),修改时请同步两边。差异说明见 setup.md「Gitea 部署附录」。
name: Custom Windows Client Generator
run-name: Custom Windows Client Generator
on:
workflow_dispatch:
inputs:
version:
description: 'version to buld'
required: true
default: ''
type: string
zip_url:
description: 'url to zip of json'
required: true
default: ''
type: string
env:
SCITER_RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503, also 1.78 has ABI change which causes our sciter version not working, https://blog.rust-lang.org/2024/03/30/i128-layout-update.html
RUST_VERSION: "1.75" # sciter failed on m1 with 1.78 because of https://blog.rust-lang.org/2024/03/30/i128-layout-update.html
CARGO_NDK_VERSION: "3.1.2"
SCITER_ARMV7_CMAKE_VERSION: "3.29.7"
SCITER_NASM_DEBVERSION: "2.15.05-1"
LLVM_VERSION: "15.0.6"
FLUTTER_VERSION: "3.24.5"
ANDROID_FLUTTER_VERSION: "3.24.5"
# for arm64 linux because official Dart SDK does not work
FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "${{ inputs.upload-tag }}"
VCPKG_BINARY_SOURCES: "clear;files,D:\vcpkg-cache,readwrite"
# vcpkg version: 2024.07.12
VCPKG_COMMIT_ID: "${{ inputs.version == 'master' && '9e593bb18ea69cc5095e012465dcd675a822ed0d' || '120deac3062162151622ca4860575a33844ba10b' }}"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836"
VERSION: "${{ inputs.version }}"
NDK_VERSION: "r28c"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
UPLOAD_ARTIFACT: 'true'
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}"
STATUS_URL: "${{ secrets.GENURL }}/updategh"
jobs:
setup:
uses: ./.gitea/workflows/fetch-encrypted-secrets.yml
with:
zip_url_json: ${{ inputs.zip_url }}
generate-bridge:
uses: ./.gitea/workflows/bridge.yml
with:
version: ${{ inputs.version }}
build-RustDeskTempTopMostWindow:
needs: setup
uses: ./.gitea/workflows/third-party-RustDeskTempTopMostWindow.yml
with:
upload-artifact: true
target: windows-2022
configuration: Release
platform: x64
target_version: Windows10
secrets: inherit
strategy:
fail-fast: false
build-for-windows-flutter:
name: Build Windows
needs: [build-RustDeskTempTopMostWindow, generate-bridge, setup]
runs-on: self-hosted
strategy:
fail-fast: false
matrix:
job:
# - { target: i686-pc-windows-msvc , os: windows-2022 }
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
- {
target: x86_64-pc-windows-msvc,
os: windows-2022,
arch: x86_64,
vcpkg-triplet: x64-windows-static,
}
# - { target: aarch64-pc-windows-msvc, os: windows-2022, arch: aarch64 }
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
path: .rdgen-src
- uses: actions/download-artifact@v4
with:
name: encrypted-secrets-zip
- name: Load Secrets
uses: ./.gitea/actions/decrypt-secrets
with:
zip_password: ${{ secrets.ZIP_PASSWORD }}
- name: Finalize and Cleanup zip/json
if: always() # Run even if previous steps fail
continue-on-error: true
uses: fjogeleit/http-request-action@v1
with:
url: "${{ secrets.GENURL }}/cleanzip"
method: 'POST'
customHeaders: '{"Content-Type": "application/json"}'
data: '{"uuid": "${{ env.uuid }}"}'
- name: Set rdgen value
if: ${{ env.rdgen == 'true' }}
run: |
echo "STATUS_URL=${{ secrets.GENURL }}/updategh" >> $env:GITHUB_ENV
- name: Set rdgen value
if: ${{ env.rdgen == 'false' }}
run: |
echo "STATUS_URL=${{ env.apiServer }}/api/updategh" >> $env:GITHUB_ENV
- name: Checkout source code
if: ${{ env.VERSION != 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
ref: refs/tags/${{ env.VERSION }}
submodules: recursive
- name: Checkout source code
if: ${{ env.VERSION == 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
submodules: recursive
- name: Restore bridge files
uses: actions/download-artifact@v4
with:
name: bridge-artifact
path: ./
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Setup NuGet
uses: NuGet/setup-nuget@v2
with:
nuget-version: 'latest'
- name: change appname to custom
if: env.appname != 'rustdesk'
continue-on-error: true
shell: bash
run: |
# ./Cargo.toml
sed -i -e 's|description = "RustDesk Remote Desktop"|description = "${{ env.appname }}"|' ./Cargo.toml
sed -i -e 's|ProductName = "RustDesk"|ProductName = "${{ env.appname }}"|' ./Cargo.toml
sed -i -e 's|FileDescription = "RustDesk Remote Desktop"|FileDescription = "${{ env.appname }}"|' ./Cargo.toml
sed -i -e 's|OriginalFilename = "rustdesk.exe"|OriginalFilename = "${{ env.appname }}.exe"|' ./Cargo.toml
# ./libs/portable/Cargo.toml
sed -i -e 's|description = "RustDesk Remote Desktop"|description = "${{ env.appname }}"|' ./libs/portable/Cargo.toml
sed -i -e 's|ProductName = "RustDesk"|ProductName = "${{ env.appname }}"|' ./libs/portable/Cargo.toml
sed -i -e 's|FileDescription = "RustDesk Remote Desktop"|FileDescription = "${{ env.appname }}"|' ./libs/portable/Cargo.toml
sed -i -e 's|OriginalFilename = "rustdesk.exe"|OriginalFilename = "${{ env.appname }}.exe"|' ./libs/portable/Cargo.toml
# ./libs/portable/src/main.rs
sed -i -e 's|const APP_PREFIX: \&str = "rustdesk";|const APP_PREFIX: \&str = "${{ env.appname }}";|' ./libs/portable/src/main.rs
# ./flutter/windows/runner/Runner.rc
sed -i -e 's|"RustDesk Remote Desktop"|"${{ env.appname }}"|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|VALUE "InternalName", "rustdesk" "\0"|VALUE "InternalName", "${{ env.appname }}" "\0"|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|"rustdesk.exe"|"${{ env.filename }}"|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|"RustDesk"|"${{ env.appname }}"|' ./flutter/windows/runner/Runner.rc
# ./src/lang/en.rs
# change powered by rustdek to powered by compname
if [ ! -z "${{ env.compname }}" ]; then
find ./src/lang -name "*.rs" -exec sed -i '/powered_by_me/s|RustDesk|${{ env.compname }}|g' {} \;
fi
find ./src/lang -name "*.rs" -exec sed -i -e 's|RustDesk|${{ env.appname }}|' {} \;
sed -i -e 's|RustDesk|${{ env.appname }}|' ./res/msi/Package/License.rtf
- name: fix registry if appname has a space
if: contains(env.appname, ' ')
continue-on-error: true
shell: bash
run: |
#./src/platform/windows.rs
sed -i -e 's|reg add {}|reg add \\\"{}\\\"|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext} /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\DefaultIcon /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\DefaultIcon\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open\\\\command|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open\\\\command\\\"|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext} /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext}\\\\shell /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open\\\\command /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open\\\\command\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|{subkey}|\\\"{subkey}\\\"|' ./src/platform/windows.rs
sed -i -e 's|reg delete HKEY_CLASSES_ROOT\\\\.{ext} /f|reg delete \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg delete HKEY_CLASSES_ROOT\\\\{ext} /f|reg delete \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\" /f|' ./src/platform/windows.rs
- name: change company name
if: env.compname != 'Purslane Ltd'
continue-on-error: true
shell: bash
run: |
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./res/msi/preprocess.py
sed -i -e 's|r"Purslane(?: Tech Pte\\.)? Ltd"|"${{ env.compname }}"|' ./res/msi/preprocess.py
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./Cargo.toml
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./libs/portable/Cargo.toml
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./res/msi/Package/License.rtf
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./src/main.rs
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e 's|PURSLANE|${{ env.compname }}|' ./res/msi/preprocess.py
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./res/msi/preprocess.py
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./Cargo.toml
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./libs/portable/Cargo.toml
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./res/msi/Package/License.rtf
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./src/main.rs
- name: change url to custom
if: env.urlLink != 'https://rustdesk.com'
continue-on-error: true
shell: bash
run: |
sed -i -e 's|Homepage: https://rustdesk.com|Homepage: ${{ env.urlLink }}|' ./build.py
sed -i -e "s|launchUrl(Uri.parse('https://rustdesk.com'));|launchUrl(Uri.parse('${{ env.urlLink }}'));|" ./flutter/lib/common.dart
sed -i -e "s|launchUrlString('https://rustdesk.com');|launchUrlString('${{ env.urlLink }}');|" ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e "s|launchUrlString('https://rustdesk.com/privacy.html')|launchUrlString('${{ env.urlLink }}/privacy.html')|" ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e "s|const url = 'https://rustdesk.com/';|const url = '${{ env.urlLink }}';|" ./flutter/lib/mobile/pages/settings_page.dart
sed -i -e "s|launchUrlString('https://rustdesk.com/privacy.html')|launchUrlString('${{ env.urlLink }}/privacy.html')|" ./flutter/lib/mobile/pages/settings_page.dart
sed -i -e "s|https://rustdesk.com/privacy.html|${{ env.urlLink }}/privacy.html|" ./flutter/lib/desktop/pages/install_page.dart
sed -i -e "s|rustdesk.com|${{ env.urlLink }}|" ./res/msi/Package/License.rtf
- name: change download link to custom
if: env.downloadLink != 'https://rustdesk.com/download'
continue-on-error: true
shell: bash
run: |
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./flutter/lib/desktop/pages/desktop_home_page.dart
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./flutter/lib/mobile/pages/connection_page.dart
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./src/ui/index.tis
sed -i -e 's|&download_url|"${{ env.downloadLink }}"|' ./src/updater.rs
sed -i -e 's|$downloadUrl/$downloadFile|${{ env.downloadLink }}|' ./flutter/lib/desktop/widgets/update_progress.dart
- name: set server, serverPort, key, and apiserver
continue-on-error: true
shell: bash
env:
SERVER_DOMAIN: ${{ env.server }}
SERVER_PORT_INPUT: ${{ env.serverPort }}
SERVER_KEY: ${{ env.key }}
API_SERVER: ${{ env.apiServer }}
run: |
# Pass secrets via bash env vars to avoid quoting issues
if [ ! -f "./libs/hbb_common/src/config.rs" ]; then
echo "Error: config.rs not found!"
exit 1
fi
if [ ! -f "./src/common.rs" ]; then
echo "Error: common.rs not found!"
exit 1
fi
# Port must be a number; views.py defaults it to 21116
if ! [[ "$SERVER_PORT_INPUT" =~ ^[0-9]+$ ]]; then
echo "Error: serverPort must be a number, got: '$SERVER_PORT_INPUT'"
exit 1
fi
# Derive the port block from the rendezvous port
# (defaults 21116/21117/21118/21119)
SERVER_PORT=$SERVER_PORT_INPUT
RELAY_PORT=$((SERVER_PORT + 1))
WS_RENDEZVOUS_PORT=$((RELAY_PORT + 1))
WS_RELAY_PORT=$((WS_RENDEZVOUS_PORT + 1))
echo "Setting server: $SERVER_DOMAIN"
echo "Setting ports: $SERVER_PORT, $RELAY_PORT, $WS_RENDEZVOUS_PORT, $WS_RELAY_PORT"
sed -i -e "s|rs-ny.rustdesk.com|$SERVER_DOMAIN|" ./libs/hbb_common/src/config.rs
# Match on numeric value so the step is independent of defaults
sed -i -e "s|pub const RENDEZVOUS_PORT: i32 = [0-9][0-9]*;|pub const RENDEZVOUS_PORT: i32 = $SERVER_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const RELAY_PORT: i32 = [0-9][0-9]*;|pub const RELAY_PORT: i32 = $RELAY_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const WS_RENDEZVOUS_PORT: i32 = [0-9][0-9]*;|pub const WS_RENDEZVOUS_PORT: i32 = $WS_RENDEZVOUS_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const WS_RELAY_PORT: i32 = [0-9][0-9]*;|pub const WS_RELAY_PORT: i32 = $WS_RELAY_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw=|$SERVER_KEY|" ./libs/hbb_common/src/config.rs
sed -i -e "s|https://admin.rustdesk.com|$API_SERVER|" ./src/common.rs
echo "=== Verification ==="
grep -nE "RENDEZVOUS_PORT|RELAY_PORT|WS_" ./libs/hbb_common/src/config.rs
# ./flutter/pubspec.yaml
#sed -i '/intl:/a \ \ archive: ^3.6.1' ./flutter/pubspec.yaml
- name: allow custom.txt
uses: nick-fields/retry@v3
with:
timeout_minutes: 1
max_attempts: 3
shell: pwsh
command: |
Copy-Item .rdgen-src/.github/patches/allowCustom.py allowCustom.py
python allowCustom.py
# Remove Setup Server Tip
Copy-Item .rdgen-src/.github/patches/removeSetupServerTip.diff removeSetupServerTip.diff
git apply removeSetupServerTip.diff
- name: Patch remote printer port to spool file
shell: python
run: |
import os, re
src = "."
app = os.environ.get("appname") or "RustDesk"
literal = "C:\\\\ProgramData\\\\%s\\\\printer-spool\\\\job.prn" % app
# atch Rust remote_printer lib.rs
rs = os.path.join(src, "libs", "remote_printer", "src", "lib.rs")
if os.path.isfile(rs):
with open(rs, "r", encoding="utf-8") as f:
t = f.read()
if "printer-spool" in t:
print("Rust printer port already points to spool file")
else:
old = ('fn get_port_name(app_name: &str) -> Vec<u16> {\n'
' format!("{} Printer", app_name)')
new = ('fn get_port_name(app_name: &str) -> Vec<u16> {\n'
' let base = std::env::var("ProgramData")\n'
' .unwrap_or_else(|_| "C:\\\\ProgramData".to_string());\n'
' let dir = format!("{}\\\\{}\\\\printer-spool", base, app_name);\n'
' let _ = std::fs::create_dir_all(&dir);\n'
' format!("{}\\\\job.prn", dir)')
if old in t:
with open(rs, "w", encoding="utf-8") as f:
f.write(t.replace(old, new, 1))
print("Successfully patched Rust printer port")
else:
print("Warning: get_port_name() signature not matched in Rust source")
# patch C++ MSI Custom Action
cpp = os.path.join(src, "res", "msi", "CustomActions", "RemotePrinter.cpp")
if os.path.isfile(cpp):
with open(cpp, "r", encoding="utf-8") as f:
t = f.read()
if "printer-spool" in t:
print("MSI printer port already points to spool file")
else:
pattern = r'(RD_PRINTER_PORT\s*=\s*)L"[^"]*"'
new_t, n = re.subn(pattern, lambda m: m.group(1) + 'L"%s"' % literal, t, count=1)
if n == 1:
with open(cpp, "w", encoding="utf-8") as f:
f.write(new_t)
print("Successfully patched MSI CustomAction printer port")
else:
print("Warning: RD_PRINTER_PORT not matched in RemotePrinter.cpp")
- name: magick stuff
if: ${{ env.iconlink_url != 'false' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 1
max_attempts: 3
shell: pwsh
command: |
Invoke-WebRequest -Uri ${{ env.iconlink_url }}/get_png?filename=${{ env.iconlink_file }}"&"uuid=${{ env.iconlink_uuid }} -OutFile ./res/iconx.png
mv ./res/icon.ico ./res/icon.ico.bak
mv ./res/icon.png ./res/icon.png.bak
mv ./res/tray-icon.ico ./res/tray-icon.ico.bak
mv ./res/iconx.png ./res/icon.png
mv ./res/32x32.png ./res/32x32.png.bak
mv ./res/64x64.png ./res/64x64.png.bak
mv ./res/128x128.png ./res/128x128.png.bak
mv ./res/128x128@2x.png ./res/128x128@2x.png.bak
magick ./res/icon.png -define icon:auto-resize=256,64,48,32,16 ./res/icon.ico
cp ./res/icon.ico ./res/tray-icon.ico
magick ./res/icon.png -resize 32x32 ./res/32x32.png
magick ./res/icon.png -resize 64x64 ./res/64x64.png
magick ./res/icon.png -resize 128x128 ./res/128x128.png
magick ./res/128x128.png -resize 200% ./res/128x128@2x.png
- name: ui.rs icon
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: true
shell: bash
run: |
cp ./src/ui.rs ./src/ui.rs.bak
b64=$(base64 -w0 < ./res/icon.png)
perl -0777 -pe "s|iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHL[A-Za-z0-9+/=]+|$b64|g" -i.bak ./src/ui.rs
b64=""
- name: fix connection delay
continue-on-error: true
if: ${{ env.delayFix == 'true' }}
shell: bash
run: |
# Precise fix: only extend the direct-connection condition with the
# key check, instead of globally replacing !key.is_empty() with
# false (which would also disable TCP encryption and UDP logic).
sed -i -e 's#if is_local || peer_nat_type == NatType::SYMMETRIC {#if is_local || peer_nat_type == NatType::SYMMETRIC || !key.is_empty() {#' ./src/client.rs
grep -n "key.is_empty()" ./src/client.rs || true
- name: use X for offline display instead of orange circle
if: env.xOffline == 'true'
uses: nick-fields/retry@v3
with:
timeout_minutes: 1
max_attempts: 3
shell: pwsh
continue_on_error: true
command: |
Copy-Item .rdgen-src/.github/patches/xoffline.diff xoffline.diff
git apply xoffline.diff
- name: removeNewVersionNotif
continue-on-error: true
if: env.removeNewVersionNotif == 'true'
shell: bash
run: |
sed -i -e 's|updateUrl.isNotEmpty|false|' ./flutter/lib/desktop/pages/desktop_home_page.dart
sed -i '/let (request, url) =/,/Ok(())/{/Ok(())/!d}' ./src/common.rs
- name: replace flutter icons
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: true
run: |
cd ./flutter
#flutter pub upgrade win32
flutter pub get
flutter pub run flutter_launcher_icons
cd ..
- name: Checkout printer-adapter crate
uses: actions/checkout@v4
with:
repository: ${{ github.repository }}
ref: ${{ github.ref_name }}
path: ./temp-repo
- name: Move printer-adapter into place
shell: pwsh
run: |
if (Test-Path "./temp-repo/printer-adapter") {
Move-Item -Path "./temp-repo/printer-adapter" -Destination "./printer-adapter" -Force
Remove-Item -Recurse -Force "./temp-repo"
Write-Host "Successfully placed printer-adapter into workspace."
} else {
Write-Host "Error: printer-adapter folder not found in repository."
}
- name: Build rustdesk
run: |
# Windows: build RustDesk
python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack
mv ./flutter/build/windows/x64/runner/Release ./rustdesk
# Download usbmmidd_v2.zip and extract it to ./rustdesk
Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip
Expand-Archive usbmmidd_v2.zip -DestinationPath . -Force
Remove-Item -Path usbmmidd_v2\Win32 -Recurse
Remove-Item -Path "usbmmidd_v2\deviceinstaller64.exe", "usbmmidd_v2\deviceinstaller.exe", "usbmmidd_v2\usbmmidd.bat"
mv -Force .\usbmmidd_v2 ./rustdesk
# Download and install driver package
try {
Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/rustdesk_printer_driver_v4-1.4.zip -OutFile rustdesk_printer_driver_v4-1.4.zip
Expand-Archive rustdesk_printer_driver_v4-1.4.zip -DestinationPath .
mkdir ./rustdesk/drivers -ErrorAction SilentlyContinue
mv -Force .\rustdesk_printer_driver_v4-1.4 ./rustdesk/drivers/RustDeskPrinterDriver
} catch {
Write-Host "Warning: Printer driver download failed."
}
# Build and install open printer adapter
if (Test-Path "./printer-adapter/Cargo.toml") {
Write-Host "Building open printer-adapter..."
pushd ./printer-adapter
cargo build --release --locked
popd
if (Test-Path "./printer-adapter/target/release/printer_driver_adapter.dll") {
cp -Force "./printer-adapter/target/release/printer_driver_adapter.dll" "./rustdesk/printer_driver_adapter.dll"
Write-Host "Replaced printer_driver_adapter.dll with open implementation."
} else {
Write-Host "Error: printer-adapter build succeeded but DLL was not found."
}
} else {
Write-Host "Warning: ./printer-adapter/Cargo.toml not found, remote printing will remain disabled."
}
- name: icon stuff
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: true
run: |
mv ./rustdesk/data/flutter_assets/assets/icon.svg ./rustdesk/data/flutter_assets/assets/icon.svg.bak
magick ./res/icon.png ./rustdesk/data/flutter_assets/assets/icon.svg
- name: logo stuff
if: ${{ env.logolink_url != 'false' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 1
max_attempts: 3
shell: pwsh
continue_on_error: true
command: |
Invoke-WebRequest -Uri ${{ env.logolink_url }}/get_png?filename=${{ env.logolink_file }}"&"uuid=${{ env.logolink_uuid }} -OutFile ./rustdesk/data/flutter_assets/assets/logo.png
- name: find Runner.res
# Windows: find Runner.res (compiled from ./flutter/windows/runner/Runner.rc), copy to ./Runner.res
# Runner.rc does not contain actual version, but Runner.res does
continue-on-error: true
shell: bash
run: |
runner_res=$(find . -name "Runner.res");
if [ "$runner_res" == "" ]; then
echo "Runner.res: not found";
else
echo "Runner.res: $runner_res";
cp $runner_res ./libs/portable/Runner.res;
echo "list ./libs/portable/Runner.res";
ls -l ./libs/portable/Runner.res;
fi
- name: Download RustDeskTempTopMostWindow artifacts
uses: actions/download-artifact@v4
if: env.UPLOAD_ARTIFACT == 'true'
with:
name: topmostwindow-artifacts
path: "./rustdesk"
- name: zip dlls
continue-on-error: true
shell: pwsh
run: |
Compress-Archive -Path ./rustdesk/*.dll, ./rustdesk/*.exe -DestinationPath ./rustdesk/unsigned_files.zip -CompressionLevel Fastest
- name: sign dlls
continue-on-error: true
shell: bash
run: |
if [ ! -z "${{ secrets.SIGN_BASE_URL }}" ] && [ ! -z "${{ secrets.SIGN_API_KEY }}" ]; then
curl -X POST -F "file=@./rustdesk/unsigned_files.zip" \
-H "X-API-KEY: ${{ secrets.SIGN_API_KEY }}" \
-m 900 \
"${{ secrets.SIGN_BASE_URL }}/sign/" -o ./rustdesk/signed_files.zip
else
echo "Signing skipped - signing URL or API key not configured"
cp ./rustdesk/unsigned_files.zip ./rustdesk/signed_files.zip
fi
- name: unzip dlls
continue-on-error: true
shell: pwsh
run: |
Expand-Archive -Path ./rustdesk/signed_files.zip -DestinationPath ./rustdesk/ -Force
Remove-Item ./rustdesk/unsigned_files.zip
Remove-Item ./rustdesk/signed_files.zip
- name: Create custom.txt file
shell: bash
run: |
echo -n "${{ env.custom }}" | cat > ./rustdesk/custom_.txt
- name: Build self-extracted executable
shell: bash
if: env.UPLOAD_ARTIFACT == 'true'
run: |
mv "./rustdesk/rustdesk.exe" "./rustdesk/${{ env.appname }}.exe" || echo "rustdesk.exe"
sed -i '/dpiAware/d' res/manifest.xml
pushd ./libs/portable
pip3 install -r requirements.txt
python3 ./generate.py -f ../../rustdesk/ -o . -e "../../rustdesk/${{ env.appname }}.exe"
popd
mkdir -p ./SignOutput
mv ./target/release/rustdesk-portable-packer.exe "./SignOutput/rustdesk.exe"
- name: Add MSBuild to PATH
uses: microsoft/setup-msbuild@v2
- name: Build msi
continue-on-error: true
if: env.UPLOAD_ARTIFACT == 'true'
run: |
$myappname = "${{ env.appname }}" -replace '\s','_'
cp "rustdesk/${{ env.appname }}.exe" "rustdesk/${myappname}.exe" -ErrorAction SilentlyContinue
pushd ./res/msi
python preprocess.py --app-name "$myappname" --arp -d ../../rustdesk
nuget restore msi.sln
msbuild msi.sln -p:Configuration=Release -p:Platform=x64 /p:TargetVersion=Windows10
cp ./Package/bin/x64/Release/en-us/Package.msi ../../SignOutput/rustdesk-latest.msi
mv ./Package/bin/x64/Release/en-us/Package.msi ../../SignOutput/rustdesk.msi
sha256sum ../../SignOutput/rustdesk.msi
- name: zip exe and msi
continue-on-error: true
shell: pwsh
run: |
Compress-Archive -Path ./SignOutput/*.exe, ./SignOutput/*.msi -DestinationPath ./SignOutput/unsigned_files.zip -CompressionLevel Fastest
- name: sign exe and msi
continue-on-error: true
shell: bash
run: |
if [ ! -z "${{ secrets.SIGN_BASE_URL }}" ] && [ ! -z "${{ secrets.SIGN_API_KEY }}" ]; then
curl -X POST -F "file=@./SignOutput/unsigned_files.zip" \
-H "X-API-KEY: ${{ secrets.SIGN_API_KEY }}" \
-m 900 \
"${{ secrets.SIGN_BASE_URL }}/sign/" -o ./SignOutput/signed_files.zip
else
echo "Signing skipped - signing URL or API key not configured"
cp ./SignOutput/unsigned_files.zip ./SignOutput/signed_files.zip
fi
- name: unzip exe and msi
continue-on-error: true
shell: pwsh
run: |
Expand-Archive -Path ./SignOutput/signed_files.zip -DestinationPath ./SignOutput/ -Force
Remove-Item ./SignOutput/unsigned_files.zip
Remove-Item ./SignOutput/signed_files.zip
- name: rename rustdesk.exe to filename.exe
run: |
mv ./SignOutput/rustdesk.exe "./SignOutput/${{ env.filename }}.exe" || echo "rustdesk"
- name: rename rustdesk.msi to filename.msi
continue-on-error: true
run: |
mv ./SignOutput/rustdesk.msi "./SignOutput/${{ env.filename }}.msi" || echo "rustdesk"
- name: send file to rdgen server
if: ${{ env.rdgen == 'true' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 1
max_attempts: 3
shell: bash
command: |
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.exe" -F "uuid=${{ env.uuid }}" ${{ secrets.GENURL }}/save_custom_client
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.msi" -F "uuid=${{ env.uuid }}" ${{ secrets.GENURL }}/save_custom_client || true
- name: send file to api server
if: ${{ env.rdgen == 'false' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 1
max_attempts: 3
shell: bash
command: |
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.exe" ${{ env.apiServer }}/api/save_custom_client
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.msi" ${{ env.apiServer }}/api/save_custom_client || true
cleanup:
needs: [build-for-windows-flutter]
runs-on: ubuntu-latest
continue-on-error: true
if: always()
steps:
- name: Delete secrets artifact
uses: geekyeggo/delete-artifact@v4
with:
name: encrypted-secrets-zip
@@ -0,0 +1,96 @@
# Gitea Actions 适配副本:由 .github/workflows 同名文件适配(本地路径/cache/artifact 差异),修改时请同步两边。差异说明见 setup.md「Gitea 部署附录」。
name: build RustDeskTempTopMostWindow
on:
workflow_call:
inputs:
upload-artifact:
type: boolean
default: true
target:
description: 'Target'
required: true
type: string
default: 'windows-2022'
configuration:
description: 'Configuration'
required: true
type: string
default: 'Release'
platform:
description: 'Platform'
required: true
type: string
default: 'x64'
target_version:
description: 'TargetVersion'
required: true
type: string
default: 'Windows10'
env:
project_path: WindowInjection/WindowInjection.vcxproj
jobs:
build-RustDeskTempTopMostWindow:
runs-on: ${{ inputs.target }}
strategy:
fail-fast: false
env:
build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }}
steps:
- name: Add MSBuild to PATH
uses: microsoft/setup-msbuild@v3
- name: Checkout Repository
uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: encrypted-secrets-zip
- name: Load Secrets
uses: ./.gitea/actions/decrypt-secrets
with:
zip_password: ${{ secrets.ZIP_PASSWORD }}
- name: Finalize and Cleanup zip/json
if: always() # Run even if previous steps fail
continue-on-error: true
uses: fjogeleit/http-request-action@v1
with:
url: "${{ secrets.GENURL }}/cleanzip"
method: 'POST'
customHeaders: '{"Content-Type": "application/json"}'
data: '{"uuid": "${{ env.uuid }}"}'
- name: Download the source code
run: |
git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow
# Build. commit 53b548a5398624f7149a382000397993542ad796 is tag v0.3
- name: Build the project
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
shell: pwsh
command: |
cd RustDeskTempTopMostWindow && git checkout 53b548a5398624f7149a382000397993542ad796
if ($env:privacylink_url-ne "false") {
Invoke-WebRequest -Uri ${{ env.privacylink_url }}/get_png?filename=${{ env.privacylink_file }}"&"uuid=${{ env.privacylink_uuid }} -OutFile privacy.png
Copy-Item ../.github/patches/privacyScreen.py privacyScreen.py
python privacyScreen.py
rm ./WindowInjection/img.cpp
mv img.cpp ./WindowInjection/img.cpp
}
msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }}
- name: Archive build artifacts
uses: actions/upload-artifact@v4
if: ${{ inputs.upload-artifact }}
with:
name: topmostwindow-artifacts
path: |
./${{ env.build_output_dir }}/WindowInjection.dll
@@ -0,0 +1,48 @@
name: 'Decrypt and Mask Secrets'
description: 'Decrypts a zip and masks the JSON contents as env vars'
inputs:
zip_password:
description: 'Password for the Zip'
required: true
zip_path:
description: 'Path to the encrypted zip'
required: false
default: 'secrets.zip'
runs:
using: "composite"
steps:
- name: install python deps
shell: bash
run: |
pip install pyzipper
- name: Decrypt and Mask
shell: python
env:
PYTHONUTF8: "1"
PYTHONIOENCODING: "utf-8"
run: |
import sys
import io
import pyzipper
import json
import os
# Force UTF-8 stdout/stderr so secrets with characters outside
# the runner codepage (CJK, emoji, etc.) do not crash this step
# on Windows runners (default cp1251) with UnicodeEncodeError
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
with pyzipper.AESZipFile('${{ inputs.zip_path }}') as zf:
zf.setpassword('${{ inputs.zip_password }}'.encode())
with zf.open('secrets.json') as f:
secrets = json.load(f)
with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as env_file:
for key, value in secrets.items():
if value:
print(f"::add-mask::{value}")
env_file.write(f"{key}={value}\n")
print(f"Successfully masked {len(secrets)} secrets.")
+29
View File
@@ -0,0 +1,29 @@
diff --git a/src/common.rs b/src/common.rs
index 56361a5da..92b221e9a 100644
--- a/src/common.rs
+++ b/src/common.rs
@@ -1474,15 +1474,15 @@ pub fn read_custom_client(config: &str) {
log::error!("Failed to decode custom client config");
return;
};
- const KEY: &str = "5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=";
- let Some(pk) = get_rs_pk(KEY) else {
- log::error!("Failed to parse public key of custom client");
- return;
- };
- let Ok(data) = sign::verify(&data, &pk) else {
- log::error!("Failed to dec custom client config");
- return;
- };
+ // const KEY: &str = "5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=";
+ // let Some(pk) = get_rs_pk(KEY) else {
+ // log::error!("Failed to parse public key of custom client");
+ // return;
+ // };
+ // let Ok(data) = sign::verify(&data, &pk) else {
+ // log::error!("Failed to dec custom client config");
+ // return;
+ // };
let Ok(mut data) =
serde_json::from_slice::<std::collections::HashMap<String, serde_json::Value>>(&data)
else {
+63
View File
@@ -0,0 +1,63 @@
import os
import shutil
def remove_line_block(filepath, start_phrase, lines_to_remove_after_start):
"""
Removes a starting line and a fixed number of lines immediately following it.
:param filepath: The path to the file to modify.
:param start_phrase: The unique string to identify the first line of the block.
:param lines_to_remove_after_start: The number of lines to remove after the starting line.
"""
# 1. Configuration for the removal logic
# The starting line is: const KEY: &str = "5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=";
# The block contains this line plus 8 following lines, so we want to skip 9 lines in total.
total_lines_to_skip = 1 + lines_to_remove_after_start # 1 (start line) + 8 (following lines) = 9
lines_to_keep = []
skip_count = 0
# 2. Read and filter the file content
try:
with open(filepath, 'r') as file:
for line in file:
# If we are currently in the process of skipping lines, decrement the counter and continue
if skip_count > 0:
skip_count -= 1
continue
# Check if the line matches the start phrase (we use .strip() to ignore indentation/whitespace)
if line.strip().startswith(start_phrase.strip()):
# Start skipping the block (including the current line)
skip_count = total_lines_to_skip - 1
# Note: We subtract 1 because the 'continue' will handle the first line removal immediately
continue
# If we are not skipping, keep the line, but change custom.txt to custom_.txt
line = line.replace("custom.txt", "custom_.txt")
lines_to_keep.append(line)
except FileNotFoundError:
print(f"Error: File not found at {filepath}")
return
# 3. Write the remaining lines back to the file
try:
with open(filepath, 'w') as file:
file.writelines(lines_to_keep)
print(f"Success! Removed the 9-line block starting with '{start_phrase.strip()}' from {filepath}.")
except IOError as e:
print(f"An error occurred while writing to the file: {e}")
def main():
file_path = 'src/common.rs'
start_phrase = 'const KEY: &str = "5Qbwsde3unUcJBtrx9ZkvUmwFNoExHzpryHuPUdqlWM=";'
lines_to_remove_after_start = 8
remove_line_block(file_path, start_phrase, lines_to_remove_after_start)
if __name__ == "__main__":
main()
@@ -0,0 +1,42 @@
diff --git a/packages/flutter/lib/src/material/dropdown_menu.dart b/packages/flutter/lib/src/material/dropdown_menu.dart
index 7e634cd2aa..c1e9acc295 100644
--- a/packages/flutter/lib/src/material/dropdown_menu.dart
+++ b/packages/flutter/lib/src/material/dropdown_menu.dart
@@ -475,7 +475,7 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
final GlobalKey _leadingKey = GlobalKey();
late List<GlobalKey> buttonItemKeys;
final MenuController _controller = MenuController();
- late bool _enableFilter;
+ bool _enableFilter = false;
late List<DropdownMenuEntry<T>> filteredEntries;
List<Widget>? _initialMenu;
int? currentHighlight;
@@ -524,6 +524,11 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
}
_localTextEditingController = widget.controller ?? TextEditingController();
}
+ if (oldWidget.enableFilter != widget.enableFilter) {
+ if (!widget.enableFilter) {
+ _enableFilter = false;
+ }
+ }
if (oldWidget.enableSearch != widget.enableSearch) {
if (!widget.enableSearch) {
currentHighlight = null;
@@ -663,6 +668,7 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
);
currentHighlight = widget.enableSearch ? i : null;
widget.onSelected?.call(entry.value);
+ _enableFilter = false;
}
: null,
requestFocusOnHover: false,
@@ -735,6 +741,8 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
if (_enableFilter) {
filteredEntries = widget.filterCallback?.call(filteredEntries, _localTextEditingController!.text)
?? filter(widget.dropdownMenuEntries, _localTextEditingController!);
+ } else {
+ filteredEntries = widget.dropdownMenuEntries;
}
if (widget.enableSearch) {
+129
View File
@@ -0,0 +1,129 @@
diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart
index 56a99446c..5991aceed 100644
--- a/flutter/lib/desktop/pages/desktop_setting_page.dart
+++ b/flutter/lib/desktop/pages/desktop_setting_page.dart
@@ -1101,8 +1101,8 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
if (usePassword)
_SubButton('Set permanent password', setPasswordDialog,
permEnabled && !locked),
- // if (usePassword)
- // hide_cm(!locked).marginOnly(left: _kContentHSubMargin - 6),
+ if (usePassword)
+ hide_cm(!locked).marginOnly(left: _kContentHSubMargin - 6),
if (usePassword) radios[2],
]);
})));
diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart
index 3032a2321..66d0d8c26 100644
--- a/flutter/lib/main.dart
+++ b/flutter/lib/main.dart
@@ -245,7 +245,7 @@ void runConnectionManagerScreen() async {
MyTheme.currentThemeMode(),
);
final hide = await bind.cmGetConfig(name: "hide_cm") == 'true';
- gFFI.serverModel.hideCm = hide;
+ // gFFI.serverModel.hideCm = hide;
if (hide) {
await hideCmWindow(isStartup: true);
} else {
diff --git a/flutter/lib/models/server_model.dart b/flutter/lib/models/server_model.dart
index 877576461..4efd6c8b0 100644
--- a/flutter/lib/models/server_model.dart
+++ b/flutter/lib/models/server_model.dart
@@ -32,7 +32,7 @@ class ServerModel with ChangeNotifier {
bool _fileOk = false;
bool _clipboardOk = false;
bool _showElevation = false;
- bool hideCm = false;
+ bool _hideCm = false;
int _connectStatus = 0; // Rendezvous Server status
String _verificationMethod = "";
String _temporaryPasswordLength = "";
@@ -62,6 +62,8 @@ class ServerModel with ChangeNotifier {
bool get clipboardOk => _clipboardOk;
+ bool get hideCm => _hideCm;
+
bool get showElevation => _showElevation;
int get connectStatus => _connectStatus;
@@ -82,12 +84,11 @@ class ServerModel with ChangeNotifier {
setVerificationMethod(String method) async {
await bind.mainSetOption(key: kOptionVerificationMethod, value: method);
- /*
+
if (method != kUsePermanentPassword) {
await bind.mainSetOption(
key: 'allow-hide-cm', value: bool2option('allow-hide-cm', false));
}
- */
}
String get temporaryPasswordLength {
@@ -104,12 +105,11 @@ class ServerModel with ChangeNotifier {
setApproveMode(String mode) async {
await bind.mainSetOption(key: kOptionApproveMode, value: mode);
- /*
+
if (mode != 'password') {
await bind.mainSetOption(
key: 'allow-hide-cm', value: bool2option('allow-hide-cm', false));
}
- */
}
TextEditingController get serverId => _serverId;
@@ -126,7 +126,6 @@ class ServerModel with ChangeNotifier {
_emptyIdShow = translate("Generating ...");
_serverId = IDTextEditingController(text: _emptyIdShow);
- /*
// initital _hideCm at startup
final verificationMethod =
bind.mainGetOptionSync(key: kOptionVerificationMethod);
@@ -137,7 +136,6 @@ class ServerModel with ChangeNotifier {
verificationMethod == kUsePermanentPassword)) {
_hideCm = false;
}
- */
timerCallback() async {
final connectionStatus =
@@ -227,14 +225,14 @@ class ServerModel with ChangeNotifier {
final temporaryPasswordLength =
await bind.mainGetOption(key: "temporary-password-length");
final approveMode = await bind.mainGetOption(key: kOptionApproveMode);
- /*
+
var hideCm = option2bool(
'allow-hide-cm', await bind.mainGetOption(key: 'allow-hide-cm'));
if (!(approveMode == 'password' &&
verificationMethod == kUsePermanentPassword)) {
hideCm = false;
}
- */
+
if (_approveMode != approveMode) {
_approveMode = approveMode;
update = true;
@@ -265,7 +263,7 @@ class ServerModel with ChangeNotifier {
_temporaryPasswordLength = temporaryPasswordLength;
update = true;
}
- /*
+
if (_hideCm != hideCm) {
_hideCm = hideCm;
if (desktopType == DesktopType.cm) {
@@ -277,7 +275,7 @@ class ServerModel with ChangeNotifier {
}
update = true;
}
- */
+
if (update) {
notifyListeners();
}
+32
View File
@@ -0,0 +1,32 @@
import os
def convert_png_to_cpp(input_file, output_file, array_name="g_img"):
if not os.path.exists(input_file):
print(f"Error: {input_file} not found.")
return
with open(input_file, "rb") as f:
data = f.read()
with open(output_file, "w") as f:
f.write('#include "pch.h"\n')
f.write('#include "./img.h"\n\n')
f.write(f"const unsigned char {array_name}[] = {{\n")
for i in range(0, len(data), 20):
chunk = data[i : i + 20]
hex_chunk = [f"0x{b:02x}" for b in chunk]
line = ", ".join(hex_chunk)
if i + 20 < len(data):
f.write(f"{line},\n")
else:
f.write(f"{line}\n")
f.write("};\n\n")
f.write(f"const long long {array_name}Len = sizeof({array_name});\n")
#print(f"Successfully converted {input_file} to {output_file}")
convert_png_to_cpp("privacy.png", "img.cpp")
@@ -0,0 +1,41 @@
diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart
index ba724eed5..1604c429f 100644
--- a/flutter/lib/desktop/pages/desktop_home_page.dart
+++ b/flutter/lib/desktop/pages/desktop_home_page.dart
@@ -424,21 +424,21 @@ class _DesktopHomePageState extends State<DesktopHomePage>
}
Widget buildHelpCards(String updateUrl) {
- if (!bind.isCustomClient() &&
- updateUrl.isNotEmpty &&
- !isCardClosed &&
- bind.mainUriPrefixSync().contains('rustdesk')) {
- return buildInstallCard(
- "Status",
- "${translate("new-version-of-{${bind.mainGetAppNameSync()}}-tip")} (${bind.mainGetNewVersion()}).",
- "Click to download", () async {
- final Uri url = Uri.parse('https://rustdesk.com/download');
- await launchUrl(url);
- }, closeButton: true);
- }
- if (systemError.isNotEmpty) {
- return buildInstallCard("", systemError, "", () {});
- }
+ // if (!bind.isCustomClient() &&
+ // updateUrl.isNotEmpty &&
+ // !isCardClosed &&
+ // bind.mainUriPrefixSync().contains('rustdesk')) {
+ // return buildInstallCard(
+ // "Status",
+ // "${translate("new-version-of-{${bind.mainGetAppNameSync()}}-tip")} (${bind.mainGetNewVersion()}).",
+ // "Click to download", () async {
+ // final Uri url = Uri.parse('https://rustdesk.com/download');
+ // await launchUrl(url);
+ // }, closeButton: true);
+ // }
+ // if (systemError.isNotEmpty) {
+ // return buildInstallCard("", systemError, "", () {});
+ // }
if (isWindows && !bind.isDisableInstallation()) {
if (!bind.mainIsInstalled()) {
+13
View File
@@ -0,0 +1,13 @@
diff --git a/flutter/lib/desktop/pages/connection_page.dart b/flutter/lib/desktop/pages/connection_page.dart
index d9dc3eec4..76f386b76 100644
--- a/flutter/lib/desktop/pages/connection_page.dart
+++ b/flutter/lib/desktop/pages/connection_page.dart
@@ -131,7 +131,7 @@ class _OnlineStatusWidgetState extends State<OnlineStatusWidget> {
if (!isIncomingOnly) startServiceWidget(),
// ready && public
// No need to show the guide if is custom client.
- if (!isIncomingOnly) setupServerWidget(),
+ //if (!isIncomingOnly) setupServerWidget(),
],
);
+20
View File
@@ -0,0 +1,20 @@
diff --git a/flutter/lib/common/widgets/peer_card.dart b/flutter/lib/common/widgets/peer_card.dart
index 0a15eb45b..d776dd556 100644
--- a/flutter/lib/common/widgets/peer_card.dart
+++ b/flutter/lib/common/widgets/peer_card.dart
@@ -1328,8 +1328,13 @@ Widget getOnline(double rightPadding, bool online) {
waitDuration: const Duration(seconds: 1),
child: Padding(
padding: EdgeInsets.fromLTRB(0, 4, rightPadding, 4),
- child: CircleAvatar(
- radius: 3, backgroundColor: online ? Colors.green : kColorWarn)));
+ child: online
+ ? CircleAvatar(radius: 3, backgroundColor: Colors.green)
+ : Icon(
+ Icons.close, // Red X for offline (Material Icon)
+ color: Colors.red,
+ size: 12.0, // Adjust size as needed
+ )));
}
Widget build_more(BuildContext context, {bool invert = false}) {
+133
View File
@@ -0,0 +1,133 @@
# This yaml shares the build bridge steps with ci and nightly.
name: Build flutter-rust-bridge
# 2023-11-23 18:00:00+00:00
on:
workflow_call:
inputs:
version:
description: 'Rustdesk Version'
required: true
default: '1.3.1'
type: string
env:
CARGO_EXPAND_VERSION: "1.0.95"
FLUTTER_VERSION: "3.22.3"
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1"
RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503
jobs:
generate_bridge:
runs-on: ${{ matrix.job.os }}
strategy:
fail-fast: false
matrix:
job:
- {
target: x86_64-unknown-linux-gnu,
os: ubuntu-24.04,
extra-build-args: "",
}
steps:
- name: Checkout source code
if: ${{ inputs.version != 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
ref: refs/tags/${{ inputs.version }}
submodules: recursive
- name: Checkout source code
if: ${{ inputs.version == 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
submodules: recursive
- name: Install prerequisites
run: |
sudo apt-get install ca-certificates -y
sudo apt-get update -y
sudo apt-get install -y \
clang \
cmake \
curl \
gcc \
git \
g++ \
libclang-dev \
libgtk-3-dev \
llvm-dev \
nasm \
ninja-build \
pkg-config \
wget
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
with:
toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@v2
with:
prefix-key: bridge-${{ matrix.job.os }}
- name: Cache cargo git
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: bridge-cargo-git-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
bridge-cargo-git-
- name: Cache Bridge
id: cache-bridge
uses: actions/cache@v4
with:
path: /tmp/flutter_rust_bridge
key: vcpkg-${{ matrix.job.arch }}
- name: Install flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install flutter rust bridge deps
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 15
shell: bash
command: |
cargo install cargo-expand --version ${{ env.CARGO_EXPAND_VERSION }} --locked
cargo install flutter_rust_bridge_codegen --version ${{ env.FLUTTER_RUST_BRIDGE_VERSION }} --features "uuid" --locked
pushd flutter && sed -i -e 's/extended_text: 14.0.0/extended_text: 13.0.0/g' pubspec.yaml && flutter pub get && popd
- name: Run flutter rust bridge
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 15
shell: bash
command: |
~/.cargo/bin/flutter_rust_bridge_codegen --rust-input ./src/flutter_ffi.rs --dart-output ./flutter/lib/generated_bridge.dart --c-output ./flutter/macos/Runner/bridge_generated.h
cp ./flutter/macos/Runner/bridge_generated.h ./flutter/ios/Runner/bridge_generated.h
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: bridge-artifact
path: |
./src/bridge_generated.rs
./src/bridge_generated.io.rs
./flutter/lib/generated_bridge.dart
./flutter/lib/generated_bridge.freezed.dart
./flutter/macos/Runner/bridge_generated.h
./flutter/ios/Runner/bridge_generated.h
+69
View File
@@ -0,0 +1,69 @@
name: Build web
on:
workflow_dispatch:
env:
FLUTTER_VERSION: "3.24.5"
TAG_NAME: "nightly"
VERSION: "1.3.9"
jobs:
build-rustdesk-web:
name: build-rustdesk-web
runs-on: ubuntu-24.04
strategy:
fail-fast: false
env:
RELEASE_NAME: web-basic
steps:
- name: Checkout source code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Prepare env
run: |
sudo apt-get update -y
sudo apt-get install -y wget npm
- name: Install flutter
uses: subosito/flutter-action@v2.12.0 #https://github.com/subosito/flutter-action/issues/277
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
# https://rustdesk.com/docs/en/dev/build/web/
- name: Build web
shell: bash
run: |
pushd flutter/web/v2
npm install yarn -g
npm install typescript -g
npm install protoc -g
# Install protoc first, see: https://google.github.io/proto-lens/installing-protoc.html
npm install ts-proto
# Only works with vite <= 2.8, see: https://github.com/vitejs/vite/blob/main/docs/guide/build.md#chunking-strategy
npm install vite@2.8
yarn install && yarn build
popd
pushd flutter/web/v2
wget https://github.com/rustdesk/doc.rustdesk.com/releases/download/console/web_deps.tar.gz
tar xzf web_deps.tar.gz
popd
pushd flutter
flutter build web --release
cd build
#cp ../web/README.md web
# TODO: Remove the following line when the web is almost complete.
#echo -e "\n\nThis build is for preview and not full functionality." >> web/README.md
dir_name="rustdesk-${{ env.VERSION }}-${{ env.RELEASE_NAME }}"
mv web "${dir_name}" && tar czf "${dir_name}".tar.gz "${dir_name}"
sha256sum "${dir_name}".tar.gz
popd
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: web
path: flutter/build/rustdesk-${{ env.VERSION }}-${{ env.RELEASE_NAME }}.tar.gz
+37
View File
@@ -0,0 +1,37 @@
name: Clear cache
on:
workflow_dispatch:
permissions:
actions: write
jobs:
clear-cache:
runs-on: ubuntu-latest
steps:
- name: Clear cache
uses: actions/github-script@v7
with:
script: |
console.log("About to clear")
const caches = await github.rest.actions.getActionsCacheList({
owner: context.repo.owner,
repo: context.repo.repo,
})
for (const cache of caches.data.actions_caches) {
console.log(cache)
github.rest.actions.deleteActionsCacheById({
owner: context.repo.owner,
repo: context.repo.repo,
cache_id: cache.id,
})
}
console.log("Clear completed")
- name: Purge cache # Above seems not clear thouroughly, so add this to double clear
uses: MyAlbum/purge-cache@v2
with:
accessed: true # Purge caches by their last accessed time (default)
created: false # Purge caches by their created time (default)
max-age: 1 # in seconds
+46
View File
@@ -0,0 +1,46 @@
name: update docker image
on:
push:
branches:
- dev
- master
tags:
- 'v*.*.*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: set docker image tag
run: |
if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
echo "IMAGE_TAG=dev" >> "$GITHUB_ENV"
echo "CREATE_IMAGE=true" >> "$GITHUB_ENV"
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
echo "IMAGE_TAG=${{ github.ref_name }}" >> "$GITHUB_ENV"
echo "CREATE_IMAGE=true" >> "$GITHUB_ENV"
elif [[ "${{ github.ref }}" == "refs/heads/master" ]]; then
echo "IMAGE_TAG=latest" >> "$GITHUB_ENV"
echo "CREATE_IMAGE=true" >> "$GITHUB_ENV"
else
echo "CREATE_IMAGE=false" >> "$GITHUB_ENV"
fi
- name: Login to Docker Hub
uses: docker/login-action@v3
if: env.CREATE_IMAGE == 'true'
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
if: env.CREATE_IMAGE == 'true'
- name: Build and push
uses: docker/build-push-action@v6
if: env.CREATE_IMAGE == 'true'
with:
push: true
tags: ${{ vars.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}:${{ env.IMAGE_TAG }}
@@ -0,0 +1,47 @@
name: Fetch Encrypted Secrets
on:
workflow_call:
inputs:
zip_url_json:
required: true
type: string
jobs:
download-zip:
runs-on: ubuntu-latest
steps:
- name: Download with Retry
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
shell: python
command: |
import requests
import json
import time
input_data = json.loads('${{ inputs.zip_url_json }}')
url = f"{input_data['url']}/get_zip?filename={input_data['file']}"
for attempt in range(5):
try:
print(f"Downloading (Attempt {attempt + 1})...")
r = requests.get(url, timeout=20)
r.raise_for_status()
with open('secrets.zip', 'wb') as f:
f.write(r.content)
break
except Exception as e:
if attempt < 4:
time.sleep(5 * (2 ** attempt))
else: raise e
- name: Upload Encrypted Artifact
uses: actions/upload-artifact@v4
with:
name: encrypted-secrets-zip
path: secrets.zip
retention-days: 1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+778
View File
@@ -0,0 +1,778 @@
name: Custom macOS Client Generator
run-name: Custom macOS Client Generator
on:
workflow_dispatch:
inputs:
version:
description: 'version to buld'
required: true
default: ''
type: string
zip_url:
description: 'url to zip of json'
required: true
default: ''
type: string
env:
SCITER_RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503, also 1.78 has ABI change which causes our sciter version not working, https://blog.rust-lang.org/2024/03/30/i128-layout-update.html
RUST_VERSION: "1.75" # sciter failed on m1 with 1.78 because of https://blog.rust-lang.org/2024/03/30/i128-layout-update.html
MAC_RUST_VERSION: "1.81"
CARGO_NDK_VERSION: "3.1.2"
SCITER_ARMV7_CMAKE_VERSION: "3.29.7"
SCITER_NASM_DEBVERSION: "2.14-1"
LLVM_VERSION: "15.0.6"
FLUTTER_VERSION: "3.24.5"
ANDROID_FLUTTER_VERSION: "3.24.5" # >= 3.16 is very slow on my android phone, but work well on most of others. We may switch to new flutter after changing to texture rendering (I believe it can solve my problem).
FLUTTER_RUST_BRIDGE_VERSION: "1.80.1" # for arm64 linux because official Dart SDK does not work
FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "${{ inputs.upload-tag }}"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
# vcpkg version: 2024.07.12
VCPKG_COMMIT_ID: "${{ inputs.version == 'master' && '9e593bb18ea69cc5095e012465dcd675a822ed0d' || '120deac3062162151622ca4860575a33844ba10b' }}"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836"
VERSION: "${{ inputs.version }}"
NDK_VERSION: "r28c"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
UPLOAD_ARTIFACT: 'true'
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}"
jobs:
setup:
uses: ./.github/workflows/fetch-encrypted-secrets.yml
with:
zip_url_json: ${{ inputs.zip_url }}
generate-bridge:
uses: ./.github/workflows/bridge.yml
with:
version: ${{ inputs.version }}
build-for-macos:
name: ${{ matrix.job.target }}
runs-on: ${{ matrix.job.os }}
needs: [generate-bridge, setup]
strategy:
fail-fast: false
matrix:
job:
- {
target: x86_64-apple-darwin,
os: macos-15-intel, #macos-latest or macos-14 use M1 now, https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#:~:text=14%20GB-,macos%2Dlatest%20or%20macos%2D14,-The%20macos%2Dlatestlabel
extra-build-args: "",
arch: x86_64,
vcpkg-triplet: x64-osx,
}
- {
target: aarch64-apple-darwin,
os: macos-14,
# extra-build-args: "--disable-flutter-texture-render", # disable this for mac, because we see a lot of users reporting flickering both on arm and x64, and we can not confirm if texture rendering has better performance if htere is no vram, https://github.com/rustdesk/rustdesk/issues/6296
extra-build-args: "--screencapturekit",
arch: aarch64,
vcpkg-triplet: arm64-osx,
}
env:
STATUS_URL: "${{ secrets.GENURL }}/updategh"
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: encrypted-secrets-zip
- name: Load Secrets
uses: ./.github/actions/decrypt-secrets
with:
zip_password: ${{ secrets.ZIP_PASSWORD }}
- name: Finalize and Cleanup zip/json
if: always() # Run even if previous steps fail
continue-on-error: true
uses: fjogeleit/http-request-action@v1
with:
url: "${{ secrets.GENURL }}/cleanzip"
method: 'POST'
customHeaders: '{"Content-Type": "application/json"}'
data: '{"uuid": "${{ env.uuid }}"}'
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Checkout source code
if: ${{ env.VERSION != 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
ref: refs/tags/${{ env.VERSION }}
submodules: recursive
- name: Checkout source code
if: ${{ env.VERSION == 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
submodules: recursive
- name: Install imagemagick and potrace and nasm and and
shell: bash
run: |
brew install imagemagick potrace nasm cmake gcc wget ninja
echo "$(brew --prefix imagemagick)/bin" >> $GITHUB_PATH
- name: Update macOS Info.plist and settings
continue-on-error: false
shell: bash
run: |
# MACSTUFF Update Info.plist
sed -i '' -e 's|<key>CFBundleName</key>.*<string>.*</string>|<key>CFBundleName</key>\n\t<string>${{ env.appname }}</string>|' ./flutter/macos/Runner/Info.plist
sed -i '' -e 's|<key>CFBundleDisplayName</key>.*<string>.*</string>|<key>CFBundleDisplayName</key>\n\t<string>${{ env.appname }}</string>|' ./flutter/macos/Runner/Info.plist
sed -i '' -e 's|<key>CFBundleIdentifier</key>.*<string>.*</string>|<key>CFBundleIdentifier</key>\n\t<string>com.${{ env.appname }}.app</string>|' ./flutter/macos/Runner/Info.plist
# MACSTUFF Update AppInfo.xcconfig
sed -i '' -e 's|PRODUCT_NAME = .*|PRODUCT_NAME = ${{ env.appname }}|' ./flutter/macos/Runner/Configs/AppInfo.xcconfig
sed -i '' -e 's|PRODUCT_BUNDLE_IDENTIFIER = .*|PRODUCT_BUNDLE_IDENTIFIER = com.${{ env.appname }}.app|' ./flutter/macos/Runner/Configs/AppInfo.xcconfig
sed -i '' -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./flutter/macos/Runner/Configs/AppInfo.xcconfig
sed -i '' -e 's|Purslane Ltd.|${{ env.compname }}|' ./flutter/macos/Runner/Configs/AppInfo.xcconfig
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./Cargo.toml
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./libs/portable/Cargo.toml
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./src/main.rs
sed -i -e 's|Purslane Ltd.|${{ env.compname }}|' ./Cargo.toml
sed -i -e 's|Purslane Ltd.|${{ env.compname }}|' ./libs/portable/Cargo.toml
sed -i -e 's|Purslane Ltd.|${{ env.compname }}|' ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./src/main.rs
# Update Xcode project settings
sed -i '' -e 's/PRODUCT_NAME = "RustDesk"/PRODUCT_NAME = "${{ env.appname }}"/' ./flutter/macos/Runner.xcodeproj/project.pbxproj
sed -i '' -e 's/PRODUCT_BUNDLE_IDENTIFIER = ".*"/PRODUCT_BUNDLE_IDENTIFIER = "com.${{ env.appname }}.app"/' ./flutter/macos/Runner.xcodeproj/project.pbxproj
# Update CMake settings
if [ -f "./flutter/macos/CMakeLists.txt" ]; then
sed -i '' -e 's/set(BINARY_NAME ".*")/set(BINARY_NAME "${{ env.appname }}")/' ./flutter/macos/CMakeLists.txt
fi
# Update Podfile - keep the target as 'Runner'
sed -i '' -e 's/target '"'"'Runner'"'"' do/target '"'"'Runner'"'"' do/' ./flutter/macos/Podfile
find ./src/lang -name "*.rs" -exec sed -i '' -e 's|RustDesk|${{ env.appname }}|' {} \;
sed -i '' -e 's|RustDesk|${{ env.appname }}|' ./src/lang/nl.rs
sed -i '' -e 's|https://rustdesk.com|${{ env.urlLink }}|' ./build.py
sed -i '' -e "s|launchUrl(Uri.parse('https://rustdesk.com'));|launchUrl(Uri.parse('${{ env.urlLink }}'));|" ./flutter/lib/common.dart
sed -i '' -e "s|launchUrlString('https://rustdesk.com');|launchUrlString('${{ env.urlLink }}');|" ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i '' -e "s|launchUrlString('https://rustdesk.com/privacy.html')|launchUrlString('${{ env.urlLink }}/privacy.html')|" ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i '' -e "s|const url = 'https://rustdesk.com/';|const url = '${{ env.urlLink }}';|" ./flutter/lib/mobile/pages/settings_page.dart
sed -i '' -e "s|launchUrlString('https://rustdesk.com/privacy.html')|launchUrlString('${{ env.urlLink }}/privacy.html')|" ./flutter/lib/mobile/pages/settings_page.dart
sed -i '' -e "s|https://rustdesk.com/privacy.html|${{ env.urlLink }}/privacy.html|" ./flutter/lib/desktop/pages/install_page.dart
- name: change download link to custom
if: env.downloadLink != 'https://rustdesk.com/download'
continue-on-error: true
shell: bash
run: |
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./flutter/lib/desktop/pages/desktop_home_page.dart
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./flutter/lib/mobile/pages/connection_page.dart
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./src/ui/index.tis
sed -i -e 's|&download_url|"${{ env.downloadLink }}"|' ./src/updater.rs
sed -i -e 's|$downloadUrl/$downloadFile|${{ env.downloadLink }}|' ./flutter/lib/desktop/widgets/update_progress.dart
# Update slogan - About in en.rs
sed -i '' -e 's/("Slogan_tip", "Made with heart in this chaotic world!")/("Slogan_tip", "Powered by ${{ env.appname }}")/' ./src/lang/en.rs
sed -i '' -e 's/("About RustDesk", "")/("About RustDesk", "About ${{ env.appname }}")/' ./src/lang/en.rs
# Update slogan - About in nl.rs
sed -i '' -e 's/("Slogan_tip", "Ontwikkeld met het hart voor deze chaotische wereld!")/("Slogan_tip", "Powered by ${{ env.appname }}")/' ./src/lang/nl.rs
sed -i '' -e 's/("Your Desktop", "Uw Bureaublad")/("Your Desktop", "Uw ${{ env.appname }}")/' ./src/lang/nl.rs
sed -i '' -e 's/("About RustDesk", "Over RustDesk")/("About RustDesk", "Over ${{ env.appname }}")/' ./src/lang/nl.rs
sed -i '' -e 's/("About", "Over")/("About", "Over ${{ env.appname }}")/' ./src/lang/nl.rs
# Update pubspec.yaml with proper YAML formatting
cp ./flutter/pubspec.yaml ./flutter/pubspec.yaml.bak
echo " archive: ^3.6.1" > ./flutter/temp_dependency.txt
awk '/intl:/{print;system("cat ./flutter/temp_dependency.txt");next}1' ./flutter/pubspec.yaml > ./flutter/pubspec.yaml.tmp
mv ./flutter/pubspec.yaml.tmp ./flutter/pubspec.yaml
rm ./flutter/temp_dependency.txt
- name: set server, serverPort, key, and apiserver
continue-on-error: true
shell: bash
env:
SERVER_DOMAIN: ${{ env.server }}
SERVER_PORT_INPUT: ${{ env.serverPort }}
SERVER_KEY: ${{ env.key }}
API_SERVER: ${{ env.apiServer }}
run: |
# Pass secrets via bash env vars to avoid quoting issues
if [ ! -f "./libs/hbb_common/src/config.rs" ]; then
echo "Error: config.rs not found!"
exit 1
fi
if [ ! -f "./src/common.rs" ]; then
echo "Error: common.rs not found!"
exit 1
fi
# Port must be a number; views.py defaults it to 21116
if ! [[ "$SERVER_PORT_INPUT" =~ ^[0-9]+$ ]]; then
echo "Error: serverPort must be a number, got: '$SERVER_PORT_INPUT'"
exit 1
fi
# Derive the port block from the rendezvous port
# (defaults 21116/21117/21118/21119)
SERVER_PORT=$SERVER_PORT_INPUT
RELAY_PORT=$((SERVER_PORT + 1))
WS_RENDEZVOUS_PORT=$((RELAY_PORT + 1))
WS_RELAY_PORT=$((WS_RENDEZVOUS_PORT + 1))
echo "Setting server: $SERVER_DOMAIN"
echo "Setting ports: $SERVER_PORT, $RELAY_PORT, $WS_RENDEZVOUS_PORT, $WS_RELAY_PORT"
sed -i -e "s|rs-ny.rustdesk.com|$SERVER_DOMAIN|" ./libs/hbb_common/src/config.rs
# Match on numeric value so the step is independent of defaults
sed -i -e "s|pub const RENDEZVOUS_PORT: i32 = [0-9][0-9]*;|pub const RENDEZVOUS_PORT: i32 = $SERVER_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const RELAY_PORT: i32 = [0-9][0-9]*;|pub const RELAY_PORT: i32 = $RELAY_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const WS_RENDEZVOUS_PORT: i32 = [0-9][0-9]*;|pub const WS_RENDEZVOUS_PORT: i32 = $WS_RENDEZVOUS_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const WS_RELAY_PORT: i32 = [0-9][0-9]*;|pub const WS_RELAY_PORT: i32 = $WS_RELAY_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw=|$SERVER_KEY|" ./libs/hbb_common/src/config.rs
sed -i -e "s|https://admin.rustdesk.com|$API_SERVER|" ./src/common.rs
echo "=== Verification ==="
grep -nE "RENDEZVOUS_PORT|RELAY_PORT|WS_" ./libs/hbb_common/src/config.rs
- name: allow custom.txt
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
shell: bash
command: |
wget https://raw.githubusercontent.com/bryangerlach/rdgen/refs/heads/master/.github/patches/allowCustom.py
python allowCustom.py
wget https://raw.githubusercontent.com/bryangerlach/rdgen/refs/heads/master/.github/patches/removeSetupServerTip.diff
git apply removeSetupServerTip.diff
# sed -i '/intl:/a \ \ archive: ^3.6.1' ./flutter/pubspec.yaml
- name: Install build runtime
run: |
brew install llvm create-dmg
# pkg-config is handled in a separate step, because it may be already installed by `macos-latest`(14.7.1) runner
if command -v pkg-config &>/dev/null; then
echo "pkg-config is already installed"
else
brew install pkg-config
fi
- name: Install NASM
run: |
# Install NASM 2.16.x from official release.
# Do NOT use `brew install nasm` which installs NASM 3.x.
# NASM 3.x is a complete rewrite with incompatible CLI options and removed features.
# aom and other multimedia libraries require NASM 2.x for x86/x86_64 assembly.
wget https://www.nasm.us/pub/nasm/releasebuilds/2.16.03/macosx/nasm-2.16.03-macosx.zip
unzip nasm-2.16.03-macosx.zip
sudo cp nasm-2.16.03/nasm /usr/local/bin/nasm
nasm --version
- name: Install flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Patch flutter
continue-on-error: true
run: |
cd $(dirname $(dirname $(which flutter)))
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply ${{ github.workspace }}/.github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff
- name: Workaround for flutter issue
shell: bash
run: |
cd "$(dirname "$(which flutter)")"
# https://github.com/flutter/flutter/issues/133533
sed -i -e 's/_setFramesEnabledState(false);/\/\/_setFramesEnabledState(false);/g' ../packages/flutter/lib/src/scheduler/binding.dart
grep -n '_setFramesEnabledState(false);' ../packages/flutter/lib/src/scheduler/binding.dart
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
with:
toolchain: ${{ env.MAC_RUST_VERSION }}
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@v2
with:
prefix-key: ${{ matrix.job.os }}
- name: Magick stuff for macOS
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: false
shell: bash
run: |
# Create all necessary directories first
mkdir -p ./res
mkdir -p ./flutter/assets
mkdir -p ./flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset
mkdir -p ./macos/Runner/Assets.xcassets/AppIcon.appiconset
mkdir -p ./rustdesk/data/flutter_assets/assets
# Download icon using curl with additional SSL options
curl -k -L --tlsv1.2 --proto =https --ssl-reqd \
-H "User-Agent: Mozilla/5.0" \
"${{ env.iconlink_url }}/get_png?filename=${{ env.iconlink_file }}&uuid=${{ env.iconlink_uuid }}" \
-o ./res/icon.png || wget --no-check-certificate -O ./res/icon.png "${{ env.iconlink_url }}/get_png?filename=${{ env.iconlink_file }}&uuid=${{ env.iconlink_uuid }}"
# Backup existing files (if they exist)
[ -f "./res/32x32.png" ] && mv ./res/32x32.png ./res/32x32.png.bak
[ -f "./res/64x64.png" ] && mv ./res/64x64.png ./res/64x64.png.bak
[ -f "./res/128x128.png" ] && mv ./res/128x128.png ./res/128x128.png.bak
[ -f "./res/mac-icon.png" ] && mv ./res/mac-icon.png ./res/mac-icon.png.bak
[ -f "./flutter/assets/icon.png" ] && mv ./flutter/assets/icon.png ./flutter/assets/icon.png.bak
[ -f "./flutter/assets/icon.svg" ] && mv ./flutter/assets/icon.svg ./flutter/assets/icon.svg.bak
[ -f "./rustdesk/data/flutter_assets/assets/icon.svg" ] && mv ./rustdesk/data/flutter_assets/assets/icon.svg ./rustdesk/data/flutter_assets/assets/icon.svg.bak
# Create standard app icons
magick ./res/icon.png -resize 32x32 ./res/32x32.png
magick ./res/icon.png -resize 64x64 ./res/64x64.png
magick ./res/icon.png -resize 128x128 ./res/128x128.png
# Copy icon to Flutter assets
cp ./res/icon.png ./flutter/assets/icon.png
cp ./res/icon.png ./rustdesk/data/flutter_assets/assets/icon.png
# Convert PNG to SVG using potrace
magick ./res/icon.png -flatten ./temp_icon.pbm
potrace --svg -o ./flutter/assets/icon.svg ./temp_icon.pbm
cp ./flutter/assets/icon.svg ./rustdesk/data/flutter_assets/assets/icon.svg
rm ./temp_icon.pbm
# Create macOS app icons
magick ./res/icon.png -resize 16x16 "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png"
magick ./res/icon.png -resize 32x32 "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png"
magick ./res/icon.png -resize 64x64 "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png"
magick ./res/icon.png -resize 128x128 "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png"
magick ./res/icon.png -resize 256x256 "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png"
magick ./res/icon.png -resize 512x512 "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png"
magick ./res/icon.png -resize 1024x1024 "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png"
# Create macOS specific icons
magick ./res/icon.png -resize 128x128 ./res/mac-icon.png
# Create dark mode tray icon (optimized for macOS menu bar)
magick ./res/icon.png -resize 22x22 -colorspace gray -alpha set -background none -channel A -evaluate set 100% ./res/mac-tray-dark-x2.png
# Create light mode tray icon (optimized for macOS menu bar)
magick ./res/icon.png -resize 22x22 -negate -colorspace gray -alpha set -background none -channel A -evaluate set 100% ./res/mac-tray-light-x2.png
# Create AppIcon.icns (macOS native icon format)
mkdir -p ./iconset.iconset
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png" "./iconset.iconset/icon_16x16.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png" "./iconset.iconset/icon_16x16@2x.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png" "./iconset.iconset/icon_32x32.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png" "./iconset.iconset/icon_32x32@2x.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png" "./iconset.iconset/icon_128x128.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png" "./iconset.iconset/icon_128x128@2x.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png" "./iconset.iconset/icon_256x256.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png" "./iconset.iconset/icon_256x256@2x.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png" "./iconset.iconset/icon_512x512.png"
cp "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png" "./iconset.iconset/icon_512x512@2x.png"
iconutil -c icns ./iconset.iconset -o ./flutter/macos/Runner/AppIcon.icns
rm -rf ./iconset.iconset
# Create Contents.json for macOS app icon
echo '{
"images": [
{"size":"16x16","idiom":"mac","filename":"app_icon_16.png","scale":"1x"},
{"size":"16x16","idiom":"mac","filename":"app_icon_32.png","scale":"2x"},
{"size":"32x32","idiom":"mac","filename":"app_icon_32.png","scale":"1x"},
{"size":"32x32","idiom":"mac","filename":"app_icon_64.png","scale":"2x"},
{"size":"128x128","idiom":"mac","filename":"app_icon_128.png","scale":"1x"},
{"size":"128x128","idiom":"mac","filename":"app_icon_256.png","scale":"2x"},
{"size":"256x256","idiom":"mac","filename":"app_icon_256.png","scale":"1x"},
{"size":"256x256","idiom":"mac","filename":"app_icon_512.png","scale":"2x"},
{"size":"512x512","idiom":"mac","filename":"app_icon_512.png","scale":"1x"},
{"size":"512x512","idiom":"mac","filename":"app_icon_1024.png","scale":"2x"}
],
"info": {
"version": 1,
"author": "xcode"
}
}' > "flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json"
# Copy icons and Contents.json to both locations
cp -r flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/* macos/Runner/Assets.xcassets/AppIcon.appiconset/
# Verify files exist and show their sizes
echo "Verifying generated files:"
ls -lh ./res/mac-tray-dark-x2.png
ls -lh ./res/mac-tray-light-x2.png
ls -lh ./res/mac-icon.png
echo "Flutter macOS app icons:"
ls -lh flutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/
echo "Flutter assets:"
ls -lh flutter/assets/icon.*
echo "RustDesk Flutter assets:"
ls -lh rustdesk/data/flutter_assets/assets/
- name: replace flutter icons
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: false
shell: bash
run: |
cd ./flutter
# Create required directories and files
mkdir -p web
mkdir -p assets
echo '{"name":"${{ env.appname }}","short_name":"${{ env.appname }}","start_url":"/","display":"standalone","background_color":"#ffffff","theme_color":"#ffffff","description":"A remote desktop software."}' > web/manifest.json
echo '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${{ env.appname }}</title></head><body></body></html>' > web/index.html
# Ensure the AppIcon.appiconset directory exists
mkdir -p macos/Runner/Assets.xcassets/AppIcon.appiconset
# Copy the processed icons to Flutter locations
cp ../res/mac-icon.png ./assets/icon.png
cp ../flutter/assets/icon.svg ./assets/icon.svg || true
flutter pub upgrade win32
flutter pub get
flutter pub run flutter_launcher_icons
cd ..
- name: ui.rs
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: true
shell: bash
run: |
cp ./src/ui.rs ./src/ui.rs.bak
if [ -f "./res/icon.png" ]; then
SEARCH_STR="iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAEiuAABIrgHwmhA7AAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAEx9JREFUeJztnXmYHMV5h9"
b64=$(base64 < ./res/icon.png)
sed -i '' -e "s~$SEARCH_STR.*\"~$b64\"~" ./src/ui.rs
fi
- name: fix connection delay
continue-on-error: false
if: ${{ env.delayFix == 'true' }}
shell: bash
run: |
# Precise fix: only extend the direct-connection condition with the
# key check, instead of globally replacing !key.is_empty() with
# false (which would also disable TCP encryption and UDP logic).
sed -i -e 's#if is_local || peer_nat_type == NatType::SYMMETRIC {#if is_local || peer_nat_type == NatType::SYMMETRIC || !key.is_empty() {#' ./src/client.rs
grep -n "key.is_empty()" ./src/client.rs || true
- name: use X for offline display instead of orange circle
continue-on-error: true
if: env.xOffline == 'true'
run: |
wget https://raw.githubusercontent.com/bryangerlach/rdgen/refs/heads/master/.github/patches/xoffline.diff
git apply xoffline.diff
- name: hide-cm
continue-on-error: true
if: env.hidecm == 'true'
run: |
wget https://raw.githubusercontent.com/bryangerlach/rdgen/refs/heads/master/.github/patches/hidecm.diff
git apply hidecm.diff
- name: removeNewVersionNotif
continue-on-error: true
if: env.removeNewVersionNotif == 'true'
run: |
sed -i -e 's|updateUrl.isNotEmpty|false|' ./flutter/lib/desktop/pages/desktop_home_page.dart
sed -i '/let (request, url) =/,/Ok(())/{/Ok(())/!d}' ./src/common.rs
- name: Restore bridge files
uses: actions/download-artifact@master
with:
name: bridge-artifact
path: ./
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11
with:
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
doNotCache: false
- name: Install vcpkg dependencies
run: |
if ! $VCPKG_ROOT/vcpkg \
install \
--x-install-root="$VCPKG_ROOT/installed"; then
find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do
echo "$_1:"
echo "======"
cat "$_1"
echo "======"
echo ""
done
exit 1
fi
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true
- name: Create MacOS directory structure
run: |
mkdir -p ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS
- name: Build rustdesk
run: |
if [ "${{ matrix.job.target }}" = "aarch64-apple-darwin" ]; then
MIN_MACOS_VERSION="12.3"
sed -i -e "s/MACOSX_DEPLOYMENT_TARGET\=[0-9]*.[0-9]*/MACOSX_DEPLOYMENT_TARGET=${MIN_MACOS_VERSION}/" build.py
sed -i -e "s/platform :osx, '.*'/platform :osx, '${MIN_MACOS_VERSION}'/" flutter/macos/Podfile
sed -i -e "s/osx_minimum_system_version = \"[0-9]*.[0-9]*\"/osx_minimum_system_version = \"${MIN_MACOS_VERSION}\"/" Cargo.toml
sed -i -e "s/MACOSX_DEPLOYMENT_TARGET = [0-9]*.[0-9]*;/MACOSX_DEPLOYMENT_TARGET = ${MIN_MACOS_VERSION};/" flutter/macos/Runner.xcodeproj/project.pbxproj
fi
sed -i -e "s/RustDesk.app/\"${{ env.appname }}.app\"/" build.py
./build.py --flutter --hwcodec --unix-file-copy-paste ${{ matrix.job.extra-build-args }}
# - name: Copy service file
# run: |
# cp -rf ../target/release/service ./build/macos/Build/Products/Release/RustDesk.app/Contents/MacOS/
- name: Embed custom config into the .app bundle
shell: bash
run: |
APP=$(find ./flutter/build/macos/Build/Products/Release -maxdepth 1 -name "*.app" | head -n 1)
echo "App bundle: $APP"
echo -n '${{ env.custom }}' > "$APP/Contents/MacOS/custom_.txt"
ls -l "$APP/Contents/MacOS/custom_.txt"
- name: Install rcodesign tool
if: env.MACOS_P12_BASE64 != null
shell: bash
run: |
pushd /tmp
wget https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F0.22.0/apple-codesign-0.22.0-macos-universal.tar.gz
tar -zxvf apple-codesign-0.22.0-macos-universal.tar.gz
mv apple-codesign-0.22.0-macos-universal/rcodesign /usr/local/bin
popd
- name: Install build runtime
run: |
brew install llvm create-dmg nasm cmake gcc wget ninja
# pkg-config is handled in a separate step, because it may be already installed by `macos-latest`(14.7.1) runner
if command -v pkg-config &>/dev/null; then
echo "pkg-config is already installed"
else
brew install pkg-config
fi
- name: Show version information (Rust, cargo, Clang)
shell: bash
run: |
clang --version || true
rustup -V
rustup toolchain list
rustup default
cargo -V
rustc -V
- name: icon svg handling
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: false
shell: bash
run: |
ASSETS_DIR="build/macos/Build/Products/Release/RustDesk.app/Contents/Frameworks/App.framework/Versions/Current/Resources/flutter_assets/assets"
mkdir -p "$ASSETS_DIR"
if [ -f "$ASSETS_DIR/icon.svg" ]; then
mv "$ASSETS_DIR/icon.svg" "$ASSETS_DIR/icon.svg.bak"
fi
# First convert PNG to PBM (bitmap)
magick convert ./res/icon.png ./temp_icon.pbm
# Then use potrace to convert to SVG
potrace --svg -o "$ASSETS_DIR/icon.svg" ./temp_icon.pbm
rm ./temp_icon.pbm
- name: logo handling
if: ${{ env.logolink_url != 'false' }}
continue-on-error: false
shell: bash
run: |
ASSETS_DIR="build/macos/Build/Products/Release/RustDesk.app/Contents/Frameworks/App.framework/Versions/Current/Resources/flutter_assets/assets"
mkdir -p "$ASSETS_DIR"
curl -k -L --tlsv1.2 --proto =https --ssl-reqd \
-H "User-Agent: Mozilla/5.0" \
"${{ env.logolink_url }}/get_png?filename=${{ env.logolink_file }}&uuid=${{ env.logolink_uuid }}" \
-o "$ASSETS_DIR/logo.png" || \
wget --no-check-certificate \
-O "$ASSETS_DIR/logo.png" \
"${{ env.logolink_url }}/get_png?filename=${{ env.logolink_file }}&uuid=${{ env.logolink_uuid }}"
- name: Sign macOS app bundle
if: env.MACOS_P12_BASE64 != ''
run: |
cd flutter/build/macos/Build/Products/Release
# Debug info
echo "Current directory contents:"
ls -la
# Rename RustDesk.app to the custom app name first
if [ -d "RustDesk.app" ]; then
# First rename the app if it's still called RustDesk.app
mv "RustDesk.app" "${{ env.appname }}.app"
echo "Renamed RustDesk.app to ${{ env.appname }}.app"
fi
echo "App bundle contents after rename:"
ls -la "${{ env.appname }}.app" || echo "App not found"
ls -la "${{ env.appname }}.app/Contents" || echo "Contents not found"
# Decode the certificate
echo "${{ secrets.MACOS_P12_BASE64 }}" | base64 --decode > certificate.p12
# Sign the app bundle and its contents
if [ -d "${{ env.appname }}.app/Contents/MacOS" ]; then
echo "Signing main executable..."
MAIN_EXECUTABLE="${{ env.appname }}.app/Contents/MacOS/${{ env.appname }}"
if [ -f "$MAIN_EXECUTABLE" ]; then
rcodesign sign --p12-file certificate.p12 --p12-password "${{ secrets.MACOS_P12_PASSWORD }}" \
--code-signature-flags runtime "$MAIN_EXECUTABLE"
else
echo "Main executable not found at expected path: $MAIN_EXECUTABLE"
# Try to find the actual executable
echo "Available executables in MacOS directory:"
ls -la "${{ env.appname }}.app/Contents/MacOS/"
ACTUAL_EXECUTABLE=$(ls "${{ env.appname }}.app/Contents/MacOS/" | head -n 1)
if [ -n "$ACTUAL_EXECUTABLE" ]; then
echo "Found executable: $ACTUAL_EXECUTABLE"
rcodesign sign --p12-file certificate.p12 --p12-password "${{ secrets.MACOS_P12_PASSWORD }}" \
--code-signature-flags runtime "${{ env.appname }}.app/Contents/MacOS/$ACTUAL_EXECUTABLE"
fi
fi
echo "Signing frameworks..."
find "${{ env.appname }}.app/Contents/Frameworks" -type f -not -name ".*" -exec \
rcodesign sign --p12-file certificate.p12 --p12-password "${{ secrets.MACOS_P12_PASSWORD }}" \
--code-signature-flags runtime {} \;
echo "Signing main bundle..."
rcodesign sign --p12-file certificate.p12 --p12-password "${{ secrets.MACOS_P12_PASSWORD }}" \
--code-signature-flags runtime "${{ env.appname }}.app"
else
echo "Error: Invalid app bundle structure"
exit 1
fi
# Clean up
rm certificate.p12
- name: Ad-hoc Sign macOS app bundle (Fallback)
if: env.MACOS_P12_BASE64 == '' || env.MACOS_P12_BASE64 == null
shell: bash
run: |
cd flutter/build/macos/Build/Products/Release
# Renombrar RustDesk.app al nombre de la marca blanca si aplica
if [ -d "RustDesk.app" ]; then
mv "RustDesk.app" "${{ env.appname }}.app"
echo "RustDesk.app renombrado a ${{ env.appname }}.app"
fi
TARGET_APP="${{ env.appname }}.app"
if [ -d "$TARGET_APP" ]; then
echo "Limpiando atributos extendidos..."
xattr -cr "$TARGET_APP" || true
echo "Re-firmando $TARGET_APP de forma Ad-Hoc y recursiva..."
codesign --force --deep --sign - "$TARGET_APP"
echo "Firma Ad-Hoc completada con exito."
else
echo "Error: No se encontro la app bundle en $TARGET_APP para firmar."
exit 1
fi
- name: Create DMG
run: |
cd /Users/runner/work/${{ github.event.repository.name }}/${{ github.event.repository.name }}/flutter/build/macos/Build/Products/Release
# Print directory contents for debugging
echo "Directory contents:"
ls -la
# Find the actual .app bundle
if [ -d "RustDesk.app" ]; then
# First rename the app if it's still called RustDesk.app
mv "RustDesk.app" "${{ env.appname }}.app"
fi
if [ ! -d "${{ env.appname }}.app" ]; then
echo "Could not find .app bundle!"
exit 1
fi
echo "Creating DMG for ${{ env.appname }}.app"
create-dmg \
--volname "${{ env.appname }}" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "${{ env.appname }}.app" 200 190 \
--hide-extension "${{ env.appname }}.app" \
--app-drop-link 600 185 \
"${{ env.appname }}-${{ matrix.job.arch }}.dmg" \
"${{ env.appname }}.app"
mv "${{ env.appname }}-${{ matrix.job.arch }}.dmg" $GITHUB_WORKSPACE/
- name: Rename rustdesk
if: env.UPLOAD_ARTIFACT == 'true'
run: |
cd $GITHUB_WORKSPACE
echo "Directory contents:"
ls -la
# Find the DMG file dynamically
DMG_FILE=$(find . -name "${{ env.appname }}-${{ matrix.job.arch }}.dmg")
if [ -n "$DMG_FILE" ]; then
echo "Found DMG file: $DMG_FILE"
mv "$DMG_FILE" "${{ env.filename }}-${{ matrix.job.arch }}.dmg"
echo "Renamed to ${{ env.filename }}-${{ matrix.job.arch }}.dmg"
else
echo "No DMG file found matching the pattern"
exit 1
fi
- name: send file to rdgen server
if: ${{ env.rdgen == 'true' }}
shell: bash
run: |
curl -i -X POST \
-H "Content-Type: multipart/form-data" \
-H "Authorization: Bearer ${{ env.token }}" \
-F "file=@$GITHUB_WORKSPACE/${{ env.filename }}-${{ matrix.job.arch }}.dmg" \
-F "uuid=${{ env.uuid }}" \
"${{ secrets.GENURL }}/save_custom_client"
- name: send file to api server
if: ${{ env.rdgen == 'false' }}
shell: bash
run: |
curl -i -X POST \
-H "Content-Type: multipart/form-data" \
-H "Authorization: Bearer ${{ env.token }}" \
-F "file=@$GITHUB_WORKSPACE/${{ env.filename }}-${{ matrix.job.arch }}.dmg" \
"${{ env.apiServer }}/api/save_custom_client"
cleanup:
needs: [build-for-macos]
runs-on: ubuntu-latest
continue-on-error: true
if: always()
steps:
- name: Delete secrets artifact
uses: geekyeggo/delete-artifact@v5
with:
name: encrypted-secrets-zip
File diff suppressed because one or more lines are too long
+753
View File
@@ -0,0 +1,753 @@
name: Custom Windows Client Generator
run-name: Custom Windows Client Generator
on:
workflow_dispatch:
inputs:
version:
description: 'version to buld'
required: true
default: ''
type: string
zip_url:
description: 'url to zip of json'
required: true
default: ''
type: string
env:
SCITER_RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503, also 1.78 has ABI change which causes our sciter version not working, https://blog.rust-lang.org/2024/03/30/i128-layout-update.html
RUST_VERSION: "1.75" # sciter failed on m1 with 1.78 because of https://blog.rust-lang.org/2024/03/30/i128-layout-update.html
CARGO_NDK_VERSION: "3.1.2"
SCITER_ARMV7_CMAKE_VERSION: "3.29.7"
SCITER_NASM_DEBVERSION: "2.15.05-1"
LLVM_VERSION: "15.0.6"
FLUTTER_VERSION: "3.24.5"
ANDROID_FLUTTER_VERSION: "3.24.5"
# for arm64 linux because official Dart SDK does not work
FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "${{ inputs.upload-tag }}"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
# vcpkg version: 2024.07.12
VCPKG_COMMIT_ID: "${{ inputs.version == 'master' && '9e593bb18ea69cc5095e012465dcd675a822ed0d' || '120deac3062162151622ca4860575a33844ba10b' }}"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836"
VERSION: "${{ inputs.version }}"
NDK_VERSION: "r28c"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
UPLOAD_ARTIFACT: 'true'
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}"
STATUS_URL: "${{ secrets.GENURL }}/updategh"
jobs:
setup:
uses: ./.github/workflows/fetch-encrypted-secrets.yml
with:
zip_url_json: ${{ inputs.zip_url }}
generate-bridge:
uses: ./.github/workflows/bridge.yml
with:
version: ${{ inputs.version }}
build-RustDeskTempTopMostWindow:
needs: setup
uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml
with:
upload-artifact: true
target: windows-2022
configuration: Release
platform: x64
target_version: Windows10
secrets: inherit
strategy:
fail-fast: false
build-for-windows-flutter:
name: Build Windows
needs: [build-RustDeskTempTopMostWindow, generate-bridge, setup]
runs-on: ${{ matrix.job.os }}
strategy:
fail-fast: false
matrix:
job:
# - { target: i686-pc-windows-msvc , os: windows-2022 }
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
- {
target: x86_64-pc-windows-msvc,
os: windows-2022,
arch: x86_64,
vcpkg-triplet: x64-windows-static,
}
# - { target: aarch64-pc-windows-msvc, os: windows-2022, arch: aarch64 }
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: encrypted-secrets-zip
- name: Load Secrets
uses: ./.github/actions/decrypt-secrets
with:
zip_password: ${{ secrets.ZIP_PASSWORD }}
- name: Finalize and Cleanup zip/json
if: always() # Run even if previous steps fail
continue-on-error: true
uses: fjogeleit/http-request-action@v1
with:
url: "${{ secrets.GENURL }}/cleanzip"
method: 'POST'
customHeaders: '{"Content-Type": "application/json"}'
data: '{"uuid": "${{ env.uuid }}"}'
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Set rdgen value
if: ${{ env.rdgen == 'true' }}
run: |
echo "STATUS_URL=${{ secrets.GENURL }}/updategh" >> $env:GITHUB_ENV
- name: Set rdgen value
if: ${{ env.rdgen == 'false' }}
run: |
echo "STATUS_URL=${{ env.apiServer }}/api/updategh" >> $env:GITHUB_ENV
- name: Checkout source code
if: ${{ env.VERSION != 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
ref: refs/tags/${{ env.VERSION }}
submodules: recursive
- name: Checkout source code
if: ${{ env.VERSION == 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
submodules: recursive
- name: Restore bridge files
uses: actions/download-artifact@master
with:
name: bridge-artifact
path: ./
- name: Install ImageMagick on Windows
run: |
choco install -y imagemagick.app --no-progress
Get-ChildItem -Path "${env:ProgramFiles}" | % { $_.FullName } | Select-String -Pattern "[\/\\]ImageMagick[^\/\\]*$" | Out-File -Append -FilePath $env:GITHUB_PATH -Encoding utf8
- name: fix flutter_gpu_texture_renderer (fixed in master rustdesk)
shell: bash
run: |
sed -i -e 's|2ded7f146437a761ffe6981e2f742038f85ca68d|08a471bb8ceccdd50483c81cdfa8b81b07b14b87|' ./flutter/pubspec.lock
sed -i -e 's|2ded7f146437a761ffe6981e2f742038f85ca68d|08a471bb8ceccdd50483c81cdfa8b81b07b14b87|' ./flutter/pubspec.yaml
- name: change appname to custom
if: env.appname != 'rustdesk'
continue-on-error: true
shell: bash
run: |
# ./Cargo.toml
sed -i -e 's|description = "RustDesk Remote Desktop"|description = "${{ env.appname }}"|' ./Cargo.toml
sed -i -e 's|ProductName = "RustDesk"|ProductName = "${{ env.appname }}"|' ./Cargo.toml
sed -i -e 's|FileDescription = "RustDesk Remote Desktop"|FileDescription = "${{ env.appname }}"|' ./Cargo.toml
sed -i -e 's|OriginalFilename = "rustdesk.exe"|OriginalFilename = "${{ env.appname }}.exe"|' ./Cargo.toml
# ./libs/portable/Cargo.toml
sed -i -e 's|description = "RustDesk Remote Desktop"|description = "${{ env.appname }}"|' ./libs/portable/Cargo.toml
sed -i -e 's|ProductName = "RustDesk"|ProductName = "${{ env.appname }}"|' ./libs/portable/Cargo.toml
sed -i -e 's|FileDescription = "RustDesk Remote Desktop"|FileDescription = "${{ env.appname }}"|' ./libs/portable/Cargo.toml
sed -i -e 's|OriginalFilename = "rustdesk.exe"|OriginalFilename = "${{ env.appname }}.exe"|' ./libs/portable/Cargo.toml
# ./libs/portable/src/main.rs
sed -i -e 's|const APP_PREFIX: \&str = "rustdesk";|const APP_PREFIX: \&str = "${{ env.appname }}";|' ./libs/portable/src/main.rs
# ./flutter/windows/runner/Runner.rc
sed -i -e 's|"RustDesk Remote Desktop"|"${{ env.appname }}"|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|VALUE "InternalName", "rustdesk" "\0"|VALUE "InternalName", "${{ env.appname }}" "\0"|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|"rustdesk.exe"|"${{ env.filename }}"|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|"RustDesk"|"${{ env.appname }}"|' ./flutter/windows/runner/Runner.rc
# ./src/lang/en.rs
# change powered by rustdek to powered by compname
if [ ! -z "${{ env.compname }}" ]; then
find ./src/lang -name "*.rs" -exec sed -i '/powered_by_me/s|RustDesk|${{ env.compname }}|g' {} \;
fi
find ./src/lang -name "*.rs" -exec sed -i -e 's|RustDesk|${{ env.appname }}|' {} \;
sed -i -e 's|RustDesk|${{ env.appname }}|' ./res/msi/Package/License.rtf
- name: fix registry if appname has a space
if: contains(env.appname, ' ')
continue-on-error: true
shell: bash
run: |
#./src/platform/windows.rs
sed -i -e 's|reg add {}|reg add \\\"{}\\\"|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext} /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\DefaultIcon /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\DefaultIcon\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open\\\\command|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open\\\\command\\\"|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext} /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext}\\\\shell /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open\\\\command /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open\\\\command\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|{subkey}|\\\"{subkey}\\\"|' ./src/platform/windows.rs
sed -i -e 's|reg delete HKEY_CLASSES_ROOT\\\\.{ext} /f|reg delete \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg delete HKEY_CLASSES_ROOT\\\\{ext} /f|reg delete \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\" /f|' ./src/platform/windows.rs
- name: change company name
if: env.compname != 'Purslane Ltd'
continue-on-error: true
shell: bash
run: |
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./res/msi/preprocess.py
sed -i -e 's|r"Purslane(?: Tech Pte\\.)? Ltd"|"${{ env.compname }}"|' ./res/msi/preprocess.py
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./Cargo.toml
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./libs/portable/Cargo.toml
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./res/msi/Package/License.rtf
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./src/main.rs
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e 's|PURSLANE|${{ env.compname }}|' ./res/msi/preprocess.py
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./res/msi/preprocess.py
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./Cargo.toml
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./libs/portable/Cargo.toml
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./res/msi/Package/License.rtf
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./src/main.rs
- name: change url to custom
if: env.urlLink != 'https://rustdesk.com'
continue-on-error: true
shell: bash
run: |
sed -i -e 's|Homepage: https://rustdesk.com|Homepage: ${{ env.urlLink }}|' ./build.py
sed -i -e "s|launchUrl(Uri.parse('https://rustdesk.com'));|launchUrl(Uri.parse('${{ env.urlLink }}'));|" ./flutter/lib/common.dart
sed -i -e "s|launchUrlString('https://rustdesk.com');|launchUrlString('${{ env.urlLink }}');|" ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e "s|launchUrlString('https://rustdesk.com/privacy.html')|launchUrlString('${{ env.urlLink }}/privacy.html')|" ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e "s|const url = 'https://rustdesk.com/';|const url = '${{ env.urlLink }}';|" ./flutter/lib/mobile/pages/settings_page.dart
sed -i -e "s|launchUrlString('https://rustdesk.com/privacy.html')|launchUrlString('${{ env.urlLink }}/privacy.html')|" ./flutter/lib/mobile/pages/settings_page.dart
sed -i -e "s|https://rustdesk.com/privacy.html|${{ env.urlLink }}/privacy.html|" ./flutter/lib/desktop/pages/install_page.dart
sed -i -e "s|rustdesk.com|${{ env.urlLink }}|" ./res/msi/Package/License.rtf
- name: change download link to custom
if: env.downloadLink != 'https://rustdesk.com/download'
continue-on-error: true
shell: bash
run: |
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./flutter/lib/desktop/pages/desktop_home_page.dart
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./flutter/lib/mobile/pages/connection_page.dart
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./src/ui/index.tis
sed -i -e 's|&download_url|"${{ env.downloadLink }}"|' ./src/updater.rs
sed -i -e 's|$downloadUrl/$downloadFile|${{ env.downloadLink }}|' ./flutter/lib/desktop/widgets/update_progress.dart
- name: set server, serverPort, key, and apiserver
continue-on-error: true
shell: bash
env:
SERVER_DOMAIN: ${{ env.server }}
SERVER_PORT_INPUT: ${{ env.serverPort }}
SERVER_KEY: ${{ env.key }}
API_SERVER: ${{ env.apiServer }}
run: |
# Pass secrets via bash env vars to avoid quoting issues
if [ ! -f "./libs/hbb_common/src/config.rs" ]; then
echo "Error: config.rs not found!"
exit 1
fi
if [ ! -f "./src/common.rs" ]; then
echo "Error: common.rs not found!"
exit 1
fi
# Port must be a number; views.py defaults it to 21116
if ! [[ "$SERVER_PORT_INPUT" =~ ^[0-9]+$ ]]; then
echo "Error: serverPort must be a number, got: '$SERVER_PORT_INPUT'"
exit 1
fi
# Derive the port block from the rendezvous port
# (defaults 21116/21117/21118/21119)
SERVER_PORT=$SERVER_PORT_INPUT
RELAY_PORT=$((SERVER_PORT + 1))
WS_RENDEZVOUS_PORT=$((RELAY_PORT + 1))
WS_RELAY_PORT=$((WS_RENDEZVOUS_PORT + 1))
echo "Setting server: $SERVER_DOMAIN"
echo "Setting ports: $SERVER_PORT, $RELAY_PORT, $WS_RENDEZVOUS_PORT, $WS_RELAY_PORT"
sed -i -e "s|rs-ny.rustdesk.com|$SERVER_DOMAIN|" ./libs/hbb_common/src/config.rs
# Match on numeric value so the step is independent of defaults
sed -i -e "s|pub const RENDEZVOUS_PORT: i32 = [0-9][0-9]*;|pub const RENDEZVOUS_PORT: i32 = $SERVER_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const RELAY_PORT: i32 = [0-9][0-9]*;|pub const RELAY_PORT: i32 = $RELAY_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const WS_RENDEZVOUS_PORT: i32 = [0-9][0-9]*;|pub const WS_RENDEZVOUS_PORT: i32 = $WS_RENDEZVOUS_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const WS_RELAY_PORT: i32 = [0-9][0-9]*;|pub const WS_RELAY_PORT: i32 = $WS_RELAY_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw=|$SERVER_KEY|" ./libs/hbb_common/src/config.rs
sed -i -e "s|https://admin.rustdesk.com|$API_SERVER|" ./src/common.rs
echo "=== Verification ==="
grep -nE "RENDEZVOUS_PORT|RELAY_PORT|WS_" ./libs/hbb_common/src/config.rs
# ./flutter/pubspec.yaml
#sed -i '/intl:/a \ \ archive: ^3.6.1' ./flutter/pubspec.yaml
- name: allow custom.txt
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
shell: pwsh
command: |
Invoke-WebRequest -Uri https://raw.githubusercontent.com/bryangerlach/rdgen/refs/heads/master/.github/patches/allowCustom.py -OutFile allowCustom.py
python allowCustom.py
# Remove Setup Server Tip
Invoke-WebRequest -Uri https://raw.githubusercontent.com/bryangerlach/rdgen/refs/heads/master/.github/patches/removeSetupServerTip.diff -OutFile removeSetupServerTip.diff
git apply removeSetupServerTip.diff
- name: Patch remote printer port to spool file
shell: python
run: |
import os, re
src = "."
app = os.environ.get("appname") or "RustDesk"
literal = "C:\\\\ProgramData\\\\%s\\\\printer-spool\\\\job.prn" % app
# atch Rust remote_printer lib.rs
rs = os.path.join(src, "libs", "remote_printer", "src", "lib.rs")
if os.path.isfile(rs):
with open(rs, "r", encoding="utf-8") as f:
t = f.read()
if "printer-spool" in t:
print("Rust printer port already points to spool file")
else:
old = ('fn get_port_name(app_name: &str) -> Vec<u16> {\n'
' format!("{} Printer", app_name)')
new = ('fn get_port_name(app_name: &str) -> Vec<u16> {\n'
' let base = std::env::var("ProgramData")\n'
' .unwrap_or_else(|_| "C:\\\\ProgramData".to_string());\n'
' let dir = format!("{}\\\\{}\\\\printer-spool", base, app_name);\n'
' let _ = std::fs::create_dir_all(&dir);\n'
' format!("{}\\\\job.prn", dir)')
if old in t:
with open(rs, "w", encoding="utf-8") as f:
f.write(t.replace(old, new, 1))
print("Successfully patched Rust printer port")
else:
print("Warning: get_port_name() signature not matched in Rust source")
# patch C++ MSI Custom Action
cpp = os.path.join(src, "res", "msi", "CustomActions", "RemotePrinter.cpp")
if os.path.isfile(cpp):
with open(cpp, "r", encoding="utf-8") as f:
t = f.read()
if "printer-spool" in t:
print("MSI printer port already points to spool file")
else:
pattern = r'(RD_PRINTER_PORT\s*=\s*)L"[^"]*"'
new_t, n = re.subn(pattern, lambda m: m.group(1) + 'L"%s"' % literal, t, count=1)
if n == 1:
with open(cpp, "w", encoding="utf-8") as f:
f.write(new_t)
print("Successfully patched MSI CustomAction printer port")
else:
print("Warning: RD_PRINTER_PORT not matched in RemotePrinter.cpp")
- name: Install LLVM and Clang
uses: KyleMayes/install-llvm-action@v1
with:
version: ${{ env.LLVM_VERSION }}
- name: Install flutter
uses: subosito/flutter-action@v2.12.0 #https://github.com/subosito/flutter-action/issues/277
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
# https://github.com/flutter/flutter/issues/155685
- name: Replace engine with rustdesk custom flutter engine
run: |
flutter doctor -v
flutter precache --windows
Invoke-WebRequest -Uri https://github.com/rustdesk/engine/releases/download/main/windows-x64-release.zip -OutFile windows-x64-release.zip
Expand-Archive -Path windows-x64-release.zip -DestinationPath windows-x64-release
mv -Force windows-x64-release/* C:/hostedtoolcache/windows/flutter/stable-${{ env.FLUTTER_VERSION }}-x64/bin/cache/artifacts/engine/windows-x64-release/
- name: Patch flutter
shell: bash
run: |
cp .github/patches/flutter_3.24.4_dropdown_menu_enableFilter.diff $(dirname $(dirname $(which flutter)))
cd $(dirname $(dirname $(which flutter)))
[[ "3.24.5" == ${{env.FLUTTER_VERSION}} ]] && git apply flutter_3.24.4_dropdown_menu_enableFilter.diff
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
with:
toolchain: ${{ env.SCITER_RUST_VERSION }}
targets: ${{ matrix.job.target }}
components: "rustfmt"
- uses: Swatinem/rust-cache@v2
with:
prefix-key: ${{ matrix.job.os }}
- name: Setup vcpkg with Github Actions binary cache
uses: lukka/run-vcpkg@v11
with:
vcpkgDirectory: C:\vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
doNotCache: false
- name: Install vcpkg dependencies
env:
VCPKG_DEFAULT_HOST_TRIPLET: ${{ matrix.job.vcpkg-triplet }}
run: |
if ! $VCPKG_ROOT/vcpkg \
install \
--triplet ${{ matrix.job.vcpkg-triplet }} \
--x-install-root="$VCPKG_ROOT/installed"; then
find "${VCPKG_ROOT}/" -name "*.log" | while read -r _1; do
echo "$_1:"
echo "======"
cat "$_1"
echo "======"
echo ""
done
exit 1
fi
head -n 100 "${VCPKG_ROOT}/buildtrees/ffmpeg/build-${{ matrix.job.vcpkg-triplet }}-rel-out.log" || true
shell: bash
- name: magick stuff
if: ${{ env.iconlink_url != 'false' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
shell: pwsh
command: |
Invoke-WebRequest -Uri ${{ env.iconlink_url }}/get_png?filename=${{ env.iconlink_file }}"&"uuid=${{ env.iconlink_uuid }} -OutFile ./res/iconx.png
mv ./res/icon.ico ./res/icon.ico.bak
mv ./res/icon.png ./res/icon.png.bak
mv ./res/tray-icon.ico ./res/tray-icon.ico.bak
mv ./res/iconx.png ./res/icon.png
mv ./res/32x32.png ./res/32x32.png.bak
mv ./res/64x64.png ./res/64x64.png.bak
mv ./res/128x128.png ./res/128x128.png.bak
mv ./res/128x128@2x.png ./res/128x128@2x.png.bak
magick ./res/icon.png -define icon:auto-resize=256,64,48,32,16 ./res/icon.ico
cp ./res/icon.ico ./res/tray-icon.ico
magick ./res/icon.png -resize 32x32 ./res/32x32.png
magick ./res/icon.png -resize 64x64 ./res/64x64.png
magick ./res/icon.png -resize 128x128 ./res/128x128.png
magick ./res/128x128.png -resize 200% ./res/128x128@2x.png
- name: ui.rs icon
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: true
shell: bash
run: |
cp ./src/ui.rs ./src/ui.rs.bak
b64=$(base64 -w0 < ./res/icon.png)
perl -0777 -pe "s|iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHL[A-Za-z0-9+/=]+|$b64|g" -i.bak ./src/ui.rs
b64=""
- name: fix connection delay
continue-on-error: true
if: ${{ env.delayFix == 'true' }}
shell: bash
run: |
# Precise fix: only extend the direct-connection condition with the
# key check, instead of globally replacing !key.is_empty() with
# false (which would also disable TCP encryption and UDP logic).
sed -i -e 's#if is_local || peer_nat_type == NatType::SYMMETRIC {#if is_local || peer_nat_type == NatType::SYMMETRIC || !key.is_empty() {#' ./src/client.rs
grep -n "key.is_empty()" ./src/client.rs || true
- name: use X for offline display instead of orange circle
if: env.xOffline == 'true'
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
shell: pwsh
continue_on_error: true
command: |
Invoke-WebRequest -Uri https://raw.githubusercontent.com/bryangerlach/rdgen/refs/heads/master/.github/patches/xoffline.diff -OutFile xoffline.diff
git apply xoffline.diff
- name: removeNewVersionNotif
continue-on-error: true
if: env.removeNewVersionNotif == 'true'
shell: bash
run: |
sed -i -e 's|updateUrl.isNotEmpty|false|' ./flutter/lib/desktop/pages/desktop_home_page.dart
sed -i '/let (request, url) =/,/Ok(())/{/Ok(())/!d}' ./src/common.rs
# - name: run as admin
# continue-on-error: true
# if: ${{ env.runasadmin == 'true' }}
# shell: bash
# run: |
# sed -i '/<\/compatibility>/a \
# <security> \
# <requestedExecutionLevel level="requireAdministrator" uiAccess="false"/> \
# </security>' ./flutter/windows/runner/runner.exe.manifest
- name: replace flutter icons
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: true
run: |
cd ./flutter
#flutter pub upgrade win32
flutter pub get
flutter pub run flutter_launcher_icons
cd ..
- name: Checkout printer-adapter crate
uses: actions/checkout@v4
with:
repository: bryangerlach/rdgen
ref: master
path: ./temp-repo
- name: Move printer-adapter into place
shell: pwsh
run: |
if (Test-Path "./temp-repo/printer-adapter") {
Move-Item -Path "./temp-repo/printer-adapter" -Destination "./printer-adapter" -Force
Remove-Item -Recurse -Force "./temp-repo"
Write-Host "Successfully placed printer-adapter into workspace."
} else {
Write-Host "Error: printer-adapter folder not found in repository."
}
- name: Build rustdesk
run: |
# Windows: build RustDesk
python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack
mv ./flutter/build/windows/x64/runner/Release ./rustdesk
# Download usbmmidd_v2.zip and extract it to ./rustdesk
Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip
Expand-Archive usbmmidd_v2.zip -DestinationPath . -Force
Remove-Item -Path usbmmidd_v2\Win32 -Recurse
Remove-Item -Path "usbmmidd_v2\deviceinstaller64.exe", "usbmmidd_v2\deviceinstaller.exe", "usbmmidd_v2\usbmmidd.bat"
mv -Force .\usbmmidd_v2 ./rustdesk
# Download and install driver package
try {
Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/rustdesk_printer_driver_v4-1.4.zip -OutFile rustdesk_printer_driver_v4-1.4.zip
Expand-Archive rustdesk_printer_driver_v4-1.4.zip -DestinationPath .
mkdir ./rustdesk/drivers -ErrorAction SilentlyContinue
mv -Force .\rustdesk_printer_driver_v4-1.4 ./rustdesk/drivers/RustDeskPrinterDriver
} catch {
Write-Host "Warning: Printer driver download failed."
}
# Build and install open printer adapter
if (Test-Path "./printer-adapter/Cargo.toml") {
Write-Host "Building open printer-adapter..."
pushd ./printer-adapter
cargo build --release --locked
popd
if (Test-Path "./printer-adapter/target/release/printer_driver_adapter.dll") {
cp -Force "./printer-adapter/target/release/printer_driver_adapter.dll" "./rustdesk/printer_driver_adapter.dll"
Write-Host "Replaced printer_driver_adapter.dll with open implementation."
} else {
Write-Host "Error: printer-adapter build succeeded but DLL was not found."
}
} else {
Write-Host "Warning: ./printer-adapter/Cargo.toml not found, remote printing will remain disabled."
}
- name: icon stuff
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: true
run: |
mv ./rustdesk/data/flutter_assets/assets/icon.svg ./rustdesk/data/flutter_assets/assets/icon.svg.bak
magick ./res/icon.png ./rustdesk/data/flutter_assets/assets/icon.svg
- name: logo stuff
if: ${{ env.logolink_url != 'false' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
shell: pwsh
continue_on_error: true
command: |
Invoke-WebRequest -Uri ${{ env.logolink_url }}/get_png?filename=${{ env.logolink_file }}"&"uuid=${{ env.logolink_uuid }} -OutFile ./rustdesk/data/flutter_assets/assets/logo.png
- name: find Runner.res
# Windows: find Runner.res (compiled from ./flutter/windows/runner/Runner.rc), copy to ./Runner.res
# Runner.rc does not contain actual version, but Runner.res does
continue-on-error: true
shell: bash
run: |
runner_res=$(find . -name "Runner.res");
if [ "$runner_res" == "" ]; then
echo "Runner.res: not found";
else
echo "Runner.res: $runner_res";
cp $runner_res ./libs/portable/Runner.res;
echo "list ./libs/portable/Runner.res";
ls -l ./libs/portable/Runner.res;
fi
- name: Download RustDeskTempTopMostWindow artifacts
uses: actions/download-artifact@master
if: env.UPLOAD_ARTIFACT == 'true'
with:
name: topmostwindow-artifacts
path: "./rustdesk"
- name: zip dlls
continue-on-error: true
shell: pwsh
run: |
Compress-Archive -Path ./rustdesk/*.dll, ./rustdesk/*.exe -DestinationPath ./rustdesk/unsigned_files.zip -CompressionLevel Fastest
- name: sign dlls
continue-on-error: true
shell: bash
run: |
if [ ! -z "${{ secrets.SIGN_BASE_URL }}" ] && [ ! -z "${{ secrets.SIGN_API_KEY }}" ]; then
curl -X POST -F "file=@./rustdesk/unsigned_files.zip" \
-H "X-API-KEY: ${{ secrets.SIGN_API_KEY }}" \
-m 900 \
"${{ secrets.SIGN_BASE_URL }}/sign/" -o ./rustdesk/signed_files.zip
else
echo "Signing skipped - signing URL or API key not configured"
cp ./rustdesk/unsigned_files.zip ./rustdesk/signed_files.zip
fi
- name: unzip dlls
continue-on-error: true
shell: pwsh
run: |
Expand-Archive -Path ./rustdesk/signed_files.zip -DestinationPath ./rustdesk/ -Force
Remove-Item ./rustdesk/unsigned_files.zip
Remove-Item ./rustdesk/signed_files.zip
- name: Create custom.txt file
shell: bash
run: |
echo -n "${{ env.custom }}" | cat > ./rustdesk/custom_.txt
- name: Build self-extracted executable
shell: bash
if: env.UPLOAD_ARTIFACT == 'true'
run: |
mv "./rustdesk/rustdesk.exe" "./rustdesk/${{ env.appname }}.exe" || echo "rustdesk.exe"
sed -i '/dpiAware/d' res/manifest.xml
pushd ./libs/portable
pip3 install -r requirements.txt
python3 ./generate.py -f ../../rustdesk/ -o . -e "../../rustdesk/${{ env.appname }}.exe"
popd
mkdir -p ./SignOutput
mv ./target/release/rustdesk-portable-packer.exe "./SignOutput/rustdesk.exe"
- name: Add MSBuild to PATH
uses: microsoft/setup-msbuild@v2
- name: Build msi
continue-on-error: true
if: env.UPLOAD_ARTIFACT == 'true'
run: |
$myappname = "${{ env.appname }}" -replace '\s','_'
cp "rustdesk/${{ env.appname }}.exe" "rustdesk/${myappname}.exe" -ErrorAction SilentlyContinue
pushd ./res/msi
python preprocess.py --app-name "$myappname" --arp -d ../../rustdesk
nuget restore msi.sln
msbuild msi.sln -p:Configuration=Release -p:Platform=x64 /p:TargetVersion=Windows10
cp ./Package/bin/x64/Release/en-us/Package.msi ../../SignOutput/rustdesk-latest.msi
mv ./Package/bin/x64/Release/en-us/Package.msi ../../SignOutput/rustdesk.msi
sha256sum ../../SignOutput/rustdesk.msi
- name: zip exe and msi
continue-on-error: true
shell: pwsh
run: |
Compress-Archive -Path ./SignOutput/*.exe, ./SignOutput/*.msi -DestinationPath ./SignOutput/unsigned_files.zip -CompressionLevel Fastest
- name: sign exe and msi
continue-on-error: true
shell: bash
run: |
if [ ! -z "${{ secrets.SIGN_BASE_URL }}" ] && [ ! -z "${{ secrets.SIGN_API_KEY }}" ]; then
curl -X POST -F "file=@./SignOutput/unsigned_files.zip" \
-H "X-API-KEY: ${{ secrets.SIGN_API_KEY }}" \
-m 900 \
"${{ secrets.SIGN_BASE_URL }}/sign/" -o ./SignOutput/signed_files.zip
else
echo "Signing skipped - signing URL or API key not configured"
cp ./SignOutput/unsigned_files.zip ./SignOutput/signed_files.zip
fi
- name: unzip exe and msi
continue-on-error: true
shell: pwsh
run: |
Expand-Archive -Path ./SignOutput/signed_files.zip -DestinationPath ./SignOutput/ -Force
Remove-Item ./SignOutput/unsigned_files.zip
Remove-Item ./SignOutput/signed_files.zip
- name: rename rustdesk.exe to filename.exe
run: |
mv ./SignOutput/rustdesk.exe "./SignOutput/${{ env.filename }}.exe" || echo "rustdesk"
- name: rename rustdesk.msi to filename.msi
continue-on-error: true
run: |
mv ./SignOutput/rustdesk.msi "./SignOutput/${{ env.filename }}.msi" || echo "rustdesk"
- name: send file to rdgen server
if: ${{ env.rdgen == 'true' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 15
shell: bash
command: |
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.exe" -F "uuid=${{ env.uuid }}" ${{ secrets.GENURL }}/save_custom_client
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.msi" -F "uuid=${{ env.uuid }}" ${{ secrets.GENURL }}/save_custom_client || true
- name: send file to api server
if: ${{ env.rdgen == 'false' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 15
shell: bash
command: |
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.exe" ${{ env.apiServer }}/api/save_custom_client
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.msi" ${{ env.apiServer }}/api/save_custom_client || true
cleanup:
needs: [build-for-windows-flutter]
runs-on: ubuntu-latest
continue-on-error: true
if: always()
steps:
- name: Delete secrets artifact
uses: geekyeggo/delete-artifact@v5
with:
name: encrypted-secrets-zip
+671
View File
@@ -0,0 +1,671 @@
name: Custom Windows Client Generator
run-name: Custom Windows Client Generator
on:
workflow_dispatch:
inputs:
version:
description: 'version to buld'
required: true
default: ''
type: string
zip_url:
description: 'url to zip of json'
required: true
default: ''
type: string
env:
SCITER_RUST_VERSION: "1.75" # https://github.com/rustdesk/rustdesk/discussions/7503, also 1.78 has ABI change which causes our sciter version not working, https://blog.rust-lang.org/2024/03/30/i128-layout-update.html
RUST_VERSION: "1.75" # sciter failed on m1 with 1.78 because of https://blog.rust-lang.org/2024/03/30/i128-layout-update.html
CARGO_NDK_VERSION: "3.1.2"
SCITER_ARMV7_CMAKE_VERSION: "3.29.7"
SCITER_NASM_DEBVERSION: "2.15.05-1"
LLVM_VERSION: "15.0.6"
FLUTTER_VERSION: "3.24.5"
ANDROID_FLUTTER_VERSION: "3.24.5"
# for arm64 linux because official Dart SDK does not work
FLUTTER_ELINUX_VERSION: "3.16.9"
TAG_NAME: "${{ inputs.upload-tag }}"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
# vcpkg version: 2024.07.12
VCPKG_COMMIT_ID: "${{ inputs.version == 'master' && '9e593bb18ea69cc5095e012465dcd675a822ed0d' || '120deac3062162151622ca4860575a33844ba10b' }}"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836"
VERSION: "${{ inputs.version }}"
NDK_VERSION: "r28c"
#signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"
MACOS_P12_BASE64: "${{ secrets.MACOS_P12_BASE64 }}"
UPLOAD_ARTIFACT: 'true'
SIGN_BASE_URL: "${{ secrets.SIGN_BASE_URL }}"
STATUS_URL: "${{ secrets.GENURL }}/updategh"
jobs:
setup:
uses: ./.github/workflows/fetch-encrypted-secrets.yml
with:
zip_url_json: ${{ inputs.zip_url }}
generate-bridge:
uses: ./.github/workflows/bridge.yml
with:
version: ${{ inputs.version }}
build-RustDeskTempTopMostWindow:
needs: setup
uses: ./.github/workflows/third-party-RustDeskTempTopMostWindow.yml
with:
upload-artifact: true
target: windows-2022
configuration: Release
platform: x64
target_version: Windows10
secrets: inherit
strategy:
fail-fast: false
build-for-windows-flutter:
name: Build Windows
needs: [build-RustDeskTempTopMostWindow, generate-bridge, setup]
runs-on: self-hosted
strategy:
fail-fast: false
matrix:
job:
# - { target: i686-pc-windows-msvc , os: windows-2022 }
# - { target: x86_64-pc-windows-gnu , os: windows-2022 }
- {
target: x86_64-pc-windows-msvc,
os: windows-2022,
arch: x86_64,
vcpkg-triplet: x64-windows-static,
}
# - { target: aarch64-pc-windows-msvc, os: windows-2022, arch: aarch64 }
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: encrypted-secrets-zip
- name: Load Secrets
uses: ./.github/actions/decrypt-secrets
with:
zip_password: ${{ secrets.ZIP_PASSWORD }}
- name: Finalize and Cleanup zip/json
if: always() # Run even if previous steps fail
continue-on-error: true
uses: fjogeleit/http-request-action@v1
with:
url: "${{ secrets.GENURL }}/cleanzip"
method: 'POST'
customHeaders: '{"Content-Type": "application/json"}'
data: '{"uuid": "${{ env.uuid }}"}'
- name: Export GitHub Actions cache environment variables
uses: actions/github-script@v6
with:
script: |
core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Set rdgen value
if: ${{ env.rdgen == 'true' }}
run: |
echo "STATUS_URL=${{ secrets.GENURL }}/updategh" >> $env:GITHUB_ENV
- name: Set rdgen value
if: ${{ env.rdgen == 'false' }}
run: |
echo "STATUS_URL=${{ env.apiServer }}/api/updategh" >> $env:GITHUB_ENV
- name: Checkout source code
if: ${{ env.VERSION != 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
ref: refs/tags/${{ env.VERSION }}
submodules: recursive
- name: Checkout source code
if: ${{ env.VERSION == 'master' }}
uses: actions/checkout@v4
with:
repository: rustdesk/rustdesk
submodules: recursive
- name: Restore bridge files
uses: actions/download-artifact@master
with:
name: bridge-artifact
path: ./
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Setup NuGet
uses: NuGet/setup-nuget@v2
with:
nuget-version: 'latest'
- name: change appname to custom
if: env.appname != 'rustdesk'
continue-on-error: true
shell: bash
run: |
# ./Cargo.toml
sed -i -e 's|description = "RustDesk Remote Desktop"|description = "${{ env.appname }}"|' ./Cargo.toml
sed -i -e 's|ProductName = "RustDesk"|ProductName = "${{ env.appname }}"|' ./Cargo.toml
sed -i -e 's|FileDescription = "RustDesk Remote Desktop"|FileDescription = "${{ env.appname }}"|' ./Cargo.toml
sed -i -e 's|OriginalFilename = "rustdesk.exe"|OriginalFilename = "${{ env.appname }}.exe"|' ./Cargo.toml
# ./libs/portable/Cargo.toml
sed -i -e 's|description = "RustDesk Remote Desktop"|description = "${{ env.appname }}"|' ./libs/portable/Cargo.toml
sed -i -e 's|ProductName = "RustDesk"|ProductName = "${{ env.appname }}"|' ./libs/portable/Cargo.toml
sed -i -e 's|FileDescription = "RustDesk Remote Desktop"|FileDescription = "${{ env.appname }}"|' ./libs/portable/Cargo.toml
sed -i -e 's|OriginalFilename = "rustdesk.exe"|OriginalFilename = "${{ env.appname }}.exe"|' ./libs/portable/Cargo.toml
# ./libs/portable/src/main.rs
sed -i -e 's|const APP_PREFIX: \&str = "rustdesk";|const APP_PREFIX: \&str = "${{ env.appname }}";|' ./libs/portable/src/main.rs
# ./flutter/windows/runner/Runner.rc
sed -i -e 's|"RustDesk Remote Desktop"|"${{ env.appname }}"|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|VALUE "InternalName", "rustdesk" "\0"|VALUE "InternalName", "${{ env.appname }}" "\0"|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|"rustdesk.exe"|"${{ env.filename }}"|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|"RustDesk"|"${{ env.appname }}"|' ./flutter/windows/runner/Runner.rc
# ./src/lang/en.rs
# change powered by rustdek to powered by compname
if [ ! -z "${{ env.compname }}" ]; then
find ./src/lang -name "*.rs" -exec sed -i '/powered_by_me/s|RustDesk|${{ env.compname }}|g' {} \;
fi
find ./src/lang -name "*.rs" -exec sed -i -e 's|RustDesk|${{ env.appname }}|' {} \;
sed -i -e 's|RustDesk|${{ env.appname }}|' ./res/msi/Package/License.rtf
- name: fix registry if appname has a space
if: contains(env.appname, ' ')
continue-on-error: true
shell: bash
run: |
#./src/platform/windows.rs
sed -i -e 's|reg add {}|reg add \\\"{}\\\"|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext} /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\DefaultIcon /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\DefaultIcon\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open /f|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open\\\\command|reg add \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\\shell\\\\open\\\\command\\\"|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext} /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext}\\\\shell /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg add HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open\\\\command /f|reg add \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\\shell\\\\open\\\\command\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|{subkey}|\\\"{subkey}\\\"|' ./src/platform/windows.rs
sed -i -e 's|reg delete HKEY_CLASSES_ROOT\\\\.{ext} /f|reg delete \\\"HKEY_CLASSES_ROOT\\\\.{ext}\\\" /f|' ./src/platform/windows.rs
sed -i -e 's|reg delete HKEY_CLASSES_ROOT\\\\{ext} /f|reg delete \\\"HKEY_CLASSES_ROOT\\\\{ext}\\\" /f|' ./src/platform/windows.rs
- name: change company name
if: env.compname != 'Purslane Ltd'
continue-on-error: true
shell: bash
run: |
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./res/msi/preprocess.py
sed -i -e 's|r"Purslane(?: Tech Pte\\.)? Ltd"|"${{ env.compname }}"|' ./res/msi/preprocess.py
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./Cargo.toml
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./libs/portable/Cargo.toml
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./res/msi/Package/License.rtf
sed -i -e 's|Purslane Tech Pte. Ltd.|${{ env.compname }}|' ./src/main.rs
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e 's|PURSLANE|${{ env.compname }}|' ./res/msi/preprocess.py
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./res/msi/preprocess.py
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./flutter/windows/runner/Runner.rc
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./Cargo.toml
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./libs/portable/Cargo.toml
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./res/msi/Package/License.rtf
sed -i -e 's|Purslane Ltd|${{ env.compname }}|' ./src/main.rs
- name: change url to custom
if: env.urlLink != 'https://rustdesk.com'
continue-on-error: true
shell: bash
run: |
sed -i -e 's|Homepage: https://rustdesk.com|Homepage: ${{ env.urlLink }}|' ./build.py
sed -i -e "s|launchUrl(Uri.parse('https://rustdesk.com'));|launchUrl(Uri.parse('${{ env.urlLink }}'));|" ./flutter/lib/common.dart
sed -i -e "s|launchUrlString('https://rustdesk.com');|launchUrlString('${{ env.urlLink }}');|" ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e "s|launchUrlString('https://rustdesk.com/privacy.html')|launchUrlString('${{ env.urlLink }}/privacy.html')|" ./flutter/lib/desktop/pages/desktop_setting_page.dart
sed -i -e "s|const url = 'https://rustdesk.com/';|const url = '${{ env.urlLink }}';|" ./flutter/lib/mobile/pages/settings_page.dart
sed -i -e "s|launchUrlString('https://rustdesk.com/privacy.html')|launchUrlString('${{ env.urlLink }}/privacy.html')|" ./flutter/lib/mobile/pages/settings_page.dart
sed -i -e "s|https://rustdesk.com/privacy.html|${{ env.urlLink }}/privacy.html|" ./flutter/lib/desktop/pages/install_page.dart
sed -i -e "s|rustdesk.com|${{ env.urlLink }}|" ./res/msi/Package/License.rtf
- name: change download link to custom
if: env.downloadLink != 'https://rustdesk.com/download'
continue-on-error: true
shell: bash
run: |
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./flutter/lib/desktop/pages/desktop_home_page.dart
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./flutter/lib/mobile/pages/connection_page.dart
sed -i -e 's|https://rustdesk.com/download|${{ env.downloadLink }}|' ./src/ui/index.tis
sed -i -e 's|&download_url|"${{ env.downloadLink }}"|' ./src/updater.rs
sed -i -e 's|$downloadUrl/$downloadFile|${{ env.downloadLink }}|' ./flutter/lib/desktop/widgets/update_progress.dart
- name: set server, serverPort, key, and apiserver
continue-on-error: true
shell: bash
env:
SERVER_DOMAIN: ${{ env.server }}
SERVER_PORT_INPUT: ${{ env.serverPort }}
SERVER_KEY: ${{ env.key }}
API_SERVER: ${{ env.apiServer }}
run: |
# Pass secrets via bash env vars to avoid quoting issues
if [ ! -f "./libs/hbb_common/src/config.rs" ]; then
echo "Error: config.rs not found!"
exit 1
fi
if [ ! -f "./src/common.rs" ]; then
echo "Error: common.rs not found!"
exit 1
fi
# Port must be a number; views.py defaults it to 21116
if ! [[ "$SERVER_PORT_INPUT" =~ ^[0-9]+$ ]]; then
echo "Error: serverPort must be a number, got: '$SERVER_PORT_INPUT'"
exit 1
fi
# Derive the port block from the rendezvous port
# (defaults 21116/21117/21118/21119)
SERVER_PORT=$SERVER_PORT_INPUT
RELAY_PORT=$((SERVER_PORT + 1))
WS_RENDEZVOUS_PORT=$((RELAY_PORT + 1))
WS_RELAY_PORT=$((WS_RENDEZVOUS_PORT + 1))
echo "Setting server: $SERVER_DOMAIN"
echo "Setting ports: $SERVER_PORT, $RELAY_PORT, $WS_RENDEZVOUS_PORT, $WS_RELAY_PORT"
sed -i -e "s|rs-ny.rustdesk.com|$SERVER_DOMAIN|" ./libs/hbb_common/src/config.rs
# Match on numeric value so the step is independent of defaults
sed -i -e "s|pub const RENDEZVOUS_PORT: i32 = [0-9][0-9]*;|pub const RENDEZVOUS_PORT: i32 = $SERVER_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const RELAY_PORT: i32 = [0-9][0-9]*;|pub const RELAY_PORT: i32 = $RELAY_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const WS_RENDEZVOUS_PORT: i32 = [0-9][0-9]*;|pub const WS_RENDEZVOUS_PORT: i32 = $WS_RENDEZVOUS_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|pub const WS_RELAY_PORT: i32 = [0-9][0-9]*;|pub const WS_RELAY_PORT: i32 = $WS_RELAY_PORT;|" ./libs/hbb_common/src/config.rs
sed -i -e "s|OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw=|$SERVER_KEY|" ./libs/hbb_common/src/config.rs
sed -i -e "s|https://admin.rustdesk.com|$API_SERVER|" ./src/common.rs
echo "=== Verification ==="
grep -nE "RENDEZVOUS_PORT|RELAY_PORT|WS_" ./libs/hbb_common/src/config.rs
# ./flutter/pubspec.yaml
#sed -i '/intl:/a \ \ archive: ^3.6.1' ./flutter/pubspec.yaml
- name: allow custom.txt
uses: nick-fields/retry@v3
with:
timeout_minutes: 1
max_attempts: 3
shell: pwsh
command: |
Invoke-WebRequest -Uri https://raw.githubusercontent.com/bryangerlach/rdgen/refs/heads/master/.github/patches/allowCustom.py -OutFile allowCustom.py
python allowCustom.py
# Remove Setup Server Tip
Invoke-WebRequest -Uri https://raw.githubusercontent.com/bryangerlach/rdgen/refs/heads/master/.github/patches/removeSetupServerTip.diff -OutFile removeSetupServerTip.diff
git apply removeSetupServerTip.diff
- name: Patch remote printer port to spool file
shell: python
run: |
import os, re
src = "."
app = os.environ.get("appname") or "RustDesk"
literal = "C:\\\\ProgramData\\\\%s\\\\printer-spool\\\\job.prn" % app
# atch Rust remote_printer lib.rs
rs = os.path.join(src, "libs", "remote_printer", "src", "lib.rs")
if os.path.isfile(rs):
with open(rs, "r", encoding="utf-8") as f:
t = f.read()
if "printer-spool" in t:
print("Rust printer port already points to spool file")
else:
old = ('fn get_port_name(app_name: &str) -> Vec<u16> {\n'
' format!("{} Printer", app_name)')
new = ('fn get_port_name(app_name: &str) -> Vec<u16> {\n'
' let base = std::env::var("ProgramData")\n'
' .unwrap_or_else(|_| "C:\\\\ProgramData".to_string());\n'
' let dir = format!("{}\\\\{}\\\\printer-spool", base, app_name);\n'
' let _ = std::fs::create_dir_all(&dir);\n'
' format!("{}\\\\job.prn", dir)')
if old in t:
with open(rs, "w", encoding="utf-8") as f:
f.write(t.replace(old, new, 1))
print("Successfully patched Rust printer port")
else:
print("Warning: get_port_name() signature not matched in Rust source")
# patch C++ MSI Custom Action
cpp = os.path.join(src, "res", "msi", "CustomActions", "RemotePrinter.cpp")
if os.path.isfile(cpp):
with open(cpp, "r", encoding="utf-8") as f:
t = f.read()
if "printer-spool" in t:
print("MSI printer port already points to spool file")
else:
pattern = r'(RD_PRINTER_PORT\s*=\s*)L"[^"]*"'
new_t, n = re.subn(pattern, lambda m: m.group(1) + 'L"%s"' % literal, t, count=1)
if n == 1:
with open(cpp, "w", encoding="utf-8") as f:
f.write(new_t)
print("Successfully patched MSI CustomAction printer port")
else:
print("Warning: RD_PRINTER_PORT not matched in RemotePrinter.cpp")
- name: magick stuff
if: ${{ env.iconlink_url != 'false' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 1
max_attempts: 3
shell: pwsh
command: |
Invoke-WebRequest -Uri ${{ env.iconlink_url }}/get_png?filename=${{ env.iconlink_file }}"&"uuid=${{ env.iconlink_uuid }} -OutFile ./res/iconx.png
mv ./res/icon.ico ./res/icon.ico.bak
mv ./res/icon.png ./res/icon.png.bak
mv ./res/tray-icon.ico ./res/tray-icon.ico.bak
mv ./res/iconx.png ./res/icon.png
mv ./res/32x32.png ./res/32x32.png.bak
mv ./res/64x64.png ./res/64x64.png.bak
mv ./res/128x128.png ./res/128x128.png.bak
mv ./res/128x128@2x.png ./res/128x128@2x.png.bak
magick ./res/icon.png -define icon:auto-resize=256,64,48,32,16 ./res/icon.ico
cp ./res/icon.ico ./res/tray-icon.ico
magick ./res/icon.png -resize 32x32 ./res/32x32.png
magick ./res/icon.png -resize 64x64 ./res/64x64.png
magick ./res/icon.png -resize 128x128 ./res/128x128.png
magick ./res/128x128.png -resize 200% ./res/128x128@2x.png
- name: ui.rs icon
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: true
shell: bash
run: |
cp ./src/ui.rs ./src/ui.rs.bak
b64=$(base64 -w0 < ./res/icon.png)
perl -0777 -pe "s|iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHL[A-Za-z0-9+/=]+|$b64|g" -i.bak ./src/ui.rs
b64=""
- name: fix connection delay
continue-on-error: true
if: ${{ env.delayFix == 'true' }}
shell: bash
run: |
# Precise fix: only extend the direct-connection condition with the
# key check, instead of globally replacing !key.is_empty() with
# false (which would also disable TCP encryption and UDP logic).
sed -i -e 's#if is_local || peer_nat_type == NatType::SYMMETRIC {#if is_local || peer_nat_type == NatType::SYMMETRIC || !key.is_empty() {#' ./src/client.rs
grep -n "key.is_empty()" ./src/client.rs || true
- name: use X for offline display instead of orange circle
if: env.xOffline == 'true'
uses: nick-fields/retry@v3
with:
timeout_minutes: 1
max_attempts: 3
shell: pwsh
continue_on_error: true
command: |
Invoke-WebRequest -Uri https://raw.githubusercontent.com/bryangerlach/rdgen/refs/heads/master/.github/patches/xoffline.diff -OutFile xoffline.diff
git apply xoffline.diff
- name: removeNewVersionNotif
continue-on-error: true
if: env.removeNewVersionNotif == 'true'
shell: bash
run: |
sed -i -e 's|updateUrl.isNotEmpty|false|' ./flutter/lib/desktop/pages/desktop_home_page.dart
sed -i '/let (request, url) =/,/Ok(())/{/Ok(())/!d}' ./src/common.rs
- name: replace flutter icons
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: true
run: |
cd ./flutter
#flutter pub upgrade win32
flutter pub get
flutter pub run flutter_launcher_icons
cd ..
- name: Checkout printer-adapter crate
uses: actions/checkout@v4
with:
repository: bryangerlach/rdgen
ref: master
path: ./temp-repo
- name: Move printer-adapter into place
shell: pwsh
run: |
if (Test-Path "./temp-repo/printer-adapter") {
Move-Item -Path "./temp-repo/printer-adapter" -Destination "./printer-adapter" -Force
Remove-Item -Recurse -Force "./temp-repo"
Write-Host "Successfully placed printer-adapter into workspace."
} else {
Write-Host "Error: printer-adapter folder not found in repository."
}
- name: Build rustdesk
run: |
# Windows: build RustDesk
python3 .\build.py --portable --hwcodec --flutter --vram --skip-portable-pack
mv ./flutter/build/windows/x64/runner/Release ./rustdesk
# Download usbmmidd_v2.zip and extract it to ./rustdesk
Invoke-WebRequest -Uri https://github.com/rustdesk-org/rdev/releases/download/usbmmidd_v2/usbmmidd_v2.zip -OutFile usbmmidd_v2.zip
Expand-Archive usbmmidd_v2.zip -DestinationPath . -Force
Remove-Item -Path usbmmidd_v2\Win32 -Recurse
Remove-Item -Path "usbmmidd_v2\deviceinstaller64.exe", "usbmmidd_v2\deviceinstaller.exe", "usbmmidd_v2\usbmmidd.bat"
mv -Force .\usbmmidd_v2 ./rustdesk
# Download and install driver package
try {
Invoke-WebRequest -Uri https://github.com/rustdesk/hbb_common/releases/download/driver/rustdesk_printer_driver_v4-1.4.zip -OutFile rustdesk_printer_driver_v4-1.4.zip
Expand-Archive rustdesk_printer_driver_v4-1.4.zip -DestinationPath .
mkdir ./rustdesk/drivers -ErrorAction SilentlyContinue
mv -Force .\rustdesk_printer_driver_v4-1.4 ./rustdesk/drivers/RustDeskPrinterDriver
} catch {
Write-Host "Warning: Printer driver download failed."
}
# Build and install open printer adapter
if (Test-Path "./printer-adapter/Cargo.toml") {
Write-Host "Building open printer-adapter..."
pushd ./printer-adapter
cargo build --release --locked
popd
if (Test-Path "./printer-adapter/target/release/printer_driver_adapter.dll") {
cp -Force "./printer-adapter/target/release/printer_driver_adapter.dll" "./rustdesk/printer_driver_adapter.dll"
Write-Host "Replaced printer_driver_adapter.dll with open implementation."
} else {
Write-Host "Error: printer-adapter build succeeded but DLL was not found."
}
} else {
Write-Host "Warning: ./printer-adapter/Cargo.toml not found, remote printing will remain disabled."
}
- name: icon stuff
if: ${{ env.iconlink_url != 'false' }}
continue-on-error: true
run: |
mv ./rustdesk/data/flutter_assets/assets/icon.svg ./rustdesk/data/flutter_assets/assets/icon.svg.bak
magick ./res/icon.png ./rustdesk/data/flutter_assets/assets/icon.svg
- name: logo stuff
if: ${{ env.logolink_url != 'false' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 1
max_attempts: 3
shell: pwsh
continue_on_error: true
command: |
Invoke-WebRequest -Uri ${{ env.logolink_url }}/get_png?filename=${{ env.logolink_file }}"&"uuid=${{ env.logolink_uuid }} -OutFile ./rustdesk/data/flutter_assets/assets/logo.png
- name: find Runner.res
# Windows: find Runner.res (compiled from ./flutter/windows/runner/Runner.rc), copy to ./Runner.res
# Runner.rc does not contain actual version, but Runner.res does
continue-on-error: true
shell: bash
run: |
runner_res=$(find . -name "Runner.res");
if [ "$runner_res" == "" ]; then
echo "Runner.res: not found";
else
echo "Runner.res: $runner_res";
cp $runner_res ./libs/portable/Runner.res;
echo "list ./libs/portable/Runner.res";
ls -l ./libs/portable/Runner.res;
fi
- name: Download RustDeskTempTopMostWindow artifacts
uses: actions/download-artifact@master
if: env.UPLOAD_ARTIFACT == 'true'
with:
name: topmostwindow-artifacts
path: "./rustdesk"
- name: zip dlls
continue-on-error: true
shell: pwsh
run: |
Compress-Archive -Path ./rustdesk/*.dll, ./rustdesk/*.exe -DestinationPath ./rustdesk/unsigned_files.zip -CompressionLevel Fastest
- name: sign dlls
continue-on-error: true
shell: bash
run: |
if [ ! -z "${{ secrets.SIGN_BASE_URL }}" ] && [ ! -z "${{ secrets.SIGN_API_KEY }}" ]; then
curl -X POST -F "file=@./rustdesk/unsigned_files.zip" \
-H "X-API-KEY: ${{ secrets.SIGN_API_KEY }}" \
-m 900 \
"${{ secrets.SIGN_BASE_URL }}/sign/" -o ./rustdesk/signed_files.zip
else
echo "Signing skipped - signing URL or API key not configured"
cp ./rustdesk/unsigned_files.zip ./rustdesk/signed_files.zip
fi
- name: unzip dlls
continue-on-error: true
shell: pwsh
run: |
Expand-Archive -Path ./rustdesk/signed_files.zip -DestinationPath ./rustdesk/ -Force
Remove-Item ./rustdesk/unsigned_files.zip
Remove-Item ./rustdesk/signed_files.zip
- name: Create custom.txt file
shell: bash
run: |
echo -n "${{ env.custom }}" | cat > ./rustdesk/custom_.txt
- name: Build self-extracted executable
shell: bash
if: env.UPLOAD_ARTIFACT == 'true'
run: |
mv "./rustdesk/rustdesk.exe" "./rustdesk/${{ env.appname }}.exe" || echo "rustdesk.exe"
sed -i '/dpiAware/d' res/manifest.xml
pushd ./libs/portable
pip3 install -r requirements.txt
python3 ./generate.py -f ../../rustdesk/ -o . -e "../../rustdesk/${{ env.appname }}.exe"
popd
mkdir -p ./SignOutput
mv ./target/release/rustdesk-portable-packer.exe "./SignOutput/rustdesk.exe"
- name: Add MSBuild to PATH
uses: microsoft/setup-msbuild@v2
- name: Build msi
continue-on-error: true
if: env.UPLOAD_ARTIFACT == 'true'
run: |
$myappname = "${{ env.appname }}" -replace '\s','_'
cp "rustdesk/${{ env.appname }}.exe" "rustdesk/${myappname}.exe" -ErrorAction SilentlyContinue
pushd ./res/msi
python preprocess.py --app-name "$myappname" --arp -d ../../rustdesk
nuget restore msi.sln
msbuild msi.sln -p:Configuration=Release -p:Platform=x64 /p:TargetVersion=Windows10
cp ./Package/bin/x64/Release/en-us/Package.msi ../../SignOutput/rustdesk-latest.msi
mv ./Package/bin/x64/Release/en-us/Package.msi ../../SignOutput/rustdesk.msi
sha256sum ../../SignOutput/rustdesk.msi
- name: zip exe and msi
continue-on-error: true
shell: pwsh
run: |
Compress-Archive -Path ./SignOutput/*.exe, ./SignOutput/*.msi -DestinationPath ./SignOutput/unsigned_files.zip -CompressionLevel Fastest
- name: sign exe and msi
continue-on-error: true
shell: bash
run: |
if [ ! -z "${{ secrets.SIGN_BASE_URL }}" ] && [ ! -z "${{ secrets.SIGN_API_KEY }}" ]; then
curl -X POST -F "file=@./SignOutput/unsigned_files.zip" \
-H "X-API-KEY: ${{ secrets.SIGN_API_KEY }}" \
-m 900 \
"${{ secrets.SIGN_BASE_URL }}/sign/" -o ./SignOutput/signed_files.zip
else
echo "Signing skipped - signing URL or API key not configured"
cp ./SignOutput/unsigned_files.zip ./SignOutput/signed_files.zip
fi
- name: unzip exe and msi
continue-on-error: true
shell: pwsh
run: |
Expand-Archive -Path ./SignOutput/signed_files.zip -DestinationPath ./SignOutput/ -Force
Remove-Item ./SignOutput/unsigned_files.zip
Remove-Item ./SignOutput/signed_files.zip
- name: rename rustdesk.exe to filename.exe
run: |
mv ./SignOutput/rustdesk.exe "./SignOutput/${{ env.filename }}.exe" || echo "rustdesk"
- name: rename rustdesk.msi to filename.msi
continue-on-error: true
run: |
mv ./SignOutput/rustdesk.msi "./SignOutput/${{ env.filename }}.msi" || echo "rustdesk"
- name: send file to rdgen server
if: ${{ env.rdgen == 'true' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 1
max_attempts: 3
shell: bash
command: |
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.exe" -F "uuid=${{ env.uuid }}" ${{ secrets.GENURL }}/save_custom_client
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.msi" -F "uuid=${{ env.uuid }}" ${{ secrets.GENURL }}/save_custom_client || true
- name: send file to api server
if: ${{ env.rdgen == 'false' }}
uses: nick-fields/retry@v3
with:
timeout_minutes: 1
max_attempts: 3
shell: bash
command: |
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.exe" ${{ env.apiServer }}/api/save_custom_client
curl -i -X POST -H "Content-Type: multipart/form-data" -H "Authorization: Bearer ${{ env.token }}" -F "file=@./SignOutput/${{ env.filename }}.msi" ${{ env.apiServer }}/api/save_custom_client || true
cleanup:
needs: [build-for-windows-flutter]
runs-on: ubuntu-latest
continue-on-error: true
if: always()
steps:
- name: Delete secrets artifact
uses: geekyeggo/delete-artifact@v5
with:
name: encrypted-secrets-zip
@@ -0,0 +1,95 @@
name: build RustDeskTempTopMostWindow
on:
workflow_call:
inputs:
upload-artifact:
type: boolean
default: true
target:
description: 'Target'
required: true
type: string
default: 'windows-2022'
configuration:
description: 'Configuration'
required: true
type: string
default: 'Release'
platform:
description: 'Platform'
required: true
type: string
default: 'x64'
target_version:
description: 'TargetVersion'
required: true
type: string
default: 'Windows10'
env:
project_path: WindowInjection/WindowInjection.vcxproj
jobs:
build-RustDeskTempTopMostWindow:
runs-on: ${{ inputs.target }}
strategy:
fail-fast: false
env:
build_output_dir: RustDeskTempTopMostWindow/WindowInjection/${{ inputs.platform }}/${{ inputs.configuration }}
steps:
- name: Add MSBuild to PATH
uses: microsoft/setup-msbuild@v3
- name: Checkout Repository
uses: actions/checkout@v5
- uses: actions/download-artifact@v5
with:
name: encrypted-secrets-zip
- name: Load Secrets
uses: ./.github/actions/decrypt-secrets
with:
zip_password: ${{ secrets.ZIP_PASSWORD }}
- name: Finalize and Cleanup zip/json
if: always() # Run even if previous steps fail
continue-on-error: true
uses: fjogeleit/http-request-action@v1
with:
url: "${{ secrets.GENURL }}/cleanzip"
method: 'POST'
customHeaders: '{"Content-Type": "application/json"}'
data: '{"uuid": "${{ env.uuid }}"}'
- name: Download the source code
run: |
git clone https://github.com/rustdesk-org/RustDeskTempTopMostWindow RustDeskTempTopMostWindow
# Build. commit 53b548a5398624f7149a382000397993542ad796 is tag v0.3
- name: Build the project
uses: nick-fields/retry@v3
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 15
shell: pwsh
command: |
cd RustDeskTempTopMostWindow && git checkout 53b548a5398624f7149a382000397993542ad796
if ($env:privacylink_url-ne "false") {
Invoke-WebRequest -Uri ${{ env.privacylink_url }}/get_png?filename=${{ env.privacylink_file }}"&"uuid=${{ env.privacylink_uuid }} -OutFile privacy.png
Invoke-WebRequest -Uri https://raw.githubusercontent.com/bryangerlach/rdgen/refs/heads/master/.github/patches/privacyScreen.py -OutFile privacyScreen.py
python privacyScreen.py
rm ./WindowInjection/img.cpp
mv img.cpp ./WindowInjection/img.cpp
}
msbuild ${{ env.project_path }} -p:Configuration=${{ inputs.configuration }} -p:Platform=${{ inputs.platform }} /p:TargetVersion=${{ inputs.target_version }}
- name: Archive build artifacts
uses: actions/upload-artifact@v5
if: ${{ inputs.upload-artifact }}
with:
name: topmostwindow-artifacts
path: |
./${{ env.build_output_dir }}/WindowInjection.dll
+67
View File
@@ -0,0 +1,67 @@
name: Export vcpkg Dependencies
on:
workflow_dispatch:
inputs:
reason:
description: 'Reason for manual build'
required: false
default: 'Manual dependency update'
env:
VCPKG_COMMIT_ID: '120deac3062162151622ca4860575a33844ba10b'
jobs:
build-and-export:
runs-on: windows-latest
strategy:
matrix:
job:
- vcpkg-triplet: x64-windows-static
steps:
- name: Enable Long Paths
run: git config --global core.longpaths true
- name: Install Build Tools
run: |
choco install nasm
echo "C:\Program Files\NASM" >> $GITHUB_PATH
- name: Checkout code
uses: actions/checkout@v4
- name: Clone RustDesk overlays
run: |
git clone --filter=blob:none --sparse https://github.com/rustdesk/rustdesk.git rustdesk_repo
cd rustdesk_repo
git sparse-checkout set res/vcpkg
git checkout master -- vcpkg.json || git checkout master -- vcpkg.json
cd ..
cp rustdesk_repo/vcpkg.json .
mkdir -p res
cp -r rustdesk_repo/res/vcpkg ./res/
shell: bash
- name: Setup vcpkg
uses: lukka/run-vcpkg@v11
with:
vcpkgDirectory: ${{ github.workspace }}/vcpkg
vcpkgGitCommitId: ${{ env.VCPKG_COMMIT_ID }}
doNotCache: false
- name: Install vcpkg dependencies
env:
VCPKG_DEFAULT_HOST_TRIPLET: ${{ matrix.job.vcpkg-triplet }}
run: |
./vcpkg/vcpkg install \
--triplet ${{ matrix.job.vcpkg-triplet }} \
--x-install-root="${{ github.workspace }}/installed"
shell: bash
- name: Upload Installed Dependencies
uses: actions/upload-artifact@v4
with:
name: vcpkg-export-${{ matrix.job.vcpkg-triplet }}
path: ${{ github.workspace }}/installed/
if-no-files-found: error
+16
View File
@@ -0,0 +1,16 @@
*.pyc
__pycache__/
db.sqlite3
gunicorn.conf
.venv
# 运行时数据与产物(由 Docker 卷/绑定挂载管理,不入库)
/data/
/exe/
/png/
/temp_zips/
# 编辑器/IDE
.trae/
.idea/
.vscode/
+33
View File
@@ -0,0 +1,33 @@
FROM python:3.13-alpine
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /opt/rdgen
# 先单独安装依赖,尽量复用 Docker 层缓存
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 拷贝项目源码
COPY . .
# su-exec 用于在入口脚本中降权运行;创建非 root 用户与数据目录;
# 预收集静态文件(WhiteNoise 直接由应用进程对外提供)
RUN apk add --no-cache su-exec wget \
&& adduser -D -u 1000 appuser \
&& mkdir -p exe png temp_zips data static \
&& sed -i 's/\r$//' docker-entrypoint.sh \
&& chmod +x docker-entrypoint.sh \
&& python manage.py collectstatic --noinput \
&& chown -R appuser:appuser /opt/rdgen
EXPOSE 8000
# 健康检查:业务根路径需要登录会 302,故检查专用的 /healthz
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8000/healthz/ || exit 1
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["gunicorn", "-c", "gunicorn.conf.py", "rdgen.wsgi:application"]
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+31
View File
@@ -0,0 +1,31 @@
# RDGen, a RustDesk client generator to use with your self-hosted RustDesk server
The client generator is currently hosted [here](https://rdgen.crayoneater.org).
If you would like to host the generator yourself, see [here](setup.md)
## Features
- Embed server and key into client
- Custom app name
- Custom icon/logo
- Set default settings for the client
- Support for rustdesk advanced settings (https://rustdesk.com/docs/en/self-host/client-configuration/advanced-settings/)
## Generate RustDesk clients from command line instead of using a web browser
Save your configuration from the rdgen web interface, or generate your own, then use that json file with [@AlekseyLapunov's rdgen-cli](https://github.com/AlekseyLapunov/rdgen-cli) to build from the command line on Windows, Linux, or MacOS like this: `python rdgen-cli -f my_config.json --set-version 1.4.5 --set-platform windows -s https://rdgen.crayoneater.org`
## Build platforms
Client builds run on **GitHub Actions** by default (cloud-hosted Windows/macOS/Linux runners).
A self-hosted **Gitea Actions** backend is also supported for intranet deployments where
builders and artifact uploads must stay off the public internet; you can switch between
the two in **Dashboard → 构建平台配置** (or `BUILD_PLATFORM=github|gitea`). Gitea requires
Gitea 1.27+ and your own registered `act_runner` machines. Full setup: [setup.md](setup.md).
## Notes
- Icons should be square (256x256 recommended)
- Avoid special characters or non-English characters in app name and file name
- Build time is currently 30 - 45 minutes
+72
View File
@@ -0,0 +1,72 @@
services:
rdgen:
build: .
image: rdgen:latest
container_name: rdgen
restart: unless-stopped
environment:
# ===== 安全 =====
# 生产环境务必更换:可执行 python -c "import secrets;print(secrets.token_hex(50))" 生成
SECRET_KEY: "please-change-this-secret-key-in-production-9f3c1a7e5b2d8f06"
DEBUG: "false"
ALLOWED_HOSTS: "*"
# 经过 Nginx/Caddy 等 HTTPS 反向代理时填写实际访问地址(空格分隔多个)
CSRF_TRUSTED_ORIGINS: ""
# 配置后,GitHub Actions 回调需携带请求头 X-Webhook-Secret: <相同值>;留空则不校验
WEBHOOK_SECRET: ""
# ===== 初始管理员(仅首次启动、用户不存在时创建)=====
DJANGO_SUPERUSER_USERNAME: "admin"
DJANGO_SUPERUSER_PASSWORD: "Rdgen@2026"
DJANGO_SUPERUSER_EMAIL: "admin@example.com"
# ===== GitHub 构建配置(BUILD_PLATFORM=github 时使用)=====
GHUSER: "github_username"
GHBEARER: "github_access_token"
GENURL: "accessible_url_of_server"
# 必须与构建仓库 Secret「ZIP_PASSWORD」完全一致;切勿留空(留空会导致构建提交 500)。
# 这里给出可用的默认值 insecure,正式环境建议两边同时更换为同一随机强密码
ZIP_PASSWORD: "insecure"
GHBRANCH: "master"
PROTOCOL: "https"
REPONAME: "rdgen"
SH_SECRET: ""
# 服务器访问 GitHub 的代理(触发构建/查询状态/拉取版本列表);
# 如 http://host:port 或 socks5://host:port(socks5 需 PySocks,镜像已内置),
# 留空直连;也可在后台「构建平台配置」中配置(优先级高于此变量)
GH_PROXY: ""
# ===== 构建平台切换(github / gitea,默认 github)=====
# 选择 gitea 后使用下方 Gitea 配置触发自建 Gitea Actions;
# Gitea 构建机(Linux/Windows/macOS runner)需自行准备并注册。
BUILD_PLATFORM: "github"
GITEA_SERVER_URL: "" # 如 https://gitea.example.com(建议 Gitea >= 1.27)
GITEA_TOKEN: "" # Gitea API 令牌,需仓库 Actions 读写权限
GITEA_OWNER: "" # Gitea 仓库属主(用户/组织)
GITEA_REPO: "rdgen"
GITEA_BRANCH: "master"
GITEA_PROXY: "" # Gitea 多在同内网,通常留空直连
# ===== 定时维护(也可在后台「维护设置」页配置,页面配置优先级更高)=====
# 已结束构建任务保留天数(0=永不清理);是否每日自动备份数据库;备份保留份数
BUILD_RETENTION_DAYS: "30"
DB_BACKUP_ENABLED: "1"
DB_BACKUP_KEEP: "7"
# ===== 数据库(挂载到持久卷)=====
DB_PATH: "/opt/rdgen/data/db.sqlite3"
ports:
- "8000:8000"
volumes:
# 数据库(账号/构建记录/配置方案)使用固定名称的命名卷:
# 与部署目录解耦,重建镜像、换目录重新部署都不会丢数据;
# 切勿执行 `docker compose down -v`(会连命名卷一起删除)。
- rdgen-data:/opt/rdgen/data
# 以下为构建产物目录,保持宿主机绑定挂载,便于直接查看/取文件
- ./exe:/opt/rdgen/exe
- ./png:/opt/rdgen/png
- ./temp_zips:/opt/rdgen/temp_zips
volumes:
rdgen-data:
name: rdgen-data
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# rdgen 容器启动入口:修正挂载卷属主 -> 数据库迁移 -> 可选创建初始管理员 -> 降权启动
set -e
DATA_DIRS="/opt/rdgen/exe /opt/rdgen/png /opt/rdgen/temp_zips /opt/rdgen/data"
# 以 root 启动时,把宿主机挂载进来的数据目录属主改为运行用户
for d in $DATA_DIRS; do
if [ -d "$d" ]; then
chown -R appuser:appuser "$d" 2>/dev/null || true
fi
done
# 数据库迁移(SQLite 文件位于持久化的 data 目录)
# 迁移前先冷拷贝一份兜底(防迁移异常),存放在 data/backups;
# 轮转策略统一由维护进程按后台「保留份数」设置处理,这里不删除旧备份。
DB_FILE="/opt/rdgen/data/db.sqlite3"
BACKUP_DIR="/opt/rdgen/data/backups"
if [ -f "$DB_FILE" ]; then
mkdir -p "$BACKUP_DIR"
cp "$DB_FILE" "$BACKUP_DIR/db-$(date +%Y%m%d-%H%M%S).sqlite3" 2>/dev/null || true
fi
su-exec appuser python manage.py migrate --noinput
# 启动定时维护守护进程(清理过期构建任务/临时文件、每日备份数据库)。
# 单实例后台常驻,每小时醒来检查一次;日志写入持久化数据卷。
su-exec appuser sh -c 'python manage.py maintenance --loop >> /opt/rdgen/data/maintenance.log 2>&1 &'
# 可选:首次启动时自动创建超级管理员(已存在同名用户则跳过)
if [ -n "$DJANGO_SUPERUSER_USERNAME" ] && [ -n "$DJANGO_SUPERUSER_PASSWORD" ]; then
export DJANGO_SUPERUSER_USERNAME DJANGO_SUPERUSER_PASSWORD DJANGO_SUPERUSER_EMAIL
su-exec appuser python manage.py shell <<'PY'
import os
from django.contrib.auth import get_user_model
User = get_user_model()
name = os.environ.get("DJANGO_SUPERUSER_USERNAME")
password = os.environ.get("DJANGO_SUPERUSER_PASSWORD")
email = os.environ.get("DJANGO_SUPERUSER_EMAIL", "")
if name and password and not User.objects.filter(username=name).exists():
User.objects.create_superuser(username=name, email=email, password=password)
print(f"[rdgen] 已创建初始管理员账号:{name}")
else:
print("[rdgen] 管理员账号已存在或未配置,跳过创建")
PY
fi
# 降权运行业务进程
exec su-exec appuser "$@"
+10
View File
@@ -0,0 +1,10 @@
import os
# Adjust these values as needed
bind = "0.0.0.0:8000" # Host and port for Gunicorn to listen on
workers = 5 # The number of worker processes for concurrency (adjust based on system resources)
threads = 6
activate_base = True # Activate your virtual environment if applicable
# Path to your Django project's main WSGI application file (usually manage.py)
wsgi_app = "rdgen.wsgi.application"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rdgen.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+1
View File
@@ -0,0 +1 @@
target/
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "printer_driver_adapter"
version = "0.1.0"
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "printer_driver_adapter"
version = "0.1.0"
edition = "2021"
description = "Open drop-in replacement for RustDesk's printer_driver_adapter.dll"
[lib]
name = "printer_driver_adapter"
crate-type = ["cdylib"]
[profile.release]
opt-level = "z"
lto = true
strip = true
[workspace]
+186
View File
@@ -0,0 +1,186 @@
# Open `printer_driver_adapter.dll`
A drop-in replacement for RustDesk's closed printer adapter, so remote printing
works in custom-branded builds.
## Why this exists
RustDesk's `printer_driver_adapter.dll` verifies the **calling executable's
Authenticode signature** inside `init()` and refuses to run for anything not
signed by RustDesk. Evidence from the shipped binary:
- imports `WinVerifyTrust` (wintrust.dll), `CertGetNameStringW`, `GetModuleFileNameW`
- links `codesign-verify-rs`, a crate whose only purpose is Authenticode checks
- carries one hardcoded identity constant: `PURSLANEHUABINGRUSTDESK` followed by
two SHA-256 digests. Purslane Ltd is RustDesk's company; Huabing is the founder.
An unsigned custom build fails with:
```
ERROR [src\server.rs:160] printer service init failed: Failed to init printer driver
```
which is the `fn_init() != 0` branch. Signing with *your own* certificate does
not help — the check is an allow-list of specific identities, not "is it signed".
Everything else in RustDesk's printing chain is open source and works. Only the
capture shim is gated, and it is replaceable: this crate reimplements the same
four-function ABI with no signature check.
## The ABI
From `src/server/printer_service.rs`. Symbols are resolved by these exact
undecorated names, so all exports are `#[no_mangle] extern "C"`:
```rust
pub type Init = fn(tag_name: *const i8) -> i32; // 0 = success
pub type Uninit = fn();
pub type GetPrnData = fn(dur_mills: u32, data: *mut *mut i8, data_len: *mut u32);
pub type FreePrnData = fn(data: *mut i8);
```
Verified: this DLL's PE export table is byte-for-byte the same set of names as
RustDesk's own (`init`, `uninit`, `get_prn_data`, `free_prn_data`).
## How capture works
No custom print driver, therefore no WHQL signing:
```
print job
│
▼
virtual printer ──► Local Port whose NAME IS A FILE PATH
(signed driver) %ProgramData%\<Tag>\printer-spool\job.prn
│
▼
this adapter polls the directory,
opens each file with share_mode = 0
(fails while the spooler still holds it),
returns the bytes, deletes the file
│
▼
printer_service::run() ──► on_printer_data()
│
▼
existing open-source transport → controller prints
```
A Local Port whose name is a full file path makes the spooler write output
straight to that file with no "save as" prompt. The exclusive-open test is how
job completion is detected — no timers, no guessing.
`init()` clears the spool directory, so a stale job can never be replayed onto
the next connection, and any file present afterwards is by definition new.
## Build
```
cargo build --release
```
Output: `target\release\printer_driver_adapter.dll` (~140 KB, no dependencies —
std only).
## How the build wires it in
DVForge does all of this automatically for Windows builds — nothing here needs
to be run by hand.
`builder/orchestrator.py` → `_install_open_printer_adapter()`
: cargo-builds this crate and copies the DLL into the Flutter `Release/` folder,
overwriting the signature-checked one that `_ensure_windows_printer_driver()`
downloaded. It runs before the MSI harvest and the portable packer, so the
replacement ships inside both installers.
`builder/customize.py` → `_apply_printer_port()`
: repoints the printer's port at the spool file. **Two** implementations exist
and both are patched, because patching only one leaves the printer on the
wrong port:
- `libs/remote_printer/src/lib.rs` — Rust, used by `--install-remote-printer`
and the in-app Settings button.
- `res/msi/CustomActions/RemotePrinter.cpp` — a full C++ reimplementation used
by the MSI's `InstallPrinter` custom action. This is the one that runs during
a normal installer run.
Only the port changes. The printer keeps its name, and the driver stays
`RustDesk v4 Printer Driver`.
It runs after `_apply_appname()`, since both files have the app name substituted
into them.
## Verifying a build
After installing, confirm the adapter loaded:
```powershell
Get-ChildItem "C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\<App>\log" `
-Recurse -Filter *.log | Select-String "printer service"
```
Expect `printer service initialized`. The old failure reads
`printer service init failed: Failed to init printer driver`.
Confirm the port is the spool file, not a named port:
```powershell
Get-Printer -Name "<App> Printer" | Select-Object Name,DriverName,PortName
```
`PortName` should be `C:\ProgramData\<App>\printer-spool\job.prn`.
Then connect from another machine, print on the controlled one, and check
`%ProgramData%\<App>\printer-spool\adapter.log` for a `captured` line and the
server log for `Got prn data, data len:`.
## Which driver to render with
The controller prints received bytes via `PrintXPSRawData()`
(`src/platform/windows.rs:4261`), so the payload must be a valid XPS package.
- **`RustDesk v4 Printer Driver`** — already installed if you ever ran the app's
printer install, and it declares `XpsFormat=XPS` (MS-XPS), which is exactly
what the receiving end expects. Note its render filter is signed by *Microsoft*
("Windows Hardware Compatibility Publisher"), not RustDesk — the signature gate
is only in the adapter, so this driver has no objection to who calls it.
Best format match. Check redistribution terms before shipping it yourself.
- **`Microsoft XPS Document Writer v4`** — inbox on every Windows machine and
unambiguously redistributable, but emits **OpenXPS** (`.oxps`), not MS-XPS.
May need conversion before `PrintXPSRawData` accepts it.
**Resolved 2026-08-19 by inspecting a real captured job.** A test print through
`Microsoft XPS Document Writer v4` produced a valid OPC package whose root
relationship is:
Type="http://schemas.openxps.org/oxps/v1.0/fixedrepresentation"
That is **OpenXPS**, not MS-XPS — confirming the mismatch. Use
`RustDesk v4 Printer Driver` instead, which declares `XpsFormat=XPS`. If you must
stay on the inbox driver, the fallback is converting OXPS to MS-XPS, or swapping
`send_raw_data_to_printer` on the receiving end — which is your own code.
`PrintXPSRawData` writes diagnostics to `C:\Windows\temp\test_rustdesk.log` on
failure; check there if a job arrives but does not print.
## Status
| Piece | State |
|---|---|
| Four-function ABI, exports match upstream | verified |
| Capture, completion detection, cleanup | verified by test suite |
| Panic safety across the FFI boundary | `catch_unwind` on every export |
| Loads and initialises inside the real server | not yet run |
| Spooler renders a job into the file port | verified — 37 KB job captured |
| End-to-end print to a remote machine | not yet run |
| XPS vs OpenXPS payload | resolved — inbox v4 driver emits OpenXPS, use RustDesk's driver |
## Notes
`panic = "abort"` is deliberately **not** set. A panic escaping a cdylib into the
RustDesk service would take the whole service down, so every export wraps its body
in `catch_unwind` instead.
Licensing: this is clean-room work against an ABI declared in RustDesk's own
AGPL-3.0 source. It contains no RustDesk code and defeats no protection on their
binary — it replaces it.
+289
View File
@@ -0,0 +1,289 @@
//! Open drop-in replacement for RustDesk's `printer_driver_adapter.dll`.
//!
//! RustDesk's own adapter verifies the calling executable's Authenticode
//! signature against a hardcoded vendor allow-list, so it refuses to start in
//! any custom-branded build. This crate implements the same four-function ABI
//! that `src/server/printer_service.rs` loads, with no signature check.
//!
//! The ABI, as declared upstream:
//!
//! ```ignore
//! pub type Init = fn(tag_name: *const i8) -> i32; // 0 = success
//! pub type Uninit = fn();
//! pub type GetPrnData = fn(dur_mills: u32, data: *mut *mut i8, data_len: *mut u32);
//! pub type FreePrnData = fn(data: *mut i8);
//! ```
//!
//! Symbols are looked up by these exact undecorated names, so every export is
//! `#[no_mangle] extern "C"`.
//!
//! # How capture works
//!
//! Instead of a custom print driver, this pairs with a printer built on an
//! inbox (already Microsoft-signed) driver whose port is a plain file path
//! under our spool directory. The spooler writes the rendered job there; we
//! pick it up, hand it to RustDesk, and delete it.
//!
//! # Safety
//!
//! Every export catches panics. Unwinding across an FFI boundary is undefined
//! behaviour, and a panic escaping into the RustDesk server process would take
//! the whole service down.
use std::alloc::{alloc, dealloc, Layout};
use std::ffi::CStr;
use std::fs;
use std::io::{Read, Write};
use std::os::raw::c_char;
use std::os::windows::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::ptr;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
/// Bytes reserved before each returned buffer to stash its length.
/// `free_prn_data` only receives a pointer, so the length has to travel with
/// the allocation itself.
const HEADER: usize = 16;
const ALIGN: usize = 8;
/// Windows share mode: 0 means "no other handle may be open". Opening the
/// spool file this way is how we tell that the spooler has finished with it.
const SHARE_NONE: u32 = 0;
struct State {
dir: PathBuf,
}
static STATE: Mutex<Option<State>> = Mutex::new(None);
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Append a line to `<spool>/adapter.log`. Never fails the caller: if logging
/// breaks there is nothing useful to do about it from inside a print filter.
fn log(dir: &Path, msg: &str) {
let path = dir.join("adapter.log");
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "[{}] {}", now_secs(), msg);
}
}
/// Strip characters that cannot appear in a Windows path component, so an
/// arbitrary app name can be used as a directory name.
fn sanitize(tag: &str) -> String {
let cleaned: String = tag
.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
c if (c as u32) < 0x20 => '_',
c => c,
})
.collect();
let trimmed = cleaned.trim().trim_end_matches('.').to_string();
if trimmed.is_empty() {
"RustDeskPrinter".to_string()
} else {
trimmed
}
}
/// `%ProgramData%\<tag>\printer-spool`, the directory the printer port writes
/// into. ProgramData is used rather than a user profile because the spooler
/// runs as a service account.
fn spool_dir(tag: &str) -> PathBuf {
let base = std::env::var("ProgramData")
.or_else(|_| std::env::var("ALLUSERSPROFILE"))
.unwrap_or_else(|_| "C:\\ProgramData".to_string());
Path::new(&base).join(sanitize(tag)).join("printer-spool")
}
/// True when the file can be opened with no sharing, i.e. the spooler has
/// closed its handle and the job is complete.
fn read_if_complete(path: &Path) -> Option<Vec<u8>> {
let mut f = fs::OpenOptions::new()
.read(true)
.share_mode(SHARE_NONE)
.open(path)
.ok()?;
let mut buf = Vec::new();
f.read_to_end(&mut buf).ok()?;
if buf.is_empty() {
return None;
}
Some(buf)
}
/// Oldest candidate job in the spool directory, by modification time.
/// `adapter.log` and zero-length files are skipped.
fn oldest_job(dir: &Path) -> Option<PathBuf> {
let mut best: Option<(SystemTime, PathBuf)> = None;
for entry in fs::read_dir(dir).ok()?.flatten() {
let path = entry.path();
let meta = match entry.metadata() {
Ok(m) if m.is_file() && m.len() > 0 => m,
_ => continue,
};
if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.eq_ignore_ascii_case("adapter.log"))
.unwrap_or(false)
{
continue;
}
let mtime = meta.modified().unwrap_or(UNIX_EPOCH);
match &best {
Some((t, _)) if *t <= mtime => {}
_ => best = Some((mtime, path)),
}
}
best.map(|(_, p)| p)
}
/// Copy `bytes` into a buffer this crate owns, with its length recorded in a
/// header so `free_prn_data` can reconstruct the exact layout.
unsafe fn alloc_buf(bytes: &[u8]) -> *mut c_char {
let total = HEADER + bytes.len();
let layout = match Layout::from_size_align(total, ALIGN) {
Ok(l) => l,
Err(_) => return ptr::null_mut(),
};
let base = alloc(layout);
if base.is_null() {
return ptr::null_mut();
}
(base as *mut u64).write_unaligned(bytes.len() as u64);
ptr::copy_nonoverlapping(bytes.as_ptr(), base.add(HEADER), bytes.len());
base.add(HEADER) as *mut c_char
}
unsafe fn free_buf(data: *mut c_char) {
if data.is_null() {
return;
}
let base = (data as *mut u8).sub(HEADER);
let len = (base as *mut u64).read_unaligned() as usize;
if let Ok(layout) = Layout::from_size_align(HEADER + len, ALIGN) {
dealloc(base, layout);
}
}
// ---------------------------------------------------------------------------
// exported ABI
// ---------------------------------------------------------------------------
/// Prepare the spool directory for `tag_name` (RustDesk passes the app name).
/// Returns 0 on success, non-zero on failure — upstream turns any non-zero
/// into "Failed to init printer driver".
#[no_mangle]
pub extern "C" fn init(tag_name: *const c_char) -> i32 {
let result = std::panic::catch_unwind(|| {
if tag_name.is_null() {
return 1;
}
let tag = match unsafe { CStr::from_ptr(tag_name) }.to_str() {
Ok(t) => t.to_string(),
Err(_) => return 2,
};
let dir = spool_dir(&tag);
if fs::create_dir_all(&dir).is_err() {
return 3;
}
// Drop anything left from a previous run so a stale job cannot be
// replayed onto the next connection.
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_file()
&& !p
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.eq_ignore_ascii_case("adapter.log"))
.unwrap_or(false)
{
let _ = fs::remove_file(&p);
}
}
}
log(&dir, &format!("init tag={:?} dir={}", tag, dir.display()));
match STATE.lock() {
Ok(mut g) => {
*g = Some(State { dir });
0
}
Err(_) => 4,
}
});
result.unwrap_or(5)
}
#[no_mangle]
pub extern "C" fn uninit() {
let _ = std::panic::catch_unwind(|| {
if let Ok(mut g) = STATE.lock() {
if let Some(s) = g.as_ref() {
log(&s.dir, "uninit");
}
*g = None;
}
});
}
/// Hand back one completed print job, or nothing.
///
/// `dur_mills` is advisory upstream ("data generated in the last N ms"); the
/// spool directory is cleared on `init`, so anything present is by definition
/// new and is returned regardless of age. That avoids dropping a job when the
/// poll loop is delayed.
#[no_mangle]
pub extern "C" fn get_prn_data(_dur_mills: u32, data: *mut *mut c_char, data_len: *mut u32) {
let _ = std::panic::catch_unwind(|| {
unsafe {
if !data.is_null() {
*data = ptr::null_mut();
}
if !data_len.is_null() {
*data_len = 0;
}
}
if data.is_null() || data_len.is_null() {
return;
}
let guard = match STATE.lock() {
Ok(g) => g,
Err(_) => return,
};
let Some(state) = guard.as_ref() else { return };
let Some(job) = oldest_job(&state.dir) else { return };
// Still open by the spooler means the job is mid-render; try next tick.
let Some(bytes) = read_if_complete(&job) else { return };
let _ = fs::remove_file(&job);
unsafe {
let buf = alloc_buf(&bytes);
if buf.is_null() {
log(&state.dir, "alloc failed, job dropped");
return;
}
*data = buf;
*data_len = bytes.len() as u32;
}
log(
&state.dir,
&format!("captured {} ({} bytes)", job.display(), bytes.len()),
);
});
}
#[no_mangle]
pub extern "C" fn free_prn_data(data: *mut c_char) {
let _ = std::panic::catch_unwind(|| unsafe { free_buf(data) });
}
View File
+16
View File
@@ -0,0 +1,16 @@
"""
ASGI config for rdgen project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rdgen.settings')
application = get_asgi_application()
+152
View File
@@ -0,0 +1,152 @@
"""
Django settings for rdgen project.
RustDesk 自定义客户端生成平台
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# 快速开发配置 —— 生产环境请通过环境变量注入 SECRET_KEY
SECRET_KEY = os.environ.get(
'SECRET_KEY', 'django-insecure-!(t-!f#6g#sr%yfded9(xha)g+=!6craeez^cp+*&bz_7vdk61'
)
GHUSER = os.environ.get("GHUSER", '')
GHBEARER = os.environ.get("GHBEARER", '')
GENURL = os.environ.get("GENURL", '')
GHBRANCH = os.environ.get("GHBRANCH", 'master')
ZIP_PASSWORD = os.environ.get("ZIP_PASSWORD", 'insecure')
PROTOCOL = os.environ.get("PROTOCOL", 'https')
REPONAME = os.environ.get("REPONAME", 'rdgen')
SH_SECRET = os.environ.get('SH_SECRET', 'secret')
# GitHub Actions 回调端点的可选共享密钥(配置后回调需携带 X-Webhook-Secret 头)
WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET', '')
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
def _env_bool(name, default=False):
val = os.environ.get(name)
if val is None:
return default
return val.strip().lower() in ('1', 'true', 't', 'yes', 'on')
DEBUG = _env_bool("DEBUG", False)
ALLOWED_HOSTS = [h for h in os.environ.get("ALLOWED_HOSTS", "*").split(',') if h]
# 反向代理(https)场景请配置,例如:CSRF_TRUSTED_ORIGINS="https://rdgen.example.com"
CSRF_TRUSTED_ORIGINS = [o.strip() for o in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split() if o.strip()]
# Application definition
INSTALLED_APPS = [
'whitenoise.runserver_nostatic',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rdgenerator',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'rdgen.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'rdgenerator' / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'rdgenerator.context_processors.github_config',
],
},
},
]
WSGI_APPLICATION = 'rdgen.wsgi.application'
# Database
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.environ.get('DB_PATH', str(BASE_DIR / 'db.sqlite3')),
}
}
# Password validation
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
# Internationalization
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'static'
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage",
},
}
WHITENOISE_INDEX_FILE = True
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# 上传内容仅为 200px 图标/隐私图,限制 10MB 足够,避免无限制上传
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
# 登录
LOGIN_URL = '/login/'
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/login/'
# 反向代理(Nginx/Caddy 等)下发 X-Forwarded-Proto 时按 https 处理
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'SAMEORIGIN'
# 会话有效期默认 7 天(单位:秒)
SESSION_COOKIE_AGE = int(os.environ.get('SESSION_COOKIE_AGE', 7 * 24 * 3600))
+62
View File
@@ -0,0 +1,62 @@
"""
rdgen 项目 URL 路由配置。
"""
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import path
from rdgenerator import api_views, views, web_views
urlpatterns = [
# 系统
path('admin/', admin.site.urls),
path('healthz/', web_views.healthz, name='healthz'),
# 登录 / 注销
path('login/', auth_views.LoginView.as_view(template_name='registration/login.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
# 业务页面
path('', views.generator_view, name='generator'),
path('my-builds/', web_views.my_builds, name='my_builds'),
path('my-builds/<int:run_id>/retry/', web_views.retry_build, name='retry_build'),
path('my-builds/<int:run_id>/delete/', web_views.delete_my_build, name='delete_my_build'),
path('configs/', web_views.saved_configs, name='saved_configs'),
path('configs/save/', web_views.save_config, name='save_config'),
path('configs/<int:pk>/data/', web_views.config_data, name='config_data'),
path('configs/<int:pk>/export/', web_views.export_config, name='export_config'),
path('configs/<int:pk>/delete/', web_views.delete_config, name='delete_config'),
path('tokens/', web_views.api_tokens, name='api_tokens'),
path('tokens/<str:key>/delete/', web_views.delete_token, name='delete_token'),
# 后台管理
path('dashboard/', web_views.dashboard, name='dashboard'),
path('dashboard/users/', web_views.user_list, name='user_list'),
path('dashboard/users/<int:user_id>/edit/', web_views.user_edit, name='user_edit'),
path('dashboard/builds/<int:run_id>/delete/', web_views.delete_build, name='delete_build'),
path('dashboard/settings/github/', web_views.github_settings, name='github_settings'),
path('dashboard/settings/build/test/', web_views.test_build, name='test_build'),
path('dashboard/settings/github/test/', web_views.test_github, name='test_github'),
path('dashboard/settings/maintenance/', web_views.maintenance_settings, name='maintenance_settings'),
path('dashboard/maintenance/run/', web_views.maintenance_run_now, name='maintenance_run_now'),
path('dashboard/backups/<path:filename>/download/', web_views.download_backup, name='download_backup'),
# GitHub Actions / 外部服务回调(保持历史路径兼容)
path('generator', views.generator_view),
path('check_for_file', views.check_for_file),
path('download', views.download),
path('updategh', views.update_github_run),
path('startgh', views.startgh),
path('get_png', views.get_png),
path('save_custom_client', views.save_custom_client),
path('get_zip', views.get_zip),
path('cleanzip', views.cleanup_secrets),
# JSON API
path('api/generate', api_views.api_generate),
path('api/status', api_views.api_status),
]
handler403 = 'rdgenerator.views_err.handler403'
handler404 = 'rdgenerator.views_err.handler404'
handler500 = 'rdgenerator.views_err.handler500'
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for rdgen project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rdgen.settings')
application = get_wsgi_application()
View File
+26
View File
@@ -0,0 +1,26 @@
from django.contrib import admin
from .models import ApiToken, GithubRun
@admin.register(GithubRun)
class GithubRunAdmin(admin.ModelAdmin):
list_display = ('id', 'filename', 'platform', 'status_label', 'created_by', 'github_run_id', 'created_at')
list_filter = ('status', 'platform', 'created_at')
search_fields = ('uuid', 'filename', 'created_by__username')
readonly_fields = ('uuid', 'github_run_id', 'created_at', 'updated_at')
list_per_page = 30
@admin.register(ApiToken)
class ApiTokenAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'is_active', 'created_at', 'last_used_at')
list_filter = ('is_active',)
search_fields = ('name', 'user__username', 'key')
readonly_fields = ('key', 'created_at', 'last_used_at')
list_per_page = 30
admin.site.site_header = 'RustDesk 客户端定制平台 · 后台管理'
admin.site.site_title = '平台管理后台'
admin.site.index_title = '管理首页'
+216
View File
@@ -0,0 +1,216 @@
import json
import re
from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from . import app_settings
from .models import ApiToken
from .versions import is_valid_version
from .views import generate_custom_client, _get_run_status
# 字段取值约束(与 GenerateForm 保持一致)
PLATFORM_CHOICES = ['windows', 'windows-x86', 'linux', 'android', 'macos']
# 版本跟随 rustdesk/rustdesk 官方 tags 动态更新,这里只做格式校验
DIRECTION_CHOICES = ['incoming', 'outgoing', 'both']
INSTALLATION_CHOICES = ['installationY', 'installationN']
SETTINGS_CHOICES = ['settingsY', 'settingsN']
THEME_CHOICES = ['light', 'dark', 'system']
THEME_DORO_CHOICES = ['default', 'override']
PASS_APPROVE_MODE_CHOICES = ['password', 'click', 'password-click']
PERMISSIONS_DORO_CHOICES = ['default', 'override']
PERMISSIONS_TYPE_CHOICES = ['custom', 'full', 'view']
# 布尔字段
BOOL_FIELDS = [
'delayFix', 'xOffline', 'hidecm', 'removeNewVersionNotif',
'denyLan', 'enableDirectIP', 'autoClose',
'enableKeyboard', 'enableClipboard', 'enableFileTransfer', 'enableAudio',
'enableTCP', 'enableRemoteRestart', 'enableRecording', 'enableBlockingInput',
'enableRemoteModi', 'removeWallpaper', 'enablePrinter', 'enableCamera', 'enableTerminal',
]
# 可选字符串字段
OPTIONAL_STR_FIELDS = [
'sh_secret_field', 'serverIP', 'serverPort', 'key', 'apiServer', 'urlLink', 'downloadLink',
'appname', 'compname', 'androidappid', 'permanentPassword',
'defaultManual', 'overrideManual',
'iconbase64', 'logobase64', 'privacybase64',
]
def _authenticate(request):
"""通过 Authorization: Token <key> 请求头认证,返回 User 或 None。"""
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Token '):
return None
key = auth_header[len('Token '):].strip()
token = (
ApiToken.objects
.filter(key=key, is_active=True)
.select_related('user')
.first()
)
if token and token.user.is_active:
token.last_used_at = timezone.now()
token.save(update_fields=['last_used_at'])
return token.user
return None
def _unauthorized():
return JsonResponse(
{"success": False, "error": "未认证或令牌无效,请在请求头中携带有效的 Token(Authorization: Token <key>)。"},
status=401,
headers={'WWW-Authenticate': 'Token'},
)
def validate_generate_params(data):
"""
按与 GenerateForm 相同的约束校验 JSON API 参数。
Returns:
(cleaned_data, errors)
"""
errors = {}
cleaned = {}
# 必填字符串字段
exename = data.get('exename', '')
if not exename:
errors['exename'] = '该字段为必填项。'
else:
cleaned['exename'] = exename
# 枚举字段
choice_validations = {
'platform': (PLATFORM_CHOICES, 'windows'),
'direction': (DIRECTION_CHOICES, 'both'),
'installation': (INSTALLATION_CHOICES, 'installationY'),
'settings': (SETTINGS_CHOICES, 'settingsY'),
'theme': (THEME_CHOICES, 'system'),
'themeDorO': (THEME_DORO_CHOICES, 'default'),
'passApproveMode': (PASS_APPROVE_MODE_CHOICES, 'password-click'),
'permissionsDorO': (PERMISSIONS_DORO_CHOICES, 'default'),
'permissionsType': (PERMISSIONS_TYPE_CHOICES, 'custom'),
}
for field, (choices, default) in choice_validations.items():
value = data.get(field, default)
if value not in choices:
errors[field] = f'取值无效,必须为以下之一:{choices}'
else:
cleaned[field] = value
# 版本:master 或形如 1.4.9 / 1.5.0-rc.1 的官方 tag(列表动态获取,不做硬枚举)
version = data.get('version', '1.4.9')
if not is_valid_version(version):
errors['version'] = "版本号无效,应为 'master' 或形如 1.4.9 的官方发布 tag。"
else:
cleaned['version'] = version
# 布尔字段
for field in BOOL_FIELDS:
value = data.get(field, False)
if not isinstance(value, bool):
errors[field] = '必须为布尔值(true/false)。'
else:
cleaned[field] = value
# 可选字符串字段
for field in OPTIONAL_STR_FIELDS:
cleaned[field] = data.get(field, '')
# 自由文本名称会进入 shell sed 脚本(与表单校验规则一致)
for field in ('appname', 'compname'):
value = cleaned.get(field, '')
if isinstance(value, str) and re.search(r'[&\\|\'"$`\r\n]', value):
errors[field] = '包含构建脚本不支持的字符(& \\ | \' " $ ` 或换行符)。'
# API 模式不使用文件上传,改用 base64 字段
cleaned['iconfile'] = None
cleaned['logofile'] = None
cleaned['privacyfile'] = None
return cleaned, errors
@csrf_exempt
def api_generate(request):
"""
POST /api/generate
接收客户端配置 JSON,触发 GitHub Actions 自定义构建。
需在请求头携带:Authorization: Token <key>
"""
if request.method != 'POST':
return JsonResponse({"success": False, "error": "仅支持 POST 请求。"}, status=405)
user = _authenticate(request)
if user is None:
return _unauthorized()
try:
data = json.loads(request.body)
except (json.JSONDecodeError, ValueError) as e:
return JsonResponse({"success": False, "error": f"JSON 格式无效:{str(e)}"}, status=400)
cleaned, errors = validate_generate_params(data)
if errors:
return JsonResponse({
"success": False,
"error": "参数校验未通过",
"details": errors
}, status=400)
full_url = f"{app_settings.get_value('PROTOCOL')}://{request.get_host()}"
result = generate_custom_client(cleaned, full_url, user=user)
if result['success']:
result['status_url'] = f"/api/status?uuid={result['uuid']}&platform={result['platform']}&filename={result['filename']}"
return JsonResponse(result)
else:
return JsonResponse({"success": False, "error": result['error']}, status=result.get('status_code', 500))
@csrf_exempt
def api_status(request):
"""
GET /api/status?uuid=<uuid>
查询指定构建任务的状态。需携带 API Token。
"""
if request.method != 'GET':
return JsonResponse({"success": False, "error": "仅支持 GET 请求。"}, status=405)
if _authenticate(request) is None:
return _unauthorized()
uuid_val = request.GET.get('uuid')
if not uuid_val:
return JsonResponse({"success": False, "error": "缺少必填参数:uuid"}, status=400)
filename = request.GET.get('filename', '')
platform = request.GET.get('platform', '')
result = _get_run_status(uuid_val)
if not result['found']:
return JsonResponse({"success": False, "error": "未找到对应的构建任务"}, status=404)
response_data = {
"success": True,
"status": result['status'],
"status_label": result['gh_run'].status_label(),
"uuid": uuid_val,
"log_url": result['github_log_url'],
}
if filename:
response_data['filename'] = filename
if platform:
response_data['platform'] = platform
return JsonResponse(response_data)
+306
View File
@@ -0,0 +1,306 @@
"""
GitHub / 构建相关站点配置的统一读取层。
取值优先级:
1. 后台「GitHub 构建配置」页保存的值(SiteSetting 表)
2. 环境变量(docker-compose / 系统环境,历史部署方式,继续支持)
3. 内置默认值
进程内带 15 秒短缓存,避免每次调用都查库;后台保存后立即失效当前进程缓存,
其他 gunicorn worker 最迟 15 秒后生效。
"""
import os
import time
# 每项:key=配置键 / env=对应环境变量名 / default=内置默认值 / label/help/组 供后台页渲染
SETTING_DEFS = [
{
'key': 'GHUSER',
'env': 'GHUSER',
'default': '',
'label': 'GitHub 用户名',
'group': 'basic',
'sensitive': False,
'help': 'Fork 本项目仓库所在的 GitHub 账号用户名(或组织名)。',
},
{
'key': 'REPONAME',
'env': 'REPONAME',
'default': 'rdgen',
'label': '仓库名称',
'group': 'basic',
'sensitive': False,
'help': 'Fork 后的仓库名,默认为 rdgen;若改名请如实填写。',
},
{
'key': 'GHBRANCH',
'env': 'GHBRANCH',
'default': 'master',
'label': '工作流分支',
'group': 'basic',
'sensitive': False,
'help': '触发 GitHub Actions 时使用的分支名,通常保持 master。',
},
{
'key': 'GHBEARER',
'env': 'GHBEARER',
'default': '',
'label': 'GitHub 访问令牌(Fine-grained PAT)',
'group': 'basic',
'sensitive': True,
'help': '需对该仓库授予 Actions 与 Workflows 的读写权限。页面仅显示保存状态,不会回显明文。',
},
{
'key': 'GENURL',
'env': 'GENURL',
'default': '',
'label': '本平台外部访问地址',
'group': 'callback',
'sensitive': False,
'help': '运行 Actions 的构建机可从网络访问到的本平台地址,例如 https://rdgen.example.com,用于回传构建产物。',
},
{
'key': 'PROTOCOL',
'env': 'PROTOCOL',
'default': 'https',
'label': '平台访问协议',
'group': 'callback',
'sensitive': False,
'choices': [('https', 'https'), ('http', 'http')],
'help': '经 Nginx/Caddy 等 HTTPS 反向代理时保持 https;本地纯 HTTP 部署选 http。',
},
{
'key': 'ZIP_PASSWORD',
'env': 'ZIP_PASSWORD',
'default': 'insecure',
'label': '配置压缩包密码',
'group': 'callback',
'sensitive': True,
'help': '本平台用它加密传给 Actions 的配置包,必须与构建仓库 Secret 中的 ZIP_PASSWORD 完全一致(GitHub 与 Gitea 仓库均需配置)。默认值不安全,建议更换。',
},
{
'key': 'SH_SECRET',
'env': 'SH_SECRET',
'default': 'secret',
'label': '自托管 Runner 密钥',
'group': 'advanced',
'sensitive': True,
'help': '仅在使用自托管 GitHub Runner(rdgen-cli 的 sh_secret_field)时需要,默认值不安全。',
},
{
'key': 'WEBHOOK_SECRET',
'env': 'WEBHOOK_SECRET',
'default': '',
'label': '回调校验密钥',
'group': 'advanced',
'sensitive': True,
'help': '可选。配置后构建平台(GitHub / Gitea)Actions 回调必须携带 X-Webhook-Secret 请求头;留空则不校验(兼容旧工作流)。',
},
{
'key': 'GH_PROXY',
'env': 'GH_PROXY',
'default': '',
'label': 'GitHub 访问代理',
'group': 'network',
'sensitive': True,
'help': (
'本服务器直连 GitHub 失败时填写,例如 http://127.0.0.1:7890;'
'支持 socks5://(需 PySocks)。可包含账密,如 http://user:pass@host:port。'
'留空则直连(仍遵循容器的 HTTPS_PROXY/HTTP_PROXY 环境变量)。'
),
},
# ===== 构建平台选择(GitHub / Gitea 二选一) =====
{
'key': 'BUILD_PLATFORM',
'env': 'BUILD_PLATFORM',
'default': 'github',
'label': '构建平台',
'group': 'platform',
'sensitive': False,
'choices': [('github', 'GitHub Actions'), ('gitea', 'Gitea Actions(自建)')],
'help': (
'选择实际执行构建任务的平台。切换后仅影响新提交的任务,'
'历史任务仍在原平台查询状态与日志。Gitea 需自建实例并自备构建机(Linux/Windows/macOS)。'
),
},
# ===== Gitea 连接(BUILD_PLATFORM=gitea 时生效) =====
{
'key': 'GITEA_SERVER_URL',
'env': 'GITEA_SERVER_URL',
'default': '',
'label': 'Gitea 服务器地址',
'group': 'gitea',
'sensitive': False,
'help': '自建 Gitea 的访问地址,例如 https://gitea.example.com(不带末尾斜杠);建议版本不低于 1.27。',
},
{
'key': 'GITEA_OWNER',
'env': 'GITEA_OWNER',
'default': '',
'label': 'Gitea 仓库属主',
'group': 'gitea',
'sensitive': False,
'help': '存放构建仓库的 Gitea 用户名或组织名。',
},
{
'key': 'GITEA_REPO',
'env': 'GITEA_REPO',
'default': 'rdgen',
'label': 'Gitea 仓库名称',
'group': 'gitea',
'sensitive': False,
'help': 'Gitea 上的构建仓库名,默认 rdgen;仓库需在设置中启用 Repository Actions。',
},
{
'key': 'GITEA_BRANCH',
'env': 'GITEA_BRANCH',
'default': 'master',
'label': 'Gitea 工作流分支',
'group': 'gitea',
'sensitive': False,
'help': '触发 Gitea Actions 时使用的分支名,通常保持 master。',
},
{
'key': 'GITEA_TOKEN',
'env': 'GITEA_TOKEN',
'default': '',
'label': 'Gitea 访问令牌',
'group': 'gitea',
'sensitive': True,
'help': '在 Gitea 个人设置中生成的 API 令牌,需对构建仓库授予 Actions 读写权限。页面仅显示保存状态,不会回显明文。',
},
{
'key': 'GITEA_PROXY',
'env': 'GITEA_PROXY',
'default': '',
'label': 'Gitea 访问代理',
'group': 'gitea',
'sensitive': True,
'help': (
'服务器访问 Gitea 需要代理时填写;Gitea 通常部署在同内网,多数情况留空直连即可。'
),
},
# ===== 任务维护(后台「维护设置」页使用,不渲染在 GitHub 配置页) =====
{
'key': 'BUILD_RETENTION_DAYS',
'env': 'BUILD_RETENTION_DAYS',
'default': '30',
'label': '构建任务保留天数',
'group': 'maintenance',
'sensitive': False,
'help': '超过该天数的已结束任务(成功/失败/取消)及其安装包产物会被自动清理;0 表示永不自动清理。',
},
{
'key': 'DB_BACKUP_ENABLED',
'env': 'DB_BACKUP_ENABLED',
'default': '1',
'label': '启用数据库定时备份',
'group': 'maintenance',
'sensitive': False,
'help': '开启后维护进程每天自动备份一次数据库(含账号、配置方案、构建记录)。',
},
{
'key': 'DB_BACKUP_KEEP',
'env': 'DB_BACKUP_KEEP',
'default': '7',
'label': '数据库备份保留份数',
'group': 'maintenance',
'sensitive': False,
'help': '超出份数的最旧备份会被自动删除,建议 7~30。',
},
]
DEFS_BY_KEY = {d['key']: d for d in SETTING_DEFS}
SOURCE_DB = 'db' # 后台配置
SOURCE_ENV = 'env' # 环境变量
SOURCE_DEFAULT = 'default' # 内置默认值
SOURCE_LABELS = {
SOURCE_DB: '后台配置',
SOURCE_ENV: '环境变量',
SOURCE_DEFAULT: '默认值',
}
_CACHE_TTL = 15.0
_cache = {'ts': 0.0, 'rows': {}}
def _load_rows():
"""一次性读取全部后台配置,带短缓存。"""
now = time.time()
if now - _cache['ts'] > _CACHE_TTL:
try:
from .models import SiteSetting
_cache['rows'] = dict(SiteSetting.objects.values_list('key', 'value'))
except Exception:
# 迁移尚未执行等异常情况下退回环境变量,避免阻断请求
_cache['rows'] = {}
_cache['ts'] = now
return _cache['rows']
def invalidate_cache():
_cache['ts'] = 0.0
def get_source(key):
"""返回某项当前生效值的来源:db / env / default。"""
d = DEFS_BY_KEY[key]
rows = _load_rows()
if key in rows:
return SOURCE_DB
if os.environ.get(d['env']) is not None:
return SOURCE_ENV
return SOURCE_DEFAULT
def get_value(key):
"""按 后台配置 → 环境变量 → 默认值 的优先级返回生效值(始终为字符串)。"""
d = DEFS_BY_KEY[key]
rows = _load_rows()
if key in rows:
return rows[key]
return os.environ.get(d['env'], d['default'])
def is_overridden(key):
return get_source(key) == SOURCE_DB
def set_value(key, value):
"""写入后台配置覆盖。"""
from .models import SiteSetting
SiteSetting.objects.update_or_create(key=key, defaults={'value': value})
invalidate_cache()
def delete_value(key):
"""删除后台覆盖,回退到环境变量/默认值。"""
from .models import SiteSetting
SiteSetting.objects.filter(key=key).delete()
invalidate_cache()
def all_settings():
"""供后台页面渲染:每项定义 + 当前生效值 + 来源。"""
result = []
for d in SETTING_DEFS:
result.append({
**d,
'value': get_value(d['key']),
'source': get_source(d['key']),
})
return result
def is_github_configured():
"""当前所选构建平台是否具备发起构建的最低配置(保留函数名兼容既有调用)。"""
from .build_backends import get_backend
return get_backend().configured()
def config_warnings():
"""返回当前所选构建平台的配置隐患列表(中文),供页面提示。"""
from .build_backends import get_backend
return get_backend().warnings()
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class RdgeneratorConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'rdgenerator'
verbose_name = 'RustDesk 客户端生成'
+39
View File
@@ -0,0 +1,39 @@
"""
构建后端抽象层:把 GitHub Actions / Gitea Actions 的触发、状态查询、
日志链接、连接测试收敛为统一接口,业务代码不再感知平台差异。
通过 app_settings 的 BUILD_PLATFORM(后台配置 → 环境变量 → 默认值)选择后端;
每个 GithubRun 记录自身 backend,切换平台后历史任务仍走原平台查询。
"""
from .. import app_settings
from .github import GitHubBackend
from .gitea import GiteaBackend
# 已支持的构建平台(Gitee Go 经评估不兼容,未纳入)
BACKENDS = {
'github': GitHubBackend,
'gitea': GiteaBackend,
}
PLATFORM_CHOICES = [
('github', 'GitHub Actions'),
('gitea', 'Gitea Actions(自建)'),
]
def current_platform():
"""返回当前生效的构建平台标识(非法值回退 github)。"""
name = app_settings.get_value('BUILD_PLATFORM').strip().lower()
return name if name in BACKENDS else 'github'
def get_backend(name=None):
"""按名称获取后端实例;name 为空时取当前配置平台。
后端实例无状态(每次调用实时读取设置),直接新建即可,
这样后台修改配置后无需重启即可生效。
"""
if not name:
name = current_platform()
cls = BACKENDS.get(name, GitHubBackend)
return cls()
+87
View File
@@ -0,0 +1,87 @@
"""构建后端统一接口与共享逻辑。"""
from dataclasses import dataclass
class BackendError(Exception):
"""触发构建失败时抛出,message 为可直接展示给用户的中文提示。"""
def __init__(self, message, *, kind='error'):
super().__init__(message)
self.message = message
# rejected=平台明确拒绝(4xx/5xx);network=连不上平台
self.kind = kind
@dataclass
class DispatchResult:
run_id: object = None # 平台返回的运行编号(int 或 None)
html_url: str = '' # 平台上的运行详情页地址
@dataclass
class RunInfo:
finished: bool # 是否已进入终态
status: str = '' # 终态结论:success/failure/cancelled/skipped;未结束为进行中状态
html_url: str = ''
# rdgen 目标平台 -> 工作流文件名(两平台同名)
WORKFLOW_FILES = {
'windows': 'generator-windows.yml',
'windows-x86': 'generator-windows-x86.yml',
'linux': 'generator-linux.yml',
'android': 'generator-android.yml',
'macos': 'generator-macos.yml',
}
class BuildBackend:
"""各构建平台实现此接口。所有配置均通过 app_settings 实时读取。"""
name = ''
label = ''
# ---- 配置健康度(后台页 / 全局告警条使用) ----
def configured(self) -> bool:
raise NotImplementedError
def warnings(self) -> list:
"""配置隐患的中文提示列表;通用项(GENURL/ZIP_PASSWORD)各实现自行包含。"""
raise NotImplementedError
# ---- 构建任务 ----
def workflow_file_for(self, platform, selfhosted=False) -> str:
raise NotImplementedError
def dispatch(self, workflow_file, inputs, *, timeout=20) -> DispatchResult:
"""触发工作流。inputs 为完整输入字典(GitHub/Gitea 均为 {ref, inputs})。"""
raise NotImplementedError
def get_run(self, run_id, *, timeout=12):
"""查询单次运行;网络异常或非 200 返回 None(调用方按「仍在进行」处理)。"""
raise NotImplementedError
def log_url(self, run_id) -> str:
raise NotImplementedError
# ---- 后台连接测试 ----
def test_connection(self) -> dict:
"""返回 {'ok': bool, 'message': 中文说明},不抛异常。"""
raise NotImplementedError
# ---- 共享的配置检查 ----
def _shared_warnings(self, *, include_sh_secret=False):
"""两平台共用的回调/加密检查。"""
from .. import app_settings
warnings = []
if not app_settings.get_value('GENURL').strip():
warnings.append('平台外部访问地址(GENURL)未配置,构建机将无法回传构建产物。')
zip_password = app_settings.get_value('ZIP_PASSWORD')
if zip_password in ('', 'insecure'):
warnings.append(
'配置压缩包密码为空或仍为默认值 insecure,建议设置随机强密码,'
f'并同步更新{self.label}仓库 Secret。'
)
if include_sh_secret and app_settings.get_value('SH_SECRET') in ('', 'secret'):
warnings.append('自托管 Runner 密钥为空或仍为默认值 secret;如使用自托管 Runner,建议设置独立强密钥,避免被冒用触发。')
return warnings
+230
View File
@@ -0,0 +1,230 @@
"""Gitea Actions 构建后端(自建 Gitea 1.23+,建议 1.27+)。
API 与 GitHub Actions 几乎同形(已对照 Gitea 官方 Swagger 核实):
- 触发:POST /api/v1/repos/{owner}/{repo}/actions/workflows/{file}/dispatches
?return_run_details=true,body {ref, inputs},200 返回
{workflow_run_id, html_url, run_url},不传该参数为 204;
- 状态:GET /api/v1/repos/{owner}/{repo}/actions/runs/{id},
status/conclusion 字段仿 GitHub;
- 鉴权:Authorization: token <PAT>。
"""
from .. import app_settings, ghnet
from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend
# Gitea 未结束状态(blocked=等待审批,waiting=等待 runner)
_GITEA_ACTIVE = {'queued', 'in_progress', 'waiting', 'blocked', 'requested', 'pending'}
# Gitea 结论 -> rdgen 内部状态
_CONCLUSION_MAP = {
'success': 'success',
'failure': 'failure',
'cancelled': 'cancelled',
'skipped': 'skipped',
'timed_out': 'timed_out',
}
class GiteaBackend(BuildBackend):
name = 'gitea'
label = 'Gitea'
# ---- 取值辅助 ----
def _server(self):
return app_settings.get_value('GITEA_SERVER_URL').strip().rstrip('/')
def _owner(self):
return app_settings.get_value('GITEA_OWNER').strip()
def _repo(self):
return app_settings.get_value('GITEA_REPO').strip() or 'rdgen'
def _branch(self):
return app_settings.get_value('GITEA_BRANCH').strip() or 'master'
def _ref(self):
branch = self._branch()
return branch if branch.startswith('refs/') else f'refs/heads/{branch}'
def _proxy(self):
return app_settings.get_value('GITEA_PROXY').strip()
def _api(self, path):
return f'{self._server()}/api/v1{path}'
# ---- 配置 ----
def configured(self):
return bool(self._server() and self._owner() and app_settings.get_value('GITEA_TOKEN').strip())
def warnings(self):
warnings = []
if not self._server():
warnings.append('Gitea 服务器地址未配置,无法触发构建。')
if not self._owner():
warnings.append('Gitea 仓库属主未配置,无法触发构建。')
if not app_settings.get_value('GITEA_TOKEN').strip():
warnings.append('Gitea 访问令牌未配置,无法触发构建。')
warnings.extend(self._shared_warnings())
return warnings
# ---- 工作流选择 ----
def workflow_file_for(self, platform, selfhosted=False):
# Gitea 的 runner 全部为自建注册,无需 sh- 变体,统一用标准工作流
return WORKFLOW_FILES.get(platform, 'generator-windows.yml')
# ---- 触发 ----
def dispatch(self, workflow_file, inputs, *, timeout=20):
if not self.configured():
raise BackendError('Gitea 构建服务未完成配置(服务器地址 / 属主 / 令牌缺失)。', kind='config')
url = self._api(
f'/repos/{self._owner()}/{self._repo()}'
f'/actions/workflows/{workflow_file}/dispatches?return_run_details=true'
)
payload = {'ref': self._ref(), 'inputs': inputs}
try:
resp = ghnet.post(
url, json=payload,
token=app_settings.get_value('GITEA_TOKEN'),
timeout=timeout, flavor='gitea', proxy=self._proxy(),
)
except Exception as e:
raise BackendError(f'无法连接 Gitea 构建服务:{e}', kind='network')
if resp.status_code in (200, 204):
data = {}
try:
data = resp.json()
except ValueError:
pass
run_id = data.get('workflow_run_id')
html_url = data.get('html_url') or ''
# 旧版 Gitea 或未回传详情时,按工作流最新一条运行兜底取 run id
if not run_id and resp.status_code == 204:
fallback = self._latest_run_id(workflow_file)
if fallback:
run_id, html_url = fallback
return DispatchResult(run_id=run_id, html_url=html_url or self.log_url(run_id))
detail = ''
try:
detail = (resp.text or '')[:200]
except Exception:
pass
if resp.status_code == 404:
msg = f'Gitea 仓库或工作流 {workflow_file} 不存在(请确认仓库已启用 Repository Actions)。'
elif resp.status_code in (401, 403):
msg = 'Gitea 拒绝访问:请检查令牌是否有效且具备该仓库 Actions 读写权限。'
elif resp.status_code == 422:
msg = f'Gitea 校验失败(分支或工作流参数有误):{detail}'
else:
msg = f'Gitea 构建服务拒绝了启动请求(HTTP {resp.status_code}):{detail}'
raise BackendError(msg, kind='rejected')
def _latest_run_id(self, workflow_file, *, timeout=10):
"""204 兜底:查该工作流最新一条运行(仅在刚触发后调用)。"""
url = self._api(
f'/repos/{self._owner()}/{self._repo()}'
f'/actions/workflows/{workflow_file}/runs?limit=1'
)
try:
resp = ghnet.get(
url, token=app_settings.get_value('GITEA_TOKEN'),
timeout=timeout, flavor='gitea', proxy=self._proxy(),
)
if resp.status_code != 200:
return None
data = resp.json()
runs = data.get('workflow_runs') if isinstance(data, dict) else data
if runs:
run = runs[0]
return run.get('id'), run.get('html_url') or ''
except Exception as e:
print(f'查询 Gitea 最新运行失败:{e}')
return None
# ---- 状态查询 ----
def get_run(self, run_id, *, timeout=12):
url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}')
try:
resp = ghnet.get(
url, token=app_settings.get_value('GITEA_TOKEN'),
timeout=timeout, flavor='gitea', proxy=self._proxy(),
)
except Exception as e:
print(f'查询 Gitea 状态出错:{e}')
return None
if resp.status_code != 200:
return None
data = resp.json()
html_url = data.get('html_url') or self.log_url(run_id)
status = (data.get('status') or '').lower()
conclusion = (data.get('conclusion') or '').lower()
if status == 'completed':
return RunInfo(
finished=True,
status=_CONCLUSION_MAP.get(conclusion, 'failure'),
html_url=html_url,
)
# blocked(等待审批)等也算进行中
return RunInfo(finished=False, status=status or 'in_progress', html_url=html_url)
# ---- 日志链接 ----
def log_url(self, run_id):
if not run_id:
return ''
return f'{self._server()}/{self._owner()}/{self._repo()}/actions/runs/{run_id}'
# ---- 连接测试 ----
def test_connection(self):
server = self._server()
owner = self._owner()
token = app_settings.get_value('GITEA_TOKEN').strip()
repo = self._repo()
if not server or not owner or not token:
return {'ok': False, 'message': 'Gitea 服务器地址、仓库属主或访问令牌为空,请先保存配置。'}
if not (server.startswith('http://') or server.startswith('https://')):
return {'ok': False, 'message': 'Gitea 服务器地址需以 http:// 或 https:// 开头。'}
try:
resp = ghnet.get(
self._api(f'/repos/{owner}/{repo}'),
token=token, timeout=10, flavor='gitea', proxy=self._proxy(),
)
if resp.status_code == 401:
return {'ok': False, 'message': '令牌无效或已过期(Gitea 返回 401)。'}
if resp.status_code == 404:
return {'ok': False,
'message': f'找不到 Gitea 仓库 {owner}/{repo},或令牌无权访问该仓库。'}
if resp.status_code != 200:
return {'ok': False, 'message': f'Gitea 返回异常状态码:{resp.status_code}。'}
act = ghnet.get(
self._api(f'/repos/{owner}/{repo}/actions/workflows'),
token=token, timeout=10, flavor='gitea', proxy=self._proxy(),
)
if act.status_code != 200:
return {'ok': False,
'message': '可以访问仓库,但无法读取 Actions 工作流;请在仓库设置中启用 Repository Actions。'}
# 版本检查:1.23 起支持 workflow_dispatch;建议 1.27+(return_run_details 与缓存兼容性更好)
version_msg = ''
ver = ghnet.get(
self._api('/version'),
token=token, timeout=10, flavor='gitea', proxy=self._proxy(),
)
if ver.status_code == 200:
raw = (ver.json().get('version') or '').strip()
parts = raw.replace('+dev', '').split('.')
try:
major, minor = int(parts[0]), int(parts[1])
if (major, minor) < (1, 23):
return {'ok': False,
'message': f'Gitea 版本为 {raw},低于 1.23,不支持 workflow_dispatch 远程触发,请先升级。'}
if (major, minor) < (1, 27):
version_msg = f'当前 Gitea 版本为 {raw},可触发构建,但建议升级到 1.27+(运行详情回传与缓存兼容性更好)。'
except (ValueError, IndexError):
pass
msg = f'连接正常:令牌可访问仓库 {owner}/{repo},Actions 已启用。'
if version_msg:
msg += version_msg
return {'ok': True, 'message': msg}
except Exception as e:
return {'ok': False, 'message': f'无法连接 Gitea API:{e}(请检查服务器地址与网络/代理配置)'}
+110
View File
@@ -0,0 +1,110 @@
"""GitHub Actions 构建后端(迁自 views.py / web_views.py 的原有逻辑,行为保持一致)。"""
from .. import app_settings, ghnet
from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend
API = 'https://api.github.com'
# 未进入 completed 的状态一律视为进行中
_GITHUB_ACTIVE = {'queued', 'in_progress', 'waiting', 'requested', 'pending'}
class GitHubBackend(BuildBackend):
name = 'github'
label = 'GitHub'
# ---- 配置 ----
def configured(self):
return bool(app_settings.get_value('GHUSER').strip()) \
and bool(app_settings.get_value('GHBEARER').strip())
def warnings(self):
warnings = []
if not app_settings.get_value('GHUSER').strip():
warnings.append('GitHub 用户名未配置,无法触发构建。')
if not app_settings.get_value('GHBEARER').strip():
warnings.append('GitHub 访问令牌未配置,无法触发构建。')
warnings.extend(self._shared_warnings(include_sh_secret=True))
return warnings
# ---- 工作流选择(windows selfhosted 走 sh-generator-windows.yml) ----
def workflow_file_for(self, platform, selfhosted=False):
if selfhosted and platform in ('windows',):
return 'sh-generator-windows.yml'
return WORKFLOW_FILES.get(platform, 'generator-windows.yml')
# ---- 触发 ----
def dispatch(self, workflow_file, inputs, *, timeout=20):
user = app_settings.get_value('GHUSER')
repo = app_settings.get_value('REPONAME')
url = f'{API}/repos/{user}/{repo}/actions/workflows/{workflow_file}/dispatches'
payload = {
'ref': app_settings.get_value('GHBRANCH'),
'inputs': inputs,
'return_run_details': True,
}
token = app_settings.get_value('GHBEARER')
try:
resp = ghnet.post(url, json=payload, token=token, timeout=timeout)
except Exception as e:
raise BackendError(f'无法连接 GitHub 构建服务:{e}', kind='network')
if resp.status_code not in (200, 204):
raise BackendError('GitHub 构建服务拒绝了启动请求,请检查服务配置或稍后重试。', kind='rejected')
data = {}
try:
data = resp.json()
except ValueError:
pass
return DispatchResult(run_id=data.get('workflow_run_id'), html_url=data.get('html_url') or '')
# ---- 状态查询 ----
def get_run(self, run_id, *, timeout=12):
user = app_settings.get_value('GHUSER')
repo = app_settings.get_value('REPONAME')
url = f'{API}/repos/{user}/{repo}/actions/runs/{run_id}'
try:
resp = ghnet.get(url, token=app_settings.get_value('GHBEARER'), timeout=timeout)
except Exception as e:
print(f'查询 GitHub 状态出错:{e}')
return None
if resp.status_code != 200:
return None
data = resp.json()
if data.get('status') == 'completed':
return RunInfo(finished=True, status=data.get('conclusion') or 'failure',
html_url=data.get('html_url') or self.log_url(run_id))
return RunInfo(finished=False, status=data.get('status') or 'in_progress',
html_url=data.get('html_url') or self.log_url(run_id))
# ---- 日志链接 ----
def log_url(self, run_id):
if not run_id:
return ''
return (
f"https://github.com/{app_settings.get_value('GHUSER')}"
f"/{app_settings.get_value('REPONAME')}/actions/runs/{run_id}"
)
# ---- 连接测试 ----
def test_connection(self):
user = app_settings.get_value('GHUSER').strip()
repo = app_settings.get_value('REPONAME').strip()
token = app_settings.get_value('GHBEARER').strip()
if not user or not token:
return {'ok': False, 'message': 'GitHub 用户名或访问令牌为空,请先保存配置。'}
try:
resp = ghnet.get(f'{API}/repos/{user}/{repo}', token=token, timeout=10)
if resp.status_code == 401:
return {'ok': False, 'message': '令牌无效或已过期(GitHub 返回 401)。'}
if resp.status_code == 404:
return {'ok': False,
'message': f'找不到仓库 {user}/{repo},或令牌无权访问该仓库(请确认仓库名与令牌的 Repository access)。'}
if resp.status_code != 200:
return {'ok': False, 'message': f'GitHub 返回异常状态码:{resp.status_code}。'}
act = ghnet.get(f'{API}/repos/{user}/{repo}/actions/workflows', token=token, timeout=10)
if act.status_code == 200:
return {'ok': True, 'message': f'连接正常:令牌可访问仓库 {user}/{repo},且具备 Actions 权限。'}
return {'ok': False,
'message': '可以访问仓库,但令牌缺少 Actions 权限;请编辑令牌,授予 Actions 与 Workflows 的读写权限。'}
except Exception as e:
return {'ok': False,
'message': f'无法连接 GitHub API:{e}(如服务器在国内网络,请在下方配置代理后重试)'}
+20
View File
@@ -0,0 +1,20 @@
"""全局模板上下文:当前构建平台的配置状态(用于未配置告警条)。"""
from .build_backends import get_backend
def github_config(request):
if not getattr(request.user, 'is_authenticated', False):
return {}
backend = get_backend()
ready = backend.configured()
warnings = backend.warnings()
return {
# 新变量名
'build_platform': backend.name,
'build_platform_label': backend.label,
'build_ready': ready,
'build_warnings': warnings,
# 兼容旧模板变量名
'github_ready': ready,
'github_warnings': warnings,
}
+440
View File
@@ -0,0 +1,440 @@
import re
from django import forms
from django.contrib.auth.models import User
from PIL import Image
from . import versions as rdeskVersions
# 应用名/公司名会被插入到单/双引号包裹的 bash sed 脚本中:& \ | 会导致替换
# 静默失败,' " $ ` 会破坏 shell 引号,CR/LF 会破坏 sed 寻址。
UNSAFE_NAME_CHARS = re.compile(r'[&\\|\'"$`\r\n]')
UNSAFE_NAME_MESSAGE = "包含构建脚本不支持的字符(& \\ | ' \" $ ` 或换行符)。"
# 统一的控件样式:文本/数字/下拉/文件输入框 .form-control,复选/单选 .form-check-input
# 注意:Django 5.x 中 NumberInput 直接继承 Input(而非 TextInput),必须单独列出
_CONTROL_WIDGETS = (
forms.TextInput, forms.NumberInput, forms.PasswordInput, forms.EmailInput,
forms.Select, forms.Textarea, forms.FileInput,
)
def apply_control_classes(form):
for field in form.fields.values():
widget = field.widget
if isinstance(widget, _CONTROL_WIDGETS):
widget.attrs.setdefault('class', 'form-control')
elif isinstance(widget, forms.CheckboxInput):
widget.attrs.setdefault('class', 'form-check-input')
class GenerateForm(forms.Form):
sh_secret_field = forms.CharField(required=False, widget=forms.HiddenInput)
# 平台与版本
platform = forms.ChoiceField(
label="目标平台",
choices=[
('windows', 'Windows 64 位'),
('windows-x86', 'Windows 32 位'),
('linux', 'Linux'),
('android', 'Android'),
('macos', 'macOS'),
],
initial='windows',
)
version = forms.ChoiceField(
label="RustDesk 版本",
choices=[('master', '开发版(nightly 每夜构建)')] + [(v, v) for v in rdeskVersions.FALLBACK_VERSIONS],
initial='1.4.9',
help_text=(
"'master' 为开发版(每夜构建),包含最新特性,但稳定性可能不如正式版本。"
"正式版列表自动取自 GitHub 官方仓库 rustdesk/rustdesk,跟随官方发版更新。"
),
)
delayFix = forms.BooleanField(label="修复使用第三方 API 时的连接延迟问题", initial=True, required=False)
# 常规设置
exename = forms.CharField(
label="配置名称",
required=True,
help_text="仅限英文、数字与下划线,不含空格;将同时作为生成文件的名称。",
)
appname = forms.CharField(
label="自定义应用名称",
required=False,
help_text="留空则使用默认名称 rustdesk。",
)
direction = forms.ChoiceField(
label="连接方向",
widget=forms.RadioSelect,
choices=[
('incoming', '仅受控(只能被他人远程连接)'),
('outgoing', '仅主控(只能主动连接他人)'),
('both', '主控 / 受控双向(默认)'),
],
initial='both',
)
installation = forms.ChoiceField(
label="是否允许安装",
choices=[
('installationY', '允许安装(默认)'),
('installationN', '禁止安装(绿色单文件运行)'),
],
initial='installationY',
)
settings = forms.ChoiceField(
label="是否允许修改设置",
choices=[
('settingsY', '允许修改设置(默认)'),
('settingsN', '禁止修改设置'),
],
initial='settingsY',
)
androidappid = forms.CharField(
label="自定义 Android 应用 ID",
required=False,
help_text="替换默认 com.carriez.flutter_hbb,留空使用默认值。",
)
# 自定义服务器
serverIP = forms.CharField(
label="ID / 中继服务器地址",
required=False,
help_text="填写自建服务器的主机名或 IP。",
)
serverPort = forms.CharField(label="服务器端口", required=False)
apiServer = forms.CharField(
label="API 服务器地址",
required=False,
help_text="留空时默认使用「服务器地址:21114」。",
)
key = forms.CharField(label="公钥(Key)", required=False)
urlLink = forms.CharField(
label="自定义官网链接",
required=False,
help_text="替换默认的 https://rustdesk.com。",
)
downloadLink = forms.CharField(
label="更新下载链接",
required=False,
help_text="替换默认的 https://rustdesk.com/download。",
)
compname = forms.CharField(
label="版权公司名称",
required=False,
help_text="替换默认的 Purslane Tech Pte. Ltd.。",
)
# 外观
iconfile = forms.FileField(
label="应用图标",
required=False,
widget=forms.FileInput(attrs={'accept': 'image/png'}),
help_text="PNG 格式,建议 256×256 正方形。",
)
logofile = forms.FileField(
label="应用 Logo",
required=False,
widget=forms.FileInput(attrs={'accept': 'image/png'}),
help_text="PNG 格式。",
)
privacyfile = forms.FileField(
label="隐私屏图片",
required=False,
widget=forms.FileInput(attrs={'accept': 'image/png'}),
help_text="PNG 格式。",
)
iconbase64 = forms.CharField(required=False, widget=forms.HiddenInput)
logobase64 = forms.CharField(required=False, widget=forms.HiddenInput)
privacybase64 = forms.CharField(required=False, widget=forms.HiddenInput)
theme = forms.ChoiceField(
label="主题",
choices=[('light', '浅色'), ('dark', '深色'), ('system', '跟随系统')],
initial='system',
)
themeDorO = forms.ChoiceField(
label="主题生效方式",
choices=[('default', '默认(允许客户端更改)'), ('override', '强制锁定(客户端无法更改)')],
initial='default',
)
# 安全
passApproveMode = forms.ChoiceField(
label="连接确认方式",
choices=[
('password', '仅通过密码接受会话'),
('click', '仅通过点击接受会话'),
('password-click', '密码或点击均可(默认)'),
],
initial='password-click',
)
permanentPassword = forms.CharField(
label="设置固定访问密码",
widget=forms.PasswordInput(),
required=False,
help_text="该密码为客户端默认密码,用户仍可在客户端自行修改。",
)
denyLan = forms.BooleanField(label="禁止局域网发现", initial=False, required=False)
enableDirectIP = forms.BooleanField(label="允许直接 IP 直连", initial=False, required=False)
autoClose = forms.BooleanField(label="用户无操作时自动断开入站会话", initial=False, required=False)
hidecm = forms.BooleanField(label="允许在远程画面中隐藏连接窗口", initial=False, required=False)
# 权限
permissionsDorO = forms.ChoiceField(
label="权限生效方式",
choices=[('default', '默认(用户可更改)'), ('override', '强制锁定(用户无法更改)')],
initial='default',
)
permissionsType = forms.ChoiceField(
label="权限预设",
choices=[('custom', '自定义'), ('full', '完全控制'), ('view', '仅查看(屏幕共享)')],
initial='custom',
)
enableKeyboard = forms.BooleanField(label="键盘 / 鼠标控制", initial=True, required=False)
enableClipboard = forms.BooleanField(label="剪贴板同步", initial=True, required=False)
enableFileTransfer = forms.BooleanField(label="文件传输", initial=True, required=False)
enableAudio = forms.BooleanField(label="音频传输", initial=True, required=False)
enableTCP = forms.BooleanField(label="TCP 隧道", initial=True, required=False)
enableRemoteRestart = forms.BooleanField(label="远程重启", initial=True, required=False)
enableRecording = forms.BooleanField(label="会话录制", initial=True, required=False)
enableBlockingInput = forms.BooleanField(label="屏蔽被控端键鼠输入", initial=True, required=False)
enableRemoteModi = forms.BooleanField(label="允许修改远程配置", initial=False, required=False)
enablePrinter = forms.BooleanField(label="远程打印机", initial=True, required=False)
enableCamera = forms.BooleanField(label="远程摄像头", initial=True, required=False)
enableTerminal = forms.BooleanField(label="远程终端", initial=True, required=False)
# 其他
removeWallpaper = forms.BooleanField(label="入站会话期间移除桌面壁纸", initial=True, required=False)
defaultManual = forms.CharField(
label="默认高级设置",
widget=forms.Textarea(attrs={'rows': 4}),
required=False,
help_text="每行一项,格式:键=值。",
)
overrideManual = forms.CharField(
label="强制高级设置",
widget=forms.Textarea(attrs={'rows': 4}),
required=False,
help_text="每行一项,格式:键=值;设置后客户端用户无法修改。",
)
# 定制功能
xOffline = forms.BooleanField(label="通讯录中离线设备显示 X 标记", initial=False, required=False)
removeNewVersionNotif = forms.BooleanField(label="移除新版本更新通知", initial=False, required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
apply_control_classes(self)
def clean_version(self):
# 视图会在绑定前把 choices 刷新为官方最新列表;这里再做宽松校验,
# 兼容「打开页面后列表刷新 / 官方最新 tag 尚未进列表」等窗口期。
value = self.cleaned_data.get('version', '')
if not rdeskVersions.is_valid_version(value):
raise forms.ValidationError("版本号无效,请从列表中选择或填写形如 1.4.9 的正式版本号。")
return value
def clean_iconfile(self):
image = self.cleaned_data['iconfile']
if image:
try:
img = Image.open(image)
if img.format != 'PNG':
raise forms.ValidationError("仅支持 PNG 格式的图标。")
width, height = img.size
if width != height:
raise forms.ValidationError("应用图标必须为正方形图片。")
return image
except forms.ValidationError:
raise
except OSError:
raise forms.ValidationError("图标文件无效或已损坏。")
except Exception as e:
raise forms.ValidationError(f"处理图标时出错:{e}")
def _reject_unsafe_name_chars(self, field):
value = self.cleaned_data.get(field, '')
if value and UNSAFE_NAME_CHARS.search(value):
raise forms.ValidationError(UNSAFE_NAME_MESSAGE)
return value
def clean_appname(self):
return self._reject_unsafe_name_chars('appname')
def clean_compname(self):
return self._reject_unsafe_name_chars('compname')
class ApiTokenForm(forms.Form):
name = forms.CharField(label="令牌名称", max_length=100, required=True,
help_text="建议按用途命名,例如:办公网构建机、CI 流水线。")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
apply_control_classes(self)
class GitHubSettingsForm(forms.Form):
"""后台「构建平台配置」表单(GitHub / Gitea)。
非密钥项直接显示生效值;密钥项不回显明文,留空表示不修改,
勾选「清除」则删除后台覆盖、回退到环境变量/默认值。
"""
# 构建平台
build_platform = forms.ChoiceField(
label="构建平台",
choices=[('github', 'GitHub Actions'), ('gitea', 'Gitea Actions(自建)')],
widget=forms.RadioSelect,
required=False,
)
# Gitea 连接
gitea_server_url = forms.CharField(
label="Gitea 服务器地址",
max_length=255,
required=False,
widget=forms.TextInput(attrs={'placeholder': 'https://gitea.example.com'}),
)
gitea_owner = forms.CharField(label="Gitea 仓库属主", max_length=100, required=False)
gitea_repo = forms.CharField(label="Gitea 仓库名称", max_length=100, required=False)
gitea_branch = forms.CharField(label="Gitea 工作流分支", max_length=100, required=False)
gitea_token = forms.CharField(
label="Gitea 访问令牌",
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '已保存时留空表示不修改'}),
required=False,
)
clear_gitea_token = forms.BooleanField(label="清除后台保存的令牌", required=False)
gitea_proxy = forms.CharField(
label="Gitea 访问代理",
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '内网直连通常留空,如 http://host:port'}),
required=False,
)
clear_gitea_proxy = forms.BooleanField(label="清除后台保存的代理", required=False)
# GitHub 基础连接
ghuser = forms.CharField(label="GitHub 用户名", max_length=100, required=False)
reponame = forms.CharField(label="仓库名称", max_length=100, required=False)
ghbranch = forms.CharField(label="工作流分支", max_length=100, required=False)
ghbearer = forms.CharField(
label="GitHub 访问令牌",
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '已保存时留空表示不修改'}),
required=False,
)
clear_ghbearer = forms.BooleanField(label="清除后台保存的令牌", required=False)
# 回调与加密
genurl = forms.CharField(
label="本平台外部访问地址",
max_length=255,
required=False,
widget=forms.TextInput(attrs={'placeholder': 'https://rdgen.example.com'}),
)
protocol = forms.ChoiceField(
label="平台访问协议",
choices=[('https', 'https'), ('http', 'http')],
required=False,
)
zip_password = forms.CharField(
label="配置压缩包密码",
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '已保存时留空表示不修改'}),
required=False,
)
clear_zip_password = forms.BooleanField(label="清除后台保存的密码", required=False)
# 高级
sh_secret = forms.CharField(
label="自托管 Runner 密钥",
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '已保存时留空表示不修改'}),
required=False,
)
clear_sh_secret = forms.BooleanField(label="清除后台保存的密钥", required=False)
webhook_secret = forms.CharField(
label="回调校验密钥",
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '已保存时留空表示不修改'}),
required=False,
)
clear_webhook_secret = forms.BooleanField(label="清除后台保存的密钥", required=False)
# 网络代理
gh_proxy = forms.CharField(
label="GitHub 访问代理",
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '留空直连,如 http://127.0.0.1:7890'}),
required=False,
)
clear_gh_proxy = forms.BooleanField(label="清除后台保存的代理", required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
apply_control_classes(self)
class MaintenanceSettingsForm(forms.Form):
"""后台「维护设置」:构建任务定时清理与数据库定时备份。"""
build_retention_days = forms.IntegerField(
label="构建任务保留天数",
min_value=0,
max_value=3650,
initial=30,
help_text="超过该天数的已结束任务(成功 / 失败 / 取消)及安装包产物会被自动清理;0 表示永不自动清理。进行中的任务不会被清理。",
)
db_backup_enabled = forms.BooleanField(
label="启用数据库定时备份(每天自动备份一次)",
required=False,
initial=True,
)
db_backup_keep = forms.IntegerField(
label="数据库备份保留份数",
min_value=1,
max_value=100,
initial=7,
help_text="超出份数的最旧备份会被自动删除。备份文件保存在持久化数据卷中,可在下方列表下载。",
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
apply_control_classes(self)
class AdminUserCreateForm(forms.Form):
username = forms.CharField(label="用户名", max_length=150, required=True)
email = forms.EmailField(label="邮箱", required=False)
password = forms.CharField(label="登录密码", widget=forms.PasswordInput, required=True, min_length=8,
help_text="至少 8 位。")
password_confirm = forms.CharField(label="确认密码", widget=forms.PasswordInput, required=True)
is_staff = forms.BooleanField(label="设为管理员(可访问后台管理、查看所有任务)", required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
apply_control_classes(self)
def clean_username(self):
username = self.cleaned_data['username'].strip()
if User.objects.filter(username=username).exists():
raise forms.ValidationError("该用户名已存在。")
return username
def clean(self):
cleaned = super().clean()
if cleaned.get('password') and cleaned.get('password') != cleaned.get('password_confirm'):
self.add_error('password_confirm', "两次输入的密码不一致。")
return cleaned
class AdminUserEditForm(forms.Form):
email = forms.EmailField(label="邮箱", required=False)
is_active = forms.BooleanField(label="账号启用", required=False)
is_staff = forms.BooleanField(label="管理员权限(可访问后台管理)", required=False)
new_password = forms.CharField(
label="重置密码(留空表示不修改)",
widget=forms.PasswordInput,
required=False,
min_length=8,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
apply_control_classes(self)
+74
View File
@@ -0,0 +1,74 @@
"""
代码托管平台 API 的统一网络层(GitHub / Gitea)。
- 所有出站请求集中在此,按 flavor 注入对应平台的推荐请求头与鉴权方式;
- 代理支持显式传入(GitHub 用 GH_PROXY、Gitea 用 GITEA_PROXY);
- 未显式配置代理时 requests 仍会自动遵循容器的 HTTPS_PROXY/HTTP_PROXY 环境变量。
"""
import requests
from . import app_settings
GITHUB_API = 'https://api.github.com'
API_VERSION = '2026-03-10'
DEFAULT_TIMEOUT = 15
def proxies(proxy=None):
"""返回 requests 使用的 proxies 字典。
proxy=None 时沿用 GitHub 的 GH_PROXY(历史默认);
显式传 '' 表示直连(环境变量仍生效),传具体地址则使用该代理。
"""
if proxy is None:
proxy = app_settings.get_value('GH_PROXY').strip()
if proxy:
return {'http': proxy, 'https': proxy}
return {}
def build_headers(token=None, json_content=False, flavor='github'):
"""按平台风格构建请求头。
- github:application/vnd.github+json + 版本头 + Bearer 鉴权;
- gitea:application/json + Gitea 官方文档主推的 token 鉴权方案。
"""
flavor = (flavor or 'github').lower()
if flavor == 'gitea':
headers = {'Accept': 'application/json'}
if token:
headers['Authorization'] = f'token {token}'
else:
headers = {
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': API_VERSION,
}
if token:
headers['Authorization'] = f'Bearer {token}'
if json_content:
headers['Content-Type'] = 'application/json'
return headers
def request(method, url, *, token=None, timeout=DEFAULT_TIMEOUT, headers=None,
flavor='github', proxy=None, **kwargs):
"""发起平台 API 请求,自动注入代理、超时与标准请求头。"""
kwargs.setdefault('proxies', proxies(proxy))
kwargs.setdefault('timeout', timeout)
merged_headers = build_headers(
token=token,
json_content=kwargs.get('json') is not None,
flavor=flavor,
)
if headers:
merged_headers.update(headers)
kwargs['headers'] = merged_headers
return requests.request(method, url, **kwargs)
def get(url, **kwargs):
return request('GET', url, **kwargs)
def post(url, **kwargs):
return request('POST', url, **kwargs)
+227
View File
@@ -0,0 +1,227 @@
"""
定时维护:清理过期构建任务(含本地产物)、清理残留临时文件、在线备份 SQLite。
被 management 命令(容器内常驻守护进程)与后台「维护设置」页面共用:
run_maintenance(force_backup=False) -> dict # 执行一轮
maintenance_status() -> dict # 页面展示的状态摘要
所有设置经 app_settings 统一读取(后台配置 → 环境变量 → 默认值)。
"""
import os
import shutil
import sqlite3
import time
from datetime import datetime, timedelta
from pathlib import Path
from django.conf import settings
from django.utils import timezone
from . import app_settings
from .models import GithubRun
# 与 GithubRun.is_finished 保持一致的终态集合
FINISHED_STATUSES = ('success', 'failure', 'cancelled', 'timed_out', 'skipped')
# 临时配置包/JSON 的最长保留时间(天),失败任务遗留时兜底清理
TEMP_FILE_DAYS = 7
BACKUP_NAME_PREFIX = 'db-'
BACKUP_NAME_SUFFIX = '.sqlite3'
# 自动备份最小间隔(秒):守护进程每小时醒一次,但一天只备份一次
BACKUP_INTERVAL = 24 * 3600
def _setting_int(key, default):
try:
return int(str(app_settings.get_value(key)).strip())
except (TypeError, ValueError):
return default
def _setting_bool(key, default=True):
return str(app_settings.get_value(key)).strip().lower() in ('1', 'true', 'yes', 'on', '是')
def retention_days():
"""构建任务保留天数;0 表示永不自动清理。"""
return max(0, _setting_int('BUILD_RETENTION_DAYS', 30))
def backup_enabled():
return _setting_bool('DB_BACKUP_ENABLED', True)
def backup_keep():
return max(1, min(100, _setting_int('DB_BACKUP_KEEP', 7)))
def db_path():
return Path(settings.DATABASES['default']['NAME'])
def backup_dir():
d = db_path().parent / 'backups'
d.mkdir(parents=True, exist_ok=True)
return d
def list_backups():
"""返回 [(Path, 大小字节, mtime), ...],按时间倒序。"""
items = []
for p in backup_dir().glob(f'{BACKUP_NAME_PREFIX}*{BACKUP_NAME_SUFFIX}'):
if p.is_file():
st = p.stat()
items.append((p, st.st_size, st.st_mtime))
items.sort(key=lambda x: x[2], reverse=True)
return items
def cleanup_expired_builds(days=None):
"""删除超过保留天数的终态任务,并同步清理 exe/png 产物。返回删除条数。"""
if days is None:
days = retention_days()
if days <= 0:
return 0
cutoff = timezone.now() - timedelta(days=days)
qs = GithubRun.objects.filter(created_at__lt=cutoff, status__in=FINISHED_STATUSES)
count = 0
for run in qs.iterator():
if run.uuid:
shutil.rmtree(Path('exe') / run.uuid, ignore_errors=True)
shutil.rmtree(Path('png') / run.uuid, ignore_errors=True)
run.delete()
count += 1
return count
def cleanup_temp_files(days=TEMP_FILE_DAYS):
"""清理失败任务遗留的加密配置包与临时 JSON。返回删除文件数。"""
cutoff = time.time() - days * 86400
count = 0
targets = []
zip_dir = Path('temp_zips')
if zip_dir.is_dir():
targets += list(zip_dir.glob('secrets_*.zip'))
# 早期版本 data_*.json 写在工作目录根下
targets += list(Path('.').glob('data_*.json'))
for p in targets:
try:
if p.is_file() and p.stat().st_mtime < cutoff:
p.unlink()
count += 1
except OSError:
pass
return count
def backup_database():
"""用 sqlite3 在线备份 API 生成一致性副本,然后按保留份数轮转。
返回备份文件 Path;非 SQLite 数据库或源文件不存在时返回 None。
"""
src = db_path()
if not src.is_file() or settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3':
return None
dest = backup_dir() / f"{BACKUP_NAME_PREFIX}{time.strftime('%Y%m%d-%H%M%S')}{BACKUP_NAME_SUFFIX}"
src_conn = sqlite3.connect(str(src))
try:
dst_conn = sqlite3.connect(str(dest))
try:
src_conn.backup(dst_conn)
finally:
dst_conn.close()
finally:
src_conn.close()
rotate_backups()
return dest
def rotate_backups():
"""按设置的保留份数删除最旧备份(每轮维护都执行,含容器启动前的预迁移备份)。"""
deleted = 0
for old_path, _size, _mtime in list_backups()[backup_keep():]:
try:
old_path.unlink()
deleted += 1
except OSError:
pass
return deleted
def _latest_backup_age():
backups = list_backups()
if not backups:
return None
return time.time() - backups[0][2]
def run_maintenance(force_backup=False):
"""执行一轮维护。返回各项动作的计数/结果(供命令行与页面反馈)。"""
result = {
'deleted_builds': cleanup_expired_builds(),
'deleted_temp_files': cleanup_temp_files(),
'rotated_backups': rotate_backups(),
'backup_created': None,
'backup_skipped': False,
}
if backup_enabled() and (force_backup or (_latest_backup_age() or BACKUP_INTERVAL) >= BACKUP_INTERVAL):
dest = backup_database()
result['backup_created'] = str(dest) if dest else None
if dest is None:
result['backup_skipped'] = True
else:
result['backup_skipped'] = True
return result
def _dir_size(path):
total = 0
base = Path(path)
if not base.is_dir():
return 0
for root, _dirs, files in os.walk(base):
for name in files:
try:
total += (Path(root) / name).stat().st_size
except OSError:
pass
return total
def maintenance_status():
"""后台维护页展示用摘要。"""
days = retention_days()
total = GithubRun.objects.count()
in_progress = GithubRun.objects.exclude(status__in=FINISHED_STATUSES).count()
expired = 0
if days > 0:
cutoff = timezone.now() - timedelta(days=days)
expired = GithubRun.objects.filter(
created_at__lt=cutoff, status__in=FINISHED_STATUSES
).count()
backups = [
{
'name': p.name,
'size': size,
'mtime': datetime.fromtimestamp(mtime, tz=timezone.get_current_timezone()),
}
for p, size, mtime in list_backups()
]
return {
'retention_days': days,
'backup_enabled': backup_enabled(),
'backup_keep': backup_keep(),
'db_path': str(db_path()),
'db_size': db_path().stat().st_size if db_path().is_file() else 0,
'total_builds': total,
'in_progress_builds': in_progress,
'expired_builds': expired,
'artifacts_size': _dir_size('exe') + _dir_size('png'),
'temp_size': _dir_size('temp_zips'),
'backups': backups,
'backup_interval_hours': BACKUP_INTERVAL // 3600,
}
View File
@@ -0,0 +1,50 @@
"""定时维护命令。
用法:
python manage.py maintenance # 同 --once
python manage.py maintenance --once # 执行一轮(清理过期任务/临时文件、按需备份)
python manage.py maintenance --loop # 常驻:启动即一轮,之后每小时醒来检查
"""
import time
from django.core.management.base import BaseCommand
from rdgenerator import maintenance
class Command(BaseCommand):
help = '清理过期构建任务与临时文件,并按设置定期备份 SQLite 数据库。'
LOOP_INTERVAL = 3600 # 每小时醒来一次(备份内部按 24h 间隔节流)
def add_arguments(self, parser):
parser.add_argument(
'--loop', action='store_true',
help='常驻模式:容器启动后在后台持续运行。',
)
parser.add_argument(
'--once', action='store_true',
help='只执行一轮后退出(默认行为,也供后台「立即执行」使用)。',
)
def handle(self, *args, **options):
if options['loop']:
self.stdout.write('[maintenance] 守护进程已启动,每小时检查一次。')
while True:
self._run_once()
time.sleep(self.LOOP_INTERVAL)
else:
self._run_once()
def _run_once(self):
result = maintenance.run_maintenance()
self.stdout.write(
'[maintenance] 清理过期任务 {deleted_builds} 条、临时文件 {deleted_temp_files} 个、'
'轮转旧备份 {rotated_backups} 份;备份:{backup}'.format(
deleted_builds=result['deleted_builds'],
deleted_temp_files=result['deleted_temp_files'],
rotated_backups=result['rotated_backups'],
backup=('已创建 ' + result['backup_created']) if result['backup_created']
else ('本轮跳过' if result['backup_skipped'] else '未创建'),
)
)
+22
View File
@@ -0,0 +1,22 @@
# Generated by Django 5.0.3 on 2024-09-26 14:48
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='GithubRun',
fields=[
('id', models.IntegerField(primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.CharField(max_length=100, verbose_name='uuid')),
('status', models.CharField(max_length=100, verbose_name='status')),
],
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.3 on 2026-03-11 04:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rdgenerator', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='githubrun',
name='github_run_id',
field=models.BigIntegerField(blank=True, null=True),
),
]
@@ -0,0 +1,106 @@
from django.conf import settings
from django.db import migrations, models
import rdgenerator.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("rdgenerator", "0002_githubrun_github_run_id"),
]
operations = [
migrations.AlterField(
model_name="githubrun",
name="id",
field=models.BigAutoField(primary_key=True, serialize=False),
),
migrations.AlterField(
model_name="githubrun",
name="uuid",
field=models.CharField(db_index=True, max_length=100, verbose_name="任务 UUID"),
),
migrations.AlterField(
model_name="githubrun",
name="status",
field=models.CharField(default="queued", max_length=100, verbose_name="构建状态"),
),
migrations.AddField(
model_name="githubrun",
name="platform",
field=models.CharField(blank=True, default="", max_length=20, verbose_name="目标平台"),
),
migrations.AddField(
model_name="githubrun",
name="filename",
field=models.CharField(blank=True, default="", max_length=200, verbose_name="文件名"),
),
migrations.AddField(
model_name="githubrun",
name="created_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name="builds",
to=settings.AUTH_USER_MODEL,
verbose_name="提交用户",
),
),
migrations.AddField(
model_name="githubrun",
name="created_at",
field=models.DateTimeField(auto_now_add=True, verbose_name="提交时间", null=True),
),
migrations.AddField(
model_name="githubrun",
name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name="更新时间", null=True),
),
migrations.AlterField(
model_name="githubrun",
name="created_at",
field=models.DateTimeField(auto_now_add=True, verbose_name="提交时间"),
),
migrations.AlterField(
model_name="githubrun",
name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name="更新时间"),
),
migrations.CreateModel(
name="ApiToken",
fields=[
(
"key",
models.CharField(
default=rdgenerator.models.generate_token_key,
editable=False,
max_length=64,
primary_key=True,
serialize=False,
verbose_name="令牌",
),
),
("name", models.CharField(help_text="便于识别用途,例如:CI 构建机", max_length=100, verbose_name="令牌名称")),
("is_active", models.BooleanField(default=True, verbose_name="启用")),
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
("last_used_at", models.DateTimeField(blank=True, null=True, verbose_name="最后使用时间")),
(
"user",
models.ForeignKey(
on_delete=models.CASCADE,
related_name="api_tokens",
to=settings.AUTH_USER_MODEL,
verbose_name="所属用户",
),
),
],
options={
"verbose_name": "API 令牌",
"verbose_name_plural": "API 令牌",
"ordering": ["-created_at"],
},
),
]
@@ -0,0 +1,23 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rdgenerator', '0003_build_management'),
]
operations = [
migrations.CreateModel(
name='SiteSetting',
fields=[
('key', models.CharField(max_length=50, primary_key=True, serialize=False, verbose_name='配置项')),
('value', models.TextField(blank=True, default='', verbose_name='配置值')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '站点配置',
'verbose_name_plural': '站点配置',
},
),
]
@@ -0,0 +1,39 @@
# Generated for SavedConfig
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('rdgenerator', '0004_sitesetting'),
]
operations = [
migrations.CreateModel(
name='SavedConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='方案名称')),
('data', models.JSONField(default=dict, verbose_name='配置内容')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('owner', models.ForeignKey(
on_delete=models.CASCADE,
related_name='saved_configs',
to=settings.AUTH_USER_MODEL,
verbose_name='所属用户',
)),
],
options={
'verbose_name': '构建配置方案',
'verbose_name_plural': '构建配置方案',
'ordering': ['-updated_at'],
},
),
migrations.AlterUniqueTogether(
name='savedconfig',
unique_together={('owner', 'name')},
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rdgenerator', '0005_savedconfig'),
]
operations = [
migrations.AddField(
model_name='githubrun',
name='config_data',
field=models.JSONField(blank=True, default=dict, verbose_name='构建配置快照'),
),
]
@@ -0,0 +1,27 @@
# Generated by Django 5.2.17 on 2026-09-29 02:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rdgenerator', '0006_githubrun_config_data'),
]
operations = [
migrations.AlterModelOptions(
name='githubrun',
options={'ordering': ['-created_at'], 'verbose_name': '构建任务', 'verbose_name_plural': '构建任务'},
),
migrations.AlterField(
model_name='githubrun',
name='github_run_id',
field=models.BigIntegerField(blank=True, null=True, verbose_name='GitHub 运行编号'),
),
migrations.AlterField(
model_name='githubrun',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]
@@ -0,0 +1,18 @@
# Generated for 多构建平台支持(GitHub / Gitea)
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rdgenerator', '0007_alter_githubrun_options_and_more'),
]
operations = [
migrations.AddField(
model_name='githubrun',
name='backend',
field=models.CharField(default='github', max_length=20, verbose_name='构建平台'),
),
]
View File
+171
View File
@@ -0,0 +1,171 @@
import secrets
from django.conf import settings
from django.db import models
# GitHub Actions 运行状态 -> 中文展示文案
STATUS_LABELS = {
"Starting generator...please wait": "正在启动构建任务",
"queued": "排队中",
"in_progress": "构建中",
"success": "构建成功",
"failure": "构建失败",
"cancelled": "已取消",
"timed_out": "已超时",
"skipped": "已跳过",
"action_required": "等待人工处理",
}
# 状态对应的徽标样式(模板中使用)
STATUS_BADGE = {
"Starting generator...please wait": "badge-info",
"queued": "badge-info",
"in_progress": "badge-info",
"success": "badge-success",
"failure": "badge-danger",
"cancelled": "badge-warning",
"timed_out": "badge-warning",
"skipped": "badge-secondary",
"action_required": "badge-warning",
}
PLATFORM_LABELS = {
"windows": "Windows 64 位",
"windows-x86": "Windows 32 位",
"linux": "Linux",
"android": "Android",
"macos": "macOS",
}
def generate_token_key():
return secrets.token_hex(20)
class GithubRun(models.Model):
uuid = models.CharField("任务 UUID", max_length=100, db_index=True)
status = models.CharField("构建状态", max_length=100, default="queued")
github_run_id = models.BigIntegerField("GitHub 运行编号", null=True, blank=True)
# 构建平台:github / gitea;历史任务默认 github。切换平台后旧任务仍按此值查询状态
backend = models.CharField("构建平台", max_length=20, default="github")
platform = models.CharField("目标平台", max_length=20, blank=True, default="")
filename = models.CharField("文件名", max_length=200, blank=True, default="")
# 提交时的表单快照(纯字符串/数值,图片为 data URL),供失败后「一键重试」重新派发
config_data = models.JSONField("构建配置快照", blank=True, default=dict)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="提交用户",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="builds",
)
created_at = models.DateTimeField("提交时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
verbose_name = "构建任务"
verbose_name_plural = "构建任务"
ordering = ["-created_at"]
def __str__(self):
return f"{self.filename or self.uuid}({self.status_label()})"
def status_label(self):
return STATUS_LABELS.get(self.status, self.status)
def status_badge(self):
return STATUS_BADGE.get(self.status, "badge-secondary")
def platform_label(self):
return PLATFORM_LABELS.get(self.platform, self.platform or "未知平台")
def is_finished(self):
return self.status in ("success", "failure", "cancelled", "timed_out", "skipped")
@property
def github_log_url(self):
if self.github_run_id:
# 局部导入避免循环依赖;按任务记录的平台生成对应站点的日志链接
from .build_backends import get_backend
return get_backend(self.backend or 'github').log_url(self.github_run_id)
return ""
def short_uuid(self):
return self.uuid[:8] if len(self.uuid) >= 8 else self.uuid
class SiteSetting(models.Model):
"""后台可改的站点配置(键值存储)。
取值优先级由 app_settings 统一处理:后台配置(本表)→ 环境变量 → 默认值,
因此历史的环境变量部署方式继续有效。
"""
key = models.CharField("配置项", max_length=50, primary_key=True)
value = models.TextField("配置值", blank=True, default='')
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
verbose_name = "站点配置"
verbose_name_plural = "站点配置"
def __str__(self):
return self.key
class SavedConfig(models.Model):
"""用户保存的客户端构建配置方案(与账号绑定,可在定制页直接载入、可导出/导入)。"""
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="所属用户",
on_delete=models.CASCADE,
related_name="saved_configs",
)
name = models.CharField("方案名称", max_length=100)
data = models.JSONField("配置内容", default=dict)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
verbose_name = "构建配置方案"
verbose_name_plural = "构建配置方案"
unique_together = [('owner', 'name')]
ordering = ["-updated_at"]
def __str__(self):
return f"{self.name}({self.owner})"
def platform_label(self):
return PLATFORM_LABELS.get(self.data.get('platform', ''), '未指定平台')
@property
def version_label(self):
return self.data.get('version', '') or '未指定版本'
class ApiToken(models.Model):
"""供 JSON API(如 rdgen-cli)使用的个人访问令牌"""
key = models.CharField("令牌", max_length=64, primary_key=True, default=generate_token_key, editable=False)
name = models.CharField("令牌名称", max_length=100, help_text="便于识别用途,例如:CI 构建机")
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="所属用户",
on_delete=models.CASCADE,
related_name="api_tokens",
)
is_active = models.BooleanField("启用", default=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
last_used_at = models.DateTimeField("最后使用时间", null=True, blank=True)
class Meta:
verbose_name = "API 令牌"
verbose_name_plural = "API 令牌"
ordering = ["-created_at"]
def __str__(self):
return f"{self.name}({self.user})"
+851
View File
@@ -0,0 +1,851 @@
/* ==========================================================================
RustDesk 客户端定制平台 · 全局设计系统
设计原则:扁平化、留白充足、控件统一;所有页面共用同一套组件类。
========================================================================== */
:root {
--primary: #2563eb;
--primary-dark: #1d4ed8;
--primary-light: #eff4ff;
--primary-border: #9db8f5;
--success: #16a34a;
--success-bg: #dcfce7;
--warning: #b45309;
--warning-bg: #fef3c7;
--danger: #dc2626;
--danger-bg: #fee2e2;
--info: #0e7490;
--info-bg: #ecfeff;
--text: #1f2937;
--text-soft: #374151;
--text-muted: #6b7280;
--border: #e5e7eb;
--control-border: #d1d5db;
--bg: #f5f7fa;
--card: #ffffff;
--radius: 10px;
--radius-sm: 8px;
--shadow: 0 1px 2px rgba(16, 24, 40, .05);
--shadow-lg: 0 16px 40px rgba(16, 24, 40, .14);
/* 统一节奏 */
--control-h: 38px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.6;
display: flex;
flex-direction: column;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ===== 内联 SVG 图标(统一线性/扁平单色) ===== */
.svg-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
.icon {
width: 1em; height: 1em;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
vertical-align: -0.12em;
}
.brand-icon {
width: 1em; height: 1em;
flex: none;
fill: currentColor;
stroke: none;
}
.page-head .icon { font-size: 15px; }
/* ===== 顶部导航 ===== */
.topbar {
background: var(--card);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 50;
}
.topbar-inner {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 16px;
color: var(--text);
white-space: nowrap;
}
.brand:hover { text-decoration: none; }
.brand-logo {
width: 32px; height: 32px;
border-radius: 8px;
background: var(--primary);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 800;
letter-spacing: .5px;
}
.topbar nav { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.nav-link {
padding: 7px 13px;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-weight: 500;
font-size: 13.5px;
line-height: 1.4;
}
.nav-link:hover { background: var(--primary-light); color: var(--primary); text-decoration: none; }
.nav-link.active { background: var(--primary-light); color: var(--primary); }
.nav-user { color: var(--text-muted); font-size: 13px; padding: 0 8px; white-space: nowrap; }
.btn-logout {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 13px;
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
font-family: inherit;
}
.btn-logout:hover { border-color: var(--danger); color: var(--danger); }
/* ===== 页面容器与标题 ===== */
.page {
flex: 1;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 28px 24px 56px;
}
.page-narrow { max-width: 860px; }
.page-head { margin-bottom: 22px; }
.page-title { font-size: 22px; font-weight: 700; margin: 0 0 6px; }
.page-subtitle { color: var(--text-muted); margin: 0; font-size: 13.5px; line-height: 1.7; }
.breadcrumb { font-size: 13px; margin-bottom: 14px; color: var(--text-muted); }
.breadcrumb a { color: var(--text-muted); font-weight: 500; }
.breadcrumb a:hover { color: var(--primary); }
.breadcrumb .sep { margin: 0 8px; color: #c0c6d0; }
.footer {
text-align: center;
color: #9aa3b2;
font-size: 12.5px;
padding: 20px;
border-top: 1px solid var(--border);
background: var(--card);
}
/* ===== 卡片 ===== */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 24px;
}
.card:last-child { margin-bottom: 0; }
.card-header {
padding: 16px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 9px;
font-weight: 600;
font-size: 15px;
}
.card-header .icon { color: #64748b; font-size: 17px; }
.card-body { padding: 22px 24px; }
.card-body.flush { padding: 0; }
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: start;
}
.grid-2 .card { margin-bottom: 0; }
.form-narrow { max-width: 540px; }
@media (max-width: 920px) { .grid-2 { grid-template-columns: 1fr; } }
/* ===== 表单 ===== */
.field { margin-bottom: 18px; }
.field:last-child { margin-bottom: 0; }
/* 与左侧「label + 输入控件」并排的独立勾选项:下移到与控件(非 label)同一水平线 */
.field-check-control { margin-bottom: 0; }
.field-check-control .check-item { margin-top: 33px; }
@media (max-width: 920px) { .field-check-control .check-item { margin-top: 4px; } }
.field > label, .field-label {
display: block;
font-weight: 600;
font-size: 13.5px;
margin-bottom: 7px;
color: var(--text-soft);
}
.form-control {
width: 100%;
min-height: var(--control-h);
padding: 8px 12px;
border: 1px solid var(--control-border);
border-radius: var(--radius-sm);
font-size: 14px;
color: var(--text);
background: #fff;
transition: border-color .15s, box-shadow .15s;
font-family: inherit;
line-height: 1.4;
}
.form-control:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, .12);
}
.form-control::placeholder { color: #9ca3af; }
select.form-control {
cursor: pointer;
padding-right: 34px;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 11px center;
background-size: 16px;
}
textarea.form-control {
resize: vertical;
min-height: 96px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.6;
}
/* 文件选择框:浅灰底 + 统一内嵌按钮 */
input[type="file"].form-control {
min-height: 0;
padding: 7px 10px;
background: #fafbfd;
cursor: pointer;
}
input[type="file"].form-control::file-selector-button {
margin-right: 12px;
padding: 5px 13px;
border: 1px solid var(--control-border);
border-radius: 6px;
background: #fff;
color: var(--text-soft);
font: 600 12.5px inherit;
cursor: pointer;
transition: border-color .15s, color .15s;
}
input[type="file"].form-control:hover::file-selector-button {
border-color: var(--primary-border);
color: var(--primary);
}
input[type="file"].form-control::-webkit-file-upload-button {
margin-right: 12px;
padding: 5px 13px;
border: 1px solid var(--control-border);
border-radius: 6px;
background: #fff;
color: var(--text-soft);
font: 600 12.5px inherit;
cursor: pointer;
}
.field-hint { color: var(--text-muted); font-size: 12.5px; margin-top: 7px; line-height: 1.6; }
.help-text {
display: block;
background: var(--info-bg);
color: var(--info);
font-size: 12.5px;
line-height: 1.6;
padding: 8px 12px;
border-radius: var(--radius-sm);
margin-top: 8px;
}
.inline-warning {
display: none;
color: var(--warning);
background: var(--warning-bg);
font-size: 12.5px;
line-height: 1.6;
padding: 8px 12px;
border-radius: var(--radius-sm);
margin: 0 0 12px;
}
.inline-error { color: var(--danger); font-size: 12.5px; margin-left: 8px; }
.errorlist { list-style: none; padding: 0; margin: 7px 0 0; color: var(--danger); font-size: 12.5px; }
.errorlist li { margin-top: 3px; }
/* 输入框 + 按钮同一行(如令牌创建) */
.input-row { display: flex; gap: 10px; align-items: center; }
.input-row .form-control { flex: 1 1 auto; min-width: 0; width: auto; }
/* 复选 / 单选 */
.form-check-input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; margin: 0; flex: none; }
.check-item {
display: flex;
align-items: center;
gap: 9px;
font-size: 13.5px;
cursor: pointer;
padding: 4px 0;
color: var(--text-soft);
}
.check-item:hover { color: var(--text); }
.check-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(195px, 1fr));
gap: 4px 24px;
margin: 10px 0 4px;
}
.radio-group ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
.radio-group li { display: flex; align-items: center; gap: 9px; padding: 2px 0; }
.radio-group li label { font-weight: 400; margin: 0; font-size: 13.5px; cursor: pointer; }
.subsection-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13.5px;
font-weight: 600;
color: var(--text-soft);
margin: 22px 0 10px;
}
.subsection-title:first-child { margin-top: 0; }
.subsection-title .icon { color: #64748b; font-size: 15px; }
.subsection-title::after {
content: "";
flex: 1;
height: 1px;
background: var(--border);
}
/* ===== 按钮 ===== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: var(--control-h);
padding: 0 18px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
transition: background .15s, border-color .15s, color .15s, box-shadow .15s;
text-decoration: none !important;
line-height: 1.2;
}
.btn .icon { font-size: 15px; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-secondary { background: #fff; color: var(--text-soft); border-color: var(--control-border); }
.btn-secondary:hover { border-color: var(--primary-border); color: var(--primary); }
.btn-danger { background: #fff; color: var(--danger); border-color: #f3c2c2; }
.btn-danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-sm { min-height: 30px; padding: 0 12px; font-size: 12.5px; border-radius: 6px; }
.btn-sm .icon { font-size: 13px; }
.btn-block { width: 100%; }
.btn:disabled { opacity: .6; cursor: not-allowed; }
.btn-row { display: flex; gap: 10px; flex-wrap: wrap; }
.inline-form { display: inline-flex; vertical-align: middle; }
.table .btn + .btn, .table .btn + .inline-form { margin-left: 8px; }
/* ===== 平台选择卡 ===== */
.platform-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
margin: 4px 0 8px;
}
.platform-card {
border: 1.5px solid var(--border);
border-radius: var(--radius);
background: #fff;
padding: 18px 8px 16px;
text-align: center;
cursor: pointer;
transition: border-color .15s, background .15s, transform .1s;
user-select: none;
}
.platform-card:hover { border-color: var(--primary-border); }
.platform-card.active { border-color: var(--primary); background: var(--primary-light); }
.platform-card:active { transform: scale(.98); }
.platform-card .brand-icon { font-size: 30px; color: #6b728b; transition: color .15s; }
.platform-card.active .brand-icon { color: var(--primary); }
.platform-name { font-weight: 600; font-size: 13.5px; margin-top: 9px; color: var(--text); }
.platform-tag { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
@media (max-width: 920px) { .platform-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 560px) { .platform-grid { grid-template-columns: repeat(2, 1fr); } }
/* ===== 后台构建平台选择卡(GitHub / Gitea 二选一) ===== */
.build-platform-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin: 4px 0 8px;
}
.build-platform-grid .platform-card { padding: 20px 12px 18px; }
.build-platform-grid .platform-card .icon { font-size: 28px; }
.platform-desc { color: var(--text-muted); font-size: 12.5px; margin-top: 5px; line-height: 1.55; }
.panel-hidden { display: none !important; }
.settings-submit-card .settings-actions { margin-top: 0; padding: 22px 24px; border-top: none; }
@media (max-width: 560px) { .build-platform-grid { grid-template-columns: 1fr; } }
.visually-hidden {
position: absolute !important;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ===== 图片预览 ===== */
.image-preview { margin-top: 9px; }
.image-preview img {
max-width: 100%;
max-height: 64px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 5px;
background: #fff;
}
.image-preview.photo img { max-height: 120px; }
/* ===== 徽标 ===== */
.badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
}
.badge-success { background: var(--success-bg); color: #15803d; }
.badge-info { background: var(--info-bg); color: var(--info); }
.badge-warning { background: var(--warning-bg); color: var(--warning); }
.badge-danger { background: var(--danger-bg); color: #b91c1c; }
.badge-secondary { background: #f1f3f7; color: var(--text-muted); }
.badge-dot::before {
content: "";
display: inline-block;
width: 6px; height: 6px;
border-radius: 50%;
background: currentColor;
margin-right: 6px;
}
.badge-info.badge-dot::before { animation: pulse 1.2s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
/* ===== 提示消息 ===== */
.alerts { margin-bottom: 20px; display: grid; gap: 10px; }
.alert {
padding: 12px 16px;
border-radius: var(--radius-sm);
border: 1px solid;
font-size: 13.5px;
line-height: 1.6;
}
.alert-success { background: var(--success-bg); border-color: #bbf7d0; color: #15803d; }
.alert-error, .alert-danger { background: var(--danger-bg); border-color: #fecaca; color: #b91c1c; }
.alert-warning { background: var(--warning-bg); border-color: #fde68a; color: var(--warning); }
.alert-info { background: var(--info-bg); border-color: #a5f3fc; color: var(--info); }
/* ===== 表格 ===== */
.table-wrap { overflow-x: auto; }
table.table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.table th, .table td {
padding: 13px 16px;
text-align: left;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.table th {
background: #f9fafb;
color: var(--text-muted);
font-weight: 600;
font-size: 12.5px;
white-space: nowrap;
}
.table tr:last-child td { border-bottom: none; }
.table tbody tr:hover td { background: #fafbfe; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; }
.text-muted { color: var(--text-muted); }
/* ===== 统计卡片 ===== */
.stat-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
box-shadow: var(--shadow);
}
.stat-value { font-size: 26px; font-weight: 700; line-height: 1.15; }
.stat-label { color: var(--text-muted); font-size: 12.5px; margin-top: 5px; }
.stat-card.is-primary .stat-value { color: var(--primary); }
.stat-card.is-success .stat-value { color: var(--success); }
.stat-card.is-info .stat-value { color: #0891b2; }
.stat-card.is-danger .stat-value { color: var(--danger); }
@media (max-width: 1000px) { .stat-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 560px) { .stat-grid { grid-template-columns: repeat(2, 1fr); } }
/* ===== 空状态 ===== */
.empty-state { text-align: center; padding: 60px 24px; color: var(--text-muted); }
.empty-state .empty-icon { font-size: 42px; color: #c3cad6; margin-bottom: 14px; }
.empty-state h3 { margin: 0 0 7px; color: var(--text); font-size: 16px; font-weight: 600; }
.empty-state p { margin: 0 0 20px; font-size: 13.5px; line-height: 1.7; }
.empty-state .btn { margin: 0 auto; }
/* ===== 登录页 ===== */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--bg);
}
.login-card {
background: #fff;
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 400px;
padding: 36px 34px 30px;
}
.login-brand { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
.login-brand .brand-logo { width: 42px; height: 42px; font-size: 15px; border-radius: 11px; }
.login-brand h1 { font-size: 18px; margin: 0; }
.login-brand p { margin: 0; color: var(--text-muted); font-size: 12.5px; }
.login-card form { margin-top: 24px; }
.login-card .field { margin-bottom: 16px; }
.login-card .btn { width: 100%; margin-top: 4px; }
/* ===== 等待 / 结果页 ===== */
.result-wrap { text-align: center; padding: 36px 20px; }
.result-icon { width: 64px; height: 64px; margin: 0 auto 16px; }
.result-icon .icon { width: 100%; height: 100%; stroke-width: 1.6; }
.result-icon.success { color: var(--success); }
.result-icon.warning { color: #d97706; }
.result-title { font-size: 22px; font-weight: 700; margin: 0 0 8px; }
.result-desc { color: var(--text-muted); margin: 0 0 24px; line-height: 1.8; }
.spinner {
width: 42px; height: 42px;
border: 4px solid #dbe5f5;
border-top-color: var(--primary);
border-radius: 50%;
margin: 0 auto 18px;
animation: spin .9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.progress-bar {
width: 100%;
max-width: 380px;
height: 8px;
background: #e7ecf5;
border-radius: 999px;
overflow: hidden;
margin: 0 auto 20px;
}
.progress-bar-fill {
width: 30%;
height: 100%;
background: linear-gradient(90deg, #3b82f6, #0ea5e9);
border-radius: 999px;
animation: progress-slide 1.6s ease-in-out infinite;
}
@keyframes progress-slide {
0% { width: 8%; margin-left: 0; }
50% { width: 45%; margin-left: 20%; }
100% { width: 8%; margin-left: 92%; }
}
.download-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
gap: 10px;
text-align: left;
margin-top: 8px;
}
.download-list .btn { justify-content: flex-start; padding: 0 14px; font-weight: 500; }
.info-box {
background: #f9fafb;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
max-width: 540px;
margin: 0 auto 20px;
font-size: 13.5px;
color: var(--text-muted);
text-align: left;
line-height: 1.9;
}
.info-box strong { color: var(--text); }
.info-box-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
.info-box-title .icon { color: #64748b; }
.info-box .icon { vertical-align: -0.15em; margin-right: 4px; }
.key-box {
background: #0f172a;
color: #a7f3d0;
border-radius: var(--radius-sm);
padding: 13px 16px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 13px;
word-break: break-all;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
/* ===== 错误页 ===== */
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
padding: 20px;
}
.error-box { text-align: center; max-width: 460px; }
.error-code { font-size: 72px; font-weight: 800; color: var(--primary); line-height: 1; }
.error-box h1 { font-size: 20px; margin: 14px 0 8px; }
.error-box p { color: var(--text-muted); margin: 0 0 24px; line-height: 1.7; }
/* ===== 全站 GitHub 未配置告警条 ===== */
.config-banner {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 20px;
font-size: 13.5px;
line-height: 1.6;
text-decoration: none;
}
a.config-banner:hover { text-decoration: none; filter: brightness(0.97); }
.config-banner .icon { flex-shrink: 0; }
/* ===== GitHub 构建配置页 ===== */
.brand-github { font-size: 1.15em; vertical-align: -0.18em; fill: currentColor; stroke: none; }
.card-desc {
margin: 0 0 18px;
font-size: 13px;
color: var(--text-muted);
line-height: 1.7;
}
.config-ok {
display: flex;
align-items: center;
gap: 9px;
color: #15803d;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: var(--radius-sm);
padding: 12px 16px;
font-size: 13.5px;
}
.config-ok .icon { color: #16a34a; flex-shrink: 0; }
.config-check-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 9px; }
.config-check-list li {
display: flex;
align-items: flex-start;
gap: 9px;
font-size: 13.5px;
line-height: 1.6;
}
.config-check-list .icon { flex-shrink: 0; margin-top: 2px; }
.config-check-list .is-ok { color: #15803d; }
.config-check-list .is-ok .icon { color: #16a34a; }
.config-check-list .is-bad { color: #b91c1c; }
.config-check-list .is-bad .icon { color: var(--danger); }
.config-check-list .is-warn { color: var(--warning); }
.config-check-list .is-warn .icon { color: #d97706; }
.setting-row { max-width: 640px; }
.setting-label {
display: flex;
align-items: center;
gap: 9px;
font-weight: 600;
font-size: 13.5px;
margin-bottom: 7px;
}
.source-badge {
font-weight: 500;
font-size: 11px;
line-height: 1;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid transparent;
white-space: nowrap;
}
.source-db { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
.source-env { background: #f1f5f9; color: #475569; border-color: #e2e8f0; }
.source-default { background: #fefce8; color: #a16207; border-color: #fde68a; }
.secret-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 14px;
margin-top: 7px;
}
.clear-check {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: var(--text-muted);
cursor: pointer;
margin: 0;
}
.clear-check input { margin: 0; }
.secret-state {
font-size: 12px;
padding: 2px 9px;
border-radius: 999px;
}
.secret-state.is-set { background: #eff6ff; color: #1d4ed8; }
.secret-state.is-env { background: #f1f5f9; color: #475569; }
.secret-state.is-empty { background: #fef2f2; color: #b91c1c; }
/* 表单内最后一张分组卡命中 .card:last-child 被去掉下边距,
需由表单本身补回,避免与表单后的引导卡片紧贴 */
.settings-form { margin-bottom: 24px; }
.settings-actions { margin-top: 22px; padding-top: 20px; border-top: 1px solid var(--border); }
.settings-actions .btn-row { margin-bottom: 0; }
/* 文件选择:隐藏原生控件,用统一按钮样式(与客户端定制页一致) */
.file-pick-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.file-pick-name { font-size: 13px; color: var(--text-muted); }
.test-result {
font-size: 13px;
padding: 9px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: #f9fafb;
color: var(--text-muted);
margin-top: 12px;
}
.test-result.is-loading { color: var(--text-muted); }
.test-result.is-ok { background: #f0fdf4; border-color: #bbf7d0; color: #15803d; }
.test-result.is-bad { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
/* 版本来源提示 */
.version-note {
display: flex;
align-items: center;
gap: 6px;
margin-top: 7px;
font-size: 12px;
line-height: 1.5;
}
.version-note .icon { font-size: 13px; flex-shrink: 0; }
.version-note.is-live { color: #1d4ed8; }
.version-note.is-fallback { color: #b45309; }
/* 定制页配置方案区 */
.profile-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.profile-row:last-child { margin-bottom: 0; }
.profile-row .form-control { width: auto; min-width: 220px; max-width: 420px; }
.profile-row #profileName { flex: 0 1 300px; }
.profile-msg { display: none; margin-top: 10px; font-size: 13px; line-height: 1.6; }
.profile-msg.is-ok { display: block; color: #15803d; }
.profile-msg.is-bad { display: block; color: #b91c1c; }
/* 配置引导教程 */
.guide-steps { list-style: none; padding: 0; margin: 0; counter-reset: guide; }
.guide-steps > li {
position: relative;
padding: 0 0 20px 42px;
counter-increment: guide;
}
.guide-steps > li:last-child { padding-bottom: 0; }
.guide-steps > li::before {
content: counter(guide);
position: absolute;
left: 0;
top: 0;
width: 27px;
height: 27px;
border-radius: 50%;
background: var(--primary);
color: #fff;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.guide-steps > li:not(:last-child)::after {
content: "";
position: absolute;
left: 13px;
top: 31px;
bottom: 2px;
width: 1.5px;
background: #dbe4f0;
}
.guide-title { font-weight: 600; font-size: 14px; margin-bottom: 5px; }
.guide-steps p { margin: 0; font-size: 13px; color: var(--text-muted); line-height: 1.75; }
.guide-steps a { color: var(--primary); font-weight: 500; }
.icon-inline { width: 13px; height: 13px; vertical-align: -0.15em; }
.guide-sublist { margin: 8px 0 0; padding-left: 18px; font-size: 13px; color: var(--text-muted); line-height: 1.85; }
.guide-sublist strong { color: var(--text); }
.guide-steps code {
background: #f1f5f9;
border-radius: 4px;
padding: 1px 6px;
font-size: 12px;
font-family: ui-monospace, Menlo, Consolas, monospace;
}
@media (max-width: 640px) {
.setting-row { max-width: none; }
.test-result { width: 100%; }
}
+19
View File
@@ -0,0 +1,19 @@
{% load static %}<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>无权访问 · RustDesk 客户端定制平台</title>
<link rel="stylesheet" href="{% static 'rdgenerator/css/app.css' %}">
</head>
<body>
<div class="error-page">
<div class="error-box">
<div class="error-code">403</div>
<h1>抱歉,你无权访问该页面</h1>
<p>该资源仅对任务提交者或管理员开放。如需权限,请联系平台管理员。</p>
<a class="btn btn-primary" href="/">返回首页</a>
</div>
</div>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
{% load static %}<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>页面不存在 · RustDesk 客户端定制平台</title>
<link rel="stylesheet" href="{% static 'rdgenerator/css/app.css' %}">
</head>
<body>
<div class="error-page">
<div class="error-box">
<div class="error-code">404</div>
<h1>页面或资源不存在</h1>
<p>链接可能已失效,或对应的构建任务 / 文件尚未生成。请确认地址无误,或返回平台首页继续操作。</p>
<a class="btn btn-primary" href="/">返回首页</a>
</div>
</div>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
{% load static %}<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>服务异常 · RustDesk 客户端定制平台</title>
<link rel="stylesheet" href="{% static 'rdgenerator/css/app.css' %}">
</head>
<body>
<div class="error-page">
<div class="error-box">
<div class="error-code">500</div>
<h1>服务暂时出现异常</h1>
<p>服务器处理请求时发生错误,请稍后重试。如问题持续存在,请联系平台管理员。</p>
<a class="btn btn-primary" href="/">返回首页</a>
</div>
</div>
</body>
</html>
File diff suppressed because one or more lines are too long
+76
View File
@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}后台管理 · RustDesk 客户端定制平台{% endblock %}
{% block content %}
<div class="page-head">
<h1 class="page-title">后台管理 · 构建任务总览</h1>
<p class="page-subtitle">查看全部用户提交的构建任务(最近 100 条),并进行用户与任务管理。</p>
</div>
<div class="stat-grid">
<div class="stat-card is-primary"><div class="stat-value">{{ stats.total }}</div><div class="stat-label">全部任务</div></div>
<div class="stat-card is-info"><div class="stat-value">{{ stats.in_progress }}</div><div class="stat-label">进行中</div></div>
<div class="stat-card is-success"><div class="stat-value">{{ stats.success }}</div><div class="stat-label">构建成功</div></div>
<div class="stat-card is-danger"><div class="stat-value">{{ stats.failure }}</div><div class="stat-label">失败 / 取消</div></div>
<div class="stat-card"><div class="stat-value">{{ stats.users }}</div><div class="stat-label">注册用户</div></div>
<div class="stat-card"><div class="stat-value">{{ stats.tokens }}</div><div class="stat-label">有效 API 令牌</div></div>
</div>
<div class="btn-row" style="margin-bottom:20px">
<a class="btn btn-secondary" href="{% url 'github_settings' %}"><svg class="icon"><use href="#i-sliders"/></svg> 构建平台配置</a>
<a class="btn btn-secondary" href="{% url 'maintenance_settings' %}"><svg class="icon"><use href="#i-clock"/></svg> 维护设置</a>
<a class="btn btn-secondary" href="{% url 'user_list' %}"><svg class="icon"><use href="#i-users"/></svg> 用户管理</a>
<a class="btn btn-secondary" href="/admin/"><svg class="icon"><use href="#i-wrench"/></svg> Django 高级管理后台</a>
</div>
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-list"/></svg> 最近构建任务</div>
{% if builds %}
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>提交时间</th>
<th>提交用户</th>
<th>配置名称</th>
<th>平台</th>
<th>状态</th>
<th>任务编号</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for run in builds %}
<tr>
<td class="text-muted" style="white-space:nowrap">{{ run.created_at|date:"Y-m-d H:i" }}</td>
<td>{% if run.created_by %}{{ run.created_by.username }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
<td><strong>{{ run.filename|default:"—" }}</strong></td>
<td>{{ run.platform_label }}</td>
<td><span class="badge {{ run.status_badge }} {% if not run.is_finished %}badge-dot{% endif %}">{{ run.status_label }}</span></td>
<td class="mono text-muted">{{ run.short_uuid }}</td>
<td style="white-space:nowrap">
<a class="btn btn-secondary btn-sm"
href="/check_for_file?filename={{ run.filename|urlencode }}&uuid={{ run.uuid|urlencode }}&platform={{ run.platform|urlencode }}">查看</a>
<form method="post" class="inline-form" action="{% url 'delete_build' run.id %}"
onsubmit="return confirm('确定删除任务「{{ run.filename|default:run.short_uuid|escapejs }}」吗?将同时删除服务器上已生成的安装包文件,且不可恢复。');">
{% csrf_token %}
<button type="submit" class="btn btn-danger btn-sm">
<svg class="icon"><use href="#i-trash"/></svg> 删除
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon"><svg class="icon" style="width:42px;height:42px"><use href="#i-inbox"/></svg></div>
<h3>暂无构建任务</h3>
<p>还没有任何用户提交过构建任务。</p>
</div>
{% endif %}
</div>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}构建未完成 · RustDesk 客户端定制平台{% endblock %}
{% block page_class %}page-narrow{% endblock %}
{% block content %}
<div class="card">
<div class="card-body result-wrap">
<div class="result-icon warning"><svg class="icon"><use href="#i-alert"/></svg></div>
<h2 class="result-title">构建未能完成</h2>
<p class="result-desc">
任务状态:<span class="badge {{ status_badge }}">{{ status_label }}</span><br>
配置名称:<strong>{{ filename }}</strong>
</p>
<div class="info-box" style="text-align:left">
<strong>构建过程异常终止,部分安装包可能并未生成。</strong><br>
你可以先尝试下载下方已经产出的文件(不存在的文件将提示 404),或查看 GitHub Actions 日志定位原因后重新提交构建。
</div>
<div class="download-list">
{% if platform == 'windows' %}
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}.exe&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}.exe</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}.msi&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}.msi</a>
{% elif platform == 'windows-x86' %}
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}.exe&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}.exe</a>
{% elif platform == 'linux' %}
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.deb&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-x86_64.deb</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-x86_64.rpm</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-suse-x86_64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-suse-x86_64.rpm</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.pkg.tar.zst&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-x86_64.pkg.tar.zst</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.deb&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-aarch64.deb</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-aarch64.rpm</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-suse-aarch64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-suse-aarch64.rpm</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.pkg.tar.zst&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-aarch64.pkg.tar.zst</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.AppImage&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-x86_64.AppImage</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.AppImage&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-aarch64.AppImage</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.flatpak&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-x86_64.flatpak</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.flatpak&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-aarch64.flatpak</a>
{% elif platform == 'android' %}
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.apk&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-aarch64.apk</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.apk&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-x86_64.apk</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-armv7.apk&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-armv7.apk</a>
{% elif platform == 'macos' %}
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.dmg&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-x86_64.dmg</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.dmg&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-aarch64.dmg</a>
{% endif %}
</div>
<p style="margin-top:22px">
{% if log_url %}
<a href="{{ log_url }}" target="_blank" rel="noopener"><svg class="icon"><use href="#i-external"/></svg> 查看 GitHub Actions 日志,了解具体失败原因</a>
{% endif %}
</p>
<div class="btn-row" style="justify-content:center">
<a class="btn btn-primary" href="{% url 'generator' %}">重新提交构建</a>
<a class="btn btn-secondary" href="{% url 'my_builds' %}">返回我的构建</a>
</div>
</div>
</div>
{% endblock %}
+67
View File
@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}构建完成 · RustDesk 客户端定制平台{% endblock %}
{% block page_class %}page-narrow{% endblock %}
{% block content %}
<div class="card">
<div class="card-body result-wrap">
<div class="result-icon success"><svg class="icon"><use href="#i-check-circle"/></svg></div>
<h2 class="result-title">客户端构建完成</h2>
<p class="result-desc">
配置名称:<strong>{{ filename }}</strong> ·
任务编号:<span class="mono">{{ uuid }}</span><br>
请选择与目标系统对应的安装包下载。
</p>
<div class="download-list">
{% if platform == 'windows' %}
<a class="btn btn-primary" href='/download?filename={{ filename|urlencode }}.exe&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 下载 {{ filename }}.exe(便携版)</a>
<a class="btn btn-primary" href='/download?filename={{ filename|urlencode }}.msi&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 下载 {{ filename }}.msi(安装版)</a>
{% elif platform == 'windows-x86' %}
<a class="btn btn-primary" href='/download?filename={{ filename|urlencode }}.exe&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 下载 {{ filename }}.exe(32 位)</a>
{% elif platform == 'linux' %}
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.deb&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-x86_64.deb</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-unattended-wayland-x86_64.deb&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-unattended-wayland-x86_64.deb</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-x86_64.rpm</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-suse-x86_64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-suse-x86_64.rpm</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.pkg.tar.zst&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-x86_64.pkg.tar.zst</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.deb&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-aarch64.deb</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-aarch64.rpm</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-suse-aarch64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-suse-aarch64.rpm</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.pkg.tar.zst&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-aarch64.pkg.tar.zst</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.AppImage&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-x86_64.AppImage</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.AppImage&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-aarch64.AppImage</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.flatpak&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-x86_64.flatpak</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.flatpak&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-aarch64.flatpak</a>
{% elif platform == 'android' %}
<a class="btn btn-primary" href='/download?filename={{ filename|urlencode }}-aarch64.apk&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-aarch64.apk(主流机型)</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.apk&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-x86_64.apk</a>
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-armv7.apk&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-armv7.apk</a>
{% elif platform == 'macos' %}
<a class="btn btn-primary" href='/download?filename={{ filename|urlencode }}-x86_64.dmg&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-x86_64.dmg(Intel 芯片)</a>
<a class="btn btn-primary" href='/download?filename={{ filename|urlencode }}-aarch64.dmg&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-aarch64.dmg(Apple 芯片)</a>
{% else %}
<div class="alert alert-warning">未匹配到目标平台,没有可下载的文件。</div>
{% endif %}
</div>
<div class="info-box" style="margin-top:22px" id="platformNote">
{% if platform == 'windows' or platform == 'windows-x86' %}
<svg class="icon"><use href="#i-alert"/></svg> Windows 首次运行时,可能需要在 SmartScreen 中选择「仍要运行」,或在安全软件中放行。
{% elif platform == 'macos' %}
<svg class="icon"><use href="#i-alert"/></svg> macOS 首次运行时,可能需要在「系统设置 → 隐私与安全性」中允许打开该应用。
{% elif platform == 'linux' %}
<svg class="icon"><use href="#i-alert"/></svg> 请确认系统已安装运行所需的相关依赖库。
{% elif platform == 'android' %}
<svg class="icon"><use href="#i-alert"/></svg> 安装时可能需要在系统设置中允许「安装未知来源应用」。
{% endif %}
</div>
<div class="btn-row" style="justify-content:center">
<a class="btn btn-secondary" href="{% url 'my_builds' %}">查看我的构建</a>
<a class="btn btn-primary" href="{% url 'generator' %}">继续定制新客户端</a>
</div>
</div>
</div>
{% endblock %}
+674
View File
@@ -0,0 +1,674 @@
{% extends "base.html" %}
{% block title %}客户端定制 · RustDesk 客户端定制平台{% endblock %}
{% block content %}
<div class="page-head">
<h1 class="page-title">定制 RustDesk 客户端</h1>
<p class="page-subtitle">填写以下配置并提交,系统将通过 GitHub Actions 自动构建专属客户端,通常需要 30~45 分钟,可在「我的构建」中随时查看进度。</p>
</div>
<form id="myForm" action="/generator" method="post" enctype="multipart/form-data">
{% csrf_token %}
{# ---------- 平台与版本 ---------- #}
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-monitor"/></svg> 目标平台与版本</div>
<div class="card-body">
<div class="platform-grid">
<div class="platform-card active" data-platform="windows">
<svg class="brand-icon"><use href="#b-windows"/></svg>
<div class="platform-name">Windows</div>
<div class="platform-tag">64 位</div>
</div>
<div class="platform-card" data-platform="windows-x86">
<svg class="brand-icon"><use href="#b-windows"/></svg>
<div class="platform-name">Windows</div>
<div class="platform-tag">32 位</div>
</div>
<div class="platform-card" data-platform="linux">
<svg class="brand-icon"><use href="#b-linux"/></svg>
<div class="platform-name">Linux</div>
<div class="platform-tag">deb / rpm 等</div>
</div>
<div class="platform-card" data-platform="android">
<svg class="brand-icon"><use href="#b-android"/></svg>
<div class="platform-name">Android</div>
<div class="platform-tag">APK</div>
</div>
<div class="platform-card" data-platform="macos">
<svg class="brand-icon"><use href="#b-apple"/></svg>
<div class="platform-name">macOS</div>
<div class="platform-tag">dmg</div>
</div>
</div>
{{ form.platform }}
<div class="grid-2" style="margin-top:20px">
<div class="field" style="margin-bottom:0">
<label for="{{ form.version.id_for_label }}">RustDesk 版本</label>
{{ form.version }}
{% if version_source == 'github' %}
<div class="version-note is-live">
<svg class="icon"><use href="#b-github"/></svg>
版本列表来自 GitHub 官方仓库 rustdesk/rustdesk,跟随官方发版自动更新
</div>
{% else %}
<div class="version-note is-fallback">
<svg class="icon"><use href="#i-alert"/></svg>
官方版本列表暂时获取失败,当前显示内置版本列表;保存配置或配置代理后会自动重试
</div>
{% endif %}
{% if form.version.help_text %}<div class="help-text">{{ form.version.help_text }}</div>{% endif %}
</div>
<div class="field field-check-control">
<label class="check-item">
{{ form.delayFix }} <span>修复使用第三方 API 时的连接延迟问题</span>
</label>
</div>
</div>
</div>
</div>
{# ---------- 配置方案:账号绑定 / 载入 / 另存 / 导入导出 ---------- #}
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-save"/></svg> 配置方案(保存 · 载入 · 导入导出)</div>
<div class="card-body">
<p class="field-hint" style="margin:0 0 14px">
把当前配置「另存为方案」后与账号绑定,下次直接选择方案即可填表创建构建任务;
也可导出 JSON 本地备份、从 JSON 文件导入,或前往
<a href="{% url 'saved_configs' %}">配置文件管理页</a>统一维护。
</p>
<div class="profile-row">
<select id="profileSelect" class="form-control">
<option value="">— 选择已保存的配置方案 —</option>
{% for p in profiles %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
<button type="button" class="btn btn-secondary" id="loadProfileBtn">
<svg class="icon"><use href="#i-zap"/></svg> 载入方案
</button>
<a class="btn btn-secondary" href="{% url 'saved_configs' %}">
<svg class="icon"><use href="#i-list"/></svg> 管理配置文件
</a>
</div>
<div class="profile-row">
<input type="text" id="profileName" class="form-control" maxlength="100"
placeholder="方案名称,如:公司标准-Windows 版">
<button type="button" class="btn btn-primary" id="saveProfileBtn">
<svg class="icon"><use href="#i-save"/></svg> 另存当前配置为方案
</button>
<button type="button" class="btn btn-secondary" onclick="saveFormData()">
<svg class="icon"><use href="#i-file-down"/></svg> 导出为 JSON 文件
</button>
<button type="button" class="btn btn-secondary" onclick="loadFormData()">
<svg class="icon"><use href="#i-file-up"/></svg> 从 JSON 文件导入
</button>
<input type="file" id="fileInput" style="display:none;" accept=".json,application/json">
</div>
<div id="profileMsg"></div>
</div>
</div>
<div class="grid-2">
{# ---------- 常规 ---------- #}
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-settings"/></svg> 常规设置</div>
<div class="card-body">
<div class="field">
<label for="{{ form.exename.id_for_label }}">{{ form.exename.label }}</label>
{{ form.exename }}
<span id="filenameError" class="inline-error"></span>
{{ form.exename.errors }}
{% if form.exename.help_text %}<div class="field-hint">{{ form.exename.help_text }}</div>{% endif %}
</div>
<div class="field">
<label for="{{ form.appname.id_for_label }}">{{ form.appname.label }}</label>
{{ form.appname }}
{{ form.appname.errors }}
{% if form.appname.help_text %}<div class="field-hint">{{ form.appname.help_text }}</div>{% endif %}
</div>
<div class="field radio-group">
<label class="field-label">{{ form.direction.label }}</label>
{{ form.direction }}
</div>
<div class="field">
<label for="{{ form.installation.id_for_label }}">{{ form.installation.label }}</label>
{{ form.installation }}
</div>
<div class="field">
<label for="{{ form.settings.id_for_label }}">{{ form.settings.label }}</label>
{{ form.settings }}
</div>
<div class="field" style="margin-bottom:0">
<label for="{{ form.androidappid.id_for_label }}">{{ form.androidappid.label }}</label>
{{ form.androidappid }}
{% if form.androidappid.help_text %}<div class="field-hint">{{ form.androidappid.help_text }}</div>{% endif %}
</div>
</div>
</div>
{# ---------- 自定义服务器 ---------- #}
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-server"/></svg> 自定义服务器</div>
<div class="card-body">
<div class="grid-2">
<div class="field">
<label for="{{ form.serverIP.id_for_label }}">{{ form.serverIP.label }}</label>
{{ form.serverIP }}
{% if form.serverIP.help_text %}<div class="field-hint">{{ form.serverIP.help_text }}</div>{% endif %}
</div>
<div class="field">
<label for="{{ form.serverPort.id_for_label }}">{{ form.serverPort.label }}</label>
{{ form.serverPort }}
</div>
</div>
<div class="field">
<label for="{{ form.apiServer.id_for_label }}">{{ form.apiServer.label }}</label>
{{ form.apiServer }}
{% if form.apiServer.help_text %}<div class="field-hint">{{ form.apiServer.help_text }}</div>{% endif %}
</div>
<div class="field">
<label for="{{ form.key.id_for_label }}">{{ form.key.label }}</label>
{{ form.key }}
</div>
<div class="field">
<label for="{{ form.urlLink.id_for_label }}">{{ form.urlLink.label }}</label>
{{ form.urlLink }}
{% if form.urlLink.help_text %}<div class="field-hint">{{ form.urlLink.help_text }}</div>{% endif %}
</div>
<div class="field">
<label for="{{ form.downloadLink.id_for_label }}">{{ form.downloadLink.label }}</label>
{{ form.downloadLink }}
{% if form.downloadLink.help_text %}<div class="field-hint">{{ form.downloadLink.help_text }}</div>{% endif %}
</div>
<div class="field" style="margin-bottom:0">
<label for="{{ form.compname.id_for_label }}">{{ form.compname.label }}</label>
{{ form.compname }}
{{ form.compname.errors }}
{% if form.compname.help_text %}<div class="field-hint">{{ form.compname.help_text }}</div>{% endif %}
</div>
</div>
</div>
</div>
<div class="grid-2">
{# ---------- 安全 ---------- #}
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-shield"/></svg> 安全设置</div>
<div class="card-body">
<div class="field">
<label for="{{ form.passApproveMode.id_for_label }}">{{ form.passApproveMode.label }}</label>
{{ form.passApproveMode }}
</div>
<div class="field">
<label for="{{ form.permanentPassword.id_for_label }}">{{ form.permanentPassword.label }}</label>
{{ form.permanentPassword }}
{% if form.permanentPassword.help_text %}<div class="field-hint">{{ form.permanentPassword.help_text }}</div>{% endif %}
</div>
<div id="passwordRequirement" class="inline-warning">使用「隐藏连接窗口」功能时,请先设置固定访问密码。</div>
<label class="check-item" for="{{ form.denyLan.id_for_label }}">{{ form.denyLan }} <span>禁止局域网发现</span></label>
<label class="check-item" for="{{ form.enableDirectIP.id_for_label }}">{{ form.enableDirectIP }} <span>允许直接 IP 直连</span></label>
<label class="check-item" for="{{ form.autoClose.id_for_label }}">{{ form.autoClose }} <span>用户无操作时自动断开入站会话</span></label>
<label class="check-item" for="{{ form.hidecm.id_for_label }}">{{ form.hidecm }} <span>允许在远程画面中隐藏连接窗口</span></label>
</div>
</div>
{# ---------- 外观 ---------- #}
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-image"/></svg> 外观定制</div>
<div class="card-body">
{{ form.iconbase64.as_hidden }}
{{ form.logobase64.as_hidden }}
{{ form.privacybase64.as_hidden }}
<div class="field">
<label for="{{ form.iconfile.id_for_label }}">{{ form.iconfile.label }}</label>
{{ form.iconfile }}
{{ form.iconfile.errors }}
{% if form.iconfile.help_text %}<div class="field-hint">{{ form.iconfile.help_text }}</div>{% endif %}
<div id="icon-preview" class="image-preview"></div>
</div>
<div class="field">
<label for="{{ form.logofile.id_for_label }}">{{ form.logofile.label }}</label>
{{ form.logofile }}
{% if form.logofile.help_text %}<div class="field-hint">{{ form.logofile.help_text }}</div>{% endif %}
<div id="logo-preview" class="image-preview"></div>
</div>
<div class="field">
<label for="{{ form.privacyfile.id_for_label }}">{{ form.privacyfile.label }}</label>
{{ form.privacyfile }}
{% if form.privacyfile.help_text %}<div class="field-hint">{{ form.privacyfile.help_text }}</div>{% endif %}
<div id="privacy-preview" class="image-preview photo"></div>
</div>
<div class="grid-2">
<div class="field" style="margin-bottom:0">
<label for="{{ form.theme.id_for_label }}">主题</label>
{{ form.theme }}
</div>
<div class="field" style="margin-bottom:0">
<label for="{{ form.themeDorO.id_for_label }}">主题生效方式</label>
{{ form.themeDorO }}
</div>
</div>
<div class="field-hint">「默认」仅设定初始主题,客户端用户可自行更改;「强制锁定」后用户无法更改主题。</div>
</div>
</div>
</div>
<div class="grid-2">
{# ---------- 权限 ---------- #}
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-lock"/></svg> 权限设置</div>
<div class="card-body">
<div class="field">
<label for="{{ form.permissionsDorO.id_for_label }}">权限生效方式</label>
{{ form.permissionsDorO }}
<div class="field-hint">「默认」用户仍可自行修改;「强制锁定」后用户无法修改以下权限。</div>
</div>
<div class="field">
<label for="{{ form.permissionsType.id_for_label }}">{{ form.permissionsType.label }}</label>
{{ form.permissionsType }}
</div>
<div class="check-grid">
<label class="check-item" for="{{ form.enableKeyboard.id_for_label }}">{{ form.enableKeyboard }} <span>键盘 / 鼠标控制</span></label>
<label class="check-item" for="{{ form.enableClipboard.id_for_label }}">{{ form.enableClipboard }} <span>剪贴板同步</span></label>
<label class="check-item" for="{{ form.enableFileTransfer.id_for_label }}">{{ form.enableFileTransfer }} <span>文件传输</span></label>
<label class="check-item" for="{{ form.enableAudio.id_for_label }}">{{ form.enableAudio }} <span>音频传输</span></label>
<label class="check-item" for="{{ form.enableTCP.id_for_label }}">{{ form.enableTCP }} <span>TCP 隧道</span></label>
<label class="check-item" for="{{ form.enableRemoteRestart.id_for_label }}">{{ form.enableRemoteRestart }} <span>远程重启</span></label>
<label class="check-item" for="{{ form.enableRecording.id_for_label }}">{{ form.enableRecording }} <span>会话录制</span></label>
<label class="check-item" for="{{ form.enableBlockingInput.id_for_label }}">{{ form.enableBlockingInput }} <span>屏蔽被控端键鼠</span></label>
<label class="check-item" for="{{ form.enableRemoteModi.id_for_label }}">{{ form.enableRemoteModi }} <span>允许修改远程配置</span></label>
<label class="check-item" for="{{ form.enablePrinter.id_for_label }}">{{ form.enablePrinter }} <span>远程打印机</span></label>
<label class="check-item" for="{{ form.enableCamera.id_for_label }}">{{ form.enableCamera }} <span>远程摄像头</span></label>
<label class="check-item" for="{{ form.enableTerminal.id_for_label }}">{{ form.enableTerminal }} <span>远程终端</span></label>
</div>
<div class="subsection-title"><svg class="icon"><use href="#i-zap"/></svg> 定制功能</div>
<label class="check-item" for="{{ form.xOffline.id_for_label }}">{{ form.xOffline }} <span>通讯录中离线设备显示 X 标记</span></label>
<label class="check-item" for="{{ form.removeNewVersionNotif.id_for_label }}">{{ form.removeNewVersionNotif }} <span>移除新版本更新通知</span></label>
</div>
</div>
{# ---------- 其他 / 高级 ---------- #}
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-sliders"/></svg> 其他与高级设置</div>
<div class="card-body">
<label class="check-item" for="{{ form.removeWallpaper.id_for_label }}">{{ form.removeWallpaper }} <span>入站会话期间移除桌面壁纸</span></label>
<p class="field-hint" style="margin:18px 0 10px">
高级设置项参考:
<a href="https://rustdesk.com/docs/en/self-host/client-configuration/advanced-settings/" target="_blank" rel="noopener">RustDesk 官方文档
<svg class="icon" style="font-size:12px"><use href="#i-external"/></svg>
</a>
</p>
<div class="field">
<label for="{{ form.defaultManual.id_for_label }}">{{ form.defaultManual.label }}</label>
{{ form.defaultManual }}
{% if form.defaultManual.help_text %}<div class="field-hint">{{ form.defaultManual.help_text }}</div>{% endif %}
</div>
<div class="field" style="margin-bottom:0">
<label for="{{ form.overrideManual.id_for_label }}">{{ form.overrideManual.label }}</label>
{{ form.overrideManual }}
{% if form.overrideManual.help_text %}<div class="field-hint">{{ form.overrideManual.help_text }}</div>{% endif %}
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body" style="text-align:center">
<button type="submit" id="submitBtn" class="btn btn-primary" style="min-width:240px">
<svg class="icon"><use href="#i-send"/></svg> 开始生成自定义客户端
</button>
<p class="field-hint" style="margin-top:12px">提交后将自动跳转到构建进度页,构建期间可关闭页面,稍后在「我的构建」中回来下载。</p>
</div>
</div>
</form>
{% endblock %}
{% block head_extra %}
<script>
document.addEventListener('DOMContentLoaded', function () {
/* ---------- 平台卡片选择 ---------- */
const platformSelect = document.getElementById('id_platform');
document.querySelectorAll('.platform-card').forEach(card => {
card.addEventListener('click', function () {
document.querySelectorAll('.platform-card').forEach(c => c.classList.remove('active'));
this.classList.add('active');
platformSelect.value = this.dataset.platform;
});
});
// 回显当前选中平台
const current = platformSelect.value;
document.querySelectorAll('.platform-card').forEach(c => {
if (c.dataset.platform === current) {
document.querySelectorAll('.platform-card').forEach(x => x.classList.remove('active'));
c.classList.add('active');
}
});
/* ---------- 图片预览 ---------- */
function previewImage(input, previewContainerId) {
const box = document.getElementById(previewContainerId);
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function (e) {
box.innerHTML = '';
const img = document.createElement('img');
img.src = e.target.result;
box.appendChild(img);
};
reader.readAsDataURL(input.files[0]);
}
}
document.getElementById("{{ form.iconfile.id_for_label }}").addEventListener('change', e => previewImage(e.target, 'icon-preview'));
document.getElementById("{{ form.logofile.id_for_label }}").addEventListener('change', e => previewImage(e.target, 'logo-preview'));
document.getElementById("{{ form.privacyfile.id_for_label }}").addEventListener('change', e => previewImage(e.target, 'privacy-preview'));
/* ---------- 隐藏连接窗口联动 ---------- */
const hidecm = document.getElementById("{{ form.hidecm.id_for_label }}");
const passApproveMode = document.getElementById("{{ form.passApproveMode.id_for_label }}");
const permanentPassword = document.getElementById("{{ form.permanentPassword.id_for_label }}");
hidecm.addEventListener('change', function () {
const tip = document.getElementById('passwordRequirement');
if (this.checked) {
tip.style.display = 'block';
permanentPassword.focus();
passApproveMode.value = 'password';
} else {
tip.style.display = 'none';
passApproveMode.value = 'password-click';
}
});
/* ---------- 权限预设联动 ---------- */
const permissionChecks = [
"{{ form.enableKeyboard.id_for_label }}", "{{ form.enableClipboard.id_for_label }}",
"{{ form.enableFileTransfer.id_for_label }}", "{{ form.enableAudio.id_for_label }}",
"{{ form.enableTCP.id_for_label }}", "{{ form.enableRemoteRestart.id_for_label }}",
"{{ form.enableRecording.id_for_label }}", "{{ form.enableBlockingInput.id_for_label }}",
"{{ form.enableRemoteModi.id_for_label }}", "{{ form.enablePrinter.id_for_label }}",
"{{ form.enableCamera.id_for_label }}", "{{ form.enableTerminal.id_for_label }}"
].map(id => document.getElementById(id));
function setChecks(checked, disabled) {
permissionChecks.forEach(cb => { cb.checked = checked; cb.disabled = disabled; });
}
document.getElementById("{{ form.permissionsType.id_for_label }}").addEventListener('change', function () {
if (this.value === 'full') {
setChecks(true, true);
} else if (this.value === 'view') {
setChecks(false, true);
} else {
// 自定义:恢复可编辑,不改变当前勾选
permissionChecks.forEach(cb => { cb.disabled = false; });
}
});
/* ---------- 重复提交保护 ---------- */
document.getElementById('myForm').addEventListener('submit', function () {
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = '正在提交构建任务…';
});
/* ---------- 配置数据收集 / 填充(账号方案、JSON 导入导出共用) ---------- */
const form = document.getElementById('myForm');
async function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => resolve(e.target.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// 导入配置中遇到未知字段时原样保留,避免另存时丢失
let carriedUnknownFields = {};
// 收集当前表单为可序列化 JSON(图片转 base64 data URL)
async function collectFormData() {
const formData = {};
new FormData(form).forEach((value, key) => {
// File 对象序列化为 {},需跳过并在下面单独读取
if (value instanceof File) return;
formData[key] = value;
});
form.querySelectorAll('select').forEach(sel => { formData[sel.name] = sel.value; });
form.querySelectorAll('input[type="checkbox"]').forEach(input => {
if (input.name) { formData[input.name] = input.checked; }
});
form.querySelectorAll('input[type="radio"]:checked').forEach(input => {
if (input.name) { formData[input.name] = input.value; }
});
// 图片:优先取已预览的 base64,其次读取新选择的文件
async function collectImage(fieldName, previewId, fileInputId) {
const preview = document.getElementById(previewId);
if (preview.firstChild && preview.firstChild.src && preview.firstChild.src.startsWith('data:image/')) {
formData[fieldName] = preview.firstChild.src;
} else {
const file = document.getElementById(fileInputId).files[0];
if (file) { formData[fieldName] = await readFileAsBase64(file); }
}
}
await collectImage('iconfile', 'icon-preview', "{{ form.iconfile.id_for_label }}");
await collectImage('logofile', 'logo-preview', "{{ form.logofile.id_for_label }}");
await collectImage('privacyfile', 'privacy-preview', "{{ form.privacyfile.id_for_label }}");
for (const k in carriedUnknownFields) {
if (!Object.prototype.hasOwnProperty.call(formData, k)) formData[k] = carriedUnknownFields[k];
}
return formData;
}
// 导出为本地 JSON 文件(沿用配置名称作为文件名)
window.saveFormData = async function () {
const filenameInput = document.getElementById("{{ form.exename.id_for_label }}");
const filename = filenameInput.value.trim();
const errorBox = document.getElementById('filenameError');
if (!filename) {
errorBox.textContent = '请先填写配置名称。';
filenameInput.focus();
return;
}
errorBox.textContent = '';
const formData = await collectFormData();
const blob = new Blob([JSON.stringify(formData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename + '.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
function showLoadWarnings(keys) {
let banner = document.getElementById('loadWarnings');
if (!keys.length) {
if (banner) banner.remove();
return;
}
if (!banner) {
banner = document.createElement('div');
banner.id = 'loadWarnings';
banner.className = 'alert alert-warning';
form.parentNode.insertBefore(banner, form);
}
banner.textContent = '以下字段包含无效值,已被忽略:' + keys.join('、') + '。';
}
function fillPreview(key, fieldId, previewId) {
// 兼容历史导出中 iconbase64 命名
const hidden = document.getElementById(fieldId);
if (hidden) hidden.value = key;
document.getElementById(previewId).innerHTML =
'<img src="' + key + '" alt="预览图">';
}
// 用 JSON 数据填充整个表单,返回被忽略的异常字段名列表
function fillFormFromData(data) {
carriedUnknownFields = {};
const skippedKeys = [];
for (const key in data) {
const elements = document.querySelectorAll('[name="' + key + '"], [id="' + key + '"]');
if (elements.length > 0) {
elements.forEach(el => {
const v = data[key];
if ((typeof v === 'object' && v !== null) || v === '[object Object]') {
if (!skippedKeys.includes(key)) skippedKeys.push(key);
return;
}
if (el.type === 'radio') {
el.checked = (el.value === String(v));
} else if (el.type === 'checkbox') {
el.checked = !!v;
} else if (el.type !== 'file') {
el.value = v;
}
});
} else if (['string', 'number', 'boolean'].includes(typeof v)) {
carriedUnknownFields[key] = v;
}
}
// 图片字段(兼容 iconfile / iconbase64 两种命名)
const imgFields = [
['iconfile', 'id_iconbase64', 'icon-preview'],
['logofile', 'id_logobase64', 'logo-preview'],
['privacyfile', 'id_privacybase64', 'privacy-preview'],
];
imgFields.forEach(([jsonKey, hiddenId, previewId]) => {
const v = data[jsonKey] || data[hiddenId];
if (v && typeof v === 'string' && v.startsWith('data:image/')) {
fillPreview(v, hiddenId, previewId);
}
});
// 同步联动控件状态
document.querySelectorAll('.platform-card').forEach(c => {
c.classList.toggle('active', c.dataset.platform === platformSelect.value);
});
document.getElementById("{{ form.permissionsType.id_for_label }}").dispatchEvent(new Event('change'));
return skippedKeys;
}
window.loadFormData = function () {
const fileInput = document.getElementById('fileInput');
fileInput.value = '';
fileInput.click();
fileInput.onchange = function (event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
let data;
try {
data = JSON.parse(e.target.result);
} catch (err) {
alert('加载失败:配置文件不是有效的 JSON。');
return;
}
showLoadWarnings(fillFormFromData(data));
showProfileMsg('已从 JSON 文件导入配置,可直接提交构建或继续修改。', true);
};
reader.readAsText(file);
};
};
/* ---------- 账号绑定的配置方案 ---------- */
const profileSelect = document.getElementById('profileSelect');
const profileName = document.getElementById('profileName');
const profileMsg = document.getElementById('profileMsg');
const csrfInput = document.querySelector('[name=csrfmiddlewaretoken]').value;
function showProfileMsg(text, ok) {
profileMsg.className = 'profile-msg ' + (ok ? 'is-ok' : 'is-bad');
profileMsg.textContent = text;
}
async function loadProfileById(id) {
try {
const resp = await fetch('{% url "saved_configs" %}' + id + '/data/');
if (!resp.ok) {
showProfileMsg('方案不存在或已被删除,请刷新页面后重试。', false);
profileSelect.value = '';
return;
}
const json = await resp.json();
showLoadWarnings(fillFormFromData(json.data));
profileSelect.value = id;
showProfileMsg('已载入方案「' + json.name + '」,可直接提交构建或继续修改。', true);
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (e) {
showProfileMsg('载入方案失败:' + e, false);
}
}
document.getElementById('loadProfileBtn').addEventListener('click', function () {
const id = profileSelect.value;
if (!id) { showProfileMsg('请先在下拉框中选择一个配置方案。', false); return; }
loadProfileById(id);
});
document.getElementById('saveProfileBtn').addEventListener('click', async function () {
const btn = this;
const name = profileName.value.trim();
if (!name) {
showProfileMsg('请先在左侧输入方案名称。', false);
profileName.focus();
return;
}
const payload = { name: name, data: await collectFormData(), overwrite: false };
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.textContent = '正在保存…';
async function submit(overwrite) {
payload.overwrite = overwrite;
const resp = await fetch('{% url "save_config" %}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfInput },
body: JSON.stringify(payload),
});
const json = await resp.json().catch(() => ({}));
if (resp.ok && json.ok) {
let option = profileSelect.querySelector('option[value="' + json.id + '"]');
if (!option) {
option = document.createElement('option');
option.value = json.id;
profileSelect.appendChild(option);
}
option.textContent = name;
profileSelect.value = json.id;
showProfileMsg(json.overwritten ? '方案「' + name + '」已更新。' : '方案「' + name + '」已保存,下次可直接选择载入。', true);
} else if (resp.status === 409 && json.duplicate) {
if (window.confirm('已存在同名方案「' + name + '」,是否用当前配置覆盖它?')) {
return submit(true);
}
} else {
showProfileMsg(json.message || '保存失败,请稍后重试。', false);
}
}
submit(false).catch(e => showProfileMsg('保存失败:' + e, false)).finally(() => {
btn.disabled = false;
btn.innerHTML = originalHtml;
});
});
// 从「配置文件管理」页点「载入并构建」跳转过来时自动填充
const autoConfigId = new URLSearchParams(window.location.search).get('config');
if (autoConfigId) {
loadProfileById(autoConfigId);
}
});
</script>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RustDesk 客户端定制平台</title>
<style>
html, body { height: 100%; }
body {
background: radial-gradient(circle at center, #fff 0%, #f8f8f8 75%, #ebebeb 100%);
color: #222;
font-family: system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", Roboto, sans-serif;
font-size: 1rem;
line-height: 1.5;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
main { padding: 1rem; text-align: center; }
h1 { font-size: 2.5rem; line-height: 1.1; margin: 0; }
@media screen and (max-width: 480px) { h1 { font-size: 1.5rem; } }
h1::after {
content: "";
background: repeating-linear-gradient(45deg, #ffe800, #ffe800 0.5rem, #222 0.5rem, #222 1.0rem);
display: block;
height: 0.5rem;
margin-top: 1rem;
}
p { margin: 1rem 0 0 0; }
</style>
</head>
<body>
<main>
<h1>RustDesk 客户端定制平台</h1>
<p>平台正在维护升级中,请稍后再访问。</p>
</main>
</body>
</html>
@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}维护设置 · RustDesk 客户端定制平台{% endblock %}
{% block content %}
<nav class="breadcrumb">
<a href="{% url 'dashboard' %}">后台管理</a>
<span class="breadcrumb-sep">/</span>
<span>维护设置</span>
</nav>
<div class="page-head">
<h1 class="page-title">
<svg class="icon"><use href="#i-clock"/></svg>
维护设置
</h1>
<p class="page-subtitle">
定时清理过期构建任务与安装包产物,并自动备份数据库。维护进程随容器启动常驻运行,每小时检查一次。
</p>
</div>
<div class="stat-grid">
<div class="stat-card"><div class="stat-value">{{ status.total_builds }}</div><div class="stat-label">构建任务总数</div></div>
<div class="stat-card is-info"><div class="stat-value">{{ status.in_progress_builds }}</div><div class="stat-label">进行中(不清理)</div></div>
<div class="stat-card is-danger"><div class="stat-value">{{ status.expired_builds }}</div><div class="stat-label">已到期待清理</div></div>
<div class="stat-card"><div class="stat-value">{{ status.db_size|filesizeformat }}</div><div class="stat-label">数据库大小</div></div>
<div class="stat-card"><div class="stat-value">{{ status.artifacts_size|filesizeformat }}</div><div class="stat-label">安装包 / 图片占用</div></div>
<div class="stat-card{% if status.backup_enabled %} is-success{% endif %}"><div class="stat-value">{{ status.backups|length }}</div><div class="stat-label">现存备份份数</div></div>
</div>
<div class="grid-2" style="align-items:start">
{# ===== 维护策略表单 ===== #}
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-sliders"/></svg> 自动维护策略</div>
<div class="card-body">
<form method="post" action="{% url 'maintenance_settings' %}">
{% csrf_token %}
<div class="field">
<label class="setting-label" for="{{ form.build_retention_days.id_for_label }}">{{ form.build_retention_days.label }}</label>
{{ form.build_retention_days }}
<div class="field-hint">{{ form.build_retention_days.help_text }}</div>
</div>
<div class="field">
<label class="clear-check" for="{{ form.db_backup_enabled.id_for_label }}">
{{ form.db_backup_enabled }} {{ form.db_backup_enabled.label }}
</label>
<div class="field-hint">{{ form.db_backup_enabled.help_text }}</div>
</div>
<div class="field">
<label class="setting-label" for="{{ form.db_backup_keep.id_for_label }}">{{ form.db_backup_keep.label }}</label>
{{ form.db_backup_keep }}
<div class="field-hint">{{ form.db_backup_keep.help_text }}</div>
</div>
<div class="settings-actions">
<div class="btn-row">
<button type="submit" class="btn btn-primary">
<svg class="icon"><use href="#i-save"/></svg> 保存设置
</button>
</div>
</div>
</form>
</div>
</div>
{# ===== 立即执行 ===== #}
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-play"/></svg> 手动维护</div>
<div class="card-body">
<p class="card-desc">
立即执行一轮维护:清理超过保留期的已结束任务、7 天前的临时配置包,并<strong>立即创建一份新的数据库备份</strong>
(不受每日一次的自动节奏限制)。
</p>
<ul class="config-check-list">
<li class="is-ok"><svg class="icon"><use href="#i-check-circle"/></svg>
当前保留策略:{% if status.retention_days > 0 %}{{ status.retention_days }} 天,预计可清理 {{ status.expired_builds }} 条已结束任务{% else %}永不自动清理{% endif %}
</li>
<li class="{% if status.backup_enabled %}is-ok{% else %}is-warn{% endif %}">
<svg class="icon"><use href="{% if status.backup_enabled %}#i-check-circle{% else %}#i-alert{% endif %}"/></svg>
定时备份:{% if status.backup_enabled %}已开启(每 {{ status.backup_interval_hours }} 小时一次,保留 {{ status.backup_keep }} 份){% else %}已关闭{% endif %}
</li>
<li class="is-ok"><svg class="icon"><use href="#i-database"/></svg>
数据库:<span class="mono text-muted">{{ status.db_path }}</span>({{ status.db_size|filesizeformat }})
</li>
</ul>
<form method="post" action="{% url 'maintenance_run_now' %}"
onsubmit="return confirm('立即执行清理并备份数据库?已到期的任务记录与安装包将被删除。');">
{% csrf_token %}
<button type="submit" class="btn btn-secondary">
<svg class="icon"><use href="#i-zap"/></svg> 立即执行清理与备份
</button>
</form>
</div>
</div>
</div>
{# ===== 备份列表 ===== #}
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-database"/></svg> 数据库备份</div>
{% if status.backups %}
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>备份文件</th>
<th>大小</th>
<th>备份时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for backup in status.backups %}
<tr>
<td class="mono">{{ backup.name }}</td>
<td class="text-muted">{{ backup.size|filesizeformat }}</td>
<td class="text-muted" style="white-space:nowrap">{{ backup.mtime|date:"Y-m-d H:i" }}</td>
<td>
<a class="btn btn-secondary btn-sm" href="{% url 'download_backup' backup.name %}">
<svg class="icon"><use href="#i-download"/></svg> 下载
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon"><svg class="icon" style="width:42px;height:42px"><use href="#i-database"/></svg></div>
<h3>暂无数据库备份</h3>
<p>开启定时备份后会自动生成;也可以点击上方「立即执行清理与备份」马上创建一份。</p>
</div>
{% endif %}
</div>
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-server"/></svg> 说明</div>
<div class="card-body">
<p class="card-desc">
备份文件是完整的 SQLite 数据库(包含账号、配置方案、构建记录与站点设置),存放在持久化数据卷
<code>data/backups/</code> 中,重建镜像与重新部署均不丢失。<strong>恢复方式</strong>:停止容器,
用备份文件替换数据卷内的 <code>db.sqlite3</code> 后重新启动即可。
也可通过 docker-compose 环境变量 <code>BUILD_RETENTION_DAYS</code>、
<code>DB_BACKUP_ENABLED</code>、<code>DB_BACKUP_KEEP</code> 配置(本页保存的优先级更高)。
</p>
</div>
</div>
{% endblock %}
+78
View File
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}我的构建 · RustDesk 客户端定制平台{% endblock %}
{% block content %}
<div class="page-head">
<h1 class="page-title">我的构建任务</h1>
<p class="page-subtitle">这里展示你提交的全部自定义客户端构建任务,单次构建通常需要 30~45 分钟。</p>
</div>
{% if builds %}
<div class="card">
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>提交时间</th>
<th>配置名称</th>
<th>平台</th>
<th>状态</th>
<th>任务编号</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for run in builds %}
<tr>
<td class="text-muted" style="white-space:nowrap">{{ run.created_at|date:"Y-m-d H:i" }}</td>
<td><strong>{{ run.filename|default:"—" }}</strong></td>
<td>{{ run.platform_label }}</td>
<td><span class="badge {{ run.status_badge }} {% if not run.is_finished %}badge-dot{% endif %}">{{ run.status_label }}</span></td>
<td class="mono text-muted">{{ run.short_uuid }}</td>
<td style="white-space:nowrap">
<a class="btn btn-secondary btn-sm"
href="/check_for_file?filename={{ run.filename|urlencode }}&uuid={{ run.uuid|urlencode }}&platform={{ run.platform|urlencode }}">
{% if run.status == 'success' %}<svg class="icon"><use href="#i-download"/></svg> 下载产物{% elif run.is_finished %}查看详情{% else %}查看进度{% endif %}
</a>
{% if run.github_log_url %}
<a class="btn btn-secondary btn-sm" href="{{ run.github_log_url }}" target="_blank" rel="noopener">
<svg class="icon"><use href="#i-external"/></svg> 日志
</a>
{% endif %}
{% if run.is_finished and run.config_data %}
<form method="post" class="inline-form" action="{% url 'retry_build' run.id %}"
onsubmit="return confirm('将使用该任务的原始配置重新提交一次构建(原任务记录保留),是否继续?');">
{% csrf_token %}
<button type="submit" class="btn btn-secondary btn-sm">
<svg class="icon"><use href="#i-refresh"/></svg> 重试
</button>
</form>
{% endif %}
{% if run.is_finished %}
<form method="post" class="inline-form" action="{% url 'delete_my_build' run.id %}"
onsubmit="return confirm('确定删除任务「{{ run.filename|default:run.short_uuid|escapejs }}」吗?将同时删除服务器上的安装包文件,且不可恢复。');">
{% csrf_token %}
<button type="submit" class="btn btn-danger btn-sm">
<svg class="icon"><use href="#i-trash"/></svg> 删除
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="card">
<div class="empty-state">
<div class="empty-icon"><svg class="icon" style="width:42px;height:42px"><use href="#i-package"/></svg></div>
<h3>还没有构建任务</h3>
<p>填写一份客户端配置,即可开始生成你的专属 RustDesk 客户端。</p>
<a class="btn btn-primary" href="{% url 'generator' %}">立即定制客户端</a>
</div>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,43 @@
{% load static %}<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 · RustDesk 客户端定制平台</title>
<link rel="stylesheet" href="{% static 'rdgenerator/css/app.css' %}">
</head>
<body class="login-page">
<div class="login-card">
<div class="login-brand">
<span class="brand-logo">RD</span>
<div>
<h1>RustDesk 客户端定制平台</h1>
<p>登录后提交和管理自定义客户端构建任务</p>
</div>
</div>
{% if form.errors %}
<div class="alerts" style="margin-top:20px">
<div class="alert alert-error">用户名或密码不正确,请重新输入。</div>
</div>
{% endif %}
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<div class="field">
<label for="id_username">用户名</label>
<input type="text" name="username" id="id_username" class="form-control"
value="{{ form.username.value|default:'' }}" autofocus required
autocomplete="username" placeholder="请输入用户名">
</div>
<div class="field">
<label for="id_password">密码</label>
<input type="password" name="password" id="id_password" class="form-control"
required autocomplete="current-password" placeholder="请输入密码">
</div>
<input type="hidden" name="next" value="{{ next }}">
<button type="submit" class="btn btn-primary">登录</button>
</form>
</div>
</body>
</html>
+125
View File
@@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block title %}配置文件管理 · RustDesk 客户端定制平台{% endblock %}
{% block content %}
<nav class="breadcrumb">
<a href="{% url 'generator' %}">客户端定制</a>
<span class="breadcrumb-sep">/</span>
<span>配置文件管理</span>
</nav>
<div class="page-head">
<h1 class="page-title">
<svg class="icon"><use href="#i-save"/></svg>
构建配置文件
</h1>
<p class="page-subtitle">
在客户端定制页可把当前表单「另存为方案」,方案与你的账号绑定,下次直接选择即可创建构建任务;
也可以在此导出为 JSON 文件备份/迁移,或从 JSON 文件导入。
</p>
</div>
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-file-up"/></svg> 从 JSON 文件导入</div>
<div class="card-body">
<form method="post" action="{% url 'saved_configs' %}" enctype="multipart/form-data">
{% csrf_token %}
<div class="grid-2">
<div class="field">
<label for="id_name">方案名称</label>
<input type="text" name="name" id="id_name" maxlength="100" class="form-control"
placeholder="留空则使用文件名">
<div class="field-hint">同一账号下方案名称不可重复。</div>
</div>
<div class="field">
<label for="id_config_file">配置文件</label>
<input type="file" name="config_file" id="id_config_file"
accept=".json,application/json" hidden>
<div class="file-pick-row">
<label for="id_config_file" class="btn btn-secondary">
<svg class="icon"><use href="#i-file-up"/></svg> 选择文件
</label>
<span id="configFileName" class="file-pick-name">未选择任何文件</span>
</div>
<div class="field-hint">支持本平台导出的配置文件,也兼容 rdgen-cli 使用的 JSON 参数。</div>
</div>
</div>
<button type="submit" class="btn btn-primary">
<svg class="icon"><use href="#i-file-up"/></svg> 导入为配置方案
</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-list"/></svg> 我的配置方案({{ configs|length }})</div>
{% if configs %}
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>方案名称</th>
<th>目标平台</th>
<th>版本</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for cfg in configs %}
<tr>
<td><strong>{{ cfg.name }}</strong></td>
<td>{{ cfg.platform_label }}</td>
<td class="mono">{{ cfg.version_label }}</td>
<td class="text-muted" style="white-space:nowrap">{{ cfg.updated_at|date:"Y-m-d H:i" }}</td>
<td style="white-space:nowrap">
<a class="btn btn-secondary btn-sm" href="{% url 'generator' %}?config={{ cfg.id }}">
<svg class="icon"><use href="#i-zap"/></svg> 载入并构建
</a>
<a class="btn btn-secondary btn-sm" href="{% url 'export_config' cfg.id %}">
<svg class="icon"><use href="#i-file-down"/></svg> 导出
</a>
<form method="post" class="inline-form" action="{% url 'delete_config' cfg.id %}"
onsubmit="return confirm('确定删除配置方案「{{ cfg.name|escapejs }}」吗?不影响已提交的构建任务。');">
{% csrf_token %}
<button type="submit" class="btn btn-danger btn-sm">
<svg class="icon"><use href="#i-trash"/></svg> 删除
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon"><svg class="icon" style="width:42px;height:42px"><use href="#i-save"/></svg></div>
<h3>暂无保存的配置方案</h3>
<p>在「客户端定制」页填好配置后点击「另存为方案」,下次即可一键载入,无需重复填写。</p>
<a class="btn btn-primary" href="{% url 'generator' %}">
<svg class="icon"><use href="#i-zap"/></svg> 去定制页创建方案
</a>
</div>
{% endif %}
</div>
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-shield"/></svg> 说明</div>
<div class="card-body">
<ul class="config-check-list">
<li class="is-warn"><svg class="icon"><use href="#i-alert"/></svg> 方案内容包含「固定访问密码」等字段,仅自己可见,请勿把导出的 JSON 随意分享给他人。</li>
<li class="is-ok"><svg class="icon"><use href="#i-check-circle"/></svg> 应用图标 / Logo / 隐私屏图片会以 base64 内嵌在方案中,无需重新上传。</li>
<li class="is-ok"><svg class="icon"><use href="#i-check-circle"/></svg> 导出的 JSON 与定制页「导出配置为 JSON」格式一致,也可直接用于 rdgen-cli 命令行构建。</li>
</ul>
</div>
</div>
<script>
document.getElementById('id_config_file').addEventListener('change', function () {
document.getElementById('configFileName').textContent =
this.files.length ? this.files[0].name : '未选择任何文件';
});
</script>
{% endblock %}
+351
View File
@@ -0,0 +1,351 @@
{% extends "base.html" %}
{% block title %}构建平台配置 · RustDesk 客户端定制平台{% endblock %}
{% block content %}
<nav class="breadcrumb">
<a href="{% url 'dashboard' %}">后台管理</a>
<span class="breadcrumb-sep">/</span>
<span>构建平台配置</span>
</nav>
<div class="page-head">
<h1 class="page-title">
<svg class="icon"><use href="#i-sliders"/></svg>
构建平台配置
</h1>
<p class="page-subtitle">
选择实际执行构建任务的平台:GitHub Actions(云托管构建机)或自建 Gitea Actions(构建机自备、可全内网运行)。
支持在本页直接配置(优先级最高),也继续兼容 docker-compose 环境变量方式。
</p>
</div>
{# ===== 配置状态总览 ===== #}
<div class="card config-card">
<div class="card-header">
<svg class="icon"><use href="#i-shield"/></svg> 配置状态检查<span class="platform-tag" style="margin-left:8px">当前平台:{{ build_platform_label }}</span>
</div>
<div class="card-body">
{% if build_ready and not warnings %}
<div class="config-ok">
<svg class="icon"><use href="#i-check-circle"/></svg>
{{ build_platform_label }} 构建服务配置完整,可以正常提交客户端构建任务。
</div>
{% else %}
<ul class="config-check-list">
{% if build_ready %}
<li class="is-ok"><svg class="icon"><use href="#i-check-circle"/></svg> {{ build_platform_label }} 连接信息已配置</li>
{% else %}
<li class="is-bad"><svg class="icon"><use href="#i-alert"/></svg> {{ build_platform_label }} 连接信息缺失,暂无法触发构建</li>
{% endif %}
{% for w in warnings %}
<li class="is-warn"><svg class="icon"><use href="#i-alert"/></svg> {{ w }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{# ===== 配置表单 ===== #}
<form method="post" action="{% url 'github_settings' %}" class="settings-form">
{% csrf_token %}
{# --- 构建平台选择(自定义平台卡,单选) --- #}
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-sliders"/></svg> 构建平台</div>
<div class="card-body">
<p class="card-desc">切换后仅影响新提交的构建任务;历史任务仍保存在原平台并可继续查询状态与日志。</p>
<div class="build-platform-grid">
<label class="platform-card {% if build_platform != 'gitea' %}active{% endif %}" data-platform-card="github">
<input type="radio" name="build_platform" value="github" class="visually-hidden"
{% if build_platform != 'gitea' %}checked{% endif %}>
<svg class="icon brand-github"><use href="#b-github"/></svg>
<div class="platform-name">GitHub Actions</div>
<div class="platform-desc">官方云托管 Windows / macOS / Linux 构建机,零运维;国内网络可能需要代理。</div>
</label>
<label class="platform-card {% if build_platform == 'gitea' %}active{% endif %}" data-platform-card="gitea">
<input type="radio" name="build_platform" value="gitea" class="visually-hidden"
{% if build_platform == 'gitea' %}checked{% endif %}>
<svg class="icon"><use href="#b-gitea"/></svg>
<div class="platform-name">Gitea Actions(自建)</div>
<div class="platform-desc">API 与工作流语法兼容 GitHub,可全内网运行、回传快;需自备全部构建机。</div>
</label>
</div>
</div>
</div>
{# --- 各配置分组(platform 组已在上方自定义渲染) --- #}
{% for group_key, group_title, group_desc in groups %}{% if group_key != 'platform' %}
<div class="card backend-panel
{% if group_key == 'gitea' %}panel-gitea{% if build_platform != 'gitea' %} panel-hidden{% endif %}
{% elif group_key == 'basic' or group_key == 'network' %}panel-github{% if build_platform == 'gitea' %} panel-hidden{% endif %}{% endif %}">
<div class="card-header"><svg class="icon"><use href="#i-sliders"/></svg> {{ group_title }}</div>
<div class="card-body">
<p class="card-desc">{{ group_desc }}</p>
{% for row in rows %}{% if row.group == group_key %}
<div class="field setting-row{% if row.key == 'SH_SECRET' %} panel-github{% if build_platform == 'gitea' %} panel-hidden{% endif %}{% endif %}">
<label class="setting-label" for="{{ row.field.id_for_label }}">
{{ row.label }}
<span class="source-badge source-{{ row.source }}">{{ row.source_label }}</span>
</label>
{{ row.field }}
{% if row.sensitive %}
<div class="secret-meta">
<label class="clear-check" for="{{ row.clear_field.id_for_label }}">
{{ row.clear_field }} 清除后台配置,回退到环境变量/默认值
</label>
{% if row.source == 'db' %}
<span class="secret-state is-set">已在后台保存</span>
{% elif row.source == 'env' %}
<span class="secret-state is-env">已通过环境变量配置</span>
{% else %}
<span class="secret-state is-empty">未配置(使用默认值)</span>
{% endif %}
</div>
{% endif %}
{% if row.help %}<div class="field-hint">{{ row.help }}</div>{% endif %}
</div>
{% endif %}{% endfor %}
</div>
</div>
{% endif %}{% endfor %}
{# --- 保存与连接测试(始终可见,测试目标跟随当前所选平台) --- #}
<div class="card settings-submit-card">
<div class="card-body settings-actions">
<div class="btn-row">
<button type="submit" class="btn btn-primary">
<svg class="icon"><use href="#i-save"/></svg> 保存配置
</button>
<button type="button" class="btn btn-secondary" id="testBuildBtn">
<svg class="icon"><use href="#i-zap"/></svg> <span id="testBuildBtnLabel">测试 GitHub 连接</span>
</button>
</div>
<div id="testResult" class="test-result" hidden></div>
</div>
</div>
</form>
{# ===== GitHub 配置引导 ===== #}
<div class="card guide-card guide-panel guide-github {% if build_platform == 'gitea' %}panel-hidden{% endif %}">
<div class="card-header">
<svg class="icon brand-github"><use href="#b-github"/></svg> GitHub 首次配置引导(约 5 分钟)
</div>
<div class="card-body">
<ol class="guide-steps">
<li>
<div class="guide-title">Fork 构建仓库</div>
<p>
打开上游仓库 <a href="https://github.com/bryangerlach/rdgen" target="_blank" rel="noopener">github.com/bryangerlach/rdgen
<svg class="icon icon-inline"><use href="#i-external"/></svg></a>,
点击右上角 <strong>Fork</strong> 复制到你自己的 GitHub 账号下。
</p>
</li>
<li>
<div class="guide-title">启用 GitHub Actions</div>
<p>
进入 Fork 后的仓库页面,打开
<a href="https://github.com/{% if gh_user %}{{ gh_user }}{% else %}你的用户名{% endif %}/{% if gh_repo %}{{ gh_repo }}{% else %}rdgen{% endif %}/actions"
target="_blank" rel="noopener">Actions 页面
<svg class="icon icon-inline"><use href="#i-external"/></svg></a>,
如页面提示则点击绿色的 <strong>Enable Actions</strong> 按钮。
</p>
</li>
<li>
<div class="guide-title">创建 Fine-grained 访问令牌(PAT)</div>
<p>
打开
<a href="https://github.com/settings/personal-access-tokens/new" target="_blank" rel="noopener">
Generate new token <svg class="icon icon-inline"><use href="#i-external"/></svg></a>,
按如下设置后生成,并把令牌(github_pat_ 开头)填入上方「GitHub 访问令牌」:
</p>
<ul class="guide-sublist">
<li><strong>Repository access</strong>:选择 Only select repositories,勾选你 Fork 的仓库</li>
<li><strong>Repository permissions</strong>:Actions 设为 <strong>Read and write</strong>,Workflows 设为 <strong>Read and write</strong></li>
<li>Expiration 建议按需设置,过期后需要回到这里更新令牌</li>
</ul>
</li>
<li>
<div class="guide-title">添加两个仓库 Secret</div>
<p>
打开仓库
<a href="https://github.com/{% if gh_user %}{{ gh_user }}{% else %}你的用户名{% endif %}/{% if gh_repo %}{{ gh_repo }}{% else %}rdgen{% endif %}/settings/secrets/actions"
target="_blank" rel="noopener">Settings → Secrets and variables → Actions
<svg class="icon icon-inline"><use href="#i-external"/></svg></a>,
分别 New repository secret:
</p>
<ul class="guide-sublist">
<li><strong>GENURL</strong>:本平台的公网访问地址,须与上方「本平台外部访问地址」一致,例如 https://rdgen.example.com</li>
<li><strong>ZIP_PASSWORD</strong>:一个随机强密码(可用 <code>python3 -c 'import secrets; print(secrets.token_hex(100))'</code> 生成),
须与上方「配置压缩包密码」<strong>完全一致</strong></li>
</ul>
</li>
<li>
<div class="guide-title">保存并验证</div>
<p>填写上方表单后点击「保存配置」,再点击「测试 GitHub 连接」,看到「连接正常」提示即配置完成,可以去客户端定制页提交构建了。</p>
</li>
</ol>
</div>
</div>
{# ===== Gitea 配置引导 ===== #}
<div class="card guide-card guide-panel guide-gitea {% if build_platform != 'gitea' %}panel-hidden{% endif %}">
<div class="card-header">
<svg class="icon"><use href="#b-gitea"/></svg> Gitea 首次配置引导(需自备 Gitea 与构建机)
</div>
<div class="card-body">
<ol class="guide-steps">
<li>
<div class="guide-title">部署 Gitea 1.27+ 并准备构建仓库</div>
<p>
自建一个 Gitea 实例(建议 1.27 或更高版本,低版本不支持运行详情回传),
把本项目仓库推送到该实例(仓库内含 <code>.gitea/workflows</code> 适配工作流)。
<a href="https://docs.gitea.com/usage/actions/quickstart" target="_blank" rel="noopener">
官方 Quick Start <svg class="icon icon-inline"><use href="#i-external"/></svg></a>
</p>
</li>
<li>
<div class="guide-title">启用仓库 Actions</div>
<p>
进入
{% if gitea_server and gitea_owner %}
<a href="{{ gitea_server }}/{{ gitea_owner }}/{{ gitea_repo|default:'rdgen' }}/settings" target="_blank" rel="noopener">
仓库设置页 <svg class="icon icon-inline"><use href="#i-external"/></svg></a>
{% else %}仓库设置页{% endif %}
,勾选 <strong>Enable Repository Actions</strong>。
</p>
</li>
<li>
<div class="guide-title">注册 act_runner 构建机</div>
<p>在仓库 Settings → Actions → Runners 创建注册令牌,并在构建机上安装注册 act_runner:</p>
<ul class="guide-sublist">
<li><strong>Linux</strong>:可用 Docker 模式(需预装 QEMU 以构建 arm64);</li>
<li><strong>Windows</strong>:只能 host 模式直接跑在 Windows 机器上(MSVC/Flutter/vcpkg 环境);</li>
<li><strong>macOS</strong>:只能 host 模式跑在 Apple 硬件上;</li>
<li>runner 自定义标签需与工作流一致:<code>ubuntu-22.04</code>、<code>ubuntu-22.04-arm</code>、<code>windows-2022</code>、<code>macos-14</code>、<code>macos-15-intel</code>。</li>
</ul>
</li>
<li>
<div class="guide-title">配置仓库 Secrets</div>
<p>
在仓库 Settings → Actions → Secrets 中添加
<strong>GENURL</strong>(本平台地址)与 <strong>ZIP_PASSWORD</strong>(与上方完全一致);
如需 Android/macOS 签名,再添加 ANDROID_SIGNING_KEY、MACOS_P12_BASE64、SIGN_BASE_URL、SIGN_API_KEY。
</p>
</li>
<li>
<div class="guide-title">第三方 actions 的网络说明</div>
<p>
工作流使用的第三方 action 默认从 github.com 拉取;纯内网环境请为 runner 配置出网白名单,
或在 Gitea 的 <code>app.ini</code> 配置 <code>[actions].DEFAULT_ACTIONS_URL</code> 走自建 action 镜像。
完整部署步骤(含 vcpkg 文件缓存配置)见项目 setup.md 的「Gitea 部署附录」。
</p>
</li>
<li>
<div class="guide-title">保存并验证</div>
<p>填写上方 Gitea 配置后点击「保存配置」,再点击「测试 Gitea 连接」,看到「连接正常」提示即可提交构建。</p>
</li>
</ol>
</div>
</div>
{# ===== 环境变量方式说明 ===== #}
<div class="card">
<div class="card-header">
<svg class="icon"><use href="#i-server"/></svg> 环境变量方式(可选,继续支持)
</div>
<div class="card-body">
<p class="card-desc">
也可以在 <code>docker-compose.yml</code> 的 environment 中配置以下变量,容器重启后自动生效;
本页保存的同名配置优先级更高(值与环境变量相同时会自动回退,不产生重复覆盖)。
</p>
<div class="table-wrap">
<table class="table">
<thead><tr><th>环境变量</th><th>对应配置</th><th>说明</th></tr></thead>
<tbody>
<tr><td class="mono">BUILD_PLATFORM</td><td>构建平台</td><td>github(默认)或 gitea</td></tr>
<tr><td class="mono">GHUSER</td><td>GitHub 用户名</td><td>Fork 仓库所在账号</td></tr>
<tr><td class="mono">GHBEARER</td><td>GitHub 访问令牌</td><td>Fine-grained PAT,Actions/Workflows 读写</td></tr>
<tr><td class="mono">GENURL</td><td>平台外部访问地址</td><td>两平台共用,供构建机回传产物,公网/内网可访问</td></tr>
<tr><td class="mono">ZIP_PASSWORD</td><td>配置压缩包密码</td><td>两平台共用,须与仓库 Secret 一致</td></tr>
<tr><td class="mono">REPONAME</td><td>GitHub 仓库名称</td><td>默认 rdgen</td></tr>
<tr><td class="mono">GHBRANCH</td><td>GitHub 工作流分支</td><td>默认 master</td></tr>
<tr><td class="mono">PROTOCOL</td><td>平台访问协议</td><td>https(默认)或 http</td></tr>
<tr><td class="mono">SH_SECRET</td><td>GitHub 自托管 Runner 密钥</td><td>仅 GitHub 自托管构建时使用</td></tr>
<tr><td class="mono">WEBHOOK_SECRET</td><td>回调校验密钥</td><td>两平台共用,留空不校验</td></tr>
<tr><td class="mono">GH_PROXY</td><td>GitHub 访问代理</td><td>如 http://host:port,留空直连</td></tr>
<tr><td class="mono">GITEA_SERVER_URL</td><td>Gitea 服务器地址</td><td>如 https://gitea.example.com</td></tr>
<tr><td class="mono">GITEA_TOKEN</td><td>Gitea 访问令牌</td><td>需仓库 Actions 读写权限</td></tr>
<tr><td class="mono">GITEA_OWNER</td><td>Gitea 仓库属主</td><td>用户名或组织名</td></tr>
<tr><td class="mono">GITEA_REPO</td><td>Gitea 仓库名称</td><td>默认 rdgen</td></tr>
<tr><td class="mono">GITEA_BRANCH</td><td>Gitea 工作流分支</td><td>默认 master</td></tr>
<tr><td class="mono">GITEA_PROXY</td><td>Gitea 访问代理</td><td>内网直连通常留空</td></tr>
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block head_extra %}
<script>
document.addEventListener('DOMContentLoaded', function () {
var radios = document.querySelectorAll('input[name=build_platform]');
var btn = document.getElementById('testBuildBtn');
var btnLabel = document.getElementById('testBuildBtnLabel');
var box = document.getElementById('testResult');
function currentPlatform() {
var checked = document.querySelector('input[name=build_platform]:checked');
return checked ? checked.value : 'github';
}
function syncPanels() {
var platform = currentPlatform();
document.querySelectorAll('.panel-gitea, .guide-gitea').forEach(function (el) {
el.classList.toggle('panel-hidden', platform !== 'gitea');
});
document.querySelectorAll('.panel-github, .guide-github').forEach(function (el) {
el.classList.toggle('panel-hidden', platform !== 'github');
});
document.querySelectorAll('[data-platform-card]').forEach(function (card) {
card.classList.toggle('active', card.getAttribute('data-platform-card') === platform);
});
btnLabel.textContent = platform === 'gitea' ? '测试 Gitea 连接' : '测试 GitHub 连接';
}
radios.forEach(function (radio) { radio.addEventListener('change', syncPanels); });
syncPanels();
btn.addEventListener('click', function () {
var platform = currentPlatform();
btn.disabled = true;
box.hidden = false;
box.className = 'test-result is-loading';
box.textContent = platform === 'gitea' ? '正在调用 Gitea API 验证配置…' : '正在调用 GitHub API 验证配置…';
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
fetch('{% url "test_build" %}', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
},
body: 'platform=' + encodeURIComponent(platform),
})
.then(function (r) { return r.json().then(function (j) { return {ok: r.ok && j.ok, message: j.message}; }); })
.then(function (res) {
box.className = 'test-result ' + (res.ok ? 'is-ok' : 'is-bad');
box.textContent = res.message;
})
.catch(function () {
box.className = 'test-result is-bad';
box.textContent = '测试请求失败,请检查网络或服务日志。';
})
.finally(function () {
btn.disabled = false;
});
});
});
</script>
{% endblock %}
+111
View File
@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}接口令牌 · RustDesk 客户端定制平台{% endblock %}
{% block page_class %}page-narrow{% endblock %}
{% block content %}
<div class="page-head">
<h1 class="page-title">接口令牌(API Token)</h1>
<p class="page-subtitle">
通过 JSON API 或 rdgen-cli 命令行提交构建任务时,请在请求头携带令牌:
<code>Authorization: Token &lt;你的令牌&gt;</code>。请妥善保管,不要泄露给他人。
</p>
</div>
{% if new_plain_key %}
<div class="alerts">
<div class="alert alert-success">
<strong>新令牌已生成,请立即复制保存:</strong>
<div class="key-box" style="margin-top:10px">
<span id="newKey">{{ new_plain_key }}</span>
<button type="button" class="btn btn-secondary btn-sm" onclick="copyKey()">
<svg class="icon"><use href="#i-copy"/></svg> 复制
</button>
</div>
<div style="margin-top:8px">为保障安全,离开本页后将无法再次查看完整令牌。</div>
</div>
</div>
{% endif %}
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-plus"/></svg> 新建令牌</div>
<div class="card-body">
<form method="post" action="{% url 'api_tokens' %}">
{% csrf_token %}
<div class="field" style="margin-bottom:0">
<label for="{{ form.name.id_for_label }}">{{ form.name.label }}</label>
<div class="input-row">
{{ form.name }}
<button type="submit" class="btn btn-primary">生成令牌</button>
</div>
{% if form.name.help_text %}<div class="field-hint">{{ form.name.help_text }}</div>{% endif %}
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-key"/></svg> 我的令牌</div>
{% if tokens %}
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>名称</th>
<th>令牌(仅显示前缀)</th>
<th>状态</th>
<th>创建时间</th>
<th>最后使用</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for token in tokens %}
<tr>
<td><strong>{{ token.name }}</strong></td>
<td class="mono text-muted">{{ token.key|slice:":12" }}…</td>
<td>
{% if token.is_active %}
<span class="badge badge-success">启用中</span>
{% else %}
<span class="badge badge-secondary">已停用</span>
{% endif %}
</td>
<td class="text-muted" style="white-space:nowrap">{{ token.created_at|date:"Y-m-d H:i" }}</td>
<td class="text-muted" style="white-space:nowrap">
{% if token.last_used_at %}{{ token.last_used_at|date:"Y-m-d H:i" }}{% else %}从未使用{% endif %}
</td>
<td style="white-space:nowrap">
<form method="post" class="inline-form" action="{% url 'delete_token' token.key %}"
onsubmit="return confirm('确定删除令牌「{{ token.name|escapejs }}」吗?删除后使用该令牌的程序将立即无法调用接口。');">
{% csrf_token %}
<button type="submit" class="btn btn-danger btn-sm">
<svg class="icon"><use href="#i-trash"/></svg> 删除
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon"><svg class="icon" style="width:42px;height:42px"><use href="#i-key"/></svg></div>
<h3>暂无接口令牌</h3>
<p>当你需要通过命令行或 API 自动提交构建时,在此创建一个令牌。</p>
</div>
{% endif %}
</div>
{% endblock %}
{% block head_extra %}
<script>
function copyKey() {
var text = document.getElementById('newKey').textContent.trim();
navigator.clipboard.writeText(text).then(function () {
alert('令牌已复制到剪贴板');
});
}
</script>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}编辑用户 · 后台管理{% endblock %}
{% block page_class %}page-narrow{% endblock %}
{% block content %}
<div class="breadcrumb">
<a href="{% url 'dashboard' %}">后台管理</a><span class="sep">/</span>
<a href="{% url 'user_list' %}">用户管理</a><span class="sep">/</span>编辑用户
</div>
<div class="page-head">
<h1 class="page-title">编辑用户</h1>
<p class="page-subtitle">正在编辑用户:<strong>{{ target.username }}</strong></p>
</div>
<div class="card">
<div class="card-body">
<form method="post" action="{% url 'user_edit' target.id %}">
{% csrf_token %}
<div class="field">
<label for="{{ form.email.id_for_label }}">{{ form.email.label }}</label>
{{ form.email }}
</div>
<label class="check-item" style="margin:12px 0">
{{ form.is_active }} <span>账号启用(取消勾选后该用户将无法登录和提交构建)</span>
</label>
<label class="check-item" style="margin:12px 0 20px">
{{ form.is_staff }} <span>管理员权限(可访问后台管理、查看所有用户的构建任务)</span>
</label>
<div class="field">
<label for="{{ form.new_password.id_for_label }}">{{ form.new_password.label }}</label>
{{ form.new_password }}
{{ form.new_password.errors }}
</div>
<div class="btn-row" style="margin-top:22px">
<button type="submit" class="btn btn-primary">保存修改</button>
<a href="{% url 'user_list' %}" class="btn btn-secondary">
<svg class="icon"><use href="#i-arrow-left"/></svg> 返回列表
</a>
</div>
</form>
</div>
</div>
{% endblock %}
+97
View File
@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}用户管理 · 后台管理{% endblock %}
{% block content %}
<div class="breadcrumb">
<a href="{% url 'dashboard' %}">后台管理</a><span class="sep">/</span>用户管理
</div>
<div class="page-head">
<h1 class="page-title">用户管理</h1>
<p class="page-subtitle">平台不开放公开注册,账号由管理员在此统一创建和管理。</p>
</div>
<div class="card form-narrow">
<div class="card-header"><svg class="icon"><use href="#i-user"/></svg> 新建用户</div>
<div class="card-body">
<form method="post" action="{% url 'user_list' %}">
{% csrf_token %}
<div class="field">
<label for="{{ form.username.id_for_label }}">{{ form.username.label }}</label>
{{ form.username }}
{{ form.username.errors }}
</div>
<div class="field">
<label for="{{ form.email.id_for_label }}">{{ form.email.label }}</label>
{{ form.email }}
</div>
<div class="grid-2">
<div class="field">
<label for="{{ form.password.id_for_label }}">{{ form.password.label }}</label>
{{ form.password }}
{{ form.password.errors }}
{% if form.password.help_text %}<div class="field-hint">{{ form.password.help_text }}</div>{% endif %}
</div>
<div class="field">
<label for="{{ form.password_confirm.id_for_label }}">{{ form.password_confirm.label }}</label>
{{ form.password_confirm }}
{{ form.password_confirm.errors }}
</div>
</div>
<label class="check-item" style="margin:4px 0 18px">
{{ form.is_staff }} <span>设为管理员(可访问后台管理、查看所有任务)</span>
</label>
<button type="submit" class="btn btn-primary btn-block">创建用户</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><svg class="icon"><use href="#i-users"/></svg> 用户列表({{ users|length }})</div>
{% if users %}
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>用户名</th>
<th>角色</th>
<th>状态</th>
<th>构建数</th>
<th>注册时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>
<strong>{{ u.username }}</strong>
{% if u.email %}<div class="text-muted" style="font-size:12px;margin-top:2px">{{ u.email }}</div>{% endif %}
</td>
<td>
{% if u.is_superuser %}<span class="badge badge-danger">超级管理员</span>
{% elif u.is_staff %}<span class="badge badge-info">管理员</span>
{% else %}<span class="badge badge-secondary">普通用户</span>{% endif %}
</td>
<td>
{% if u.is_active %}<span class="badge badge-success">启用</span>
{% else %}<span class="badge badge-secondary">已停用</span>{% endif %}
</td>
<td>{{ u.build_count }}</td>
<td class="text-muted" style="white-space:nowrap">{{ u.date_joined|date:"Y-m-d" }}</td>
<td>
<a class="btn btn-secondary btn-sm" href="{% url 'user_edit' u.id %}">编辑</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon"><svg class="icon" style="width:42px;height:42px"><use href="#i-user"/></svg></div>
<h3>暂无用户</h3>
</div>
{% endif %}
</div>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}构建进行中 · RustDesk 客户端定制平台{% endblock %}
{% block page_class %}page-narrow{% endblock %}
{% block content %}
<div class="card">
<div class="card-body result-wrap">
<div class="spinner"></div>
<h2 class="result-title">正在构建自定义客户端</h2>
<p class="result-desc">
构建通常需要 <strong>30~45 分钟</strong>,本页每 30 秒自动刷新一次状态。<br>
你也可以关闭本页,稍后到「我的构建」中查看结果。
</p>
<div class="progress-bar"><div class="progress-bar-fill"></div></div>
<p style="margin:0 0 6px">
当前状态:
<span class="badge {{ status_badge }} badge-dot" id="statusBadge">{{ status_label }}</span>
<span id="stageText" class="text-muted" style="font-size:12.5px;margin-left:8px"></span>
</p>
<div class="info-box" style="margin-top:24px">
<div class="info-box-title"><svg class="icon"><use href="#i-package"/></svg> 任务信息</div>
<div>配置名称:<strong>{{ filename }}</strong></div>
<div>目标平台:<strong id="platformText">{{ platform }}</strong></div>
{% if log_url %}
<div style="margin-top:8px">
<a href="{{ log_url }}" target="_blank" rel="noopener">
<svg class="icon"><use href="#i-external"/></svg> 技术排查:查看 GitHub Actions 构建日志
</a>
</div>
{% endif %}
</div>
<div class="btn-row" style="justify-content:center">
<a class="btn btn-secondary btn-sm" href="{% url 'my_builds' %}">查看我的构建列表</a>
<a class="btn btn-secondary btn-sm" href="{% url 'generator' %}">继续新建任务</a>
</div>
</div>
</div>
{% endblock %}
{% block head_extra %}
<script>
(function () {
var platformLabels = {
'windows': 'Windows 64 位',
'windows-x86': 'Windows 32 位',
'linux': 'Linux',
'android': 'Android',
'macos': 'macOS'
};
var platform = ('' + '{{ platform|escapejs }}').toLowerCase();
document.getElementById('platformText').textContent = platformLabels[platform] || platform;
document.title = '正在构建客户端';
// 状态阶段提示(仅用于本地动画展示,实际状态以服务端轮询为准)
var stages = ['正在初始化构建环境…', '正在拉取并编译源码…', '正在生成平台安装包…', '正在打包与上传产物…'];
var i = 0;
var stageEl = document.getElementById('stageText');
if (stageEl) {
stageEl.textContent = stages[0];
setInterval(function () {
i = (i + 1) % stages.length;
stageEl.textContent = stages[i];
}, 8000);
}
// 30 秒后整页轮询一次服务端状态
setTimeout(function () {
window.location.replace('/check_for_file?filename={{ filename|urlencode }}&uuid={{ uuid|urlencode }}&platform={{ platform|urlencode }}');
}, 30000);
})();
</script>
{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+85
View File
@@ -0,0 +1,85 @@
"""
RustDesk 可选版本列表。
优先从 GitHub 官方仓库 rustdesk/rustdesk 的 tags 动态获取(跟随官方发版自动更新),
进程内缓存 6 小时;网络失败时回退内置版本列表,且失败结果短缓存 10 分钟,
避免每次打开页面都等待 GitHub 超时。所有请求经由 ghnet,自动使用代理配置。
"""
import re
import time
from . import ghnet
TAGS_URL = 'https://api.github.com/repos/rustdesk/rustdesk/tags?per_page=100'
# 离线兜底(截至本版本发布时的近期正式版)
FALLBACK_VERSIONS = [
'1.4.9', '1.4.8', '1.4.7', '1.4.6', '1.4.5',
'1.4.4', '1.4.3', '1.4.2', '1.4.1', '1.4.0',
]
# 形如 1.4.9 / 1.4.10 / 1.5.0-rc.1 的正式 tag
TAG_RE = re.compile(r'^\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.]+)?$')
# 表单/API 对用户提交版本号的宽松校验,兼容动态列表刷新窗口期
VERSION_RE = re.compile(r'^(?:master|main|\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.]+)?)$')
MAX_VERSIONS = 20
CACHE_TTL = 6 * 3600 # 成功结果缓存 6 小时
FALLBACK_TTL = 10 * 60 # 失败回退缓存 10 分钟
_memo = {
'ts': 0.0,
'ttl': CACHE_TTL,
'versions': FALLBACK_VERSIONS,
'source': 'builtin',
}
def _sort_versions(versions):
"""按版本号数字段降序排列,预发布标识排在同正式版之后。"""
def key(v):
main, _, suffix = v.partition('-')
parts = [int(x) if x.isdigit() else 0 for x in main.split('.')]
parts += [0] * (3 - len(parts))
# 无后缀(正式版)排在 rc/beta 之前
return (parts, 1 if not suffix else 0, suffix)
return sorted(versions, key=key, reverse=True)
def _fetch_from_github():
resp = ghnet.get(TAGS_URL, timeout=8)
resp.raise_for_status()
tags = [item['name'].lstrip('v') for item in resp.json() if item.get('name')]
versions = _sort_versions({t for t in tags if TAG_RE.match(t)})
return versions[:MAX_VERSIONS]
def get_versions(force=False):
"""返回 (版本号列表, 来源 source, 缓存时间戳);source: github / builtin。"""
now = time.time()
if not force and now - _memo['ts'] < _memo['ttl']:
return _memo['versions'], _memo['source'], _memo['ts']
try:
versions = _fetch_from_github()
if versions:
_memo.update(ts=now, ttl=CACHE_TTL, versions=versions, source='github')
else:
_memo.update(ts=now, ttl=FALLBACK_TTL, versions=FALLBACK_VERSIONS, source='builtin')
except Exception:
_memo.update(ts=now, ttl=FALLBACK_TTL, versions=FALLBACK_VERSIONS, source='builtin')
return _memo['versions'], _memo['source'], _memo['ts']
def version_choices():
"""供 ChoiceField 使用的 (value, label) 列表。"""
versions, source, _ = get_versions()
label_suffix = '' if source == 'github' else '(内置列表,获取官方列表失败)'
choices = [('master', '开发版(nightly 每夜构建)')]
choices += [(v, v) for v in versions]
return choices
def is_valid_version(value):
"""API / 表单提交的版本号合法性校验。"""
return bool(value) and bool(VERSION_RE.match(str(value)))
+695
View File
@@ -0,0 +1,695 @@
import io
import json
import os
import re
import uuid
from pathlib import Path
from urllib.parse import quote
import base64
import pyzipper
from PIL import Image
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.core.files.base import ContentFile
from django.db.models import Q
from django.http import (
HttpResponse,
JsonResponse,
HttpResponseForbidden,
Http404,
)
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from . import app_settings, build_backends, versions as rdeskVersions
from .forms import GenerateForm
from .models import GithubRun, STATUS_LABELS, STATUS_BADGE
def _webhook_denied(request):
"""配置了 WEBHOOK_SECRET 时,校验 GitHub Actions 回调请求头;未配置则放行(兼容旧工作流)。"""
secret = app_settings.get_value('WEBHOOK_SECRET')
if secret and request.headers.get('X-Webhook-Secret') != secret:
return HttpResponseForbidden('无效的 Webhook 密钥')
return None
def _status_extra(status_code):
return (
STATUS_LABELS.get(status_code, status_code),
STATUS_BADGE.get(status_code, 'badge-secondary'),
)
def _can_access_run(request, run):
"""任务提交者本人或管理员可访问;历史任务无归属人时登录用户均可访问。"""
if request.user.is_staff:
return True
return not run.created_by_id or run.created_by_id == request.user.id
def generate_custom_client(params, full_url, user=None):
"""
网页表单与 JSON API 共用的核心生成逻辑。
Args:
params: 包含全部配置项的字典(键名与 GenerateForm 字段一致)
full_url: 本服务的完整 URL(协议 + 主机名)
user: 提交任务的登录用户(可为 None,如匿名 API 调用)
Returns:
成功时包含 success/uuid/filename/platform/log_url;
失败时包含 success=False、error(中文提示)及 status_code。
"""
user_secret = params.get('sh_secret_field', '')
selfhosted = (app_settings.get_value('SH_SECRET') == user_secret)
platform = params.get('platform', 'windows')
version = params.get('version', '1.4.9')
delayFix = params.get('delayFix', True)
xOffline = params.get('xOffline', False)
hidecm = params.get('hidecm', False)
removeNewVersionNotif = params.get('removeNewVersionNotif', False)
server = params.get('serverIP', '')
serverPort = params.get('serverPort', '')
key = params.get('key', '')
apiServer = params.get('apiServer', '')
urlLink = params.get('urlLink', '')
downloadLink = params.get('downloadLink', '')
if not server:
server = 'rs-ny.rustdesk.com' # RustDesk 默认服务器
if not serverPort:
serverPort = '21116' # RustDesk 默认会合端口
if not key:
key = 'OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw=' # RustDesk 默认公钥
if not apiServer:
apiServer = server+":21114"
if not urlLink:
urlLink = "https://rustdesk.com"
if not downloadLink:
downloadLink = "https://rustdesk.com/download"
direction = params.get('direction', 'both')
installation = params.get('installation', 'installationY')
settings = params.get('settings', 'settingsY')
appname = params.get('appname', '')
if not appname:
appname = "rustdesk"
filename = params.get('exename', 'rustdesk')
compname = params.get('compname', '')
if not compname:
compname = "Purslane Ltd"
androidappid = params.get('androidappid', '')
if not androidappid:
androidappid = "com.carriez.flutter_hbb"
compname = compname.replace("&","\\&")
permPass = params.get('permanentPassword', '')
theme = params.get('theme', 'system')
themeDorO = params.get('themeDorO', 'default')
passApproveMode = params.get('passApproveMode', 'password-click')
denyLan = params.get('denyLan', False)
enableDirectIP = params.get('enableDirectIP', False)
autoClose = params.get('autoClose', False)
permissionsDorO = params.get('permissionsDorO', 'default')
permissionsType = params.get('permissionsType', 'custom')
enableKeyboard = params.get('enableKeyboard', True)
enableClipboard = params.get('enableClipboard', True)
enableFileTransfer = params.get('enableFileTransfer', True)
enableAudio = params.get('enableAudio', True)
enableTCP = params.get('enableTCP', True)
enableRemoteRestart = params.get('enableRemoteRestart', True)
enableRecording = params.get('enableRecording', True)
enableBlockingInput = params.get('enableBlockingInput', True)
enableRemoteModi = params.get('enableRemoteModi', False)
removeWallpaper = params.get('removeWallpaper', True)
defaultManual = params.get('defaultManual', '')
overrideManual = params.get('overrideManual', '')
enablePrinter = params.get('enablePrinter', True)
enableCamera = params.get('enableCamera', True)
enableTerminal = params.get('enableTerminal', True)
if all(char.isascii() for char in filename):
filename = re.sub(r'[^\w\s-]', '_', filename).strip()
filename = filename.replace(" ","_")
else:
filename = "rustdesk"
if not all(char.isascii() for char in appname):
appname = "rustdesk"
myuuid = str(uuid.uuid4())
try:
iconfile = params.get('iconfile')
if not iconfile:
iconfile = params.get('iconbase64')
iconlink_url, iconlink_uuid, iconlink_file = save_png(iconfile,myuuid,full_url,"icon.png")
except Exception:
print("获取图标失败,使用默认图标")
iconlink_url = "false"
iconlink_uuid = "false"
iconlink_file = "false"
try:
logofile = params.get('logofile')
if not logofile:
logofile = params.get('logobase64')
logolink_url, logolink_uuid, logolink_file = save_png(logofile,myuuid,full_url,"logo.png")
except Exception:
print("获取 Logo 失败")
logolink_url = "false"
logolink_uuid = "false"
logolink_file = "false"
try:
privacyfile = params.get('privacyfile')
if not privacyfile:
privacyfile = params.get('privacybase64')
privacylink_url, privacylink_uuid, privacylink_file = save_png(privacyfile,myuuid,full_url,"privacy.png")
except Exception:
print("获取隐私屏图片失败")
privacylink_url = "false"
privacylink_uuid = "false"
privacylink_file = "false"
### 生成 custom.txt 配置 JSON,作为工作流输入
decodedCustom = {}
if direction != "Both":
decodedCustom['conn-type'] = direction
if installation == "installationN":
decodedCustom['disable-installation'] = 'Y'
if settings == "settingsN":
decodedCustom['disable-settings'] = 'Y'
if appname.lower() != "rustdesk" and appname != "":
decodedCustom['app-name'] = appname
decodedCustom['override-settings'] = {}
decodedCustom['default-settings'] = {}
if permPass != "":
decodedCustom['password'] = permPass
if theme != "system":
if themeDorO == "default":
if platform == "windows-x86":
decodedCustom['default-settings']['allow-darktheme'] = 'Y' if theme == "dark" else 'N'
else:
decodedCustom['default-settings']['theme'] = theme
elif themeDorO == "override":
if platform == "windows-x86":
decodedCustom['override-settings']['allow-darktheme'] = 'Y' if theme == "dark" else 'N'
else:
decodedCustom['override-settings']['theme'] = theme
decodedCustom['enable-lan-discovery'] = 'N' if denyLan else 'Y'
#decodedCustom['direct-server'] = 'Y' if enableDirectIP else 'N'
decodedCustom['allow-auto-disconnect'] = 'Y' if autoClose else 'N'
if permissionsDorO == "default":
decodedCustom['default-settings']['access-mode'] = permissionsType
decodedCustom['default-settings']['enable-keyboard'] = 'Y' if enableKeyboard else 'N'
decodedCustom['default-settings']['enable-clipboard'] = 'Y' if enableClipboard else 'N'
decodedCustom['default-settings']['enable-file-transfer'] = 'Y' if enableFileTransfer else 'N'
decodedCustom['default-settings']['enable-audio'] = 'Y' if enableAudio else 'N'
decodedCustom['default-settings']['enable-tunnel'] = 'Y' if enableTCP else 'N'
decodedCustom['default-settings']['enable-remote-restart'] = 'Y' if enableRemoteRestart else 'N'
decodedCustom['default-settings']['enable-record-session'] = 'Y' if enableRecording else 'N'
decodedCustom['default-settings']['enable-block-input'] = 'Y' if enableBlockingInput else 'N'
decodedCustom['default-settings']['allow-remote-config-modification'] = 'Y' if enableRemoteModi else 'N'
decodedCustom['default-settings']['direct-server'] = 'Y' if enableDirectIP else 'N'
decodedCustom['default-settings']['verification-method'] = 'use-permanent-password' if hidecm else 'use-both-passwords'
decodedCustom['default-settings']['approve-mode'] = passApproveMode
decodedCustom['default-settings']['allow-hide-cm'] = 'Y' if hidecm else 'N'
decodedCustom['default-settings']['allow-remove-wallpaper'] = 'Y' if removeWallpaper else 'N'
decodedCustom['default-settings']['enable-remote-printer'] = 'Y' if enablePrinter else 'N'
decodedCustom['default-settings']['enable-camera'] = 'Y' if enableCamera else 'N'
decodedCustom['default-settings']['enable-terminal'] = 'Y' if enableTerminal else 'N'
else:
decodedCustom['override-settings']['access-mode'] = permissionsType
decodedCustom['override-settings']['enable-keyboard'] = 'Y' if enableKeyboard else 'N'
decodedCustom['override-settings']['enable-clipboard'] = 'Y' if enableClipboard else 'N'
decodedCustom['override-settings']['enable-file-transfer'] = 'Y' if enableFileTransfer else 'N'
decodedCustom['override-settings']['enable-audio'] = 'Y' if enableAudio else 'N'
decodedCustom['override-settings']['enable-tunnel'] = 'Y' if enableTCP else 'N'
decodedCustom['override-settings']['enable-remote-restart'] = 'Y' if enableRemoteRestart else 'N'
decodedCustom['override-settings']['enable-record-session'] = 'Y' if enableRecording else 'N'
decodedCustom['override-settings']['enable-block-input'] = 'Y' if enableBlockingInput else 'N'
decodedCustom['override-settings']['allow-remote-config-modification'] = 'Y' if enableRemoteModi else 'N'
decodedCustom['override-settings']['direct-server'] = 'Y' if enableDirectIP else 'N'
decodedCustom['override-settings']['verification-method'] = 'use-permanent-password' if hidecm else 'use-both-passwords'
decodedCustom['override-settings']['approve-mode'] = passApproveMode
decodedCustom['override-settings']['allow-hide-cm'] = 'Y' if hidecm else 'N'
decodedCustom['override-settings']['allow-remove-wallpaper'] = 'Y' if removeWallpaper else 'N'
decodedCustom['override-settings']['enable-remote-printer'] = 'Y' if enablePrinter else 'N'
decodedCustom['override-settings']['enable-camera'] = 'Y' if enableCamera else 'N'
decodedCustom['override-settings']['enable-terminal'] = 'Y' if enableTerminal else 'N'
if direction == 'incoming':
decodedCustom['override-settings']['custom-rendezvous-server'] = server
decodedCustom['override-settings']['api-server'] = apiServer
if defaultManual:
for line in defaultManual.splitlines():
if '=' in line:
k, value = line.split('=', 1)
decodedCustom['default-settings'][k.strip()] = value.strip()
if overrideManual:
for line in overrideManual.splitlines():
if '=' in line:
k, value = line.split('=', 1)
decodedCustom['override-settings'][k.strip()] = value.strip()
decodedCustomJson = json.dumps(decodedCustom)
string_bytes = decodedCustomJson.encode("ascii")
base64_bytes = base64.b64encode(string_bytes)
encodedCustom = base64_bytes.decode("ascii")
#### 触发构建平台工作流(GitHub / Gitea 可在后台「构建平台配置」切换)
backend = build_backends.get_backend()
workflow_file = backend.workflow_file_for(platform, selfhosted=bool(selfhosted))
inputs_raw = {
"server":server,
"serverPort":serverPort,
"key":key,
"apiServer":apiServer,
"custom":encodedCustom,
"uuid":myuuid,
"iconlink_url":iconlink_url,
"iconlink_uuid":iconlink_uuid,
"iconlink_file":iconlink_file,
"logolink_url":logolink_url,
"logolink_uuid":logolink_uuid,
"logolink_file":logolink_file,
"privacylink_url":privacylink_url,
"privacylink_uuid":privacylink_uuid,
"privacylink_file":privacylink_file,
"appname":appname,
"genurl":app_settings.get_value('GENURL'),
"urlLink":urlLink,
"downloadLink":downloadLink,
"delayFix": 'true' if delayFix else 'false',
"rdgen":'true',
"xOffline": 'true' if xOffline else 'false',
"removeNewVersionNotif": 'true' if removeNewVersionNotif else 'false',
"compname": compname,
"androidappid":androidappid,
"filename":filename
}
# ZIP_PASSWORD 为空时 pyzipper 的 AES 加密会直接抛 RuntimeError(500),
# 这里前置校验,返回可操作的中文提示(网页与 API 共用此函数)。
zip_password = app_settings.get_value('ZIP_PASSWORD')
if not zip_password.strip():
return {
"success": False,
"error": (
"配置压缩包密码(ZIP_PASSWORD)未配置,无法加密构建配置包。"
"请联系管理员在后台「GitHub 构建配置 - 回调与加密」中设置,"
"并确保与 GitHub 仓库 Secret「ZIP_PASSWORD」完全一致。"
),
"status_code": 500,
}
temp_json_path = f"data_{uuid.uuid4()}.json"
zip_filename = f"secrets_{uuid.uuid4()}.zip"
zip_path = "temp_zips/%s" % (zip_filename)
Path("temp_zips").mkdir(parents=True, exist_ok=True)
with open(temp_json_path, "w") as f:
json.dump(inputs_raw, f)
with pyzipper.AESZipFile(zip_path, 'w', compression=pyzipper.ZIP_LZMA, encryption=pyzipper.WZ_AES) as zf:
zf.setpassword(zip_password.encode())
zf.write(temp_json_path, arcname="secrets.json")
if os.path.exists(temp_json_path):
os.remove(temp_json_path)
zipJson = {}
zipJson['url'] = full_url
zipJson['file'] = zip_filename
zip_url = json.dumps(zipJson)
dispatch_inputs = {
"version": version,
"zip_url": zip_url,
}
# 保存提交时的配置快照,供终态任务「一键重试」。
# 仅保留 JSON 安全类型(上传文件对象跳过——网页提交时图片已转为
# iconbase64/logobase64/privacybase64 data URL 字符串,不会丢失)。
config_snapshot = {}
for _k, _v in params.items():
if isinstance(_v, (str, int, float, bool)) or _v is None:
config_snapshot[_k] = _v
new_github_run = GithubRun(
uuid=myuuid,
status="Starting generator...please wait",
platform=platform,
filename=filename,
config_data=config_snapshot,
backend=backend.name,
created_by=(user if user is not None and getattr(user, 'is_authenticated', False) else None),
)
try:
dispatch_result = backend.dispatch(workflow_file, dispatch_inputs, timeout=20)
new_github_run.github_run_id = dispatch_result.run_id
new_github_run.status = "in_progress"
new_github_run.save()
return {
"success": True,
"uuid": myuuid,
"filename": filename,
"platform": platform,
"log_url": dispatch_result.html_url,
"run": new_github_run,
}
except build_backends.base.BackendError as e:
return {
"success": False,
"error": e.message,
"status_code": 502
}
except Exception as e:
return {
"success": False,
"error": f"触发构建服务时发生未预期的错误:{str(e)}",
"status_code": 502
}
def _get_run_status(uuid_val):
"""
查询构建状态(网页与 JSON API 共用)。
"""
try:
gh_run = GithubRun.objects.get(uuid=uuid_val)
except GithubRun.DoesNotExist:
return {"found": False}
# 历史任务按其记录的构建平台查询,平台切换后不影响在途任务
backend = build_backends.get_backend(gh_run.backend or 'github')
github_log_url = backend.log_url(gh_run.github_run_id) if gh_run.github_run_id else ''
if gh_run.status not in ['success', 'failure', 'cancelled', 'timed_out', 'skipped'] and gh_run.github_run_id:
try:
info = backend.get_run(gh_run.github_run_id)
if info is not None and info.finished:
gh_run.status = info.status
gh_run.save()
except Exception as e:
print(f"查询构建状态出错:{e}")
return {
"found": True,
"status": gh_run.status,
"github_log_url": github_log_url,
"gh_run": gh_run
}
@login_required
def generator_view(request):
# 版本下拉始终刷新为官方仓库最新 tags(内部带缓存与内置兜底,不会拖慢页面)
version_choices = rdeskVersions.version_choices()
if request.method == 'POST':
form = GenerateForm(request.POST, request.FILES)
form.fields['version'].choices = version_choices
if form.is_valid():
params = form.cleaned_data
full_url = f"{app_settings.get_value('PROTOCOL')}://{request.get_host()}"
result = generate_custom_client(params, full_url, user=request.user)
if result['success']:
status_label, status_badge = _status_extra("Starting generator...please wait")
return render(request, 'waiting.html', {
'filename': result['filename'],
'uuid': result['uuid'],
'status': "Starting generator...please wait",
'status_label': status_label,
'status_badge': status_badge,
'platform': result['platform'],
'log_url': result['log_url']
})
else:
messages.error(request, result['error'])
else:
messages.error(request, "表单校验未通过,请根据下方提示修正后重新提交。")
else:
form = GenerateForm()
form.fields['version'].choices = version_choices
_, version_source, version_ts = rdeskVersions.get_versions()
profiles = request.user.saved_configs.all().values('id', 'name')
return render(request, 'generator.html', {
'form': form,
'profiles': profiles,
'version_source': version_source,
})
@login_required
def check_for_file(request):
filename = request.GET.get('filename')
uuid_val = request.GET.get('uuid')
platform = request.GET.get('platform')
result = _get_run_status(uuid_val)
if not result['found']:
raise Http404("未找到对应的构建任务")
gh_run = result['gh_run']
if not _can_access_run(request, gh_run):
raise PermissionDenied("无权查看该构建任务")
github_log_url = result['github_log_url']
status_label, status_badge = _status_extra(gh_run.status)
context = {
'filename': filename,
'uuid': uuid_val,
'platform': platform,
'status': gh_run.status,
'status_label': status_label,
'status_badge': status_badge,
'log_url': github_log_url,
}
if gh_run.status == "success":
return render(request, 'generated.html', context)
elif gh_run.status in ['failure', 'cancelled', 'timed_out', 'skipped', 'action_required']:
return render(request, 'failure.html', context)
else:
return render(request, 'waiting.html', context)
@login_required
def download(request):
filename = request.GET.get('filename', '')
uuid_val = request.GET.get('uuid', '')
if not filename or not uuid_val:
raise Http404
gh_run = GithubRun.objects.filter(uuid=uuid_val).first()
if gh_run is None:
raise Http404("未找到对应的构建任务")
if not _can_access_run(request, gh_run):
raise PermissionDenied("无权下载该文件")
# 防路径穿越:最终路径必须位于 exe/<uuid>/ 目录内
base_dir = os.path.abspath(os.path.join('exe', uuid_val))
file_path = os.path.abspath(os.path.join(base_dir, os.path.basename(filename)))
if not file_path.startswith(base_dir + os.sep) or not os.path.isfile(file_path):
raise Http404("文件不存在或尚未生成完成")
from django.http import FileResponse
return FileResponse(
open(file_path, 'rb'),
content_type='application/octet-stream',
as_attachment=True,
filename=os.path.basename(filename),
)
def get_png(request):
"""供 GitHub Actions 工作流下载构建所用的图片资源。"""
filename = request.GET.get('filename', '')
uuid_val = request.GET.get('uuid', '')
if not filename or not uuid_val:
raise Http404
base_dir = os.path.abspath(os.path.join('png', uuid_val))
file_path = os.path.abspath(os.path.join(base_dir, os.path.basename(filename)))
if not file_path.startswith(base_dir + os.sep) or not os.path.isfile(file_path):
raise Http404("图片不存在")
from django.http import FileResponse
return FileResponse(open(file_path, 'rb'), content_type='image/png')
@csrf_exempt
def update_github_run(request):
"""GitHub Actions 回调:更新构建状态。"""
denied = _webhook_denied(request)
if denied:
return denied
try:
data = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return HttpResponse(status=400)
myuuid = data.get('uuid')
mystatus = data.get('status')
if not myuuid or not mystatus:
return HttpResponse(status=400)
GithubRun.objects.filter(Q(uuid=myuuid)).update(status=mystatus)
return HttpResponse('')
def resize_and_encode_icon(imagefile):
maxWidth = 200
try:
with io.BytesIO() as image_buffer:
for chunk in imagefile.chunks():
image_buffer.write(chunk)
image_buffer.seek(0)
img = Image.open(image_buffer)
imgcopy = img.copy()
except (IOError, OSError):
raise ValueError("上传的文件不是有效的图片格式。")
# 无需缩放时直接返回
if img.size[0] <= maxWidth:
with io.BytesIO() as image_buffer:
imgcopy.save(image_buffer, format=imagefile.content_type.split('/')[1])
image_buffer.seek(0)
return_image = ContentFile(image_buffer.read(), name=imagefile.name)
return base64.b64encode(return_image.read())
# 等比例缩放
wpercent = (maxWidth / float(img.size[0]))
hsize = int((float(img.size[1]) * float(wpercent)))
# LANCZOS 高质量重采样
imgcopy = imgcopy.resize((maxWidth, hsize), Image.Resampling.LANCZOS)
with io.BytesIO() as resized_image_buffer:
imgcopy.save(resized_image_buffer, format=imagefile.content_type.split('/')[1])
resized_image_buffer.seek(0)
resized_imagefile = ContentFile(resized_image_buffer.read(), name=imagefile.name)
resized64 = base64.b64encode(resized_imagefile.read())
return resized64
# 以下接口供外部来源(如自建 RustDesk API 服务器)调用
@csrf_exempt
def startgh(request):
denied = _webhook_denied(request)
if denied:
return denied
data_ = json.loads(request.body)
#### 触发构建平台工作流(GitHub / Gitea 按后台配置切换)
backend = build_backends.get_backend()
workflow_file = 'generator-' + str(data_.get('platform')) + '.yml'
inputs = {
"server":data_.get('server'),
"key":data_.get('key'),
"apiServer":data_.get('apiServer'),
"custom":data_.get('custom'),
"uuid":data_.get('uuid'),
"iconlink":data_.get('iconlink'),
"logolink":data_.get('logolink'),
"appname":data_.get('appname'),
"extras":data_.get('extras'),
"filename":data_.get('filename')
}
try:
result = backend.dispatch(workflow_file, inputs, timeout=20)
print(result)
except build_backends.base.BackendError as e:
# 历史行为:本端点无论成败都返回 204,仅记录日志
print(f"startgh 触发失败:{e.message}")
return HttpResponse(status=204)
def save_png(file, uuid, domain, name):
file_save_path = "png/%s/%s" % (uuid, name)
Path("png/%s" % uuid).mkdir(parents=True, exist_ok=True)
if isinstance(file, str): # base64 字符串
try:
header, encoded = file.split(';base64,')
decoded_img = base64.b64decode(encoded)
file = ContentFile(decoded_img, name=name) # 类文件对象
except ValueError:
print("base64 数据无效")
return None
except Exception as e:
print(f"base64 解码出错:{e}")
return None
with open(file_save_path, "wb+") as f:
for chunk in file.chunks():
f.write(chunk)
return domain, uuid, name
@csrf_exempt
def save_custom_client(request):
"""GitHub Actions 回调:上传构建产物。"""
denied = _webhook_denied(request)
if denied:
return denied
file = request.FILES.get('file')
myuuid = request.POST.get('uuid')
if not file or not myuuid:
return HttpResponse("缺少文件或任务 UUID", status=400)
file_save_path = "exe/%s/%s" % (myuuid, os.path.basename(file.name))
Path("exe/%s" % myuuid).mkdir(parents=True, exist_ok=True)
with open(file_save_path, "wb+") as f:
for chunk in file.chunks():
f.write(chunk)
return HttpResponse("文件保存成功")
@csrf_exempt
def cleanup_secrets(request):
"""GitHub Actions 回调:清理加密的临时配置包。"""
denied = _webhook_denied(request)
if denied:
return denied
try:
data = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return HttpResponse("请求体不是有效的 JSON", status=400)
my_uuid = data.get('uuid')
if not my_uuid:
return HttpResponse("缺少任务 UUID", status=400)
temp_dir = os.path.join('temp_zips')
if not os.path.isdir(temp_dir):
return HttpResponse("清理完成", status=200)
for filename in os.listdir(temp_dir):
if my_uuid in filename and filename.endswith('.zip'):
file_path = os.path.join(temp_dir, filename)
try:
os.remove(file_path)
print(f"已删除 {file_path}")
except OSError as e:
print(f"删除文件失败:{e}")
return HttpResponse("清理完成", status=200)
def get_zip(request):
"""供 GitHub Actions 工作流下载 AES 加密的构建配置包。"""
filename = request.GET.get('filename', '')
base_dir = os.path.abspath('temp_zips')
file_path = os.path.abspath(os.path.join(base_dir, os.path.basename(filename)))
if not file_path.startswith(base_dir + os.sep) or not os.path.isfile(file_path):
return HttpResponseForbidden("无效的文件名")
from django.http import FileResponse
return FileResponse(
open(file_path, 'rb'),
content_type='application/zip',
as_attachment=True,
filename=os.path.basename(filename),
)
+14
View File
@@ -0,0 +1,14 @@
"""统一的中文错误页面。"""
from django.shortcuts import render
def handler403(request, exception=None):
return render(request, '403.html', status=403)
def handler404(request, exception=None):
return render(request, '404.html', status=404)
def handler500(request):
return render(request, '500.html', status=500)

Some files were not shown because too many files have changed in this diff Show More