diff --git a/.github/release-notes.md b/.github/release-notes.md new file mode 100644 index 0000000..228cada --- /dev/null +++ b/.github/release-notes.md @@ -0,0 +1,84 @@ +Align synchronizes camera footage and separately recorded audio using waveform +matching, timecode, and recording metadata. + +## What's new in @VERSION@ + +- **Much faster synchronization.** MOV, MP4 and WAV (including RF64/BW64) are + read natively instead of through FFmpeg. On a 22-file test set, a full + synchronization dropped from about 11 s to under 1 s with the same accuracy. +- **Faster media export.** Camera files with replacement audio are written + natively, in a fraction of a second instead of several seconds. +- **Smaller macOS app.** macOS no longer bundles FFmpeg; Linux and Windows ship + a slimmer FFmpeg build (about 9 MB instead of 19 MB). +- **Refined interface.** Round timeline ruler steps, smoother progress, + a real drop zone, clearer search-accuracy settings, and Ctrl shortcuts on + Windows and Linux (F11 for full screen). +- **Updates on every platform.** Align checks for a new release at launch and + offers it in a small card. One click downloads it, verifies its checksum and + installs it: through the Windows installer, by replacing the app on macOS, or + through `dnf`/`pacman` on Fedora and Arch. Copies of 0.1.0 do not check for + updates, so install this release manually once. +- **Windows fixes.** Premiere Pro and Final Cut Pro exports now reference + Windows media with valid file URLs, the app has its icon, and no console + windows flash while it works. + +## Downloads + +| Platform | Installer | +| :--- | :--- | +| macOS 15+ (Apple Silicon) | `Align-@VERSION@-macOS-arm64.dmg` | +| Windows 10/11 (x64) | `Align-@VERSION@-Windows-x64-Setup.exe` | +| Fedora (x86_64) | `Align-@VERSION@-Fedora-x86_64-Setup.run` or `Align-@VERSION@-Fedora-x86_64.rpm` | +| Omarchy / Arch Linux (x86_64) | `Align-@VERSION@-Omarchy-x86_64-Setup.run` or `align-@VERSION@-1-x86_64.pkg.tar.zst` | + +Every build includes the desktop app, the CLI, the MCP server, and the AAF +module. No separate Python installation is needed. + +### macOS + +Open the DMG and drag **Align** to **Applications**. The app is ad-hoc signed, +not Developer ID signed or notarized. If macOS says the app is damaged, move it +to `/Applications`, then for a copy downloaded from this release run: + +```sh +sudo xattr -dr com.apple.quarantine "/Applications/Align.app" +open "/Applications/Align.app" +``` + +### Windows + +Run `Align-@VERSION@-Windows-x64-Setup.exe`. It installs for the current user, +adds Align to the Start menu, and installs the Microsoft VC++ runtime if needed. +Running it over 0.1.0 upgrades that installation. The installer is unsigned, so +SmartScreen may ask for confirmation. + +### Fedora and Omarchy / Arch + +The `.run` installers open a graphical installer in Align's style and ask for +system authorization. Run them as your ordinary user: + +```sh +chmod +x Align-@VERSION@-Fedora-x86_64-Setup.run +./Align-@VERSION@-Fedora-x86_64-Setup.run +``` + +Or install the native package directly with +`sudo dnf install ./Align-@VERSION@-Fedora-x86_64.rpm` or +`sudo pacman -U ./align-@VERSION@-1-x86_64.pkg.tar.zst`. Choose one method; each +`.run` contains the same package. Linux requires glibc 2.39+, a working Vulkan +driver, and GTK 4 with polkit for the graphical installer. Later versions install +from inside Align, behind the same system authorization prompt. + +## Sources and verification + +`align-source-.tar.gz` contains the corresponding Align source, locked +and vendored Rust dependencies, the FFmpeg source used on Linux and Windows, and +the AAF component sources. License notices are included in every installer. +Align is licensed under GPL-3.0-only. + +Each download has a `.sha256` file. Verify from the download directory, for +example `shasum -a 256 -c Align-@VERSION@-macOS-arm64.dmg.sha256`. + +Every installer in this release was built and checked by GitHub Actions: tests +and lint on all three platforms, end-to-end synchronization and export checks, +and a package installation test on Fedora and Arch Linux. diff --git a/.github/workflows/align-rs.yml b/.github/workflows/align-rs.yml index 2216cb1..b23a3fe 100644 --- a/.github/workflows/align-rs.yml +++ b/.github/workflows/align-rs.yml @@ -8,7 +8,7 @@ on: - "Cargo.lock" - "about.toml" - "script/**" - - "Support/aaf/**" + - "Support/**" - "docs/**" - "README.md" - "LICENSE" @@ -21,7 +21,7 @@ on: - "Cargo.lock" - "about.toml" - "script/**" - - "Support/aaf/**" + - "Support/**" - "docs/**" - "README.md" - "LICENSE" @@ -104,19 +104,32 @@ jobs: path: release-smoke/ + # Tags vX.Y.Z build and publish every installer. A manual run on any + # branch builds the same installers as artifacts without publishing. release: name: release binaries (${{ matrix.os }}) env: VELOPACK_VERSION: 1.2.0 needs: check-test - if: startsWith(github.ref, 'refs/tags/align-rs-v') + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [macos-latest, windows-latest, ubuntu-latest] + # Linux binaries are built against the oldest glibc the packages + # support (2.39), so they run on current Fedora and Arch. + os: [macos-latest, windows-latest, ubuntu-24.04] steps: - uses: actions/checkout@v4 + - name: Check the tag matches the workspace version + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + run: | + workspace_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)" + if [[ "${GITHUB_REF_NAME#v}" != "$workspace_version" ]]; then + echo "Tag $GITHUB_REF_NAME does not match Cargo.toml version $workspace_version" >&2 + exit 1 + fi - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - uses: msys2/setup-msys2@v2 @@ -127,7 +140,10 @@ jobs: curl diffutils make mingw-w64-x86_64-gcc nasm pkgconf tar xz - name: Install build dependencies (Linux) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y nasm libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libasound2-dev libudev-dev libvulkan-dev + run: >- + sudo apt-get update && sudo apt-get install -y nasm libxkbcommon-dev + libxkbcommon-x11-dev libwayland-dev libasound2-dev libudev-dev libvulkan-dev + libgtk-4-dev rpm - name: Install assembler (macOS) if: runner.os == 'macOS' run: brew install nasm @@ -184,54 +200,126 @@ jobs: name: sources-${{ runner.os }} path: target/release-sources/ if-no-files-found: error + + - name: Build DMG (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)" + python -m venv target/dmg-venv + target/dmg-venv/bin/python -m pip install dmgbuild==1.6.7 + AAF_DIR="$PWD/target/release" \ + RELEASE_LICENSES="$PWD/dist-bundle/ThirdPartyLicenses" \ + ALIGN_SKIP_LAUNCH_CHECK=1 \ + ./script/package-macos.sh "$PWD/target/macos-package" + mkdir -p installers + PYTHON="$PWD/target/dmg-venv/bin/python" ./script/create-macos-dmg.sh \ + target/macos-package/Align.app "installers/Align-$version-macOS-arm64.dmg" - uses: actions/setup-dotnet@v5 + if: runner.os == 'Windows' with: dotnet-version: "8.0.x" - - name: Install Velopack packager - run: dotnet tool install --global vpk --version ${{ env.VELOPACK_VERSION }} - - name: Build installable update package + - name: Build installer and update feed (Windows) + if: runner.os == 'Windows' shell: bash run: | - version="${GITHUB_REF_NAME#align-rs-v}" - workspace_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)" - if [[ "$version" != "$workspace_version" ]]; then - echo "Tag version $version does not match Cargo.toml version $workspace_version" >&2 - exit 1 - fi - exe="" - icon="Support/AppIcon.png" - extra=() - if [[ "${{ runner.os }}" == "Windows" ]]; then - exe=".exe" - icon="" - elif [[ "${{ runner.os }}" == "macOS" ]]; then - icon="Support/AppIcon.icns" - extra+=(--bundleId com.align.app) - fi - args=(pack --packId com.align.app --packTitle Align --packVersion "$version" --packDir dist-bundle --mainExe "align$exe" --outputDir velopack-release) - if [[ -n "$icon" ]]; then args+=(--icon "$icon"); fi - vpk "${args[@]}" "${extra[@]}" + dotnet tool install --global vpk --version ${{ env.VELOPACK_VERSION }} + ./script/package-windows.sh dist-bundle installers + # The update feed lets installed copies update in place; the + # generic Setup name duplicates the versioned installer. + rm installers/updates/com.align.app-win-Setup.exe + mv installers/updates/* installers/ + rmdir installers/updates + - name: Build Fedora package and installer (Linux) + if: runner.os == 'Linux' + run: | + version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)" + ./script/package-fedora.sh dist-bundle installers + ./script/package-fedora-gui.sh "installers/Align-$version-Fedora-x86_64.rpm" \ + "installers/Align-$version-Fedora-x86_64-Setup.run" + # Artifacts drop Unix permissions; the Arch job unpacks this. + tar -czf linux-stage.tar.gz -C dist-bundle . + - uses: actions/upload-artifact@v4 + if: runner.os == 'Linux' + with: + name: linux-stage + path: linux-stage.tar.gz + if-no-files-found: error + - name: List installers + shell: bash + run: ls -la installers/ && cat installers/*.sha256 - uses: actions/upload-artifact@v4 with: - name: align-${{ runner.os }} - path: dist-bundle/ + name: installers-${{ runner.os }} + path: installers/ + if-no-files-found: error + + arch: + name: Omarchy / Arch package + needs: release + runs-on: ubuntu-24.04 + container: archlinux:latest + steps: + - name: Install packaging tools + run: pacman -Syu --noconfirm --needed base-devel gtk4 pkgconf + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v5 + with: + name: linux-stage + - name: Build package and installer + run: | + version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)" + mkdir stage out + tar -xzf linux-stage.tar.gz -C stage + # makepkg refuses to run as root. + useradd --create-home builder + chown -R builder "$PWD" + su builder -c "bash ./script/package-arch.sh stage out" + bash ./script/package-arch-gui.sh "out/align-$version-1-x86_64.pkg.tar.zst" \ + "out/Align-$version-Omarchy-x86_64-Setup.run" + mkdir installers + mv out/*.pkg.tar.zst* out/*.run* installers/ + - name: Install and run the package + run: | + pacman -U --noconfirm installers/*.pkg.tar.zst + align-cli --help >/dev/null + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | align-mcp | grep -q align_sync + pacman -Qkk align - uses: actions/upload-artifact@v4 with: - name: updates-${{ runner.os }} - path: velopack-release/ + name: installers-Arch + path: installers/ if-no-files-found: error + fedora: + name: Fedora package check + needs: release + runs-on: ubuntu-24.04 + container: fedora:latest + steps: + - uses: actions/download-artifact@v5 + with: + name: installers-Linux + - name: Install and run the package + run: | + dnf install -y ./Align-*-Fedora-x86_64.rpm + align-cli --help >/dev/null + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | align-mcp | grep -q align_sync + rpm -V align + sha256sum -c ./*.sha256 + publish-release: name: publish GitHub release - needs: release - if: startsWith(github.ref, 'refs/tags/align-rs-v') + needs: [release, arch, fedora] + if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest permissions: contents: write steps: + - uses: actions/checkout@v4 - uses: actions/download-artifact@v5 with: - pattern: updates-* + pattern: installers-* path: release-assets merge-multiple: true - uses: actions/download-artifact@v5 @@ -239,33 +327,28 @@ jobs: pattern: sources-* path: release-assets merge-multiple: true - - name: Verify source bundles for every platform + - name: Verify every download shell: bash run: | - for platform in Linux macOS Windows; do - test -s "release-assets/align-source-$platform.tar.gz" - (cd release-assets && sha256sum -c "align-source-$platform.tar.gz.sha256") + version="${GITHUB_REF_NAME#v}" + cd release-assets + for asset in \ + "Align-$version-macOS-arm64.dmg" \ + "Align-$version-Windows-x64-Setup.exe" \ + "Align-$version-Fedora-x86_64-Setup.run" \ + "Align-$version-Fedora-x86_64.rpm" \ + "Align-$version-Omarchy-x86_64-Setup.run" \ + "align-$version-1-x86_64.pkg.tar.zst" \ + align-source-Linux.tar.gz align-source-macOS.tar.gz align-source-Windows.tar.gz; do + test -s "$asset" done + sha256sum -c ./*.sha256 + test -s releases.win.json + sed "s/@VERSION@/$version/g" ../.github/release-notes.md > ../release-notes.md - uses: softprops/action-gh-release@v2 with: + name: Align ${{ github.ref_name }} + body_path: release-notes.md generate_release_notes: true fail_on_unmatched_files: true - body: | - Corresponding source archives for each platform are attached as - `align-source-.tar.gz`, with SHA-256 checksums. They include - Align, vendored Rust dependencies, FFmpeg (Linux and Windows), and AAF - component sources. - Dependency notices are included in the installers. - - ### macOS installation - - The app is not Developer ID signed or notarized. If macOS says the - app is damaged, move `Align.app` to `/Applications`, then run the - following commands in Terminal. Only do this for a copy downloaded - from this project's official release. - - ```sh - sudo xattr -dr com.apple.quarantine "/Applications/Align.app" - open "/Applications/Align.app" - ``` files: release-assets/* diff --git a/Cargo.lock b/Cargo.lock index 7d52cf2..a98b2d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "align-cli" -version = "0.1.0" +version = "0.2.0" dependencies = [ "align-core", "align-decode", @@ -56,7 +56,7 @@ dependencies = [ [[package]] name = "align-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "bincode", "blake3", @@ -73,7 +73,7 @@ dependencies = [ [[package]] name = "align-decode" -version = "0.1.0" +version = "0.2.0" dependencies = [ "align-core", "blake3", @@ -99,16 +99,20 @@ dependencies = [ [[package]] name = "align-gpui" -version = "0.1.0" +version = "0.2.0" dependencies = [ "align-core", "align-decode", "dirs 6.0.0", + "embed-resource", "futures", "gpui", "mimalloc", + "semver", "serde", "serde_json", + "sha2 0.10.9", + "ureq", "usvg", "velopack", "windows-sys 0.59.0", @@ -116,7 +120,7 @@ dependencies = [ [[package]] name = "align-mcp" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index b1e1ef8..2f74a26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ resolver = "2" edition = "2024" rust-version = "1.85" license = "GPL-3.0-only" -version = "0.1.0" +version = "0.2.0" [workspace.dependencies] align-core = { path = "crates/align-core" } diff --git a/Support/Installer/Align.ico b/Support/Installer/Align.ico new file mode 100644 index 0000000..ea0b364 Binary files /dev/null and b/Support/Installer/Align.ico differ diff --git a/Support/Installer/Setup.gif b/Support/Installer/Setup.gif new file mode 100644 index 0000000..393f835 Binary files /dev/null and b/Support/Installer/Setup.gif differ diff --git a/Support/Installer/Setup.png b/Support/Installer/Setup.png new file mode 100644 index 0000000..319c127 Binary files /dev/null and b/Support/Installer/Setup.png differ diff --git a/Support/Linux/PKGBUILD.in b/Support/Linux/PKGBUILD.in new file mode 100644 index 0000000..12bef37 --- /dev/null +++ b/Support/Linux/PKGBUILD.in @@ -0,0 +1,34 @@ +# Generated by script/package-arch.sh from the exact staged Linux release. +pkgname=align +pkgver=@VERSION@ +pkgrel=1 +pkgdesc='Sync your recordings. Keep your edit. Audio and video synchronization' +arch=('x86_64') +url='https://github.com/FedyaLight/align' +license=('GPL-3.0-only') +depends=('glibc>=2.39' 'gcc-libs' 'alsa-lib' 'fontconfig' 'freetype2' + 'libx11' 'libxcb' 'libxkbcommon' 'libxkbcommon-x11' 'wayland' + 'vulkan-icd-loader' 'systemd-libs' 'zlib' 'hicolor-icon-theme') +optdepends=('vulkan-intel: Vulkan driver for Intel graphics' + 'vulkan-radeon: Vulkan driver for AMD graphics' + 'nvidia-utils: Vulkan driver for NVIDIA graphics' + 'xdg-desktop-portal: desktop file dialogs and integration') +# The input is already stripped. Rewriting PyInstaller binaries damages AAF. +options=('!strip' '!debug') +source=('align-payload.tar.gz') +sha256sums=('@PAYLOAD_SHA256@') + +package() { + install -d "$pkgdir/opt/align" "$pkgdir/usr/bin" + cp -a "$srcdir/payload/." "$pkgdir/opt/align/" + for executable in align align-cli align-mcp; do + ln -s "/opt/align/$executable" "$pkgdir/usr/bin/$executable" + done + install -Dm644 "$srcdir/integration/com.align.app.desktop" \ + "$pkgdir/usr/share/applications/com.align.app.desktop" + install -Dm644 "$srcdir/integration/com.align.app.metainfo.xml" \ + "$pkgdir/usr/share/metainfo/com.align.app.metainfo.xml" + install -Dm644 "$srcdir/integration/AppIcon.png" \ + "$pkgdir/usr/share/icons/hicolor/1024x1024/apps/com.align.app.png" + install -Dm644 "$srcdir/payload/LICENSE" "$pkgdir/usr/share/licenses/align/LICENSE" +} diff --git a/Support/Linux/com.align.app.desktop b/Support/Linux/com.align.app.desktop new file mode 100644 index 0000000..75ca85e --- /dev/null +++ b/Support/Linux/com.align.app.desktop @@ -0,0 +1,14 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Align +GenericName=Audio and video synchronization +Comment=Sync your recordings. Keep your edit. +Comment[ru]=Синхронизация аудио и видео для монтажа +Exec=/opt/align/align +Icon=com.align.app +Terminal=false +Categories=AudioVideo;AudioVideoEditing; +Keywords=audio;video;sync;timecode;multicam; +StartupNotify=true +StartupWMClass=Align diff --git a/Support/Linux/com.align.app.metainfo.xml b/Support/Linux/com.align.app.metainfo.xml new file mode 100644 index 0000000..b39ec8b --- /dev/null +++ b/Support/Linux/com.align.app.metainfo.xml @@ -0,0 +1,24 @@ + + + com.align.app + Align + Sync your recordings. Keep your edit. + CC0-1.0 + GPL-3.0-only + +

