diff --git a/.github/actions/setup-flutter-sdk/action.yml b/.github/actions/setup-flutter-sdk/action.yml
new file mode 100644
index 00000000..8fa593ab
--- /dev/null
+++ b/.github/actions/setup-flutter-sdk/action.yml
@@ -0,0 +1,20 @@
+name: Setup Flutter SDK
+description: Install the pinned Flutter SDK on Linux, macOS, or Windows runners
+
+inputs:
+ flutter-version:
+ description: Flutter SDK version to install
+ required: true
+
+runs:
+ using: composite
+ steps:
+ - name: Install Flutter SDK on Linux/macOS
+ if: ${{ runner.os != 'Windows' }}
+ shell: bash
+ run: bash ./scripts/ci/install_flutter_sdk.sh "${{ inputs.flutter-version }}"
+
+ - name: Install Flutter SDK on Windows
+ if: ${{ runner.os == 'Windows' }}
+ shell: pwsh
+ run: pwsh -File ./scripts/ci/install_flutter_sdk.ps1 -FlutterVersion "${{ inputs.flutter-version }}"
diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml
new file mode 100644
index 00000000..b159f2a6
--- /dev/null
+++ b/.github/workflows/build-and-release.yml
@@ -0,0 +1,194 @@
+name: Build and Release XWorkmate Packages
+
+on:
+ push:
+ branches:
+ - main
+ tags:
+ - "v*"
+ paths-ignore:
+ - "README.md"
+ pull_request:
+ branches:
+ - main
+ paths:
+ - "lib/**"
+ - "assets/**"
+ - "android/**"
+ - "ios/**"
+ - "macos/**"
+ - "linux/**"
+ - "windows/**"
+ - "rust/**"
+ - "scripts/**"
+ - "pubspec.*"
+ - "Makefile"
+ - ".github/actions/setup-flutter-sdk/action.yml"
+ - ".github/workflows/build-and-release.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: build-and-release-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ FLUTTER_VERSION: 3.41.4
+
+jobs:
+ prepare:
+ runs-on: ubuntu-22.04
+ permissions:
+ contents: write
+ outputs:
+ should_release: ${{ steps.flags.outputs.should_release }}
+ release_tag: ${{ steps.meta.outputs.release_tag }}
+ release_title: ${{ steps.meta.outputs.release_title }}
+ release_notes: ${{ steps.meta.outputs.release_notes }}
+ steps:
+ - name: Checkout source
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Determine release mode
+ id: flags
+ shell: bash
+ run: |
+ if [[ "${GITHUB_REF:-}" == refs/tags/v* || "${GITHUB_EVENT_NAME:-}" == "workflow_dispatch" ]]; then
+ echo "should_release=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "should_release=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Compute release metadata
+ id: meta
+ shell: bash
+ run: bash ./scripts/ci/compute_release_metadata.sh
+
+ verify:
+ if: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
+ runs-on: ubuntu-22.04
+ steps:
+ - name: Checkout source
+ uses: actions/checkout@v4
+
+ - name: Set up Flutter SDK
+ uses: ./.github/actions/setup-flutter-sdk
+ with:
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+
+ - name: Install Linux dependencies
+ shell: bash
+ run: bash ./scripts/ci/setup_platform_deps.sh linux
+
+ - name: Run analysis and tests
+ shell: bash
+ run: bash ./scripts/ci/run_code_analysis.sh
+
+ build:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: linux
+ arch: amd64
+ runs_on: ubuntu-22.04
+ - platform: windows
+ arch: amd64
+ runs_on: windows-2022
+ - platform: macos
+ arch: arm64
+ runs_on: macos-14
+ - platform: ios
+ arch: arm64
+ runs_on: macos-14
+ - platform: android
+ arch: arm64
+ runs_on: ubuntu-22.04
+ runs-on: ${{ matrix.runs_on }}
+ needs:
+ - prepare
+ - verify
+ env:
+ PLATFORM: ${{ matrix.platform }}
+ ARCH: ${{ matrix.arch }}
+ SHOULD_RELEASE: ${{ needs.prepare.outputs.should_release }}
+ XWORKMATE_SIGN_IDENTITY: ${{ secrets.XWORKMATE_SIGN_IDENTITY }}
+ WINDOWS_PFX_BASE64: ${{ secrets.WINDOWS_PFX_BASE64 }}
+ WINDOWS_PFX_PASSWORD: ${{ secrets.WINDOWS_PFX_PASSWORD }}
+ WINDOWS_CODESIGN_SUBJECT: ${{ secrets.WINDOWS_CODESIGN_SUBJECT }}
+ APPLE_CERT_P12_BASE64: ${{ secrets.APPLE_CERT_P12_BASE64 }}
+ APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
+ APPLE_PROVISION_PROFILE_BASE64: ${{ secrets.APPLE_PROVISION_PROFILE_BASE64 }}
+ APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }}
+ APPLE_EXPORT_METHOD: ${{ secrets.APPLE_EXPORT_METHOD }}
+ ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
+ ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
+ ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
+ ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
+ steps:
+ - name: Checkout source
+ uses: actions/checkout@v4
+
+ - name: Set up Flutter SDK
+ uses: ./.github/actions/setup-flutter-sdk
+ with:
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+
+ - name: Install Go
+ if: ${{ matrix.platform == 'macos' }}
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.24.1"
+
+ - name: Install platform dependencies
+ shell: bash
+ run: bash ./scripts/ci/setup_platform_deps.sh "$PLATFORM"
+
+ - name: Build platform artifacts
+ shell: bash
+ run: bash ./scripts/ci/build_matrix_artifacts.sh "$PLATFORM" "$ARCH" "$SHOULD_RELEASE"
+
+ - name: Upload build artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-${{ matrix.platform }}-${{ matrix.arch }}
+ path: |
+ dist/linux/*.deb
+ dist/linux/*.rpm
+ dist/macos/*.dmg
+ dist/windows/*.msi
+ dist/windows/*.zip
+ dist/ios/*.ipa
+ dist/ios/*.zip
+ dist/android/*.apk
+ if-no-files-found: error
+
+ release:
+ if: ${{ needs.prepare.outputs.should_release == 'true' }}
+ runs-on: ubuntu-22.04
+ permissions:
+ contents: write
+ needs:
+ - prepare
+ - build
+ steps:
+ - name: Checkout source
+ uses: actions/checkout@v4
+
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: release-artifacts
+
+ - name: Upload assets to GitHub Release
+ shell: bash
+ run: bash ./scripts/ci/github_release_upload.sh release-artifacts
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }}
+ RELEASE_TITLE: ${{ needs.prepare.outputs.release_title }}
+ RELEASE_NOTES: ${{ needs.prepare.outputs.release_notes }}
diff --git a/.gitignore b/.gitignore
index 0f16cb03..d87aa0df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,6 +46,10 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
+/android/key.properties
+/*.p12
+/*.mobileprovision
+/*.keystore
# Rust / FFI artifacts
/rust/target/
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 911a2976..4e0fb86d 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -5,6 +5,14 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
+import java.util.Properties
+
+val keystoreProperties = Properties()
+val keystorePropertiesFile = rootProject.file("key.properties")
+if (keystorePropertiesFile.exists()) {
+ keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
+}
+
android {
namespace = "plus.svc.xworkmate"
compileSdk = flutter.compileSdkVersion
@@ -30,11 +38,25 @@ android {
versionName = flutter.versionName
}
+ signingConfigs {
+ create("release") {
+ val storeFilePath = keystoreProperties.getProperty("storeFile")
+ if (!storeFilePath.isNullOrBlank()) {
+ storeFile = file(storeFilePath)
+ storePassword = keystoreProperties.getProperty("storePassword")
+ keyAlias = keystoreProperties.getProperty("keyAlias")
+ keyPassword = keystoreProperties.getProperty("keyPassword")
+ }
+ }
+ }
+
buildTypes {
release {
- // TODO: Add your own signing config for the release build.
- // Signing with the debug keys for now, so `flutter run --release` works.
- signingConfig = signingConfigs.getByName("debug")
+ signingConfig = if (keystorePropertiesFile.exists()) {
+ signingConfigs.getByName("release")
+ } else {
+ signingConfigs.getByName("debug")
+ }
}
}
}
diff --git a/ios/ExportOptions.plist b/ios/ExportOptions.plist
new file mode 100644
index 00000000..6119752b
--- /dev/null
+++ b/ios/ExportOptions.plist
@@ -0,0 +1,20 @@
+
+
+
+
+ compileBitcode
+
+ destination
+ export
+ method
+ ${EXPORT_METHOD}
+ signingStyle
+ automatic
+ stripSwiftSymbols
+
+ teamID
+ N3G9T67W78
+ thinning
+ <none>
+
+
diff --git a/packaging/windows/main.wxs b/packaging/windows/main.wxs
new file mode 100644
index 00000000..5aeca4bc
--- /dev/null
+++ b/packaging/windows/main.wxs
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/ci/build_matrix_artifacts.sh b/scripts/ci/build_matrix_artifacts.sh
new file mode 100755
index 00000000..c2dfac65
--- /dev/null
+++ b/scripts/ci/build_matrix_artifacts.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+platform="${1:?platform is required}"
+arch="${2:?arch is required}"
+should_release="${3:-false}"
+
+flutter pub get
+
+case "$platform" in
+ linux)
+ bash ./scripts/package-linux.sh
+ ;;
+ macos)
+ bash ./scripts/package-flutter-mac-app.sh
+ mkdir -p dist/macos
+ find dist -maxdepth 1 -name '*.dmg' -exec mv {} dist/macos/ \;
+ ;;
+ windows)
+ flutter build windows --release
+ pwsh -File ./scripts/package-windows-msi.ps1 -Arch "$arch"
+ ;;
+ ios)
+ if [[ "$should_release" == "true" ]]; then
+ bash ./scripts/package-ios-ipa.sh
+ else
+ echo "Release secrets not required for non-release runs; building unsigned iOS app bundle."
+ flutter build ios --release --no-codesign
+ mkdir -p dist/ios
+ (
+ cd build/ios/iphoneos
+ rm -f XWorkmate.app.zip
+ zip -qry XWorkmate.app.zip Runner.app
+ mv XWorkmate.app.zip ../../../dist/ios/
+ )
+ fi
+ ;;
+ android)
+ bash ./scripts/package-android-apk.sh
+ ;;
+ *)
+ echo "Unsupported platform: $platform" >&2
+ exit 1
+ ;;
+esac
diff --git a/scripts/ci/compute_release_metadata.sh b/scripts/ci/compute_release_metadata.sh
new file mode 100755
index 00000000..751ee055
--- /dev/null
+++ b/scripts/ci/compute_release_metadata.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ -z "${GITHUB_OUTPUT:-}" ]]; then
+ echo "GITHUB_OUTPUT is required" >&2
+ exit 1
+fi
+
+if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
+ release_tag="${GITHUB_REF_NAME}"
+ release_title="Release ${GITHUB_REF_NAME}"
+ release_notes="Automated release for ${GITHUB_REF_NAME}"
+elif [[ "${GITHUB_EVENT_NAME:-}" == "workflow_dispatch" ]]; then
+ release_tag="manual-${GITHUB_RUN_NUMBER:-0}"
+ release_title="Manual Build ${GITHUB_RUN_NUMBER:-0}"
+ release_notes="Automated manual build from ${GITHUB_SHA:-unknown}"
+else
+ release_tag="main-${GITHUB_RUN_NUMBER:-0}"
+ release_title="Main Build ${GITHUB_RUN_NUMBER:-0}"
+ release_notes="Automated build from ${GITHUB_SHA:-unknown}"
+fi
+
+{
+ echo "release_tag=${release_tag}"
+ echo "release_title=${release_title}"
+ echo "release_notes=${release_notes}"
+} >> "$GITHUB_OUTPUT"
diff --git a/scripts/ci/github_release_upload.sh b/scripts/ci/github_release_upload.sh
new file mode 100755
index 00000000..c6abe520
--- /dev/null
+++ b/scripts/ci/github_release_upload.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+artifact_dir="${1:-release-artifacts}"
+tag="${RELEASE_TAG:-manual-${GITHUB_RUN_NUMBER:-0}}"
+title="${RELEASE_TITLE:-Manual Build ${GITHUB_RUN_NUMBER:-0}}"
+notes="${RELEASE_NOTES:-Automated build}"
+
+if ! command -v gh >/dev/null 2>&1; then
+ echo "GitHub CLI is required to upload release artifacts." >&2
+ exit 1
+fi
+
+if ! gh release view "$tag" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
+ gh release create "$tag" --repo "${GITHUB_REPOSITORY}" --title "$title" --notes "$notes"
+else
+ gh release edit "$tag" --repo "${GITHUB_REPOSITORY}" --title "$title" --notes "$notes"
+fi
+
+mapfile -d '' files < <(find "$artifact_dir" -type f -print0)
+
+if [[ "${#files[@]}" -eq 0 ]]; then
+ echo "No release artifacts found in $artifact_dir" >&2
+ exit 1
+fi
+
+for file in "${files[@]}"; do
+ gh release upload "$tag" "$file" --repo "${GITHUB_REPOSITORY}" --clobber
+done
diff --git a/scripts/ci/install_flutter_sdk.ps1 b/scripts/ci/install_flutter_sdk.ps1
new file mode 100644
index 00000000..793ef485
--- /dev/null
+++ b/scripts/ci/install_flutter_sdk.ps1
@@ -0,0 +1,27 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$FlutterVersion
+)
+
+$ErrorActionPreference = "Stop"
+
+$installRoot = Join-Path ($env:RUNNER_TEMP ?? $env:TEMP) "flutter-sdk"
+$flutterRoot = Join-Path $installRoot "flutter"
+$archiveName = "flutter_windows_${FlutterVersion}-stable.zip"
+$archivePath = Join-Path ($env:RUNNER_TEMP ?? $env:TEMP) $archiveName
+$downloadUrl = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/$archiveName"
+
+New-Item -ItemType Directory -Path $installRoot -Force | Out-Null
+
+if (-not (Test-Path (Join-Path $flutterRoot "bin/flutter.bat"))) {
+ Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath
+ if (Test-Path $flutterRoot) {
+ Remove-Item -Recurse -Force $flutterRoot
+ }
+ Expand-Archive -Path $archivePath -DestinationPath $installRoot -Force
+}
+
+"$flutterRoot\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
+& "$flutterRoot\bin\flutter.bat" --disable-analytics
+& "$flutterRoot\bin\flutter.bat" config --no-analytics
+& "$flutterRoot\bin\flutter.bat" --version
diff --git a/scripts/ci/install_flutter_sdk.sh b/scripts/ci/install_flutter_sdk.sh
new file mode 100755
index 00000000..6ec3b974
--- /dev/null
+++ b/scripts/ci/install_flutter_sdk.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+version="${1:?flutter version is required}"
+runner_temp="${RUNNER_TEMP:-/tmp}"
+install_root="$runner_temp/flutter-sdk"
+archive_name="flutter_linux_${version}-stable.tar.xz"
+
+case "${RUNNER_OS:-$(uname -s)}" in
+ Linux)
+ archive_name="flutter_linux_${version}-stable.tar.xz"
+ download_url="https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/${archive_name}"
+ ;;
+ macOS|Darwin)
+ if [[ "$(uname -m)" == "arm64" ]]; then
+ archive_name="flutter_macos_arm64_${version}-stable.zip"
+ else
+ archive_name="flutter_macos_${version}-stable.zip"
+ fi
+ download_url="https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/${archive_name}"
+ ;;
+ *)
+ echo "Unsupported OS for Flutter install: ${RUNNER_OS:-$(uname -s)}" >&2
+ exit 1
+ ;;
+esac
+
+mkdir -p "$install_root"
+
+if [[ ! -x "$install_root/flutter/bin/flutter" ]]; then
+ archive_path="$runner_temp/$archive_name"
+ curl -fsSL "$download_url" -o "$archive_path"
+ rm -rf "$install_root/flutter"
+ case "$archive_name" in
+ *.tar.xz)
+ tar -xJf "$archive_path" -C "$install_root"
+ ;;
+ *.zip)
+ unzip -q "$archive_path" -d "$install_root"
+ ;;
+ esac
+fi
+
+echo "$install_root/flutter/bin" >> "$GITHUB_PATH"
+"$install_root/flutter/bin/flutter" --disable-analytics
+"$install_root/flutter/bin/flutter" config --no-analytics
+"$install_root/flutter/bin/flutter" --version
diff --git a/scripts/ci/run_code_analysis.sh b/scripts/ci/run_code_analysis.sh
new file mode 100755
index 00000000..51846902
--- /dev/null
+++ b/scripts/ci/run_code_analysis.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+flutter pub get
+flutter analyze
+flutter test
diff --git a/scripts/ci/setup_platform_deps.sh b/scripts/ci/setup_platform_deps.sh
new file mode 100755
index 00000000..86e77ed2
--- /dev/null
+++ b/scripts/ci/setup_platform_deps.sh
@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+platform="${1:?platform is required}"
+
+case "$platform" in
+ linux)
+ sudo apt-get update
+ sudo apt-get install -y \
+ clang \
+ cmake \
+ ninja-build \
+ libgtk-3-dev \
+ pkg-config \
+ libx11-dev \
+ libgl1-mesa-dev \
+ libayatana-appindicator3-dev \
+ dpkg-dev \
+ rpm \
+ imagemagick
+ ;;
+ android)
+ sudo apt-get update
+ sudo apt-get install -y clang cmake ninja-build libgtk-3-dev pkg-config libx11-dev libgl1-mesa-dev
+
+ android_sdk_root="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-/usr/local/lib/android/sdk}}"
+ export ANDROID_SDK_ROOT="$android_sdk_root"
+ export ANDROID_HOME="$android_sdk_root"
+
+ {
+ echo "ANDROID_SDK_ROOT=$android_sdk_root"
+ echo "ANDROID_HOME=$android_sdk_root"
+ } >> "$GITHUB_ENV"
+
+ for candidate in \
+ "$android_sdk_root/cmdline-tools/latest/bin/sdkmanager" \
+ "$android_sdk_root/cmdline-tools/bin/sdkmanager" \
+ "$android_sdk_root/tools/bin/sdkmanager"; do
+ if [[ -x "$candidate" ]]; then
+ sdkmanager="$candidate"
+ break
+ fi
+ done
+
+ if [[ -z "${sdkmanager:-}" ]]; then
+ echo "Android sdkmanager not found under $android_sdk_root" >&2
+ exit 1
+ fi
+
+ yes | "$sdkmanager" --licenses >/dev/null 2>&1 || true
+ "$sdkmanager" "platform-tools" "platforms;android-35" "build-tools;35.0.0" "ndk;27.1.12297006"
+
+ flutter_bin="$(command -v flutter)"
+ flutter_root="$(cd "$(dirname "$flutter_bin")/.." && pwd)"
+ app_version="$(sed -n 's/^version:[[:space:]]*//p' pubspec.yaml | head -n 1)"
+ app_version="${app_version%%+*}"
+ version_code="${GITHUB_RUN_NUMBER:-1}"
+
+ cat > android/local.properties <> "$GITHUB_PATH"
+ ;;
+ *)
+ echo "Unsupported platform: $platform" >&2
+ exit 1
+ ;;
+esac
diff --git a/scripts/package-android-apk.sh b/scripts/package-android-apk.sh
new file mode 100755
index 00000000..32b0bbcb
--- /dev/null
+++ b/scripts/package-android-apk.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+dist_dir="$root_dir/dist/android"
+key_properties="$root_dir/android/key.properties"
+keystore_path="$root_dir/android/upload-keystore.jks"
+
+mkdir -p "$dist_dir"
+
+cleanup() {
+ rm -f "$key_properties" "$keystore_path"
+}
+trap cleanup EXIT
+
+if [[ -n "${ANDROID_KEYSTORE_BASE64:-}" && -n "${ANDROID_KEYSTORE_PASSWORD:-}" && -n "${ANDROID_KEY_ALIAS:-}" && -n "${ANDROID_KEY_PASSWORD:-}" ]]; then
+ printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$keystore_path"
+ cat > "$key_properties" <&1 | grep -q -- '--decode'; then
+ base64 --decode
+ else
+ base64 -D
+ fi
+}
+
+required_vars=(
+ APPLE_CERT_P12_BASE64
+ APPLE_CERT_PASSWORD
+ APPLE_PROVISION_PROFILE_BASE64
+ APPLE_KEYCHAIN_PASSWORD
+)
+
+missing=()
+for var_name in "${required_vars[@]}"; do
+ if [[ -z "${!var_name:-}" ]]; then
+ missing+=("$var_name")
+ fi
+done
+
+if [[ "${#missing[@]}" -gt 0 ]]; then
+ echo "Missing iOS signing secrets: ${missing[*]}" >&2
+ exit 1
+fi
+
+tmp_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/xworkmate-ios.XXXXXX")"
+keychain_name="xworkmate-build.keychain-db"
+keychain_path="$HOME/Library/Keychains/$keychain_name"
+cert_path="$tmp_dir/dist-cert.p12"
+profile_path="$tmp_dir/profile.mobileprovision"
+export_options_path="$tmp_dir/ExportOptions.plist"
+
+cleanup() {
+ security delete-keychain "$keychain_path" >/dev/null 2>&1 || true
+ rm -rf "$tmp_dir"
+}
+trap cleanup EXIT
+
+printf '%s' "$APPLE_CERT_P12_BASE64" | decode_base64 > "$cert_path"
+printf '%s' "$APPLE_PROVISION_PROFILE_BASE64" | decode_base64 > "$profile_path"
+
+security create-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$keychain_name"
+security set-keychain-settings -lut 21600 "$keychain_path"
+security unlock-keychain -p "$APPLE_KEYCHAIN_PASSWORD" "$keychain_path"
+security import "$cert_path" -P "$APPLE_CERT_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path"
+security list-keychains -d user -s "$keychain_path"
+security set-key-partition-list -S apple-tool:,apple: -s -k "$APPLE_KEYCHAIN_PASSWORD" "$keychain_path"
+
+mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
+cp "$profile_path" "$HOME/Library/MobileDevice/Provisioning Profiles/xworkmate.mobileprovision"
+
+sed "s|\${EXPORT_METHOD}|$export_method|g" "$root_dir/ios/ExportOptions.plist" > "$export_options_path"
+
+flutter pub get
+flutter build ipa --release --export-options-plist="$export_options_path"
+
+find "$root_dir/build/ios/ipa" -maxdepth 1 -name '*.ipa' -exec cp {} "$dist_dir/" \;
+
+if ! compgen -G "$dist_dir/*.ipa" >/dev/null; then
+ echo "No IPA was produced under $dist_dir" >&2
+ exit 1
+fi
diff --git a/scripts/package-windows-msi.ps1 b/scripts/package-windows-msi.ps1
new file mode 100644
index 00000000..126cbaa3
--- /dev/null
+++ b/scripts/package-windows-msi.ps1
@@ -0,0 +1,60 @@
+param(
+ [string]$Arch = "amd64"
+)
+
+$ErrorActionPreference = "Stop"
+
+$root = Resolve-Path (Join-Path $PSScriptRoot "..")
+$sourceDir = Join-Path $root "build\windows\x64\runner\Release"
+$distDir = Join-Path $root "dist\windows"
+$wxsPath = Join-Path $root "packaging\windows\main.wxs"
+$versionLine = (Get-Content (Join-Path $root "pubspec.yaml") | Select-String '^version:\s*').ToString()
+$versionValue = ($versionLine -replace '^version:\s*', '').Split('+')[0]
+$msiPath = Join-Path $distDir "xworkmate-$versionValue-$Arch.msi"
+$zipPath = Join-Path $distDir "xworkmate-windows-$Arch.zip"
+
+if (-not (Test-Path $sourceDir)) {
+ throw "Expected Windows release bundle not found: $sourceDir"
+}
+
+New-Item -ItemType Directory -Path $distDir -Force | Out-Null
+
+if (Test-Path $zipPath) {
+ Remove-Item -Force $zipPath
+}
+Compress-Archive -Path (Join-Path $sourceDir '*') -DestinationPath $zipPath
+
+$wixVersion = $versionValue
+if ($wixVersion -notmatch '^\d+\.\d+\.\d+$') {
+ $wixVersion = "0.0.0"
+}
+
+& wix build $wxsPath `
+ -arch x64 `
+ -d SourceDir=$sourceDir `
+ -d ProductVersion=$wixVersion `
+ -o $msiPath
+
+if ($env:WINDOWS_PFX_BASE64 -and $env:WINDOWS_PFX_PASSWORD) {
+ $certDir = Join-Path $env:RUNNER_TEMP "windows-signing"
+ $pfxPath = Join-Path $certDir "codesign.pfx"
+ New-Item -ItemType Directory -Path $certDir -Force | Out-Null
+ [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WINDOWS_PFX_BASE64))
+
+ $signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter signtool.exe |
+ Sort-Object FullName -Descending |
+ Select-Object -First 1
+ if (-not $signtool) {
+ throw "signtool.exe not found after Windows SDK discovery."
+ }
+
+ $subjectArgs = @()
+ if ($env:WINDOWS_CODESIGN_SUBJECT) {
+ $subjectArgs = @("/n", $env:WINDOWS_CODESIGN_SUBJECT)
+ }
+
+ & $signtool.FullName sign /fd SHA256 /f $pfxPath /p $env:WINDOWS_PFX_PASSWORD /tr http://timestamp.digicert.com /td SHA256 @subjectArgs $msiPath
+}
+
+Write-Host "MSI: $msiPath"
+Write-Host "ZIP: $zipPath"