name: Desktop Client Build on: push: branches: - main paths: - "apps/desktop-client/**" - "packages/**" - "package.json" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" - ".gitea/workflows/desktop-client-build.yml" workflow_dispatch: inputs: platforms: description: "Build platforms: mac, win, or mac,win" required: true default: "mac,win" mac_arches: description: "macOS architectures: universal, arm64, x64, or comma-separated" required: true default: "universal" win_arches: description: "Windows architectures: x64, arm64, ia32, or comma-separated" required: true default: "x64" version_prefix: description: "Client version prefix. Final version is .." required: true default: "0.1" version_patch: description: "Optional fixed patch number. Empty means auto-increment." required: false default: "" output_dir: description: "NAS output directory" required: true default: "/vol1/1000/share/gl-test" host: description: "NAS SSH host" required: true default: "192.168.100.19" port: description: "NAS SSH port" required: true default: "22" user: description: "NAS SSH user" required: true default: "admin" password_source: description: "NAS SSH password source" required: true default: "repository_secret" type: choice options: - repository_secret - workflow_input ssh_password: description: "NAS SSH password. Used only when password_source is workflow_input." required: false default: "" use_sudo: description: "Use sudo for NAS file operations" required: true default: "true" type: choice options: - "true" - "false" permissions: contents: read concurrency: group: desktop-client-build cancel-in-progress: false env: DEFAULT_DESKTOP_PLATFORMS: "mac,win" DEFAULT_MAC_ARCHES: "universal" DEFAULT_WIN_ARCHES: "x64" DEFAULT_VERSION_PREFIX: "0.1" DEFAULT_NAS_OUTPUT_DIR: "/vol1/1000/share/gl-test" DEFAULT_NAS_HOST: "192.168.100.19" DEFAULT_NAS_PORT: "22" DEFAULT_NAS_USER: "admin" DEFAULT_NAS_USE_SUDO: "true" PNPM_VERSION: "10.33.0" jobs: resolve: name: Resolve Build Metadata runs-on: ubuntu-latest timeout-minutes: 15 outputs: build_mac: ${{ steps.meta.outputs.build_mac }} build_win: ${{ steps.meta.outputs.build_win }} client_version: ${{ steps.meta.outputs.client_version }} mac_arch_flags: ${{ steps.meta.outputs.mac_arch_flags }} nas_host: ${{ steps.meta.outputs.nas_host }} nas_port: ${{ steps.meta.outputs.nas_port }} nas_user: ${{ steps.meta.outputs.nas_user }} nas_use_sudo: ${{ steps.meta.outputs.nas_use_sudo }} output_dir: ${{ steps.meta.outputs.output_dir }} release_name: ${{ steps.meta.outputs.release_name }} win_arch_flags: ${{ steps.meta.outputs.win_arch_flags }} env: REQUESTED_PLATFORMS: ${{ inputs.platforms }} REQUESTED_MAC_ARCHES: ${{ inputs.mac_arches }} REQUESTED_WIN_ARCHES: ${{ inputs.win_arches }} REQUESTED_VERSION_PREFIX: ${{ inputs.version_prefix }} REQUESTED_VERSION_PATCH: ${{ inputs.version_patch }} REQUESTED_OUTPUT_DIR: ${{ inputs.output_dir }} REQUESTED_NAS_HOST: ${{ inputs.host }} REQUESTED_NAS_PORT: ${{ inputs.port }} REQUESTED_NAS_USER: ${{ inputs.user }} REQUESTED_NAS_USE_SUDO: ${{ inputs.use_sudo }} NAS_SSH_PASSWORD_SOURCE: ${{ inputs.password_source }} NAS_SSH_PASSWORD_INPUT: ${{ inputs.ssh_password }} NAS_SSH_PASSWORD_SECRET: ${{ secrets.NAS_SSH_PASSWORD }} steps: - name: Checkout uses: actions/checkout@v4 - name: Install SSH tools run: | set -eu apt-get update apt-get install -y openssh-client sshpass - name: Resolve version and targets id: meta run: | set -eu normalize_csv() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]' } arch_flags() { value="$(normalize_csv "$1")" allowed="$2" result="" if [ -z "${value}" ]; then echo "::error::Architecture list cannot be empty" exit 1 fi for arch in $(printf '%s' "${value}" | tr ',' ' '); do found=false for candidate in ${allowed}; do if [ "${arch}" = "${candidate}" ]; then found=true break fi done if [ "${found}" != "true" ]; then echo "::error::Unsupported architecture '${arch}'. Allowed: ${allowed}" exit 1 fi result="${result} --${arch}" done printf '%s' "${result# }" } platforms="$(normalize_csv "${REQUESTED_PLATFORMS:-${DEFAULT_DESKTOP_PLATFORMS}}")" build_mac=false build_win=false for platform in $(printf '%s' "${platforms}" | tr ',' ' '); do case "${platform}" in mac|macos) build_mac=true ;; win|windows) build_win=true ;; *) echo "::error::Unsupported platform '${platform}'. Use mac, win, or mac,win." exit 1 ;; esac done if [ "${build_mac}" != "true" ] && [ "${build_win}" != "true" ]; then echo "::error::At least one platform must be selected." exit 1 fi mac_arch_flags="$(arch_flags "${REQUESTED_MAC_ARCHES:-${DEFAULT_MAC_ARCHES}}" "x64 arm64 universal")" win_arch_flags="$(arch_flags "${REQUESTED_WIN_ARCHES:-${DEFAULT_WIN_ARCHES}}" "x64 arm64 ia32")" version_prefix="${REQUESTED_VERSION_PREFIX:-${DEFAULT_VERSION_PREFIX}}" if ! printf '%s' "${version_prefix}" | grep -Eq '^[0-9]+\.[0-9]+$'; then echo "::error::version_prefix must look like 0.1" exit 1 fi output_dir="${REQUESTED_OUTPUT_DIR:-${DEFAULT_NAS_OUTPUT_DIR}}" output_dir="${output_dir%/}" case "${output_dir}" in /*) ;; *) echo "::error::output_dir must be an absolute NAS path" exit 1 ;; esac case "${output_dir}" in *[!A-Za-z0-9_./-]*) echo "::error::output_dir contains unsupported characters" exit 1 ;; esac nas_host="${REQUESTED_NAS_HOST:-${DEFAULT_NAS_HOST}}" nas_port="${REQUESTED_NAS_PORT:-${DEFAULT_NAS_PORT}}" nas_user="${REQUESTED_NAS_USER:-${DEFAULT_NAS_USER}}" nas_use_sudo="${REQUESTED_NAS_USE_SUDO:-${DEFAULT_NAS_USE_SUDO}}" case "${nas_port}" in *[!0-9]*|"") echo "::error::NAS SSH port must be numeric" exit 1 ;; esac case "${nas_host}" in *[!A-Za-z0-9_.:-]*|"") echo "::error::NAS host contains unsupported characters" exit 1 ;; esac case "${nas_user}" in *[!A-Za-z0-9_.@-]*|"") echo "::error::NAS user contains unsupported characters" exit 1 ;; esac case "${nas_use_sudo}" in true|false) ;; *) echo "::error::use_sudo must be true or false" exit 1 ;; esac if [ -n "${REQUESTED_VERSION_PATCH:-}" ]; then if ! printf '%s' "${REQUESTED_VERSION_PATCH}" | grep -Eq '^[0-9]+$'; then echo "::error::version_patch must be a non-negative integer" exit 1 fi version_patch="${REQUESTED_VERSION_PATCH}" else password_source="${NAS_SSH_PASSWORD_SOURCE:-repository_secret}" case "${password_source}" in repository_secret) nas_ssh_password="${NAS_SSH_PASSWORD_SECRET:-}" ;; workflow_input) nas_ssh_password="${NAS_SSH_PASSWORD_INPUT:-}" ;; *) echo "::error::password_source must be repository_secret or workflow_input" exit 1 ;; esac if [ -n "${nas_ssh_password}" ]; then echo "::add-mask::${nas_ssh_password}" fi if [ -z "${nas_ssh_password}" ]; then if [ "${password_source}" = "workflow_input" ]; then echo "::error::Missing workflow input ssh_password" else echo "::error::Missing repository secret NAS_SSH_PASSWORD" fi exit 1 fi mkdir -p ~/.ssh chmod 700 ~/.ssh ssh-keyscan -p "${nas_port}" "${nas_host}" >> ~/.ssh/known_hosts sudo_password_b64="$(printf '%s' "${nas_ssh_password}" | base64 | tr -d '\n')" remote_names="$( { printf 'OUTPUT_DIR=%s\n' "${output_dir}" printf 'USE_SUDO=%s\n' "${nas_use_sudo}" printf 'SUDO_PASSWORD_B64=%s\n' "${sudo_password_b64}" cat <<'REMOTE_SCRIPT' set -eu SUDO_PASSWORD="$(printf '%s' "${SUDO_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" unset SUDO_PASSWORD_B64 run_cmd() { if [ "${USE_SUDO}" = "true" ]; then printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" "$@" else "$@" fi } run_cmd mkdir -p "${OUTPUT_DIR}" run_cmd find "${OUTPUT_DIR}" -maxdepth 3 \( -type f -o -type d -o -type l \) -print 2>/dev/null | sed 's#.*/##' REMOTE_SCRIPT } | SSHPASS="${nas_ssh_password}" sshpass -e ssh \ -p "${nas_port}" \ -o BatchMode=no \ -o StrictHostKeyChecking=yes \ "${nas_user}@${nas_host}" \ "sh -s" )" prefix_regex="$(printf '%s' "${version_prefix}" | sed 's/\./\\./g')" remote_patch="$( printf '%s\n' "${remote_names}" \ | sed -n "s/.*${prefix_regex}\.\([0-9][0-9]*\).*/\1/p" \ | sort -n \ | tail -n1 )" if [ -n "${remote_patch}" ]; then version_patch=$((remote_patch + 1)) else version_patch=0 fi fi client_version="${version_prefix}.${version_patch}" release_name="desktop-client-${client_version}" { echo "build_mac=${build_mac}" echo "build_win=${build_win}" echo "client_version=${client_version}" echo "mac_arch_flags=${mac_arch_flags}" echo "nas_host=${nas_host}" echo "nas_port=${nas_port}" echo "nas_user=${nas_user}" echo "nas_use_sudo=${nas_use_sudo}" echo "output_dir=${output_dir}" echo "release_name=${release_name}" echo "win_arch_flags=${win_arch_flags}" } >> "${GITHUB_OUTPUT}" echo "Version: ${client_version}" echo "Platforms: mac=${build_mac}, win=${build_win}" echo "macOS arch flags: ${mac_arch_flags}" echo "Windows arch flags: ${win_arch_flags}" echo "Use sudo on NAS: ${nas_use_sudo}" echo "NAS output: ${nas_user}@${nas_host}:${output_dir}/${release_name}" build-mac: name: Build macOS Client needs: resolve if: needs.resolve.outputs.build_mac == 'true' runs-on: macos-latest timeout-minutes: 120 env: CLIENT_VERSION: ${{ needs.resolve.outputs.client_version }} MAC_ARCH_FLAGS: ${{ needs.resolve.outputs.mac_arch_flags }} CSC_IDENTITY_AUTO_DISCOVERY: "false" steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" - name: Setup pnpm run: | set -eu corepack enable corepack prepare pnpm@${PNPM_VERSION} --activate - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build unsigned macOS package working-directory: apps/desktop-client run: | set -eu unset CSC_LINK CSC_NAME CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID export CSC_IDENTITY_AUTO_DISCOVERY=false node -e 'const fs = require("fs"); const p = "package.json"; const pkg = JSON.parse(fs.readFileSync(p, "utf8")); pkg.version = process.env.CLIENT_VERSION; fs.writeFileSync(p, `${JSON.stringify(pkg, null, 2)}\n`); console.log(`Desktop client version: ${pkg.version}`);' rm -rf release pnpm run build:obf pnpm exec electron-builder --mac zip ${MAC_ARCH_FLAGS} --publish never -c.mac.target=zip -c.extraMetadata.version="${CLIENT_VERSION}" - name: Collect macOS artifacts working-directory: apps/desktop-client run: | set -eu artifact_dir="${RUNNER_TEMP}/desktop-client-mac" mkdir -p "${artifact_dir}" zip_count="$(find release -maxdepth 1 -type f -name "*.zip" | wc -l | tr -d ' ')" if [ "${zip_count}" = "0" ]; then echo "::error::No macOS artifacts found" exit 1 fi if [ "${zip_count}" != "1" ]; then echo "::error::Expected exactly one macOS zip artifact for this build, found ${zip_count}." find release -maxdepth 1 -type f -name "*.zip" -print exit 1 fi mac_zip="$(find release -maxdepth 1 -type f -name "*.zip" | head -n1)" cp -p "${mac_zip}" "${artifact_dir}/省心推-mac-${CLIENT_VERSION}.zip" ls -lh "${artifact_dir}" - name: Upload macOS artifacts uses: actions/upload-artifact@v3 with: name: mac path: ${{ runner.temp }}/desktop-client-mac if-no-files-found: error retention-days: 14 build-win: name: Build Windows Client needs: resolve if: needs.resolve.outputs.build_win == 'true' runs-on: windows-latest timeout-minutes: 120 env: CLIENT_VERSION: ${{ needs.resolve.outputs.client_version }} WIN_ARCH_FLAGS: ${{ needs.resolve.outputs.win_arch_flags }} CSC_IDENTITY_AUTO_DISCOVERY: "false" steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" - name: Setup pnpm shell: pwsh run: | corepack enable corepack prepare pnpm@$env:PNPM_VERSION --activate - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build unsigned Windows package working-directory: apps/desktop-client shell: pwsh run: | Remove-Item Env:CSC_LINK -ErrorAction SilentlyContinue Remove-Item Env:CSC_NAME -ErrorAction SilentlyContinue Remove-Item Env:CSC_KEY_PASSWORD -ErrorAction SilentlyContinue Remove-Item Env:WIN_CSC_LINK -ErrorAction SilentlyContinue Remove-Item Env:WIN_CSC_KEY_PASSWORD -ErrorAction SilentlyContinue $env:CSC_IDENTITY_AUTO_DISCOVERY = "false" node -e "const fs = require('fs'); const p = 'package.json'; const pkg = JSON.parse(fs.readFileSync(p, 'utf8')); pkg.version = process.env.CLIENT_VERSION; fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n'); console.log('Desktop client version: ' + pkg.version);" Remove-Item -Recurse -Force release -ErrorAction SilentlyContinue pnpm run build:obf $archFlags = $env:WIN_ARCH_FLAGS -split " " | Where-Object { $_ } pnpm exec electron-builder --win zip @archFlags --publish never "-c.win.target=zip" "-c.extraMetadata.version=$env:CLIENT_VERSION" - name: Collect Windows artifacts working-directory: apps/desktop-client shell: pwsh run: | $artifactDir = Join-Path $env:RUNNER_TEMP "desktop-client-win" New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null $files = Get-ChildItem -Path release -Filter "*.zip" -File -ErrorAction SilentlyContinue if (-not $files) { throw "No Windows artifacts found" } $zipFiles = $files | Sort-Object FullName -Unique if ($zipFiles.Count -ne 1) { $zipFiles | Format-Table FullName throw "Expected exactly one Windows zip artifact for this build, found $($zipFiles.Count)." } Copy-Item -Path $zipFiles[0].FullName -Destination (Join-Path $artifactDir "省心推-win-$env:CLIENT_VERSION.zip") -Force Get-ChildItem $artifactDir | Format-Table Name, Length - name: Upload Windows artifacts uses: actions/upload-artifact@v3 with: name: win path: ${{ runner.temp }}/desktop-client-win if-no-files-found: error retention-days: 14 publish-to-nas: name: Publish Client Artifacts to NAS needs: - resolve - build-mac - build-win if: >- always() && needs.resolve.result == 'success' && (needs.build-mac.result == 'success' || needs.build-mac.result == 'skipped') && (needs.build-win.result == 'success' || needs.build-win.result == 'skipped') runs-on: ubuntu-latest timeout-minutes: 30 env: CLIENT_VERSION: ${{ needs.resolve.outputs.client_version }} NAS_HOST: ${{ needs.resolve.outputs.nas_host }} NAS_PORT: ${{ needs.resolve.outputs.nas_port }} NAS_USER: ${{ needs.resolve.outputs.nas_user }} NAS_USE_SUDO: ${{ needs.resolve.outputs.nas_use_sudo }} OUTPUT_DIR: ${{ needs.resolve.outputs.output_dir }} RELEASE_NAME: ${{ needs.resolve.outputs.release_name }} NAS_SSH_PASSWORD_SOURCE: ${{ inputs.password_source }} NAS_SSH_PASSWORD_INPUT: ${{ inputs.ssh_password }} NAS_SSH_PASSWORD_SECRET: ${{ secrets.NAS_SSH_PASSWORD }} steps: - name: Install SSH tools run: | set -eu apt-get update apt-get install -y openssh-client sshpass tar - name: Download client artifacts uses: actions/download-artifact@v3 with: path: desktop-artifacts - name: Write manifest run: | set -eu artifact_count="$(find desktop-artifacts -type f | wc -l | tr -d ' ')" if [ "${artifact_count}" = "0" ]; then echo "::error::No downloaded artifacts found" exit 1 fi printf '%s\n' "${CLIENT_VERSION}" > desktop-artifacts/VERSION cat > desktop-artifacts/manifest.json <> ~/.ssh/known_hosts remote="${NAS_USER}@${NAS_HOST}" ssh_opts="-p ${NAS_PORT} -o BatchMode=no -o StrictHostKeyChecking=yes" scp_opts="-P ${NAS_PORT} -o BatchMode=no -o StrictHostKeyChecking=yes" remote_release_dir="${OUTPUT_DIR}/${RELEASE_NAME}" sudo_password_b64="$(printf '%s' "${nas_ssh_password}" | base64 | tr -d '\n')" archive_path="${RUNNER_TEMP:-/tmp}/${RELEASE_NAME}.tar.gz" remote_archive="/tmp/${RELEASE_NAME}-${GITHUB_RUN_ID:-manual}.tar.gz" { printf 'OUTPUT_DIR=%s\n' "${OUTPUT_DIR}" printf 'RELEASE_NAME=%s\n' "${RELEASE_NAME}" printf 'USE_SUDO=%s\n' "${NAS_USE_SUDO}" printf 'SUDO_PASSWORD_B64=%s\n' "${sudo_password_b64}" cat <<'REMOTE_SCRIPT' set -eu SUDO_PASSWORD="$(printf '%s' "${SUDO_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" unset SUDO_PASSWORD_B64 run_cmd() { if [ "${USE_SUDO}" = "true" ]; then printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" "$@" else "$@" fi } run_cmd mkdir -p "${OUTPUT_DIR}/${RELEASE_NAME}" REMOTE_SCRIPT } | SSHPASS="${nas_ssh_password}" sshpass -e ssh ${ssh_opts} "${remote}" "sh -s" tar -C desktop-artifacts -czf "${archive_path}" . SSHPASS="${nas_ssh_password}" sshpass -e scp ${scp_opts} "${archive_path}" "${remote}:${remote_archive}" { printf 'REMOTE_ARCHIVE=%s\n' "${remote_archive}" printf 'REMOTE_RELEASE_DIR=%s\n' "${remote_release_dir}" printf 'USE_SUDO=%s\n' "${NAS_USE_SUDO}" printf 'SUDO_PASSWORD_B64=%s\n' "${sudo_password_b64}" cat <<'REMOTE_SCRIPT' set -eu SUDO_PASSWORD="$(printf '%s' "${SUDO_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" unset SUDO_PASSWORD_B64 if [ "${USE_SUDO}" = "true" ]; then printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" tar -xzf "${REMOTE_ARCHIVE}" -C "${REMOTE_RELEASE_DIR}" rm -f "${REMOTE_ARCHIVE}" else tar -xzf "${REMOTE_ARCHIVE}" -C "${REMOTE_RELEASE_DIR}" rm -f "${REMOTE_ARCHIVE}" fi REMOTE_SCRIPT } | SSHPASS="${nas_ssh_password}" sshpass -e ssh ${ssh_opts} "${remote}" "sh -s" { printf 'OUTPUT_DIR=%s\n' "${OUTPUT_DIR}" printf 'RELEASE_NAME=%s\n' "${RELEASE_NAME}" printf 'USE_SUDO=%s\n' "${NAS_USE_SUDO}" printf 'SUDO_PASSWORD_B64=%s\n' "${sudo_password_b64}" cat <<'REMOTE_SCRIPT' set -eu SUDO_PASSWORD="$(printf '%s' "${SUDO_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" unset SUDO_PASSWORD_B64 if [ "${USE_SUDO}" = "true" ]; then tmp_file="$(mktemp)" printf '%s\n' "${RELEASE_NAME}" > "${tmp_file}" printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" cp "${tmp_file}" "${OUTPUT_DIR}/latest-desktop-client.txt" rm -f "${tmp_file}" else printf '%s\n' "${RELEASE_NAME}" > "${OUTPUT_DIR}/latest-desktop-client.txt" fi REMOTE_SCRIPT } | SSHPASS="${nas_ssh_password}" sshpass -e ssh ${ssh_opts} "${remote}" "sh -s" { printf 'OUTPUT_DIR=%s\n' "${OUTPUT_DIR}" printf 'CLIENT_VERSION=%s\n' "${CLIENT_VERSION}" printf 'USE_SUDO=%s\n' "${NAS_USE_SUDO}" printf 'SUDO_PASSWORD_B64=%s\n' "${sudo_password_b64}" cat <<'REMOTE_SCRIPT' set -eu SUDO_PASSWORD="$(printf '%s' "${SUDO_PASSWORD_B64}" | base64 -d 2>/dev/null || true)" unset SUDO_PASSWORD_B64 run_cmd() { if [ "${USE_SUDO}" = "true" ]; then printf '%s\n' "${SUDO_PASSWORD}" | sudo -S -p "" "$@" else "$@" fi } versioned_paths_desc() { root="$1" pattern="$2" prefix="$3" suffix="$4" for candidate in "${root}"/${pattern}; do [ -e "${candidate}" ] || continue name="${candidate##*/}" version="${name#${prefix}}" if [ -n "${suffix}" ]; then version="${version%${suffix}}" fi case "${version}" in *[!0-9.]*|""|*.*.*.*) continue ;; esac major="$(printf '%s' "${version}" | cut -d. -f1)" minor="$(printf '%s' "${version}" | cut -d. -f2)" patch="$(printf '%s' "${version}" | cut -d. -f3)" case "${major}${minor}${patch}" in *[!0-9]*|"") continue ;; esac printf '%s.%s.%s %s\n' "${major}" "${minor}" "${patch}" "${candidate}" done | sort -t. -k1,1nr -k2,2nr -k3,3nr | awk '{print $2}' } keep_latest_two() { root="$1" pattern="$2" prefix="$3" suffix="$4" mode="$5" keep=0 for path in $(versioned_paths_desc "${root}" "${pattern}" "${prefix}" "${suffix}"); do keep=$((keep + 1)) if [ "${keep}" -gt 2 ]; then if [ "${mode}" = "dir" ]; then run_cmd rm -rf "${path}" else run_cmd rm -f "${path}" fi fi done } for platform in mac win; do release_platform_dir="${OUTPUT_DIR}/desktop-client-${CLIENT_VERSION}/${platform}" [ -d "${release_platform_dir}" ] || continue platform_root="${OUTPUT_DIR}/${platform}" run_cmd mkdir -p "${platform_root}" for zip_file in "${release_platform_dir}"/*.zip; do [ -f "${zip_file}" ] || continue run_cmd cp -p "${zip_file}" "${platform_root}/" done keep_latest_two "${platform_root}" "省心推-${platform}-*.zip" "省心推-${platform}-" ".zip" "file" done keep_latest_two "${OUTPUT_DIR}" "desktop-client-*" "desktop-client-" "" "dir" REMOTE_SCRIPT } | SSHPASS="${nas_ssh_password}" sshpass -e ssh ${ssh_opts} "${remote}" "sh -s" echo "Uploaded desktop client ${CLIENT_VERSION} to ${remote}:${remote_release_dir}"