Synchronize camera footage and separately recorded audio using waveforms, + timecode, and recording metadata. Review your timeline and export it to + Premiere Pro, DaVinci Resolve, Final Cut Pro, or an AAF-compatible editor.

+

Align works locally. Original recordings stay untouched. The desktop app, + command-line tools, AAF module, FFmpeg, and ffprobe are included.

+
+ com.align.app.desktop + https://github.com/FedyaLight/align + https://github.com/FedyaLight/align/issues + align + + + + + +
diff --git a/Support/Linux/installer.c b/Support/Linux/installer.c new file mode 100644 index 0000000..e04244c --- /dev/null +++ b/Support/Linux/installer.c @@ -0,0 +1,172 @@ +/* Branded Linux installer. Define ALIGN_PACKAGE_ARCH for Omarchy/Arch. */ +#include + +static GtkWidget *window, *status_label, *detail_label, *button, *progress; +static char *package_path, *icon_path; +static gboolean installing = FALSE, installed = FALSE; +static guint pulse_id = 0; + +static gboolean pulse(gpointer unused) { + (void)unused; + gtk_progress_bar_pulse(GTK_PROGRESS_BAR(progress)); + return G_SOURCE_CONTINUE; +} + +static gboolean close_request(GtkWindow *w, gpointer unused) { + (void)w; (void)unused; + return installing; +} + +static void finished(GObject *object, GAsyncResult *result, gpointer unused) { + (void)unused; + GError *error = NULL; + char *out = NULL, *err = NULL; + gboolean communicated = g_subprocess_communicate_utf8_finish(G_SUBPROCESS(object), result, &out, &err, &error); + installing = FALSE; + if (pulse_id) { g_source_remove(pulse_id); pulse_id = 0; } + gtk_widget_set_sensitive(button, TRUE); + if (communicated && g_subprocess_get_successful(G_SUBPROCESS(object))) { + installed = TRUE; + gtk_progress_bar_set_fraction(GTK_PROGRESS_BAR(progress), 1.0); + gtk_label_set_text(GTK_LABEL(status_label), "You're ready to sync."); + gtk_label_set_text(GTK_LABEL(detail_label), "Align is installed. Find it in your applications, or open it below."); + gtk_button_set_label(GTK_BUTTON(button), "Open Align"); + } else { + gtk_progress_bar_set_fraction(GTK_PROGRESS_BAR(progress), 0); + gtk_label_set_text(GTK_LABEL(status_label), "Installation wasn't completed."); + const char *reason = error ? error->message : (err && *err ? err : out); + gtk_label_set_text(GTK_LABEL(detail_label), reason && *reason ? reason : "Authorization was cancelled. You can try again."); + gtk_button_set_label(GTK_BUTTON(button), "Try again"); + } + g_clear_error(&error); + g_free(out); g_free(err); + g_object_unref(object); +} + +static void install_clicked(GtkButton *b, gpointer unused) { + (void)b; (void)unused; + GError *error = NULL; + if (installed) { + GSubprocess *app = g_subprocess_new(G_SUBPROCESS_FLAGS_NONE, &error, "/opt/align/align", NULL); + if (app) { g_object_unref(app); gtk_window_destroy(GTK_WINDOW(window)); } + else { + gtk_label_set_text(GTK_LABEL(detail_label), error->message); + g_clear_error(&error); + } + return; + } + // Only the package manager is elevated. UI and Align run as the user. + GSubprocess *process = g_subprocess_new( + G_SUBPROCESS_FLAGS_STDOUT_PIPE | G_SUBPROCESS_FLAGS_STDERR_PIPE, &error, +#ifdef ALIGN_PACKAGE_ARCH + "/usr/bin/pkexec", "/usr/bin/pacman", "-U", "--noconfirm", package_path, NULL); +#else + "/usr/bin/pkexec", "/usr/bin/dnf", "--assumeyes", "install", package_path, NULL); +#endif + if (!process) { + gtk_label_set_text(GTK_LABEL(detail_label), error->message); + g_clear_error(&error); + return; + } + installing = TRUE; + gtk_widget_set_sensitive(button, FALSE); + gtk_button_set_label(GTK_BUTTON(button), "Installing…"); + gtk_label_set_text(GTK_LABEL(status_label), "Installing Align…"); + gtk_label_set_text(GTK_LABEL(detail_label), "Approve the system prompt. Required dependencies will be installed automatically."); + pulse_id = g_timeout_add(90, pulse, NULL); + g_subprocess_communicate_utf8_async(process, NULL, NULL, finished, NULL); +} + +static GtkWidget *label(const char *text, const char *css) { + GtkWidget *widget = gtk_label_new(text); + gtk_label_set_xalign(GTK_LABEL(widget), 0); + gtk_widget_add_css_class(widget, css); + return widget; +} + +static void activate(GtkApplication *app, gpointer unused) { + (void)unused; + GtkCssProvider *css = gtk_css_provider_new(); + gtk_css_provider_load_from_string(css, + "window { background: #18191b; color: #f2f2f7; }" + ".brand { font-size: 30px; font-weight: 700; }" + ".eyebrow { font-size: 10px; letter-spacing: 1px; color: #ababb4; }" + ".headline { font-size: 32px; font-weight: 600; }" + ".subtitle { font-size: 16px; color: #ababb4; }" + ".status { font-size: 15px; font-weight: 600; }" + ".detail { font-size: 13px; color: #ababb4; }" + ".footnote { font-size: 12px; color: #ababb4; }" + "button.install { background: #0a84ff; color: white; border: 0; border-radius: 10px;" + " padding: 12px 26px; font-weight: 600; box-shadow: none; }" + "button.install:hover { background: #409cff; }" + "button.install:disabled { background: #2b2d31; color: #ababb4; }" + "progressbar trough { background: #2b2d31; border: 0; min-height: 5px; border-radius: 4px; }" + "progressbar progress { background: #0a84ff; border: 0; min-height: 5px; border-radius: 4px; }" + ".lane { min-height: 13px; border-radius: 4px; }" + ".blue { background: #0a84ff; } .cyan { background: #32ade6; } .green { background: #30d158; }"); + gtk_style_context_add_provider_for_display(gdk_display_get_default(), GTK_STYLE_PROVIDER(css), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION); + g_object_unref(css); + window = gtk_application_window_new(app); + gtk_window_set_title(GTK_WINDOW(window), "Install Align"); + gtk_window_set_default_size(GTK_WINDOW(window), 720, 520); + gtk_window_set_resizable(GTK_WINDOW(window), FALSE); + g_signal_connect(window, "close-request", G_CALLBACK(close_request), NULL); + GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_set_margin_start(box, 48); gtk_widget_set_margin_end(box, 48); + gtk_widget_set_margin_top(box, 36); gtk_widget_set_margin_bottom(box, 32); + gtk_window_set_child(GTK_WINDOW(window), box); + GtkWidget *brand = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 18); + GtkWidget *icon = gtk_image_new_from_file(icon_path); + gtk_image_set_pixel_size(GTK_IMAGE(icon), 64); + gtk_box_append(GTK_BOX(brand), icon); + GtkWidget *name = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4); + gtk_widget_set_valign(name, GTK_ALIGN_CENTER); + gtk_box_append(GTK_BOX(name), label("Align", "brand")); + gtk_box_append(GTK_BOX(name), label("AUDIO & VIDEO SYNC", "eyebrow")); + gtk_box_append(GTK_BOX(brand), name); gtk_box_append(GTK_BOX(box), brand); + GtkWidget *title = label("Everything in sync.", "headline"); + gtk_widget_set_margin_top(title, 30); gtk_box_append(GTK_BOX(box), title); + GtkWidget *subtitle = label("Your recordings. One timeline.", "subtitle"); + gtk_widget_set_margin_top(subtitle, 8); gtk_box_append(GTK_BOX(box), subtitle); + const char *colors[] = {"blue", "cyan", "green"}; + for (int i = 0; i < 3; i++) { + GtkWidget *lane = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + gtk_widget_add_css_class(lane, "lane"); gtk_widget_add_css_class(lane, colors[i]); + gtk_widget_set_margin_top(lane, i == 0 ? 30 : 7); + gtk_widget_set_size_request(lane, 500 - i * 55, 13); + gtk_widget_set_halign(lane, GTK_ALIGN_START); gtk_box_append(GTK_BOX(box), lane); + } + status_label = label("A little setup. Then you're in sync.", "status"); + gtk_widget_set_margin_top(status_label, 28); gtk_box_append(GTK_BOX(box), status_label); + detail_label = label("Includes the app, media tools, AAF support and command-line tools.", "detail"); + gtk_widget_set_margin_top(detail_label, 8); + gtk_label_set_wrap(GTK_LABEL(detail_label), TRUE); + gtk_label_set_max_width_chars(GTK_LABEL(detail_label), 76); + gtk_label_set_lines(GTK_LABEL(detail_label), 3); + gtk_label_set_ellipsize(GTK_LABEL(detail_label), PANGO_ELLIPSIZE_END); + gtk_box_append(GTK_BOX(box), detail_label); + progress = gtk_progress_bar_new(); + gtk_widget_set_margin_top(progress, 24); gtk_box_append(GTK_BOX(box), progress); + GtkWidget *footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 16); + gtk_widget_set_margin_top(footer, 24); + GtkWidget *note = label("Free. Open source. Yours.", "footnote"); + gtk_widget_set_hexpand(note, TRUE); gtk_box_append(GTK_BOX(footer), note); + button = gtk_button_new_with_label("Install Align"); + gtk_widget_add_css_class(button, "install"); + g_signal_connect(button, "clicked", G_CALLBACK(install_clicked), NULL); + gtk_box_append(GTK_BOX(footer), button); gtk_box_append(GTK_BOX(box), footer); + gtk_window_present(GTK_WINDOW(window)); +} + +int main(int argc, char **argv) { + if (argc != 3 || !g_file_test(argv[1], G_FILE_TEST_IS_REGULAR)) { + g_printerr("Usage: align-installer PACKAGE ICON.png\n"); return 2; + } + package_path = g_canonicalize_filename(argv[1], NULL); + icon_path = g_canonicalize_filename(argv[2], NULL); + GtkApplication *app = gtk_application_new("com.align.installer", G_APPLICATION_NON_UNIQUE); + g_signal_connect(app, "activate", G_CALLBACK(activate), NULL); + int status = g_application_run(G_APPLICATION(app), 1, argv); + g_object_unref(app); g_free(package_path); g_free(icon_path); + return status; +} diff --git a/crates/align-decode/src/aaf.rs b/crates/align-decode/src/aaf.rs index eb35c57..bccedc7 100644 --- a/crates/align-decode/src/aaf.rs +++ b/crates/align-decode/src/aaf.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use std::{ path::{Path, PathBuf}, - process::{Command, Stdio}, + process::Stdio, sync::atomic::{AtomicBool, Ordering}, }; @@ -198,7 +198,7 @@ pub fn read_timeline(path: &Path, cancel: &AtomicBool) -> Result) -> Command { + #[allow(unused_mut)] + let mut command = Command::new(program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + command +} + /// Locate `ffprobe`/`ffmpeg`: explicit env override → executable's dir /// (bundled sidecar layout) → PATH. pub fn resolve_bin(env_var: &str, name: &str) -> Option { @@ -66,7 +81,7 @@ pub fn ffmpeg_bin() -> Option { pub fn inspect(path: &Path) -> Result { let bin = ffprobe_bin().ok_or_else(|| DecodeError::FfmpegMissing("ffprobe not found".into()))?; - let out = Command::new(bin) + let out = command(bin) .args([ "-v", "error", @@ -291,7 +306,7 @@ fn video_timing_uncached( use align_core::canonical_frame_duration; use std::io::BufRead; - let mut child = Command::new(bin) + let mut child = command(bin) .args([ "-v", "error", @@ -311,7 +326,7 @@ fn video_timing_uncached( let stdout = child.stdout.take().ok_or(DecodeError::UnsupportedOutput)?; // Nominal rate + dimensions from the header call. - let header_json = Command::new(bin) + let header_json = command(bin) .args([ "-v", "error", @@ -369,7 +384,7 @@ fn video_timing_uncached( /// Full video probe: header facts + timing walk + timecode tag. /// `None` when the container has no usable video stream. pub fn inspect_video(path: &Path) -> Option { - let header_json = Command::new(ffprobe_bin()?) + let header_json = command(ffprobe_bin()?) .args([ "-v", "error", @@ -423,7 +438,7 @@ impl AudioPipe { ) -> Result { let bin = ffmpeg_bin().ok_or_else(|| DecodeError::FfmpegMissing("ffmpeg not found".into()))?; - let mut cmd = Command::new(bin); + let mut cmd = command(bin); cmd.args(["-v", "error", "-hide_banner", "-i"]).arg(path); if let Some((start, duration)) = window { cmd.args([ @@ -524,7 +539,7 @@ impl AudioPipeI32 { ) -> Result { let bin = ffmpeg_bin().ok_or_else(|| DecodeError::FfmpegMissing("ffmpeg not found".into()))?; - let mut cmd = Command::new(bin); + let mut cmd = command(bin); cmd.args(["-v", "error", "-hide_banner", "-i"]).arg(path); if let Some((start, duration)) = window { cmd.args([ diff --git a/crates/align-gpui/Cargo.toml b/crates/align-gpui/Cargo.toml index 422c818..7e42596 100644 --- a/crates/align-gpui/Cargo.toml +++ b/crates/align-gpui/Cargo.toml @@ -20,10 +20,17 @@ serde.workspace = true serde_json.workspace = true dirs.workspace = true velopack = "1.2.0" +# Same HTTP client and version parser Velopack uses; no extra crates. +ureq = "3" +semver = "1" +sha2.workspace = true [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_UI_WindowsAndMessaging"] } +[target.'cfg(windows)'.build-dependencies] +embed-resource = "3.0.11" + [dev-dependencies] # Same renderer the GPUI sprite atlas uses: the test below parses every # icon exactly like `SvgRenderer::render_pixmap` does. diff --git a/crates/align-gpui/build.rs b/crates/align-gpui/build.rs index b77dac6..86d8d45 100644 --- a/crates/align-gpui/build.rs +++ b/crates/align-gpui/build.rs @@ -1,4 +1,5 @@ -//! Build identifier for the diagnostics panel (proves which binary is running). +//! Build identifier for the diagnostics panel (proves which binary is running), +//! and on Windows the executable's icon, manifest and version information. fn main() { let hash = std::process::Command::new("git") @@ -21,4 +22,72 @@ fn main() { println!("cargo:rerun-if-changed=build.rs"); // Rebuild the id when the tree changes (best effort; timestamp fallback). println!("cargo:rerun-if-changed=../../.git/HEAD"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + windows_resources(); + } } + +/// Embed the icon Explorer, the taskbar and installer shortcuts show, and +/// version details from Cargo.toml. GPUI links the application manifest +/// (per-monitor DPI, common controls) itself; a second one would collide. +#[cfg(windows)] +fn windows_resources() { + use std::path::PathBuf; + let support = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../Support/Installer"); + let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let version = env!("CARGO_PKG_VERSION"); + let mut numbers: Vec<&str> = version.split(['.', '-', '+']).take(3).collect(); + numbers.resize(3, "0"); + let [major, minor, patch] = [numbers[0], numbers[1], numbers[2]]; + println!( + "cargo:rerun-if-changed={}", + support.join("Align.ico").display() + ); + let icon = support.join("Align.ico").canonicalize().unwrap(); + // rc.exe accepts forward slashes; the verbatim prefix it does not. + let icon = align_path(&icon); + let rc = format!( + r#"1 ICON "{icon}" +1 VERSIONINFO +FILEVERSION {major},{minor},{patch},0 +PRODUCTVERSION {major},{minor},{patch},0 +FILEOS 0x40004 +FILETYPE 1 +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "Align contributors\0" + VALUE "FileDescription", "Align\0" + VALUE "FileVersion", "{version}\0" + VALUE "InternalName", "align\0" + VALUE "OriginalFilename", "align.exe\0" + VALUE "ProductName", "Align\0" + VALUE "ProductVersion", "{version}\0" + VALUE "LegalCopyright", "Copyright 2026 Align contributors. GPL-3.0-only.\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END +"# + ); + let rc_path = out.join("Align.rc"); + std::fs::write(&rc_path, rc).unwrap(); + embed_resource::compile(&rc_path, embed_resource::NONE) + .manifest_optional() + .unwrap(); +} + +#[cfg(windows)] +fn align_path(path: &std::path::Path) -> String { + let text = path.display().to_string().replace('\\', "/"); + text.strip_prefix("//?/").map(str::to_owned).unwrap_or(text) +} + +/// Cross-compiled Windows builds skip resources (no resource compiler). +#[cfg(not(windows))] +fn windows_resources() {} diff --git a/crates/align-gpui/src/main.rs b/crates/align-gpui/src/main.rs index d2e2422..ed96049 100644 --- a/crates/align-gpui/src/main.rs +++ b/crates/align-gpui/src/main.rs @@ -1,4 +1,6 @@ //! align: native GPUI desktop app (Metal / DirectX / Vulkan via Blade). +// Release builds on Windows are GUI programs: no console window behind them. +#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; @@ -358,6 +360,7 @@ fn main() { let view = cx.new(move |cx| { let mut app = views::AlignApp::new(cx); app.data.add_paths(initial_paths); + app.check_for_updates_on_launch(cx); app }); cx.set_global(AppView(view.clone())); diff --git a/crates/align-gpui/src/updater.rs b/crates/align-gpui/src/updater.rs index ff8a83c..7fcca6d 100644 --- a/crates/align-gpui/src/updater.rs +++ b/crates/align-gpui/src/updater.rs @@ -1,10 +1,25 @@ -//! Cross-platform application updates backed by Velopack packages on GitHub Releases. +//! Application updates from GitHub Releases, on every platform. +//! +//! Windows Setup installs update through Velopack. The macOS app and the +//! Fedora and Arch packages are not Velopack installations: for those the +//! release's own installer asset is downloaded, checked against its +//! `.sha256` file and installed in place (the app bundle is replaced; the +//! package goes through the system package manager behind a polkit prompt). +//! Anything else — a portable copy, an app run from the DMG, a development +//! build — is told about the new version and offered the release page. +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use sha2::{Digest, Sha256}; use velopack::{UpdateCheck, UpdateInfo, UpdateManager, VelopackAsset, sources::GithubSource}; pub const REPOSITORY_URL: &str = "https://github.com/FedyaLight/align"; /// One-time donation, the `custom` link in `.github/FUNDING.yml`. pub const DONATION_URL: &str = "https://www.patreon.com/fedyalight/posts/support-once-170786295"; +const LATEST_RELEASE_API: &str = "https://api.github.com/repos/FedyaLight/align/releases/latest"; #[derive(Default)] pub enum UpdateState { @@ -12,48 +27,106 @@ pub enum UpdateState { Idle, Checking, Current, - Available { + Available(Update), + Downloading { + version: String, + }, + Ready(Prepared), + Installing { + version: String, + }, + Failed, +} + +/// A newer release and how this copy of Align can reach it. +pub enum Update { + Velopack { manager: UpdateManager, update: Box, }, - Downloading { + Package { + install: Install, version: String, + asset: ReleaseAsset, }, - Ready { + /// No in-place route: open the release page. + Manual { version: String, page: String }, +} + +/// A downloaded update, ready to install. +pub enum Prepared { + Velopack { manager: UpdateManager, asset: VelopackAsset, }, - Failed, + Package { + install: Install, + version: String, + file: PathBuf, + }, +} + +/// How a non-Velopack installation replaces itself. +#[derive(Clone, Debug, PartialEq)] +pub enum Install { + /// The app bundle at this path, in a folder the user can write to. + MacApp(PathBuf), + Rpm, + Pacman, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ReleaseAsset { + pub name: String, + pub url: String, + pub checksum_url: String, } impl UpdateState { pub fn version(&self) -> Option<&str> { match self { - Self::Available { update, .. } => Some(&update.TargetFullRelease.Version), - Self::Downloading { version } => Some(version), - Self::Ready { asset, .. } => Some(&asset.Version), + Self::Available(update) => Some(update.version()), + Self::Downloading { version } | Self::Installing { version } => Some(version), + Self::Ready(Prepared::Velopack { asset, .. }) => Some(&asset.Version), + Self::Ready(Prepared::Package { version, .. }) => Some(version), _ => None, } } pub fn is_busy(&self) -> bool { - matches!(self, Self::Checking | Self::Downloading { .. }) + matches!( + self, + Self::Checking | Self::Downloading { .. } | Self::Installing { .. } + ) } } -pub fn check() -> UpdateState { - let source = GithubSource::new(REPOSITORY_URL, None, false); - let manager = match UpdateManager::new(source, None, None) { - Ok(manager) => manager, - Err(error) => { - eprintln!("Align updater is unavailable: {error}"); - return UpdateState::Failed; +impl Update { + pub fn version(&self) -> &str { + match self { + Self::Velopack { update, .. } => &update.TargetFullRelease.Version, + Self::Package { version, .. } | Self::Manual { version, .. } => version, } - }; + } +} - match manager.check_for_updates() { - Ok(UpdateCheck::UpdateAvailable(update)) => UpdateState::Available { manager, update }, - Ok(UpdateCheck::NoUpdateAvailable | UpdateCheck::RemoteIsEmpty) => UpdateState::Current, +pub fn check() -> UpdateState { + if let Ok(manager) = + UpdateManager::new(GithubSource::new(REPOSITORY_URL, None, false), None, None) + { + return match manager.check_for_updates() { + Ok(UpdateCheck::UpdateAvailable(update)) => { + UpdateState::Available(Update::Velopack { manager, update }) + } + Ok(UpdateCheck::NoUpdateAvailable | UpdateCheck::RemoteIsEmpty) => UpdateState::Current, + Err(error) => { + eprintln!("Could not check for Align updates: {error}"); + UpdateState::Failed + } + }; + } + match latest_release() { + Ok(release) => evaluate(&release, env!("CARGO_PKG_VERSION"), detect_install()), Err(error) => { eprintln!("Could not check for Align updates: {error}"); UpdateState::Failed @@ -61,21 +134,331 @@ pub fn check() -> UpdateState { } } -pub fn download(manager: UpdateManager, update: Box) -> UpdateState { - let asset = update.TargetFullRelease.clone(); - match manager.download_updates(&update, None) { - Ok(()) => UpdateState::Ready { manager, asset }, - Err(error) => { - eprintln!("Could not download the Align update: {error}"); - UpdateState::Failed +pub fn download(update: Update) -> UpdateState { + match update { + Update::Velopack { manager, update } => { + let asset = update.TargetFullRelease.clone(); + match manager.download_updates(&update, None) { + Ok(()) => UpdateState::Ready(Prepared::Velopack { manager, asset }), + Err(error) => { + eprintln!("Could not download the Align update: {error}"); + UpdateState::Failed + } + } + } + Update::Package { + install, + version, + asset, + } => match download_verified(&asset) { + Ok(file) => UpdateState::Ready(Prepared::Package { + install, + version, + file, + }), + Err(error) => { + eprintln!("Could not download the Align update: {error}"); + UpdateState::Failed + } + }, + Update::Manual { .. } => UpdateState::Failed, + } +} + +/// Install a downloaded package in place; the caller relaunches. +pub fn install(install: &Install, file: &Path) -> Result<(), String> { + match install { + Install::MacApp(bundle) => replace_app_bundle(bundle, file), + Install::Rpm => run(Command::new("/usr/bin/pkexec").args([ + "/usr/bin/dnf".as_ref(), + "--assumeyes".as_ref(), + "install".as_ref(), + file.as_os_str(), + ])), + Install::Pacman => run(Command::new("/usr/bin/pkexec").args([ + "/usr/bin/pacman".as_ref(), + "-U".as_ref(), + "--noconfirm".as_ref(), + file.as_os_str(), + ])), + } +} + +/// Start the freshly installed copy. The caller quits this one. +pub fn relaunch(install: &Install) -> Result<(), String> { + let mut command = match install { + Install::MacApp(bundle) => { + let mut command = Command::new("/usr/bin/open"); + command.arg("-n").arg(bundle); + command + } + Install::Rpm | Install::Pacman => Command::new("/opt/align/align"), + }; + command.spawn().map(drop).map_err(|error| error.to_string()) +} + +// ---------------------------------------------------------------- GitHub + +#[derive(Debug, PartialEq)] +struct Release { + version: String, + page: String, + assets: Vec<(String, String)>, +} + +fn agent() -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(15))) + .timeout_recv_response(Some(Duration::from_secs(30))) + .user_agent(concat!("Align/", env!("CARGO_PKG_VERSION"))) + .build() + .into() +} + +fn latest_release() -> Result { + let body = agent() + .get(LATEST_RELEASE_API) + .header("Accept", "application/vnd.github+json") + .call() + .map_err(|error| error.to_string())? + .body_mut() + .read_to_string() + .map_err(|error| error.to_string())?; + parse_release(&body).ok_or_else(|| "unexpected release response".to_owned()) +} + +fn parse_release(body: &str) -> Option { + let json: serde_json::Value = serde_json::from_str(body).ok()?; + let tag = json["tag_name"].as_str()?; + Some(Release { + version: tag.trim_start_matches('v').to_owned(), + page: json["html_url"].as_str()?.to_owned(), + assets: json["assets"] + .as_array()? + .iter() + .filter_map(|asset| { + Some(( + asset["name"].as_str()?.to_owned(), + asset["browser_download_url"].as_str()?.to_owned(), + )) + }) + .collect(), + }) +} + +/// Compare the latest release with the running version and pick the +/// installer asset this installation can apply. +fn evaluate(release: &Release, current: &str, install: Option) -> UpdateState { + let (Ok(latest), Ok(current)) = ( + semver::Version::parse(&release.version), + semver::Version::parse(current), + ) else { + return UpdateState::Failed; + }; + if latest <= current { + return UpdateState::Current; + } + let version = release.version.clone(); + let wanted = install + .as_ref() + .map(|install| asset_name(install, &version)); + let asset = wanted.and_then(|name| { + let url = |wanted: &str| { + release + .assets + .iter() + .find(|(name, _)| name == wanted) + .map(|(_, url)| url.clone()) + }; + Some(ReleaseAsset { + url: url(&name)?, + checksum_url: url(&format!("{name}.sha256"))?, + name, + }) + }); + UpdateState::Available(match (install, asset) { + (Some(install), Some(asset)) => Update::Package { + install, + version, + asset, + }, + _ => Update::Manual { + version, + page: release.page.clone(), + }, + }) +} + +fn asset_name(install: &Install, version: &str) -> String { + match install { + Install::MacApp(_) => format!("Align-{version}-macOS-arm64.dmg"), + Install::Rpm => format!("Align-{version}-Fedora-x86_64.rpm"), + Install::Pacman => format!("align-{version}-1-x86_64.pkg.tar.zst"), + } +} + +fn download_verified(asset: &ReleaseAsset) -> Result { + let agent = agent(); + let expected = agent + .get(&asset.checksum_url) + .call() + .map_err(|error| error.to_string())? + .body_mut() + .read_to_string() + .map_err(|error| error.to_string())?; + let expected = expected + .split_whitespace() + .next() + .ok_or("empty checksum file")? + .to_ascii_lowercase(); + let dir = std::env::temp_dir().join(format!("align-update-{}", std::process::id())); + std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + let path = dir.join(&asset.name); + let mut file = std::fs::File::create(&path).map_err(|error| error.to_string())?; + let mut reader = agent + .get(&asset.url) + .call() + .map_err(|error| error.to_string())? + .into_body() + .into_reader(); + let mut hasher = Sha256::new(); + let mut buffer = vec![0; 1 << 16]; + loop { + let read = reader + .read(&mut buffer) + .map_err(|error| error.to_string())?; + if read == 0 { + break; } + hasher.update(&buffer[..read]); + file.write_all(&buffer[..read]) + .map_err(|error| error.to_string())?; } + file.sync_all().map_err(|error| error.to_string())?; + let actual: String = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + if actual != expected { + let _ = std::fs::remove_file(&path); + return Err(format!("checksum mismatch for {}", asset.name)); + } + Ok(path) +} + +// ---------------------------------------------------------- installation + +/// How this copy was installed, when it can update itself in place. +fn detect_install() -> Option { + let exe = std::env::current_exe().ok()?.canonicalize().ok()?; + if cfg!(target_os = "macos") { + let bundle = exe + .ancestors() + .find(|path| path.extension().is_some_and(|extension| extension == "app"))?; + // A copy running from the mounted DMG or from a read-only folder + // cannot be replaced; it is pointed at the download instead. + if bundle.starts_with("/Volumes") || !writable(bundle.parent()?) { + return None; + } + return Some(Install::MacApp(bundle.to_path_buf())); + } + if cfg!(target_os = "linux") && exe.starts_with("/opt/align") { + let owns = |tool: &str, args: &[&str]| { + Path::new(tool).exists() + && Command::new(tool) + .args(args) + .output() + .is_ok_and(|output| output.status.success()) + }; + if !Path::new("/usr/bin/pkexec").exists() { + return None; + } + if owns("/usr/bin/dnf", &["--version"]) && owns("/usr/bin/rpm", &["-q", "align"]) { + return Some(Install::Rpm); + } + if owns("/usr/bin/pacman", &["-Q", "align"]) { + return Some(Install::Pacman); + } + } + None +} + +fn writable(dir: &Path) -> bool { + let probe = dir.join(format!(".align-update-{}", std::process::id())); + let ok = std::fs::create_dir(&probe).is_ok(); + let _ = std::fs::remove_dir(&probe); + ok +} + +fn run(command: &mut Command) -> Result<(), String> { + let output = command.output().map_err(|error| error.to_string())?; + if output.status.success() { + return Ok(()); + } + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + Err(if detail.is_empty() { + format!("installer exited with {}", output.status) + } else { + detail + }) +} + +/// Copy the new bundle out of the disk image next to the current one, then +/// swap the two, so a failure leaves the installed app untouched. +fn replace_app_bundle(bundle: &Path, dmg: &Path) -> Result<(), String> { + let parent = bundle.parent().ok_or("app bundle has no folder")?; + let mount = dmg.with_extension("mount"); + std::fs::create_dir_all(&mount).map_err(|error| error.to_string())?; + run(Command::new("/usr/bin/hdiutil") + .args([ + "attach", + "-nobrowse", + "-readonly", + "-noautoopen", + "-mountpoint", + ]) + .arg(&mount) + .arg(dmg))?; + let staged = parent.join(".Align-update.app"); + let _ = std::fs::remove_dir_all(&staged); + let copied = run(Command::new("/usr/bin/ditto") + .arg(mount.join("Align.app")) + .arg(&staged)); + let _ = run(Command::new("/usr/bin/hdiutil") + .args(["detach", "-force"]) + .arg(&mount)); + copied?; + let _ = run(Command::new("/usr/bin/xattr") + .args(["-dr", "com.apple.quarantine"]) + .arg(&staged)); + let previous = parent.join(".Align-previous.app"); + let _ = std::fs::remove_dir_all(&previous); + std::fs::rename(bundle, &previous).map_err(|error| error.to_string())?; + if let Err(error) = std::fs::rename(&staged, bundle) { + let _ = std::fs::rename(&previous, bundle); + return Err(error.to_string()); + } + let _ = std::fs::remove_dir_all(&previous); + let _ = std::fs::remove_file(dmg); + Ok(()) } #[cfg(test)] mod tests { use super::*; + fn release(version: &str, assets: &[&str]) -> Release { + Release { + version: version.into(), + page: format!("{REPOSITORY_URL}/releases/tag/v{version}"), + assets: assets + .iter() + .map(|name| (name.to_string(), format!("https://example.invalid/{name}"))) + .collect(), + } + } + #[test] fn transient_update_states_are_busy() { assert!(UpdateState::Checking.is_busy()); @@ -85,7 +468,109 @@ mod tests { } .is_busy() ); + assert!( + UpdateState::Installing { + version: "1.2.3".into() + } + .is_busy() + ); assert!(!UpdateState::Idle.is_busy()); assert!(!UpdateState::Current.is_busy()); } + + #[test] + fn github_release_responses_parse() { + let body = r#"{"tag_name":"v0.3.0","html_url":"https://github.com/FedyaLight/align/releases/tag/v0.3.0", + "assets":[{"name":"Align-0.3.0-Fedora-x86_64.rpm","browser_download_url":"https://x/rpm"}]}"#; + let release = parse_release(body).unwrap(); + assert_eq!(release.version, "0.3.0"); + assert_eq!( + release.assets, + vec![( + "Align-0.3.0-Fedora-x86_64.rpm".into(), + "https://x/rpm".into() + )] + ); + assert!(parse_release(r#"{"message":"Not Found"}"#).is_none()); + } + + #[test] + fn only_newer_releases_are_offered() { + let latest = release("0.2.0", &[]); + assert!(matches!( + evaluate(&latest, "0.2.0", None), + UpdateState::Current + )); + assert!(matches!( + evaluate(&latest, "0.10.0", None), + UpdateState::Current + )); + assert!(matches!( + evaluate(&latest, "0.1.0", None), + UpdateState::Available(Update::Manual { .. }) + )); + } + + #[test] + fn installations_pick_their_own_verified_asset() { + let latest = release( + "0.3.0", + &[ + "Align-0.3.0-macOS-arm64.dmg", + "Align-0.3.0-macOS-arm64.dmg.sha256", + "Align-0.3.0-Fedora-x86_64.rpm", + "Align-0.3.0-Fedora-x86_64.rpm.sha256", + "align-0.3.0-1-x86_64.pkg.tar.zst", + ], + ); + let UpdateState::Available(Update::Package { asset, .. }) = + evaluate(&latest, "0.2.0", Some(Install::Rpm)) + else { + panic!("the RPM should update in place"); + }; + assert_eq!(asset.name, "Align-0.3.0-Fedora-x86_64.rpm"); + assert!(asset.checksum_url.ends_with(".rpm.sha256")); + let mac = Install::MacApp("/Applications/Align.app".into()); + assert!(matches!( + evaluate(&latest, "0.2.0", Some(mac)), + UpdateState::Available(Update::Package { .. }) + )); + // Without a checksum file the package is never installed blindly. + assert!(matches!( + evaluate(&latest, "0.2.0", Some(Install::Pacman)), + UpdateState::Available(Update::Manual { .. }) + )); + } +} + +#[cfg(test)] +mod network_tests { + use super::*; + + /// Hits the live GitHub API: `cargo test -p align-gpui -- --ignored`. + #[test] + #[ignore] + fn the_latest_release_is_readable() { + let release = latest_release().unwrap(); + assert!(semver::Version::parse(&release.version).is_ok()); + assert!( + release + .assets + .iter() + .any(|(name, _)| name.ends_with(".sha256")) + ); + let asset = release + .assets + .iter() + .find(|(name, _)| name.ends_with(".rpm")) + .map(|(name, url)| ReleaseAsset { + name: name.clone(), + url: url.clone(), + checksum_url: format!("{url}.sha256"), + }) + .unwrap(); + let file = download_verified(&asset).unwrap(); + assert!(std::fs::metadata(&file).unwrap().len() > 1_000_000); + std::fs::remove_file(file).unwrap(); + } } diff --git a/crates/align-gpui/src/views.rs b/crates/align-gpui/src/views.rs index c653a1e..2ce4577 100644 --- a/crates/align-gpui/src/views.rs +++ b/crates/align-gpui/src/views.rs @@ -827,6 +827,8 @@ pub struct AlignApp { timeline_motion_generation: u64, progress_motion: ProgressMotion, update_state: UpdateState, + /// The update card was closed; the About panel still offers the update. + update_notice_dismissed: bool, } impl Focusable for AlignApp { @@ -865,6 +867,7 @@ impl AlignApp { timeline_motion_generation: 0, progress_motion: ProgressMotion::new(), update_state: UpdateState::default(), + update_notice_dismissed: false, } } @@ -891,6 +894,19 @@ impl AlignApp { } pub(crate) fn check_for_updates(&mut self, cx: &mut Context) { + self.run_update_check(false, cx); + } + + /// Quiet check at launch: a new release shows the update card; being + /// offline or up to date shows nothing. Development builds skip it. + pub(crate) fn check_for_updates_on_launch(&mut self, cx: &mut Context) { + if cfg!(debug_assertions) || std::env::var_os("ALIGN_NO_UPDATE_CHECK").is_some() { + return; + } + self.run_update_check(true, cx); + } + + fn run_update_check(&mut self, quiet: bool, cx: &mut Context) { if self.update_state.is_busy() { return; } @@ -905,7 +921,10 @@ impl AlignApp { cx.spawn(async move |_, cx| { let state = rx.await.unwrap_or(UpdateState::Failed); let _ = view.update(cx, |this, cx| { - this.update_state = state; + this.update_state = match state { + UpdateState::Failed | UpdateState::Current if quiet => UpdateState::Idle, + state => state, + }; cx.notify(); }); }) @@ -914,17 +933,22 @@ impl AlignApp { fn download_update(&mut self, cx: &mut Context) { let state = std::mem::take(&mut self.update_state); - let UpdateState::Available { manager, update } = state else { + let UpdateState::Available(update) = state else { self.update_state = state; return; }; - let version = update.TargetFullRelease.Version.clone(); + if let updater::Update::Manual { page, .. } = &update { + cx.open_url(page); + self.update_state = UpdateState::Available(update); + return; + } + let version = update.version().to_owned(); self.update_state = UpdateState::Downloading { version }; cx.notify(); let (tx, rx) = futures::channel::oneshot::channel(); std::thread::spawn(move || { - let _ = tx.send(updater::download(manager, update)); + let _ = tx.send(updater::download(update)); }); let view = cx.entity(); cx.spawn(async move |_, cx| { @@ -937,7 +961,7 @@ impl AlignApp { .detach(); } - fn restart_and_update(&mut self) { + fn restart_and_update(&mut self, cx: &mut Context) { if matches!( self.data.operation, Operation::Synchronizing | Operation::Exporting | Operation::Repairing @@ -945,15 +969,52 @@ impl AlignApp { return; } let state = std::mem::take(&mut self.update_state); - let UpdateState::Ready { manager, asset } = state else { + let UpdateState::Ready(prepared) = state else { self.update_state = state; return; }; - super::icons::cleanup(); - align_decode::media_assets::cleanup(); - if let Err(error) = manager.apply_updates_and_restart(&asset) { - eprintln!("Could not install the Align update: {error}"); - self.update_state = UpdateState::Failed; + match prepared { + updater::Prepared::Velopack { manager, asset } => { + super::icons::cleanup(); + align_decode::media_assets::cleanup(); + if let Err(error) = manager.apply_updates_and_restart(&asset) { + eprintln!("Could not install the Align update: {error}"); + self.update_state = UpdateState::Failed; + } + } + updater::Prepared::Package { + install, + version, + file, + } => { + // The package manager may wait on an authorization prompt, + // so it runs off the UI thread; Align restarts once it is done. + self.update_state = UpdateState::Installing { version }; + cx.notify(); + let (tx, rx) = futures::channel::oneshot::channel(); + std::thread::spawn(move || { + let result = updater::install(&install, &file) + .and_then(|()| updater::relaunch(&install)); + let _ = tx.send(result); + }); + let view = cx.entity(); + cx.spawn(async move |_, cx| { + let result = rx.await.unwrap_or_else(|_| Err("cancelled".into())); + let _ = view.update(cx, |this, cx| match result { + Ok(()) => { + super::icons::cleanup(); + align_decode::media_assets::cleanup(); + cx.quit(); + } + Err(error) => { + eprintln!("Could not install the Align update: {error}"); + this.update_state = UpdateState::Failed; + cx.notify(); + } + }); + }) + .detach(); + } } } @@ -2597,6 +2658,12 @@ impl Render for AlignApp { if self.data.show_search_quality { root = root.child(search_quality_dismiss_layer(cx)); } + if !self.update_notice_dismissed && !self.data.show_about { + if let Some(notice) = update_notice(cx, &theme, &self.update_state, self.data.operation) + { + root = root.child(notice); + } + } if self.data.show_stage_settings { root = root.child(overlay( &theme, @@ -7168,6 +7235,139 @@ fn export_sheet( sheet } +// ---------------- updates + +fn update_action_label(update: &updater::Update) -> String { + match update { + updater::Update::Manual { version, .. } => format!("Download {version}"), + update => format!("Update to {}", update.version()), + } +} + +/// Floating card announcing a new release, with the next step inline. It +/// works on every platform (the About panel is in the macOS menu only). +fn update_notice( + cx: &mut Context, + theme: &Theme, + update_state: &UpdateState, + operation: Operation, +) -> Option { + let version = update_state.version()?.to_owned(); + let restart_blocked = matches!( + operation, + Operation::Synchronizing | Operation::Exporting | Operation::Repairing + ); + let (detail, action) = match update_state { + UpdateState::Available(update) => ( + "A new version is ready to download.", + prominent_button( + cx, + theme, + "update-notice-action", + update_action_label(update), + true, + |this, _, _, cx| this.download_update(cx), + ), + ), + UpdateState::Downloading { .. } => ( + "Downloading in the background.", + prominent_button( + cx, + theme, + "update-notice-action", + "Downloading…", + false, + |_, _, _, _| {}, + ), + ), + UpdateState::Ready(_) => ( + if restart_blocked { + "Installs when the current operation finishes." + } else { + "Downloaded. Align restarts to finish." + }, + prominent_button( + cx, + theme, + "update-notice-action", + "Restart and Update", + !restart_blocked, + |this, _, _, cx| this.restart_and_update(cx), + ), + ), + UpdateState::Installing { .. } => ( + "Approve the system prompt if one appears.", + prominent_button( + cx, + theme, + "update-notice-action", + "Installing…", + false, + |_, _, _, _| {}, + ), + ), + _ => return None, + }; + let dismissible = !update_state.is_busy(); + let card = div() + .id("update-notice") + .w(px(300.)) + .p_3() + .flex() + .flex_row() + .items_start() + .gap_3() + .rounded_lg() + .border_1() + .border_color(rgb(theme.border)) + .bg(rgb(theme.panel)) + .shadow_lg() + .child(img(PathBuf::from(&icons().app)).size(px(36.)).rounded_md()) + .child( + div() + .flex_1() + .min_w(px(0.)) + .flex() + .flex_col() + .gap(px(2.)) + .child( + div() + .text_size(px(13.)) + .font_weight(gpui::FontWeight(600.0)) + .child(format!("Align {version}")), + ) + .child( + div() + .text_size(px(11.)) + .text_color(rgb(theme.dim)) + .child(detail), + ) + .child(div().mt_2().flex().child(action)), + ) + .when(dismissible, |card| { + card.child( + div() + .id("update-notice-close") + .cursor_pointer() + .opacity(0.6) + .hover(|this| this.opacity(1.)) + .child(svg_icon(icons().close.clone(), 12., theme.icon)) + .tooltip(hover_tip("Later".to_string(), theme)) + .on_click(cx.listener(|this, _, _, cx| { + this.update_notice_dismissed = true; + cx.notify(); + })), + ) + }); + Some( + div() + .absolute() + .right(px(16.)) + .bottom(px(16.)) + .child(slide_in(card, "update-notice-entrance", 0)), + ) +} + // ---------------- about panel (Align menu → About Align) fn platform_label() -> String { @@ -7224,16 +7424,26 @@ fn about_panel( |_, _, _, _| {}, )); } - UpdateState::Available { .. } => { + UpdateState::Available(update) => { update_actions = update_actions.child(prominent_button( cx, theme, "about-download-update", - format!("Update to {update_version}"), + update_action_label(update), true, |this, _, _, cx| this.download_update(cx), )); } + UpdateState::Installing { .. } => { + update_actions = update_actions.child(prominent_button( + cx, + theme, + "about-installing-update", + format!("Installing {update_version}…"), + false, + |_, _, _, _| {}, + )); + } UpdateState::Downloading { .. } => { update_actions = update_actions.child(prominent_button( cx, @@ -7244,7 +7454,7 @@ fn about_panel( |_, _, _, _| {}, )); } - UpdateState::Ready { .. } => { + UpdateState::Ready(_) => { update_actions = update_actions.child(prominent_button( cx, theme, @@ -7255,7 +7465,7 @@ fn about_panel( "Restart and Update" }, !restart_blocked, - |this, _, _, _| this.restart_and_update(), + |this, _, _, cx| this.restart_and_update(cx), )); } } diff --git a/docs/development.md b/docs/development.md index e797e94..0aad850 100644 --- a/docs/development.md +++ b/docs/development.md @@ -109,8 +109,8 @@ open desktop or editor applications. Tests that require external media tools need those tools installed even when only the native backend is being developed. CI runs the same acceptance command -on macOS, Windows, and Linux. Release tags matching `align-rs-v*` also build -platform bundles. +on macOS, Windows, and Linux. Release tags matching `v*` also build and +publish the installers. ### Performance @@ -186,17 +186,24 @@ sidecars: ``` That build requires a C toolchain, make, curl, tar, xz, and NASM where applicable. -For a tag named `align-rs-vX.Y.Z` matching the workspace version, the release -workflow also builds Velopack installers/update packages for macOS, Windows, -and Linux and publishes them with matching source archives in a GitHub Release. -See [distribution](distribution.md) for the source and license workflow. The desktop updater uses those -packages for **Align → Check for Updates…** when installed through Velopack. -Its GitHub source is configured without authentication, so release assets must -be publicly accessible. A private repository does not provide an update feed -for ordinary users with the current configuration. Local Cargo builds and the -ad hoc macOS bundle are not Velopack installations. Distribution -code signing and Apple notarization still require release credentials and are -not configured in this repository. +For a tag named `vX.Y.Z` matching the workspace version, the release workflow +builds the macOS DMG, the Windows installer with its Velopack update feed, and +Fedora and Arch packages with their graphical installers, and publishes them with +matching source archives in a GitHub Release. See [distribution](distribution.md) +for the installers and the source and license workflow. + +Release builds check for a newer release at launch (`crates/align-gpui/src/updater.rs`) +and show an update card; **Align → Check for Updates…** does the same on demand. +Windows Setup installations update through the Velopack feed. Otherwise Align reads +the latest GitHub release: the macOS app downloads the DMG and replaces its bundle +when its folder is writable, and the Fedora and Arch packages download the package +and install it with `pkexec dnf`/`pkexec pacman`. Each download is checked against +its `.sha256` asset. Any other copy (a portable build, the app run from the DMG) +gets a link to the release page. Development builds and runs with +`ALIGN_NO_UPDATE_CHECK` set skip the launch check. The GitHub requests are not +authenticated, so release assets must be publicly accessible. Distribution code +signing and Apple notarization still require release credentials and are not +configured in this repository. ## Repository conventions diff --git a/docs/distribution.md b/docs/distribution.md index 89f3598..cd60ab4 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -5,16 +5,35 @@ licenses. The full GPL text is in [LICENSE](../LICENSE). ## GitHub releases -A tag named `align-rs-vX.Y.Z` must match the workspace version in Cargo.toml. -The release workflow first runs the checks, then builds each platform's binaries, -source archive, dependency notices, and Velopack installers. It publishes only -after all platform jobs succeed and the source archive checksums pass. +Push a tag named `vX.Y.Z` that matches the workspace version in Cargo.toml. +The release workflow runs the checks, then builds each platform's binaries, +source archive, dependency notices, and installers. Fedora and Arch packages are +installed and run in clean containers. The release is published only after every +job succeeds and all checksums pass; its notes come from +`.github/release-notes.md`. Running the workflow manually on a branch builds the +same installers as workflow artifacts without publishing anything. Each release includes: -- Velopack installers and update packages. -- `align-source-Linux.tar.gz`, `align-source-macOS.tar.gz`, and - `align-source-Windows.tar.gz`, each with a `.sha256` file. +| Platform | Installer | Built by | +| --- | --- | --- | +| macOS (Apple Silicon) | `Align-X.Y.Z-macOS-arm64.dmg` | `package-macos.sh`, `create-macos-dmg.sh` | +| Windows x64 | `Align-X.Y.Z-Windows-x64-Setup.exe` and the Velopack update feed | `package-windows.sh` | +| Fedora x86_64 | `Align-X.Y.Z-Fedora-x86_64.rpm` and `-Setup.run` | `package-fedora.sh`, `package-fedora-gui.sh` | +| Omarchy / Arch x86_64 | `align-X.Y.Z-1-x86_64.pkg.tar.zst` and `Align-X.Y.Z-Omarchy-x86_64-Setup.run` | `package-arch.sh`, `package-arch-gui.sh` | + +Also `align-source-Linux.tar.gz`, `align-source-macOS.tar.gz`, and +`align-source-Windows.tar.gz`, each with a `.sha256` file like every installer. + +The installers share Align's look: the DMG window has a drag-to-Applications +layout, the Windows installer shows an animated splash (`Support/Installer`), +and the Linux `.run` files open a GTK 4 installer (`Support/Linux/installer.c`) +in the app's dark theme that installs the native package through polkit. +`script/render-installer-artwork.swift` regenerates the Windows artwork and icon +on macOS. The Windows executable carries the app icon and version details +(`crates/align-gpui/build.rs`). Windows installs update through the Velopack +feed; the macOS app and the Linux packages update from the DMG and package assets +named above, so keep those names and their `.sha256` files unchanged. The source archives contain the exact committed Align checkout, Cargo.lock, vendored Rust dependencies, a Cargo configuration for offline dependency @@ -60,15 +79,11 @@ license cannot be resolved under those choices. Adding a new license requires review rather than disabling that check. Automated collection is not a legal opinion about every future dependency or packaging change. -## macOS DMG releases +## Packaging locally -A standalone macOS release can use a `vX.Y.Z` tag and the local packaging scripts. -This does not trigger the `align-rs-vX.Y.Z` multiplatform installer jobs. Match the -workspace version, commit all changes, and prepare the corresponding sources -with the commands above before packaging. Pass the same AAF build directory -used by source preparation. Install `dmgbuild==1.6.7` in a Python -virtual environment and set `PYTHON` to that environment’s interpreter for the DMG -step. Layout metadata is written directly without automating Finder: +The packaging scripts also run outside CI on a staged release directory (the +`dist-bundle` layout the workflow builds). The DMG needs macOS and +`dmgbuild==1.6.7` in a virtual environment passed as `PYTHON`: ```sh AAF_DIR="$PWD/target/release" \ @@ -78,17 +93,11 @@ RELEASE_LICENSES="$PWD/target/release/ThirdPartyLicenses" \ target/Align-macOS-arm64.dmg ``` -The DMG contains Align.app and an Applications shortcut with a custom Finder -layout. CLI, MCP, AAF, and license notices are inside the app bundle. -The packaging script checks the bundle signature, CLI, MCP, and app launch. -Run `script/check-end-to-end.py` against the packaged CLI before publishing. -Upload the DMG, corresponding `align-source-macOS.tar.gz`, and both checksum -files together. Include the macOS installation instructions from the README in -the release notes. Verify uploaded assets before publishing a draft release. - -Without `RELEASE_LICENSES`, `package-macos.sh` only makes a development bundle; -it does not collect release sources itself. The app is ad-hoc signed. Developer -ID signing and Apple notarization require separate credentials. +The Fedora scripts need `rpmbuild` and GTK 4 development files; the Arch script +needs `makepkg` and runs as an unprivileged user. The Windows installer needs the +`vpk` tool and can be built on any OS. Without `RELEASE_LICENSES`, +`package-macos.sh` only makes a development bundle. The app is ad-hoc signed. +Developer ID signing and Apple notarization require separate credentials. ## Component details diff --git a/script/build-ffmpeg-minimal.sh b/script/build-ffmpeg-minimal.sh index 34997da..099832c 100755 --- a/script/build-ffmpeg-minimal.sh +++ b/script/build-ffmpeg-minimal.sh @@ -47,12 +47,12 @@ JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/ --enable-muxer=pcm_f32le,pcm_s32le,mov \ --enable-filter=abuffer,aformat,aresample,anull,abuffersink,atrim,asetpts,adelay,apad -make -j"$JOBS" ffmpeg ffprobe - +# Make targets carry the executable suffix (ffmpeg.exe on MSYS2). EXE="" case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) EXE=".exe" ;; esac +make -j"$JOBS" "ffmpeg$EXE" "ffprobe$EXE" cp "ffmpeg$EXE" "ffprobe$EXE" "$OUT_DIR/" cp COPYING.LGPLv2.1 "$OUT_DIR/FFMPEG-LICENSE.txt" mkdir -p "$OUT_DIR/Sources" diff --git a/script/package-arch-gui.sh b/script/package-arch-gui.sh new file mode 100755 index 0000000..f3a042c --- /dev/null +++ b/script/package-arch-gui.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Wrap a native Arch package in Align's GTK4 installer. Build on x86_64 Linux. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PACKAGE="$(realpath "${1:?Arch package required}")" +OUT="$(realpath -m "${2:?output .run path required}")" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +cc -O2 -Wall -Wextra -Werror -DALIGN_PACKAGE_ARCH "$ROOT/Support/Linux/installer.c" \ + $(pkg-config --cflags --libs gtk4) -o "$TMP/align-installer" +strip "$TMP/align-installer" +cp "$PACKAGE" "$TMP/Align.pkg.tar.zst" +cp "$ROOT/Support/AppIcon.png" "$TMP/Align.png" +mkdir -p "$(dirname "$OUT")" +cat > "$OUT" <<'SH' +#!/bin/sh +set -eu +if [ "$(uname -m)" != x86_64 ] || [ ! -x /usr/bin/pacman ]; then + echo 'This installer requires Omarchy or Arch Linux on an x86_64 computer.' >&2 + exit 1 +fi +if [ ! -x /usr/bin/pkexec ]; then + echo 'Install polkit to use the graphical installer, or install the separate package with sudo pacman -U.' >&2 + exit 1 +fi +unpack_dir=$(mktemp -d "${TMPDIR:-/tmp}/align-setup.XXXXXX") +trap 'rm -rf "$unpack_dir"' EXIT HUP INT TERM +payload_line=$(awk '/^__ALIGN_PAYLOAD__$/ { print NR + 1; exit }' "$0") +tail -n +"$payload_line" "$0" | tar -xz -C "$unpack_dir" +if { [ -n "${WAYLAND_DISPLAY:-}" ] || [ -n "${DISPLAY:-}" ]; } && + ! ldd "$unpack_dir/align-installer" 2>/dev/null | grep -q 'not found'; then + "$unpack_dir/align-installer" "$unpack_dir/Align.pkg.tar.zst" "$unpack_dir/Align.png" +else + echo 'Installing the native Arch package through pacman.' + pkexec /usr/bin/pacman -U "$unpack_dir/Align.pkg.tar.zst" +fi +exit 0 +__ALIGN_PAYLOAD__ +SH +tar -czf - -C "$TMP" align-installer Align.pkg.tar.zst Align.png >> "$OUT" +chmod +x "$OUT" +(cd "$(dirname "$OUT")" && sha256sum "$(basename "$OUT")" > "$(basename "$OUT").sha256") diff --git a/script/package-arch.sh b/script/package-arch.sh new file mode 100755 index 0000000..33e4527 --- /dev/null +++ b/script/package-arch.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Create an Omarchy/Arch package using makepkg as an unprivileged user. +# Usage: script/package-arch.sh STAGED_DIRECTORY OUTPUT_DIRECTORY +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +STAGE="$(cd "${1:?staged release required}" && pwd)" +mkdir -p "${2:?output directory required}" +OUT="$(cd "$2" && pwd)" +VERSION="$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$ROOT/Cargo.toml" | head -1)" +if [[ $(id -u) == 0 ]]; then + echo 'Run this script as an unprivileged build user (makepkg requires it).' >&2 + exit 1 +fi +for bin in align align-cli align-mcp align-aaf ffmpeg ffprobe; do + test -x "$STAGE/$bin" +done +for notice in LICENSE THIRD_PARTY_NOTICES.txt FFMPEG-LICENSE.txt; do + test -s "$STAGE/$notice" +done +test -s "$STAGE/ThirdPartyLicenses/BUILD-MANIFEST.json" +test -d "$STAGE/AAF-Licenses" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/input/payload" "$TMP/input/integration" "$TMP/build" +tar -C "$STAGE" --exclude=./AAF-Sources -cf - . | tar -C "$TMP/input/payload" -xf - +cp "$ROOT/Support/Linux/com.align.app.desktop" "$ROOT/Support/Linux/com.align.app.metainfo.xml" \ + "$ROOT/Support/AppIcon.png" "$TMP/input/integration/" +tar -C "$TMP/input" -czf "$TMP/build/align-payload.tar.gz" payload integration +HASH="$(sha256sum "$TMP/build/align-payload.tar.gz" | cut -d ' ' -f 1)" +sed -e "s/@VERSION@/$VERSION/g" -e "s/@PAYLOAD_SHA256@/$HASH/g" \ + "$ROOT/Support/Linux/PKGBUILD.in" > "$TMP/build/PKGBUILD" +(cd "$TMP/build" && makepkg --nodeps --noconfirm) +cp "$TMP/build/align-$VERSION-1-x86_64.pkg.tar.zst" "$OUT/" +# Retain the exact packaging recipe and its input for reproducibility. +mkdir -p "$OUT/BuildInputs" +cp "$TMP/build/PKGBUILD" "$TMP/build/align-payload.tar.gz" "$OUT/BuildInputs/" +(cd "$OUT" && sha256sum "align-$VERSION-1-x86_64.pkg.tar.zst" > "align-$VERSION-1-x86_64.pkg.tar.zst.sha256") diff --git a/script/package-fedora-gui.sh b/script/package-fedora-gui.sh new file mode 100755 index 0000000..0756337 --- /dev/null +++ b/script/package-fedora-gui.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Wrap an RPM in Align's branded GTK4 installer. Build on x86_64 Linux. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +RPM="$(realpath "${1:?RPM file required}")" +OUT="$(realpath -m "${2:?output .run path required}")" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +cc -O2 -Wall -Wextra -Werror "$ROOT/Support/Linux/installer.c" \ + $(pkg-config --cflags --libs gtk4) -o "$TMP/align-installer" +strip "$TMP/align-installer" +cp "$RPM" "$TMP/Align.rpm" +cp "$ROOT/Support/AppIcon.png" "$TMP/Align.png" +mkdir -p "$(dirname "$OUT")" +cat > "$OUT" <<'SH' +#!/bin/sh +set -eu +if [ "$(uname -m)" != x86_64 ] || ! command -v dnf >/dev/null; then + echo 'This installer requires Fedora on an x86_64 computer.' >&2 + exit 1 +fi +unpack_dir=$(mktemp -d "${TMPDIR:-/tmp}/align-setup.XXXXXX") +trap 'rm -rf "$unpack_dir"' EXIT HUP INT TERM +payload_line=$(awk '/^__ALIGN_PAYLOAD__$/ { print NR + 1; exit }' "$0") +tail -n +"$payload_line" "$0" | tar -xz -C "$unpack_dir" +if ! ldd "$unpack_dir/align-installer" 2>/dev/null | grep -q 'not found'; then + "$unpack_dir/align-installer" "$unpack_dir/Align.rpm" "$unpack_dir/Align.png" +else + echo 'GTK 4 is required for the graphical installer. Installing the RPM through DNF.' + pkexec /usr/bin/dnf --assumeyes install "$unpack_dir/Align.rpm" +fi +exit 0 +__ALIGN_PAYLOAD__ +SH +tar -czf - -C "$TMP" align-installer Align.rpm Align.png >> "$OUT" +chmod +x "$OUT" +(cd "$(dirname "$OUT")" && sha256sum "$(basename "$OUT")" > "$(basename "$OUT").sha256") diff --git a/script/package-fedora.sh b/script/package-fedora.sh new file mode 100755 index 0000000..d6dae86 --- /dev/null +++ b/script/package-fedora.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Package a staged x86_64 Linux release. Run on Linux with rpm-build installed. +# Usage: script/package-fedora.sh STAGED_DIRECTORY OUTPUT_DIRECTORY +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +STAGE="$(cd "${1:?staged release required}" && pwd)" +mkdir -p "${2:?output directory required}" +OUT="$(cd "$2" && pwd)" +VERSION="$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$ROOT/Cargo.toml" | head -1)" +for bin in align align-cli align-mcp align-aaf ffmpeg ffprobe; do + test -x "$STAGE/$bin" +done +for notice in LICENSE THIRD_PARTY_NOTICES.txt FFMPEG-LICENSE.txt; do + test -s "$STAGE/$notice" +done +test -d "$STAGE/AAF-Licenses" +test -d "$STAGE/ThirdPartyLicenses" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP"/{BUILD,RPMS,SOURCES,SPECS,SRPMS,BUILDROOT} +cat > "$TMP/SPECS/align.spec" < "Align-$VERSION-Fedora-x86_64.rpm.sha256") diff --git a/script/package-windows.sh b/script/package-windows.sh new file mode 100755 index 0000000..316c522 --- /dev/null +++ b/script/package-windows.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Velopack can build a Windows installer on macOS, Linux, or Windows. +# Usage: VPK=/path/to/vpk script/package-windows.sh STAGED_DIRECTORY OUTPUT_DIRECTORY +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +STAGE="$(cd "${1:?staged release required}" && pwd)" +mkdir -p "${2:?output directory required}" +OUT="$(cd "$2" && pwd)" +VERSION="$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$ROOT/Cargo.toml" | head -1)" +for bin in align align-cli align-mcp align-aaf ffmpeg ffprobe; do + test -s "$STAGE/$bin.exe" +done +for notice in LICENSE THIRD_PARTY_NOTICES.txt FFMPEG-LICENSE.txt; do + test -s "$STAGE/$notice" +done +test -d "$STAGE/AAF-Licenses" +test -d "$STAGE/ThirdPartyLicenses" +# Velopack needs the [win] directive only when packing on another OS. +target=() +case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) ;; *) target=('[win]') ;; esac +"${VPK:-vpk}" "${target[@]}" pack --packId com.align.app --packTitle Align \ + --packAuthors 'Align contributors' --packVersion "$VERSION" \ + --runtime win-x64 --packDir "$STAGE" --mainExe align.exe \ + --framework vcredist143-x64 \ + --icon "$ROOT/Support/Installer/Align.ico" \ + --splashImage "$ROOT/Support/Installer/Setup.gif" \ + --splashProgressColor '#0A84FF' --shortcuts StartMenuRoot \ + --exclude '(^|[/\\])AAF-Sources([/\\]|$)' \ + --outputDir "$OUT/updates" --noPortable +cp "$OUT/updates/com.align.app-win-Setup.exe" "$OUT/Align-$VERSION-Windows-x64-Setup.exe" +(cd "$OUT" && sha256sum "Align-$VERSION-Windows-x64-Setup.exe" > "Align-$VERSION-Windows-x64-Setup.exe.sha256") diff --git a/script/render-installer-artwork.swift b/script/render-installer-artwork.swift new file mode 100644 index 0000000..7a2ffb5 --- /dev/null +++ b/script/render-installer-artwork.swift @@ -0,0 +1,92 @@ +// Rebuild committed installer artwork on macOS: swift script/render-installer-artwork.swift +import AppKit +import ImageIO +import UniformTypeIdentifiers + +let output = URL(fileURLWithPath: "Support/Installer", isDirectory: true) +try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) +let icon = NSImage(contentsOfFile: "Support/AppIcon.png")! +let width = 720, height = 440 +func color(_ hex: UInt32) -> NSColor { + NSColor(srgbRed: CGFloat((hex >> 16) & 255) / 255, + green: CGFloat((hex >> 8) & 255) / 255, + blue: CGFloat(hex & 255) / 255, alpha: 1) +} +func frame(_ phase: Double) -> CGImage { + let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)! + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap) + color(0x18191B).setFill() + NSRect(x: 0, y: 0, width: width, height: height).fill() + func text(_ value: String, x: CGFloat, y: CGFloat, size: CGFloat, + weight: NSFont.Weight = .regular, ink: UInt32 = 0xF2F2F7) { + (value as NSString).draw(at: NSPoint(x: x, y: y), withAttributes: [ + .font: NSFont.systemFont(ofSize: size, weight: weight), .foregroundColor: color(ink)]) + } + icon.draw(in: NSRect(x: 48, y: 314, width: 70, height: 70)) + text("Align", x: 136, y: 335, size: 32, weight: .semibold) + text("AUDIO & VIDEO SYNC", x: 138, y: 314, size: 10, weight: .medium, ink: 0xABABB4) + text("Everything in sync.", x: 48, y: 246, size: 34, weight: .medium) + text("Your recordings. One timeline.", x: 49, y: 213, size: 17, ink: 0xABABB4) + // Timeline lanes echo Align's actual blue, cyan and green clip colors. + let hues: [UInt32] = [0x0A84FF, 0x32ADE6, 0x30D158] + let starts: [CGFloat] = [0, 68, 32] + for lane in 0..<3 { + let y = CGFloat(144 - lane * 22) + color(0x292B30).setFill() + NSBezierPath(roundedRect: NSRect(x: 49, y: y, width: 622, height: 15), xRadius: 4, yRadius: 4).fill() + let shift = starts[lane] * CGFloat((1 + cos(phase * 2 * .pi)) / 2) + color(hues[lane]).withAlphaComponent(0.8).setFill() + NSBezierPath(roundedRect: NSRect(x: 85 + shift, y: y, width: 380 - CGFloat(lane * 42), height: 15), xRadius: 4, yRadius: 4).fill() + for tick in 0..<54 { + let h = CGFloat(3 + (tick * 13 + lane * 7) % 8) + color(0xFFFFFF).withAlphaComponent(0.42).setFill() + NSRect(x: 94 + shift + CGFloat(tick * 5), y: y + (15 - h) / 2, width: 1, height: h).fill() + } + } + color(0xF2F2F7).withAlphaComponent(0.7).setFill() + NSRect(x: 85, y: 95, width: 1, height: 72).fill() + text("Installing Align…", x: 49, y: 49, size: 14, weight: .medium) + text("Free. Open source. Yours.", x: 500, y: 49, size: 12, ink: 0xABABB4) + NSGraphicsContext.restoreGraphicsState() + return bitmap.cgImage! +} +let gif = CGImageDestinationCreateWithURL(output.appendingPathComponent("Setup.gif") as CFURL, + UTType.gif.identifier as CFString, 48, nil)! +CGImageDestinationSetProperties(gif, [kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFLoopCount: 0]] as CFDictionary) +for index in 0..<48 { + CGImageDestinationAddImage(gif, frame(Double(index) / 48), + [kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFDelayTime: 0.06]] as CFDictionary) +} +precondition(CGImageDestinationFinalize(gif)) +let png = CGImageDestinationCreateWithURL(output.appendingPathComponent("Setup.png") as CFURL, + UTType.png.identifier as CFString, 1, nil)! +CGImageDestinationAddImage(png, frame(0.5), nil) +precondition(CGImageDestinationFinalize(png)) +// ICO is a container of PNG images; preserve the existing icon exactly. +var images: [Data] = [] +let sizes = [16, 32, 48, 64, 128, 256] +for size in sizes { + let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: size, pixelsHigh: size, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)! + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap) + icon.draw(in: NSRect(x: 0, y: 0, width: size, height: size)) + NSGraphicsContext.restoreGraphicsState() + images.append(bitmap.representation(using: .png, properties: [:])!) +} +var ico = Data() +func u16(_ value: UInt16) { var n = value.littleEndian; withUnsafeBytes(of: &n) { ico.append(contentsOf: $0) } } +func u32(_ value: UInt32) { var n = value.littleEndian; withUnsafeBytes(of: &n) { ico.append(contentsOf: $0) } } +u16(0); u16(1); u16(UInt16(sizes.count)) +var offset = 6 + 16 * sizes.count +for (size, data) in zip(sizes, images) { + ico.append(contentsOf: [UInt8(size % 256), UInt8(size % 256), 0, 0]) + u16(1); u16(32); u32(UInt32(data.count)); u32(UInt32(offset)) + offset += data.count +} +for data in images { ico.append(data) } +try ico.write(to: output.appendingPathComponent("Align.ico"))