diff --git a/.github/workflows/ci-pr-build.yml b/.github/workflows/ci-pr-build.yml index f71cbdd4..8bdbca08 100644 --- a/.github/workflows/ci-pr-build.yml +++ b/.github/workflows/ci-pr-build.yml @@ -128,8 +128,20 @@ jobs: sudo apt-get update sudo apt-get install --yes cabextract xdg-utils desktop-file-utils shared-mime-info - - name: Test installer and launcher lifecycle - run: make test + - name: Test while a separate wineserver process runs + run: | + separate_wine_dir="$(mktemp -d /tmp/ableton-separate-wine.XXXXXX)" + cp -- "$(command -v sleep)" "$separate_wine_dir/wineserver" + "$separate_wine_dir/wineserver" 7200 & + separate_wine_pid=$! + stop_separate_wine() { + kill "$separate_wine_pid" 2>/dev/null || true + wait "$separate_wine_pid" 2>/dev/null || true + } + trap stop_separate_wine EXIT + test "$(cat "/proc/$separate_wine_pid/comm")" = wineserver + make test + kill -0 "$separate_wine_pid" # These are properties of the built runtime, so `make test` cannot assert # them: its suites are all host-side. The tarball exists by this point, so diff --git a/BUILDING.md b/BUILDING.md index e63499e4..8652636a 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -52,10 +52,15 @@ Use one component directly when a full install is not needed: ./scripts/installer.sh link status ``` -Preview any supported operation without changing files: +Preview an update while preserving current files: ```bash ./scripts/installer.sh plan update +``` + +Validate the paths and records for an uninstall request: + +```bash ./scripts/installer.sh plan uninstall --delete-prefix ``` @@ -72,6 +77,8 @@ make test `make check` inspects the pointer changes and tests their limits with difficult input. Neither it nor `make verify` starts Wine or Live. +`scripts/test-installer-ui.sh` covers the installer's screen. It renders a fixed run and compares the result byte for byte with a golden transcript of the template. It replays a real pseudo-terminal through a small terminal simulator to check the `└─` to `├─` rewrite. It checks every adjoining box stroke for matching direction and weight. It also confirms that colour codes apply to text while each line keeps the terminal's default colour. The remaining checks confirm that every tree glyph comes from `scripts/lib/ui.sh` and every sentence comes from the dictionary. + Verify all pinned build and packaging inputs: ```bash @@ -115,12 +122,22 @@ After a successful `./build.sh`, create the single-file installer: This writes `dist/ableton-wine-setup-.run` and its SHA-256 file. Packaging refuses a build that lacks the required sanitizer result or fails the runtime audit. +To try a script change with the Wine runtime you already built, pack a development installer from the current contents of `dist/`: + +```bash +./scripts/make-installer.sh --dev +``` + +This command writes `dist/ableton-wine-setup--dev.run` and skips the BUILD-INFO, digest, and attestation checks. Publish only the release `.run`. + +The packer builds the `.run` header from `scripts/setup-run-header.sh` and inserts `scripts/lib/ui.sh` at the `@UI_LIB@` marker, so the banner and the action menu render before the installer unpacks the kit. `./scripts/make-installer.sh --render-header --version V --payload-sha S` prints that assembled header. The test suites use it to build stub installers. + ## Current configuration -The installer saves the runtime root, Wine prefix, selected Live major version, and Link mode. For these values, a command-line option overrides an exported `ABLETON_*` variable, which overrides the saved XDG configuration and then the default. +The installer saves the runtime root, the ableton-linux prefix, the selected Live major version, and the Link mode. For these values, a command-line option overrides an exported `ABLETON_*` variable, which overrides the saved XDG configuration and then the default. - `ABLETON_WINE_ROOT` selects the Wine runtime. The default is `~/.local/opt/wine-d2d1-nspa-11.13`; the Nix package's launchers and shipped scripts default to their own store path instead. -- `ABLETON_WINEPREFIX` selects the Wine prefix. The default is `~/.wine-ableton`. +- `ABLETON_WINEPREFIX` selects the ableton-linux prefix. The default is `~/.wine-ableton`. - `ABLETON_LIVE_VERSION=11|12` selects a Live major version for the launcher. - `ABLETON_LINK_MODE=off|session|always` controls when Ableton Link runs. @@ -134,7 +151,7 @@ These environment variables change one launch without changing the saved install - `ABLETON_LIVE_EXE` selects one exact Live executable. - `ABLETON_LINK_MODE=off|session|always` selects the Link policy shared by the - installer, Live launcher, Max launcher, service, and uninstaller. + installer, Live launcher, Max launcher, and service. - `ABLETON_LINKD` selects the Link daemon path. The generated user unit uses this exact resolved path. - `ABLETON_MAX_AUDIO_THREADS=auto|off|` controls Live 12's audio thread diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f45ed1..e671d15c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,679 +1,342 @@ # Changelog -## 2026.08.26.2 — in progress - -### Installation and desktop integration - -- The installer now treats the Wine runtime, prefix, Live result, and PipeASIO - registry state as the core install. A warning from desktop integration, Link, - saved settings, the installed-file list, or cleanup keeps that working core - and reports what still needs attention. -- The installed-file list now stays outside runtime and prefix recovery. - Install and update rebuild a stale list instead of rejecting a valid - install. This fixes the false end-of-install failure from issue 280. -- Generated launchers, protocol handlers, file associations, icons, and panel - entries are replaced with the current project versions. Live repairs its own - generated desktop entries before launch. Repair failures produce one warning - and Live continues (#279). -- Each shortcut and support file is repaired independently. One blocked path, - stale recovery record, or failed status message no longer stops later files - from being updated or reverses a valid Wine, prefix, or Live result. -- Desktop database refreshes are now silent best-effort work, and the installer - no longer rebuilds the user-wide GTK icon cache. Unrelated icons or a - follow-up default-app query can no longer turn successfully written shortcuts - and file associations into a retry result. -- When a generated launcher replaces an existing file or symlink, the installer - keeps that object beside it as `.bak`. Removing the generated launcher - restores the displaced object and anything that already occupied the `.bak` - path. If the launcher changed again, all recovery copies are preserved and - the installer explains what remains (#279). -- Uninstall clears generated Ableton handler defaults and preserves unrelated - desktop associations. Optional desktop and Link cleanup failures keep the - completed core removal. -- Interrupted Link setup keeps Live installed and prints the command that - resumes Link setup. This covers issue 258 (#268). - -### Live, Wine, and audio - -- Live no longer enters a half-old, half-new DPI session when automatic display - scaling changes. The launcher writes `LogPixels` and every applicable Live - awareness override as one fenced transaction, stops and waits for only that - owned Wine session, then runs `wineboot` before Live. A busy prefix is left - untouched. GNOME mixed-monitor detection now follows Xwayland's shared - highest scale, and automatic fractional setup requires confirmed native - Xwayland scaling. This restores stable window dimensions and the application - menu when moving Live across differently scaled monitors. -- Live dialogs such as Separate Stems retain their content when the desktop - first displays them. This addresses the black-dialog report in issue 263; - confirmation on the original KDE/NVIDIA setup is still pending (#267). -- Live 12 uses an automatic audio-worker count based on physical cores and - Live's own worker count. Existing user settings still take priority. The - troubleshooting guide provides a one-launch comparison with Live's own - setting (#278). -- PipeASIO skips a redundant output publication after Live supplies an audio - block. Its fallback still publishes silence when the callback or buffer size - requires it (#269). -- PipeASIO now identifies graph-quantum requests by the PipeWire node bound to - each request. It returns Live to the saved buffer size after the other request - ends (#274). -- The NTSync check now proves that the running wineserver opens `/dev/ntsync`. - The audio report records the available CPU set, physical layout, and Linux - processor preferences (#272, #275). - -### Experimental measurement tools - -- The new CPU and audio benchmark suite runs 5 fixed Live Sets and produces - comparable JSON, Markdown, and CSV reports (#271). -- `PIPEASIO_TELEMETRY=on` records callback timing through a separate reporting - worker. The regular driver path keeps telemetry disabled (#273). -- The PipeWire Pro Audio comparison tool measures one device in its current and - Pro Audio profiles, then restores the original profile (#276). -- `WINE_APC_FASTPATH=1` selects an experimental NTSync wait path for controlled - tests. Wine's regular wait path remains the release default (#277). -- The CPU optimisation research summary records the evidence, rejected ideas, - and test gates for future defaults (#270). +## 2026.08.28.1 + +- **Installer v3:** + - The installer draws one nested tree. It shows a banner, the system check, the action menu, one numbered box per step (`1/8`, `2/8`, and so on) with its operations underneath, and a footer with the time taken, the warning and error counts, and the runtime and prefix paths. The running operation shows `└─` and a spinner. It changes to `├─ ✓` (or `𐄂` after a failure) as soon as the operation finishes. Colour highlights apply to the text and status symbols. Tree and box lines keep the terminal's default colour. + - Every sentence the installer shows comes from one dictionary at the top of `scripts/lib/ui.sh`. Edit the text there. The packer copies the dictionary into the `.run` header. Errors and everything the scripts print outside the tree go to the log beside the `.run`. On failure the footer lists them. + - Long lines wrap inside the tree. A terminal with a locale other than UTF-8 gets an ASCII version of the same tree. + - The action menu offers `E[x]it`. When several Ableton Live downloads sit next to the installer, the installer lists them newest first and uses the first after 5 seconds. + - Every update of a support file (launchers, shortcuts, Link, settings) runs through one fixed mapping loop. When the installer finds existing files it asks once: Overwrite all (the default, also after 5 seconds), Keep originals, or Abort. An overwrite moves the existing file into one dated backup directory for the run, then copies the new file. + - `scripts/make-installer.sh --dev` packs a `-dev.run` from the current `dist/` and skips the release checks, so you can try script changes with the Wine runtime you already built. + - Fixed an early exit in `setup-link.sh disable` on a closed output pipe. It could delete the saved settings file before it wrote the new one. + - Backup and copy failures report the affected path and continue. Desktop, launcher, PipeASIO panel, Link, or saved-settings failures can no longer unwind the completed Wine runtime, prefix, or Live installation. + - The installer records each support file that it writes. It saves earlier shared configuration files for restoration during uninstall. Ordinary support-file replacements use dated recovery copies. + - If Live installed, launched, and makes sound, the installer is no longer allowed to throw all of that in the bin because a desktop icon cache hiccupped, a bookkeeping file went stale, or Ableton Link setup got interrupted halfway. Core install means core install (and this time, we mean it). Everything else is a warning you can ignore or fix later. + - We now assume ownership over all the stuff we write out. So, each shortcut, launcher, and file association can be repaired independently and a single failure can't cascade into a rollback that reverses your whole prefix. To help with this, **we now assume that desktop launchers, scripts, etc are fair game** and we will overwrite them with new defaults as needed. + - Interrupted Link setup leaves Live installed and prints the single command that picks up where it left off. No redo, no "please start over." + - Fixes #280, #279, #258 (#268). Thanks @CoenKonings for the install report with enough log detail to actually reproduce, and ΦNYX from Discord for the Link interrupt report. + - Thank you to everyone for their patience with this. Writing installers that also won't nuke your system is actually harder than you'd think. + +- **Performance Moonshot part deux:** + - Further hardening of our graphics stack. Dragging Live between monitors with different scaling no longer produces a funhouse-mirror window at the wrong size with an application menu that has simply left the chat. Display negotiation happens once, cleanly, before Live launches. + - Dialog windows like Separate Stems paint their content now. You'll see the progress bar, the options, the whole experience. Not a void staring back at you (#263, #267). Confirmation on @Sandai64's original KDE/NVIDIA setup is still pending — get angry at us in the Github Issues if you still see the void staring back at you. + - Live 12's audio worker count now is picked from your CPU's physical core count instead of whatever Live guessed. This means that we can try to optimse Live's audio performance for your own CPU configuration. We also added a troubleshooting entry to walk you through A/B-testing to compare against Live's auto-assignment (#278). + - Made PipeASIO more resilient. Now, it does **not** re-publish audio it already sent, and when another application briefly borrows your audio graph (a video call, a browser tab that thinks it's the DJ), Live snaps back to your saved buffer size instead of staying stuck on the stranger's setting (#269, #274) and freaking out about it. + - The NTSync check now actually proves wineserver uses it, instead of nodding along because a file exists somewhere. The audio report captures your full CPU topology so bug reports can explain themselves to us (#272, #275). This helps us understand whether bad Live performance is your fault or our fault. :3 + - Thanks @ClickSentinel for the relentless review across every PR in this section — six blocking findings caught before merge, all hardware-verified on real silicon. + +- **First rollout of diagnostics and measurement tooling** (aka preparing to flex the moonshot work): + - We now have a CPU and audio benchmark suite that can run five fixed Live Sets and produces reports you can actually compare between versions, machines, or arguments. JSON, Markdown, CSV — take your pick (#271). + - `PIPEASIO_TELEMETRY=on` records audio callback timing through a side-channel worker. The regular driver path will not pay a performance tax for your curiosity (#273). + - A new PipeWire Pro Audio comparison tool allows you to measure an audio device in both its current and Pro Audio profiles, then sets it back to whatever setting you had. Useful for finding out whether Pro Audio actually helps your interface or is just a placebo (#276). + - `WINE_APC_FASTPATH=1` selects an experimental NTSync wait path for controlled tests for performance. It is off by default (#277). + - A CPU optimisation research summary records the evidence, the rejected ideas, and the test gates for future defaults, so nobody has to re-litigate a dead end (#270). + - Thank you to @haushaushaus for the Ableton Live Benchmark set. Remember to read the README.md for that, because there are some free third party VSTs used in the test and we don't distribute those with this project. + +## ~~2026.08.27.1~~ + +- This release was withdrawn due to a major regression in the installer that blocked most updates. + +- **~~The installer learned to stop panicking and accept that your install works:~~** + - ~~If Live installed, launched, and makes sound, the installer is no longer allowed to throw all of that in the bin because a desktop icon cache hiccupped, a bookkeeping file went stale, or Ableton Link setup got interrupted halfway. Core install means core install (and this time, we mean it). Everything else is a warning you can ignore or fix later.~~ + - ~~We now assume ownership over all the stuff we write out. So, each shortcut, launcher, and file association can be repaired independently and a single failure can't cascade into a rollback that reverses your whole prefix. To help with this, **we now assume that desktop launchers, scripts, etc are fair game** and we will overwrite them with new defaults as needed. ~~ + - ~~Interrupted Link setup leaves Live installed and prints the single command that picks up where it left off. No redo, no "please start over."~~ + - ~~Fixes #280, #279, #258 (#268). Thanks @CoenKonings for the install report with enough log detail to actually reproduce, and ΦNYX from Discord for the Link interrupt report.~~ + - ~~Thank you to everyone for their patience with this. Writing installers that also won't nuke your system is actually harder than you'd think.~~ + +- **~~Performance Moonshot part deux:~~** + - ~~Further hardening of our graphics stack. Dragging Live between monitors with different scaling no longer produces a funhouse-mirror window at the wrong size with an application menu that has simply left the chat. Display negotiation happens once, cleanly, before Live launches.~~ + - ~~Dialog windows like Separate Stems paint their content now. You'll see the progress bar, the options, the whole experience. Not a void staring back at you (#263, #267). Confirmation on @Sandai64's original KDE/NVIDIA setup is still pending — get angry at us in the Github Issues if you still see the void staring back at you.~~ + - ~~Live 12's audio worker count now is picked from your CPU's physical core count instead of whatever Live guessed. This means that we can try to optimse Live's audio performance for your own CPU configuration. We also added a troubleshooting entry to walk you through A/B-testing to compare against Live's auto-assignment (#278).~~ + - ~~Made PipeASIO more resilient. Now, it does **not** re-publish audio it already sent, and when another application briefly borrows your audio graph (a video call, a browser tab that thinks it's the DJ), Live snaps back to your saved buffer size instead of staying stuck on the stranger's setting (#269, #274) and freaking out about it.~~ + - ~~The NTSync check now actually proves wineserver uses it, instead of nodding along because a file exists somewhere. The audio report captures your full CPU topology so bug reports can explain themselves to us (#272, #275). This helps us understand whether bad Live performance is your fault or our fault. :3~~ + - ~~Thanks @ClickSentinel for the relentless review across every PR in this section — six blocking findings caught before merge, all hardware-verified on real silicon.~~ + +- ~~**First rollout of diagnostics and measurement tooling** (aka preparing to flex the moonshot work):~~ + - ~~We now have a CPU and audio benchmark suite that can run five fixed Live Sets and produces reports you can actually compare between versions, machines, or arguments. JSON, Markdown, CSV — take your pick (#271).~~ + - ~~`PIPEASIO_TELEMETRY=on` records audio callback timing through a side-channel worker. The regular driver path will not pay a performance tax for your curiosity (#273).~~ + - ~~A new PipeWire Pro Audio comparison tool allows you to measure an audio device in both its current and Pro Audio profiles, then sets it back to whatever setting you had. Useful for finding out whether Pro Audio actually helps your interface or is just a placebo (#276).~~ + - ~~`WINE_APC_FASTPATH=1` selects an experimental NTSync wait path for controlled tests for performance. It is off by default (#277).~~ + - ~~A CPU optimisation research summary records the evidence, the rejected ideas, and the test gates for future defaults, so nobody has to re-litigate a dead end (#270).~~ + - ~~Thank you to @haushaushaus for the Ableton Live Benchmark set. Remember to read the README.md for that, because there are some free third party VSTs used in the test and we don't distribute those with this project.~~ ## 2026.08.26.1 - **MIDI controllers and audio interfaces find their way back:** - - Reconnected controllers stay available, and Push 3 keeps its pad-light - messages out of MIDI input. - - Your selected audio interface returns to the same port. A matching - interface on another port waits for you to choose it. - - Thanks Lucas Gillingham (@ClickSentinel) for the review, and @Velkas and - Soonganoid from Discord for the Push 3 reports. + - Reconnected controllers stay available, and Push 3 keeps its pad-light messages out of MIDI input. + - Your selected audio interface returns to the same port. A matching interface on another port waits for you to choose it. + - Thanks @ClickSentinel for the review, and @Velkas and Soonganoid from Discord for the Push 3 reports. ## 2026.08.24.2 - **Ableton Live on Linux now builds and runs with Nix (#237):** - If you use Nix, `nix run .` builds the runtime and launches Live. - - The Nix build uses the same sources and the same patch set as the installer - build, and it passes the same quality checks. The result is the same - verified runtime. - - Each setup step runs as a Nix command: the Wine prefix, real-time audio and - Ableton Link. You can run them again after an update. - - The audio report and the NTSync check run as Nix commands too, so you can - gather the same diagnostics you would from the installer. + - The Nix build uses the same sources and the same patch set as the installer build, and it passes the same quality checks. The result is the same verified runtime. + - Each setup step runs as a Nix command: the Wine prefix, real-time audio and Ableton Link. You can run them again after an update. + - The audio report and the NTSync check run as Nix commands too, so you can gather the same diagnostics you would from the installer. - **An update to a Nix install keeps working:** - - The launcher always points at the current Nix build. An update does not - leave an old or missing install behind. + - The launcher always points at the current Nix build. An update does not leave an old or missing install behind. - A session that is running stays running across an update. - - Thanks Lucas Gillingham (@ClickSentinel) for the flake and the Nix runtime - support, @Version33 for the original flake, @realitymolder for splitting it - from the general fixes, Immabed for the NixOS testing and the PipeASIO - settings report, and @shibco for the review. + - Thanks @ClickSentinel for the flake and the Nix runtime support, @Version33 for the original flake, @realitymolder for splitting it from the general fixes, Immabed for the NixOS testing and the PipeASIO settings report, and @shibco for the review. ## 2026.08.24.1 - **Push 3 finally works in controller mode with Live 12 (#254)!** - - Live detects Push 3 and creates its control surface automatically. Leave - the Push 3 rows empty in Live's MIDI settings and let Live do its thing. - - Both Push 3 models are supported: the Controller model, and the Standalone - model after switching it to Control Mode. The display and controls connect - alongside the MIDI ports. - - Push 3 needs desktop USB access on Linux. The README now covers the one-time - setup, first connection and firmware-update dance. - - Thanks @Version33 for the original MIDI identity work and relentless - testing, @Velkas and @ilikecake123 for the early hardware reports, and - @berryhill and @cicklolwut for end-to-end validation. + - Live detects Push 3 and creates its control surface automatically. Leave the Push 3 rows empty in Live's MIDI settings and let Live do its thing. + - Both Push 3 models are supported: the Controller model, and the Standalone model after switching it to Control Mode. The display and controls connect alongside the MIDI ports. + - Push 3 needs desktop USB access on Linux. The README now covers the one-time setup, first connection and firmware-update dance. + - Thanks @Version33 for the original MIDI identity work and relentless testing, @Velkas and @ilikecake123 for the early hardware reports, and @berryhill and @cicklolwut for end-to-end validation. - **July's reconnect work now also covers gear that arrives late (#245):** - - To be completely clear: July already let MIDI and audio devices reconnect - while Live stayed open. Now, a MIDI controller first connected after Live - starts appears in the MIDI device list. - - Open MIDI inputs and outputs stay with the same controller when Linux gives - it a new device number after reconnection. - - Disconnecting a MIDI input clears held notes, sustain and unfinished SysEx - messages. No more reconnecting to a phantom note screaming into eternity. - - PipeASIO remembers the exact interface and channels you selected through - disconnects, late PipeWire startup and PipeWire restarts. It keeps the - missing route silent, then restores it when the complete route returns. - - New audio interfaces appear in PipeASIO Settings while Live runs. Automatic - routing still follows the PipeWire default, and available hardware details - keep identical devices apart. - - **Heads up:** USB MIDI ports now use Windows-style names. You may need to - select your MIDI devices and control surfaces again after updating. + - To be completely clear: July already let MIDI and audio devices reconnect while Live stayed open. Now, a MIDI controller first connected after Live starts appears in the MIDI device list. + - Open MIDI inputs and outputs stay with the same controller when Linux gives it a new device number after reconnection. + - Disconnecting a MIDI input clears held notes, sustain and unfinished SysEx messages. No more reconnecting to a phantom note screaming into eternity. + - PipeASIO remembers the exact interface and channels you selected through disconnects, late PipeWire startup and PipeWire restarts. It keeps the missing route silent, then restores it when the complete route returns. + - New audio interfaces appear in PipeASIO Settings while Live runs. Automatic routing still follows the PipeWire default, and available hardware details keep identical devices apart. + - **Heads up:** USB MIDI ports now use Windows-style names. You may need to select your MIDI devices and control surfaces again after updating. - Thanks @jackson-57 for reporting the missing half of July's hotplug work. - **The mouse and touchpad system got another frankly excessive overhaul:** - - Fast smooth scrolling now keeps the full distance, even during a very large - movement. - - Drags recover after button releases, focus changes, closed windows and - device changes, so scrolling and gestures do not stay stranded afterwards. - - Scrolling inertia and middle-button throw remain on by default. Both now - use elapsed time, giving the same gesture much more consistent travel. - - Pinch zoom keeps every scale change and restores Ctrl to the state it had - before the gesture. - - Dragging files in from another app now releases old targets properly and - reports whether the first target accepts the drop. - - `WINE_X11_POINTER_FEATURES=disabled` still turns the optional pointer work - off for comparisons while keeping the issue 122 clipping repair. - - Thanks @SLiZzuL, Anizo, jackson, Immabed, claire. and slop for the - scrolling reports, and @berryhill for the drag-and-drop investigation. + - Fast smooth scrolling now keeps the full distance, even during a very large movement. + - Drags recover after button releases, focus changes, closed windows and device changes, so scrolling and gestures do not stay stranded afterwards. + - Scrolling inertia and middle-button throw remain on by default. Both now use elapsed time, giving the same gesture much more consistent travel. + - Pinch zoom keeps every scale change and restores Ctrl to the state it had before the gesture. + - Dragging files in from another app now releases old targets properly and reports whether the first target accepts the drop. + - `WINE_X11_POINTER_FEATURES=disabled` still turns the optional pointer work off for comparisons while keeping the issue 122 clipping repair. + - Thanks @SLiZzuL, Anizo, jackson, Immabed, claire. and slop for the scrolling reports, and @berryhill for the drag-and-drop investigation. - **Fonts found three more ways to be weird, so we fixed all three:** - - Tiny text keeps smooth greyscale or colour-subpixel edges instead of - falling back to jagged one-bit glyphs. - - Text can keep its position between whole pixels, making spacing and - horizontal movement noticeably smoother. - - Resizing a window no longer quietly changes its ClearType or bitmap text - settings. - - Thanks Giang Nguyen (@giang17) for finding and fixing the bitmap-strike - gap, and Lucas Gillingham (@ClickSentinel) for the review, follow-up fixes - and tests. + - Tiny text keeps smooth greyscale or colour-subpixel edges instead of falling back to jagged one-bit glyphs. + - Text can keep its position between whole pixels, making spacing and horizontal movement noticeably smoother. + - Resizing a window no longer quietly changes its ClearType or bitmap text settings. + - Thanks @giang17 for finding and fixing the bitmap-strike gap, and @ClickSentinel for the review, follow-up fixes and tests. - **Live 12 now uses a saner default for its audio workers:** - - Live starts with at most one audio worker per physical CPU core when that - is lower than Live's own calculation. This cut Linux CPU use in our tests, - especially at small buffers. - - A demanding Set may still prefer Live's original count. Close every Live - process and launch with `ABLETON_MAX_AUDIO_THREADS=off` for a direct - comparison if an update brings more overloads or dropouts. + - Live starts with at most one audio worker per physical CPU core when that is lower than Live's own calculation. This cut Linux CPU use in our tests, especially at small buffers. + - A demanding Set may still prefer Live's original count. Close every Live process and launch with `ABLETON_MAX_AUDIO_THREADS=off` for a direct comparison if an update brings more overloads or dropouts. - If you already chose a worker count yourself, updating will not replace it. - - Opening a Set, licence or `ableton://` link in an existing session leaves - the automatic setting alone. An explicit worker request still asks for a - cold launch. - - The CPU report and troubleshooting guide now separate Linux process CPU - from Live's deadline meter, and explain the trade-off without blaming - PipeASIO for work it never did. PipeASIO real-time scheduling remains off - by default. - - Thanks Lucas Gillingham (@ClickSentinel) for finding and fixing the first - version's configuration replacement failures before we made it the default. + - Opening a Set, licence or `ableton://` link in an existing session leaves the automatic setting alone. An explicit worker request still asks for a cold launch. + - The CPU report and troubleshooting guide now separate Linux process CPU from Live's deadline meter, and explain the trade-off without blaming PipeASIO for work it never did. PipeASIO real-time scheduling remains off by default. + - Thanks @ClickSentinel for finding and fixing the first version's configuration replacement failures before we made it the default. - **GNOME and Live have stopped fighting over the same keyboard shortcuts:** - - Ctrl+Alt+Up and Ctrl+Alt+Down belong to Live while it runs. Live 11 also - gets Ctrl+Alt+Delete. - - The launcher restores your exact GNOME settings after the final Live - session closes. `ABLETON_SHORTCUTS=preserve` leaves them alone instead. - - A misspelt `ABLETON_SHORTCUTS` value now stops with a useful error instead - of silently opting out. - - Thanks Lucas Gillingham (@ClickSentinel) for the review that hardened - shortcut recovery before we turned it on by default. + - Ctrl+Alt+Up and Ctrl+Alt+Down belong to Live while it runs. Live 11 also gets Ctrl+Alt+Delete. + - The launcher restores your exact GNOME settings after the final Live session closes. `ABLETON_SHORTCUTS=preserve` leaves them alone instead. + - A misspelt `ABLETON_SHORTCUTS` value now stops with a useful error instead of silently opting out. + - Thanks @ClickSentinel for the review that hardened shortcut recovery before we turned it on by default. - **Updates no longer trip over files the project changed itself (#251):** - - The installer refreshes stale copies of its own launcher and Live menu - entry instead of treating them as somebody else's work. - - A symlinked launcher or a menu entry pointed at another setup still stays - yours. + - The installer refreshes stale copies of its own launcher and Live menu entry instead of treating them as somebody else's work. + - A symlinked launcher or a menu entry pointed at another setup still stays yours. - Thanks @Sandai64 for the complete report that made this easy to reproduce. - **NTSync is now an actual requirement, rather than an optimistic suggestion:** - Use Linux 6.14 or newer with NTSync active. - - Desktop integration installs one NTSync check, and the audio report points - straight to it. The troubleshooting guide tells you what a working result - looks like and where to ask for help if yours differs. + - Desktop integration installs one NTSync check, and the audio report points straight to it. The troubleshooting guide tells you what a working result looks like and where to ask for help if yours differs. ## 2026.08.19.1 - Fixed a slew of problems introduced by redesigning the new Installer too defensively, therefore **breaking it** for lots of people :( : - - Fixed an issue where a Micros*ft installer bundled with Live 12 would hang after installation and be considered a fatal error by our installer. This caused a perceived hang and crashout, despite the install completing successfully. The installer would then - delete the completed install. Even better: on an upgrade it restored the old prefix and - discarded the new runtime! **lol, lmao even!** Now the installer hunts the Micros*ft installer by name and **terminates it with extreme prejudice**. - - The installer now asks you to confirm you're ok with it stopping everything running in its Ableton prefix (including Live or Max) before an install or upgrade. Use the flag `--yes` - to auto-agree in advance. - - Following a seriously botched attempt by @shibco to be extremely clever, the installer would fail with a baffling 'out of disk space' error during the Live install. This was because @shibco decided it would be cool to **extract the Live install files into RAM before copying said installer to the target directory**. You can't vibe code this clever act. Now, the installer stages the Live payload beside the zip and reuses a completed - extraction, so no more RAM-dump, and subsequent attempts to install don't waste your time by re-extracting over the top of already-ready files. + - Fixed an issue where a Micros\*ft installer bundled with Live 12 would hang after installation and be considered a fatal error by our installer. This caused a perceived hang and crashout, despite the install completing successfully. The installer would then delete the completed install. Even better: on an upgrade it restored the old prefix and discarded the new runtime! **lol, lmao even!** Now the installer hunts the Micros\*ft installer by name and **terminates it with extreme prejudice**. + - The installer now asks you to confirm you're ok with it stopping everything running in its Ableton prefix (including Live or Max) before an install or upgrade. Use the flag `--yes` to auto-agree in advance. + - Following a seriously botched attempt by @shibco to be extremely clever, the installer would fail with a baffling 'out of disk space' error during the Live install. This was because @shibco decided it would be cool to **extract the Live install files into RAM before copying said installer to the target directory**. You can't vibe code this clever act. Now, the installer stages the Live payload beside the zip and reuses a completed extraction, so no more RAM-dump, and subsequent attempts to install don't waste your time by re-extracting over the top of already-ready files. - Updates now replace the Live menu entry the installer manages (#211). Again, too cautious and being concerned that people were **actually editing the desktop file by hand** which is simply not the case. -- Closing Live or Max now brings the whole session down. Custom cleanup code to get around fun Wine shenanigans. Thanks Lucas Gillingham. +- Closing Live or Max now brings the whole session down. Custom cleanup code to get around fun Wine shenanigans. Thanks @ClickSentinel. - If you run Live via the `ableton-live` terminal command, you can use that terminal again as soon as Live closes. - Uninstall keeps a runtime and prefix that a running program still uses, and reports the uninstall as partial. -- Fixed Live crashing while it loads a set that uses a Max for Live gen~ device. **This marks another first for the project:** we are now actually shipping memory management code, lmao. In this case, a large set exhausts the runtime's reserved memory pool, and the device's compiled code then lands too far from its base address and the app crashes out. Now, the runtime grows the pool in order to accommodate this situation. Thanks Lucas Gillingham. -- The Live launcher has cool new flags! Including `--version`, `--help` and `--config`. `--config` - describes every launcher environment variable and its default, and the launcher warns about unknown arguments. Thanks, Sebastian Thümmel! +- Fixed Live crashing while it loads a set that uses a Max for Live gen~ device. **This marks another first for the project:** we are now actually shipping memory management code, lmao. In this case, a large set exhausts the runtime's reserved memory pool, and the device's compiled code then lands too far from its base address and the app crashes out. Now, the runtime grows the pool in order to accommodate this situation. Thanks @ClickSentinel. +- The Live launcher has cool new flags! Including `--version`, `--help` and `--config`. `--config` describes every launcher environment variable and its default, and the launcher warns about unknown arguments. Thanks, @Seebass22! - We updated the troubleshooting guide to account for new quirks in the wild and to mitigate some of our egregiously clever code. ## 2026.08.14.3 - **ROLLED OUT THE NEW INPUT SYSTEM ABANDONED IN `2026.08.14.1`!!** - - Added fine vertical and horizontal scrolling, pinch zoom, and middle-button - navigation. Middle-button dragging moves the content with the pointer on - both axes. Holding Ctrl during that drag zooms instead, with a drag towards - the top of the screen zooming in. A plain middle click remains a click. - - Scrolling sends fractional movement by default. Selecting the XI2 events it - needs once made faders and knobs cross their whole range from a small - movement on XWayland; the held-button repair below stops that, so the - setting no longer has to be off. `WINE_X11_SMOOTH_SCROLLING=disabled` - restores whole wheel notches. Found by Lucas Gillingham. - - Fixed left- and right-button control drags speeding up when a second touch - starts scrolling. Normal one-finger dragging and middle-button navigation - remain unchanged. - - Touchpad scrolling and pinch zoom cannot move a control while a mouse button - is held. A mouse wheel still works except during middle-button navigation, - and pressing a button stops earlier continued movement. - - Added scrolling inertia and middle-drag throw. Both are on by default and - have separate off switches. Fast releases keep their speed, then movement - slows gradually. A short or curved middle-button drag can throw. New input - or a window change stops it. Live does not replay missed movement after a - pause. - - Added a repair for fader jumps after loading a Max for Live device. Testing - on the affected Fedora computer remains open. - - Fixed faders, sliders and knobs jumping, running ahead of the pointer or - snapping back on release during left- and right-button drags. While any - ordinary mouse button is held, Wine now leaves the drag entirely to the X - server's stock pointer path: no smooth-scroll selection, no XInput2 - reconstruction, no inertia, no pinch, and no coordinate rewrites, on every - desktop and from the moment Live loads. - - Added an XWayland repair for faders and knobs that move farther than the - pointer. It defaults to `auto`, engaging only after Wine observes failed - warps, and a button release is repaired only when the drag's own motion was. - Desktop testing remains open. - - Added `WINE_X11_POINTER_FEATURES=disabled`, a master switch that turns the - optional pointer features off for one launch for baseline comparisons. The - issue 122 clipping-state repair remains active. - - Named pointer values ignore letter case. `off` and `0` work wherever - `disabled` works. Invalid settings appear in the normal launch log. - - Added a mitigation strategy in response to consistent reports of - misbehaving drag inputs on COSMIC. The held-button repair above answers - those reports: on COSMIC, as on every other desktop, holding a button hands - the whole drag to the X server. + - Added fine vertical and horizontal scrolling, pinch zoom, and middle-button navigation. Middle-button dragging moves the content with the pointer on both axes. Holding Ctrl during that drag zooms instead, with a drag towards the top of the screen zooming in. A plain middle click remains a click. + - Scrolling sends fractional movement by default. Selecting the XI2 events it needs once made faders and knobs cross their whole range from a small movement on XWayland; the held-button repair below stops that, so the setting no longer has to be off. `WINE_X11_SMOOTH_SCROLLING=disabled` restores whole wheel notches. Found by @ClickSentinel. + - Fixed left- and right-button control drags speeding up when a second touch starts scrolling. Normal one-finger dragging and middle-button navigation remain unchanged. + - Touchpad scrolling and pinch zoom cannot move a control while a mouse button is held. A mouse wheel still works except during middle-button navigation, and pressing a button stops earlier continued movement. + - Added scrolling inertia and middle-drag throw. Both are on by default and have separate off switches. Fast releases keep their speed, then movement slows gradually. A short or curved middle-button drag can throw. New input or a window change stops it. Live does not replay missed movement after a pause. + - Added a repair for fader jumps after loading a Max for Live device. Testing on the affected Fedora computer remains open. + - Fixed faders, sliders and knobs jumping, running ahead of the pointer or snapping back on release during left- and right-button drags. While any ordinary mouse button is held, Wine now leaves the drag entirely to the X server's stock pointer path: no smooth-scroll selection, no XInput2 reconstruction, no inertia, no pinch, and no coordinate rewrites, on every desktop and from the moment Live loads. + - Added an XWayland repair for faders and knobs that move farther than the pointer. It defaults to `auto`, engaging only after Wine observes failed warps, and a button release is repaired only when the drag's own motion was. Desktop testing remains open. + - Added `WINE_X11_POINTER_FEATURES=disabled`, a master switch that turns the optional pointer features off for one launch for baseline comparisons. The issue 122 clipping-state repair remains active. + - Named pointer values ignore letter case. `off` and `0` work wherever `disabled` works. Invalid settings appear in the normal launch log. + - Added a mitigation strategy in response to consistent reports of misbehaving drag inputs on COSMIC. The held-button repair above answers those reports: on COSMIC, as on every other desktop, holding a button hands the whole drag to the X server. - **Substantially hardened the new Runtime+Ableton Live installer script:** - - Hardened `sudo` password requests in the rare case that the installer - requires admin privileges. - - When asking for `sudo` privileges, the installer waits 120 seconds for your - password, then stops and tells you that it timed out. + - Hardened `sudo` password requests in the rare case that the installer requires admin privileges. + - When asking for `sudo` privileges, the installer waits 120 seconds for your password, then stops and tells you that it timed out. - A failed install no longer reports that it could not undo itself when it did. - - A failed install will now reliably write the reason its recovery failed into - `rollback.log`, next to the failure record. - - The installer checks every file it copies into your menus before it changes - anything. - - If you have an Ableton Live or Max desktop file from some other Wine project, - the installer now won't make assumptions and 'helpfully' remove it. - - For safety reasons, the real-time setup now checks `ABLETON_RT_GROUP` before - it completing setup. It will reject a user group that grants more than audio - access. - - `installer update` and `installer prefix update` will now tell you what it is - actually missing when it can't find a runtime, prefix or component. + - A failed install will now reliably write the reason its recovery failed into `rollback.log`, next to the failure record. + - The installer checks every file it copies into your menus before it changes anything. + - If you have an Ableton Live or Max desktop file from some other Wine project, the installer now won't make assumptions and 'helpfully' remove it. + - For safety reasons, the real-time setup now checks `ABLETON_RT_GROUP` before it completing setup. It will reject a user group that grants more than audio access. + - `installer update` and `installer prefix update` will now tell you what it is actually missing when it can't find a runtime, prefix or component. - Revised the `uninstall` command in the installer script. Uninstall now: - properly clears the file types and links it registered. - recovers old file types that you assigned yourself. - tells you when it encounters a file it does not recognise. -- The Ableton Link portion of the installer has also been hardened since its - redesign: - - The Ableton Link service will now start on computers that don't have systemd - user service. You only need systemd if you plan leaving `ableton-linkd` running - in the background even when Live isn't running. - - `installer link enable` now installs every file that - `~/.local/share/ableton-wine/setup-link.sh` needs to run, even if installing - separately to Live. - - `installer link status` will let you query the state of the Link install process - while an install, update, or uninstall runs. - - Stopping the Link daemon waits for the lock the launcher uses, so starting - Live during an install cannot leave a daemon behind. (Thanks Lucas Gillingham!) - - Similar to the `sudo` hardening above, Link setup will now actually tell you - why it needs your password for any `sudo` command. +- The Ableton Link portion of the installer has also been hardened since its redesign: + - The Ableton Link service will now start on computers that don't have systemd user service. You only need systemd if you plan leaving `ableton-linkd` running in the background even when Live isn't running. + - `installer link enable` now installs every file that `~/.local/share/ableton-wine/setup-link.sh` needs to run, even if installing separately to Live. + - `installer link status` will let you query the state of the Link install process while an install, update, or uninstall runs. + - Stopping the Link daemon waits for the lock the launcher uses, so starting Live during an install cannot leave a daemon behind. (Thanks @ClickSentinel!) + - Similar to the `sudo` hardening above, Link setup will now actually tell you why it needs your password for any `sudo` command. - Real-time setup detects attempts to run blindly as root. ## 2026.08.14.2 -- Registering the audio driver no longer reports failure after it succeeded. - Windows `reg` ends its lines with a carriage return, which the check did not - strip, so the CLSID never matched. Fix by Lucas Gillingham. +- Registering the audio driver no longer reports failure after it succeeded. Windows `reg` ends its lines with a carriage return, which the check did not strip, so the CLSID never matched. Fix by @ClickSentinel. - README and TROUBLESHOOTING match the commands the installer actually takes. ## 2026.08.14.1 -- The original installer was written at a time where the potential of this - project was unfathomable. Completed a complete rewrite of the installer: - - The installer can update individual parts, restore the previous runtime, and - remove files while preserving user changes. +- The original installer was written at a time where the potential of this project was unfathomable. Completed a complete rewrite of the installer: + - The installer can update individual parts, restore the previous runtime, and remove files while preserving user changes. - Link setup retries after an incomplete service step. - - A failed install or update now restores the saved installer and PipeASIO - configuration as well as the previous runtime. - - The single-file installer includes audio reporting, real-time setup, and - rollback tools. - - Official packages now require a completed ThreadSanitizer run. Local builds - that skip a recognised ThreadSanitizer startup limitation cannot be released. - - Release checks now reject incomplete patch lists and mismatched build, - runtime, or installer records. - - The session power setting now stays active when Live starts from an - `ableton://` URL or an `.auz` file. + - A failed install or update now restores the saved installer and PipeASIO configuration as well as the previous runtime. + - The single-file installer includes audio reporting, real-time setup, and rollback tools. + - Official packages now require a completed ThreadSanitizer run. Local builds that skip a recognised ThreadSanitizer startup limitation cannot be released. + - Release checks now reject incomplete patch lists and mismatched build, runtime, or installer records. + - The session power setting now stays active when Live starts from an `ableton://` URL or an `.auz` file. - An unresponsive power service no longer stalls Live's launch. - - The real-time setup removes the boot-time CPU setting used by earlier - releases. - - `ableton-linkd` rejects fractional linger values instead of treating them as - a request to stay open forever. - - Closing a file dialog with the titlebar X or Escape now cancels it instead - of opening Wine's own dialog (issue 146). Affected Mint 22 and Ubuntu - 24.04. - - Live no longer shows two icons in the taskbar. The launcher now takes the - name, icon and window class from the version it finds. Entries you wrote - yourself are left alone. - - The desktop entries describe Live in plain words instead of naming the - build's internals. + - The real-time setup removes the boot-time CPU setting used by earlier releases. + - `ableton-linkd` rejects fractional linger values instead of treating them as a request to stay open forever. + - Closing a file dialog with the titlebar X or Escape now cancels it instead of opening Wine's own dialog (issue 146). Affected Mint 22 and Ubuntu 24.04. + - Live no longer shows two icons in the taskbar. The launcher now takes the name, icon and window class from the version it finds. Entries you wrote yourself are left alone. + - The desktop entries describe Live in plain words instead of naming the build's internals. - Fixed the beta launcher's icon. - Completed a substantial upgrade to the Ableton-Linux audio system: - Updated the audio driver from PipeASIO 1.2.2 to 1.5.0. - The installer and audio driver now require PipeWire 1.4.2 or newer. - Enabled the ability to use every buffer size from 32 to 8192 frames. - - Live now pauses audio while it changes to the PipeWire buffer size used by - another audio programme, then resumes at the correct speed. - - Any sample rate accepted by Live is now retained after the audio engine - restarts. + - Live now pauses audio while it changes to the PipeWire buffer size used by another audio programme, then resumes at the correct speed. + - Any sample rate accepted by Live is now retained after the audio engine restarts. - MIDI timestamps continue across a 49.7-day timer wrap while audio is running. - - PipeASIO Settings ships with the runtime and can be accessed from applications - (eg in Live's Settings -> Audio, Hardware Config). - - PipeASIO real-time scheduling is off by default. Its environment override now - accepts `1/0`, `true/false`, `yes/no`, `on/off`, and `enabled/disabled`. -- Attempted (and aborted) an ambitious and optimised input Wine patch. - (see `2026.08.14.3`): + - PipeASIO Settings ships with the runtime and can be accessed from applications (eg in Live's Settings -> Audio, Hardware Config). + - PipeASIO real-time scheduling is off by default. Its environment override now accepts `1/0`, `true/false`, `yes/no`, `on/off`, and `enabled/disabled`. +- Attempted (and aborted) an ambitious and optimised input Wine patch. (see `2026.08.14.3`): - .... :( - Completed the first part of a multi-stage performance moonshot: - - Unlocked Live's GPU renderer on Intel GPU chipsets built into 2015 - through 2019 processors (Wine patch 0066). Wine's device table skipped 24 - of those models, and reported every one of them to Ableton Live as a 2012 - Ivy Bridge chipset that Live refuses to use. - - Graphics cards missing from Wine's device table keep their real identity - even when the driver reports no video memory, instead of falling back to - that refused 2012 model (Wine patch 0067). - - Bypassed Live's GPU deny-list, that specifically contained a GPU chipsets - with terrible closed-source Windows drivers (thanks, Microsoft and Intel!!) - but have good Linux support. We do this by lying to Live's deny list, - reporting a device ID that is not on it. Live reads the ID alone when it - decides, so your card keeps its real name and carries the reported one - alongside it. For affected machines, this is the first time you can run Live - with GPU acceleration, providing a noticeable performance boost. - - Added `WINE_D3D_FORCE_GPU_RENDERING=1` to enable the above GPU denylist - bypass (Wine patch 0068). In crash logs, we tell Ableton's engineering team - that the GPU listed in the crash log is substituted, to avoid poisoning - Ableton's QA team data set. -- The launch log now warns when a drawing fault holds Live on the slow copy - path, which costs about one core of CPU with nothing on screen to show it - (issue 100 follow-up, Wine patch 0071). -- **Completed an entirely custom and open-source font anti-aliasing renderer for - Wine applications**: - - Experimental ClearType-style subpixel rendering is now available to - DirectWrite, Direct2D and GDI text. Prefix setup enables it by default and - follows the desktop's RGB/BGR order; set `ABLETON_TEXT_SMOOTHING=grayscale` - for deliberate greyscale rendering or `preserve` to leave an existing - prefix policy untouched. An explicitly disabled `FontSmoothing=0` is never - overwritten by the launcher. - - Added standalone DirectWrite, Direct2D and GDI probes for checking the text - path without launching Live. The ClearType texture probe uses an outline - size and compares each RGB coverage triple, so symmetric filtering no longer - produces a false greyscale verdict. - - Added a downstream `DesktopUIFont` integration hook for changing Wine's - semantic desktop UI stock font without globally substituting Tahoma. - This means that applications (including Live) launched in this runtime - will render with your DE's interface font. + - Unlocked Live's GPU renderer on Intel GPU chipsets built into 2015 through 2019 processors (Wine patch 0066). Wine's device table skipped 24 of those models, and reported every one of them to Ableton Live as a 2012 Ivy Bridge chipset that Live refuses to use. + - Graphics cards missing from Wine's device table keep their real identity even when the driver reports no video memory, instead of falling back to that refused 2012 model (Wine patch 0067). + - Bypassed Live's GPU deny-list, that specifically contained a GPU chipsets with terrible closed-source Windows drivers (thanks, Microsoft and Intel!!) but have good Linux support. We do this by lying to Live's deny list, reporting a device ID that is not on it. Live reads the ID alone when it decides, so your card keeps its real name and carries the reported one alongside it. For affected machines, this is the first time you can run Live with GPU acceleration, providing a noticeable performance boost. + - Added `WINE_D3D_FORCE_GPU_RENDERING=1` to enable the above GPU denylist bypass (Wine patch 0068). In crash logs, we tell Ableton's engineering team that the GPU listed in the crash log is substituted, to avoid poisoning Ableton's QA team data set. +- The launch log now warns when a drawing fault holds Live on the slow copy path, which costs about one core of CPU with nothing on screen to show it (issue 100 follow-up, Wine patch 0071). +- **Completed an entirely custom and open-source font anti-aliasing renderer for Wine applications**: + - Experimental ClearType-style subpixel rendering is now available to DirectWrite, Direct2D and GDI text. Prefix setup enables it by default and follows the desktop's RGB/BGR order; set `ABLETON_TEXT_SMOOTHING=grayscale` for deliberate greyscale rendering or `preserve` to leave an existing prefix policy untouched. An explicitly disabled `FontSmoothing=0` is never overwritten by the launcher. + - Added standalone DirectWrite, Direct2D and GDI probes for checking the text path without launching Live. The ClearType texture probe uses an outline size and compares each RGB coverage triple, so symmetric filtering no longer produces a false greyscale verdict. + - Added a downstream `DesktopUIFont` integration hook for changing Wine's semantic desktop UI stock font without globally substituting Tahoma. This means that applications (including Live) launched in this runtime will render with your DE's interface font. - Significantly revised window management, logic and user interface behaviour: - - A Live window sized to fill the monitor can be resized again (Wine patch - 0069). `WINE_WIN32_RESIZABLE_CLASS=off` turns the fix off. - - Completed a full pass over Live's keyboard shortcuts, meaning Live now - responds exactly as expected to keyboard shortcuts. (Wine patch 0070). - For system shortcuts especially, the runtime now catches those before the DE - but will give them back on exit. Run - `env ABLETON_SHORTCUTS=take ableton-live` to enable this feature. - - An application can maximise its own window while a startup dialog holds it - disabled (Wine patch 0077). - - Windows open at the size they ask for on per-monitor-aware applications - (Wine patch 0078). Wine 11.13 sized them at a quarter of the request. - -- Shaved about two seconds off Live's startup time by focusing on specific - Max 4 Live startup optimisations. (Wine patch 0096). In this case, Max 4 Live's - startup forces the system to re-read every host font on every launch. - We now cache this in the prefix and re-run when your font directories (eg - `~/.fonts`) change. `env WINE_DISABLE_HOST_FONT_CACHE=1` turns the cache off. -- Fixed three missing entry points that ended a plugin or helper process - outright (Wine patches 0075, 0076 and 0080). Patch 0080 implements the four - entry points beside it as well, which belong to the same lifecycle and would - each have ended the process in turn. -- Fixed an application scanning every window on the desktop twice a frame - while it drew its interface (Wine patch 0079). + - A Live window sized to fill the monitor can be resized again (Wine patch 0069). `WINE_WIN32_RESIZABLE_CLASS=off` turns the fix off. + - Completed a full pass over Live's keyboard shortcuts, meaning Live now responds exactly as expected to keyboard shortcuts. (Wine patch 0070). For system shortcuts especially, the runtime now catches those before the DE but will give them back on exit. Run `env ABLETON_SHORTCUTS=take ableton-live` to enable this feature. + - An application can maximise its own window while a startup dialog holds it disabled (Wine patch 0077). + - Windows open at the size they ask for on per-monitor-aware applications (Wine patch 0078). Wine 11.13 sized them at a quarter of the request. + +- Shaved about two seconds off Live's startup time by focusing on specific Max 4 Live startup optimisations. (Wine patch 0096). In this case, Max 4 Live's startup forces the system to re-read every host font on every launch. We now cache this in the prefix and re-run when your font directories (eg `~/.fonts`) change. `env WINE_DISABLE_HOST_FONT_CACHE=1` turns the cache off. +- Fixed three missing entry points that ended a plugin or helper process outright (Wine patches 0075, 0076 and 0080). Patch 0080 implements the four entry points beside it as well, which belong to the same lifecycle and would each have ended the process in turn. +- Fixed an application scanning every window on the desktop twice a frame while it drew its interface (Wine patch 0079). ## 2026.08.04.1 -- Full Screen works (issue 42). Entering it no longer shifts Live's - content or makes clicks land away from their targets, and leaving it no - longer keeps the fullscreen image on screen until the window is moved. - Contributed by trendwhore. -- The Live 12 installer now runs by itself during setup: no clicking - through its window, and it skips Ableton's Windows-only USB audio - driver. Live 11 ships a different installer that ignores the same - instructions, so it still opens its window; click through it as before. - Telling the two installers apart is by Lucas Gillingham. -- Setup no longer stops at a "Wine Mono Installer" window. Live does not - use Mono, so setup turns it off and Wine stops asking; unattended - installs no longer hang there. Fix by Lucas Gillingham. -- Installing Live 11 no longer shows two "Program Error" windows (issue - 111). A helper of Ableton's installer fails in a way that changes - nothing, and the install was always fine; the alarming windows are gone. - Fix by Lucas Gillingham. -- Updating while Live is running now works. The updater used to refuse - and ask you to close Live and rerun; it now closes everything the old - version left running, and asks first when Live itself is open, since - closing Live discards unsaved work. Pressing Ctrl-C during an update - now puts the previous version back, or reports that nothing was - changed. Confirmation and safeguards by Lucas Gillingham. -- Updates to ableton-linkd apply immediately. The updater now restarts - it, so the old copy no longer keeps running until the computer - restarts. -- ableton-linkd now writes a log line only when something changes: an - app joining or leaving the Link session, a tempo change, or play and - stop. The status line it repeated every 10 seconds filled the system - log and made a healthy process look stuck; `--verbose` brings it back. +- Full Screen works (issue 42). Entering it no longer shifts Live's content or makes clicks land away from their targets, and leaving it no longer keeps the fullscreen image on screen until the window is moved. Contributed by trendwhore. +- The Live 12 installer now runs by itself during setup: no clicking through its window, and it skips Ableton's Windows-only USB audio driver. Live 11 ships a different installer that ignores the same instructions, so it still opens its window; click through it as before. Telling the two installers apart is by @ClickSentinel. +- Setup no longer stops at a "Wine Mono Installer" window. Live does not use Mono, so setup turns it off and Wine stops asking; unattended installs no longer hang there. Fix by @ClickSentinel. +- Installing Live 11 no longer shows two "Program Error" windows (issue 111). A helper of Ableton's installer fails in a way that changes nothing, and the install was always fine; the alarming windows are gone. Fix by @ClickSentinel. +- Updating while Live is running now works. The updater used to refuse and ask you to close Live and rerun; it now closes everything the old version left running, and asks first when Live itself is open, since closing Live discards unsaved work. Pressing Ctrl-C during an update now puts the previous version back, or reports that nothing was changed. Confirmation and safeguards by @ClickSentinel. +- Updates to ableton-linkd apply immediately. The updater now restarts it, so the old copy no longer keeps running until the computer restarts. +- ableton-linkd now writes a log line only when something changes: an app joining or leaving the Link session, a tempo change, or play and stop. The status line it repeated every 10 seconds filled the system log and made a healthy process look stuck; `--verbose` brings it back. ## 2026.08.01.1 -- Live's GPU renderer is available again on Intel graphics newer than 2019 - (issue 84, Wine patch 0057). Wine reported every Intel GPU from Ice Lake - through Lunar Lake, and the Arc A-series cards, as "Intel(R) HD Graphics - 4000", a 2012 device that Live refuses to use, so the Preferences dialog - greyed out "Enable GPU Renderer". Wine now reports the real device names. - Reported by stickyfran. -- Fixed Live's frame drawn too low under fractional display scaling, a black - band on top and the bottom rows clipped (PR 98, issue 100, Wine patches - 0058 and 0059). The frame's destination rectangle was captured in the - window's DPI context, but the present-time blit re-queried the client rect - in the render thread's context, and at 125% or similar scaling the two - disagreed by the difference. Both queries now run in the window's DPI - context, and any frame where they still disagree takes the GDI path, which - renders correctly under the mismatch. Reported by v33 and NoskyD. -- GPUs missing from Wine's device table now report their real names (Wine - patch 0061). Unlisted cards fell back to a guess from the OpenGL feature - level, so a Radeon RX 7900 XT was reported as "ATI Radeon HD 5600 Series" - and newer Intel GPUs as "Intel(R) HD Graphics 4000". The name, PCI IDs and - video memory now come from the driver's own report, as the Vulkan backend - already does. Contributed by Lucas Gillingham. -- Fixed Live freezing when loading certain Max for Live devices: window - black and unresponsive, audio still playing. Devices authored on macOS - name fonts that do not exist here, and Max's own last-resort fallback is - Bitstream Vera, which neither Wine nor Live nor modern distributions - ship, so Max parked Live's UI thread waiting for a font that never came. - The Bitstream Vera faces are now vendored, installed and registered by - prefix setup. Diagnosis and fix by Lucas Gillingham. -- Fixed a black flash of Live's whole window when selecting away from and - back to a Max for Live track under Xwayland (Wine patch 0062). The M4L - device view's child window toggled Live's client surface between two - rendering paths, and each toggle unmapped and remapped the full client - window mid-frame. The launcher now keeps Live's top-level window on the - offscreen-composited path (`WINE_X11_FORCE_OFFSCREEN_CLASS`). Contributed - by trendwhore. -- Non-Latin menu text renders correctly (issue 35, Wine patch 0054). Live's - menus use its own Latin-only fonts, so Cyrillic, CJK and other non-Latin - menu items, project names and track names showed as boxes or nothing. - Menu drawing now falls back to a linked font for missing glyphs, and - prefix setup registers a glyph fallback chain for Live's fonts. - Contributed by Lucas Gillingham. -- mp3 and video import works again (issue 44). The build silently dropped - Wine's GStreamer support when the GStreamer development packages were - missing from the build image, so Live's import path had no decoder and - failed without an error message. The build and the installer now fail - loudly when the component is missing, and GStreamer with its base and - good plugin sets joins the runtime requirements. -- Deleting files from Live's browser works (Wine patch 0060). Live deletes - through `IFileOperation::DeleteItem`, which Wine left unimplemented, so - browser deletion and the pruning of old project backups failed. Deletes - now run through Wine's existing file operation engine and keep the - recycle-bin behaviour. -- "Show in Explorer" on a folder opens the desktop file manager (issue 41, - Wine patches 0063 and 0064). Folder targets went through the OpenURI - portal's OpenDirectory method, which is defined for files, so on the - reported setups the reveal fell back to Wine Explorer, and the library - panel's folder-open form (`explorer.exe /e`) was never intercepted at - all. Reveals now call `org.freedesktop.FileManager1.ShowItems`, which - opens the parent folder with the target selected, and folder opens call - `ShowFolders`, which opens the folder itself; the portal and Wine - Explorer remain the fallbacks. File targets are unchanged. +- Live's GPU renderer is available again on Intel graphics newer than 2019 (issue 84, Wine patch 0057). Wine reported every Intel GPU from Ice Lake through Lunar Lake, and the Arc A-series cards, as "Intel(R) HD Graphics 4000", a 2012 device that Live refuses to use, so the Preferences dialog greyed out "Enable GPU Renderer". Wine now reports the real device names. Reported by stickyfran. +- Fixed Live's frame drawn too low under fractional display scaling, a black band on top and the bottom rows clipped (PR 98, issue 100, Wine patches 0058 and 0059). The frame's destination rectangle was captured in the window's DPI context, but the present-time blit re-queried the client rect in the render thread's context, and at 125% or similar scaling the two disagreed by the difference. Both queries now run in the window's DPI context, and any frame where they still disagree takes the GDI path, which renders correctly under the mismatch. Reported by v33 and NoskyD. +- GPUs missing from Wine's device table now report their real names (Wine patch 0061). Unlisted cards fell back to a guess from the OpenGL feature level, so a Radeon RX 7900 XT was reported as "ATI Radeon HD 5600 Series" and newer Intel GPUs as "Intel(R) HD Graphics 4000". The name, PCI IDs and video memory now come from the driver's own report, as the Vulkan backend already does. Contributed by @ClickSentinel. +- Fixed Live freezing when loading certain Max for Live devices: window black and unresponsive, audio still playing. Devices authored on macOS name fonts that do not exist here, and Max's own last-resort fallback is Bitstream Vera, which neither Wine nor Live nor modern distributions ship, so Max parked Live's UI thread waiting for a font that never came. The Bitstream Vera faces are now vendored, installed and registered by prefix setup. Diagnosis and fix by @ClickSentinel. +- Fixed a black flash of Live's whole window when selecting away from and back to a Max for Live track under Xwayland (Wine patch 0062). The M4L device view's child window toggled Live's client surface between two rendering paths, and each toggle unmapped and remapped the full client window mid-frame. The launcher now keeps Live's top-level window on the offscreen-composited path (`WINE_X11_FORCE_OFFSCREEN_CLASS`). Contributed by trendwhore. +- Non-Latin menu text renders correctly (issue 35, Wine patch 0054). Live's menus use its own Latin-only fonts, so Cyrillic, CJK and other non-Latin menu items, project names and track names showed as boxes or nothing. Menu drawing now falls back to a linked font for missing glyphs, and prefix setup registers a glyph fallback chain for Live's fonts. Contributed by @ClickSentinel. +- mp3 and video import works again (issue 44). The build silently dropped Wine's GStreamer support when the GStreamer development packages were missing from the build image, so Live's import path had no decoder and failed without an error message. The build and the installer now fail loudly when the component is missing, and GStreamer with its base and good plugin sets joins the runtime requirements. +- Deleting files from Live's browser works (Wine patch 0060). Live deletes through `IFileOperation::DeleteItem`, which Wine left unimplemented, so browser deletion and the pruning of old project backups failed. Deletes now run through Wine's existing file operation engine and keep the recycle-bin behaviour. +- "Show in Explorer" on a folder opens the desktop file manager (issue 41, Wine patches 0063 and 0064). Folder targets went through the OpenURI portal's OpenDirectory method, which is defined for files, so on the reported setups the reveal fell back to Wine Explorer, and the library panel's folder-open form (`explorer.exe /e`) was never intercepted at all. Reveals now call `org.freedesktop.FileManager1.ShowItems`, which opens the parent folder with the target selected, and folder opens call `ShowFolders`, which opens the folder itself; the portal and Wine Explorer remain the fallbacks. File targets are unchanged. ## 2026.07.29.1 -- Live now uses its GPU renderer. Prefix setup removes the legacy - `-_ForceGdiBackend` line from `Options.txt` (step 5c). This removes the - Learn View and Splice view flicker in the measured cases and drops idle CPU - to 1-2%. Some edge cases remain under investigation. -- Fixed windows fighting an interactive resize below the app minimum - (Wine patch 0053). winex11 now exports the `WM_GETMINMAXINFO` minimum - as the X11 `PMinSize` hint, and the window manager stops the drag at - the minimum. -- Fixed high CPU use and display traffic with the GPU renderer (issue 91, - Wine patch 0055). Wine copied every finished frame of Live's main window - from the graphics card back into main memory and sent it to the display - server as a full image, about 650 MB per second during continuous UI - activity such as mouse movement. Wine now shows finished frames directly - from the graphics card. Set `WINE_DISABLE_GL_PRESENT=1` to restore the - previous behaviour. Diagnosis and measurements by Lucas Gillingham. -- Fixed the flicker left behind in the Learn View's rectangle after the pane - closes (issue 57, Wine patch 0056). Live parks the WebView2 pane rather than - destroying it, so Wine kept stamping the last captured frame into the closed - pane's area at timer cadence. DirectComposition re-blits now stop while the - target window's ancestor chain is hidden. Reported by jttdev, reviewed by - Giang Nguyen. -- Menu colours now follow the desktop theme correctly (issue 35, Wine patches - 0049 to 0052). The menu bar takes the darker chrome colour and dropdowns take - the lighter content colour, greyed items lose the engraved bevel, - `SetSysColors` invalidates the per-process colour cache and repaints the - non-client area, and the menu bar hides its alt-key mnemonic underlines until - Alt is held. The theme watcher waits on inotify when `inotify-tools` is - installed and selects the newest `Preferences.cfg` by modification time. A - live theme switch can still take a few seconds to appear. -- Moved the Wine base from 11.11 to 11.13 (giang17/wine `d2d1-dcomp-11.13` at - `5c23dd1c`). Wine patches 0046 to 0048 fix the series against 11.13's - frame-latency, fractional-DPI, and libusb detection changes. The runtime now - installs to `~/.local/opt/wine-d2d1-nspa-11.13`; the 11.11 directory from - earlier releases stays on disk and can be deleted, about 380 MB. -- The installer now configures Ableton Link during installation. Setup no - longer adds a multicast route or NetworkManager hook: the Link SDK selects - its interfaces itself. `sudo` is used to open UDP port 20808 when UFW or - firewalld is active, and on existing installs to remove the old hook and - route during one setup re-run. `--no-link` skips the step and is - remembered on later runs; `--link` opts back in. -- The README now covers installation and ordinary use. Troubleshooting, source - builds, configuration overrides, and maintainer material have dedicated - documents. The credits name the giang17/wine `d2d1-dcomp` stack this project - builds on. +- Live now uses its GPU renderer. Prefix setup removes the legacy `-_ForceGdiBackend` line from `Options.txt` (step 5c). This removes the Learn View and Splice view flicker in the measured cases and drops idle CPU to 1-2%. Some edge cases remain under investigation. +- Fixed windows fighting an interactive resize below the app minimum (Wine patch 0053). winex11 now exports the `WM_GETMINMAXINFO` minimum as the X11 `PMinSize` hint, and the window manager stops the drag at the minimum. +- Fixed high CPU use and display traffic with the GPU renderer (issue 91, Wine patch 0055). Wine copied every finished frame of Live's main window from the graphics card back into main memory and sent it to the display server as a full image, about 650 MB per second during continuous UI activity such as mouse movement. Wine now shows finished frames directly from the graphics card. Set `WINE_DISABLE_GL_PRESENT=1` to restore the previous behaviour. Diagnosis and measurements by @ClickSentinel. +- Fixed the flicker left behind in the Learn View's rectangle after the pane closes (issue 57, Wine patch 0056). Live parks the WebView2 pane rather than destroying it, so Wine kept stamping the last captured frame into the closed pane's area at timer cadence. DirectComposition re-blits now stop while the target window's ancestor chain is hidden. Reported by jttdev, reviewed by @giang17. +- Menu colours now follow the desktop theme correctly (issue 35, Wine patches 0049 to 0052). The menu bar takes the darker chrome colour and dropdowns take the lighter content colour, greyed items lose the engraved bevel, `SetSysColors` invalidates the per-process colour cache and repaints the non-client area, and the menu bar hides its alt-key mnemonic underlines until Alt is held. The theme watcher waits on inotify when `inotify-tools` is installed and selects the newest `Preferences.cfg` by modification time. A live theme switch can still take a few seconds to appear. +- Moved the Wine base from 11.11 to 11.13 (giang17/wine `d2d1-dcomp-11.13` at `5c23dd1c`). Wine patches 0046 to 0048 fix the series against 11.13's frame-latency, fractional-DPI, and libusb detection changes. The runtime now installs to `~/.local/opt/wine-d2d1-nspa-11.13`; the 11.11 directory from earlier releases stays on disk and can be deleted, about 380 MB. +- The installer now configures Ableton Link during installation. Setup no longer adds a multicast route or NetworkManager hook: the Link SDK selects its interfaces itself. `sudo` is used to open UDP port 20808 when UFW or firewalld is active, and on existing installs to remove the old hook and route during one setup re-run. `--no-link` skips the step and is remembered on later runs; `--link` opts back in. +- The README now covers installation and ordinary use. Troubleshooting, source builds, configuration overrides, and maintainer material have dedicated documents. The credits name the giang17/wine `d2d1-dcomp` stack this project builds on. - Added a repository Code of Conduct. -- Fixed a Live crash when closing WebView2 plugin editors (issue 52, Wine - patch 0045). `RevokeDragDrop` now rejects windows owned by another process, - matching `RegisterDragDrop`. Fix by Giang Nguyen. -- `build.sh` now creates `dist/ableton-linkd`. Installer packaging calls the - same Podman helper when that artifact is absent or not executable. The - helper builds against the vendored Ableton Link SDK. -- Fixed Live installers failing on non-ASCII Max filenames under the `C` - locale (issues 51 and 55). Setup now uses `C.UTF-8`. The fault affected only - fresh installer runs. -- Prefix setup now shows the failing command and exit status when winetricks - fails (issue 28). -- `scripts/release.sh --notes-file ` places a hand-written summary at the - top of the GitHub release body, above the install instructions and build - provenance the workflow generates. The file is read at publish time and is - committed nowhere. +- Fixed a Live crash when closing WebView2 plugin editors (issue 52, Wine patch 0045). `RevokeDragDrop` now rejects windows owned by another process, matching `RegisterDragDrop`. Fix by @giang17. +- `build.sh` now creates `dist/ableton-linkd`. Installer packaging calls the same Podman helper when that artifact is absent or not executable. The helper builds against the vendored Ableton Link SDK. +- Fixed Live installers failing on non-ASCII Max filenames under the `C` locale (issues 51 and 55). Setup now uses `C.UTF-8`. The fault affected only fresh installer runs. +- Prefix setup now shows the failing command and exit status when winetricks fails (issue 28). +- `scripts/release.sh --notes-file ` places a hand-written summary at the top of the GitHub release body, above the install instructions and build provenance the workflow generates. The file is read at publish time and is committed nowhere. - CI builds Wine on pull requests that touch the runtime, using ccache. ## 2026.07.23.1 -- Added built-in Ableton Link support. `ableton-linkd` is a passive native peer - that remains in the session while Live restarts. `ableton-linkd --probe 10` - reports the peer count and tempo. +- Added built-in Ableton Link support. `ableton-linkd` is a passive native peer that remains in the session while Live restarts. `ableton-linkd --probe 10` reports the peer count and tempo. - Added `linkprobe.exe` to test Wine multicast on UDP port 20808 without Live. -- Shipped `setup-link.sh`. It configures the multicast route, adds a UDP 20808 - allowance when UFW or firewalld is installed, installs a NetworkManager hook - when its dispatcher directory exists, and enables `ableton-linkd.service` - when its files are present. -- Added COSMIC display-scale detection. Contributed by ClickSentinel in pull - request 54. +- Shipped `setup-link.sh`. It configures the multicast route, adds a UDP 20808 allowance when UFW or firewalld is installed, installs a NetworkManager hook when its dispatcher directory exists, and enables `ableton-linkd.service` when its files are present. +- Added COSMIC display-scale detection. Contributed by ClickSentinel in pull request 54. ## 2026.07.22.1 -- Show in Explorer now opens the host file manager when the XDG portal accepts - the request (issue 41, Wine patch 0043). Wine Explorer remains the fallback. -- Added a Max 9 launcher, desktop entry, `c74max` URL handler, and Max for Live - file association. +- Show in Explorer now opens the host file manager when the XDG portal accepts the request (issue 41, Wine patch 0043). Wine Explorer remains the fallback. +- Added a Max 9 launcher, desktop entry, `c74max` URL handler, and Max for Live file association. - Added the missing `learnheal.exe` to the installer kit. -- PipeASIO now registers without a `regsvr32` dialog. Contributed by jackson-57 - in pull request 37. +- PipeASIO now registers without a `regsvr32` dialog. Contributed by jackson-57 in pull request 37. ## 2026.07.21.2 -- Fixed unbounded main-window growth during interactive resize at 125% scale. - Wine now returns the requested Win32 geometry when the host grant differs - only by sub-scale rounding (Wine patch 0042). +- Fixed unbounded main-window growth during interactive resize at 125% scale. Wine now returns the requested Win32 geometry when the host grant differs only by sub-scale rounding (Wine patch 0042). - Added `learnheal.exe` to repair a clipped Learn View after its layout settles. -- Added `learnheal.c`, `fakepane.c`, `livepanes.c`, `menucmd.c`, - `build_learnheal.sh`, `posteresize.exe`, `uidrag.c`, and `ukey.c`. -- Set the installer locale to `C` so localized `readelf` output could not break - the Push 2 check (issue 36). This was changed to `C.UTF-8` after issues 51 - and 55. -- The launcher now accepts Live documents from the command line (issue 38). - The installer also registers Live file types and icons (issue 40). -- Desktop entry names, icons, and window classes now match the installed Live - edition (issue 39). Icons were contributed by yioannides in pull request 25. +- Added `learnheal.c`, `fakepane.c`, `livepanes.c`, `menucmd.c`, `build_learnheal.sh`, `posteresize.exe`, `uidrag.c`, and `ukey.c`. +- Set the installer locale to `C` so localized `readelf` output could not break the Push 2 check (issue 36). This was changed to `C.UTF-8` after issues 51 and 55. +- The launcher now accepts Live documents from the command line (issue 38). The installer also registers Live file types and icons (issue 40). +- Desktop entry names, icons, and window classes now match the installed Live edition (issue 39). Icons were contributed by yioannides in pull request 25. ## 2026.07.21.1 -- Corrected Live's menu-band size model at 96 and 192 DPI (Wine patch 0040). - This fixed tiling but did not fix every interactive resize. Release - 2026.07.21.2 addressed the remaining parity loop. -- Changed Learn View's DirectComposition handling so current frames reach the - screen (Wine patch 0041). A one-pixel splitter resize was still needed when - Chromium opened the pane with a stale layout. +- Corrected Live's menu-band size model at 96 and 192 DPI (Wine patch 0040). This fixed tiling but did not fix every interactive resize. Release 2026.07.21.2 addressed the remaining parity loop. +- Changed Learn View's DirectComposition handling so current frames reach the screen (Wine patch 0041). A one-pixel splitter resize was still needed when Chromium opened the pane with a stale layout. - Added `posteresize.c`, `metricprobe2.c`, `xclose.c`, and `uiclick.c`. ## 2026.07.19.2 -- Fixed Live dropdown windows changing from unmanaged popups to managed - dialogs while open (issue 3, Wine patch 0039). That transition caused lost - clicks, flashes, and duplicate shadows. +- Fixed Live dropdown windows changing from unmanaged popups to managed dialogs while open (issue 3, Wine patch 0039). That transition caused lost clicks, flashes, and duplicate shadows. ## 2026.07.19.1 -- Removed `-DontCombineAPCs` from `Options.txt`. The option introduced slow and - broken playback in 2026.07.18.1 (issue 29). +- Removed `-DontCombineAPCs` from `Options.txt`. The option introduced slow and broken playback in 2026.07.18.1 (issue 29). - Added `ABLETON_RT=off` for normal-scheduling comparisons and low-core hosts. -- Pinned the base image, Ubuntu archive snapshot, and LLVM version used by the - build. -- Fixed menu cancellation after transient X11 focus changes (issue 3, Wine - patch 0038). -- Kept the close button on Live's title bar while its startup modal is open - under KDE (issue 31, Wine patch 0037). -- Added Live-themed and system-themed menu colours, Ableton Sans menu text, and - the `setsyscolors.exe` live refresh helper. +- Pinned the base image, Ubuntu archive snapshot, and LLVM version used by the build. +- Fixed menu cancellation after transient X11 focus changes (issue 3, Wine patch 0038). +- Kept the close button on Live's title bar while its startup modal is open under KDE (issue 31, Wine patch 0037). +- Added Live-themed and system-themed menu colours, Ableton Sans menu text, and the `setsyscolors.exe` live refresh helper. ## 2026.07.18.1 - Added experimental Live 11 setup through `ABLETON_LIVE_VERSION=11`. - Corrected GPU identification for Intel Arc B580 device `0xe20b` (issue 11). -- Stopped DirectComposition re-blits when its d2d1 device failed to initialise, - including the reported NVIDIA setup under NixOS and `steam-run` (issue 16). -- Added display-scale profiles from 100% to 250% and - `ABLETON_DPI_MODE=dpi`. -- Added the initial, unverified Link route setup and optional `jack_link` - launcher integration. +- Stopped DirectComposition re-blits when its d2d1 device failed to initialise, including the reported NVIDIA setup under NixOS and `steam-run` (issue 16). +- Added display-scale profiles from 100% to 250% and `ABLETON_DPI_MODE=dpi`. +- Added the initial, unverified Link route setup and optional `jack_link` launcher integration. - Added `setup-realtime.sh`. -- Added `-DontCombineAPCs` to reduce an idle Wine thread. Release 2026.07.19.1 - removed it because it broke playback. +- Added `-DontCombineAPCs` to reduce an idle Wine thread. Release 2026.07.19.1 removed it because it broke playback. - Synced Win32 menu colours to the desktop light or dark scheme. -- Changed Learn View to use SwiftShader and added `ABLETON_DCOMP=off`. - Later releases refined the Learn View fix. +- Changed Learn View to use SwiftShader and added `ABLETON_DCOMP=off`. Later releases refined the Learn View fix. - Reused Live's bundled VC++ redistributable when it was already valid. - Added `setup-prefix.sh --post-first-run` for the Max 8 preferences crash. -- Refused ambiguous launcher selection when one prefix contains several - editions of the same Live major. +- Refused ambiguous launcher selection when one prefix contains several editions of the same Live major. - Serialized launcher setup to prevent concurrent prefix changes. - Disabled Wine's Mono and HTML-help hooks for Live. - Added a capped `WINE_CPU_TOPOLOGY` value for future runtime support. - Added `scripts/ableton-profile.sh` for the Live 11 and 12 product matrix. - Documented Linux-native plugins routed through PipeWire. - Added `ntsyncprobe`, beta test documentation, and `scripts/bench-run.sh`. -- Removed machine-specific paths from probe builds, the beta prefix, and the - beta desktop entry. +- Removed machine-specific paths from probe builds, the beta prefix, and the beta desktop entry. ## 2026.07.17.3 -- Fixed dropped MIDI input under PipeASIO. The driver now reports - `timeGetTime`, matching the clock Live uses for MIDI timestamps. +- Fixed dropped MIDI input under PipeASIO. The driver now reports `timeGetTime`, matching the clock Live uses for MIDI timestamps. ## 2026.07.17.2 @@ -694,9 +357,6 @@ ## 2026.07.14.1 -- Published the first release with Wine 11.11, the d2d1-dcomp stack, 33 Wine - patches, one WineASIO patch, WineASIO 1.3.0, launchers, installer, and beta - kit. -- Set `WINE_DISABLE_UNIX_MOUNT_REPARSE=1` so Live treats host mount points as - directories. +- Published the first release with Wine 11.11, the d2d1-dcomp stack, 33 Wine patches, one WineASIO patch, WineASIO 1.3.0, launchers, installer, and beta kit. +- Set `WINE_DISABLE_UNIX_MOUNT_REPARSE=1` so Live treats host mount points as directories. - Made patch application reproducible with synthesized `git am` headers. diff --git a/INSTALLER.md b/INSTALLER.md index 27aeb649..324d8f1c 100644 --- a/INSTALLER.md +++ b/INSTALLER.md @@ -17,15 +17,52 @@ sh ~/Downloads/install-ableton-latest.run update | `install --live-installer FILE` | Installs Ableton Live and the Linux support files. Replace `FILE` with the path to your Ableton installation ZIP. | | `update` | Updates the software that runs Live on Linux, its launchers, and compatibility fixes. It keeps Live, your authorization, your projects, and your current Link choice. | | `runtime install` | Replaces only the software that runs Live on Linux. | -| `prefix create` | Creates the private Windows-style folder that holds Live and its settings. | +| `prefix create` | Creates the private Windows-style folder that contains Live and its settings. | | `prefix update` | Updates the audio support and settings in an existing Live folder. | | `prefix repair-live11` | Moves incompatible Live 11 Max preferences aside so Max can create clean ones. | | `link enable --mode=session` | Enables Link while Live or Max is running. | | `link enable --mode=always` | Starts Link after login and keeps it running. | | `link disable` | Turns off Link and removes only the Link files and firewall rule this project added. | -| `link status` | Shows whether Link is running and whether this project added a firewall rule. | -| `uninstall --keep-prefix` | Removes this project's Linux support while keeping Live and its authorization. | -| `uninstall --delete-prefix` | Removes this project, Live, and its authorization. It keeps your Live Sets. | -| `plan COMMAND` | Checks and shows what another command will do without applying the changes. Replace `COMMAND` with a command such as `update`. | +| `link status` | Reports whether Link is running and whether this project added a firewall rule. | +| `uninstall --keep-prefix` | Removes recorded Linux files and the Wine runtime. It doesn't remove the ableton-linux prefix, Live, authorization, installer settings, or shared records. | +| `uninstall --delete-prefix` | Removes recorded Linux files, the runtime, prefix, Live, authorization, and valid installer settings. It keeps Live Sets stored outside the prefix. | +| `plan COMMAND` | Validates another command while preserving current files. Install and update plans list their actions. Uninstall plans validate selected paths and report component status. | | `extract DIR` | Unpacks the installer files without installing them. Replace `DIR` with the folder you want to use. | -| `help` | Shows the main command list. | +| `help` | Lists the main commands. | + +If a recorded Linux file or link changed after installation, uninstall asks +whether to remove it. Keep is the default answer. + +## Pre-flight choices + +Interactive Install, Update, and Reinstall runs ask these questions one at a time. +Press `Esc` to go back one question. Press Enter to accept the shown default +or current value. The launcher preferences are persistent. A nonempty +environment variable takes priority for one launch. + +You can pass the same choices to `install` and `update`: + +| Option | Values and explanation | +| --- | --- | +| `--audio-buffer=VALUE` | `64`, `128`, `256`, `512`, or `1024` frames. The default is `128`; larger buffers improve stability and add latency. | +| `--shortcuts=VALUE` | `take` assigns conflicting GNOME shortcuts to Live; `preserve` keeps the desktop assignments. The default is `take`. | +| `--dpi=VALUE` | `auto`, `100`, `fractional`, or `preserve`. The default `auto` matches the detected display scale. | +| `--audio-threads=VALUE` | `auto`, `off`, or `1` to `63`. The default `auto` selects a worker count; `off` lets Live decide. | +| `--rt=VALUE` | `auto` uses real-time scheduling when available; `off` uses normal scheduling. The default is `auto`. | +| `--power=VALUE` | `performance`, `balanced`, or `off`. The default uses the performance profile while Live or Max is open. | + +## Logs and backups + +Everything the scripts print outside the installer display, including every +`!!` error, goes to the log named at the end of the run. The installer tree is +not copied into the log. The log sits next to the `.run` file, or in your +temporary directory when that folder is read-only. + +When an ordinary support file already exists, the installer asks whether to +overwrite all files, keep the originals, or abort. The installer selects +Overwrite all after 30 seconds. It moves each replaced file to a dated folder +under `~/.local/state/ableton-wine/backups/`. + +Shared configuration files use saved earlier copies under +`~/.local/state/ableton-wine/install-prestate/`. Uninstall restores these copies +after it removes the installer version. diff --git a/Makefile b/Makefile index 234306ff..a60d2bf0 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,11 @@ test: ## run installer and launcher lifecycle gates ./scripts/test-tsan-policy.sh ./scripts/test-release-policy.sh ./scripts/test-config-boundary.sh + ./scripts/test-project-file-installer.sh + ./scripts/test-installer-ui.sh + ./scripts/test-preflight-preferences.sh + ./scripts/test-preflight-menu.sh + ./scripts/test-preflight-integration.sh ./scripts/test-check-ntsync.sh ./scripts/test-shortcut-hold.sh ./scripts/test-cpu-topology.sh diff --git a/README.md b/README.md index 5592b965..d3e5b48a 100644 --- a/README.md +++ b/README.md @@ -113,10 +113,26 @@ the installer will try to find one in the same directory. Once started, the install is mostly automatic. -Near the end, the installer may name a program still running in the Wine prefix and ask whether to close it. Pressing Enter leaves it running, which is the safe answer if you also have Max or another Wine program open. Live is already installed by that point, so either answer keeps it. +The installer asks six pre-flight questions before an interactive Install, +Update, or Reinstall. Press `Esc` to go back one question. Press Enter to accept +the shown default or current value. The launcher preferences are persistent, +while a nonempty environment variable overrides its saved value for one launch. + +The same choices are available as command options: + +| Option | Values and effect | +| --- | --- | +| `--audio-buffer=VALUE` | `64`, `128`, `256`, `512`, or `1024` frames. The default is `128`; larger buffers improve stability and add latency. | +| `--shortcuts=VALUE` | `take` assigns conflicting GNOME shortcuts to Live; `preserve` keeps the desktop assignments. The default is `take`. | +| `--dpi=VALUE` | `auto`, `100`, `fractional`, or `preserve`. The default `auto` matches the detected display scale. | +| `--audio-threads=VALUE` | `auto`, `off`, or `1` to `63`. The default `auto` selects a worker count; `off` lets Live decide. | +| `--rt=VALUE` | `auto` uses real-time scheduling when available; `off` uses normal scheduling. The default is `auto`. | +| `--power=VALUE` | `performance`, `balanced`, or `off`. The default uses the performance profile while Live or Max is open. | + +Near the end, the installer may name a program still running in the ableton-linux prefix and ask whether to close it. Press Enter to leave every program running. Use that choice when Max or another Wine program is open. Live installation finishes before this question, and either answer leaves the installed Live files in place. A final warning about desktop shortcuts, Link, saved settings, or old recovery -files also keeps the installed runtime, Wine prefix, and Live. The final summary +files doesn't remove the installed runtime, ableton-linux prefix, or Live. The final summary reports what still needs attention. The [installer warning guide](TROUBLESHOOTING.md#live-installs-and-the-final-summary-asks-for-a-retry) explains each result and when an update should be retried. @@ -172,26 +188,32 @@ change a launcher default or ask you to select a renamed device again. ### Uninstalling -To remove this project's runtime and desktop integration while keeping Live and -its authorization, just run: +To remove the recorded Linux files and Wine runtime while keeping the Wine +prefix, Live, authorization, installer settings, and shared records, run: ```bash sh ~/Downloads/install-ableton-latest.run uninstall ``` -If you're really nervous, you can ensure your copy of Live and all of your VSTs remain -by adding the extra (redundant) flag: +The command above selects `--keep-prefix`. You can also write the option: ```bash sh ~/Downloads/install-ableton-latest.run uninstall --keep-prefix ``` -To get rid of everything, including Live, its authorization, and any third-party plugins: +To remove the ableton-linux prefix, Live, its authorization, and plug-ins installed +inside that prefix as well: ```bash sh ~/Downloads/install-ableton-latest.run uninstall --delete-prefix ``` +If a recorded Linux file or link changed after installation, uninstall asks +whether to remove it. Keep is the default answer. + +Run `link disable` before uninstall when Link opened a firewall port or enabled +the user service. + ### Other functionality Our installer supports a range of commands for advanced users or complicated use cases. To understand what you can do with the installer, take a look at diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 39155e9b..403a938d 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -80,21 +80,21 @@ sh ~/Downloads/install-ableton-latest.run update ### Live installs and the final summary asks for a retry -The installer keeps a working runtime, Wine prefix, and Live installation when -a desktop shortcut, file association, Link setting, saved setting, or cleanup -step needs another try. It also keeps every shortcut or support file that was -updated successfully; one failed path does not undo the other repairs. +A failed desktop shortcut, file association, Link setting, saved setting, or +cleanup step doesn't remove a working runtime, ableton-linux prefix, or Live +installation. Files updated earlier in the run stay in place. The failed path +doesn't undo those repairs. -Check the final `OK` summary. It shows which optional item needs repair: +Check the final `OK` summary. It lists the optional item that needs repair: -- `desktop shortcuts: retry needed` means the core installation is intact, but +- `Desktop shortcuts need another try` means the core installation is intact, but the launchers and application-menu entries need another update -- `Link: unchanged; setup can be retried` means Live remains installed with the +- `Link is unchanged; you can retry its setup` means Live remains installed with the previous Link setting -- `saved settings: retry needed` means the current install works and the next +- `Saved settings need another try` means the current install works and the next command needs the same custom path options -- `old recovery files: remain at ...` means the core install completed and the - installer kept files that it could not remove +- `old recovery files remain at ...` means the core install completed and the + installer kept files for a later recovery or removal attempt Follow the instruction printed with the warning. For desktop shortcuts, run: diff --git a/VERSION b/VERSION index 082ccdc3..a895df94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2026.08.26.1 +2026.08.28.1 diff --git a/dist/BUILD-INFO-2026.08.27.1.txt b/dist/BUILD-INFO-2026.08.27.1.txt new file mode 100644 index 00000000..57f69d7c --- /dev/null +++ b/dist/BUILD-INFO-2026.08.27.1.txt @@ -0,0 +1,30 @@ +dist-version: 2026.08.27.1 +wine: wine-11.13 +base: giang17/wine d2d1-dcomp-11.13 @ 5c23dd1c +prefix: /opt/wine-d2d1-nspa-11.13 (configure-time only; tarball is relocatable, see relocation gate) +patches: 110 +wine-patches: 95 +performance-patches: 1 +pipeasio-patches: 14 +patch-head: b1ff598f3b18b6663b3088feca92dade1a4eacff +patch-stack: 606b1462a43b575539048b39e22726ca348d8723df43c61a16b2a28434509a57 +source-tree: fc71287630e4c1b96a573675533d43872af03b708fef3c2d1fcb3589af3a54c3 +builder-packages: 1dcd332ce0fa5ccbf47b1a26d6288e11c6de0584571d7239e7e63ba641a4187c +cabextract-static: 7943e60673f029eeb7150dd201f39a8cdefa16b7995a948540789d659b1bc92b +ableton-linkd: 7a93fd6c5d6f0908536b05ceb741aae19854957eb5a39a63befa90f8aa79dae4 +pipeasio: 1.5.0 +pipewire-floor: 1.4.2 (required for both client library and daemon at install and driver startup) +pipewire-version-probe: 4ea7a631decb71c0c9dfa562c916efeb82df8b5d7187b06e942f74dbbb63c31f +pipewire-version-probe-tests: client-stub+ASan+UBSan passed +pipeasio-panel: built +pipeasio-settings: 8e8304ac13baa19d9c78c1a7f3baa96264f8e5fc4005cde71477e78c3ba9c44c (Qt 6.2 link) +pipeasio-tests: CTest non-integration scope passed (unit, registration/layout, ABI, panel) +pipeasio-no-qt: CMake driver build/install + non-integration CTest passed +pipeasio-sanitizers: ASan+UBSan unit+panel passed (driver imports verified); TSan unit passed +ntsync: yes (vendored linux/ntsync.h 006437ee52a3e04f921df77081eb5c21c44c71f598b10ac534c6ef9e78296262) +libusb-pe: 5c74521e7f333b955144136ca067c9d58b93db7074f14a4ea3ef643d75045eb4 +libusb-unix: 9d918088af64a1fea465f1a78e0885b29161f9061f950fb0892dd26764cc4298 +portal-unix: 2bc1af44d3aa5452d8ecee3c1cea3fd948cd1019222ac46eb00b17b251b8fa57 +pipeasio-pe: 9143f186c90faedd9220752d57c201871e3b864cf4bbd0097e3ad6f8e1ac6794 +pipeasio-unix: a1d61fce317efe60ef6dbe5d177e935298101b1629cd569271f6f66e66851fbe +built-on: Ubuntu 22.04 (glibc 2.35) diff --git a/dist/BUILD-INFO-2026.08.28.1.txt b/dist/BUILD-INFO-2026.08.28.1.txt new file mode 100644 index 00000000..3de605e6 --- /dev/null +++ b/dist/BUILD-INFO-2026.08.28.1.txt @@ -0,0 +1,30 @@ +dist-version: 2026.08.28.1 +wine: wine-11.13 +base: giang17/wine d2d1-dcomp-11.13 @ 5c23dd1c +prefix: /opt/wine-d2d1-nspa-11.13 (configure-time only; tarball is relocatable, see relocation gate) +patches: 110 +wine-patches: 95 +performance-patches: 1 +pipeasio-patches: 14 +patch-head: a0603e7d7ead8dc987a2a27649c9211cbc8e5b31 +patch-stack: 606b1462a43b575539048b39e22726ca348d8723df43c61a16b2a28434509a57 +source-tree: 659506f7542f85db4cb0580156b4805edc4f92498ce6c4f989e097d462b28b87 +builder-packages: 1dcd332ce0fa5ccbf47b1a26d6288e11c6de0584571d7239e7e63ba641a4187c +cabextract-static: 7943e60673f029eeb7150dd201f39a8cdefa16b7995a948540789d659b1bc92b +ableton-linkd: 8deda9a8651cd57d94ddc4f25bd8bd8445204161f84eab7525eeb560a9245398 +pipeasio: 1.5.0 +pipewire-floor: 1.4.2 (required for both client library and daemon at install and driver startup) +pipewire-version-probe: 4ea7a631decb71c0c9dfa562c916efeb82df8b5d7187b06e942f74dbbb63c31f +pipewire-version-probe-tests: client-stub+ASan+UBSan passed +pipeasio-panel: built +pipeasio-settings: 8e8304ac13baa19d9c78c1a7f3baa96264f8e5fc4005cde71477e78c3ba9c44c (Qt 6.2 link) +pipeasio-tests: CTest non-integration scope passed (unit, registration/layout, ABI, panel) +pipeasio-no-qt: CMake driver build/install + non-integration CTest passed +pipeasio-sanitizers: ASan+UBSan unit+panel passed (driver imports verified); TSan unit passed +ntsync: yes (vendored linux/ntsync.h 006437ee52a3e04f921df77081eb5c21c44c71f598b10ac534c6ef9e78296262) +libusb-pe: d87861dc8a1482b67548f6c551a1ba79625efaf622dc03b0ac3a6a96a74980a6 +libusb-unix: 9d918088af64a1fea465f1a78e0885b29161f9061f950fb0892dd26764cc4298 +portal-unix: 2bc1af44d3aa5452d8ecee3c1cea3fd948cd1019222ac46eb00b17b251b8fa57 +pipeasio-pe: 9143f186c90faedd9220752d57c201871e3b864cf4bbd0097e3ad6f8e1ac6794 +pipeasio-unix: 1a6333adc44e7bf1c6e8bba8650b49500519d94dafdd483ef4b3a08d880446ed +built-on: Ubuntu 22.04 (glibc 2.35) diff --git a/dist/BUILD-INFO.txt b/dist/BUILD-INFO.txt index e9b2b28c..3de605e6 100644 --- a/dist/BUILD-INFO.txt +++ b/dist/BUILD-INFO.txt @@ -1,16 +1,17 @@ -dist-version: 2026.08.26.1 +dist-version: 2026.08.28.1 wine: wine-11.13 base: giang17/wine d2d1-dcomp-11.13 @ 5c23dd1c prefix: /opt/wine-d2d1-nspa-11.13 (configure-time only; tarball is relocatable, see relocation gate) -patches: 104 -wine-patches: 94 -pipeasio-patches: 10 -patch-head: a8a9f5e076ee2e8fbe61be13dc5191712eac07f7 -patch-stack: 76cfc6cbc45dc8d16d388c6ba15d725c73ebcd738714750c98be884a7acc81ce -source-tree: b34d3c81024712aa21f47b4fcfa2003197ee59c220138565514a69b18f22e4a4 +patches: 110 +wine-patches: 95 +performance-patches: 1 +pipeasio-patches: 14 +patch-head: a0603e7d7ead8dc987a2a27649c9211cbc8e5b31 +patch-stack: 606b1462a43b575539048b39e22726ca348d8723df43c61a16b2a28434509a57 +source-tree: 659506f7542f85db4cb0580156b4805edc4f92498ce6c4f989e097d462b28b87 builder-packages: 1dcd332ce0fa5ccbf47b1a26d6288e11c6de0584571d7239e7e63ba641a4187c cabextract-static: 7943e60673f029eeb7150dd201f39a8cdefa16b7995a948540789d659b1bc92b -ableton-linkd: d8a0c7d91c69778cd07ca8b832314d4e84a4dfa0b1af532a91eaec233534745d +ableton-linkd: 8deda9a8651cd57d94ddc4f25bd8bd8445204161f84eab7525eeb560a9245398 pipeasio: 1.5.0 pipewire-floor: 1.4.2 (required for both client library and daemon at install and driver startup) pipewire-version-probe: 4ea7a631decb71c0c9dfa562c916efeb82df8b5d7187b06e942f74dbbb63c31f @@ -21,9 +22,9 @@ pipeasio-tests: CTest non-integration scope passed (unit, registration/layout, A pipeasio-no-qt: CMake driver build/install + non-integration CTest passed pipeasio-sanitizers: ASan+UBSan unit+panel passed (driver imports verified); TSan unit passed ntsync: yes (vendored linux/ntsync.h 006437ee52a3e04f921df77081eb5c21c44c71f598b10ac534c6ef9e78296262) -libusb-pe: bf156f2ce618e4f66836e2b2e2b9fc88523ddc5074e4ee552389f25d8567c723 +libusb-pe: d87861dc8a1482b67548f6c551a1ba79625efaf622dc03b0ac3a6a96a74980a6 libusb-unix: 9d918088af64a1fea465f1a78e0885b29161f9061f950fb0892dd26764cc4298 portal-unix: 2bc1af44d3aa5452d8ecee3c1cea3fd948cd1019222ac46eb00b17b251b8fa57 pipeasio-pe: 9143f186c90faedd9220752d57c201871e3b864cf4bbd0097e3ad6f8e1ac6794 -pipeasio-unix: 6d5710c4a3f46f1396cb51277bca867d8c8a615a1ceebf71842190beab5ffcd3 +pipeasio-unix: 1a6333adc44e7bf1c6e8bba8650b49500519d94dafdd483ef4b3a08d880446ed built-on: Ubuntu 22.04 (glibc 2.35) diff --git a/dist/wine-d2d1-nspa-11.13-2026.08.27.1.tar.zst.sha256 b/dist/wine-d2d1-nspa-11.13-2026.08.27.1.tar.zst.sha256 new file mode 100644 index 00000000..df8ed9c7 --- /dev/null +++ b/dist/wine-d2d1-nspa-11.13-2026.08.27.1.tar.zst.sha256 @@ -0,0 +1 @@ +fa114134a93792b2239b62b1107b19052191a53418728e519c1a63c8d9cc34d4 wine-d2d1-nspa-11.13-2026.08.27.1.tar.zst diff --git a/dist/wine-d2d1-nspa-11.13-2026.08.28.1.tar.zst.sha256 b/dist/wine-d2d1-nspa-11.13-2026.08.28.1.tar.zst.sha256 new file mode 100644 index 00000000..bf10afd5 --- /dev/null +++ b/dist/wine-d2d1-nspa-11.13-2026.08.28.1.tar.zst.sha256 @@ -0,0 +1 @@ +c43e2aecdc430d3c826028d6d2052ffc0fc5c24afce91e9690c426d9e8d0f60d wine-d2d1-nspa-11.13-2026.08.28.1.tar.zst diff --git a/nix/ableton-wine.nix b/nix/ableton-wine.nix index 28e61165..9d05becf 100644 --- a/nix/ableton-wine.nix +++ b/nix/ableton-wine.nix @@ -104,8 +104,8 @@ stdenv.mkDerivation { chmod -R u+w $out # Both names: Wine resolves pipeasio64.dll to builtin "pipeasio.dll" # (from its spec) and looks for the unix half under that name. The - # aliases stay RELATIVE symlinks — upstream's CMake install contract, - # which build-audit.sh verifies. + # aliases stay RELATIVE symlinks. The upstream CMake install requires + # these links, and build-audit.sh verifies them. for pair in \ pipeasio64.dll:x86_64-windows \ pipeasio64.dll.so:x86_64-unix; do @@ -301,16 +301,14 @@ stdenv.mkDerivation { substituteInPlace $out/share/ableton-wine/scripts/check-live-audio.sh \ --replace-fail '$HOME/.local/bin/ableton-live' "$out/bin/ableton-live" - # The vendored (pinned) winetricks + payload cache, not nixpkgs' — same - # setup path as the tarball install; the Live 12 verbs need no network. + # Use the repository's winetricks program and cached files. This matches + # the tarball setup path and lets the Live 12 verbs work offline. install -m755 ${../vendor/winetricks} $out/share/ableton-wine/vendor/winetricks cp -a ${../vendor/winetricks-cache} $out/share/ableton-wine/vendor/winetricks-cache - # cabextract: winetricks corefonts; unzip: setup-prefix's Live installer - # step; update-desktop-database and xdg-mime: the launcher's handler - # repair, which otherwise reports them missing on a NixOS that carries - # neither, leaving the ableton:// callback unregistered. Kit bin, not - # $out/bin — setup-prefix.sh resolves these via its kit-bin PATH - # prepend; $out/bin is merged onto user PATHs by profile installs. + # cabextract supports winetricks corefonts. unzip extracts the Live + # installer. The other 2 programs repair desktop handlers. + # Place all 4 programs in kit bin, which setup-prefix adds to PATH. + # Profile installs add $out/bin to the user's PATH. mkdir -p $out/share/ableton-wine/bin ln -s ${cabextract}/bin/cabextract $out/share/ableton-wine/bin/cabextract ln -s ${lib.getBin unzip}/bin/unzip $out/share/ableton-wine/bin/unzip @@ -406,13 +404,13 @@ stdenv.mkDerivation { runHook postInstall ''; - # Stamped after fixup: strip/patchelf rewrite the recorded binaries, so - # hashing them any earlier writes records the audit's byte-match rejects. + # Run this step after fixup because strip and patchelf change the binaries. + # Earlier hashes would differ from the installed files. postFixup = '' - # -- Builder manifest -- - # The container records its apt package set here; this build's inputs - # are the pinned nix closure, so record those versions instead. Same - # contract build-audit.sh checks: sorted, unique, two fields per line. + # -- Package list -- + # The container records apt packages. This Nix build records versioned + # inputs from the Nix store. build-audit.sh requires sorted, unique rows + # with 2 fields per row. printf '%s\n' \ "ableton-linkd ${ableton-linkd.version}" \ "cabextract ${cabextract.version}" \ @@ -422,17 +420,14 @@ stdenv.mkDerivation { "wine-d2d1-nspa ${wine.version}" \ | LC_ALL=C sort -u > $out/ABLETON-WINE-BUILD-PACKAGES.txt - # -- Provenance -- - # The two files the tarball carries (scripts/container-build.sh), for - # the same two reasons: build-audit.sh diffs the stack against - # patches/SERIES.sha256 and reads the patch count out of the stamp, and - # a bug report can name the runtime it came from. Regenerated from the - # patches this build applied, never copied from the manifest — a copy - # would only agree with itself. - # Same glob as container-build.sh and build-audit.sh's --freeze. A - # two-digit prefix glob stops matching at 0100 and would drop that - # patch from the stamp while wine.nix still applied it, so the audit - # would reject the tree over a stamp that is the only thing wrong. + # -- Patch records -- + # container-build.sh writes the same 2 files into the tarball. + # build-audit.sh compares the patch list with patches/SERIES.sha256. + # It also reads the patch count and runtime name from the stamp. + # Generate both records from the patches applied during this build. + # A copied record would only verify itself. + # Use the same path pattern as container-build.sh and build-audit.sh. + # The 4-digit pattern includes patch 0100 and later patches. ( cd ${patchesDir} && sha256sum [0-9][0-9][0-9][0-9]-*.patch performance/*.patch pipeasio/*.patch ) \ > $out/ABLETON-WINE-PATCH-STACK.txt n_wine=$(ls ${patchesDir}/[0-9][0-9][0-9][0-9]-*.patch | wc -l) @@ -519,10 +514,20 @@ stdenv.mkDerivation { # Derived from the source directory, so a helper added upstream is covered # without editing this gate. A hardcoded list here missed live-options.sh # and reported success while the launcher refused to start. + expected_config=$TMPDIR/expected-config.sh + cp -- ${../scripts/lib/config.sh} "$expected_config" + substituteInPlace "$expected_config" \ + --replace-fail '$HOME/.local/opt/$ABLETON_RUNTIME_NAME' "$out" \ + --replace-fail '"''${configured:-$ABLETON_DATA_HOME/ableton-linkd}"' \ + "\"\''${configured:-$out/share/ableton-wine/ableton-linkd}\"" for helper in ${../scripts/lib}/*.sh; do + expected=$helper + [ "$(basename "$helper")" != config.sh ] || expected=$expected_config for d in libexec/lib share/ableton-wine/scripts/lib; do [ -r $out/$d/$(basename $helper) ] \ || { echo "$(basename $helper) is not staged in $d"; exit 1; } + cmp -s -- "$expected" "$out/$d/$(basename "$helper")" \ + || { echo "$(basename "$helper") differs in $d"; exit 1; } done done # The launcher sources these and exits 1 naming any whose functions are diff --git a/notes/ABLETON-WINE-LINK.md b/notes/ABLETON-WINE-LINK.md index 8804a4dc..aa7ebc96 100644 --- a/notes/ABLETON-WINE-LINK.md +++ b/notes/ABLETON-WINE-LINK.md @@ -1,7 +1,7 @@ # Configure and test Ableton Link -The installer stores one Link mode for installation, launch, update, and -removal. +The installer stores one Link mode for installation, launch, update, and the +`link disable` command. ## Select a mode @@ -25,8 +25,9 @@ path with `./scripts/installer.sh`. Enabling Link can remove the obsolete NetworkManager hook and multicast route from early releases, open UDP 20808 in UFW or firewalld, and write a marked -systemd user unit. The installer records its own firewall change and removes -only that change during disable or uninstall. +systemd user unit. The installer records its own firewall change. The +`link disable` command removes that change. Run `link disable` before uninstall +when Link opened a firewall port or enabled the user service. Session mode keeps the user unit disabled. Always mode enables and starts it. Installer transactions save the previous firewall, unit, process, file, and diff --git a/notes/MOONSHOT-CPU-OPTIMISATION-CONSOLIDATION-2026-08-26.md b/notes/MOONSHOT-CPU-OPTIMISATION-CONSOLIDATION-2026-08-26.md index 7188a196..99324c35 100644 --- a/notes/MOONSHOT-CPU-OPTIMISATION-CONSOLIDATION-2026-08-26.md +++ b/notes/MOONSHOT-CPU-OPTIMISATION-CONSOLIDATION-2026-08-26.md @@ -42,7 +42,7 @@ The following table records the final state on 26 August 2026. | [276](https://github.com/shibco/ableton-linux/pull/276) | The Pro Audio tool runs a matched profile comparison and restores the original profile. | The user starts each comparison. | | [277](https://github.com/shibco/ableton-linux/pull/277) | Wine includes one bounded NTSync alertable wait trial. | The regular Wine wait remains the default. | | [278](https://github.com/shibco/ableton-linux/pull/278) | The launcher chooses a reliable automatic Live worker count. | `ABLETON_MAX_AUDIO_THREADS=auto` is the default. | -| [279](https://github.com/shibco/ableton-linux/pull/279) | The installer preserves launcher and adjacent backup prestate across updates and uninstall. | Regular installer behaviour applies. | +| [279](https://github.com/shibco/ableton-linux/pull/279) | The release preserved launcher and adjacent backup state across updates and uninstall. The current installer writes displaced ordinary files to dated backup folders. Its uninstaller processes older launcher and adjacent backup records separately. | Regular installer behaviour applies. | PRs 267 to 269 and 271 to 279 are merged. PR 270 contains this final documentation review. diff --git a/notes/PREFLIGHT-SETTINGS-TEST-MATRIX.md b/notes/PREFLIGHT-SETTINGS-TEST-MATRIX.md new file mode 100644 index 00000000..16664fe8 --- /dev/null +++ b/notes/PREFLIGHT-SETTINGS-TEST-MATRIX.md @@ -0,0 +1,105 @@ +# Installer pre-flight settings: frozen behaviour matrix + +Refrozen on 2026-08-28 after fixture corrections, clean-context execution, and +repeated adversarial review. Production work must satisfy these assertions +without weakening them. + +This file is the executable-coverage index for the confirmed pseudocode. A +behaviour is covered only when the named test asserts its externally observable +result. The tests are frozen after adversarial review; implementation changes +must satisfy them rather than weaken them. + +## Action routing and sequential UI + +| Behaviour ID | Pseudocode branch | Frozen assertion | +|---|---|---| +| ACT-FRESH | No valid managed ownership: offer **Install** and Quit; Install is default | `test-preflight-menu.sh`: exact action block and delegated `install` | +| ACT-HEALTHY | Valid configuration/marker and complete prefix: Update, Reinstall, Remove, Quit | `test-preflight-menu.sh`: exact custom-prefix action block and Update default | +| ACT-INCOMPLETE | Recognised managed prefix is incomplete: Reinstall, Remove, Quit; no Update | `test-preflight-menu.sh`: exact incomplete action block | +| ACT-FOREIGN | Foreign Wine files are not ownership evidence | `test-preflight-menu.sh`: unmarked `system.reg` stays fresh Install | +| ACT-EVIDENCE | Marker-only/config-only ownership is recognised; missing targets are incomplete; malformed/symlink evidence is ignored; a valid config has precedence over conflicting default-marker evidence; optional support alone is not ownership | `test-preflight-menu.sh`: complete/incomplete marker and config, fully valid symlink referents, both config/marker conflict directions, malformed-marker precedence, preferences-only and support-only matrix | +| ACT-DEFAULT-ROUTING | Enter/non-TTY selects the state default; Remove directly selects uninstall | `test-preflight-menu.sh`: healthy/incomplete TTY and non-TTY routes plus Remove | +| ROUTE-EXPLICIT | An explicit command skips action and setting prompts | `test-preflight-menu.sh`: explicit Update delegates unchanged | +| ROUTE-NONTTY | No TTY: choose state default without prompting or persisting questionnaire defaults | `test-preflight-menu.sh`: stdin `/dev/null` delegates fresh `install` and healthy `update` unchanged; saved-preference checksum is stable | +| NAV-ORDER | Ask the six questions directly, one at a time, in the confirmed order | `test-preflight-menu.sh`: live pre-answer frontier forbids future headings/delegation, then normalized adjacent heading/explanation/options/Esc blocks, strict heading order, exact counts, one delegation after question 6, no seventh prompt/summary | +| NAV-EXPLANATIONS | Each question has its confirmed one-line explanation | `test-preflight-menu.sh`: exact explanation immediately adjacent to its own heading | +| NAV-CHOICES | Each option is selected on its question; there is no settings submenu or summary menu | `test-preflight-menu.sh`: all non-default answers and absence of `Choose a setting` | +| NAV-OPTION-MATRIX | Every selectable row maps to its exact flag or retained default | `test-preflight-menu.sh`: five PTY paths cover all 19 choices | +| NAV-BACK | Escape from question N returns to N-1 and retains the draft answers | `test-preflight-menu.sh`: question 3 to 2, revised answer, retained other values | +| NAV-BACK-MATRIX | Escape returns one step at each boundary from questions 2 through 6 without an intervening menu, summary or different question | `test-preflight-menu.sh`: five-boundary PTY loop with transcript-segment rejection | +| NAV-FIRST-BACK | Escape from question 1 returns exactly to the action menu | `test-preflight-menu.sh`: action prompt appears twice | +| NAV-ACTION-RESET | Quit or a changed action discards the uncommitted setting draft | `test-preflight-menu.sh`: Quit discards; Update draft followed by Reinstall reloads 2048 and delegates no flag | +| NAV-EXPLICIT-DEFAULTS | Typing defaults resets differing saved/current values while Enter retains them | `test-preflight-menu.sh`: non-default saved fixture emits all six reset flags | +| NAV-VALIDATION | Invalid direct custom worker input reprompts on question 4 | `test-preflight-menu.sh`: 0, 64 and text fail; 63 proceeds | +| NAV-CURRENT | Valid saved settings and any valid existing custom buffer are the Enter defaults | `test-preflight-menu.sh`: saved values and 2048 shown as Current; Update has no synthetic flags | +| DEFAULTS | Fresh defaults are 128/take/auto/auto/auto/performance | `test-preflight-menu.sh`: default labels and no arguments on straight Enter path; `test-preflight-preferences.sh`: absent launcher defaults; `test-preflight-integration.sh`: coordinated 128 seed | +| CLI-ROUTING | Only values deliberately changed in the questionnaire become flags | `test-preflight-menu.sh`: exact delegated argument strings for direct and backtracked paths | +| HEADER-READONLY | The transport UI never persists a draft, default or current value, even transiently, before delegation | `test-preflight-menu.sh`: delegation-time and final full HOME snapshots including type, mode, link target, bytes, device/inode, birth time and nanosecond mtime/ctime for every PTY/non-TTY run | + +## Preference and PipeASIO data boundaries + +| Behaviour ID | Pseudocode branch | Frozen assertion | +|---|---|---| +| PREF-API | One side-effect-free library owns validation, merge, generation tokens, writes and removal | `test-preflight-preferences.sh`: required function API | +| PREF-DEFAULTS | Absent store returns compatibility defaults without creating a file | `test-preflight-preferences.sh`: exact values and absence after read | +| PREF-LOAD | Exact format 1 loads every saved launcher value | `test-preflight-preferences.sh`: all five non-default values | +| PREF-MERGE | One strict API overlays explicit CLI intent on saved/default values without environment | `test-preflight-preferences.sh`: absent, saved and invalid merge cases | +| PREF-ENV | Non-empty environment is a one-run override; empty means unset | `test-preflight-preferences.sh`: mixed saved/environment result | +| PREF-COMPAT | `ABLETON_POWER=on|auto` maps to performance; unknown values fail | `test-preflight-preferences.sh`: aliases and actionable invalid-value error | +| PREF-MALFORMED | Malformed current data warns, falls back for launch, and is never rewritten by a reader | `test-preflight-preferences.sh`: duplicate-key fixture, defaults, checksum | +| PREF-FUTURE | Unknown future format is rejected and preserved | `test-preflight-preferences.sh`: format 2 validation/write refusal | +| PREF-VALIDATION | Exact seven-line record has its managed header first and every key exactly once; comments, order, whitespace, CRLF, unknown/incomplete/NUL/symlink/directory variants fail closed | `test-preflight-preferences.sh`: every-key missing/duplicate plus exact-layout/header/object matrix with identity-and-time snapshots | +| PREF-APPLY-UNSAFE | Future, binary, indirect and incomplete optional stores cannot block launch | `test-preflight-preferences.sh`: defaults plus environment overlay, warning and exact object preservation for every unsafe shape | +| PREF-HOSTILE | Invalid values and shell metacharacters remain inert; all stored/environment grammars fail closed | `test-preflight-preferences.sh`: field matrices and command-substitution sentinels | +| PREF-WRITE | Exact record is atomically published as a replacement at mode 0600 | `test-preflight-preferences.sh`: exact bytes, mode, changed inode and no remaining temporary file | +| PREF-PUBLISH-FAIL | A replacement-write failure cannot truncate or alter the prior record | `test-preflight-preferences.sh`: zero-size-limit failure, exact old-object snapshot and temporary cleanup | +| PREF-RACE | Writer compares the original object-generation token and never follows a symlink | `test-preflight-preferences.sh`: stale-token, symlink and valid-to-valid replacement fixtures | +| PREF-ABA | Same-byte inode replacement and same-inode modify/restore races invalidate the generation token | `test-preflight-preferences.sh`: inode-replacement and timestamp-preserving content-ABA fixtures retain the raced object exactly | +| PREF-REMOVE | The preference removal helper removes a valid unchanged record | `test-preflight-preferences.sh`: valid removal plus changed and malformed file retention | +| BUFFER-READ | Read one unique integer from 32 through 8192, including custom values | `test-preflight-preferences.sh`: boundary/preset/custom/invalid values | +| BUFFER-WRITE | Only five offered presets may be newly requested; unrelated bytes/order survive | `test-preflight-preferences.sh`: exact before/after fixtures, preset loop and custom refusal | +| BUFFER-EOF | Updating an existing buffer preserves whether the original file did or did not end in a newline | `test-preflight-preferences.sh`: exact byte comparisons for both final-newline states | +| BUFFER-MISSING | A missing key in one unique `[pipeasio]` section is inserted inside it | `test-preflight-preferences.sh`: section-scoped insertion | +| BUFFER-AMBIGUOUS | Duplicate key/section, NUL, symlink, directory and stale token are preserved | `test-preflight-preferences.sh`: refusal/checksum loop | +| BUFFER-SECTIONS | Only the key inside one unique `[pipeasio]` section is authoritative; no section is preserved | `test-preflight-preferences.sh`: competing other-section keys, scoped insertion and absent-section immutable refusal | +| PIPEASIO-SEED-PROVENANCE | A fresh coordinated seed publishes rollback-only producer provenance before its no-clobber rename, promotes only the exact renamed producer to a full generation, and permits commit only after promotion. Rollback removes matching producer temp/final objects, preserves foreign or same-byte competing objects, and rejects unsafe paths or malformed evidence without mutation. Full commit retires only the journal. | `test-preflight-preferences.sh`: before-rename, post-rename/pre-promotion, full-promotion, no-clobber, same-byte replacement, full-commit and strict four-line schema/path matrices, including foreign directory, missing terminal LF and fifth-line evidence; `test-installer-lifecycle.sh`: extracted production-writer transient ordering, injected destination race and all four real `setup-prefix.sh` recovery entry points; `test-preflight-integration.sh`: provisional commit-boundary rollback and malformed two-domain coordinator refusal/preservation | + +## Dispatcher, transaction and lifecycle integration + +| Behaviour ID | Pseudocode branch | Frozen assertion | +|---|---|---| +| CLI-SCHEMA | Six exact `--flag=value` grammars; bare, empty, spaced, invalid and any duplicate forms fail for Install and Update; flags belong only to those actions | `test-preflight-integration.sh`: valid plan/commit plus invalid-form/value, identical/conflicting duplicate and unrelated-command matrices | +| CLI-COMMIT-MATRIX | Every selectable equivalence class maps exactly and without duplicate records through both Install and Update | `test-preflight-integration.sh`: 42 isolated commits with exact-line/count checks plus all 1–63 worker values through both parsers | +| DRY-RUN | `plan` and `--dry-run` describe all intent, mutate no user root and start no core transaction | `test-preflight-integration.sh`: fresh plan, option alias and existing Update snapshots | +| FRESH-SEED | Coordinated Install with absent PipeASIO uses selected/default 128 inside core, and every offered preset is consumed by the real prefix writer | `test-preflight-integration.sh`: created 128 and core seed log; `test-installer-lifecycle.sh`: extracted production writer consumes 64/128/256/512/1024 exactly with durable promoted provenance | +| NO-IMPLICIT-PREFS | Launcher environment/current display values are not silently persisted | `test-preflight-integration.sh`: environment without flags creates no preference file; menu current path delegates no flags | +| INTENT-ORIGIN | CLI intent overlays saved/default values; conflicting environment is never persisted | `test-preflight-integration.sh`: absent exact record, saved exact merge and no-flag checksum | +| EXPLICIT-COMMIT | Explicit launcher choices write one strict record only after successful core and its pre-flight commit | `test-preflight-integration.sh`: core/preflight stubs observe unchanged settings; exact record and 0600 appear only afterward | +| DPI-CORE | Selected DPI is exported for prefix validation and mutation immediately | `test-preflight-integration.sh`: validation/core call logs contain fractional DPI | +| MERGE | A partial explicit update changes one field and retains the other saved/current values | `test-preflight-integration.sh`: power-only update fixture and untouched 2048 buffer | +| EXISTING-BUFFER | Existing stable unique PipeASIO is patched after core without replacing unrelated data | `test-preflight-integration.sh`: update fixture preserves comment and changes one line | +| BUFFER-RACE | Buffer changed during core is preserved and reported without failing committed core or independent preferences | `test-preflight-integration.sh`: raced file plus independent power commit | +| PREF-COORDINATOR-RACE | Valid or absent preferences changed/created during core are retained | `test-preflight-integration.sh`: two stub-controlled core races, exact replacement and warning | +| CORE-FAIL | Runtime-component, prefix core, after-seed prefix failure, provisional seed at commit, malformed recovery evidence, either pre-flight commit failure, or a second-domain rollback-preflight failure exposes/commits neither launcher setting domain | `test-preflight-integration.sh`: stubs assert settings unchanged before/during core; existing checksums and fresh absence after forced failures; exact producer cleanup for valid provisional recovery; malformed journal/settings/active evidence preservation with both domain preflights and zero domain mutations; forced second-domain preflight failure proves every rollback preflight finishes in order before either rollback mutates the exact prefix/settings/journal/active generations | +| POSTCORE-FAIL | Preference publication or component cleanup failure preserves committed core and continues independent work | `test-preflight-integration.sh`: unsafe preference destination with buffer commit and forced component cleanup with preference commit | +| MALFORMED-PRESERVE | Explicit changes do not overwrite malformed preferences | `test-preflight-integration.sh`: checksum and warning after successful core | +| INTEGRATION-FAIL | Optional launcher/support or Link failure never rolls back core or blocks independent preferences | `test-preflight-integration.sh`: forced integration/Link failures | +| STANDALONE-SEED | Direct `prefix create` retains historical 256 default | `test-preflight-integration.sh`: absent internal seed and resulting 256 through the coordinator stub; `test-installer-lifecycle.sh`: extracted production writer with no override writes exactly 256 | + +## Consumers, ownership, packaging and documentation + +| Behaviour ID | Pseudocode branch | Frozen assertion | +|---|---|---| +| LAUNCHER-CONSUMERS | Live and Max apply every applicable saved preference before launch, with environment precedence | `test-launcher-transactions.sh`: fake-Wine environment, RT and Live shortcut wrappers plus exact Live Options/managed-marker results for audio workers | +| POWER-PROFILE | Saved Performance/Balanced reach `powerprofilesctl`; Don't change bypasses it; unavailable profile continues | `test-launcher-transactions.sh`: both-launcher logging wrapper matrix | +| SUPPORT-FALLBACK | Missing optional preferences support launches with legacy Performance behavior | `test-launcher-transactions.sh`: isolated launcher copies without `preferences.sh` | +| UNINSTALL-OWNERSHIP | Delete-prefix removes valid preferences after runtime and prefix removal. Keep-prefix keeps preferences. | `test-uninstall-boundary.sh`: keep-prefix behavior and delete-prefix removal order; `test-preflight-preferences.sh`: valid, changed, and malformed records | +| PACKAGE-RUN | Self-extracting kit contains/inlines the preference reader needed before extraction | `test-preflight-menu.sh`: rendered-header questionnaire reads current values; `test-preflight-integration.sh`: normal packaging is statically connected to the same dynamically exercised exact stage/audit functions, whose audit rejects corruption | +| PACKAGE-INSTALL | Installed integration includes exact non-executable `preferences.sh` bytes | `test-desktop-integration.sh`: real isolated integration install | +| PACKAGE-MANIFEST | The installed-file list includes the support library. Mutable preferences remain separate. | `test-preflight-integration.sh`: installed-file list, uninstall use, and checked removal | +| PACKAGE-VERIFY | Payload verifier requires exact source bytes at mode 0644 | `test-preflight-integration.sh`: isolated `expected_from_source` assertion | +| PACKAGE-NIX | Nix source-stages and exact-byte audits every library in both launcher locations | `test-preflight-integration.sh`: comment-stripped structural dual-loop and exact `cmp` audit assertions for both destinations | +| DOCS | Flags, values, 128 default, persistence and Escape navigation are user documented | `test-preflight-integration.sh`: README/INSTALLER searches | + +Every confirmed pseudocode branch above has at least one frozen assertion. The +final adversarial review found no remaining P0/P1 coverage gap. diff --git a/scripts/ableton-live b/scripts/ableton-live index e85716ca..2675f8f1 100755 --- a/scripts/ableton-live +++ b/scripts/ableton-live @@ -25,10 +25,9 @@ ABLETON-LIVE VARIABLES: ABLETON_LIVE_VERSION=11|12 only look for installs of this major version - ABLETON_RT=off - skip the realtime probe and run Live under normal scheduling; - by default the launcher runs Live under SCHED_RR wherever - realtime rights exist + ABLETON_RT=auto|off + auto uses SCHED_RR wherever realtime rights exist; off runs + Live under normal scheduling; default auto ABLETON_NTSYNC_WARN=off keep launch quiet while Wine uses its regular wait route; @@ -46,10 +45,9 @@ ABLETON-LIVE VARIABLES: A demanding Set can work better with a higher count or off. Check auto after you move the prefix to another computer. - ABLETON_POWER=off - skip the performance power-profile hold for this launch; by - default the launcher holds the "performance" profile while - Live runs and releases it on exit + ABLETON_POWER=performance|balanced|off + hold the selected power profile while Live runs; off keeps the + current profile; default performance ABLETON_SHORTCUTS=take|preserve GNOME only: hold the desktop shortcuts that shadow Live @@ -267,6 +265,25 @@ if ! ableton_config_init 2>/dev/null; then echo "ableton-live: installer settings could not be read. Run the latest installer to repair them, then try again." >&2 exit 1 fi +# Launcher preferences are optional so a partially published support update +# retains the historical defaults. Nonempty environment values take priority. +max_audio_threads_preference_requested=0 +[ -z "${ABLETON_MAX_AUDIO_THREADS-}" ] \ + || max_audio_threads_preference_requested=1 +preferences_apply_status=0 +for preferences_lib in "$launcher_here/lib/preferences.sh" \ + "$ABLETON_DATA_HOME/lib/preferences.sh" \ + "$ABLETON_WINE_ROOT/share/ableton-wine/scripts/lib/preferences.sh"; do + if [ -r "$preferences_lib" ]; then . "$preferences_lib"; break; fi +done +if declare -F ableton_preferences_apply >/dev/null 2>&1; then + launcher_preferences_path="${XDG_CONFIG_HOME:-$HOME/.config}/ableton-wine/preferences" + if declare -F ableton_preferences_valid >/dev/null 2>&1 \ + && ableton_preferences_valid "$launcher_preferences_path"; then + max_audio_threads_preference_requested=1 + fi + ableton_preferences_apply || preferences_apply_status=$? +fi for lifecycle_lib in "$launcher_here/lib/lifecycle.sh" "$ABLETON_DATA_HOME/lib/lifecycle.sh"; do if [ -r "$lifecycle_lib" ]; then . "$lifecycle_lib"; break; fi done @@ -333,8 +350,7 @@ case "${ABLETON_LIVE_VERSION:-}" in ''|11|12) ;; *) echo "ableton-live: ABLETON_LIVE_VERSION must be 11 or 12" >&2; exit 2 ;; esac max_audio_threads_request="${ABLETON_MAX_AUDIO_THREADS:-auto}" -max_audio_threads_replace_seeded=1 -[ -n "${ABLETON_MAX_AUDIO_THREADS+x}" ] || max_audio_threads_replace_seeded=0 +max_audio_threads_replace_seeded="$max_audio_threads_preference_requested" max_audio_threads_restore_at_default=0 max_audio_threads_action=1 case "$max_audio_threads_request" in @@ -372,6 +388,7 @@ case "$shortcuts_mode" in take|preserve) ;; *) echo "ableton-live: Set ABLETON_SHORTCUTS to take or preserve." >&2; exit 2 ;; esac +[ "$preferences_apply_status" -eq 0 ] || exit "$preferences_apply_status" ABLETON_SHORTCUTS="$shortcuts_mode" # Armed after the guards: teardown ends this prefix's agents, and a launch that @@ -1692,7 +1709,10 @@ run=("$WINE_ROOT/bin/wine") # can be active without any opt-in here. SCHED_RR covers the whole process, GUI # threads included; ABLETON_RT=off exists for A/B runs and low-core machines # where that is a net loss. -if [ "${ABLETON_RT:-on}" != off ] && command -v chrt >/dev/null 2>&1 \ +rt_mode="${ABLETON_RT:-auto}" +[ "$rt_mode" != on ] || rt_mode=auto +export ABLETON_RT="$rt_mode" +if [ "$rt_mode" != off ] && command -v chrt >/dev/null 2>&1 \ && chrt -r 10 true >/dev/null 2>&1; then run=(chrt -r 10 "$WINE_ROOT/bin/wine") fi @@ -1713,9 +1733,12 @@ fi # either way: scheduling policy is inherited across fork/exec. One side # effect: with /w the spawned process sits in a kill-on-close job object, so # killing the launcher chain now takes Live down with it. -if [ "${ABLETON_POWER:-on}" != off ] && command -v powerprofilesctl >/dev/null 2>&1 \ - && ableton_run_bounded 10 powerprofilesctl launch -p performance -r "Ableton Live session" -i ableton-live -- true >/dev/null 2>&1; then - run=(powerprofilesctl launch -p performance -r "Ableton Live session" -i ableton-live -- "${run[@]}") +power_profile="${ABLETON_POWER:-performance}" +case "$power_profile" in on|auto) power_profile=performance ;; esac +export ABLETON_POWER="$power_profile" +if [ "$power_profile" != off ] && command -v powerprofilesctl >/dev/null 2>&1 \ + && ableton_run_bounded 10 powerprofilesctl launch -p "$power_profile" -r "Ableton Live session" -i ableton-live -- true >/dev/null 2>&1; then + run=(powerprofilesctl launch -p "$power_profile" -r "Ableton Live session" -i ableton-live -- "${run[@]}") fi # Ableton Link session anchor (optional): if ableton-linkd is installed but diff --git a/scripts/detect-scale.sh b/scripts/detect-scale.sh index 7f4e2829..366412e9 100644 --- a/scripts/detect-scale.sh +++ b/scripts/detect-scale.sh @@ -33,7 +33,8 @@ _ads_gnome() { ')" [ -n "$chosen" ] || return 1 if [ "$(printf '%s\n' "$all" | wc -l)" -gt 1 ]; then - echo "note: monitors run mixed scales ($(printf '%s' "$all" | tr '\n' ' ' )): using GNOME/Xwayland's shared framebuffer scale from $chosen" >&2 + echo "note: monitors run mixed scales ($(printf '%s' "$all" | tr '\n' ' '))." >&2 + echo "note: using GNOME/Xwayland's shared framebuffer scale: $chosen." >&2 fi printf '%s\n' "$chosen" } diff --git a/scripts/install.sh b/scripts/install.sh index 769c128e..a19d86ae 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,9 +2,10 @@ # Install independently selectable runtime, desktop-integration, and Link-asset # components. Prefix creation is deliberately separate (setup-prefix.sh). set -euo pipefail -export LC_ALL=C.UTF-8 here="$(cd "$(dirname "$0")" && pwd)" root="$(cd "$here/.." && pwd)" +. "$here/lib/ui.sh" +export LC_ALL=C.UTF-8 . "$here/lib/config.sh" . "$here/lib/lifecycle.sh" . "$here/lib/manifest.sh" @@ -63,17 +64,12 @@ elif [ "$want_runtime" -eq 1 ]; then # transaction data. Desktop, settings, and Link roots are checked only when # their optional phase actually begins. ABLETON_CONFIG_LAYOUT_ROOTS=runtime -elif [ "$want_integration" -eq 1 ]; then - # Desktop integration writes generated files under data/state/bin. - # Runtime and prefix are referenced by launchers but are not destinations. - ABLETON_CONFIG_LAYOUT_ROOTS='data state bin' -elif [ "$want_link" -eq 1 ]; then - # Link assets are generated only under the project data and state roots. - ABLETON_CONFIG_LAYOUT_ROOTS='data state' else + # Optional project files are handled one destination at a time below. ABLETON_CONFIG_LAYOUT_ROOTS=none + ABLETON_SIMPLE_PROJECT_FILES=1 fi -export ABLETON_CONFIG_LAYOUT_ROOTS +export ABLETON_CONFIG_LAYOUT_ROOTS ABLETON_SIMPLE_PROJECT_FILES ableton_config_init repair export WINEPREFIX="$ABLETON_WINEPREFIX" data="$ABLETON_DATA_HOME" @@ -82,8 +78,23 @@ apps="${XDG_DATA_HOME:-$HOME/.local/share}/applications" icons="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" mime_root="${XDG_DATA_HOME:-$HOME/.local/share}/mime" stamp="$(date -u +%Y%m%dT%H%M%SZ)" -ownership_manifest_was_present=0 -[ ! -r "$ABLETON_STATE_HOME/install-manifest.tsv" ] || ownership_manifest_was_present=1 +ABLETON_PROJECT_ASSUME_YES="$assume_yes" +ABLETON_RECORD_PROJECT_FILES=1 +export ABLETON_PROJECT_ASSUME_YES ABLETON_RECORD_PROJECT_FILES + +# Shared configuration files use atomic prestate so uninstall can put back the +# exact file that occupied their fixed path. Ordinary project files keep the +# installer's existing overwrite prompt and dated backups. +ableton_install_generated_config() +{ + local mode="$1" source="$2" target="$3" + [ "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 0 ] || return 0 + if ! ableton_install_file "$mode" "$source" "$target" config; then + echo "!! copy failed: $source -> $target" >&2 || true + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + fi + return 0 +} if [ "$operation" != install ] || { [ "$validate_only" -eq 0 ] && [ "$dry_run" -eq 0 ]; }; then ableton_install_lock_acquire @@ -355,22 +366,22 @@ if [ "$want_runtime" -eq 1 ] && [ "$want_integration" -eq 0 ] \ && [ "$want_link" -eq 0 ]; then runtime_core_only=1 fi +if [ -n "$transaction_arg" ] && [ "$want_runtime" -eq 1 ] \ + && { [ "$want_integration" -eq 1 ] || [ "$want_link" -eq 1 ]; }; then + echo "!! runtime transactions cannot include desktop or Link files" >&2 + exit 2 +fi own_transaction=0 -if [ -n "$transaction_arg" ]; then - ABLETON_TRANSACTION_DIR="$transaction_arg" -else - # Only the public coordinator keeps recovery state under XDG_STATE_HOME. - # A direct component call is private scratch work; if its cleanup is - # interrupted, launchers must never mistake it for an unfinished core run. - if ABLETON_TRANSACTION_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ableton-install-plan.XXXXXX")"; then - own_transaction=1 - elif [ "$want_runtime" -eq 1 ]; then - exit 1 +ABLETON_TRANSACTION_DIR="" +if [ "$want_runtime" -eq 1 ]; then + if [ -n "$transaction_arg" ]; then + ABLETON_TRANSACTION_DIR="$transaction_arg" else - ABLETON_TRANSACTION_DIR="" - echo "!! Temporary recovery files are unavailable. Continuing with repairable shortcuts and support files." >&2 \ - || true + # Wine is the only component installed by this script that keeps an + # automatic rollback transaction. + ABLETON_TRANSACTION_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ableton-install-plan.XXXXXX")" + own_transaction=1 fi fi export ABLETON_TRANSACTION_DIR @@ -380,6 +391,7 @@ cleanup_unstarted_component_transaction() { local rc=$? trap - EXIT + ui_cleanup "$rc" if [ "$rc" -ne 0 ] && [ "$own_transaction" -eq 1 ] \ && ! rm -rf -- "$ABLETON_TRANSACTION_DIR"; then echo "!! Temporary installer files could not be removed: $ABLETON_TRANSACTION_DIR" >&2 @@ -387,24 +399,12 @@ cleanup_unstarted_component_transaction() exit "$rc" } trap cleanup_unstarted_component_transaction EXIT -if ! ableton_txn_init; then - if [ "$want_runtime" -eq 1 ] || [ -n "$transaction_arg" ]; then - exit 1 - fi - [ "$own_transaction" -eq 0 ] || rm -rf -- "$ABLETON_TRANSACTION_DIR" 2>/dev/null || true - own_transaction=0 - ABLETON_TRANSACTION_DIR="" - export ABLETON_TRANSACTION_DIR - echo "!! Temporary recovery files are unavailable. Continuing with repairable shortcuts and support files." >&2 \ - || true -fi -ableton_validate_install_state_journals repair +[ "$want_runtime" -eq 0 ] || ableton_txn_init stage="" component_commit_started=0 -runtime_core_ready=0 runtime_core_committed=0 optional_files_ready=0 -direct_optional_transaction=0 +manifest_publication_failed=0 cleanup_preview_scratch() { local stage_name="" @@ -439,6 +439,7 @@ cleanup() # Recovery must not depend on whether a diagnostic stream is writable. # Every mutation/recovery status below is handled explicitly. set +e + ui_cleanup "$rc" if [ "$validate_only" -eq 1 ] || [ "$dry_run" -eq 1 ]; then # Preview work never mutates installation state. Preserve the real # validation/planning result and dispose only its private scratch; @@ -450,34 +451,23 @@ cleanup() restore_error="temporary runtime cleanup failed" rc=1 fi - if [ "$rc" -ne 0 ] \ + if [ "$rc" -ne 0 ] && [ "$manifest_publication_failed" -eq 0 ] \ && { [ "$runtime_core_committed" -eq 1 ] || [ "$optional_files_ready" -eq 1 ]; }; then if [ "$runtime_core_committed" -eq 1 ]; then - if [ "$direct_optional_transaction" -eq 1 ] \ - && { [ -e "$ABLETON_TRANSACTION_DIR/active" ] \ - || [ -L "$ABLETON_TRANSACTION_DIR/active" ]; }; then - if ! rollback_transaction "$ABLETON_TRANSACTION_DIR"; then - restore_error="optional files could not be fully restored" - elif ! rm -rf -- "$ABLETON_TRANSACTION_DIR"; then - restore_error="temporary optional-file recovery data could not be removed" - fi - fi echo "!! Wine is ready. Run the installer again to retry shortcuts or Ableton Link files." >&2 || true - [ -z "$restore_error" ] \ - || echo "!! Some shortcut or Link files from this attempt could not be restored: $restore_error" >&2 || true - if [ -z "$transaction_arg" ]; then - echo "OK: Installation complete." || true - fi + [ -n "$transaction_arg" ] || ui_status i_runtime_ready else echo "!! Ableton is ready. Run the installer again to retry shortcuts or Ableton Link files." >&2 || true fi rc=0 - elif [ "$rc" -ne 0 ] && [ "$component_commit_started" -eq 1 ]; then + elif [ "$rc" -ne 0 ] && [ "$component_commit_started" -eq 1 ] \ + && [ -n "$ABLETON_TRANSACTION_DIR" ]; then printf 'component=install.sh\nstatus=committed-cleanup-incomplete\nexit=%s\ncleanup_error=%s\n' \ "$rc" "$restore_error" > "$ABLETON_TRANSACTION_DIR/COMMITTED_CLEANUP_FAILURE" 2>/dev/null || true echo "!! Installation finished, but temporary recovery files could not be removed." >&2 echo "!! Details were saved at $ABLETON_TRANSACTION_DIR/COMMITTED_CLEANUP_FAILURE." >&2 - elif [ "$rc" -ne 0 ] && [ -e "$ABLETON_TRANSACTION_DIR/active" ]; then + elif [ "$rc" -ne 0 ] && [ -n "$ABLETON_TRANSACTION_DIR" ] \ + && [ -e "$ABLETON_TRANSACTION_DIR/active" ]; then echo "!! Installation did not finish. Restoring the files from before this attempt." >&2 if ! rollback_transaction "$ABLETON_TRANSACTION_DIR"; then restore_error="${restore_error}${restore_error:+; }earlier files could not be fully restored" @@ -537,8 +527,9 @@ validate_runtime_payload() bundle_probe="$root/bin/pipewire-version-probe" [ -x "$bundle_probe" ] || bundle_probe="$root/dist/pipewire-version-probe" [ -x "$bundle_probe" ] || { echo "!! installer kit is missing its PipeWire compatibility check" >&2; return 1; } - echo "== Check the Wine package: $(basename "$tarball") ==" || true - echo "$(basename "$tarball"): OK" || true + ui_item_begin i_check_wine_package "$(basename "$tarball")" + ui_status i_wine_package_valid "$(basename "$tarball")" + ui_item_end ok local parent parent="$(dirname "$ABLETON_WINE_ROOT")" || return 1 if [ "$validate_only" -eq 1 ] || [ "$dry_run" -eq 1 ]; then @@ -550,9 +541,9 @@ validate_runtime_payload() local extract_timeout extract_timeout="$(ableton_timeout_value "${ABLETON_RUNTIME_EXTRACT_TIMEOUT:-1800}" ABLETON_RUNTIME_EXTRACT_TIMEOUT 60 7200)" \ || return 1 - echo " Unpacking and checking Wine (up to ${extract_timeout}s)" || true - ableton_run_bounded "$extract_timeout" tar -C "$stage" -I zstd -xf "$tarball" \ - >/dev/null || return 1 + ui_run i_unpack_runtime -- \ + ableton_run_bounded "$extract_timeout" tar -C "$stage" -I zstd -xf "$tarball" \ + || return 1 candidate="$stage/$ABLETON_RUNTIME_NAME" local required for required in \ @@ -587,120 +578,18 @@ validate_runtime_payload() echo "!! runtime GStreamer dependency validation failed" >&2; return 1; } fi ableton_pipeasio_validate_runtime "$candidate" "$runtime_info" "$version" || return 1 - if ! ableton_pipeasio_validate_panel "$candidate" "$runtime_info" >/dev/null 2>&1; then - echo "!! Wine and PipeASIO passed their checks, but PipeASIO Settings is incomplete. Its desktop shortcut will be left unchanged." >&2 \ - || true - fi cmp -s -- "$bundle_probe" "$candidate/bin/pipewire-version-probe" || { echo "!! installer and runtime compatibility checks do not match" >&2; return 1; } ableton_run_bounded 30 "$candidate/bin/wine" --version >/dev/null || { echo "!! staged runtime Wine executable did not pass its bounded version check" >&2 return 1 } -} - -validate_integration_sources() -{ - local required probe_source="" ntsync_probe_source="" - for required in ableton-live max9 detect-scale.sh detect-theme.sh shortcut-hold.sh \ - setup-realtime.sh audio-report.sh check-ntsync.sh rollback.sh; do - [ -f "$here/$required" ] || { echo "!! installer kit is missing scripts/$required" >&2; return 1; } - done - for required in config.sh lifecycle.sh live-options.sh manifest.sh pipeasio.sh; do - [ -f "$here/lib/$required" ] || { echo "!! installer kit is missing scripts/lib/$required" >&2; return 1; } - done - for required in "$ABLETON_WINE_ROOT/bin/pipewire-version-probe" \ - "$root/bin/pipewire-version-probe" "$root/dist/pipewire-version-probe"; do - [ -x "$required" ] || continue - probe_source="$required" - break - done - [ -n "$probe_source" ] || { - echo "!! installer kit is missing its PipeWire compatibility check" >&2 - return 1 - } - for required in "$here/ntsyncprobe.exe" \ - "$root/beta/tester-kit/probes/windows/ntsyncprobe.exe"; do - [ -f "$required" ] || continue - ntsync_probe_source="$required" - break - done - [ -n "$ntsync_probe_source" ] || { - echo "!! installer kit is missing its NTSync semantics probe" >&2 - return 1 - } - for required in ableton-live.desktop.in ableton-linux-protocol.desktop.in \ - ableton-linux-auz.desktop.in x-wine-extension-auz.xml \ - icons/application-ableton-live.xml max9.desktop.in \ - wine-protocol-c74max.desktop.in; do - [ -f "$root/desktop/$required" ] || { - echo "!! installer kit is missing desktop/$required" >&2; return 1; } - done -} - -validate_link_sources() -{ - local file needed managed_linkd="$ABLETON_DATA_HOME/ableton-linkd" - for file in "$here/../bin/ableton-linkd" "$root/dist/ableton-linkd"; do - [ -f "$file" ] && { linkd_source="$file"; break; } - done - if [ "$ABLETON_LINKD" = "$managed_linkd" ] && [ -z "$linkd_source" ]; then - echo "!! installer kit is missing bin/ableton-linkd" >&2 - return 1 - fi - unit_source="$here/ableton-linkd.service" - [ -f "$unit_source" ] || unit_source="$root/scripts/ableton-linkd.service" - [ -f "$unit_source" ] || { echo "!! installer kit is missing ableton-linkd.service" >&2; return 1; } - [ -f "$here/ableton-linkctl" ] || { echo "!! installer kit is missing ableton-linkctl" >&2; return 1; } - if [ -n "$linkd_source" ] && command -v readelf >/dev/null 2>&1; then - needed="$(readelf -d "$linkd_source" \ - | sed -n 's/.*Shared library: \[\(.*\)\]/\1/p')" || { - echo "!! could not inspect ableton-linkd dependencies" >&2 - return 1 - } - for file in $needed; do - case "$file" in linux-vdso.so*|libm.so*|libc.so*|libpthread.so*|libatomic.so*|ld-linux*.so*) ;; - *) echo "!! ableton-linkd links unexpected library $file" >&2; return 1 ;; - esac - done - fi - if [ -n "$linkd_source" ]; then - ableton_run_bounded 10 "$linkd_source" --help >/dev/null || { - echo "!! ableton-linkd did not pass its bounded startup check" >&2 - return 1 - } - fi + ui_status i_runtime_checks_passed } if [ "$want_runtime" -eq 1 ]; then validate_runtime_payload fi -if [ "$want_integration" -eq 1 ]; then - if ! validate_integration_sources; then - if [ "$validate_only" -eq 1 ] || [ "$dry_run" -eq 1 ]; then - exit 1 - fi - echo "!! Some shortcut or support files are missing from this installer. Available files will still be updated." >&2 \ - || true - fi -fi -if [ "$want_link" -eq 1 ]; then - if ! validate_link_sources; then - if [ "$validate_only" -eq 1 ] || [ "$dry_run" -eq 1 ]; then - exit 1 - fi - echo "!! Some Ableton Link files are missing from this installer. Available files will still be updated." >&2 \ - || true - fi -fi -if [ "$want_runtime" -eq 1 ] \ - && { [ "$want_integration" -eq 1 ] || [ "$want_link" -eq 1 ]; } \ - && ! ableton_config_validate_layout 'runtime prefix data config state bin'; then - echo "!! Wine can still be installed, but shortcuts and Link support were skipped because one of their locations cannot be used safely." >&2 \ - || true - want_integration=0 - want_link=0 -fi if [ "$want_runtime" -eq 1 ] && [ "$want_integration" -eq 0 ] \ && [ "$want_link" -eq 0 ]; then runtime_core_only=1 @@ -710,9 +599,10 @@ if [ "$validate_only" -eq 1 ]; then # Validation has already succeeded. Reporting and disposal of private # scratch files cannot turn that result into a failure. set +e - echo "OK: The selected files passed all checks." + ui_status i_validate_ok cleanup_preview_scratch trap - EXIT + ui_cleanup 0 exit 0 fi @@ -720,79 +610,38 @@ if [ "$dry_run" -eq 1 ]; then # Planning mutates no installation state. Its report and scratch cleanup # are presentation/housekeeping only. set +e - echo "PLAN: resolved configuration" + ui_status i_plan_heading [ "$want_runtime" -eq 0 ] || { - printf ' install or update Wine: %s\n' "$ABLETON_WINE_ROOT" - echo " update PipeASIO Settings shortcuts to match this Wine build" + ui_status i_plan_runtime "$ABLETON_WINE_ROOT" + ui_status i_plan_panel_shortcuts } if [ "$want_integration" -eq 1 ]; then - echo " install or update launchers, desktop shortcuts, file-opening support, and icons" - echo " install diagnostic and recovery tools" + ui_status i_plan_integration + ui_status i_plan_tools if [ -f "$ABLETON_WINEPREFIX/drive_c/Program Files/Cycling '74/Max 9/Max.exe" ]; then - echo " install or update the Max 9 launcher and file-opening support" + ui_status i_plan_max9 fi fi if [ "$want_link" -eq 1 ]; then if [ "$ABLETON_LINKD" = "$ABLETON_DATA_HOME/ableton-linkd" ]; then - echo " install or update Ableton Link support" + ui_status i_plan_link else - printf ' use the configured Ableton Link helper and leave it unchanged: %s\n' "$ABLETON_LINKD" + ui_status i_plan_link_external "$ABLETON_LINKD" fi fi - if [ "$want_integration" -eq 1 ] || [ "$want_link" -eq 1 ] \ - || { [ "$want_runtime" -eq 1 ] && [ "$ownership_manifest_was_present" -eq 1 ]; }; then - echo " record the installed release and files for later repair or removal" - fi + [ "$want_integration" -eq 0 ] && [ "$want_link" -eq 0 ] \ + || ui_status i_plan_backups "$ABLETON_STATE_HOME" cleanup_preview_scratch trap - EXIT + ui_cleanup 0 exit 0 fi -# Direct component use receives the same gate as the wrapper. Validation and -# dry-run remain usable without a running daemon; Link/integration-only work -# carries no PipeASIO driver replacement and is not gated. +# Validation and dry-run remain usable without a running daemon. if [ "$want_runtime" -eq 1 ]; then ableton_pipewire_preflight "$candidate/bin/pipewire-version-probe" "installing PipeASIO" fi -runtime_pids_all() -{ - local proc pid - for proc in /proc/[0-9]*; do - pid="${proc#/proc/}" - ableton_pid_uses_runtime "$pid" && printf '%s\n' "$pid" - done - return 0 -} - -stop_runtime_clients() -{ - local all scoped foreign pid answer="" - all="$(runtime_pids_all)" - [ -n "$all" ] || return 0 - scoped="$(ableton_prefix_pids)" - foreign="" - for pid in $all; do - case " $scoped " in *" $pid "*) ;; *) foreign="$foreign $pid" ;; esac - done - if [ -n "$foreign" ]; then - echo "!! runtime is used by another Wine prefix (PIDs:$foreign); close it before updating" >&2 - return 1 - fi - echo "!! the selected prefix has running Wine clients: $(tr ' ' '\n' <<< "$scoped" | sed '/^$/d' | paste -sd, -)" >&2 - if [ "$assume_yes" -ne 1 ]; then - if [ -t 0 ]; then - printf 'Stop every client in this prefix (including Live or Max)? [y/N] ' >&2 - read -r -t 60 answer || answer="" - case "$answer" in y|Y|yes|YES|Yes) ;; *) return 1 ;; esac - else - echo "!! refusing without --yes in a noninteractive session" >&2 - return 1 - fi - fi - ableton_stop_prefix || { echo "!! could not stop all scoped Wine clients" >&2; return 1; } -} - promote_runtime() { local target="$ABLETON_WINE_ROOT" parent backup safe marker marker_tmp record @@ -821,7 +670,7 @@ promote_runtime() [ ! -e "$backup" ] && [ ! -L "$backup" ] \ || { echo "!! transaction backup already exists: $backup" >&2; return 1; } fi - stop_runtime_clients || return 1 + ableton_stop_runtime_clients || return 1 # Mark and verify the staged tree before it can become the live runtime. marker="$candidate/.ableton-linux-runtime" [ ! -e "$marker" ] && [ ! -L "$marker" ] || { @@ -876,57 +725,65 @@ finish_direct_runtime_transaction() own_transaction=0 } -start_direct_optional_transaction() +sed_escape() { - if ! ABLETON_TRANSACTION_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ableton-install-files.XXXXXX")"; then - ABLETON_TRANSACTION_DIR="" - export ABLETON_TRANSACTION_DIR - own_transaction=0 - direct_optional_transaction=0 - echo "!! Temporary recovery files are unavailable. Continuing with repairable shortcuts and support files." >&2 \ - || true - return 0 - fi - export ABLETON_TRANSACTION_DIR - own_transaction=1 - direct_optional_transaction=1 - ABLETON_PUBLICATION_JOURNAL_BROKEN=0 - if ! ableton_txn_init; then - rm -rf -- "$ABLETON_TRANSACTION_DIR" 2>/dev/null || true - ABLETON_TRANSACTION_DIR="" - export ABLETON_TRANSACTION_DIR - own_transaction=0 - direct_optional_transaction=0 - echo "!! Temporary recovery files are unavailable. Continuing with repairable shortcuts and support files." >&2 \ - || true - fi - return 0 + printf '%s' "$1" | sed 's/[\\&#]/\\&/g' } -sed_escape() +render_link_unit() { - printf '%s' "$1" | sed 's/[\\&#]/\\&/g' + local escaped + escaped="${ABLETON_LINKD//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + escaped="${escaped//%/%%}" + cat <&2 || true ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) else - ableton_try_publish_file 644 "$ntsync_probe_source" "$data/ntsyncprobe.exe" file replace-generated + ableton_install_project_file 644 "$ntsync_probe_source" "$data/ntsyncprobe.exe" fi for source in "$ABLETON_WINE_ROOT/bin/pipewire-version-probe" \ "$root/bin/pipewire-version-probe" "$root/dist/pipewire-version-probe"; do @@ -950,12 +807,12 @@ install_integration() echo "!! installer kit is missing its PipeWire compatibility check" >&2 || true ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) else - ableton_try_publish_file 755 "$probe_source" "$data/pipewire-version-probe" file replace-generated + ableton_install_project_file 755 "$probe_source" "$data/pipewire-version-probe" fi for tool in setsyscolors.exe learnheal.exe; do for source in "$here/$tool" "$root/tools/$tool"; do [ -f "$source" ] || continue - ableton_try_publish_file 644 "$source" "$data/$tool" file replace-generated + ableton_install_project_file 644 "$source" "$data/$tool" break done done @@ -981,7 +838,7 @@ install_integration() -e "s#@WMCLASS@#$(sed_escape "$live_wmclass")#g" \ "$root/desktop/ableton-live.desktop.in" > "$tmp" \ && { [ -n "$live_wmclass" ] || sed -i '/^StartupWMClass=/d' "$tmp"; }; then - ableton_try_publish_launcher_file 644 "$tmp" "$apps/ableton-live.desktop" + ableton_install_project_file 644 "$tmp" "$apps/ableton-live.desktop" else ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) fi @@ -994,63 +851,38 @@ install_integration() && sed -e "s#@HOME@#$(sed_escape "$HOME")#g" \ -e "s#@BIN@#$(sed_escape "$bin")#g" \ "$root/desktop/${handler_templates[i]}.desktop.in" > "$tmp"; then - ableton_try_publish_launcher_file 644 "$tmp" "$data/$d" - ableton_try_publish_launcher_file 644 "$tmp" "$apps/$d" + ableton_install_project_file 644 "$tmp" "$data/$d" + ableton_install_project_file 644 "$tmp" "$apps/$d" else ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) fi [ -z "$tmp" ] || rm -f -- "$tmp" 2>/dev/null || true done - # Retire only the legacy handlers written by this project. Wine may still - # own files with these globally contested names, so an entry that does not - # route through ableton-live stays in place while the default moves to the - # project-specific ID. - for target in \ - "$data/wine-protocol-ableton.desktop" "$data/wine-extension-auz.desktop" \ - "$apps/wine-protocol-ableton.desktop" "$apps/wine-extension-auz.desktop"; do - [ -e "$target" ] || continue - if ableton_legacy_owned_path "$target"; then - # Exact legacy launchers are obsolete generated output. Removing - # them must not consult stale uninstall inventory or prestate. - rm -f -- "$target" 2>/dev/null || true - if [ -e "$target" ] || [ -L "$target" ]; then - echo "!! Ableton was installed, but an old shortcut could not be removed: $target" >&2 \ - || true - else - ableton_record_deowned "$target" >/dev/null 2>&1 || true - fi - else - echo " kept an existing shortcut at $target" || true - fi - done - - for source in "$root"/desktop/icons/scalable/apps/*.svg; do - ableton_try_publish_file 644 "$source" "$icons/scalable/apps/$(basename "$source")" + for d in "${app_icons[@]}"; do + ableton_install_project_file 644 \ + "$root/desktop/icons/scalable/apps/$d.svg" "$icons/scalable/apps/$d.svg" done - for source in "$root"/desktop/icons/scalable/mimetypes/*.svg; do - ableton_try_publish_file 644 "$source" "$icons/scalable/mimetypes/$(basename "$source")" + for d in "${mimetype_icons[@]}"; do + ableton_install_project_file 644 \ + "$root/desktop/icons/scalable/mimetypes/$d.svg" "$icons/scalable/mimetypes/$d.svg" done - for source in "$root"/desktop/icons/symbolic/apps/*.svg; do - ableton_try_publish_file 644 "$source" "$icons/symbolic/apps/$(basename "$source")" - done - ableton_try_publish_file 644 "$root/desktop/x-wine-extension-auz.xml" "$mime_root/packages/x-wine-extension-auz.xml" - ableton_try_publish_file 644 "$root/desktop/icons/application-ableton-live.xml" "$mime_root/packages/application-ableton-live.xml" + ableton_install_project_file 644 \ + "$root/desktop/icons/symbolic/apps/live-symbolic.svg" \ + "$icons/symbolic/apps/live-symbolic.svg" + ableton_install_project_file 644 "$root/desktop/x-wine-extension-auz.xml" "$mime_root/packages/x-wine-extension-auz.xml" + ableton_install_project_file 644 "$root/desktop/icons/application-ableton-live.xml" "$mime_root/packages/application-ableton-live.xml" - if command -v xdg-mime >/dev/null 2>&1; then - if [ -e "$mimeapps_file" ] || [ -L "$mimeapps_file" ]; then - if [ ! -f "$mimeapps_file" ] || [ -L "$mimeapps_file" ]; then - mime_setup_ok=0 - mimeapps_file="" - fi - fi - if [ -n "$mimeapps_file" ]; then - mime_stage="$(mktemp -d "${TMPDIR:-/tmp}/ableton-mimeapps.XXXXXX")" || true - [ -n "$mime_stage" ] || mime_setup_ok=0 - fi - if [ -n "$mime_stage" ] && [ -e "$mimeapps_file" ] \ + if [ "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 1 ]; then + : + elif command -v xdg-mime >/dev/null 2>&1; then + mime_stage="$(mktemp -d "${TMPDIR:-/tmp}/ableton-mimeapps.XXXXXX")" || true + [ -n "$mime_stage" ] || mime_setup_ok=0 + if [ -n "$mime_stage" ] && [ -f "$mimeapps_file" ] \ && ! cp -- "$mimeapps_file" "$mime_stage/mimeapps.list"; then rm -rf -- "$mime_stage" 2>/dev/null || true + echo "!! copy failed: $mimeapps_file -> $mime_stage/mimeapps.list" >&2 || true + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) mime_stage="" mime_setup_ok=0 fi @@ -1076,22 +908,23 @@ install_integration() mime_setup_ok=0 fi if [ -n "$mime_stage" ] && [ -f "$mime_stage/mimeapps.list" ]; then - if ! ableton_atomic_restore_object "$mime_stage/mimeapps.list" "$mimeapps_file"; then - mime_setup_ok=0 - fi + mime_failures_before="$ABLETON_OPTIONAL_FILE_FAILURES" + ableton_install_generated_config 644 "$mime_stage/mimeapps.list" "$mimeapps_file" + [ "$ABLETON_OPTIONAL_FILE_FAILURES" -eq "$mime_failures_before" ] \ + || mime_setup_ok=0 fi [ -z "$mime_stage" ] || rm -rf -- "$mime_stage" 2>/dev/null || true else mime_setup_ok=0 fi - if [ "$mime_setup_ok" -ne 1 ]; then + if [ "$mime_setup_ok" -ne 1 ] && [ "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 0 ]; then ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) echo "!! Could not set Ableton as the default app for Live files. Ableton itself can still be used normally." >&2 \ || true fi if [ -f "$max_unix" ]; then - ableton_try_publish_launcher_file 755 "$here/max9" "$bin/max9" + ableton_install_project_file 755 "$here/max9" "$bin/max9" for d in max9 wine-protocol-c74max; do tmp="" if tmp="$(mktemp)" \ @@ -1100,7 +933,7 @@ install_integration() -e "s#@PREFIX@#$(sed_escape "$ABLETON_WINEPREFIX")#g" \ "$root/desktop/$d.desktop.in" > "$tmp"; then target="$apps/$d.desktop" - ableton_try_publish_launcher_file 644 "$tmp" "$target" + ableton_install_project_file 644 "$tmp" "$target" else ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) fi @@ -1108,13 +941,15 @@ install_integration() done fi - echo "== Refresh desktop menus and file associations ==" || true + ui_item_end ok + ui_item_begin i_refresh_desktop_menus if command -v update-mime-database >/dev/null 2>&1; then update-mime-database "$mime_root" >/dev/null 2>&1 || true fi if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "$apps" >/dev/null 2>&1 || true fi + ui_item_end ok if [ "$ABLETON_OPTIONAL_FILE_FAILURES" -gt "$failures_before" ]; then echo "!! Some shortcuts or support files could not be updated. Run the installer again to retry them." >&2 \ @@ -1125,49 +960,30 @@ install_integration() install_link_assets() { - echo "== Install Ableton Link support files ==" || true - local tool failures_before="$ABLETON_OPTIONAL_FILE_FAILURES" + [ "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 0 ] || return 0 + ui_item_begin i_install_link_files + local tool source tmp="" failures_before="$ABLETON_OPTIONAL_FILE_FAILURES" local managed_linkd="$ABLETON_DATA_HOME/ableton-linkd" - local legacy_custom="${ABLETON_PR182_CUSTOM_LINKD:-}" - # Support files are published atomically, so a currently running Link - # process may keep using its old executable until the policy step restarts - # it. Lifecycle and firewall changes belong to setup-link.sh; they must not - # become prerequisites for replacing files that this installer owns. - # setup-link.sh sources manifest.sh and stops without it, so a Link-only - # install ships it too. - for tool in config.sh lifecycle.sh manifest.sh; do - ableton_try_publish_file 644 "$here/lib/$tool" "$data/lib/$tool" file replace-generated + local installed_unit="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/ableton-linkd.service" + for source in "$here/../bin/ableton-linkd" "$root/dist/ableton-linkd"; do + [ -f "$source" ] || continue + linkd_source="$source" + break done - if [ -n "$legacy_custom" ]; then - # Seal the proof first and keep it: the VERSION write below stops the live - # recogniser matching, and the sealed copy is what authorises this path for - # the rest of the transaction. Then dispose of it the way every other - # relinquished managed file is disposed of. An unmodified object is removed - # and any pre-install file it displaced comes back; a changed one is left - # exactly where it is and only the ownership claim is dropped. - if ! ableton_txn_authorize_pr182_custom_link "$legacy_custom"; then - echo "!! Kept an older custom Link helper because the installer could not confirm that it created the file." >&2 \ - || true - elif ableton_pr182_custom_link_owned "$legacy_custom"; then - if ableton_remove_managed_file "$legacy_custom"; then - echo " removed the old custom Link helper" || true - else - ableton_record_deowned "$legacy_custom" >/dev/null 2>&1 || true - echo "!! Kept the older custom Link helper because its saved recovery data could not be used safely." >&2 \ - || true - fi - else - ableton_abandon_managed_file "$legacy_custom" >/dev/null 2>&1 \ - || ableton_record_deowned "$legacy_custom" >/dev/null 2>&1 \ - || true - echo " kept the modified custom Link helper" || true - fi + unit_source="$here/ableton-linkd.service" + [ -f "$unit_source" ] || unit_source="$root/scripts/ableton-linkd.service" + # A Link-only run needs these common support files. A combined integration + # run already handled the same destinations once. + if [ "$want_integration" -eq 0 ]; then + for tool in config.sh lifecycle.sh manifest.sh preferences.sh ui.sh; do + ableton_install_project_file 644 "$here/lib/$tool" "$data/lib/$tool" + done fi # A configured external Link daemon may be executed by the controller, but # it is never installed, claimed, restored, or removed by this project. if [ "$ABLETON_LINKD" = "$managed_linkd" ]; then if [ -n "$linkd_source" ]; then - ableton_try_publish_file 755 "$linkd_source" "$managed_linkd" file replace-generated + ableton_install_project_file 755 "$linkd_source" "$managed_linkd" else ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) fi @@ -1177,21 +993,30 @@ install_link_assets() || true ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) else - printf ' using the configured Ableton Link helper and leaving it unchanged: %s\n' "$ABLETON_LINKD" \ - || true + ui_status i_link_helper_external "$ABLETON_LINKD" fi fi - ableton_try_publish_file 755 "$here/ableton-linkctl" "$data/ableton-linkctl" file replace-generated - ableton_try_publish_file 755 "$here/setup-link.sh" "$data/setup-link.sh" file replace-generated + ableton_install_project_file 755 "$here/ableton-linkctl" "$data/ableton-linkctl" + ableton_install_project_file 755 "$here/setup-link.sh" "$data/setup-link.sh" if [ -n "$unit_source" ] && [ -f "$unit_source" ]; then - ableton_try_publish_file 644 "$unit_source" "$data/ableton-linkd.service" file replace-generated + ableton_install_project_file 644 "$unit_source" "$data/ableton-linkd.service" else ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) fi + if [ "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 0 ] \ + && tmp="$(mktemp "${TMPDIR:-/tmp}/ableton-link-service.XXXXXX")" \ + && render_link_unit > "$tmp"; then + ableton_install_generated_config 644 "$tmp" "$installed_unit" + elif [ "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 0 ]; then + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + echo "!! copy failed: generated Ableton Link service -> $installed_unit" >&2 || true + fi + [ -z "$tmp" ] || rm -f -- "$tmp" 2>/dev/null || true if [ "$ABLETON_OPTIONAL_FILE_FAILURES" -gt "$failures_before" ]; then echo "!! Some Ableton Link files could not be updated. Run the installer again to retry them." >&2 \ || true fi + ui_item_end ok return 0 } @@ -1201,12 +1026,8 @@ if [ "$want_runtime" -eq 1 ]; then # Promotion and final core validation are the runtime boundary in every # component combination. A direct call closes its private transaction now; # a coordinator-owned transaction stays open for the prefix/Live parent. - runtime_core_ready=1 if [ "$own_transaction" -eq 1 ]; then finish_direct_runtime_transaction - if [ "$want_integration" -eq 1 ] || [ "$want_link" -eq 1 ]; then - start_direct_optional_transaction - fi fi fi if [ "$want_integration" -eq 1 ]; then @@ -1237,7 +1058,7 @@ if [ "$want_runtime" -eq 1 ]; then elif [ "$runtime_core_only" -eq 1 ]; then # A parent-owned core phase leaves all desktop work to the later repair # phase. A direct runtime update performs the same repair only after the - # runtime has committed, so launcher drift cannot undo valid Wine files. + # runtime has committed, so shortcut failures cannot undo valid Wine files. [ -n "$transaction_arg" ] || postcommit_runtime_panel=1 else if ! ableton_pipeasio_sync_panel "$ABLETON_WINE_ROOT" reconcile; then @@ -1249,42 +1070,22 @@ if [ "$want_runtime" -eq 1 ]; then elif [ "$want_integration" -eq 1 ]; then if grep -qxF 'pipeasio-panel: built' "$ABLETON_WINE_ROOT/ABLETON-WINE-BUILD-INFO.txt" 2>/dev/null \ || grep -qxF 'pipeasio-panel: skipped' "$ABLETON_WINE_ROOT/ABLETON-WINE-BUILD-INFO.txt" 2>/dev/null; then - if ableton_pipeasio_validate_runtime "$ABLETON_WINE_ROOT" >/dev/null 2>&1; then - if ! ableton_pipeasio_sync_panel "$ABLETON_WINE_ROOT" install; then - ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) - echo "!! Ableton was installed, but the PipeASIO settings shortcut could not be updated." >&2 \ - || true - fi - else - echo " kept existing PipeASIO panel links because this runtime uses another panel format" \ + if ! ableton_pipeasio_sync_panel "$ABLETON_WINE_ROOT" install; then + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + echo "!! Ableton was installed, but the PipeASIO settings shortcut could not be updated." >&2 \ || true fi else - echo " launcher integration continues with the available PipeASIO files" || true + ui_status i_panel_files_available fi fi if [ "$want_integration" -eq 1 ] || [ "$want_link" -eq 1 ]; then version_tmp="" - version_ready=0 if mkdir -p -- "$data" \ && version_tmp="$(mktemp "$data/.VERSION.XXXXXX")" \ && printf '%s\n' "$(cat "$root/VERSION" 2>/dev/null || echo unknown)" > "$version_tmp"; then - if [ "$want_runtime" -eq 0 ]; then - # This label is repairable metadata. Publish it atomically without - # adding it to a parent-owned core journal, then include it in the - # best-effort installed-file list below. - if chmod 644 "$version_tmp" \ - && ableton_atomic_restore_object "$version_tmp" "$data/VERSION" \ - && ableton_record_owned "$data/VERSION"; then - version_ready=1 - fi - elif ableton_publish_file 644 "$version_tmp" "$data/VERSION" file replace-generated; then - version_ready=1 - fi - fi - if [ "$version_ready" -eq 1 ]; then - : + ableton_install_project_file 644 "$version_tmp" "$data/VERSION" else ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) echo "!! Ableton was installed, but its support files could not be fully updated." >&2 \ @@ -1292,106 +1093,80 @@ if [ "$want_integration" -eq 1 ] || [ "$want_link" -eq 1 ]; then fi [ -z "$version_tmp" ] || rm -f -- "$version_tmp" 2>/dev/null || true fi -if [ "$runtime_core_only" -eq 0 ] \ - && { [ "$want_integration" -eq 1 ] || [ "$want_link" -eq 1 ] \ - || { [ "$want_runtime" -eq 1 ] && [ "$ownership_manifest_was_present" -eq 1 ]; } \ - || [ "${#ABLETON_OWNED_PATHS[@]}" -gt 0 ]; }; then - if ! ableton_write_ownership_manifest "$ABLETON_STATE_HOME/install-manifest.tsv"; then - ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) - echo "!! Ableton was installed, but the installed-file list could not be updated." >&2 \ - || true - fi -fi if [ -n "$stage" ]; then # The validated runtime has already moved out of this private scratch tree. - # Failure to remove that scratch directory does not invalidate the promoted - # runtime or any journalled integration changes, so it must not trigger an - # otherwise unnecessary rollback. + # Failure to remove that scratch directory does not invalidate Wine. if ! rm -rf -- "$stage" || [ -e "$stage" ] || [ -L "$stage" ]; then echo "!! Wine was installed, but temporary files remain at $stage" >&2 || true fi stage="" fi -if [ "$own_transaction" -eq 1 ]; then - if [ "$optional_files_ready" -eq 1 ] || [ "$runtime_core_ready" -eq 1 ]; then - if ! commit_transaction "$ABLETON_TRANSACTION_DIR" 0 1 >/dev/null 2>&1; then - if [ "$direct_optional_transaction" -eq 1 ]; then - echo "!! Ableton's files are ready, but the installer could not finish its final housekeeping." >&2 \ - || true - elif [ "$runtime_core_ready" -eq 1 ]; then - echo "!! Wine is ready, but the installer may not be able to restore the previous Wine version automatically. Temporary recovery files may remain." >&2 \ - || true - else - echo "!! Ableton's files are ready, but the installer could not finish its final housekeeping." >&2 \ - || true - fi - fi - else - commit_transaction "$ABLETON_TRANSACTION_DIR" 0 1 - fi - if ! rm -rf -- "$ABLETON_TRANSACTION_DIR"; then - if [ "$direct_optional_transaction" -eq 1 ]; then - echo "!! Installed files are ready, but temporary installer data remains at $ABLETON_TRANSACTION_DIR" >&2 \ - || true - elif [ "$runtime_core_ready" -eq 1 ]; then - echo "!! Wine is ready, but temporary installer data remains at $ABLETON_TRANSACTION_DIR" >&2 \ - || true - else - echo "!! Installed files are ready, but temporary installer data remains at $ABLETON_TRANSACTION_DIR" >&2 \ - || true - fi - fi - if [ "$direct_optional_transaction" -eq 1 ]; then - direct_optional_transaction=0 - own_transaction=0 - ABLETON_TRANSACTION_DIR="" - export ABLETON_TRANSACTION_DIR - fi -fi if [ "$postcommit_runtime_panel" -eq 1 ]; then # The runtime is committed and its private records are gone. Panel repair is - # deliberately unjournalled and warning-only from this point forward. + # deliberately warning-only from this point forward. unset ABLETON_TRANSACTION_DIR panel_repair_status=0 set +e - if ! ableton_config_validate_layout 'runtime prefix data config state bin'; then - panel_repair_status=126 - else - ( - set -e - ableton_pipeasio_sync_panel "$ABLETON_WINE_ROOT" reconcile - ableton_write_ownership_manifest "$ABLETON_STATE_HOME/install-manifest.tsv" || exit 125 - ) - panel_repair_status=$? - fi + ( + set -e + ableton_pipeasio_sync_panel "$ABLETON_WINE_ROOT" reconcile + ) + panel_repair_status=$? set -e case "$panel_repair_status" in 0) ;; - 125) - echo "!! Wine was installed, but the installed-file list could not be updated." >&2 \ - || true ;; - 126) - echo "!! Wine was installed, but the PipeASIO settings shortcut was skipped because one of its locations cannot be used safely." >&2 \ - || true ;; *) echo "!! Wine was installed, but the PipeASIO settings shortcut could not be updated." >&2 \ || true ;; esac fi + +# The installed-file list is the final publication for every public component +# run. It always names the configured runtime once, while file/config/symlink +# rows come only from successful fixed-path publications above. +ABLETON_RUNTIME_INSTALLED=1 +export ABLETON_RUNTIME_INSTALLED +if ! ableton_write_ownership_manifest; then + manifest_publication_failed=1 + echo "!! Ableton was installed, but its installed-file list could not be published." >&2 \ + || true + exit 1 +fi + +if [ "$ABLETON_OPTIONAL_FILES_BACKED_UP" -gt 0 ]; then + ui_status i_backed_up_files "$ABLETON_OPTIONAL_FILES_BACKED_UP" +fi +if [ "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 1 ]; then + trap - EXIT + ui_cleanup 4 + exit 4 +fi if [ "${ABLETON_INTERNAL_OPTIONAL_STATUS:-0}" = 1 ] \ && [ "$ABLETON_OPTIONAL_FILE_FAILURES" -gt 0 ]; then # Internal advisory status for the public installer summary. All selected # repair work and transaction cleanup above has already finished. trap - EXIT + ui_cleanup 3 exit 3 fi -if [ -z "$transaction_arg" ]; then - echo "OK: Installation complete." || true -fi -if [ "$want_integration" -eq 1 ]; then - printf ' Audio report: %s/audio-report.sh\n' "$data" || true - printf ' NTSync check: %s/check-ntsync.sh\n' "$data" || true - printf ' Realtime setup: %s/setup-realtime.sh\n' "$data" || true - printf ' Restore previous Wine version: %s/rollback.sh\n' "$data" || true +# A standalone run reports its own outcome; the public installer's summary +# covers a coordinated one. +if [ -z "$transaction_arg" ] && [ "${ABLETON_INTERNAL_OPTIONAL_STATUS:-0}" != 1 ]; then + [ "$want_runtime" -eq 0 ] || ui_status i_runtime_ready + if [ "$ABLETON_OPTIONAL_FILE_FAILURES" -gt 0 ]; then + ui_warn i_integration_partial + elif [ "$ABLETON_OPTIONAL_FILES_KEPT" -gt 0 ]; then + ui_warn i_kept_files "$ABLETON_OPTIONAL_FILES_KEPT" + elif [ "$want_integration" -eq 1 ]; then + ui_status i_launchers_ready + elif [ "$want_link" -eq 1 ]; then + ui_status i_link_files_ready + fi + if [ "$want_integration" -eq 1 ]; then + ui_info i_hint_audio_report "$data" + ui_info i_hint_ntsync_check "$data" + ui_info i_hint_realtime_setup "$data" + ui_info i_hint_rollback "$data" + fi fi diff --git a/scripts/installer.sh b/scripts/installer.sh index f5761179..f354fe86 100755 --- a/scripts/installer.sh +++ b/scripts/installer.sh @@ -3,9 +3,10 @@ # payload transport; all policy and component selection lives here so it can be # tested from a repository checkout or an extracted kit. set -euo pipefail -export LC_ALL=C.UTF-8 here="$(cd "$(dirname "$0")" && pwd)" root="$(cd "$here/.." && pwd)" +. "$here/lib/ui.sh" +export LC_ALL=C.UTF-8 if [ -d "$here/../bin" ]; then kit_bin="$(cd "$here/../bin" && pwd)" export PATH="$kit_bin:$PATH" @@ -14,6 +15,8 @@ fi . "$here/lib/lifecycle.sh" . "$here/lib/pipeasio.sh" . "$here/lib/manifest.sh" +. "$here/lib/preferences.sh" +unset ABLETON_PROJECT_BACKUP_DIR ABLETON_PROJECT_BACKUP_STAMP usage() { @@ -21,14 +24,24 @@ usage() Usage: installer install [--live-installer FILE] [--prefix PATH] [--runtime-root PATH] [--live-major 11|12] [--link=off|session|always] + [--audio-buffer=64|128|256|512|1024] + [--shortcuts=take|preserve] + [--dpi=auto|100|fractional|preserve] + [--audio-threads=auto|off|1..63] [--rt=auto|off] + [--power=performance|balanced|off] [--skip-live-install] [--yes] [--dry-run] installer update [--prefix PATH] [--runtime-root PATH] [--link=keep|off|session|always] [--yes] [--dry-run] + [--audio-buffer=64|128|256|512|1024] + [--shortcuts=take|preserve] + [--dpi=auto|100|fractional|preserve] + [--audio-threads=auto|off|1..63] [--rt=auto|off] + [--power=performance|balanced|off] installer runtime install [--runtime-root PATH] [--yes] [--dry-run] installer prefix create|update [--prefix PATH] [--live-major 11|12] [--dry-run] installer prefix repair-live11 [--prefix PATH] [--dry-run] installer link enable [--mode=session|always] | disable | status - installer uninstall [--keep-prefix|--delete-prefix] [--yes] [--dry-run] + installer uninstall [--keep-prefix|--prefix-only|--delete-prefix] [--yes] [--dry-run] installer plan COMMAND ... Compatibility aliases (deprecated, conflicts are errors): @@ -41,8 +54,9 @@ Noninteractive installs require --live-installer or --skip-live-install. EOF } -warn_compat() +deprecated_option() { + ui_note d_deprecated_option "$1" "$2" printf 'WARNING: %s is deprecated; use %s\n' "$1" "$2" >&2 || true return 0 } @@ -60,6 +74,7 @@ cli_link="" link_mode_option="" delete_prefix=0 keep_prefix=0 +prefix_only=0 compat_mode="" compat_link="" compat_no_launch=0 @@ -71,6 +86,18 @@ runtime_seen=0 major_seen=0 mode_seen=0 link_seen=0 +cli_audio_buffer="" +cli_shortcuts="" +cli_dpi="" +cli_audio_threads="" +cli_rt="" +cli_power="" +audio_buffer_seen=0 +shortcuts_seen=0 +dpi_seen=0 +audio_threads_seen=0 +rt_seen=0 +power_seen=0 if [ "${1:-}" = plan ]; then dry_run=1 @@ -131,35 +158,54 @@ while [ $# -gt 0 ]; do --mode=*) [ "$mode_seen" -eq 0 ] || { echo "!! --mode was specified more than once" >&2; exit 2; } mode_seen=1; link_mode_option="${1#*=}" ;; + --audio-buffer=*) + [ "$audio_buffer_seen" -eq 0 ] || { echo "!! --audio-buffer was specified more than once" >&2; exit 2; } + audio_buffer_seen=1; cli_audio_buffer="${1#*=}" ;; + --shortcuts=*) + [ "$shortcuts_seen" -eq 0 ] || { echo "!! --shortcuts was specified more than once" >&2; exit 2; } + shortcuts_seen=1; cli_shortcuts="${1#*=}" ;; + --dpi=*) + [ "$dpi_seen" -eq 0 ] || { echo "!! --dpi was specified more than once" >&2; exit 2; } + dpi_seen=1; cli_dpi="${1#*=}" ;; + --audio-threads=*) + [ "$audio_threads_seen" -eq 0 ] || { echo "!! --audio-threads was specified more than once" >&2; exit 2; } + audio_threads_seen=1; cli_audio_threads="${1#*=}" ;; + --rt=*) + [ "$rt_seen" -eq 0 ] || { echo "!! --rt was specified more than once" >&2; exit 2; } + rt_seen=1; cli_rt="${1#*=}" ;; + --power=*) + [ "$power_seen" -eq 0 ] || { echo "!! --power was specified more than once" >&2; exit 2; } + power_seen=1; cli_power="${1#*=}" ;; --skip-live-install) skip_live=1 ;; --yes|-y) assume_yes=1 ;; --dry-run) dry_run=1 ;; --keep-prefix) keep_prefix=1 ;; + --prefix-only) prefix_only=1 ;; --delete-prefix) delete_prefix=1 ;; --runtime-only) [ -z "$compat_mode" ] || { echo "!! conflicting compatibility mode flags" >&2; exit 2; } compat_mode=runtime - warn_compat --runtime-only "runtime install" ;; + deprecated_option --runtime-only "runtime install" ;; --update) [ -z "$compat_mode" ] || { echo "!! conflicting compatibility mode flags" >&2; exit 2; } compat_mode=update - warn_compat --update update ;; + deprecated_option --update update ;; --no-launch) skip_live=1 compat_no_launch=1 - warn_compat --no-launch "--skip-live-install --link=off" ;; + deprecated_option --no-launch "--skip-live-install --link=off" ;; --no-link) [ -z "$compat_link" ] || { echo "!! --no-link conflicts with --link" >&2; exit 2; } compat_link=off - warn_compat --no-link --link=off ;; + deprecated_option --no-link --link=off ;; --link) [ -z "$compat_link" ] || { echo "!! --link conflicts with --no-link" >&2; exit 2; } compat_link=session - warn_compat --link "link enable --mode=session" ;; + deprecated_option --link "link enable --mode=session" ;; --uninstall) [ -z "$compat_mode" ] || { echo "!! conflicting compatibility mode flags" >&2; exit 2; } compat_mode=uninstall - warn_compat --uninstall uninstall ;; + deprecated_option --uninstall uninstall ;; *) echo "!! unknown installer argument: $1" >&2; usage >&2; exit 2 ;; esac shift @@ -195,7 +241,7 @@ fi if [ "$compat_prefix" -eq 1 ]; then [ "$command_name" = uninstall ] || { echo "!! legacy --prefix applies only to uninstall" >&2; exit 2; } delete_prefix=1 - warn_compat "--uninstall --prefix" "uninstall --delete-prefix" + deprecated_option "--uninstall --prefix" "uninstall --delete-prefix" fi case "$command_name:$subcommand" in @@ -213,10 +259,49 @@ esac echo "!! --link needs off, session, always, or keep" >&2; exit 2; } [ "$mode_seen" -eq 0 ] || [ -n "$link_mode_option" ] || { echo "!! --mode needs session or always" >&2; exit 2; } -[ "$delete_prefix" -eq 0 ] || [ "$keep_prefix" -eq 0 ] || { echo "!! --keep-prefix and --delete-prefix conflict" >&2; exit 2; } +uninstall_scope_count=$((delete_prefix + keep_prefix + prefix_only)) +[ "$uninstall_scope_count" -le 1 ] || { + echo "!! --keep-prefix, --prefix-only, and --delete-prefix conflict" >&2 + exit 2 +} +if [ "$command_name" = uninstall ] && [ "$uninstall_scope_count" -eq 0 ]; then + keep_prefix=1 +fi case "$cli_major" in ''|11|12) ;; *) echo "!! --live-major must be 11 or 12" >&2; exit 2 ;; esac case "$cli_link" in ''|off|session|always|keep) ;; *) echo "!! --link must be off, session, always, or keep" >&2; exit 2 ;; esac case "$link_mode_option" in ''|session|always) ;; *) echo "!! --mode must be session or always" >&2; exit 2 ;; esac +[ "$audio_buffer_seen" -eq 0 ] || [ -n "$cli_audio_buffer" ] \ + || { echo "!! --audio-buffer needs a value" >&2; exit 2; } +[ "$shortcuts_seen" -eq 0 ] || [ -n "$cli_shortcuts" ] \ + || { echo "!! --shortcuts needs a value" >&2; exit 2; } +[ "$dpi_seen" -eq 0 ] || [ -n "$cli_dpi" ] \ + || { echo "!! --dpi needs a value" >&2; exit 2; } +[ "$audio_threads_seen" -eq 0 ] || [ -n "$cli_audio_threads" ] \ + || { echo "!! --audio-threads needs a value" >&2; exit 2; } +[ "$rt_seen" -eq 0 ] || [ -n "$cli_rt" ] \ + || { echo "!! --rt needs a value" >&2; exit 2; } +[ "$power_seen" -eq 0 ] || [ -n "$cli_power" ] \ + || { echo "!! --power needs a value" >&2; exit 2; } +case "$cli_audio_buffer" in ''|64|128|256|512|1024) ;; + *) echo "!! --audio-buffer must be 64, 128, 256, 512, or 1024" >&2; exit 2 ;; +esac +case "$cli_shortcuts" in ''|take|preserve) ;; + *) echo "!! --shortcuts must be take or preserve" >&2; exit 2 ;; +esac +case "$cli_dpi" in ''|auto|100|fractional|preserve) ;; + *) echo "!! --dpi must be auto, 100, fractional, or preserve" >&2; exit 2 ;; +esac +case "$cli_audio_threads" in + ''|auto|off) ;; + *) [[ "$cli_audio_threads" =~ ^([1-9]|[1-5][0-9]|6[0-3])$ ]] \ + || { echo "!! --audio-threads must be auto, off, or 1 through 63" >&2; exit 2; } ;; +esac +case "$cli_rt" in ''|auto|off) ;; + *) echo "!! --rt must be auto or off" >&2; exit 2 ;; +esac +case "$cli_power" in ''|performance|balanced|off) ;; + *) echo "!! --power must be performance, balanced, or off" >&2; exit 2 ;; +esac [ "$skip_live" -eq 0 ] || [ -z "$live_payload" ] || { echo "!! --skip-live-install conflicts with --live-installer" >&2; exit 2; } [ "$cli_link" != keep ] || [ "$command_name" = update ] || { @@ -228,45 +313,79 @@ invalid_option() exit 2 } +if [ "$command_name" != install ] && [ "$command_name" != update ]; then + [ "$audio_buffer_seen" -eq 0 ] || invalid_option --audio-buffer + [ "$shortcuts_seen" -eq 0 ] || invalid_option --shortcuts + [ "$dpi_seen" -eq 0 ] || invalid_option --dpi + [ "$audio_threads_seen" -eq 0 ] || invalid_option --audio-threads + [ "$rt_seen" -eq 0 ] || invalid_option --rt + [ "$power_seen" -eq 0 ] || invalid_option --power +fi + # A selected command has one fixed option schema. Irrelevant values are # rejected here instead of becoming order-dependent or silent no-ops. case "$command_name:$subcommand" in install:) - [ "$delete_prefix$keep_prefix" = 00 ] || invalid_option "prefix-retention options" + [ "$delete_prefix$keep_prefix$prefix_only" = 000 ] || invalid_option "prefix-retention options" [ -z "$link_mode_option" ] || invalid_option --mode ;; update:) [ -z "$live_payload" ] || invalid_option --live-installer [ "$skip_live" -eq 0 ] || invalid_option --skip-live-install - [ "$delete_prefix$keep_prefix" = 00 ] || invalid_option "prefix-retention options" + [ "$delete_prefix$keep_prefix$prefix_only" = 000 ] || invalid_option "prefix-retention options" [ -z "$link_mode_option" ] || invalid_option --mode ;; runtime:install) [ -z "$live_payload$cli_prefix$cli_major$cli_link$link_mode_option" ] || invalid_option "non-runtime options" - [ "$skip_live$delete_prefix$keep_prefix" = 000 ] || invalid_option "non-runtime options" ;; + [ "$skip_live$delete_prefix$keep_prefix$prefix_only" = 0000 ] || invalid_option "non-runtime options" ;; prefix:create|prefix:update) [ -z "$live_payload$cli_link$link_mode_option" ] || invalid_option "non-prefix options" - [ "$skip_live$delete_prefix$keep_prefix$assume_yes" = 0000 ] || invalid_option "non-prefix options" ;; + [ "$skip_live$delete_prefix$keep_prefix$prefix_only$assume_yes" = 00000 ] || invalid_option "non-prefix options" ;; prefix:repair-live11) [ -z "$live_payload$cli_runtime$cli_major$cli_link$link_mode_option" ] \ || invalid_option "non-repair options" - [ "$skip_live$delete_prefix$keep_prefix$assume_yes" = 0000 ] \ + [ "$skip_live$delete_prefix$keep_prefix$prefix_only$assume_yes" = 00000 ] \ || invalid_option "non-repair options" ;; link:enable) if [ -n "$cli_link" ] && { [ "$explicit_command" -eq 1 ] || [ "$compat_link" != session ]; }; then invalid_option --link fi [ -z "$live_payload$cli_prefix$cli_runtime$cli_major" ] || invalid_option "non-Link options" - [ "$skip_live$delete_prefix$keep_prefix$assume_yes" = 0000 ] || invalid_option "non-Link options" ;; + [ "$skip_live$delete_prefix$keep_prefix$prefix_only$assume_yes" = 00000 ] || invalid_option "non-Link options" ;; link:disable|link:status) [ -z "$live_payload$cli_prefix$cli_runtime$cli_major$cli_link$link_mode_option" ] || invalid_option options - [ "$skip_live$delete_prefix$keep_prefix$assume_yes" = 0000 ] || invalid_option options ;; + [ "$skip_live$delete_prefix$keep_prefix$prefix_only$assume_yes" = 00000 ] || invalid_option options ;; uninstall:) [ -z "$live_payload$cli_major$cli_link$link_mode_option" ] || invalid_option "non-uninstall options" [ "$skip_live" -eq 0 ] || invalid_option --skip-live-install ;; esac +# The step list is the renderer's: the action key selects it and the .run +# header, when present, has already drawn the first step. +case "$command_name:$subcommand" in + install:) ui_action=install ;; + update:) ui_action=update ;; + runtime:install) ui_action=runtime ;; + prefix:create) ui_action=prefix_create ;; + prefix:update) ui_action=prefix_update ;; + prefix:repair-live11) ui_action=prefix_repair ;; + link:enable) ui_action=link_enable ;; + link:disable) ui_action=link_disable ;; + link:status) ui_action=link_status ;; + uninstall:) ui_action=uninstall ;; + *) ui_action=install ;; +esac +[ "$dry_run" -eq 0 ] || ui_action=plan +ABLETON_UI_ACTION="$ui_action" +export ABLETON_UI_ACTION + if [ -n "$cli_prefix" ]; then ABLETON_WINEPREFIX="$cli_prefix"; export ABLETON_WINEPREFIX; fi if [ -n "$cli_runtime" ]; then ABLETON_WINE_ROOT="$cli_runtime"; export ABLETON_WINE_ROOT; fi if [ -n "$cli_major" ]; then ABLETON_LIVE_VERSION="$cli_major"; export ABLETON_LIVE_VERSION; fi +case "$command_name:$subcommand" in + install:|update:|runtime:install|prefix:create|prefix:update|prefix:repair-live11|\ + link:enable|link:disable) + ABLETON_SIMPLE_PROJECT_FILES=1 + export ABLETON_SIMPLE_PROJECT_FILES ;; +esac if [ "$dry_run" -eq 1 ]; then ABLETON_CONFIG_LAYOUT_ROOTS=none else @@ -275,41 +394,17 @@ else install:|update:|prefix:create|prefix:update) ABLETON_CONFIG_LAYOUT_ROOTS='runtime prefix state' ;; prefix:repair-live11) ABLETON_CONFIG_LAYOUT_ROOTS='prefix state' ;; - link:enable|link:disable) ABLETON_CONFIG_LAYOUT_ROOTS='data config state' ;; + link:enable|link:disable) ABLETON_CONFIG_LAYOUT_ROOTS=none ;; link:status) ABLETON_CONFIG_LAYOUT_ROOTS=none ;; uninstall:) ABLETON_CONFIG_LAYOUT_ROOTS='runtime prefix' ;; *) ABLETON_CONFIG_LAYOUT_ROOTS='runtime prefix state' ;; esac fi export ABLETON_CONFIG_LAYOUT_ROOTS -ableton_config_init repair - -# PR #182 briefly owned a configured custom Link binary. Only a narrowly -# proven install of that release is migrated to the canonical managed path. -if [ "$command_name" = install ] || [ "$command_name" = update ] \ - || [ "$command_name:$subcommand" = link:enable ] \ - || [ "$command_name:$subcommand" = link:disable ]; then - if [ "$ABLETON_LINKD" != "$ABLETON_DATA_HOME/ableton-linkd" ] \ - && ableton_pr182_custom_link_recorded "$ABLETON_LINKD"; then - ABLETON_PR182_CUSTOM_LINKD="$ABLETON_LINKD" - ABLETON_LINKD="$ABLETON_DATA_HOME/ableton-linkd" - export ABLETON_PR182_CUSTOM_LINKD ABLETON_LINKD - ableton_config_snapshot_capture - fi -fi - -prior_link="$ABLETON_LINK_MODE" -desired_link="$cli_link" -case "$command_name" in - install) [ -n "$desired_link" ] || desired_link=session ;; - update) [ -n "$desired_link" ] || desired_link=keep ;; - link) - case "$subcommand" in enable) desired_link="${link_mode_option:-session}" ;; disable) desired_link=off ;; esac ;; -esac -[ "$desired_link" != keep ] || desired_link="$prior_link" -[ -n "$desired_link" ] || desired_link="$prior_link" -case "$desired_link" in off|session|always|'') ;; *) echo "!! No saved Ableton Link setting is available; choose --link=off|session|always" >&2; exit 2 ;; esac +# The Live installer file is chosen on the trunk, before the first step, as +# the template shows. One candidate is used as it is; several are listed +# newest first with the first as the timed default. resolve_payload() { [ "$command_name" = install ] || return 0 @@ -319,35 +414,140 @@ resolve_payload() live_payload="$(readlink -f -- "$live_payload")" return 0 fi - if [ ! -t 0 ]; then - echo "!! noninteractive install requires --live-installer FILE or --skip-live-install" >&2 - return 2 - fi + local media="${ABLETON_INSTALLER_MEDIA_DIR:-$PWD}" shown f base i=1 answer seconds local -a found=() - local f base answer="" - for f in "${ABLETON_INSTALLER_MEDIA_DIR:-$PWD}"/*; do + for f in "$media"/*; do [ -f "$f" ] || continue base="$(basename "$f" | tr '[:upper:]' '[:lower:]')" case "$base" in ableton_live*.zip|*ableton*.exe|*live*.exe) found+=("$f") ;; esac done [ "${#found[@]}" -gt 0 ] || { - echo "!! no Live installer payload found; rerun with --live-installer FILE or --skip-live-install" >&2 + echo "!! $(ui_text c_none "$media") Rerun with --live-installer FILE or --skip-live-install." >&2 return 2 } + mapfile -t found < <(printf '%s\n' "${found[@]}" | sort -rV) + shown="$media" + # shellcheck disable=SC2088 # display form, not a shell expansion + case "$shown" in "$HOME"/*) shown="~/${shown#"$HOME"/}" ;; "$HOME") shown='~' ;; esac + ui_blank if [ "${#found[@]}" -eq 1 ]; then + ui_heading h_found_at + ui_note c_dir "$shown" + ui_menu_option c_item 1 "$(basename "${found[0]}")" live_payload="${found[0]}" else - printf 'Found multiple Live installers:\n' >&2 - local i=1 - for f in "${found[@]}"; do printf ' %s) %s\n' "$i" "$f" >&2; i=$((i+1)); done - printf 'Choose one [1-%s] (times out after 120 seconds): ' "${#found[@]}" >&2 - read -r -t 120 answer || answer="" - case "$answer" in ''|*[!0-9]*) echo "!! no valid payload selected" >&2; return 2 ;; esac - [ "$answer" -ge 1 ] && [ "$answer" -le "${#found[@]}" ] || { echo "!! invalid selection" >&2; return 2; } + ui_heading h_found_at + ui_note c_dir "$shown" + ui_blank + ui_heading h_candidates + ui_blank + for f in "${found[@]}"; do ui_menu_option c_item "$i" "$(basename "$f")"; i=$((i + 1)); done + ui_blank + ui__timeout; seconds="$UI_R" + ui_ask c_prompt c_hint "$seconds" + answer="${UI_ANSWER:-1}" + case "$answer" in *[!0-9]*) echo "!! $(ui_text c_invalid)" >&2; return 2 ;; esac + [ "$answer" -ge 1 ] && [ "$answer" -le "${#found[@]}" ] || { echo "!! $(ui_text c_invalid)" >&2; return 2; } live_payload="${found[$((answer-1))]}" fi + ui_blank live_payload="$(readlink -f -- "$live_payload")" } +resolve_payload + +ui_step_begin s_validate +# Until the transaction handlers take over, an early exit still closes the +# open step as Failed. +trap 'ui_cleanup $?' EXIT +if [ "$command_name" = uninstall ]; then + ableton_require_home + uninstall_config_home="${ABLETON_CONFIG_HOME:-${XDG_CONFIG_HOME:-$HOME/.config}/ableton-wine}" + uninstall_config_file="${ABLETON_CONFIG_FILE:-$uninstall_config_home/config}" + if ! ableton_managed_config_valid "$uninstall_config_file" >/dev/null 2>&1; then + : "${ABLETON_WINE_ROOT:=$HOME/.local/opt/$ABLETON_RUNTIME_NAME}" + : "${ABLETON_WINEPREFIX:=$HOME/.wine-ableton}" + export ABLETON_WINE_ROOT ABLETON_WINEPREFIX + fi +fi +ableton_config_init repair + +prior_link="$ABLETON_LINK_MODE" +desired_link="$cli_link" +case "$command_name" in + install) [ -n "$desired_link" ] || desired_link=session ;; + update) [ -n "$desired_link" ] || desired_link=keep ;; + link) + case "$subcommand" in enable) desired_link="${link_mode_option:-session}" ;; disable) desired_link=off ;; esac ;; +esac +[ "$desired_link" != keep ] || desired_link="$prior_link" +[ -n "$desired_link" ] || desired_link="$prior_link" +case "$desired_link" in off|session|always|'') ;; *) echo "!! No saved Ableton Link setting is available; choose --link=off|session|always" >&2; exit 2 ;; esac + +preferences_path="${XDG_CONFIG_HOME:-$HOME/.config}/ableton-wine/preferences" +pipeasio_config_path="${XDG_CONFIG_HOME:-$HOME/.config}/pipeasio/config.ini" +preferences_requested=0 +[ "$shortcuts_seen$dpi_seen$audio_threads_seen$rt_seen$power_seen" = 00000 ] \ + || preferences_requested=1 +preferences_token=absent +pipeasio_token=absent +merged_shortcuts=take +merged_dpi=auto +merged_audio_threads=auto +merged_rt=auto +merged_power=performance +if [ "$command_name" = install ] || [ "$command_name" = update ]; then + preferences_token="$(ableton_preferences_object_token "$preferences_path")" + pipeasio_token="$(ableton_preferences_object_token "$pipeasio_config_path")" + merged_preferences="$(ableton_preferences_merge "$preferences_path" \ + "$cli_shortcuts" "$cli_dpi" "$cli_audio_threads" "$cli_rt" "$cli_power")" \ + || { echo "!! persistent pre-flight settings are invalid" >&2; exit 2; } + IFS='|' read -r merged_shortcuts merged_dpi merged_audio_threads merged_rt merged_power \ + <<< "$merged_preferences" +fi + +# A coordinated first seed is part of prefix construction. Existing PipeASIO +# topology is never touched until after the core generation is known stable. +if [ "$command_name" = install ] || [ "$command_name" = update ]; then + if [ "$pipeasio_token" = absent ]; then + ABLETON_PIPEASIO_BUFFER_SEED="${cli_audio_buffer:-128}" + export ABLETON_PIPEASIO_BUFFER_SEED + fi + if [ "$dpi_seen" -eq 1 ]; then + ABLETON_DPI_MODE="$cli_dpi" + export ABLETON_DPI_MODE + fi +fi + +describe_preflight_plan() +{ + [ "$audio_buffer_seen" -eq 0 ] \ + || printf 'Audio buffer: %s frames\n' "$cli_audio_buffer" + case "$cli_shortcuts" in + take) printf 'Keyboard shortcuts: Assign to Live\n' ;; + preserve) printf 'Keyboard shortcuts: Preserve desktop shortcuts\n' ;; + esac + case "$cli_dpi" in + auto) printf 'Display scaling: Automatic\n' ;; + 100) printf 'Display scaling: 100%%\n' ;; + fractional) printf 'Display scaling: Fractional\n' ;; + preserve) printf 'Display scaling: Preserve\n' ;; + esac + case "$cli_audio_threads" in + auto) printf 'Audio workers: Automatic\n' ;; + off) printf 'Audio workers: Let Live decide\n' ;; + '') ;; + *) printf 'Audio workers: %s\n' "$cli_audio_threads" ;; + esac + case "$cli_rt" in + auto) printf 'Real-time scheduling: Automatic\n' ;; + off) printf 'Real-time scheduling: Normal\n' ;; + esac + case "$cli_power" in + performance) printf 'Power profile: Performance\n' ;; + balanced) printf 'Power profile: Balanced\n' ;; + off) printf "Power profile: Don't change\n" ;; + esac +} payload_major() { @@ -411,7 +611,6 @@ validate_payload_major() fi } -resolve_payload validate_payload_major host_preflight() @@ -461,6 +660,9 @@ esac # Plans, help, extraction transport, Link operations, and uninstall remain # available without a running PipeWire daemon. if [ "$dry_run" -eq 0 ]; then + ABLETON_PIPEWIRE_PREFLIGHT_CACHE=1 + ABLETON_PIPEWIRE_PREFLIGHT_DONE=0 + export ABLETON_PIPEWIRE_PREFLIGHT_CACHE ABLETON_PIPEWIRE_PREFLIGHT_DONE pipewire_probe="" case "$command_name:$subcommand" in install:|update:|runtime:install) @@ -472,7 +674,14 @@ if [ "$dry_run" -eq 0 ]; then break done ;; prefix:create|prefix:update) - pipewire_probe="$ABLETON_WINE_ROOT/bin/pipewire-version-probe" ;; + pipewire_probe="$ABLETON_WINE_ROOT/bin/pipewire-version-probe" + for pipewire_probe_candidate in \ + "$ABLETON_WINE_ROOT/bin/pipewire-version-probe" \ + "$root/bin/pipewire-version-probe"; do + [ -x "$pipewire_probe_candidate" ] || continue + pipewire_probe="$pipewire_probe_candidate" + break + done ;; esac if [ -n "$pipewire_probe" ]; then ableton_pipewire_preflight "$pipewire_probe" "changing PipeASIO" @@ -482,6 +691,8 @@ fi install_args=() [ "$assume_yes" -eq 0 ] || install_args+=(--yes) [ "$dry_run" -eq 0 ] || install_args+=(--dry-run) +ABLETON_PROJECT_ASSUME_YES="$assume_yes" +export ABLETON_PROJECT_ASSUME_YES components=() if [ "$command_name" = install ] || [ "$command_name" = update ]; then # Runtime and prefix/registry state are the core transaction. Desktop and @@ -489,63 +700,126 @@ if [ "$command_name" = install ] || [ "$command_name" = update ]; then components=(--runtime-only) fi +ABLETON_INSTALLER_CONFIG_KEPT=0 +write_installer_config() +{ + local tmp="" failures_before="$ABLETON_OPTIONAL_FILE_FAILURES" + local kept_before="$ABLETON_OPTIONAL_FILES_KEPT" + tmp="$(mktemp "${TMPDIR:-/tmp}/ableton-config.XXXXXX")" || return 1 + if ! ableton_render_config > "$tmp"; then + rm -f -- "$tmp" 2>/dev/null || true + return 1 + fi + ableton_install_project_file 600 "$tmp" "$ABLETON_CONFIG_FILE" + rm -f -- "$tmp" 2>/dev/null || true + [ "$ABLETON_OPTIONAL_FILES_KEPT" -eq "$kept_before" ] \ + || ABLETON_INSTALLER_CONFIG_KEPT=1 + [ "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 0 ] \ + && [ "$ABLETON_OPTIONAL_FILE_FAILURES" -eq "$failures_before" ] +} + case "$command_name:$subcommand" in uninstall:) args=() [ "$delete_prefix" -eq 0 ] || args+=(--delete-prefix) [ "$keep_prefix" -eq 0 ] || args+=(--keep-prefix) + [ "$prefix_only" -eq 0 ] || args+=(--prefix-only) [ "$assume_yes" -eq 0 ] || args+=(--yes) [ "$dry_run" -eq 0 ] || args+=(--dry-run) + ui_step_end ok exec "$here/uninstall.sh" "${args[@]}" ;; link:status) - exec "$here/setup-link.sh" status ;; + ui_step_end ok + ui_step_begin s_link_status + "$here/setup-link.sh" status + ui_step_end ok + exit ;; runtime:install) - if [ "$dry_run" -eq 1 ]; then "$here/install.sh" --runtime-only --dry-run; exit; fi ;; + if [ "$dry_run" -eq 1 ]; then + ui_step_end ok + ui_step_begin s_plan + "$here/install.sh" --runtime-only --dry-run + ui_step_end ok + exit + fi ;; prefix:create) "$here/setup-prefix.sh" --validate if [ "$dry_run" -eq 1 ]; then - printf 'PLAN: create prefix %s using runtime %s\n' "$ABLETON_WINEPREFIX" "$ABLETON_WINE_ROOT" + ui_step_end ok + ui_step_begin s_plan + ui_status d_plan_prefix_create "$ABLETON_WINEPREFIX" "$ABLETON_WINE_ROOT" + ui_step_end ok exit fi ;; prefix:update) "$here/setup-prefix.sh" --refresh --validate - if [ "$dry_run" -eq 1 ]; then printf 'PLAN: update the Ableton Wine prefix at %s\n' "$ABLETON_WINEPREFIX"; exit; fi ;; + if [ "$dry_run" -eq 1 ]; then + ui_step_end ok + ui_step_begin s_plan + ui_status d_plan_prefix_update "$ABLETON_WINEPREFIX" + ui_step_end ok + exit + fi ;; prefix:repair-live11) if [ "$dry_run" -eq 1 ]; then - printf 'PLAN: move aside stale Live 11 Max preferences in %s\n' "$ABLETON_WINEPREFIX" + ui_step_end ok + ui_step_begin s_plan + ui_status d_plan_repair_live11 "$ABLETON_WINEPREFIX" + ui_step_end ok exit fi # setup-prefix owns the lock and performs only the idempotent preference # move for this mode. It deliberately needs neither Wine nor PipeWire. - exec "$here/setup-prefix.sh" --post-first-run ;; + ui_step_end ok + ui_step_begin s_prefix_repair + "$here/setup-prefix.sh" --post-first-run + ui_step_end ok + exit ;; link:enable) if [ "$dry_run" -eq 1 ]; then + ui_step_end ok + ui_step_begin s_plan "$here/install.sh" --link-assets-only --dry-run "$here/setup-link.sh" plan-enable "--mode=$desired_link" + ui_step_end ok exit fi ;; link:disable) - if [ "$dry_run" -eq 1 ]; then "$here/setup-link.sh" plan-disable; exit; fi ;; + if [ "$dry_run" -eq 1 ]; then + ui_step_end ok + ui_step_begin s_plan + "$here/setup-link.sh" plan-disable + ui_step_end ok + exit + fi ;; install:|update:) prefix_validate=() [ "$command_name" != update ] || prefix_validate+=(--refresh) ABLETON_RUNTIME_PENDING=1 "$here/setup-prefix.sh" "${prefix_validate[@]}" --validate if [ "$dry_run" -eq 1 ]; then + ui_step_end ok + ui_step_begin s_plan "$here/install.sh" "${components[@]}" --dry-run "$here/install.sh" --integration-only --dry-run if [ "$desired_link" != off ]; then "$here/install.sh" --link-assets-only --dry-run fi - printf ' %s the Ableton Wine prefix: %s\n' "$([ "$command_name" = update ] && echo Update || echo Create)" "$ABLETON_WINEPREFIX" - printf ' configure PipeASIO: %s/pipeasio/config.ini\n' "${XDG_CONFIG_HOME:-$HOME/.config}" - [ -z "$live_payload" ] || printf ' run the Live %s installer: %s\n' "$ABLETON_LIVE_VERSION" "$live_payload" - printf ' save installer settings: %s\n' "$ABLETON_CONFIG_FILE" + if [ "$command_name" = update ]; then + ui_status d_plan_prefix_update_line "$ABLETON_WINEPREFIX" + else + ui_status d_plan_prefix_create_line "$ABLETON_WINEPREFIX" + fi + describe_preflight_plan + ui_status d_plan_pipeasio_config "${XDG_CONFIG_HOME:-$HOME/.config}" + [ -z "$live_payload" ] || ui_status d_plan_run_live_installer "$ABLETON_LIVE_VERSION" "$live_payload" + ui_status d_plan_save_settings "$ABLETON_CONFIG_FILE" if [ "$desired_link" = off ]; then "$here/setup-link.sh" plan-disable else "$here/setup-link.sh" plan-enable "--mode=$desired_link" fi - printf ' set Ableton Link mode: %s\n' "$desired_link" + ui_status d_plan_link_mode "$desired_link" + ui_step_end ok exit fi ;; esac @@ -554,11 +828,97 @@ esac # mutation. Child scripts inherit the locked directory descriptor. ableton_install_lock_acquire +run_direct_link_command() +{ + local action_status=0 asset_status=0 files_ready=1 settings_ready=1 + ABLETON_SIMPLE_PROJECT_FILES=1 + export ABLETON_SIMPLE_PROJECT_FILES + unset ABLETON_CONFIG_SNAPSHOT_PATH ABLETON_CONFIG_SNAPSHOT_TOKEN \ + ABLETON_CONFIG_SNAPSHOT_VALUES ABLETON_TRANSACTION_DIR + ableton_prepare_project_backup_dir || true + + if [ "$subcommand" = enable ]; then + ABLETON_INTERNAL_OPTIONAL_STATUS=1 \ + "$here/install.sh" --link-assets-only "${install_args[@]}" \ + || asset_status=$? + case "$asset_status" in + 0) ;; + 3) + files_ready=0 + echo "!! Some Ableton Link files could not be updated; continuing with the files that are available." >&2 \ + || true ;; + 4) + echo "!! Ableton Link setup was cancelled; completed file copies were kept." >&2 || true + return 4 ;; + *) + files_ready=0 + echo "!! Ableton Link support files could not be fully updated; continuing with the files that are available." >&2 \ + || true ;; + esac + ABLETON_LINK_MODE="$desired_link" + export ABLETON_LINK_MODE + ABLETON_LINK_COORDINATED=1 ABLETON_LINK_FILES_MAPPED=1 \ + "$here/setup-link.sh" enable "--mode=$desired_link" \ + || action_status=$? + if [ "$action_status" -ne 0 ]; then + ABLETON_LINK_MODE="$prior_link" + export ABLETON_LINK_MODE + echo "!! Ableton Link could not be enabled; the completed file copies were kept." >&2 \ + || true + fi + else + ABLETON_LINK_COORDINATED=1 "$here/setup-link.sh" disable \ + || action_status=$? + if [ "$action_status" -ne 0 ]; then + ABLETON_LINK_MODE="$prior_link" + export ABLETON_LINK_MODE + echo "!! Ableton Link could not be disabled." >&2 || true + else + ABLETON_LINK_MODE=off + export ABLETON_LINK_MODE + fi + fi + + if ! write_installer_config; then + settings_ready=0 + echo "!! Ableton Link changed, but the installer could not save that setting. Run the same command again to retry." >&2 \ + || true + fi + [ "$action_status" -eq 0 ] || return "$action_status" + case "$subcommand" in + enable) ui_item_begin d_link_enabled "$ABLETON_LINK_MODE" ;; + disable) ui_item_begin d_link_off ;; + esac + [ "$files_ready" -eq 1 ] || ui_warn d_link_files_retry + [ "$ABLETON_INSTALLER_CONFIG_KEPT" -eq 0 ] || ui_warn d_settings_kept + [ "$settings_ready" -eq 1 ] || ui_warn d_settings_retry + ui_item_end ok +} + +case "$command_name:$subcommand" in + link:enable|link:disable) + ui_step_end ok + ui_step_begin s_link + run_direct_link_command + ui_step_end ok + ui_step_begin s_finish_link + ui_item_begin d_sum_command_completed "$command_name" " $subcommand" + ui_item_end ok + ui_step_end ok + exit ;; +esac + if [ "$command_name:$subcommand" = runtime:install ]; then - transaction="$(mktemp -d "${TMPDIR:-/tmp}/ableton-runtime-install.XXXXXX")" + transaction="$(mktemp -d "${TMPDIR:-/tmp}/ableton-runtime-install.XXXXXX")" || { + echo "!! The installer could not create temporary runtime recovery files under ${TMPDIR:-/tmp}." >&2 + exit 1 + } else ableton_prepare_transactions_dir - transaction="$(mktemp -d "$ABLETON_STATE_HOME/transactions/installer.XXXXXX")" + transaction="$(mktemp -d "$ABLETON_STATE_HOME/transactions/installer.XXXXXX")" || { + echo "!! The installer could not create recovery files under $ABLETON_STATE_HOME/transactions." >&2 + exit 1 + } fi # ShellCheck does not follow function names stored in traps. # shellcheck disable=SC2329 @@ -566,6 +926,7 @@ cleanup_unstarted_transaction() { local rc=$? trap - EXIT + ui_cleanup "$rc" if [ "$rc" -ne 0 ] && ! rm -rf -- "$transaction"; then echo "!! The installer stopped before making changes, and temporary files remain at $transaction." >&2 fi @@ -574,62 +935,53 @@ cleanup_unstarted_transaction() trap cleanup_unstarted_transaction EXIT ABLETON_TRANSACTION_DIR="$transaction" export ABLETON_TRANSACTION_DIR -config_backup="$transaction/config.before" -config_existed=0 -config_rollback_state=absent -if [ -e "$ABLETON_CONFIG_FILE" ] || [ -L "$ABLETON_CONFIG_FILE" ]; then - config_rollback_state=unchanged - if [ -f "$ABLETON_CONFIG_FILE" ] && [ ! -L "$ABLETON_CONFIG_FILE" ] \ - && [ -n "$(ableton_manifest_digest "$ABLETON_CONFIG_FILE" 2>/dev/null || true)" ] \ - && cp -a -- "$ABLETON_CONFIG_FILE" "$config_backup"; then - config_existed=1 - config_rollback_state=present - fi -fi -pipeasio_config_parent="$(ableton_realpath_m \ - "${XDG_CONFIG_HOME:-$HOME/.config}/pipeasio" 2>/dev/null || true)" -pipeasio_config="${XDG_CONFIG_HOME:-$HOME/.config}/pipeasio/config.ini" -pipeasio_config_resolved=0 -if [ -n "$pipeasio_config_parent" ]; then - pipeasio_config="$pipeasio_config_parent/config.ini" - pipeasio_config_resolved=1 -fi -pipeasio_config_backup="$transaction/pipeasio-config.before" -pipeasio_config_existed=0 -pipeasio_rollback_state=absent -if [ "$pipeasio_config_resolved" -ne 1 ]; then - # Saved settings are an optional rollback convenience. A path that cannot - # be resolved leaves the current PipeASIO settings untouched. - pipeasio_rollback_state=unchanged -elif [ -e "$pipeasio_config" ] || [ -L "$pipeasio_config" ]; then - pipeasio_rollback_state=unchanged - if [ -f "$pipeasio_config" ] && [ ! -L "$pipeasio_config" ] \ - && [ -n "$(ableton_manifest_digest "$pipeasio_config" 2>/dev/null || true)" ] \ - && cp -a -- "$pipeasio_config" "$pipeasio_config_backup"; then - pipeasio_config_existed=1 - pipeasio_rollback_state=present - fi -fi -panel_integration_existed=0 -ownership_manifest="$ABLETON_STATE_HOME/install-manifest.tsv" -if [ -r "$ownership_manifest" ] && awk -F '\t' -v b="$ABLETON_BIN_HOME/pipeasio-settings" \ - -v d="${XDG_DATA_HOME:-$HOME/.local/share}/applications/pipeasio-settings.desktop" \ - -v i="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps/pipeasio.svg" \ - '$2==b || $2==d || $2==i { found=1 } END { exit !found }' "$ownership_manifest"; then - panel_integration_existed=1 -fi transaction_complete=0 rollback_log="$transaction/rollback.log" rollback_sink="$rollback_log" cleanup_log="$transaction/cleanup.log" -link_transaction=0 integration_ready=1 link_ready=1 settings_ready=1 cleanup_ready=1 +optional_cancelled=0 link_resume_command="" live_unpack="" +commit_preflight_settings() +{ + local current_buffer="" + if [ "$preferences_requested" -eq 1 ]; then + if ! ableton_preferences_write "$preferences_path" "$preferences_token" \ + "$merged_shortcuts" "$merged_dpi" "$merged_audio_threads" \ + "$merged_rt" "$merged_power"; then + settings_ready=0 + if ! _ableton_token_matches "$preferences_path" "$preferences_token"; then + echo "!! Launcher preferences changed while Ableton was being prepared; the newer settings were kept." >&2 || true + elif [ -f "$preferences_path" ] && [ ! -L "$preferences_path" ] \ + && ! ableton_preferences_valid "$preferences_path"; then + echo "!! Launcher preferences are malformed and were not saved; repair or remove the file, then retry." >&2 || true + else + echo "!! Launcher preferences could not be saved; Ableton itself is ready, so rerun the installer to retry settings." >&2 || true + fi + fi + fi + + if [ "$audio_buffer_seen" -eq 1 ]; then + if [ "$pipeasio_token" = absent ]; then + current_buffer="$(ableton_pipeasio_buffer_read "$pipeasio_config_path" 2>/dev/null || true)" + if [ "$current_buffer" != "$cli_audio_buffer" ]; then + settings_ready=0 + echo "!! The audio buffer configuration changed while Ableton was being prepared; the newer file was kept." >&2 || true + fi + elif ! ableton_pipeasio_buffer_write "$pipeasio_config_path" \ + "$pipeasio_token" "$cli_audio_buffer"; then + settings_ready=0 + echo "!! The audio buffer configuration changed while Ableton was being prepared; the newer file was kept." >&2 || true + fi + fi + return 0 +} + format_link_resume_command() { local installer_path @@ -666,6 +1018,7 @@ rollback_all() local rc=$? restore_error="" restoration_complete=yes failure_record="$transaction/FAILURE" local rollback_preflight_ok=1 failure_record_written=0 trap - EXIT + ui_cleanup "$rc" # A closed terminal must not stop recovery from running. From this point # every recovery command and status write is checked explicitly. set +e @@ -679,9 +1032,9 @@ rollback_all() rollback_log="" fi rollback_log_step "Recovery started for $command_name${subcommand:+ $subcommand} (exit $rc)" - rollback_log_step "Checking Wine prefix recovery" + rollback_log_step "Checking ableton-linux prefix recovery" if ! "$here/setup-prefix.sh" --preflight-rollback "$transaction" >> "$rollback_sink" 2>&1; then - restore_error="Wine prefix recovery could not be checked" + restore_error="ableton-linux prefix recovery could not be checked" rollback_preflight_ok=0 fi rollback_log_step "Checking Wine runtime recovery" @@ -689,28 +1042,16 @@ rollback_all() restore_error="${restore_error}${restore_error:+; }Wine runtime recovery could not be checked" rollback_preflight_ok=0 fi - [ "$link_transaction" -ne 1 ] || rollback_log_step "Checking Link recovery" - if [ "$link_transaction" -eq 1 ] \ - && ! "$here/setup-link.sh" preflight-rollback "$transaction" >> "$rollback_sink" 2>&1; then - restore_error="${restore_error}${restore_error:+; }Link recovery could not be checked" - rollback_preflight_ok=0 - fi - [ "$rollback_preflight_ok" -ne 1 ] || rollback_log_step "Restoring Wine prefix" + [ "$rollback_preflight_ok" -ne 1 ] || rollback_log_step "Restoring ableton-linux prefix" if [ "$rollback_preflight_ok" -eq 1 ] \ && ! "$here/setup-prefix.sh" --rollback "$transaction" >> "$rollback_sink" 2>&1; then - restore_error="Wine prefix could not be restored" + restore_error="ableton-linux prefix could not be restored" fi [ "$rollback_preflight_ok" -ne 1 ] || rollback_log_step "Restoring Wine runtime" if [ "$rollback_preflight_ok" -eq 1 ] \ && ! "$here/install.sh" --rollback "$transaction" >> "$rollback_sink" 2>&1; then restore_error="${restore_error}${restore_error:+; }Wine runtime could not be restored" fi - if [ "$rollback_preflight_ok" -eq 1 ] && [ "$link_transaction" -eq 1 ]; then - rollback_log_step "Restoring Link settings" - if ! "$here/setup-link.sh" rollback "$transaction" >> "$rollback_sink" 2>&1; then - restore_error="${restore_error}${restore_error:+; }Link settings could not be restored" - fi - fi if [ "$rollback_preflight_ok" -eq 1 ] && ! cleanup_live_unpack; then rollback_log_step "temporary Live installer files were retained at $live_unpack" fi @@ -749,49 +1090,37 @@ trap rollback_all EXIT trap 'exit 130' INT trap 'exit 143' TERM -case "$command_name:$subcommand" in - link:enable|link:disable) - "$here/setup-link.sh" snapshot "$transaction" - link_transaction=1 ;; -esac - ABLETON_LINK_MODE="$desired_link" export ABLETON_LINK_MODE -# Child lifecycle helpers inherit both the lock and this snapshot. Refresh the -# resolved-value half after applying the requested policy; the file itself has -# not changed, so its original token remains the concurrency boundary. -ableton_config_snapshot_capture +ui_step_end ok case "$command_name:$subcommand" in runtime:install) + ui_step_begin s_runtime_install "$here/install.sh" --runtime-only --transaction-dir "$transaction" "${install_args[@]}" ;; prefix:create) + ui_step_begin s_prefix_create "$here/setup-prefix.sh" --transaction-dir "$transaction" ;; prefix:update) + ui_step_begin s_prefix_update "$here/setup-prefix.sh" --refresh --transaction-dir "$transaction" ;; - link:enable) - "$here/install.sh" --link-assets-only --transaction-dir "$transaction" "${install_args[@]}" - ABLETON_LINK_COORDINATED=1 "$here/setup-link.sh" enable "--mode=$desired_link" ;; - link:disable) - ABLETON_LINK_COORDINATED=1 "$here/setup-link.sh" disable ;; install:|update:) + if [ "$command_name" = update ]; then + ui_step_begin s_runtime_update + else + ui_step_begin s_runtime_install + fi "$here/install.sh" "${components[@]}" --transaction-dir "$transaction" "${install_args[@]}" + ui_step_end ok if [ "$command_name" = update ]; then + ui_step_begin s_prefix_update ABLETON_PREFIX_MANAGED=1 "$here/setup-prefix.sh" --refresh --transaction-dir "$transaction" else + ui_step_begin s_prefix_create ABLETON_PREFIX_MANAGED=1 "$here/setup-prefix.sh" --transaction-dir "$transaction" fi ;; esac -# Direct Link commands save their selected mode themselves. Drop the parent's -# now-stale view of that generated file so later cleanup cannot mistake the -# child's successful write for an installation failure. -case "$command_name:$subcommand" in - link:enable|link:disable) - unset ABLETON_CONFIG_SNAPSHOT_PATH ABLETON_CONFIG_SNAPSHOT_TOKEN \ - ABLETON_CONFIG_SNAPSHOT_VALUES ;; -esac - live11_registry_ready() { ableton_run_bounded 30 env WINEPREFIX="$ABLETON_WINEPREFIX" \ @@ -874,7 +1203,7 @@ install_live_payload() zip_bytes="$(stat -c %s -- "$installer" 2>/dev/null || wc -c < "$installer" 2>/dev/null || echo 0)" zip_fingerprint="$zip_name $zip_bytes" if [ -f "$unpack/.extracted" ] && [ "$(cat "$unpack/.extracted" 2>/dev/null)" = "$zip_fingerprint" ]; then - echo "-- Reusing the extracted Live installer at $unpack" || true + ui_status d_reusing_extracted_installer "$unpack" else live_unpack="$unpack" if cleanup_live_unpack; then @@ -906,12 +1235,15 @@ install_live_payload() esac fi extract_timeout="$(ableton_timeout_value "${ABLETON_PAYLOAD_EXTRACT_TIMEOUT:-900}" ABLETON_PAYLOAD_EXTRACT_TIMEOUT 60 7200)" - echo "-- Extracting the Live installer (up to ${extract_timeout}s)" || true # -o + /dev/null 2>&1; then ableton_run_bounded "$extract_timeout" unzip -oq "$installer" -d "$unpack" /dev/null 2>&1; then ableton_run_bounded "$extract_timeout" bsdtar -xf "$installer" -C "$unpack" /dev/null 2>&1; then ableton_run_bounded "$extract_timeout" python3 -m zipfile -e "$installer" "$unpack" &2; return 1; fi + extract_live_zip() + { + if command -v unzip >/dev/null 2>&1; then ableton_run_bounded "$extract_timeout" unzip -oq "$installer" -d "$unpack" /dev/null 2>&1; then ableton_run_bounded "$extract_timeout" bsdtar -xf "$installer" -C "$unpack" /dev/null 2>&1; then ableton_run_bounded "$extract_timeout" python3 -m zipfile -e "$installer" "$unpack" &2; return 1; fi + } + ui_run d_extract_live_installer "$extract_timeout" -- extract_live_zip || return 1 printf '%s\n' "$zip_fingerprint" > "$unpack/.extracted" 2>/dev/null || true fi live_unpack="$unpack" @@ -974,7 +1306,7 @@ EOF fi rm -f -- "$signature" 2>/dev/null || true timeout_secs="$(ableton_timeout_value "${ABLETON_LIVE_INSTALL_TIMEOUT:-3600}" ABLETON_LIVE_INSTALL_TIMEOUT 60 14400)" - echo "-- Running the Live installer (up to ${timeout_secs}s)" || true + run_live_installer() ( if ! cd "$(dirname "$installer")"; then echo "!! The Live installer directory is no longer available." >&2 @@ -982,7 +1314,8 @@ EOF fi ableton_run_bounded "$timeout_secs" env WINEPREFIX="$ABLETON_WINEPREFIX" \ "$ABLETON_WINE_ROOT/bin/wine" "./$(basename "$installer")" "${flags[@]}" - ) || status=$? + ) + ui_run d_run_live_installer "$timeout_secs" -- run_live_installer || status=$? if [ "$status" -ne 0 ]; then echo "!! Live installer failed or timed out (exit $status)" >&2 return "$status" @@ -1031,15 +1364,13 @@ EOF # The unknown set, not every holder: naming an agent the step just ended # and then saying nothing needs to be done reads as a contradiction. if [ -n "$unknown" ]; then - echo "-- The Live installer finished, but a program is still using its Wine prefix:" \ - || true + ui_item_begin d_prefix_held_unknown while IFS="$(printf '\t')" read -r holder holder_image; do [ -n "$holder" ] || continue - printf ' %s (pid %s)\n' "$holder_image" "$holder" || true + ui_status d_prefix_holder_entry "$holder_image" "$holder" done <<< "$unknown" else - echo "-- The Live installer finished; a background program is still using its Wine prefix." \ - || true + ui_item_begin d_prefix_held_background fi # The question is asked only when a program this project did not install is # holding the prefix. A helper this project installed exits once the prefix @@ -1049,27 +1380,25 @@ EOF # one of those. A prefix a Max may be sharing is not ended without an # answer, so with nobody to ask the install completes and prints the command. if [ -z "$unknown" ] || [ "$assume_yes" -eq 1 ] || [ ! -t 0 ]; then - echo "-- You can leave it running. To stop every program in the prefix instead, run:" \ - || true - printf ' WINEPREFIX=%s %s/bin/wineserver -k\n' \ - "$ABLETON_WINEPREFIX" "$ABLETON_WINE_ROOT" || true + ui_info d_prefix_held_hint + ui_status d_prefix_held_command "$ABLETON_WINEPREFIX" "$ABLETON_WINE_ROOT" + ui_item_end ok else - # Default no, and a timed read that falls back to it: pressing return or + # Leaving the programs running is the default: pressing return or # walking away leaves the prefix as it is. - printf 'End every program in the prefix now? [y/N] ' >&2 || true - read -r -t 60 stop_answer || stop_answer="" + ui_item_end ok + ui_question q_stop_prefix_title l q_stop_prefix_end q_stop_prefix_leave + stop_answer="$UI_ANSWER" case "$stop_answer" in - [yY]*) + e) if ableton_run_bounded 20 env WINEPREFIX="$ABLETON_WINEPREFIX" \ "$ABLETON_WINE_ROOT/bin/wineserver" -k >/dev/null 2>&1; then - echo "-- stopped the programs in the prefix" || true + ui_info d_prefix_programs_stopped else - echo "!! Live is installed, but the programs in its Wine prefix could not be stopped." >&2 \ + echo "!! Live is installed, but the programs in its ableton-linux prefix could not be stopped." >&2 \ || true fi ;; - *) - echo "-- left them running; the next update may ask you to close them" \ - || true ;; + *) ui_info d_prefix_programs_left ;; esac fi elif [ "$wait_status" -ne 0 ]; then @@ -1088,15 +1417,14 @@ EOF } if [ "$command_name" = install ]; then + ui_step_end ok + ui_step_begin s_live install_live_payload fi -persist_runtime_rollback_metadata() +finish_runtime_rollback() { - local record="$transaction/runtime-rollback-path" rollback_path meta new_meta old_meta - local marker marker_tmp parent base target installer_state="$config_rollback_state" - local pipeasio_state="$pipeasio_rollback_state" - local old_moved=0 + local record="$transaction/runtime-rollback-path" rollback_path parent base marker [ -r "$record" ] || return 0 rollback_path="$(sed -n '1p' "$record")" || return 1 parent="$(ableton_realpath_m "$(dirname "$rollback_path")")" || return 1 @@ -1107,113 +1435,24 @@ persist_runtime_rollback_metadata() echo "!! saved runtime rollback path is invalid" >&2 return 1 } - target="$rollback_path/.ableton-linux-runtime" - [ ! -L "$target" ] || { - echo "!! saved runtime ownership marker is unsafe" >&2 + ableton_runtime_marker_valid "$rollback_path" "$ABLETON_RUNTIME_NAME" || { + echo "!! saved runtime rollback has an invalid ownership marker" >&2 return 1 } - if [ -e "$target" ]; then - ableton_runtime_marker_valid "$rollback_path" "$ABLETON_RUNTIME_NAME" || { - echo "!! saved runtime ownership marker is invalid" >&2 - return 1 - } - else - marker_tmp="$(mktemp "$rollback_path/.runtime-marker.XXXXXX")" || return 1 - if ! printf 'format=1\nname=%s\n' "$ABLETON_RUNTIME_NAME" > "$marker_tmp" \ - || ! chmod 600 "$marker_tmp" \ - || ! mv -T -n -- "$marker_tmp" "$target" \ - || [ -e "$marker_tmp" ] \ - || ! ableton_runtime_marker_valid "$rollback_path" "$ABLETON_RUNTIME_NAME"; then - rm -f -- "$marker_tmp" - echo "!! could not mark the saved runtime rollback" >&2 - return 1 - fi - fi - meta="$rollback_path/.ableton-linux-rollback" - if [ -e "$meta" ] || [ -L "$meta" ]; then - [ ! -L "$meta" ] && [ -d "$meta" ] || { - echo "!! saved runtime rollback metadata directory is unsafe" >&2 - return 1 - } - fi - new_meta="$(mktemp -d "$rollback_path/.ableton-linux-rollback.new.XXXXXX")" || return 1 - old_meta="$rollback_path/.ableton-linux-rollback.previous.$$" - if [ -e "$old_meta" ] || [ -L "$old_meta" ]; then - rmdir -- "$new_meta" 2>/dev/null || true - echo "!! saved runtime has a conflicting rollback metadata path" >&2 - return 1 - fi - if [ "$config_existed" -eq 1 ]; then - if ! cp -a -- "$config_backup" "$new_meta/installer-config"; then - rm -rf -- "$new_meta" - echo "!! could not save the previous installer configuration" >&2 - return 1 - fi - installer_state=present - fi - if [ "$pipeasio_config_existed" -eq 1 ]; then - if ! cp -a -- "$pipeasio_config_backup" "$new_meta/pipeasio-config.ini"; then - rm -rf -- "$new_meta" - echo "!! could not save the previous PipeASIO configuration" >&2 - return 1 - fi - pipeasio_state=present - fi - if ! { - printf 'format=1\n' - printf 'runtime_root=%s\n' "$ABLETON_WINE_ROOT" - printf 'prefix=%s\n' "$ABLETON_WINEPREFIX" - printf 'installer_config_path=%s\n' "$ABLETON_CONFIG_FILE" - printf 'installer_config_state=%s\n' "$installer_state" - printf 'pipeasio_config_path=%s\n' "$pipeasio_config" - printf 'pipeasio_config_state=%s\n' "$pipeasio_state" - printf 'panel_integration=%s\n' "$panel_integration_existed" - } > "$new_meta/metadata" || ! chmod 600 "$new_meta/metadata"; then - rm -rf -- "$new_meta" - echo "!! could not write saved runtime rollback metadata" >&2 - return 1 - fi - - # Publish the complete directory as a unit. Keep any prior generation in - # place unless both short same-filesystem renames succeed. - trap '' INT TERM - if [ -d "$meta" ]; then - if ! mv -T -n -- "$meta" "$old_meta" \ - || [ -e "$meta" ] || [ ! -d "$old_meta" ]; then - trap 'exit 130' INT; trap 'exit 143' TERM - rm -rf -- "$new_meta" - echo "!! could not stage existing rollback metadata" >&2 - return 1 - fi - old_moved=1 - fi - if ! mv -T -n -- "$new_meta" "$meta" \ - || [ -e "$new_meta" ] || [ ! -d "$meta" ]; then - if [ "$old_moved" -eq 1 ] && [ ! -e "$meta" ] && [ ! -L "$meta" ]; then - mv -T -n -- "$old_meta" "$meta" >/dev/null 2>&1 || true - fi - trap 'exit 130' INT; trap 'exit 143' TERM - [ ! -e "$new_meta" ] || rm -rf -- "$new_meta" - echo "!! could not publish saved runtime rollback metadata" >&2 - return 1 - fi - trap 'exit 130' INT - trap 'exit 143' TERM - if [ "$old_moved" -eq 1 ]; then - rm -rf -- "$old_meta" || return 1 - fi marker="$rollback_path/.ableton-linux-rollback-incomplete" rm -f -- "$marker" || return 1 } +remove_prefix_recovery_files() +{ + "$here/setup-prefix.sh" --commit "$transaction" >> "$cleanup_log" 2>&1 +} + # Validate the core rollback records before discarding any of them. These # records cover only the runtime and prefix; generated desktop and Link files # are repaired after this point and cannot invalidate the core install. "$here/install.sh" --preflight-commit "$transaction" "$here/setup-prefix.sh" --preflight-commit "$transaction" -if [ "$link_transaction" -eq 1 ]; then - "$here/setup-link.sh" preflight-commit "$transaction" -fi # The runtime, prefix, Live installation, and registry are valid. Nothing # below is allowed to roll them back: the remaining work either retires old @@ -1224,6 +1463,7 @@ transaction_complete=1 # when a terminal write or another advisory operation fails; the EXIT guard # still reports one retry warning for any uncaught final status. set +e +commit_preflight_settings cleanup_status=0 component_commit_ok=1 core_marker_ready=1 @@ -1241,13 +1481,19 @@ if ! "$here/install.sh" --commit "$transaction" >> "$cleanup_log" 2>&1; then cleanup_status=1 fi if [ "$component_commit_ok" -eq 1 ] \ - && ! persist_runtime_rollback_metadata >> "$cleanup_log" 2>&1; then + && ! finish_runtime_rollback >> "$cleanup_log" 2>&1; then + cleanup_status=1 +fi +if ! ui_run d_remove_prefix_recovery -- remove_prefix_recovery_files; then cleanup_status=1 fi -if [ "$link_transaction" -eq 1 ]; then - "$here/setup-link.sh" commit "$transaction" >> "$cleanup_log" 2>&1 || cleanup_status=1 +if [ -n "${ABLETON_INSTALLER_EVENT_LOG:-}" ] && [ "$cleanup_log" != /dev/null ]; then + while IFS= read -r cleanup_line || [ -n "$cleanup_line" ]; do + cleanup_level=INFO + case "$cleanup_line" in '!! '*) cleanup_level=ERR ;; esac + ui__event "$cleanup_level" cleanup "$cleanup_line" + done < "$cleanup_log" fi -"$here/setup-prefix.sh" --commit "$transaction" >> "$cleanup_log" 2>&1 || cleanup_status=1 if ! rm -f -- "$transaction/active"; then cleanup_status=1 fi @@ -1270,23 +1516,30 @@ if [ "$cleanup_status" -ne 0 ]; then || true fi fi +unset ABLETON_TRANSACTION_DIR -# Desktop integration and Link are deliberately outside the core transaction. -# Their files belong to this project and are replaced authoritatively on every -# run, so a later run is the repair path; failure here never removes Ableton. +# Desktop integration and Link files are deliberately outside the core +# transaction. Each fixed destination is handled once; failures never remove +# Ableton. if [ "$command_name" = install ] || [ "$command_name" = update ]; then if [ "$command_name" = update ]; then core_outcome="Ableton is updated" elif [ -n "$live_payload" ]; then core_outcome="Ableton Live $ABLETON_LIVE_VERSION is installed" else - core_outcome="The Ableton runtime and Wine prefix are ready" + core_outcome="The Ableton runtime and ableton-linux prefix are ready" fi ABLETON_LINK_MODE="$desired_link" - export ABLETON_LINK_MODE + ABLETON_SIMPLE_PROJECT_FILES=1 + export ABLETON_LINK_MODE ABLETON_SIMPLE_PROJECT_FILES + ableton_prepare_project_backup_dir || true + ui_step_end ok + ui_step_begin s_launchers integration_status=0 + integration_components=(--integration-only) + [ "$desired_link" = off ] || integration_components+=(--link-assets-only) ABLETON_INTERNAL_OPTIONAL_STATUS=1 \ - "$here/install.sh" --integration-only "${install_args[@]}" \ + "$here/install.sh" "${integration_components[@]}" "${install_args[@]}" \ || integration_status=$? case "$integration_status" in 0) ;; @@ -1294,25 +1547,40 @@ if [ "$command_name" = install ] || [ "$command_name" = update ]; then integration_ready=0 echo "!! $core_outcome, but some desktop files need another update. Run the installer again to retry them." >&2 \ || true ;; + 4) + optional_cancelled=1 + integration_ready=0 + link_ready=0 + settings_ready=0 + echo "!! $core_outcome; the remaining desktop and Link work was cancelled." >&2 \ + || true ;; *) integration_ready=0 echo "!! $core_outcome, but its desktop shortcuts could not be updated. Run the installer again to repair them." >&2 \ || true ;; esac + ui_step_end ok + ui_step_begin s_link_settings link_resume_command="$(format_link_resume_command "$desired_link")" - if [ "$desired_link" = off ]; then + if [ "$optional_cancelled" -eq 1 ]; then + ABLETON_LINK_MODE="$prior_link" + elif [ "$desired_link" = off ]; then if ABLETON_LINK_COORDINATED=1 "$here/setup-link.sh" disable; then ABLETON_LINK_MODE=off + ui_item_begin d_link_off + ui_item_end ok else link_ready=0 ABLETON_LINK_MODE="$prior_link" echo "!! $core_outcome, but Link could not be turned off completely. Run the installer again to retry." >&2 \ || true fi - elif "$here/install.sh" --link-assets-only "${install_args[@]}" \ - && ABLETON_LINK_COORDINATED=1 "$here/setup-link.sh" enable "--mode=$desired_link"; then + elif ABLETON_LINK_COORDINATED=1 ABLETON_LINK_FILES_MAPPED=1 \ + "$here/setup-link.sh" enable "--mode=$desired_link"; then ABLETON_LINK_MODE="$desired_link" + ui_item_begin d_link_configured_mode "$desired_link" + ui_item_end ok else link_ready=0 ABLETON_LINK_MODE="$prior_link" @@ -1320,72 +1588,79 @@ if [ "$command_name" = install ] || [ "$command_name" = update ]; then || true fi export ABLETON_LINK_MODE - # Link owns its own requested system change. Its generated preference file - # is safe to reload and replace; a stale parent checksum must not add a - # second failure path after Link has already finished. - unset ABLETON_CONFIG_SNAPSHOT_PATH ABLETON_CONFIG_SNAPSHOT_TOKEN \ - ABLETON_CONFIG_SNAPSHOT_VALUES - if ! ableton_write_config; then - settings_ready=0 - echo "!! $core_outcome, but the installer could not save these settings. Run the installer again to retry." >&2 \ - || true + if [ "$optional_cancelled" -eq 0 ]; then + # Link owns its requested system change. Reload its saved preference + # before writing the final installer settings. + unset ABLETON_CONFIG_SNAPSHOT_PATH ABLETON_CONFIG_SNAPSHOT_TOKEN \ + ABLETON_CONFIG_SNAPSHOT_VALUES + ui_item_begin d_settings_ready + if ! write_installer_config; then + settings_ready=0 + echo "!! $core_outcome, but the installer could not save these settings. Run the installer again to retry." >&2 \ + || true + ui_warn d_settings_retry + ui_item_end fail + elif [ "$ABLETON_INSTALLER_CONFIG_KEPT" -eq 1 ]; then + ui_warn d_settings_kept + ui_item_end ok + else + ui_item_end ok + fi fi elif [ "$command_name:$subcommand" = runtime:install ] \ && [ "${ABLETON_CONFIG_REPAIR_NEEDED:-0}" = 1 ]; then - if ! ableton_write_config; then + if ! write_installer_config; then settings_ready=0 echo "!! Wine is ready, but installer settings could not be saved. Run the same command again to retry." >&2 \ || true fi elif [ "$command_name:$subcommand" = prefix:create ] \ || [ "$command_name:$subcommand" = prefix:update ]; then - if ! ableton_write_config; then + if ! write_installer_config; then settings_ready=0 - echo "!! The Wine prefix is ready, but the installer could not save its location. Run the same command again to retry." >&2 \ + echo "!! The ableton-linux prefix is ready, but the installer could not save its location. Run the same command again to retry." >&2 \ || true fi fi +ui_step_end ok case "$command_name:$subcommand" in runtime:install) - echo "OK: the Wine runtime is installed" - printf ' runtime: %s\n' "$ABLETON_WINE_ROOT" ;; + ui_step_begin s_finish_runtime + ui_item_begin d_sum_runtime_installed + ui_status d_sum_runtime_path "$ABLETON_WINE_ROOT" + ui_item_end ok ;; install:|update:) - printf 'OK: %s\n' "$core_outcome" - printf ' runtime: %s\n prefix: %s\n' "$ABLETON_WINE_ROOT" "$ABLETON_WINEPREFIX" - if [ "$integration_ready" -eq 1 ]; then - printf ' desktop shortcuts: ready\n' - else - printf ' desktop shortcuts: retry needed\n' - fi - if [ "$link_ready" -eq 1 ]; then - printf ' Link: %s\n' "$ABLETON_LINK_MODE" - else - printf ' Link: unchanged; setup can be retried\n' - fi - if [ "$settings_ready" -eq 1 ]; then - printf ' saved settings: ready\n' + if [ "$command_name" = update ]; then + ui_step_begin s_finish_update + ui_item_begin d_sum_updated else - printf ' saved settings: retry needed\n' + ui_step_begin s_finish_install + if [ -n "$live_payload" ]; then + ui_item_begin d_sum_live_installed "$ABLETON_LIVE_VERSION" + else + ui_item_begin d_sum_runtime_prefix_ready + fi fi - if [ "$cleanup_ready" -eq 1 ]; then - printf ' old recovery files: removed\n' - else - printf ' old recovery files: remain at %s\n' "$transaction" - fi ;; - prefix:create) - echo "OK: the Wine prefix is ready" - printf ' runtime: %s\n prefix: %s\n' "$ABLETON_WINE_ROOT" "$ABLETON_WINEPREFIX" - [ "$settings_ready" -eq 1 ] || printf ' saved settings: retry needed\n' ;; - prefix:update) - echo "OK: the Wine prefix is updated" - printf ' runtime: %s\n prefix: %s\n' "$ABLETON_WINE_ROOT" "$ABLETON_WINEPREFIX" - [ "$settings_ready" -eq 1 ] || printf ' saved settings: retry needed\n' ;; - link:enable) - printf 'OK: Ableton Link is enabled (%s)\n' "$ABLETON_LINK_MODE" ;; - link:disable) - echo "OK: Ableton Link is off" ;; + ui_status d_sum_runtime_path "$ABLETON_WINE_ROOT" + ui_status d_sum_prefix_path "$ABLETON_WINEPREFIX" + [ "$integration_ready" -eq 1 ] || ui_warn d_sum_shortcuts_retry + [ "$link_ready" -eq 1 ] || ui_warn d_sum_link_retry + [ "$settings_ready" -eq 1 ] || ui_warn d_settings_retry + [ "$cleanup_ready" -eq 1 ] || ui_warn d_sum_recovery_files_remain "$transaction" + ui_item_end ok ;; + prefix:create|prefix:update) + ui_step_begin s_finish_prefix + if [ "$subcommand" = create ]; then ui_item_begin d_sum_prefix_ready; else ui_item_begin d_sum_prefix_updated; fi + ui_status d_sum_runtime_path "$ABLETON_WINE_ROOT" + ui_status d_sum_prefix_path "$ABLETON_WINEPREFIX" + [ "$settings_ready" -eq 1 ] || ui_warn d_settings_retry + ui_item_end ok ;; *) - echo "OK: requested change completed" - printf ' runtime: %s\n prefix: %s\n' "$ABLETON_WINE_ROOT" "$ABLETON_WINEPREFIX" ;; + ui_step_begin s_finish_install + ui_item_begin d_sum_command_completed "$command_name" "${subcommand:+ $subcommand}" + ui_status d_sum_runtime_path "$ABLETON_WINE_ROOT" + ui_status d_sum_prefix_path "$ABLETON_WINEPREFIX" + ui_item_end ok ;; esac +ui_step_end ok diff --git a/scripts/lib/config.sh b/scripts/lib/config.sh index 4150ca24..ee9bd364 100644 --- a/scripts/lib/config.sh +++ b/scripts/lib/config.sh @@ -488,6 +488,7 @@ ableton_config_validate_write_layout() ableton_config_init() { local requested_mode="${1:-}" repair_mode=0 config_valid=1 + local simple_project_files="${ABLETON_SIMPLE_PROJECT_FILES:-0}" case "$requested_mode" in repair) repair_mode=1 ;; strict) @@ -516,10 +517,13 @@ ableton_config_init() local config_file_name config_file_parent config_generation_before config_generation_after config_file_name="$(basename -- "$ABLETON_CONFIG_FILE")" || return 1 config_file_parent="$(dirname -- "$ABLETON_CONFIG_FILE")" || return 1 - config_generation_before="$(ableton_config_object_token "$ABLETON_CONFIG_FILE")" || { - ableton_config_error "cannot read installer configuration $ABLETON_CONFIG_FILE" - return 1 - } + config_generation_before=untracked + if [ "$simple_project_files" -ne 1 ]; then + config_generation_before="$(ableton_config_object_token "$ABLETON_CONFIG_FILE")" || { + ableton_config_error "cannot read installer configuration $ABLETON_CONFIG_FILE" + return 1 + } + fi if [ -e "$ABLETON_CONFIG_FILE" ] || [ -L "$ABLETON_CONFIG_FILE" ]; then if ! ableton_managed_config_valid "$ABLETON_CONFIG_FILE"; then config_valid=0 @@ -592,13 +596,15 @@ ableton_config_init() # field read above to the generation that was present before parsing; a # replacement between individual reads must not produce a mixed A/B # configuration whose final B token is then treated as authoritative. - config_generation_after="$(ableton_config_object_token "$ABLETON_CONFIG_FILE")" || { - ableton_config_error "cannot reread installer configuration $ABLETON_CONFIG_FILE" - return 1 - } - if [ "$config_generation_after" != "$config_generation_before" ]; then - ableton_config_error "installation configuration changed while it was being read; retry the command" - return 1 + if [ "$simple_project_files" -ne 1 ]; then + config_generation_after="$(ableton_config_object_token "$ABLETON_CONFIG_FILE")" || { + ableton_config_error "cannot reread installer configuration $ABLETON_CONFIG_FILE" + return 1 + } + if [ "$config_generation_after" != "$config_generation_before" ]; then + ableton_config_error "installation configuration changed while it was being read; retry the command" + return 1 + fi fi case "$ABLETON_LINK_MODE" in off|session|always) ;; @@ -643,9 +649,12 @@ ableton_config_init() : "${ABLETON_CONFIG_REPAIR_NEEDED:=0}" export ABLETON_CONFIG_REPAIR_MODE ABLETON_CONFIG_REPAIR_NEEDED fi - if [ -z "${ABLETON_CONFIG_SNAPSHOT_PATH:-}" ] \ - || [ -z "${ABLETON_CONFIG_SNAPSHOT_TOKEN:-}" ] \ - || [ -z "${ABLETON_CONFIG_SNAPSHOT_VALUES:-}" ]; then + if [ "$simple_project_files" -eq 1 ]; then + unset ABLETON_CONFIG_SNAPSHOT_PATH ABLETON_CONFIG_SNAPSHOT_TOKEN \ + ABLETON_CONFIG_SNAPSHOT_VALUES + elif [ -z "${ABLETON_CONFIG_SNAPSHOT_PATH:-}" ] \ + || [ -z "${ABLETON_CONFIG_SNAPSHOT_TOKEN:-}" ] \ + || [ -z "${ABLETON_CONFIG_SNAPSHOT_VALUES:-}" ]; then # Treat the exported snapshot as one record. A partial inherited # environment must be replaced, never trusted or expanded under set -u. ableton_config_snapshot_capture "$config_generation_before" @@ -664,11 +673,6 @@ ableton_render_config() printf 'linkd=%s\n' "$ABLETON_LINKD" } -ableton_expected_config_token() -{ - ableton_render_config | sha256sum | awk '{print "file:"$1}' -} - ableton_write_config() { if [ "${ABLETON_CONFIG_REPAIR_MODE:-0}" = 1 ]; then @@ -783,14 +787,16 @@ ableton_prepare_transactions_dir() ableton_install_lock_acquire() { local expected observed inherited="${ABLETON_INSTALL_LOCK_FD:-}" lock_fd + local simple_project_files="${ABLETON_SIMPLE_PROJECT_FILES:-0}" command -v flock >/dev/null 2>&1 || { ableton_config_error "flock is required to change this installation" return 1 } - if [ "${ABLETON_CONFIG_REPAIR_MODE:-0}" != 1 ] \ + if [ "$simple_project_files" -ne 1 ] \ + && { [ "${ABLETON_CONFIG_REPAIR_MODE:-0}" != 1 ] \ || [ -z "${ABLETON_CONFIG_SNAPSHOT_PATH:-}" ] \ || [ -z "${ABLETON_CONFIG_SNAPSHOT_TOKEN:-}" ] \ - || [ -z "${ABLETON_CONFIG_SNAPSHOT_VALUES:-}" ]; then + || [ -z "${ABLETON_CONFIG_SNAPSHOT_VALUES:-}" ]; }; then [ ! -L "$ABLETON_CONFIG_FILE" ] || { ableton_config_error "refusing symlink installer configuration $ABLETON_CONFIG_FILE" return 1 @@ -812,7 +818,7 @@ ableton_install_lock_acquire() *) observed="$(readlink -f -- "/proc/${BASHPID:-$$}/fd/$inherited" 2>/dev/null || true)" if [ "$observed" = "$expected" ] && flock -n "$inherited"; then - ableton_config_snapshot_verify || { + [ "$simple_project_files" -eq 1 ] || ableton_config_snapshot_verify || { ableton_config_error "installation configuration changed; retry the command" return 1 } @@ -835,7 +841,7 @@ ableton_install_lock_acquire() # inherited descriptor, but only the shell that opened it may issue # LOCK_UN. BASHPID also distinguishes a forked Bash subshell from its parent. ABLETON_INSTALL_LOCK_OWNER_BASHPID="${BASHPID:-$$}" - ableton_config_snapshot_verify || { + [ "$simple_project_files" -eq 1 ] || ableton_config_snapshot_verify || { ableton_config_error "installation configuration changed; retry the command" flock -u "$lock_fd" 2>/dev/null || true exec {lock_fd}<&- diff --git a/scripts/lib/lifecycle.sh b/scripts/lib/lifecycle.sh index be64cef2..06e1db47 100644 --- a/scripts/lib/lifecycle.sh +++ b/scripts/lib/lifecycle.sh @@ -98,6 +98,41 @@ ableton_runtime_pids() return 0 } +ableton_stop_runtime_clients() +{ + local runtime="${1:-$ABLETON_WINE_ROOT}" pids pid deadline + pids="$(ableton_runtime_pids "$runtime")" + [ -n "$pids" ] || return 0 + if [ ! -t 0 ]; then + echo "!! Wine is running. Run the installer in a terminal so it can ask before stopping Wine." >&2 + return 1 + fi + ui_question q_stop_wine_title n q_stop_wine_yes q_stop_wine_no + [ "$UI_ANSWER" = y ] || return 1 + + pids="$(ableton_runtime_pids "$runtime")" + for pid in $pids; do + ableton_pid_uses_runtime "$pid" "$runtime" && kill "$pid" 2>/dev/null || true + done + deadline=$((SECONDS + 30)) + while [ "$SECONDS" -lt "$deadline" ]; do + pids="$(ableton_runtime_pids "$runtime")" + [ -n "$pids" ] || return 0 + sleep 0.1 + done + for pid in $pids; do + ableton_pid_uses_runtime "$pid" "$runtime" && kill -KILL "$pid" 2>/dev/null || true + done + deadline=$((SECONDS + 5)) + while [ "$SECONDS" -lt "$deadline" ]; do + pids="$(ableton_runtime_pids "$runtime")" + [ -n "$pids" ] || return 0 + sleep 0.1 + done + echo "!! Wine did not stop within 35 seconds." >&2 + return 1 +} + ableton_pid_cmdline() { tr '\0' ' ' < "/proc/$1/cmdline" 2>/dev/null || true @@ -241,7 +276,7 @@ ableton_prefix_holders() } # A helper this project installed and started, asked by the data home rather than -# by name so it stays right as helpers come and go. Each carries its own exit +# by name so it stays right as helpers come and go. Each has its own exit # contract - learnheal.exe outlives Live deliberately, to heal the Learn View # pane - so one still running does not mean the session is unfinished. ableton_vendored_helper_image() @@ -393,15 +428,16 @@ ableton_prefix_wait_progress() return "$rc" } -# Wait, and on timeout end every process in the prefix and wait again. The stop is -# indiscriminate: only for a prefix the caller owns outright, never one a user can -# reach. Returns 3 when a straggler survived the stop - the caller's prefix is -# usable and its registry is saved, wineserver -k having shut down through SIGINT - -# and the wait's own exit code when the wait could not run at all, which is a -# different thing and stays fatal. +# Ask as soon as a background application is visible. Otherwise wait for Wine's +# own processes to finish, and ask before stopping them if the wait expires. ableton_prefix_quiesce() { - local runtime="${1:-$ABLETON_WINE_ROOT}" prefix="${2:-$ABLETON_WINEPREFIX}" rc=0 + local runtime="${1:-$ABLETON_WINE_ROOT}" prefix="${2:-$ABLETON_WINEPREFIX}" rc=0 holders + holders="$(ableton_prefix_unknown_holders "$runtime" "$prefix")" + if [ -n "$holders" ]; then + ableton_stop_runtime_clients "$runtime" + return + fi ableton_prefix_wait_progress "$runtime" "$prefix" || rc=$? if [ "$rc" -eq 0 ]; then return 0 @@ -409,9 +445,7 @@ ableton_prefix_quiesce() if [ "$rc" -ne 124 ] && [ "$rc" -ne 137 ]; then return "$rc" fi - echo "-- a leftover background program is holding the prefix open; stopping it" || true - ableton_run_bounded 20 env WINEPREFIX="$prefix" \ - "$runtime/bin/wineserver" -k >/dev/null 2>&1 || true + ableton_stop_runtime_clients "$runtime" || return 1 ableton_prefix_wait "$runtime" "$prefix" || return 3 } diff --git a/scripts/lib/manifest.sh b/scripts/lib/manifest.sh index cf0e62c3..a7d66a65 100644 --- a/scripts/lib/manifest.sh +++ b/scripts/lib/manifest.sh @@ -9,7 +9,11 @@ declare -Ag ABLETON_OWNED_KINDS=() declare -Ag ABLETON_MANIFEST_TOUCHED=() declare -Ag ABLETON_MANIFEST_DEOWNED=() declare -gi ABLETON_OPTIONAL_FILE_FAILURES=0 -declare -gi ABLETON_PUBLICATION_JOURNAL_BROKEN=0 +declare -gi ABLETON_OPTIONAL_FILE_CANCELLED=0 +declare -gi ABLETON_OPTIONAL_FILES_KEPT=0 +declare -gi ABLETON_OPTIONAL_FILES_BACKED_UP=0 +declare -gi ABLETON_PROJECT_OVERWRITE_ALL=0 +declare -gi ABLETON_PROJECT_KEEP_ALL=0 ableton_file_has_no_nul() { @@ -60,16 +64,6 @@ ableton_pr182_custom_link_recorded() [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 } -ableton_pr182_custom_link_owned() -{ - local path="$1" manifest digest current - ableton_pr182_custom_link_recorded "$path" || return 1 - manifest="$ABLETON_STATE_HOME/install-manifest.tsv" - digest="$(awk -F '\t' -v p="$path" '$1=="file" && $2==p { print $3 }' "$manifest")" - current="$(ableton_manifest_digest "$path" 2>/dev/null || true)" - [ "$current" = "$digest" ] || return 1 -} - ableton_txn_pr182_custom_link_authorized() { local path="$1" proof="${ABLETON_TRANSACTION_DIR:-}/pr182-custom-link" @@ -108,39 +102,6 @@ ableton_txn_pr182_custom_link_authorized() [ "$count" -eq 1 ] } -ableton_txn_authorize_pr182_custom_link() -{ - local path="$1" proof="$ABLETON_TRANSACTION_DIR/pr182-custom-link" tmp digest current_digest - ableton_pr182_custom_link_recorded "$path" || return 1 - if [ -e "$proof" ] || [ -L "$proof" ]; then - ableton_txn_pr182_custom_link_authorized "$path" - return - fi - digest="$(awk -F '\t' -v p="$path" '$1=="file" && $2==p { print $3 }' \ - "$ABLETON_STATE_HOME/install-manifest.tsv")" - [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 - current_digest="$(ableton_manifest_digest "$path")" || return 1 - tmp="$(mktemp -d "$ABLETON_TRANSACTION_DIR/.pr182-custom-link.XXXXXX")" || return 1 - if ! printf 'format=1\npath=%s\nrecorded_digest=%s\ncurrent_digest=%s\n' \ - "$path" "$digest" "$current_digest" > "$tmp/metadata" \ - || ! cp -- "$ABLETON_CONFIG_FILE" "$tmp/config" \ - || ! cp -- "$ABLETON_DATA_HOME/VERSION" "$tmp/version" \ - || ! cp -- "$ABLETON_BIN_HOME/ableton-live" "$tmp/launcher" \ - || ! cp -- "$ABLETON_STATE_HOME/.ableton-linux-state" "$tmp/state-marker" \ - || ! cp -- "$ABLETON_STATE_HOME/install-manifest.tsv" "$tmp/manifest" \ - || ! cp -a -- "$path" "$tmp/object" \ - || ! chmod 600 "$tmp/metadata" "$tmp/config" "$tmp/version" \ - "$tmp/launcher" "$tmp/state-marker" "$tmp/manifest" \ - || ! { [ -L "$tmp/object" ] || chmod 600 "$tmp/object"; } \ - || ! mv -T -n -- "$tmp" "$proof" \ - || [ -e "$tmp" ] \ - || ! ableton_txn_pr182_custom_link_authorized "$path"; then - rm -rf -- "$tmp" - ableton_config_error "could not seal the historical custom Link ownership proof" - return 1 - fi -} - ableton_launcher_path_allowed() { local path="$1" data_root="${XDG_DATA_HOME:-$HOME/.local/share}" @@ -162,15 +123,23 @@ ableton_launcher_path_allowed() ableton_managed_path_allowed() { local kind="$1" path="$2" data_root="${XDG_DATA_HOME:-$HOME/.local/share}" + local config_root="${XDG_CONFIG_HOME:-$HOME/.config}" case "$kind" in file|config|symlink) ;; *) return 1 ;; esac if ableton_launcher_path_allowed "$path"; then return 0; fi + if [ "$kind" = config ]; then + case "$path" in + "$config_root/mimeapps.list"|\ + "$config_root/systemd/user/ableton-linkd.service") return 0 ;; + esac + fi case "$path" in *.bak) ableton_launcher_path_allowed "${path%.bak}" && return 0 ;; esac case "$path" in "$ABLETON_DATA_HOME/lib/config.sh"|"$ABLETON_DATA_HOME/lib/lifecycle.sh"|\ "$ABLETON_DATA_HOME/lib/live-options.sh"|"$ABLETON_DATA_HOME/lib/manifest.sh"|\ - "$ABLETON_DATA_HOME/lib/pipeasio.sh"|\ + "$ABLETON_DATA_HOME/lib/pipeasio.sh"|"$ABLETON_DATA_HOME/lib/preferences.sh"|\ + "$ABLETON_DATA_HOME/lib/ui.sh"|\ "$ABLETON_DATA_HOME/detect-scale.sh"|"$ABLETON_DATA_HOME/detect-theme.sh"|\ "$ABLETON_DATA_HOME/shortcut-hold.sh"|"$ABLETON_DATA_HOME/setup-realtime.sh"|\ "$ABLETON_DATA_HOME/audio-report.sh"|"$ABLETON_DATA_HOME/check-ntsync.sh"|\ @@ -221,7 +190,7 @@ ableton_replace_generated_path_allowed() case "$1" in "$ABLETON_DATA_HOME/lib/config.sh"|"$ABLETON_DATA_HOME/lib/lifecycle.sh"|\ "$ABLETON_DATA_HOME/lib/live-options.sh"|"$ABLETON_DATA_HOME/lib/manifest.sh"|\ - "$ABLETON_DATA_HOME/lib/pipeasio.sh"|\ + "$ABLETON_DATA_HOME/lib/pipeasio.sh"|"$ABLETON_DATA_HOME/lib/preferences.sh"|\ "$ABLETON_DATA_HOME/detect-scale.sh"|"$ABLETON_DATA_HOME/detect-theme.sh"|\ "$ABLETON_DATA_HOME/shortcut-hold.sh"|"$ABLETON_DATA_HOME/setup-realtime.sh"|\ "$ABLETON_DATA_HOME/audio-report.sh"|"$ABLETON_DATA_HOME/check-ntsync.sh"|\ @@ -261,7 +230,6 @@ ableton_txn_target_allowed() "$config_root/mimeapps.list"|\ "$ABLETON_STATE_HOME/install-manifest.tsv"|\ "$ABLETON_STATE_HOME/install-prestate.tsv"|\ - "$ABLETON_STATE_HOME/mime-prestate.tsv"|\ "$ABLETON_STATE_HOME/link-firewall"|\ "$ABLETON_WINE_ROOT/.ableton-linux-runtime"|\ "$ABLETON_WINEPREFIX/.ableton-linux-prefix") return 0 ;; @@ -511,45 +479,6 @@ ableton_txn_init() if [ ! -e "$journal" ]; then : > "$journal" || return 1; fi } -# Generated integration files are independent repairs. Once one publication -# has reached its final token, retire only that file-level recovery data so a -# later optional failure cannot undo an earlier successful repair. -ableton_txn_checkpoint_files() -{ - local txn="${ABLETON_TRANSACTION_DIR:-}" journal files tmp key - [ -n "$txn" ] || return 0 - if ! ableton_txn_preflight_commit_files "$txn"; then - ABLETON_PUBLICATION_JOURNAL_BROKEN=1 - echo "!! A support file was updated, but temporary installer data could not be refreshed. Continuing with the installed file." >&2 \ - || true - return 0 - fi - journal="$txn/files.tsv" - files="$txn/files" - if ! tmp="$(mktemp "$txn/.files.tsv.XXXXXX")"; then - ABLETON_PUBLICATION_JOURNAL_BROKEN=1 - echo "!! A support file was updated, but temporary installer data could not be refreshed. Continuing with the installed file." >&2 \ - || true - return 0 - fi - if ! chmod 600 "$tmp" || ! mv -f -- "$tmp" "$journal"; then - rm -f -- "$tmp" 2>/dev/null || true - ABLETON_PUBLICATION_JOURNAL_BROKEN=1 - echo "!! A support file was updated, but temporary installer data could not be refreshed. Continuing with the installed file." >&2 \ - || true - return 0 - fi - for key in "${!ABLETON_TXN_SEEN[@]}"; do - case "$key" in "$txn"$'\t'*) unset 'ABLETON_TXN_SEEN[$key]' ;; esac - done - if ! rm -rf -- "$files" || ! mkdir -p -- "$files"; then - ABLETON_PUBLICATION_JOURNAL_BROKEN=1 - echo "!! A support file was updated, but temporary installer data could not be removed. Continuing with the installed file." >&2 \ - || true - fi - return 0 -} - ableton_txn_snapshot() { local path="$1" id backup existing journal_tmp status backup_created=0 seen_key @@ -815,20 +744,6 @@ ableton_install_state_has_active_transaction() return 1 } -ableton_live_desktop_entry_valid() -{ - local file="$1" - [ -f "$file" ] && [ ! -L "$file" ] && ableton_file_has_no_nul "$file" || return 1 - [ "$(grep -c '^\[Desktop Entry\]$' "$file")" -eq 1 ] \ - && [ "$(grep -c '^Type=Application$' "$file")" -eq 1 ] \ - && [ "$(grep -c '^Name=.' "$file")" -eq 1 ] \ - && [ "$(grep -c '^Comment=Music production and performance$' "$file")" -eq 1 ] \ - && [ "$(grep -cF "Exec=$ABLETON_BIN_HOME/ableton-live %f" "$file")" -eq 1 ] \ - && [ "$(grep -Ec '^Icon=live-(beta|intro|lite|standard|suite)$' "$file")" -eq 1 ] \ - && [ "$(grep -Ec '^StartupWMClass=.+[.]exe$' "$file")" -eq 1 ] \ - && [ "$(grep -c '^MimeType=application/x-ableton-live-set;application/x-ableton-live-clip;application/x-ableton-live-pack;$' "$file")" -eq 1 ] -} - ableton_object_token() { local path="$1" digest @@ -853,13 +768,6 @@ ableton_regular_source_token() printf 'file:%s\n' "$digest" } -ableton_symlink_text_token() -{ - local digest - digest="$({ printf 'symlink\0'; printf '%s' "$1"; } | sha256sum | awk '{print $1}')" || return 1 - printf 'symlink:%s\n' "$digest" -} - ableton_atomic_restore_object() { local source="$1" target="$2" expected tmp parent @@ -878,16 +786,6 @@ ableton_atomic_restore_object() fi } -ableton_txn_replace_unowned_file() -{ - local source="$1" target="$2" post - [ -f "$source" ] && [ ! -L "$source" ] || return 1 - ableton_txn_snapshot "$target" || return 1 - post="$(ableton_object_token "$source")" || return 1 - ableton_txn_expect "$target" "$post" || return 1 - ableton_atomic_restore_object "$source" "$target" -} - ableton_validate_ownership_manifest() { local manifest="${1:-$ABLETON_STATE_HOME/install-manifest.tsv}" @@ -972,41 +870,6 @@ ableton_validate_prestate_index() return 0 } -ableton_validate_mime_prestate() -{ - local state="${1:-$ABLETON_STATE_HOME/mime-prestate.tsv}" - local type prior extra - local -A seen=() - if [ ! -e "$state" ] && [ ! -L "$state" ]; then return 0; fi - [ -f "$state" ] && [ ! -L "$state" ] && [ -r "$state" ] || { - ableton_config_error "the saved file-opening settings are unsafe or unreadable" - return 1 - } - ableton_file_has_no_nul "$state" || { - ableton_config_error "the saved file-opening settings contain invalid binary data" - return 1 - } - while IFS=$'\t' read -r type prior extra || [ -n "$type$prior$extra" ]; do - [ -z "$extra" ] && [ -n "$type" ] && [ -z "${seen[$type]+x}" ] || { - ableton_config_error "the saved file-opening settings are invalid or ambiguous" - return 1 - } - case "$type" in - x-scheme-handler/ableton|application/x-wine-extension-auz|\ - application/x-ableton-live-set|application/x-ableton-live-clip|\ - application/x-ableton-live-pack|application/x-ableton-live-max-device|\ - x-scheme-handler/c74max) ;; - *) ableton_config_error "the saved file-opening settings contain an unknown file type"; return 1 ;; - esac - [ -z "$prior" ] || [[ "$prior" =~ ^[A-Za-z0-9_.+-]+[.]desktop$ ]] || { - ableton_config_error "the saved file-opening settings contain an invalid application" - return 1 - } - seen["$type"]=1 - done < "$state" - return 0 -} - ableton_validate_prestate_store() { local index="$ABLETON_STATE_HOME/install-prestate.tsv" @@ -1062,7 +925,7 @@ ableton_validate_install_state_journals() "$ABLETON_STATE_HOME/install-manifest.tsv" || return 1 elif ! ableton_validate_ownership_manifest \ "$ABLETON_STATE_HOME/install-manifest.tsv" >/dev/null 2>&1; then - echo " rebuilding the installed-file list" || true + ui_status m_rebuilding_file_list fi # Repair rebuilds installer-generated files and their inventory. Saved # earlier files are relevant only when uninstall needs to restore them, so @@ -1300,179 +1163,174 @@ ableton_install_file() ableton_record_owned "$target" "$kind" || return 1 } -ableton_install_symlink() -{ - local link_text="$1" target="$2" prestate_policy="${3:-preserve-local}" post tmp parent - [ ! -d "$target" ] || [ -L "$target" ] || { - ableton_config_error "refusing to replace directory with a symlink: $target" - return 1 - } - ableton_persist_file_prestate "$target" "" "$prestate_policy" || return 1 - ableton_txn_snapshot "$target" || return 1 - post="$(ableton_symlink_text_token "$link_text")" || return 1 - ableton_txn_expect "$target" "$post" || return 1 - parent="$(dirname "$target")" || return 1 - mkdir -p -- "$parent" || return 1 - tmp="$(mktemp "$parent/.ableton-link.XXXXXX")" || return 1 - rm -f -- "$tmp" || return 1 - if ! ln -s -- "$link_text" "$tmp" \ - || [ "$(ableton_object_token "$tmp" 2>/dev/null || true)" != "$post" ] \ - || ! mv -T -f -- "$tmp" "$target"; then - rm -f -- "$tmp" - return 1 - fi - ableton_record_owned "$target" symlink || return 1 -} - -# Save the most recently displaced launcher object beside its path as -# .bak. The transaction tracks both paths and restores both objects after -# a later failure. Persist each foreign object once before replacing either -# path, so uninstall can restore both the original launcher and a personal file -# that already occupied .bak, even after later launcher updates. -ableton_backup_launcher() +# Project files are a fixed list of independent copies. A failed path does not +# stop later paths, and completed copies are never restored automatically. +ableton_prepare_project_backup_dir() { - local target="$1" desired="$2" desired_mode="${3:-}" backup="$1.bak" current - local manifest="$ABLETON_STATE_HOME/install-manifest.tsv" - ableton_launcher_path_allowed "$target" || { - ableton_config_error "launcher path falls outside the managed launcher set: $target" - return 1 - } - [ -e "$target" ] || [ -L "$target" ] || return 0 - [ ! -d "$target" ] || [ -L "$target" ] || { - ableton_config_error "launcher path points to a directory: $target" - return 1 - } - [ -f "$target" ] || [ -L "$target" ] || { - ableton_config_error "launcher path is not a regular file or symlink: $target" - return 1 - } - current="$(ableton_object_token "$target")" || return 1 - if [ "$current" = "$desired" ] \ - && { [ -z "$desired_mode" ] \ - || { [ ! -L "$target" ] && [ "$(stat -c '%a' -- "$target")" = "$desired_mode" ]; }; }; then - return 0 - fi - # Once both paths belong to an installed launcher pair, the adjacent copy - # is the first displaced launcher, not scratch space for every generation. - # Keep it unchanged while refreshing the canonical launcher; the active - # transaction already protects the canonical path if this update fails. - if { [ -n "${ABLETON_OWNED_KINDS[$target]+x}" ] \ - && [ -n "${ABLETON_OWNED_KINDS[$backup]+x}" ]; } \ - || { ableton_validate_ownership_manifest "$manifest" >/dev/null 2>&1 \ - && ableton_manifest_path_claimed "$target" \ - && ableton_manifest_path_claimed "$backup"; }; then + local root stamp marker + if [ -n "${ABLETON_PROJECT_BACKUP_DIR:-}" ]; then + if [ -z "${ABLETON_PROJECT_BACKUP_STAMP:-}" ]; then + ABLETON_PROJECT_BACKUP_STAMP="$(date -u +%Y%m%dT%H%M%SZ)" || return 1 + export ABLETON_PROJECT_BACKUP_STAMP + fi return 0 fi - [ ! -d "$backup" ] || [ -L "$backup" ] || { - ableton_config_error "launcher backup path points to a directory: $backup" - return 1 - } - if [ -e "$backup" ] && [ ! -f "$backup" ] && [ ! -L "$backup" ]; then - ableton_config_error "launcher backup path is not a regular file or symlink: $backup" - return 1 - fi - ableton_persist_file_prestate "$backup" "" preserve-local || return 1 - ableton_txn_snapshot "$backup" || return 1 - ableton_txn_expect "$backup" "$current" || return 1 - ableton_atomic_restore_object "$target" "$backup" || return 1 - ableton_record_owned "$backup" "${current%%:*}" -} - -ableton_launcher_claim_matches() -{ - local target="$1" current="$2" - local record kind digest - kind="${current%%:*}" - if [ "${ABLETON_OWNED_KINDS[$target]-}" = "$kind" ]; then - return 0 + root="$ABLETON_STATE_HOME/backups" + marker="$ABLETON_STATE_HOME/.ableton-linux-state" + stamp="$(date -u +%Y%m%dT%H%M%SZ)" || return 1 + mkdir -p -- "$root" || return 1 + if [ ! -e "$marker" ] && [ ! -L "$marker" ]; then + printf 'format=1\nowner=ableton-linux\n' > "$marker" 2>/dev/null || true + chmod 600 "$marker" 2>/dev/null || true fi - record="$(ableton_manifest_object_record "$target")" || return 1 - IFS=$'\t' read -r kind digest <<< "$record" - case "$kind" in file|symlink) ;; *) return 1 ;; esac - [ "$current" = "$kind:$digest" ] + ABLETON_PROJECT_BACKUP_DIR="$(mktemp -d "$root/$stamp.XXXXXX")" || return 1 + ABLETON_PROJECT_BACKUP_STAMP="$stamp" + export ABLETON_PROJECT_BACKUP_DIR ABLETON_PROJECT_BACKUP_STAMP } -ableton_install_launcher_file() +ableton_project_choice_marker() # marker name: present or created in the run's backup dir { - local mode="$1" source="$2" target="$3" kind="${4:-file}" desired - desired="$(ableton_regular_source_token "$source")" || return 1 - ableton_backup_launcher "$target" "$desired" "$mode" || return 1 - ableton_install_file "$mode" "$source" "$target" "$kind" replace-launcher + local marker + ableton_prepare_project_backup_dir || return 1 + marker="$ABLETON_PROJECT_BACKUP_DIR/$1" + mkdir -- "$marker" 2>/dev/null || { [ -d "$marker" ] && [ ! -L "$marker" ]; } } -ableton_install_launcher_symlink() +# One question per installer run, at the first existing destination. 0 = +# overwrite (the default), 1 = keep every existing destination, 2 = abort, +# 3 = the overwrite choice could not be recorded for later processes. +ableton_project_overwrite_choice() { - local link_text="$1" target="$2" desired - desired="$(ableton_symlink_text_token "$link_text")" || return 1 - ableton_backup_launcher "$target" "$desired" || return 1 - ableton_install_symlink "$link_text" "$target" replace-launcher -} - -ableton_publish_file() -{ - if [ "$ABLETON_PUBLICATION_JOURNAL_BROKEN" -eq 1 ]; then - ableton_publish_without_file_journal ableton_install_file "$@" - return + local target="$1" run="${ABLETON_PROJECT_BACKUP_DIR:-}" + if [ "$ABLETON_PROJECT_KEEP_ALL" -eq 1 ] \ + || { [ -n "$run" ] && [ -d "$run/.keep-all" ] && [ ! -L "$run/.keep-all" ]; }; then + return 1 fi - ableton_install_file "$@" || return 1 - ableton_txn_checkpoint_files -} - -ableton_publish_launcher_file() -{ - if [ "$ABLETON_PUBLICATION_JOURNAL_BROKEN" -eq 1 ]; then - ableton_publish_without_file_journal ableton_install_launcher_file "$@" - return + [ "${ABLETON_PROJECT_ASSUME_YES:-0}" != 1 ] \ + && [ "$ABLETON_PROJECT_OVERWRITE_ALL" -ne 1 ] \ + && { [ -z "$run" ] || [ ! -d "$run/.overwrite-all" ] || [ -L "$run/.overwrite-all" ]; } \ + || return 0 + printf '%s exists.\n' "$target" >&2 || true + ui_question q_overwrite_title o q_overwrite_all q_keep q_abort + case "$UI_ANSWER" in + k) + ableton_project_choice_marker .keep-all || true + ABLETON_PROJECT_KEEP_ALL=1 + ui_info q_keep_chosen + return 1 ;; + a) + ui_info q_abort_chosen + return 2 ;; + esac + if ! ableton_project_choice_marker .overwrite-all; then + echo "!! $(ui_text m_overwrite_all_failed)" >&2 || true + return 3 fi - ableton_install_launcher_file "$@" || return 1 - ableton_txn_checkpoint_files + ABLETON_PROJECT_OVERWRITE_ALL=1 + ui_info q_overwrite_chosen "$ABLETON_PROJECT_BACKUP_DIR" + return 0 } -ableton_publish_launcher_symlink() +ableton_backup_project_destination() { - if [ "$ABLETON_PUBLICATION_JOURNAL_BROKEN" -eq 1 ]; then - ableton_publish_without_file_journal ableton_install_launcher_symlink "$@" - return + local target="$1" relative backup parent + if ! ableton_prepare_project_backup_dir; then + echo "!! backup failed; left destination untouched: $target" >&2 || true + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + return 1 fi - ableton_install_launcher_symlink "$@" || return 1 - ableton_txn_checkpoint_files -} - -# Once optional recovery bookkeeping itself becomes unavailable, keep repairing -# independent files atomically instead of turning that bookkeeping into a gate. -# The caller still owns and later retires the surrounding core transaction. -ableton_publish_without_file_journal() -{ - local operation="$1" saved_transaction="${ABLETON_TRANSACTION_DIR:-}" rc - shift - ABLETON_TRANSACTION_DIR="" - export ABLETON_TRANSACTION_DIR - if "$operation" "$@"; then rc=0; else rc=$?; fi - ABLETON_TRANSACTION_DIR="$saved_transaction" - export ABLETON_TRANSACTION_DIR - return "$rc" + relative="${target#/}" + backup="$ABLETON_PROJECT_BACKUP_DIR/$relative.bak-$ABLETON_PROJECT_BACKUP_STAMP" + parent="$(dirname -- "$backup")" || return 1 + if ! mkdir -p -- "$parent"; then + echo "!! backup failed; left destination untouched: $target -> $backup" >&2 || true + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + return 1 + fi + if ! mv -T -- "$target" "$backup"; then + echo "!! backup failed; left destination untouched: $target -> $backup" >&2 || true + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + return 1 + fi + ABLETON_OPTIONAL_FILES_BACKED_UP=$((ABLETON_OPTIONAL_FILES_BACKED_UP + 1)) + printf ' backup: %s\n' "$backup" || true } -# Optional files are separate repairs. A blocked shared path must not prevent -# later project-owned files from being refreshed in the same run. -ableton_try_publish_file() +ableton_prepare_project_destination() { - if ableton_publish_file "$@"; then return 0; fi - ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) - return 0 + local source="$1" target="$2" choice parent + [ "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 0 ] || return 1 + if [ -e "$target" ] || [ -L "$target" ]; then + if ableton_project_overwrite_choice "$target"; then + choice=0 + else + choice=$? + fi + case "$choice" in + 0) ableton_backup_project_destination "$target" || return 1 ;; + 1) + ABLETON_OPTIONAL_FILES_KEPT=$((ABLETON_OPTIONAL_FILES_KEPT + 1)) + ui_status m_kept_file "$target" + return 1 ;; + 2) + ABLETON_OPTIONAL_FILE_CANCELLED=1 + echo "!! cancelled before replacing $target" >&2 || true + return 1 ;; + 3) + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + return 1 ;; + esac + fi + parent="$(dirname -- "$target")" || return 1 + if ! mkdir -p -- "$parent"; then + echo "!! copy failed: $source -> $target" >&2 || true + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + return 1 + fi } -ableton_try_publish_launcher_file() +ableton_install_project_file() { - if ableton_publish_launcher_file "$@"; then return 0; fi - ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + local mode="$1" source="$2" target="$3" + [ "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 0 ] || return 0 + if [ ! -f "$source" ]; then + echo "!! copy failed: $source -> $target" >&2 || true + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + return 0 + fi + ableton_prepare_project_destination "$source" "$target" || return 0 + if ! cp -- "$source" "$target"; then + echo "!! copy failed: $source -> $target" >&2 || true + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + elif ! chmod "$mode" "$target"; then + echo "!! chmod failed: $target (mode $mode)" >&2 || true + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + elif [ "${ABLETON_RECORD_PROJECT_FILES:-0}" = 1 ] \ + && ! ableton_record_owned "$target" file; then + echo "!! ownership record failed: $target" >&2 || true + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + fi return 0 } -ableton_try_publish_launcher_symlink() +ableton_install_project_symlink() { - if ableton_publish_launcher_symlink "$@"; then return 0; fi - ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + local source="$1" target="$2" + [ "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 0 ] || return 0 + if [ ! -e "$source" ] && [ ! -L "$source" ]; then + echo "!! copy failed: $source -> $target" >&2 || true + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + return 0 + fi + ableton_prepare_project_destination "$source" "$target" || return 0 + if ! ln -s -- "$source" "$target"; then + echo "!! copy failed: $source -> $target" >&2 || true + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + elif [ "${ABLETON_RECORD_PROJECT_FILES:-0}" = 1 ] \ + && ! ableton_record_owned "$target" symlink; then + echo "!! ownership record failed: $target" >&2 || true + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + fi return 0 } @@ -1575,7 +1433,7 @@ ableton_remove_managed_file() fi ableton_txn_expect "$backup" absent || return 1 rm -f -- "$backup" || return 1 - printf ' restored your previous %s\n' "$target" || true + ui_status pa_restored_previous "$target" else rm -f -- "$target" || return 1 [ ! -e "$target" ] && [ ! -L "$target" ] || return 1 @@ -1583,44 +1441,6 @@ ableton_remove_managed_file() ableton_record_deowned "$target" } -# Relinquish a historical ownership claim after the user changed the object. -# The live path is untouched; any installer-only prestate is consumed so a -# later uninstall cannot resurrect or remove the user's replacement. -ableton_abandon_managed_file() -{ - local target="$1" index="$ABLETON_STATE_HOME/install-prestate.tsv" - local id backup recorded="" index_tmp row_count=0 - ableton_validate_install_state_journals || return 1 - id="$(printf '%s' "$target" | sha256sum | awk '{print $1}')" || return 1 - backup="$ABLETON_STATE_HOME/install-prestate/$id" - if [ -r "$index" ]; then - row_count="$(awk -F '\t' -v p="$target" '$2==p { n++ } END { print n+0 }' "$index")" \ - || return 1 - recorded="$(awk -F '\t' -v p="$target" '$1=="present" && $2==p { print $3; exit }' "$index")" \ - || return 1 - if [ "$row_count" -ne 0 ]; then - if ! { [ "$row_count" -eq 1 ] && [ "$recorded" = "$backup" ] \ - && { [ -f "$backup" ] || [ -L "$backup" ]; }; }; then - ableton_config_error "cannot safely relinquish pre-install state for $target" - return 1 - fi - ableton_txn_snapshot "$index" || return 1 - ableton_txn_snapshot "$backup" || return 1 - index_tmp="$(mktemp "$ABLETON_STATE_HOME/.prestate.XXXXXX")" || return 1 - if ! awk -F '\t' -v p="$target" '$2 != p { print }' "$index" > "$index_tmp" \ - || ! chmod 600 "$index_tmp" \ - || ! ableton_txn_expect "$index" "$(ableton_regular_source_token "$index_tmp")" \ - || ! ableton_txn_expect "$backup" absent \ - || ! mv -f -- "$index_tmp" "$index" \ - || ! rm -f -- "$backup"; then - rm -f -- "$index_tmp" - return 1 - fi - fi - fi - ableton_record_deowned "$target" -} - ableton_write_ownership_manifest() { # This file is an uninstall inventory, not installation integrity state. @@ -1632,12 +1452,14 @@ ableton_write_ownership_manifest() local manifest="$ABLETON_STATE_HOME/install-manifest.tsv" tmp kind path digest ableton_mark_state_home || return 1 tmp="$(mktemp "$ABLETON_STATE_HOME/.manifest.XXXXXX")" || return 1 - # Preserve records for components this invocation did not touch. Touched - # paths are replaced below by their new digest. + # Preserve records for components this invocation did not touch. Touched + # paths are replaced below by their new digest. Runtime ownership is a + # singleton, so every historical runtime row is retired before the + # configured runtime is appended below. if [ -f "$manifest" ] && [ ! -L "$manifest" ] \ && ableton_validate_ownership_manifest "$manifest" >/dev/null 2>&1; then while IFS=$'\t' read -r kind path digest; do - case "$kind" in file|config|symlink|runtime) ;; *) continue ;; esac + case "$kind" in file|config|symlink) ;; *) continue ;; esac [ -n "$path" ] || continue [ -z "${ABLETON_MANIFEST_TOUCHED[$path]+x}" ] || continue printf '%s\t%s\t%s\n' "$kind" "$path" "$digest" >> "$tmp" \ @@ -1658,8 +1480,8 @@ ableton_write_ownership_manifest() fi if ! sort -u "$tmp" -o "$tmp" \ || ! chmod 600 "$tmp" \ - || ! mv -f -- "$tmp" "$manifest" \ - || ! ableton_validate_ownership_manifest "$manifest"; then + || ! ableton_validate_ownership_manifest "$tmp" \ + || ! mv -T -f -- "$tmp" "$manifest"; then rm -f -- "$tmp" return 1 fi diff --git a/scripts/lib/pipeasio.sh b/scripts/lib/pipeasio.sh index c76f73aa..ca0666fb 100644 --- a/scripts/lib/pipeasio.sh +++ b/scripts/lib/pipeasio.sh @@ -9,9 +9,8 @@ readonly ABLETON_PIPEASIO_CLSID='{2D3CA9E2-1193-4C5D-B5FD-38798F3DC074}' readonly ABLETON_PIPEASIO_ASIO_KEY='HKLM\Software\ASIO\PipeASIO' readonly ABLETON_PIPEASIO_CLASS_KEY="HKCR\\CLSID\\$ABLETON_PIPEASIO_CLSID" -ABLETON_PIPEWIRE_CLIENT_VERSION="" -ABLETON_PIPEWIRE_DAEMON_VERSION="" -ABLETON_PIPEASIO_PANEL_STATE="" +: "${ABLETON_PIPEWIRE_CLIENT_VERSION:=}" +: "${ABLETON_PIPEWIRE_DAEMON_VERSION:=}" ABLETON_PIPEASIO_DIAGNOSTIC_NOTICE_SHOWN=0 ableton_pipewire_version_core() @@ -48,6 +47,12 @@ ableton_pipewire_preflight() { local probe="${1:?PipeWire probe path required}" purpose="${2:-using PipeASIO}" local output client daemon client_count daemon_count + if [ "${ABLETON_PIPEWIRE_PREFLIGHT_CACHE:-0}" = 1 ] \ + && [ "${ABLETON_PIPEWIRE_PREFLIGHT_DONE:-0}" = 1 ] \ + && ableton_pipewire_version_ge "$ABLETON_PIPEWIRE_CLIENT_VERSION" "$ABLETON_PIPEWIRE_FLOOR" \ + && ableton_pipewire_version_ge "$ABLETON_PIPEWIRE_DAEMON_VERSION" "$ABLETON_PIPEWIRE_FLOOR"; then + return 0 + fi ABLETON_PIPEWIRE_CLIENT_VERSION="" ABLETON_PIPEWIRE_DAEMON_VERSION="" @@ -97,9 +102,17 @@ ableton_pipewire_preflight() printf '!! Upgrade the complete PipeWire stack, then retry. Nothing was changed.\n' >&2 return 1 fi - # Reporting comes after the compatibility gate has succeeded. A closed - # terminal must not turn that successful proof into a rollback request. - printf ' PipeWire %s (client %s): supported\n' "$daemon" "$client" || true + if [ "${ABLETON_PIPEWIRE_PREFLIGHT_CACHE:-0}" = 1 ]; then + ABLETON_PIPEWIRE_PREFLIGHT_DONE=1 + export ABLETON_PIPEWIRE_PREFLIGHT_DONE + export ABLETON_PIPEWIRE_CLIENT_VERSION ABLETON_PIPEWIRE_DAEMON_VERSION + fi + # Reporting comes after the compatibility gate has succeeded. The .run + # system report already names PipeWire, so a coordinated run records one + # compatibility proof without printing the same version at every child. + if [ "${ABLETON_PIPEWIRE_REPORT_SHOWN:-0}" != 1 ]; then + ui_status pa_pipewire_supported "$daemon" "$client" + fi return 0 } @@ -158,7 +171,6 @@ ableton_pipeasio_validate_panel() echo "!! PipeASIO Settings icon reference is malformed" >&2; return 1; } grep -q '&2; return 1; } - ABLETON_PIPEASIO_PANEL_STATE=built ;; skipped) case "$record" in @@ -169,7 +181,6 @@ ableton_pipeasio_validate_panel() echo "!! runtime says the settings panel was skipped, but panel files are present" >&2 return 1 } - ABLETON_PIPEASIO_PANEL_STATE=skipped ;; *) echo "!! runtime has an unknown PipeASIO panel state" >&2; return 1 ;; esac @@ -265,16 +276,6 @@ ableton_pipeasio_desktop_exec_arg() -e 's/%/%%/g' } -ableton_pipeasio_icon_may_replace() -{ - local path="$1" - if [ ! -e "$path" ] && [ ! -L "$path" ]; then return 0; fi - if ableton_manifest_path_matches "$path" || ableton_legacy_owned_path "$path"; then return 0; fi - ableton_record_deowned "$path" - printf ' preserved your existing %s\n' "$path" - return 1 -} - ableton_pipeasio_restore_adjacent_backup() { local path="$1" saved="$1.bak" path_record saved_record path_kind path_digest @@ -356,7 +357,7 @@ ableton_pipeasio_restore_adjacent_backup() fi ableton_record_deowned "$path" || return 1 ableton_record_deowned "$saved" || return 1 - printf ' restored your previous %s\n' "$path" || true + ui_status pa_restored_previous "$path" return 0 } @@ -379,7 +380,7 @@ ableton_pipeasio_remove_panel_path() ableton_remove_managed_file "$path" else ableton_record_deowned "$path" - printf ' preserved your existing %s\n' "$path" || true + ui_status pa_preserved_existing "$path" fi } @@ -439,9 +440,8 @@ ableton_pipeasio_optional_tools_advice() command -v "$tool" >/dev/null 2>&1 || missing="${missing}${missing:+, }$tool" done if [ -n "$missing" ]; then - printf ' Optional PipeWire tools missing: %s.\n' "$missing" || true - echo ' pw-dump enables panel device lists; pw-top enables Monitor; both enrich audio-report.sh.' \ - || true + ui_info pa_optional_tools_missing "$missing" + ui_info pa_optional_tools_hint fi return 0 } @@ -487,8 +487,7 @@ ableton_pipeasio_qt_advice() missing="${missing}${missing:+$'\n'}Qt $qpa platform plugin" fi if [ -n "$missing" ]; then - printf ' PipeASIO Settings is optional. To enable it, run: %s\n' \ - "$(ableton_pipeasio_qt_package_hint "$qpa")" || true + ui_info pa_qt_advice "$(ableton_pipeasio_qt_package_hint "$qpa")" fi return 0 } @@ -501,52 +500,47 @@ ableton_pipeasio_sync_panel() local icon="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps/pipeasio.svg" local source_desktop="$runtime/share/applications/pipeasio-settings.desktop" local source_icon="$runtime/share/icons/hicolor/scalable/apps/pipeasio.svg" - local tmp="" failures_before="$ABLETON_OPTIONAL_FILE_FAILURES" removal_ok=1 + local tmp="" failures_before="$ABLETON_OPTIONAL_FILE_FAILURES" + [ "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 0 ] || return 0 case "$policy" in install|reconcile) ;; *) return 2 ;; esac - ableton_pipeasio_validate_panel "$runtime" || return 1 - state="$ABLETON_PIPEASIO_PANEL_STATE" + if [ "$policy" = reconcile ] \ + && [ ! -e "$command" ] && [ ! -L "$command" ] \ + && [ ! -e "$desktop" ] && [ ! -L "$desktop" ] \ + && [ ! -e "$icon" ] && [ ! -L "$icon" ]; then + return 0 + fi + state="$(ableton_pipeasio_build_info_value \ + "$runtime/ABLETON-WINE-BUILD-INFO.txt" pipeasio-panel 2>/dev/null || true)" + case "$state" in + built|skipped) ;; + *) + echo "!! copy failed: PipeASIO Settings files from $runtime" >&2 || true + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) + return 1 ;; + esac if [ "$state" = skipped ]; then - ableton_pipeasio_remove_panel_integration || removal_ok=0 - echo " PipeASIO Settings was not packaged; edit the INI file when needed" \ - || true - ableton_pipeasio_refresh_panel_caches - [ "$removal_ok" -eq 1 ] - return + ui_status pa_panel_not_packaged + return 0 fi - if [ "$policy" = reconcile ] && [ ! -e "$command" ] && [ ! -L "$command" ] \ - && ! ableton_manifest_path_claimed "$command"; then - ableton_record_deowned "$command" || true - else - ableton_try_publish_launcher_symlink "$runtime/bin/pipeasio-settings" "$command" - fi - if [ "$policy" = reconcile ] && [ ! -e "$desktop" ] && [ ! -L "$desktop" ] \ - && ! ableton_manifest_path_claimed "$desktop"; then - ableton_record_deowned "$desktop" || true + ableton_install_project_symlink "$runtime/bin/pipeasio-settings" "$command" + if tmp="$(mktemp "${TMPDIR:-/tmp}/pipeasio-settings.desktop.XXXXXX")" \ + && { while IFS= read -r line || [ -n "$line" ]; do + if [ "$line" = 'Exec=pipeasio-settings' ]; then + printf 'Exec="%s"\n' \ + "$(ableton_pipeasio_desktop_exec_arg "$runtime/bin/pipeasio-settings")" + else + printf '%s\n' "$line" + fi + done < "$source_desktop" + printf '%s\n' 'X-Ableton-Wine-Managed=true'; + } > "$tmp"; then + ableton_install_project_file 644 "$tmp" "$desktop" else - if tmp="$(mktemp "${TMPDIR:-/tmp}/pipeasio-settings.desktop.XXXXXX")" \ - && { while IFS= read -r line || [ -n "$line" ]; do - if [ "$line" = 'Exec=pipeasio-settings' ]; then - printf 'Exec="%s"\n' \ - "$(ableton_pipeasio_desktop_exec_arg "$runtime/bin/pipeasio-settings")" - else - printf '%s\n' "$line" - fi - done < "$source_desktop" - printf '%s\n' 'X-Ableton-Wine-Managed=true'; - } > "$tmp"; then - ableton_try_publish_launcher_file 644 "$tmp" "$desktop" - else - ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) - fi - [ -z "$tmp" ] || rm -f -- "$tmp" 2>/dev/null || true - fi - if [ "$policy" = reconcile ] && [ ! -e "$icon" ] && [ ! -L "$icon" ] \ - && ! ableton_manifest_path_claimed "$icon"; then - ableton_record_deowned "$icon" || true - elif ableton_pipeasio_icon_may_replace "$icon"; then - ableton_try_publish_file 644 "$source_icon" "$icon" + ABLETON_OPTIONAL_FILE_FAILURES=$((ABLETON_OPTIONAL_FILE_FAILURES + 1)) fi + [ -z "$tmp" ] || rm -f -- "$tmp" 2>/dev/null || true + ableton_install_project_file 644 "$source_icon" "$icon" [ "$policy" != install ] || ableton_pipeasio_qt_advice "$runtime/bin/pipeasio-settings" ableton_pipeasio_refresh_panel_caches [ "$ABLETON_OPTIONAL_FILE_FAILURES" -eq "$failures_before" ] @@ -589,7 +583,7 @@ ableton_pipeasio_register() local wine_command="${1:?wine command required}" wait_command="${2:-}" local result ableton_pipeasio_unregister "$wine_command" "$wait_command" || return 1 - "$wine_command" regsvr32 /s pipeasio64.dll || { + "$wine_command" regsvr32 /s pipeasio64.dll >/dev/null 2>&1 || { echo "!! PipeASIO registration command failed" >&2 return 1 } @@ -614,4 +608,5 @@ ableton_pipeasio_register() echo "!! PipeASIO class registration is incomplete" >&2 return 1 } + ui_status pa_registered } diff --git a/scripts/lib/preferences.sh b/scripts/lib/preferences.sh new file mode 100644 index 00000000..c974225a --- /dev/null +++ b/scripts/lib/preferences.sh @@ -0,0 +1,598 @@ +#!/usr/bin/env bash +# Strict, data-only launcher preferences and the narrow PipeASIO buffer editor. +# Sourcing this file is intentionally side-effect free. + +_ableton_preferences_path() +{ + printf '%s/ableton-wine/preferences\n' \ + "${XDG_CONFIG_HOME:-$HOME/.config}" +} + +_ableton_no_nul() +{ + local path="$1" + ! LC_ALL=C od -An -v -tu1 -- "$path" 2>/dev/null | grep -qw 0 +} + +_ableton_audio_threads_valid() +{ + case "$1" in + auto|off) return 0 ;; + esac + [[ "$1" =~ ^([1-9]|[1-5][0-9]|6[0-3])$ ]] +} + +_ableton_dpi_stored_valid() +{ + case "$1" in auto|100|fractional|preserve) return 0 ;; esac + return 1 +} + +_ableton_dpi_environment_valid() +{ + local value="$1" number + _ableton_dpi_stored_valid "$value" && return 0 + case "$value" in + dpi*) number="${value#dpi}" ;; + fractional*) number="${value#fractional}" ;; + *) return 1 ;; + esac + [[ "$number" =~ ^[0-9]+$ ]] \ + && [ "$number" -ge 72 ] && [ "$number" -le 384 ] +} + +_ableton_preferences_values_valid() +{ + local shortcuts="$1" dpi="$2" threads="$3" rt="$4" power="$5" + case "$shortcuts" in take|preserve) ;; *) return 1 ;; esac + _ableton_dpi_stored_valid "$dpi" || return 1 + _ableton_audio_threads_valid "$threads" || return 1 + case "$rt" in auto|off) ;; *) return 1 ;; esac + case "$power" in performance|balanced|off) ;; *) return 1 ;; esac +} + +_ableton_preferences_parse() +{ + local path="$1" + local -a lines=() + [ -f "$path" ] && [ ! -L "$path" ] && _ableton_no_nul "$path" \ + || return 1 + mapfile -t lines < "$path" || return 1 + [ "${#lines[@]}" -eq 7 ] || return 1 + [ "$(tail -c 1 -- "$path" | od -An -tu1 | tr -d '[:space:]')" = 10 ] \ + || return 1 + [ "${lines[0]}" = '# ableton-linux launcher preferences; managed by the installer' ] \ + && [ "${lines[1]}" = format=1 ] \ + && [[ "${lines[2]}" == shortcuts=* ]] \ + && [[ "${lines[3]}" == dpi=* ]] \ + && [[ "${lines[4]}" == audio_threads=* ]] \ + && [[ "${lines[5]}" == rt=* ]] \ + && [[ "${lines[6]}" == power=* ]] || return 1 + ABLETON_PREFERENCES_SHORTCUTS="${lines[2]#*=}" + ABLETON_PREFERENCES_DPI="${lines[3]#*=}" + ABLETON_PREFERENCES_AUDIO_THREADS="${lines[4]#*=}" + ABLETON_PREFERENCES_RT="${lines[5]#*=}" + ABLETON_PREFERENCES_POWER="${lines[6]#*=}" + _ableton_preferences_values_valid \ + "$ABLETON_PREFERENCES_SHORTCUTS" "$ABLETON_PREFERENCES_DPI" \ + "$ABLETON_PREFERENCES_AUDIO_THREADS" "$ABLETON_PREFERENCES_RT" \ + "$ABLETON_PREFERENCES_POWER" +} + +ableton_preferences_valid() +{ + _ableton_preferences_parse "${1:?preference path is required}" +} + +# The token deliberately includes identity, metadata, and bytes. Unsafe direct +# objects still receive a token so a caller can snapshot them without guessing +# their type, but writers below refuse them regardless of token equality. +ableton_preferences_object_token() +{ + local path="${1:?object path is required}" metadata digest target + if [ -L "$path" ]; then + metadata="$(stat -c '%d|%i|%f|%a|%u|%g|%s|%h|%w|%y|%z' -- "$path")" \ + || return 1 + target="$(readlink -- "$path")" || return 1 + printf 'symlink|%s|%s\n' "$metadata" "$target" + elif [ -f "$path" ]; then + metadata="$(stat -c '%d|%i|%f|%a|%u|%g|%s|%h|%w|%y|%z' -- "$path")" \ + || return 1 + digest="$(sha256sum -- "$path" | awk '{print $1}')" || return 1 + printf 'file|%s|%s\n' "$metadata" "$digest" + elif [ -e "$path" ]; then + metadata="$(stat -c '%d|%i|%f|%a|%u|%g|%s|%h|%w|%y|%z' -- "$path")" \ + || return 1 + printf 'other|%s\n' "$metadata" + else + printf 'absent\n' + fi +} + +_ableton_preferences_load_or_defaults() +{ + local path="$1" + ABLETON_PREFERENCES_SHORTCUTS=take + ABLETON_PREFERENCES_DPI=auto + ABLETON_PREFERENCES_AUDIO_THREADS=auto + ABLETON_PREFERENCES_RT=auto + ABLETON_PREFERENCES_POWER=performance + if [ -e "$path" ] || [ -L "$path" ]; then + if ! _ableton_preferences_parse "$path"; then + printf '!! launcher preferences are malformed; using defaults: %s\n' \ + "$path" >&2 + ABLETON_PREFERENCES_SHORTCUTS=take + ABLETON_PREFERENCES_DPI=auto + ABLETON_PREFERENCES_AUDIO_THREADS=auto + ABLETON_PREFERENCES_RT=auto + ABLETON_PREFERENCES_POWER=performance + return 1 + fi + fi + return 0 +} + +ableton_preferences_apply() +{ + local path="${1:-$(_ableton_preferences_path)}" + local env_shortcuts="${ABLETON_SHORTCUTS-}" env_dpi="${ABLETON_DPI_MODE-}" + local env_threads="${ABLETON_MAX_AUDIO_THREADS-}" env_rt="${ABLETON_RT-}" + local env_power="${ABLETON_POWER-}" + _ableton_preferences_load_or_defaults "$path" || true + + [ -z "$env_shortcuts" ] || ABLETON_PREFERENCES_SHORTCUTS="$env_shortcuts" + [ -z "$env_dpi" ] || ABLETON_PREFERENCES_DPI="$env_dpi" + [ -z "$env_threads" ] || ABLETON_PREFERENCES_AUDIO_THREADS="$env_threads" + [ -z "$env_rt" ] || ABLETON_PREFERENCES_RT="$env_rt" + if [ -n "$env_power" ]; then + case "$env_power" in + on|auto) ABLETON_PREFERENCES_POWER=performance ;; + *) ABLETON_PREFERENCES_POWER="$env_power" ;; + esac + fi + + case "$ABLETON_PREFERENCES_SHORTCUTS" in take|preserve) ;; *) + printf '!! ABLETON_SHORTCUTS must be take or preserve\n' >&2; return 2 ;; + esac + _ableton_dpi_environment_valid "$ABLETON_PREFERENCES_DPI" || { + printf '!! ABLETON_DPI_MODE must be auto, 100, fractional, preserve, dpi, or fractional\n' >&2 + return 2 + } + _ableton_audio_threads_valid "$ABLETON_PREFERENCES_AUDIO_THREADS" || { + printf '!! ABLETON_MAX_AUDIO_THREADS must be auto, off, or 1 through 63\n' >&2 + return 2 + } + case "$ABLETON_PREFERENCES_RT" in auto|off) ;; *) + printf '!! ABLETON_RT must be auto or off\n' >&2; return 2 ;; + esac + case "$ABLETON_PREFERENCES_POWER" in performance|balanced|off) ;; *) + printf '!! ABLETON_POWER must be performance, balanced, or off\n' >&2; return 2 ;; + esac + + ABLETON_SHORTCUTS="$ABLETON_PREFERENCES_SHORTCUTS" + ABLETON_DPI_MODE="$ABLETON_PREFERENCES_DPI" + ABLETON_MAX_AUDIO_THREADS="$ABLETON_PREFERENCES_AUDIO_THREADS" + ABLETON_RT="$ABLETON_PREFERENCES_RT" + ABLETON_POWER="$ABLETON_PREFERENCES_POWER" + export ABLETON_SHORTCUTS ABLETON_DPI_MODE ABLETON_MAX_AUDIO_THREADS \ + ABLETON_RT ABLETON_POWER +} + +ableton_preferences_merge() +{ + local path="${1:?preference path is required}" + local shortcuts="${2-}" dpi="${3-}" threads="${4-}" rt="${5-}" power="${6-}" + _ableton_preferences_load_or_defaults "$path" || true + [ -z "$shortcuts" ] || ABLETON_PREFERENCES_SHORTCUTS="$shortcuts" + [ -z "$dpi" ] || ABLETON_PREFERENCES_DPI="$dpi" + [ -z "$threads" ] || ABLETON_PREFERENCES_AUDIO_THREADS="$threads" + [ -z "$rt" ] || ABLETON_PREFERENCES_RT="$rt" + [ -z "$power" ] || ABLETON_PREFERENCES_POWER="$power" + _ableton_preferences_values_valid \ + "$ABLETON_PREFERENCES_SHORTCUTS" "$ABLETON_PREFERENCES_DPI" \ + "$ABLETON_PREFERENCES_AUDIO_THREADS" "$ABLETON_PREFERENCES_RT" \ + "$ABLETON_PREFERENCES_POWER" || return 2 + printf '%s|%s|%s|%s|%s\n' \ + "$ABLETON_PREFERENCES_SHORTCUTS" "$ABLETON_PREFERENCES_DPI" \ + "$ABLETON_PREFERENCES_AUDIO_THREADS" "$ABLETON_PREFERENCES_RT" \ + "$ABLETON_PREFERENCES_POWER" +} + +_ableton_token_matches() +{ + local path="$1" expected="$2" current + current="$(ableton_preferences_object_token "$path")" || return 1 + [ "$current" = "$expected" ] +} + +_ableton_pipeasio_seed_encode() +{ + printf '%s' "$1" | base64 | tr -d '\n' +} + +_ableton_pipeasio_seed_decode() +{ + local encoded="$1" decoded canonical + [ -n "$encoded" ] && [[ "$encoded" =~ ^[A-Za-z0-9+/]*={0,2}$ ]] || return 1 + decoded="$(printf '%s' "$encoded" | base64 --decode 2>/dev/null)" || return 1 + canonical="$(_ableton_pipeasio_seed_encode "$decoded")" || return 1 + [ "$canonical" = "$encoded" ] || return 1 + printf '%s' "$decoded" +} + +_ableton_pipeasio_full_token_valid() +{ + local token="$1" timestamp number pattern + timestamp='[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}[.][0-9]{9} [+-][0-9]{4}' + number='[0-9]+' + pattern="^file[|]${number}[|]${number}[|][0-9a-f]+[|][0-7]+[|]${number}[|]${number}[|]${number}[|]${number}[|](-|${timestamp})[|]${timestamp}[|]${timestamp}[|][0-9a-f]{64}$" + [[ "$token" =~ $pattern ]] +} + +_ableton_pipeasio_seed_identity_valid() +{ + local token="$1" timestamp number pattern + timestamp='[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}[.][0-9]{9} [+-][0-9]{4}' + number='[0-9]+' + pattern="^seed-file[|]${number}[|]${number}[|][0-9a-f]+[|][0-7]+[|]${number}[|]${number}[|]${number}[|](-|${timestamp})[|]${timestamp}[|][0-9a-f]{64}$" + [[ "$token" =~ $pattern ]] +} + +# Producer identity deliberately omits ctime, the one inode field changed by +# the no-clobber rename itself. Device, inode, birth/mtime, metadata and bytes +# remain stable across that rename and distinguish a same-value replacement. +ableton_pipeasio_seed_identity_token() +{ + local path="${1:?PipeASIO seed path is required}" metadata digest + [ -f "$path" ] && [ ! -L "$path" ] || return 1 + metadata="$(stat -c '%d|%i|%f|%a|%u|%g|%s|%w|%y' -- "$path")" \ + || return 1 + digest="$(sha256sum -- "$path" | awk '{print $1}')" || return 1 + printf 'seed-file|%s|%s\n' "$metadata" "$digest" +} + +_ableton_pipeasio_seed_temp_valid() +{ + local final_path="$1" temp_path="$2" temp_name + [ "$(dirname -- "$temp_path")" = "$(dirname -- "$final_path")" ] || return 1 + temp_name="$(basename -- "$temp_path")" + [[ "$temp_name" =~ ^[.]config[.]ini[.][A-Za-z0-9]{6}$ ]] +} + +# A fresh PipeASIO file belongs to prefix setup only while this exact object +# generation remains in place. The durable record lets both an in-process +# failure and the outer installer roll it back without guessing from content. +ableton_pipeasio_seed_record_load() +{ + local txn="${1:?transaction directory is required}" + local record="$txn/pipeasio-seed.v1" path_encoded token_encoded temp_encoded + local expected_path="${XDG_CONFIG_HOME:-$HOME/.config}/pipeasio/config.ini" + local -a lines=() + ABLETON_PIPEASIO_SEED_PRESENT=0 + ABLETON_PIPEASIO_SEED_PATH="" + ABLETON_PIPEASIO_SEED_TOKEN="" + ABLETON_PIPEASIO_SEED_TEMP="" + if [ ! -e "$record" ] && [ ! -L "$record" ]; then + return 0 + fi + [ -f "$record" ] && [ ! -L "$record" ] && [ -r "$record" ] \ + && _ableton_no_nul "$record" || return 1 + mapfile -t lines < "$record" || return 1 + [ "${#lines[@]}" -eq 4 ] \ + && [ "$(tail -c 1 -- "$record" | od -An -tu1 | tr -d '[:space:]')" = 10 ] \ + && [ "${lines[0]}" = format=1 ] \ + && [[ "${lines[1]}" == path_b64=* ]] \ + && [[ "${lines[2]}" == token_b64=* ]] \ + && [[ "${lines[3]}" == temp_b64=* ]] || return 1 + path_encoded="${lines[1]#*=}" + token_encoded="${lines[2]#*=}" + temp_encoded="${lines[3]#*=}" + ABLETON_PIPEASIO_SEED_PATH="$(_ableton_pipeasio_seed_decode "$path_encoded")" \ + || return 1 + ABLETON_PIPEASIO_SEED_TOKEN="$(_ableton_pipeasio_seed_decode "$token_encoded")" \ + || return 1 + [ "$ABLETON_PIPEASIO_SEED_PATH" = "$expected_path" ] || return 1 + if _ableton_pipeasio_full_token_valid "$ABLETON_PIPEASIO_SEED_TOKEN"; then + [ -z "$temp_encoded" ] || return 1 + elif _ableton_pipeasio_seed_identity_valid "$ABLETON_PIPEASIO_SEED_TOKEN"; then + ABLETON_PIPEASIO_SEED_TEMP="$(_ableton_pipeasio_seed_decode "$temp_encoded")" \ + || return 1 + _ableton_pipeasio_seed_temp_valid "$ABLETON_PIPEASIO_SEED_PATH" \ + "$ABLETON_PIPEASIO_SEED_TEMP" || return 1 + else + return 1 + fi + ABLETON_PIPEASIO_SEED_PRESENT=1 +} + +ableton_pipeasio_seed_record_preflight() +{ + ableton_pipeasio_seed_record_load "${1:?transaction directory is required}" +} + +ableton_pipeasio_seed_record_commit_preflight() +{ + ableton_pipeasio_seed_record_load "${1:?transaction directory is required}" \ + || return 1 + [ "$ABLETON_PIPEASIO_SEED_PRESENT" -eq 0 ] \ + || { _ableton_pipeasio_full_token_valid "$ABLETON_PIPEASIO_SEED_TOKEN" \ + && [ -z "$ABLETON_PIPEASIO_SEED_TEMP" ]; } +} + +_ableton_pipeasio_seed_record_write() +{ + local txn="${1:?transaction directory is required}" + local path="${2:?PipeASIO path is required}" token="${3:?generation token is required}" + local temp_path="${4-}" expected="${5:?expected record generation is required}" + local expected_temp="${6-}" + local record="$txn/pipeasio-seed.v1" tmp="" path_encoded token_encoded temp_encoded="" + local expected_path="${XDG_CONFIG_HOME:-$HOME/.config}/pipeasio/config.ini" + [ "$path" = "$expected_path" ] || return 1 + if _ableton_pipeasio_full_token_valid "$token"; then + [ -z "$temp_path" ] || return 1 + elif _ableton_pipeasio_seed_identity_valid "$token"; then + _ableton_pipeasio_seed_temp_valid "$path" "$temp_path" || return 1 + temp_encoded="$(_ableton_pipeasio_seed_encode "$temp_path")" || return 1 + else + return 1 + fi + path_encoded="$(_ableton_pipeasio_seed_encode "$path")" || return 1 + token_encoded="$(_ableton_pipeasio_seed_encode "$token")" || return 1 + ( + cleanup_pipeasio_seed_record_tmp() + { + [ -z "$tmp" ] || rm -f -- "$tmp" 2>/dev/null || true + } + trap cleanup_pipeasio_seed_record_tmp EXIT HUP INT TERM XFSZ + umask 077 + tmp="$(mktemp "$txn/.pipeasio-seed.XXXXXX")" || exit 1 + printf 'format=1\npath_b64=%s\ntoken_b64=%s\ntemp_b64=%s\n' \ + "$path_encoded" "$token_encoded" "$temp_encoded" > "$tmp" || exit 1 + chmod 600 "$tmp" || exit 1 + ableton_pipeasio_seed_record_load "$txn" || exit 1 + if [ "$expected" = absent ]; then + [ "$ABLETON_PIPEASIO_SEED_PRESENT" -eq 0 ] || exit 1 + mv -T -n -- "$tmp" "$record" || exit 1 + else + [ "$ABLETON_PIPEASIO_SEED_PRESENT" -eq 1 ] \ + && [ "$ABLETON_PIPEASIO_SEED_PATH" = "$path" ] \ + && [ "$ABLETON_PIPEASIO_SEED_TOKEN" = "$expected" ] \ + && [ "$ABLETON_PIPEASIO_SEED_TEMP" = "$expected_temp" ] || exit 1 + mv -T -f -- "$tmp" "$record" || exit 1 + fi + [ ! -e "$tmp" ] && [ -f "$record" ] && [ ! -L "$record" ] || exit 1 + tmp="" + ) || return 1 + ableton_pipeasio_seed_record_load "$txn" \ + && [ "$ABLETON_PIPEASIO_SEED_PRESENT" -eq 1 ] \ + && [ "$ABLETON_PIPEASIO_SEED_PATH" = "$path" ] \ + && [ "$ABLETON_PIPEASIO_SEED_TOKEN" = "$token" ] \ + && [ "$ABLETON_PIPEASIO_SEED_TEMP" = "$temp_path" ] +} + +ableton_pipeasio_seed_record_publish() +{ + _ableton_pipeasio_seed_record_write \ + "${1:?transaction directory is required}" \ + "${2:?PipeASIO path is required}" \ + "${3:?generation token is required}" "${4-}" absent "" +} + +ableton_pipeasio_seed_record_promote() +{ + local txn="${1:?transaction directory is required}" + local path="${2:?PipeASIO path is required}" + local provisional="${3:?provisional producer token is required}" + local identity_before full_token identity_after provisional_temp + _ableton_pipeasio_seed_identity_valid "$provisional" || return 1 + ableton_pipeasio_seed_record_load "$txn" || return 1 + [ "$ABLETON_PIPEASIO_SEED_PRESENT" -eq 1 ] \ + && [ "$ABLETON_PIPEASIO_SEED_PATH" = "$path" ] \ + && [ "$ABLETON_PIPEASIO_SEED_TOKEN" = "$provisional" ] || return 1 + provisional_temp="$ABLETON_PIPEASIO_SEED_TEMP" + [ -n "$provisional_temp" ] \ + && [ ! -e "$provisional_temp" ] && [ ! -L "$provisional_temp" ] || return 1 + identity_before="$(ableton_pipeasio_seed_identity_token "$path")" || return 1 + [ "$identity_before" = "$provisional" ] || return 1 + full_token="$(ableton_preferences_object_token "$path")" || return 1 + _ableton_pipeasio_full_token_valid "$full_token" || return 1 + identity_after="$(ableton_pipeasio_seed_identity_token "$path")" || return 1 + [ "$identity_after" = "$provisional" ] \ + && _ableton_token_matches "$path" "$full_token" || return 1 + _ableton_pipeasio_seed_record_write "$txn" "$path" "$full_token" "" \ + "$provisional" "$provisional_temp" +} + +ableton_pipeasio_seed_record_rollback() +{ + local txn="${1:?transaction directory is required}" + local record="$txn/pipeasio-seed.v1" + ableton_pipeasio_seed_record_load "$txn" || return 1 + [ "$ABLETON_PIPEASIO_SEED_PRESENT" -eq 1 ] || return 0 + if _ableton_pipeasio_full_token_valid "$ABLETON_PIPEASIO_SEED_TOKEN"; then + if _ableton_token_matches "$ABLETON_PIPEASIO_SEED_PATH" \ + "$ABLETON_PIPEASIO_SEED_TOKEN"; then + rm -f -- "$ABLETON_PIPEASIO_SEED_PATH" || return 1 + [ ! -e "$ABLETON_PIPEASIO_SEED_PATH" ] \ + && [ ! -L "$ABLETON_PIPEASIO_SEED_PATH" ] || return 1 + fi + else + local candidate current_identity + for candidate in "$ABLETON_PIPEASIO_SEED_PATH" "$ABLETON_PIPEASIO_SEED_TEMP"; do + current_identity="$(ableton_pipeasio_seed_identity_token \ + "$candidate" 2>/dev/null || true)" + [ "$current_identity" = "$ABLETON_PIPEASIO_SEED_TOKEN" ] || continue + rm -f -- "$candidate" || return 1 + [ ! -e "$candidate" ] && [ ! -L "$candidate" ] || return 1 + done + fi + rm -f -- "$record" +} + +ableton_pipeasio_seed_record_commit() +{ + local txn="${1:?transaction directory is required}" + local record="$txn/pipeasio-seed.v1" + ableton_pipeasio_seed_record_commit_preflight "$txn" || return 1 + [ "$ABLETON_PIPEASIO_SEED_PRESENT" -eq 1 ] || return 0 + rm -f -- "$record" +} + +ableton_preferences_write() +{ + local path="${1:?preference path is required}" expected="${2:?generation token is required}" + local shortcuts="${3-}" dpi="${4-}" threads="${5-}" rt="${6-}" power="${7-}" + local parent current + _ableton_preferences_values_valid "$shortcuts" "$dpi" "$threads" "$rt" "$power" \ + || return 2 + current="$(ableton_preferences_object_token "$path")" || return 1 + [ "$current" = "$expected" ] || return 1 + case "$current" in + absent) ;; + file\|*) ableton_preferences_valid "$path" || return 1 ;; + *) return 1 ;; + esac + parent="$(dirname -- "$path")" + mkdir -p -- "$parent" || return 1 + [ -d "$parent" ] && [ ! -L "$path" ] || return 1 + ( + local tmp="" + cleanup_preference_tmp() + { + [ -z "$tmp" ] || rm -f -- "$tmp" 2>/dev/null || true + } + trap cleanup_preference_tmp EXIT HUP INT TERM XFSZ + tmp="$(mktemp "$parent/.preferences.XXXXXX")" || exit 1 + umask 077 + printf '%s\n' \ + '# ableton-linux launcher preferences; managed by the installer' \ + 'format=1' "shortcuts=$shortcuts" "dpi=$dpi" \ + "audio_threads=$threads" "rt=$rt" "power=$power" > "$tmp" \ + || exit 1 + chmod 600 "$tmp" || exit 1 + _ableton_token_matches "$path" "$expected" || exit 1 + case "$expected" in + absent) + mv -T -n -- "$tmp" "$path" || exit 1 + [ ! -e "$tmp" ] || exit 1 ;; + *) mv -T -f -- "$tmp" "$path" || exit 1 ;; + esac + tmp="" + ) +} + +ableton_preferences_remove() +{ + local path="${1:?preference path is required}" expected="${2:?generation token is required}" + local remover="${3:-}" + ableton_preferences_valid "$path" || return 1 + _ableton_token_matches "$path" "$expected" || return 1 + if [ -n "$remover" ]; then + "$remover" "$path" + else + rm -f -- "$path" + fi +} + +_ableton_pipeasio_analyse() +{ + local path="$1" line section="" index=0 value="" section_count=0 key_count=0 + ABLETON_PIPEASIO_LINES=() + ABLETON_PIPEASIO_SECTION_END=-1 + ABLETON_PIPEASIO_KEY_INDEX=-1 + ABLETON_PIPEASIO_VALUE="" + ABLETON_PIPEASIO_FINAL_NEWLINE=0 + [ -f "$path" ] && [ ! -L "$path" ] && _ableton_no_nul "$path" \ + || return 1 + mapfile -t ABLETON_PIPEASIO_LINES < "$path" || return 1 + if [ -s "$path" ] \ + && [ "$(tail -c 1 -- "$path" | od -An -tu1 | tr -d '[:space:]')" = 10 ]; then + ABLETON_PIPEASIO_FINAL_NEWLINE=1 + fi + for index in "${!ABLETON_PIPEASIO_LINES[@]}"; do + line="${ABLETON_PIPEASIO_LINES[$index]}" + if [[ "$line" =~ ^\[([^]]+)\][[:space:]]*$ ]]; then + if [ "$section" = pipeasio ] && [ "$ABLETON_PIPEASIO_SECTION_END" -lt 0 ]; then + ABLETON_PIPEASIO_SECTION_END="$index" + fi + section="${BASH_REMATCH[1]}" + if [ "$section" = pipeasio ]; then + section_count=$((section_count + 1)) + fi + continue + fi + if [ "$section" = pipeasio ] \ + && [[ "$line" =~ ^[[:space:]]*buffer_size[[:space:]]*=[[:space:]]*([^[:space:]]+)[[:space:]]*$ ]]; then + key_count=$((key_count + 1)) + value="${BASH_REMATCH[1]}" + ABLETON_PIPEASIO_KEY_INDEX="$index" + fi + done + [ "$section" != pipeasio ] || [ "$ABLETON_PIPEASIO_SECTION_END" -ge 0 ] \ + || ABLETON_PIPEASIO_SECTION_END="${#ABLETON_PIPEASIO_LINES[@]}" + [ "$section_count" -eq 1 ] && [ "$key_count" -le 1 ] || return 1 + if [ "$key_count" -eq 1 ]; then + [[ "$value" =~ ^[0-9]+$ ]] \ + && [ "$value" -ge 32 ] && [ "$value" -le 8192 ] || return 1 + ABLETON_PIPEASIO_VALUE="$value" + fi +} + +ableton_pipeasio_buffer_read() +{ + local path="${1:?PipeASIO path is required}" + _ableton_pipeasio_analyse "$path" || return 1 + [ "$ABLETON_PIPEASIO_KEY_INDEX" -ge 0 ] || return 1 + printf '%s\n' "$ABLETON_PIPEASIO_VALUE" +} + +ableton_pipeasio_buffer_write() +{ + local path="${1:?PipeASIO path is required}" expected="${2:?generation token is required}" + local wanted="${3:?buffer value is required}" parent tmp index line inserted=0 + case "$wanted" in 64|128|256|512|1024) ;; *) return 2 ;; esac + _ableton_token_matches "$path" "$expected" || return 1 + _ableton_pipeasio_analyse "$path" || return 1 + parent="$(dirname -- "$path")" + tmp="$(mktemp "$parent/.config.ini.XXXXXX")" || return 1 + if ! ( + for index in "${!ABLETON_PIPEASIO_LINES[@]}"; do + line="${ABLETON_PIPEASIO_LINES[$index]}" + if [ "$ABLETON_PIPEASIO_KEY_INDEX" -eq "$index" ]; then + if [[ "$line" =~ ^([[:space:]]*buffer_size[[:space:]]*=[[:space:]]*)[^[:space:]]+([[:space:]]*)$ ]]; then + printf '%s%s%s\n' "${BASH_REMATCH[1]}" "$wanted" "${BASH_REMATCH[2]}" + else + exit 1 + fi + else + if [ "$ABLETON_PIPEASIO_KEY_INDEX" -lt 0 ] \ + && [ "$inserted" -eq 0 ] \ + && [ "$index" -eq "$ABLETON_PIPEASIO_SECTION_END" ]; then + printf 'buffer_size = %s\n' "$wanted" + inserted=1 + fi + printf '%s\n' "$line" + fi + done + if [ "$ABLETON_PIPEASIO_KEY_INDEX" -lt 0 ] && [ "$inserted" -eq 0 ]; then + printf 'buffer_size = %s\n' "$wanted" + fi + ) > "$tmp"; then + rm -f -- "$tmp" 2>/dev/null || true + return 1 + fi + if [ "$ABLETON_PIPEASIO_FINAL_NEWLINE" -eq 0 ] && [ -s "$tmp" ]; then + truncate -s -1 -- "$tmp" || { + rm -f -- "$tmp" 2>/dev/null || true + return 1 + } + fi + if ! chmod 600 "$tmp" || ! _ableton_token_matches "$path" "$expected"; then + rm -f -- "$tmp" 2>/dev/null || true + return 1 + fi + if ! mv -T -f -- "$tmp" "$path"; then + rm -f -- "$tmp" 2>/dev/null || true + return 1 + fi +} diff --git a/scripts/lib/ui.sh b/scripts/lib/ui.sh new file mode 100644 index 00000000..48221fb3 --- /dev/null +++ b/scripts/lib/ui.sh @@ -0,0 +1,1571 @@ +# shellcheck shell=bash +# The installer's renderer. Every sentence the installer shows lives in the +# dictionary below; the functions after "# END UI_TEXT" draw the nested tree +# from it. Edit the right-hand side of an entry to change what people read. +# %s placeholders are filled in the order the comment gives. +# +# Environment: ABLETON_UI_ACTION (which step list applies), ABLETON_UI_KIT=1 +# (rendering under the .run header, whose "prepare" step comes first), +# ABLETON_UI_TTY_FD (the terminal; unset means stdout, static), +# ABLETON_UI_CHARSET (utf8|ascii, captured before LC_ALL is exported), +# ABLETON_UI_PROMPT_TIMEOUT (seconds, minimum 1), ABLETON_INSTALLER_LOG. + +declare -A UI_TEXT=( + # ---- glyphs, UTF-8 set -------------------------------------------------- + [g_trunk]='│' [g_branch]='├─' [g_last]='└─' + [g_sub_trunk]='│' [g_step_join]='├──' [g_ok]='✓' + [g_fail]='𐄂' [g_info]='🛈' [g_warn]='⚠' + [g_detail]='>' [g_ellipsis]='…' + [g_box_top]='╒═╤═╕' [g_box_rule]='├┈┴┈┤' [g_box_side]='│' + [g_box_bottom]='╞═╛' [g_box_sep]='┊' + [g_step_top]='┲━┳━┓' [g_step_mid]='┃╏┃' [g_step_bottom]='┡━┻━┛' + [g_foot_top]='╞═╤═╕' [g_foot_rule]='├─┼─┤' [g_foot_dash]='├┈┼┈┤' + [g_foot_merge]='├┈┴┈┤' [g_foot_bottom]='╘═╛' + [g_spinner]='⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷ ⠁ ⠂ ⠄ ⡀ ⢀ ⠠ ⠐ ⠈' + + # ---- glyphs, ASCII set (used when the locale is not UTF-8) -------------- + [a_trunk]='|' [a_branch]='|-' [a_last]='`-' + [a_sub_trunk]='|' [a_step_join]='|--' [a_ok]='+' + [a_fail]='x' [a_info]='i' [a_warn]='!' + [a_detail]='>' [a_ellipsis]='...' + [a_box_top]='+=+=+' [a_box_rule]='+-+-+' [a_box_side]='|' + [a_box_bottom]='+=+' [a_box_sep]='|' + [a_step_top]='+=+=+' [a_step_mid]='|:|' [a_step_bottom]='+=+=+' + [a_foot_top]='+=+=+' [a_foot_rule]='+-+-+' [a_foot_dash]='+-+-+' + [a_foot_merge]='+-+-+' [a_foot_bottom]='+=+' + [a_spinner]='- \ | /' + + # ---- banner --------------------------------------------------------------- + [banner_title]='ABLETON-LINUX INSTALLER' + [banner_version]='v %s' # version + [banner_url]='https://github.com/shibco/ableton-linux/' + + # ---- headings ------------------------------------------------------------- + [h_system]='System Check' + [h_disk]='Disk Space' + [h_warnings]='Warnings' + [h_action]='Ableton-Linux Installer Choice:' + [h_found_at]='Found Ableton Live install files at' + [h_candidates]='Found multiple Ableton Live install candidates:' + + # ---- System Check rows ---------------------------------------------------- + [r_date]='Date and time' + [r_distro]='Distribution' + [r_kernel]='Kernel' + [r_cpu]='CPU' + [r_gpu]='GPU' + [r_memory]='Memory' + [r_desktop]='Desktop' + [r_pipewire]='PipeWire' + [r_deps]='Dependencies' + [r_temp]='Temporary' + [r_install]='Installation' + [r_kit]='Kit workspace' + [v_unavailable]='unavailable' + [v_desktop]='%s (%s)' # desktop, session type + [v_deps_ready]='%s (ready)' # list + [v_deps_missing]='missing: %s' # list + [v_free_at]='%s free at %s' # size, path + [v_free_under]='%s free under %s' # size, path + [v_required]='%s required' # size + + # ---- host warnings -------------------------------------------------------- + [w_none]='No host warnings found.' + [w_not_linux]='This installer requires Linux.' + [w_not_x86_64]='Ableton Linux currently requires an x86_64 host.' + [w_missing_command]='Required command is missing: %s.' # name + [w_temp_small]='Temporary storage is too small for the embedded installer kit.' + [w_home_small]='Less than 10 GiB is free for the Wine runtime, the ableton-linux prefix, and Live.' + [w_no_display]='No graphical desktop display was detected; Live cannot open without one.' + [w_pipewire_unknown]='PipeWire could not be identified; the installer will run its bundled compatibility check before changing audio support.' + [w_pipewire_old]='PipeWire %s is older than the required %s.' # found, floor + + # ---- action menu ---------------------------------------------------------- + [m_install]='[I]nstall' + [m_update]='[U]pdate' + [m_reinstall]='[R]einstall' + [m_remove]='Remo[v]e Ableton Linux' + # m_exit remains for renderer/API compatibility with callers of ui.sh. + [m_exit]='E[x]it' + [m_quit]='[Q]uit' + [m_default_hint]=' (or press Enter)' + [m_prompt]='Choose an action:' + [m_unknown]='Unknown action: %s' # answer + [m_uninstall_scope]='WHAT DO YOU WANT TO REMOVE' + [m_uninstall_runtime]='[R]untime only' + [m_uninstall_prefix]='[P]refix only' + [m_uninstall_all]='[A]ll' + [m_uninstall_exit]='[E]xit' + [m_uninstall_prompt]='Please choose [R/P/A/E]:' + [m_uninstall_unknown]='Unknown uninstall scope: %s' # answer + [label_update]='Update' + [label_install]='Install' + [label_reinstall]='Reinstall' + [label_remove]='Remove' + [label_runtime]='Install Wine runtime' + [label_prefix]='ableton-linux prefix %s' # subcommand + [label_link]='Ableton Link %s' # subcommand + [label_extract]='Extract installer kit' + [label_plan]='Show installation plan' + + # ---- Live installer candidates -------------------------------------------- + [c_item]='[%s] %s' # number, file name + [c_dir]='%s' # directory + [c_prompt]='Please choose a number:' + [c_hint]='(Press Enter for [1] or wait %s seconds)' # seconds + [c_none]='No Ableton Live installer was found in %s. Copy the Live download next to the installer, or run with --live-installer FILE or --skip-live-install.' + [c_invalid]='No valid installer was selected.' + + # ---- questions inside a step ---------------------------------------------- + [q_default_tag]=' (Default)' + [q_prompt]='Please choose [%s]:' # choices + [q_hint]='(Press Enter for default or wait %s seconds)' # seconds + [q_current_tag]=' (Current)' + [q_back_hint]='(Press Esc for previous setting)' + [q_buffer_title]='1/6 SET DEFAULT AUDIO BUFFER' + [q_buffer_explanation]='A lower default audio buffer reduces audio latency but can cause distortion during playback.' + [q_buffer_explanation_2]='You can change this default later using pipeasio-settings.' + [q_buffer_64]='[1] 64 frames' + [q_buffer_128]='[2] 128 frames' + [q_buffer_256]='[3] 256 frames' + [q_buffer_512]='[4] 512 frames' + [q_buffer_1024]='[5] 1024 frames' + [q_buffer_custom]='%s frames' + [q_shortcuts_title]='2/6 ENABLE KEYBOARD SHORTCUTS' + [q_shortcuts_explanation]='Determine whether Live can take over system shortcuts,' + [q_shortcuts_explanation_2]='to remain faithful to your Live keyboard shortcut muscle memory.' + [q_shortcuts_take]='[A]ssign to Live' + [q_shortcuts_preserve]='[P]reserve desktop shortcuts' + [q_dpi_title]='3/6 SET LIVE DISPLAY SCALING' + [q_dpi_explanation]='Changes how Live will be displayed on your montior.' + [q_dpi_explanation_2]='You almost always want '\''Automatic,'\'' unless your system has a particular quirk.' + [q_dpi_auto]='[A]utomatic' + [q_dpi_100]='[N]ormal (100%%)' + [q_dpi_fractional]='[F]ractional' + [q_dpi_preserve]='[P]reserve (Don'\''t change under any circumstance)' + [q_threads_title]='4/6 SET AUDIO WORKER LIMIT' + [q_threads_explanation]='Balances the available amount of computational power set aside for Live.' + [q_threads_explanation_2]='Automatic is our optimisation work. Live is optimised for Windows.' + [q_threads_explanation_3]='Depending on your CPU, more may not always be better!' + [q_threads_auto]='[A]utomatic' + [q_threads_off]='[L]et Live decide (Not recommended)' + [q_threads_custom]='[1-63] Specify a custom limit (1 to 63)' + [q_threads_value]='%s workers' + [q_threads_invalid]='Choose Automatic, Let Live decide, or a number from 1 to 63.' + [q_rt_title]='5/6 TOGGLE REAL-TIME SCHEDULING' + [q_rt_explanation]='Another optimisation that prioritises Live and audio on your system.' + [q_rt_explanation_2]='Change this to normal if you are struggling with performance or issues with' + [q_rt_explanation_3]='your system.' + [q_rt_auto]='[A]utomatic' + [q_rt_off]='[N]ormal scheduling' + [q_power_title]='6/6 SET POWER MANAGEMENT' + [q_power_explanation]='For convenience, this project can automatically manage your linux machine'\''s power profile' + [q_power_explanation_2]='while Live is open and running.' + [q_power_performance]='[P]erformance' + [q_power_balanced]='[B]alanced' + [q_power_off]='[D]on'\''t change' + [q_choice_invalid]='Choose one of the listed options.' + [q_overwrite_title]='FILES FROM AN EARLIER INSTALLATION ALREADY EXIST' + [q_overwrite_all]='[O]verwrite all' + [q_keep]='[K]eep originals' + [q_abort]='[A]bort' + [q_overwrite_chosen]='Existing files will be moved to %s before they are replaced.' # backup dir + [q_keep_chosen]='Existing files are kept unchanged; new files are written only where nothing exists.' + [q_abort_chosen]='Stopping before any existing file is replaced.' + [q_stop_prefix_title]='STOP PROGRAMS USING THE ABLETON-LINUX PREFIX' + [q_stop_prefix_end]='[E]nd every program in the ableton-linux prefix' + [q_stop_prefix_leave]='[L]eave the ableton-linux prefix running' + [q_stop_wine_title]='STOP ALL RUNNING WINE PROCESSES' + [q_stop_wine_yes]='[Y]es, stop them and continue' + [q_stop_wine_no]='[N]o, exit the installer' + + # ---- steps ---------------------------------------------------------------- + [s_prepare]='Prepare the installer kit' + [s_validate]='Check the host and the request' + [s_runtime_install]='Install the Wine runtime' + [s_runtime_update]='Update the Wine runtime' + [s_prefix_create]='Prepare the ableton-linux prefix' + [s_prefix_update]='Update the ableton-linux prefix' + [s_prefix_repair]='Repair Live 11 preferences' + [s_live]='Install Ableton Live' + [s_launchers]='Install launchers and shortcuts' + [s_link_settings]='Configure Ableton Link and save settings' + [s_link]='Configure Ableton Link' + [s_link_status]='Show Ableton Link status' + [s_remove]='Remove Ableton Linux' + [s_plan]='Show the plan' + [s_extract]='Extract the installer kit' + [s_finish_install]='Finish the installation' + [s_finish_update]='Finish the update' + [s_finish_runtime]='Finish the Wine runtime install' + [s_finish_prefix]='Finish the ableton-linux prefix setup' + [s_finish_link]='Finish the Ableton Link change' + [s_complete]='Step %s Complete!' # number + [s_failed]='Step %s Failed!' # number + [i_done]='Done.' + [i_failed]='Failed.' + + # ---- .run header items (before the kit exists) ---------------------------- + [i_copy]='Copy the embedded kit' + [i_copy_done]='Copied %s MiB.' # total + [i_check]='Check the embedded kit' + [i_check_done]='The kit is intact.' + [i_extract]='Extract the embedded kit' + [i_extract_to]='Extracted the kit to %s.' # dir + [i_progress]='(%s / %s MiB)' # done, total + [e_copy]='The embedded installer kit could not be copied.' + [e_check]='The embedded installer kit could not be checked.' + [e_integrity]='The installer integrity check failed. Download or copy the .run file again.' + [e_extract]='The embedded installer kit could not be extracted.' + [e_damaged]='This installer file is incomplete or damaged. Download it again.' + [e_workspace]='The installer could not create its temporary workspace under %s.' # dir + [e_temp_left]='The installer finished, but temporary files remain at %s.' # dir + [e_extract_args]='extract needs one destination directory.' + + # ---- footer --------------------------------------------------------------- + [f_title]='Ableton-Linux %s v. %s' # action label, version + [f_complete]='Complete' + [f_failed]='Failed' + [f_interrupted]='Interrupted' + [f_cancelled]='Cancelled' + [f_time]='Time taken:' + [f_seconds]='%s sec' # seconds + [f_warnings]='Warnings:' + [f_errors]='Errors:' + [f_runtime]='runtime:' + [f_prefix]='ableton-linux prefix:' + + # ---- tail (plain text after the footer) ----------------------------------- + [t_launch]='Launch Ableton Live via your desktop applications launcher or in the terminal:' + [t_launch_cmd]='> $ ableton-live' + [t_bsky_h]='Stay up to date:' + [t_bsky]='https://bsky.app/profile/wires.sh' + [t_discord_h]='Ableton on Linux Discord:' + [t_discord]='https://discord.gg/XD5EeZyP3' + [t_help_h]='Need help?' + [t_help]='https://github.com/shibco/ableton-linux' + [t_report_h]='Report this error:' + [t_report]='https://github.com/shibco/ableton-linux/issues' + [t_errors_h]='Errors:' + [t_error_item]='> %s' # message + [t_log_h]='Saved a log of this operation at' + [t_log_none]='The installer could not save its log.' + + # ---- help ----------------------------------------------------------------- + [help_title]='Ableton Linux installer' + [help_commands]='install, update, runtime install, prefix create|update|repair-live11, link enable|disable|status, uninstall, extract DIR, plan COMMAND ...' + [help_menu]='Run without a command for the interactive action menu.' + + # ---- sentences owned by the installer scripts ----------------------------- + # Keys start with the script: d_ installer.sh, i_ install.sh, + # p_ setup-prefix.sh, l_ setup-link.sh, u_ uninstall.sh, m_ manifest.sh, + # pa_ pipeasio.sh. + [d_plan_prefix_update_line]='Update the ableton-linux prefix at %s' # prefix + [d_plan_prefix_create_line]='Create the ableton-linux prefix at %s' # prefix + [d_sum_updated]='Ableton is updated' + [d_sum_live_installed]='Ableton Live %s is installed' # major + [d_sum_runtime_prefix_ready]='The Ableton runtime and ableton-linux prefix are ready' + [d_deprecated_option]='%s is deprecated; use %s instead.' # old, new + [i_integration_partial]='Some shortcuts or support files could not be updated.' + [i_kept_files]='Kept %s existing files unchanged.' # count + [i_runtime_ready]='The Wine runtime is ready.' + [m_kept_file]='Kept existing file unchanged: %s.' # path + [d_link_files_retry]='Some Ableton Link support files need another try.' + [d_settings_kept]='Saved settings were kept unchanged.' + [d_prefix_held_unknown]='The Live installer finished, but a program is still using its ableton-linux prefix:' + [d_prefix_held_background]='The Live installer finished; a background program is still using its ableton-linux prefix.' + [d_prefix_held_hint]='You can leave it running. To stop every program in the ableton-linux prefix, run:' + [m_overwrite_all_failed]='Overwrite all could not be saved for this installer run. This file was left unchanged.' + # -- scripts/installer.sh -- + [d_plan_prefix_create]='Create the ableton-linux prefix %s using the runtime at %s' + [d_plan_prefix_update]='Update the ableton-linux prefix at %s' + [d_plan_repair_live11]='Move aside stale Live 11 Max preferences in %s' + [d_plan_pipeasio_config]='Configure PipeASIO at %s/pipeasio/config.ini' + [d_plan_run_live_installer]='Run the Live %s installer: %s' + [d_plan_save_settings]='Save installer settings to %s' + [d_plan_link_mode]='Set Ableton Link mode to %s' + [d_link_enabled]='Ableton Link is enabled (%s)' + [d_link_off]='Ableton Link is off.' + [d_reusing_extracted_installer]='Reusing the extracted Live installer at %s' + [d_extract_live_installer]='Extract the Live installer (up to %s seconds)' + [d_run_live_installer]='Run the Live installer (up to %s seconds)' + [d_prefix_holder_entry]='%s (pid %s)' + [d_prefix_held_command]='WINEPREFIX=%s %s/bin/wineserver -k' + [d_prefix_programs_stopped]='Stopped the programs in the ableton-linux prefix' + [d_prefix_programs_left]='Left them running; the next update may ask you to close them' + [d_remove_prefix_recovery]='Remove ableton-linux prefix recovery files' + [d_link_configured_mode]='Ableton Link is set to %s mode' + [d_settings_ready]='Saved settings are ready' + [d_sum_runtime_installed]='The Wine runtime is installed' + [d_sum_runtime_path]='Runtime: %s' + [d_sum_prefix_path]='ableton-linux prefix: %s' + [d_sum_shortcuts_retry]='Desktop shortcuts need another try' + [d_sum_link_retry]='Link is unchanged; you can retry its setup' + [d_settings_retry]='Saved settings need another try' + [d_sum_recovery_files_remain]='Old recovery files remain at %s' + [d_sum_prefix_ready]='The ableton-linux prefix is ready' + [d_sum_prefix_updated]='The ableton-linux prefix is updated' + [d_sum_command_completed]='%s%s completed' + # -- scripts/install.sh -- + [i_check_wine_package]='Check the Wine package %s' + [i_wine_package_valid]='%s is valid' + [i_unpack_runtime]='Unpack the Wine runtime' + [i_runtime_checks_passed]='The Wine runtime passed its checks' + [i_validate_ok]='The selected files passed all checks' + [i_plan_heading]='Resolved configuration' + [i_plan_runtime]='Install or update Wine at %s' + [i_plan_panel_shortcuts]='Update the PipeASIO Settings shortcuts to match this Wine build' + [i_plan_integration]='Install or update launchers, desktop shortcuts, file-opening support, and icons' + [i_plan_tools]='Install diagnostic and recovery tools' + [i_plan_max9]='Install or update the Max 9 launcher and file-opening support' + [i_plan_link]='Install or update Ableton Link support' + [i_plan_link_external]='Use the configured Ableton Link helper and leave it unchanged: %s' + [i_plan_backups]='Back up each replaced project file under %s/backups' + [i_setup_launchers]='Set up Ableton launchers and desktop shortcuts' + [i_refresh_desktop_menus]='Refresh desktop menus and file associations' + [i_install_link_files]='Install Ableton Link support files' + [i_link_helper_external]='Using the configured Ableton Link helper at %s and leaving it unchanged' + [i_panel_files_available]='Launcher setup continues with the PipeASIO files that are available' + [i_backed_up_files]='Backed up %s replaced files in this step' + [i_launchers_ready]='Launchers, shortcuts, and file support are ready' + [i_link_files_ready]='Ableton Link support files are ready' + [i_hint_audio_report]='Audio report: %s/audio-report.sh' + [i_hint_ntsync_check]='NTSync check: %s/check-ntsync.sh' + [i_hint_realtime_setup]='Realtime setup: %s/setup-realtime.sh' + [i_hint_rollback]='Restore previous Wine version: %s/rollback.sh' + # -- scripts/setup-prefix.sh -- + [p_using_existing_prefix]='Using the existing ableton-linux prefix at %s' + [p_settings_valid]='ableton-linux prefix settings are valid' + [p_repair_moved]='Max preferences moved aside; Max regenerates them on next start' + [p_repair_nothing]='No Live 11 Max preferences needed repair' + [p_copy_existing_prefix]='Copy the existing ableton-linux prefix' + [p_options_line_removed]='Removed %s from %s' + [p_options_file_removed]='Removed %s because it contained only %s' + [p_scale_detected]='Detected display scale %s (%s); applying %s scaling settings' + [p_scale_already_configured]='Display scaling is already set for scale %s' + [p_scale_undetected_existing]='Display scale could not be detected, so existing settings were kept' + [p_zip_live_major]='%s is Live %s; preparing its required Windows support files' + [p_prepare_wine]='Prepare Wine at %s' + [p_update_prefix_files]='Update ableton-linux prefix files' + [p_keep_fonts_support]='Keep the installed Windows fonts and support files' + [p_install_fonts_support]='Install Windows fonts and support files for Live %s' + [p_using_bundled_cache]='Using the bundled dependency cache' + [p_install_m4l_fonts]='Install Max for Live fallback fonts' + [p_check_vc]='Check Microsoft Visual C++ support' + [p_vc_install_from_live]='Installing the Microsoft Visual C++ files included with Live' + [p_vc_already_installed]='Required Microsoft Visual C++ files are already installed' + [p_vc_installing_file]='Installing %s' + [p_configure_scaling]='Configure display scaling' + [p_scaling_kept]='Existing display scaling settings were kept' + [p_scaling_deferred_to_launch]='Live-specific scaling is applied automatically when Live first starts' + [p_configure_theme]='Configure the desktop theme' + [p_theme_applying]='Applying the %s theme' + [p_theme_kept]='Existing theme settings were kept' + [p_configure_text]='Configure text rendering' + [p_subpixel_bgr]='Using BGR subpixel order' + [p_subpixel_rgb]='Using RGB subpixel order' + [p_configure_pipeasio]='Configure PipeASIO audio' + [p_pipeasio_settings_created]='Created default PipeASIO settings at %s' + [p_pipeasio_settings_kept]='Kept the existing PipeASIO settings at %s' + [p_pipeasio_settings_link_kept]='Kept your PipeASIO settings link even though its target is missing: %s' + [p_configure_dialogs_push]='Configure file dialogs and Push USB access' + [p_removing_obsolete_shortcut]='Removing an obsolete desktop shortcut: %s' + [p_remove_obsolete_audio_setting]='Remove an obsolete Live audio setting' + [p_enable_gpu_renderer]='Enable Live'\''s GPU renderer' + [p_check_live]='Check Ableton Live' + [p_live_installed]='Live is already installed' + [p_live_install_pending]='The installer installs Live after Wine setup finishes' + [p_live_installed_unchanged]='Live is already installed and was left unchanged' + [p_live_autoinstall_disabled]='Automatic Live installation is disabled' + [p_live_zip_found]='Found %s. Set ABLETON_LIVE_AUTOINSTALL=1 to install it' + [p_live_zip_missing_hint]='Put the official ableton.com zip there, or set ABLETON_INSTALLER_DIR to its directory' + [p_live_eula_note]='Ableton'\''s license agreement appears when Live first starts' + [p_live_autoinstall_hint]='To install your own Ableton download automatically, place it in %s and set ABLETON_LIVE_AUTOINSTALL=1' + [p_live_unpacking]='Unpacking %s' + [p_live_installer_window]='Starting the Ableton installer. Follow the installer window' + [p_live_installing]='Installing Ableton Live. This can take a few minutes' + [p_prefix_ready]='The ableton-linux prefix is ready' + [p_wine_ready_at]='Wine is ready at %s' + [p_remaining_steps_licensed]='Remaining steps (you supply your own license):' + [p_remaining_step1_installed]='1. Live is installed; nothing more to supply here' + [p_remaining_steps_unlicensed]='Remaining steps (you supply Ableton and your own license):' + [p_remaining_step1_install]='1. Install Live (any edition) through this Wine (plain wine reads WINEPREFIX, not the ABLETON_* launcher variables). For Live 12 these flags let the installer run by itself and skip Ableton'\''s Windows USB audio driver, which does nothing on Linux: WINEPREFIX=%s %s/bin/wine "/path/to/Ableton Live 12 Edition Installer.exe" /SILENT /SUPPRESSMSGBOXES /NORESTART '\''/MERGETASKS=!audiodriver'\''. Live 11'\''s installer is a WiX Burn bundle and ignores those flags; run it without them and click through its window' + [p_remaining_step2_launch]='2. Launch: ableton-live' + [p_remaining_step3_authorize]='3. Authorize Live with your own account (this binds to the prefix'\''s MachineGuid)' + [p_remaining_step4_audio]='4. Audio: Settings/Preferences > Audio > Driver Type: ASIO > Audio Device: PipeASIO. PipeASIO is a native PipeWire client with no JACK layer involved' + # -- scripts/setup-link.sh -- + [l_remove_ufw_rule]='Removing the ufw rule added for Ableton Link' + [l_remove_firewalld_rule]='Removing the firewalld rule added for Ableton Link' + [l_remove_multicast_hook]='Removing the old Ableton Link multicast hook' + [l_turn_off_link]='Turn off Ableton Link' + [l_kept_pr182_helper]='Kept the older custom Link helper at %s' + [l_kept_external_helper]='Kept the external Link helper at %s' + [l_off_unsaved]='Ableton Link is off; run the installer again to save that preference' + [l_off]='Ableton Link is off' + [l_off_residual]='Ableton Link is off; a later installer run can clean up some obsolete local files' + [l_check_ufw_rule]='Checking the ufw rule for Link (UDP 20808)' + [l_restore_ufw_rule]='Restoring the missing ufw rule for Link' + [l_check_firewalld_rule]='Checking the firewalld rule for Link (UDP 20808)' + [l_restore_firewalld_rule]='Restoring the missing firewalld rule for Link' + [l_ufw_rule_exists]='ufw already allows UDP 20808; the existing rule is left unchanged' + [l_ufw_open_port]='ufw is active; opening UDP 20808 for Ableton Link' + [l_firewalld_rule_exists]='firewalld already allows UDP 20808; the existing rule is left unchanged' + [l_firewalld_open_port]='firewalld is active; opening UDP 20808 for Ableton Link' + [l_no_firewall]='No active ufw or firewalld; the firewall is unchanged' + [l_plan_disable_heading]='Turn off Ableton Link' + [l_plan_stop_service]='Stop the Ableton Link service and helper' + [l_plan_remove_firewall]='Remove the firewall rule added for Link' + [l_plan_remove_multicast]='Remove the old Link multicast route' + [l_plan_keep_files_save_off]='Keep installed Link support files and save Link as off' + [l_plan_enable_heading]='Enable Ableton Link' + [l_plan_keep_ufw]='Keep the existing ufw rule for UDP 20808' + [l_plan_open_ufw]='Open UDP 20808 with ufw' + [l_plan_check_ufw]='Check ufw and open UDP 20808 if needed' + [l_plan_keep_firewalld]='Keep the existing firewalld rule for UDP 20808' + [l_plan_open_firewalld]='Open UDP 20808 with firewalld' + [l_plan_check_firewalld]='Check firewalld and open UDP 20808 if needed' + [l_plan_firewall_unchanged]='Leave the firewall unchanged' + [l_plan_mode_session]='Start Link only while Ableton Live is running' + [l_plan_mode_always]='Start Link in the background with your user session' + [l_plan_save_setting]='Save the Link setting' + [l_enable_link]='Enable Ableton Link' + [l_enabled_unsaved]='Ableton Link system changes finished; run the installer again to save the preference' + [l_enabled_session]='Ableton Link is enabled while Ableton Live is running' + [l_enabled_always]='Ableton Link is enabled in the background' + [l_status_mode_off]='mode: off' + [l_status_mode_session]='mode: session (while Ableton Live is running)' + [l_status_mode_always]='mode: always (background)' + [l_status_running_systemd]='state: running (systemd)' + [l_status_running_pid]='state: running (pid %s)' + [l_status_stopped]='state: stopped' + [l_status_not_installed]='state: not installed' + [l_status_firewall_unknown]='firewall: unknown (the saved Link firewall information is unreadable)' + [l_status_firewall_unchanged]='firewall: unchanged' + [l_status_firewall_ufw]='firewall: UDP 20808 opened with ufw' + [l_status_firewall_firewalld]='firewall: UDP 20808 opened with firewalld' + # -- scripts/uninstall.sh -- + [u_component_integration]='Remove Linux integration' + [u_component_runtime]='Remove the Wine runtime' + [u_component_prefix]='Remove the ableton-linux prefix' + [u_component_settings]='Remove installer settings' + [u_component_state]='Remove shared state' + [u_moved_to_trash]='Moved to Trash: %s' + [u_deleted_permanently]='Permanently deleted: %s' + [u_changed_path]='The installed file or link differs from the recorded version: %s' + [u_kept_changed]='Left the changed file or link in place: %s' + [u_q_remove_changed]='REMOVE THE CHANGED FILE OR LINK' + [u_q_remove_changed_yes]='[R]emove this path' + [u_q_remove_changed_no]='[K]eep this path' + [u_no_trash]='No supported Trash tool is available; removal would permanently delete files.' + [u_q_permanent]='PERMANENTLY DELETE THE SELECTED FILES' + [u_q_permanent_yes]='[D]elete permanently' + [u_q_permanent_no]='[K]eep everything' + [u_q_delete_prefix]='REMOVE LIVE, ITS AUTHORISATION, AND THE ABLETON-LINUX PREFIX' + [u_q_delete_yes]='[D]elete the ableton-linux prefix' + [u_q_delete_no]='[K]eep the ableton-linux prefix' + [u_report_heading]='What remains' + [u_report_runtime]='Runtime: %s' + [u_report_prefix]='ableton-linux prefix: %s' + [u_report_integration]='Linux integration: %s' + [u_report_settings]='Installer settings: %s' + [u_report_state]='Shared state: %s' + # -- scripts/lib/manifest.sh -- + [m_rebuilding_file_list]='Rebuilding the installed-file list' + # -- scripts/lib/pipeasio.sh -- + [pa_pipewire_supported]='PipeWire %s (client %s) is supported' + [pa_preserved_existing]='Kept your existing %s' + [pa_restored_previous]='Restored your previous %s' + [pa_optional_tools_missing]='Optional PipeWire tools missing: %s.' + [pa_optional_tools_hint]='pw-dump enables panel device lists; pw-top enables Monitor; both enrich audio-report.sh.' + [pa_qt_advice]='PipeASIO Settings is optional. To enable it, run: %s' + [pa_panel_not_packaged]='PipeASIO Settings was not packaged; existing shortcut files were left unchanged' + [pa_registered]='PipeASIO is registered with Wine' + # -- scripts/lib/config.sh -- +) + +# Steps per action, in table order; X is the count and N the position. +# Without ABLETON_UI_KIT=1 the leading s_prepare step belongs to nobody and +# is dropped. +declare -A UI_STEPS=( + [install]='s_prepare s_validate s_runtime_install s_prefix_create s_live s_launchers s_link_settings s_finish_install' + [update]='s_prepare s_validate s_runtime_update s_prefix_update s_launchers s_link_settings s_finish_update' + [runtime]='s_prepare s_validate s_runtime_install s_finish_runtime' + [prefix_create]='s_prepare s_validate s_prefix_create s_finish_prefix' + [prefix_update]='s_prepare s_validate s_prefix_update s_finish_prefix' + [prefix_repair]='s_prepare s_validate s_prefix_repair' + [link_enable]='s_prepare s_validate s_link s_finish_link' + [link_disable]='s_prepare s_validate s_link s_finish_link' + [link_status]='s_prepare s_validate s_link_status' + [uninstall]='s_prepare s_validate s_remove' + [plan]='s_prepare s_validate s_plan' + [extract]='s_extract' +) +# END UI_TEXT + +# --------------------------------------------------------------------------- +# The charset is read from the environment the user started with. Scripts +# source this file before they export LC_ALL=C.UTF-8 so the capture is real. +if [ -z "${ABLETON_UI_CHARSET:-}" ]; then + case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in + *[Uu][Tt][Ff]-8*|*[Uu][Tt][Ff]8*) ABLETON_UI_CHARSET=utf8 ;; + *) ABLETON_UI_CHARSET=ascii ;; + esac +fi +export ABLETON_UI_CHARSET +UI_SPINNER_PID="${UI_SPINNER_PID:-}" +UI_SPINNER_OWNER_PID="${UI_SPINNER_OWNER_PID:-}" +export UI_SPINNER_PID UI_SPINNER_OWNER_PID + +# ---- state of this process ------------------------------------------------ +UI_READY=0 +UI_FD=1 UI_LIVE=0 UI_ROWS=0 UI_COLS=80 UI_WIDTH=78 UI_LOG="" +UI_STEP_OPEN=0 UI_STEP_N=0 UI_STEP_ITEMS=0 +UI_ITEM_OPEN=0 UI_ITEM_TITLE="" UI_ITEM_MARK="" UI_ITEM_LINES=0 UI_ITEM_LAST=0 +UI_ITEM_ROWS=0 UI_ITEM_COLS=0 UI_RUN_ACTIVE=0 UI_SPINNER_KIND="" UI_ITEM_WAIT=0 +UI_ITEM_STATE="" +UI_ANSWER="" UI_R="" UI_G="" UI_WRAPPED=0 UI_READ_ACTIVE=0 +UI_STAMP_FORMAT='%(%d/%m/%Y, %H:%M:%S)T' + +ui__init() +{ + [ "$UI_READY" -eq 0 ] || return 0 + UI_READY=1 + # Character counting must match the glyph set: a UTF-8 dictionary under a + # C locale would pad by bytes. The scripts export C.UTF-8 themselves; this + # covers a renderer used on its own. + if [ "$ABLETON_UI_CHARSET" = utf8 ]; then + case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in + *[Uu][Tt][Ff]-8*|*[Uu][Tt][Ff]8*) ;; + *) if [ -n "${LC_ALL:-}" ]; then export LC_ALL=C.UTF-8; else export LC_CTYPE=C.UTF-8; fi ;; + esac + fi + UI_LOG="${ABLETON_INSTALLER_LOG:-}" + [ "$UI_LOG" != /dev/null ] || UI_LOG="" + case "${ABLETON_UI_TTY_FD:-}" in + ''|*[!0-9]*) UI_FD=1 ;; + *) UI_FD="$ABLETON_UI_TTY_FD" ;; + esac + if [ -n "${ABLETON_UI_TTY_FD:-}" ] && [ -t "$UI_FD" ] 2>/dev/null \ + && [ -n "${TERM:-}" ] && [ "$TERM" != dumb ]; then + UI_LIVE=1 + fi + ui__size +} + +# Rows and columns of the screen. Live rendering needs the real terminal +# size; static rendering takes COLUMNS or 80. +ui__size() +{ + local size rows cols + if [ "$UI_LIVE" -eq 1 ]; then + size="$(stty size <&"$UI_FD" 2>/dev/null)" || size="" + rows="${size%% *}"; cols="${size#* }" + [ "$size" != "$rows" ] || cols="" + case "$rows$cols" in + ''|*[!0-9]*) UI_LIVE=0 ;; + *) if [ "$rows" -gt 0 ] && [ "$cols" -gt 0 ]; then + UI_ROWS="$rows"; UI_COLS="$cols" + else + UI_LIVE=0 + fi ;; + esac + fi + if [ "$UI_LIVE" -eq 0 ]; then + # Static output is a log or a pipe: it wraps only when COLUMNS asks + # for it, so paths and sentences stay whole for grep. + UI_ROWS=0 + case "${COLUMNS:-}" in + ''|*[!0-9]*) UI_COLS=100002 ;; + *) UI_COLS="$COLUMNS" ;; + esac + fi + UI_WIDTH=$((UI_COLS - 2)) + [ "$UI_WIDTH" -ge 20 ] || UI_WIDTH=20 +} + +ui__g() # glyph name -> UI_G +{ + if [ "$ABLETON_UI_CHARSET" = utf8 ]; then + UI_G="${UI_TEXT[g_$1]-}" + else + UI_G="${UI_TEXT[a_$1]-}" + fi +} + +ui__text() # key, args -> UI_R +{ + local key="$1" fmt + shift + fmt="${UI_TEXT[$key]-}" + [ -n "$fmt" ] || fmt="[$key]" + case "$fmt" in + *%*) printf -v UI_R -- "$fmt" "$@" ;; + *) UI_R="$fmt" ;; + esac +} + +ui_text() # key, args -> stdout, for callers that compose a value +{ + ui__text "$@" + printf '%s' "$UI_R" +} + +ui__rep() # glyph, count -> UI_R +{ + local pad + printf -v pad '%*s' "$2" '' + UI_R="${pad// /$1}" +} + +ui__pad() # text, width -> UI_R (left aligned, character count) +{ + local n=$(( $2 - ${#1} )) + if [ "$n" -gt 0 ]; then printf -v UI_R '%s%*s' "$1" "$n" ''; else UI_R="$1"; fi +} + +ui__rpad() # text, width -> UI_R (right aligned) +{ + local n=$(( $2 - ${#1} )) + if [ "$n" -gt 0 ]; then printf -v UI_R '%*s%s' "$n" '' "$1"; else UI_R="$1"; fi +} + +ui__log() # level, text +{ + local stamp + [ -n "$UI_LOG" ] || return 0 + printf -v stamp "$UI_STAMP_FORMAT" -1 + printf '%-7s%s [ableton-linux][installer][ui] %s\n' "[$1]" "$stamp" "$2" >> "$UI_LOG" 2>/dev/null || true + return 0 +} + +ui__event() # level, context, text +{ + local level="$1" context="$2" message="$3" stamp event_log="${ABLETON_INSTALLER_EVENT_LOG:-}" + [ -n "$event_log" ] && [ "$event_log" != /dev/null ] || return 0 + message="${message//$'\r'/ }" + message="${message//$'\n'/ }" + message="${message//$'\033'/Escape}" + printf -v stamp "$UI_STAMP_FORMAT" -1 + printf '%-7s%s [ableton-linux][installer][%s] %s\n' "[$level]" "$stamp" "$context" "$message" >> "$event_log" 2>/dev/null || true + return 0 +} + +ui__screen() # raw bytes to the screen, no log +{ + { printf '%s' "$1" >&"$UI_FD"; } 2>/dev/null || true + return 0 +} + +# Add live-terminal presentation without changing the text that is measured +# or written to the log. OSC 8 turns displayed web addresses into links. SGR +# colours start after the tree prefix and end before trailing whitespace, so +# every box-drawing glyph remains in the terminal's default colour. +ui__decorate() # state, plain text -> UI_R +{ + local state="$1" shown="$2" scheme="" before rest url after linked code="" + local prefix="" body trailing="" t branch last sub + if [ "$UI_LIVE" -eq 1 ]; then + if [ -z "${NO_COLOR:-}" ]; then + case "$state" in + active|status) code=96 ;; + complete) code=36 ;; + wait|warn) code=93 ;; + fail) code=91 ;; + esac + fi + + # Coloured lines can have an outer trunk and one inner trunk or + # branch. Keep that structural prefix, its indentation, and trailing + # padding outside the SGR span. The branch alternatives must be tested + # before the one-character sub-trunk in the ASCII glyph set. + if [ -n "$code" ]; then + rest="$shown" + while [ "${rest# }" != "$rest" ]; do prefix="$prefix "; rest="${rest# }"; done + ui__g trunk; t="$UI_G" + case "$rest" in "$t"*) prefix="$prefix$t"; rest="${rest#"$t"}" ;; esac + while [ "${rest# }" != "$rest" ]; do prefix="$prefix "; rest="${rest# }"; done + ui__g branch; branch="$UI_G"; ui__g last; last="$UI_G"; ui__g sub_trunk; sub="$UI_G" + case "$rest" in + "$branch"*) prefix="$prefix$branch"; rest="${rest#"$branch"}" ;; + "$last"*) prefix="$prefix$last"; rest="${rest#"$last"}" ;; + "$sub"*) prefix="$prefix$sub"; rest="${rest#"$sub"}" ;; + esac + while [ "${rest# }" != "$rest" ]; do prefix="$prefix "; rest="${rest# }"; done + body="$rest" + while [ "${body% }" != "$body" ]; do trailing=" $trailing"; body="${body% }"; done + shown="$body" + fi + + case "$shown" in *https://*) scheme=https:// ;; *http://*) scheme=http:// ;; esac + if [ -n "$scheme" ]; then + before="${shown%%"$scheme"*}" + rest="${shown#*"$scheme"}" + url="$scheme${rest%%[[:space:]]*}" + after="${shown#"$before$url"}" + linked=$'\033]8;;'"$url"$'\033\\'"$url"$'\033]8;;\033\\' + shown="$before$linked$after" + fi + if [ -n "$code" ]; then + if [ -n "$body" ]; then + shown="$prefix"$'\033['"${code}m$shown"$'\033[0m'"$trailing" + else + shown="$prefix$trailing" + fi + fi + fi + UI_R="$shown" +} + +ui__emit_log_only() # a rendered line for the log only (settled form) +{ + local logged="$1" t b l + ui__g trunk; t="$UI_G"; ui__g last; l="$UI_G"; ui__g branch; b="$UI_G" + case "$logged" in "$t $l "*) logged="$t $b ${logged#"$t $l "}" ;; esac + ui__log INFO "$logged" +} + +ui__emit() # one finished line: screen plus log (always the settled form) +{ + local logged="$1" level="${2:-INFO}" state="${3:-}" saved="$UI_R" t b l s + if [ -z "$state" ]; then + case "$level" in OK) state=complete ;; WARN) state=warn ;; ERR) state=fail ;; esac + fi + ui__decorate "$state" "$1" + { printf '%s\n' "$UI_R" >&"$UI_FD"; } 2>/dev/null || true + if [ "$UI_ITEM_LAST" -eq 1 ]; then + ui__g trunk; t="$UI_G"; ui__g last; l="$UI_G"; ui__g branch; b="$UI_G"; ui__g sub_trunk; s="$UI_G" + case "$logged" in + "$t $l "*) logged="$t $b ${logged#"$t $l "}" ;; + "$t "*) logged="$t $s ${logged#"$t "}" ;; + "$t") logged="$t $s" ;; + esac + fi + ui__log "$level" "$logged" + UI_R="$saved" + return 0 +} + +# Wrap text at the width on the last space; a token longer than the room is +# cut hard. Each output line starts with its prefix. UI_WRAPPED counts them. +ui__wrap() # first prefix, continuation prefix, text, [level], [state] +{ + local prefix="$1" cont="$2" text="$3" level="${4:-INFO}" state="${5:-}" room line="" word + local -a words=() + UI_WRAPPED=0 + read -r -a words <<< "$text" + room=$((UI_WIDTH - ${#prefix})) + for word in "${words[@]}"; do + while [ "${#word}" -gt "$room" ]; do + if [ -n "$line" ]; then + ui__emit "$prefix$line" "$level" "$state"; UI_WRAPPED=$((UI_WRAPPED + 1)) + line=""; prefix="$cont"; room=$((UI_WIDTH - ${#prefix})) + fi + ui__emit "$prefix${word:0:room}" "$level" "$state"; UI_WRAPPED=$((UI_WRAPPED + 1)) + word="${word:room}"; prefix="$cont"; room=$((UI_WIDTH - ${#prefix})) + done + if [ -z "$line" ]; then + line="$word" + elif [ $(( ${#line} + 1 + ${#word} )) -le "$room" ]; then + line="$line $word" + else + ui__emit "$prefix$line" "$level" "$state"; UI_WRAPPED=$((UI_WRAPPED + 1)) + prefix="$cont"; room=$((UI_WIDTH - ${#prefix})); line="$word" + fi + done + ui__emit "$prefix$line" "$level" "$state"; UI_WRAPPED=$((UI_WRAPPED + 1)) +} + +# ---- trunk level ------------------------------------------------------------ +ui_banner() # version +{ + local t title version side sep left=26 right=17 l r + ui__init + ui__event INFO banner "${UI_TEXT[banner_title]} $1 — ${UI_TEXT[banner_url]}" + ui__g box_top; t="$UI_G" + ui__rep "${t:1:1}" "$left"; l="$UI_R"; ui__rep "${t:3:1}" "$right"; r="$UI_R" + ui__emit "${t:0:1}$l${t:2:1}$r${t:4:1}" + ui__text banner_version "$1"; version="$UI_R" + ui__pad " ${UI_TEXT[banner_title]} " "$left"; title="$UI_R" + ui__pad " $version" "$right"; version="$UI_R" + ui__g box_side; side="$UI_G"; ui__g box_sep; sep="$UI_G" + ui__emit "$side$title$sep$version$side" + ui__g box_rule; t="$UI_G" + ui__rep "${t:1:1}" "$left"; l="$UI_R"; ui__rep "${t:3:1}" "$right"; r="$UI_R" + ui__emit "${t:0:1}$l${t:2:1}$r${t:4:1}" + ui__g box_side; ui__pad " ${UI_TEXT[banner_url]}" 44; ui__emit "$UI_G$UI_R$UI_G" + ui__g box_bottom; t="$UI_G" + ui__rep "${t:1:1}" 44; ui__emit "${t:0:1}$UI_R${t:2:1}" +} + +ui_blank() +{ + ui__init + ui__g trunk; ui__emit "$UI_G" +} + +ui_heading() # key +{ + ui__init + ui__text "$@"; ui__event INFO section "$UI_R"; ui__g trunk; ui__emit "$UI_G $UI_R" +} + +ui_row() # label key, value +{ + local label text + ui__init + ui__text "$1"; text="$UI_R"; ui__event INFO host "$text: $2" + ui__pad "$text" 14; label="$UI_R" + ui__g trunk + ui__wrap "$UI_G $label " "$UI_G " "$2" +} + +ui_note() # key, args +{ + ui__init + ui__text "$@"; ui__event INFO note "$UI_R"; ui__g trunk + ui__wrap "$UI_G " "$UI_G " "$UI_R" +} + +ui_host_warning() # key, args +{ + local t + ui__init + ui__text "$@"; ui__event WARN host "$UI_R"; ui__g trunk; t="$UI_G"; ui__g warn + ui__wrap "$t $UI_G " "$t " "$UI_R" WARN +} + +ui_menu_option() # key, [default], args +{ + local key="$1" hint="" t + shift + if [ "${1:-}" = default ]; then ui__text m_default_hint; hint="$UI_R"; shift; fi + ui__init + ui__text "$key" "$@"; ui__event INFO option "$UI_R$hint"; ui__g trunk; t="$UI_G"; ui__g detail + ui__emit "$t $UI_G $UI_R$hint" +} + +# A pre-flight option has either the compatibility-default tag or the current +# value tag. The caller owns the option mapping; this function owns its tree +# rendering so the wrapper never prints presentation text itself. +ui_preflight_option() # key, default|current|plain, [args] +{ + local key="$1" tag="$2" hint="" t + shift 2 + case "$tag" in + default) ui__text q_default_tag; hint="$UI_R" ;; + current) ui__text q_current_tag; hint="$UI_R" ;; + esac + ui__init + ui__text "$key" "$@"; ui__event INFO option "$UI_R$hint"; ui__g trunk; t="$UI_G"; ui__g detail + ui__emit "$t $UI_G $UI_R$hint" +} + +# Read a normal line, except that a bare Escape is accepted immediately. A +# line-oriented read cannot implement the documented one-key back navigation. +ui_preflight_read() # displayed choices +{ + local choices="$1" first="" rest="" rc=0 + ui__init + ui_blank + ui_note q_back_hint + ui_prompt q_prompt "$choices" + UI_READ_ACTIVE=1 + IFS= read -r -n 1 first || rc=$? + if [ "$rc" -eq 0 ] && [ -n "$first" ] && [ "$first" != $'\033' ]; then + IFS= read -r rest || true + fi + UI_READ_ACTIVE=0 + if [ "$rc" -ne 0 ]; then + UI_ANSWER="" + else + UI_ANSWER="$first$rest" + fi + if [ "$rc" -ne 0 ] || [ "$first" = $'\033' ]; then + ui__screen $'\n' + elif [ "$UI_LIVE" -eq 0 ]; then + ui__screen "$UI_ANSWER"$'\n' + fi + ui__log INFO "answer: ${UI_ANSWER:-(default)}" + ui__event INFO answer "${UI_ANSWER:-(default)}" +} + +ui_prompt() # key: a prompt on the trunk, no newline; the caller reads +{ + ui__init + ui__text "$@"; ui__g trunk + ui__event INFO prompt "$UI_R" + ui__decorate wait "$UI_G $UI_R " + ui__screen "$UI_R" + ui__log INFO "$UI_G $UI_R" +} + +# A trunk-level question: hint line, prompt line, and the answer typed on the +# prompt line. UI_ANSWER contains the raw answer; EOF and the timeout leave it +# empty. +ui_ask() # prompt key, hint key, hint args +{ + local prompt hint t + ui__init + ui__text "$1"; prompt="$UI_R"; shift + ui__text "$@"; hint="$UI_R" + ui__event INFO question "$prompt" + ui__g trunk; t="$UI_G" + ui__read_inline "$t $prompt" "$t $hint" +} + +ui__timeout() # -> UI_R seconds, minimum 1 +{ + UI_R="${ABLETON_UI_PROMPT_TIMEOUT:-30}" + case "$UI_R" in ''|*[!0-9]*) UI_R=30 ;; esac + [ "$UI_R" -ge 1 ] || UI_R=1 +} + +# Print the hint and prompt, then read the answer on the prompt line. +ui__read_inline() # prompt line, hint line +{ + local prompt="$1" hint="$2" seconds rc=0 + ui__timeout; seconds="$UI_R" + UI_ANSWER="" + ui__emit "$hint" INFO wait + ui__emit "$prompt" INFO wait + if [ "$UI_LIVE" -eq 1 ]; then + ui__screen $'\033[1A\r'"$(printf '\033[%sC' "$(( ${#prompt} + 1 ))")" + fi + UI_READ_ACTIVE=1 + IFS= read -r -t "$seconds" UI_ANSWER || rc=$? + UI_READ_ACTIVE=0 + if [ "$rc" -ne 0 ]; then UI_ANSWER=""; fi + if [ "$UI_LIVE" -eq 1 ]; then + [ "$rc" -eq 0 ] || ui__screen $'\n' + fi + ui__log INFO "answer: ${UI_ANSWER:-(default)}" + ui__event INFO answer "${UI_ANSWER:-(default)}" + return 0 +} + +# ---- steps --------------------------------------------------------------------- +ui__step_list() # -> UI_R: the step keys for this action +{ + local action="${ABLETON_UI_ACTION:-}" list + list="${UI_STEPS[$action]-}" + if [ "${ABLETON_UI_KIT:-0}" != 1 ]; then + list="${list#s_prepare }" + [ "$list" != s_prepare ] || list="" + fi + UI_R="$list" +} + +ui_step_begin() # step key +{ + local key="$1" list n=0 x=0 k name t counter w1 w2 left mid right + ui__init + [ "$UI_ITEM_OPEN" -eq 0 ] || { ui__log INFO "ui_step_begin with an item still open"; ui_item_end ok; } + [ "$UI_STEP_OPEN" -eq 0 ] || { ui__log INFO "ui_step_begin with a step still open"; ui_step_end ok; } + ui__step_list; list="$UI_R" + for k in $list; do + x=$((x + 1)) + [ "$k" != "$key" ] || n="$x" + done + if [ "$n" -eq 0 ]; then + ui__log ERR "unknown step key $key for action ${ABLETON_UI_ACTION:-}" + exit 70 + fi + ui__text "$key"; name="${UI_R^^}" + counter="$n/$x" + ui__event INFO step "started $counter: $name" + w1=$(( ${#counter} + 2 )); w2=$(( ${#name} + 2 )) + ui__g step_join; left="$UI_G" + ui__g step_top; t="$UI_G" + ui__rep "${t:1:1}" "$w1"; mid="$UI_R"; ui__rep "${t:3:1}" "$w2"; right="$UI_R" + ui__emit "$left${t:0:1}$mid${t:2:1}$right${t:4:1}" + ui__g trunk; left="$UI_G" + ui__g step_mid; t="$UI_G" + ui__emit "$left ${t:0:1} $counter ${t:1:1} $name ${t:2:1}" + ui__g step_bottom; t="$UI_G" + ui__rep "${t:1:1}" "$w1"; mid="$UI_R"; ui__rep "${t:3:1}" "$w2"; right="$UI_R" + ui__emit "$left ${t:0:1}$mid${t:2:1}$right${t:4:1}" + ui__g sub_trunk; ui__emit "$left $UI_G" + UI_STEP_OPEN=1; UI_STEP_N="$n"; UI_STEP_ITEMS=0 + UI_ITEM_LAST=0; UI_ITEM_LINES=0 +} + +ui_step_end() # ok|fail +{ + local t mark text + ui__init + if [ "$UI_STEP_OPEN" -eq 0 ]; then + ui__log INFO "ui_step_end with no open step" + return 0 + fi + [ "$UI_ITEM_OPEN" -eq 0 ] || ui_item_end "$1" + ui_settle + ui__g trunk; t="$UI_G" + ui__g sub_trunk; ui__emit "$t $UI_G" + if [ "$1" = ok ]; then + ui__g ok; mark="$UI_G"; ui__text s_complete "$UI_STEP_N" + ui__event OK step "completed step $UI_STEP_N" + else + ui__g fail; mark="$UI_G"; ui__text s_failed "$UI_STEP_N" + ui__event ERR step "failed step $UI_STEP_N" + fi + text="$UI_R" + ui__g last + if [ "$1" = ok ]; then + ui__emit "$t $UI_G $text $mark" OK complete + else + ui__emit "$t $UI_G $text $mark" INFO fail + fi + ui__emit "$t" + UI_STEP_OPEN=0; UI_STEP_ITEMS=0 +} + +# ---- items --------------------------------------------------------------------- +ui__detail_prefix() # -> UI_R: "│ │ " or "│ " for the current block +{ + local t + ui__g trunk; t="$UI_G" + if [ "$UI_ITEM_LAST" -eq 1 ]; then + UI_R="$t " + else + ui__g sub_trunk; UI_R="$t $UI_G " + fi +} + +ui__detail_blank() +{ + local t + ui__pause_item_spinner + ui__g trunk; t="$UI_G" + if [ "$UI_ITEM_LAST" -eq 1 ]; then + ui__emit "$t" + else + ui__g sub_trunk; ui__emit "$t $UI_G" + fi + UI_ITEM_LINES=$((UI_ITEM_LINES + 1)) + ui__start_detail_spinner +} + +ui__detail() # marker glyph name, text, level, [state] +{ + local prefix cont g state="${4:-$UI_ITEM_STATE}" + ui__pause_item_spinner + ui__detail_prefix; prefix="$UI_R"; cont="$prefix " + ui__g "$1"; g="$UI_G" + ui__wrap "$prefix$g " "$cont" "$2" "${3:-INFO}" "$state" + UI_ITEM_LINES=$((UI_ITEM_LINES + UI_WRAPPED)) + ui__start_detail_spinner +} + +ui__title() # text -> UI_R cut to one line +{ + local room ell + ui__g ellipsis; ell="$UI_G" + room=$((UI_WIDTH - 6 - 2)) + if [ "${#1}" -gt "$room" ]; then + UI_R="${1:0:room-${#ell}}$ell" + else + UI_R="$1" + fi +} + +ui__item_open() # title text, [wait]: settle the previous block, draw the title +{ + local t b line mode="${2:-active}" + [ "$UI_ITEM_OPEN" -eq 0 ] || ui_item_end ok + ui_settle + if [ "$UI_STEP_ITEMS" -gt 0 ]; then + UI_ITEM_LAST=0; ui__detail_blank + fi + UI_STEP_ITEMS=$((UI_STEP_ITEMS + 1)) + UI_ITEM_WAIT=0; UI_ITEM_STATE=active + if [ "$mode" = wait ]; then UI_ITEM_WAIT=1; UI_ITEM_STATE="wait"; fi + ui__title "$1"; UI_ITEM_TITLE="$UI_R"; UI_ITEM_MARK="" + ui__g trunk; t="$UI_G" + if [ "$UI_LIVE" -eq 1 ]; then + ui__size + ui__g last; b="$UI_G" + UI_ITEM_LAST=1; UI_ITEM_ROWS="$UI_ROWS"; UI_ITEM_COLS="$UI_COLS" + line="$t $b $UI_ITEM_TITLE" + if [ "$mode" = wait ]; then + ui__emit "$line" INFO wait + else + ui__decorate active "$line" + ui__screen "$UI_R"$'\033[?25l' + UI_RUN_ACTIVE=1; UI_SPINNER_KIND=title; UI_SPINNER_OWNER_PID=$$ + ui__spinner "$$" "$line" "" 0 & + UI_SPINNER_PID=$! + fi + else + ui__g branch; b="$UI_G" + ui__emit "$t $b $UI_ITEM_TITLE" + UI_ITEM_LAST=0 + fi + UI_ITEM_LINES=1 + UI_ITEM_OPEN=1 +} + +ui_item_begin() # key, args +{ + ui__init + ui__text "$@" + ui__item_open "$UI_R" + ui__event INFO task "started: $UI_ITEM_TITLE" +} + +ui_status() # key, args +{ + ui__init + ui__text "$@" + ui__event INFO detail "$UI_R" + ui__detail detail "$UI_R" INFO status +} + +ui_info() # key, args +{ + local text + ui__init + ui__text "$@"; text="$UI_R" + ui__event INFO detail "$text" + ui__detail_blank + ui__detail info "$text" INFO status +} + +ui_warn() # key, args +{ + local text + ui__init + ui__text "$@"; text="$UI_R" + ui__event WARN detail "$text" + ui__detail_blank + ui__detail warn "$text" WARN warn +} + +# Rewrite the block's title line n lines up. Only when the block still fits +# the screen and the terminal has not changed size since it was drawn. +ui__rewrite_title() # branch glyph name, mark, n, [state] +{ + local t b line n="$3" state="${4:-$UI_ITEM_STATE}" + ui__size + [ "$UI_LIVE" -eq 1 ] || return 1 + [ "$UI_ROWS" -eq "$UI_ITEM_ROWS" ] && [ "$UI_COLS" -eq "$UI_ITEM_COLS" ] || return 1 + [ "$n" -lt $((UI_ROWS - 1)) ] || return 1 + ui__g trunk; t="$UI_G"; ui__g "$1"; b="$UI_G" + line="$t $b $UI_ITEM_TITLE${2:+ $2}" + ui__decorate "$state" "$line" + ui__screen "$(printf '\033[%sA\r\033[2K%s\033[%sB\r' "$n" "$UI_R" "$n")" +} + +ui_item_end() # ok|fail +{ + local mark text level=OK + ui__init + if [ "$UI_ITEM_OPEN" -eq 0 ]; then + ui__log INFO "ui_item_end with no open item" + return 0 + fi + if [ "$1" = ok ]; then + ui__g ok; UI_ITEM_STATE=complete + ui__event OK task "completed: $UI_ITEM_TITLE" + else + ui__g fail; level=ERR; UI_ITEM_STATE=fail + ui__event ERR task "failed: $UI_ITEM_TITLE" + fi + mark="$UI_G" + UI_ITEM_MARK="$mark" + if [ "$UI_RUN_ACTIVE" -eq 1 ]; then + ui__finish_item_spinner "$mark" "$UI_ITEM_STATE" + elif [ "$UI_ITEM_LAST" -eq 1 ]; then + ui__rewrite_title last "$mark" "$UI_ITEM_LINES" "$UI_ITEM_STATE" || true + else + if [ "$1" = ok ]; then ui__text i_done; else ui__text i_failed; fi + text="$UI_R" + ui__detail_prefix + ui__emit "$UI_R$mark $text" INFO "$UI_ITEM_STATE" + fi + ui__log "$level" "$UI_ITEM_TITLE $mark" + UI_ITEM_OPEN=0 + # A resolved operation always has the step-completion node after it. Draw + # its permanent branch and detail trunk now instead of leaving the live + # screen temporarily in the active └─ form. + ui_settle +} + +# Flip the last block to its settled form: the title from └─ to ├─ and each +# detail line's sub-trunk from blank to │. +ui_settle() +{ + local i n t + ui__init + [ "$UI_ITEM_LAST" -eq 1 ] || return 0 + n="$UI_ITEM_LINES" + UI_ITEM_LAST=0 + if ui__rewrite_title branch "$UI_ITEM_MARK" "$n" "$UI_ITEM_STATE"; then + ui__g sub_trunk; t="$UI_G" + i=$((n - 1)) + while [ "$i" -ge 1 ]; do + ui__screen "$(printf '\033[%sA\r\033[3C%s\033[%sB\r' "$i" "$t" "$i")" + i=$((i - 1)) + done + fi +} + +# ---- one command with a spinner ------------------------------------------------ +ui__spinner() # parent pid, line, progress file, total bytes +{ + local parent="$1" line="$2" progress="$3" total="$4" + local frame cur suffix + local -a frames=() + ui__g spinner; read -r -a frames <<< "$UI_G" + local cols shown rendered + while kill -0 "$parent" 2>/dev/null; do + frame="${frames[RANDOM % ${#frames[@]}]}" + suffix="" + if [ -n "$progress" ]; then + cur="$(stat -c %s -- "$progress" 2>/dev/null)" || cur=0 + ui__text i_progress "$((cur / 1048576))" "$((total / 1048576))" + suffix="$UI_R " + fi + shown="$line $suffix$frame" + cols="$(stty size <&"$UI_FD" 2>/dev/null)"; cols="${cols#* }" + case "$cols" in ''|*[!0-9]*) ;; *) [ "${#shown}" -lt "$cols" ] || shown="${shown:0:cols-1}" ;; esac + ui__decorate active "$shown" + # Keep formatting in this background shell. If the spinner is killed + # during a command substitution, its child printf can otherwise lose + # the substitution pipe and leak a "Broken pipe" line into the tree. + printf -v rendered '\r\033[2K%s' "$UI_R" + ui__screen "$rendered" + sleep 0.08 + done +} + +ui__stop_spinner() +{ + if [ -n "$UI_SPINNER_PID" ]; then + kill "$UI_SPINNER_PID" 2>/dev/null || true + wait "$UI_SPINNER_PID" 2>/dev/null || true + UI_SPINNER_PID="" + UI_SPINNER_OWNER_PID="" + fi +} + +ui__run_finish() # mark, [state]: settle a spinner on the title line +{ + local mark="$1" state="${2:-active}" saved="$UI_R" t b line + ui__stop_spinner + ui__g trunk; t="$UI_G"; ui__g last; b="$UI_G" + line="$t $b $UI_ITEM_TITLE${mark:+ $mark}" + ui__decorate "$state" "$line" + ui__screen "$(printf '\r\033[2K%s\033[?25h' "$UI_R")"$'\n' + ui__emit_log_only "$t $b $UI_ITEM_TITLE" + UI_RUN_ACTIVE=0; UI_SPINNER_KIND="" + UI_R="$saved" +} + +# Stop the temporary spinner before the renderer writes another line. A title +# spinner becomes a normal title line. A detail spinner is temporary and is +# erased, so it never remains in the completed tree. +ui__pause_item_spinner() +{ + [ "$UI_RUN_ACTIVE" -eq 1 ] || return 0 + if [ "$UI_SPINNER_KIND" = detail ]; then + ui__stop_spinner + ui__screen $'\r\033[2K\033[?25h' + UI_RUN_ACTIVE=0; UI_SPINNER_KIND="" + else + ui__run_finish "" active + fi +} + +# After detail text, keep an animated state marker on the current line until +# more text arrives or the item resolves. +ui__start_detail_spinner() +{ + local line saved="$UI_R" + [ "$UI_LIVE" -eq 1 ] && [ "$UI_ITEM_OPEN" -eq 1 ] \ + && [ "$UI_ITEM_WAIT" -eq 0 ] && [ "$UI_RUN_ACTIVE" -eq 0 ] || return 0 + ui__detail_prefix + line="${UI_R% }" + ui__decorate active "$line" + ui__screen "$UI_R"$'\033[?25l' + UI_RUN_ACTIVE=1; UI_SPINNER_KIND=detail; UI_SPINNER_OWNER_PID=$$ + ui__spinner "$$" "$line" "" 0 & + UI_SPINNER_PID=$! + UI_R="$saved" +} + +ui__finish_item_spinner() # mark, state +{ + if [ "$UI_SPINNER_KIND" = detail ]; then + ui__pause_item_spinner + ui__rewrite_title last "$1" "$UI_ITEM_LINES" "$2" || true + else + ui__run_finish "$1" "$2" + fi +} + +ui_run() # key [title args] [--progress FILE TOTAL] -- command... +{ + local key="$1" progress="" total=0 rc=0 t b line mark title level=OK + local -a targs=() + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --progress) progress="$2"; total="$3"; shift 3 ;; + --) shift; break ;; + *) targs+=("$1"); shift ;; + esac + done + ui__init + ui__text "$key" "${targs[@]}"; title="$UI_R" + [ "$UI_ITEM_OPEN" -eq 0 ] || ui_item_end ok + ui_settle + ui__event INFO task "started: $title" + if [ "$UI_STEP_ITEMS" -gt 0 ]; then UI_ITEM_LAST=0; ui__detail_blank; fi + UI_STEP_ITEMS=$((UI_STEP_ITEMS + 1)) + UI_ITEM_WAIT=0; UI_ITEM_STATE=active; UI_ITEM_OPEN=1 + ui__title "$title"; UI_ITEM_TITLE="$UI_R"; UI_ITEM_MARK="" + ui__g trunk; t="$UI_G" + if [ "$UI_LIVE" -eq 1 ]; then + # The title is the last line and stays open: the spinner redraws it + # in place until the command returns. + ui__size + ui__g last; b="$UI_G" + line="$t $b $UI_ITEM_TITLE" + UI_ITEM_LAST=1; UI_ITEM_ROWS="$UI_ROWS"; UI_ITEM_COLS="$UI_COLS"; UI_ITEM_LINES=1 + ui__decorate active "$line" + ui__screen "$UI_R"$'\033[?25l' + UI_RUN_ACTIVE=1; UI_SPINNER_KIND=run; UI_SPINNER_OWNER_PID=$$ + ui__spinner "$$" "$line" "$progress" "$total" & + UI_SPINNER_PID=$! + "$@" || rc=$? + if [ "$rc" -eq 0 ]; then + ui__g ok; UI_ITEM_STATE=complete + else + ui__g fail; level=ERR; UI_ITEM_STATE=fail + fi + mark="$UI_G"; UI_ITEM_MARK="$mark" + if [ "$UI_RUN_ACTIVE" -eq 1 ]; then + ui__finish_item_spinner "$mark" "$UI_ITEM_STATE" + else + ui__rewrite_title last "$mark" "$UI_ITEM_LINES" "$UI_ITEM_STATE" || true + fi + else + "$@" || rc=$? + if [ "$rc" -eq 0 ]; then + ui__g ok; UI_ITEM_STATE=complete + else + ui__g fail; level=ERR; UI_ITEM_STATE=fail + fi + mark="$UI_G"; UI_ITEM_MARK="$mark" + ui__g branch; b="$UI_G" + ui__emit "$t $b $UI_ITEM_TITLE $mark" + UI_ITEM_LINES=1; UI_ITEM_LAST=0 + fi + UI_ITEM_OPEN=0 + ui__log "$level" "$UI_ITEM_TITLE $mark" + if [ "$rc" -eq 0 ]; then + ui__event OK task "completed: $UI_ITEM_TITLE" + else + ui__event ERR task "failed with status $rc: $UI_ITEM_TITLE" + fi + ui_settle + return "$rc" +} + +# ---- a question inside a step ---------------------------------------------------- +# ui_question TITLE_KEY DEFAULT_LETTER OPTION_KEY... ; the answer's letter +# lands in UI_ANSWER. An unknown answer repeats the prompt. +ui_question() +{ + local title_key="$1" default="$2" key text letter label prefix prompt hint seconds choices + local -a letters=() labels=() + shift 2 + ui__init + if [ "$UI_RUN_ACTIVE" -eq 0 ] && [ -n "$UI_SPINNER_PID" ] \ + && [ "$UI_SPINNER_OWNER_PID" = "$PPID" ] \ + && [ "$(sed -n 's/^PPid:[[:space:]]*//p' "/proc/$UI_SPINNER_PID/status" 2>/dev/null)" = "$PPID" ]; then + ui__stop_spinner + ui__screen $'\r\033[2K\033[?25h' + fi + ui__text "$title_key" + ui__event INFO question "$UI_R" + ui__item_open "$UI_R" wait + ui__detail_blank + for key in "$@"; do + ui__text "$key"; text="$UI_R" + letter="${text#*[}"; letter="${letter%%]*}"; letter="${letter,,}" + label="${text//[/}"; label="${label//]/}"; label="${label,,}" + letters+=("$letter"); labels+=("$label") + [ "$letter" != "$default" ] || { ui__text q_default_tag; text="$text$UI_R"; } + ui__event INFO option "$text" + ui__detail detail "$text" + done + ui__detail_blank + ui__timeout; seconds="$UI_R" + printf -v choices '/%s' "${letters[@]}" + choices="${choices#/}" + ui__text q_prompt "${choices^^}"; prompt="$UI_R" + ui__text q_hint "$seconds"; hint="$UI_R" + while :; do + ui__detail_prefix; prefix="$UI_R" + ui__read_inline "$prefix$prompt" "$prefix$hint" + UI_ITEM_LINES=$((UI_ITEM_LINES + 2)) + text="${UI_ANSWER,,}" + text="${text#"${text%%[![:space:]]*}"}"; text="${text%"${text##*[![:space:]]}"}" + if [ -z "$text" ]; then UI_ANSWER="$default"; break; fi + UI_ANSWER="" + for key in "${!letters[@]}"; do + if [ "$text" = "${letters[key]}" ] || [ "$text" = "${labels[key]}" ] \ + || [ "$text" = "${labels[key]%% *}" ]; then + UI_ANSWER="${letters[key]}" + fi + done + [ -z "$UI_ANSWER" ] || break + done + UI_ITEM_OPEN=0; UI_ITEM_WAIT=0 + ui_settle + ui__log INFO "chosen: $UI_ANSWER" +} + +# ---- footer and tail --------------------------------------------------------------- +ui__foot_line() # glyph set (5 chars), left width, right width +{ + local t="$1" + ui__rep "${t:1:1}" "$2"; local l="$UI_R" + ui__rep "${t:3:1}" "$3"; local r="$UI_R" + ui__emit "${t:0:1}$l${t:2:1}$r${t:4:1}" +} + +ui__foot_row() # left text, right text, left width, right width +{ + local side + ui__g box_side; side="$UI_G" + ui__pad " $1" "$3"; local l="$UI_R" + ui__rpad "$2" $(( $4 - 1 )); local r="$UI_R " + ui__emit "$side$l$side$r$side" +} + +# ui_footer LABEL VERSION STATUS SECONDS WARNINGS ERRORS RUNTIME PREFIX +ui_footer() +{ + local label="$1" version="$2" status="$3" seconds="$4" warnings="$5" errors="$6" + local runtime="$7" prefix="$8" inner=53 left right title state t side row + local -a rows=() + ui__init + ui__text f_title "$label" "$version"; title="$UI_R" + case "$status" in + complete) ui__text f_complete ;; + interrupted) ui__text f_interrupted ;; + cancelled) ui__text f_cancelled ;; + *) ui__text f_failed ;; + esac + state="$UI_R" + ui__event INFO summary "$label $version: $state; ${seconds}s; warnings=$warnings; errors=$errors" + [ -z "$runtime" ] || { ui__text f_runtime; rows+=("$UI_R $runtime"); } + [ -z "$prefix" ] || { ui__text f_prefix; rows+=("$UI_R $prefix"); } + right=10 + [ $(( ${#state} + 2 )) -le "$right" ] || right=$(( ${#state} + 2 )) + if [ $(( ${#title} + 2 + right + 1 )) -gt $((UI_WIDTH - 2)) ]; then + ui__g ellipsis + title="${title:0:$((UI_WIDTH - 2 - right - 1 - 2 - ${#UI_G}))}$UI_G" + fi + for row in "${rows[@]}"; do + [ $(( ${#row} + 2 )) -le "$inner" ] || inner=$(( ${#row} + 2 )) + done + [ $(( ${#title} + 2 + right + 1 )) -le "$inner" ] || inner=$(( ${#title} + 2 + right + 1 )) + [ "$inner" -le $((UI_WIDTH - 2)) ] || inner=$((UI_WIDTH - 2)) + left=$((inner - right - 1)) + ui__g foot_top; ui__foot_line "$UI_G" "$left" "$right" + ui__foot_row "$title" "$state" "$left" "$right" + ui__g foot_rule; ui__foot_line "$UI_G" "$left" "$right" + ui__text f_time; t="$UI_R"; ui__text f_seconds "$seconds" + ui__foot_row "$t" "$UI_R" "$left" "$right" + ui__g foot_dash; ui__foot_line "$UI_G" "$left" "$right" + ui__text f_warnings; ui__foot_row "$UI_R" "$warnings" "$left" "$right" + ui__g foot_dash; ui__foot_line "$UI_G" "$left" "$right" + ui__text f_errors; ui__foot_row "$UI_R" "$errors" "$left" "$right" + ui__g foot_merge; ui__foot_line "$UI_G" "$left" "$right" + ui__g box_side; side="$UI_G" + for row in "${rows[@]}"; do + if [ $(( ${#row} + 2 )) -gt "$inner" ]; then + ui__g ellipsis + t="${row%% *} " + if [ $((inner - 2 - ${#t} - ${#UI_G})) -gt 0 ]; then + row="$t$UI_G${row:$(( ${#row} - (inner - 2 - ${#t} - ${#UI_G}) ))}" + else + row="${row:0:$((inner - 2))}" + fi + fi + ui__pad " $row" "$inner"; ui__emit "$side$UI_R$side" + done + ui__g foot_bottom; t="$UI_G" + ui__rep "${t:1:1}" "$inner"; ui__emit "${t:0:1}$UI_R${t:2:1}" +} + +# ui_tail STATUS LOG_PATH [ERROR...] +ui_tail() +{ + local status="$1" log="$2" error + shift 2 + ui__init + ui__emit "" + if [ "$#" -gt 0 ]; then + ui__text t_errors_h; ui__emit "$UI_R" INFO fail + for error in "$@"; do ui__text t_error_item "$error"; ui__emit " $UI_R" INFO fail; done + ui__emit "" + fi + if [ "$status" = complete ]; then + ui__text t_launch; ui__emit "$UI_R"; ui__emit "" + ui__text t_launch_cmd; ui__emit " $UI_R"; ui__emit "" + fi + ui__text t_bsky_h; ui__emit "$UI_R"; ui__text t_bsky; ui__emit " $UI_R"; ui__emit "" + ui__text t_discord_h; ui__emit "$UI_R"; ui__text t_discord; ui__emit " $UI_R"; ui__emit "" + case "$status" in + complete|cancelled) ui__text t_help_h; ui__emit "$UI_R"; ui__text t_help; ui__emit " $UI_R" ;; + *) ui__text t_report_h; ui__emit "$UI_R"; ui__text t_report; ui__emit " $UI_R" ;; + esac + ui__emit "" + if [ -n "$log" ]; then + ui__text t_log_h; ui__emit "$UI_R"; ui__emit " $log" + else + ui__text t_log_none; ui__emit "$UI_R" WARN + fi +} + +# ---- exit path ----------------------------------------------------------------------- +# ui_cleanup STATUS: called first by every script's EXIT handler. Stops the +# spinner, ends an open item and step (ok when STATUS is 0), settles the +# last block, and shows the cursor. +ui_cleanup() +{ + local outcome=fail + IFS=$' \t\n' + ui__init + if [ "$UI_READ_ACTIVE" -eq 1 ]; then + UI_READ_ACTIVE=0 + [ "$UI_LIVE" -eq 0 ] || ui__screen $'\n' + fi + [ "${1:-1}" != 0 ] || outcome=ok + if [ "$UI_RUN_ACTIVE" -eq 1 ]; then + if [ "$outcome" = ok ]; then + ui__g ok; UI_ITEM_STATE=complete + else + ui__g fail; UI_ITEM_STATE=fail + fi + UI_ITEM_MARK="$UI_G" + ui__finish_item_spinner "$UI_G" "$UI_ITEM_STATE" + if [ "$outcome" = ok ]; then + ui__event OK task "completed: $UI_ITEM_TITLE" + else + ui__event ERR task "interrupted: $UI_ITEM_TITLE" + fi + UI_ITEM_OPEN=0 + if [ "$outcome" = ok ]; then ui__log OK "$UI_ITEM_TITLE $UI_G"; else ui__log ERR "$UI_ITEM_TITLE $UI_G"; fi + fi + ui__stop_spinner + [ "$UI_ITEM_OPEN" -eq 0 ] || ui_item_end "$outcome" + [ "$UI_STEP_OPEN" -eq 0 ] || ui_step_end "$outcome" + ui_settle + [ "$UI_LIVE" -eq 0 ] || ui__screen $'\033[?25h' + return 0 +} diff --git a/scripts/make-installer.sh b/scripts/make-installer.sh index 28fe5c53..76ce2cb8 100755 --- a/scripts/make-installer.sh +++ b/scripts/make-installer.sh @@ -12,6 +12,125 @@ here="$(cd "$(dirname "$0")" && pwd)" root="$(cd "$here/.." && pwd)" cd "$root" +usage() +{ + cat <<'EOF' +Usage: scripts/make-installer.sh [--dev] + scripts/make-installer.sh --render-header --version VERSION --payload-sha SHA + scripts/make-installer.sh --stage-project-files DIRECTORY + scripts/make-installer.sh --audit-project-files DIRECTORY + +Packs dist/ableton-wine-setup-VERSION.run from the attested dist/ artefacts. + --dev pack dist/ableton-wine-setup-VERSION-dev.run from whatever + is in dist/ with no BUILD-INFO, digest, or attestation + check; for trying script changes, never for a release + --render-header print the .run header (setup-run-header.sh with + the pre-extraction libraries inlined and markers filled) + --stage-project-files DIRECTORY + stage the repository-owned script payload in DIRECTORY + --audit-project-files DIRECTORY + verify that staged project scripts exactly match the source +EOF +} + +# The header carries the renderer so the banner, the system check, and the +# action menu render before the kit exists. +render_header() +{ + local version="$1" payload_sha="$2" + [ -r scripts/lib/preferences.sh ] || { + echo "!! scripts/lib/preferences.sh is required to render the pre-flight header" >&2 + return 1 + } + sed -e "s/@VERSION@/$version/g" -e "s/@PAYLOAD_SHA@/$payload_sha/g" \ + -e '/^@UI_LIB@$/{r scripts/lib/ui.sh' -e 'd}' scripts/setup-run-header.sh \ + | sed -e '/^@PREFERENCES_LIB@$/{r scripts/lib/preferences.sh' -e 'd}' +} + +project_file_specs() +{ + cat <<'EOF' +scripts/installer.sh|scripts/installer.sh|755 +scripts/install.sh|scripts/install.sh|755 +scripts/setup-prefix.sh|scripts/setup-prefix.sh|755 +scripts/uninstall.sh|scripts/uninstall.sh|755 +scripts/ableton-live|scripts/ableton-live|755 +scripts/max9|scripts/max9|755 +scripts/detect-scale.sh|scripts/detect-scale.sh|755 +scripts/detect-theme.sh|scripts/detect-theme.sh|755 +scripts/shortcut-hold.sh|scripts/shortcut-hold.sh|755 +scripts/check-live-audio.sh|scripts/check-live-audio.sh|755 +scripts/check-ntsync.sh|scripts/check-ntsync.sh|755 +scripts/setup-link.sh|scripts/setup-link.sh|755 +scripts/ableton-linkctl|scripts/ableton-linkctl|755 +scripts/setup-realtime.sh|scripts/setup-realtime.sh|755 +scripts/audio-report.sh|scripts/audio-report.sh|755 +scripts/rollback.sh|scripts/rollback.sh|755 +scripts/lib/config.sh|scripts/lib/config.sh|644 +scripts/lib/lifecycle.sh|scripts/lib/lifecycle.sh|644 +scripts/lib/live-options.sh|scripts/lib/live-options.sh|644 +scripts/lib/manifest.sh|scripts/lib/manifest.sh|644 +scripts/lib/pipeasio.sh|scripts/lib/pipeasio.sh|644 +scripts/lib/preferences.sh|scripts/lib/preferences.sh|644 +scripts/lib/ui.sh|scripts/lib/ui.sh|644 +EOF +} + +stage_project_files() +{ + local destination="$1" source relative mode + mkdir -p -- "$destination/scripts/lib" + while IFS='|' read -r source relative mode; do + [ -f "$source" ] || { + echo "!! project installer input is missing: $source" >&2 + return 1 + } + install -m "$mode" -- "$source" "$destination/$relative" + done < <(project_file_specs) +} + +audit_project_files() +{ + local destination="$1" source relative mode staged + while IFS='|' read -r source relative mode; do + staged="$destination/$relative" + [ -f "$staged" ] && [ ! -L "$staged" ] \ + && cmp -s -- "$source" "$staged" \ + && [ "$(stat -c '%a' -- "$staged")" = "$mode" ] || { + echo "!! staged project file differs from source: $relative" >&2 + return 1 + } + done < <(project_file_specs) +} + +dev_pack=0 +case "${1:-}" in + --help|-h) usage; exit 0 ;; + --dev) dev_pack=1; shift ;; + --render-header) + shift + header_version="" header_sha="" + while [ "$#" -gt 0 ]; do + case "$1" in + --version) header_version="${2:?}"; shift 2 ;; + --payload-sha) header_sha="${2:?}"; shift 2 ;; + *) usage >&2; exit 2 ;; + esac + done + [ -n "$header_version" ] && [ -n "$header_sha" ] || { usage >&2; exit 2; } + render_header "$header_version" "$header_sha" + exit 0 ;; + --stage-project-files) + [ "$#" -eq 2 ] || { usage >&2; exit 2; } + stage_project_files "$2" + exit $? ;; + --audit-project-files) + [ "$#" -eq 2 ] || { usage >&2; exit 2; } + audit_project_files "$2" + exit $? ;; +esac +[ "$#" -eq 0 ] || { usage >&2; exit 2; } + NAME="wine-d2d1-nspa-11.13" VERSION="$(cat VERSION)" [[ "$VERSION" =~ ^20[0-9]{2}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$ ]] \ @@ -31,6 +150,9 @@ linkd="dist/ableton-linkd" [ -f "$ntsync_probe" ] || { echo "!! $ntsync_probe is missing" >&2; exit 1; } [ -x "$cabextract_static" ] || { echo "!! dist/cabextract-static is missing: run ./build.sh first" >&2; exit 1; } [ -x "$linkd" ] || { echo "!! dist/ableton-linkd is missing: run ./build.sh first" >&2; exit 1; } +if [ "$dev_pack" -eq 1 ]; then + echo "== dev pack: BUILD-INFO, digest, and attestation checks skipped ==" +else if [ "$(grep -c '^dist-version:' "$build_info" || true)" -ne 1 ] \ || ! grep -qxF "dist-version: $VERSION" "$build_info"; then echo "!! BUILD-INFO does not match VERSION $VERSION" >&2 @@ -55,6 +177,7 @@ for helper_spec in \ exit 1 } done +fi [ "$(wc -l < "$tarball.sha256")" -eq 1 ] || { echo "!! exact runtime checksum record is invalid" >&2; exit 1; } read -r runtime_sha runtime_checksum_name runtime_checksum_extra < "$tarball.sha256" @@ -66,7 +189,7 @@ runtime_checksum_name="${runtime_checksum_name#\*}" echo " runtime: $(basename "$tarball")" echo "== [0/5] build audit (no unaudited runtime gets packed) ==" -bash scripts/build-audit.sh "$tarball" +[ "$dev_pack" -eq 1 ] || bash scripts/build-audit.sh "$tarball" echo "== [1/5] verify attested installer helpers ==" stage="$(mktemp -d)" @@ -83,29 +206,9 @@ kit="$stage/kit" mkdir -p "$kit/bin" "$kit/dist" "$kit/vendor" "$kit/scripts/lib" install -m644 "$tarball" "$tarball.sha256" "$kit/dist/" install -m644 "$build_info" "$kit/" -mkdir -p "$kit/scripts" -cp -- scripts/installer.sh scripts/install.sh scripts/setup-prefix.sh scripts/uninstall.sh \ - scripts/ableton-live scripts/max9 scripts/detect-scale.sh \ - scripts/detect-theme.sh scripts/shortcut-hold.sh \ - scripts/check-live-audio.sh scripts/check-ntsync.sh \ - scripts/setup-link.sh scripts/ableton-linkctl \ - "$kit/scripts/" -chmod 755 "$kit/scripts/installer.sh" "$kit/scripts/install.sh" \ - "$kit/scripts/setup-prefix.sh" "$kit/scripts/uninstall.sh" \ - "$kit/scripts/ableton-live" "$kit/scripts/max9" \ - "$kit/scripts/detect-scale.sh" "$kit/scripts/detect-theme.sh" \ - "$kit/scripts/shortcut-hold.sh" "$kit/scripts/check-live-audio.sh" \ - "$kit/scripts/check-ntsync.sh" "$kit/scripts/setup-link.sh" \ - "$kit/scripts/ableton-linkctl" -install -m755 scripts/setup-realtime.sh scripts/audio-report.sh scripts/rollback.sh \ - "$kit/scripts/" +stage_project_files "$kit" +audit_project_files "$kit" install -m644 "$ntsync_probe" "$kit/scripts/ntsyncprobe.exe" -cp -- scripts/lib/config.sh scripts/lib/lifecycle.sh scripts/lib/live-options.sh \ - scripts/lib/manifest.sh scripts/lib/pipeasio.sh \ - "$kit/scripts/lib/" -chmod 644 "$kit/scripts/lib/config.sh" "$kit/scripts/lib/lifecycle.sh" \ - "$kit/scripts/lib/live-options.sh" "$kit/scripts/lib/manifest.sh" \ - "$kit/scripts/lib/pipeasio.sh" install -m644 scripts/ableton-linkd.service "$kit/scripts/ableton-linkd.service" install -m644 tools/setsyscolors.exe "$kit/scripts/setsyscolors.exe" install -m644 tools/learnheal.exe "$kit/scripts/learnheal.exe" @@ -185,17 +288,25 @@ tar --sort=name --owner=0 --group=0 --numeric-owner \ -cf "$payload" -C "$kit" . payload_sha="$(sha256sum "$payload" | awk '{print $1}')" out="dist/ableton-wine-setup-${VERSION}.run" -sed -e "s/@VERSION@/$VERSION/g" -e "s/@PAYLOAD_SHA@/$payload_sha/g" \ - scripts/setup-run-header.sh > "$out" +[ "$dev_pack" -eq 0 ] || out="dist/ableton-wine-setup-${VERSION}-dev.run" +render_header "$VERSION" "$payload_sha" > "$out" +grep -q '^declare -A UI_TEXT=' "$out" || { + echo "!! the .run header did not receive the renderer" >&2; exit 1; } cat "$payload" >> "$out" chmod +x "$out" ( cd dist && sha256sum "$(basename "$out")" > "$(basename "$out").sha256" ) echo "== [4/5] wrapper self-check ==" -bash scripts/check-release-build-info.sh "$build_info" \ - --version "$VERSION" --runtime "$tarball" --installer "$out" +if [ "$dev_pack" -eq 1 ]; then + bash "$out" extract "$stage/wrapper-self-check" >/dev/null + [ -f "$stage/wrapper-self-check/scripts/installer.sh" ] || { + echo "!! dev wrapper did not extract the staged installer" >&2; exit 1; } +else + bash scripts/check-release-build-info.sh "$build_info" \ + --version "$VERSION" --runtime "$tarball" --installer "$out" +fi echo echo "== [5/5] done ==" echo "OK: $out ($(du -h "$out" | cut -f1))" echo "Copy it (plus your Ableton installer file) to a USB stick and run:" -echo " sh /run/media/*/*/ableton-wine-setup-${VERSION}.run install" +echo " sh /run/media/*/*/$(basename "$out") install" diff --git a/scripts/max9 b/scripts/max9 index 72c194c9..8da1ac0a 100755 --- a/scripts/max9 +++ b/scripts/max9 @@ -14,6 +14,15 @@ if ! ableton_config_init 2>/dev/null; then echo "max9: installer settings could not be read. Run the latest installer to repair them, then try again." >&2 exit 1 fi +# Saved pre-flight choices are optional. A launcher from a partial support +# update therefore keeps its established defaults and one-run environment. +for lib in "$here/lib/preferences.sh" "$ABLETON_DATA_HOME/lib/preferences.sh" \ + "$ABLETON_WINE_ROOT/share/ableton-wine/scripts/lib/preferences.sh"; do + if [ -r "$lib" ]; then . "$lib"; break; fi +done +if declare -F ableton_preferences_apply >/dev/null 2>&1; then + ableton_preferences_apply || exit 2 +fi for lib in "$here/lib/lifecycle.sh" "$ABLETON_DATA_HOME/lib/lifecycle.sh"; do if [ -r "$lib" ]; then . "$lib"; break; fi done @@ -118,13 +127,19 @@ printf 'max %s\n' "$$" >&9 || { session_started=1 run=("$WINE_ROOT/bin/wine") -if [ "${ABLETON_RT:-on}" != off ] && command -v chrt >/dev/null 2>&1 \ +rt_mode="${ABLETON_RT:-auto}" +[ "$rt_mode" != on ] || rt_mode=auto +export ABLETON_RT="$rt_mode" +if [ "$rt_mode" != off ] && command -v chrt >/dev/null 2>&1 \ && chrt -r 10 true >/dev/null 2>&1; then run=(chrt -r 10 "$WINE_ROOT/bin/wine") fi -if [ "${ABLETON_POWER:-on}" != off ] && command -v powerprofilesctl >/dev/null 2>&1 \ - && ableton_run_bounded 10 powerprofilesctl launch -p performance -r "Max session" -i max9 -- true >/dev/null 2>&1; then - run=(powerprofilesctl launch -p performance -r "Max session" -i max9 -- "${run[@]}") +power_profile="${ABLETON_POWER:-performance}" +case "$power_profile" in on|auto) power_profile=performance ;; esac +export ABLETON_POWER="$power_profile" +if [ "$power_profile" != off ] && command -v powerprofilesctl >/dev/null 2>&1 \ + && ableton_run_bounded 10 powerprofilesctl launch -p "$power_profile" -r "Max session" -i max9 -- true >/dev/null 2>&1; then + run=(powerprofilesctl launch -p "$power_profile" -r "Max session" -i max9 -- "${run[@]}") fi args=() diff --git a/scripts/rollback.sh b/scripts/rollback.sh index 5edc624c..0c1739c3 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -20,7 +20,7 @@ declare -F ableton_config_init >/dev/null 2>&1 || { ABLETON_CONFIG_LAYOUT_ROOTS='runtime prefix state' export ABLETON_CONFIG_LAYOUT_ROOTS ableton_config_init repair -for name in lifecycle manifest pipeasio; do +for name in ui lifecycle manifest pipeasio; do found="" for lib in "$here/lib/$name.sh" "$ABLETON_DATA_HOME/lib/$name.sh"; do [ -r "$lib" ] || continue @@ -507,7 +507,7 @@ if ableton_pipeasio_validate_runtime "$runtime" >/dev/null 2>&1; then ableton_pipeasio_remove_panel_integration || panel_restore_ready=0 fi else - # Legacy runtimes have no sealed panel contract. Remove only projections + # Legacy runtimes have no sealed panel state. Remove only projections # whose literal manifest digest still proves this project owns them. ableton_pipeasio_remove_panel_integration || panel_restore_ready=0 fi diff --git a/scripts/setup-link.sh b/scripts/setup-link.sh index 03324c2b..7b05a52c 100755 --- a/scripts/setup-link.sh +++ b/scripts/setup-link.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Enable, disable, or inspect the single persistent Ableton Link policy. -# Firewall and service state are recorded under XDG_STATE_HOME so uninstall can -# reverse only changes made by this project. +# Firewall and service state are recorded under XDG_STATE_HOME. The disable +# command reverses changes made by this project. set -euo pipefail here="$(cd "$(dirname "$0")" && pwd)" for lib in "$here/lib/config.sh" "$here/config.sh" \ @@ -11,17 +11,15 @@ for lib in "$here/lib/config.sh" "$here/config.sh" \ if [ -r "$lib" ]; then . "$lib"; break; fi done declare -F ableton_config_init >/dev/null 2>&1 || { echo "!! setup-link: config helper is missing" >&2; exit 1; } +. "$here/lib/ui.sh" +trap 'ui_cleanup $?' EXIT +# Progress output is informational: a closed stdout must never stop the work +# between moving the old settings aside and writing the new ones. +trap '' PIPE action="${1:-enable}" [ $# -eq 0 ] || shift mode=session -transaction_dir="" -case "$action" in - snapshot|preflight-rollback|preflight-commit|rollback|commit) - [ $# -ge 1 ] || { echo "!! setup-link.sh $action needs a transaction directory" >&2; exit 2; } - transaction_dir="$1" - shift ;; -esac while [ $# -gt 0 ]; do case "$1" in --mode=session|--mode=always) mode="${1#--mode=}" ;; @@ -29,70 +27,15 @@ while [ $# -gt 0 ]; do esac shift done -if [ -n "$transaction_dir" ]; then - ABLETON_TRANSACTION_DIR="$transaction_dir" - export ABLETON_TRANSACTION_DIR -fi -case "$action" in enable|disable|status|snapshot|preflight-rollback|preflight-commit|rollback|commit|plan-enable|plan-disable) ;; +case "$action" in enable|disable|status|plan-enable|plan-disable) ;; *) echo "usage: setup-link.sh enable [--mode=session|always] | disable | status" >&2; exit 2 ;; esac LINK_COORDINATED_ACTION=0 -coordinator_dir="${ABLETON_TRANSACTION_DIR:-}" if [ "${ABLETON_LINK_COORDINATED:-0}" = 1 ]; then LINK_COORDINATED_ACTION=1 fi -case "$action:$coordinator_dir" in - enable:/*|disable:/*) - if [ -d "$coordinator_dir" ] && [ ! -L "$coordinator_dir" ] \ - && [ -f "$coordinator_dir/active" ] && [ ! -L "$coordinator_dir/active" ] \ - && [ ! -s "$coordinator_dir/active" ]; then - LINK_COORDINATED_ACTION=1 - fi ;; -esac -repair_link_config_before_init() -{ - local candidate parent name header format nul_count - case "$action" in enable|disable) ;; *) return 0 ;; esac - if [ "${ABLETON_CONFIG_REPAIR_MODE:-0}" = 1 ] \ - && [ -n "${ABLETON_CONFIG_SNAPSHOT_PATH:-}" ] \ - && [ -n "${ABLETON_CONFIG_SNAPSHOT_TOKEN:-}" ] \ - && [ -n "${ABLETON_CONFIG_SNAPSHOT_VALUES:-}" ]; then - # The public installer already bound this object under the lifecycle - # lock and salvaged/defaulted its usable values. - return 0 - fi - candidate="${ABLETON_CONFIG_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/ableton-wine/config}" - parent="$(ableton_realpath_m "$(dirname -- "$candidate")")" || return 1 - name="$(basename -- "$candidate")" || return 1 - candidate="$parent/$name" - [ -e "$candidate" ] || [ -L "$candidate" ] || return 0 - ableton_managed_config_valid "$candidate" && return 0 - # A project-owned regular file from an older format can be repaired by the - # shared field-by-field parser. Foreign objects, directories, and symlinks - # remain a hard pre-mutation boundary. - [ -f "$candidate" ] && [ ! -L "$candidate" ] && [ -r "$candidate" ] || { - echo "!! Installer settings at $candidate were left unchanged because they are not a regular project file" >&2 - return 1 - } - nul_count="$(LC_ALL=C tr -cd '\000' < "$candidate" 2>/dev/null | wc -c)" \ - || return 1 - [ "$nul_count" -eq 0 ] || return 1 - IFS= read -r header < "$candidate" || return 1 - [ "$header" = '# ableton-linux installer configuration; managed by the installer' ] || { - echo "!! Installer settings at $candidate were left unchanged because they do not belong to Ableton Linux" >&2 - return 1 - } - format="$(awk -F = '$1=="format" { n++; value=substr($0, index($0, "=")+1) } END { if (n==1) print value; else exit 1 }' "$candidate")" || return 1 - [ "$format" = 1 ] || return 1 - return 0 -} - -if ! repair_link_config_before_init; then - echo "!! Installer settings are damaged. Re-run the main installer to rebuild them before changing Link." >&2 - exit 1 -fi case "$action" in status) ABLETON_CONFIG_LAYOUT_ROOTS=none @@ -102,8 +45,11 @@ case "$action" in fi ;; plan-enable|plan-disable) ABLETON_CONFIG_LAYOUT_ROOTS=none; ableton_config_init repair ;; - *) - ABLETON_CONFIG_LAYOUT_ROOTS='data config state'; ableton_config_init repair ;; + enable|disable) + ABLETON_CONFIG_LAYOUT_ROOTS=none + ABLETON_SIMPLE_PROJECT_FILES=1 + export ABLETON_SIMPLE_PROJECT_FILES + ableton_config_init repair ;; esac export ABLETON_CONFIG_LAYOUT_ROOTS for lib in "$here/lib/manifest.sh" "$ABLETON_DATA_HOME/lib/manifest.sh"; do @@ -111,30 +57,20 @@ for lib in "$here/lib/manifest.sh" "$ABLETON_DATA_HOME/lib/manifest.sh"; do # shellcheck disable=SC1090 if [ -r "$lib" ]; then . "$lib"; break; fi done -declare -F ableton_validate_install_state_journals >/dev/null 2>&1 || { +declare -F ableton_install_project_file >/dev/null 2>&1 || { echo "!! setup-link: lifecycle helper is missing" >&2; exit 1; } case "$action" in - enable|disable|snapshot|preflight-rollback|preflight-commit|rollback|commit) - ableton_install_lock_acquire - # Link files are generated integration. Stale installed-file or legacy - # restoration records must not prevent them from being rebuilt. - if ! ableton_validate_install_state_journals repair >/dev/null 2>&1; then - echo "!! Old Link file records could not be repaired; Link will ignore them" >&2 - fi ;; + enable|disable) ableton_install_lock_acquire ;; status) ;; esac state_file="$ABLETON_STATE_HOME/link-firewall" unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" unit_file="$unit_dir/ableton-linkd.service" -data_unit="$ABLETON_DATA_HOME/ableton-linkd.service" linkctl="$ABLETON_DATA_HOME/ableton-linkctl" link_pid_file="${XDG_RUNTIME_DIR:-$ABLETON_STATE_HOME/run}/ableton-wine/linkd.pid" -legacy_policy_file="$ABLETON_DATA_HOME/link-configured" legacy_hook=/etc/NetworkManager/dispatcher.d/50-link-multicast -LINK_ENABLE_RECOVERED_STATUS=75 -LINK_ENABLE_INCOMPLETE_STATUS=70 link_residual=0 LINK_DISABLE_COMMITTED=0 LINK_SETTING_SAVED=1 @@ -318,12 +254,6 @@ loaded_unit_is_owned() case "$exec_line" in *"$ABLETON_LINKD"*) return 0 ;; *) return 1 ;; esac } -stop_owned_service() -{ - loaded_unit_is_owned || return 0 - stop_canonical_link_service -} - stop_canonical_link_service() { local fragment="" wants="$unit_dir/default.target.wants/ableton-linkd.service" @@ -396,186 +326,6 @@ stop_canonical_link_service() fi } -snapshot_link_service_state() -{ - local snapshot="$1" first_pid="" enabled_status=0 active_status=0 - if systemd_user_available; then - : > "$snapshot/manager" || return 1 - ableton_run_bounded 20 systemctl --user is-enabled --quiet \ - ableton-linkd.service 2>/dev/null || enabled_status=$? - ableton_run_bounded 20 systemctl --user is-active --quiet \ - ableton-linkd.service 2>/dev/null || active_status=$? - case "$enabled_status" in 0|1|3|4) ;; *) - echo "!! Ableton Link could not read the current user-service setting" >&2 - return 1 ;; - esac - case "$active_status" in 0|1|3|4) ;; *) - echo "!! Ableton Link could not read whether its user service is running" >&2 - return 1 ;; - esac - if [ "$enabled_status" -eq 0 ]; then - : > "$snapshot/enabled" || return 1 - else - : > "$snapshot/enabled.absent" || return 1 - fi - if [ "$active_status" -eq 0 ]; then - : > "$snapshot/active" || return 1 - else - : > "$snapshot/active.absent" || return 1 - fi - else - : > "$snapshot/manager.absent" || return 1 - : > "$snapshot/enabled.absent" || return 1 - : > "$snapshot/active.absent" || return 1 - fi - first_pid="$(first_owned_link_pid)" || return 1 - if [ -n "$first_pid" ]; then - : > "$snapshot/detached-active" || return 1 - else - : > "$snapshot/detached-active.absent" || return 1 - fi -} - -link_snapshot_prior_token() -{ - local base="$1" - if [ -e "$base" ] || [ -L "$base" ]; then - ableton_object_token "$base" - elif [ -f "$base.absent" ] && [ ! -L "$base.absent" ]; then - printf '%s\n' absent - else - return 1 - fi -} - -link_service_matches_snapshot() -{ - local snapshot="$1" enabled_status=0 active_status=0 - [ -e "$snapshot/manager" ] || return 0 - systemd_user_available || return 1 - ableton_run_bounded 20 systemctl --user is-enabled --quiet \ - ableton-linkd.service 2>/dev/null || enabled_status=$? - ableton_run_bounded 20 systemctl --user is-active --quiet \ - ableton-linkd.service 2>/dev/null || active_status=$? - case "$enabled_status:$active_status" in - 0:0|0:1|0:3|0:4|1:0|1:1|1:3|1:4|3:0|3:1|3:3|3:4|4:0|4:1|4:3|4:4) ;; - *) return 1 ;; - esac - if [ -e "$snapshot/enabled" ]; then - [ "$enabled_status" -eq 0 ] || return 1 - else - [ "$enabled_status" -ne 0 ] || return 1 - fi - if [ -e "$snapshot/active" ]; then - [ "$active_status" -eq 0 ] - else - [ "$active_status" -ne 0 ] - fi -} - -link_detached_matches_snapshot() -{ - local snapshot="$1" pid="" - pid="$(first_owned_link_pid)" || return 1 - { [ -e "$snapshot/detached-active" ] && [ -n "$pid" ]; } \ - || { [ -e "$snapshot/detached-active.absent" ] && [ -z "$pid" ]; } -} - -quiesce_link_process_state_for_restore() -{ - local snapshot="$1" rc=0 restore_service=1 restore_detached=1 - if [ -e "$snapshot/local-recovery" ]; then - [ -e "$snapshot/service-mutating" ] || restore_service=0 - [ -e "$snapshot/detached-mutating" ] || restore_detached=0 - else - link_service_matches_snapshot "$snapshot" && restore_service=0 - link_detached_matches_snapshot "$snapshot" && restore_detached=0 - fi - if [ "$restore_service" -eq 1 ] && [ -e "$snapshot/manager" ]; then - systemd_user_available || { - echo "!! The previous Link service state cannot be restored outside a running user session" >&2 - return 1 - } - stop_owned_service || rc=$? - elif [ "$restore_service" -eq 1 ] && [ -e "$snapshot/service-mutating" ]; then - echo "!! The previous Link service state cannot be restored outside a running user session" >&2 - rc=1 - fi - if [ "$rc" -eq 0 ] && [ "$restore_detached" -eq 1 ]; then - stop_owned_detached_link_daemons || rc=$? - fi - return "$rc" -} - -restore_link_process_state() -{ - local snapshot="$1" ctl actual_enabled=0 actual_active=0 restored_pid="" rc=0 - local restore_service=1 restore_detached=1 - if [ -e "$snapshot/local-recovery" ]; then - [ -e "$snapshot/service-mutating" ] || restore_service=0 - [ -e "$snapshot/detached-mutating" ] || restore_detached=0 - fi - if [ "$restore_service" -eq 1 ] && [ -e "$snapshot/manager" ]; then - systemd_user_available || { - echo "!! no systemd user manager is reachable to restore the Link unit state" >&2 - return 1 - } - ableton_run_bounded 20 systemctl --user daemon-reload >/dev/null 2>&1 || true - if [ -e "$snapshot/enabled" ]; then - unit_is_owned || rc=1 - [ "$rc" -ne 0 ] || ableton_run_bounded 20 systemctl --user enable \ - ableton-linkd.service >/dev/null 2>&1 || true - elif unit_is_owned; then - ableton_run_bounded 20 systemctl --user disable \ - ableton-linkd.service >/dev/null 2>&1 || true - fi - if [ "$rc" -eq 0 ] && [ -e "$snapshot/active" ]; then - unit_is_owned || rc=1 - [ "$rc" -ne 0 ] || ableton_run_bounded 20 systemctl --user start \ - ableton-linkd.service >/dev/null 2>&1 || true - elif [ "$rc" -eq 0 ] && loaded_unit_is_owned; then - ableton_run_bounded 20 systemctl --user stop \ - ableton-linkd.service >/dev/null 2>&1 || true - fi - fi - if [ "$rc" -eq 0 ] && [ -e "$snapshot/manager" ]; then - systemd_user_available || rc=1 - if [ "$rc" -eq 0 ]; then - ableton_run_bounded 20 systemctl --user is-enabled --quiet \ - ableton-linkd.service 2>/dev/null || actual_enabled=$? - ableton_run_bounded 20 systemctl --user is-active --quiet \ - ableton-linkd.service 2>/dev/null || actual_active=$? - case "$actual_enabled:$actual_active" in - 0:0|0:1|0:3|0:4|1:0|1:1|1:3|1:4|3:0|3:1|3:3|3:4|4:0|4:1|4:3|4:4) ;; - *) rc=1 ;; - esac - if [ "$rc" -eq 0 ] \ - && { { [ -e "$snapshot/enabled" ] && [ "$actual_enabled" -ne 0 ]; } \ - || { [ -e "$snapshot/enabled.absent" ] && [ "$actual_enabled" -eq 0 ]; } \ - || { [ -e "$snapshot/active" ] && [ "$actual_active" -ne 0 ]; } \ - || { [ -e "$snapshot/active.absent" ] && [ "$actual_active" -eq 0 ]; }; }; then - echo "!! The previous Link user-service state could not be verified" >&2 - rc=1 - fi - fi - fi - ctl="$here/ableton-linkctl"; [ -x "$ctl" ] || ctl="$linkctl" - if [ "$rc" -eq 0 ] && [ "$restore_detached" -eq 1 ] \ - && [ -e "$snapshot/detached-active" ]; then - [ -x "$ctl" ] || rc=1 - [ "$rc" -ne 0 ] || ABLETON_LINK_MODE=session "$ctl" start || true - fi - if [ "$rc" -eq 0 ]; then - restored_pid="$(first_owned_link_pid)" || rc=$? - if { [ -e "$snapshot/detached-active" ] && [ -z "$restored_pid" ]; } \ - || { [ -e "$snapshot/detached-active.absent" ] && [ -n "$restored_pid" ]; }; then - echo "!! The previous Link process state could not be verified" >&2 - rc=1 - fi - fi - return "$rc" -} - link_firewall_record_valid() { local file="$1" @@ -595,20 +345,6 @@ validate_link_firewall_state() && link_firewall_record_valid "$state_file" } -snapshot_link_firewall_state() -{ - local destination="$1" - if [ ! -e "$state_file" ] && [ ! -L "$state_file" ]; then - : > "$destination.absent" || return 1 - return 0 - fi - validate_link_firewall_state || { - echo "!! unsafe Link firewall ownership record: $state_file" >&2 - return 1 - } - cp -a -- "$state_file" "$destination" -} - prepare_link_firewall_state_for_enable() { validate_link_firewall_state && return 0 @@ -690,7 +426,7 @@ remove_owned_firewall() state="$(sed -n '1p' "$state_file")" case "$state" in ufw-added) - echo "-- removing the ufw rule added for Ableton Link" + ui_status l_remove_ufw_rule ufw_link_rule_state || rule_state=$? if [ "$rule_state" -eq 0 ]; then ableton_sudo_run_bounded 120 ufw delete allow 20808/udp || true @@ -703,7 +439,7 @@ remove_owned_firewall() [ "$rule_state" -eq 1 ] || rc=1 fi ;; firewalld-added) - echo "-- removing the firewalld rule added for Ableton Link" + ui_status l_remove_firewalld_rule if command -v firewall-cmd >/dev/null 2>&1 \ && ableton_run_bounded 20 firewall-cmd --state >/dev/null 2>&1; then firewalld_link_rule_state || rule_state=$? @@ -748,63 +484,6 @@ remove_owned_firewall() link_cleanup_warning "The old Link firewall information could not be removed" } -restore_firewall_snapshot() -{ - local snapshot="$1" prior="" current="" rc=0 - if [ -e "$snapshot" ] || [ -L "$snapshot" ]; then - link_firewall_record_valid "$snapshot" || return 1 - prior="$(sed -n '1p' "$snapshot")" - fi - validate_link_firewall_state || return 1 - [ ! -r "$state_file" ] || current="$(sed -n '1p' "$state_file")" - if [ -r "$snapshot" ] && [ "$current" = "$prior" ]; then - # The ownership record is authoritative. Revalidate/repair the actual - # owned rule as well; equal record bytes alone do not prove host state. - case "$prior" in - ufw-added|firewalld-added) restore_firewall_record "$prior" ;; - none) return 0 ;; - esac - return - fi - [ ! -e "$state_file" ] && [ ! -L "$state_file" ] \ - || remove_owned_firewall || rc=$? - [ "$rc" -eq 0 ] || return "$rc" - if [ -r "$snapshot" ]; then - restore_firewall_record "$prior" - else - rm -f -- "$state_file" 2>/dev/null || true - [ ! -e "$state_file" ] && [ ! -L "$state_file" ] || \ - echo "!! The old Link firewall information could not be removed" >&2 - fi -} - -firewall_matches_snapshot() -{ - local snapshot="$1" prior current rule_state=0 - prior="$(link_snapshot_prior_token "$snapshot")" || return 1 - current="$(ableton_object_token "$state_file" 2>/dev/null || true)" - [ "$current" = "$prior" ] || return 1 - [ ! -e "$snapshot" ] && return 0 - case "$(sed -n '1p' "$snapshot")" in - ufw-added) - ufw_link_rule_state || rule_state=$? - [ "$rule_state" -eq 0 ] ;; - firewalld-added) - if command -v firewall-cmd >/dev/null 2>&1 \ - && ableton_run_bounded 20 firewall-cmd --state >/dev/null 2>&1; then - firewalld_link_rule_state || rule_state=$? - [ "$rule_state" -eq 0 ] || return 1 - rule_state=0 - firewalld_runtime_link_rule_state || rule_state=$? - else - firewalld_offline_link_rule_state || rule_state=$? - fi - [ "$rule_state" -eq 0 ] ;; - none) return 0 ;; - *) return 1 ;; - esac -} - legacy_hook_is_owned() { [ -f "$legacy_hook" ] && [ ! -L "$legacy_hook" ] \ @@ -822,95 +501,6 @@ first_multicast_route() printf '%s\n' "${routes%%$'\n'*}" } -snapshot_legacy_network() -{ - local destination="$1" route_line="" - if legacy_hook_is_owned; then - cp -a -- "$legacy_hook" "$destination.hook" || return 1 - route_line="$(first_multicast_route)" || { - echo "!! Link cannot safely inspect the old multicast route" >&2 - return 1 - } - if [ -n "$route_line" ]; then - printf '%s\n' "$route_line" > "$destination.route" || return 1 - else - : > "$destination.route.absent" || return 1 - fi - else - : > "$destination.hook.absent" || return 1 - # No recognised project hook means this command will not touch the - # legacy route, so its live value is outside Link recovery. - : > "$destination.route.absent" || return 1 - fi -} - -restore_legacy_network() -{ - local snapshot="$1" mode route_line current_route prior_hook current_hook - [ -e "$snapshot.hook" ] || return 0 - if [ -e "$legacy_hook" ] && ! legacy_hook_is_owned; then - echo "!! The old Link network hook changed, so it was left alone" >&2 - return 1 - fi - mode="$(stat -c '%a' "$snapshot.hook")" || return 1 - prior_hook="$(ableton_object_token "$snapshot.hook" 2>/dev/null || true)" - [ -n "$prior_hook" ] || return 1 - ableton_sudo_run_bounded 120 install -m "$mode" -- "$snapshot.hook" "$legacy_hook" || true - current_hook="$(ableton_object_token "$legacy_hook" 2>/dev/null || true)" - [ "$current_hook" = "$prior_hook" ] || { - echo "!! The old Link network hook could not be restored" >&2 - return 1 - } - route_line="$(sed -n '1p' "$snapshot.route" 2>/dev/null || true)" - if [ -n "$route_line" ]; then - local -a route_args=() - read -r -a route_args <<< "$route_line" - [ "${route_args[0]:-}" = 224.0.0.0/4 ] || { - echo "!! The saved multicast route is unreadable" >&2; return 1; } - ableton_sudo_run_bounded 120 ip route replace "${route_args[@]}" || true - current_route="$(first_multicast_route)" || return 1 - [ "$current_route" = "$route_line" ] || { - echo "!! The previous multicast route could not be restored" >&2 - return 1 - } - else - current_route="$(first_multicast_route)" || return 1 - [ -z "$current_route" ] || { - echo "!! The multicast route changed while Link was being restored" >&2 - return 1 - } - fi -} - -legacy_network_safe_for_restore() -{ - local snapshot="$1" prior_route current_route prior_hook=absent current_hook=absent - [ -e "$snapshot.hook" ] || return 0 - [ ! -e "$snapshot.hook" ] \ - || prior_hook="$(ableton_object_token "$snapshot.hook" 2>/dev/null || true)" - [ ! -e "$legacy_hook" ] \ - || current_hook="$(ableton_object_token "$legacy_hook" 2>/dev/null || true)" - [ -n "$prior_hook$current_hook" ] || return 1 - [ "$current_hook" = absent ] || [ "$current_hook" = "$prior_hook" ] \ - || legacy_hook_is_owned || return 1 - prior_route="$(sed -n '1p' "$snapshot.route" 2>/dev/null || true)" - current_route="$(first_multicast_route)" || return 1 - [ -z "$current_route" ] || [ "$current_route" = "$prior_route" ] -} - -legacy_network_matches_snapshot() -{ - local snapshot="$1" prior_hook=absent current_hook=absent prior_route current_route - [ -e "$snapshot.hook" ] || return 0 - [ ! -e "$snapshot.hook" ] \ - || prior_hook="$(ableton_object_token "$snapshot.hook" 2>/dev/null || true)" - [ ! -e "$legacy_hook" ] \ - || current_hook="$(ableton_object_token "$legacy_hook" 2>/dev/null || true)" - prior_route="$(sed -n '1p' "$snapshot.route" 2>/dev/null || true)" - current_route="$(first_multicast_route)" || return 1 - [ "$current_hook" = "$prior_hook" ] && [ "$current_route" = "$prior_route" ] -} - remove_owned_legacy_hook() { local route_line="" current_route="" @@ -927,7 +517,7 @@ remove_owned_legacy_hook() echo "!! The old Ableton Link multicast route could not be checked" >&2 return 1 } - echo "-- removing the old Ableton Link multicast hook" + ui_status l_remove_multicast_hook if [ -n "$route_line" ]; then ableton_sudo_run_bounded 120 ip route del 224.0.0.0/4 >/dev/null || true current_route="$(first_multicast_route)" || { @@ -948,116 +538,10 @@ remove_owned_legacy_hook() link_cleanup_warning "The obsolete Link network hook could not be removed: $legacy_hook" } -manifest_digest_for() -{ - local wanted="$1" kind path digest extra found="" - local manifest="$ABLETON_STATE_HOME/install-manifest.tsv" - [ -f "$manifest" ] && [ ! -L "$manifest" ] && [ -r "$manifest" ] || return 1 - while IFS=$'\t' read -r kind path digest extra; do - [ "$kind" = file ] && [ "$path" = "$wanted" ] || continue - [ -z "$extra" ] && [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 - [ -z "$found" ] || return 1 - found="$digest" - done < "$manifest" - [ -n "$found" ] || return 1 - printf '%s\n' "$found" -} - -legacy_link_file_is_owned() -{ - local target="$1" - [ -f "$target" ] && [ ! -L "$target" ] && [ -r "$target" ] || return 1 - case "$target" in - "$ABLETON_DATA_HOME/ableton-linkd") - strings "$target" 2>/dev/null \ - | grep -xF 'ableton-linkd: native Ableton Link session anchor and probe' \ - >/dev/null ;; - "$ABLETON_DATA_HOME/ableton-linkctl") - ableton_file_has_no_nul "$target" \ - && [ "$(sed -n '1p' "$target")" = '#!/usr/bin/env bash' ] \ - && grep -qxF '# Project-owned Ableton Link lifecycle controller. The PID record plus exact' \ - "$target" \ - && grep -qxF "case \"\$link_action\" in" "$target" \ - && grep -qxF ' *) echo "usage: ableton-linkctl start|stop|restart|status" >&2; exit 2 ;;' \ - "$target" ;; - "$ABLETON_DATA_HOME/setup-link.sh") - ableton_file_has_no_nul "$target" \ - && [ "$(sed -n '1p' "$target")" = '#!/usr/bin/env bash' ] \ - && grep -qxF '# Enable, disable, or inspect the single persistent Ableton Link policy.' \ - "$target" \ - && grep -qxF "legacy_policy_file=\"\$ABLETON_DATA_HOME/link-configured\"" \ - "$target" \ - && grep -qxF ' enable) enable_link ;;' "$target" ;; - "$ABLETON_DATA_HOME/ableton-linkd.service") - cmp -s -- "$target" <(cat <<'EOF' -# Template only. setup-link.sh renders @ABLETON_LINKD@ to the one resolved -# executable path and adds the ownership marker before installing this unit. -[Unit] -Description=Ableton Link session anchor (ableton-linkd) -After=default.target -X-AbletonLinuxOwned=true - -[Service] -ExecStart="@ABLETON_LINKD@" --linger 0 -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=default.target -EOF - ) ;; - *) return 1 ;; - esac -} - -generated_link_file_is_owned() -{ - local target="$1" expected current - [ -f "$target" ] && [ ! -L "$target" ] || return 1 - case "$target" in - "$unit_file") unit_is_owned; return ;; - esac - expected="$(manifest_digest_for "$target" 2>/dev/null || true)" - if [ -n "$expected" ]; then - current="$(link_path_digest "$target" 2>/dev/null || true)" - [ -n "$current" ] && [ "$current" = "$expected" ] && return 0 - fi - legacy_link_file_is_owned "$target" -} - -legacy_policy_file_is_owned() -{ - [ -f "$legacy_policy_file" ] && [ ! -L "$legacy_policy_file" ] \ - && [ -r "$legacy_policy_file" ] \ - && ableton_file_has_no_nul "$legacy_policy_file" \ - && [ "$(wc -l < "$legacy_policy_file")" -eq 1 ] || return 1 - case "$(sed -n '1p' "$legacy_policy_file")" in - configured|declined) return 0 ;; - *) return 1 ;; - esac -} - link_binary_is_owned() { - local expected current - [ "$ABLETON_LINKD" = "$ABLETON_DATA_HOME/ableton-linkd" ] || return 1 - expected="$(manifest_digest_for "$ABLETON_LINKD" 2>/dev/null || true)" - if [ -n "$expected" ]; then - current="$(link_path_digest "$ABLETON_LINKD" 2>/dev/null || true)" - [ -n "$current" ] && [ "$current" = "$expected" ] && return 0 - fi - legacy_link_file_is_owned "$ABLETON_LINKD" -} - -link_path_digest() -{ - if [ -L "$1" ]; then - { printf 'symlink\0'; readlink -n -- "$1"; } | sha256sum | awk '{print $1}' - elif [ -f "$1" ]; then - sha256sum -- "$1" | awk '{print $1}' - else - return 1 - fi + [ "$ABLETON_LINKD" = "$ABLETON_DATA_HOME/ableton-linkd" ] \ + && [ -f "$ABLETON_LINKD" ] && [ ! -L "$ABLETON_LINKD" ] } link_cleanup_warning() @@ -1067,109 +551,12 @@ link_cleanup_warning() return 0 } -remove_owned_link_file() -{ - local target="$1" before current - [ -e "$target" ] || [ -L "$target" ] || return 0 - if ! generated_link_file_is_owned "$target"; then - printf '!! Kept %s because it does not match a file installed by Ableton Linux\n' \ - "$target" >&2 || true - return 0 - fi - before="$(ableton_object_token "$target" 2>/dev/null || true)" - current="$(ableton_object_token "$target" 2>/dev/null || true)" - if [ -z "$before" ] || [ "$current" != "$before" ] \ - || ! generated_link_file_is_owned "$target"; then - printf '!! Kept %s because it changed while Ableton Link was being turned off\n' \ - "$target" >&2 || true - return 0 - fi - rm -f -- "$target" 2>/dev/null || true - [ ! -e "$target" ] && [ ! -L "$target" ] || \ - link_cleanup_warning "An obsolete Link file could not be removed: $target" -} - -remove_legacy_policy_file() -{ - local before current - [ -e "$legacy_policy_file" ] || [ -L "$legacy_policy_file" ] || return 0 - if ! legacy_policy_file_is_owned; then - printf '!! Kept %s because it is not an old Ableton Linux Link setting\n' \ - "$legacy_policy_file" >&2 || true - return 0 - fi - before="$(ableton_object_token "$legacy_policy_file" 2>/dev/null || true)" - current="$(ableton_object_token "$legacy_policy_file" 2>/dev/null || true)" - if [ -z "$before" ] || [ "$current" != "$before" ] \ - || ! legacy_policy_file_is_owned; then - printf '!! Kept %s because it changed while Ableton Link was being updated\n' \ - "$legacy_policy_file" >&2 || true - return 0 - fi - rm -f -- "$legacy_policy_file" 2>/dev/null || true - [ ! -e "$legacy_policy_file" ] && [ ! -L "$legacy_policy_file" ] || \ - link_cleanup_warning "The old Link setting could not be removed: $legacy_policy_file" -} - -prune_link_metadata() -{ - local manifest="$ABLETON_STATE_HOME/install-manifest.tsv" - local index="$ABLETON_STATE_HOME/install-prestate.tsv" tmp="" target id - local legacy_custom="${ABLETON_PR182_CUSTOM_LINKD:-}" - local -a targets=("$ABLETON_DATA_HOME/ableton-linkd" "$data_unit" "$linkctl" \ - "$ABLETON_DATA_HOME/setup-link.sh") - if [ -e "$manifest" ] || [ -L "$manifest" ]; then - if [ -f "$manifest" ] && [ ! -L "$manifest" ] && [ -r "$manifest" ] \ - && tmp="$(mktemp "$ABLETON_STATE_HOME/.installed-files-link.XXXXXX")" \ - && awk -F '\t' -v a="${targets[0]}" -v b="${targets[1]}" \ - -v c="${targets[2]}" -v d="${targets[3]}" \ - -v e="$legacy_custom" \ - '$2 != a && $2 != b && $2 != c && $2 != d && (e == "" || $2 != e)' \ - "$manifest" > "$tmp" \ - && chmod 600 "$tmp" && mv -f -- "$tmp" "$manifest"; then - : - else - [ -z "$tmp" ] || rm -f -- "$tmp" - link_cleanup_warning "Old Link file records could not be updated; a later installer run can rebuild them" - fi - fi - if [ -e "$index" ] || [ -L "$index" ]; then - tmp="" - if [ -f "$index" ] && [ ! -L "$index" ] && [ -r "$index" ] \ - && tmp="$(mktemp "$ABLETON_STATE_HOME/.old-link-files.XXXXXX")" \ - && awk -F '\t' -v a="${targets[0]}" -v b="${targets[1]}" \ - -v c="${targets[2]}" -v d="${targets[3]}" \ - '$2 != a && $2 != b && $2 != c && $2 != d' \ - "$index" > "$tmp" \ - && chmod 600 "$tmp" && mv -f -- "$tmp" "$index"; then - for target in "${targets[@]}"; do - id="$(printf '%s' "$target" | sha256sum | awk '{print $1}')" \ - || continue - rm -f -- "$ABLETON_STATE_HOME/install-prestate/$id" 2>/dev/null || true - [ ! -e "$ABLETON_STATE_HOME/install-prestate/$id" ] \ - && [ ! -L "$ABLETON_STATE_HOME/install-prestate/$id" ] || \ - link_cleanup_warning "An obsolete Link backup could not be removed" - done - else - [ -z "$tmp" ] || rm -f -- "$tmp" - link_cleanup_warning "Old Link restoration records could not be updated; they will be ignored" - fi - fi -} - disable_link() { local firewall_trusted=1 trap 'if [ "$LINK_DISABLE_COMMITTED" -eq 1 ]; then exit 0; else exit 130; fi' INT trap 'if [ "$LINK_DISABLE_COMMITTED" -eq 1 ]; then exit 0; else exit 143; fi' TERM - # Repair project settings before any firewall or service change. A later - # genuine host failure must not leave an interrupted old generation behind. - if [ "${ABLETON_CONFIG_REPAIR_NEEDED:-0}" = 1 ]; then - if ! save_link_mode; then - echo "!! Installer settings could not be repaired yet; continuing with the Link change" >&2 - fi - fi - printf '%s\n' "== turn off Ableton Link ==" || true + [ "$LINK_COORDINATED_ACTION" -eq 1 ] || ui_item_begin l_turn_off_link if ! validate_link_firewall_state; then firewall_trusted=0 printf '%s\n' "!! Link firewall information was unreadable, so no firewall rule was changed" >&2 || true @@ -1200,37 +587,28 @@ disable_link() fi LINK_DISABLE_COMMITTED=1 trap '' INT TERM PIPE - # From here on the requested external Link state is off. Generated-file, - # preference, and terminal cleanup cannot turn that completed outcome into - # an installer failure. + # From here on the requested external Link state is off. Installed support + # files stay in place for the next enable or installer run. set +e - remove_owned_link_file "$unit_file" - # Clear the directory if nothing else is using it. - rmdir --ignore-fail-on-non-empty -- "$unit_dir" 2>/dev/null || true if command -v systemctl >/dev/null 2>&1; then ableton_run_bounded 20 systemctl --user daemon-reload >/dev/null 2>&1 || \ link_cleanup_warning "The user-service list will refresh at the next login" fi - remove_owned_link_file "$ABLETON_DATA_HOME/ableton-linkd" if [ -n "${ABLETON_PR182_CUSTOM_LINKD:-}" ]; then - echo " Kept the older custom Link helper at $ABLETON_PR182_CUSTOM_LINKD" + ui_status l_kept_pr182_helper "$ABLETON_PR182_CUSTOM_LINKD" fi if [ "$ABLETON_LINKD" != "$ABLETON_DATA_HOME/ableton-linkd" ]; then - echo " Kept the external Link helper at $ABLETON_LINKD" + ui_status l_kept_external_helper "$ABLETON_LINKD" fi - remove_owned_link_file "$data_unit" - remove_owned_link_file "$linkctl" - remove_owned_link_file "$ABLETON_DATA_HOME/setup-link.sh" - prune_link_metadata - remove_legacy_policy_file if [ "$LINK_COORDINATED_ACTION" -ne 1 ]; then if [ "$LINK_SETTING_SAVED" -ne 1 ]; then - echo "OK: Ableton Link is off; run the installer again to save that preference" + ui_status l_off_unsaved elif [ "$link_residual" -eq 0 ]; then - echo "OK: Ableton Link is off" + ui_status l_off else - echo "OK: Ableton Link is off; some obsolete local files can be cleaned up by a later installer run" + ui_status l_off_residual fi + ui_item_end ok fi return 0 } @@ -1259,97 +637,40 @@ EOF save_link_mode() { - local tmp="" expected="" actual="" - if [ -L "$ABLETON_CONFIG_FILE" ]; then - echo "!! Ableton Link left the settings symlink unchanged: $ABLETON_CONFIG_FILE" >&2 - return 1 - fi - if [ -d "$ABLETON_CONFIG_FILE" ] && [ ! -L "$ABLETON_CONFIG_FILE" ]; then - echo "!! Ableton Link cannot replace a directory at $ABLETON_CONFIG_FILE" >&2 - return 1 - fi - if [ -e "$ABLETON_CONFIG_FILE" ] && [ ! -f "$ABLETON_CONFIG_FILE" ]; then - echo "!! Ableton Link left the non-file settings object unchanged: $ABLETON_CONFIG_FILE" >&2 - return 1 - fi - mkdir -p -- "$ABLETON_CONFIG_HOME" || return 1 - tmp="$(mktemp "$ABLETON_CONFIG_HOME/.config.XXXXXX")" || return 1 - if ! chmod 600 "$tmp" \ - || ! ableton_render_config > "$tmp" \ - || ! ableton_managed_config_valid "$tmp"; then - rm -f -- "$tmp" - echo "!! Ableton Link could not save its setting" >&2 - return 1 + [ "$LINK_COORDINATED_ACTION" -ne 1 ] || return 0 + if [ "${ABLETON_CONFIG_REPAIR_NEEDED:-0}" -eq 1 ]; then + [ -f "$ABLETON_CONFIG_FILE" ] && [ ! -L "$ABLETON_CONFIG_FILE" ] \ + && [ "$(sed -n '1p' "$ABLETON_CONFIG_FILE")" = \ + '# ableton-linux installer configuration; managed by the installer' ] \ + || return 1 fi - expected="$(ableton_config_object_token "$tmp")" || { - rm -f -- "$tmp" - return 1 - } - mv -T -f -- "$tmp" "$ABLETON_CONFIG_FILE" 2>/dev/null || true - actual="$(ableton_config_object_token "$ABLETON_CONFIG_FILE" 2>/dev/null || true)" - if [ "$actual" != "$expected" ] \ - || ! ableton_managed_config_valid "$ABLETON_CONFIG_FILE"; then - rm -f -- "$tmp" - echo "!! Ableton Link could not save its setting" >&2 - return 1 - fi - [ ! -e "$tmp" ] || rm -f -- "$tmp" 2>/dev/null || true - [ ! -e "$tmp" ] && [ ! -L "$tmp" ] || \ - echo "!! Temporary Link settings could not be removed: $tmp" >&2 - # This checksum only coordinates later installer helpers. The settings file - # above is already complete and verified, so bookkeeping cannot undo it. - ableton_config_snapshot_capture >/dev/null 2>&1 || true - ABLETON_CONFIG_REPAIR_NEEDED=0 - export ABLETON_CONFIG_REPAIR_NEEDED + ableton_write_config } install_unit() { - local loaded_fragment="" tmp="" + local tmp="" failures_before="$ABLETON_OPTIONAL_FILE_FAILURES" [ -x "$ABLETON_LINKD" ] || { echo "!! The Ableton Link helper is missing. Re-run the installer to restore it." >&2 return 1 } - if [ -d "$unit_file" ] && [ ! -L "$unit_file" ]; then - echo "!! Ableton Link cannot replace a directory at $unit_file" >&2 - return 1 - fi - if systemd_user_available; then - if ! loaded_fragment="$(ableton_run_bounded 20 systemctl --user show \ - -p FragmentPath --value ableton-linkd.service 2>/dev/null)"; then - echo "!! Ableton Link could not inspect the user service" >&2 + if [ "${ABLETON_LINK_FILES_MAPPED:-0}" != 1 ]; then + tmp="$(mktemp "${TMPDIR:-/tmp}/ableton-link-service.XXXXXX")" || return 1 + if ! render_owned_link_unit > "$tmp"; then + rm -f -- "$tmp" 2>/dev/null || true return 1 fi - if [ -n "$loaded_fragment" ] && [ "$(ableton_realpath_m "$loaded_fragment")" != "$(ableton_realpath_m "$unit_file")" ]; then - echo "!! Another user service already uses the name ableton-linkd.service: $loaded_fragment" >&2 - return 1 - fi - fi - mkdir -p -- "$unit_dir" || { - echo "!! Ableton Link could not create its user-service directory" >&2 - return 1 - } - tmp="$(mktemp "$unit_dir/.ableton-linkd.service.XXXXXX")" || return 1 - if ! render_owned_link_unit > "$tmp"; then - rm -f -- "$tmp" - return 1 + ableton_install_project_file 644 "$tmp" "$unit_file" + rm -f -- "$tmp" 2>/dev/null || true + [ "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 0 ] \ + && [ "$ABLETON_OPTIONAL_FILE_FAILURES" -eq "$failures_before" ] || return 1 fi - if ! chmod 644 "$tmp"; then - rm -f -- "$tmp" - echo "!! Ableton Link could not write its user service" >&2 - return 1 - fi - mv -T -f -- "$tmp" "$unit_file" 2>/dev/null || true - if ! unit_is_owned; then - rm -f -- "$tmp" - echo "!! Ableton Link could not write its user service" >&2 + if [ ! -f "$unit_file" ] || [ -L "$unit_file" ]; then + echo "!! The Ableton Link user service is missing. Re-run the installer to restore it." >&2 return 1 fi - [ ! -e "$tmp" ] || rm -f -- "$tmp" 2>/dev/null || true - [ ! -e "$tmp" ] && [ ! -L "$tmp" ] || \ - echo "!! Temporary Link service files could not be removed: $tmp" >&2 - # This writes the unit for a later session either way. With no running user - # manager there is nothing to reload, and session policy never needs it. + # The fixed project-file loop already installed this unit. With no running + # user manager there is nothing to reload, and session policy never needs it. systemd_user_available || return 0 if ! ableton_run_bounded 20 systemctl --user daemon-reload; then echo "!! The Link user service could not be refreshed yet; its requested state will be checked before setup finishes" >&2 @@ -1365,10 +686,10 @@ ensure_recorded_firewall() echo "!! The ufw rule previously added for Link cannot be checked because ufw is missing" >&2 return 127 } - echo " Checking the ufw rule for Link (UDP 20808)" + ui_status l_check_ufw_rule ufw_link_rule_state || rule_state=$? if [ "$rule_state" -eq 1 ]; then - echo " Restoring the missing ufw rule for Link" + ui_status l_restore_ufw_rule ableton_sudo_run_bounded 120 ufw allow 20808/udp || true rule_state=0 ufw_link_rule_state || rule_state=$? @@ -1381,12 +702,12 @@ ensure_recorded_firewall() return 1 fi ;; firewalld-added) - echo " Checking the firewalld rule for Link (UDP 20808)" + ui_status l_check_firewalld_rule if command -v firewall-cmd >/dev/null 2>&1 \ && ableton_run_bounded 20 firewall-cmd --state >/dev/null 2>&1; then firewalld_link_rule_state || rule_state=$? if [ "$rule_state" -eq 1 ]; then - echo " Restoring the missing firewalld rule for Link" + ui_status l_restore_firewalld_rule ableton_sudo_run_bounded 120 \ firewall-cmd --permanent --add-port=20808/udp || true ableton_sudo_run_bounded 120 firewall-cmd --reload || true @@ -1406,7 +727,7 @@ ensure_recorded_firewall() elif command -v firewall-offline-cmd >/dev/null 2>&1; then firewalld_offline_link_rule_state || rule_state=$? if [ "$rule_state" -eq 1 ]; then - echo " Restoring the missing firewalld rule for Link" + ui_status l_restore_firewalld_rule ableton_sudo_run_bounded 120 \ firewall-offline-cmd --add-port=20808/udp || true elif [ "$rule_state" -ne 0 ]; then @@ -1447,15 +768,15 @@ configure_firewall() echo "!! Ableton Link could not save that the existing ufw rule was left unchanged" >&2 return 1 } - echo " ufw already allows UDP 20808; leaving the existing rule unchanged" + ui_status l_ufw_rule_exists elif [ "$rule_state" -eq 1 ]; then - echo " ufw is active: opening UDP 20808 for Ableton Link" + ui_status l_ufw_open_port write_link_firewall_state ufw-added || { echo "!! Ableton Link cannot safely track a new ufw rule" >&2 return 1 } - # Persist ownership before ufw can make a partial change. The - # caller's recovery can then remove only this attempted rule. + # Record the rule before ufw can make a partial change. A later + # disable command can then remove the rule added by this project. ableton_sudo_run_bounded 120 ufw allow 20808/udp || true rule_state=0 ufw_link_rule_state || rule_state=$? @@ -1476,7 +797,7 @@ configure_firewall() echo "!! Ableton Link could not save that the existing firewalld rule was left unchanged" >&2 return 1 } - echo " firewalld already allows UDP 20808; leaving the existing rule unchanged" + ui_status l_firewalld_rule_exists rule_state=0 firewalld_runtime_link_rule_state || rule_state=$? if [ "$rule_state" -ne 0 ]; then @@ -1489,7 +810,7 @@ configure_firewall() } fi elif [ "$rule_state" -eq 1 ]; then - echo " firewalld is active: opening UDP 20808 for Ableton Link" + ui_status l_firewalld_open_port write_link_firewall_state firewalld-added || { echo "!! Ableton Link cannot safely track a new firewalld rule" >&2 return 1 @@ -1515,477 +836,59 @@ configure_firewall() echo "!! Ableton Link could not save its firewall status" >&2 return 1 } - echo " No active ufw or firewalld; the firewall is unchanged" - fi -} - -restore_firewall_record() -{ - local prior="$1" rc=0 rule_state=0 - ableton_mark_state_home || return 1 - case "$prior" in - ufw-added) - ufw_link_rule_state || rule_state=$? - if [ "$rule_state" -eq 1 ]; then - ableton_sudo_run_bounded 120 ufw allow 20808/udp || true - elif [ "$rule_state" -ne 0 ]; then - rc=1 - fi - if [ "$rc" -eq 0 ]; then - rule_state=0 - ufw_link_rule_state || rule_state=$? - [ "$rule_state" -eq 0 ] || rc=1 - fi ;; - firewalld-added) - if command -v firewall-cmd >/dev/null 2>&1 \ - && ableton_run_bounded 20 firewall-cmd --state >/dev/null 2>&1; then - rule_state=0 - firewalld_link_rule_state || rule_state=$? - if [ "$rule_state" -eq 1 ]; then - ableton_sudo_run_bounded 120 firewall-cmd --permanent --add-port=20808/udp || true - ableton_sudo_run_bounded 120 firewall-cmd --reload || true - elif [ "$rule_state" -ne 0 ]; then - rc=1 - fi - if [ "$rc" -eq 0 ]; then - rule_state=0 - firewalld_link_rule_state || rule_state=$? - [ "$rule_state" -eq 0 ] || rc=1 - fi - if [ "$rc" -eq 0 ]; then - rule_state=0 - firewalld_runtime_link_rule_state || rule_state=$? - [ "$rule_state" -eq 0 ] || rc=1 - fi - elif command -v firewall-offline-cmd >/dev/null 2>&1; then - rule_state=0 - firewalld_offline_link_rule_state || rule_state=$? - if [ "$rule_state" -eq 1 ]; then - ableton_sudo_run_bounded 120 \ - firewall-offline-cmd --add-port=20808/udp || true - elif [ "$rule_state" -ne 0 ]; then - rc=1 - fi - if [ "$rc" -eq 0 ]; then - rule_state=0 - firewalld_offline_link_rule_state || rule_state=$? - [ "$rule_state" -eq 0 ] || rc=1 - fi - else - rc=127 - fi ;; - none|'') ;; - *) echo "!! cannot restore unknown firewall record '$prior'" >&2; return 1 ;; - esac - [ "$rc" -eq 0 ] || return "$rc" - write_link_firewall_state "$prior" -} - -populate_link_transaction_snapshot() -{ - local snap="$1" - printf '3\n' > "$snap/format" || return 1 - printf '%s\n' "$ABLETON_LINK_MODE" > "$snap/policy" || return 1 - snapshot_link_firewall_state "$snap/firewall" || return 1 - snapshot_legacy_network "$snap/legacy" || return 1 - snapshot_link_service_state "$snap" || return 1 - : > "$snap/ready" || return 1 -} - -snapshot_link_transaction() -{ - local snap="$transaction_dir/link" build="" entries="" - ableton_txn_init || return 1 - if [ -e "$snap" ] || [ -L "$snap" ]; then - [ -d "$snap" ] && [ ! -L "$snap" ] || { - echo "!! Link transaction snapshot path is unsafe" >&2 - return 1 - } - entries="$(find "$snap" -mindepth 1 -maxdepth 1 -print -quit)" || return 1 - if [ -n "$entries" ]; then - if validate_link_transaction_snapshot; then - return 0 - fi - # This directory is generated by this helper inside the caller's - # new work area. Rebuild it instead of making stale local data a gate. - rm -rf -- "$snap" 2>/dev/null || true - [ ! -e "$snap" ] && [ ! -L "$snap" ] || return 1 - else - rmdir -- "$snap" 2>/dev/null || true - [ ! -e "$snap" ] && [ ! -L "$snap" ] || return 1 - fi - fi - build="$(mktemp -d "$transaction_dir/.link-snapshot.XXXXXX")" || return 1 - if ! populate_link_transaction_snapshot "$build"; then - rm -rf -- "$build" 2>/dev/null || true - [ ! -e "$build" ] && [ ! -L "$build" ] || \ - echo "!! Temporary Link recovery files could not be removed: $build" >&2 - return 1 - fi - if ! mv -T -n -- "$build" "$snap" \ - || [ -e "$build" ] || [ ! -d "$snap" ] || [ -L "$snap" ]; then - [ ! -e "$build" ] || rm -rf -- "$build" - echo "!! Ableton Link could not save the current firewall and service settings" >&2 - return 1 + ui_status l_no_firewall fi - validate_link_transaction_snapshot -} - -link_snapshot_pair_valid() -{ - local base="$1" type="$2" absent_path="${3:-$1.absent}" present=0 absent=0 digest - [ ! -e "$base" ] && [ ! -L "$base" ] || present=1 - [ ! -e "$absent_path" ] && [ ! -L "$absent_path" ] || absent=1 - [ $((present + absent)) -eq 1 ] || return 1 - if [ "$absent" -eq 1 ]; then - [ -f "$absent_path" ] && [ ! -L "$absent_path" ] && [ ! -s "$absent_path" ] - return - fi - case "$type" in - regular) - [ -f "$base" ] && [ ! -L "$base" ] && [ -r "$base" ] \ - && ableton_file_has_no_nul "$base" ;; - object) - { [ -f "$base" ] || [ -L "$base" ]; } \ - && digest="$(ableton_manifest_digest "$base" 2>/dev/null || true)" \ - && [ -n "$digest" ] ;; - directory) [ -d "$base" ] && [ ! -L "$base" ] ;; - marker) [ -f "$base" ] && [ ! -L "$base" ] && [ ! -s "$base" ] ;; - *) return 1 ;; - esac -} - -validate_link_transaction_snapshot() -{ - local snap="$transaction_dir/link" prior name names_count=0 members="" - [ ! -e "$snap" ] && [ ! -L "$snap" ] && return 0 - [ -d "$snap" ] && [ ! -L "$snap" ] || { - echo "!! Link transaction snapshot is unsafe" >&2; return 1; } - [ -f "$snap/ready" ] && [ ! -L "$snap/ready" ] && [ ! -s "$snap/ready" ] || { - echo "!! Link transaction ready marker is invalid" >&2; return 1; } - [ -f "$snap/format" ] && [ ! -L "$snap/format" ] \ - && [ "$(sed -n '1p' "$snap/format")" = 3 ] \ - && [ "$(wc -l < "$snap/format")" -eq 1 ] || { - echo "!! Link transaction snapshot format is invalid" >&2; return 1; } - [ -f "$snap/policy" ] && [ ! -L "$snap/policy" ] \ - && ableton_file_has_no_nul "$snap/policy" \ - && [ "$(wc -l < "$snap/policy")" -eq 1 ] || { - echo "!! Link transaction policy is invalid" >&2; return 1; } - prior="$(sed -n '1p' "$snap/policy")" - case "$prior" in off|session|always) ;; *) echo "!! Link transaction policy is invalid" >&2; return 1 ;; esac - members="$(find "$snap" -mindepth 1 -maxdepth 1 -printf '%f\n')" || return 1 - while IFS= read -r name; do - [ -n "$name" ] || continue - names_count=$((names_count + 1)) - case "$name" in - ready|format|policy|firewall|firewall.absent|manager|manager.absent|\ - enabled|enabled.absent|active|active.absent|\ - detached-active|detached-active.absent|legacy.hook|legacy.hook.absent|\ - legacy.route|legacy.route.absent) ;; - *) echo "!! Link transaction snapshot has an unknown member: $name" >&2; return 1 ;; - esac - done <<< "$members" - # format/ready/policy plus exactly one member from each of seven evidence - # pairs below. - [ "$names_count" -eq 10 ] || { - echo "!! Link transaction snapshot inventory is incomplete" >&2; return 1; } - if ! { link_snapshot_pair_valid "$snap/firewall" regular \ - && link_snapshot_pair_valid "$snap/manager" marker \ - && link_snapshot_pair_valid "$snap/enabled" marker \ - && link_snapshot_pair_valid "$snap/active" marker \ - && link_snapshot_pair_valid "$snap/detached-active" marker \ - && link_snapshot_pair_valid "$snap/legacy.hook" regular \ - && link_snapshot_pair_valid "$snap/legacy.route" regular; }; then - echo "!! Link transaction present/absent evidence is invalid" >&2 - return 1 - fi - [ ! -e "$snap/firewall" ] || link_firewall_record_valid "$snap/firewall" || { - echo "!! Link firewall snapshot is invalid" >&2; return 1; } - [ ! -e "$snap/legacy.hook" ] || { - grep -qxF '#!/bin/sh' "$snap/legacy.hook" \ - && grep -qF "[ \"\$2\" = \"up\" ] || exit 0" "$snap/legacy.hook" \ - && grep -qF 'ip route replace 224.0.0.0/4' "$snap/legacy.hook"; } || { - echo "!! Link legacy-hook snapshot is invalid" >&2; return 1; } - if [ -e "$snap/legacy.route" ]; then - case "$(sed -n '1p' "$snap/legacy.route")" in 224.0.0.0/4\ *) ;; *) - echo "!! Link legacy-route snapshot is invalid" >&2; return 1 ;; esac - fi -} - -preflight_link_transaction() -{ - local snap="$transaction_dir/link" - validate_link_transaction_snapshot || return 1 - validate_link_firewall_state || { - echo "!! Link firewall information changed before the previous system settings could be restored" >&2 - return 1 - } - legacy_network_safe_for_restore "$snap/legacy" || { - echo "!! The old multicast route changed before it could be restored" >&2 - return 1 - } -} - -preflight_link_commit_transaction() -{ - # Successful Link actions do not depend on disposable recovery files. - return 0 -} - -rollback_link_transaction() -{ - local snap="$transaction_dir/link" rc=0 - preflight_link_transaction || return 1 - [ -e "$snap/ready" ] || return 0 - quiesce_link_process_state_for_restore "$snap" || rc=$? - [ "$rc" -ne 0 ] || restore_firewall_snapshot "$snap/firewall" || rc=$? - [ "$rc" -ne 0 ] || restore_legacy_network "$snap/legacy" || rc=$? - [ "$rc" -ne 0 ] || restore_link_process_state "$snap" || rc=$? - [ "$rc" -ne 0 ] || firewall_matches_snapshot "$snap/firewall" || rc=1 - [ "$rc" -ne 0 ] || legacy_network_matches_snapshot "$snap/legacy" || rc=1 - [ "$rc" -eq 0 ] || { - echo "!! The previous Link firewall or service settings could not be fully restored" >&2 - return "$rc" - } - rm -rf -- "$snap" 2>/dev/null || true - [ ! -e "$snap" ] && [ ! -L "$snap" ] || \ - echo "!! Temporary Link recovery files could not be removed: $snap" >&2 || true - return 0 -} - -commit_link_transaction() -{ - local snap="$transaction_dir/link" - [ -e "$snap" ] || [ -L "$snap" ] || return 0 - if [ -d "$snap" ] && [ ! -L "$snap" ]; then - rm -rf -- "$snap" 2>/dev/null || true - [ ! -e "$snap" ] && [ ! -L "$snap" ] || \ - echo "!! Temporary Link recovery files could not be removed: $snap" >&2 || true - else - echo "!! Temporary Link recovery files could not be removed: $snap" >&2 || true - fi - return 0 } plan_link() { local output="" if [ "$action" = plan-disable ]; then - echo "PLAN: Turn off Ableton Link" - echo ' Stop the Ableton Link service and helper' - [ ! -r "$state_file" ] || echo ' Remove the firewall rule added for Link' - [ ! -e "$legacy_hook" ] || echo ' Remove the old Link multicast route' - echo ' Remove generated Link files and save Link as off' + ui_status l_plan_disable_heading + ui_status l_plan_stop_service + [ ! -r "$state_file" ] || ui_status l_plan_remove_firewall + [ ! -e "$legacy_hook" ] || ui_status l_plan_remove_multicast + ui_status l_plan_keep_files_save_off return 0 fi - echo "PLAN: Enable Ableton Link" - [ ! -e "$legacy_hook" ] || echo ' Remove the old Link multicast route' + ui_status l_plan_enable_heading + [ ! -e "$legacy_hook" ] || ui_status l_plan_remove_multicast if command -v ufw >/dev/null 2>&1 && grep -qsi '^ENABLED=yes' /etc/ufw/ufw.conf; then if output="$(ableton_run_bounded 20 ufw status 2>/dev/null)"; then if grep -Eq '(^|[[:space:]])20808/udp([[:space:]]|$)' \ <<< "$output"; then - echo ' Keep the existing UFW rule for UDP 20808' + ui_status l_plan_keep_ufw else - echo ' Open UDP 20808 with UFW' + ui_status l_plan_open_ufw fi else - echo ' Check UFW and open UDP 20808 if needed' + ui_status l_plan_check_ufw fi elif command -v firewall-cmd >/dev/null 2>&1 \ && ableton_run_bounded 20 firewall-cmd --state >/dev/null 2>&1; then if output="$(ableton_run_bounded 20 firewall-cmd --permanent --list-ports 2>/dev/null)"; then if grep -Eq '(^|[[:space:]])20808/udp([[:space:]]|$)' \ <<< "$output"; then - echo ' Keep the existing firewalld rule for UDP 20808' + ui_status l_plan_keep_firewalld else - echo ' Open UDP 20808 with firewalld' + ui_status l_plan_open_firewalld fi else - echo ' Check firewalld and open UDP 20808 if needed' + ui_status l_plan_check_firewalld fi else - echo ' Leave the firewall unchanged' + ui_status l_plan_firewall_unchanged fi case "$mode" in - session) echo ' Start Link only while Ableton Live is running' ;; - always) echo ' Start Link in the background with your user session' ;; + session) ui_status l_plan_mode_session ;; + always) ui_status l_plan_mode_always ;; esac - echo ' Save the Link setting' -} - -ABLETON_LINK_ENABLE_RECOVERY_ERROR="" -link_enable_recovery_error() -{ - ABLETON_LINK_ENABLE_RECOVERY_ERROR="${ABLETON_LINK_ENABLE_RECOVERY_ERROR}${ABLETON_LINK_ENABLE_RECOVERY_ERROR:+; }$1" -} - -populate_link_enable_snapshot() -{ - local snapshot="$1" - printf '3\n' > "$snapshot/format" || return 1 - : > "$snapshot/local-recovery" || return 1 - snapshot_link_firewall_state "$snapshot/firewall" || return 1 - snapshot_legacy_network "$snapshot/legacy" || return 1 - snapshot_link_service_state "$snapshot" || return 1 - : > "$snapshot/ready" || return 1 -} - -LINK_ENABLE_UNSTARTED_SNAPSHOT="" -LINK_ENABLE_ACTIVE_SNAPSHOT="" -LINK_ENABLE_COMMITTED=0 -# ShellCheck does not follow function names stored in traps. -# shellcheck disable=SC2329 -cleanup_unstarted_link_enable_snapshot() -{ - local rc=$? - trap - EXIT - case "$LINK_ENABLE_UNSTARTED_SNAPSHOT" in - "$ABLETON_STATE_HOME"/.link-enable.*) - if [ -d "$LINK_ENABLE_UNSTARTED_SNAPSHOT" ] \ - && [ ! -L "$LINK_ENABLE_UNSTARTED_SNAPSHOT" ]; then - rm -rf -- "$LINK_ENABLE_UNSTARTED_SNAPSHOT" 2>/dev/null || true - [ ! -e "$LINK_ENABLE_UNSTARTED_SNAPSHOT" ] \ - && [ ! -L "$LINK_ENABLE_UNSTARTED_SNAPSHOT" ] || \ - echo "!! Temporary Link recovery files could not be removed: $LINK_ENABLE_UNSTARTED_SNAPSHOT" >&2 - fi ;; - esac - exit "$rc" -} - -validate_link_enable_snapshot() -{ - local snapshot="$1" name members - [ -d "$snapshot" ] && [ ! -L "$snapshot" ] || return 1 - [ -f "$snapshot/format" ] && [ "$(sed -n '1p' "$snapshot/format")" = 3 ] \ - && [ -f "$snapshot/ready" ] && [ ! -s "$snapshot/ready" ] \ - && [ -f "$snapshot/local-recovery" ] && [ ! -s "$snapshot/local-recovery" ] \ - || return 1 - members="$(find "$snapshot" -mindepth 1 -maxdepth 1 -printf '%f\n')" || return 1 - while IFS= read -r name; do - [ -n "$name" ] || continue - case "$name" in - format|ready|local-recovery|firewall|firewall.absent|\ - manager|manager.absent|enabled|enabled.absent|active|active.absent|\ - detached-active|detached-active.absent|legacy.hook|legacy.hook.absent|\ - legacy.route|legacy.route.absent|\ - service-mutating|detached-mutating|FAILURE) ;; - *) return 1 ;; - esac - done <<< "$members" - link_snapshot_pair_valid "$snapshot/firewall" regular \ - && link_snapshot_pair_valid "$snapshot/manager" marker \ - && link_snapshot_pair_valid "$snapshot/enabled" marker \ - && link_snapshot_pair_valid "$snapshot/active" marker \ - && link_snapshot_pair_valid "$snapshot/detached-active" marker \ - && link_snapshot_pair_valid "$snapshot/legacy.hook" regular \ - && link_snapshot_pair_valid "$snapshot/legacy.route" regular || return 1 - [ ! -e "$snapshot/firewall" ] || link_firewall_record_valid "$snapshot/firewall" || return 1 - for name in service-mutating detached-mutating; do - [ ! -e "$snapshot/$name" ] \ - || { [ -f "$snapshot/$name" ] && [ ! -L "$snapshot/$name" ] \ - && [ ! -s "$snapshot/$name" ]; } || return 1 - done -} - -write_link_enable_failure() -{ - local snapshot="$1" operation_rc="$2" - printf 'operation=enable\noperation_exit=%s\nrestoration_complete=no\nrestoration_error=%s\n' \ - "$operation_rc" "$ABLETON_LINK_ENABLE_RECOVERY_ERROR" \ - > "$snapshot/FAILURE" 2>/dev/null || true -} - -restore_link_enable_snapshot() -{ - local snapshot="$1" operation_rc="$2" recovery_rc=0 - ABLETON_LINK_ENABLE_RECOVERY_ERROR="" - validate_link_enable_snapshot "$snapshot" || { - ABLETON_LINK_ENABLE_RECOVERY_ERROR="the saved firewall or service state is incomplete" - write_link_enable_failure "$snapshot" "$operation_rc" - return 1 - } - legacy_network_safe_for_restore "$snapshot/legacy" || { - link_enable_recovery_error "the old multicast route changed" - recovery_rc=1 - } - [ "$recovery_rc" -eq 0 ] || { - write_link_enable_failure "$snapshot" "$operation_rc" - return 1 - } - if [ "$recovery_rc" -eq 0 ] \ - && ! quiesce_link_process_state_for_restore "$snapshot"; then - link_enable_recovery_error "the Link service could not be stopped" - recovery_rc=1 - fi - if [ "$recovery_rc" -eq 0 ] && ! restore_firewall_snapshot "$snapshot/firewall"; then - link_enable_recovery_error "the previous firewall setting could not be restored"; recovery_rc=1 - fi - if [ "$recovery_rc" -eq 0 ] && ! restore_legacy_network "$snapshot/legacy"; then - link_enable_recovery_error "the previous multicast route could not be restored"; recovery_rc=1 - fi - if [ "$recovery_rc" -eq 0 ] && ! restore_link_process_state "$snapshot"; then - link_enable_recovery_error "the previous Link service state could not be restored" - recovery_rc=1 - fi - if [ "$recovery_rc" -eq 0 ]; then - firewall_matches_snapshot "$snapshot/firewall" || recovery_rc=1 - legacy_network_matches_snapshot "$snapshot/legacy" || recovery_rc=1 - [ "$recovery_rc" -eq 0 ] \ - || link_enable_recovery_error "the restored firewall or service setting could not be verified" - fi - if [ "$recovery_rc" -eq 0 ]; then - rm -rf -- "$snapshot" 2>/dev/null || true - [ ! -e "$snapshot" ] && [ ! -L "$snapshot" ] || \ - echo "!! Temporary Link recovery files could not be removed: $snapshot" >&2 - return 0 - fi - write_link_enable_failure "$snapshot" "$operation_rc" - return "$recovery_rc" -} - -finish_link_enable_recovery() -{ - local snapshot="$1" operation_rc="$2" - trap - EXIT - # Once restoration starts, a second Ctrl-C/TERM must not interrupt the - # recovery that the first signal requested. Every external step remains - # bounded, so ignoring those signals here cannot hang indefinitely. - trap '' INT TERM - LINK_ENABLE_ACTIVE_SNAPSHOT="" - if restore_link_enable_snapshot "$snapshot" "$operation_rc"; then - echo "!! Ableton Link could not be enabled. The previous firewall and service settings were restored." >&2 - trap - INT TERM - return "$LINK_ENABLE_RECOVERED_STATUS" - fi - echo "!! Ableton Link could not be enabled, and the previous system settings were not fully restored: $ABLETON_LINK_ENABLE_RECOVERY_ERROR" >&2 - echo "!! Recovery details were kept at $snapshot" >&2 - trap - INT TERM - return "$LINK_ENABLE_INCOMPLETE_STATUS" -} - -# ShellCheck does not follow function names stored in traps. -# shellcheck disable=SC2329 -link_enable_recovery_trap() -{ - local operation_rc="$1" outcome=0 snapshot="$LINK_ENABLE_ACTIVE_SNAPSHOT" - trap - EXIT - trap '' INT TERM - set +e - [ "$LINK_ENABLE_COMMITTED" -ne 1 ] || exit 0 - if [ -n "$snapshot" ]; then - finish_link_enable_recovery "$snapshot" "$operation_rc" || outcome=$? - else - outcome="$operation_rc" - fi - exit "$outcome" + ui_status l_plan_save_setting } enable_link() { - # Availability check only, against a local: a packaged install stages - # Packaged installs place the controller beside this script; repository - # installs also keep a copy in the generated data directory. - local ctl="$here/ableton-linkctl" + local ctl="$here/ableton-linkctl" rc=0 enabled_status=0 active_status=0 [ -x "$ctl" ] || ctl="$linkctl" [ -x "$ctl" ] || { echo "!! The Ableton Link controller is missing. Re-run the installer to restore it." >&2 @@ -1995,131 +898,65 @@ enable_link() echo "!! The Ableton Link helper is missing. Re-run the installer to restore it." >&2 return 1 } - echo "== enable Ableton Link ==" - local snapshot rc=0 outcome=0 enabled_status=0 active_status=0 if [ "$mode" = always ] && ! systemd_user_available; then echo "!! Background Link mode needs a running systemd user session" >&2 return 127 fi - # Publish a complete salvaged project configuration before the first host - # mutation. The requested mode is saved separately after it is achieved. - if [ "${ABLETON_CONFIG_REPAIR_NEEDED:-0}" = 1 ]; then - if ! save_link_mode; then - echo "!! Installer settings could not be repaired yet; continuing with the Link change" >&2 - fi - fi + + [ "$LINK_COORDINATED_ACTION" -eq 1 ] || ui_item_begin l_enable_link ABLETON_LINK_MODE="$mode" export ABLETON_LINK_MODE - ableton_mark_state_home || { - echo "!! Ableton Link cannot safely save the firewall changes it may need to undo" >&2 - return 1 - } + ableton_mark_state_home || return 1 prepare_link_firewall_state_for_enable || return 1 - snapshot="$(mktemp -d "$ABLETON_STATE_HOME/.link-enable.XXXXXX")" || { - echo "!! Ableton Link cannot save the current firewall and service settings, so nothing was changed" >&2 - return 1 - } - LINK_ENABLE_UNSTARTED_SNAPSHOT="$snapshot" - trap cleanup_unstarted_link_enable_snapshot EXIT - if ! populate_link_enable_snapshot "$snapshot"; then - rm -rf -- "$snapshot" 2>/dev/null || true - [ ! -e "$snapshot" ] && [ ! -L "$snapshot" ] || \ - echo "!! Temporary Link recovery files could not be removed: $snapshot" >&2 - LINK_ENABLE_UNSTARTED_SNAPSHOT="" - trap - EXIT - echo "!! Ableton Link cannot save the current firewall and service settings, so nothing was changed" >&2 - return 1 - fi - if [ "$mode" = always ] && [ ! -e "$snapshot/manager" ]; then - rm -rf -- "$snapshot" 2>/dev/null || true - [ ! -e "$snapshot" ] && [ ! -L "$snapshot" ] || \ - echo "!! Temporary Link recovery files could not be removed: $snapshot" >&2 - LINK_ENABLE_UNSTARTED_SNAPSHOT="" - trap - EXIT - echo "!! Background Link mode needs a running systemd user session" >&2 - return 127 - fi - LINK_ENABLE_UNSTARTED_SNAPSHOT="" - trap - EXIT - LINK_ENABLE_ACTIVE_SNAPSHOT="$snapshot" - trap 'link_enable_recovery_trap $?' EXIT - trap 'link_enable_recovery_trap 130' INT - trap 'link_enable_recovery_trap 143' TERM - remove_owned_legacy_hook || rc=$? - if [ "$rc" -eq 0 ]; then configure_firewall || rc=$?; fi - if [ "$rc" -eq 0 ] && [ "$mode" = always ]; then - if ! systemd_user_available; then - echo "!! Background Link mode needs a running systemd user session" >&2 - rc=127 - else - : > "$snapshot/service-mutating" - stop_canonical_link_service || rc=$? - fi + remove_owned_legacy_hook || return + configure_firewall || return + + if [ "$mode" = always ]; then + stop_canonical_link_service || return fi - if [ "$rc" -eq 0 ] && ! install_unit; then + if ! install_unit; then if [ "$mode" = always ]; then - rc=1 - else - link_cleanup_warning "The optional background Link service could not be prepared; Link will still run with Ableton Live" + return 1 fi - fi - if [ "$rc" -ne 0 ]; then - finish_link_enable_recovery "$snapshot" "$rc" || outcome=$? - return "$outcome" + link_cleanup_warning "The optional background Link service could not be prepared; Link will still run with Ableton Live" fi case "$mode" in - session) - if systemd_user_available; then - : > "$snapshot/service-mutating" - fi - stop_canonical_link_service || rc=$? ;; + session) stop_canonical_link_service || rc=$? ;; always) - : > "$snapshot/detached-mutating" stop_owned_detached_link_daemons || rc=$? - [ "$rc" -ne 0 ] || \ + if [ "$rc" -eq 0 ]; then ableton_run_bounded 20 systemctl --user enable --now \ - ableton-linkd.service || true ;; + ableton-linkd.service >/dev/null 2>&1 || rc=$? + fi ;; esac - if [ "$rc" -ne 0 ]; then - finish_link_enable_recovery "$snapshot" "$rc" || outcome=$? - return "$outcome" - fi + [ "$rc" -eq 0 ] || return "$rc" + if [ "$mode" = always ]; then ableton_run_bounded 20 systemctl --user is-enabled --quiet \ ableton-linkd.service 2>/dev/null || enabled_status=$? ableton_run_bounded 20 systemctl --user is-active --quiet \ ableton-linkd.service 2>/dev/null || active_status=$? - [ "$enabled_status" -eq 0 ] && [ "$active_status" -eq 0 ] || rc=1 - [ -z "$(first_owned_link_pid)" ] || rc=1 - fi - if [ "$rc" -ne 0 ]; then - finish_link_enable_recovery "$snapshot" "$rc" || outcome=$? - return "$outcome" + [ "$enabled_status" -eq 0 ] && [ "$active_status" -eq 0 ] || return 1 + [ -z "$(first_owned_link_pid)" ] || return 1 fi if ! save_link_mode; then LINK_SETTING_SAVED=0 echo "!! Ableton Link system changes completed, but its saved preference could not be updated" >&2 fi - LINK_ENABLE_COMMITTED=1 - LINK_ENABLE_ACTIVE_SNAPSHOT="" - trap - EXIT + # Link and its saved preference are complete. A closed progress stream or a + # late signal cannot turn reporting into an operation failure. trap '' INT TERM PIPE - # The requested firewall/service state is now complete. Preference failure - # was already reported and everything below is disposable cleanup/reporting. set +e - remove_legacy_policy_file - rm -rf -- "$snapshot" 2>/dev/null || true - [ ! -e "$snapshot" ] && [ ! -L "$snapshot" ] || \ - echo "!! Ableton Link is enabled, but temporary recovery files could not be removed: $snapshot" >&2 if [ "$LINK_COORDINATED_ACTION" -ne 1 ]; then if [ "$LINK_SETTING_SAVED" -ne 1 ]; then - echo "OK: Ableton Link system changes finished; run the installer again to save the preference" + ui_status l_enabled_unsaved else case "$mode" in - session) echo "OK: Ableton Link is enabled when Ableton Live is running" ;; - always) echo "OK: Ableton Link is enabled in the background" ;; + session) ui_status l_enabled_session ;; + always) ui_status l_enabled_always ;; esac fi + ui_item_end ok fi return 0 } @@ -2129,39 +966,34 @@ case "$action" in disable) disable_link ;; status) case "$ABLETON_LINK_MODE" in - off) echo 'mode: off' ;; - session) echo 'mode: session (while Ableton Live is running)' ;; - always) echo 'mode: always (background)' ;; + off) ui_status l_status_mode_off ;; + session) ui_status l_status_mode_session ;; + always) ui_status l_status_mode_always ;; esac status_pid="" if [ "$ABLETON_LINK_MODE" = always ] && loaded_unit_is_owned \ && ableton_run_bounded 20 systemctl --user is-active --quiet \ ableton-linkd.service 2>/dev/null; then - echo 'state: running (systemd)' + ui_status l_status_running_systemd elif status_pid="$(first_owned_link_pid)" \ && [ -n "$status_pid" ]; then - printf 'state: running (pid %s)\n' "$status_pid" + ui_status l_status_running_pid "$status_pid" elif [ -x "$ABLETON_LINKD" ]; then - echo 'state: stopped' + ui_status l_status_stopped else - echo 'state: not installed' + ui_status l_status_not_installed fi if ! validate_link_firewall_state; then - echo 'firewall: unknown (the saved Link firewall information is unreadable)' + ui_status l_status_firewall_unknown elif [ ! -r "$state_file" ]; then - echo 'firewall: unchanged' + ui_status l_status_firewall_unchanged else case "$(sed -n '1p' "$state_file")" in - ufw-added) echo 'firewall: UDP 20808 opened with ufw' ;; - firewalld-added) echo 'firewall: UDP 20808 opened with firewalld' ;; - none) echo 'firewall: unchanged' ;; + ufw-added) ui_status l_status_firewall_ufw ;; + firewalld-added) ui_status l_status_firewall_firewalld ;; + none) ui_status l_status_firewall_unchanged ;; esac fi ;; - snapshot) snapshot_link_transaction ;; - preflight-rollback) preflight_link_transaction ;; - preflight-commit) preflight_link_commit_transaction ;; - rollback) rollback_link_transaction ;; - commit) commit_link_transaction ;; plan-enable|plan-disable) plan_link ;; esac diff --git a/scripts/setup-prefix.sh b/scripts/setup-prefix.sh index df2a35a1..5572e99d 100755 --- a/scripts/setup-prefix.sh +++ b/scripts/setup-prefix.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# End-user step 2: create or refresh the Ableton Wine prefix. Idempotent. +# End-user step 2: create or refresh the ableton-linux prefix. Idempotent. # Ships no Ableton Live payload and no license; standalone setup can run the # user's own ableton_live*.zip download (~/Proprietary by default) - strictly opt-in # via ABLETON_LIVE_AUTOINSTALL=1, otherwise the manual steps are printed. @@ -13,29 +13,28 @@ # auto-install derives it from the chosen zip's filename (default 12). set -euo pipefail -# Terminal output is informational. A closed terminal, full log, or broken -# pipe must never change the result of prefix setup or recovery. -prefix_note() +wine_registry() { - printf '%s\n' "$*" 2>/dev/null || true - return 0 -} - -prefix_warn() -{ - printf '%s\n' "$*" >&2 2>/dev/null || true - return 0 + local task="$1" + shift + wine reg "$@" >/dev/null 2>&1 || { + echo "!! $task could not be saved in the Wine registry." >&2 || true + return 1 + } } here="$(cd "$(dirname "$0")" && pwd)" +. "$here/lib/ui.sh" +trap 'ui_cleanup $?' EXIT for config_lib in "$here/lib/config.sh" "$here/config.sh" \ "${XDG_DATA_HOME:-$HOME/.local/share}/ableton-wine/lib/config.sh"; do if [ -r "$config_lib" ]; then . "$config_lib"; break; fi done declare -F ableton_config_init >/dev/null 2>&1 \ - || { prefix_warn "!! Setup support files are incomplete. Run the latest installer again."; exit 1; } + || { echo "!! Setup support files are incomplete. Run the latest installer again." >&2 || true; exit 1; } . "$here/lib/manifest.sh" . "$here/lib/pipeasio.sh" +. "$here/lib/preferences.sh" # The kit root holds vendor/. Layouts that must work: # /scripts/setup-prefix.sh -> vendor at $here/../vendor (repo, extracted .run kit) @@ -56,18 +55,12 @@ kit_root() { } kit_root_or_die() { kit_root && return 0 - prefix_warn "!! The Windows support files for Live are missing." - prefix_warn "!! Run: sh install-ableton-latest.run update" - prefix_warn "!! Or extract the kit and run prefix setup from there." + echo "!! The Windows support files for Live are missing." >&2 || true + echo "!! Run: sh install-ableton-latest.run update" >&2 || true + echo "!! Or extract the kit and run prefix setup from there." >&2 || true exit 1 } -report_existing_prefix() -{ - printf ':: Using the existing Wine prefix: %s\n' "$1" 2>/dev/null || true - return 0 -} - refresh=0 post_first_run=0 validate_only=0 @@ -79,21 +72,21 @@ while [ $# -gt 0 ]; do --post-first-run) post_first_run=1 ;; --validate) validate_only=1 ;; --transaction-dir) - [ $# -ge 2 ] || { prefix_warn "!! --transaction-dir needs a directory"; exit 2; } + [ $# -ge 2 ] || { echo "!! --transaction-dir needs a directory" >&2 || true; exit 2; } transaction_dir="$2"; shift ;; --rollback) - [ $# -ge 2 ] || { prefix_warn "!! --rollback needs a directory"; exit 2; } + [ $# -ge 2 ] || { echo "!! --rollback needs a directory" >&2 || true; exit 2; } operation=rollback; transaction_dir="$2"; shift ;; --preflight-rollback) - [ $# -ge 2 ] || { prefix_warn "!! --preflight-rollback needs a directory"; exit 2; } + [ $# -ge 2 ] || { echo "!! --preflight-rollback needs a directory" >&2 || true; exit 2; } operation=preflight-rollback; transaction_dir="$2"; shift ;; --preflight-commit) - [ $# -ge 2 ] || { prefix_warn "!! --preflight-commit needs a directory"; exit 2; } + [ $# -ge 2 ] || { echo "!! --preflight-commit needs a directory" >&2 || true; exit 2; } operation=preflight-commit; transaction_dir="$2"; shift ;; --commit) - [ $# -ge 2 ] || { prefix_warn "!! --commit needs a directory"; exit 2; } + [ $# -ge 2 ] || { echo "!! --commit needs a directory" >&2 || true; exit 2; } operation=commit; transaction_dir="$2"; shift ;; - *) prefix_warn "!! Unknown option: $1"; exit 2 ;; + *) echo "!! Unknown option: $1" >&2 || true; exit 2 ;; esac shift done @@ -113,7 +106,7 @@ ableton_config_init repair case "${ABLETON_LIVE_VERSION:-12}" in 11|12) ;; - *) prefix_warn "!! ABLETON_LIVE_VERSION must be 11 or 12 (got '$ABLETON_LIVE_VERSION')"; exit 2 ;; + *) echo "!! ABLETON_LIVE_VERSION must be 11 or 12 (got '$ABLETON_LIVE_VERSION')" >&2 || true; exit 2 ;; esac caller_winedlloverrides="${WINEDLLOVERRIDES:-}" @@ -140,39 +133,39 @@ prefix_transaction_preflight() [ -f "$record" ] && [ ! -L "$record" ] && [ -r "$record" ] \ && ableton_file_has_no_nul "$record" \ && [ "$(wc -l < "$record")" -eq 1 ] || { - prefix_warn "!! The saved Wine prefix recovery data is unsafe or invalid."; return 1; } + echo "!! The saved ableton-linux prefix recovery data is unsafe or invalid." >&2 || true; return 1; } IFS=$'\t' read -r target backup extra < "$record" || { - prefix_warn "!! The saved Wine prefix recovery data could not be read."; return 1; } + echo "!! The saved ableton-linux prefix recovery data could not be read." >&2 || true; return 1; } [ -z "$extra" ] && [ -n "$target" ] && [ -n "$backup" ] || { - prefix_warn "!! The saved Wine prefix recovery data is malformed."; return 1; } + echo "!! The saved ableton-linux prefix recovery data is malformed." >&2 || true; return 1; } safe="$(ableton_path_is_safe_delete_target "$target")" || { - prefix_warn "!! The saved Wine prefix path is unsafe: $target"; return 1; } + echo "!! The saved ableton-linux prefix path is unsafe: $target" >&2 || true; return 1; } [ "$target" = "$safe" ] \ && [ "$safe" = "$(ableton_realpath_m "$ABLETON_WINEPREFIX")" ] || { - prefix_warn "!! The saved Wine prefix does not match this installation: $target" + echo "!! The saved ableton-linux prefix doesn't match this installation: $target" >&2 || true return 1 } if [ -e "$safe" ] || [ -L "$safe" ]; then if ! { [ -d "$safe" ] && [ ! -L "$safe" ] \ && ableton_prefix_marker_valid "$safe" "$safe"; }; then - prefix_warn "!! The Wine prefix selected for recovery is not recognized: $safe" + echo "!! The ableton-linux prefix selected for recovery is not recognized: $safe" >&2 || true return 1 fi fi if [ "$backup" != absent ]; then expected_backup="$safe.transaction-${txn##*/}" backup_safe="$(ableton_path_is_safe_delete_target "$backup")" || { - prefix_warn "!! The saved previous Wine prefix path is unsafe: $backup"; return 1; } + echo "!! The saved previous ableton-linux prefix path is unsafe: $backup" >&2 || true; return 1; } [ "$backup" = "$expected_backup" ] && [ "$backup" = "$backup_safe" ] || { - prefix_warn "!! The saved previous Wine prefix is in the wrong place: $backup"; return 1; } + echo "!! The saved previous ableton-linux prefix is in the wrong place: $backup" >&2 || true; return 1; } if [ -e "$backup" ] || [ -L "$backup" ]; then if ! { [ -d "$backup" ] && [ ! -L "$backup" ] \ && ableton_prefix_marker_valid "$backup" "$safe"; }; then - prefix_warn "!! The saved previous Wine prefix is not recognized: $backup" + echo "!! The saved previous ableton-linux prefix is not recognized: $backup" >&2 || true return 1 fi elif [ "$mode" != commit ]; then - prefix_warn "!! The saved previous Wine prefix is missing: $backup" + echo "!! The saved previous ableton-linux prefix is missing: $backup" >&2 || true return 1 fi fi @@ -182,9 +175,13 @@ prefix_transaction_preflight() # readable so an interrupted older install can still be restored safely. if [ -e "$txn/prefix-host" ] || [ -L "$txn/prefix-host" ]; then [ -d "$txn/prefix-host" ] && [ ! -L "$txn/prefix-host" ] || { - prefix_warn "!! Saved support-file recovery data is unsafe."; return 1; } + echo "!! Saved support-file recovery data is unsafe." >&2 || true; return 1; } ableton_txn_preflight_rollback_files "$txn/prefix-host" || return 1 fi + ableton_pipeasio_seed_record_preflight "$txn" || { + echo "!! The saved PipeASIO recovery data is unsafe or invalid." >&2 || true + return 1 + } } prefix_transaction_commit_preflight() @@ -194,6 +191,10 @@ prefix_transaction_commit_preflight() if [ -e "$txn/prefix-host" ]; then ableton_txn_preflight_commit_files "$txn/prefix-host" || return 1 fi + ableton_pipeasio_seed_record_commit_preflight "$txn" || { + echo "!! Fresh PipeASIO settings have not reached a committable generation." >&2 || true + return 1 + } } prefix_transaction_rollback() @@ -203,15 +204,15 @@ prefix_transaction_rollback() prefix_transaction_preflight "$txn" || return 1 if [ -r "$txn/prefix.tsv" ]; then IFS=$'\t' read -r target backup < "$txn/prefix.tsv" || { - prefix_warn "!! The saved Wine prefix recovery data could not be read." + echo "!! The saved ableton-linux prefix recovery data could not be read." >&2 || true return 1 } if ! safe="$(ableton_path_is_safe_delete_target "$target")"; then - prefix_warn "!! The saved Wine prefix path is unsafe: $target" + echo "!! The saved ableton-linux prefix path is unsafe: $target" >&2 || true rc=1; layout_ok=0 elif [ "$target" != "$safe" ] \ || [ "$safe" != "$(ableton_realpath_m "$ABLETON_WINEPREFIX")" ]; then - prefix_warn "!! The saved Wine prefix does not match this installation: $target" + echo "!! The saved ableton-linux prefix doesn't match this installation: $target" >&2 || true rc=1; layout_ok=0 fi if [ "$layout_ok" -eq 1 ] && [ "$backup" != absent ]; then @@ -220,16 +221,16 @@ prefix_transaction_rollback() || [ "$backup" != "$expected_backup" ] || [ "$backup" != "$backup_safe" ] \ || [ ! -d "$backup" ] || [ -L "$backup" ] \ || ! ableton_prefix_marker_valid "$backup" "$safe"; then - prefix_warn "!! The saved previous Wine prefix is missing, misplaced, or not recognized: $backup" + echo "!! The saved previous ableton-linux prefix is missing, misplaced, or not recognized: $backup" >&2 || true rc=1; layout_ok=0 fi fi if [ "$layout_ok" -eq 1 ] && [ -e "$safe" ]; then if [ -L "$safe" ]; then - prefix_warn "!! Recovery stopped because the Wine prefix path is a link: $safe" + echo "!! Recovery stopped because the ableton-linux prefix path is a link: $safe" >&2 || true rc=1; layout_ok=0 elif ! ableton_prefix_marker_valid "$safe" "$safe"; then - prefix_warn "!! Recovery stopped because this Wine prefix was not created by Ableton Linux: $safe" + echo "!! Recovery stopped because this ableton-linux prefix was not created by Ableton Linux: $safe" >&2 || true rc=1; layout_ok=0 elif ! rm -rf -- "$safe"; then rc=1; layout_ok=0 @@ -252,6 +253,10 @@ prefix_transaction_rollback() rm -f -- "$txn/prefix-host/active" || rc=1 fi fi + if ! ableton_pipeasio_seed_record_rollback "$txn"; then + echo "!! Fresh PipeASIO settings could not be restored safely." >&2 || true + rc=1 + fi update-desktop-database "${XDG_DATA_HOME:-$HOME/.local/share}/applications" >/dev/null 2>&1 || true return "$rc" } @@ -260,27 +265,29 @@ prefix_transaction_commit() { local txn="$1" target backup safe prefix_transaction_commit_preflight "$txn" || return 1 - [ -r "$txn/prefix.tsv" ] || return 0 - IFS=$'\t' read -r target backup < "$txn/prefix.tsv" || { - prefix_warn "!! The saved Wine prefix cleanup data could not be read." - return 1 - } - if [ "$backup" != absent ] && [ -e "$backup" ]; then - safe="$(ableton_path_is_safe_delete_target "$backup")" || { prefix_warn "!! The saved previous Wine prefix path is unsafe: $backup"; return 1; } - [ ! -L "$safe" ] || { prefix_warn "!! Cleanup stopped because the saved previous Wine prefix is a link: $safe"; return 1; } - ableton_prefix_marker_valid "$safe" "$target" || { - prefix_warn "!! Cleanup stopped because the saved previous Wine prefix is not recognized: $safe" + if [ -r "$txn/prefix.tsv" ]; then + IFS=$'\t' read -r target backup < "$txn/prefix.tsv" || { + echo "!! The saved ableton-linux prefix cleanup data could not be read." >&2 || true return 1 } + if [ "$backup" != absent ] && [ -e "$backup" ]; then + safe="$(ableton_path_is_safe_delete_target "$backup")" || { echo "!! The saved previous ableton-linux prefix path is unsafe: $backup" >&2 || true; return 1; } + [ ! -L "$safe" ] || { echo "!! Cleanup stopped because the saved previous ableton-linux prefix is a link: $safe" >&2 || true; return 1; } + ableton_prefix_marker_valid "$safe" "$target" || { + echo "!! Cleanup stopped because the saved previous ableton-linux prefix is not recognized: $safe" >&2 || true + return 1 + } + prefix_commit_started=1 + rm -rf -- "$safe" || return 1 + [ ! -e "$safe" ] && [ ! -L "$safe" ] || return 1 + fi prefix_commit_started=1 - rm -rf -- "$safe" || return 1 - [ ! -e "$safe" ] && [ ! -L "$safe" ] || return 1 + rm -f -- "$txn/prefix.tsv" || return 1 fi - prefix_commit_started=1 - rm -f -- "$txn/prefix.tsv" || return 1 if [ -d "$txn/prefix-host" ] && [ ! -L "$txn/prefix-host" ]; then rm -f -- "$txn/prefix-host/active" || return 1 fi + ableton_pipeasio_seed_record_commit "$txn" || return 1 } if [ "$operation" != setup ]; then @@ -294,7 +301,7 @@ case "$operation" in esac case "${ABLETON_THEME_MODE:-auto}" in auto|dark|light|preserve) ;; - *) prefix_warn "!! ABLETON_THEME_MODE must be auto, dark, light, or preserve"; exit 2 ;; + *) echo "!! ABLETON_THEME_MODE must be auto, dark, light, or preserve" >&2 || true; exit 2 ;; esac if [ "$validate_only" -eq 1 ]; then @@ -303,16 +310,16 @@ if [ "$validate_only" -eq 1 ]; then case "$validate_dpi" in auto|preserve|100|fractional) ;; dpi*|fractional*) ableton_dpi_block_values "$validate_dpi" >/dev/null || { - prefix_warn "!! Invalid ABLETON_DPI_MODE '$validate_dpi'"; exit 2; } ;; - *) prefix_warn "!! ABLETON_DPI_MODE must be auto, preserve, 100, fractional, dpi, or fractional"; exit 2 ;; + echo "!! Invalid ABLETON_DPI_MODE '$validate_dpi'" >&2 || true; exit 2; } ;; + *) echo "!! ABLETON_DPI_MODE must be auto, preserve, 100, fractional, dpi, or fractional" >&2 || true; exit 2 ;; esac if [ "${ABLETON_RUNTIME_PENDING:-0}" != 1 ]; then - [ -x "$WINE_ROOT/bin/wine" ] || { prefix_warn "!! Patched Wine was not found at $WINE_ROOT"; exit 1; } + [ -x "$WINE_ROOT/bin/wine" ] || { echo "!! Patched Wine was not found at $WINE_ROOT" >&2 || true; exit 1; } fi if [ "$refresh" -eq 1 ]; then - [ -f "$WINEPREFIX/system.reg" ] || { prefix_warn "!! --refresh needs an existing Wine prefix at $WINEPREFIX"; exit 2; } + [ -f "$WINEPREFIX/system.reg" ] || { echo "!! --refresh needs an existing ableton-linux prefix at $WINEPREFIX" >&2 || true; exit 2; } fi - prefix_note "-- Wine prefix settings are valid" + ui_status p_settings_valid exit 0 fi @@ -325,18 +332,18 @@ ableton_install_lock_acquire if [ "$post_first_run" -eq 1 ]; then ableton_install_lock_acquire safe_repair_prefix="$(ableton_path_is_safe_delete_target "$WINEPREFIX")" || { - prefix_warn "!! The Wine prefix path is unsafe: $WINEPREFIX"; exit 2; } + echo "!! The ableton-linux prefix path is unsafe: $WINEPREFIX" >&2 || true; exit 2; } if ! { [ "$safe_repair_prefix" = "$WINEPREFIX" ] && [ ! -L "$WINEPREFIX" ] \ && ableton_prefix_marker_valid "$WINEPREFIX" "$WINEPREFIX"; }; then - prefix_warn "!! Live 11 repair stopped because this Wine prefix was not created by Ableton Linux." + echo "!! Live 11 repair stopped because this ableton-linux prefix was not created by Ableton Linux." >&2 || true exit 2 fi . "$here/lib/lifecycle.sh" if ableton_prefix_busy; then - prefix_warn "!! Close Live, Max, and other Wine programs before running the Live 11 repair." + echo "!! Close Live, Max, and other Wine programs before running the Live 11 repair." >&2 || true exit 1 fi - [ -f "$WINEPREFIX/system.reg" ] || { prefix_warn "!! No Wine prefix was found at $WINEPREFIX for Live 11 repair."; exit 2; } + [ -f "$WINEPREFIX/system.reg" ] || { echo "!! No ableton-linux prefix was found at $WINEPREFIX for Live 11 repair." >&2 || true; exit 2; } moved=0 move_failed=0 for maxpref in "$WINEPREFIX"/drive_c/users/*/"AppData/Roaming/Cycling '74/Max 8/Settings/maxpreferences.maxpref"; do @@ -355,15 +362,15 @@ if [ "$post_first_run" -eq 1 ]; then printf '!! Live 11 repair did not finish for every user. Files already moved aside were kept safely.\n' >&2 2>/dev/null || true exit 1 elif [ "$moved" -gt 0 ]; then - printf 'OK: Max preferences moved aside; Max regenerates them on next start\n' 2>/dev/null || true + ui_status p_repair_moved else - printf 'OK: No Live 11 Max preferences needed repair.\n' 2>/dev/null || true + ui_status p_repair_nothing fi exit 0 fi [ -x "$WINE_ROOT/bin/wine" ] || { - prefix_warn "!! Patched Wine was not found at $WINE_ROOT. Install the Wine runtime first." + echo "!! Patched Wine was not found at $WINE_ROOT. Install the Wine runtime first." >&2 || true exit 1 } for required in \ @@ -376,7 +383,7 @@ for required in \ lib/wine/x86_64-windows/pipeasio.dll \ lib/wine/x86_64-unix/pipeasio64.dll.so \ lib/wine/x86_64-unix/pipeasio.dll.so; do - [ -s "$WINE_ROOT/$required" ] || { prefix_warn "!! The Wine runtime is incomplete: $required is missing."; exit 1; } + [ -s "$WINE_ROOT/$required" ] || { echo "!! The Wine runtime is incomplete: $required is missing." >&2 || true; exit 1; } done ableton_pipeasio_validate_runtime "$WINE_ROOT" ableton_pipewire_preflight "$WINE_ROOT/bin/pipewire-version-probe" "configuring PipeASIO" @@ -387,15 +394,14 @@ ableton_pipewire_preflight "$WINE_ROOT/bin/pipewire-version-probe" "configuring # through Live installation, so any later failure can restore the old prefix. . "$here/lib/lifecycle.sh" if ableton_prefix_busy; then - prefix_warn "!! Close Live, Max, and other Wine programs before updating the Wine prefix." - exit 1 + ableton_stop_runtime_clients || exit 1 fi final_prefix="$WINEPREFIX" final_parent="$(dirname "$final_prefix")" final_name="$(basename "$final_prefix")" safe_final="$(ableton_path_is_safe_delete_target "$final_prefix")" || { - prefix_warn "!! The Wine prefix path is unsafe: $final_prefix"; exit 2; } -[ ! -L "$final_prefix" ] || { prefix_warn "!! The Wine prefix path must not be a link: $final_prefix"; exit 2; } + echo "!! The ableton-linux prefix path is unsafe: $final_prefix" >&2 || true; exit 2; } +[ ! -L "$final_prefix" ] || { echo "!! The ableton-linux prefix path must not be a link: $final_prefix" >&2 || true; exit 2; } if [ -e "$final_prefix" ] && ! ableton_prefix_marker_valid "$final_prefix" "$safe_final"; then if ableton_legacy_default_prefix_valid "$final_prefix"; then # Adopt it here, before any transaction opens. The run below moves this @@ -405,11 +411,11 @@ if [ -e "$final_prefix" ] && ! ableton_prefix_marker_valid "$final_prefix" "$saf # the commit then fails with the backup unrecognised. adopt_marker="$final_prefix/.ableton-linux-prefix" [ ! -L "$adopt_marker" ] && [ ! -e "$adopt_marker" ] || { - prefix_warn "!! The existing Wine prefix was left unchanged because its Ableton Linux setup record is not a regular file: $adopt_marker" + echo "!! The existing ableton-linux prefix was left unchanged because its Ableton Linux setup record is not a regular file: $adopt_marker" >&2 || true exit 2 } adopt_tmp="$(mktemp "$final_prefix/.prefix-marker.XXXXXX")" || { - prefix_warn "!! The existing Wine prefix is not writable: $final_prefix"; exit 2; } + echo "!! The existing ableton-linux prefix is not writable: $final_prefix" >&2 || true; exit 2; } if ! printf 'format=1\nprefix=%s\n' "$safe_final" > "$adopt_tmp" \ || ! chmod 600 "$adopt_tmp" \ || ! mv -T -f -- "$adopt_tmp" "$adopt_marker" \ @@ -418,13 +424,13 @@ if [ -e "$final_prefix" ] && ! ableton_prefix_marker_valid "$final_prefix" "$saf # ableton_legacy_default_prefix_valid stops recognising the prefix, # so a half-written one would refuse every later run as well. rm -f -- "$adopt_tmp" "$adopt_marker" - prefix_warn "!! The existing Wine prefix could not be prepared safely: $final_prefix" + echo "!! The existing ableton-linux prefix could not be prepared safely: $final_prefix" >&2 || true exit 2 fi - report_existing_prefix "$final_prefix" + ui_status p_using_existing_prefix "$final_prefix" else - prefix_warn "!! This Wine prefix was left unchanged because the installer could not confirm that it created it: $final_prefix" - prefix_warn "!! Choose a different prefix, or move this one aside and run setup again." + echo "!! This ableton-linux prefix was left unchanged because the installer could not confirm that it created it: $final_prefix" >&2 || true + echo "!! Choose a different prefix, or move this one aside and run setup again." >&2 || true exit 2 fi fi @@ -445,6 +451,7 @@ cleanup_unstarted_prefix_transaction() { local rc=$? restore_ok=1 trap - EXIT + ui_cleanup "$rc" if [ "$rc" -ne 0 ]; then prefix_transaction_rollback "$transaction_dir" || restore_ok=0 fi @@ -453,14 +460,14 @@ cleanup_unstarted_prefix_transaction() rm -rf -- "$transaction_dir" || restore_ok=0 fi if [ "$restore_ok" -ne 1 ]; then - prefix_warn "!! Wine prefix setup failed, and recovery did not finish. Keep the files at $transaction_dir and report the problem." + echo "!! The ableton-linux prefix setup failed, and recovery did not finish. Keep the files at $transaction_dir and report the problem." >&2 || true fi exit "$rc" } trap cleanup_unstarted_prefix_transaction EXIT if [ -e "$final_prefix" ]; then ableton_adopt_prefix_marker "$final_prefix" "$safe_final" || { - prefix_warn "!! This Wine prefix was left unchanged because it was not created by Ableton Linux: $final_prefix" + echo "!! This ableton-linux prefix was left unchanged because it was not created by Ableton Linux: $final_prefix" >&2 || true exit 2 } fi @@ -471,17 +478,18 @@ prefix_cleanup() { local rc=$? restore_error="" restoration_complete=yes trap - EXIT + ui_cleanup "$rc" # This handler decides the result from the prefix/recovery state below. # A closed terminal or log consumer must not interrupt that decision. set +e if [ "$rc" -ne 0 ]; then if [ "$prefix_core_ready" -eq 1 ]; then - echo "!! The Wine prefix is ready, but old recovery files may remain at $transaction_dir." >&2 || true + echo "!! The ableton-linux prefix is ready, but old recovery files may remain at $transaction_dir." >&2 || true rc=0 elif [ "$prefix_commit_started" -eq 1 ]; then printf 'status=committed-cleanup-incomplete\nprefix=%s\nexit=%s\n' \ "$final_prefix" "$rc" > "$transaction_dir/COMMITTED_CLEANUP_FAILURE" 2>/dev/null || true - echo "!! The Wine prefix is ready, but old recovery files remain at $transaction_dir." >&2 || true + echo "!! The ableton-linux prefix is ready, but old recovery files remain at $transaction_dir." >&2 || true rc=0 elif [ "$prefix_promoted" -eq 1 ]; then if ! prefix_transaction_rollback "$transaction_dir"; then @@ -492,7 +500,7 @@ prefix_cleanup() restore_error="staged prefix cleanup failed" fi if ! prefix_transaction_rollback "$transaction_dir"; then - restore_error="${restore_error}${restore_error:+; }Wine prefix recovery failed" + restore_error="${restore_error}${restore_error:+; }ableton-linux prefix recovery failed" fi fi if [ "$rc" -ne 0 ] && [ "$prefix_commit_started" -ne 1 ]; then @@ -501,9 +509,9 @@ prefix_cleanup() "$final_prefix" "$rc" "$restoration_complete" "$restore_error" \ > "$transaction_dir/prefix-failure" || true if [ "$restoration_complete" = yes ]; then - echo "!! Wine prefix setup failed. The previous prefix is back in place." >&2 || true + echo "!! The ableton-linux prefix setup failed. The previous prefix is back in place." >&2 || true else - echo "!! Wine prefix setup failed, and the previous prefix could not be fully restored: $restore_error" >&2 || true + echo "!! The ableton-linux prefix setup failed, and the previous prefix could not be fully restored: $restore_error" >&2 || true echo "!! Keep the recovery files at $transaction_dir and report the problem." >&2 || true fi fi @@ -515,8 +523,8 @@ trap 'exit 130' INT trap 'exit 143' TERM if [ -f "$final_prefix/system.reg" ]; then - prefix_note "== Preparing the existing Wine prefix ==" - cp -a --reflink=auto -- "$final_prefix/." "$stage_prefix/" + ui_run p_copy_existing_prefix -- \ + cp -a --reflink=auto -- "$final_prefix/." "$stage_prefix/" fi export WINEPREFIX="$stage_prefix" @@ -607,13 +615,13 @@ check_mutter_knob() { # warn when mutter's xwayland-native-scaling disagrees wi case "$1" in fractional*) # upscaled-framebuffer sets need the knob if [[ "$feats" != *xwayland-native-scaling* ]]; then - prefix_warn "!! GNOME's xwayland-native-scaling setting is off, but the selected display scaling needs it." - prefix_warn "!! Add xwayland-native-scaling to org.gnome.mutter experimental-features." + echo "!! GNOME's xwayland-native-scaling setting is off, but the selected display scaling needs it." >&2 || true + echo "!! Add xwayland-native-scaling to org.gnome.mutter experimental-features." >&2 || true fi ;; *) if [[ "$feats" = *xwayland-native-scaling* ]]; then - prefix_warn "!! GNOME's xwayland-native-scaling setting is on, but the selected display scaling does not use it." - prefix_warn "!! Remove xwayland-native-scaling from org.gnome.mutter experimental-features." + echo "!! GNOME's xwayland-native-scaling setting is on, but the selected display scaling does not use it." >&2 || true + echo "!! Remove xwayland-native-scaling from org.gnome.mutter experimental-features." >&2 || true fi ;; esac return 0 @@ -648,24 +656,24 @@ strip_options_txt() { || ! awk -v opt="$line" '{ l = $0; sub(/\r$/, "", l) } l != opt { print }' \ "$f" > "$tmp"; then [ -z "${tmp:-}" ] || rm -f -- "$tmp" 2>/dev/null || true - prefix_warn "!! Could not update $f. Remove $line from that file before using Live." + echo "!! Could not update $f. Remove $line from that file before using Live." >&2 || true continue fi if [ -s "$tmp" ]; then # Write through the existing inode: keeps the file's permissions. if cat "$tmp" > "$f"; then - prefix_note " Removed $line from $f" + ui_status p_options_line_removed "$line" "$f" else - prefix_warn "!! Could not update $f. Remove $line from that file before using Live." + echo "!! Could not update $f. Remove $line from that file before using Live." >&2 || true fi rm -f -- "$tmp" 2>/dev/null || true else # The seed's touch created the file; nothing else in it: undo fully. rm -f -- "$tmp" 2>/dev/null || true if rm -f -- "$f"; then - prefix_note " Removed $f because it contained only $line" + ui_status p_options_file_removed "$f" "$line" else - prefix_warn "!! Could not remove $line from $f. Remove that file before using Live." + echo "!! Could not remove $line from $f. Remove that file before using Live." >&2 || true fi fi done @@ -675,7 +683,7 @@ strip_options_txt() { fresh_prefix=0 [ -f "$WINEPREFIX/system.reg" ] || fresh_prefix=1 if [ "$refresh" -eq 1 ] && [ "$fresh_prefix" -eq 1 ]; then - prefix_warn "!! --refresh needs an existing Wine prefix at $WINEPREFIX. Run without --refresh to create one." + echo "!! --refresh needs an existing ableton-linux prefix at $WINEPREFIX. Run without --refresh to create one." >&2 || true exit 2 fi @@ -692,7 +700,7 @@ case "$dpi_mode" in if ableton_dpi_block_values "$dpi_mode" >/dev/null; then dpi_block="$dpi_mode" else - prefix_warn "!! ABLETON_DPI_MODE '$dpi_mode' is invalid. Use dpi or fractional, with N from 72 to 384." + echo "!! ABLETON_DPI_MODE '$dpi_mode' is invalid. Use dpi or fractional, with N from 72 to 384." >&2 || true exit 2 fi ;; @@ -706,37 +714,37 @@ case "$dpi_mode" in if [ "$fresh_prefix" -eq 1 ]; then if [ "$dpi_family" = gnome ] && [[ "$block" = fractional* ]] \ && ! gnome_native_scaling_active; then - prefix_warn "!! GNOME native Xwayland scaling could not be confirmed; using 100% scaling for this fresh prefix." - prefix_warn "!! Set ABLETON_DPI_MODE=$block explicitly only after enabling xwayland-native-scaling." + echo "!! GNOME native Xwayland scaling could not be confirmed; using 100% scaling for this fresh prefix." >&2 || true + echo "!! Set ABLETON_DPI_MODE=$block explicitly only after enabling xwayland-native-scaling." >&2 || true dpi_block=100 else - prefix_note " Detected display scale $scale ($dpi_family); applying $block scaling settings." + ui_status p_scale_detected "$scale" "$dpi_family" "$block" dpi_block="$block" fi else have="$(current_dpi_block)" if [ "$have" = "$block" ]; then - prefix_note " Display scaling is already configured for scale $scale." + ui_status p_scale_already_configured "$scale" else - prefix_warn "!! Display scale $scale differs from this Wine prefix's current settings ($have)." - prefix_warn "!! Existing settings were kept. To change them, rerun with ABLETON_DPI_MODE=$block." + echo "!! Display scale $scale differs from the current settings for this ableton-linux prefix ($have)." >&2 || true + echo "!! Existing settings were kept. To change them, rerun with ABLETON_DPI_MODE=$block." >&2 || true fi fi elif [ "$fresh_prefix" -eq 1 ]; then dpi_block=100 - prefix_warn "!! Display scale $scale is outside the supported automatic range. Using 100% scaling." + echo "!! Display scale $scale is outside the supported automatic range. Using 100% scaling." >&2 || true else - prefix_warn "!! Display scale $scale is outside the supported automatic range. Existing settings were kept." + echo "!! Display scale $scale is outside the supported automatic range. Existing settings were kept." >&2 || true fi elif [ "$fresh_prefix" -eq 1 ]; then dpi_block=100 - prefix_warn "!! Display scale could not be detected. Using 100% scaling." + echo "!! Display scale could not be detected. Using 100% scaling." >&2 || true else - prefix_note " Display scale could not be detected; existing settings were kept." + ui_status p_scale_undetected_existing fi ;; *) - prefix_warn "!! ABLETON_DPI_MODE must be auto, preserve, 100, fractional, or dpi" + echo "!! ABLETON_DPI_MODE must be auto, preserve, 100, fractional, or dpi" >&2 || true exit 2 ;; esac @@ -788,26 +796,27 @@ if [ "${ABLETON_PREFIX_MANAGED:-0}" != 1 ] && [ -z "${ABLETON_LIVE_VERSION:-}" ] 11|12) live_major="$zip_major" if [ "$live_major" != 12 ]; then - prefix_note ":: $(basename "$live_zip") is Live $live_major; preparing its required Windows support files." + ui_status p_zip_live_major "$(basename "$live_zip")" "$live_major" fi ;; "") - prefix_warn "!! Could not read the Live version from $(basename "$live_zip"); preparing for Live 12. Set ABLETON_LIVE_VERSION if that is wrong." + echo "!! Could not read the Live version from $(basename "$live_zip"); preparing for Live 12. Set ABLETON_LIVE_VERSION if that is wrong." >&2 || true ;; *) - prefix_warn "!! $(basename "$live_zip") looks like Live $zip_major, but setup supports Live 11 or 12. Set ABLETON_LIVE_VERSION or remove that zip." + echo "!! $(basename "$live_zip") looks like Live $zip_major, but setup supports Live 11 or 12. Set ABLETON_LIVE_VERSION or remove that zip." >&2 || true exit 2 ;; esac fi -prefix_note "== Preparing Wine at $WINEPREFIX ==" # While updating the prefix, wineboot offers Wine's Mono and Gecko installers. This runtime # vendors neither, so on a machine with no cached package it opens a modal "Wine Mono # Installer" prompt; nothing answers it in an unattended run and the wineserver -w below then # never returns. Live needs neither - ableton-live and max9 already disable both on every # launch - so disable them here and wineboot stops asking. -WINEDLLOVERRIDES="${caller_winedlloverrides:+$caller_winedlloverrides;}mscoree,mshtml=" wineboot -u +WINEDLLOVERRIDES="${caller_winedlloverrides:+$caller_winedlloverrides;}mscoree,mshtml=" \ + ui_run p_update_prefix_files -- wineboot -u +ui_item_begin p_prepare_wine "$WINEPREFIX" # A prefix that got Ableton's USB audio driver carries the driver's tray agent, and # wineboot's startup pass relaunches it on every boot. The agent never exits by itself and # does not always have a window: Live 11's driver MSI (v5.57 File table) installs @@ -848,12 +857,13 @@ ableton_wineserver_quiesce || boot_wait_rc=$? # not run - means the boot did not complete, and the winetricks step must not run # against a prefix in that state. [ "$boot_wait_rc" -eq 0 ] || [ "$boot_wait_rc" -eq 3 ] || exit "$boot_wait_rc" +ui_item_end ok if [ "$refresh" -eq 1 ]; then - prefix_note "== Keeping the installed Windows fonts and support files ==" + ui_item_begin p_keep_fonts_support else command -v cabextract >/dev/null || { - prefix_warn "!! cabextract is needed to unpack the Windows support files. Install it and run setup again." + echo "!! cabextract is needed to unpack the Windows support files. Install it and run setup again." >&2 || true exit 1 } # Verb set per Live major: Live 12 needs vcrun2022 + mfc42; Live 11 needs @@ -862,9 +872,9 @@ else case "$live_major" in 11) verbs=(corefonts vcrun2019 gdiplus) ;; 12) verbs=(corefonts vcrun2022 mfc42) ;; - *) prefix_warn "!! Live $live_major is not supported by this setup."; exit 2 ;; + *) echo "!! Live $live_major is not supported by this setup." >&2 || true; exit 2 ;; esac - prefix_note "== Installing Windows fonts and support files for Live $live_major ==" + ui_item_begin p_install_fonts_support "$live_major" kit_root_or_die export W_CACHE_OVERRIDE="" # unused export WINETRICKS_LATEST_VERSION_CHECK=disabled @@ -891,11 +901,11 @@ else ln -s "$cached" "$tmpc/winetricks/" || exit 1 done ); then export XDG_CACHE_HOME="$tmpc" - prefix_note " Using the bundled dependency cache." + ui_status p_using_bundled_cache else [ -z "$tmpc" ] || rm -rf -- "$tmpc" 2>/dev/null || true tmpc="" - prefix_warn "!! The bundled dependency cache could not be prepared. Setup will download the required files instead." + echo "!! The bundled dependency cache could not be prepared. Setup will download the required files instead." >&2 || true fi fi # WINE64 preset: this is a new-style WoW64 tree (single wine binary, no @@ -921,6 +931,7 @@ else fi ableton_wineserver_wait fi +ui_item_end ok # Repair Max for Live's font fallback chain. # @@ -967,8 +978,8 @@ install_maxplug_fallback_fonts() { fi if [ -z "$src" ]; then - prefix_warn "!! Bitstream Vera fonts were not found. Some Max for Live devices may freeze Live when a typeface is missing." - prefix_warn "!! Install ttf-bitstream-vera (Debian/Ubuntu or Arch) or bitstream-vera-fonts (Fedora), then rerun setup." + echo "!! Bitstream Vera fonts were not found. Some Max for Live devices may freeze Live when a typeface is missing." >&2 || true + echo "!! Install ttf-bitstream-vera (Debian/Ubuntu or Arch) or bitstream-vera-fonts (Fedora), then rerun setup." >&2 || true return 0 # non-fatal: everything else still works fi @@ -977,7 +988,7 @@ install_maxplug_fallback_fonts() { # worse than a working prefix plus a loud warning. check-m4l-fonts.sh # catches the not-installed state later. if ! mkdir -p "$winfonts"; then - prefix_warn "!! Max for Live fallback fonts could not be installed at $winfonts." + echo "!! Max for Live fallback fonts could not be installed at $winfonts." >&2 || true return 0 fi @@ -989,11 +1000,11 @@ install_maxplug_fallback_fonts() { if cp -u "$src/$n" "$winfonts/$n" 2>/dev/null || cp "$src/$n" "$winfonts/$n"; then copied=$((copied + 1)) else - prefix_warn "!! Max for Live fallback font $n could not be copied to $winfonts." + echo "!! Max for Live fallback font $n could not be copied to $winfonts." >&2 || true fi done if [ "$missing" -eq 1 ]; then - prefix_warn "!! Some Bitstream Vera font files are missing; the available fonts will still be installed." + echo "!! Some Bitstream Vera font files are missing; the available fonts will still be installed." >&2 || true fi # One import rather than ten `wine reg add` calls: each spawns a wine @@ -1001,7 +1012,7 @@ install_maxplug_fallback_fonts() { # doubled and lines are CRLF because that is what .reg format wants; the # values land byte-identical to what reg add wrote. local reg_file fonts_key='HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Fonts' - reg_file="$(mktemp)" || { prefix_warn "!! Max for Live fallback fonts could not be registered."; return 0; } + reg_file="$(mktemp)" || { echo "!! Max for Live fallback fonts could not be registered." >&2 || true; return 0; } { printf 'REGEDIT4\r\n\r\n' printf '[%s]\r\n' "$fonts_key" @@ -1022,16 +1033,15 @@ install_maxplug_fallback_fonts() { ableton_wineserver_wait || true if [ "$registered" -eq 0 ]; then - prefix_warn "!! Copied $copied Max for Live fallback font files, but they could not be registered." - prefix_warn "!! Close Live, rerun setup, then check with scripts/check-m4l-fonts.sh." + echo "!! Copied $copied Max for Live fallback font files, but they could not be registered." >&2 || true + echo "!! Close Live, rerun setup, then check with scripts/check-m4l-fonts.sh." >&2 || true elif [ "$registered" -lt "${#faces[@]}" ]; then - prefix_warn "!! Only $registered of ${#faces[@]} Max for Live fallback fonts were registered. Check with scripts/check-m4l-fonts.sh." - else - prefix_note " Max for Live fallback fonts are ready." + echo "!! Only $registered of ${#faces[@]} Max for Live fallback fonts were registered. Check with scripts/check-m4l-fonts.sh." >&2 || true fi } -prefix_note "== Installing Max for Live fallback fonts ==" +ui_item_begin p_install_m4l_fonts install_maxplug_fallback_fonts +ui_item_end ok # Live bundles the exact VC++ redistributable it was built against in its own # Redist folder (/Redist, next to Program/): present only after @@ -1071,9 +1081,9 @@ install_live_redist() { ableton_wineserver_wait case "$status" in 0|102|194) ;; - *) prefix_warn "!! Live's Microsoft Visual C++ installer failed (exit $status)."; return 1 ;; + *) echo "!! Live's Microsoft Visual C++ installer failed (exit $status)." >&2 || true; return 1 ;; esac - vc_runtime_ready || { prefix_warn "!! Required Microsoft Visual C++ files are still missing after installation."; return 1; } + vc_runtime_ready || { echo "!! Required Microsoft Visual C++ files are still missing after installation." >&2 || true; return 1; } } # wineboot -u replaces redist natives (msvcp140 etc.) with wine's higher-versioned stubs, which @@ -1083,14 +1093,14 @@ install_live_redist() { # or vcrun2019 (Live 11): both ship the vc_redist.x64/x86.exe pair with the same cab layout. redist_verb=vcrun2022 [ "$live_major" = 11 ] && redist_verb=vcrun2019 -prefix_note "== Checking Microsoft Visual C++ support ==" +ui_item_begin p_check_vc kit_root || true # vendored cache is only a candidate; absence is not fatal here if ! vc_runtime_ready; then live_redist="$(find_live_redist || true)" if [ -n "$live_redist" ]; then - prefix_note " Installing the Microsoft Visual C++ files included with Live." + ui_status p_vc_install_from_live install_live_redist "$live_redist" || \ - prefix_warn "!! Trying the bundled Microsoft Visual C++ files instead." + echo "!! Trying the bundled Microsoft Visual C++ files instead." >&2 || true fi fi redist_dir="" @@ -1100,14 +1110,14 @@ for d in "$root/vendor/winetricks-cache/$redist_verb" \ done if [ -z "$redist_dir" ]; then if vc_runtime_ready; then - prefix_note " Required Microsoft Visual C++ files are already installed." + ui_status p_vc_already_installed else - prefix_warn "!! Required Microsoft Visual C++ files are missing, and their installer could not be found." + echo "!! Required Microsoft Visual C++ files are missing, and their installer could not be found." >&2 || true exit 1 fi else command -v cabextract >/dev/null || { - prefix_warn "!! cabextract is needed to unpack the Microsoft Visual C++ files. Install it and run setup again." + echo "!! cabextract is needed to unpack the Microsoft Visual C++ files. Install it and run setup again." >&2 || true exit 1 } vc_tmp="$(mktemp -d)" @@ -1127,23 +1137,24 @@ else dest="$WINEPREFIX/drive_c/windows/$wdir/$name" builtin="$WINE_ROOT/lib/wine/$barch/$name" if [ ! -s "$dest" ] || { [ -s "$builtin" ] && cmp -s "$dest" "$builtin"; }; then - prefix_note " Installing $name." + ui_status p_vc_installing_file "$name" install -m 644 "$f" "$dest" fi # gate: a file still identical to wine's builtin means the heal failed if [ -s "$builtin" ] && cmp -s "$dest" "$builtin"; then - prefix_warn "!! Required Microsoft Visual C++ file $wdir/$name was not installed correctly." + echo "!! Required Microsoft Visual C++ file $wdir/$name was not installed correctly." >&2 || true vc_bad=1 fi done rm -rf -- "$vc_tmp" 2>/dev/null || true - [ "$vc_bad" -eq 0 ] || { prefix_warn "!! Ableton's required Microsoft Visual C++ files could not be installed correctly."; exit 1; } + [ "$vc_bad" -eq 0 ] || { echo "!! Ableton's required Microsoft Visual C++ files could not be installed correctly." >&2 || true; exit 1; } fi +ui_item_end ok -prefix_note "== Configuring display scaling ==" +ui_item_begin p_configure_scaling case "$dpi_block" in preserve) - prefix_note " Existing display scaling settings were kept." + ui_status p_scaling_kept # Still sanity-check the mutter knob against what the prefix holds. have="$(current_dpi_block)" if [ "$have" != custom ]; then @@ -1154,26 +1165,29 @@ case "$dpi_block" in lp_ifeo="$(ableton_dpi_block_values "$dpi_block")" dpi_lp="${lp_ifeo% *}" dpi_ifeo="${lp_ifeo#* }" - wine reg add 'HKCU\Control Panel\Desktop' /v LogPixels /t REG_DWORD /d "$dpi_lp" /f + wine_registry "Display scaling" add 'HKCU\Control Panel\Desktop' \ + /v LogPixels /t REG_DWORD /d "$dpi_lp" /f ifeo_set=0 while IFS= read -r name; do [ -n "$name" ] || continue ifeo_set=1 if [ "$dpi_ifeo" = 2 ]; then - wine reg add "$ifeo_root\\$name" /v dpiAwareness /t REG_DWORD /d 2 /f + wine_registry "Display scaling for $name" add "$ifeo_root\\$name" \ + /v dpiAwareness /t REG_DWORD /d 2 /f else wine reg delete "$ifeo_root\\$name" /v dpiAwareness /f >/dev/null 2>&1 || true # reg.exe errors land on stdout fi done < <(live_exe_names) if [ "$ifeo_set" -eq 0 ] && [ "$dpi_ifeo" = 2 ]; then - prefix_note " Live-specific scaling will be applied automatically when Live first starts." + ui_info p_scaling_deferred_to_launch fi check_mutter_knob "$dpi_block" "$dpi_family" ;; esac ableton_wineserver_wait +ui_item_end ok -prefix_note "== Configuring the desktop theme ==" +ui_item_begin p_configure_theme # Live's "Follow system" theme reads AppsUseLightTheme; without the key it always renders # light. Seed it from the host scheme (the launcher re-syncs on every start), plus the # EnableTransparency=0 the known-good prefixes carry. @@ -1187,15 +1201,20 @@ case "$theme_mode" in preserve|'') light_val="" ;; esac if [ -n "$light_val" ]; then - prefix_note " Applying the $theme_mode theme." - wine reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' /v AppsUseLightTheme /t REG_DWORD /d "$light_val" /f - wine reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' /v EnableTransparency /t REG_DWORD /d 0 /f + ui_status p_theme_applying "$theme_mode" + wine_registry "Desktop theme" add \ + 'HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' \ + /v AppsUseLightTheme /t REG_DWORD /d "$light_val" /f + wine_registry "Desktop transparency" add \ + 'HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' \ + /v EnableTransparency /t REG_DWORD /d 0 /f ableton_wineserver_wait else - prefix_note " Existing theme settings were kept." + ui_status p_theme_kept fi +ui_item_end ok -prefix_note "== Configuring text rendering ==" +ui_item_begin p_configure_text # Wine takes the antialiasing mode from the host's Xft resources, so a desktop # set to grayscale renders every Win32 menu, dialog and control grayscale # whatever the prefix asks for. Patch 0084 lets an explicit FontSmoothingType @@ -1211,29 +1230,31 @@ if command -v gsettings >/dev/null 2>&1; then esac fi case "$smoothing_order" in - 0) prefix_note " Using BGR subpixel order." ;; - *) prefix_note " Using RGB subpixel order." ;; + 0) ui_status p_subpixel_bgr ;; + *) ui_status p_subpixel_rgb ;; esac -wine reg add 'HKCU\Control Panel\Desktop' /v FontSmoothing /t REG_SZ /d 2 /f -wine reg add 'HKCU\Control Panel\Desktop' /v FontSmoothingType /t REG_DWORD /d 2 /f -wine reg add 'HKCU\Control Panel\Desktop' /v FontSmoothingOrientation /t REG_DWORD /d "$smoothing_order" /f +wine_registry "Text smoothing" add 'HKCU\Control Panel\Desktop' \ + /v FontSmoothing /t REG_SZ /d 2 /f +wine_registry "Text smoothing type" add 'HKCU\Control Panel\Desktop' \ + /v FontSmoothingType /t REG_DWORD /d 2 /f +wine_registry "Text subpixel order" add 'HKCU\Control Panel\Desktop' \ + /v FontSmoothingOrientation /t REG_DWORD /d "$smoothing_order" /f ableton_wineserver_wait +ui_item_end ok -prefix_note "== Configuring PipeASIO audio ==" -# Recheck at the last safe point. The prefix is still the sibling staging -# copy, so a service/client change cannot leave the retained prefix half -# registered. Registration removes and verifies only PipeASIO's one CLSID. +ui_item_begin p_configure_pipeasio +# PipeWire compatibility was checked once before prefix staging. Registration +# removes and verifies only PipeASIO's one CLSID. # The driver's unix half must resolve libpipewire-0.3.so.0 - the tarball build # from the host's libs (it carries no rpath on purpose), the nix build from its # nixpkgs RUNPATH. ldd follows both; ldconfig -p sees neither on NixOS. pipeasio_ldd="$(ldd "$WINE_ROOT/lib/wine/x86_64-unix/pipeasio64.dll.so" 2>/dev/null || true)" if grep -Eq 'libpipewire-0[.]3[.]so[.]0.*not found' <<< "$pipeasio_ldd"; then - prefix_warn "!! PipeASIO cannot start because the PipeWire client library is missing. Install PipeWire 1.4.2 or newer." + echo "!! PipeASIO cannot start because the PipeWire client library is missing. Install PipeWire 1.4.2 or newer." >&2 || true fi if [ ! -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pipewire-0" ]; then - prefix_warn "!! PipeWire is not running. Live will not list a PipeASIO device until PipeWire starts." + echo "!! PipeWire is not running. Live will not list a PipeASIO device until PipeWire starts." >&2 || true fi -ableton_pipewire_preflight "$WINE_ROOT/bin/pipewire-version-probe" "registering PipeASIO" ableton_pipeasio_register wine ableton_wineserver_wait # Seed the driver defaults once; the file is the config surface (PIPEASIO_* @@ -1242,56 +1263,88 @@ write_default_pipeasio_settings() { local pipeasio_cfg="${XDG_CONFIG_HOME:-$HOME/.config}/pipeasio/config.ini" local pipeasio_tmp="" pipeasio_parent pipeasio_seeded=0 + local pipeasio_producer_token="" + local pipeasio_buffer_seed="${ABLETON_PIPEASIO_BUFFER_SEED:-256}" + case "$pipeasio_buffer_seed" in + 64|128|256|512|1024) ;; + *) + echo "!! ABLETON_PIPEASIO_BUFFER_SEED must be 64, 128, 256, 512, or 1024." >&2 || true + return 1 ;; + esac if [ ! -e "$pipeasio_cfg" ] && [ ! -L "$pipeasio_cfg" ]; then + if ! ableton_pipeasio_seed_record_load "$transaction_dir" \ + || [ "$ABLETON_PIPEASIO_SEED_PRESENT" -ne 0 ]; then + echo "!! PipeASIO recovery data already exists or is invalid; prefix setup stopped safely." >&2 || true + return 1 + fi pipeasio_parent="$(dirname "$pipeasio_cfg")" if mkdir -p -- "$pipeasio_parent" \ && pipeasio_tmp="$(mktemp "$pipeasio_parent/.config.ini.XXXXXX")"; then - if cat > "$pipeasio_tmp" <<'EOF' + if cat > "$pipeasio_tmp" </dev/null || true)" \ + = "$pipeasio_producer_token" ] \ + && ableton_pipeasio_seed_record_promote "$transaction_dir" \ + "$pipeasio_cfg" "$pipeasio_producer_token"; then + pipeasio_seeded=1 + elif ! ableton_pipeasio_seed_record_rollback "$transaction_dir"; then + echo "!! Fresh PipeASIO settings could not be restored safely." >&2 || true + return 1 + fi + fi fi fi fi if [ "$pipeasio_seeded" -eq 1 ]; then - printf ' created default PipeASIO settings at %s (2 in / 2 out, fixed 256-frame buffer)\n' \ - "$pipeasio_cfg" 2>/dev/null || true + ui_status p_pipeasio_settings_created "$pipeasio_cfg" else [ -z "$pipeasio_tmp" ] || rm -f -- "$pipeasio_tmp" 2>/dev/null || true if [ -e "$pipeasio_cfg" ] || [ -L "$pipeasio_cfg" ]; then - printf ' kept the existing PipeASIO settings at %s\n' \ - "$pipeasio_cfg" 2>/dev/null || true + ui_status p_pipeasio_settings_kept "$pipeasio_cfg" else - printf '%s\n' "!! Default PipeASIO settings could not be written. Run prefix update to retry." >&2 2>/dev/null || true + echo "!! Default PipeASIO settings could not be written. Run prefix update to retry." >&2 || true fi fi elif [ -L "$pipeasio_cfg" ] && [ ! -e "$pipeasio_cfg" ]; then - printf ' kept your PipeASIO settings link even though its target is missing: %s\n' \ - "$pipeasio_cfg" 2>/dev/null || true + ui_status p_pipeasio_settings_link_kept "$pipeasio_cfg" fi return 0 } write_default_pipeasio_settings +ui_item_end ok -prefix_note "== Configuring file dialogs and Push USB access ==" +ui_item_begin p_configure_dialogs_push # Default only: a policy the user set with set-file-portal-policy survives re-runs. if ! wine reg query 'HKCU\Software\Wine\X11 Driver' /v FileDialogPortal >/dev/null 2>&1; then - wine reg add 'HKCU\Software\Wine\X11 Driver' \ - /v FileDialogPortal /t REG_SZ /d auto /f + wine_registry "File dialog policy" add 'HKCU\Software\Wine\X11 Driver' \ + /v FileDialogPortal /t REG_SZ /d auto /f fi for push_helper in Push2DisplayProcess.exe Push3.exe; do push_key="HKCU\\Software\\Wine\\AppDefaults\\$push_helper\\DllOverrides" - wine reg add "$push_key" /v libusb-1.0 /t REG_SZ /d builtin /f - wine reg query "$push_key" /v libusb-1.0 + wine_registry "Push USB access for $push_helper" add "$push_key" \ + /v libusb-1.0 /t REG_SZ /d builtin /f + wine reg query "$push_key" /v libusb-1.0 >/dev/null 2>&1 || { + echo "!! Push USB access for $push_helper could not be verified." >&2 || true + exit 1 + } done # Ableton's tlsetupfx.exe (kernel USB driver installer) faults under Wine and pops a winedbg @@ -1299,7 +1352,8 @@ done # records it (0x80070643), carries on, and Live installs fine (issue 111), but two unexplained # "Program Error" boxes make a working install look broken. Suppress the dialog only: winedbg # still runs and still writes the backtrace to stderr. -wine reg add 'HKCU\Software\Wine\WineDbg' /v ShowCrashDialog /t REG_DWORD /d 0 /f +wine_registry "Wine crash dialog policy" add 'HKCU\Software\Wine\WineDbg' \ + /v ShowCrashDialog /t REG_DWORD /d 0 /f # Earlier revisions of this script registered a placeholder Ableton Push USB Audio Driver # product (version 99.0.0, invented ProductCode {B0B57A61-11E0-4A2E-9A11-AB1E70201126}) @@ -1313,7 +1367,7 @@ wine reg delete 'HKLM\Software\Classes\Installer\UpgradeCodes\86C5CFEA462003E469 wine reg delete 'HKLM\Software\Classes\Installer\Products\16A75B0B0E11E2A4A911BAE107021162' /f >/dev/null 2>&1 || true wine reg delete 'HKLM\Software\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products\16A75B0B0E11E2A4A911BAE107021162' /f >/dev/null 2>&1 || true wine reg query 'HKLM\Software' >/dev/null 2>&1 || { - prefix_warn "!! The Wine registry could not be checked after removing an old Live 11 installer workaround." + echo "!! The Wine registry could not be checked after removing an old Live 11 installer workaround." >&2 || true exit 1 } for placeholder_query in \ @@ -1330,14 +1384,15 @@ for placeholder_query in \ wine reg query "$placeholder_key" >/dev/null 2>&1 || placeholder_status=$? fi [ "$placeholder_status" -eq 1 ] || { - prefix_warn "!! An old Live 11 installer workaround is still present in the Wine registry." + echo "!! An old Live 11 installer workaround is still present in the Wine registry." >&2 || true exit 1 } done # winemenubuilder's entries assume `wine` on PATH (never true here) and are dead buttons: disable # it and delete entries it already wrote for this prefix (matched by WINEPREFIX=; install.sh's entries can't match). -wine reg add 'HKCU\Software\Wine\DllOverrides' /v winemenubuilder.exe /t REG_SZ /d '' /f +wine_registry "Wine desktop shortcut policy" add 'HKCU\Software\Wine\DllOverrides' \ + /v winemenubuilder.exe /t REG_SZ /d '' /f desktop_dir="${XDG_DESKTOP_DIR:-}" if [ -z "$desktop_dir" ] && command -v xdg-user-dir >/dev/null 2>&1; then desktop_dir="$(xdg-user-dir DESKTOP 2>/dev/null || true)" @@ -1345,27 +1400,30 @@ fi for entry_dir in "${XDG_DATA_HOME:-$HOME/.local/share}/applications" "$desktop_dir"; do [ -n "$entry_dir" ] || continue [ -d "$entry_dir" ] || continue - find "$entry_dir" -maxdepth 3 -name '*.desktop' -type f 2>/dev/null | while IFS= read -r f; do + while IFS= read -r f; do if grep -qF "WINEPREFIX=\"$final_prefix\"" "$f" 2>/dev/null; then - prefix_note " Removing an obsolete desktop shortcut: $f" + ui_status p_removing_obsolete_shortcut "$f" rm -f -- "$f" 2>/dev/null \ - || prefix_warn "!! Could not remove the obsolete desktop shortcut at $f." + || echo "!! Could not remove the obsolete desktop shortcut at $f." >&2 || true fi - done || true + done < <(find "$entry_dir" -maxdepth 3 -name '*.desktop' -type f 2>/dev/null) done update-desktop-database "${XDG_DATA_HOME:-$HOME/.local/share}/applications" 2>/dev/null || true ableton_wineserver_wait +ui_item_end ok -prefix_note "== Removing an obsolete Live audio setting ==" +ui_item_begin p_remove_obsolete_audio_setting strip_options_txt "-DontCombineAPCs" +ui_item_end ok # -_ForceGdiBackend disables Live's GPU renderer. Early prefixes carried it # (inherited from pre-repo setups); with the d2d1 base fork Live's GPU # renderer works, removes the WebView2 pane flicker, and drops idle CPU. -prefix_note "== Enabling Live's GPU renderer ==" +ui_item_begin p_enable_gpu_renderer strip_options_txt "-_ForceGdiBackend" +ui_item_end ok -prefix_note "== Checking Ableton Live ==" +ui_item_begin p_check_live # Runs the USER'S OWN Ableton download - this repo ships no Live payload and no # license. OPT-IN ONLY (ABLETON_LIVE_AUTOINSTALL=1): the automatic run is # silent, which defers Ableton's EULA to first launch, and a prefix refresh must @@ -1390,31 +1448,31 @@ remove_extracted_live_installer() if [ "${ABLETON_PREFIX_MANAGED:-0}" = 1 ]; then live_installed && live_ready=1 if [ "$live_ready" -eq 1 ]; then - prefix_note " Live is already installed." + ui_status p_live_installed else - prefix_note " The installer will install Live after Wine setup finishes." + ui_info p_live_install_pending fi elif live_installed; then live_ready=1 - prefix_note " Live is already installed; it was left unchanged." + ui_status p_live_installed_unchanged elif [ "${ABLETON_LIVE_AUTOINSTALL:-}" = 0 ]; then - prefix_note " Automatic Live installation is disabled." + ui_status p_live_autoinstall_disabled elif [ "${ABLETON_LIVE_AUTOINSTALL:-0}" != 1 ]; then if [ -n "$live_zip" ]; then - prefix_note " Found $(basename "$live_zip"). Set ABLETON_LIVE_AUTOINSTALL=1 to install it." - prefix_note " Ableton's license agreement will be shown when Live first starts." + ui_info p_live_zip_found "$(basename "$live_zip")" + ui_info p_live_eula_note else - prefix_note " To install your own Ableton download automatically, place it in $installer_dir and set ABLETON_LIVE_AUTOINSTALL=1." + ui_info p_live_autoinstall_hint "$installer_dir" fi elif [ -z "$live_zip" ]; then if [ -n "${ABLETON_LIVE_VERSION:-}" ]; then - prefix_warn "!! No Live $ABLETON_LIVE_VERSION installer was found in $installer_dir." + echo "!! No Live $ABLETON_LIVE_VERSION installer was found in $installer_dir." >&2 || true else - prefix_warn "!! No Ableton Live installer zip was found in $installer_dir." + echo "!! No Ableton Live installer zip was found in $installer_dir." >&2 || true fi - prefix_note " Put the official ableton.com zip there, or set ABLETON_INSTALLER_DIR to its directory." + ui_info p_live_zip_missing_hint else - prefix_note " Unpacking $(basename "$live_zip")." + ui_status p_live_unpacking "$(basename "$live_zip")" preferred_unpack_dir="${XDG_CACHE_HOME:-$HOME/.cache}/ableton-wine-setup/live-installer" unpack_dir="$preferred_unpack_dir" if [ -d "$unpack_dir" ] && [ ! -L "$unpack_dir" ]; then @@ -1426,7 +1484,7 @@ else # unwritable cache path cannot veto prefix preparation; use private # scratch space and leave the old object untouched for inspection. unpack_dir="$(mktemp -d "${TMPDIR:-/tmp}/ableton-live-installer.XXXXXX")" \ - || { prefix_warn "!! Temporary space for the Live installer could not be created."; exit 1; } + || { echo "!! Temporary space for the Live installer could not be created." >&2 || true; exit 1; } printf '!! Old extracted installer files could not be replaced at %s; using temporary space instead.\n' \ "$preferred_unpack_dir" >&2 2>/dev/null || true fi @@ -1441,7 +1499,7 @@ else ableton_run_bounded "$extract_timeout" python3 -m zipfile -e "$live_zip" "$unpack_dir" &2 || true fi live_exe="" if [ "$unpack_ok" -eq 1 ]; then @@ -1451,13 +1509,13 @@ else if [ "${#payload_exes[@]}" -eq 1 ]; then live_exe="${payload_exes[0]}" else - prefix_warn "!! Expected one installer program in the Live zip, but found ${#payload_exes[@]}." + echo "!! Expected one installer program in the Live zip, but found ${#payload_exes[@]}." >&2 || true fi fi if [ "$unpack_ok" -eq 0 ]; then - prefix_warn "!! Could not unpack $(basename "$live_zip"). You can still install Live manually." + echo "!! Could not unpack $(basename "$live_zip"). You can still install Live manually." >&2 || true elif [ -z "$live_exe" ]; then - prefix_warn "!! The zip does not contain one clear Live installer. Use the official ableton.com download." + echo "!! The zip does not contain one clear Live installer. Use the official ableton.com download." >&2 || true else # The silent flags depend on the installer engine, identified by the # test installer.sh makes: Live 11 ships a WiX Burn bundle, Live 12 an @@ -1488,19 +1546,19 @@ else [ "$rc" -eq 0 ] || [ "$rc" -eq 3 ] || return "$rc" } if [ "${ABLETON_INSTALLER_UI:-0}" = 1 ]; then - prefix_note " Starting the Ableton installer. Follow the installer window." - run_installer || prefix_warn "!! The Ableton installer exited with an error. You can retry the installation manually." + ui_status p_live_installer_window + run_installer || echo "!! The Ableton installer exited with an error. You can retry the installation manually." >&2 || true end_session else - prefix_note " Installing Ableton Live. This can take a few minutes." + ui_status p_live_installing # Keep the display attached: the installer engines need a window # connection even under these switches - a headless run installs # nothing at all; with the display it installs silently. run_installer "${silent_flags[@]}" || true end_session if ! live_installed; then - prefix_warn "!! Automatic installation did not install Live. Starting the installer window." - run_installer || prefix_warn "!! The Ableton installer exited with an error. You can retry the installation manually." + echo "!! Automatic installation did not install Live. Starting the installer window." >&2 || true + run_installer || echo "!! The Ableton installer exited with an error. You can retry the installation manually." >&2 || true end_session fi fi @@ -1508,12 +1566,13 @@ else fi remove_extracted_live_installer "$unpack_dir" fi +ui_item_end ok # Promote the fully prepared prefix only after every Wine command and gate has # succeeded. The original remains beside it until the outer transaction commits. prefix_marker="$WINEPREFIX/.ableton-linux-prefix" if [ -L "$prefix_marker" ] || { [ -e "$prefix_marker" ] && [ ! -f "$prefix_marker" ]; }; then - prefix_warn "!! The prepared Wine prefix is missing a valid Ableton Linux setup record." + echo "!! The prepared ableton-linux prefix is missing a valid Ableton Linux setup record." >&2 || true exit 1 fi prefix_marker_tmp="$(mktemp "$WINEPREFIX/.prefix-marker.XXXXXX")" @@ -1523,14 +1582,14 @@ if ! printf 'format=1\nprefix=%s\n' "$final_prefix" > "$prefix_marker_tmp" \ || [ -e "$prefix_marker_tmp" ] || [ ! -f "$prefix_marker" ] || [ -L "$prefix_marker" ] \ || ! ableton_prefix_marker_valid "$WINEPREFIX" "$final_prefix"; then rm -f -- "$prefix_marker_tmp" - prefix_warn "!! The prepared Wine prefix could not be marked safely." + echo "!! The prepared ableton-linux prefix could not be marked safely." >&2 || true exit 1 fi prefix_backup=absent if [ -e "$final_prefix" ]; then prefix_backup="$final_prefix.transaction-${transaction_dir##*/}" [ ! -e "$prefix_backup" ] && [ ! -L "$prefix_backup" ] \ - || { prefix_warn "!! An old Wine prefix recovery directory already exists: $prefix_backup"; exit 1; } + || { echo "!! An old ableton-linux prefix recovery directory already exists: $prefix_backup" >&2 || true; exit 1; } fi # Revalidate both names immediately before the rename. New project launchers # share the global installation lock, but a direct stock-Wine command or a @@ -1542,19 +1601,19 @@ fi late_prefix_holders="$(ableton_prefix_wine_processes_any_runtime "$final_prefix")" if [ -n "$late_prefix_holders" ]; then IFS=$'\t' read -r late_prefix_pid late_prefix_exe <<< "$late_prefix_holders" || { - prefix_warn "!! The Wine process using $final_prefix could not be identified." + echo "!! The Wine process using $final_prefix could not be identified." >&2 || true exit 1 } - prefix_warn "!! A Wine program started while setup was running (pid $late_prefix_pid, $late_prefix_exe). Close it and run setup again." + echo "!! A Wine program started while setup was running (pid $late_prefix_pid, $late_prefix_exe). Close it and run setup again." >&2 || true exit 1 fi late_prefix_holders="$(ableton_prefix_wine_processes_any_runtime "$WINEPREFIX")" if [ -n "$late_prefix_holders" ]; then IFS=$'\t' read -r late_prefix_pid late_prefix_exe <<< "$late_prefix_holders" || { - prefix_warn "!! A Wine process using the prepared prefix could not be identified." + echo "!! A Wine process using the prepared prefix could not be identified." >&2 || true exit 1 } - prefix_warn "!! A Wine program is still using the prepared prefix (pid $late_prefix_pid, $late_prefix_exe). Close it and run setup again." + echo "!! A Wine program is still using the prepared prefix (pid $late_prefix_pid, $late_prefix_exe). Close it and run setup again." >&2 || true exit 1 fi # A direct setup has not changed the live prefix before this point. Publish its @@ -1565,7 +1624,7 @@ if [ "$own_prefix_transaction" -eq 1 ]; then fi if ! ableton_promote_directory "$WINEPREFIX" "$final_prefix" "$prefix_backup" \ "$transaction_dir/prefix.tsv"; then - prefix_warn "!! The prepared Wine prefix could not be put into place." + echo "!! The prepared ableton-linux prefix could not be put into place." >&2 || true exit 1 fi prefix_promoted=1 @@ -1575,50 +1634,29 @@ if [ "$own_prefix_transaction" -eq 1 ]; then ableton_mark_transaction_core_complete "$transaction_dir" 2>/dev/null || true if prefix_transaction_commit "$transaction_dir"; then if ! rm -rf -- "$transaction_dir"; then - prefix_warn "!! The Wine prefix is ready, but old recovery files remain at $transaction_dir." + echo "!! The ableton-linux prefix is ready, but old recovery files remain at $transaction_dir." >&2 || true fi else rm -f -- "$transaction_dir/active" 2>/dev/null || true - prefix_warn "!! The Wine prefix is ready, but old recovery files remain at $transaction_dir." + echo "!! The ableton-linux prefix is ready, but old recovery files remain at $transaction_dir." >&2 || true fi fi -if [ "$own_prefix_transaction" -eq 1 ]; then -prefix_note "" -prefix_note "OK: Wine is ready at $WINEPREFIX" -if [ "${ABLETON_PREFIX_MANAGED:-0}" != 1 ]; then -if [ "$live_ready" -eq 1 ]; then - banner="Remaining steps (you supply your own license):" - step1=" 1. Live is installed - nothing more to supply here." -else - banner="Remaining steps (you supply Ableton + your own license):" - step1="$(cat </dev/null || true - -──────────────────────────────────────────────────────────────────────── -$banner +ui_status p_prefix_ready -$step1 - - 2. Launch: ableton-live - 3. Authorize Live with your own account (binds to this prefix's MachineGuid). - 4. Audio: Settings/Preferences > Audio > Driver Type: ASIO > - Audio Device: PipeASIO. - PipeASIO is a native PipeWire client: no JACK layer involved. -──────────────────────────────────────────────────────────────────────── -EOF -fi +if [ "$own_prefix_transaction" -eq 1 ]; then + ui_status p_wine_ready_at "$WINEPREFIX" + if [ "${ABLETON_PREFIX_MANAGED:-0}" != 1 ]; then + if [ "$live_ready" -eq 1 ]; then + ui_info p_remaining_steps_licensed + ui_status p_remaining_step1_installed + else + ui_info p_remaining_steps_unlicensed + ui_status p_remaining_step1_install "$WINEPREFIX" "$WINE_ROOT" + fi + ui_status p_remaining_step2_launch + ui_status p_remaining_step3_authorize + ui_status p_remaining_step4_audio + fi fi prefix_promoted=0 diff --git a/scripts/setup-run-header.sh b/scripts/setup-run-header.sh index 157241f1..dd613fb1 100755 --- a/scripts/setup-run-header.sh +++ b/scripts/setup-run-header.sh @@ -1,10 +1,17 @@ #!/bin/sh # shellcheck shell=bash -# Ableton-on-Wine self-extracting installer transport. Policy lives in the -# packaged scripts/installer.sh; this header only verifies and unpacks the kit. +# Ableton Linux self-extracting installer transport. The wrapper reports the +# host, records stdout and stderr, and unpacks the embedded kit. Installation +# policy remains in the packaged scripts/installer.sh. Everything on screen is +# drawn by the renderer inlined below at build time (scripts/lib/ui.sh). [ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@" set -euo pipefail +@UI_LIB@ +@PREFERENCES_LIB@ export LC_ALL=C.UTF-8 +started_at=$SECONDS +footer_enabled=1 +cancelled=0 VERSION="@VERSION@" PAYLOAD_SHA="@PAYLOAD_SHA@" @@ -13,46 +20,149 @@ media_dir="$(dirname -- "$self")" short_help() { - cat <<'EOF' -Ableton-on-Wine installer + printf '%s\n\n%s\n %s\n\n%s\n' "${UI_TEXT[help_title]}" 'Commands:' \ + "${UI_TEXT[help_commands]}" "${UI_TEXT[help_menu]}" +} -Commands: - install, update, runtime install, prefix create|update|repair-live11, - link enable|disable|status, uninstall, extract DIR, plan COMMAND ... +# The log lives beside the .run so it exists even when setup stops before the +# Wine runtime is created. A read-only installer directory falls back to the +# temporary directory, and its path is printed at exit. +run_id="$(date '+%Y%m%dT%H%M%S')-$$" +log_name="ableton-linux-installer-$run_id.log" +log_path="$media_dir/$log_name" +if ! (umask 077; : > "$log_path") 2>/dev/null; then + log_path="${TMPDIR:-/tmp}/$log_name" + if ! (umask 077; : > "$log_path") 2>/dev/null; then + log_path=/dev/null + fi +fi +# The saved log records wrapper events, plain operation events, and the child +# process's stdout and stderr. The renderer writes only to the terminal. +export ABLETON_INSTALLER_LOG=/dev/null +export ABLETON_INSTALLER_EVENT_LOG="$log_path" -After extracting the kit, run `bash DIR/scripts/installer.sh --help`, replacing -DIR with the extraction directory, for the complete command and option list. -Compatibility flags remain temporarily -available with warnings. -EOF +log_event() +{ + local level="$1" context="$2" message="$3" stamp + printf -v stamp '%(%d/%m/%Y, %H:%M:%S)T' -1 + printf '%-7s%s [ableton-linux][installer][%s] %s\n' \ + "[$level]" "$stamp" "$context" "$message" >> "$log_path" 2>/dev/null || true + return 0 } -case "${1:-}" in --help|-h|help) short_help; exit 0 ;; esac - -io_timeout="${ABLETON_PAYLOAD_IO_TIMEOUT:-3600}" -case "$io_timeout" in ''|*[!0-9]*) echo "!! ABLETON_PAYLOAD_IO_TIMEOUT must be whole seconds" >&2; exit 2 ;; esac -[ "$io_timeout" -ge 60 ] && [ "$io_timeout" -le 14400 ] || { - echo "!! ABLETON_PAYLOAD_IO_TIMEOUT must be between 60 and 14400 seconds" >&2; exit 2; } -command -v timeout >/dev/null 2>&1 || { echo "!! GNU timeout is required" >&2; exit 1; } -bounded() +# Everything the scripts print goes to the log, one timestamped record per +# line. The renderer writes the installer tree only to the terminal. +mirror_stream() { - timeout --signal=TERM --kill-after=5s "${io_timeout}s" "$@" + local stream="$1" line level stamp + trap '' PIPE + while IFS= read -r line || [ -n "$line" ]; do + level=INFO + case "$line" in + '!! '*) level=ERR ;; + WARNING:*|Warning:*|warning:*) level=WARN ;; + esac + printf -v stamp '%(%d/%m/%Y, %H:%M:%S)T' -1 + line="${line//$'\r'/}" + printf '%-7s%s [ableton-linux][installer][%s] %s\n' \ + "[$level]" "$stamp" "$stream" "$line" >> "$log_path" 2>/dev/null || true + done + return 0 } -extract_dir="" -if [ "${1:-}" = extract ] || [ "${1:-}" = --extract ]; then - [ $# -eq 2 ] || { echo "!! extract needs exactly one destination directory" >&2; exit 2; } - extract_dir="$2" -fi +exec 7>&1 8>&2 +export ABLETON_UI_TTY_FD=7 +exec > >(mirror_stream stdout) +stdout_mirror_pid=$! +exec 2> >(mirror_stream stderr) +stderr_mirror_pid=$! +log_event INFO run "starting installer $VERSION from $self" + +# The footer reads the paths the installer saved, when it saved them. +saved_setting() +{ + local file="${XDG_CONFIG_HOME:-$HOME/.config}/ableton-wine/config" + [ -r "$file" ] || return 0 + sed -n "s/^$1=//p" "$file" 2>/dev/null | head -n 1 +} -workdir="$(mktemp -d "${TMPDIR:-/tmp}/ableton-installer.XXXXXX")" +workdir="" +# shellcheck disable=SC2329 # called by the EXIT trap cleanup() { - local rc=$? + local rc=$? elapsed outcome warn_count logged_errors display_log="$log_path" + local runtime="" prefix="" error + local -a errors=() trap - EXIT - rm -rf -- "$workdir" 2>/dev/null || true - if [ -e "$workdir" ] || [ -L "$workdir" ]; then - echo "!! The installer finished, but temporary files remain at $workdir. You can remove that directory." >&2 || true + ui_cleanup "$rc" + if [ -n "$workdir" ]; then + rm -rf -- "$workdir" 2>/dev/null || true + if [ -e "$workdir" ] || [ -L "$workdir" ]; then + printf '!! %s\n' "$(ui_text e_temp_left "$workdir")" >&2 || true + fi + fi + exec 1>&7 2>&8 + # A stray Wine process can hold the pipes open; the log is complete once + # the readers drain what the scripts wrote, so give them a moment only. + for _ in $(seq 30); do + kill -0 "$stdout_mirror_pid" 2>/dev/null || kill -0 "$stderr_mirror_pid" 2>/dev/null || break + sleep 0.1 + done + kill "$stdout_mirror_pid" "$stderr_mirror_pid" 2>/dev/null || true + wait "$stdout_mirror_pid" 2>/dev/null || true + wait "$stderr_mirror_pid" 2>/dev/null || true + if [ "$rc" -eq 0 ]; then + log_event OK run "${selected_label:-Installer} completed" + else + log_event ERR run "${selected_label:-Installer} failed with exit status $rc" + fi + [ "$footer_enabled" -eq 1 ] || exit "$rc" + + trap '' PIPE + elapsed=$((SECONDS - started_at)) + warn_count="$(grep -Ec \ + '^\[WARN\] +.*\[ableton-linux\]\[installer\]\[(stdout|stderr)\] ' \ + "$log_path" 2>/dev/null || true)" + case "$warn_count" in ''|*[!0-9]*) warn_count=0 ;; esac + if [ "$rc" -eq 0 ]; then + outcome=complete + [ "$cancelled" -eq 0 ] || outcome=cancelled + logged_errors="$(grep -Ec \ + '^\[ERR\] +.*\[ableton-linux\]\[installer\]\[(stdout|stderr)\] ' \ + "$log_path" 2>/dev/null || true)" + case "$logged_errors" in ''|*[!0-9]*) logged_errors=0 ;; esac + warn_count=$((warn_count + logged_errors)) + # A run that succeeded had no errors: what the scripts flagged on the + # way is kept as warnings (rule D3), in the log as in the footer. + sed -i 's/^\[ERR\] /[WARN] /' "$log_path" 2>/dev/null || true + else + outcome=failed + case "$rc" in 130|143) outcome=interrupted ;; esac + while IFS= read -r error; do + error="${error#"!! "}" + [ -n "$error" ] || continue + case " ${errors[*]-} " in *" $error "*) continue ;; esac + errors+=("$error") + done < <(sed -n \ + 's/^\[ERR\] [^[]*\[ableton-linux\]\[installer\]\[\(stdout\|stderr\)\] //p' \ + "$log_path" 2>/dev/null) + [ "${#errors[@]}" -gt 0 ] || errors=("Installer exited with status $rc.") + fi + if [ "$outcome" = complete ]; then + runtime="$(saved_setting runtime_root)" + prefix="$(saved_setting prefix)" + fi + # shellcheck disable=SC2088 # display form, not a shell expansion + case "$display_log" in + "$HOME"/*) display_log="~/${display_log#"$HOME"/}" ;; + esac + [ "$log_path" != /dev/null ] || display_log="" + ui_footer "${selected_label:-Installer}" "$VERSION" "$outcome" "$elapsed" \ + "$warn_count" "${#errors[@]}" "$runtime" "$prefix" + if [ "$outcome" = complete ] && [ "${launch_hint:-1}" -eq 0 ]; then + ui_tail cancelled "$display_log" + else + ui_tail "$outcome" "$display_log" "${errors[@]}" fi exit "$rc" } @@ -60,56 +170,604 @@ trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM +case "${1:-}" in + --help|-h|help) footer_enabled=0; short_help >&7; exit 0 ;; +esac + +io_timeout="${ABLETON_PAYLOAD_IO_TIMEOUT:-3600}" +case "$io_timeout" in + ''|*[!0-9]*) printf '!! ABLETON_PAYLOAD_IO_TIMEOUT must be whole seconds.\n' >&2; exit 2 ;; +esac +[ "$io_timeout" -ge 60 ] && [ "$io_timeout" -le 14400 ] || { + printf '!! ABLETON_PAYLOAD_IO_TIMEOUT must be between 60 and 14400 seconds.\n' >&2 + exit 2 +} + +bounded() +{ + timeout --signal=TERM --kill-after=5s "${io_timeout}s" "$@" +} + payload_line="$(awk '/^__PAYLOAD_BELOW__$/{print NR+1; exit}' "$self")" [ -n "$payload_line" ] || { - echo "!! This installer file is incomplete or damaged. Download it again." >&2 + printf '!! %s\n' "$(ui_text e_damaged)" >&2 exit 1 } header_bytes="$(head -n "$((payload_line-1))" "$self" | wc -c)" total_bytes="$(stat -c %s "$self" 2>/dev/null || wc -c < "$self")" payload_bytes=$((total_bytes - header_bytes)) -printf '== Ableton-on-Wine installer %s ==\n' "$VERSION" || true -printf '%s\n' "-- copying embedded kit ($((payload_bytes / 1024 / 1024)) MiB; progress is bytes copied)" || true -payload="$workdir/payload.tar" -if dd --help 2>&1 | grep iflag >/dev/null; then - bounded dd if="$self" of="$payload" iflag=skip_bytes skip="$header_bytes" bs=4M status=progress -else - bounded tail -n +"$payload_line" "$self" > "$payload" + +format_kib() +{ + awk -v kib="${1:-0}" 'BEGIN { + if (kib >= 1073741824) printf "%.1f TiB", kib / 1073741824; + else if (kib >= 1048576 / 2) printf "%.1f GiB", kib / 1048576; + else printf "%.0f MiB", kib / 1024; + }' +} + +distro=Linux +if [ -r /etc/os-release ]; then + distro="$(. /etc/os-release; printf '%s' "${PRETTY_NAME:-${NAME:-Linux}}")" +fi +cpu="$(awk -F: '/model name/{sub(/^[ \t]+/, "", $2); print $2; exit}' /proc/cpuinfo 2>/dev/null || true)" +[ -n "$cpu" ] || cpu="$(uname -m 2>/dev/null || printf unknown)" +gpu="" +if command -v lspci >/dev/null 2>&1; then + gpu="$(lspci -mm 2>/dev/null \ + | awk -F\" 'tolower($2) ~ /(vga|3d|display)/ && !found {print $6 " " $8; found=1}' \ + | tr '\n\r\t' ' ' | sed 's/ */ /g; s/^ //; s/ $//' || true)" +fi +[ -n "$gpu" ] || gpu="$(ui_text v_unavailable)" +memory="$(awk '/MemTotal:/{printf "%.1f GiB", $2 / 1048576; exit}' /proc/meminfo 2>/dev/null || true)" +[ -n "$memory" ] || memory="$(ui_text v_unavailable)" +pipewire_version="" +if command -v pw-cli >/dev/null 2>&1; then + pipewire_version="$(pw-cli --version 2>/dev/null \ + | awk '/Linked with libpipewire/ && !found {print $NF; found=1}' || true)" +fi +[ -n "$pipewire_version" ] || pipewire_version="$(ui_text v_unavailable)" +export ABLETON_PIPEWIRE_REPORT_SHOWN=1 +missing_dependencies=() +for required in bash timeout tar zstd sha256sum; do + command -v "$required" >/dev/null 2>&1 || missing_dependencies+=("$required") +done +dependencies="$(ui_text v_deps_ready 'bash timeout tar zstd sha256sum')" +[ "${#missing_dependencies[@]}" -eq 0 ] \ + || dependencies="$(ui_text v_deps_missing "${missing_dependencies[*]}")" + +temp_root="${TMPDIR:-/tmp}" +temp_free_kib="$(df -Pk -- "$temp_root" 2>/dev/null | awk 'NR==2 {print $4}')" +home_free_kib="$(df -Pk -- "$HOME" 2>/dev/null | awk 'NR==2 {print $4}')" +case "$temp_free_kib" in ''|*[!0-9]*) temp_free_kib=0 ;; esac +case "$home_free_kib" in ''|*[!0-9]*) home_free_kib=0 ;; esac +temp_need_kib=$((payload_bytes * 2 / 1024 + 262144)) + +ui_banner "$VERSION" +ui_blank; ui_heading h_system; ui_blank +ui_row r_date "$(date '+%d/%m/%Y, %H:%M:%S %Z')" +ui_row r_distro "$distro" +ui_row r_kernel "$(uname -sr 2>/dev/null || ui_text v_unavailable)" +ui_row r_cpu "$cpu" +ui_row r_gpu "$gpu" +ui_row r_memory "$memory" +ui_row r_desktop "$(ui_text v_desktop "${XDG_CURRENT_DESKTOP:-$(ui_text v_unavailable)}" "${XDG_SESSION_TYPE:-unknown}")" +ui_row r_pipewire "$pipewire_version" +ui_row r_deps "$dependencies" +ui_blank; ui_heading h_disk; ui_blank +ui_row r_temp "$(ui_text v_free_at "$(format_kib "$temp_free_kib")" "$temp_root")" +ui_row r_install "$(ui_text v_free_under "$(format_kib "$home_free_kib")" "$HOME")" +ui_row r_kit "$(ui_text v_required "$(format_kib "$temp_need_kib")")" + +ui_blank; ui_heading h_warnings; ui_blank +host_warnings=0 +[ "$(uname -s 2>/dev/null || true)" = Linux ] \ + || { ui_host_warning w_not_linux; host_warnings=1; } +[ "$(uname -m 2>/dev/null || true)" = x86_64 ] \ + || { ui_host_warning w_not_x86_64; host_warnings=1; } +for required in "${missing_dependencies[@]}"; do + ui_host_warning w_missing_command "$required"; host_warnings=1 +done +[ "$temp_free_kib" -ge "$temp_need_kib" ] \ + || { ui_host_warning w_temp_small; host_warnings=1; } +[ "$home_free_kib" -ge 10485760 ] \ + || { ui_host_warning w_home_small; host_warnings=1; } +[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] \ + || { ui_host_warning w_no_display; host_warnings=1; } +if [ "$pipewire_version" = "$(ui_text v_unavailable)" ]; then + ui_host_warning w_pipewire_unknown; host_warnings=1 +elif ! printf '1.4.2\n%s\n' "$pipewire_version" | sort -V -C 2>/dev/null; then + ui_host_warning w_pipewire_old "$pipewire_version" 1.4.2; host_warnings=1 fi +[ "$host_warnings" -eq 1 ] || ui_note w_none + +original_args=("$@") +launch_hint=1 +for argument in "${original_args[@]}"; do + case "$argument" in + --skip-live-install|--no-launch) launch_hint=0 ;; + esac +done +selected_action="" +selected_label="" +explicit_action=0 +case "${1:-}" in + install) selected_action=install; selected_label="$(ui_text label_install)"; explicit_action=1 ;; + update|--update) selected_action=update; selected_label="$(ui_text label_update)"; explicit_action=1 ;; + uninstall|--uninstall) selected_action=uninstall; selected_label="$(ui_text label_remove)"; explicit_action=1 ;; + runtime|--runtime-only) selected_action=runtime; selected_label="$(ui_text label_runtime)"; explicit_action=1 ;; + prefix) selected_action=prefix; selected_label="$(ui_text label_prefix "${2:-command}")"; explicit_action=1 ;; + link) selected_action='link'; selected_label="$(ui_text label_link "${2:-command}")"; explicit_action=1 ;; + extract|--extract) selected_action=extract; selected_label="$(ui_text label_extract)"; explicit_action=1 ;; + plan) selected_action=plan; selected_label="$(ui_text label_plan)"; explicit_action=1 ;; +esac + +# Return the configured prefix only for the exact format-1 configuration. It +# is parsed as inert data: the pre-extraction wrapper must never source HOME. +configured_prefix() +{ + local file="${XDG_CONFIG_HOME:-$HOME/.config}/ableton-wine/config" + local line="" key value header=0 + local -A seen=() values=() + [ -f "$file" ] && [ ! -L "$file" ] && [ -r "$file" ] || return 1 + [ "$(LC_ALL=C tr -cd '\000' < "$file" 2>/dev/null | wc -c)" -eq 0 ] \ + || return 1 + while IFS= read -r line || [ -n "$line" ]; do + if [ "$header" -eq 0 ]; then + [ "$line" = '# ableton-linux installer configuration; managed by the installer' ] \ + || return 1 + header=1 + continue + fi + case "$line" in *=*) ;; *) return 1 ;; esac + key="${line%%=*}"; value="${line#*=}" + [ -z "${seen[$key]+x}" ] || return 1 + seen["$key"]=1; values["$key"]="$value" + case "$key" in + format) [ "$value" = 1 ] || return 1 ;; + runtime_root|prefix|linkd) + [ -n "$value" ] && [[ "$value" = /* ]] || return 1 + case "$value" in *$'\n'*|*$'\r'*|*$'\t'*) return 1 ;; esac ;; + live_major) case "$value" in ''|11|12) ;; *) return 1 ;; esac ;; + link_mode) case "$value" in off|session|always) ;; *) return 1 ;; esac ;; + *) return 1 ;; + esac + done < "$file" + [ "$header" -eq 1 ] && [ "${#seen[@]}" -eq 6 ] \ + && [ -n "${seen[format]+x}" ] \ + && [ -n "${seen[runtime_root]+x}" ] \ + && [ -n "${seen[prefix]+x}" ] \ + && [ -n "${seen[live_major]+x}" ] \ + && [ -n "${seen[link_mode]+x}" ] \ + && [ -n "${seen[linkd]+x}" ] || return 1 + printf '%s\n' "${values[prefix]}" +} + +marked_default_prefix() +{ + local prefix="$HOME/.wine-ableton" file="$HOME/.wine-ableton/.ableton-linux-prefix" + local -a line=() + [ -f "$file" ] && [ ! -L "$file" ] && [ -r "$file" ] || return 1 + [ "$(LC_ALL=C tr -cd '\000' < "$file" 2>/dev/null | wc -c)" -eq 0 ] \ + || return 1 + mapfile -t line < "$file" || return 1 + [ "${#line[@]}" -eq 2 ] && [ "${line[0]}" = format=1 ] \ + && [ "${line[1]}" = "prefix=$prefix" ] || return 1 + printf '%s\n' "$prefix" +} + +classify_installation() +{ + local prefix="" + if prefix="$(configured_prefix)"; then + : + elif prefix="$(marked_default_prefix)"; then + : + else + printf '%s\n' fresh + return 0 + fi + if [ -d "$prefix" ] && [ ! -L "$prefix" ] \ + && [ -f "$prefix/system.reg" ] && [ ! -L "$prefix/system.reg" ]; then + printf '%s\n' healthy + else + printf '%s\n' incomplete + fi +} -printf '%s\n' "-- verifying embedded kit (progress is bytes hashed)" || true -if dd --help 2>&1 | grep status >/dev/null; then - actual="$(bounded dd if="$payload" bs=4M status=progress | sha256sum | awk '{print $1}')" +install_state="$(classify_installation)" +case "$install_state" in + healthy) default_action=update ;; + *) default_action=install ;; +esac + +render_action_options() +{ + case "$install_state" in + healthy) + ui_menu_option m_update default + ui_menu_option m_reinstall + ui_menu_option m_remove + ui_menu_option m_quit ;; + incomplete) + ui_menu_option m_reinstall default + ui_menu_option m_remove + ui_menu_option m_quit ;; + *) + ui_menu_option m_install default + ui_menu_option m_quit ;; + esac +} + +choose_uninstall_scope() +{ + local answer + while :; do + ui_blank; ui_heading m_uninstall_scope; ui_blank + ui_preflight_option m_uninstall_runtime default + ui_preflight_option m_uninstall_prefix plain + ui_preflight_option m_uninstall_all plain + ui_preflight_option m_uninstall_exit plain + ui_blank + ui_prompt m_uninstall_prompt + IFS= read -r answer || answer="" + log_event INFO menu "uninstall scope: ${answer:-(default)}" + answer="${answer,,}" + answer="${answer#"${answer%%[![:space:]]*}"}" + answer="${answer%"${answer##*[![:space:]]}"}" + case "$answer" in + ''|r|runtime|runtime\ only) + uninstall_scope=--keep-prefix; return 0 ;; + p|prefix|prefix\ only) + uninstall_scope=--prefix-only; return 0 ;; + a|all) + uninstall_scope=--delete-prefix; return 0 ;; + e|exit|q|quit|x) + return 10 ;; + *) + ui_note m_uninstall_unknown "$answer" ;; + esac + done +} + +load_preflight_values() +{ + local loaded buffer_file + loaded="$( + unset ABLETON_SHORTCUTS ABLETON_DPI_MODE ABLETON_MAX_AUDIO_THREADS \ + ABLETON_RT ABLETON_POWER + ableton_preferences_apply + printf '%s|%s|%s|%s|%s' "$ABLETON_SHORTCUTS" "$ABLETON_DPI_MODE" \ + "$ABLETON_MAX_AUDIO_THREADS" "$ABLETON_RT" "$ABLETON_POWER" + )" || loaded='take|auto|auto|auto|performance' + IFS='|' read -r initial_shortcuts initial_dpi initial_threads initial_rt \ + initial_power <<< "$loaded" + buffer_file="${XDG_CONFIG_HOME:-$HOME/.config}/pipeasio/config.ini" + initial_buffer="$(ableton_pipeasio_buffer_read "$buffer_file" 2>/dev/null)" \ + || initial_buffer=128 +} + +preflight_option() # value compatibility-default dictionary-key [args] +{ + local value="$1" compatibility="$2" key="$3" tag=plain + shift 3 + if [ "$value" = "${draft[q_index]}" ]; then + if [ "${touched[q_index]}" -eq 1 ] \ + || [ "${initial[q_index]}" != "$compatibility" ]; then + tag=current + else + tag=default + fi + fi + ui_preflight_option "$key" "$tag" "$@" +} + +render_preflight_question() +{ + local selected="${draft[q_index]}" + ui_blank + case "$q_index" in + 0) + ui_heading q_buffer_title + ui_note q_buffer_explanation + ui_note q_buffer_explanation_2 + case "$selected" in 64|128|256|512|1024) ;; *) + preflight_option "$selected" 128 q_buffer_custom "$selected" ;; + esac + preflight_option 64 128 q_buffer_64 + preflight_option 128 128 q_buffer_128 + preflight_option 256 128 q_buffer_256 + preflight_option 512 128 q_buffer_512 + preflight_option 1024 128 q_buffer_1024 ;; + 1) + ui_heading q_shortcuts_title + ui_note q_shortcuts_explanation + ui_note q_shortcuts_explanation_2 + preflight_option take take q_shortcuts_take + preflight_option preserve take q_shortcuts_preserve ;; + 2) + ui_heading q_dpi_title + ui_note q_dpi_explanation + ui_note q_dpi_explanation_2 + preflight_option auto auto q_dpi_auto + preflight_option 100 auto q_dpi_100 + preflight_option fractional auto q_dpi_fractional + preflight_option preserve auto q_dpi_preserve ;; + 3) + ui_heading q_threads_title + ui_note q_threads_explanation + ui_note q_threads_explanation_2 + ui_note q_threads_explanation_3 + preflight_option auto auto q_threads_auto + preflight_option off auto q_threads_off + case "$selected" in auto|off) + ui_preflight_option q_threads_custom plain ;; + *) preflight_option "$selected" auto q_threads_value "$selected" ;; + esac ;; + 4) + ui_heading q_rt_title + ui_note q_rt_explanation + ui_note q_rt_explanation_2 + ui_note q_rt_explanation_3 + preflight_option auto auto q_rt_auto + preflight_option off auto q_rt_off ;; + 5) + ui_heading q_power_title + ui_note q_power_explanation + ui_note q_power_explanation_2 + preflight_option performance performance q_power_performance + preflight_option balanced performance q_power_balanced + preflight_option off performance q_power_off ;; + esac +} + +trim_answer() +{ + answer="${UI_ANSWER,,}" + answer="${answer#"${answer%%[![:space:]]*}"}" + answer="${answer%"${answer##*[![:space:]]}"}" +} + +collect_preflight() +{ + local answer value + local -a prompt_choices=('1-5' 'A/P' 'A/N/F/P' 'A/L/1-63' 'A/N' 'P/B/D') + load_preflight_values + initial=("$initial_buffer" "$initial_shortcuts" "$initial_dpi" \ + "$initial_threads" "$initial_rt" "$initial_power") + draft=("${initial[@]}") + touched=(0 0 0 0 0 0) + q_index=0 + while [ "$q_index" -lt 6 ]; do + render_preflight_question + ui_preflight_read "${prompt_choices[q_index]}" + if [ "$UI_ANSWER" = $'\033' ]; then + if [ "$q_index" -eq 0 ]; then return 10; fi + q_index=$((q_index - 1)) + continue + fi + trim_answer + if [ -z "$answer" ]; then + q_index=$((q_index + 1)) + continue + fi + value="" + case "$q_index:$answer" in + 0:1|0:64) value=64 ;; 0:2|0:128) value=128 ;; + 0:3|0:256) value=256 ;; 0:4|0:512) value=512 ;; + 0:5|0:1024) value=1024 ;; + 1:a|1:assign|1:take) value=take ;; 1:p|1:preserve) value=preserve ;; + 2:a|2:auto|2:automatic) value=auto ;; 2:n|2:normal|2:1|2:100|2:100%) value=100 ;; + 2:f|2:fractional) value=fractional ;; 2:p|2:preserve) value=preserve ;; + 3:a|3:auto|3:automatic) value=auto ;; 3:l|3:off) value=off ;; + 3:*) + if [[ "$answer" =~ ^[0-9]+$ ]] && [ "$answer" -ge 1 ] \ + && [ "$answer" -le 63 ]; then + value="$answer" + fi ;; + 4:a|4:auto|4:automatic) value=auto ;; 4:n|4:normal|4:off) value=off ;; + 5:p|5:performance) value=performance ;; 5:b|5:balanced) value=balanced ;; + 5:d|5:don\'t\ change|5:dont\ change|5:o|5:off|5:none) value=off ;; + esac + if [ -z "$value" ]; then + if [ "$q_index" -eq 3 ]; then ui_note q_threads_invalid; else ui_note q_choice_invalid; fi + continue + fi + draft[q_index]="$value" + touched[q_index]=1 + q_index=$((q_index + 1)) + done + setting_flags=() + [ "${draft[0]}" = "${initial[0]}" ] || setting_flags+=("--audio-buffer=${draft[0]}") + [ "${draft[1]}" = "${initial[1]}" ] || setting_flags+=("--shortcuts=${draft[1]}") + [ "${draft[2]}" = "${initial[2]}" ] || setting_flags+=("--dpi=${draft[2]}") + [ "${draft[3]}" = "${initial[3]}" ] || setting_flags+=("--audio-threads=${draft[3]}") + [ "${draft[4]}" = "${initial[4]}" ] || setting_flags+=("--rt=${draft[4]}") + [ "${draft[5]}" = "${initial[5]}" ] || setting_flags+=("--power=${draft[5]}") + return 0 +} + +if [ "$explicit_action" -eq 0 ]; then + while :; do + selected_action="" + if [ -t 0 ]; then + ui_blank; ui_heading h_action; ui_blank + render_action_options + ui_blank + ui_prompt m_prompt + IFS= read -r answer || answer="" + log_event INFO menu "answer: ${answer:-(default)}" + else + answer="" + fi + case "${answer,,}" in + '') selected_action="$default_action" ;; + i|install) selected_action=install ;; + u|update) [ "$install_state" = healthy ] && selected_action=update ;; + r|reinstall) [ "$install_state" != fresh ] && selected_action=install ;; + v|remove|uninstall) [ "$install_state" != fresh ] && selected_action=uninstall ;; + x|exit|q|quit) cancelled=1; launch_hint=0; exit 0 ;; + esac + if [ -z "$selected_action" ]; then + ui_note m_unknown "$answer" + printf '!! %s\n' "$(ui_text m_unknown "$answer")" >&2 + exit 2 + fi + case "$selected_action" in + update) selected_label="$(ui_text label_update)" ;; + install) + if [ "$install_state" = fresh ]; then + selected_label="$(ui_text label_install)" + else + selected_label="$(ui_text label_reinstall)" + fi ;; + uninstall) selected_label="$(ui_text label_remove)" ;; + esac + if [ -t 0 ] && { [ "$selected_action" = install ] || [ "$selected_action" = update ]; }; then + if collect_preflight; then + original_args=("$selected_action" "${setting_flags[@]}") + break + else + collect_rc=$? + fi + [ "$collect_rc" -eq 10 ] || exit "$collect_rc" + continue + fi + original_args=("$selected_action") + break + done +fi + +# Uninstall scope is chosen while the self-extracting payload is still sealed. +# An explicit scope remains untouched so the dispatcher can reject conflicts. +if [ "$selected_action" = uninstall ]; then + launch_hint=0 +fi +if [ "$selected_action" = uninstall ] && [ "$explicit_action" -eq 1 ]; then + uninstall_scope="" + uninstall_scope_present=0 + for ((argument_index=1; argument_index < ${#original_args[@]}; argument_index++)); do + argument="${original_args[argument_index]}" + case "$argument" in + --keep-prefix|--prefix-only|--delete-prefix) + uninstall_scope_present=1 ;; + --prefix) + # Preserve the value-less legacy delete-prefix spelling. A + # following non-option is instead the configured prefix path. + next_argument="${original_args[argument_index + 1]:-}" + if [ -z "$next_argument" ] || [[ "$next_argument" = --* ]]; then + uninstall_scope_present=1 + fi ;; + esac + done + if [ "$uninstall_scope_present" -eq 0 ]; then + if [ -t 0 ]; then + choose_uninstall_scope || { + choose_rc=$? + [ "$choose_rc" -eq 10 ] || exit "$choose_rc" + cancelled=1 + exit 0 + } + else + uninstall_scope=--keep-prefix + fi + original_args=("${original_args[0]}" "$uninstall_scope" "${original_args[@]:1}") + fi +fi + +case "$selected_action" in + prefix) + case "${original_args[1]:-}" in + create) ui_action=prefix_create ;; + update) ui_action=prefix_update ;; + repair-live11) ui_action=prefix_repair ;; + *) ui_action=prefix_create ;; + esac ;; + link) + case "${original_args[1]:-}" in + disable) ui_action=link_disable ;; + status) ui_action=link_status ;; + *) ui_action=link_enable ;; + esac ;; + *) ui_action="$selected_action" ;; +esac +export ABLETON_UI_ACTION="$ui_action" +export ABLETON_UI_KIT=1 +printf -v selected_command '%q ' "${original_args[@]}" +log_event INFO run "selected command: ${selected_command% }" + +if [ "$selected_action" = extract ]; then + ui_step_begin s_extract else - actual="$(bounded sha256sum "$payload" | awk '{print $1}')" + ui_step_begin s_prepare fi + +workdir="$(mktemp -d "${TMPDIR:-/tmp}/ableton-installer.XXXXXX")" || { + printf '!! %s\n' "$(ui_text e_workspace "${TMPDIR:-/tmp}")" >&2 + exit 1 +} +payload="$workdir/payload.tar" + +copy_payload() +{ + if dd --help 2>&1 | grep -q iflag; then + bounded dd if="$self" of="$payload" iflag=skip_bytes skip="$header_bytes" \ + bs=4M status=none + else + bounded tail -n +"$payload_line" "$self" > "$payload" + fi +} +: > "$payload" +ui_run i_copy --progress "$payload" "$payload_bytes" -- copy_payload || { + printf '!! %s\n' "$(ui_text e_copy)" >&2 + exit 1 +} +ui_status i_copy_done "$((payload_bytes / 1024 / 1024))" + +hash_payload() +{ + bounded sha256sum "$payload" > "$workdir/payload.sha256" +} +ui_run i_check -- hash_payload || { + printf '!! %s\n' "$(ui_text e_check)" >&2 + exit 1 +} +read -r actual _ < "$workdir/payload.sha256" [ "$actual" = "$PAYLOAD_SHA" ] || { - echo "!! installer integrity check failed; download or copy it again" >&2 + printf '!! %s\n' "$(ui_text e_integrity)" >&2 exit 1 } +ui_status i_check_done kit="$workdir/kit" mkdir -p -- "$kit" -printf '%s\n' "-- extracting embedded kit" || true -if tar --help 2>&1 | grep -- '--checkpoint' >/dev/null; then - bounded tar --checkpoint=200 --checkpoint-action=dot -xf "$payload" -C "$kit" - printf '\n' >&2 || true -else +extract_payload() +{ bounded tar -xf "$payload" -C "$kit" -fi -rm -f -- "$payload" 2>/dev/null || true +} +ui_run i_extract -- extract_payload || { + printf '!! %s\n' "$(ui_text e_extract)" >&2 + exit 1 +} +rm -f -- "$payload" "$workdir/payload.sha256" 2>/dev/null || true -if [ -n "$extract_dir" ]; then +if [ "$selected_action" = extract ]; then + [ "${#original_args[@]}" -eq 2 ] \ + || { printf '!! %s\n' "$(ui_text e_extract_args)" >&2; exit 2; } + extract_dir="${original_args[1]}" mkdir -p -- "$extract_dir" cp -a -- "$kit/." "$extract_dir/" - printf 'OK: kit extracted to %s\n' "$extract_dir" || true + ui_status i_extract_to "$extract_dir" + ui_step_end ok + launch_hint=0 exit 0 fi +ui_step_end ok export ABLETON_INSTALLER_MEDIA_DIR="$media_dir" export ABLETON_INSTALLER_PATH="$self" export ABLETON_INSTALLER_VERSION="$VERSION" -# Delegate without exec: the EXIT trap must still remove $workdir. The exit -# keeps bash from reading past the payload marker after a successful install. -bash "$kit/scripts/installer.sh" "$@"; exit $? +bash "$kit/scripts/installer.sh" "${original_args[@]}" +exit $? +# shellcheck disable=SC2317 # payload marker, not a command __PAYLOAD_BELOW__ diff --git a/scripts/test-config-boundary.sh b/scripts/test-config-boundary.sh index fdc791c3..03b3757c 100755 --- a/scripts/test-config-boundary.sh +++ b/scripts/test-config-boundary.sh @@ -39,6 +39,7 @@ run_isolated() -u ABLETON_CONFIG_FILE -u ABLETON_STATE_HOME \ -u ABLETON_CACHE_HOME -u ABLETON_BIN_HOME \ -u ABLETON_LINKD -u ABLETON_LINK_MODE \ + -u ABLETON_SIMPLE_PROJECT_FILES \ -u ABLETON_CONFIG_LAYOUT_ROOTS -u ABLETON_CONFIG_REPAIR_MODE \ -u ABLETON_CONFIG_REPAIR_NEEDED \ -u ABLETON_CONFIG_SNAPSHOT_PATH -u ABLETON_CONFIG_SNAPSHOT_TOKEN \ @@ -91,6 +92,28 @@ run_isolated "$base" env \ ' _ "$here" || fail "partial installer-settings snapshot was accepted or caused an unbound-variable exit" ok "installer-settings snapshots are complete records under set -u" +# Repair mode records the config file before it requests the install lock. +# The lock checks the same file again after another process changes it. +base="$(new_env repair-lock-file-change)" +config="$base/config/ableton-wine/config" +mkdir -p -- "$(dirname "$config")" +printf '# ableton-linux installer configuration; managed by the installer\nformat=1\nruntime_root=%s\nprefix=%s\nobsolete=malformed\n' \ + "$base/runtime" "$base/prefix" > "$config" +run_isolated "$base" bash -c ' + set -euo pipefail + . "$1/lib/config.sh" + ABLETON_CONFIG_LAYOUT_ROOTS=none + export ABLETON_CONFIG_LAYOUT_ROOTS + ableton_config_init repair + [ "$ABLETON_CONFIG_REPAIR_NEEDED" -eq 1 ] + printf "changed-after-recording\n" >> "$ABLETON_CONFIG_FILE" + ! ableton_install_lock_acquire +' _ "$here" > "$base/out" 2> "$base/err" \ + || fail "repair mode accepted a changed config file while it waited for the install lock" +grep -qF 'installation configuration changed; retry the command' "$base/err" \ + || fail "repair mode returned the wrong message for a changed config file" +ok "repair mode checks the recorded config file again before it obtains the install lock" + # Once a caller intentionally enters repair mode, the state marker and settings # writer must not re-enter strict mode and reject the same repairable generation. base="$(new_env repair-reentry)" diff --git a/scripts/test-desktop-integration.sh b/scripts/test-desktop-integration.sh index 81b82de7..29b0cd3a 100755 --- a/scripts/test-desktop-integration.sh +++ b/scripts/test-desktop-integration.sh @@ -145,10 +145,8 @@ ok "launcher gives one repair instruction for incomplete support files" base="$(new_env unique-handlers)" install_fake_desktop_tools "$base" state="$base/state/ableton-wine" -legacy_handler="$base/data/applications/wine-protocol-ableton.desktop" private_support="$base/data/ableton-wine/setup-realtime.sh" -mkdir -p -- "$base/data/applications" "$(dirname "$private_support")" \ - "$state/install-prestate" +mkdir -p -- "$base/data/applications" "$(dirname "$private_support")" printf 'older generated support bytes\n' > "$private_support" cat > "$base/data/applications/wine-protocol-ableton.desktop" < "$base/data/applications/wine-extension-auz.desktop" <<'EOF' [Desktop Entry] Exec=env WINEPREFIX=/tmp/foreign wine start %f EOF -# Repair installs must identify the legacy handler from its exact generated -# shape, not from old uninstall inventory. Deliberately leave both historical -# state files unusable and make sure neither can veto or redirect the repair. -printf 'format=1\nowner=ableton-linux\n' > "$state/.ableton-linux-state" -printf 'malformed legacy ownership row\n' > "$state/install-manifest.tsv" -printf 'present\t%s\t/tmp/untrusted-handler-backup\n' "$legacy_handler" \ - > "$state/install-prestate.tsv" -printf 'stale backup sentinel\n' > "$state/install-prestate/not-a-valid-slot" -chmod 600 "$state/.ableton-linux-state" "$state/install-manifest.tsv" \ - "$state/install-prestate.tsv" -if ! run_isolated "$base" bash "$here/install.sh" --integration-only >"$base/out" 2>"$base/err"; then +if ! printf 'o\n' | run_isolated "$base" \ + bash "$here/install.sh" --integration-only >"$base/out" 2>"$base/err"; then cat "$base/err" >&2 fail "installer cannot install desktop integration in an isolated home" fi @@ -181,19 +170,38 @@ fi [ -f "$base/data/ableton-wine/$protocol_id" ] || fail "installer omits the staged protocol handler" [ -f "$base/data/ableton-wine/$auz_id" ] || fail "installer omits the staged AUZ handler" cmp -s -- "$here/setup-realtime.sh" "$private_support" \ - || fail "damaged optional bookkeeping blocked private support-file repair" -[ ! -e "$base/data/applications/wine-protocol-ableton.desktop" ] \ - || fail "installer keeps its globally named legacy protocol handler" + || fail "Overwrite did not replace the selected support file" +[ -f "$base/data/applications/wine-protocol-ableton.desktop" ] \ + || fail "installer removed an unrelated legacy protocol handler" [ -f "$base/data/applications/wine-extension-auz.desktop" ] \ || fail "installer removes a foreign globally named handler" -grep -qxF 'stale backup sentinel' "$state/install-prestate/not-a-valid-slot" \ - || fail "legacy handler repair consumed unrelated stale recovery data" +grep -qxF "$private_support exists." "$base/err" \ + || fail "existing support file prompt did not name its destination" +grep -qF '│ ├─ FILES FROM AN EARLIER INSTALLATION ALREADY EXIST' "$base/out" \ + || fail "existing support file did not ask for an overwrite choice" +mapfile -t support_backups < <(find "$state/backups" -type f \ + -name 'setup-realtime.sh.bak-*') +[ "${#support_backups[@]}" -eq 1 ] \ + || fail "Overwrite did not create exactly one dated backup" +grep -qxF 'older generated support bytes' "${support_backups[0]}" \ + || fail "Overwrite backup does not contain the displaced file" +support_digest="$(sha256sum -- "$private_support" | awk '{print $1}')" +grep -qxF "$(printf 'file\t%s\t%s' "$private_support" "$support_digest")" \ + "$state/install-manifest.tsv" \ + || fail "simple integration did not record the installed support file" +[ ! -e "$state/install-prestate.tsv" ] \ + || fail "ordinary support-file overwrite created automatic prestate" [ "$(awk -F '\t' '$1 == "x-scheme-handler/ableton" { print $2 }' "$base/mime-defaults.tsv")" = "$protocol_id" ] \ || fail "installer does not pin the project protocol handler" [ "$(awk -F '\t' '$1 == "application/x-wine-extension-auz" { print $2 }' "$base/mime-defaults.tsv")" = "$auz_id" ] \ || fail "installer does not pin the project AUZ handler" installed_ntsync_check="$base/data/ableton-wine/check-ntsync.sh" installed_ntsync_probe="$base/data/ableton-wine/ntsyncprobe.exe" +installed_preferences_lib="$base/data/ableton-wine/lib/preferences.sh" +if [ ! -f "$installed_preferences_lib" ] || [ -x "$installed_preferences_lib" ] \ + || ! cmp -s -- "$here/lib/preferences.sh" "$installed_preferences_lib"; then + fail "installer omits or changes the data-only launcher preferences support" +fi [ -x "$installed_ntsync_check" ] \ || fail "installer omits the executable NTSync diagnostic" [ -f "$installed_ntsync_probe" ] && [ ! -x "$installed_ntsync_probe" ] \ @@ -230,29 +238,7 @@ fi || fail "installed NTSync diagnostic cannot find its packaged sibling probe" grep -q 'FAIL: rebuild Wine with linux/ntsync.h' "$base/ntsync.err" \ || fail "installed NTSync diagnostic does not reach its runtime check" -ok "installer uses unique callback handlers and retires only its legacy entry" - -ownership_data="$base/ownership-data" -ownership_check="$ownership_data/check-ntsync.sh" -mkdir -p -- "$ownership_data" -legacy_ntsync_owned() -{ - run_isolated "$base" env \ - "ABLETON_DATA_HOME=$ownership_data" \ - "ABLETON_BIN_HOME=$base/bin" \ - "ABLETON_WINE_ROOT=$base/fake-wine" \ - bash -c ". \"\$1\"; ableton_legacy_owned_path \"\$2\"" \ - legacy-ntsync "$base/data/ableton-wine/lib/manifest.sh" "$ownership_check" -} -cp -- "$here/check-ntsync.sh" "$ownership_check" -legacy_ntsync_owned || fail "legacy ownership rejects the current NTSync diagnostic" -printf '# NT sync semantics hold\n' > "$ownership_check" -legacy_ntsync_owned || fail "legacy ownership rejects the pre-rewrite NTSync diagnostic" -printf '# foreign NTSync diagnostic\n' > "$ownership_check" -if legacy_ntsync_owned; then - fail "legacy ownership accepts a foreign NTSync diagnostic" -fi -ok "legacy ownership recognises both project NTSync diagnostic generations" +ok "installer uses unique handlers and backs up only selected fixed destinations" base="$(new_env mime-query-ignored)" install_fake_desktop_tools "$base" @@ -278,58 +264,59 @@ grep -qxF $'x-scheme-handler/ableton\tio.github.shibco.ableton-linux.protocol.de "$base/mime-defaults.tsv" || fail "MIME default was not published before verification was skipped" ok "MIME defaults are published without a redundant query failure gate" -optional_real_mv="$(command -v mv)" +optional_real_cp="$(command -v cp)" base="$(new_env optional-version-warning)" install_fake_desktop_tools "$base" -cat > "$base/fakebin/mv" < "$base/fakebin/cp" <"$base/out" 2>"$base/err" \ - || fail "optional version failure aborted a parent-owned integration phase" + || fail "optional version failure aborted integration" [ -x "$base/home/.local/bin/ableton-live" ] \ || fail "optional version failure rolled back the generated launcher" -grep -qF 'Ableton was installed, but its support files could not be fully updated.' \ - "$base/err" || fail "optional version failure was not a plain warning" -if grep -q '^OK:' "$base/out"; then - fail "parent-owned integration phase printed a child success line" -fi -run_isolated "$base" bash "$here/install.sh" --preflight-commit "$txn" \ - >"$base/preflight.out" 2>"$base/preflight.err" \ - || fail "optional version failure poisoned the parent commit check" -ok "optional version failure warns without invalidating parent-owned generated files" +grep -qF -- "-> $base/data/ableton-wine/VERSION" "$base/err" \ + || fail "optional version failure did not report the actual failed path" +grep -qF '⚠ Some shortcuts or support files could not be updated.' "$base/out" \ + || fail "optional version failure hid the partial integration result" +ok "optional version copy failure reports its path and keeps completed files" -base="$(new_env optional-file-list-warning)" +base="$(new_env overwrite-keep-continues)" install_fake_desktop_tools "$base" -cat > "$base/fakebin/mv" <"$base/out" 2>"$base/err" \ - || fail "installed-file list failure aborted generated integration" +kept_config="$base/data/ableton-wine/lib/config.sh" +mkdir -p -- "$(dirname "$kept_config")" +printf 'keep this config\n' > "$kept_config" +printf 'Keep\n' | run_isolated "$base" \ + bash "$here/install.sh" --integration-only \ + >"$base/out" 2>"$base/err" \ + || fail "Keep stopped the integration mapping loop" +grep -qxF 'keep this config' "$kept_config" \ + || fail "Keep changed the selected destination" +[ -f "$base/data/ableton-wine/lib/lifecycle.sh" ] \ + || fail "Keep prevented the next project file from being installed" [ -x "$base/home/.local/bin/ableton-live" ] \ - || fail "installed-file list failure rolled back the generated launcher" -grep -qF 'Ableton was installed, but the installed-file list could not be updated.' \ - "$base/err" || fail "installed-file list failure was not a plain warning" -grep -qxF 'OK: Installation complete.' "$base/out" \ - || fail "direct integration did not print its simple outcome" -ok "installed-file list failure warns without invalidating generated files" + || fail "Keep prevented later project files from being installed" +if [ -d "$base/state/ableton-wine/backups" ] \ + && find "$base/state/ableton-wine/backups" -type f -name 'config.sh.bak-*' \ + -print -quit | grep -q .; then + fail "Keep created a backup for an untouched destination" +fi +grep -qF 'Kept existing file unchanged:' "$base/out" \ + && grep -qF '⚠ Kept 1 existing files unchanged.' "$base/out" \ + || fail "Keep was not reported as an unchanged result" +! grep -qF 'Launchers, shortcuts, and file support are ready.' "$base/out" \ + || fail "Keep was falsely reported as a fully applied update" +grep -qF '│ ├─ FILES FROM AN EARLIER INSTALLATION ALREADY EXIST' "$base/out" \ + || fail "the overwrite question is rendered inside the tree" +ok "Keep leaves one destination unchanged and continues the fixed mapping loop" base="$(new_env optional-cache-ignored)" install_fake_desktop_tools "$base" @@ -359,20 +346,20 @@ run_isolated "$base" env ABLETON_INTERNAL_OPTIONAL_STATUS=1 \ || fail "desktop integration invoked gtk-update-icon-cache" ! grep -qF 'Some shortcuts or support files could not be updated.' "$base/err" \ || fail "desktop refresh failure produced an alarming retry warning" -grep -qxF 'OK: Installation complete.' "$base/out" \ - || fail "desktop refresh failure hid the successful install outcome" +! grep -q 'Step [0-9]* Complete' "$base/out" \ + || fail "coordinated desktop refresh closed a step that belongs to the installer" ok "desktop database refresh is silent best-effort work and GTK cache generation is skipped" base="$(new_env generated-file-publication-status)" install_fake_desktop_tools "$base" -cat > "$base/fakebin/mv" < "$base/fakebin/cp" < $base/home/.local/bin/ableton-live" "$base/err" \ + || fail "generated launcher failure did not report the actual failed path" grep -qF 'Some shortcuts or support files could not be updated. Run the installer again to retry them.' \ "$base/err" || fail "generated launcher publication failure omitted its retry warning" ok "generated-file publication failures still request a repair run" -optional_real_rm="$(command -v rm)" -for optional_mode in integration link; do - for cleanup_failure in active directory; do - base="$(new_env "optional-final-cleanup-$optional_mode-$cleanup_failure")" - install_fake_desktop_tools "$base" - cat > "$base/fakebin/rm" <"$base/out" 2>"$base/err" \ - || fail "$optional_mode $cleanup_failure cleanup failure rejected usable generated files" - case "$optional_mode" in - integration) - [ -x "$base/home/.local/bin/ableton-live" ] \ - || fail "integration cleanup warning rolled back the generated launcher" ;; - link) - [ -x "$base/data/ableton-wine/ableton-linkctl" ] \ - && [ -x "$base/data/ableton-wine/setup-link.sh" ] \ - || fail "Link cleanup warning rolled back generated Link files" ;; - esac - case "$cleanup_failure" in - active) - grep -qF "Ableton's files are ready, but the installer could not finish its final housekeeping." \ - "$base/err" || fail "$optional_mode active cleanup failure was not a plain warning" ;; - directory) - grep -qF 'Installed files are ready, but temporary installer data remains at ' \ - "$base/err" || fail "$optional_mode directory cleanup failure was not a plain warning" ;; - esac - grep -qxF 'OK: Installation complete.' "$base/out" \ - || fail "$optional_mode direct call did not print its simple outcome" - if [ -d "$base/state/ableton-wine/transactions" ] \ - && find "$base/state/ableton-wine/transactions" -mindepth 1 -print -quit \ - | grep -q .; then - fail "$optional_mode direct cleanup left a persistent core-recovery record" - fi - done -done -ok "integration and Link activity/directory cleanup failures warn after generated files are usable" +base="$(new_env overwrite-cancel-stops)" +install_fake_desktop_tools "$base" +cancel_target="$base/data/ableton-wine/lib/lifecycle.sh" +mkdir -p -- "$(dirname "$cancel_target")" +printf 'keep on cancel\n' > "$cancel_target" +cancel_status=0 +printf 'a\n' | run_isolated "$base" \ + bash "$here/install.sh" --integration-only >"$base/out" 2>"$base/err" \ + || cancel_status=$? +[ "$cancel_status" -eq 4 ] || fail "Cancel did not return the cancellation status" +[ -f "$base/data/ableton-wine/lib/config.sh" ] \ + || fail "Cancel unwound an earlier completed mapping" +grep -qxF 'keep on cancel' "$cancel_target" \ + || fail "Cancel changed its selected destination" +[ ! -e "$base/data/ableton-wine/lib/live-options.sh" ] \ + && [ ! -e "$base/home/.local/bin/ableton-live" ] \ + || fail "Cancel did not stop later project-file mappings" +ok "Cancel stops later mappings without unwinding completed files" base="$(new_env callback-discovery)" install_fake_desktop_tools "$base" diff --git a/scripts/test-installer-lifecycle.sh b/scripts/test-installer-lifecycle.sh index 5b7ccb29..0d3e6642 100755 --- a/scripts/test-installer-lifecycle.sh +++ b/scripts/test-installer-lifecycle.sh @@ -39,9 +39,31 @@ fail() exit 1 } +lifecycle_object_snapshot() +{ + local path="$1" metadata digest target + if [ -L "$path" ]; then + metadata="$(stat -c '%d|%i|%f|%a|%u|%g|%s|%h|%w|%y|%z' -- "$path")" \ + || return 1 + target="$(readlink -- "$path")" || return 1 + printf 'symlink|%s|%s\n' "$metadata" "$target" + elif [ -f "$path" ]; then + metadata="$(stat -c '%d|%i|%f|%a|%u|%g|%s|%h|%w|%y|%z' -- "$path")" \ + || return 1 + digest="$(sha256sum -- "$path" | awk '{print $1}')" || return 1 + printf 'file|%s|%s\n' "$metadata" "$digest" + elif [ -e "$path" ]; then + metadata="$(stat -c '%d|%i|%f|%a|%u|%g|%s|%h|%w|%y|%z' -- "$path")" \ + || return 1 + printf 'other|%s\n' "$metadata" + else + printf 'absent\n' + fi +} + # The suite installs real build artifacts: the exact VERSION runtime from the -# runtime-plan check onwards and dist/ableton-linkd from the link-prestate -# check. A checkout can update the tracked runtime checksum while retaining +# runtime-plan check onwards and dist/ableton-linkd from the Link checks. A +# checkout can update the tracked runtime checksum while retaining # an ignored, now-stale tarball. Validate the pair here because its first use # redirects the diagnostic to a temporary log and set -e would otherwise end # the suite without a failure line or summary. @@ -240,6 +262,27 @@ grep -qxF 'password-submitted' "$base/sudo.log" \ || fail "the command-timeout path did not complete password input" ok "sudo password input and the privileged command have separate bounds" +# A ui_run task runs exactly once and keeps its exit status; the rendering +# itself is covered by test-installer-ui.sh. +cat > "$base/ui-worker" <<'EOF' +#!/bin/sh +printf 'run\n' >> "${ABLETON_TEST_UI_LOG:?}" +exit "${1:-0}" +EOF +chmod +x "$base/ui-worker" +: > "$base/ui.log" +status=0 +env ABLETON_TEST_UI_LOG="$base/ui.log" ABLETON_UI_ACTION=install bash -c ' + . "$1/lib/ui.sh" + ui_step_begin s_validate + ui_run i_copy -- "$2" 23 +' _ "$here" "$base/ui-worker" > "$base/ui-plain.out" 2>&1 || status=$? +[ "$status" -eq 23 ] || fail "ui_run keeps its task's exit status (got $status)" +[ "$(wc -l < "$base/ui.log")" -eq 1 ] || fail "ui_run ran its task more than once" +grep -qF '│ ├─ Copy the embedded kit 𐄂' "$base/ui-plain.out" \ + || fail "a failed ui_run task is marked on its title" +ok "ui_run runs one task and preserves its status" + base="$(new_env realtime-destdir)" mkdir -p "$base/fakebin" cat > "$base/fakebin/sudo" <<'EOF' @@ -369,31 +412,114 @@ env HOME="$base/home" XDG_CONFIG_HOME="$base/config-link" \ || fail "resolved XDG roots changed their user-owned symlink projections" ok "resolved XDG roots use real target directories without replacing parent symlinks" -# make-installer's own [5/5] self-check runs --help, which returns before the -# delegation, so only this case guards the header's exit path. +# make-installer's own self-check runs --help, which returns before the +# delegation, so only this case guards the header's exit path. The stub +# renders through ui.sh exactly as the real installer does. base="$(new_env run-header)" kit="$base/kit" -mkdir -p "$kit/scripts" -printf '#!/bin/sh\nprintf "%%s\\n" "${ABLETON_INSTALLER_PATH:-}" > "${STUB_PATH_FILE:?}"\nexit "${STUB_EXIT:-0}"\n' \ - > "$kit/scripts/installer.sh" +mkdir -p "$kit/scripts/lib" +cp "$here/lib/ui.sh" "$kit/scripts/lib/ui.sh" +cat > "$kit/scripts/installer.sh" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "${ABLETON_INSTALLER_PATH:-}" > "${STUB_PATH_FILE:?}" +. "$(dirname "$0")/lib/ui.sh" +ui_step_begin s_validate +ui_item_begin i_copy +printf ' backup: /known/recovery/path\n' +if [ "${STUB_EXIT:-0}" -eq 0 ]; then + ui_item_end ok + printf '!! recoverable stub warning\n' >&2 + ui_step_end ok +else + ui_item_end fail + printf '🛈 ordinary stderr context.\n!! delegated installer failed with status %s.\n' \ + "${STUB_EXIT:-0}" >&2 + ui_step_end fail +fi +exit "${STUB_EXIT:-0}" +EOF tar -cf "$base/payload.tar" -C "$kit" . -sed -e 's/@VERSION@/suite-check/g' \ - -e "s/@PAYLOAD_SHA@/$(sha256sum "$base/payload.tar" | awk '{print $1}')/g" \ - "$here/setup-run-header.sh" > "$base/kit.run" +"$here/make-installer.sh" --render-header --version suite-check \ + --payload-sha "$(sha256sum "$base/payload.tar" | awk '{print $1}')" > "$base/kit.run" \ + || fail "make-installer.sh renders the .run header for the suite" cat "$base/payload.tar" >> "$base/kit.run" -run_isolated "$base" env STUB_EXIT=0 STUB_PATH_FILE="$base/installer-path" \ +run_isolated "$base" env PATH="$PATH" STUB_EXIT=0 STUB_PATH_FILE="$base/installer-path" \ sh "$base/kit.run" >"$base/out" 2>"$base/err" \ || fail "a successful delegated install exits zero through the .run header" +grep -qF '│ ┃ 2/8 ╏ CHECK THE HOST AND THE REQUEST ┃' "$base/out" \ + || fail "the delegated installer continues the header's step numbering" +grep -qF '│ └─ Step 1 Complete! ✓' "$base/out" \ + && grep -qF '│ └─ Step 2 Complete! ✓' "$base/out" \ + || fail "the header and the delegated installer each close their step" +grep -qF 'Launch Ableton Live via your desktop applications launcher' "$base/out" \ + || fail "a successful install ends with the launch hint" status=0 -run_isolated "$base" env STUB_EXIT=42 STUB_PATH_FILE="$base/installer-path" \ +run_isolated "$base" env PATH="$PATH" STUB_EXIT=42 STUB_PATH_FILE="$base/installer-path" \ sh "$base/kit.run" >>"$base/out" 2>>"$base/err" || status=$? [ "$status" -eq 42 ] || fail "a delegated install failure code passes through the .run header" +grep -q '^│ Ableton-Linux Install v. suite-check *│ Complete │$' "$base/out" \ + || fail "successful .run output omitted its completion footer" +grep -q '^│ Ableton-Linux Install v. suite-check *│ Failed │$' "$base/out" \ + || fail "failed .run output omitted its failure footer" +grep -qF ' > delegated installer failed with status 42.' "$base/out" \ + || fail "failed .run output omitted its line-by-line error" +! grep -qF ' > ordinary stderr context.' "$base/out" \ + || fail "failed .run output mislabeled informational stderr as an error" +! grep -q '^!! \|^🛈 ' "$base/out" \ + || fail "raw child output reached the terminal instead of the log" +grep -qF 'https://discord.gg/XD5EeZyP3' "$base/out" \ + && grep -qF 'https://github.com/shibco/ableton-linux/issues' "$base/out" \ + || fail "failed .run output omitted its support links" +grep -qF 'Saved a log of this operation at' "$base/out" \ + || fail "the .run footer omitted its log location" +! grep -qF 'backup: /known/recovery/path' "$base/out" \ + || fail "successful backup paths flooded the .run terminal" +grep -qF '[ableton-linux][installer][stdout] backup: /known/recovery/path' \ + "$base"/ableton-linux-installer-*.log \ + || fail "the .run log omitted a successful backup path" +! grep -qF '[ableton-linux][installer][ui]' "$base"/ableton-linux-installer-*.log \ + || fail "the .run log repeats the rendered installer tree" +grep -qF '[ableton-linux][installer][step] started 1/8: PREPARE THE INSTALLER KIT' \ + "$base"/ableton-linux-installer-*.log \ + || fail "the .run log omits step activity" +grep -qF '[ableton-linux][installer][task] completed: Copy the embedded kit' \ + "$base"/ableton-linux-installer-*.log \ + || fail "the .run log omits completed task activity" +grep -qF '[WARN] ' "$base"/ableton-linux-installer-*.log \ + && grep -qF 'recoverable stub warning' "$base"/ableton-linux-installer-*.log \ + || fail "a recoverable successful-run warning remained an error in the log" +! LC_ALL=C grep -q $'\033' "$base"/ableton-linux-installer-*.log \ + || fail "the .run log contains terminal escapes" [ "$(cat "$base/installer-path")" = "$(readlink -f -- "$base/kit.run")" ] \ || fail "the .run header does not pass its reusable path to the installer" ! find "$base/tmp" -mindepth 1 -maxdepth 1 -name 'ableton-installer.*' 2>/dev/null | grep -q . \ || fail "the .run header removes its work directory" ok "the .run header propagates the delegated installer exit code" +# Host-report commands can keep writing after their first useful line. The +# wrapper must drain them instead of turning that normal SIGPIPE into exit 141. +base="$(new_env run-header-host-report)" +mkdir -p "$base/fakebin" +cat > "$base/fakebin/lspci" <<'EOF' +#!/bin/sh +printf '%s\n' '00:00.0 "VGA compatible controller" "Test Vendor" "Test GPU" -r00 "Test Subvendor" "Test Subdevice"' +exec /usr/bin/seq 1 100000 +EOF +cat > "$base/fakebin/pw-cli" <<'EOF' +#!/bin/sh +printf '%s\n' 'pw-cli' 'Linked with libpipewire 1.6.8' +exec /usr/bin/seq 1 100000 +EOF +chmod +x "$base/fakebin/lspci" "$base/fakebin/pw-cli" +run_isolated "$base" env PATH="$base/fakebin:$PATH" STUB_EXIT=0 \ + STUB_PATH_FILE="$base/installer-path" sh "$work/run-header/kit.run" \ + > "$base/out" 2> "$base/err" \ + || fail "large host-report output stopped the .run wrapper" +grep -qF 'Test GPU Test Subvendor' "$base/out" \ + && grep -qF '│ PipeWire 1.6.8' "$base/out" \ + || fail "the .run wrapper did not report the first GPU and PipeWire results" +ok "host reporting drains first-match commands without an exit 141" + # Extraction has completed once the files are copied. A closed output stream # must not reverse that result or prevent any of the transport work that precedes # the final success line. @@ -473,6 +599,139 @@ grep -q -- '--live-installer FILE or --skip-live-install' "$base/err" || fail "n [ ! -e "$base/config" ] && [ ! -e "$base/data" ] && [ ! -e "$base/state" ] || fail "failed parse is mutation-free" ok "noninteractive payload failure is mutation-free" +# Live installer candidates come from the installer's own directory, newest +# first; [1] is the default on Enter, EOF, or the timeout. +base="$(new_env candidates)" +mkdir -p "$base/media" +for spec in 'ableton_live_11.3.35_64.zip:Ableton Live 11 Suite Installer.exe' \ + 'ableton_live_12.0_64.zip:Ableton Live 12 Suite Installer.exe' \ + 'ableton_live_12.3.1_64.zip:Ableton Live 12 Suite Installer.exe'; do + python3 - "$base/media/${spec%%:*}" "${spec#*:}" <<'PY' +import sys, zipfile +zipfile.ZipFile(sys.argv[1], 'w').writestr(sys.argv[2], 'x') +PY +done +run_candidates() +{ + run_isolated "$base" env ABLETON_INSTALLER_MEDIA_DIR="$base/media" \ + ABLETON_UI_PROMPT_TIMEOUT=1 bash "$here/installer.sh" plan install +} +run_candidates < /dev/null > "$base/out" 2> "$base/err" \ + || { sed -n '1,40p' "$base/err" >&2; fail "EOF picks the first candidate"; } +grep -qF '│ Found multiple Ableton Live install candidates:' "$base/out" \ + || fail "several candidates are announced" +[ "$(grep '│ > \[[0-9]\] ableton_live' "$base/out" | sed 's/.*\] //' | paste -sd,)" = \ + 'ableton_live_12.3.1_64.zip,ableton_live_12.0_64.zip,ableton_live_11.3.35_64.zip' ] \ + || fail "candidates are listed newest first" +grep -qF '(Press Enter for [1] or wait 1 seconds)' "$base/out" \ + || fail "the candidate hint names the default and the timeout" +grep -qiF "run the Live 12 installer: $base/media/ableton_live_12.3.1_64.zip" "$base/out" \ + || fail "EOF selected the first candidate" +printf '2\n' | run_candidates > "$base/out" 2> "$base/err" || fail "a number picks its candidate" +grep -qiF "run the Live 12 installer: $base/media/ableton_live_12.0_64.zip" "$base/out" \ + || fail "2 picks the second candidate" +run_candidates < <(sleep 3) > "$base/out" 2> "$base/err" || fail "the timeout picks the first candidate" +grep -qiF "run the Live 12 installer: $base/media/ableton_live_12.3.1_64.zip" "$base/out" \ + || fail "the timeout selected [1]" +status=0 +printf '9\n' | run_candidates > "$base/out" 2> "$base/err" || status=$? +[ "$status" -eq 2 ] || fail "a number out of range fails the run (got $status)" +rm -f "$base/media/ableton_live_12.0_64.zip" "$base/media/ableton_live_11.3.35_64.zip" +run_candidates < /dev/null > "$base/out" 2> "$base/err" \ + || fail "a single candidate is used without a question" +grep -qF '│ Found Ableton Live install files at' "$base/out" \ + && grep -qF '│ > [1] ableton_live_12.3.1_64.zip' "$base/out" \ + || fail "a single candidate is shown" +! grep -qF 'Which one?' "$base/out" || fail "a single candidate asks no question" +rm -f "$base/media"/*.zip +status=0 +run_candidates < /dev/null > "$base/out" 2> "$base/err" || status=$? +[ "$status" -eq 2 ] || fail "no candidate at all fails the run (got $status)" +grep -q -- '--live-installer FILE or --skip-live-install' "$base/err" || fail "no candidate names the remedy" +mkdir -p "$base/home/Downloads" +python3 - "$base/home/Downloads/ableton_live_12.3.1_64.zip" 'Ableton Live 12 Suite Installer.exe' <<'PY' +import sys, zipfile +zipfile.ZipFile(sys.argv[1], 'w').writestr(sys.argv[2], 'x') +PY +run_isolated "$base" env ABLETON_INSTALLER_MEDIA_DIR="$base/home/Downloads" ABLETON_UI_PROMPT_TIMEOUT=1 \ + bash "$here/installer.sh" plan install < /dev/null > "$base/out" 2> "$base/err" \ + || fail "a candidate under the home directory is found" +grep -qxF '│ ~/Downloads' "$base/out" || fail "the candidate directory is shown with a tilde" +ok "Live installer candidates are listed newest first with a timed default" + +# The uninstall command hands over to uninstall.sh with exec: the validate +# step is closed first and uninstall.sh draws its own step exactly once. +base="$(new_env uninstall-exec)" +status=0 +run_isolated "$base" bash "$here/installer.sh" uninstall --yes > "$base/out" 2> "$base/err" || status=$? +[ "$(grep -c '^│ └─ Step 1 Complete! ✓$' "$base/out")" -eq 1 ] \ + || fail "the validate step closes before the handover to uninstall.sh" +[ "$(grep -c '^│ ┃ 2/2 ╏ REMOVE ABLETON LINUX ┃$' "$base/out")" -eq 1 ] \ + || fail "uninstall.sh draws its own step after the handover" +[ "$(grep -c '^│ └─ Step 2 ' "$base/out")" -eq 1 ] \ + || fail "uninstall.sh closes its step exactly once (exit $status)" +ok "the uninstall handover keeps one step open at a time" + +# The dispatcher normalizes every uninstall request to exactly one scope flag. +# All three pairwise conflicts are rejected before uninstall.sh can run. +base="$(new_env uninstall-scope-dispatch)" +dispatcher="$base/dispatcher" +mkdir -p -- "$dispatcher" +cp -- "$here/installer.sh" "$dispatcher/installer.sh" +cp -R -- "$here/lib" "$dispatcher/lib" +cat > "$dispatcher/uninstall.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +: > "${ABLETON_TEST_UNINSTALL_HANDOFF:?}" +for argument do + printf '%s\n' "$argument" >> "$ABLETON_TEST_UNINSTALL_HANDOFF" +done +EOF +chmod +x "$dispatcher/uninstall.sh" + +assert_uninstall_scope_handoff() +{ + local name="$1" expected="$2" + shift 2 + rm -f -- "$base/handoff" + run_isolated "$base" env ABLETON_TEST_UNINSTALL_HANDOFF="$base/handoff" \ + bash "$dispatcher/installer.sh" uninstall "$@" \ + >"$base/$name.out" 2>"$base/$name.err" \ + || fail "$name was rejected before the uninstall handoff" + mapfile -t handed_off < "$base/handoff" + [ "${#handed_off[@]}" -eq 1 ] && [ "${handed_off[0]}" = "$expected" ] \ + || fail "$name did not hand off exactly one normalized scope flag" +} + +assert_uninstall_scope_handoff no-flag --keep-prefix +assert_uninstall_scope_handoff keep-prefix --keep-prefix --keep-prefix +assert_uninstall_scope_handoff prefix-only --prefix-only --prefix-only +assert_uninstall_scope_handoff delete-prefix --delete-prefix --delete-prefix + +assert_uninstall_scope_conflict() +{ + local name="$1" + shift + local status=0 + rm -f -- "$base/handoff" + run_isolated "$base" env ABLETON_TEST_UNINSTALL_HANDOFF="$base/handoff" \ + bash "$dispatcher/installer.sh" uninstall "$@" \ + >"$base/$name.out" 2>"$base/$name.err" || status=$? + [ "$status" -eq 2 ] || fail "$name did not return the parse-error status" + grep -qi 'conflict' "$base/$name.err" \ + || fail "$name was not identified as a scope conflict" + [ ! -e "$base/handoff" ] \ + || fail "$name reached uninstall.sh before its conflict was rejected" +} + +assert_uninstall_scope_conflict keep-prefix-prefix-only \ + --keep-prefix --prefix-only +assert_uninstall_scope_conflict keep-prefix-delete-prefix \ + --keep-prefix --delete-prefix +assert_uninstall_scope_conflict prefix-only-delete-prefix \ + --prefix-only --delete-prefix +ok "uninstall dispatch normalizes three exclusive scopes and rejects every pair" + base="$(new_env conflict)" if run_isolated "$base" bash "$here/installer.sh" install --no-link --link=off --skip-live-install >"$base/out" 2>"$base/err"; then fail "conflicting Link options fail" @@ -487,9 +746,11 @@ if run_isolated "$base" bash "$here/installer.sh" install --skip-live-install \ fail "duplicate immutable options are accepted" fi [ ! -e "$base/config" ] && [ ! -e "$base/state" ] || fail "duplicate-option failure mutates state" +legacy_status=0 run_isolated "$base" bash "$here/installer.sh" --prefix --uninstall --dry-run \ - >"$base/legacy.out" 2>"$base/legacy.err" -grep -q 'delete the Wine prefix, including Live and its authorisation' "$base/legacy.out" \ + >"$base/legacy.out" 2>"$base/legacy.err" || legacy_status=$? +[ "$legacy_status" -ne 2 ] \ + && ! grep -qi 'specified more than once' "$base/legacy.err" \ || fail "legacy uninstall prefix alias depends on argument order" ok "immutable options reject duplicates and legacy parsing is order-independent" @@ -500,58 +761,20 @@ run_isolated "$base" bash "$here/installer.sh" link disable \ || fail "an unrelated PipeASIO configuration path blocks Link disable" if find "$base/state/ableton-wine/transactions" -mindepth 1 -maxdepth 1 \ -name 'installer.*' -print -quit 2>/dev/null | grep -q .; then - fail "successful Link disable leaks its temporary recovery directory" + fail "Link disable created an optional transaction journal" fi [ -d "$base/config/pipeasio/config.ini" ] \ || fail "Link disable changed an unrelated PipeASIO configuration path" -grep -qF 'OK: Ableton Link is off' "$base/out" \ +grep -q '^│ ├─ Ableton Link is off\.' "$base/out" \ || fail "successful Link disable does not report its outcome" -ok "Link disable preserves unrelated configuration and retires temporary recovery data" - -base="$(new_env link-snapshot-cleanup)" -mkdir -p "$base/data/ableton-wine" "$base/state/ableton-wine" \ - "$base/fakebin" "$base/txn" -printf 'format=1\nowner=ableton-linux\n' > "$base/state/ableton-wine/.ableton-linux-state" -printf 'none\n' > "$base/state/ableton-wine/link-firewall" -printf '#!/bin/sh\nexit 0\n' > "$base/data/ableton-wine/ableton-linkctl" -printf '#!/bin/sh\nexit 0\n' > "$base/data/ableton-wine/ableton-linkd" -cat > "$base/fakebin/cp" <<'EOF' -#!/bin/sh -for argument do - case "$argument" in - */.link-snapshot.*/firewall|*/.link-enable.*/firewall) exit 9 ;; - esac -done -exec /usr/bin/cp "$@" -EOF -chmod +x "$base/data/ableton-wine/ableton-linkctl" \ - "$base/data/ableton-wine/ableton-linkd" "$base/fakebin/cp" -if run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - bash "$here/setup-link.sh" snapshot "$base/txn" \ - >"$base/snapshot.out" 2>"$base/snapshot.err"; then - fail "Link transaction snapshot succeeds after a copy failure" -fi -[ ! -e "$base/txn/link" ] \ - || fail "failed Link transaction snapshot publishes incomplete rollback state" -! find "$base/txn" -mindepth 1 -maxdepth 1 -name '.link-snapshot.*' \ - -print -quit | grep -q . \ - || fail "failed Link transaction snapshot leaks its staging directory" -if run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - bash "$here/setup-link.sh" enable --mode=session \ - >"$base/enable.out" 2>"$base/enable.err"; then - fail "Link enable succeeds after its recovery snapshot copy fails" -fi -! find "$base/state/ableton-wine" -mindepth 1 -maxdepth 1 \ - -name '.link-enable.*' -print -quit | grep -q . \ - || fail "failed Link enable leaks an unstarted recovery snapshot" -ok "failed Link snapshots never publish or retain incomplete recovery state" +ok "Link disable preserves unrelated configuration without an optional transaction" base="$(new_env runtime-plan)" run_isolated "$base" bash "$here/installer.sh" --runtime-only --runtime-root "$base/runtime" --dry-run >"$base/out" 2>"$base/err" -grep -q 'install or update Wine' "$base/out" || fail "runtime plan contains runtime" -! grep -q 'install or update launchers' "$base/out" || fail "runtime plan excludes integration" +grep -q 'Install or update Wine' "$base/out" || fail "runtime plan contains runtime" +! grep -q 'Install or update launchers' "$base/out" || fail "runtime plan excludes integration" ! grep -q 'Ableton Link support' "$base/out" || fail "runtime plan excludes Link" -grep -q 'update PipeASIO Settings shortcuts to match this Wine build' "$base/out" \ +grep -q 'Update the PipeASIO Settings shortcuts to match this Wine build' "$base/out" \ || fail "runtime plan describes PipeASIO launcher reconciliation" [ ! -e "$base/runtime" ] && [ ! -e "$base/config" ] && [ ! -e "$base/state" ] || fail "runtime plan mutates no target" ok "runtime-only plan contains only the runtime component" @@ -585,11 +808,11 @@ ok "runtime-only rejects unrelated Link options before mutation" base="$(new_env compat-plan)" run_isolated "$base" bash "$here/installer.sh" --no-launch --dry-run >"$base/out" 2>"$base/err" -grep -q 'install or update launchers' "$base/out" || fail "no-launch still means skip Live payload only" -grep -q 'set Ableton Link mode: off' "$base/out" || fail "no-launch defaults Link off" -! grep -Eq 'install or update Ableton Link support|use the configured Ableton Link helper' "$base/out" \ +grep -q 'Install or update launchers' "$base/out" || fail "no-launch still means skip Live payload only" +grep -q 'Set Ableton Link mode to off' "$base/out" || fail "no-launch defaults Link off" +! grep -Eq 'Install or update Ableton Link support|Use the configured Ableton Link helper' "$base/out" \ || fail "no-launch unexpectedly stages Link assets" -! grep -Eq 'write ownership-marked user unit|launchers start session daemon|enable/start the owned user unit' "$base/out" \ +! grep -Eq 'Write ownership-marked user unit|Launchers start session daemon|Enable/start the owned user unit' "$base/out" \ || fail "no-launch plans a Link service action" grep -q 'deprecated' "$base/err" || fail "compatibility warning is printed" ok "no-launch compatibility excludes Link assets and service enablement" @@ -607,7 +830,7 @@ link_mode=off linkd=$base/data/ableton-wine/ableton-linkd EOF run_isolated "$base" env ABLETON_DPI_MODE=preserve bash "$here/installer.sh" update --dry-run >"$base/out" 2>"$base/err" -grep -q 'set Ableton Link mode: off' "$base/out" || fail "update preserves Link opt-out" +grep -q 'Set Ableton Link mode to off' "$base/out" || fail "update preserves Link opt-out" ! grep -q 'Ableton Link support' "$base/out" || fail "opted-out update excludes Link assets" ok "update preserves the persistent Link opt-out" @@ -653,6 +876,16 @@ cat > "$base/run-quiesce" <> "\${ABLETON_TEST_LOG:?}" + [ "\${ABLETON_TEST_UNKILLABLE:-0}" -eq 1 ] || rm -f -- "\${ABLETON_TEST_BUSY:?}" + } +fi status=0 ableton_prefix_quiesce "\$@" || status=\$? printf 'rc=%s\n' "\$status" @@ -680,16 +913,15 @@ grep -qx -- "-w prefix=$base/prefix" "$base/log" || fail "the wait names the con ok "a quiet prefix is waited for once and never stopped" : > "$base/busy" -run_quiesce -grep -qx 'rc=0' "$base/out" || fail "a stopped straggler reports success" -[ "$(quiesce_calls)" = "-w -k -w " ] || fail "a held prefix is stopped and waited for again" -grep -q 'holding the prefix open' "$base/out" || fail "stopping the prefix is reported" -ok "a straggler holding the prefix open is stopped, not made fatal" +run_quiesce ABLETON_TEST_BACKGROUND=1 ABLETON_TEST_APPROVED_STOP=1 +grep -qx 'rc=0' "$base/out" || fail "an approved background-process stop reports success" +[ "$(quiesce_calls)" = "stop " ] || fail "background process detection waited before requesting a stop" +ok "a detected background program requests approval before waiting" : > "$base/busy" -run_quiesce ABLETON_TEST_UNKILLABLE=1 +run_quiesce ABLETON_TEST_APPROVED_STOP=1 ABLETON_TEST_UNKILLABLE=1 grep -qx 'rc=3' "$base/out" || fail "a surviving straggler is reported as still busy" -[ "$(quiesce_calls)" = "-w -k -w " ] || fail "a surviving straggler is not waited for a third time" +[ "$(quiesce_calls)" = "-w stop -w " ] || fail "a surviving straggler is not waited for a third time" ok "a straggler that survives the stop is reported, and the wait stays bounded" # setup-prefix.sh continues past a straggler and must stop on a wait that could not @@ -708,9 +940,9 @@ ok "only an expired wait stops the prefix; any other failure is passed back" # setup-prefix.sh waits on its staging prefix, which is not the configured one. : > "$base/busy" -run_quiesce "$base/runtime" "$base/staging-prefix" +run_quiesce ABLETON_TEST_APPROVED_STOP=1 "$base/runtime" "$base/staging-prefix" grep -qx 'rc=0' "$base/out" || fail "an explicitly named prefix is quiesced" -grep -qx -- "-k prefix=$base/staging-prefix" "$base/log" || fail "the stop names the prefix it was given" +[ "$(quiesce_calls)" = "-w stop -w " ] || fail "the named prefix is stopped after its wait expires" ! grep -q -- "prefix=$base/prefix\$" "$base/log" || fail "a named prefix displaces the configured one" ok "the runtime and prefix a caller names override the configured pair" @@ -721,7 +953,9 @@ kit="$base/kit" mkdir -p "$kit/scripts/lib" "$kit/bin" "$base/runtime/bin" cp -- "$here/installer.sh" "$kit/scripts/" cp -- "$here/lib/config.sh" "$here/lib/lifecycle.sh" "$here/lib/live-options.sh" \ - "$here/lib/manifest.sh" "$here/lib/pipeasio.sh" "$kit/scripts/lib/" + "$here/lib/manifest.sh" "$here/lib/pipeasio.sh" "$here/lib/preferences.sh" \ + "$here/lib/ui.sh" \ + "$kit/scripts/lib/" cat > "$kit/bin/pipewire-version-probe" <<'EOF' #!/bin/sh printf 'client=1.4.2\ndaemon=1.4.2\n' @@ -797,20 +1031,28 @@ run_payload_install() # 124 is timeout's TERM verdict: the wait ran out with a process still in the prefix. run_payload_install ABLETON_TEST_WAIT_EXIT=124 || fail "an expired payload wait fails the install" -grep -q 'OK: Ableton Live 12 is installed' "$base/out" \ +grep -q '^│ ├─ Ableton Live 12 is installed' "$base/out" \ || fail "an expired payload wait leaves the install incomplete" ! grep -q 'wineserver -k' "$base/calls.log" || fail "the payload step stops the promoted prefix" -grep -q 'The Live installer finished; a background program is still using its Wine prefix' "$base/out" \ +grep -q 'The Live installer finished; a background program is still using its ableton-linux prefix' "$base/out" \ || fail "an expired payload wait goes unreported" grep -qF -- "$base/runtime/bin/wineserver -k" "$base/out" \ || fail "the report withholds the command that ends the prefix" ! grep -q 'End every program' "$base/out" "$base/err" \ || fail "a non-interactive install offers to end the prefix" +[ "$(grep -c '^│ ┃ [1-7]/7 ╏ ' "$base/out")" -eq 7 ] \ + || fail "a checkout install renders its seven numbered steps" +grep -qF '│ ┃ 7/7 ╏ FINISH THE INSTALLATION ┃' "$base/out" \ + || fail "the final step is the finish step" +[ "$(grep -c '^│ └─ Step [1-7] Complete! ✓$' "$base/out")" -eq 7 ] \ + || fail "every step closes with its own number" +! grep -q '^│ ├─ .* 𐄂$\|^│ │ 𐄂 ' "$base/out" \ + || fail "a completed install shows a failed item" ok "an expired payload wait reports the straggler, stops nothing, and still completes" run_payload_install ABLETON_TEST_WAIT_EXIT=127 \ || fail "an unavailable post-install Wine check fails a validated Live install" -grep -q 'OK: Ableton Live 12 is installed' "$base/out" \ +grep -q '^│ ├─ Ableton Live 12 is installed' "$base/out" \ || fail "an unavailable post-install Wine check hides the completed install" grep -q 'Live is installed, but Wine could not be checked afterward (exit 127)' "$base/err" \ || fail "an unavailable post-install Wine check was not reported as advisory" @@ -863,7 +1105,7 @@ command() EOF run_payload_install BASH_ENV="$base/no-cabextract.bash" \ || fail "the coordinator required cabextract before reaching the selected prefix path" -grep -q 'OK: Ableton Live 12 is installed' "$base/out" \ +grep -q '^│ ├─ Ableton Live 12 is installed' "$base/out" \ || fail "the redundant cabextract preflight hid the completed install" ok "cabextract is checked only by the prefix extraction path that uses it" @@ -887,7 +1129,7 @@ EOF run_payload_install --payload "$base/Ableton Live 12 Suite.zip" \ BASH_ENV="$base/payload-progress-failure.bash" \ || fail "failed Live progress output rolled back a valid ZIP installation" -grep -q 'OK: Ableton Live 12 is installed' "$base/out" \ +grep -q '^│ ├─ Ableton Live 12 is installed' "$base/out" \ || fail "failed Live progress output hid the completed installation" if grep -Eq '(^|[[:space:]])(inflating:|extracting:|creating:)' "$base/out"; then fail "Live ZIP extraction printed its archive member list" @@ -912,7 +1154,8 @@ if ! grep -q 'prefix --preflight-rollback ' "$base/calls.log" \ fail "missing Live executable does not roll back the core install" fi [ ! -e "$base/prefix" ] || fail "missing Live executable leaves the fresh prefix promoted" -! grep -q '^OK:' "$base/out" || fail "missing Live executable reports success" +! grep -q '^│ ├─ Ableton Live 12 is installed' "$base/out" \ + || fail "missing Live executable reports success" ok "Wine exit 0 without the selected Live executable fails and rolls back" # Desktop integration status 3 is a repair request after the core install has @@ -920,7 +1163,7 @@ ok "Wine exit 0 without the selected Live executable fails and rolls back" live_exe="$base/prefix/drive_c/ProgramData/Ableton/Live 12 Suite/Program/Ableton Live 12 Suite.exe" run_payload_install ABLETON_TEST_INTEGRATION_EXIT=3 \ || fail "desktop integration retry status rejects a completed install" -grep -q 'OK: Ableton Live 12 is installed' "$base/out" \ +grep -q '^│ ├─ Ableton Live 12 is installed' "$base/out" \ || fail "desktop integration retry status hides the completed install" [ -x "$base/runtime/bin/wine" ] && [ -f "$base/prefix/system.reg" ] \ && [ -f "$live_exe" ] \ @@ -929,26 +1172,36 @@ grep -q 'OK: Ableton Live 12 is installed' "$base/out" \ || fail "desktop integration retry status rolls back the committed core" grep -qF 'Ableton Live 12 is installed, but some desktop files need another update. Run the installer again to retry them.' \ "$base/err" || fail "desktop integration retry status omits its repair warning" -grep -qF 'desktop shortcuts: retry needed' "$base/out" \ +grep -qF 'Desktop shortcuts need another try' "$base/out" \ || fail "desktop integration retry status is missing from the final summary" ok "desktop integration retry status preserves the core and names the remaining repair" -# A settings-path collision can appear after the core commit. Saving those -# settings remains retryable and the final summary must say so explicitly. +# A settings-path collision can appear after the core commit. Project settings +# use the same simple rule as every other optional destination: move the old +# object into this run's inert backup directory, then install the new file. +mkdir -p -- "$base/stale-backup/.overwrite-all" run_payload_install ABLETON_TEST_CONFIG_DIRECTORY=1 \ - || fail "saved-settings failure rejects a completed install" -grep -q 'OK: Ableton Live 12 is installed' "$base/out" \ - || fail "saved-settings failure hides the completed install" + ABLETON_PROJECT_BACKUP_DIR="$base/stale-backup" \ + ABLETON_PROJECT_BACKUP_STAMP=old \ + || fail "saved-settings overwrite rejects a completed install" +grep -q '^│ ├─ Ableton Live 12 is installed' "$base/out" \ + || fail "saved-settings overwrite hides the completed install" [ -x "$base/runtime/bin/wine" ] && [ -f "$base/prefix/system.reg" ] \ && [ -f "$live_exe" ] \ - || fail "saved-settings failure discards the committed core" + || fail "saved-settings overwrite discards the committed core" ! grep -q -- '--rollback' "$base/calls.log" \ - || fail "saved-settings failure rolls back the committed core" -grep -qF 'Ableton Live 12 is installed, but the installer could not save these settings. Run the installer again to retry.' \ - "$base/err" || fail "saved-settings failure omits its retry warning" -grep -qF 'saved settings: retry needed' "$base/out" \ - || fail "saved-settings failure is missing from the final summary" -ok "saved-settings failure preserves the core and appears in the final summary" + || fail "saved-settings overwrite rolls back the committed core" +[ -f "$base/config/ableton-wine/config" ] \ + || fail "saved-settings overwrite did not install the settings file" +grep -qF 'Saved settings are ready' "$base/out" \ + || fail "saved-settings overwrite is missing from the final summary" +settings_backup="$(find "$base/state/ableton-wine/backups" -type d \ + -name 'config.bak-*' -print -quit 2>/dev/null || true)" +[ -n "$settings_backup" ] \ + || fail "saved-settings overwrite did not retain the displaced directory" +! find "$base/stale-backup" -type f -print -quit | grep -q . \ + || fail "an old Overwrite all marker leaked into a new installer run" +ok "saved-settings collisions are backed up after the core commit" # Link setup runs after the core runtime, prefix, and Live installation commit. # Any Link failure keeps that usable core and reports a concrete retry command. @@ -976,7 +1229,7 @@ link_resume_command="sh $quoted_link_installer link enable --mode=always" for link_failure in 1 70 75; do run_failed_link_install "$link_failure" \ || fail "full install fails when Link exits $link_failure" - grep -q 'OK: Ableton Live 12 is installed' "$base/out" \ + grep -q '^│ ├─ Ableton Live 12 is installed' "$base/out" \ || fail "Link failure $link_failure hides the completed Live install" [ -x "$base/runtime/bin/wine" ] && [ -f "$base/prefix/system.reg" ] \ && [ -f "$live_exe" ] \ @@ -985,7 +1238,7 @@ for link_failure in 1 70 75; do || fail "Link failure $link_failure rolls back the completed core install" grep -qF "Ableton Live 12 is installed, but Link could not be set up. Retry with: $link_resume_command" "$base/err" \ || fail "Link failure $link_failure omits the retry command and completed outcome" - grep -qF 'Link: unchanged; setup can be retried' "$base/out" \ + grep -qF 'Link is unchanged; you can retry its setup' "$base/out" \ || fail "Link failure $link_failure summary does not report retryable Link state" done ok "all Link failure statuses preserve a completed install with retry wording" @@ -999,7 +1252,7 @@ for link_failure in 1 70 75; do bash "$kit/scripts/installer.sh" update --link=always --yes \ >"$base/out" 2>"$base/err" \ || fail "full update fails when Link exits $link_failure" - grep -q 'OK: Ableton is updated' "$base/out" \ + grep -q '^│ ├─ Ableton is updated' "$base/out" \ || fail "Link failure $link_failure hides the completed update" [ -x "$base/runtime/bin/wine" ] && [ -f "$base/prefix/system.reg" ] \ && [ -f "$live_exe" ] \ @@ -1008,7 +1261,7 @@ for link_failure in 1 70 75; do || fail "Link failure $link_failure rolls back the completed update" grep -qF "Ableton is updated, but Link could not be set up. Retry with: $link_resume_command" "$base/err" \ || fail "update Link failure $link_failure omits the retry command and outcome" - grep -qF 'Link: unchanged; setup can be retried' "$base/out" \ + grep -qF 'Link is unchanged; you can retry its setup' "$base/out" \ || fail "update Link failure $link_failure summary does not report retryable Link state" done ok "all Link failure statuses preserve a completed update with retry wording" @@ -1023,10 +1276,12 @@ for cleanup_failure in component prefix; do esac run_payload_install "${cleanup_env[@]}" \ || fail "$cleanup_failure cleanup failure rejects a completed install" - grep -q 'OK: Ableton Live 12 is installed' "$base/out" \ + grep -q '^│ ├─ Ableton Live 12 is installed' "$base/out" \ || fail "$cleanup_failure cleanup failure hides the completed install" grep -q 'old recovery files could not be removed' "$base/err" \ || fail "$cleanup_failure cleanup failure omits its warning" + grep -qF 'Remove ableton-linux prefix recovery files' "$base/out" \ + || fail "$cleanup_failure cleanup runs without a visible progress item" [ -x "$base/runtime/bin/wine" ] && [ -f "$base/prefix/system.reg" ] \ && [ -f "$live_exe" ] \ || fail "$cleanup_failure cleanup failure discards the committed core" @@ -1205,7 +1460,6 @@ env WINEPREFIX="$base/prefix" \ bash -c 'exec -a "C:\\learnheal.exe" "$1" 600' _ "$base/runtime/bin/wine-client" & helper_pid=$! sleep 0.3 -start_s=$SECONDS run_isolated "$base" env ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ ABLETON_LINK_MODE=off ABLETON_POWER=off ABLETON_RT=off ABLETON_THEME_MODE=preserve \ ABLETON_DPI_MODE=preserve ABLETON_UI_FONT=preserve ABLETON_TEXT_SMOOTHING=preserve \ @@ -1440,6 +1694,39 @@ grep -qxF 'old prefix' "$prefix/generation" \ || fail "prefix-only recovery did not restore the saved prefix" ok "prefix recovery needs no empty host-file journal" +# Canonical encoding cannot make malformed seed ownership trustworthy. Every +# production recovery entry point must reject it before touching the current +# settings, the journal, or the coordinator's active marker. +base="$(new_env malformed-pipeasio-seed-recovery)" +prefix="$base/prefix" +txn="$base/txn" +pipeasio="$base/config/pipeasio/config.ini" +active="$txn/active" +journal="$txn/pipeasio-seed.v1" +mkdir -p -- "$txn" "$(dirname "$pipeasio")" +printf '[pipeasio]\nbuffer_size = 512\n' > "$pipeasio" +printf 'active recovery evidence\n' > "$active" +path_b64="$(printf '%s' "$pipeasio" | base64 | tr -d '\n')" +token_b64="$(printf '%s' 'file|garbage' | base64 | tr -d '\n')" +printf 'format=1\npath_b64=%s\ntoken_b64=%s\ntemp_b64=\n' \ + "$path_b64" "$token_b64" > "$journal" +journal_before="$(lifecycle_object_snapshot "$journal")" +pipeasio_before="$(lifecycle_object_snapshot "$pipeasio")" +active_before="$(lifecycle_object_snapshot "$active")" +for recovery_operation in --preflight-rollback --rollback --preflight-commit --commit; do + if run_isolated "$base" env ABLETON_WINEPREFIX="$prefix" \ + bash "$here/setup-prefix.sh" "$recovery_operation" "$txn" \ + >"$base/${recovery_operation#--}.out" \ + 2>"$base/${recovery_operation#--}.err"; then + fail "setup-prefix accepts malformed PipeASIO evidence for $recovery_operation" + fi + [ "$(lifecycle_object_snapshot "$journal")" = "$journal_before" ] \ + && [ "$(lifecycle_object_snapshot "$pipeasio")" = "$pipeasio_before" ] \ + && [ "$(lifecycle_object_snapshot "$active")" = "$active_before" ] \ + || fail "setup-prefix $recovery_operation changes malformed recovery evidence or settings" +done +ok "every prefix recovery entry point preserves malformed PipeASIO evidence" + # The EXIT handler has enough state to decide whether a late failure happened # before or after the prefix became usable. Diagnostic output is not part of # that decision, and a ready prefix must not also be reported as rolled back. @@ -1463,6 +1750,7 @@ run_isolated "$base" bash -c ' transaction_dir="$2" final_prefix="$3" stage_prefix="$4" + ui_cleanup() { :; } prefix_transaction_rollback() { return 0; } trap prefix_cleanup EXIT false @@ -1480,6 +1768,7 @@ run_isolated "$base" bash -c ' transaction_dir="$2" final_prefix="$3" stage_prefix="$4" + ui_cleanup() { :; } prefix_transaction_rollback() { return 0; } trap prefix_cleanup EXIT false @@ -1497,33 +1786,155 @@ extract_setup_prefix_function write_default_pipeasio_settings \ "$base/pipeasio-defaults.function" extract_setup_prefix_function remove_extracted_live_installer \ "$base/live-cleanup.function" -extract_setup_prefix_function report_existing_prefix \ - "$base/prefix-report.function" -extract_setup_prefix_function prefix_note "$base/prefix-note.function" -extract_setup_prefix_function prefix_warn "$base/prefix-warn.function" -run_isolated "$base" bash -c ' +mkdir -p -- "$base/defaults-txn" "$base/selected-defaults-txn" \ + "$base/raced-defaults-txn" +run_isolated "$base" env XDG_CONFIG_HOME="$base/defaults" \ + ABLETON_TEST_SEED_ORDER_LOG="$base/defaults-order.log" bash -c ' set -euo pipefail . "$1" . "$2" - prefix_note "progress" > /dev/full - prefix_warn "warning" 2> /dev/full -' output "$base/prefix-note.function" "$base/prefix-warn.function" \ - || fail "closed output made Wine setup progress fail" -run_isolated "$base" env XDG_CONFIG_HOME="$base/defaults" bash -c ' - set -euo pipefail - . "$1" + . "$3" + + eval "$(declare -f ableton_pipeasio_seed_record_publish \ + | sed "1s/^ableton_pipeasio_seed_record_publish/production_seed_record_publish/")" + eval "$(declare -f ableton_pipeasio_seed_record_promote \ + | sed "1s/^ableton_pipeasio_seed_record_promote/production_seed_record_promote/")" + seed_case=normal + inject_destination_race=0 + ableton_pipeasio_seed_record_publish() + { + local txn="$1" final="$2" token="$3" temp="$4" + [ ! -e "$final" ] && [ ! -L "$final" ] \ + && [ -f "$temp" ] && [ ! -L "$temp" ] \ + || return 81 + printf "%s-publish-before\n" "$seed_case" \ + >> "${ABLETON_TEST_SEED_ORDER_LOG:?}" + production_seed_record_publish "$@" || return + ableton_pipeasio_seed_record_load "$txn" \ + && [ "$ABLETON_PIPEASIO_SEED_PRESENT" -eq 1 ] \ + && [ "$ABLETON_PIPEASIO_SEED_PATH" = "$final" ] \ + && [ "$ABLETON_PIPEASIO_SEED_TOKEN" = "$token" ] \ + && [ "$ABLETON_PIPEASIO_SEED_TEMP" = "$temp" ] \ + && [ ! -e "$final" ] && [ -f "$temp" ] && [ ! -L "$temp" ] \ + || return 82 + printf "%s-publish-after\n" "$seed_case" \ + >> "${ABLETON_TEST_SEED_ORDER_LOG:?}" + if [ "$inject_destination_race" -eq 1 ]; then + cp -- "$temp" "$final.race" + mv -T -n -- "$final.race" "$final" + [ -f "$final" ] && [ ! -L "$final" ] \ + && [ "$(ableton_pipeasio_seed_identity_token "$final")" != "$token" ] \ + || return 83 + printf "%s-race-injected\n" "$seed_case" \ + >> "${ABLETON_TEST_SEED_ORDER_LOG:?}" + fi + } + ableton_pipeasio_seed_record_promote() + { + local txn="$1" final="$2" token="$3" + [ "$inject_destination_race" -eq 0 ] || return 84 + ableton_pipeasio_seed_record_load "$txn" \ + && [ "$ABLETON_PIPEASIO_SEED_PRESENT" -eq 1 ] \ + && [ "$ABLETON_PIPEASIO_SEED_TOKEN" = "$token" ] \ + && [ -n "$ABLETON_PIPEASIO_SEED_TEMP" ] \ + && [ ! -e "$ABLETON_PIPEASIO_SEED_TEMP" ] \ + && [ ! -L "$ABLETON_PIPEASIO_SEED_TEMP" ] \ + && [ -f "$final" ] && [ ! -L "$final" ] \ + && [ "$(ableton_pipeasio_seed_identity_token "$final")" = "$token" ] \ + || return 85 + printf "%s-promote-before\n" "$seed_case" \ + >> "${ABLETON_TEST_SEED_ORDER_LOG:?}" + production_seed_record_promote "$@" || return + ableton_pipeasio_seed_record_load "$txn" \ + && [ "$ABLETON_PIPEASIO_SEED_PRESENT" -eq 1 ] \ + && _ableton_pipeasio_full_token_valid "$ABLETON_PIPEASIO_SEED_TOKEN" \ + && [ -z "$ABLETON_PIPEASIO_SEED_TEMP" ] \ + && [ "$ABLETON_PIPEASIO_SEED_TOKEN" \ + = "$(ableton_preferences_object_token "$final")" ] \ + || return 86 + printf "%s-promote-after\n" "$seed_case" \ + >> "${ABLETON_TEST_SEED_ORDER_LOG:?}" + } + + transaction_dir="$4" + write_default_pipeasio_settings > /dev/full + [ -f "$XDG_CONFIG_HOME/pipeasio/config.ini" ] \ + && grep -qxF "buffer_size = 256" \ + "$XDG_CONFIG_HOME/pipeasio/config.ini" \ + && [ -f "$transaction_dir/pipeasio-seed.v1" ] + ableton_pipeasio_seed_record_commit "$transaction_dir" + + for selected_seed in 64 128 256 512 1024; do + seed_case="selected-$selected_seed" + inject_destination_race=0 + XDG_CONFIG_HOME="$5/$selected_seed" + export XDG_CONFIG_HOME + transaction_dir="$6/$selected_seed" + mkdir -p -- "$transaction_dir" + ABLETON_PIPEASIO_BUFFER_SEED="$selected_seed" + export ABLETON_PIPEASIO_BUFFER_SEED + write_default_pipeasio_settings > /dev/full + grep -qxF "buffer_size = $selected_seed" \ + "$XDG_CONFIG_HOME/pipeasio/config.ini" + ableton_pipeasio_seed_record_commit "$transaction_dir" + done + unset ABLETON_PIPEASIO_BUFFER_SEED + + seed_case=raced + inject_destination_race=1 + XDG_CONFIG_HOME="$7" + export XDG_CONFIG_HOME + transaction_dir="$8" write_default_pipeasio_settings > /dev/full -' defaults "$base/pipeasio-defaults.function" \ - || fail "closed output made default PipeASIO settings fail" + raced_cfg="$XDG_CONFIG_HOME/pipeasio/config.ini" + [ -f "$raced_cfg" ] && [ ! -L "$raced_cfg" ] \ + && [ ! -e "$transaction_dir/pipeasio-seed.v1" ] \ + && ! find "$(dirname "$raced_cfg")" -maxdepth 1 \ + -name ".config.ini.*" -print -quit | grep -q . + cmp -s -- "$9/pipeasio/config.ini" "$raced_cfg" + + mapfile -t observed_order < "${ABLETON_TEST_SEED_ORDER_LOG:?}" + expected_order=(normal-publish-before normal-publish-after + normal-promote-before normal-promote-after) + for selected_seed in 64 128 256 512 1024; do + expected_order+=("selected-$selected_seed-publish-before" + "selected-$selected_seed-publish-after" + "selected-$selected_seed-promote-before" + "selected-$selected_seed-promote-after") + done + expected_order+=(raced-publish-before raced-publish-after + raced-race-injected) + [ "${observed_order[*]}" = "${expected_order[*]}" ] +' defaults "$base/pipeasio-defaults.function" "$here/lib/ui.sh" \ + "$here/lib/preferences.sh" "$base/defaults-txn" \ + "$base/selected-defaults" "$base/selected-defaults-txn" \ + "$base/raced-defaults" "$base/raced-defaults-txn" "$base/defaults" \ + || fail "production PipeASIO writer violated publication ordering or no-clobber recovery" [ -f "$base/defaults/pipeasio/config.ini" ] \ || fail "closed output stopped default PipeASIO settings from being written" +[ ! -e "$base/defaults-txn/pipeasio-seed.v1" ] \ + || fail "successful default-settings fixture did not retire seed provenance" +for selected_seed in 64 128 256 512 1024; do + grep -qxF "buffer_size = $selected_seed" \ + "$base/selected-defaults/$selected_seed/pipeasio/config.ini" \ + || fail "production PipeASIO writer ignored selected seed $selected_seed" + [ ! -e "$base/selected-defaults-txn/$selected_seed/pipeasio-seed.v1" ] \ + || fail "selected seed $selected_seed retained committed provenance" +done +[ -f "$base/raced-defaults/pipeasio/config.ini" ] \ + && [ ! -e "$base/raced-defaults-txn/pipeasio-seed.v1" ] \ + || fail "real writer no-clobber recovery removed a raced destination or retained provenance" mkdir -p "$base/blocked" printf 'not a directory\n' > "$base/blocked/pipeasio" run_isolated "$base" env XDG_CONFIG_HOME="$base/blocked" bash -c ' set -euo pipefail . "$1" + . "$2" + . "$3" + transaction_dir="$4" write_default_pipeasio_settings -' defaults "$base/pipeasio-defaults.function" \ +' defaults "$base/pipeasio-defaults.function" "$here/lib/ui.sh" \ + "$here/lib/preferences.sh" "$base/blocked-txn" \ > "$base/defaults.out" 2> /dev/full \ || fail "an unwritable optional PipeASIO settings path failed prefix setup" mkdir -p "$base/unpack" "$base/fakebin" @@ -1560,8 +1971,8 @@ fi run_isolated "$base" bash -c ' set -euo pipefail . "$1" - report_existing_prefix "$2" > /dev/full -' report "$base/prefix-report.function" "$base/prefix" \ + ui_status p_using_existing_prefix "$2" > /dev/full +' report "$here/lib/ui.sh" "$base/prefix" \ || fail "closed output failed after a legacy prefix was adopted" ok "optional prefix reports cannot reverse completed or nonessential work" @@ -1581,8 +1992,8 @@ for detector in unavailable out-of-range; do dpi_mode=auto dpi_block=preserve dpi_family="" - prefix_note() { :; } - prefix_warn() { :; } + ui_item_begin() { :; }; ui_item_end() { :; }; ui_run() { shift; while [ "$1" != -- ]; do shift; done; shift; "$@"; } + ui_status() { :; }; ui_info() { :; }; ui_warn() { :; } current_dpi_block() { printf "custom\n"; } if [ "$2" = unavailable ]; then ableton_detect_scale_ex() { return 1; } @@ -1603,8 +2014,8 @@ for native_state in active unavailable; do dpi_mode=auto dpi_block=preserve dpi_family="" - prefix_note() { :; } - prefix_warn() { :; } + ui_item_begin() { :; }; ui_item_end() { :; }; ui_run() { shift; while [ "$1" != -- ]; do shift; done; shift; "$@"; } + ui_status() { :; }; ui_info() { :; }; ui_warn() { :; } current_dpi_block() { printf "custom\n"; } ableton_detect_scale_ex() { printf "1.33333 gnome\n"; } block_for_scale() { printf "fractional\n"; } @@ -1629,309 +2040,218 @@ if run_isolated "$base" bash -c ' dpi_mode=not-a-mode dpi_block=preserve dpi_family="" - prefix_note() { :; } - prefix_warn() { :; } + ui_item_begin() { :; }; ui_item_end() { :; }; ui_run() { shift; while [ "$1" != -- ]; do shift; done; shift; "$@"; } + ui_status() { :; }; ui_info() { :; }; ui_warn() { :; } . "$1" ' dpi "$base/dpi-selection.bash"; then fail "an explicitly invalid display mode was accepted" fi ok "fresh Wine setup falls back to 100% only for automatic display detection" -base="$(new_env uninstall-authoritative-icon)" -foreign_icon="$base/data/icons/hicolor/scalable/apps/live-suite.svg" -mkdir -p "$(dirname "$foreign_icon")" "$base/fakebin" -printf 'foreign icon\n' > "$foreign_icon" -cat > "$base/fakebin/systemctl" <<'EOF' +# Public integration publishes the exact successful outputs it owns. The +# low-level manual-backup path has its separate focused helper suite. +prepare_manifest_integration_fixture() +{ + local fixture="$1" + mkdir -p -- "$fixture/runtime/bin" \ + "$fixture/runtime/share/applications" \ + "$fixture/runtime/share/icons/hicolor/scalable/apps" \ + "$fixture/fakebin" + cat > "$fixture/runtime/bin/pipeasio-settings" <<'EOF' #!/bin/sh exit 0 EOF -chmod +x "$base/fakebin/systemctl" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/install.sh" --integration-only >"$base/install.out" 2>"$base/install.err" -grep -q '"$base/out" 2>"$base/err" -[ "$(cat "$foreign_icon")" = 'foreign icon' ] \ - || fail "uninstall did not restore the icon that preceded installation" -grep -qF "restored your earlier file at $foreign_icon" "$base/out" \ - || fail "uninstall did not report restoration of the earlier icon" -ok "canonical project icons are updated authoritatively and earlier files return on uninstall" - -base="$(new_env user-config)" -user_config="$base/config/pipeasio/config.ini" -mkdir -p "$(dirname "$user_config")" "$base/state/ableton-wine" "$base/fakebin" -printf 'format=1\nowner=ableton-linux\n' > "$base/state/ableton-wine/.ableton-linux-state" -printf 'seeded\n' > "$user_config" -printf 'config\t%s\t%s\n' "$user_config" "$(sha256sum "$user_config" | awk '{print $1}')" \ - > "$base/state/ableton-wine/install-manifest.tsv" -printf 'user buffer setting\n' > "$user_config" -printf '#!/bin/sh\nexit 0\n' > "$base/fakebin/systemctl" -chmod +x "$base/fakebin/systemctl" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/uninstall.sh" \ - --keep-prefix --yes >"$base/out" 2>"$base/err" -[ "$(cat "$user_config")" = 'user buffer setting' ] \ - || fail "uninstall removes user-modified seeded configuration" -grep -q 'kept user-modified configuration' "$base/err" \ - || fail "preserved user configuration is not reported" -ok "uninstall preserves and de-owns user-modified seeded configuration" - -base="$(new_env authoritative-support-file)" -run_isolated "$base" bash "$here/install.sh" --integration-only >"$base/first.out" 2>"$base/first.err" -printf '\n# user change\n' >> "$base/data/ableton-wine/detect-scale.sh" -run_isolated "$base" bash "$here/install.sh" --integration-only >"$base/out" 2>"$base/err" \ - || fail "integration could not repair a modified canonical support file" -cmp -s -- "$here/detect-scale.sh" "$base/data/ableton-wine/detect-scale.sh" \ - || fail "integration did not replace drift in a canonical support file" -ok "update repairs a modified canonical support file authoritatively" - -# install.sh installs the version stamp inside an "if !" condition, which -# suppresses set -e, so ableton_install_file must return non-zero for the -# refusal to stop the run. -base="$(new_env authoritative-version-stamp)" -run_isolated "$base" bash "$here/install.sh" --integration-only >"$base/first.out" 2>"$base/first.err" -printf 'tampered\n' > "$base/data/ableton-wine/VERSION" -run_isolated "$base" bash "$here/install.sh" --integration-only >"$base/out" 2>"$base/err" \ - || fail "integration could not repair a modified canonical version stamp" -cmp -s -- "$root/VERSION" "$base/data/ableton-wine/VERSION" \ - || fail "integration did not replace drift in the canonical version stamp" -ok "update repairs a modified canonical version stamp authoritatively" - -# Issues #211 and #251 showed that launcher checksum drift triggered rollback. -# Cover every primary launcher, including Max and the handler copies used by -# runtime repair. At update time, each launcher may be a regular file or a -# symlink. The update replaces either form and records the installed checksum. -base="$(new_env stale-launchers)" -mkdir -p "$base/home/.wine-ableton/drive_c/Program Files/Cycling '74/Max 9" -printf 'exe\n' > "$base/home/.wine-ableton/drive_c/Program Files/Cycling '74/Max 9/Max.exe" -run_isolated "$base" bash "$here/install.sh" --integration-only \ - >"$base/first.out" 2>"$base/first.err" -manifest="$base/state/ableton-wine/install-manifest.tsv" -launcher_paths=( - "$base/home/.local/bin/ableton-live" - "$base/data/applications/ableton-live.desktop" - "$base/data/ableton-wine/$ABLETON_PROTOCOL_DESKTOP_ID" - "$base/data/applications/$ABLETON_PROTOCOL_DESKTOP_ID" - "$base/data/ableton-wine/$ABLETON_AUZ_DESKTOP_ID" - "$base/data/applications/$ABLETON_AUZ_DESKTOP_ID" - "$base/home/.local/bin/max9" - "$base/data/applications/max9.desktop" - "$base/data/applications/wine-protocol-c74max.desktop" -) -launcher_copy="$base/home/ableton-live-before-update" -mv "${launcher_paths[0]}" "$launcher_copy" -ln -s "$launcher_copy" "${launcher_paths[0]}" -for launcher in "${launcher_paths[@]:1}"; do - printf '\n# local launcher drift\n' >> "$launcher" -done -for launcher in "${launcher_paths[@]}"; do - recorded="$(awk -F '\t' -v p="$launcher" '$2==p { print $3 }' "$manifest")" - current="$( - if [ -L "$launcher" ]; then - { printf 'symlink\0'; readlink -n -- "$launcher"; } | sha256sum | awk '{print $1}' - else - sha256sum "$launcher" | awk '{print $1}' - fi - )" - [ -n "$recorded" ] && [ "$recorded" != "$current" ] \ - || fail "launcher fixture requires different saved and current checksums: $launcher" -done -run_isolated "$base" bash "$here/install.sh" --integration-only \ - >"$base/out" 2>"$base/err" \ - || { sed -n '1,80p' "$base/err" >&2; fail "launcher checksum drift aborted integration"; } -for launcher in "${launcher_paths[@]}"; do - [ -f "$launcher" ] && [ ! -L "$launcher" ] \ - || fail "update did not install a regular launcher at $launcher" - ! grep -qF '# local launcher drift' "$launcher" \ - || fail "update left launcher drift in $launcher" - recorded="$(awk -F '\t' -v p="$launcher" '$1=="file" && $2==p { print $3 }' "$manifest")" - current="$(sha256sum "$launcher" | awk '{print $1}')" - [ "$recorded" = "$current" ] \ - || fail "update saved an incorrect launcher checksum for $launcher" -done -[ -L "${launcher_paths[0]}.bak" ] \ - && [ "$(readlink -- "${launcher_paths[0]}.bak")" = "$launcher_copy" ] \ - || fail "Live launcher backup is not the exact displaced symlink" -for launcher in "${launcher_paths[@]:1}"; do - grep -qF '# local launcher drift' "${launcher}.bak" \ - || fail "launcher backup does not contain the displaced file: ${launcher}.bak" + chmod +x "$fixture/runtime/bin/pipeasio-settings" + cat > "$fixture/runtime/share/applications/pipeasio-settings.desktop" <<'EOF' +[Desktop Entry] +Type=Application +Name=PipeASIO Settings +Exec=pipeasio-settings +Icon=pipeasio +EOF + printf '\n' \ + > "$fixture/runtime/share/icons/hicolor/scalable/apps/pipeasio.svg" + printf 'pipeasio-panel: built\n' \ + > "$fixture/runtime/ABLETON-WINE-BUILD-INFO.txt" + printf '#!/bin/sh\nexit 0\n' > "$fixture/external-linkd" + chmod +x "$fixture/external-linkd" + cat > "$fixture/fakebin/xdg-mime" <<'EOF' +#!/bin/sh +set -eu +[ "${1:-}" = default ] && [ "$#" -ge 3 ] || exit 2 +desktop="$2" +shift 2 +file="${XDG_CONFIG_HOME:?}/mimeapps.list" +mkdir -p -- "$(dirname "$file")" +[ -e "$file" ] || printf '[Default Applications]\n' > "$file" +for type do + grep -qF "$type=" "$file" || printf '%s=%s\n' "$type" "$desktop" >> "$file" done -run_isolated "$base" bash "$here/install.sh" --integration-only \ - >"$base/noop.out" 2>"$base/noop.err" \ - || fail "no-op launcher update failed" -[ -L "${launcher_paths[0]}.bak" ] \ - && [ "$(readlink -- "${launcher_paths[0]}.bak")" = "$launcher_copy" ] \ - || fail "no-op update replaced the prior Live launcher backup" -for launcher in "${launcher_paths[@]:1}"; do - grep -qF '# local launcher drift' "${launcher}.bak" \ - || fail "no-op update replaced the prior launcher backup: ${launcher}.bak" +EOF + printf '#!/bin/sh\nexit 0\n' > "$fixture/fakebin/update-mime-database" + printf '#!/bin/sh\nexit 0\n' > "$fixture/fakebin/update-desktop-database" + chmod +x "$fixture/fakebin/xdg-mime" \ + "$fixture/fakebin/update-mime-database" \ + "$fixture/fakebin/update-desktop-database" +} + +run_manifest_integration() +{ + local fixture="$1" + shift + run_isolated "$fixture" env PATH="$fixture/fakebin:$PATH" \ + ABLETON_WINE_ROOT="$fixture/runtime" \ + ABLETON_LINKD="$fixture/external-linkd" "$@" \ + bash "$here/install.sh" --integration-only --link-assets-only --yes +} + +validate_manifest_integration_state() +{ + local fixture="$1" + run_isolated "$fixture" env ABLETON_WINE_ROOT="$fixture/runtime" \ + ABLETON_LINKD="$fixture/external-linkd" bash -c ' + set -euo pipefail + . "$1/lib/config.sh" + . "$1/lib/manifest.sh" + ableton_config_init repair + ableton_validate_ownership_manifest + ableton_validate_prestate_store + ' _ "$here" +} + +assert_published_object() +{ + local manifest="$1" kind="$2" path="$3" digest="$4" description="$5" + local row + row="$(printf '%s\t%s\t%s' "$kind" "$path" "$digest")" + [ "$(grep -cFx -- "$row" "$manifest")" -eq 1 ] \ + || fail "$description is not published once with its exact digest" +} + +base="$(new_env manifest-integration)" +prepare_manifest_integration_fixture "$base" +unit="$base/config/systemd/user/ableton-linkd.service" +mimeapps="$base/config/mimeapps.list" +mkdir -p -- "$(dirname "$unit")" +printf 'foreign generated-unit destination\n' > "$unit" +cp -- "$unit" "$base/foreign-unit.before" +printf '[Default Applications]\ntext/plain=foreign.desktop\n' > "$mimeapps" +run_manifest_integration "$base" >"$base/out" 2>"$base/err" \ + || { sed -n '1,60p' "$base/err" >&2; fail "integration inventory was not published"; } +manifest="$base/state/ableton-wine/install-manifest.tsv" +prestate="$base/state/ableton-wine/install-prestate.tsv" +[ -f "$manifest" ] && [ ! -L "$manifest" ] \ + || fail "integration did not publish a regular ownership manifest" +validate_manifest_integration_state "$base" \ + || fail "integration published invalid manifest or prestate data" +[ "$(awk -F '\t' -v p="$base/runtime" -v n="$ABLETON_RUNTIME_NAME" \ + '$1=="runtime" { count++; if ($2==p && $3==n && NF==3) matching++ } \ + END { print count ":" matching+0 }' "$manifest")" = 1:1 ] \ + || fail "the installed-file list needs one runtime row for the selected runtime" + +project_file="$base/data/ableton-wine/lib/config.sh" +project_digest="$(sha256sum -- "$project_file" | awk '{print $1}')" +assert_published_object "$manifest" file "$project_file" "$project_digest" \ + "a successful project file" + +panel_link="$base/home/.local/bin/pipeasio-settings" +panel_target="$base/runtime/bin/pipeasio-settings" +panel_digest="$({ printf 'symlink\0'; printf '%s' "$panel_target"; } \ + | sha256sum | awk '{print $1}')" +[ -L "$panel_link" ] && [ "$(readlink -- "$panel_link")" = "$panel_target" ] \ + || fail "integration did not create the PipeASIO symlink fixture" +assert_published_object "$manifest" symlink "$panel_link" "$panel_digest" \ + "a successful project symlink" + +mime_digest="$(sha256sum -- "$mimeapps" | awk '{print $1}')" +unit_digest="$(sha256sum -- "$unit" | awk '{print $1}')" +assert_published_object "$manifest" config "$mimeapps" "$mime_digest" \ + "the MIME application configuration" +assert_published_object "$manifest" config "$unit" "$unit_digest" \ + "the generated systemd unit" + +unit_id="$(printf '%s' "$unit" | sha256sum | awk '{print $1}')" +unit_backup="$base/state/ableton-wine/install-prestate/$unit_id" +grep -qxF "$(printf 'present\t%s\t%s' "$unit" "$unit_backup")" "$prestate" \ + || fail "the first foreign systemd unit is not indexed as pre-install state" +cmp -s -- "$base/foreign-unit.before" "$unit_backup" \ + || fail "the saved first foreign systemd unit changed" +cp -- "$prestate" "$base/prestate.before" +cp -a -- "$unit_backup" "$base/unit-backup.before" +printf 'user edit after the first managed generation\n' > "$unit" +run_manifest_integration "$base" >"$base/update.out" 2>"$base/update.err" \ + || fail "a second integration publication failed" +if ! cmp -s -- "$base/prestate.before" "$prestate" \ + || ! cmp -s -- "$base/unit-backup.before" "$unit_backup"; then + fail "a later integration replaced the first saved foreign object" +fi +validate_manifest_integration_state "$base" \ + || fail "updated integration state is invalid" +ok "integration publishes exact file, config and symlink outputs and keeps first prestate" + +# One failed output is omitted while independent successes are still published; +# the public optional-status path makes that partial failure observable. +base="$(new_env manifest-output-failure)" +prepare_manifest_integration_fixture "$base" +cat > "$base/fakebin/cp" <<'EOF' +#!/bin/sh +for argument do + [ "$argument" != "${ABLETON_TEST_FAIL_SOURCE:?}" ] || exit 74 done -cmp -s -- "$here/ableton-live" "${launcher_paths[0]}" \ - || fail "update did not install the current Live launcher" -cmp -s -- "$here/max9" "$base/home/.local/bin/max9" \ - || fail "update did not install the current Max launcher" -grep -qF 'Ableton Live launcher for the patched Wine stack' "$launcher_copy" \ - || fail "replacing the launcher symlink changed its referent" -ok "update replaces every primary launcher after checksum drift" - -rollback_launcher="$base/data/applications/max9.desktop" -printf '\n# launcher before refresh failure\n' >> "$rollback_launcher" -printf 'previous adjacent backup\n' > "${rollback_launcher}.bak" -mkdir -p "$base/fakebin" -cat > "$base/fakebin/update-desktop-database" <<'EOF' +exec /usr/bin/cp "$@" +EOF +cat > "$base/fakebin/install" <<'EOF' #!/bin/sh -exit 1 +for argument do + [ "$argument" != "${ABLETON_TEST_FAIL_SOURCE:?}" ] || exit 74 +done +exec /usr/bin/install "$@" EOF -chmod +x "$base/fakebin/update-desktop-database" -run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - bash "$here/install.sh" --integration-only >"$base/failure.out" 2>"$base/failure.err" \ - || fail "desktop database refresh failure rejects installed shortcuts" -! grep -qF '# launcher before refresh failure' "$rollback_launcher" \ - || fail "desktop refresh failure left launcher drift in place" -grep -qxF 'previous adjacent backup' "${rollback_launcher}.bak" \ - || fail "desktop refresh failure replaced the existing saved launcher" -! grep -qF 'Some shortcuts or support files could not be updated.' "$base/failure.err" \ - || fail "desktop database refresh failure produced a retry warning" -ok "desktop database refresh failure is silent and does not roll back installed shortcuts" - -# When the custom data root matches the XDG applications root, one invocation -# reaches the handler twice. The second projection recognises the installed -# launcher from the first projection and preserves the original adjacent backup. -base="$(new_env overlapping-launcher-roots)" -overlap_apps="$base/data/applications" -overlap_launcher="$overlap_apps/$ABLETON_PROTOCOL_DESKTOP_ID" -mkdir -p "$overlap_apps" -printf '[Desktop Entry]\nName=Original overlapping handler\n' > "$overlap_launcher" -cp -- "$overlap_launcher" "$base/original-handler" -run_isolated "$base" env ABLETON_DATA_HOME="$overlap_apps" \ - bash "$here/install.sh" --integration-only >"$base/out" 2>"$base/err" \ - || { sed -n '1,80p' "$base/err" >&2; fail "overlapping launcher roots aborted integration"; } -cmp -s -- "$base/original-handler" "${overlap_launcher}.bak" \ - || fail "the second handler projection replaced the original adjacent backup" -grep -q '^Exec=.*/ableton-live %u$' "$overlap_launcher" \ - || fail "overlapping launcher roots did not install the canonical handler" -ok "duplicate launcher projections preserve the original adjacent backup" - -# A fresh install writes the desktop entry before Live exists. The first Live -# start adds the edition name, icon, and window class. The saved checksum still -# describes the original entry. The next update must replace the entry. -base="$(new_env stale-live-desktop)" -run_isolated "$base" bash "$here/install.sh" --integration-only \ - >"$base/first.out" 2>"$base/first.err" -live_desktop="$base/data/applications/ableton-live.desktop" +chmod +x "$base/fakebin/cp" "$base/fakebin/install" +partial_status=0 +run_manifest_integration "$base" \ + ABLETON_TEST_FAIL_SOURCE="$here/lib/lifecycle.sh" \ + ABLETON_INTERNAL_OPTIONAL_STATUS=1 \ + >"$base/out" 2>"$base/err" || partial_status=$? +[ "$partial_status" -eq 3 ] \ + || fail "a failed project output did not return the optional retry status" manifest="$base/state/ableton-wine/install-manifest.tsv" -! grep -q '^StartupWMClass=' "$live_desktop" \ - || fail "fresh integration guessed a Live window class before Live existed" -mkdir -p "$base/home/.wine-ableton/drive_c/ProgramData/Ableton/Live 12 Suite/Program" -printf 'exe\n' \ - > "$base/home/.wine-ableton/drive_c/ProgramData/Ableton/Live 12 Suite/Program/Ableton Live 12 Suite.exe" -sed -i 's/^Name=.*/Name=Ableton Live 12 Suite/' "$live_desktop" -printf 'StartupWMClass=ableton live 12 suite.exe\n' >> "$live_desktop" -recorded="$(awk -F '\t' -v p="$live_desktop" '$1=="file" && $2==p { print $3 }' "$manifest")" -current="$(sha256sum "$live_desktop" | awk '{print $1}')" -[ -n "$recorded" ] && [ "$recorded" != "$current" ] \ - || fail "launcher-updated desktop fixture requires different saved and current checksums" -run_isolated "$base" bash "$here/install.sh" --integration-only \ - >"$base/out" 2>"$base/err" \ - || { sed -n '1,40p' "$base/err" >&2; fail "update rejected the launcher-updated managed desktop entry"; } -grep -qxF 'Name=Ableton Live 12 Suite' "$live_desktop" \ - || fail "update left the old Live desktop name" -grep -qxF 'StartupWMClass=ableton live 12 suite.exe' "$live_desktop" \ - || fail "update left the old Live desktop window class" -[ "$(grep -c '^StartupWMClass=' "$live_desktop")" -eq 1 ] \ - || fail "updated Live desktop entry contains duplicate window classes" -wmclass_line="$(grep -n '^StartupWMClass=' "$live_desktop" | cut -d: -f1)" -mime_line="$(grep -n '^MimeType=' "$live_desktop" | cut -d: -f1)" -[ "$wmclass_line" -lt "$mime_line" ] \ - || fail "updated Live desktop entry does not use canonical template order" -recorded="$(awk -F '\t' -v p="$live_desktop" '$1=="file" && $2==p { print $3 }' "$manifest")" -current="$(sha256sum "$live_desktop" | awk '{print $1}')" -[ "$recorded" = "$current" ] \ - || fail "update did not refresh the Live desktop ownership digest" -grep -qF 'StartupWMClass=ableton live 12 suite.exe' "${live_desktop}.bak" \ - || fail "Live desktop backup does not contain the displaced entry" -ok "update refreshes a launcher-updated Live desktop entry" - -# Uninstall must accept the same launcher-updated entry. The entry still uses -# the project launcher. -base="$(new_env uninstall-launcher-updated-desktop)" -mkdir -p "$base/fakebin" -printf '#!/bin/sh\nexit 0\n' > "$base/fakebin/systemctl" -chmod +x "$base/fakebin/systemctl" -run_isolated "$base" bash "$here/install.sh" --integration-only \ - >"$base/first.out" 2>"$base/first.err" -live_desktop="$base/data/applications/ableton-live.desktop" -sed -i 's/^Name=.*/Name=Ableton Live 12 Suite/' "$live_desktop" -printf 'StartupWMClass=ableton live 12 suite.exe\n' >> "$live_desktop" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/uninstall.sh" \ - --keep-prefix --yes >"$base/out" 2>"$base/err" \ - || { sed -n '1,40p' "$base/err" >&2; fail "uninstall rejected the launcher-updated managed desktop entry"; } -[ ! -e "$live_desktop" ] || fail "uninstall kept the launcher-updated desktop entry" -grep -qF "removed $live_desktop" "$base/out" \ - || fail "uninstall omitted the desktop entry removal message" -ok "uninstall removes a launcher-updated managed Live desktop entry" - -# The allowance holds only while the Exec line routes through this project's -# launcher; one re-pointed at another program is hand-made and stays kept, -# even when a leftover comment still mentions the launcher path. -base="$(new_env uninstall-repointed-desktop)" -mkdir -p "$base/fakebin" -printf '#!/bin/sh\nexit 0\n' > "$base/fakebin/systemctl" -chmod +x "$base/fakebin/systemctl" -run_isolated "$base" bash "$here/install.sh" --integration-only \ - >"$base/first.out" 2>"$base/first.err" -live_desktop="$base/data/applications/ableton-live.desktop" -orig_exec="$(grep '^Exec=' "$live_desktop")" -sed -i 's|^Exec=.*|Exec=/usr/bin/other-app %f|' "$live_desktop" -printf '# was: %s\n' "$orig_exec" >> "$live_desktop" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/uninstall.sh" \ - --keep-prefix --yes >"$base/out" 2>"$base/err" \ - || fail "a preserved user-repointed desktop entry makes uninstall fail" -[ -e "$live_desktop" ] || fail "uninstall removed the re-pointed desktop entry" -grep -qF "kept an unrecognised or user-modified file at $live_desktop" "$base/err" \ - || fail "kept re-pointed desktop entry is not reported" -ok "uninstall warns and keeps a desktop entry re-pointed away from the launcher" - -# The transaction snapshots a launcher symlink before replacement and leaves -# its referent unchanged. -base="$(new_env symlinked-live-desktop)" -run_isolated "$base" bash "$here/install.sh" --integration-only \ - >"$base/first.out" 2>"$base/first.err" -live_desktop="$base/data/applications/ableton-live.desktop" -mv "$live_desktop" "$base/home/live-entry-copy.desktop" -ln -s "$base/home/live-entry-copy.desktop" "$live_desktop" -run_isolated "$base" bash "$here/install.sh" --integration-only \ - >"$base/out" 2>"$base/err" \ - || fail "symlinked Live desktop entry aborted integration" -[ -f "$live_desktop" ] && [ ! -L "$live_desktop" ] \ - || fail "update did not replace the symlinked Live desktop entry" -[ -f "$base/home/live-entry-copy.desktop" ] \ - || fail "replacing the desktop symlink changed its referent" -[ -L "${live_desktop}.bak" ] \ - && [ "$(readlink -- "${live_desktop}.bak")" = "$base/home/live-entry-copy.desktop" ] \ - || fail "Live desktop backup is not the displaced symlink" -ok "update replaces a symlinked managed Live desktop entry" - -base="$(new_env authoritative-link-binary)" -foreign_linkd="$base/data/ableton-wine/ableton-linkd" -mkdir -p "$(dirname "$foreign_linkd")" "$base/fakebin" -printf '#!/bin/sh\necho foreign\n' > "$foreign_linkd" -chmod +x "$foreign_linkd" -cat > "$base/fakebin/systemctl" <<'EOF' +validate_manifest_integration_state "$base" \ + || fail "a partial integration published invalid state" +successful_target="$base/data/ableton-wine/lib/config.sh" +failed_target="$base/data/ableton-wine/lib/lifecycle.sh" +grep -qF "$(printf 'file\t%s\t' "$successful_target")" "$manifest" \ + || fail "a successful output was lost after another output failed" +! grep -qF "$(printf '\t%s\t' "$failed_target")" "$manifest" \ + || fail "a failed output was recorded as installed" +ok "partial integration records only successful outputs and returns nonzero" + +# Publication itself is atomic: a failed final rename is nonzero and leaves the +# last validated generation byte-for-byte intact. +base="$(new_env manifest-publication-failure)" +prepare_manifest_integration_fixture "$base" +run_manifest_integration "$base" >"$base/first.out" 2>"$base/first.err" \ + || fail "manifest publication fixture did not complete its first generation" +manifest="$base/state/ableton-wine/install-manifest.tsv" +cp -- "$manifest" "$base/manifest.before" +cat > "$base/fakebin/mv" <<'EOF' #!/bin/sh -case "$*" in - *'show -p Version --value'*) echo 255 ;; - *'show -p FragmentPath --value ableton-linkd.service'*) : ;; - *'is-enabled --quiet ableton-linkd.service'*) exit 1 ;; - *'is-active --quiet ableton-linkd.service'*) exit 1 ;; - *) exit 0 ;; -esac +last="" +for argument do last="$argument"; done +[ "$last" != "${ABLETON_TEST_FAIL_MANIFEST:?}" ] || exit 73 +exec /usr/bin/mv "$@" EOF -chmod +x "$base/fakebin/systemctl" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/install.sh" --link-assets-only >"$base/install.out" 2>"$base/install.err" -grep -qF 'native Ableton Link session anchor' < <(strings "$foreign_linkd") \ - || fail "Link asset install did not replace the collision fixture" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/setup-link.sh" disable >"$base/out" 2>"$base/err" -[ ! -e "$foreign_linkd" ] && [ ! -L "$foreign_linkd" ] \ - || fail "Link disable kept the managed canonical Link binary" -ok "canonical Link assets are replaced authoritatively and removed on disable" +chmod +x "$base/fakebin/mv" +publication_status=0 +run_manifest_integration "$base" ABLETON_TEST_FAIL_MANIFEST="$manifest" \ + >"$base/out" 2>"$base/err" || publication_status=$? +[ "$publication_status" -ne 0 ] \ + || fail "a failed manifest publication was reported as success" +cmp -s -- "$base/manifest.before" "$manifest" \ + || fail "a failed manifest publication changed the live generation" +validate_manifest_integration_state "$base" \ + || fail "a failed publication left invalid live state" +ok "manifest publication failure is nonzero and preserves the prior valid generation" base="$(new_env uninstall-safety)" if run_isolated "$base" env ABLETON_WINEPREFIX=/ ABLETON_WINE_ROOT="$base/runtime" \ @@ -1953,346 +2273,7 @@ fi [ -f "$base/victim/system.reg" ] || fail "symlink rejection changed its target" ok "uninstall rejects a symlinked custom prefix before mutation" -base="$(new_env uninstall-link)" -mkdir -p "$base/data/ableton-wine/lib" "$base/config/ableton-wine" \ - "$base/state/ableton-wine" "$base/run/ableton-wine" "$base/runtime/bin" "$base/prefix" "$base/fakebin" -printf 'format=1\nowner=ableton-linux\n' > "$base/state/ableton-wine/.ableton-linux-state" -cp "$here/lib/config.sh" "$base/data/ableton-wine/lib/config.sh" -cp "$here/lib/lifecycle.sh" "$base/data/ableton-wine/lib/lifecycle.sh" -cp "$here/lib/live-options.sh" "$base/data/ableton-wine/lib/live-options.sh" -cp "$here/lib/manifest.sh" "$base/data/ableton-wine/lib/manifest.sh" -cp "$here/lib/pipeasio.sh" "$base/data/ableton-wine/lib/pipeasio.sh" -cp "$here/ableton-linkctl" "$base/data/ableton-wine/ableton-linkctl" -cp "$here/setup-link.sh" "$base/data/ableton-wine/setup-link.sh" -cp /bin/sleep "$base/data/ableton-wine/ableton-linkd" -chmod +x "$base/data/ableton-wine/ableton-linkctl" "$base/data/ableton-wine/setup-link.sh" \ - "$base/data/ableton-wine/ableton-linkd" -printf 'format=1\nname=wine-d2d1-nspa-11.13\n' > "$base/runtime/.ableton-linux-runtime" -cat > "$base/runtime/bin/wine" <<'EOF' -#!/bin/sh -case "$*" in - *'reg query HKCU\Software'*) exit 0 ;; - *'reg query'*) exit 1 ;; - *) exit 0 ;; -esac -EOF -printf '#!/bin/sh\nexit 0\n' > "$base/runtime/bin/wineserver" -chmod +x "$base/runtime/bin/wine" "$base/runtime/bin/wineserver" -printf 'registry\n' > "$base/prefix/system.reg" -printf 'format=1\nprefix=%s\n' "$base/prefix" > "$base/prefix/.ableton-linux-prefix" -cat > "$base/config/ableton-wine/config" < "$base/fakebin/systemctl" <<'EOF' -#!/bin/sh -exit 0 -EOF -chmod +x "$base/fakebin/systemctl" -for owned in "$base/data/ableton-wine/lib/config.sh" "$base/data/ableton-wine/lib/lifecycle.sh" \ - "$base/data/ableton-wine/lib/live-options.sh" "$base/data/ableton-wine/lib/manifest.sh" \ - "$base/data/ableton-wine/lib/pipeasio.sh" \ - "$base/data/ableton-wine/ableton-linkctl" "$base/data/ableton-wine/setup-link.sh" \ - "$base/data/ableton-wine/ableton-linkd"; do - printf 'file\t%s\t%s\n' "$owned" "$(sha256sum "$owned" | awk '{print $1}')" \ - >> "$base/state/ableton-wine/install-manifest.tsv" -done -"$base/data/ableton-wine/ableton-linkd" 60 & -link_pid=$! -printf '%s\n' "$link_pid" > "$base/run/ableton-wine/linkd.pid" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/installer.sh" uninstall \ - --runtime-root "$base/runtime" --prefix "$base/prefix" --keep-prefix --yes >"$base/out" 2>"$base/err" -wait "$link_pid" 2>/dev/null || true -kill -0 "$link_pid" 2>/dev/null && fail "uninstall leaves detached Link running" -[ ! -e "$base/runtime" ] && [ ! -e "$base/data/ableton-wine/ableton-linkd" ] \ - && [ -e "$base/prefix/system.reg" ] || fail "uninstall removes owned runtime/Link and keeps requested prefix" -ok "uninstall stops an exact-owned detached Link daemon before removing it" - -base="$(new_env link-firewall-rollback)" -mkdir -p "$base/data/ableton-wine" "$base/state/ableton-wine" "$base/fakebin" -printf 'format=1\nowner=ableton-linux\n' > "$base/state/ableton-wine/.ableton-linux-state" -printf '#!/bin/sh\nexit 0\n' > "$base/data/ableton-wine/ableton-linkctl" -printf '#!/bin/sh\nexit 0\n' > "$base/data/ableton-wine/ableton-linkd" -chmod +x "$base/data/ableton-wine/ableton-linkctl" "$base/data/ableton-wine/ableton-linkd" -printf 'none\n' > "$base/state/ableton-wine/link-firewall" -cat > "$base/fakebin/grep" <<'EOF' -#!/bin/sh -for argument do - [ "$argument" != /etc/ufw/ufw.conf ] || exit 0 -done -exec /usr/bin/grep "$@" -EOF -cat > "$base/fakebin/ufw" <<'EOF' -#!/bin/sh -case "$1" in - status) - [ "${ABLETON_TEST_UFW_STATUS_FAIL:-0}" -ne 1 ] || exit 9 - [ ! -e "${ABLETON_TEST_UFW:?}" ] || echo '20808/udp ALLOW Anywhere' ;; - allow) : > "${ABLETON_TEST_UFW:?}" ;; - delete) rm -f -- "${ABLETON_TEST_UFW:?}" ;; - *) exit 2 ;; -esac -EOF -cat > "$base/fakebin/sudo" <<'EOF' -#!/bin/sh -case "${1:-}" in - -n) - shift - [ "${1:-}" != -- ] || shift ;; - -S) - shift - if [ "${1:-}" = -p ]; then shift 2; fi - [ "${1:-}" != -- ] || shift - IFS= read -r password || exit 1 ;; -esac -exec "$@" -EOF -cat > "$base/fakebin/systemctl" <<'EOF' -#!/bin/sh -unit="${XDG_CONFIG_HOME:?}/systemd/user/ableton-linkd.service" -case "$*" in - *daemon-reload*) exit "${ABLETON_TEST_SYSTEMCTL_RELOAD_STATUS:-1}" ;; - *'show -p Version --value'*) echo 255 ;; - *'show -p FragmentPath --value ableton-linkd.service'*) - [ ! -f "$unit" ] || printf '%s\n' "$unit" ;; - *'is-enabled --quiet ableton-linkd.service'*) exit 1 ;; - *'is-active --quiet ableton-linkd.service'*) exit 1 ;; - *) exit 0 ;; -esac -EOF -chmod +x "$base/fakebin/grep" "$base/fakebin/ufw" "$base/fakebin/sudo" "$base/fakebin/systemctl" -if run_isolated "$base" env PATH="$base/fakebin:$PATH" ABLETON_TEST_UFW="$base/ufw-rule" \ - ABLETON_TEST_UFW_STATUS_FAIL=1 bash "$here/setup-link.sh" enable --mode=session \ - >"$base/query.out" 2>"$base/query.err"; then - fail "Link enable treats a failed privileged ufw query as an absent rule" -fi -[ ! -e "$base/ufw-rule" ] || fail "failed ufw inspection changed the firewall" -[ "$(cat "$base/state/ableton-wine/link-firewall")" = none ] \ - || fail "failed ufw inspection changed the ownership record" -if ! grep -qF 'could not inspect the active ufw rules' "$base/query.err"; then - sed -n '1,100p' "$base/query.err" >&2 - fail "failed ufw inspection does not explain the refusal" -fi -ok "Link enable refuses when it cannot inspect the active ufw rules" - -run_isolated "$base" env PATH="$base/fakebin:$PATH" ABLETON_TEST_UFW="$base/ufw-rule" \ - bash "$here/setup-link.sh" enable --mode=session >"$base/out" 2>"$base/err" \ - || fail "session Link depends on optional systemd registration" -[ -e "$base/ufw-rule" ] || fail "successful session Link lost its firewall rule" -[ "$(cat "$base/state/ableton-wine/link-firewall")" = ufw-added ] \ - || fail "successful session Link lost its firewall tracking" -grep -qF 'Link user service could not be refreshed yet' "$base/err" \ - || fail "optional systemd refresh warning did not explain the actual effect" -grep -qxF 'link_mode=session' "$base/config/ableton-wine/config" \ - || fail "optional systemd refresh prevented Link mode from being saved" -ok "session Link succeeds when only optional systemd registration fails" - -printf 'ufw-added\n' > "$base/state/ableton-wine/link-firewall" -rm -f -- "$base/ufw-rule" -run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - ABLETON_TEST_UFW="$base/ufw-rule" ABLETON_TEST_SYSTEMCTL_RELOAD_STATUS=0 \ - bash "$here/setup-link.sh" enable --mode=session \ - >"$base/reconcile.out" 2>"$base/reconcile.err" \ - || fail "Link enable does not repair a missing recorded ufw rule" -[ -e "$base/ufw-rule" ] \ - || fail "Link enable trusts stale ufw ownership without restoring the rule" -[ "$(cat "$base/state/ableton-wine/link-firewall")" = ufw-added ] \ - || fail "Link enable loses ownership while repairing its ufw rule" -grep -qF 'Restoring the missing ufw rule for Link' "$base/reconcile.out" \ - || fail "Link enable does not report stale firewall reconciliation" -ok "Link enable verifies and repairs its recorded firewall rule" - -# A failed enable/disable command may already have changed systemd state. Link -# recovery must restore enabled and active separately and return a dedicated -# status only after verifying them. This fake persists every systemctl change. -base="$(new_env link-service-recovery-contract)" -mkdir -p -- "$base/data/ableton-wine" "$base/state/ableton-wine" "$base/fakebin" \ - "$base/service-state" -printf 'format=1\nowner=ableton-linux\n' > "$base/state/ableton-wine/.ableton-linux-state" -printf '#!/bin/sh\nexit 0\n' > "$base/data/ableton-wine/ableton-linkctl" -printf '#!/bin/sh\nexit 0\n' > "$base/data/ableton-wine/ableton-linkd" -chmod +x "$base/data/ableton-wine/ableton-linkctl" "$base/data/ableton-wine/ableton-linkd" -cat > "$base/fakebin/systemctl" <<'EOF' -#!/usr/bin/env bash -set -eu -state="${ABLETON_TEST_SERVICE_STATE:?}" -unit="${XDG_CONFIG_HOME:?}/systemd/user/ableton-linkd.service" -case "$*" in - *'show -p Version --value'*) echo 255 ;; - *'show -p FragmentPath --value ableton-linkd.service'*) - [ ! -f "$unit" ] || printf '%s\n' "$unit" ;; - *'show -p ExecStart --value ableton-linkd.service'*) - printf '{ path=%s ; argv[]=%s ; }\n' \ - "${ABLETON_LINKD:?}" "${ABLETON_LINKD:?}" ;; - *'is-enabled --quiet ableton-linkd.service'*) [ -e "$state/enabled" ] ;; - *'is-active --quiet ableton-linkd.service'*) [ -e "$state/active" ] ;; - *'daemon-reload'*) : ;; - *'enable --now ableton-linkd.service'*) - [ ! -e "$state/fail-enable-before" ] || exit 88 - : > "$state/enabled" - if [ -e "$state/partial-enable" ]; then - : > "$state/refuse-disable" - exit 88 - fi - : > "$state/active" - if [ -e "$state/signal-enable" ]; then - timeout_parent="$PPID" - setup_parent="$(ps -o ppid= -p "$timeout_parent" | tr -d ' ')" - kill -TERM "$setup_parent" - fi - [ ! -e "$state/fail-enable-after" ] || exit 88 ;; - *'disable --now ableton-linkd.service'*) - [ ! -e "$state/refuse-disable" ] || exit 89 - rm -f -- "$state/enabled" "$state/active" ;; - *'enable ableton-linkd.service'*) : > "$state/enabled" ;; - *'disable ableton-linkd.service'*) rm -f -- "$state/enabled" ;; - *'start ableton-linkd.service'*) : > "$state/active" ;; - *'stop ableton-linkd.service'*) rm -f -- "$state/active" ;; - *) echo "unexpected systemctl invocation: $*" >&2; exit 97 ;; -esac -EOF -cat > "$base/fakebin/grep" <<'EOF' -#!/bin/sh -for argument do - [ "$argument" != /etc/ufw/ufw.conf ] || exit 1 -done -exec /usr/bin/grep "$@" -EOF -printf '#!/bin/sh\nexit 1\n' > "$base/fakebin/firewall-cmd" -printf '#!/bin/sh\nexec "$@"\n' > "$base/fakebin/sudo" -chmod +x "$base/fakebin/systemctl" "$base/fakebin/grep" \ - "$base/fakebin/firewall-cmd" "$base/fakebin/sudo" -run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - ABLETON_TEST_SERVICE_STATE="$base/service-state" \ - bash "$here/setup-link.sh" enable --mode=always >"$base/initial.out" 2>"$base/initial.err" \ - || { sed -n '1,80p' "$base/initial.err" >&2; fail "stateful Link fixture could not enable its initial service"; } -: > "$base/service-state/fail-enable-after" -status=0 -run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - ABLETON_TEST_SERVICE_STATE="$base/service-state" \ - bash "$here/setup-link.sh" enable --mode=always >"$base/command-error.out" 2>"$base/command-error.err" \ - || status=$? -[ "$status" -eq 0 ] || fail "a systemctl command error gated an already-achieved Link state (got $status)" -[ -e "$base/service-state/enabled" ] && [ -e "$base/service-state/active" ] \ - || fail "Link rejected an enable command whose requested state was achieved" -grep -qF 'OK: Ableton Link is enabled in the background' "$base/command-error.out" \ - || fail "Link did not report the achieved background state" -ok "Link accepts an achieved service state despite a command error" - -rm -f -- "$base/service-state/fail-enable-after" -: > "$base/service-state/fail-enable-before" -status=0 -run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - ABLETON_TEST_SERVICE_STATE="$base/service-state" \ - bash "$here/setup-link.sh" enable --mode=always >"$base/recovered.out" 2>"$base/recovered.err" \ - || status=$? -[ "$status" -eq 75 ] || fail "verified Link recovery did not return its dedicated status (got $status)" -[ -e "$base/service-state/enabled" ] && [ -e "$base/service-state/active" ] \ - || fail "Link recovery lost the prior enabled/active service state" -grep -qxF 'link_mode=always' "$base/config/ableton-wine/config" \ - || fail "Link recovery changed the last successfully saved setting" -! find "$base/state/ableton-wine" -mindepth 1 -maxdepth 1 -name '.link-enable.*' \ - -print -quit | grep -q . \ - || fail "verified Link recovery retained a completed local snapshot" -grep -qF 'previous firewall and service settings were restored' "$base/recovered.err" \ - || fail "verified Link recovery did not identify its outcome" -ok "Link enable failure restores the prior service state without gating on generated files" - -# Deliver TERM to setup-link after the fake enable command changes both flags. -rm -f -- "$base/service-state/fail-enable-before" -: > "$base/service-state/signal-enable" -status=0 -run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - ABLETON_TEST_SERVICE_STATE="$base/service-state" \ - bash "$here/setup-link.sh" enable --mode=always >"$base/signal.out" 2>"$base/signal.err" \ - || status=$? -[ "$status" -eq 75 ] || fail "signal recovery did not return verified-recovered status (got $status)" -[ -e "$base/service-state/enabled" ] && [ -e "$base/service-state/active" ] \ - || fail "signal recovery lost the prior service state" -ok "TERM during service mutation completes verified Link recovery" - -# A failure while quiescing the partially changed service is incomplete and -# must retain durable evidence rather than returning the recovered status. -rm -f -- "$base/service-state/signal-enable" -: > "$base/service-state/partial-enable" -status=0 -run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - ABLETON_TEST_SERVICE_STATE="$base/service-state" \ - bash "$here/setup-link.sh" enable --mode=always >"$base/incomplete.out" 2>"$base/incomplete.err" \ - || status=$? -[ "$status" -eq 70 ] || fail "incomplete Link recovery did not return its dedicated status (got $status)" -recovery_snapshot="$(find "$base/state/ableton-wine" -mindepth 1 -maxdepth 1 \ - -type d -name '.link-enable.*' -print -quit)" -[ -n "$recovery_snapshot" ] && [ -f "$recovery_snapshot/FAILURE" ] \ - || fail "incomplete Link recovery discarded its durable failure snapshot" -grep -qxF 'restoration_complete=no' "$recovery_snapshot/FAILURE" \ - || fail "incomplete Link recovery did not record its classification" -ok "incomplete Link service recovery is distinct and keeps authoritative evidence" - -for legacy_args in '' ' --linger 0'; do - case "$legacy_args" in - '') legacy_name=initial ;; - *) legacy_name=session ;; - esac - base="$(new_env "link-legacy-unit-$legacy_name")" - unit="$base/config/systemd/user/ableton-linkd.service" - mkdir -p "$(dirname "$unit")" "$base/data/ableton-wine" "$base/fakebin" - printf '#!/bin/sh\nexit 0\n' > "$base/data/ableton-wine/ableton-linkctl" - printf '#!/bin/sh\nexit 0\n' > "$base/data/ableton-wine/ableton-linkd" - chmod +x "$base/data/ableton-wine/ableton-linkctl" "$base/data/ableton-wine/ableton-linkd" - cat > "$unit" < "$base/fakebin/systemctl" <<'EOF' -#!/bin/sh -unit="${XDG_CONFIG_HOME:?}/systemd/user/ableton-linkd.service" -case "$*" in - *'show -p Version --value'*) echo 255 ;; - *'show -p FragmentPath --value ableton-linkd.service'*) printf '%s\n' "$unit" ;; - *'is-enabled --quiet ableton-linkd.service'*) exit 1 ;; - *'is-active --quiet ableton-linkd.service'*) exit 1 ;; - *) exit 0 ;; -esac -EOF - cat > "$base/fakebin/grep" <<'EOF' -#!/bin/sh -for argument do - [ "$argument" != /etc/ufw/ufw.conf ] || exit 1 -done -exec /usr/bin/grep "$@" -EOF - printf '#!/bin/sh\nexit 1\n' > "$base/fakebin/firewall-cmd" - printf '#!/bin/sh\nexec "$@"\n' > "$base/fakebin/sudo" - chmod +x "$base/fakebin/systemctl" "$base/fakebin/grep" \ - "$base/fakebin/firewall-cmd" "$base/fakebin/sudo" - if ! run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - bash "$here/setup-link.sh" enable --mode=session >"$base/out" 2>"$base/err"; then - sed -n '1,80p' "$base/err" >&2 - fail "Link setup cannot adopt the $legacy_name legacy unit" - fi - grep -qxF 'X-AbletonLinuxOwned=true' "$unit" \ - || fail "Link setup does not adopt the $legacy_name legacy unit" - grep -qxF "ExecStart=\"$base/data/ableton-wine/ableton-linkd\" --linger 0" "$unit" \ - || fail "Link setup does not replace the $legacy_name legacy unit" -done -ok "Link setup adopts both exact legacy unit definitions" - -base="$(new_env link-modified-legacy-unit)" +base="$(new_env link-unit-overwrite)" unit="$base/config/systemd/user/ableton-linkd.service" mkdir -p "$(dirname "$unit")" "$base/data/ableton-wine" "$base/fakebin" printf '#!/bin/sh\nexit 0\n' > "$base/data/ableton-wine/ableton-linkctl" @@ -2335,45 +2316,14 @@ printf '#!/bin/sh\nexec "$@"\n' > "$base/fakebin/sudo" chmod +x "$base/fakebin/systemctl" "$base/fakebin/grep" \ "$base/fakebin/firewall-cmd" "$base/fakebin/sudo" run_isolated "$base" env PATH="$base/fakebin:$PATH" \ + ABLETON_PROJECT_ASSUME_YES=1 \ bash "$here/setup-link.sh" enable --mode=session >"$base/out" 2>"$base/err" \ - || fail "a modified generated Link unit blocks repair" + || fail "Overwrite did not replace the existing Link unit" grep -qxF 'X-AbletonLinuxOwned=true' "$unit" \ - || fail "Link setup did not replace the modified generated unit" + || fail "Link setup did not install its fixed unit destination" ! grep -qF 'Environment=FOREIGN_SETTING=1' "$unit" \ - || fail "Link setup retained foreign bytes at its canonical unit path" -ok "Link setup overwrites its generated unit authoritatively" - -base="$(new_env link-unit-ownership)" -unit="$base/config/systemd/user/ableton-linkd.service" -link_binary="$base/data/ableton-wine/link%d" -mkdir -p "$(dirname "$unit")" "$(dirname "$link_binary")" "$base/fakebin" -printf '#!/bin/sh\nexit 0\n' > "$link_binary" -chmod +x "$link_binary" -printf '[Service]\nExecStart=/usr/bin/foreign-linkd\n' > "$unit" -cat > "$base/fakebin/systemctl" <<'EOF' -#!/bin/sh -printf '%s\n' "$*" >> "${ABLETON_TEST_SYSTEMCTL:?}" -exit 0 -EOF -chmod +x "$base/fakebin/systemctl" -if run_isolated "$base" env PATH="$base/fakebin:$PATH" ABLETON_LINK_MODE=always \ - ABLETON_LINKD="$link_binary" ABLETON_TEST_SYSTEMCTL="$base/systemctl.log" \ - bash "$here/ableton-linkctl" start >"$base/out" 2>"$base/err"; then - fail "Link controller starts a foreign canonical systemd unit" -fi -[ ! -e "$base/systemctl.log" ] || fail "foreign Link unit reaches systemctl" -cat > "$unit" <"$base/owned.out" 2>"$base/owned.err" -grep -q -- '--user start ableton-linkd.service' "$base/systemctl.log" \ - || fail "exact owned Link unit is not started" -ok "Link controller starts only the exact ownership-marked unit" + || fail "Link setup retained the overwritten unit bytes" +ok "Link setup overwrites its fixed unit destination after approval" # Direct controller mutations participate in the same lifecycle lock as the # installer. A child already inside that transaction may reuse the descriptor, @@ -2423,35 +2373,9 @@ kill -0 "$link_controller_pid" 2>/dev/null \ || fail "unlocked Link stop retained its PID record" ok "Link controller serializes direct mutations without leaking the installer lock to its daemon" -base="$(new_env legacy-ownership)" -foreign_desktop="$base/data/applications/ableton-live.desktop" -mkdir -p "$(dirname "$foreign_desktop")" "$base/fakebin" -printf '[Desktop Entry]\nName=Foreign application\n' > "$foreign_desktop" -printf '#!/bin/sh\nexit 0\n' > "$base/fakebin/systemctl" -chmod +x "$base/fakebin/systemctl" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/uninstall.sh" \ - --keep-prefix --yes >"$base/out" 2>"$base/err" \ - || fail "preserving a foreign manifest-free desktop entry makes uninstall fail" -[ -f "$foreign_desktop" ] || fail "legacy uninstall removes an unrecognised canonical desktop file" -grep -qF "kept an unrecognised or modified file at $foreign_desktop" "$base/err" \ - || fail "legacy ownership refusal is not reported" -# This legacy fixture had no installer settings before uninstall. Defaults are -# enough for a retry, so optional cleanup must not invent a configuration file. -# If a future path does retain one, it still has to be a valid installer file. -legacy_config="$base/config/ableton-wine/config" -if [ -e "$legacy_config" ] || [ -L "$legacy_config" ]; then - ableton_managed_config_valid "$legacy_config" \ - || fail "partial legacy uninstall retained invalid installer settings" -fi -grep -Eq 'Retry: .*uninstall\.sh --keep-prefix --yes' "$base/err" \ - || fail "legacy ownership rejection does not say how to finish the uninstall" -ok "legacy uninstall warns and retains unrecognised canonical files" - -# Uninstall used to restore the MIME defaults after deleting its own desktop -# entries. xdg-mime reports no default once the entry file is gone, so the -# check that clears the line never matched, and the line stayed behind naming a -# file that no longer existed. -base="$(new_env mime-restore)" +# Current installs save the complete MIME settings file before replacement. +# Uninstall restores that file after it removes the project desktop entries. +base="$(new_env mime-cleanup)" mkdir -p "$base/data/applications" "$base/fakebin" printf '#!/bin/sh\nexit 0\n' > "$base/fakebin/systemctl" chmod +x "$base/fakebin/systemctl" @@ -2477,146 +2401,39 @@ grep -qxF 'x-scheme-handler/ableton=third-party.desktop' "$base/config/mimeapps. || fail "test fixture did not record an explicit pre-install default" grep -qxF 'text/plain=third-party.desktop' "$base/config/mimeapps.list" \ || fail "test fixture did not record its unrelated MIME default" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/install.sh" --integration-only \ +run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/install.sh" --integration-only --yes \ >"$base/install.out" 2>"$base/install.err" \ || { sed -n '1,40p' "$base/install.err" >&2; fail "integration install failed before MIME restoration"; } grep -qxF "x-scheme-handler/ableton=$ABLETON_PROTOCOL_DESKTOP_ID" "$base/config/mimeapps.list" \ || fail "integration did not take over the scheme handler it registers" +mkdir -p -- "$base/config/ableton-wine" +cat > "$base/config/ableton-wine/config" <"$base/out" 2>"$base/err" \ || { sed -n '1,40p' "$base/err" >&2; fail "uninstall failed after integration"; } +[ ! -e "$base/data/applications/$ABLETON_PROTOCOL_DESKTOP_ID" ] \ + && [ ! -e "$base/data/applications/$ABLETON_AUZ_DESKTOP_ID" ] \ + || fail "uninstall retained a fixed project desktop entry" if grep -Eq "=($ABLETON_PROTOCOL_DESKTOP_ID|$ABLETON_AUZ_DESKTOP_ID|ableton-live\.desktop|max9\.desktop|wine-protocol-c74max\.desktop)\$" \ "$base/config/mimeapps.list"; then fail "uninstall leaves a MIME default naming an entry it removed" fi -! grep -q '^x-scheme-handler/ableton=' "$base/config/mimeapps.list" \ - || fail "uninstall reconstructed an unknowable previous scheme default" +grep -qxF 'x-scheme-handler/ableton=third-party.desktop' "$base/config/mimeapps.list" \ + || fail "uninstall did not restore the pre-install scheme default" ! grep -q '^application/x-wine-extension-auz=' "$base/config/mimeapps.list" \ || fail "uninstall writes out a default the user never set explicitly" grep -qxF 'text/plain=third-party.desktop' "$base/config/mimeapps.list" \ || fail "uninstall changed an unrelated MIME default" -ok "uninstall clears project MIME defaults and preserves unrelated entries" - -# The canonical Live entry and its adjacent backup are installer-managed paths, -# but either may contain an unrelated file before installation. Updates may -# replace both while active; uninstall must return both originals exactly. -base="$(new_env authoritative-live-entry)" -mkdir -p "$base/data/applications" "$base/fakebin" -printf '#!/bin/sh\nexit 0\n' > "$base/fakebin/systemctl" -chmod +x "$base/fakebin/systemctl" -printf '[Desktop Entry]\nType=Application\nName=Foreign Live\nExec=/usr/bin/true %%f\n' \ - > "$base/data/applications/ableton-live.desktop" -cp "$base/data/applications/ableton-live.desktop" "$base/foreign-live.before" -printf 'personal adjacent backup\n' > "$base/data/applications/ableton-live.desktop.bak" -chmod 600 "$base/data/applications/ableton-live.desktop.bak" -cp "$base/data/applications/ableton-live.desktop.bak" "$base/foreign-live-backup.before" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/install.sh" --integration-only \ - >"$base/out" 2>"$base/err" \ - || { sed -n '1,40p' "$base/err" >&2; fail "integration install failed with a foreign Live entry"; } -grep -qxF 'Name=Ableton Live' "$base/data/applications/ableton-live.desktop" \ - || fail "integration did not replace a foreign Live desktop entry" -grep -Eq '^application/x-ableton-live-(set|clip|pack)=ableton-live\.desktop$' \ - "$base/config/mimeapps.list" \ - || fail "integration did not assign Live file types to its installed entry" -cmp -s "$base/foreign-live.before" "$base/data/applications/ableton-live.desktop.bak" \ - || fail "integration did not create ableton-live.desktop.bak" -# Force another launcher generation. The first install's saved originals must -# remain authoritative instead of being replaced by this intermediate state. -printf '\n# later generated-entry drift\n' >> "$base/data/applications/ableton-live.desktop" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/install.sh" --integration-only \ - >"$base/update.out" 2>"$base/update.err" \ - || { sed -n '1,40p' "$base/update.err" >&2; fail "launcher update failed after preserving both collisions"; } -grep -qxF 'Name=Ableton Live' "$base/data/applications/ableton-live.desktop" \ - || fail "launcher update did not repair the generated Live entry" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/uninstall.sh" \ - --keep-prefix --yes >"$base/uninstall.out" 2>"$base/uninstall.err" \ - || { sed -n '1,40p' "$base/uninstall.err" >&2; fail "uninstall failed after replacing a foreign Live entry"; } -cmp -s "$base/foreign-live.before" "$base/data/applications/ableton-live.desktop" \ - || fail "uninstall did not restore the foreign Live desktop entry" -cmp -s "$base/foreign-live-backup.before" "$base/data/applications/ableton-live.desktop.bak" \ - || fail "uninstall did not restore the pre-existing adjacent backup" -ok "launcher updates preserve and uninstall restores both pre-existing desktop files" - -base="$(new_env authoritative-dangling-launcher-backup)" -dangling_launcher="$base/data/applications/ableton-live.desktop" -dangling_backup="${dangling_launcher}.bak" -dangling_target=personal-missing-backup-target -mkdir -p "$(dirname "$dangling_launcher")" "$base/fakebin" -printf '#!/bin/sh\nexit 0\n' > "$base/fakebin/systemctl" -chmod +x "$base/fakebin/systemctl" -printf '[Desktop Entry]\nType=Application\nName=Foreign Live\nExec=/usr/bin/true %%f\n' \ - > "$dangling_launcher" -cp "$dangling_launcher" "$base/foreign-live.before" -ln -s "$dangling_target" "$dangling_backup" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/install.sh" --integration-only \ - >"$base/install.out" 2>"$base/install.err" \ - || fail "integration install failed with a dangling adjacent backup" -grep -qxF 'Name=Foreign Live' "$dangling_backup" \ - || fail "integration did not replace the dangling backup with the displaced launcher" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/uninstall.sh" \ - --keep-prefix --yes >"$base/uninstall.out" 2>"$base/uninstall.err" \ - || { sed -n '1,40p' "$base/uninstall.err" >&2; fail "uninstall failed after replacing a dangling launcher backup"; } -cmp -s "$base/foreign-live.before" "$dangling_launcher" \ - || fail "uninstall did not restore the launcher displaced before a dangling collision" -[ -L "$dangling_backup" ] && [ "$(readlink -- "$dangling_backup")" = "$dangling_target" ] \ - || fail "uninstall did not restore the pre-existing dangling adjacent backup" -ok "launcher replacement preserves and restores a dangling adjacent backup" - -base="$(new_env identical-foreign-launcher)" -identical_launcher="$base/home/.local/bin/ableton-live" -mkdir -p "$(dirname "$identical_launcher")" "$base/fakebin" -install -m 700 "$here/ableton-live" "$identical_launcher" -printf '#!/bin/sh\nexit 0\n' > "$base/fakebin/systemctl" -chmod +x "$base/fakebin/systemctl" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/install.sh" --integration-only \ - >"$base/install.out" 2>"$base/install.err" \ - || fail "byte-identical pre-existing launcher blocked integration" -cmp -s "$here/ableton-live" "${identical_launcher}.bak" \ - && [ "$(stat -c '%a' "${identical_launcher}.bak")" = 700 ] \ - || fail "byte-identical pre-existing launcher was not backed up with its metadata" -[ "$(stat -c '%a' "$identical_launcher")" = 755 ] \ - || fail "integration did not install the canonical launcher mode" -run_isolated "$base" env PATH="$base/fakebin:$PATH" bash "$here/uninstall.sh" \ - --keep-prefix --yes >"$base/uninstall.out" 2>"$base/uninstall.err" \ - || fail "uninstall failed after replacing a byte-identical launcher" -[ ! -e "$identical_launcher" ] && [ ! -L "$identical_launcher" ] \ - || fail "uninstall retained the legacy-shaped installed launcher" -[ ! -e "${identical_launcher}.bak" ] && [ ! -L "${identical_launcher}.bak" ] \ - || fail "uninstall retained an installer-created adjacent backup" -ok "byte-identical pre-existing launchers receive managed adjacent backups" - -base="$(new_env launcher-directory-collision)" -directory_launcher="$base/data/applications/ableton-live.desktop" -mkdir -p "$directory_launcher" -run_isolated "$base" bash "$here/install.sh" --integration-only \ - >"$base/out" 2>"$base/err" \ - || fail "one launcher directory collision blocked the rest of integration" -[ -d "$directory_launcher" ] && [ ! -L "$directory_launcher" ] \ - || fail "launcher directory refusal changed the collision" -[ ! -e "${directory_launcher}.bak" ] && [ ! -L "${directory_launcher}.bak" ] \ - || fail "launcher directory refusal created an adjacent backup" -grep -qF "launcher path points to a directory: $directory_launcher" "$base/err" \ - || fail "launcher directory refusal did not report the actual cause" -grep -qF 'Some shortcuts or support files could not be updated. Run the installer again to retry them.' \ - "$base/err" || fail "launcher directory refusal omitted the plain retry warning" -ok "launcher directory collisions are preserved and warn without blocking other repairs" - -base="$(new_env launcher-fifo-collision)" -fifo_launcher="$base/home/.local/bin/ableton-live" -mkdir -p "$(dirname "$fifo_launcher")" -mkfifo "$fifo_launcher" -run_isolated "$base" bash "$here/install.sh" --integration-only \ - >"$base/out" 2>"$base/err" \ - || fail "one launcher FIFO collision blocked the rest of integration" -[ -p "$fifo_launcher" ] \ - || fail "launcher FIFO refusal changed the collision" -[ ! -e "${fifo_launcher}.bak" ] && [ ! -L "${fifo_launcher}.bak" ] \ - || fail "launcher FIFO refusal created an adjacent backup" -grep -qF "launcher path is not a regular file or symlink: $fifo_launcher" "$base/err" \ - || fail "launcher FIFO refusal did not report the actual path" -grep -qF 'Some shortcuts or support files could not be updated. Run the installer again to retry them.' \ - "$base/err" || fail "launcher FIFO refusal omitted the plain retry warning" -ok "launcher special-file collisions are preserved and warn without blocking other repairs" +ok "uninstall restores pre-install MIME defaults and preserves unrelated entries" # Session Link mode writes the unit for later but never runs it, so it must # not need a user manager that answers. Only always-on policy does. @@ -2650,13 +2467,14 @@ run_isolated "$base" env PATH="$base/fakebin:$PATH" \ [ -f "$base/config/systemd/user/ableton-linkd.service" ] \ || fail "session Link enable skipped the unit it writes for a later session" if run_isolated "$base" env PATH="$base/fakebin:$PATH" \ + ABLETON_PROJECT_ASSUME_YES=1 \ bash "$here/setup-link.sh" enable --mode=always >"$base/always.out" 2>"$base/always.err"; then fail "always-on Link enable succeeded with no systemd user manager" fi ok "session Link mode does not depend on a reachable systemd user manager" -# setup-link.sh sources the ownership helper, so a Link-only install has to ship -# it: TROUBLESHOOTING tells people to run the installed copy. +# setup-link.sh sources the shared project-file helper, so a Link-only install +# has to ship it: TROUBLESHOOTING tells people to run the installed copy. base="$(new_env link-assets-runnable)" run_isolated "$base" bash "$here/install.sh" --link-assets-only \ >"$base/out" 2>"$base/err" \ @@ -2664,7 +2482,7 @@ run_isolated "$base" bash "$here/install.sh" --link-assets-only \ run_isolated "$base" bash "$base/data/ableton-wine/setup-link.sh" status \ >"$base/status.out" 2>"$base/status.err" \ || { sed -n '1,20p' "$base/status.err" >&2; fail "the installed setup-link.sh cannot run"; } -grep -q '^mode:' "$base/status.out" || fail "installed Link status reported no mode" +grep -q 'mode:' "$base/status.out" || fail "installed Link status reported no mode" ok "a link-only install ships every library its setup-link.sh needs" # Status only reads. It has to answer while a lifecycle command holds the @@ -2677,7 +2495,7 @@ run_isolated "$base" bash -c ' flock -n "$held" || exit 9 bash "$1" status' locked "$here/setup-link.sh" >"$base/out" 2>"$base/err" \ || { sed -n '1,20p' "$base/err" >&2; fail "Link status fails while the installation lock is held"; } -grep -q '^mode:' "$base/out" || fail "locked Link status reported no mode" +grep -q 'mode:' "$base/out" || fail "locked Link status reported no mode" ok "Link status answers while the installation lock is held" # A missing prefix or runtime names itself. Both used to arrive as an audio diff --git a/scripts/test-installer-ui.sh b/scripts/test-installer-ui.sh new file mode 100755 index 00000000..12a567a4 --- /dev/null +++ b/scripts/test-installer-ui.sh @@ -0,0 +1,958 @@ +#!/usr/bin/env bash +# Renderer checks for scripts/lib/ui.sh: the installer's nested tree, its +# dictionary, the live └─/├─ flip, wrapping, prompts, footer, and the lints +# that keep every other script out of the rendering business. +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +ui_lib="$here/lib/ui.sh" +work="$(mktemp -d "${TMPDIR:-/tmp}/ableton-ui-test.XXXXXX")" +trap 'if [ "${ABLETON_KEEP_TEST_WORK:-0}" -eq 0 ]; then rm -rf -- "$work"; else printf "kept test work: %s\n" "$work" >&2; fi' EXIT +pass=0 +ok() { pass=$((pass + 1)); printf 'ok - %s\n' "$1"; } +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } + +command -v script >/dev/null 2>&1 || fail "script(1) from util-linux is available for the terminal checks" +[ -r "$ui_lib" ] || fail "scripts/lib/ui.sh exists" +mkdir -p "$work/home" +# A fixed environment: no inherited terminal, width, charset, or timeout. +run_static() +{ + env -i PATH="$PATH" HOME="$work/home" TMPDIR="$work" LANG=C.UTF-8 TERM=xterm \ + COLUMNS=80 ABLETON_UI_ACTION=install ABLETON_UI_KIT=1 \ + UI_LIB="$ui_lib" "$@" +} + +# A real pseudo-terminal. script(1) keeps running until its stdin closes, so +# the feed stays open and silent until the fixture's last line ($marker) +# appears in $out, then closes; keys, when given, are typed first. +# $out receives the raw byte stream the terminal received, escapes included. +done_marker='@@DONE@@' +feed() +{ + local out="$1" marker="$2" keys="${3:-}" i=0 + [ -z "$keys" ] || printf '%s' "$keys" + until grep -aq "$marker" "$out" 2>/dev/null; do + sleep 0.1 + i=$((i + 1)) + [ "$i" -lt 900 ] || break + done +} +run_pty_raw() +{ + local rows="$1" cols="$2" out="$3"; shift 3 + : > "$out" + env -i PATH="$PATH" HOME="$work/home" TMPDIR="$work" LANG=C.UTF-8 TERM="${PTY_TERM:-xterm}" \ + SHELL=/bin/bash ABLETON_UI_ACTION=install ABLETON_UI_KIT=1 ABLETON_UI_PROMPT_TIMEOUT=1 \ + FIXTURE_TTY=1 FIXTURE_SLEEP="${FIXTURE_SLEEP:-0}" \ + FIXTURE_ITEM_SLEEP="${FIXTURE_ITEM_SLEEP:-0}" \ + FIXTURE_DETAIL_SLEEP="${FIXTURE_DETAIL_SLEEP:-0}" UI_LIB="$ui_lib" \ + script -qfec "stty rows $rows cols $cols; $*; echo $done_marker" /dev/null \ + < <(feed "$out" "$done_marker") > "$out" 2>&1 || true +} + +# Terminal simulator: replays the few sequences emitted by ui.sh and counts +# cursor columns as Unicode code points without relying on host awk behavior. +cat > "$work/vt.py" <<'PY' +import re +import sys + +data = open(sys.argv[1], "rb").read().decode("utf-8", "replace") +visibility = re.compile(r"\x1b\[\?[0-9]+[hl]") +sgr = re.compile(r"\x1b\[[0-9;]*m") +cursor = re.compile(r"\x1b\[([0-9]*)([ABCK])") +screen = {} +row = column = 0 +rows = 1 +i = 0 + +while i < len(data): + char = data[i] + if char == "\r": + column = 0 + i += 1 + continue + if char == "\n": + row += 1 + column = 0 + rows = max(rows, row + 1) + i += 1 + continue + if char == "\x1b": + match = visibility.match(data, i) or sgr.match(data, i) + if match: + i = match.end() + continue + if data.startswith("\x1b]", i): + stop = data.find("\x1b\\", i + 2) + if stop >= 0: + i = stop + 2 + continue + match = cursor.match(data, i) + if match: + count = int(match.group(1) or 1) + command = match.group(2) + if command == "A": + row = max(0, row - count) + elif command == "B": + row += count + rows = max(rows, row + 1) + elif command == "C": + column += count + else: + line = screen.setdefault(row, []) + if match.group(1) == "2": + line.clear() + else: + del line[column:] + i = match.end() + continue + i += 1 + continue + line = screen.setdefault(row, []) + line.extend(" " for _ in range(column - len(line))) + if column == len(line): + line.append(char) + else: + line[column] = char + column += 1 + i += 1 + +for row in range(rows): + print("".join(screen.get(row, []))) +PY +vt() { LC_ALL=C.UTF-8 python3 "$work/vt.py" "$1" | grep -v "^$done_marker" | sed -e 's/[[:space:]]*$//' -e '${/^$/d}'; } + +# Cursor columns count Unicode code points, not UTF-8 bytes. This regression +# catches byte-oriented replay before the full PTY checks. +printf '│ X\r\033[3C│\n' > "$work/vt-unicode.raw" +vt "$work/vt-unicode.raw" > "$work/vt-unicode.screen" +grep -qxF '│ │' "$work/vt-unicode.screen" \ + || fail "the terminal simulator moves across Unicode code points" +ok "the terminal simulator counts Unicode glyphs as one column" + +frames="$(bash -c '. "$1"; printf "%s" "${UI_TEXT[g_spinner]}"' _ "$ui_lib" | tr -d ' ')" +[ -n "$frames" ] || fail "the dictionary defines the spinner frames" +has_frame() { grep -q "[$frames]" "$1"; } + +cat > "$work/fixture-golden.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +[ -z "${FIXTURE_TTY:-}" ] || { exec 7>&1; export ABLETON_UI_TTY_FD=7; } +. "${UI_LIB:?}" +ui_banner '2026.08.28.1' +ui_blank; ui_heading h_system; ui_blank +ui_row r_date '28/08/2026, 11:42:56 CEST' +ui_row r_deps "$(ui_text v_deps_ready 'bash timeout tar zstd sha256sum')" +ui_blank; ui_heading h_warnings; ui_blank +if [ -n "${FIXTURE_WARN:-}" ]; then ui_host_warning w_missing_command tar; else ui_note w_none; fi +ui_blank; ui_heading h_action; ui_blank +ui_menu_option m_update default +ui_menu_option m_reinstall +ui_menu_option m_remove +ui_menu_option m_quit +ui_blank +ui_step_begin s_validate +ui_item_begin i_copy +sleep "${FIXTURE_ITEM_SLEEP:-0}" +ui_status i_copy_done 482 +sleep "${FIXTURE_DETAIL_SLEEP:-0}" +ui_item_end ok +ui_item_begin i_check +ui_status i_check_done +ui_info w_home_small +ui_warn w_not_x86_64 +ui_item_end fail +ui_step_end fail +ui_step_begin s_runtime_install +ui_run i_extract -- sleep "${FIXTURE_SLEEP:-0}" +ui_question q_overwrite_title o q_overwrite_all q_keep q_abort +ui_step_end ok +ui_footer "$(ui_text label_update)" '2026.08.28.1' complete 109 0 0 \ + /home/theo/.local/opt/wine-d2d1-nspa-11.13 /home/theo/.wine-ableton +ui_tail complete '~/Downloads/ableton-linux-installer-x.log' +EOF +chmod +x "$work/fixture-golden.sh" + +# The golden is the template rendered for one fixed run. It is literal on +# purpose: a dictionary or layout edit must change this text deliberately. +# The live cursor leaves one space between the question prompt and input. +cat > "$work/golden-static.txt" <<'EOF' +╒══════════════════════════╤═════════════════╕ +│ ABLETON-LINUX INSTALLER ┊ v 2026.08.28.1 │ +├┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┴┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┤ +│ https://github.com/shibco/ableton-linux/ │ +╞════════════════════════════════════════════╛ +│ +│ System Check +│ +│ Date and time 28/08/2026, 11:42:56 CEST +│ Dependencies bash timeout tar zstd sha256sum (ready) +│ +│ Warnings +│ +│ No host warnings found. +│ +│ Ableton-Linux Installer Choice: +│ +│ > [U]pdate (or press Enter) +│ > [R]einstall +│ > Remo[v]e Ableton Linux +│ > [Q]uit +│ +├──┲━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ ┃ 2/8 ╏ CHECK THE HOST AND THE REQUEST ┃ +│ ┡━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +│ │ +│ ├─ Copy the embedded kit +│ │ > Copied 482 MiB. +│ │ ✓ Done. +│ │ +│ ├─ Check the embedded kit +│ │ > The kit is intact. +│ │ +│ │ 🛈 Less than 10 GiB is free for the Wine runtime, the ableton-linux +│ │ prefix, and Live. +│ │ +│ │ ⚠ Ableton Linux currently requires an x86_64 host. +│ │ 𐄂 Failed. +│ │ +│ └─ Step 2 Failed! 𐄂 +│ +├──┲━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ ┃ 3/8 ╏ INSTALL THE WINE RUNTIME ┃ +│ ┡━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +│ │ +│ ├─ Extract the embedded kit ✓ +│ │ +│ ├─ FILES FROM AN EARLIER INSTALLATION ALREADY EXIST +│ │ +│ │ > [O]verwrite all (Default) +│ │ > [K]eep originals +│ │ > [A]bort +│ │ +│ │ (Press Enter for default or wait 30 seconds) +│ │ Please choose [O/K/A]: +│ │ +│ └─ Step 3 Complete! ✓ +│ +╞══════════════════════════════════════════╤══════════╕ +│ Ableton-Linux Update v. 2026.08.28.1 │ Complete │ +├──────────────────────────────────────────┼──────────┤ +│ Time taken: │ 109 sec │ +├┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┼┈┈┈┈┈┈┈┈┈┈┤ +│ Warnings: │ 0 │ +├┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┼┈┈┈┈┈┈┈┈┈┈┤ +│ Errors: │ 0 │ +├┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┴┈┈┈┈┈┈┈┈┈┈┤ +│ runtime: /home/theo/.local/opt/wine-d2d1-nspa-11.13 │ +│ ableton-linux prefix: /home/theo/.wine-ableton │ +╘═════════════════════════════════════════════════════╛ + +Launch Ableton Live via your desktop applications launcher or in the terminal: + + > $ ableton-live + +Stay up to date: + https://bsky.app/profile/wires.sh + +Ableton on Linux Discord: + https://discord.gg/XD5EeZyP3 + +Need help? + https://github.com/shibco/ableton-linux + +Saved a log of this operation at + ~/Downloads/ableton-linux-installer-x.log +EOF +grep -q '^│ │ Please choose \[O/K/A\]:$' "$work/golden-static.txt" \ + || fail "the golden keeps the question prompt" + +# T1: static rendering is byte for byte the template. +run_static bash "$work/fixture-golden.sh" < /dev/null > "$work/static.out" 2> "$work/static.err" \ + || fail "the static golden fixture ran (stderr: $(head -c 300 "$work/static.err"))" +if ! cmp -s "$work/static.out" "$work/golden-static.txt"; then + diff -u "$work/golden-static.txt" "$work/static.out" | head -40 >&2 || true + fail "static rendering matches the template golden" +fi +FIXTURE_WARN=1 run_static env FIXTURE_WARN=1 bash "$work/fixture-golden.sh" < /dev/null > "$work/static-warn.out" 2>&1 \ + || fail "the host-warning fixture ran" +grep -qxF '│ ⚠ Required command is missing: tar.' "$work/static-warn.out" \ + || fail "a host warning is rendered on the trunk" +ok "static rendering matches the template golden byte for byte" + +# Every pair of adjacent box-drawing glyphs must agree about both the +# direction and weight of their shared stroke. This covers the light nested +# tree, the light/heavy step junctions, and the single/double banner and +# footer junctions. It catches a visually broken line even if the same wrong +# glyph was copied into the literal golden transcript. +python3 - "$work/static.out" <<'PY' || fail "every box-drawing junction connects with the matching weight" +import sys + +paths = { + "╒": {"R": 3, "D": 1}, "═": {"L": 3, "R": 3}, "╤": {"L": 3, "R": 3, "D": 1}, + "╕": {"L": 3, "D": 1}, "│": {"U": 1, "D": 1}, "┊": {"U": 1, "D": 1}, + "├": {"U": 1, "D": 1, "R": 1}, "┈": {"L": 1, "R": 1}, + "┴": {"U": 1, "L": 1, "R": 1}, "┤": {"U": 1, "D": 1, "L": 1}, + "╞": {"U": 1, "D": 1, "R": 3}, "╛": {"U": 1, "L": 3}, + "─": {"L": 1, "R": 1}, "┲": {"L": 1, "R": 2, "D": 2}, + "━": {"L": 2, "R": 2}, "┳": {"L": 2, "R": 2, "D": 2}, + "┓": {"L": 2, "D": 2}, "┃": {"U": 2, "D": 2}, "╏": {"U": 2, "D": 2}, + "┡": {"U": 2, "R": 2, "D": 1}, "┻": {"U": 2, "L": 2, "R": 2}, + "┛": {"U": 2, "L": 2}, "└": {"U": 1, "R": 1}, + "┼": {"U": 1, "D": 1, "L": 1, "R": 1}, "╘": {"U": 1, "R": 3}, +} +lines = open(sys.argv[1], encoding="utf-8").read().splitlines() +used = {char for line in lines for char in line if 0x2500 <= ord(char) <= 0x257F} +if unknown := used - paths.keys(): + raise SystemExit(f"unmapped box-drawing glyphs: {''.join(sorted(unknown))}") +for row, line in enumerate(lines): + for col, char in enumerate(line): + if char not in paths: + continue + for dx, dy, outgoing, incoming in ((1, 0, "R", "L"), (0, 1, "D", "U")): + if row + dy >= len(lines) or col + dx >= len(lines[row + dy]): + continue + neighbour = lines[row + dy][col + dx] + if neighbour not in paths: + continue + left = paths[char].get(outgoing) + right = paths[neighbour].get(incoming) + if left != right: + raise SystemExit( + f"broken junction at {row + 1}:{col + 1}: {char}{neighbour} ({left} != {right})" + ) +PY +ok "every adjacent box-drawing stroke has matching direction and weight" + +# T4: static output never animates or moves the cursor. +! LC_ALL=C grep -q $'\033\\[' "$work/static.out" || fail "static output contains no cursor sequences" +! LC_ALL=C grep -q $'\r' "$work/static.out" || fail "static output contains no carriage returns" +! has_frame "$work/static.out" || fail "static output contains no spinner frames" +run_static bash -c 'before="$(trap -p)"; . "$UI_LIB"; after="$(trap -p)"; [ "$before" = "$after" ]' \ + < /dev/null > "$work/traps.out" 2>&1 || fail "ui.sh preserves the inherited trap state" +[ ! -s "$work/traps.out" ] || fail "ui.sh installs no traps of its own" +! grep -qw 'tput\|flock' "$ui_lib" || fail "ui.sh uses neither tput nor flock" +ok "static output has no escapes, no frames, and ui.sh sets no traps" + +# T3: on a terminal the same fixture ends on the same screen, every settled +# item as ├─ with its mark on the title, and the raw stream proves the item +# was └─ first and its details were rewritten with it. The settled rewrite is +# immediate: no completed title or detail remains in the temporary last-child +# form while the caller moves on to the rest of the step. +FIXTURE_SLEEP=1.5 FIXTURE_ITEM_SLEEP=1.5 FIXTURE_DETAIL_SLEEP=1.5 \ + run_pty_raw 40 80 "$work/live.raw" bash "$work/fixture-golden.sh" +vt "$work/live.raw" > "$work/live.screen" +sed -e '/^│ │ ✓ Done\.$/d' -e '/^│ │ 𐄂 Failed\.$/d' \ + -e 's/^│ ├─ Copy the embedded kit$/│ ├─ Copy the embedded kit ✓/' \ + -e 's/^│ ├─ Check the embedded kit$/│ ├─ Check the embedded kit 𐄂/' \ + -e 's/wait 30 seconds/wait 1 seconds/' \ + "$work/golden-static.txt" | sed -e 's/[[:space:]]*$//' > "$work/golden-live.txt" +if ! cmp -s "$work/live.screen" "$work/golden-live.txt"; then + diff -u "$work/golden-live.txt" "$work/live.screen" | head -40 >&2 || true + fail "the live screen ends as the template golden" +fi +first_last="$(LC_ALL=C grep -abo $'└─ \033\[96mCopy the embedded kit' "$work/live.raw" | head -1 | cut -d: -f1)" +first_settled="$(LC_ALL=C grep -abo $'├─ \033\[36mCopy the embedded kit ✓' "$work/live.raw" | head -1 | cut -d: -f1)" +[ -n "$first_last" ] && [ -n "$first_settled" ] && [ "$first_last" -lt "$first_settled" ] \ + || fail "an item is drawn └─ first and rewritten to ├─ when it completes" +LC_ALL=C grep -q $'│ \033\[96m> Copied 482 MiB\.' "$work/live.raw" \ + || fail "details under the last item use a blank sub-trunk before the flip" +LC_ALL=C grep -q $'\033\\[2K│ ├─ \033\\[36mCopy the embedded kit ✓\033\\[0m' "$work/live.raw" \ + || fail "a completed title settles immediately and only its text is coloured" +LC_ALL=C grep -q $'\033\\[3C│' "$work/live.raw" \ + || fail "a completed detail gains its missing sub-trunk immediately" +has_frame "$work/live.raw" || fail "a running ui_run shows spinner frames" +[ "$(LC_ALL=C grep -o $'\r\033\\[2K│ └─ \033\\[96mCopy the embedded kit' "$work/live.raw" | wc -l)" -ge 2 ] \ + || fail "an ordinary active item shows spinner frames" +[ "$(LC_ALL=C grep -o $'\r\033\\[2K│ \033\\[96m' "$work/live.raw" | wc -l)" -ge 2 ] \ + || fail "the spinner remains active after an item prints status text" +[ "$(LC_ALL=C grep -o $'\r\033\\[2K│ └─ \033\\[96mExtract the embedded kit' "$work/live.raw" | wc -l)" -ge 2 ] \ + || fail "the spinner redraws the running title in place" +LC_ALL=C grep -q $'\033\\[96m' "$work/live.raw" || fail "active work uses the bright primary colour" +LC_ALL=C grep -q $'\033\\[36m' "$work/live.raw" || fail "completed work uses the darker history colour" +LC_ALL=C grep -q $'\033\\[91m' "$work/live.raw" || fail "failed work stays bright red" +LC_ALL=C grep -q $'\033\\[93m' "$work/live.raw" || fail "warnings and prompts stay bright yellow" +LC_ALL=C grep -Fq $'\033]8;;https://github.com/shibco/ableton-linux/\033\\https://github.com/shibco/ableton-linux/\033]8;;\033\\' \ + "$work/live.raw" || fail "displayed web addresses use terminal hyperlinks" +! LC_ALL=C grep -q $'\033\\[s\\|\0337\\|\033\\[u\\|\0338' "$work/live.raw" \ + || fail "the renderer never uses cursor save or restore" +LC_ALL=C grep -q $'\033\\[?25h' "$work/live.raw" || fail "the cursor is shown again after the spinner" +# Inspect every SGR span, including transient spinner and rewrite frames. A +# Unicode box-drawing code point inside a non-default foreground span means a +# structural line was coloured along with its text. +python3 - "$work/live.raw" <<'PY' || fail "box-drawing glyphs keep the terminal's default colour" +import re +import sys + +data = open(sys.argv[1], "rb").read().decode("utf-8", "replace") +colour = None +for token in re.split(r"(\x1b\[[0-9;]*m)", data): + match = re.fullmatch(r"\x1b\[([0-9;]*)m", token) + if match: + params = match.group(1).split(";") if match.group(1) else ["0"] + if "0" in params or "39" in params: + colour = None + for param in params: + if param in {"36", "91", "93", "96"}: + colour = param + continue + if colour and any(0x2500 <= ord(char) <= 0x257F for char in token): + raise SystemExit(1) +PY +PTY_TERM=dumb run_pty_raw 40 80 "$work/dumb.raw" bash "$work/fixture-golden.sh" +! LC_ALL=C grep -q $'\033\\[' "$work/dumb.raw" && ! has_frame "$work/dumb.raw" \ + || fail "TERM=dumb renders static even on a terminal" +ok "live rendering settles every branch and colours text without colouring its lines" + +# Regression for the reported prefix/scaling pair. Once the operation gets +# its check mark, the title becomes a branch and the detail gets the matching +# vertical sub-trunk. +cat > "$work/fixture-settled-detail.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +exec 7>&1 +export ABLETON_UI_TTY_FD=7 +. "${UI_LIB:?}" +ui_step_begin s_prefix_create +ui_item_begin p_copy_existing_prefix +ui_status p_scale_already_configured 1.33333 +ui_item_end ok +ui_step_end ok +EOF +run_pty_raw 40 80 "$work/settled-detail.raw" bash "$work/fixture-settled-detail.sh" +vt "$work/settled-detail.raw" > "$work/settled-detail.screen" +if ! grep -q '^│ ├─ Copy the existing ableton-linux prefix ✓$' "$work/settled-detail.screen" \ + || ! grep -q '^│ │ > Display scaling is already set for scale 1.33333$' "$work/settled-detail.screen"; then + fail "a completed prefix copy keeps the vertical line beside its scaling detail" +fi +ok "the reported prefix scaling detail keeps its box-drawing line" + +# T2 and C5: long lines wrap on a space at width-2 and continuation lines +# keep the tree; a long token is cut hard; a long title is cut to one line. +cat > "$work/fixture-wrap.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +[ -z "${FIXTURE_TTY:-}" ] || { exec 7>&1; export ABLETON_UI_TTY_FD=7; } +. "${UI_LIB:?}" +UI_TEXT[i_wrap_test]='%s' +ui_step_begin s_validate +ui_item_begin i_copy +ui_status i_wrap_test "$(printf 'word%02d ' $(seq 1 40))" +ui_status i_wrap_test "$(printf 'x%.0s' $(seq 1 120))" +ui_item_end ok +ui_item_begin i_wrap_test "$(printf 'title%02d ' $(seq 1 30))" +ui_item_end ok +ui_step_end ok +EOF +run_static bash "$work/fixture-wrap.sh" < /dev/null > "$work/wrap.out" 2>&1 || fail "the wrap fixture ran" +while IFS= read -r line; do + [ "${#line}" -le 78 ] || fail "every rendered line is at most 78 columns (got ${#line}: $line)" +done < "$work/wrap.out" +[ "$(grep -c '^│ │ word' "$work/wrap.out")" -ge 2 ] \ + || fail "wrapped status continuation lines keep the tree prefix and indent" +! grep -q '^│ │ > word.*word40' "$work/wrap.out" || fail "a long status is wrapped, not printed whole" +grep -q '^│ │ > x\{70\}' "$work/wrap.out" && grep -q '^│ │ x\{40,\}' "$work/wrap.out" \ + || fail "a token longer than the room is cut hard across lines" +grep -q '^│ ├─ title01 .*…$' "$work/wrap.out" || fail "a long title is cut to one line with an ellipsis" +[ "$(grep -c '^│ ├─ title01' "$work/wrap.out")" -eq 1 ] || fail "a cut title occupies one line" +run_pty_raw 40 50 "$work/narrow.raw" bash "$work/fixture-wrap.sh" +while IFS= read -r line; do + [ "${#line}" -le 58 ] || fail "a terminal narrower than 60 columns wraps at 58 (got ${#line})" +done < <(vt "$work/narrow.raw" | grep '^│ │') +ok "long lines wrap inside the tree at width-2" + +# T5: a timed question waits for its timeout on a silent terminal and not at +# all on a closed stdin; both return the default; piped keys and whole words +# are read even when stdin is not a terminal. +cat > "$work/fixture-question.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +[ -z "${FIXTURE_TTY:-}" ] || { exec 7>&1; export ABLETON_UI_TTY_FD=7; } +. "${UI_LIB:?}" +ui_step_begin s_validate +ui_question q_overwrite_title o q_overwrite_all q_keep q_abort +printf 'answer=%s\n' "$UI_ANSWER" > "${ANSWER_FILE:?}" +ui_step_end ok +EOF +start="$EPOCHREALTIME" +run_pty_raw 40 80 "$work/question.raw" env ANSWER_FILE="$work/answer-tty" bash "$work/fixture-question.sh" +awk -v a="$start" -v b="$EPOCHREALTIME" 'BEGIN { exit !(b - a >= 0.9 && b - a <= 6) }' \ + || fail "a silent terminal waits for the timeout" +grep -qx 'answer=o' "$work/answer-tty" || fail "the timeout returns the default answer" +question_hint_line="$(vt "$work/question.raw" | grep -n '^│ │ (Press Enter for default or wait 1 seconds)' | cut -d: -f1)" +question_prompt_line="$(vt "$work/question.raw" | grep -n '^│ │ Please choose \[O/K/A\]:' | cut -d: -f1)" +[ "$question_prompt_line" -eq $((question_hint_line + 1)) ] \ + || fail "the question hint is immediately above its prompt" +! has_frame "$work/question.raw" || fail "a question shows no spinner while it waits for input" +start="$EPOCHREALTIME" +run_static env ANSWER_FILE="$work/answer-eof" bash "$work/fixture-question.sh" < /dev/null > /dev/null 2>&1 \ + || fail "a question with closed stdin completes" +awk -v a="$start" -v b="$EPOCHREALTIME" 'BEGIN { exit !(b - a < 1) }' || fail "a closed stdin returns the default at once" +grep -qx 'answer=o' "$work/answer-eof" || fail "closed stdin returns the default answer" +printf 'k\n' | run_static env ANSWER_FILE="$work/answer-pipe" bash "$work/fixture-question.sh" > /dev/null 2>&1 \ + || fail "a piped answer is accepted" +grep -qx 'answer=k' "$work/answer-pipe" || fail "a piped answer is read even when stdin is not a terminal" +printf 'Keep\n' | run_static env ANSWER_FILE="$work/answer-word" bash "$work/fixture-question.sh" > /dev/null 2>&1 \ + || fail "a whole-word answer is accepted" +grep -qx 'answer=k' "$work/answer-word" || fail "a whole-word answer maps to its key letter" +printf 'maybe\nA\n' | run_static env ANSWER_FILE="$work/answer-retry" bash "$work/fixture-question.sh" > "$work/retry.out" 2>&1 \ + || fail "an unknown answer is asked again" +grep -qx 'answer=a' "$work/answer-retry" || fail "the answer after a retry is honoured" +[ "$(grep -c 'Please choose \[O/K/A\]:' "$work/retry.out")" -eq 2 ] \ + || fail "an unknown answer repeats the prompt once" +ok "timed questions honour the timeout, EOF, piped keys, whole words, and retries" + +# T9: step numbers and names come from the table, in table order, for +# every action in the table. +cat > "$work/fixture-steps.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +. "${UI_LIB:?}" +for key in ${UI_STEPS[$ABLETON_UI_ACTION]}; do + # The .run header owns the prepare step; a checkout run starts after it. + [ "${ABLETON_UI_KIT:-0}" = 1 ] || [ "$key" != s_prepare ] || continue + printf 'expect %s\n' "$(ui_text "$key" | tr '[:lower:]' '[:upper:]')" >&2 + ui_step_begin "$key" + ui_step_end ok +done +EOF +mapfile -t actions < <(bash -c '. "$1"; printf "%s\n" "${!UI_STEPS[@]}"' _ "$ui_lib" | sort) +[ "${#actions[@]}" -ge 10 ] || fail "the step table covers every action (found ${#actions[@]})" +for action in "${actions[@]}"; do + run_static env ABLETON_UI_ACTION="$action" bash "$work/fixture-steps.sh" \ + < /dev/null > "$work/steps-$action.out" 2> "$work/steps-$action.names" \ + || fail "step fixture ran for $action" + mapfile -t names < <(sed -n 's/^expect //p' "$work/steps-$action.names") + total="${#names[@]}" + n=0 + while IFS= read -r line; do + case "$line" in + '│ ┃ '*) + n=$((n + 1)) + [ "$line" = "│ ┃ $n/$total ╏ ${names[n-1]} ┃" ] \ + || fail "$action step $n reads $n/$total with its table name (got: $line)" ;; + '│ └─ Step '*) + [ "$line" = "│ └─ Step $n Complete! ✓" ] || fail "$action closing $n matches its box (got: $line)" ;; + esac + done < "$work/steps-$action.out" + [ "$n" -eq "$total" ] || fail "$action renders every step in its table" +done +ABLETON_UI_KIT= run_static env -u ABLETON_UI_KIT ABLETON_UI_ACTION=install bash "$work/fixture-steps.sh" \ + < /dev/null > "$work/steps-checkout.out" 2>/dev/null || fail "step fixture ran from a checkout" +grep -q '^│ ┃ 1/7 ╏ CHECK THE HOST AND THE REQUEST ┃$' "$work/steps-checkout.out" \ + || fail "without the .run header the list starts at the validate step" +cat > "$work/fixture-wide.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +. "${UI_LIB:?}" +UI_STEPS[wide]='' +for i in $(seq 1 12); do UI_TEXT[s_w$i]="Step number $i"; UI_STEPS[wide]="${UI_STEPS[wide]} s_w$i"; done +export ABLETON_UI_ACTION=wide +for key in ${UI_STEPS[wide]}; do ui_step_begin "$key"; ui_step_end ok; done +EOF +run_static bash "$work/fixture-wide.sh" < /dev/null > "$work/steps-wide.out" 2>&1 || fail "wide step fixture ran" +top="$(grep -B1 '^│ ┃ 10/12 ╏' "$work/steps-wide.out" | head -1)" +mid="$(grep '^│ ┃ 10/12 ╏' "$work/steps-wide.out")" +bottom="$(grep -A1 '^│ ┃ 10/12 ╏' "$work/steps-wide.out" | tail -1)" +[ "${#top}" -eq "${#mid}" ] && [ "${#mid}" -eq "${#bottom}" ] \ + || fail "a two-digit step counter keeps the three box lines aligned" +[[ "$top" == '├──┲━━━━━━━┳'* ]] || fail "the counter cell widens with its text" +status=0 +run_static bash -c '. "$UI_LIB"; ui_step_begin s_no_such_step' < /dev/null > /dev/null 2>&1 || status=$? +[ "$status" -eq 70 ] || fail "a step key outside the table exits 70 (got $status)" +ok "step numbering follows the table for every action" + +# T10: footer variants and the width guard. +cat > "$work/fixture-footer.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +. "${UI_LIB:?}" +case "${1:?}" in + failed) + ui_footer "$(ui_text label_reinstall)" 2026.08.28.1 failed 7 2 1 '' '' + ui_tail failed '~/x.log' 'The Live installer failed or timed out (exit 1)' ;; + interrupted) ui_footer Update 2026.08.28.1 interrupted 3 0 0 /r /p; ui_tail interrupted '' ;; + cancelled) ui_footer Update 2026.08.28.1 cancelled 0 0 0 '' ''; ui_tail cancelled '~/x.log' ;; + long) + ui_footer Update 2026.08.28.1 complete 9 0 0 \ + /home/someone/with/a/very/long/path/to/.local/opt/wine-d2d1-nspa-11.13-rebuilt-2026 \ + /home/someone/.wine-ableton + ui_tail complete '~/x.log' ;; +esac +EOF +run_static bash "$work/fixture-footer.sh" failed < /dev/null > "$work/footer-failed.out" 2>&1 || fail "footer fixture ran" +grep -q '^│ Ableton-Linux Reinstall v. 2026.08.28.1 *│ Failed │$' "$work/footer-failed.out" \ + || fail "a failed run shows Failed in the status cell" +grep -q '^│ Warnings: *│ 2 │$' "$work/footer-failed.out" && grep -q '^│ Errors: *│ 1 │$' "$work/footer-failed.out" \ + || fail "the footer shows the warning and error counts" +! grep -q '^│ runtime:' "$work/footer-failed.out" || fail "unknown runtime and prefix rows are omitted" +grep -q '^Errors:$' "$work/footer-failed.out" \ + && grep -q '^ > The Live installer failed or timed out (exit 1)$' "$work/footer-failed.out" \ + || fail "a failed run lists its errors one per line" +grep -q 'https://github.com/shibco/ableton-linux/issues' "$work/footer-failed.out" \ + || fail "a failed run offers the issues link" +! grep -q 'ableton-live$' "$work/footer-failed.out" || fail "a failed run has no launch hint" +run_static bash "$work/fixture-footer.sh" interrupted < /dev/null > "$work/footer-int.out" 2>&1 || fail "footer fixture ran" +grep -q '│ Interrupted │$' "$work/footer-int.out" || fail "an interrupted run says Interrupted" +grep -q '^The installer could not save its log\.$' "$work/footer-int.out" || fail "a missing log is reported" +run_static bash "$work/fixture-footer.sh" cancelled < /dev/null > "$work/footer-cancel.out" 2>&1 || fail "footer fixture ran" +grep -q '│ Cancelled │$' "$work/footer-cancel.out" || fail "a cancelled run says Cancelled" +! grep -q 'ableton-live$' "$work/footer-cancel.out" || fail "a cancelled run has no launch hint" +run_static bash "$work/fixture-footer.sh" long < /dev/null > "$work/footer-long.out" 2>&1 || fail "footer fixture ran" +while IFS= read -r line; do + [ "${#line}" -eq 78 ] || fail "a widened footer never exceeds width-2 (got ${#line}: $line)" +done < <(grep '^[╞├│╘]' "$work/footer-long.out") +grep -q '^│ runtime: …[^ ]*wine-d2d1-nspa-11.13-rebuilt-2026 *│$' "$work/footer-long.out" \ + || fail "an overlong path is shortened from the left so its tail survives" +grep -q '^│ ableton-linux prefix: /home/someone/.wine-ableton *│$' "$work/footer-long.out" \ + || fail "a short row in a widened footer keeps its border" +ok "the footer renders every status, widens to the terminal, and shortens long paths" + +# T12: a non-UTF-8 locale gets the ASCII glyph set with the same structure, +# and the charset captured by the .run header wins over the current locale. +env -i PATH="$PATH" HOME="$work/home" TMPDIR="$work" LANG=C TERM=xterm COLUMNS=80 \ + ABLETON_UI_ACTION=install ABLETON_UI_KIT=1 ABLETON_UI_PROMPT_TIMEOUT=30 UI_LIB="$ui_lib" \ + bash "$work/fixture-golden.sh" < /dev/null > "$work/ascii.out" 2>&1 || fail "the ASCII fixture ran" +! LC_ALL=C grep -q $'[\x80-\xff]' "$work/ascii.out" || fail "a C locale never receives a byte above 127" +[ "$(wc -l < "$work/ascii.out")" -eq "$(wc -l < "$work/golden-static.txt")" ] \ + || fail "the ASCII rendering has the same line structure as the UTF-8 one" +env -i PATH="$PATH" HOME="$work/home" TMPDIR="$work" LANG=C ABLETON_UI_CHARSET=utf8 TERM=xterm COLUMNS=80 \ + ABLETON_UI_ACTION=install ABLETON_UI_KIT=1 ABLETON_UI_PROMPT_TIMEOUT=30 UI_LIB="$ui_lib" \ + bash "$work/fixture-golden.sh" < /dev/null > "$work/charset-env.out" 2>&1 || fail "the charset override fixture ran" +cmp -s "$work/charset-env.out" "$work/golden-static.txt" \ + || fail "ABLETON_UI_CHARSET captured by the .run header wins over the current locale" +ok "the ASCII glyph set is used when the inherited locale is not UTF-8" + +# T13: a full or closed screen never changes an exit status or leaks noise, +# and the log still receives the lines. +cat > "$work/fixture-closed.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +case "${1:?}" in + full) exec 7> /dev/full ;; + closed) exec 7> /dev/null; exec 7>&- ;; +esac +export ABLETON_UI_TTY_FD=7 +. "${UI_LIB:?}" +ui_step_begin s_validate +ui_item_begin i_copy +ui_status i_copy_done 1 +ui_item_end ok +ui_run i_extract -- true +ui_step_end ok +exit 0 +EOF +for mode in full closed; do + run_static env ABLETON_INSTALLER_LOG="$work/closed-$mode.log" bash "$work/fixture-closed.sh" "$mode" \ + < /dev/null > "$work/closed-$mode.out" 2> "$work/closed-$mode.err" \ + || fail "a $mode screen keeps the exit status" + ! grep -qi 'bad file descriptor\|no space' "$work/closed-$mode.err" \ + || fail "a $mode screen produces no error noise" + grep -q 'Copied 1 MiB' "$work/closed-$mode.log" || fail "a $mode screen still logs the rendered lines" +done +ok "a full or closed screen is silent and harmless" + +# D2: from a checkout, raw output between two rendered lines stays in order. +run_static bash -c '. "$UI_LIB"; ui_step_begin s_validate; ui_item_begin i_copy; ui_status i_copy_done 1; echo raw-line; ui_status i_copy_done 2; ui_item_end ok; ui_step_end ok' \ + < /dev/null > "$work/order.out" 2>&1 || fail "the ordering fixture ran" +[ "$(grep -n 'Copied 1 MiB\|raw-line\|Copied 2 MiB' "$work/order.out" | cut -d: -f2- | paste -sd'|')" = \ + '│ │ > Copied 1 MiB.|raw-line|│ │ > Copied 2 MiB.' ] || fail "checkout output keeps raw lines in order" +ok "checkout rendering interleaves raw output in order" + +# T19: a block taller than the terminal is left as drawn; a short one flips. +cat > "$work/fixture-tall.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +[ -z "${FIXTURE_TTY:-}" ] || { exec 7>&1; export ABLETON_UI_TTY_FD=7; } +. "${UI_LIB:?}" +UI_TEXT[i_tall_test]='Tall item' +UI_TEXT[i_short_test]='Short item' +UI_TEXT[i_line_test]='line %s' +ui_step_begin s_validate +ui_item_begin i_short_test +ui_item_end ok +ui_item_begin i_tall_test +for i in 1 2 3 4 5 6 7; do ui_status i_line_test "$i"; done +ui_item_end ok +ui_item_begin i_short_test +ui_item_end ok +ui_step_end ok +EOF +run_pty_raw 6 80 "$work/tall.raw" bash "$work/fixture-tall.sh" +LC_ALL=C grep -q $'├─ \033\[36mShort item ✓' "$work/tall.raw" || fail "a short block still flips on a small terminal" +! LC_ALL=C grep -q $'├─ \033\[[0-9;]*mTall item' "$work/tall.raw" || fail "a block taller than the terminal is not rewritten" +LC_ALL=C grep -q $'└─ \033\[96mTall item' "$work/tall.raw" || fail "the tall block was drawn" +! LC_ALL=C grep -qE $'\033\\[([5-9]|[1-9][0-9]+)A' "$work/tall.raw" \ + || fail "no cursor movement reaches beyond the terminal height" +ok "rewrites are skipped when the block does not fit the terminal" + +# C3: a ui_run with a progress file shows the running size on its title. +cat > "$work/fixture-progress.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +[ -z "${FIXTURE_TTY:-}" ] || { exec 7>&1; export ABLETON_UI_TTY_FD=7; } +. "${UI_LIB:?}" +ui_step_begin s_validate +: > "${PROGRESS_FILE:?}" +ui_run i_copy --progress "$PROGRESS_FILE" 2097152 -- \ + sh -c 'head -c 1048576 /dev/zero >> "$1"; sleep 0.6' _ "$PROGRESS_FILE" +ui_step_end ok +EOF +PROGRESS_FILE="$work/progress" run_pty_raw 40 80 "$work/progress.raw" env PROGRESS_FILE="$work/progress" bash "$work/fixture-progress.sh" +LC_ALL=C grep -q '(1 / 2 MiB)' "$work/progress.raw" || fail "the spinner line shows the copied size" +vt "$work/progress.raw" | grep '^│ ├─ Copy the embedded kit ✓$' >/dev/null || fail "the progress title settles without the counter" +ok "a ui_run with a progress file shows its running size" + +# T22: ending twice, or ending with nothing open, changes nothing on screen +# and leaves one log line each. +cat > "$work/fixture-double.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +. "${UI_LIB:?}" +ui_step_begin s_validate +ui_item_begin i_copy +ui_item_end ok +[ "${DOUBLE:-0}" -eq 0 ] || ui_item_end ok +ui_step_end ok +[ "${DOUBLE:-0}" -eq 0 ] || ui_step_end ok +EOF +run_static env ABLETON_INSTALLER_LOG="$work/single.log" bash "$work/fixture-double.sh" < /dev/null > "$work/single.out" 2>&1 \ + || fail "the single-end fixture ran" +run_static env DOUBLE=1 ABLETON_INSTALLER_LOG="$work/double.log" bash "$work/fixture-double.sh" \ + < /dev/null > "$work/double.out" 2>&1 || fail "a double end does not fail the script" +cmp -s "$work/single.out" "$work/double.out" || fail "a double item or step end prints nothing extra" +[ "$(wc -l < "$work/double.log")" -eq $(( $(wc -l < "$work/single.log") + 2 )) ] \ + || fail "each stray end leaves exactly one log line" +ok "double ends are harmless no-ops with a log line" + +# T15: every screen string lives in the dictionary, and every entry is used. +grep -q '^declare -A UI_TEXT=' "$ui_lib" && grep -q '^declare -A UI_STEPS=' "$ui_lib" \ + || fail "ui.sh declares UI_TEXT and UI_STEPS" +tail_start="$(grep -n '^# END UI_TEXT' "$ui_lib" | head -1 | cut -d: -f1)" +[ -n "$tail_start" ] || fail "ui.sh marks the end of its dictionary with '# END UI_TEXT'" +[ "$(grep -n '^declare -A UI_TEXT=' "$ui_lib" | cut -d: -f1)" -lt "$tail_start" ] || fail "the dictionary opens ui.sh" +leaks="$(tail -n +"$tail_start" "$ui_lib" | grep -v '^[[:space:]]*#' \ + | grep -E '^[[:space:]]*(\{ )?(printf|echo)' \ + | grep -v '>> *"\$UI_LOG"\|>>"\$UI_LOG"\|>> *"\$event_log"\|>&2' \ + | sed -E 's/%[-0-9.*]*[sdc]//g; s/\\[nrte]//g; s/\\e\[[0-9;?]*[A-Za-z]//g; s/\\033\[[0-9;?]*[A-Za-z]//g; s/\$\{[^}]*\}//g; s/\$[A-Za-z_0-9]+//g' \ + | grep -E "'[^']*[A-Za-z][^']*'|\"[^\"]*[A-Za-z][^\"]*\"" \ + | grep -v 'UI_TEXT\[' || true)" +[ -z "$leaks" ] || { printf '%s\n' "$leaks" | head -5 >&2; fail "no screen text is printed from outside the dictionary"; } +mapfile -t keys < <(sed -n 's/^[[:space:]]*\[\([a-z0-9_]*\)\]=.*/\1/p' "$ui_lib" | sort -u) +# Explicit file list: some grep builds ignore --exclude on named files. +users=() +for f in "$here"/*.sh "$here"/lib/*.sh; do + case "$f" in */test-*.sh|*/lib/ui.sh) continue ;; esac + users+=("$f") +done +unused=() +for key in "${keys[@]}"; do + case "$key" in g_*|a_*) continue ;; esac + if ! grep -qw -- "$key" "${users[@]}" \ + && ! grep -Ev "^[[:space:]]*\[$key\]=" "$ui_lib" | grep -w -- "$key" > /dev/null; then + unused+=("$key") + fi +done +[ "${#unused[@]}" -eq 0 ] || fail "every dictionary entry is referenced (unused: ${unused[*]})" +for g in $(sed -n 's/^[[:space:]]*\[g_\([a-z0-9_]*\)\]=.*/\1/p' "$ui_lib"); do + grep -q "^[[:space:]]*\[a_$g\]=" "$ui_lib" || fail "glyph $g has an ASCII twin" +done +for key in $(bash -c '. "$1"; printf "%s\n" ${UI_STEPS[@]}' _ "$ui_lib" | sort -u); do + grep -q "^[[:space:]]*\[$key\]=" "$ui_lib" || fail "step key $key has a name entry" +done +ok "the dictionary is the only source of screen text and has no dead entries" + +# T16: ui.sh draws the installer tree. +installer_scripts=("$here"/installer.sh "$here"/install.sh "$here"/setup-prefix.sh "$here"/setup-link.sh \ + "$here"/uninstall.sh "$here"/setup-run-header.sh "$here"/make-installer.sh "$here"/detect-scale.sh \ + "$here"/detect-theme.sh "$here"/lib/config.sh "$here"/lib/lifecycle.sh "$here"/lib/manifest.sh \ + "$here"/lib/pipeasio.sh "$here"/lib/live-options.sh) +if grep -l '[├└│┃┏┓┡┲╒╞╘═┈✓𐄂⚠🛈]' "${installer_scripts[@]}" | grep -q .; then + grep -l '[├└│┃┏┓┡┲╒╞╘═┈✓𐄂⚠🛈]' "${installer_scripts[@]}" >&2 + fail "no installer script other than ui.sh contains a tree glyph" +fi +ok "ui.sh is the installer tree renderer" + +# T17: the .run header is assembled from the same renderer. +"$here/make-installer.sh" --render-header --version suite-check --payload-sha 0 > "$work/header.sh" \ + || fail "make-installer.sh renders the .run header on its own" +grep -q '^declare -A UI_TEXT=' "$work/header.sh" || fail "the rendered header inlines the dictionary" +! grep -q '@UI_LIB@\|@VERSION@\|@PAYLOAD_SHA@' "$work/header.sh" || fail "the rendered header has no markers left" +grep -q '^__PAYLOAD_BELOW__$' "$work/header.sh" || fail "the rendered header keeps its payload marker" +sh "$work/header.sh" --help > "$work/header-help.out" 2>&1 || fail "the rendered header answers --help" +grep -q 'install, update, runtime install' "$work/header-help.out" || fail "--help lists the commands" +bash -n "$work/header.sh" || fail "the rendered header is valid bash" +"$here/make-installer.sh" --help 2>&1 | grep -q -- '--dev' || fail "make-installer.sh documents its dev pack switch" +ok "the .run header is rendered from ui.sh by make-installer.sh" + +# T6: the action menu through a stub .run on a terminal. Keys are sent only +# once the prompt is on screen, so the echo lands where a person's would. +kit="$work/kit" +mkdir -p "$kit/scripts/lib" +cp "$ui_lib" "$kit/scripts/lib/ui.sh" +cat > "$kit/scripts/installer.sh" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" > "${STUB_ARGS_FILE:?}" +exit 0 +EOF +build_stub_run() +{ + tar -cf "$work/payload.tar" -C "$kit" . + "$here/make-installer.sh" --render-header --version suite-check \ + --payload-sha "$(sha256sum "$work/payload.tar" | awk '{print $1}')" > "$1" + cat "$work/payload.tar" >> "$1" +} +build_stub_run "$work/kit.run" +menu_case() +{ + local keys="$1" out="$2"; shift 2 + rm -f "$work/stub-args" + : > "$out" + env -i PATH="$PATH" HOME="$work/home" TMPDIR="$work" LANG=C.UTF-8 TERM=xterm SHELL=/bin/bash \ + STUB_ARGS_FILE="$work/stub-args" "$@" \ + script -qfec "stty rows 40 cols 100; sh $work/kit.run; echo exit=\$?" /dev/null \ + < <(for _ in $(seq 300); do grep -aq 'Choose an action:' "$out" 2>/dev/null && break; sleep 0.05; done + feed "$out" 'exit=[0-9]' "$keys") > "$out" 2>&1 || true +} +menu_case $'q\n' "$work/menu-q.out" +[ ! -e "$work/stub-args" ] || fail "[Q]uit never reaches the installer" +grep -q 'exit=0' "$work/menu-q.out" || fail "[Q]uit exits 0" +vt "$work/menu-q.out" > "$work/menu-q.screen" +grep -q '│ Cancelled │' "$work/menu-q.screen" || fail "[Q]uit ends with a Cancelled footer" +grep -q '^│ > \[Q\]uit$' "$work/menu-q.screen" || fail "the fresh menu offers [Q]uit" +! grep -q 'Remo\[v\]e Ableton Linux' "$work/menu-q.screen" \ + || fail "the fresh menu does not claim an owned install can be removed" +grep -q '^│ Ableton-Linux Installer Choice:$' "$work/menu-q.screen" || fail "the menu heading follows the template" +grep -q '^│ Choose an action: q$' "$work/menu-q.screen" || fail "the answer is echoed on the prompt line" +! grep -q 'Selected:' "$work/menu-q.screen" || fail "the menu no longer prints a Selected line" +menu_case $'\n\n\n\n\n\n\n' "$work/menu-enter.out" +grep -qx 'install' "$work/stub-args" || fail "Enter picks the default action (no prefix means install)" +vt "$work/menu-enter.out" | grep '^│ > \[I\]nstall (or press Enter)$' >/dev/null \ + || fail "the default option carries the Enter hint" +mkdir -p "$work/home/.wine-ableton" "$work/home/.config/ableton-wine" +printf 'WINE REGISTRY Version 2\n' > "$work/home/.wine-ableton/system.reg" +printf 'format=1\nprefix=%s\n' "$work/home/.wine-ableton" \ + > "$work/home/.wine-ableton/.ableton-linux-prefix" +cat > "$work/home/.config/ableton-wine/config" < \[U\]pdate (or press Enter)$' >/dev/null || fail "update is the default with a prefix" +for key in u U update; do + menu_case "$key"$'\n\n\n\n\n\n\n' "$work/menu-$key.out" + grep -qx 'update' "$work/stub-args" || fail "$key picks update" +done +for key in r reinstall; do + menu_case "$key"$'\n\n\n\n\n\n\n' "$work/menu-$key.out" + grep -qx 'install' "$work/stub-args" || fail "$key picks reinstall" +done +menu_case $'V\n' "$work/menu-v.out" +grep -qx 'uninstall' "$work/stub-args" || fail "V picks remove regardless of case" +menu_case $'?\n' "$work/menu-bad.out" +[ ! -e "$work/stub-args" ] || fail "an unknown key never reaches the installer" +grep -q 'exit=2' "$work/menu-bad.out" || fail "an unknown key exits 2" +vt "$work/menu-bad.out" | grep 'Unknown action: ?' >/dev/null || fail "an unknown key is named" +vt "$work/menu-bad.out" | grep '│ Failed │' >/dev/null || fail "an unknown key ends as a failed run" +rm -f "$work/stub-args" +env -i PATH="$PATH" HOME="$work/home" TMPDIR="$work" LANG=C.UTF-8 TERM=xterm STUB_ARGS_FILE="$work/stub-args" \ + sh "$work/kit.run" < /dev/null > "$work/menu-notty.out" 2>&1 || fail "a run without a terminal takes the default" +grep -qx 'update' "$work/stub-args" || fail "stdin off a terminal takes the default action without reading" +! grep -q 'Choose an action' "$work/menu-notty.out" || fail "stdin off a terminal never prompts" +: > "$work/menu-ascii.out" +env -i PATH="$PATH" HOME="$work/home" TMPDIR="$work" LANG=C TERM=xterm SHELL=/bin/bash STUB_ARGS_FILE="$work/stub-args" \ + script -qfec "stty rows 40 cols 100; sh $work/kit.run update; echo $done_marker" /dev/null \ + < <(feed "$work/menu-ascii.out" "$done_marker") > "$work/menu-ascii.out" 2>&1 || true +! LC_ALL=C grep -q $'[\x80-\xff]' "$work/menu-ascii.out" \ + || fail "the .run header captures a C locale before it exports its own" +env -i PATH="$PATH" HOME="$work/home" TMPDIR="$work" LANG=C.UTF-8 TERM=xterm \ + sh "$work/kit.run" extract "$work/extracted" < /dev/null > "$work/extract.out" 2>&1 \ + || fail "extract mode completes" +[ -f "$work/extracted/scripts/installer.sh" ] || fail "extract mode writes the kit" +grep -q '│ ┃ 1/1 ╏ ' "$work/extract.out" && grep -q '│ └─ Step 1 Complete! ✓' "$work/extract.out" \ + && grep -q '│ Complete │' "$work/extract.out" || fail "extract mode is one step of one" +ok "the action menu follows the template and routes every key" + +# T11 and T18 through the same stub: a child that fails mid-step, whose raw +# error reaches the log and the footer but not the screen. The stub's EXIT +# handler stands in for installer.sh's, which calls ui_cleanup first (E2). +cat > "$kit/scripts/installer.sh" <<'EOF' +#!/usr/bin/env bash +. "$(dirname "$0")/lib/ui.sh" +trap 'ui_cleanup $?' EXIT +ui_step_begin s_validate +ui_item_begin i_copy +echo '!! boom from the child' >&2 +exit 3 +EOF +build_stub_run "$work/kit-fail.run" +rm -f "$work"/ableton-linux-installer-*.log +: > "$work/fail.raw" +env -i PATH="$PATH" HOME="$work/home" TMPDIR="$work" LANG=C.UTF-8 TERM=xterm SHELL=/bin/bash \ + script -qfec "stty rows 40 cols 100; sh $work/kit-fail.run update; echo exit=\$?" /dev/null \ + < <(feed "$work/fail.raw" 'exit=[0-9]') > "$work/fail.raw" 2>&1 || true +grep -q 'exit=3' "$work/fail.raw" || fail "the child's exit status passes through" +vt "$work/fail.raw" > "$work/fail.screen" +grep -q '^│ ├─ Copy the embedded kit 𐄂$' "$work/fail.screen" || fail "an item open at a child's failure shows 𐄂" +[ "$(grep -c '^│ └─ Step 2 Failed! 𐄂$' "$work/fail.screen")" -eq 1 ] || fail "the step is closed as Failed exactly once" +grep -q '│ Failed │' "$work/fail.screen" || fail "the footer says Failed" +grep -q '^│ Errors: *│ [1-9] │$' "$work/fail.screen" || fail "the footer counts the error" +grep -q '^ > boom from the child$' "$work/fail.screen" || fail "the child's error is listed in the footer" +! grep -q '^!! boom' "$work/fail.screen" || fail "raw child output does not reach the screen mid-tree" +log="$(ls -t "$work"/ableton-linux-installer-*.log 2>/dev/null | head -1)" +[ -n "$log" ] || fail "the .run wrote its log beside itself" +grep -q '^\[ERR\] .*boom from the child' "$log" || fail "the child's error is in the log as ERR" +! grep -qF '[ableton-linux][installer][ui]' "$log" \ + || fail "the log repeats the rendered tree" +! LC_ALL=C grep -q $'\033' "$log" || fail "the log has no escape sequences" +ok "a failing child closes its item and step, and its raw error reaches the log and footer" + +# T14: Ctrl-C during a running item, and a parent that vanishes under its +# spinner. +hold="30.$$" +cat > "$kit/scripts/installer.sh" < "\${STUB_MARKER:?}" +ui_run i_copy -- sleep $hold +ui_step_end ok +EOF +build_stub_run "$work/kit-int.run" +rm -f "$work/marker" +: > "$work/int.raw" +env -i PATH="$PATH" HOME="$work/home" TMPDIR="$work" LANG=C.UTF-8 TERM=xterm SHELL=/bin/bash STUB_MARKER="$work/marker" \ + script -qfec "stty rows 40 cols 100; sh $work/kit-int.run update; echo exit=\$?" /dev/null \ + < <(for _ in $(seq 300); do [ -e "$work/marker" ] && break; sleep 0.1; done; sleep 0.5 + feed "$work/int.raw" 'exit=[0-9]' $'\003') > "$work/int.raw" 2>&1 || true +grep -q 'exit=130' "$work/int.raw" || fail "Ctrl-C exits 130 through the .run header" +vt "$work/int.raw" | grep '│ Interrupted │' >/dev/null || fail "Ctrl-C ends with an Interrupted footer" +last_escape="$(LC_ALL=C grep -ao $'\033\\[?25[hl]' "$work/int.raw" | tail -1)" +[ "$last_escape" = $'\033[?25h' ] || fail "the cursor is visible after an interrupt" +sleep 1 +! pgrep -f "sleep $hold" > /dev/null 2>&1 || fail "no task is left behind after an interrupt" +rm -f "$work/marker" +: > "$work/orphan.raw" +env -i PATH="$PATH" HOME="$work/home" TMPDIR="$work" LANG=C.UTF-8 TERM=xterm SHELL=/bin/bash STUB_MARKER="$work/marker" \ + script -qfec "stty rows 40 cols 100; sh $work/kit-int.run update; echo $done_marker" /dev/null \ + < <(for _ in $(seq 300); do [ -e "$work/marker" ] && break; sleep 0.1; done; sleep 0.5 + kill -9 "$(cat "$work/marker")" 2>/dev/null; feed "$work/orphan.raw" "$done_marker") > "$work/orphan.raw" 2>&1 & +orphan_pid=$! +for _ in $(seq 100); do [ -e "$work/marker" ] && break; sleep 0.1; done +sleep 2 +size_a="$(stat -c %s "$work/orphan.raw")"; sleep 1; size_b="$(stat -c %s "$work/orphan.raw")" +[ "$size_a" -eq "$size_b" ] || fail "a spinner whose parent vanished keeps drawing" +pkill -f "sleep $hold" > /dev/null 2>&1 || true +wait "$orphan_pid" 2>/dev/null || true +ok "an interrupt cleans up the spinner, restores the cursor, and reports Interrupted" + +printf 'PASS: %d installer UI checks\n' "$pass" diff --git a/scripts/test-launcher-transactions.sh b/scripts/test-launcher-transactions.sh index fdae0b24..a5e54c43 100755 --- a/scripts/test-launcher-transactions.sh +++ b/scripts/test-launcher-transactions.sh @@ -66,6 +66,12 @@ case " $* " in exit 0 ;; esac printf 'wine %s\n' "$*" >> "${ABLETON_TEST_WINE_LOG:?}" +if [ -n "${ABLETON_TEST_PREFERENCE_ENV_LOG:-}" ]; then + printf 'shortcuts=%s dpi=%s threads=%s rt=%s power=%s\n' \ + "${ABLETON_SHORTCUTS:-unset}" "${ABLETON_DPI_MODE:-unset}" \ + "${ABLETON_MAX_AUDIO_THREADS:-unset}" "${ABLETON_RT:-unset}" \ + "${ABLETON_POWER:-unset}" >> "$ABLETON_TEST_PREFERENCE_ENV_LOG" +fi if [ "${1:-}" = reg ]; then # Real reg.exe brings up a wineserver which inherits the transaction token # and remains observable until wineserver -k. Model that ownership contract @@ -192,6 +198,201 @@ run_live() ABLETON_TEST_WINE_LOG="$base/wine.log" bash "$here/ableton-live" "$@" } +# Saved pre-flight preferences are a real launcher input, not merely a sourced +# helper. Exercise both launchers through an observable fake Wine child and a +# logging powerprofilesctl wrapper. The optional-library copy proves that a +# launcher published after a support-file failure retains its legacy behavior. +write_launcher_preferences() # base power [shortcuts dpi threads rt] +{ + local base="$1" power="$2" shortcuts="${3:-preserve}" \ + dpi="${4:-preserve}" threads="${5:-off}" rt="${6:-off}" + cat > "$base/config/ableton-wine/preferences" < "$base/wine.log" + : > "$base/power.log" + : > "$base/chrt.log" + : > "$base/preference-env.log" + : > "$base/shortcut.log" + run_isolated "$base" env -u ABLETON_SHORTCUTS -u ABLETON_DPI_MODE \ + -u ABLETON_MAX_AUDIO_THREADS -u ABLETON_RT -u ABLETON_POWER \ + USER=test ABLETON_THEME_MODE=preserve ABLETON_UI_FONT=preserve \ + ABLETON_TEXT_SMOOTHING=preserve ABLETON_TOPBAR_MODE=preserve \ + ABLETON_LAUNCH_TIMEOUT=5 ABLETON_TEST_POWER_LOG="$base/power.log" \ + ABLETON_TEST_APP_IMAGE="$image" ABLETON_TEST_APP_PID_FILE="$base/app.pid" \ + ABLETON_TEST_WINE_LOG="$base/wine.log" \ + ABLETON_TEST_PREFERENCE_ENV_LOG="$base/preference-env.log" \ + ABLETON_TEST_CHRT_LOG="$base/chrt.log" \ + ABLETON_TEST_SHORTCUT_LOG="$base/shortcut.log" \ + "$@" bash "$launcher" \ + >"$base/launcher.out" 2>"$base/launcher.err" & + launcher_pid=$! + cleanup_pids+=("$launcher_pid") + for ((i=0; i<200; i++)); do + [ ! -s "$base/app.pid" ] || { app_pid="$(cat "$base/app.pid")"; break; } + kill -0 "$launcher_pid" 2>/dev/null || break + sleep 0.05 + done + if [ -z "$app_pid" ]; then + kill "$launcher_pid" 2>/dev/null || true + wait "$launcher_pid" 2>/dev/null || true + fail "$(basename "$launcher") did not reach Wine" + fi + cleanup_pids+=("$app_pid") + sleep 0.3 + kill "$app_pid" 2>/dev/null || true + wait "$app_pid" 2>/dev/null || true + wait "$launcher_pid" || launcher_status=$? + case "$launcher_status" in + 0|143) ;; # the fixture ends its observable sleep with SIGTERM + *) fail "$(basename "$launcher") returned $launcher_status after its observable Wine child" ;; + esac +} + +base="$(new_env saved-launcher-preferences)" +prepare_fixture "$base" +cat > "$base/fakebin/powerprofilesctl" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${ABLETON_TEST_POWER_LOG:?}" +printf 'env shortcuts=%s dpi=%s threads=%s rt=%s power=%s\n' \ + "${ABLETON_SHORTCUTS:-unset}" "${ABLETON_DPI_MODE:-unset}" \ + "${ABLETON_MAX_AUDIO_THREADS:-unset}" "${ABLETON_RT:-unset}" \ + "${ABLETON_POWER:-unset}" >> "${ABLETON_TEST_POWER_LOG:?}" +[ "${ABLETON_TEST_POWER_FAIL:-0}" -eq 0 ] || exit 75 +while [ "$#" -gt 0 ] && [ "$1" != -- ]; do shift; done +[ "${1:-}" = -- ] || exit 2 +shift +exec "$@" +EOF +cat > "$base/fakebin/chrt" <<'EOF' +#!/usr/bin/env bash +printf '%s env rt=%s\n' "$*" "${ABLETON_RT:-unset}" >> "${ABLETON_TEST_CHRT_LOG:?}" +[ "${1:-}" = -r ] && [ "${2:-}" = 10 ] || exit 2 +shift 2 +exec "$@" +EOF +cat > "$base/fakebin/getconf" <<'EOF' +#!/usr/bin/env bash +[ "${1:-}" != _NPROCESSORS_ONLN ] || { printf '32\n'; exit 0; } +exec /usr/bin/getconf "$@" +EOF +cat > "$base/fakebin/nproc" <<'EOF' +#!/usr/bin/env bash +printf '32\n' +EOF +cat > "$base/data/ableton-wine/shortcut-hold.sh" <<'EOF' +#!/usr/bin/env bash +ableton_shortcuts_prepare() +{ + printf '%s\n' "${ABLETON_SHORTCUTS:-unset}" >> "${ABLETON_TEST_SHORTCUT_LOG:?}" + ableton_shortcuts_active=0 +} +ableton_shortcuts_watch_loop() { return 0; } +EOF +chmod 755 "$base/fakebin/powerprofilesctl" "$base/fakebin/chrt" \ + "$base/fakebin/getconf" "$base/fakebin/nproc" +audio_preferences="$base/prefix/drive_c/users/test/AppData/Roaming/Ableton/Live 12.1.0/Preferences" +seed_audio_threads_fixture() +{ + mkdir -p -- "$audio_preferences" + printf '%s\n' '-MaxAudioThreads=8' > "$audio_preferences/Options.txt" + printf 'format=1\ndefault=-MaxAudioThreads=8\n' \ + > "$audio_preferences/.ableton-linux-max-audio-threads-v1" + chmod 600 "$audio_preferences/Options.txt" \ + "$audio_preferences/.ableton-linux-max-audio-threads-v1" +} +for launcher in ableton-live max9; do + case "$launcher" in + ableton-live) image='Ableton Live 12 Suite.exe' ;; + max9) image="C:\\Program Files\\Cycling '74\\Max 9\\Max.exe" ;; + esac + write_launcher_preferences "$base" balanced + [ "$launcher" != ableton-live ] || seed_audio_threads_fixture + run_preferences_launcher "$base" "$here/$launcher" "$image" + grep -qF 'launch -p balanced' "$base/power.log" \ + || fail "$launcher does not hold the saved Balanced profile" + grep -qF 'shortcuts=preserve dpi=preserve threads=off rt=off power=balanced' \ + "$base/preference-env.log" \ + || fail "$launcher does not apply all saved settings before Wine" + if [ "$launcher" = ableton-live ]; then + if grep -q '^-MaxAudioThreads' "$audio_preferences/Options.txt" \ + || [ -e "$audio_preferences/.ableton-linux-max-audio-threads-v1" ]; then + fail "ableton-live does not apply saved Let Live decide to Options.txt" + fi + fi + + write_launcher_preferences "$base" performance + run_preferences_launcher "$base" "$here/$launcher" "$image" + grep -qF 'launch -p performance' "$base/power.log" \ + || fail "$launcher does not hold an explicitly saved Performance profile" + + write_launcher_preferences "$base" off + run_preferences_launcher "$base" "$here/$launcher" "$image" + [ ! -s "$base/power.log" ] \ + || fail "$launcher invokes powerprofilesctl for saved Don't change" + + write_launcher_preferences "$base" balanced + run_preferences_launcher "$base" "$here/$launcher" "$image" \ + ABLETON_SHORTCUTS=take ABLETON_DPI_MODE=fractional \ + ABLETON_MAX_AUDIO_THREADS=8 ABLETON_RT=auto ABLETON_POWER=performance + grep -qF 'launch -p performance' "$base/power.log" \ + || fail "$launcher ignores a one-run power override over saved Balanced" + grep -qF 'shortcuts=take dpi=fractional threads=8 rt=auto power=performance' \ + "$base/preference-env.log" \ + || fail "$launcher does not apply all nonempty environment overrides" + grep -qF 'env rt=auto' "$base/chrt.log" \ + || fail "$launcher does not consume saved/overridden Automatic RT behavior" + if [ "$launcher" = ableton-live ]; then + grep -qxF take "$base/shortcut.log" \ + || fail "ableton-live does not consume the shortcut preference" + if ! grep -qxF -- '-MaxAudioThreads=8' "$audio_preferences/Options.txt" \ + || [ ! -f "$audio_preferences/.ableton-linux-max-audio-threads-v1" ]; then + fail "ableton-live does not apply the audio-worker environment override" + fi + fi + + write_launcher_preferences "$base" balanced + run_preferences_launcher "$base" "$here/$launcher" "$image" ABLETON_POWER=off + [ ! -s "$base/power.log" ] \ + || fail "$launcher invokes powerprofilesctl despite one-run Don't change" + + run_preferences_launcher "$base" "$here/$launcher" "$image" \ + ABLETON_TEST_POWER_FAIL=1 + grep -qF 'launch -p balanced' "$base/power.log" \ + || fail "$launcher does not probe the saved unavailable Balanced profile" + grep -q '^wine ' "$base/wine.log" \ + || fail "$launcher does not continue without an unavailable saved profile" + + isolated="$base/no-preferences-$launcher" + mkdir -p -- "$isolated/lib" + cp -- "$here/$launcher" "$isolated/$launcher" + cp -- "$here/lib/config.sh" "$here/lib/lifecycle.sh" "$here/lib/manifest.sh" \ + "$isolated/lib/" + [ "$launcher" != ableton-live ] \ + || cp -- "$here/lib/live-options.sh" "$isolated/lib/live-options.sh" + chmod 755 "$isolated/$launcher" + run_preferences_launcher "$base" "$isolated/$launcher" "$image" \ + ABLETON_SHORTCUTS=preserve ABLETON_DPI_MODE=preserve \ + ABLETON_MAX_AUDIO_THREADS=off ABLETON_RT=off + grep -qF 'launch -p performance' "$base/power.log" \ + || fail "$launcher without optional preferences support loses its legacy Performance default" +done +ok "Live and Max execute saved Balanced/Don't-change choices, environment precedence, and missing-support fallback" + # Mutter exposes mixed logical monitors to Xwayland through one framebuffer at # the highest active integer scale. The GNOME detector must therefore not # follow a 100% primary while a fractional secondary has doubled X11 space. @@ -211,8 +412,12 @@ block="$(run_isolated "$base" bash -c \ '. "$1"; ableton_dpi_block_for_scale 1.33333 gnome' _ "$here/detect-scale.sh")" [ "$block" = fractional ] \ || fail "GNOME mixed-scale detection did not select the doubled-framebuffer DPI block" -grep -qF "using GNOME/Xwayland's shared framebuffer scale from 1.3333333333333333" \ +grep -qxF 'note: monitors run mixed scales (1.0 1.3333333333333333).' \ + "$base/detect.err" || fail "GNOME mixed-scale detection did not list the active scales cleanly" +grep -qxF "note: using GNOME/Xwayland's shared framebuffer scale: 1.3333333333333333." \ "$base/detect.err" || fail "GNOME mixed-scale detection did not explain its global choice" +awk 'length > 80 { exit 1 }' "$base/detect.err" \ + || fail "GNOME mixed-scale diagnostics exceed the terminal-friendly line width" ok "GNOME mixed scales follow Xwayland's shared framebuffer" # Changing LogPixels through reg.exe starts Wine under the old value. A cold diff --git a/scripts/test-link-boundary.sh b/scripts/test-link-boundary.sh index 3cd5e92f..b1a4a1ef 100755 --- a/scripts/test-link-boundary.sh +++ b/scripts/test-link-boundary.sh @@ -83,11 +83,13 @@ run_link env ABLETON_LINK_COORDINATED=1 \ bash "$here/setup-link.sh" enable --mode=session \ > "$base/out" 2> "$base/err" \ || { sed -n '1,100p' "$base/err" >&2; fail "coordinated Link setup failed"; } -! grep -q '^OK:' "$base/out" \ - || fail "Link child reported final success before its coordinator finished" -grep -qxF 'link_mode=session' "$base/config/ableton-wine/config" \ - || fail "coordinated Link child did not complete its requested mode" -ok "coordinated Link leaves final success reporting to the installer" +! grep -q 'Step [0-9]* Complete' "$base/out" \ + || fail "Link child closed a step that belongs to its coordinator" +[ ! -e "$base/config/ableton-wine/config" ] \ + || fail "coordinated Link child wrote the installer's final config mapping" +grep -qF '│ │ > No active ufw or firewalld; the firewall is unchanged' "$base/out" \ + || fail "coordinated Link progress bypassed the installer tree" +ok "coordinated Link leaves final config and success reporting to the installer" # The canonical service file belongs to this project. Re-running setup repairs # arbitrary bytes at that path instead of demanding an old checksum. @@ -95,7 +97,7 @@ new_env authoritative-unit mkdir -p -- "$base/config/systemd/user" printf 'foreign bytes at a generated path\n' \ > "$base/config/systemd/user/ableton-linkd.service" -run_link bash "$here/setup-link.sh" enable --mode=session \ +printf 'o\n' | run_link bash "$here/setup-link.sh" enable --mode=session \ > "$base/out" 2> "$base/err" \ || { sed -n '1,100p' "$base/err" >&2; fail "a modified generated unit blocks Link repair"; } grep -qxF 'X-AbletonLinuxOwned=true' \ @@ -110,7 +112,7 @@ ok "Link overwrites its generated user service authoritatively" # there is reported but cannot reject an otherwise complete session setup. new_env optional-session-unit mkdir -p -- "$base/config/systemd/user/ableton-linkd.service" -run_link bash "$here/setup-link.sh" enable --mode=session \ +printf 'Keep\n' | run_link bash "$here/setup-link.sh" enable --mode=session \ > "$base/out" 2> "$base/err" \ || { sed -n '1,100p' "$base/err" >&2; fail "optional systemd preparation blocks session Link"; } grep -qxF 'link_mode=session' "$base/config/ableton-wine/config" \ @@ -131,7 +133,7 @@ printf 'orphan\n' > "$base/state/ableton-wine/install-prestate/orphan" run_link bash "$here/setup-link.sh" enable --mode=session \ > "$base/out" 2> "$base/err" \ || { sed -n '1,100p' "$base/err" >&2; fail "optional local records block Link repair"; } -grep -qF 'OK: Ableton Link is enabled' "$base/out" \ +grep -qF 'Ableton Link is enabled' "$base/out" \ || fail "Link did not report the successful repair" ok "malformed optional local records do not gate Link setup" @@ -152,7 +154,7 @@ EOF cp -- "$base/config/ableton-wine/config" "$base/config.before" run_link bash "$here/setup-link.sh" status > "$base/out" 2> "$base/err" \ || fail "an interrupted project settings file hid read-only Link status" -grep -qxF 'mode: session (while Ableton Live is running)' "$base/out" \ +grep -qF 'mode: session (while Ableton Live is running)' "$base/out" \ || fail "Link status did not use the unambiguous saved mode" grep -qF 'Installer settings need repair; showing Link status' "$base/err" \ || fail "Link status did not explain its salvaged settings" @@ -209,7 +211,7 @@ link_mode=session linkd=$base/custom/ableton-linkd unexpected=old-generated-line EOF -run_link bash "$here/setup-link.sh" disable > /dev/full 2> "$base/err" \ +printf 'o\n' | run_link bash "$here/setup-link.sh" disable > /dev/full 2> "$base/err" \ || { sed -n '1,100p' "$base/err" >&2; fail "closed repair output blocks Link disable"; } grep -qxF '# ableton-linux installer configuration; managed by the installer' \ "$base/config/ableton-wine/config" \ @@ -222,9 +224,8 @@ grep -qxF '#!/bin/sh' "$base/custom/ableton-linkd" \ || fail "Link disable changed an external configured helper" ok "repairable generated settings are rebuilt without losing safe values" -# Repair publishes a complete canonical config before any firewall or service -# mutation. A later real system failure therefore cannot erase the settings it -# used to locate the existing install. +# A Link system failure before the settings mapping leaves that destination +# untouched. There is no config preflight or automatic repair transaction. new_env repair-before-system-failure mkdir -p -- "$base/config/ableton-wine" "$base/custom" printf '#!/bin/sh\nexit 0\n' > "$base/custom/ableton-linkd" @@ -239,6 +240,7 @@ link_mode=session linkd=$base/custom/ableton-linkd obsolete_generated_key=remove-me EOF +cp -- "$base/config/ableton-wine/config" "$base/config.before" cat > "$base/fakebin/grep" <<'EOF' #!/bin/sh for argument do @@ -257,17 +259,20 @@ if run_link bash "$here/setup-link.sh" enable --mode=session \ > "$base/out" 2> "$base/err"; then fail "an unreadable active firewall was accepted" fi -if ! grep -qxF '# ableton-linux installer configuration; managed by the installer' \ - "$base/config/ableton-wine/config" \ - || ! grep -qxF "linkd=$base/custom/ableton-linkd" "$base/config/ableton-wine/config" \ - || grep -qF obsolete_generated_key "$base/config/ableton-wine/config"; then - fail "a later Link failure lost or partially published repaired settings" -fi -ok "Link publishes repaired settings before a later system failure" +cmp -s -- "$base/config.before" "$base/config/ableton-wine/config" \ + || fail "a Link system failure changed project settings before their mapping" +ok "a Link system failure leaves project settings untouched" # Settings are generated local integration. If their atomic publication is # unavailable, the requested firewall/service change remains authoritative and # the user gets a concrete retry instruction instead of a fake Link failure. +# The settings file is a project file now: when it already exists the run asks +# [O]verwrite all / [K]eep originals / [A]bort once (rule F3), and EOF on stdin +# takes the default, Overwrite all (rule F4), instead of cancelling. Under +# Overwrite all the first mv that touches the file is the backup move (settings +# file -> backup directory), so the mv stub below fails whenever the settings +# path is any operand, not only the destination. A failed backup leaves the +# file untouched and counts as a preference failure; Link must still finish. new_env optional-link-preference-write mkdir -p -- "$base/config/ableton-wine" cat > "$base/config/ableton-wine/config" < "$base/fakebin/mv" <<'EOF' #!/bin/sh -target="" -for argument do target="$argument"; done -[ "$target" != "${ABLETON_TEST_CONFIG_TARGET:?}" ] || exit 73 +for argument do + [ "$argument" != "${ABLETON_TEST_CONFIG_TARGET:?}" ] || exit 73 +done exec /usr/bin/mv "$@" EOF chmod 755 "$base/fakebin/mv" run_link env ABLETON_TEST_CONFIG_TARGET="$base/config/ableton-wine/config" \ bash "$here/setup-link.sh" enable --mode=session > "$base/out" 2> "$base/err" \ + < /dev/null \ || { sed -n '1,100p' "$base/err" >&2; fail "optional Link preference publication became an enable gate"; } cmp -s -- "$base/config.before" "$base/config/ableton-wine/config" \ || fail "failed optional preference publication damaged the prior config" @@ -299,8 +305,7 @@ grep -qF 'run the installer again to save the preference' "$base/out" \ || fail "Link preference warning did not give the user a direct next step" ok "Link preference persistence is warning-only after the requested system change" -# A foreign file or symlink is not proof of project-owned generated state. -# Direct Link commands refuse it without deleting or following it. +# Keep preserves any existing settings object and lets Link continue. new_env foreign-config-preservation mkdir -p -- "$base/config/ableton-wine" cat > "$base/config/ableton-wine/config" < "$base/out" 2> "$base/err"; then - fail "direct Link replaced a foreign settings file" -fi +printf 'Keep\n' | run_link bash "$here/setup-link.sh" disable \ + > "$base/out" 2> "$base/err" \ + || fail "Keep at a foreign settings file blocked Link disable" cmp -s -- "$base/foreign.before" "$base/config/ableton-wine/config" \ || fail "direct Link changed a foreign settings file" -ok "direct Link preserves foreign settings" +ok "Keep preserves a foreign settings file and continues" new_env symlink-config-preservation mkdir -p -- "$base/config/ableton-wine" "$base/personal" @@ -334,84 +339,48 @@ obsolete_generated_key=remove-me EOF cp -- "$base/personal/config" "$base/symlink-target.before" ln -s -- "$base/personal/config" "$base/config/ableton-wine/config" -if run_link bash "$here/setup-link.sh" disable > "$base/out" 2> "$base/err"; then - fail "direct Link followed a settings symlink" -fi +printf 'Keep\n' | run_link bash "$here/setup-link.sh" disable \ + > "$base/out" 2> "$base/err" \ + || fail "Keep at a settings symlink blocked Link disable" if [ ! -L "$base/config/ableton-wine/config" ] \ || [ "$(readlink -- "$base/config/ableton-wine/config")" != "$base/personal/config" ] \ || ! cmp -s -- "$base/symlink-target.before" "$base/personal/config"; then fail "direct Link changed a settings symlink or its target" fi -ok "direct Link preserves settings symlinks and their targets" - -# Once firewall/service state and the saved mode are verified, failure to -# delete temporary recovery data is cleanup—not failure of Link itself. -new_env cleanup-after-success -cat > "$base/fakebin/rm" <<'EOF' -#!/bin/sh -for argument do - case "$argument" in - *.link-enable.*) - kill -TERM "$PPID" - exit 99 ;; - esac -done -exec /usr/bin/rm "$@" -EOF -chmod 755 "$base/fakebin/rm" -run_link bash "$here/setup-link.sh" enable --mode=session \ - > "$base/out" 2> "$base/err" \ - || { sed -n '1,100p' "$base/err" >&2; fail "temporary cleanup turns successful Link setup into failure"; } -grep -qF 'temporary recovery files could not be removed' "$base/err" \ - || fail "Link cleanup warning did not explain what remains" -grep -qxF 'link_mode=session' "$base/config/ableton-wine/config" \ - || fail "cleanup warning lost the successful Link mode" -ok "enable cleanup and a late signal cannot reverse completed Link setup" +ok "Keep preserves settings symlinks and their targets" -# Disable has the same commit boundary: once the helper is stopped and off is -# saved, a signal raised by generated-file cleanup must not make the public -# installer roll back a completed result. -new_env disable-after-success-signal +# Disabling Link changes the mode but leaves the installed support files in +# place for the next enable or installer run. +new_env disable-keeps-support-files run_link bash "$here/setup-link.sh" enable --mode=session \ > "$base/enable.out" 2> "$base/enable.err" \ - || fail "disable signal fixture could not first enable Link" -printf 'file\t%s\t%s\n' "$base/data/ableton-wine/ableton-linkd" \ - "$(sha256sum -- "$base/data/ableton-wine/ableton-linkd" | awk '{print $1}')" \ - > "$base/state/ableton-wine/install-manifest.tsv" -cat > "$base/fakebin/rm" < "$base/out" 2> "$base/err" \ - || { sed -n '1,100p' "$base/err" >&2; fail "late cleanup signal reversed completed Link disable"; } + || fail "Link disable fixture could not first enable Link" +cp -- "$base/data/ableton-wine/ableton-linkd" "$base/linkd.before" +printf 'o\n' | run_link bash "$here/setup-link.sh" disable \ + > "$base/out" 2> "$base/err" \ + || { sed -n '1,100p' "$base/err" >&2; fail "Link disable failed"; } grep -qxF 'link_mode=off' "$base/config/ableton-wine/config" \ - || fail "late cleanup signal lost the saved off mode" -ok "disable cleanup and a late signal cannot reverse completed Link setup" + || fail "Link disable lost the saved off mode" +cmp -s -- "$base/linkd.before" "$base/data/ableton-wine/ableton-linkd" \ + || fail "Link disable changed or removed its installed support helper" +ok "Link disable keeps installed support files while saving the off mode" -# The pre-config legacy marker is generated by old releases. Only its two exact -# one-line values belong to this project, and removing it happens after the new -# Link mode has already been saved. -new_env exact-legacy-policy-cleanup +# Old Link-setting files are outside the fixed installer mappings. Their +# contents are not interpreted and they are never removed automatically. +new_env exact-legacy-policy-preservation printf 'configured\n' > "$base/data/ableton-wine/link-configured" run_link bash "$here/setup-link.sh" enable --mode=session \ > "$base/enable.out" 2> "$base/enable.err" \ || fail "an exact old Link setting blocked Link enable" -[ ! -e "$base/data/ableton-wine/link-configured" ] \ - && [ ! -L "$base/data/ableton-wine/link-configured" ] \ - || fail "Link enable retained its exact obsolete setting" +grep -qxF 'configured' "$base/data/ableton-wine/link-configured" \ + || fail "Link enable changed an old Link-setting file" printf 'declined\n' > "$base/data/ableton-wine/link-configured" -run_link bash "$here/setup-link.sh" disable > "$base/disable.out" 2> "$base/disable.err" \ +printf 'o\n' | run_link bash "$here/setup-link.sh" disable \ + > "$base/disable.out" 2> "$base/disable.err" \ || fail "an exact old Link setting blocked Link disable" -[ ! -e "$base/data/ableton-wine/link-configured" ] \ - && [ ! -L "$base/data/ableton-wine/link-configured" ] \ - || fail "Link disable retained its exact obsolete setting" -ok "exact old Link settings are best-effort cleanup after the requested mode is saved" +grep -qxF 'declined' "$base/data/ableton-wine/link-configured" \ + || fail "Link disable changed an old Link-setting file" +ok "old Link-setting files are not interpreted or removed" for foreign_policy_kind in multiline symlink directory; do new_env "foreign-legacy-policy-$foreign_policy_kind" @@ -437,7 +406,7 @@ for foreign_policy_kind in multiline symlink directory; do || fail "Link disable removed a foreign marker directory" ;; esac done -ok "foreign objects at the old Link-setting path are preserved without gating disable" +ok "all objects at the old Link-setting path are preserved without gating disable" # Disabling Link may remove only assets proven by their saved digest or an exact # historical signature. Canonical-looking paths alone are not ownership proof. @@ -478,7 +447,7 @@ if ! run_link bash "$here/setup-link.sh" enable --mode=session 2> "$base/enable. fi grep -qxF 'link_mode=session' "$base/config/ableton-wine/config" \ || fail "closed progress output lost the enabled mode" -if ! run_link bash "$here/setup-link.sh" disable 2> "$base/disable.err" \ +if ! printf 'o\n' | run_link bash "$here/setup-link.sh" disable 2> "$base/disable.err" \ | head -n 1 > /dev/null; then fail "closed progress output reversed completed Link disable" fi diff --git a/scripts/test-pipeasio-installer.sh b/scripts/test-pipeasio-installer.sh index d40941e5..d77b8815 100755 --- a/scripts/test-pipeasio-installer.sh +++ b/scripts/test-pipeasio-installer.sh @@ -30,6 +30,8 @@ fail() # replace an inherited value rather than allowing the environment to lower it. ABLETON_PIPEWIRE_FLOOR=0.3.56 # shellcheck disable=SC1091 +. "$here/lib/ui.sh" +# shellcheck disable=SC1091 . "$here/lib/pipeasio.sh" [ "$ABLETON_PIPEWIRE_FLOOR" = 1.4.2 ] || fail "environment overrode the hard PipeWire floor" declare -F ableton_pipewire_preflight >/dev/null || fail "PipeWire preflight API is missing" @@ -66,8 +68,9 @@ write_probe() { local target="$1" mkdir -p -- "$(dirname "$target")" - cat > "$target" <<'EOF' +cat > "$target" <<'EOF' #!/bin/bash +[ -z "${PROBE_COUNT_FILE:-}" ] || printf 'probe\n' >> "$PROBE_COUNT_FILE" case "${1:-}" in --client) [ "${PROBE_EXIT:-0}" -eq 0 ] || exit "$PROBE_EXIT" @@ -99,8 +102,8 @@ invoke_preflight() env PATH="$probe_base/core-path" \ PROBE_CLIENT="$client" PROBE_DAEMON="$daemon" PROBE_EXIT="$probe_exit" \ ABLETON_PIPEWIRE_FLOOR=0.3.56 \ - /bin/bash -c '. "$1"; ableton_pipewire_preflight "$2"' \ - _ "$here/lib/pipeasio.sh" "$probe" + /bin/bash -c '. "$1/lib/ui.sh"; . "$1/lib/pipeasio.sh"; ableton_pipewire_preflight "$2"' \ + _ "$here" "$probe" } preflight_ok() @@ -139,6 +142,14 @@ if invoke_preflight 1.6.8 1.6.8 9 >/dev/null 2>&1; then fi ok "native probe gates loaded client and daemon without PipeWire CLI utilities" +probe_count="$probe_base/count" +env PATH="$probe_base/core-path" PROBE_CLIENT=1.6.8 PROBE_DAEMON=1.6.8 \ + PROBE_COUNT_FILE="$probe_count" ABLETON_PIPEWIRE_PREFLIGHT_CACHE=1 \ + /bin/bash -c '. "$1/lib/ui.sh"; . "$1/lib/pipeasio.sh"; ableton_pipewire_preflight "$2"; ableton_pipewire_preflight "$2"' \ + _ "$here" "$probe" >/dev/null +[ "$(wc -l < "$probe_count")" -eq 1 ] || fail "one installer run repeated its PipeWire check" +ok "one installer run checks PipeWire once" + # Once the native probe and version comparison have succeeded, their terminal # report is presentation only. Optional-tool advice is presentation from the # outset. A closed stdout must not turn either into a component/rollback error. @@ -146,11 +157,12 @@ if ! env PATH="$probe_base/core-path" \ PROBE_CLIENT=1.6.8 PROBE_DAEMON=1.6.8 PROBE_EXIT=0 \ /bin/bash -c ' set -euo pipefail - . "$1" + . "$1/lib/ui.sh" + . "$1/lib/pipeasio.sh" ableton_pipewire_preflight "$2" >&- ABLETON_PIPEASIO_DIAGNOSTIC_NOTICE_SHOWN=0 ableton_pipeasio_optional_tools_advice >&- - ' _ "$here/lib/pipeasio.sh" "$probe" 2>/dev/null; then + ' _ "$here" "$probe" 2>/dev/null; then fail "successful PipeWire proof or optional-tool advice inherited terminal output status" fi ok "successful compatibility proof and optional-tool advice ignore presentation failures" @@ -161,7 +173,7 @@ printf 'format=1\nowner=ableton-linux\n' \ > "$base/xdg/state/ableton-wine/.ableton-linux-state" exec {held_lock_fd}< "$base/home" flock -n "$held_lock_fd" || fail "could not create mutation-lock fixture" -# A child in the same installer transaction inherits the descriptor and may +# A child in the same installer command inherits the descriptor and may # continue; an independent process must be refused while it is held. # shellcheck disable=SC2016 run_isolated "$base" env ABLETON_INSTALL_LOCK_FD="$held_lock_fd" bash -c ' @@ -179,14 +191,12 @@ if run_isolated "$base" env -u ABLETON_INSTALL_LOCK_FD bash -c ' fi state_marker_digest="$(sha256sum -- "$base/xdg/state/ableton-wine/.ableton-linux-state" | awk '{print $1}')" if run_isolated "$base" env -u ABLETON_INSTALL_LOCK_FD \ - bash "$here/setup-link.sh" snapshot "$base/link-transaction" \ + bash "$here/setup-link.sh" disable \ >"$base/setup-link.out" 2>"$base/setup-link.err"; then fail "direct setup-link mutator bypassed the held installation lock" fi grep -qF 'Another Ableton Linux install, repair, or removal is already running. Wait for it to finish and try again.' \ "$base/setup-link.err" || fail "setup-link lock refusal was not explicit" -[ ! -e "$base/link-transaction" ] \ - || fail "refused setup-link mutator created a transaction snapshot" [ "$(sha256sum -- "$base/xdg/state/ableton-wine/.ableton-linux-state" | awk '{print $1}')" \ = "$state_marker_digest" ] || fail "refused setup-link mutator changed installer state" exec {held_lock_fd}<&- @@ -432,7 +442,9 @@ make_runtime_only_kit() cp -- "$here/install.sh" "$here/installer.sh" "$here/setup-prefix.sh" \ "$kit/scripts/" cp -- "$here/lib/config.sh" "$here/lib/lifecycle.sh" "$here/lib/live-options.sh" \ - "$here/lib/manifest.sh" "$here/lib/pipeasio.sh" "$kit/scripts/lib/" + "$here/lib/manifest.sh" "$here/lib/pipeasio.sh" "$here/lib/preferences.sh" \ + "$here/lib/ui.sh" \ + "$kit/scripts/lib/" printf '%s\n' "$version" > "$kit/VERSION" make_runtime "$payload/$runtime_name" "$base/BUILD-INFO.txt" built cat > "$payload/$runtime_name/bin/wine" <<'EOF' @@ -492,18 +504,14 @@ run_isolated "$base" env \ && [ ! -e "$base/xdg/data/applications/pipeasio-settings.desktop" ] \ && [ ! -e "$base/xdg/data/icons/hicolor/scalable/apps/pipeasio.svg" ] \ || fail "direct runtime-only reconcile created previously absent panel shortcuts" -runtime_only_manifest="$base/xdg/state/ableton-wine/install-manifest.tsv" -if [ -r "$runtime_only_manifest" ] && awk -F '\t' \ - -v command="$base/home/.local/bin/pipeasio-settings" \ - -v desktop="$base/xdg/data/applications/pipeasio-settings.desktop" \ - -v icon="$base/xdg/data/icons/hicolor/scalable/apps/pipeasio.svg" \ - '$2 == command || $2 == desktop || $2 == icon { found=1 } END { exit !found }' \ - "$runtime_only_manifest"; then - fail "direct runtime-only reconcile claimed absent panel shortcuts" -fi -grep -qxF 'OK: Installation complete.' "$base/out" \ +grep -qxF "$(printf 'runtime\t%s\twine-d2d1-nspa-11.13' "$base/runtime")" \ + "$base/xdg/state/ableton-wine/install-manifest.tsv" \ + || fail "direct runtime-only install did not publish its runtime ownership" +[ ! -e "$base/xdg/state/ableton-wine/install-prestate.tsv" ] \ + || fail "direct runtime-only install created unrelated file prestate" +grep -qF 'The Wine runtime is ready.' "$base/out" \ || fail "direct runtime-only install did not print its simple outcome" -ok "direct runtime-only install completes before reconciling optional panel shortcuts" +ok "direct runtime-only install leaves absent panel paths alone and records its runtime" # Repair-mode parsing keeps safe, unambiguous values from this project's file # even when an obsolete field makes the whole generation invalid. CLI values @@ -571,38 +579,39 @@ for config_kind in foreign-regular symlink directory; do || fail "$config_kind installer settings invalidated a runtime install" [ -f "$base/runtime/.ableton-linux-runtime" ] \ || fail "$config_kind installer settings displaced the committed runtime" + run_isolated "$base" bash -c ' + set -euo pipefail + . "$1/lib/config.sh" + ableton_config_init strict + ableton_managed_config_valid "$ABLETON_CONFIG_FILE" + ' _ "$here" || fail "$config_kind settings were not replaced by a valid generation" + grep -qxF "runtime_root=$base/runtime" "$config_path" \ + || fail "$config_kind settings replacement did not keep the CLI runtime" + config_backup="$(find "$base/xdg/state/ableton-wine/backups" \ + -name 'config.bak-*' -print -quit 2>/dev/null || true)" + [ -n "$config_backup" ] \ + || fail "$config_kind settings replacement did not retain an inert backup" case "$config_kind" in foreign-regular) - run_isolated "$base" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init strict - ableton_managed_config_valid "$ABLETON_CONFIG_FILE" - ' _ "$here" || fail "arbitrary regular settings were not rebuilt" - grep -qxF "runtime_root=$base/runtime" "$config_path" \ - || fail "arbitrary settings repair did not keep the CLI runtime" ;; + grep -qxF 'foreign arbitrary settings' "$config_backup" \ + || fail "regular settings backup changed the displaced bytes" ;; symlink) - [ -L "$config_path" ] \ - && [ "$(readlink -- "$config_path")" = "$base/foreign-config" ] \ + [ -L "$config_backup" ] \ + && [ "$(readlink -- "$config_backup")" = "$base/foreign-config" ] \ && grep -qxF 'foreign symlink referent' "$base/foreign-config" \ - || fail "config repair replaced a symlink or changed its referent" - grep -qF 'Wine is ready, but installer settings could not be saved.' "$base/err" \ - || fail "symlink config retention was not a saved-settings warning" ;; + || fail "symlink settings backup changed the link or its referent" ;; directory) - [ -d "$config_path" ] && [ ! -L "$config_path" ] \ - && grep -qxF 'foreign directory sentinel' "$config_path/sentinel" \ - || fail "config repair replaced a foreign settings directory" - grep -qF 'Wine is ready, but installer settings could not be saved.' "$base/err" \ - || fail "directory config retention was not a saved-settings warning" ;; + [ -d "$config_backup" ] && [ ! -L "$config_backup" ] \ + && grep -qxF 'foreign directory sentinel' "$config_backup/sentinel" \ + || fail "directory settings backup changed the displaced tree" ;; esac done -ok "arbitrary settings cannot gate Wine, while symlink and directory objects stay unchanged" +ok "settings objects cannot gate Wine and are moved to inert backups before replacement" -# Runtime-only work does not use prefix, desktop-data, installer-settings, -# persistent-state, or launcher roots for its core promotion. Foreign objects -# at any of those optional paths must survive while Wine still commits; the -# post-core panel/config repair may warn and skip them. -for optional_root in prefix data config state bin; do +# Runtime-only work does not use prefix, desktop-data, installer-settings, or +# launcher roots for its core promotion. State now holds the required runtime +# manifest; the other optional paths can still be foreign without gating Wine. +for optional_root in prefix data config bin; do base="$(new_env "runtime-unrelated-${optional_root}-root")" make_runtime_only_kit "$base" foreign_root="$base/foreign-$optional_root-root" @@ -611,7 +620,6 @@ for optional_root in prefix data config state bin; do prefix) root_var=ABLETON_WINEPREFIX ;; data) root_var=ABLETON_DATA_HOME ;; config) root_var=ABLETON_CONFIG_HOME ;; - state) root_var=ABLETON_STATE_HOME ;; bin) root_var=ABLETON_BIN_HOME ;; esac run_isolated "$base" env PATH="$base/fakebin:$PATH" \ @@ -628,12 +636,14 @@ ok "unrelated optional root objects cannot gate runtime-only installation" base="$(new_env coordinator-postcore-output)" make_runtime_only_kit "$base" cat > "$base/postcore-failure.bash" <<'EOF' -echo() +printf() { - if [ "$*" = 'OK: the Wine runtime is installed' ]; then + if [ "${1:-}" = '%s %s\n' ] \ + && [ "${2:-}" = ' ' ] \ + && [ "${3:-}" = 'the Wine runtime is installed ✓' ]; then return 74 fi - builtin echo "$@" + builtin printf "$@" } EOF run_isolated "$base" env PATH="$base/fakebin:$PATH" \ @@ -644,7 +654,7 @@ run_isolated "$base" env PATH="$base/fakebin:$PATH" \ || fail "final output failure reported a committed runtime as failed" [ -f "$base/runtime/.ableton-linux-runtime" ] \ || fail "final output failure rolled back the committed runtime" -grep -qxF " runtime: $base/runtime" "$base/out" \ +grep -qF "Runtime: $base/runtime" "$base/out" \ || fail "one failed success line stopped the remaining final presentation" ok "coordinator continues final presentation after one output fails" @@ -685,14 +695,22 @@ run_isolated "$base" env \ PROBE_CLIENT=1.4.2 PROBE_DAEMON=1.4.2 \ bash "$base/kit/scripts/install.sh" --runtime-only --yes \ >"$base/out" 2>"$base/err" \ - || fail "postcommit panel repair failure invalidated a runtime install" + || fail "postcommit panel replacement invalidated a runtime install" [ -f "$base/runtime/.ableton-linux-runtime" ] \ - || fail "panel repair warning displaced the committed runtime" -grep -qxF 'foreign directory sentinel' "$base/home/.local/bin/pipeasio-settings/sentinel" \ - || fail "panel repair warning corrupted a foreign directory" -grep -qF 'Wine was installed, but the PipeASIO settings shortcut could not be updated.' \ - "$base/err" || fail "postcommit panel repair failure was not a plain warning" -ok "direct runtime panel repair is postcommit and warning-only" + || fail "panel replacement displaced the committed runtime" +[ -L "$base/home/.local/bin/pipeasio-settings" ] \ + && [ "$(readlink -- "$base/home/.local/bin/pipeasio-settings")" \ + = "$base/runtime/bin/pipeasio-settings" ] \ + || fail "panel replacement did not install the fixed command link" +panel_backup="$(find "$base/xdg/state/ableton-wine/backups" \ + -type d -name 'pipeasio-settings.bak-*' -print -quit)" +[ -n "$panel_backup" ] \ + && grep -qxF 'foreign directory sentinel' "$panel_backup/sentinel" \ + || fail "panel replacement did not preserve the displaced directory as an inert backup" +[ -f "$base/xdg/data/applications/pipeasio-settings.desktop" ] \ + && [ -f "$base/xdg/data/icons/hicolor/scalable/apps/pipeasio.svg" ] \ + || fail "panel replacement did not continue through the remaining fixed paths" +ok "direct runtime panel repair runs postcommit with one inert backup" base="$(new_env optional-panel-payload-warning)" make_runtime_only_kit "$base" @@ -719,11 +737,23 @@ run_isolated "$base" env \ && [ -f "$base/runtime/lib/wine/x86_64-windows/pipeasio64.dll" ] \ && [ -f "$base/runtime/lib/wine/x86_64-unix/pipeasio64.dll.so" ] \ || fail "optional panel warning did not preserve the promoted core runtime and driver" -grep -qxF 'foreign panel command' "$base/home/.local/bin/pipeasio-settings" \ - || fail "invalid optional panel payload changed an existing panel command" -grep -qF 'Wine and PipeASIO passed their checks, but PipeASIO Settings is incomplete.' \ - "$base/err" || fail "invalid optional panel payload did not produce its plain warning" -ok "invalid optional panel payload warns without invalidating Wine or changing panel shortcuts" +[ -L "$base/home/.local/bin/pipeasio-settings" ] \ + && [ "$(readlink -- "$base/home/.local/bin/pipeasio-settings")" \ + = "$base/runtime/bin/pipeasio-settings" ] \ + || fail "incomplete panel payload did not install its available command mapping" +panel_backup="$(find "$base/xdg/state/ableton-wine/backups" \ + -type f -name 'pipeasio-settings.bak-*' -print -quit)" +[ -n "$panel_backup" ] \ + && grep -qxF 'foreign panel command' "$panel_backup" \ + || fail "incomplete panel payload lost the inert command backup" +[ -f "$base/xdg/data/applications/pipeasio-settings.desktop" ] \ + && [ ! -e "$base/xdg/data/icons/hicolor/scalable/apps/pipeasio.svg" ] \ + || fail "incomplete panel payload did not continue its independent mappings" +grep -qF "copy failed: $base/runtime/share/icons/hicolor/scalable/apps/pipeasio.svg -> $base/xdg/data/icons/hicolor/scalable/apps/pipeasio.svg" \ + "$base/err" || fail "incomplete panel payload did not report the actual failed path" +grep -qF 'Wine was installed, but the PipeASIO settings shortcut could not be updated.' \ + "$base/err" || fail "incomplete panel payload did not produce its postcommit warning" +ok "incomplete panel mappings report their path without invalidating Wine or earlier copies" base="$(new_env runtime-validator-command-failure)" make_runtime_only_kit "$base" @@ -760,24 +790,26 @@ env WINEPREFIX="$base/foreign-prefix" "$base/runtime/bin/wine-client" 60 & foreign_runtime_pid=$! sleep 0.1 runtime_update_succeeded=0 -if run_isolated "$base" env PATH="$base/fakebin:$PATH" \ +if printf 'y\n' | run_isolated "$base" env PATH="$base/fakebin:$PATH" \ ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ PROBE_CLIENT=1.4.2 PROBE_DAEMON=1.4.2 \ - bash "$base/kit/scripts/install.sh" --runtime-only --yes \ + script -qfec "bash '$base/kit/scripts/install.sh' --runtime-only --yes" /dev/null \ >"$base/out" 2>"$base/err"; then runtime_update_succeeded=1 fi -kill -0 "$foreign_runtime_pid" 2>/dev/null \ - || fail "runtime refusal killed a foreign-prefix process" -kill "$foreign_runtime_pid" 2>/dev/null || true +if kill -0 "$foreign_runtime_pid" 2>/dev/null; then + kill "$foreign_runtime_pid" 2>/dev/null || true + wait "$foreign_runtime_pid" 2>/dev/null || true + fail "approval left a foreign-prefix Wine process running" +fi wait "$foreign_runtime_pid" 2>/dev/null || true -[ "$runtime_update_succeeded" -eq 0 ] \ - || fail "runtime promotion continued after refusing a foreign-prefix client" -[ -f "$base/runtime/old-generation" ] \ - || fail "foreign-prefix refusal replaced the live runtime anyway" -grep -qF 'runtime is used by another Wine prefix' "$base/err" \ - || fail "foreign-prefix runtime refusal lost its original cause" -ok "runtime-client refusal cannot be ignored by a later promotion" +[ "$runtime_update_succeeded" -eq 1 ] \ + || fail "runtime promotion stopped after the user approved the Wine-process question" +[ ! -f "$base/runtime/old-generation" ] \ + || fail "approval left the previous runtime generation in place" +grep -qF 'STOP ALL RUNNING WINE PROCESSES' "$base/out" \ + || fail "--yes bypassed the Wine-process question" +ok "runtime update asks before stopping every client that uses the runtime" base="$(new_env runtime-scratch-cleanup-warning)" make_runtime_only_kit "$base" @@ -896,14 +928,14 @@ if find "$base/tmp" "$base/xdg/state/ableton-wine/transactions" \ fi grep -qF 'Wine is ready. Run the installer again to retry shortcuts or Ableton Link files.' \ "$base/err" || fail "verified-core EXIT catch-all did not report optional retry" -grep -qxF 'OK: Installation complete.' "$base/out" \ +grep -qF 'The Wine runtime is ready.' "$base/out" \ || fail "verified-core EXIT catch-all omitted the direct success outcome" ok "the EXIT catch-all cannot fail or roll back a finally validated runtime" # A direct mixed invocation must close its runtime transaction before generated -# desktop work starts. Inject a failure in the first optional file publication: -# Wine stays live, the previous generation stays available for rollback, and no -# active core marker is left for launchers to reject. +# desktop work starts. Inject a copy failure in the first fixed destination: +# Wine stays live, the displaced file remains as an inert backup, later copies +# continue, and no active core marker is left for launchers to reject. base="$(new_env direct-mixed-optional-failure)" prepare_runtime_metadata_fixture "$base" prepare_mixed_component_kit "$base" @@ -911,22 +943,20 @@ foreign_optional="$base/xdg/data/ableton-wine/lib/config.sh" mkdir -p -- "$(dirname "$foreign_optional")" printf 'foreign optional config helper\n' > "$foreign_optional" cp -a -- "$foreign_optional" "$base/foreign-optional.before" -real_install="$(command -v install)" -cat > "$base/fakebin/install" < "$base/fakebin/cp" < "$base/mixed-optional-failure-fired" - exit 74 - fi ;; - esac -done -exec "$real_install" "\$@" +last="" +for argument do last="\$argument"; done +if [ "\$last" = "$foreign_optional" ] \ + && [ -f "$base/runtime/.ableton-linux-runtime" ] \ + && [ ! -e "$base/mixed-optional-failure-fired" ]; then + : > "$base/mixed-optional-failure-fired" + exit 74 +fi +exec "$real_cp" "\$@" EOF -chmod 755 "$base/fakebin/install" +chmod 755 "$base/fakebin/cp" run_isolated "$base" env PATH="$base/fakebin:$PATH" \ ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ PROBE_CLIENT=1.4.2 PROBE_DAEMON=1.4.2 \ @@ -935,19 +965,16 @@ run_isolated "$base" env PATH="$base/fakebin:$PATH" \ || fail "optional mixed-mode failure invalidated a committed runtime" [ -e "$base/mixed-optional-failure-fired" ] \ || fail "mixed-mode fixture did not reach optional file publication" -cmp -s -- "$base/foreign-optional.before" "$foreign_optional" \ - || fail "failed optional publication changed the existing support file" +[ ! -e "$foreign_optional" ] && [ ! -L "$foreign_optional" ] \ + || fail "failed optional copy restored or partially published its destination" +foreign_backup="$(find "$base/xdg/state/ableton-wine/backups" \ + -type f -name 'config.sh.bak-*' -print -quit)" +[ -n "$foreign_backup" ] \ + && cmp -s -- "$base/foreign-optional.before" "$foreign_backup" \ + || fail "failed optional copy lost the inert displaced-file backup" cmp -s -- "$base/kit/scripts/lib/lifecycle.sh" \ "$base/xdg/data/ableton-wine/lib/lifecycle.sh" \ || fail "one failed support file blocked a later independent repair" -prestate_index="$base/xdg/state/ableton-wine/install-prestate.tsv" -prestate_dir="$base/xdg/state/ableton-wine/install-prestate" -[ ! -e "$prestate_index" ] && [ ! -L "$prestate_index" ] \ - || fail "mixed-mode optional rollback left an unindexed prestate inventory" -if [ -d "$prestate_dir" ] \ - && find "$prestate_dir" -mindepth 1 -maxdepth 1 -print -quit | grep -q .; then - fail "mixed-mode optional rollback stranded a saved foreign object" -fi [ -f "$base/runtime/.ableton-linux-runtime" ] \ && [ ! -e "$base/runtime/.ableton-linux-rollback/old-sentinel" ] \ || fail "mixed-mode optional failure restored the previous runtime" @@ -977,7 +1004,9 @@ run_isolated "$base" env \ [ "$(grep -cF 'Some shortcuts or support files could not be updated. Run the installer again to retry them.' \ "$base/err")" -eq 1 ] \ || fail "mixed-mode optional failure did not produce one plain retry warning" -grep -qxF 'OK: Installation complete.' "$base/out" \ +grep -qF "copy failed: $base/kit/scripts/lib/config.sh -> $foreign_optional" \ + "$base/err" || fail "mixed-mode optional failure did not report the actual path" +grep -qF 'The Wine runtime is ready.' "$base/out" \ || fail "mixed-mode optional failure omitted the direct success outcome" ok "direct mixed mode commits Wine before warning-only generated-file work" @@ -989,20 +1018,20 @@ advisory_status=0 run_isolated "$base" env PATH="$base/fakebin:$PATH" \ ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ ABLETON_INTERNAL_OPTIONAL_STATUS=1 \ - bash "$base/kit/scripts/install.sh" --integration-only \ + bash "$base/kit/scripts/install.sh" --integration-only --yes \ >"$base/advisory.out" 2>"$base/advisory.err" \ || advisory_status=$? [ "$advisory_status" -eq 3 ] \ || fail "internal desktop repair result did not distinguish a retry warning" [ -e "$base/mixed-optional-failure-fired" ] \ || fail "internal desktop repair fixture did not reach its injected warning" -! grep -qF 'OK: Installation complete.' "$base/advisory.out" \ +! grep -q 'are ready\.' "$base/advisory.out" \ || fail "internal retry result also printed a complete result" ok "desktop repair warnings have an internal advisory result for the final summary" # Each completed generated-file repair is final. Break stderr and fail after # the first repair; later failure handling must leave that valid repair in -# place and must not strand obsolete recovery data. +# place and must leave its displaced input only in the inert backup tree. base="$(new_env component-recovery-broken-stderr)" make_runtime_only_kit "$base" prepare_mixed_component_kit "$base" @@ -1011,25 +1040,22 @@ foreign_optional="$base/xdg/data/ableton-wine/lib/config.sh" mkdir -p -- "$(dirname "$foreign_optional")" printf 'foreign helper before failed component\n' > "$foreign_optional" cp -a -- "$foreign_optional" "$base/foreign-helper.before" -sed -i '/ableton_try_publish_file 644 "$here\/lib\/$tool" "$data\/lib\/$tool" file replace-generated/a\ +sed -i '/ableton_install_project_file 644 "$here\/lib\/$tool" "$data\/lib\/$tool"/a\ if [ "$tool" = config.sh ]; then exec 2>/dev/full; false; fi' \ "$base/kit/scripts/install.sh" if run_isolated "$base" env PATH="$base/fakebin:$PATH" \ ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ - bash "$base/kit/scripts/install.sh" --integration-only \ + bash "$base/kit/scripts/install.sh" --integration-only --yes \ >"$base/out" 2>"$base/err"; then fail "injected component failure unexpectedly reported success" fi cmp -s -- "$base/kit/scripts/lib/config.sh" "$foreign_optional" \ || fail "a later optional failure undid an earlier generated-file repair" -prestate_index="$base/xdg/state/ableton-wine/install-prestate.tsv" -prestate_dir="$base/xdg/state/ableton-wine/install-prestate" -[ ! -e "$prestate_index" ] && [ ! -L "$prestate_index" ] \ - || fail "broken recovery diagnostics left a prestate index behind" -if [ -d "$prestate_dir" ] \ - && find "$prestate_dir" -mindepth 1 -maxdepth 1 -print -quit | grep -q .; then - fail "broken recovery diagnostics left an unindexed foreign backup" -fi +foreign_backup="$(find "$base/xdg/state/ableton-wine/backups" \ + -type f -name 'config.sh.bak-*' -print -quit)" +[ -n "$foreign_backup" ] \ + && cmp -s -- "$base/foreign-helper.before" "$foreign_backup" \ + || fail "broken diagnostics lost the inert displaced-file backup" if find "$base/tmp" -type f -name active -print -quit 2>/dev/null | grep -q .; then fail "broken component diagnostics left its private transaction active" fi @@ -1043,7 +1069,8 @@ mkdir -p -- "$base/kit/scripts/lib" "$base/kit/bin" cp -- "$here/installer.sh" "$base/kit/scripts/" cp -- "$here/lib/config.sh" "$here/lib/lifecycle.sh" \ "$here/lib/live-options.sh" "$here/lib/manifest.sh" \ - "$here/lib/pipeasio.sh" "$base/kit/scripts/lib/" + "$here/lib/pipeasio.sh" "$here/lib/preferences.sh" "$here/lib/ui.sh" \ + "$base/kit/scripts/lib/" cp -- "$here/../VERSION" "$base/kit/VERSION" cat > "$base/kit/bin/pipewire-version-probe" <<'EOF' #!/bin/sh @@ -1114,7 +1141,8 @@ mkdir -p -- "$base/kit/scripts/lib" "$base/kit/bin" "$base/runtime/bin" cp -- "$here/installer.sh" "$base/kit/scripts/" cp -- "$here/lib/config.sh" "$here/lib/lifecycle.sh" \ "$here/lib/live-options.sh" "$here/lib/manifest.sh" \ - "$here/lib/pipeasio.sh" "$base/kit/scripts/lib/" + "$here/lib/pipeasio.sh" "$here/lib/preferences.sh" "$here/lib/ui.sh" \ + "$base/kit/scripts/lib/" cp -- "$here/../VERSION" "$base/kit/VERSION" cat > "$base/kit/bin/pipewire-version-probe" <<'EOF' #!/bin/sh @@ -1246,7 +1274,7 @@ if find "$base" -mindepth 1 -maxdepth 1 -type d -name 'runtime-rollback-*' \ fi grep -qF 'Wine is ready, but the installer may not be able to restore the previous Wine version automatically. Temporary recovery files may remain.' \ "$base/err" || fail "rollback-name publication failure was not a plain warning" -grep -qxF 'OK: Installation complete.' "$base/out" \ +grep -qF 'The Wine runtime is ready.' "$base/out" \ || fail "runtime with a rollback-name warning omitted its simple outcome" ok "direct runtime rollback publication is warning-only after final validation" @@ -1282,7 +1310,7 @@ EOF grep -qF 'Wine is ready, but temporary installer data remains at ' \ "$base/err" || fail "runtime directory cleanup failure was not a plain warning" ;; esac - grep -qxF 'OK: Installation complete.' "$base/out" \ + grep -qF 'The Wine runtime is ready.' "$base/out" \ || fail "runtime $cleanup_failure cleanup warning omitted the simple outcome" done ok "direct runtime activity/directory cleanup is warning-only after final validation" @@ -1317,76 +1345,28 @@ if ! run_runtime_installer_fixture "$base" >"$base/out" 2>"$base/err"; then sed -n '1,120p' "$base/err" >&2 fail "runtime update failed while replacing rollback metadata" fi -[ "$(grep -c '^== Check the Wine package:' "$base/out")" -eq 1 ] \ +[ "$(grep -c 'Check the Wine package' "$base/out")" -eq 1 ] \ || fail "runtime install validates and extracts its payload more than once" +grep -qF '│ ├─ Check the Wine package ' "$base/out" \ + || fail "runtime operation omitted its nested tree branch" +grep -Eq '^│ │ > .* is valid$' "$base/out" \ + || fail "runtime result was not shown beneath its operation" saved_runtime="$(find_saved_runtime "$base")" \ || fail "runtime update did not retain a saved runtime" saved_meta="$saved_runtime/.ableton-linux-rollback" -[ -r "$saved_meta/metadata" ] \ - && [ -r "$saved_meta/installer-config" ] \ - && [ -r "$saved_meta/pipeasio-config.ini" ] \ - || fail "published rollback metadata is incomplete" -[ ! -e "$saved_meta/old-sentinel" ] \ - && [ ! -e "$saved_runtime/.ableton-linux-rollback-incomplete" ] \ - || fail "published rollback metadata mixed old and new generations" -grep -qxF 'format=1' "$saved_meta/metadata" \ - || fail "published rollback metadata has the wrong format" -grep -qxF 'installer_config_state=present' "$saved_meta/metadata" \ - || fail "published rollback metadata omits its installer snapshot" -grep -qxF 'pipeasio_config_state=present' "$saved_meta/metadata" \ - || fail "published rollback metadata omits its PipeASIO snapshot" -if find "$saved_runtime" -maxdepth 1 -name '.ableton-linux-rollback.new.*' -print -quit \ - | grep -q .; then - fail "successful metadata publication left a staging directory" -fi -ok "runtime update atomically replaces rollback metadata as one complete generation" - -base="$(new_env rollback-metadata-copy-failure)" -prepare_runtime_metadata_fixture "$base" -real_cp="$(command -v cp)" -cat > "$base/fakebin/cp" <"$base/out" 2>"$base/err" \ - || fail "optional rollback snapshot-copy failure invalidated the installed runtime" -[ -f "$base/runtime/.ableton-linux-runtime" ] \ - && [ ! -e "$base/runtime/.ableton-linux-rollback/old-sentinel" ] \ - || fail "rollback snapshot-copy warning restored the previous runtime" -grep -qF 'The install is ready, but old recovery files could not be removed.' "$base/err" \ - || fail "rollback snapshot-copy failure was not reported as a cleanup warning" -saved_runtime="$(find_saved_runtime "$base")" \ - || fail "runtime update lost its saved runtime after optional metadata failure" -saved_meta="$saved_runtime/.ableton-linux-rollback" -[ -e "$saved_runtime/.ableton-linux-rollback-incomplete" ] \ - || fail "metadata copy failure published a rollback candidate as complete" +[ ! -e "$saved_runtime/.ableton-linux-rollback-incomplete" ] \ + || fail "saved runtime remained sealed after core commit" grep -qxF 'old metadata generation' "$saved_meta/old-sentinel" \ - || fail "metadata copy failure removed the previous metadata sentinel" -grep -qxF 'format=0' "$saved_meta/metadata" \ - || fail "metadata copy failure replaced the previous metadata record" -[ ! -e "$saved_meta/installer-config" ] && [ ! -e "$saved_meta/pipeasio-config.ini" ] \ - || fail "metadata copy failure published mixed snapshot files" + && grep -qxF 'format=0' "$saved_meta/metadata" \ + || fail "runtime update rewrote files inside the saved Wine tree" +[ ! -e "$saved_meta/installer-config" ] \ + && [ ! -e "$saved_meta/pipeasio-config.ini" ] \ + || fail "runtime update created automatic settings-restoration snapshots" if find "$saved_runtime" -maxdepth 1 -name '.ableton-linux-rollback.new.*' -print -quit \ | grep -q .; then - fail "metadata copy failure left a publishable staging directory" -fi -if run_isolated "$base" env \ - PATH="$base/fakebin:$PATH" \ - ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ - PROBE_CLIENT=1.4.2 PROBE_DAEMON=1.4.2 \ - bash "$here/rollback.sh" >"$base/rollback.out" 2>"$base/rollback.err"; then - fail "rollback selected a candidate with incomplete metadata" + fail "runtime update left an obsolete settings-snapshot staging directory" fi -grep -qF 'There is no previous Wine version to restore.' "$base/rollback.err" \ - || fail "incomplete metadata candidate refusal was not explicit" -ok "snapshot-copy warning keeps the new runtime and leaves the old candidate safely unselectable" +ok "runtime update publishes the saved Wine tree without optional settings snapshots" base="$(new_env rollback-marker-permission-failure)" prepare_runtime_metadata_fixture "$base" @@ -1499,52 +1479,6 @@ run_component_install() bash "$here/install.sh" "$@" } -manifest_has_path() -{ - local manifest="$1" wanted="$2" - awk -F '\t' -v wanted="$wanted" '$2 == wanted { found=1 } END { exit !found }' "$manifest" -} - -# A top-level coordinator owns an explicitly supplied transaction directory. -# Component success, commit, and rollback may finish their own domain, but may -# not make unfinished sibling domains look inactive. A standalone invocation, -# on the other hand, must retire the transaction it created for itself. -base="$(new_env component-outer-active-owner)" -install_fake_host_tools "$base" -make_runtime "$base/runtime" "$base/BUILD-INFO.txt" built -printf 'format=1\nname=wine-d2d1-nspa-11.13\n' > "$base/runtime/.ableton-linux-runtime" -outer="$base/coordinator-transaction" -mkdir -p -- "$outer" -run_component_install "$base" "$base/runtime" --integration-only \ - --transaction-dir "$outer" >"$base/install.out" 2>"$base/install.err" \ - || fail "external component transaction could not complete" -[ -f "$outer/active" ] && [ ! -L "$outer/active" ] \ - || fail "external component completion retired the coordinator active marker" -run_component_install "$base" "$base/runtime" --commit "$outer" \ - >"$base/commit.out" 2>"$base/commit.err" \ - || fail "external component transaction could not commit" -[ -f "$outer/active" ] && [ ! -L "$outer/active" ] \ - || fail "component --commit retired the coordinator active marker" -run_component_install "$base" "$base/runtime" --rollback "$outer" \ - >"$base/rollback.out" 2>"$base/rollback.err" \ - || fail "external component transaction could not roll back" -[ -f "$outer/active" ] && [ ! -L "$outer/active" ] \ - || fail "component --rollback retired the coordinator active marker" - -base="$(new_env component-own-active-owner)" -install_fake_host_tools "$base" -make_runtime "$base/runtime" "$base/BUILD-INFO.txt" built -printf 'format=1\nname=wine-d2d1-nspa-11.13\n' > "$base/runtime/.ableton-linux-runtime" -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/install.out" 2>"$base/install.err" \ - || fail "standalone component transaction could not complete" -transactions="$base/xdg/state/ableton-wine/transactions" -if [ -d "$transactions" ] \ - && find "$transactions" -mindepth 1 -print -quit | grep -q .; then - fail "standalone component completion retained its own transaction or active marker" -fi -ok "only the coordinator retires external active markers; standalone components retire their own transaction" - base="$(new_env panel-lifecycle)" install_fake_host_tools "$base" make_runtime "$base/runtime" "$base/BUILD-INFO.txt" built @@ -1555,64 +1489,49 @@ run_component_install "$base" "$base/runtime" --integration-only \ panel_command="$base/home/.local/bin/pipeasio-settings" panel_desktop="$base/xdg/data/applications/pipeasio-settings.desktop" panel_icon="$base/xdg/data/icons/hicolor/scalable/apps/pipeasio.svg" -manifest="$base/xdg/state/ableton-wine/install-manifest.tsv" [ -L "$panel_command" ] || fail "panel command was not installed as a managed symlink" [ "$(readlink -- "$panel_command")" = "$base/runtime/bin/pipeasio-settings" ] \ || fail "panel command points outside the selected runtime" [ -f "$panel_desktop" ] && [ -f "$panel_icon" ] || fail "panel desktop/icon ignored custom XDG data home" [ ! -e "$base/home/.local/share/applications/pipeasio-settings.desktop" ] \ || fail "panel desktop escaped custom XDG data home" -grep -Fq $'symlink\t'"$panel_command"$'\t' "$manifest" \ - || fail "literal panel symlink ownership was not recorded" -recorded_link_digest="$(awk -F '\t' -v wanted="$panel_command" \ - '$1 == "symlink" && $2 == wanted { print $3; exit }' "$manifest")" -literal_link_digest="$({ printf 'symlink\0'; readlink -n -- "$panel_command"; } \ - | sha256sum | awk '{print $1}')" -[ "$recorded_link_digest" = "$literal_link_digest" ] \ - || fail "panel ownership hashed the symlink referent instead of its literal target" -manifest_has_path "$manifest" "$panel_desktop" || fail "panel desktop ownership was not recorded" -manifest_has_path "$manifest" "$panel_icon" || fail "panel icon ownership was not recorded" live_options="$base/xdg/data/ableton-wine/lib/live-options.sh" [ -f "$live_options" ] && [ "$(stat -c '%a' "$live_options")" = 644 ] \ || fail "The installer writes the audio thread settings script with mode 644." cmp -s -- "$here/lib/live-options.sh" "$live_options" \ || fail "The installed audio thread settings script matches its source file." -manifest_has_path "$manifest" "$live_options" \ - || fail "The installation record lists the audio thread settings script." -ok "built panel installs transactionally under custom XDG paths" +ok "built panel installs its fixed paths under custom XDG roots" user_panel_target="$base/user-retargeted-pipeasio-settings" rm -f -- "$panel_command" ln -s -- "$user_panel_target" "$panel_command" printf '\n# local panel launcher drift\n' >> "$panel_desktop" -run_component_install "$base" "$base/runtime" --integration-only \ +run_component_install "$base" "$base/runtime" --integration-only --yes \ >"$base/install-drift.out" 2>"$base/install-drift.err" \ || { sed -n '1,80p' "$base/install-drift.err" >&2; fail "panel launcher drift aborted integration"; } [ -L "$panel_command" ] \ && [ "$(readlink -- "$panel_command")" = "$base/runtime/bin/pipeasio-settings" ] \ || fail "integration did not replace the retargeted panel command" -[ -L "${panel_command}.bak" ] \ - && [ "$(readlink -- "${panel_command}.bak")" = "$user_panel_target" ] \ +panel_command_backup="$(find "$base/xdg/state/ableton-wine/backups" \ + -type l -name 'pipeasio-settings.bak-*' -print -quit)" +[ -n "$panel_command_backup" ] \ + && [ "$(readlink -- "$panel_command_backup")" = "$user_panel_target" ] \ || fail "panel command backup is not the displaced symlink" ! grep -qF '# local panel launcher drift' "$panel_desktop" \ || fail "integration left drift in the panel desktop entry" -grep -qF '# local panel launcher drift' "${panel_desktop}.bak" \ +panel_desktop_backup="$(find "$base/xdg/state/ableton-wine/backups" \ + -type f -name 'pipeasio-settings.desktop.bak-*' -print -quit)" +grep -qF '# local panel launcher drift' "$panel_desktop_backup" \ || fail "panel desktop backup does not contain the displaced entry" -! grep -qF 'saved checksum differed' "$base/install-drift.out" \ - || fail "ordinary panel repair printed internal checksum details" -for launcher in "$panel_command" "$panel_desktop"; do - recorded="$(awk -F '\t' -v wanted="$launcher" '$2 == wanted { print $3; exit }' "$manifest")" - current="$( - if [ -L "$launcher" ]; then - { printf 'symlink\0'; readlink -n -- "$launcher"; } | sha256sum | awk '{print $1}' - else - sha256sum -- "$launcher" | awk '{print $1}' - fi - )" - [ "$recorded" = "$current" ] \ - || fail "panel launcher manifest digest was not refreshed for $launcher" -done -ok "PipeASIO launcher drift is backed up and replaced" +mapfile -t panel_backup_runs < <(find "$base/xdg/state/ableton-wine/backups" \ + -mindepth 1 -maxdepth 1 -type d) +[ "${#panel_backup_runs[@]}" -eq 1 ] \ + || fail "panel replacements did not use one per-run backup directory" +case "$panel_command_backup:$panel_desktop_backup" in + "${panel_backup_runs[0]}"/*:"${panel_backup_runs[0]}"/*) ;; + *) fail "panel replacements escaped their per-run backup directory" ;; +esac +ok "PipeASIO panel paths are moved to the central backup tree and replaced" for durable_tool in audio-report.sh setup-realtime.sh rollback.sh; do [ -x "$base/xdg/data/ableton-wine/$durable_tool" ] \ @@ -1656,51 +1575,27 @@ grep -qF 'unavailable: pw-top is not installed' "$base/report.out" \ ok "missing pw-dump/pw-top remains installable and produces actionable diagnostic advice" base="$panel_lifecycle_base" -# Remove the panel through an externally-owned component transaction, then -# exercise the same rollback entry point used by the top-level installer. -write_build_info "$base/runtime" "$base/BUILD-INFO.txt" skipped -txn="$base/panel-transition-transaction" -mkdir -p -- "$txn" -run_component_install "$base" "$base/runtime" --integration-only --transaction-dir "$txn" \ - >"$base/install-skipped-txn.out" 2>"$base/install-skipped-txn.err" \ - || { sed -n '1,120p' "$base/install-skipped-txn.err" >&2; fail "built-to-skipped transactional transition failed"; } -[ -L "$panel_command" ] && [ "$(readlink -- "$panel_command")" = "$user_panel_target" ] \ - && grep -qF '# local panel launcher drift' "$panel_desktop" \ - && [ ! -e "${panel_command}.bak" ] && [ ! -L "${panel_command}.bak" ] \ - && [ ! -e "${panel_desktop}.bak" ] && [ ! -L "${panel_desktop}.bak" ] \ - && [ ! -e "$panel_icon" ] \ - || fail "skipped panel did not restore and consume displaced foreign launchers" -! manifest_has_path "$manifest" "$panel_command" || fail "skipped panel retained command ownership" - -# Put the runtime payload back before restoring its host projections. Runtime -# payload changes belong to the runtime transaction, not this integration one. -write_build_info "$base/runtime" "$base/BUILD-INFO.txt" built -run_component_install "$base" "$base/runtime" --rollback "$txn" \ - >"$base/rollback.out" 2>"$base/rollback.err" \ - || fail "panel integration rollback failed" -[ -L "$panel_command" ] && [ "$(readlink -- "$panel_command")" = "$base/runtime/bin/pipeasio-settings" ] \ - && [ -f "$panel_desktop" ] && [ -f "$panel_icon" ] \ - && [ -L "${panel_command}.bak" ] \ - && [ "$(readlink -- "${panel_command}.bak")" = "$user_panel_target" ] \ - && grep -qF '# local panel launcher drift' "${panel_desktop}.bak" \ - || fail "rollback did not restore panel host projections" -! manifest_has_path "$manifest" "$panel_command" \ - || fail "component rollback rewound disposable installed-file inventory" -ok "panel files participate in component rollback without rewinding uninstall inventory" - +# A skipped panel payload does not remove, restore, or reinterpret existing host +# files. Earlier overwrite backups remain inert manual recovery objects. +panel_command_target_before="$(readlink -- "$panel_command")" +panel_desktop_digest_before="$(sha256sum -- "$panel_desktop" | awk '{print $1}')" +panel_icon_digest_before="$(sha256sum -- "$panel_icon" | awk '{print $1}')" +panel_backup_digest_before="$(sha256sum -- "$panel_desktop_backup" | awk '{print $1}')" write_build_info "$base/runtime" "$base/BUILD-INFO.txt" skipped -run_component_install "$base" "$base/runtime" --integration-only \ +run_component_install "$base" "$base/runtime" --integration-only --yes \ >"$base/install-skipped.out" 2>"$base/install-skipped.err" \ - || fail "committed built-to-skipped transition failed" -[ ! -e "$panel_command" ] && [ ! -L "$panel_command" ] \ - && [ ! -e "$panel_desktop" ] && [ -f "$panel_icon" ] \ - || fail "committed skipped panel did not remove recognised launchers and preserve its unverified icon" -grep -qF "preserved your existing $panel_icon" "$base/install-skipped.out" \ - || fail "unverified panel icon preservation was not reported" -! manifest_has_path "$manifest" "$panel_command" || fail "built-to-skipped transition left stale command ownership" -! manifest_has_path "$manifest" "$panel_desktop" || fail "built-to-skipped transition left stale desktop ownership" -! manifest_has_path "$manifest" "$panel_icon" || fail "built-to-skipped transition left stale icon ownership" -ok "built-to-skipped transition removes recognised launchers and preserves an unverified icon" + || fail "skipped-panel integration failed" +[ -L "$panel_command" ] \ + && [ "$(readlink -- "$panel_command")" = "$panel_command_target_before" ] \ + && [ "$(sha256sum -- "$panel_desktop" | awk '{print $1}')" = "$panel_desktop_digest_before" ] \ + && [ "$(sha256sum -- "$panel_icon" | awk '{print $1}')" = "$panel_icon_digest_before" ] \ + || fail "skipped panel payload changed existing panel paths" +[ "$(sha256sum -- "$panel_desktop_backup" | awk '{print $1}')" = "$panel_backup_digest_before" ] \ + || fail "skipped panel payload changed an inert backup" +grep -qF 'PipeASIO Settings was not packaged; existing shortcut files were left unchanged' \ + "$base/install-skipped.out" \ + || fail "skipped panel payload did not report that existing paths were kept" +ok "skipped panel payload leaves panel paths and inert backups unchanged" base="$(new_env panel-desktop-escaping)" install_fake_host_tools "$base" @@ -1715,503 +1610,62 @@ grep -qxF "Exec=\"$escaped_runtime/bin/pipeasio-settings\"" \ || fail "panel desktop Exec did not quote spaces and escape literal percent signs" ok "panel desktop Exec safely represents custom runtime paths" -base="$(new_env skipped-restores-prestate)" -install_fake_host_tools "$base" -make_runtime "$base/runtime" "$base/BUILD-INFO.txt" built -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/install-built.out" 2>"$base/install-built.err" \ - || fail "prestate transition fixture install failed" -prestate_command="$base/home/.local/bin/pipeasio-settings" -prestate_manifest="$base/xdg/state/ableton-wine/install-manifest.tsv" -prestate_index="$base/xdg/state/ableton-wine/install-prestate.tsv" -prestate_id="$(printf '%s' "$prestate_command" | sha256sum | awk '{print $1}')" -prestate_backup="$base/xdg/state/ableton-wine/install-prestate/$prestate_id" -printf '#!/bin/sh\necho original-prestate\n' > "$base/original-prestate-command" -chmod 755 "$base/original-prestate-command" -mkdir -p -- "$(dirname "$prestate_backup")" -ln -s -- "$base/original-prestate-command" "$prestate_backup" -printf 'present\t%s\t%s\n' "$prestate_command" "$prestate_backup" >> "$prestate_index" -write_build_info "$base/runtime" "$base/BUILD-INFO.txt" skipped -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/install-skipped.out" 2>"$base/install-skipped.err" \ - || fail "built-to-skipped transition with prestate failed" -[ -L "$prestate_command" ] \ - && [ "$(readlink -- "$prestate_command")" = "$base/original-prestate-command" ] \ - || fail "built-to-skipped transition did not immediately restore foreign prestate" -! manifest_has_path "$prestate_manifest" "$prestate_command" \ - || fail "restored foreign prestate remained claimed after built-to-skipped transition" -grep -qF "restored your previous $prestate_command" "$base/install-skipped.out" \ - || fail "immediate prestate restoration was not reported" -ok "built-to-skipped transition restores exact foreign prestate before de-ownership" - -[ ! -e "$prestate_backup" ] && [ ! -L "$prestate_backup" ] \ - || fail "restored prestate backup was not consumed" -! awk -F '\t' -v p="$prestate_command" '$2 == p { found=1 } END { exit !found }' \ - "$prestate_index" || fail "restored prestate index record was not consumed" -rm -f -- "$prestate_command" -write_build_info "$base/runtime" "$base/BUILD-INFO.txt" built -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/install-built-again.out" 2>"$base/install-built-again.err" \ - || fail "second built-panel epoch failed" -write_build_info "$base/runtime" "$base/BUILD-INFO.txt" skipped -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/install-skipped-again.out" 2>"$base/install-skipped-again.err" \ - || fail "second skipped-panel epoch failed" -[ ! -e "$prestate_command" ] && [ ! -L "$prestate_command" ] \ - || fail "consumed foreign prestate was resurrected in a later panel epoch" -ok "restored panel prestate is consumed and cannot be resurrected" - -base="$(new_env skipped-restores-absent-managed-prestate)" -install_fake_host_tools "$base" -make_runtime "$base/runtime" "$base/BUILD-INFO.txt" built -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/install-built.out" 2>"$base/install-built.err" \ - || fail "absent-managed prestate fixture install failed" -absent_command="$base/home/.local/bin/pipeasio-settings" -absent_manifest="$base/xdg/state/ableton-wine/install-manifest.tsv" -absent_index="$base/xdg/state/ableton-wine/install-prestate.tsv" -absent_id="$(printf '%s' "$absent_command" | sha256sum | awk '{print $1}')" -absent_backup="$base/xdg/state/ableton-wine/install-prestate/$absent_id" -printf '#!/bin/sh\necho original-absent-prestate\n' > "$base/original-absent-command" -chmod 755 "$base/original-absent-command" -mkdir -p -- "$(dirname "$absent_backup")" -ln -s -- "$base/original-absent-command" "$absent_backup" -printf 'present\t%s\t%s\n' "$absent_command" "$absent_backup" >> "$absent_index" -rm -f -- "$absent_command" -write_build_info "$base/runtime" "$base/BUILD-INFO.txt" skipped -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/install-skipped.out" 2>"$base/install-skipped.err" \ - || fail "skipped transition failed for an absent managed path" -[ -L "$absent_command" ] \ - && [ "$(readlink -- "$absent_command")" = "$base/original-absent-command" ] \ - || fail "absent managed panel path stranded its foreign prestate" -! manifest_has_path "$absent_manifest" "$absent_command" \ - || fail "restored absent-path prestate remained claimed" -ok "absent managed panel path restores prestate before de-ownership" - -base="$(new_env runtime-reconcile-absent-claimed-panel)" -install_fake_host_tools "$base" -make_runtime "$base/runtime" "$base/BUILD-INFO.txt" built -printf 'format=1\nname=wine-d2d1-nspa-11.13\n' > "$base/runtime/.ableton-linux-runtime" -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/install-built.out" 2>"$base/install-built.err" \ - || fail "runtime reconcile panel fixture install failed" -reconcile_command="$base/home/.local/bin/pipeasio-settings" -reconcile_manifest="$base/xdg/state/ableton-wine/install-manifest.tsv" -reconcile_index="$base/xdg/state/ableton-wine/install-prestate.tsv" -reconcile_id="$(printf '%s' "$reconcile_command" | sha256sum | awk '{print $1}')" -reconcile_backup="$base/xdg/state/ableton-wine/install-prestate/$reconcile_id" -printf '#!/bin/sh\necho original-reconcile-prestate\n' > "$base/original-reconcile-command" -chmod 755 "$base/original-reconcile-command" -mkdir -p -- "$(dirname "$reconcile_backup")" -ln -s -- "$base/original-reconcile-command" "$reconcile_backup" -printf 'present\t%s\t%s\n' "$reconcile_command" "$reconcile_backup" \ - >> "$reconcile_index" -rm -f -- "$reconcile_command" -make_runtime_only_kit "$base" -if ! run_isolated "$base" env \ - PATH="$base/fakebin:$PATH" \ - ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ - PROBE_CLIENT=1.4.2 PROBE_DAEMON=1.4.2 \ - bash "$base/kit/scripts/install.sh" --runtime-only --yes \ - >"$base/runtime-update.out" 2>"$base/runtime-update.err"; then - sed -n '1,120p' "$base/runtime-update.err" >&2 - fail "built runtime-only reconcile failed for an absent claimed panel path" -fi -[ -L "$reconcile_command" ] \ - && [ "$(readlink -- "$reconcile_command")" = "$base/runtime/bin/pipeasio-settings" ] \ - || fail "runtime-only reconcile did not reinstate the claimed panel command" -manifest_has_path "$reconcile_manifest" "$reconcile_command" \ - || fail "runtime-only reconcile de-owned the reinstated panel command" -[ -L "$reconcile_backup" ] \ - && [ "$(readlink -- "$reconcile_backup")" = "$base/original-reconcile-command" ] \ - || fail "runtime-only reconcile replaced the retained prestate backup" -grep -qF $'present\t'"$reconcile_command"$'\t'"$reconcile_backup" "$reconcile_index" \ - || fail "runtime-only reconcile discarded the retained prestate index" -if ! run_isolated "$base" env \ - PATH="$base/fakebin:$PATH" \ - ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ - ABLETON_LINK_MODE=off \ - bash "$here/uninstall.sh" --keep-prefix --yes \ - >"$base/uninstall.out" 2>"$base/uninstall.err"; then - sed -n '1,120p' "$base/uninstall.err" >&2 - fail "uninstall failed after runtime-only panel reconcile" -fi -[ -L "$reconcile_command" ] \ - && [ "$(readlink -- "$reconcile_command")" = "$base/original-reconcile-command" ] \ - || fail "uninstall did not restore prestate retained across runtime-only reconcile" -ok "built runtime-only reconcile reinstates claimed paths and preserves their uninstall prestate" - -# A runtime-only installation replaces existing PipeASIO command and desktop -# launchers. Ownership records preserve each prior object for uninstall. -base="$(new_env foreign-panel-runtime-only)" -make_runtime_only_kit "$base" -install_fake_host_tools "$base" -runtime_command="$base/home/.local/bin/pipeasio-settings" -runtime_desktop="$base/xdg/data/applications/pipeasio-settings.desktop" -mkdir -p -- "$(dirname "$runtime_command")" "$(dirname "$runtime_desktop")" -printf '#!/bin/sh\necho runtime-only-foreign-command\n' > "$runtime_command" -chmod 755 "$runtime_command" -printf '[Desktop Entry]\nName=Runtime-only foreign panel\n' > "$runtime_desktop" -cp -- "$runtime_command" "$base/foreign-command.before" -cp -- "$runtime_desktop" "$base/foreign-desktop.before" -if ! run_isolated "$base" env \ - PATH="$base/fakebin:$PATH" \ - ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ - PROBE_CLIENT=1.4.2 PROBE_DAEMON=1.4.2 \ - bash "$base/kit/scripts/install.sh" --runtime-only --yes \ - >"$base/install.out" 2>"$base/install.err"; then - sed -n '1,120p' "$base/install.err" >&2 - fail "runtime-only installation failed for foreign panel launchers" -fi -[ -L "$runtime_command" ] \ - && [ "$(readlink -- "$runtime_command")" = "$base/runtime/bin/pipeasio-settings" ] \ - || fail "runtime-only installation wrote the wrong panel command" -cmp -s -- "$base/foreign-command.before" "${runtime_command}.bak" \ - || fail "runtime-only installation saved the wrong panel command backup" -grep -qxF 'Name=PipeASIO Settings' "$runtime_desktop" \ - || fail "runtime-only installation wrote the wrong panel entry" -cmp -s -- "$base/foreign-desktop.before" "${runtime_desktop}.bak" \ - || fail "runtime-only installation saved the wrong panel entry backup" -runtime_manifest="$base/xdg/state/ableton-wine/install-manifest.tsv" -manifest_has_path "$runtime_manifest" "$runtime_command" \ - || fail "runtime-only installation omitted panel command ownership" -manifest_has_path "$runtime_manifest" "$runtime_desktop" \ - || fail "runtime-only installation omitted panel entry ownership" -manifest_has_path "$runtime_manifest" "${runtime_command}.bak" \ - || fail "runtime-only installation omitted panel command backup ownership" -manifest_has_path "$runtime_manifest" "${runtime_desktop}.bak" \ - || fail "runtime-only installation omitted panel entry backup ownership" -run_isolated "$base" env \ - PATH="$base/fakebin:$PATH" \ - ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ - ABLETON_LINK_MODE=off \ - bash "$here/uninstall.sh" --keep-prefix --yes \ - >"$base/uninstall.out" 2>"$base/uninstall.err" \ - || { sed -n '1,120p' "$base/uninstall.err" >&2; fail "uninstall failed after runtime-only launcher replacement"; } -cmp -s -- "$base/foreign-command.before" "$runtime_command" \ - || fail "uninstall restored the wrong foreign panel command" -cmp -s -- "$base/foreign-desktop.before" "$runtime_desktop" \ - || fail "uninstall restored the wrong foreign panel entry" -[ ! -e "${runtime_command}.bak" ] && [ ! -L "${runtime_command}.bak" ] \ - || fail "uninstall retained the runtime-only panel command backup" -[ ! -e "${runtime_desktop}.bak" ] && [ ! -L "${runtime_desktop}.bak" ] \ - || fail "uninstall retained the runtime-only panel entry backup" -ok "runtime-only launcher replacement records ownership for uninstall" - -base="$(new_env foreign-panel-command)" -install_fake_host_tools "$base" -make_runtime "$base/runtime" "$base/BUILD-INFO.txt" built -mkdir -p -- "$base/home/.local/bin" -cat > "$base/home/.local/bin/pipeasio-settings" <<'EOF' -#!/bin/sh -echo foreign-settings-command -EOF -chmod 755 "$base/home/.local/bin/pipeasio-settings" -foreign_digest="$(sha256sum -- "$base/home/.local/bin/pipeasio-settings" | awk '{print $1}')" -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/install.out" 2>"$base/install.err" \ - || fail "foreign panel command prevented otherwise safe integration" -[ -L "$base/home/.local/bin/pipeasio-settings" ] \ - && [ "$(readlink -- "$base/home/.local/bin/pipeasio-settings")" = "$base/runtime/bin/pipeasio-settings" ] \ - || fail "foreign pipeasio-settings command was not replaced" -grep -qxF 'echo foreign-settings-command' "$base/home/.local/bin/pipeasio-settings.bak" \ - || fail "foreign panel command was not saved as pipeasio-settings.bak" -foreign_manifest="$base/xdg/state/ableton-wine/install-manifest.tsv" -manifest_has_path "$foreign_manifest" "$base/home/.local/bin/pipeasio-settings" \ - || fail "installed pipeasio-settings command was not claimed in the manifest" -foreign_id="$(printf '%s' "$base/home/.local/bin/pipeasio-settings" | sha256sum | awk '{print $1}')" -[ ! -e "$base/xdg/state/ableton-wine/install-prestate/$foreign_id" ] \ - && { [ ! -r "$base/xdg/state/ableton-wine/install-prestate.tsv" ] \ - || ! awk -F '\t' -v p="$base/home/.local/bin/pipeasio-settings" \ - '$2==p { found=1 } END { exit !found }' \ - "$base/xdg/state/ableton-wine/install-prestate.tsv"; } \ - || fail "fresh panel repair duplicated its adjacent backup in legacy prestate" -write_build_info "$base/runtime" "$base/BUILD-INFO.txt" skipped -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/skipped.out" 2>"$base/skipped.err" \ - || fail "skipped-panel update failed while restoring the displaced command" -[ ! -L "$base/home/.local/bin/pipeasio-settings" ] \ - && [ "$(sha256sum -- "$base/home/.local/bin/pipeasio-settings" | awk '{print $1}')" = "$foreign_digest" ] \ - || fail "skipped-panel transition did not restore the displaced command" -! manifest_has_path "$foreign_manifest" "$base/home/.local/bin/pipeasio-settings" \ - || fail "restored panel command remained claimed in the manifest" -! manifest_has_path "$foreign_manifest" "$base/home/.local/bin/pipeasio-settings.bak" \ - || fail "consumed adjacent panel backup remained claimed in the manifest" -[ ! -e "$base/home/.local/bin/pipeasio-settings.bak" ] \ - && [ ! -L "$base/home/.local/bin/pipeasio-settings.bak" ] \ - || fail "skipped-panel transition retained its consumed adjacent backup" -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/retry.out" 2>"$base/retry.err" \ - || fail "repeated skipped-panel repair rejected the already-restored command" -[ ! -L "$base/home/.local/bin/pipeasio-settings" ] \ - && [ "$(sha256sum -- "$base/home/.local/bin/pipeasio-settings" | awk '{print $1}')" = "$foreign_digest" ] \ - && [ ! -e "$base/home/.local/bin/pipeasio-settings.bak" ] \ - || fail "repeated skipped-panel repair changed the restored foreign command" -ok "foreign pipeasio-settings command is backed up, restored once, and remains stable on retry" - -base="$(new_env modified-panel-command-with-backup)" -install_fake_host_tools "$base" -make_runtime "$base/runtime" "$base/BUILD-INFO.txt" built -panel_command="$base/home/.local/bin/pipeasio-settings" -mkdir -p -- "$(dirname "$panel_command")" -printf 'foreign panel bytes\n' > "$panel_command" -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/install.out" 2>"$base/install.err" \ - || fail "modified-panel fixture could not install its managed projection" -rm -f -- "$panel_command" -printf 'user modified panel bytes\n' > "$panel_command" -write_build_info "$base/runtime" "$base/BUILD-INFO.txt" skipped -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/skipped.out" 2>"$base/skipped.err" \ - || fail "modified panel command made skipped-panel repair fatal" -grep -qxF 'user modified panel bytes' "$panel_command" \ - && grep -qxF 'foreign panel bytes' "$panel_command.bak" \ - || fail "skipped-panel repair changed a modified command or its saved foreign copy" -[ "$(grep -cF "Kept both $panel_command and $panel_command.bak because the current shortcut was changed." \ - "$base/skipped.err")" -eq 1 ] \ - || fail "modified command and saved copy did not produce exactly one plain warning" -ok "skipped-panel repair preserves a modified command and its saved foreign copy with one warning" - -base="$(new_env panel-command-missing-backup)" +# Keep leaves the current panel command unchanged and continues through later panel paths. +base="$(new_env panel-choice-keep)" install_fake_host_tools "$base" make_runtime "$base/runtime" "$base/BUILD-INFO.txt" built panel_command="$base/home/.local/bin/pipeasio-settings" +panel_desktop="$base/xdg/data/applications/pipeasio-settings.desktop" +panel_icon="$base/xdg/data/icons/hicolor/scalable/apps/pipeasio.svg" mkdir -p -- "$(dirname "$panel_command")" -printf 'foreign panel bytes\n' > "$panel_command" -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/install.out" 2>"$base/install.err" \ - || fail "missing-backup fixture could not install its managed projection" -rm -f -- "$panel_command.bak" -write_build_info "$base/runtime" "$base/BUILD-INFO.txt" skipped -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/skipped.out" 2>"$base/skipped.err" \ - || fail "missing saved panel command made skipped-panel repair fatal" -[ -L "$panel_command" ] \ - && [ "$(readlink -- "$panel_command")" = "$base/runtime/bin/pipeasio-settings" ] \ - && [ ! -e "$panel_command.bak" ] && [ ! -L "$panel_command.bak" ] \ - || fail "skipped-panel repair changed the live command after its saved copy disappeared" -[ "$(grep -cF "Kept $panel_command because its saved earlier shortcut is missing." \ - "$base/skipped.err")" -eq 1 ] \ - || fail "missing saved panel command did not produce one accurate warning" -if manifest_has_path "$base/xdg/state/ableton-wine/install-manifest.tsv" "$panel_command" \ - || manifest_has_path "$base/xdg/state/ableton-wine/install-manifest.tsv" "$panel_command.bak"; then - fail "missing saved panel command left an unsafe adjacent ownership claim" +printf 'keep this panel command\n' > "$panel_command" +if ! printf 'Keep\n' | run_component_install "$base" "$base/runtime" --integration-only \ + >"$base/out" 2>"$base/err"; then + fail "Keep stopped the panel mapping loop" +fi +grep -qxF "$panel_command exists." "$base/err" \ + || fail "panel Keep prompt did not name its destination" +grep -qF '│ ├─ FILES FROM AN EARLIER INSTALLATION ALREADY EXIST' "$base/out" \ + || fail "panel Keep choice did not show the required prompt" +grep -qxF 'keep this panel command' "$panel_command" \ + || fail "panel Keep choice changed its destination" +[ -f "$panel_desktop" ] && [ -f "$panel_icon" ] \ + || fail "panel Keep choice prevented later panel mappings" +if [ -d "$base/xdg/state/ableton-wine/backups" ] \ + && find "$base/xdg/state/ableton-wine/backups" -name 'pipeasio-settings.bak-*' \ + -print -quit | grep -q .; then + fail "panel Keep choice created a backup for an unchanged destination" fi -ok "skipped-panel repair keeps the live command and accurately reports a missing saved copy" +ok "Keep leaves the panel command unchanged and continues through later panel mappings" -base="$(new_env panel-adjacent-stale-prestate)" +# Cancel keeps the current panel command, retains earlier integration copies, +# and prevents every later panel mapping. +base="$(new_env panel-choice-cancel)" install_fake_host_tools "$base" make_runtime "$base/runtime" "$base/BUILD-INFO.txt" built panel_command="$base/home/.local/bin/pipeasio-settings" +panel_desktop="$base/xdg/data/applications/pipeasio-settings.desktop" +panel_icon="$base/xdg/data/icons/hicolor/scalable/apps/pipeasio.svg" mkdir -p -- "$(dirname "$panel_command")" -printf 'foreign panel bytes\n' > "$panel_command" -run_component_install "$base" "$base/runtime" --integration-only \ - >"$base/install.out" 2>"$base/install.err" \ - || fail "stale-prestate fixture could not install its managed projection" -cp -a -- "$panel_command" "$base/current.before" -cp -a -- "$panel_command.bak" "$base/saved.before" -printf 'present\t%s\t/tmp/untrusted-panel-backup\n' "$base/unrelated-path" \ - > "$base/xdg/state/ableton-wine/install-prestate.tsv" -if ! run_isolated "$base" env \ - ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ - bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - . "$1/lib/pipeasio.sh" - rc=0 - ableton_pipeasio_restore_adjacent_backup "$2" || rc=$? - [ "$rc" -eq 2 ] - ' _ "$here" "$panel_command"; then - fail "adjacent panel recovery trusted a pair while legacy prestate was unverifiable" -fi -cmp -s -- "$base/current.before" "$panel_command" \ - && cmp -s -- "$base/saved.before" "$panel_command.bak" \ - && grep -qxF $'present\t'"$base/unrelated-path"$'\t/tmp/untrusted-panel-backup' \ - "$base/xdg/state/ableton-wine/install-prestate.tsv" \ - || fail "refusing ambiguous adjacent recovery changed live or legacy recovery data" -ok "adjacent panel recovery defers when any legacy prestate inventory is unverifiable" - -install_managed_link() -{ - local base="$1" link_text="$2" target="$3" txn="$4" - mkdir -p -- "$txn" - # shellcheck disable=SC2016 - run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - ableton_install_symlink "$2" "$3" - ableton_write_ownership_manifest - rm -f -- "$ABLETON_TRANSACTION_DIR/active" - ' _ "$here" "$link_text" "$target" -} - -install_managed_file() -{ - local base="$1" source="$2" target="$3" txn="$4" - mkdir -p -- "$txn" - # shellcheck disable=SC2016 - run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - ableton_install_file 755 "$2" "$3" - ableton_write_ownership_manifest - rm -f -- "$ABLETON_TRANSACTION_DIR/active" - ' _ "$here" "$source" "$target" -} - -run_minimal_uninstall() -{ - local base="$1" - install_fake_host_tools "$base" - run_isolated "$base" env \ - PATH="$base/fakebin:$PATH" \ - ABLETON_WINE_ROOT="$base/runtime" \ - ABLETON_WINEPREFIX="$base/prefix" \ - ABLETON_LINK_MODE=off \ - bash "$here/uninstall.sh" --keep-prefix --yes -} - -base="$(new_env relative-symlink-prestate)" -managed_link="$base/home/.local/bin/pipeasio-settings" -mkdir -p -- "$(dirname "$managed_link")" -printf '#!/bin/sh\necho original\n' > "$(dirname "$managed_link")/original-foreign-command" -chmod 755 "$(dirname "$managed_link")/original-foreign-command" -ln -s -- original-foreign-command "$managed_link" -install_managed_link "$base" "$base/managed-panel-v1" "$managed_link" "$base/txn-v1" \ - || fail "first managed symlink update failed" -[ "$(readlink -- "$managed_link")" = "$base/managed-panel-v1" ] \ - || fail "first managed symlink update did not take effect" -install_managed_link "$base" "$base/managed-panel-v2" "$managed_link" "$base/txn-v2" \ - || fail "second managed symlink update failed" -[ "$(readlink -- "$managed_link")" = "$base/managed-panel-v2" ] \ - || fail "second managed symlink update did not take effect" -run_minimal_uninstall "$base" >"$base/uninstall.out" 2>"$base/uninstall.err" \ - || fail "uninstall failed while restoring symlink prestate" -[ -L "$managed_link" ] \ - && [ -e "$managed_link" ] \ - && [ "$(readlink -- "$managed_link")" = original-foreign-command ] \ - || fail "uninstall did not restore the original relative foreign symlink" -ok "two managed symlink updates retain and restore relative foreign prestate" - -base="$(new_env regular-file-prestate)" -managed_file="$base/home/.local/bin/pipeasio-settings" -mkdir -p -- "$(dirname "$managed_file")" -printf '#!/bin/sh\necho original-foreign-file\n' > "$managed_file" -printf '#!/bin/sh\necho managed-v1\n' > "$base/managed-file-v1" -printf '#!/bin/sh\necho managed-v2\n' > "$base/managed-file-v2" -chmod 755 "$managed_file" "$base/managed-file-v1" "$base/managed-file-v2" -cp -a -- "$managed_file" "$base/original-foreign-file.before" -install_managed_file "$base" "$base/managed-file-v1" "$managed_file" "$base/file-txn-v1" \ - || fail "first managed regular-file update failed" -cmp -s -- "$base/managed-file-v1" "$managed_file" \ - || fail "first managed regular-file update did not take effect" -install_managed_file "$base" "$base/managed-file-v2" "$managed_file" "$base/file-txn-v2" \ - || fail "second managed regular-file update failed" -cmp -s -- "$base/managed-file-v2" "$managed_file" \ - || fail "second managed regular-file update did not take effect" -run_minimal_uninstall "$base" >"$base/uninstall.out" 2>"$base/uninstall.err" \ - || fail "uninstall failed while restoring regular-file prestate" -cmp -s -- "$base/original-foreign-file.before" "$managed_file" \ - || fail "uninstall did not restore the original foreign regular file" -ok "two managed regular-file updates retain and restore foreign prestate" - -base="$(new_env retargeted-managed-link)" -managed_link="$base/home/.local/bin/pipeasio-settings" -install_managed_link "$base" "$base/managed-panel" "$managed_link" "$base/txn" \ - || fail "managed symlink fixture install failed" -rm -f -- "$managed_link" -ln -s -- "$base/user-retargeted-panel" "$managed_link" -run_minimal_uninstall "$base" >"$base/uninstall.out" 2>"$base/uninstall.err" \ - || fail "uninstall treated a retargeted managed link as a fatal residual" -[ -L "$managed_link" ] \ - && [ "$(readlink -- "$managed_link")" = "$base/user-retargeted-panel" ] \ - || fail "uninstall removed a user-retargeted managed link" -grep -qF "kept a link at $managed_link because it was changed or points somewhere else" "$base/uninstall.err" \ - || fail "preserved retargeted link was not reported" -ok "uninstall preserves a user-retargeted managed symlink" - -make_legacy_panel_fixture() -{ - local base="$1" command_mode="${2:-managed}" - local command="$base/home/.local/bin/pipeasio-settings" - local desktop="$base/xdg/data/applications/pipeasio-settings.desktop" - local icon="$base/xdg/data/icons/hicolor/scalable/apps/pipeasio.svg" - make_runtime "$base/runtime" "$base/BUILD-INFO.txt" built - printf 'format=1\nname=wine-d2d1-nspa-11.13\n' > "$base/runtime/.ableton-linux-runtime" - mkdir -p -- "$base/home/.local/bin" "$(dirname "$desktop")" \ - "$(dirname "$icon")" "$base/xdg/data/ableton-wine" - printf '2026.08.12.999\n' > "$base/xdg/data/ableton-wine/VERSION" - case "$command_mode" in - managed) ln -s -- "$base/runtime/bin/pipeasio-settings" "$command" ;; - foreign) - printf '#!/bin/sh\necho foreign-legacy-panel\n' > "$base/foreign-panel-command" - chmod 755 "$base/foreign-panel-command" - ln -s -- "$base/foreign-panel-command" "$command" - ;; - *) fail "unknown legacy panel fixture mode" ;; - esac - cat > "$desktop" <"$base/out" 2>"$base/err" \ - || fail "uninstall failed while cleaning legacy panel projections" -[ ! -e "$base/home/.local/bin/pipeasio-settings" ] \ - && [ ! -L "$base/home/.local/bin/pipeasio-settings" ] \ - && [ ! -e "$base/xdg/data/applications/pipeasio-settings.desktop" ] \ - && [ ! -e "$base/xdg/data/icons/hicolor/scalable/apps/pipeasio.svg" ] \ - || fail "uninstall left a recognisable legacy panel projection" -grep -qF 'removed a PipeASIO Settings file from an older release:' "$base/out" \ - || fail "legacy panel cleanup was not reported" -ok "uninstall removes exact pre-manifest PipeASIO panel projections" - -base="$(new_env foreign-legacy-panel-symlink)" -make_legacy_panel_fixture "$base" foreign -run_minimal_uninstall "$base" >"$base/out" 2>"$base/err" \ - || fail "foreign legacy panel symlink made uninstall fail" -[ -L "$base/home/.local/bin/pipeasio-settings" ] \ - && [ "$(readlink -- "$base/home/.local/bin/pipeasio-settings")" \ - = "$base/foreign-panel-command" ] \ - || fail "uninstall removed a foreign panel symlink" -grep -qF "kept an independently installed PipeASIO panel file at $base/home/.local/bin/pipeasio-settings" \ - "$base/err" || fail "foreign legacy panel symlink preservation was not reported" -ok "legacy cleanup preserves an independently targeted panel symlink" - -base="$(new_env unsafe-uninstall-no-state)" -mkdir -p -- "$base/unrecognised-runtime" -printf 'foreign runtime\n' > "$base/unrecognised-runtime/foreign" -if run_isolated "$base" env \ - ABLETON_WINE_ROOT="$base/unrecognised-runtime" \ - ABLETON_WINEPREFIX="$base/prefix" ABLETON_LINK_MODE=off \ - bash "$here/uninstall.sh" --keep-prefix --yes \ - >"$base/out" 2>"$base/err"; then - fail "unsafe uninstall accepted an unrecognised custom runtime" -fi -[ ! -e "$base/xdg/state/ableton-wine/.ableton-linux-state" ] \ - && [ ! -e "$base/xdg/state/ableton-wine" ] \ - || fail "unsafe uninstall refusal created an installer state marker" -[ -f "$base/unrecognised-runtime/foreign" ] \ - || fail "unsafe uninstall refusal changed the unrecognised runtime" -grep -qF 'The Wine runtime was not deleted because the installer could not confirm that it created it:' "$base/err" \ - || fail "unsafe uninstall refusal was not explicit" -ok "unsafe uninstall refuses before creating or claiming installer state" +printf 'cancel before replacing this command\n' > "$panel_command" +cancel_status=0 +printf 'a\n' | run_component_install "$base" "$base/runtime" --integration-only \ + >"$base/out" 2>"$base/err" || cancel_status=$? +[ "$cancel_status" -eq 4 ] || fail "panel Cancel did not return the cancellation status" +grep -qxF "$panel_command exists." "$base/err" \ + || fail "panel Cancel prompt did not name its destination" +grep -qF '│ ├─ FILES FROM AN EARLIER INSTALLATION ALREADY EXIST' "$base/out" \ + || fail "panel Cancel choice did not show the required prompt" +grep -qxF 'cancel before replacing this command' "$panel_command" \ + || fail "panel Cancel choice changed its destination" +[ -f "$base/xdg/data/ableton-wine/lib/live-options.sh" ] \ + || fail "panel Cancel unwound an earlier completed integration copy" +[ ! -e "$panel_desktop" ] && [ ! -e "$panel_icon" ] \ + || fail "panel Cancel did not stop later panel mappings" +ok "Cancel stops later panel mappings without unwinding earlier copies" + +# The minimal uninstaller has its own focused ownership, process, Trash, and +# scope boundary suite in test-uninstall-boundary.sh. make_registry_runtime() { @@ -2277,19 +1731,6 @@ EOF chmod 755 "$runtime/bin/wine" "$runtime/bin/wineserver" "$base/fakebin/systemctl" } -run_registry_uninstall() -{ - local base="$1" - shift - run_isolated "$base" env \ - PATH="$base/fakebin:$PATH" \ - ABLETON_WINE_ROOT="$base/runtime" \ - ABLETON_WINEPREFIX="$base/prefix" \ - ABLETON_LINK_MODE=off \ - ABLETON_TEST_REGISTRY_LOG="$base/registry.log" \ - ABLETON_TEST_REGISTRY_STATE="$base/registry-present" \ - "$@" bash "$here/uninstall.sh" --keep-prefix --yes -} make_rollback_fixture() { @@ -2306,8 +1747,7 @@ make_rollback_fixture() ROLLBACK_INSTALLER_CONFIG="$base/xdg/config/ableton-wine/config" ROLLBACK_PIPEASIO_CONFIG="$base/xdg/config/pipeasio/config.ini" mkdir -p -- "$(dirname "$ROLLBACK_INSTALLER_CONFIG")" \ - "$(dirname "$ROLLBACK_PIPEASIO_CONFIG")" \ - "$saved/.ableton-linux-rollback" + "$(dirname "$ROLLBACK_PIPEASIO_CONFIG")" cat > "$ROLLBACK_INSTALLER_CONFIG" < "$ROLLBACK_PIPEASIO_CONFIG" - cat > "$saved/.ableton-linux-rollback/installer-config" < "$saved/.ableton-linux-rollback/pipeasio-config.ini" - cat > "$saved/.ableton-linux-rollback/metadata" < "$candidate/.ableton-linux-runtime" -transaction_target_authorised_on_exit \ - "$base" "$candidate/.ableton-linux-rollback/metadata" \ - || fail "valid reverse-runtime metadata target was refused during failure recovery" -printf 'format=1\nmalformed=yes\n' > "$candidate/.ableton-linux-runtime" -if transaction_target_authorised_on_exit \ - "$base" "$candidate/.ableton-linux-rollback/metadata"; then - fail "malformed reverse-runtime marker authorised a recovery target" -fi -ok "failure recovery accepts only metadata inside an exactly marked reverse runtime" - -base="$(new_env prestate-target-exit)" -prestate="$base/xdg/state/ableton-wine/install-prestate" -mkdir -p -- "$prestate" -valid_prestate="$prestate/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -transaction_target_authorised_on_exit "$base" "$valid_prestate" \ - || fail "valid pre-install backup target was refused during failure recovery" -if transaction_target_authorised_on_exit "$base" "$prestate/not-a-digest"; then - fail "malformed pre-install backup target was authorised during failure recovery" -fi -ok "failure recovery accepts only exact pre-install backup slots from an EXIT trap" - -base="$(new_env launcher-backup-target-exit)" -launcher_backup="$base/xdg/data/applications/max9.desktop.bak" -transaction_target_authorised_on_exit "$base" "$launcher_backup" \ - || fail "exact adjacent launcher backup was refused during failure recovery" -if transaction_target_authorised_on_exit \ - "$base" "$base/xdg/data/applications/not-a-launcher.desktop.bak"; then - fail "arbitrary adjacent backup target was authorised during failure recovery" -fi -if transaction_target_authorised_on_exit "$base" "${launcher_backup}.bak"; then - fail "nested launcher backup target was authorised during failure recovery" -fi -ok "failure recovery authorises only exact adjacent launcher backup names" base="$(new_env staged-prefix-register)" make_registry_runtime "$base" @@ -2452,34 +1812,6 @@ run_user_rollback "$base" env ABLETON_TEST_REGISTRY_STICKY=0 \ || fail "rollback executed the stale generated host probe" ok "rollback uses each sealed runtime probe instead of stale generated host copies" -base="$(new_env rollback-postcore-output)" -make_rollback_fixture "$base" -cat > "$base/postcore-failure.bash" <<'EOF' -echo() -{ - if [ "$*" = '== Restore saved settings ==' ]; then - return 74 - fi - builtin echo "$@" -} -EOF -run_user_rollback "$base" env BASH_ENV="$base/postcore-failure.bash" \ - ABLETON_TEST_REGISTRY_STICKY=0 >"$base/out" 2>"$base/err" \ - || fail "post-core output failure reported a committed rollback as failed" -[ -f "$base/runtime/previous-generation" ] \ - || fail "post-core output failure reversed a committed runtime rollback" -grep -qxF 'live_major=11' "$ROLLBACK_INSTALLER_CONFIG" \ - && grep -qxF 'buffer_size = 883' "$ROLLBACK_PIPEASIO_CONFIG" \ - || fail "post-core output failure stopped recorded settings restoration" -[ -L "$base/home/.local/bin/pipeasio-settings" ] \ - && [ "$(readlink -- "$base/home/.local/bin/pipeasio-settings")" \ - = "$base/runtime/bin/pipeasio-settings" ] \ - || fail "post-core output failure stopped panel repair" -if find "$base/xdg/state/ableton-wine/transactions" -type f -name active \ - -print -quit 2>/dev/null | grep -q .; then - fail "post-core output failure stopped rollback cleanup" -fi -ok "runtime rollback continues optional repair and cleanup after output fails" # The runtime swap has no host-file mutations to put in manifest.sh's file # journal. Neither that empty journal nor a failed progress write may become a @@ -2674,7 +2006,7 @@ printf 'previous generation\n' > "$saved/previous-generation" installer_config="$base/xdg/config/ableton-wine/config" pipeasio_config="$base/xdg/config/pipeasio/config.ini" mkdir -p -- "$(dirname "$installer_config")" "$(dirname "$pipeasio_config")" \ - "$saved/.ableton-linux-rollback" + "$saved" cat > "$installer_config" < "$pipeasio_config" -cat > "$saved/.ableton-linux-rollback/installer-config" < "$saved/.ableton-linux-rollback/pipeasio-config.ini" -cat > "$saved/.ableton-linux-rollback/metadata" </dev/null \ - || fail "failed rollback mutated active runtime rollback metadata" failed_record="$(find "$base/xdg/state/ableton-wine/transactions" -mindepth 2 \ -maxdepth 2 -type f -name FAILURE -print | head -n 1)" [ -n "$failed_record" ] \ @@ -2790,54 +2089,6 @@ grep -qF 'The previous Wine version could not be restored automatically: origina || fail "failed original registration printed a false restored claim" ok "post-swap failure restores the runtime layout and records failed re-registration" -# Change optional installer settings during the final desktop refresh, after -# the runtime swap and PipeASIO registration have committed. The concurrent -# settings generation must be preserved and must not turn a valid runtime -# rollback into a failure or start a second restoration pass. -base="$(new_env rollback-post-core-config-drift)" -make_rollback_fixture "$base" -cp -- "$ROLLBACK_INSTALLER_CONFIG" "$base/installer-config.before" -cp -- "$ROLLBACK_PIPEASIO_CONFIG" "$base/pipeasio-config.before" -printf '0\n' > "$base/update-desktop-count" -cat > "$base/fakebin/update-desktop-database" <<'EOF' -#!/bin/bash -set -euo pipefail -count="$(cat "${ABLETON_TEST_UPDATE_COUNT:?}")" -count=$((count + 1)) -printf '%s\n' "$count" > "$ABLETON_TEST_UPDATE_COUNT" -if [ "$count" -ge 2 ] && [ ! -e "${ABLETON_TEST_COMMIT_DRIFT_FIRED:?}" ]; then - cp -- "${ABLETON_TEST_COMMIT_DRIFT_SOURCE:?}" \ - "${ABLETON_TEST_COMMIT_DRIFT_TARGET:?}" - : > "$ABLETON_TEST_COMMIT_DRIFT_FIRED" -fi -exit 0 -EOF -chmod 755 "$base/fakebin/update-desktop-database" -run_user_rollback "$base" env \ - ABLETON_TEST_REGISTRY_STICKY=0 \ - ABLETON_TEST_UPDATE_COUNT="$base/update-desktop-count" \ - ABLETON_TEST_COMMIT_DRIFT_FIRED="$base/commit-drift-fired" \ - ABLETON_TEST_COMMIT_DRIFT_SOURCE="$base/installer-config.before" \ - ABLETON_TEST_COMMIT_DRIFT_TARGET="$ROLLBACK_INSTALLER_CONFIG" \ - >"$base/out" 2>"$base/err" \ - || fail "post-core installer-settings drift invalidated a restored runtime" -[ -e "$base/commit-drift-fired" ] \ - || fail "post-core installer-settings drift fixture did not mutate its destination" -[ -f "$base/runtime/previous-generation" ] \ - && [ ! -e "$ROLLBACK_SAVED" ] && [ ! -L "$ROLLBACK_SAVED" ] \ - && [ -e "$base/registry-present" ] \ - && cmp -s -- "$ROLLBACK_INSTALLER_CONFIG" "$base/installer-config.before" \ - && grep -qF 'buffer_size = 883' "$ROLLBACK_PIPEASIO_CONFIG" \ - || fail "post-core installer-settings drift reversed Wine or changed the concurrent settings generation" -! find "$base/xdg/state/ableton-wine/transactions" -mindepth 2 \ - -maxdepth 2 -type f -name FAILURE -print -quit | grep -q . \ - || fail "post-core installer-settings drift invented a rollback failure" -! find "$base/xdg/state/ableton-wine/transactions" -mindepth 2 \ - -maxdepth 2 -type f -name active -print -quit | grep -q . \ - || fail "post-core installer-settings drift retained an active rollback" -grep -qxF 'OK: The previous Wine version is restored.' "$base/out" \ - || fail "post-core installer-settings drift hid the restored runtime outcome" -ok "post-core installer-settings drift is preserved without invalidating the restored runtime" # Once the runtime swap and PipeASIO registration have committed, cleanup # errors are not rollback errors. @@ -2857,1403 +2108,55 @@ for argument do if [ "\${ABLETON_TEST_CLEANUP_FAILURE:?}" = directory ]; then case "\$argument" in "\$transaction_root"/rollback.*) - if [ "\${argument%/*}" = "\$transaction_root" ]; then - printf 'directory\n' > "\${ABLETON_TEST_CLEANUP_ATTEMPT:?}" - exit 89 - fi - ;; - esac - fi -done -exec "$real_rm" "\$@" -EOF - chmod 755 "$base/fakebin/rm" - run_user_rollback "$base" env \ - ABLETON_TEST_REGISTRY_STICKY=0 \ - ABLETON_TEST_CLEANUP_FAILURE="$cleanup_failure" \ - ABLETON_TEST_CLEANUP_ATTEMPT="$base/cleanup-attempt" \ - >"$base/out" 2>"$base/err" \ - || fail "committed $cleanup_failure cleanup failure invalidated the restored runtime" - grep -qxF "$cleanup_failure" "$base/cleanup-attempt" \ - || fail "committed $cleanup_failure cleanup fixture did not reach its boundary" - cleanup_txn="$(find "$base/xdg/state/ableton-wine/transactions" -mindepth 1 \ - -maxdepth 1 -type d -name 'rollback.*' -print | head -n 1)" - [ -n "$cleanup_txn" ] \ - || fail "committed $cleanup_failure cleanup did not retain its recovery directory" - [ ! -e "$cleanup_txn/COMMITTED_CLEANUP_FAILURE" ] \ - || fail "committed $cleanup_failure cleanup invented a failure record" - [ ! -e "$cleanup_txn/active" ] && [ ! -e "$cleanup_txn/files.tsv" ] \ - || fail "cleanup-only directory contains an unused host-file transaction" - [ ! -e "$cleanup_txn/FAILURE" ] \ - || fail "committed $cleanup_failure cleanup incorrectly entered restoration recovery" - [ -f "$base/runtime/previous-generation" ] \ - && [ ! -e "$ROLLBACK_SAVED" ] && [ ! -L "$ROLLBACK_SAVED" ] \ - && grep -qF 'live_major=11' "$ROLLBACK_INSTALLER_CONFIG" \ - && grep -qF 'buffer_size = 883' "$ROLLBACK_PIPEASIO_CONFIG" \ - || fail "committed $cleanup_failure cleanup failure reversed successful state" - grep -qF "The previous Wine version is restored, but temporary files remain at $cleanup_txn." "$base/err" \ - || fail "committed $cleanup_failure cleanup was not reported as cleanup-only" - ! grep -Eq 'could not be restored automatically|Wine version from before this attempt is back in place' "$base/err" \ - || fail "committed $cleanup_failure cleanup printed a restoration claim" -done -ok "postcommit recovery-directory cleanup failures preserve committed state and never claim restoration" - -base="$(new_env retained-prefix-unregister)" -make_registry_runtime "$base" -run_registry_uninstall "$base" env ABLETON_TEST_REGISTRY_STICKY=0 \ - >"$base/out" 2>"$base/err" || fail "retained-prefix uninstall failed after successful unregistration" -[ -e "$base/prefix/system.reg" ] || fail "retained-prefix uninstall deleted the prefix" -[ ! -e "$base/runtime" ] || fail "successful retained-prefix uninstall kept the runtime" -[ ! -e "$base/registry-present" ] || fail "retained-prefix uninstall left PipeASIO registered" -grep -Fq '{2D3CA9E2-1193-4C5D-B5FD-38798F3DC074}' "$base/registry.log" \ - || fail "retained-prefix uninstall did not query the PipeASIO CLSID" -! grep -Fq '{48D0C522-BFCC-45CC-8B84-17F25F33E6E8}' "$base/registry.log" \ - || fail "retained-prefix uninstall touched the legacy WineASIO CLSID" -ok "retained-prefix uninstall unregisters and verifies PipeASIO before deleting runtime" - -base="$(new_env retained-prefix-refusal)" -make_registry_runtime "$base" -if run_registry_uninstall "$base" env ABLETON_TEST_REGISTRY_STICKY=1 \ - >"$base/out" 2>"$base/err"; then - fail "uninstall succeeded while PipeASIO registration remained" -fi -[ -e "$base/runtime/.ableton-linux-runtime" ] \ - || fail "failed unregistration still deleted the runtime" -[ -e "$base/prefix/system.reg" ] && [ -e "$base/registry-present" ] \ - || fail "failed unregistration changed retained prefix state" -ok "failed PipeASIO unregistration preserves runtime and retained prefix" - -base="$(new_env retained-prefix-query-failure)" -make_registry_runtime "$base" -if run_registry_uninstall "$base" env ABLETON_TEST_REGISTRY_STICKY=1 \ - ABLETON_TEST_REGISTRY_QUERY_BROKEN=1 >"$base/out" 2>"$base/err"; then - fail "uninstall accepted failed registry queries as proof of PipeASIO absence" -fi -[ -e "$base/runtime/.ableton-linux-runtime" ] \ - && [ -e "$base/prefix/system.reg" ] && [ -e "$base/registry-present" ] \ - || fail "registry verification failure did not retain runtime and prefix state" -grep -qF 'removal could not be verified' "$base/err" \ - || fail "registry control-query failure was not reported" -ok "failed registry control query cannot authorize retained-prefix runtime deletion" - -base="$(new_env uninstall-missing-prestate)" -managed_link="$base/home/.local/bin/pipeasio-settings" -mkdir -p -- "$(dirname "$managed_link")" -printf '#!/bin/sh\necho original-prestate\n' > "$base/original-prestate-command" -chmod 755 "$base/original-prestate-command" -ln -s -- "$base/original-prestate-command" "$managed_link" -install_managed_link "$base" "$base/managed-panel" "$managed_link" "$base/txn" \ - || fail "missing-prestate fixture install failed" -prestate_id="$(printf '%s' "$managed_link" | sha256sum | awk '{print $1}')" -prestate_backup="$base/xdg/state/ableton-wine/install-prestate/$prestate_id" -rm -f -- "$prestate_backup" -run_minimal_uninstall "$base" >"$base/out" 2>"$base/err" \ - || fail "a missing optional pre-install backup made uninstall fail" -[ -L "$managed_link" ] && [ "$(readlink -- "$managed_link")" = "$base/managed-panel" ] \ - || fail "missing optional prestate did not preserve the managed target" -[ -r "$base/xdg/state/ableton-wine/install-manifest.tsv" ] \ - && [ -r "$base/xdg/state/ableton-wine/install-prestate.tsv" ] \ - || fail "missing optional prestate discarded the records needed for inspection" -grep -qF "Desktop shortcuts, file-opening settings, and older Wine runtimes were left unchanged because the installer's file list could not be trusted" \ - "$base/err" || fail "missing optional prestate warning was not explicit" -ok "missing optional prestate preserves integration records without failing uninstall" - -base="$(new_env literal-runtime-sibling)" -literal_runtime="$base/runtime[1]" -literal_rollback="$base/runtime[1]-rollback-owned" -glob_collision="$base/runtime1-rollback-foreign" -mkdir -p -- "$literal_runtime" "$literal_rollback" "$glob_collision" \ - "$base/xdg/state/ableton-wine" "$base/fakebin" -for candidate in "$literal_runtime" "$literal_rollback" "$glob_collision"; do - printf 'format=1\nname=wine-d2d1-nspa-11.13\n' > "$candidate/.ableton-linux-runtime" -done -printf 'foreign sibling must survive\n' > "$glob_collision/foreign" -printf 'runtime\t%s\twine-d2d1-nspa-11.13\n' "$literal_runtime" \ - > "$base/xdg/state/ableton-wine/install-manifest.tsv" -printf 'format=1\nowner=ableton-linux\n' \ - > "$base/xdg/state/ableton-wine/.ableton-linux-state" -install_fake_host_tools "$base" -run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - ABLETON_WINE_ROOT="$literal_runtime" ABLETON_WINEPREFIX="$base/prefix" \ - ABLETON_LINK_MODE=off bash "$here/uninstall.sh" --keep-prefix --yes \ - >"$base/out" 2>"$base/err" || fail "literal-runtime uninstall fixture failed" -[ ! -e "$literal_runtime" ] && [ ! -e "$literal_rollback" ] \ - || fail "uninstall did not remove the literal managed runtime family" -[ -f "$glob_collision/foreign" ] \ - || fail "runtime basename metacharacters selected an unrelated sibling" -ok "uninstall matches custom runtime rollback siblings by literal basename" - -run_txn_file_rollback() -{ - local base="$1" txn="$2" - # shellcheck disable=SC2016 - run_isolated "$base" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_rollback_files "$2" - ' _ "$here" "$txn" -} - -for corrupt_kind in missing external duplicate nul; do - base="$(new_env "txn-preflight-$corrupt_kind")" - txn="$base/txn" - first="$base/live-first" - second="$base/live-second" - mkdir -p -- "$txn/files" - printf 'new first\n' > "$first" - printf 'new second\n' > "$second" - printf 'old first\n' > "$txn/files/0" - printf 'old second\n' > "$txn/files/1" - printf 'external backup\n' > "$base/external-backup" - case "$corrupt_kind" in - missing) - rm -f -- "$txn/files/1" - printf 'present\t%s\t%s\npresent\t%s\t%s\n' \ - "$first" "$txn/files/0" "$second" "$txn/files/1" > "$txn/files.tsv" - ;; - external) - printf 'present\t%s\t%s\npresent\t%s\t%s\n' \ - "$first" "$txn/files/0" "$second" "$base/external-backup" > "$txn/files.tsv" - ;; - duplicate) - printf 'present\t%s\t%s\npresent\t%s\t%s\n' \ - "$first" "$txn/files/0" "$first" "$txn/files/1" > "$txn/files.tsv" - ;; - nul) - printf 'present\t%s\t%s\npresent\t%s\t%s\n' \ - "$first" "$txn/files/0" "$second" "$txn/files/1" > "$txn/files.tsv" - printf '\0' >> "$txn/files.tsv" - ;; - esac - if run_txn_file_rollback "$base" "$txn" >"$base/out" 2>"$base/err"; then - fail "file rollback accepted a $corrupt_kind transaction journal" - fi - if ! grep -qxF 'new first' "$first" || ! grep -qxF 'new second' "$second"; then - fail "$corrupt_kind transaction preflight changed a live target" - fi - grep -qxF 'external backup' "$base/external-backup" \ - || fail "$corrupt_kind transaction preflight changed an external backup" -done -ok "file rollback fully preflights indexed, unique, NUL-free backups before mutation" - -run_remove_managed_file() -{ - local base="$1" target="$2" - # shellcheck disable=SC2016 - run_isolated "$base" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_remove_managed_file "$2" - ' _ "$here" "$target" -} - -for corrupt_kind in duplicate misplaced directory nul; do - base="$(new_env "remove-prestate-$corrupt_kind")" - state="$base/xdg/state/ableton-wine" - target="$base/xdg/data/ableton-wine/detect-scale.sh" - id="$(printf '%s' "$target" | sha256sum | awk '{print $1}')" - backup="$state/install-prestate/$id" - mkdir -p -- "$state/install-prestate" "$(dirname "$target")" - printf 'managed live bytes\n' > "$target" - target_digest="$(sha256sum -- "$target" | awk '{print $1}')" - printf 'file\t%s\t%s\n' "$target" "$target_digest" > "$state/install-manifest.tsv" - printf 'external prestate bytes\n' > "$base/external-prestate" - case "$corrupt_kind" in - duplicate) - cp -- "$base/external-prestate" "$backup" - printf 'present\t%s\t%s\npresent\t%s\t%s\n' \ - "$target" "$backup" "$target" "$backup" > "$state/install-prestate.tsv" - ;; - misplaced) - cp -- "$base/external-prestate" "$backup" - printf 'present\t%s\t%s\n' "$target" "$base/external-prestate" \ - > "$state/install-prestate.tsv" - ;; - directory) - mkdir -p -- "$backup" - printf 'directory sentinel\n' > "$backup/sentinel" - printf 'present\t%s\t%s\n' "$target" "$backup" > "$state/install-prestate.tsv" - ;; - nul) - cp -- "$base/external-prestate" "$backup" - printf 'present\t%s\t%s\n' "$target" "$backup" > "$state/install-prestate.tsv" - printf '\0' >> "$state/install-prestate.tsv" - ;; - esac - if run_remove_managed_file "$base" "$target" >"$base/out" 2>"$base/err"; then - fail "managed-file removal accepted $corrupt_kind prestate" - fi - grep -qxF 'managed live bytes' "$target" \ - || fail "$corrupt_kind prestate was rejected after target removal" - grep -qxF 'external prestate bytes' "$base/external-prestate" \ - || fail "$corrupt_kind prestate changed an external object" -done -ok "managed-file removal preflights unique, local, file-shaped, NUL-free prestate" - -run_directory_target_guard() -{ - local base="$1" operation="$2" target="$3" txn="$4" - mkdir -p -- "$txn" - mkdir -p -- "$txn" - # shellcheck disable=SC2016 - run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - case "$2" in - install) ableton_install_file 755 "$3" "$4" ;; - symlink) ableton_install_symlink "$3" "$4" ;; - esac - ' _ "$here" "$operation" "$base/source" "$target" -} - -for operation in install symlink; do - base="$(new_env "directory-target-$operation")" - target="$base/managed-target" - txn="$base/txn" - mkdir -p -- "$target" - printf 'directory sentinel\n' > "$target/sentinel" - printf 'replacement bytes\n' > "$base/source" - if run_directory_target_guard "$base" "$operation" "$target" "$txn" \ - >"$base/out" 2>"$base/err"; then - fail "$operation helper accepted a directory target" - fi - grep -qxF 'directory sentinel' "$target/sentinel" \ - || fail "$operation directory refusal changed the target" - [ -f "$txn/files.tsv" ] && [ ! -s "$txn/files.tsv" ] \ - || fail "$operation directory refusal mutated its transaction journal" - [ ! -e "$base/xdg/state/ableton-wine/install-prestate.tsv" ] \ - && [ ! -e "$base/xdg/state/ableton-wine/install-prestate" ] \ - || fail "$operation directory refusal persisted prestate" -done -ok "file and symlink directory refusals leave transaction and prestate journals untouched" - -run_guarded_file_install() -{ - local base="$1" txn="$2" target="$3" policy="${4:-preserve-local}" - mkdir -p -- "$txn" - mkdir -p -- "$txn" - # shellcheck disable=SC2016 - run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - ableton_install_file 600 "$2" "$3" file "$4" - ' _ "$here" "$base/replacement" "$target" "$policy" -} - -for journal in manifest prestate; do - for corrupt_kind in symlink unreadable malformed nul; do - base="$(new_env "install-$journal-$corrupt_kind")" - state="$base/xdg/state/ableton-wine" - txn="$base/txn" - target="$base/xdg/data/ableton-wine/setup-realtime.sh" - mkdir -p -- "$state" "$(dirname "$target")" - printf 'format=1\nowner=ableton-linux\n' > "$state/.ableton-linux-state" - printf 'foreign live bytes\n' > "$target" - printf 'replacement bytes\n' > "$base/replacement" - printf 'external journal bytes\n' > "$base/external-journal" - if [ "$journal" = prestate ]; then - printf 'file\t%s\t%s\n' "$target" \ - "$(sha256sum -- "$target" | awk '{print $1}')" \ - > "$state/install-manifest.tsv" - fi - path="$state/install-$journal.tsv" - case "$corrupt_kind" in - symlink) ln -s -- "$base/external-journal" "$path" ;; - unreadable) printf 'unreadable journal\n' > "$path"; chmod 000 "$path" ;; - malformed) printf 'not-a-valid-record\n' > "$path" ;; - nul) printf '\0' > "$path" ;; - esac - run_guarded_file_install "$base" "$txn" "$target" \ - >"$base/out" 2>"$base/err" \ - || { chmod 600 "$path" 2>/dev/null || true; \ - fail "repair install consulted a $corrupt_kind legacy $journal journal"; } - chmod 600 "$path" 2>/dev/null || true - grep -qxF 'replacement bytes' "$target" \ - || fail "$corrupt_kind legacy $journal journal blocked canonical repair" - grep -qxF 'external journal bytes' "$base/external-journal" \ - || fail "$corrupt_kind legacy $journal journal changed a symlink referent" - done -done -ok "repair installs ignore malformed legacy inventory and prestate without touching external data" - -# Both optional bookkeeping files can be damaged at once. Files published from -# the current installer payload are still authoritative; their immediate -# transaction copy is sufficient to restore this attempt if a later step fails. -base="$(new_env generated-repair-both-journals-damaged)" -state="$base/xdg/state/ableton-wine" -txn="$base/txn" -target="$base/xdg/data/ableton-wine/setup-realtime.sh" -mkdir -p -- "$state/install-prestate" "$(dirname "$target")" -printf 'format=1\nowner=ableton-linux\n' > "$state/.ableton-linux-state" -printf 'older generated bytes\n' > "$target" -printf 'replacement bytes\n' > "$base/replacement" -printf 'not-a-valid-installed-file-list\n' > "$state/install-manifest.tsv" -printf 'not-a-valid-saved-copy-list\n' > "$state/install-prestate.tsv" -printf 'stale saved-copy sentinel\n' > "$state/install-prestate/not-a-valid-slot" -cp -a -- "$state/install-manifest.tsv" "$base/manifest.before" -cp -a -- "$state/install-prestate.tsv" "$base/prestate.before" -run_guarded_file_install "$base" "$txn" "$target" replace-generated \ - >"$base/out" 2>"$base/err" \ - || fail "damaged optional bookkeeping blocked generated-file repair" -grep -qxF 'replacement bytes' "$target" \ - || fail "generated-file repair retained its old bytes" -cmp -s -- "$state/install-manifest.tsv" "$base/manifest.before" \ - && cmp -s -- "$state/install-prestate.tsv" "$base/prestate.before" \ - && grep -qxF 'stale saved-copy sentinel' \ - "$state/install-prestate/not-a-valid-slot" \ - || fail "generated-file repair consumed damaged optional bookkeeping" -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_rollback_files "$2" -' _ "$here" "$txn" \ - || fail "generated-file repair lost same-run rollback" -grep -qxF 'older generated bytes' "$target" \ - || fail "same-run rollback did not restore the replaced generated file" -ok "generated files ignore damaged optional bookkeeping and keep same-run rollback" - -# The authoritative overwrite policy is deliberately narrow. Shared desktop -# names still preserve unrelated local files and cannot opt into private-file -# replacement through a future call-site mistake. -base="$(new_env replace-generated-shared-path)" -target="$base/xdg/data/icons/hicolor/scalable/apps/live-suite.svg" -mkdir -p -- "$(dirname "$target")" -printf 'foreign shared icon\n' > "$target" -printf 'replacement bytes\n' > "$base/replacement" -if run_guarded_file_install "$base" "$base/txn" "$target" replace-generated \ - >"$base/out" 2>"$base/err"; then - fail "private-file overwrite policy accepted a shared desktop path" -fi -grep -qxF 'foreign shared icon' "$target" \ - || fail "rejected private-file policy changed a shared desktop path" -[ -f "$base/txn/files.tsv" ] && [ ! -s "$base/txn/files.tsv" ] \ - || fail "rejected private-file policy changed transaction recovery data" -ok "private generated-file replacement is enforced by an exact path allowlist" - -# Once a generated file has reached its final path, optional recovery metadata -# cannot turn that valid repair into a failure or block the next independent -# repair. Force the post-publication check to fail after the first atomic move. -base="$(new_env publication-checkpoint-warning)" -mkdir -p -- "$base/txn" "$base/xdg/data/ableton-wine/lib" -printf 'first replacement\n' > "$base/first" -printf 'second replacement\n' > "$base/second" -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$base/txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - ableton_txn_preflight_commit_files() { return 1; } - ableton_publish_file 644 "$2" "$4/lib/config.sh" file replace-generated - [ "$ABLETON_PUBLICATION_JOURNAL_BROKEN" -eq 1 ] - ableton_publish_file 644 "$3" "$4/lib/lifecycle.sh" file replace-generated -' _ "$here" "$base/first" "$base/second" "$base/xdg/data/ableton-wine" \ - >"$base/out" 2>"$base/err" \ - || fail "recovery bookkeeping failure became a generated-file gate" -grep -qxF 'first replacement' "$base/xdg/data/ableton-wine/lib/config.sh" \ - && grep -qxF 'second replacement' "$base/xdg/data/ableton-wine/lib/lifecycle.sh" \ - || fail "recovery bookkeeping failure blocked an independent repair" -grep -qF 'Continuing with the installed file.' "$base/err" \ - || fail "post-publication recovery warning was not plain" -ok "post-publication bookkeeping cannot invalidate or block generated-file repairs" - -base="$(new_env manifest-external-path)" -state="$base/xdg/state/ableton-wine" -mkdir -p -- "$state" -external_target="$base/external-valid-digest" -printf 'external valid digest bytes\n' > "$external_target" -printf 'file\t%s\t%s\n' "$external_target" \ - "$(sha256sum -- "$external_target" | awk '{print $1}')" > "$state/install-manifest.tsv" -repair_target="$base/xdg/data/ableton-wine/setup-realtime.sh" -printf 'replacement bytes\n' > "$base/replacement" -run_guarded_file_install "$base" "$base/txn" "$repair_target" \ - >"$base/out" 2>"$base/err" \ - || fail "arbitrary legacy inventory row vetoed canonical generated-file repair" -grep -qxF 'replacement bytes' "$repair_target" \ - || fail "arbitrary legacy inventory row blocked canonical repair" -grep -qxF 'external valid digest bytes' "$external_target" \ - || fail "repair followed an arbitrary path from legacy inventory" -ok "legacy inventory never authorizes or vetoes generated-file repair targets" - -run_direct_uninstall() -{ - local base="$1" mode="${2:---keep-prefix}" - if [ ! -x "$base/fakebin/systemctl" ] || [ ! -x "$base/fakebin/xdg-mime" ]; then - install_fake_host_tools "$base" - fi - run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ - ABLETON_LINK_MODE=off \ - ABLETON_TEST_REGISTRY_LOG="$base/registry.log" \ - ABLETON_TEST_REGISTRY_STATE="$base/registry-present" \ - bash "$here/uninstall.sh" "$mode" --yes -} - -for marker_kind in runtime prefix state; do - base="$(new_env "nul-$marker_kind-marker")" - make_registry_runtime "$base" - printf 'runtime sentinel\n' > "$base/runtime/sentinel" - printf 'prefix sentinel\n' > "$base/prefix/sentinel" - printf 'state sentinel\n' > "$base/xdg/state/ableton-wine/sentinel" - mode=--keep-prefix - case "$marker_kind" in - runtime) printf '\0' >> "$base/runtime/.ableton-linux-runtime" ;; - prefix) printf '\0' >> "$base/prefix/.ableton-linux-prefix"; mode=--delete-prefix ;; - state) printf '\0' >> "$base/xdg/state/ableton-wine/.ableton-linux-state" ;; - esac - if [ "$marker_kind" = state ]; then - run_direct_uninstall "$base" "$mode" >"$base/out" 2>"$base/err" \ - || fail "damaged optional state marker blocked exact runtime removal" - [ ! -e "$base/runtime" ] && [ ! -L "$base/runtime" ] \ - || fail "damaged optional state marker retained the configured runtime" - grep -qxF 'prefix sentinel' "$base/prefix/sentinel" \ - && grep -qxF 'state sentinel' "$base/xdg/state/ableton-wine/sentinel" \ - || fail "damaged optional state marker changed retained prefix or support data" - grep -qF 'Ableton Linux support files were left unchanged because their directory is not recognised:' \ - "$base/err" || fail "damaged optional state marker did not produce its warning" - else - if run_direct_uninstall "$base" "$mode" >"$base/out" 2>"$base/err"; then - fail "uninstall accepted a $marker_kind marker with trailing NUL" - fi - if ! grep -qxF 'runtime sentinel' "$base/runtime/sentinel" \ - || ! grep -qxF 'prefix sentinel' "$base/prefix/sentinel" \ - || ! grep -qxF 'state sentinel' "$base/xdg/state/ableton-wine/sentinel"; then - fail "$marker_kind marker refusal changed an owned tree" - fi - fi -done -ok "runtime and prefix markers remain fatal while damaged optional state is retained with a warning" - -for journal in manifest prestate mime; do - for corrupt_kind in symlink unreadable nul; do - base="$(new_env "uninstall-$journal-$corrupt_kind")" - make_registry_runtime "$base" - printf 'runtime sentinel\n' > "$base/runtime/sentinel" - printf 'prefix sentinel\n' > "$base/prefix/sentinel" - state="$base/xdg/state/ableton-wine" - case "$journal" in - manifest) path="$state/install-manifest.tsv" ;; - prestate) path="$state/install-prestate.tsv" ;; - mime) path="$state/mime-prestate.tsv" ;; - esac - printf 'external uninstall journal\n' > "$base/external-journal" - case "$corrupt_kind" in - symlink) - [ ! -e "$path" ] || mv -- "$path" "$base/original-journal" - ln -s -- "$base/external-journal" "$path" - ;; - unreadable) printf 'unreadable\n' > "$path"; chmod 000 "$path" ;; - nul) printf '\0' >> "$path" ;; - esac - run_direct_uninstall "$base" >"$base/out" 2>"$base/err" \ - || { chmod 600 "$path" 2>/dev/null || true; \ - fail "$corrupt_kind optional $journal journal blocked runtime removal"; } - chmod 600 "$path" 2>/dev/null || true - [ ! -e "$base/runtime" ] && [ ! -L "$base/runtime" ] \ - && grep -qxF 'prefix sentinel' "$base/prefix/sentinel" \ - || fail "$corrupt_kind $journal state blocked runtime removal or changed the kept prefix" - grep -qxF 'external uninstall journal' "$base/external-journal" \ - || fail "$corrupt_kind $journal warning changed an external referent" - grep -qF '!! warning:' "$base/err" \ - || fail "$corrupt_kind $journal state did not produce an optional-cleanup warning" - done -done - -base="$(new_env uninstall-runtime-prestate-claim)" -make_registry_runtime "$base" -state="$base/xdg/state/ableton-wine" -runtime_id="$(printf '%s' "$base/runtime" | sha256sum | awk '{print $1}')" -mkdir -p -- "$state/install-prestate" -printf 'invalid runtime prestate\n' > "$state/install-prestate/$runtime_id" -printf 'present\t%s\t%s\n' "$base/runtime" "$state/install-prestate/$runtime_id" \ - > "$state/install-prestate.tsv" -printf 'runtime sentinel\n' > "$base/runtime/sentinel" -run_direct_uninstall "$base" >"$base/out" 2>"$base/err" \ - || fail "invalid optional prestate attached to a runtime blocked runtime removal" -[ ! -e "$base/runtime" ] && [ ! -L "$base/runtime" ] \ - && [ -f "$state/install-prestate/$runtime_id" ] \ - && [ -f "$state/install-prestate.tsv" ] \ - || fail "runtime removal did not preserve the invalid optional prestate for inspection" -grep -qF '!! warning:' "$base/err" \ - || fail "invalid runtime prestate did not produce an optional-cleanup warning" -ok "uninstall leaves unsafe optional journals untouched while removing the exact configured runtime" - -base="$(new_env uninstall-late-missing-prestate)" -make_registry_runtime "$base" -state="$base/xdg/state/ableton-wine" -first="$base/first-managed" -second="$base/second-managed" -printf 'first managed bytes\n' > "$first" -printf 'second managed bytes\n' > "$second" -first_digest="$(sha256sum -- "$first" | awk '{print $1}')" -second_digest="$(sha256sum -- "$second" | awk '{print $1}')" -printf 'file\t%s\t%s\nfile\t%s\t%s\nruntime\t%s\twine-d2d1-nspa-11.13\n' \ - "$first" "$first_digest" "$second" "$second_digest" "$base/runtime" \ - > "$state/install-manifest.tsv" -first_id="$(printf '%s' "$first" | sha256sum | awk '{print $1}')" -second_id="$(printf '%s' "$second" | sha256sum | awk '{print $1}')" -mkdir -p -- "$state/install-prestate" -printf 'first previous bytes\n' > "$state/install-prestate/$first_id" -printf 'present\t%s\t%s\npresent\t%s\t%s\n' \ - "$first" "$state/install-prestate/$first_id" \ - "$second" "$state/install-prestate/$second_id" > "$state/install-prestate.tsv" -printf 'runtime sentinel\n' > "$base/runtime/sentinel" -run_direct_uninstall "$base" >"$base/out" 2>"$base/err" \ - || fail "later missing optional prestate blocked runtime removal" -if ! grep -qxF 'first managed bytes' "$first" \ - || ! grep -qxF 'second managed bytes' "$second" \ - || { [ -e "$base/runtime" ] || [ -L "$base/runtime" ]; }; then - fail "late missing prestate changed optional files or retained the configured runtime" -fi -grep -qF '!! warning:' "$base/err" \ - || fail "late missing prestate did not produce an optional-cleanup warning" -ok "late missing prestate leaves optional files untouched without blocking runtime removal" - -make_mime_failure_fixture() -{ - local base="$1" prior="$2" - mkdir -p -- "$base/runtime" "$base/xdg/state/ableton-wine" - printf 'format=1\nname=wine-d2d1-nspa-11.13\n' > "$base/runtime/.ableton-linux-runtime" - printf 'runtime\t%s\twine-d2d1-nspa-11.13\n' "$base/runtime" \ - > "$base/xdg/state/ableton-wine/install-manifest.tsv" - printf 'format=1\nowner=ableton-linux\n' \ - > "$base/xdg/state/ableton-wine/.ableton-linux-state" - printf 'x-scheme-handler/ableton\t%s\n' "$prior" \ - > "$base/xdg/state/ableton-wine/mime-prestate.tsv" - install_fake_host_tools "$base" -} - -base="$(new_env mime-default-failure)" -make_mime_failure_fixture "$base" foreign.desktop -cat > "$base/fakebin/xdg-mime" <<'EOF' -#!/bin/sh -case "${1:-}" in - query) echo ableton-live.desktop ;; - default) exit 77 ;; - *) exit 2 ;; -esac -EOF -chmod 755 "$base/fakebin/xdg-mime" -run_direct_uninstall "$base" >"$base/out" 2>"$base/err" \ - || fail "MIME default restoration failure blocked runtime removal" -[ ! -e "$base/runtime" ] && [ ! -L "$base/runtime" ] \ - || fail "MIME default restoration failure retained the configured runtime" -[ -r "$base/xdg/state/ableton-wine/mime-prestate.tsv" ] \ - && [ -r "$base/xdg/state/ableton-wine/.ableton-linux-state" ] \ - || fail "MIME default restoration failure discarded retry state" -grep -qF '!! warning:' "$base/err" \ - || fail "MIME default restoration failure did not produce a retry warning" - -base="$(new_env mime-clear-failure)" -make_mime_failure_fixture "$base" '' -mkdir -p -- "$base/xdg/config" -printf '[Default Applications]\nx-scheme-handler/ableton=ableton-live.desktop\n' \ - > "$base/xdg/config/mimeapps.list" -real_awk="$(command -v awk)" -cat > "$base/fakebin/xdg-mime" <<'EOF' -#!/bin/sh -case "${1:-}" in - query) echo ableton-live.desktop ;; - default) exit 0 ;; - *) exit 2 ;; -esac -EOF -# Reading and clearing the defaults list are the only awk programs that name the -# group, so this breaks that step alone and leaves every other awk call working. -cat > "$base/fakebin/awk" <"$base/out" 2>"$base/err" \ - || fail "MIME clear failure blocked runtime removal" -[ ! -e "$base/runtime" ] && [ ! -L "$base/runtime" ] \ - || fail "MIME clear failure retained the configured runtime" -[ -r "$base/xdg/state/ableton-wine/mime-prestate.tsv" ] \ - && [ -r "$base/xdg/state/ableton-wine/.ableton-linux-state" ] \ - || fail "MIME clear failure discarded retry state" -grep -qF '!! warning:' "$base/err" \ - || fail "MIME clear failure did not produce a retry warning" -ok "MIME command and mimeapps restoration failures warn while runtime removal succeeds" - -base="$(new_env mime-post-lock-mutation)" -make_mime_failure_fixture "$base" foreign.desktop -state="$base/xdg/state/ableton-wine" -printf 'runtime sentinel\n' > "$base/runtime/sentinel" -real_flock="$(command -v flock)" -cat > "$base/fakebin/flock" < "$state/mime-prestate.tsv" - : > "$base/mime-mutated" -fi -EOF -chmod 755 "$base/fakebin/flock" -run_direct_uninstall "$base" >"$base/out" 2>"$base/err" \ - || fail "post-lock MIME mutation blocked runtime removal" -[ -e "$base/mime-mutated" ] && [ ! -e "$base/runtime" ] \ - && [ -f "$state/.ableton-linux-state" ] \ - || fail "post-lock MIME mutation did not preserve support state while removing runtime" -grep -qF 'File-opening defaults were left unchanged because their saved settings changed while uninstall was starting' \ - "$base/err" || fail "post-lock MIME mutation warning was not explicit" -ok "uninstall revalidates changed MIME state, retains it, and still removes the runtime" - -base="$(new_env partial-uninstall-retry)" -mkdir -p -- "$base/runtime" "$base/xdg/state/ableton-wine/install-prestate" -install_fake_host_tools "$base" -mkdir -p -- "$base/xdg/config/ableton-wine" -printf 'unrelated user settings\n' > "$base/xdg/config/ableton-wine/user-notes" -printf 'format=1\nname=wine-d2d1-nspa-11.13\n' > "$base/runtime/.ableton-linux-runtime" -printf 'format=1\nowner=ableton-linux\n' \ - > "$base/xdg/state/ableton-wine/.ableton-linux-state" -mkdir -p -- "$base/xdg/data/ableton-wine" -first="$base/xdg/data/ableton-wine/VERSION" -second="$base/xdg/data/ableton-wine/setup-realtime.sh" -printf 'first previous bytes\n' > "$first" -printf 'user conflict bytes\n' > "$second" -first_id="$(printf '%s' "$first" | sha256sum | awk '{print $1}')" -second_id="$(printf '%s' "$second" | sha256sum | awk '{print $1}')" -printf 'first previous bytes\n' > "$base/xdg/state/ableton-wine/install-prestate/$first_id" -printf 'second previous bytes\n' > "$base/xdg/state/ableton-wine/install-prestate/$second_id" -first_managed="$base/first-managed-reference" -second_managed="$base/second-managed-reference" -printf 'first managed bytes\n' > "$first_managed" -printf 'second managed bytes\n' > "$second_managed" -printf 'file\t%s\t%s\nfile\t%s\t%s\nruntime\t%s\twine-d2d1-nspa-11.13\n' \ - "$first" "$(sha256sum -- "$first_managed" | awk '{print $1}')" \ - "$second" "$(sha256sum -- "$second_managed" | awk '{print $1}')" \ - "$base/runtime" > "$base/xdg/state/ableton-wine/install-manifest.tsv" -printf 'present\t%s\t%s\npresent\t%s\t%s\n' \ - "$first" "$base/xdg/state/ableton-wine/install-prestate/$first_id" \ - "$second" "$base/xdg/state/ableton-wine/install-prestate/$second_id" \ - > "$base/xdg/state/ableton-wine/install-prestate.tsv" -run_direct_uninstall "$base" >"$base/first.out" 2>"$base/first.err" \ - || fail "optional file conflict blocked configured runtime removal" -grep -qxF 'first previous bytes' "$first" \ - && grep -qxF 'user conflict bytes' "$second" \ - && [ ! -e "$base/runtime" ] && [ ! -L "$base/runtime" ] \ - && [ -d "$base/xdg/state/ableton-wine" ] \ - || fail "partial uninstall did not remove Wine while retaining the file conflict and retry state" -grep -qF 'kept an unrecognised or user-modified file at' "$base/first.err" \ - || fail "optional file conflict was not reported" -cp -- "$second_managed" "$second" -if ! run_direct_uninstall "$base" >"$base/retry.out" 2>"$base/retry.err"; then - sed -n '1,120p' "$base/retry.err" >&2 - fail "partial uninstall did not complete after its remaining conflict was resolved" -fi -if ! grep -qxF 'first previous bytes' "$first" \ - || ! grep -qxF 'second previous bytes' "$second"; then - fail "partial uninstall retry did not preserve/restore exact prestate" -fi -[ ! -e "$base/runtime" ] && [ ! -e "$base/xdg/state/ableton-wine" ] \ - && grep -qxF 'unrelated user settings' \ - "$base/xdg/config/ableton-wine/user-notes" \ - || fail "successful partial-uninstall retry retained runtime or ownership state" -grep -qF "kept your already-restored earlier file at $first" "$base/retry.out" \ - || fail "partial uninstall retry did not recognize its already-restored file" -ok "optional uninstall conflicts preserve retry state without retaining the runtime" - -for snapshot in installer-config pipeasio-config.ini; do - base="$(new_env "rollback-directory-${snapshot//./-}")" - make_rollback_fixture "$base" - rm -f -- "$ROLLBACK_SAVED/.ableton-linux-rollback/$snapshot" - mkdir -p -- "$ROLLBACK_SAVED/.ableton-linux-rollback/$snapshot" - printf 'directory snapshot sentinel\n' \ - > "$ROLLBACK_SAVED/.ableton-linux-rollback/$snapshot/sentinel" - run_user_rollback "$base" env ABLETON_TEST_REGISTRY_STICKY=0 \ - >"$base/out" 2>"$base/err" \ - || fail "unavailable saved $snapshot settings blocked runtime rollback" - [ -f "$base/runtime/previous-generation" ] \ - && [ ! -e "$ROLLBACK_SAVED" ] && [ ! -L "$ROLLBACK_SAVED" ] \ - && [ -f "$base/runtime/.ableton-linux-rollback/$snapshot/sentinel" ] \ - && grep -qF 'live_major=12' "$ROLLBACK_INSTALLER_CONFIG" \ - && grep -qF 'buffer_size = 512' "$ROLLBACK_PIPEASIO_CONFIG" \ - || fail "unavailable saved $snapshot settings changed current settings or blocked Wine" - grep -qF 'The saved runtime is usable, but its saved settings are unavailable. Current settings will stay in place.' \ - "$base/err" || fail "unavailable saved $snapshot settings warning was not explicit" -done -ok "unavailable saved settings warn without blocking runtime rollback" - -run_txn_init_only() -{ - local base="$1" txn="$2" - # shellcheck disable=SC2016 - run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - ' _ "$here" -} - -run_txn_commit_preflight_only() -{ - local base="$1" txn="$2" - # shellcheck disable=SC2016 - run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_preflight_commit_files "$2" - ' _ "$here" "$txn" -} - -for entry in files active files.tsv; do - base="$(new_env "txn-init-symlink-${entry//./-}")" - txn="$base/txn" - mkdir -p -- "$txn" - if [ "$entry" = files ]; then - mkdir -p -- "$base/external-transaction-object" - printf 'external directory sentinel\n' > "$base/external-transaction-object/sentinel" - else - printf 'external file sentinel\n' > "$base/external-transaction-object" - fi - ln -s -- "$base/external-transaction-object" "$txn/$entry" - if run_txn_init_only "$base" "$txn" >"$base/out" 2>"$base/err"; then - fail "transaction init accepted symlink $entry" - fi - if [ "$entry" = files ]; then - grep -qxF 'external directory sentinel' "$base/external-transaction-object/sentinel" \ - || fail "transaction init changed the symlinked $entry directory" - else - grep -qxF 'external file sentinel' "$base/external-transaction-object" \ - || fail "transaction init changed the symlinked $entry file" - fi - [ "$(find "$txn" -mindepth 1 -maxdepth 1 -printf x | wc -c)" -eq 1 ] \ - || fail "transaction init partially populated a rejected symlink fixture" -done - -base="$(new_env txn-occupied-backup-slot)" -txn="$base/txn" -mkdir -p -- "$txn" -run_txn_init_only "$base" "$txn" || fail "occupied-slot transaction setup failed" -target="$base/xdg/data/ableton-wine/detect-theme.sh" -mkdir -p -- "$(dirname "$target")" -printf 'live target sentinel\n' > "$target" -printf 'external slot sentinel\n' > "$base/external-slot" -ln -s -- "$base/external-slot" "$txn/files/0" -# shellcheck disable=SC2016 -if run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - ableton_txn_snapshot "$2" -' _ "$here" "$target" >"$base/out" 2>"$base/err"; then - fail "transaction snapshot accepted an occupied exact backup slot" -fi -grep -qxF 'live target sentinel' "$target" \ - && grep -qxF 'external slot sentinel' "$base/external-slot" \ - && [ ! -s "$txn/files.tsv" ] \ - || fail "occupied transaction slot refusal changed target, referent, or journal" -ok "transaction init/snapshot reject symlink control objects and occupied slots without referent changes" - -base="$(new_env txn-commit-preflight)" -txn="$base/txn" -target_present="$base/xdg/data/ableton-wine/detect-theme.sh" -target_absent="$base/xdg/data/ableton-wine/detect-scale.sh" -mkdir -p -- "$txn" "$(dirname "$target_present")" -printf 'live bytes\n' > "$target_present" -printf 'replacement bytes\n' > "$base/replacement" -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - ableton_txn_snapshot "$2" - ableton_txn_expect "$2" "$(ableton_regular_source_token "$3")" - cp -- "$3" "$2" - ableton_txn_snapshot "$4" - ableton_txn_expect "$4" absent -' _ "$here" "$target_present" "$base/replacement" "$target_absent" \ - || fail "commit-preflight positive fixture could not bind exact expected objects" -run_txn_commit_preflight_only "$base" "$txn" >"$base/ok.out" 2>"$base/ok.err" \ - || fail "commit preflight rejected exact already-committed transaction objects" - -base="$(new_env txn-commit-pending)" -txn="$base/txn" -target="$base/xdg/data/ableton-wine/detect-theme.sh" -mkdir -p -- "$txn" "$(dirname "$target")" -printf 'live pending bytes\n' > "$target" -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - ableton_txn_snapshot "$2" -' _ "$here" "$target" || fail "pending-commit fixture could not snapshot its target" -if run_txn_commit_preflight_only "$base" "$txn" >"$base/out" 2>"$base/err"; then - fail "commit preflight accepted a pending transaction row" -fi -grep -qF "A file update stopped before it finished: $target" "$base/err" \ - || fail "pending commit refusal was not explicit" -grep -qxF 'live pending bytes' "$target" \ - || fail "pending commit refusal changed the live target" - -base="$(new_env txn-commit-present-changed)" -txn="$base/txn" -target="$base/xdg/data/ableton-wine/detect-theme.sh" -mkdir -p -- "$txn" "$(dirname "$target")" -printf 'live old bytes\n' > "$target" -printf 'replacement bytes\n' > "$base/replacement" -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - ableton_txn_snapshot "$2" - ableton_txn_expect "$2" "$(ableton_regular_source_token "$3")" -' _ "$here" "$target" "$base/replacement" \ - || fail "present-changed fixture could not record its expected token" -printf 'third-party bytes\n' > "$target" -if run_txn_commit_preflight_only "$base" "$txn" >"$base/out" 2>"$base/err"; then - fail "commit preflight accepted a present target replaced by a third party" -fi -grep -qF "A file changed while the installer was updating it: $target" "$base/err" \ - || fail "present changed commit refusal was not explicit" -grep -qxF 'third-party bytes' "$target" \ - || fail "present changed commit refusal rewrote the live target" - -base="$(new_env txn-commit-absent-changed)" -txn="$base/txn" -target="$base/xdg/data/ableton-wine/detect-scale.sh" -mkdir -p -- "$txn" "$(dirname "$target")" -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - ableton_txn_snapshot "$2" - ableton_txn_expect "$2" absent -' _ "$here" "$target" || fail "absent-changed fixture could not record its expected token" -printf 'third-party appeared\n' > "$target" -if run_txn_commit_preflight_only "$base" "$txn" >"$base/out" 2>"$base/err"; then - fail "commit preflight accepted an absent target recreated by a third party" -fi -grep -qF "A file changed while the installer was updating it: $target" "$base/err" \ - || fail "absent changed commit refusal was not explicit" -grep -qxF 'third-party appeared' "$target" \ - || fail "absent changed commit refusal rewrote the recreated target" -ok "commit preflight requires exact committed post-operation objects and rejects pending or third-party rows" - -# A journal row can be rebound more than once before the outer transaction -# closes. The immediately preceding installer generation is rollback-safe -# while a new atomic publication is pending; only the last token may commit. -base="$(new_env txn-generation-chain)" -txn="$base/txn" -target="$base/xdg/data/ableton-wine/detect-theme.sh" -mkdir -p -- "$txn" "$(dirname "$target")" -printf 'original generation\n' > "$target" -printf 'installer generation A\n' > "$base/generation-a" -printf 'installer generation B\n' > "$base/generation-b" -printf 'installer generation C\n' > "$base/generation-c" -printf 'third-party generation\n' > "$base/third-party" -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - target="$2" - token_a="$(ableton_regular_source_token "$3")" - token_b="$(ableton_regular_source_token "$4")" - token_c="$(ableton_regular_source_token "$5")" - ableton_txn_snapshot "$target" - ableton_txn_expect "$target" "$token_a" - ableton_atomic_restore_object "$3" "$target" - ableton_txn_expect "$target" "$token_b" - [ "$(cut -f4 "$6/files.tsv")" = "$token_a,$token_b" ] - ableton_txn_preflight_rollback_files "$6" - if ableton_txn_preflight_commit_files "$6" >/dev/null 2>&1; then exit 81; fi - ableton_atomic_restore_object "$4" "$target" - ableton_txn_preflight_commit_files "$6" - ableton_txn_expect "$target" "$token_c" - [ "$(cut -f4 "$6/files.tsv")" = "$token_b,$token_c" ] - ableton_txn_preflight_rollback_files "$6" - if ableton_txn_preflight_commit_files "$6" >/dev/null 2>&1; then exit 82; fi - ableton_atomic_restore_object "$5" "$target" - ableton_txn_preflight_commit_files "$6" - ableton_atomic_restore_object "$7" "$target" - if ableton_txn_preflight_commit_files "$6" >/dev/null 2>&1; then exit 83; fi - if ableton_txn_preflight_rollback_files "$6" >/dev/null 2>&1; then exit 84; fi - ableton_atomic_restore_object "$5" "$target" - ableton_txn_rollback_files "$6" -' _ "$here" "$target" "$base/generation-a" "$base/generation-b" \ - "$base/generation-c" "$txn" "$base/third-party" \ - || fail "two-generation transaction chain rejected an installer-owned transition" -grep -qxF 'original generation' "$target" \ - || fail "two-generation transaction rollback did not restore the original object" - -base="$(new_env txn-generation-schema)" -txn="$base/txn" -target="$base/xdg/data/ableton-wine/detect-scale.sh" -mkdir -p -- "$txn" "$(dirname "$target")" -token_a="file:$(printf 'schema A\n' | sha256sum | awk '{print $1}')" -token_b="file:$(printf 'schema B\n' | sha256sum | awk '{print $1}')" -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - target="$2" - token_a="$3" - token_b="$4" - good=(pending absent "$token_a" "absent,$token_a" "$token_a,$token_b") - bad=(",$token_a" "$token_a," "$token_a,,$token_b" \ - "$token_a,$token_a" "$token_a,$token_b,absent" \ - "pending,$token_a" file:abc arbitrary) - for post in "${good[@]}"; do - printf "absent\\t%s\\t-\\t%s\\n" "$target" "$post" > "$5/files.tsv" - ableton_txn_validate_files "$5" - done - for post in "${bad[@]}"; do - printf "absent\\t%s\\t-\\t%s\\n" "$target" "$post" > "$5/files.tsv" - if ableton_txn_validate_files "$5" >/dev/null 2>&1; then exit 85; fi - done -' _ "$here" "$target" "$token_a" "$token_b" "$txn" \ - || fail "transaction generation-token schema accepted an ambiguous chain" -ok "transaction generations keep one rollback-safe predecessor, commit only the final token, and reject ambiguous chains" - -# The in-process snapshot cache must include the journal identity. Component -# and prefix phases can switch transaction directories without starting a new -# shell; the same target still needs an independent prestate row in each one. -base="$(new_env txn-snapshot-cache-journal-scope)" -first_txn="$base/first-transaction" -second_txn="$base/second-transaction" -target="$base/xdg/data/ableton-wine/detect-theme.sh" -mkdir -p -- "$first_txn" "$second_txn" "$(dirname "$target")" -printf 'shared original bytes\n' > "$target" -# shellcheck disable=SC2016 -run_isolated "$base" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ABLETON_TRANSACTION_DIR="$3" - export ABLETON_TRANSACTION_DIR - ableton_txn_init - ableton_txn_snapshot "$2" - ABLETON_TRANSACTION_DIR="$4" - export ABLETON_TRANSACTION_DIR - ableton_txn_init - ableton_txn_snapshot "$2" -' _ "$here" "$target" "$first_txn" "$second_txn" \ - || fail "same-shell snapshot could not switch transaction journals" -for txn in "$first_txn" "$second_txn"; do - [ "$(wc -l < "$txn/files.tsv")" -eq 1 ] \ - || fail "same-shell snapshot omitted or duplicated the target in $txn" - backup="$(awk -F '\t' -v p="$target" '$2==p { print $3 }' "$txn/files.tsv")" - if [ -z "$backup" ] || ! cmp -s -- "$backup" "$target"; then - fail "same-shell snapshot did not preserve exact prestate in $txn" - fi -done -ok "snapshot caching is scoped by transaction directory as well as target path" - -# The installed-file list is disposable uninstall inventory, not integrity -# state. Component and prefix phases may rewrite it sequentially, but neither -# the outer core journal nor the prefix-host journal may claim it (issue #280). -base="$(new_env shared-ownership-manifest)" -outer="$base/outer-transaction" -prefix_host="$outer/prefix-host" -version_source="$base/version-source" -pipeasio_source="$base/pipeasio-source" -mkdir -p -- "$outer" "$prefix_host" -printf 'component version\n' > "$version_source" -printf '[pipeasio]\nbuffer_size = 256\n' > "$pipeasio_source" -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$outer" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_mark_state_home - ableton_txn_init - ableton_install_file 644 "$2" "$ABLETON_DATA_HOME/VERSION" - ableton_write_ownership_manifest -' _ "$here" "$version_source" \ - || fail "component phase could not create the shared-manifest fixture" -manifest="$base/xdg/state/ableton-wine/install-manifest.tsv" -manifest_before="file:$(sha256sum -- "$manifest" | awk '{print $1}')" -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$prefix_host" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - ableton_install_file 600 "$2" "$XDG_CONFIG_HOME/pipeasio/config.ini" config - ableton_write_ownership_manifest "$3" -' _ "$here" "$pipeasio_source" "$outer" \ - || fail "prefix phase could not extend the shared ownership manifest" -manifest_after="file:$(sha256sum -- "$manifest" | awk '{print $1}')" -[ "$manifest_before" != "$manifest_after" ] \ - || fail "prefix fixture did not exercise a real installed-file-list rewrite" -version_target="$base/xdg/data/ableton-wine/VERSION" -pipeasio_target="$base/xdg/config/pipeasio/config.ini" -version_digest="$(sha256sum -- "$version_target" | awk '{print $1}')" -pipeasio_digest="$(sha256sum -- "$pipeasio_target" | awk '{print $1}')" -awk -F '\t' -v p="$version_target" -v d="$version_digest" \ - '$1=="file" && $2==p && $3==d { n++ } END { exit n != 1 }' "$manifest" \ - || fail "installed-file list lost the component VERSION row" -awk -F '\t' -v p="$pipeasio_target" -v d="$pipeasio_digest" \ - '$1=="config" && $2==p && $3==d { n++ } END { exit n != 1 }' "$manifest" \ - || fail "installed-file list lost the prefix PipeASIO row" -for journal in "$outer/files.tsv" "$prefix_host/files.tsv"; do - ! awk -F '\t' -v p="$manifest" '$2==p { found=1 } END { exit !found }' "$journal" \ - || fail "installed-file list was recorded in $journal" -done -run_txn_commit_preflight_only "$base" "$prefix_host" \ - || fail "prefix-host preflight rejected its own completed work" -run_txn_commit_preflight_only "$base" "$outer" \ - || fail "outer preflight inspected the installed-file list" -for txn in "$prefix_host" "$outer"; do - run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_preflight_rollback_files "$2" - ' _ "$here" "$txn" \ - || fail "rollback preflight inspected installed-file inventory in $txn" -done -run_isolated "$base" bash "$here/setup-prefix.sh" --rollback "$outer" \ - || fail "prefix rollback rejected the independent-inventory fixture" -run_isolated "$base" bash "$here/install.sh" --rollback "$outer" \ - || fail "component rollback rejected the independent-inventory fixture" -[ ! -e "$pipeasio_target" ] && [ ! -e "$version_target" ] \ - || fail "ordered prefix/component rollback did not restore generated-file prestate" -[ "file:$(sha256sum -- "$manifest" | awk '{print $1}')" = "$manifest_after" ] \ - || fail "core rollback rewound disposable installed-file inventory" -ok "component and prefix phases share current uninstall inventory outside both journals" - -# A failed second inventory publication leaves the prior inventory generation -# intact. Because neither journal owns it, that warning cannot poison core -# commit or rollback checks. -base="$(new_env shared-manifest-publish-failure)" -outer="$base/outer-transaction" -prefix_host="$outer/prefix-host" -version_source="$base/version-source" -pipeasio_source="$base/pipeasio-source" -mkdir -p -- "$outer" "$prefix_host" -printf 'component version\n' > "$version_source" -printf '[pipeasio]\nbuffer_size = 256\n' > "$pipeasio_source" -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$outer" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_mark_state_home - ableton_txn_init - ableton_install_file 644 "$2" "$ABLETON_DATA_HOME/VERSION" - ableton_write_ownership_manifest -' _ "$here" "$version_source" \ - || fail "component phase could not create the failed-manifest fixture" -manifest="$base/xdg/state/ableton-wine/install-manifest.tsv" -manifest_before="file:$(sha256sum -- "$manifest" | awk '{print $1}')" -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$prefix_host" \ - INJECT_MANIFEST="$manifest" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - ableton_install_file 600 "$2" "$XDG_CONFIG_HOME/pipeasio/config.ini" config - mv() - { - [ "${@: -1}" != "$INJECT_MANIFEST" ] || return 88 - command mv "$@" - } - set +e - ableton_write_ownership_manifest "$3" - rc=$? - set -e - [ "$rc" -ne 0 ] -' _ "$here" "$pipeasio_source" "$outer" \ - || fail "manifest publication failure fixture did not stop at the injected rename" -[ "file:$(sha256sum -- "$manifest" | awk '{print $1}')" = "$manifest_before" ] \ - || fail "failed second manifest publication changed the first generation" -for journal in "$outer/files.tsv" "$prefix_host/files.tsv"; do - ! awk -F '\t' -v p="$manifest" '$2==p { found=1 } END { exit !found }' "$journal" \ - || fail "failed inventory publication added the list to $journal" -done -run_txn_commit_preflight_only "$base" "$prefix_host" \ - || fail "prefix-host commit preflight rejected work completed before manifest failure" -run_txn_commit_preflight_only "$base" "$outer" \ - || fail "outer commit preflight was poisoned by optional inventory failure" -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$prefix_host" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_preflight_rollback_files "$2" -' _ "$here" "$prefix_host" \ - || fail "prefix-host rollback preflight rejected completed config work" -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$outer" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_preflight_rollback_files "$2" -' _ "$here" "$outer" \ - || fail "outer rollback preflight was poisoned by optional inventory failure" -run_isolated "$base" bash "$here/setup-prefix.sh" --rollback "$outer" \ - || fail "prefix rollback failed after interrupted manifest publication" -run_isolated "$base" bash "$here/install.sh" --rollback "$outer" \ - || fail "component rollback failed after interrupted manifest publication" -[ ! -e "$base/xdg/config/pipeasio/config.ini" ] \ - && [ ! -e "$base/xdg/data/ableton-wine/VERSION" ] \ - || fail "inventory warning prevented exact generated-file rollback" -[ "file:$(sha256sum -- "$manifest" | awk '{print $1}')" = "$manifest_before" ] \ - || fail "rollback changed the prior installed-file inventory generation" -ok "failed installed-file-list publication leaves prior inventory and cannot poison core checks" - -for corrupt_kind in symlink-dir orphan-slot; do - base="$(new_env "persistent-prestate-$corrupt_kind")" - state="$base/xdg/state/ableton-wine" - txn="$base/txn" - target="$base/xdg/data/ableton-wine/detect-scale.sh" - mkdir -p -- "$state" "$txn" "$(dirname "$target")" - printf 'format=1\nowner=ableton-linux\n' > "$state/.ableton-linux-state" - cp -- "$here/detect-scale.sh" "$target" - printf 'replacement bytes\n' > "$base/replacement" - if [ "$corrupt_kind" = symlink-dir ]; then - mkdir -p -- "$base/external-prestate-dir" - printf 'external prestate directory sentinel\n' \ - > "$base/external-prestate-dir/sentinel" - ln -s -- "$base/external-prestate-dir" "$state/install-prestate" - else - mkdir -p -- "$state/install-prestate" - id="$(printf '%s' "$target" | sha256sum | awk '{print $1}')" - printf 'external orphan sentinel\n' > "$base/external-orphan" - ln -s -- "$base/external-orphan" "$state/install-prestate/$id" - fi - run_guarded_file_install "$base" "$txn" "$target" \ - >"$base/out" 2>"$base/err" \ - || fail "repair install consulted stale persistent prestate $corrupt_kind" - grep -qxF 'replacement bytes' "$target" \ - || fail "stale persistent prestate $corrupt_kind blocked the canonical replacement" - run_txn_commit_preflight_only "$base" "$txn" \ - || fail "stale persistent prestate $corrupt_kind poisoned commit preflight" - if [ "$corrupt_kind" = symlink-dir ]; then - grep -qxF 'external prestate directory sentinel' \ - "$base/external-prestate-dir/sentinel" \ - || fail "repair install changed a stale prestate symlink referent" - else - grep -qxF 'external orphan sentinel' "$base/external-orphan" \ - || fail "repair install changed a stale orphan backup referent" - fi -done -ok "repair installs ignore stale persistent prestate while leaving legacy recovery objects untouched" - -base="$(new_env atomic-install-failure)" -txn="$base/txn" -state="$base/xdg/state/ableton-wine" -target="$base/xdg/data/ableton-wine/detect-scale.sh" -mkdir -p -- "$txn" "$base/fakebin" "$state" "$(dirname "$target")" -printf 'format=1\nowner=ableton-linux\n' > "$state/.ableton-linux-state" -printf 'stable original bytes\n' > "$base/source" -cp -- "$base/source" "$target" -printf 'file\t%s\t%s\n' "$target" \ - "$(sha256sum -- "$target" | awk '{print $1}')" \ - > "$state/install-manifest.tsv" -cat > "$base/fakebin/install" <<'EOF' -#!/bin/bash -target="${@: -1}" -: > "${ABLETON_TEST_INSTALL_CALLED:?}" -printf 'partial replacement\n' > "$target" -exit 99 -EOF -chmod 755 "$base/fakebin/"* -# shellcheck disable=SC2016 -if run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - ABLETON_TRANSACTION_DIR="$txn" \ - ABLETON_TEST_INSTALL_CALLED="$base/install-called" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - ableton_install_file 600 "$2" "$3" -' _ "$here" "$base/source" "$target" >"$base/out" 2>"$base/err"; then - fail "install helper unexpectedly succeeded after its staged writer failed" -fi -[ -e "$base/install-called" ] \ - || fail "atomic install failure fixture never reached the staged writer" -grep -qxF 'stable original bytes' "$target" \ - || fail "install helper destroyed the original target after a staged write failure" -ok "atomic file installers keep the original target when a staged write fails" - -# Refreshing already-owned generated files does not create new persistent -# prestate. Faults aimed at that legacy path therefore never fire, and ordinary -# transaction rollback still restores both prior managed generations. -for failure in backup-copy index-publish; do - base="$(new_env "prestate-second-generation-$failure")" - txn="$base/txn" - state="$base/xdg/state/ableton-wine" - prestate_dir="$state/install-prestate" - first="$base/xdg/data/ableton-wine/detect-theme.sh" - second="$base/xdg/data/ableton-wine/detect-scale.sh" - mkdir -p -- "$txn" "$state" "$(dirname "$first")" - printf 'format=1\nowner=ableton-linux\n' > "$state/.ableton-linux-state" - printf 'first user original\n' > "$first" - printf 'second user original\n' > "$second" - cp -- "$first" "$base/first.original" - cp -- "$second" "$base/second.original" - printf 'file\t%s\t%s\nfile\t%s\t%s\n' \ - "$first" "$(sha256sum -- "$first" | awk '{print $1}')" \ - "$second" "$(sha256sum -- "$second" | awk '{print $1}')" \ - > "$state/install-manifest.tsv" - printf 'first installer replacement\n' > "$base/first.replacement" - printf 'second installer replacement\n' > "$base/second.replacement" - # shellcheck disable=SC2016 - run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" \ - INJECT_PRESTATE_FAILURE="$failure" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_init - ableton_install_file 644 "$4" "$2" - injection_marker="$7" - cp() - { - if [ "$INJECT_PRESTATE_FAILURE" = backup-copy ]; then - case "${@: -1}" in - "$ABLETON_STATE_HOME/install-prestate/.ableton-restore."*) - : > "$injection_marker" - return 88 ;; - esac - fi - command cp "$@" - } - mv() - { - if [ "$INJECT_PRESTATE_FAILURE" = index-publish ] \ - && [ "${@: -1}" = "$ABLETON_STATE_HOME/install-prestate.tsv" ]; then - : > "$injection_marker" - return 88 - fi - command mv "$@" - } - ableton_install_file 644 "$5" "$3" - [ ! -e "$injection_marker" ] - ableton_txn_preflight_commit_files "$6" - ableton_txn_preflight_rollback_files "$6" - ' _ "$here" "$first" "$second" "$base/first.replacement" \ - "$base/second.replacement" "$txn" "$base/prestate-injection-fired" \ - || fail "repair install reached obsolete prestate $failure publication" - index="$state/install-prestate.tsv" - [ ! -e "$index" ] && [ ! -L "$index" ] \ - || fail "repair install created a legacy prestate index during $failure fixture" - [ ! -e "$prestate_dir" ] && [ ! -L "$prestate_dir" ] \ - || fail "repair install created a legacy prestate directory during $failure fixture" - grep -qxF 'first installer replacement' "$first" \ - && grep -qxF 'second installer replacement' "$second" \ - || fail "repair install did not publish both canonical generations during $failure fixture" - run_txn_file_rollback "$base" "$txn" \ - || fail "full rollback failed after ignored prestate $failure injection" - if ! cmp -s -- "$first" "$base/first.original" \ - || ! cmp -s -- "$second" "$base/second.original"; then - fail "full rollback after ignored prestate $failure injection changed an original target" + if [ "\${argument%/*}" = "\$transaction_root" ]; then + printf 'directory\n' > "\${ABLETON_TEST_CLEANUP_ATTEMPT:?}" + exit 89 + fi + ;; + esac fi done -ok "repair installs bypass obsolete prestate publication and remain transaction-rollback-safe" +exec "$real_rm" "\$@" +EOF + chmod 755 "$base/fakebin/rm" + run_user_rollback "$base" env \ + ABLETON_TEST_REGISTRY_STICKY=0 \ + ABLETON_TEST_CLEANUP_FAILURE="$cleanup_failure" \ + ABLETON_TEST_CLEANUP_ATTEMPT="$base/cleanup-attempt" \ + >"$base/out" 2>"$base/err" \ + || fail "committed $cleanup_failure cleanup failure invalidated the restored runtime" + grep -qxF "$cleanup_failure" "$base/cleanup-attempt" \ + || fail "committed $cleanup_failure cleanup fixture did not reach its boundary" + cleanup_txn="$(find "$base/xdg/state/ableton-wine/transactions" -mindepth 1 \ + -maxdepth 1 -type d -name 'rollback.*' -print | head -n 1)" + [ -n "$cleanup_txn" ] \ + || fail "committed $cleanup_failure cleanup did not retain its recovery directory" + [ ! -e "$cleanup_txn/COMMITTED_CLEANUP_FAILURE" ] \ + || fail "committed $cleanup_failure cleanup invented a failure record" + [ ! -e "$cleanup_txn/active" ] && [ ! -e "$cleanup_txn/files.tsv" ] \ + || fail "cleanup-only directory contains an unused host-file transaction" + [ ! -e "$cleanup_txn/FAILURE" ] \ + || fail "committed $cleanup_failure cleanup incorrectly entered restoration recovery" + [ -f "$base/runtime/previous-generation" ] \ + && [ ! -e "$ROLLBACK_SAVED" ] && [ ! -L "$ROLLBACK_SAVED" ] \ + && grep -qF 'live_major=12' "$ROLLBACK_INSTALLER_CONFIG" \ + && grep -qF 'buffer_size = 512' "$ROLLBACK_PIPEASIO_CONFIG" \ + || fail "committed $cleanup_failure cleanup failure reversed successful state" + grep -qF "The previous Wine version is restored, but temporary files remain at $cleanup_txn." "$base/err" \ + || fail "committed $cleanup_failure cleanup was not reported as cleanup-only" + ! grep -Eq 'could not be restored automatically|Wine version from before this attempt is back in place' "$base/err" \ + || fail "committed $cleanup_failure cleanup printed a restoration claim" +done +ok "postcommit recovery-directory cleanup failures preserve committed state and never claim restoration" + + + + + + + -# Restoring a user's saved object must be one atomic replacement. A failed -# restore may not delete the managed live object and thereby invalidate the -# rollback token recorded immediately beforehand. -base="$(new_env managed-removal-restore-failure)" -txn="$base/txn" -state="$base/xdg/state/ableton-wine" -target="$base/xdg/data/ableton-wine/detect-theme.sh" -id="$(printf '%s' "$target" | sha256sum | awk '{print $1}')" -backup="$state/install-prestate/$id" -index="$state/install-prestate.tsv" -mkdir -p -- "$txn" "$(dirname "$target")" "$(dirname "$backup")" -printf 'format=1\nowner=ableton-linux\n' > "$state/.ableton-linux-state" -printf 'managed live generation\n' > "$target" -printf 'saved user generation\n' > "$backup" -printf 'present\t%s\t%s\n' "$target" "$backup" > "$index" -cp -- "$target" "$base/target.before" -cp -- "$backup" "$base/backup.before" -cp -- "$index" "$base/index.before" -# shellcheck disable=SC2016 -run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" \ - INJECT_RESTORE_SOURCE="$backup" INJECT_RESTORE_TARGET="$target" bash -c ' - set -euo pipefail - . "$1/lib/config.sh" - ableton_config_init - . "$1/lib/manifest.sh" - eval "$(declare -f ableton_atomic_restore_object \ - | sed "1s/^ableton_atomic_restore_object/ableton_atomic_restore_object_real/")" - ableton_atomic_restore_object() - { - if [ "$1" = "$INJECT_RESTORE_SOURCE" ] \ - && [ "$2" = "$INJECT_RESTORE_TARGET" ]; then - return 88 - fi - ableton_atomic_restore_object_real "$@" - } - ableton_txn_init - set +e - ableton_remove_managed_file "$2" - rc=$? - set -e - [ "$rc" -ne 0 ] - cmp -s -- "$2" "$3" - ableton_txn_preflight_rollback_files "$4" -' _ "$here" "$target" "$base/target.before" "$txn" \ - || fail "failed managed restore deleted its live object or poisoned rollback" -run_txn_file_rollback "$base" "$txn" \ - || fail "full rollback failed after the managed restore injection" -if ! cmp -s -- "$target" "$base/target.before" \ - || ! cmp -s -- "$backup" "$base/backup.before" \ - || ! cmp -s -- "$index" "$base/index.before"; then - fail "managed restore failure rollback did not restore exact target and prestate" -fi -ok "failed managed-file restoration leaves the live object intact and fully rollback-safe" -base="$(new_env rollback-current-config-directory)" -make_rollback_fixture "$base" -rm -f -- "$ROLLBACK_INSTALLER_CONFIG" -mkdir -p -- "$ROLLBACK_INSTALLER_CONFIG" -printf 'current config directory sentinel\n' > "$ROLLBACK_INSTALLER_CONFIG/sentinel" -run_user_rollback "$base" env ABLETON_TEST_REGISTRY_STICKY=0 \ - >"$base/out" 2>"$base/err" \ - || fail "current installer-settings directory blocked runtime rollback" -[ -f "$base/runtime/previous-generation" ] \ - && [ ! -e "$ROLLBACK_SAVED" ] && [ ! -L "$ROLLBACK_SAVED" ] \ - && [ -f "$ROLLBACK_INSTALLER_CONFIG/sentinel" ] \ - && grep -qF 'buffer_size = 883' "$ROLLBACK_PIPEASIO_CONFIG" \ - || fail "current installer-settings directory was changed or blocked the restored runtime" -grep -qF 'The previous Wine version is restored, but its installer settings could not be restored.' \ - "$base/err" || fail "current installer-settings directory warning was not explicit" -ok "current installer-settings conflicts warn without blocking runtime rollback" make_legacy_project_evidence() { @@ -4376,61 +2279,7 @@ diff -qr --no-dereference --exclude=.ableton-linux-runtime \ || fail "legacy runtime rollback retained promotion state or retired the outer marker" ok "runtime promotion rollback preserves committed legacy adoption and restores every other runtime object" -base="$(new_env legacy-runtime-uninstall)" -make_legacy_default_runtime "$base" -install_fake_host_tools "$base" -run_isolated "$base" env PATH="$base/fakebin:$PATH" ABLETON_LINK_MODE=off \ - bash "$here/uninstall.sh" --keep-prefix --yes >"$base/out" 2>"$base/err" \ - || fail "optional support-state cleanup reported failure after removing a canonical legacy runtime" -[ ! -e "$base/home/.local/opt/wine-d2d1-nspa-11.13" ] \ - || fail "uninstall retained the adopted canonical legacy runtime" -legacy_uninstall_state="$base/xdg/state/ableton-wine" -if [ -e "$legacy_uninstall_state" ] || [ -L "$legacy_uninstall_state" ]; then - [ -d "$legacy_uninstall_state" ] && [ ! -L "$legacy_uninstall_state" ] \ - && cmp -s -- "$legacy_uninstall_state/.ableton-linux-state" \ - <(printf 'format=1\nowner=ableton-linux\n') \ - || fail "optional legacy-uninstall cleanup retained unsafe unmarked state" - grep -Eq 'Ableton Linux support (files|state).*(remain|retained)|support directory changed.*retained' \ - "$base/out" "$base/err" \ - || fail "retained optional legacy-uninstall state was not reported" -fi -ok "canonical legacy-runtime removal succeeds even when optional support-state cleanup remains" -base="$(new_env legacy-partial-uninstall-retry)" -make_legacy_default_runtime "$base" -legacy_runtime="$base/home/.local/opt/wine-d2d1-nspa-11.13" -state="$base/xdg/state/ableton-wine" -mkdir -p -- "$state" "$base/xdg/data/ableton-wine" -printf 'format=1\nowner=ableton-linux\n' > "$state/.ableton-linux-state" -conflict="$base/xdg/data/ableton-wine/setup-realtime.sh" -printf 'user conflict bytes\n' > "$conflict" -printf 'managed reference bytes\n' > "$base/managed-reference" -printf 'file\t%s\t%s\n' "$conflict" \ - "$(sha256sum -- "$base/managed-reference" | awk '{print $1}')" \ - > "$state/install-manifest.tsv" -install_fake_host_tools "$base" -run_isolated "$base" env PATH="$base/fakebin:$PATH" ABLETON_LINK_MODE=off \ - bash "$here/uninstall.sh" --keep-prefix --yes >"$base/first.out" 2>"$base/first.err" \ - || fail "a preserved modified integration file made core legacy uninstall report failure" -if [ -e "$legacy_runtime" ] || [ -L "$legacy_runtime" ] \ - || ! grep -qxF 'user conflict bytes' "$conflict" \ - || ! cmp -s -- "$state/.ableton-linux-state" \ - <(printf 'format=1\nowner=ableton-linux\n') \ - || [ ! -f "$state/install-manifest.tsv" ]; then - sed -n '1,160p' "$base/first.out" >&2 - sed -n '1,160p' "$base/first.err" >&2 - find "$base" -maxdepth 6 -printf '%y %p -> %l\n' >&2 - fail "core legacy uninstall did not remove Wine while preserving the conflict and safe retry state" -fi -grep -qF "kept an unrecognised or user-modified file at $conflict" "$base/first.err" \ - || fail "preserved modified integration file was not reported" -cp -- "$base/managed-reference" "$conflict" -run_isolated "$base" env PATH="$base/fakebin:$PATH" ABLETON_LINK_MODE=off \ - bash "$here/uninstall.sh" --keep-prefix --yes >"$base/retry.out" 2>"$base/retry.err" \ - || fail "legacy partial uninstall did not complete on retry without legacy evidence" -[ ! -e "$conflict" ] && [ ! -e "$legacy_runtime" ] && [ ! -e "$state" ] \ - || fail "legacy partial-uninstall retry retained the resolved conflict or ownership state" -ok "legacy uninstall removes core Wine, preserves modified integration, and supports optional cleanup retry" base="$(new_env legacy-prefix-adopt)" make_legacy_default_prefix "$base" @@ -4477,210 +2326,7 @@ for kind in runtime prefix; do done ok "legacy migration adopts only canonical proven runtime/prefix trees and refuses custom or malformed ones" -base="$(new_env external-linkd-manifest)" -make_registry_runtime "$base" -external_linkd="$base/external/linkd" -mkdir -p -- "$(dirname "$external_linkd")" -printf 'external Link daemon\n' > "$external_linkd" -external_digest="$(sha256sum -- "$external_linkd" | awk '{print $1}')" -printf 'file\t%s\t%s\nruntime\t%s\twine-d2d1-nspa-11.13\n' \ - "$external_linkd" "$external_digest" "$base/runtime" \ - > "$base/xdg/state/ableton-wine/install-manifest.tsv" -run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ - ABLETON_LINKD="$external_linkd" ABLETON_LINK_MODE=off \ - ABLETON_TEST_REGISTRY_LOG="$base/registry.log" \ - ABLETON_TEST_REGISTRY_STATE="$base/registry-present" \ - bash "$here/uninstall.sh" --keep-prefix --yes >"$base/out" 2>"$base/err" \ - || fail "forged optional inventory blocked configured runtime removal" -grep -qxF 'external Link daemon' "$external_linkd" \ - || fail "forged optional inventory changed the external Link daemon" -[ ! -e "$base/runtime" ] && [ ! -L "$base/runtime" ] \ - && [ -f "$base/prefix/system.reg" ] && [ ! -e "$base/registry-present" ] \ - && [ -r "$base/xdg/state/ableton-wine/install-manifest.tsv" ] \ - || fail "forged optional inventory retained Wine or discarded its inspection record" -grep -qF "Desktop shortcuts, file-opening settings, and older Wine runtimes were left unchanged because the installer's file list could not be trusted" \ - "$base/err" || fail "forged optional inventory warning was not explicit" -ok "external Link helpers are never claimed, while invalid inventory cannot retain Wine" - -base="$(new_env transaction-external-target)" -txn="$base/txn" -victim="$base/external/victim" -mkdir -p -- "$txn/files" "$(dirname "$victim")" -: > "$txn/active" -printf 'live external\n' > "$victim" -printf 'forged restore\n' > "$txn/files/0" -printf 'present\t%s\t%s\n' "$victim" "$txn/files/0" > "$txn/files.tsv" -if run_isolated "$base" env ABLETON_TRANSACTION_DIR="$txn" bash -c ' - set -euo pipefail - . "$1/lib/config.sh"; ableton_config_init - . "$1/lib/manifest.sh" - ableton_txn_rollback_files "$2" -' _ "$here" "$txn" >"$base/out" 2>"$base/err"; then - fail "transaction rollback accepted an arbitrary external target" -fi -grep -qxF 'live external' "$victim" \ - || fail "external transaction target was changed before scope refusal" -ok "transaction rollback authorizes only exact lifecycle targets" - -base="$(new_env aggregate-runtime-preflight)" -txn="$base/txn" -backup="$base/runtime.transaction-${txn##*/}" -mkdir -p -- "$base/runtime" "$backup" "$txn/files" -printf 'format=1\nname=wine-d2d1-nspa-11.13\n' > "$base/runtime/.ableton-linux-runtime" -printf 'format=1\nname=wine-d2d1-nspa-11.13\n' > "$backup/.ableton-linux-runtime" -printf 'new runtime\n' > "$base/runtime/generation" -printf 'old runtime\n' > "$backup/generation" -printf '%s\t%s\n' "$base/runtime" "$backup" > "$txn/runtime.tsv" -: > "$txn/active" -printf 'forged\n' > "$txn/files/0" -printf 'present\t%s\t%s/files/999\n' \ - "$base/xdg/data/ableton-wine/detect-theme.sh" "$txn" > "$txn/files.tsv" -if run_isolated "$base" env ABLETON_WINE_ROOT="$base/runtime" \ - bash "$here/install.sh" --rollback "$txn" >"$base/out" 2>"$base/err"; then - fail "component rollback accepted a malformed later file journal" -fi -grep -qxF 'new runtime' "$base/runtime/generation" \ - && grep -qxF 'old runtime' "$backup/generation" \ - && [ -f "$txn/runtime.tsv" ] \ - || fail "component rollback mutated runtime before full preflight" - -base="$(new_env aggregate-prefix-preflight)" -txn="$base/txn" -backup="$base/prefix.transaction-${txn##*/}" -mkdir -p -- "$base/prefix" "$backup" "$txn/prefix-host/files" -printf 'format=1\nprefix=%s\n' "$base/prefix" > "$base/prefix/.ableton-linux-prefix" -printf 'format=1\nprefix=%s\n' "$base/prefix" > "$backup/.ableton-linux-prefix" -printf 'new prefix\n' > "$base/prefix/generation" -printf 'old prefix\n' > "$backup/generation" -printf '%s\t%s\n' "$base/prefix" "$backup" > "$txn/prefix.tsv" -: > "$txn/prefix-host/active" -printf 'forged\n' > "$txn/prefix-host/files/0" -printf 'present\t%s\t%s/files/999\n' \ - "$base/xdg/config/pipeasio/config.ini" "$txn/prefix-host" \ - > "$txn/prefix-host/files.tsv" -if run_isolated "$base" env ABLETON_WINEPREFIX="$base/prefix" \ - bash "$here/setup-prefix.sh" --rollback "$txn" >"$base/out" 2>"$base/err"; then - fail "prefix rollback accepted a malformed later host-file journal" -fi -grep -qxF 'new prefix' "$base/prefix/generation" \ - && grep -qxF 'old prefix' "$backup/generation" \ - && [ -f "$txn/prefix.tsv" ] \ - || fail "prefix rollback mutated layout before full preflight" -ok "runtime and prefix rollback preflight every journal before layout mutation" - -run_link_transaction_action() -{ - local base="$1" action="$2" txn="$3" - if [ ! -x "$base/fakebin/systemctl" ] || [ ! -x "$base/fakebin/xdg-mime" ]; then - install_fake_host_tools "$base" - fi - run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ - ABLETON_LINK_MODE=session bash "$here/setup-link.sh" "$action" "$txn" -} - -base="$(new_env link-valid-snapshot)" -txn="$base/txn" -data="$base/xdg/data/ableton-wine" -state="$base/xdg/state/ableton-wine" -mkdir -p -- "$txn" "$base/runtime" "$base/prefix" "$data" "$state" "$base/fakebin" -# A manager that is unavailable both before and during rollback is exact state: -# no service command can have changed it. This keeps this fixture focused on -# policy-file ownership rather than service-manager behavior. -printf '#!/bin/sh\nexit 1\n' > "$base/fakebin/systemctl" -printf '#!/bin/sh\nexit 0\n' > "$base/fakebin/xdg-mime" -chmod 755 "$base/fakebin/systemctl" "$base/fakebin/xdg-mime" -printf 'format=1\nowner=ableton-linux\n' > "$state/.ableton-linux-state" -printf 'configured\n' > "$data/link-configured" -printf 'component asset before\n' > "$data/setup-link.sh" -printf 'file\t%s\t%s\n' "$data/setup-link.sh" \ - "$(sha256sum -- "$data/setup-link.sh" | awk '{print $1}')" \ - > "$state/install-manifest.tsv" -run_link_transaction_action "$base" snapshot "$txn" >"$base/snapshot.out" 2>"$base/snapshot.err" \ - || fail "valid Link snapshot fixture could not capture a complete snapshot" -run_link_transaction_action "$base" preflight-rollback "$txn" >"$base/preflight.out" 2>"$base/preflight.err" \ - || fail "valid complete Link snapshot was rejected by preflight" -find "$txn/link" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort > "$base/link.members" -[ "$(wc -l < "$base/link.members")" -eq 10 ] \ - || fail "valid Link snapshot did not record the full inventory" -! grep -Eq '^(asset-|manifest|prestate)' "$base/link.members" \ - || fail "Link policy snapshot duplicated component transaction state" -! grep -Eq '^link-configured([.]absent)?$' "$base/link.members" \ - || fail "Link policy snapshot turned disposable legacy metadata into a rollback gate" -# Component files may advance after the policy snapshot. Link preflight must not -# reject or later restore those generic-transaction-owned objects. -printf 'component asset after\n' > "$data/setup-link.sh" -printf 'file\t%s\t%s\n' "$data/setup-link.sh" \ - "$(sha256sum -- "$data/setup-link.sh" | awk '{print $1}')" \ - > "$state/install-manifest.tsv" -rm -f -- "$data/link-configured" -run_link_transaction_action "$base" preflight-rollback "$txn" \ - >"$base/changed-preflight.out" 2>"$base/changed-preflight.err" \ - || fail "Link policy preflight inspected component-owned files" -run_link_transaction_action "$base" rollback "$txn" >"$base/rollback.out" 2>"$base/rollback.err" \ - || { sed -n '1,80p' "$base/rollback.err" >&2; fail "Link policy rollback failed"; } -[ ! -e "$data/link-configured" ] && [ ! -L "$data/link-configured" ] \ - || fail "Link rollback recreated disposable legacy policy metadata" -grep -qxF 'component asset after' "$data/setup-link.sh" \ - && grep -qF "$(sha256sum -- "$data/setup-link.sh" | awk '{print $1}')" \ - "$state/install-manifest.tsv" \ - || fail "Link rollback rewound generic component state" -ok "Link snapshot owns external system state without gating on disposable legacy metadata" - -base="$(new_env link-snapshot-inventory-tamper)" -txn="$base/txn" -mkdir -p -- "$txn" "$base/runtime" "$base/prefix" "$base/fakebin" -printf '#!/bin/sh\nexit 1\n' > "$base/fakebin/systemctl" -printf '#!/bin/sh\nexit 0\n' > "$base/fakebin/xdg-mime" -chmod 755 "$base/fakebin/systemctl" "$base/fakebin/xdg-mime" -run_link_transaction_action "$base" snapshot "$txn" >"$base/snapshot.out" 2>"$base/snapshot.err" \ - || fail "Link inventory-tamper fixture could not capture its snapshot" -: > "$txn/link/asset-0.path" -if run_link_transaction_action "$base" preflight-rollback "$txn" >"$base/out" 2>"$base/err"; then - fail "Link preflight accepted a component-asset member in its policy snapshot" -fi -grep -qF 'Link transaction snapshot has an unknown member: asset-0.path' "$base/err" \ - || fail "Link policy inventory tamper refusal was not explicit" -ok "Link policy snapshot inventory rejects component-owned members" - -for corrupt_kind in symlink multiline valid-unmarked; do - base="$(new_env "link-firewall-${corrupt_kind}")" - txn="$base/txn" - mkdir -p -- "$txn" "$base/runtime" "$base/prefix" "$base/xdg/state/ableton-wine" - case "$corrupt_kind" in - symlink) - printf 'format=1\nowner=ableton-linux\n' \ - > "$base/xdg/state/ableton-wine/.ableton-linux-state" - printf 'external firewall sentinel\n' > "$base/external-firewall" - ln -s -- "$base/external-firewall" "$base/xdg/state/ableton-wine/link-firewall" - ;; - multiline) - printf 'format=1\nowner=ableton-linux\n' \ - > "$base/xdg/state/ableton-wine/.ableton-linux-state" - printf 'ufw-added\nextra\n' > "$base/xdg/state/ableton-wine/link-firewall" - ;; - valid-unmarked) - printf 'ufw-added\n' > "$base/xdg/state/ableton-wine/link-firewall" ;; - esac - if run_link_transaction_action "$base" snapshot "$txn" >"$base/out" 2>"$base/err"; then - fail "Link snapshot accepted a $corrupt_kind firewall state record" - fi - grep -qF "unsafe Link firewall ownership record: $base/xdg/state/ableton-wine/link-firewall" "$base/err" \ - || fail "$corrupt_kind firewall snapshot refusal was not explicit" -done -base="$(new_env link-unit-symlink)" -txn="$base/txn" -unit_file="$base/xdg/config/systemd/user/ableton-linkd.service" -mkdir -p -- "$txn" "$base/runtime" "$base/prefix" "$(dirname "$unit_file")" -printf 'external unit sentinel\n' > "$base/external-unit" -ln -s -- "$base/external-unit" "$unit_file" -run_link_transaction_action "$base" snapshot "$txn" >"$base/out" 2>"$base/err" \ - || fail "a generated user-service collision became a Link snapshot gate" -grep -qxF 'external unit sentinel' "$base/external-unit" \ - || fail "Link snapshot followed or changed a generated-path symlink" -ok "Link snapshot rejects unsafe firewall authority without gating on generated unit files" base="$(new_env mime-postpublish-query-not-used)" install_fake_host_tools "$base" @@ -4710,7 +2356,7 @@ case "${1:-}" in esac EOF chmod 755 "$base/fakebin/xdg-mime" -run_component_install "$base" "$base/runtime" --integration-only \ +run_component_install "$base" "$base/runtime" --integration-only --yes \ >"$base/out" 2>"$base/err" \ || fail "MIME default publication aborted generated integration" [ ! -e "$base/xdg/config/mimeapps.list.query-called" ] \ @@ -4749,7 +2395,7 @@ case "${1:-}" in esac EOF chmod 755 "$base/fakebin/xdg-mime" -run_component_install "$base" "$base/runtime" --integration-only >"$base/out" 2>"$base/err" \ +run_component_install "$base" "$base/runtime" --integration-only --yes >"$base/out" 2>"$base/err" \ || fail "staged MIME failure aborted generated integration" grep -qF 'Could not set Ableton as the default app for Live files. Ableton itself can still be used normally.' \ "$base/err" || fail "staged MIME failure did not produce its plain warning" @@ -4759,100 +2405,6 @@ cmp -s -- "$base/xdg/config/mimeapps.list" <(printf '[Default Applications]\nx-s || fail "staged MIME failure rolled back the generated launcher" ok "MIME staging failure warns, preserves live associations, and keeps generated integration" -# Model an xdg-mime backend with process-external side effects: every default -# command consumes a global serial and records it in the selected mimeapps -# file. Defaults are prepared against one temporary copy and published once as -# optional live state, outside the generated-file transaction journal. -base="$(new_env mime-single-publication)" -install_fake_host_tools "$base" -make_runtime "$base/runtime" "$base/BUILD-INFO.txt" built -printf 'format=1\nname=wine-d2d1-nspa-11.13\n' > "$base/runtime/.ableton-linux-runtime" -mkdir -p -- "$base/xdg/config" -printf '[Default Applications]\nx-scheme-handler/ableton=foreign.desktop\napplication/x-unrelated=foreign-other.desktop\n' \ - > "$base/xdg/config/mimeapps.list" -cp -- "$base/xdg/config/mimeapps.list" "$base/mimeapps.before" -printf '0\n' > "$base/mime-backend-count" -: > "$base/mime-call-log" -cat > "$base/fakebin/xdg-mime" <<'EOF' -#!/bin/sh -set -eu -state="${XDG_CONFIG_HOME:?}/mimeapps.list" -global="${MIME_GLOBAL_STATE:?}" -log="${MIME_CALL_LOG:?}" -case "${1:-}" in - query) - [ "${2:-}" = default ] && [ "$#" -eq 3 ] - printf 'query\t%s\t%s\n' "$XDG_CONFIG_HOME" "$3" >> "$log" - awk -F '=' -v type="$3" '$1 == type { value=$2 } END { print value }' \ - "$state" 2>/dev/null || true - ;; - default) - [ "$#" -ge 3 ] - application="$2" - shift 2 - serial="$(cat "$global")" - serial=$((serial + 1)) - printf '%s\n' "$serial" > "$global" - printf 'default\t%s\t%s\n' "$XDG_CONFIG_HOME" "$application" >> "$log" - mkdir -p -- "$(dirname "$state")" - touch "$state" - awk '$0 !~ /^# backend-serial=/' "$state" > "$state.clean" - mv -- "$state.clean" "$state" - for type in "$@"; do - awk -F '=' -v type="$type" '$1 != type' "$state" > "$state.tmp" - printf '%s=%s\n' "$type" "$application" >> "$state.tmp" - mv -- "$state.tmp" "$state" - done - printf '# backend-serial=%s\n' "$serial" >> "$state" - ;; - *) exit 2 ;; -esac -EOF -chmod 755 "$base/fakebin/xdg-mime" -txn="$base/mime-transaction" -mkdir -p -- "$txn" -export MIME_GLOBAL_STATE="$base/mime-backend-count" -export MIME_CALL_LOG="$base/mime-call-log" -run_component_install "$base" "$base/runtime" --integration-only \ - --transaction-dir "$txn" >"$base/install.out" 2>"$base/install.err" \ - || fail "transactional MIME integration failed with a serializing backend" -unset MIME_GLOBAL_STATE MIME_CALL_LOG -[ "$(cat "$base/mime-backend-count")" -eq 3 ] \ - || fail "MIME integration replayed default mutations after staged publication" -[ "$(awk -F '\t' '$1=="default" { n++ } END { print n+0 }' "$base/mime-call-log")" -eq 3 ] \ - || fail "MIME backend saw an unexpected number of default mutations" -awk -F '\t' -v prefix="$base/tmp/ableton-mimeapps." ' - $1=="default" && index($2, prefix) != 1 { bad=1 } - END { exit bad } -' "$base/mime-call-log" \ - || fail "a MIME default mutation targeted the live configuration home" -grep -qxF '# backend-serial=3' "$base/xdg/config/mimeapps.list" \ - || fail "the staged MIME generation was not the single published live object" -! awk -F '\t' -v p="$base/xdg/config/mimeapps.list" \ - '$2==p { found=1 } END { exit !found }' "$txn/files.tsv" \ - || fail "optional MIME defaults were recorded in the generated-file journal" -run_component_install "$base" "$base/runtime" --preflight-commit "$txn" \ - >"$base/commit-preflight.out" 2>"$base/commit-preflight.err" \ - || fail "MIME integration rejected its own final generation at commit preflight" -run_component_install "$base" "$base/runtime" --preflight-rollback "$txn" \ - >"$base/rollback-preflight.out" 2>"$base/rollback-preflight.err" \ - || fail "MIME integration rejected its own final generation at rollback preflight" -run_component_install "$base" "$base/runtime" --rollback "$txn" \ - >"$base/rollback.out" 2>"$base/rollback.err" \ - || fail "generated integration could not roll back with optional MIME state present" -grep -qxF 'application/x-unrelated=foreign-other.desktop' \ - "$base/xdg/config/mimeapps.list" \ - || fail "optional MIME publication or generated-file rollback corrupted an unrelated default" -grep -qxF 'x-scheme-handler/ableton=io.github.shibco.ableton-linux.protocol.desktop' \ - "$base/xdg/config/mimeapps.list" \ - || fail "generated-file rollback rewound the independently published Ableton default" -grep -qxF '# backend-serial=3' "$base/xdg/config/mimeapps.list" \ - || fail "generated-file rollback changed the optional MIME generation" -[ -x "$base/home/.local/bin/ableton-live" ] \ - || fail "component rollback removed an independently completed launcher repair" -[ -f "$txn/active" ] \ - || fail "MIME component rollback retired its coordinator-owned active marker" -ok "MIME defaults and completed generated files stay published across later component recovery" write_valid_managed_config() { @@ -4993,7 +2545,7 @@ runtime_root=$base/runtime prefix=$base/prefix link_mode=session EOF -run_isolated "$base" env PATH="$base/fakebin:$PATH" \ +run_isolated "$base" env PATH="$base/fakebin:$PATH" ABLETON_PROJECT_ASSUME_YES=1 \ bash "$here/setup-link.sh" disable >"$base/out" 2>"$base/err" \ || fail "missing defaultable installer settings blocked Link disable" run_isolated "$base" bash -c ' @@ -5029,7 +2581,7 @@ run_isolated "$base" env BASH_ENV="$base/hide-cabextract.bash" \ ABLETON_RUNTIME_PENDING=1 \ bash "$here/setup-prefix.sh" --refresh --validate >"$base/out" 2>"$base/err" \ || fail "unused cabextract or missing default settings blocked prefix refresh validation" -grep -qF -- '-- Wine prefix settings are valid' "$base/out" \ +grep -qF -- 'ableton-linux prefix settings are valid' "$base/out" \ || fail "direct prefix validation did not reach its successful result" grep -qxF "runtime_root=$base/runtime" "$config_path" \ || fail "prefix validation changed the project settings it only needed to read" @@ -5067,7 +2619,7 @@ run_isolated "$base" env PATH="$base/fakebin:$PATH" \ bash "$base/kit/scripts/installer.sh" plan runtime install \ --runtime-root "$base/runtime" --yes >"$base/out" 2>"$base/err" \ || fail "public runtime plan failed" -[ "$(grep -c '^== Check the Wine package:' "$base/out")" -eq 1 ] \ +[ "$(grep -c 'Check the Wine package' "$base/out")" -eq 1 ] \ || fail "public runtime plan validates and extracts its payload more than once" if find "$base/tmp" -mindepth 1 -maxdepth 1 \ \( -name 'ableton-install-plan.*' -o -name 'ableton-runtime-validate.*' \) \ @@ -5151,116 +2703,7 @@ EOF done ok "failed runtime checks preserve their cause without fake rollback or recovery state" -base="$(new_env rollback-literal-pipeasio-symlink)" -make_rollback_fixture "$base" -foreign_pipeasio="$base/foreign-pipeasio.ini" -printf '[pipeasio]\nbuffer_size = 192\nforeign = keep\n' > "$foreign_pipeasio" -foreign_pipeasio_digest="$(sha256sum -- "$foreign_pipeasio" | awk '{print $1}')" -rm -f -- "$ROLLBACK_SAVED/.ableton-linux-rollback/pipeasio-config.ini" -ln -s -- "$foreign_pipeasio" \ - "$ROLLBACK_SAVED/.ableton-linux-rollback/pipeasio-config.ini" -run_user_rollback "$base" env ABLETON_TEST_REGISTRY_STICKY=0 \ - >"$base/out" 2>"$base/err" \ - || fail "rollback rejected a literal PipeASIO configuration symlink snapshot" -[ -L "$ROLLBACK_PIPEASIO_CONFIG" ] \ - && [ "$(readlink -- "$ROLLBACK_PIPEASIO_CONFIG")" = "$foreign_pipeasio" ] \ - || fail "rollback restored the PipeASIO symlink referent instead of the literal object" -[ "$(sha256sum -- "$foreign_pipeasio" | awk '{print $1}')" = "$foreign_pipeasio_digest" ] \ - || fail "literal PipeASIO symlink rollback changed the external referent" -ok "runtime rollback restores the literal PipeASIO config symlink object" - -install_config_race_flock() -{ - local base="$1" real_flock - real_flock="$(command -v flock)" - mkdir -p -- "$base/fakebin" - cat > "$base/fakebin/flock" < "\${ABLETON_TEST_FLOCK_MUTATED:?}" -fi -exec "$real_flock" "\$@" -EOF - chmod 755 "$base/fakebin/flock" -} - -for race_kind in public-runtime direct-link; do - base="$(new_env "config-race-${race_kind}")" - config_path="$base/xdg/config/ableton-wine/config" - old_linkd="$base/home/old-linkd" - new_linkd="$base/home/new-linkd" - printf 'old Link sentinel\n' > "$old_linkd" - printf 'new Link sentinel\n' > "$new_linkd" - write_valid_managed_config "$config_path" "$base/runtime" "$base/prefix" "$old_linkd" - replacement="$base/config.replacement" - write_valid_managed_config "$replacement" "$base/changed-runtime" \ - "$base/changed-prefix" "$new_linkd" - install_config_race_flock "$base" - if [ "$race_kind" = public-runtime ]; then - make_runtime_only_kit "$base" - command=(bash "$base/kit/scripts/installer.sh" runtime install \ - --runtime-root "$base/runtime" --yes) - else - install_fake_host_tools "$base" - command=(bash "$here/setup-link.sh" disable) - fi - if run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - PROBE_CLIENT=1.4.2 PROBE_DAEMON=1.4.2 \ - ABLETON_TEST_FLOCK_MUTATED="$base/flock-mutated" \ - ABLETON_TEST_CONFIG_REPLACEMENT="$replacement" \ - ABLETON_TEST_CONFIG_TARGET="$config_path" \ - "${command[@]}" >"$base/out" 2>"$base/err"; then - fail "$race_kind accepted an installer configuration changed while waiting for the lock" - fi - grep -qF 'installation configuration changed; retry the command' "$base/err" \ - || fail "$race_kind config-race refusal did not report the exact retry diagnostic" - if ! grep -qxF 'old Link sentinel' "$old_linkd" \ - || ! grep -qxF 'new Link sentinel' "$new_linkd"; then - fail "$race_kind config-race refusal changed a resolved Link target" - fi - [ ! -e "$base/runtime" ] && [ ! -e "$base/changed-runtime" ] \ - || fail "$race_kind config-race refusal changed a runtime path" -done -ok "public and direct mutators refuse a configuration changed before lock acquisition" -base="$(new_env config-repair-race)" -config_path="$base/xdg/config/ableton-wine/config" -mkdir -p -- "$(dirname "$config_path")" -cat > "$config_path" < "$replacement" <"$base/out" 2>"$base/err"; then - fail "repair mode accepted a malformed config replaced before lock acquisition" -fi -[ -e "$base/flock-mutated" ] \ - || fail "malformed-config lock-race fixture did not replace its generation" -grep -qF 'installation configuration changed; retry the command' "$base/err" \ - || fail "malformed-config generation race did not report an exact retry" -[ ! -e "$base/runtime" ] \ - || fail "malformed-config generation race reached runtime promotion" -ok "repair mode binds salvaged values to one generation before any core mutation" base="$(new_env inherited-lock-daemon)" linkd="$base/xdg/data/ableton-wine/ableton-linkd" @@ -5293,348 +2736,39 @@ run_isolated "$base" env -u ABLETON_INSTALL_LOCK_FD bash -c ' kill "$link_pid" 2>/dev/null || true ok "detached Link daemons close the inherited installer lock descriptor" -make_commit_preflight_kit() -{ - local base="$1" kit="$1/commit-kit" - mkdir -p -- "$kit/scripts/lib" - cp -- "$here/installer.sh" "$kit/scripts/" - cp -- "$here/lib/config.sh" "$here/lib/lifecycle.sh" "$here/lib/live-options.sh" \ - "$here/lib/manifest.sh" "$here/lib/pipeasio.sh" "$kit/scripts/lib/" - cat > "$kit/scripts/install.sh" <<'EOF' -#!/bin/sh -set -eu -printf 'component %s\n' "$*" >> "${ABLETON_TEST_CALL_LOG:?}" -case " $* " in - *' --preflight-commit '*) exit 0 ;; - *' --commit '*) rm -f -- "$2/component-backup" ;; -esac -EOF - cat > "$kit/scripts/setup-prefix.sh" <<'EOF' -#!/bin/sh -set -eu -printf 'prefix %s\n' "$*" >> "${ABLETON_TEST_CALL_LOG:?}" -case " $* " in - *' --preflight-commit '*) - if [ "${ABLETON_TEST_FAIL_COMMIT_DOMAIN:-}" = prefix ]; then - echo '!! injected prefix commit preflight failure' >&2 - exit 77 - fi ;; - *' --commit '*) rm -f -- "$2/prefix-backup" ;; -esac -EOF - cat > "$kit/scripts/setup-link.sh" <<'EOF' -#!/bin/sh -set -eu -printf 'link %s\n' "$*" >> "${ABLETON_TEST_CALL_LOG:?}" -case "${1:-}" in - snapshot) - mkdir -p -- "$2/link" - : > "$2/link/ready" - : > "$2/component-backup" - : > "$2/prefix-backup" - ;; - enable) - if [ "${ABLETON_TEST_WRITE_LINK_CONFIG:-0}" -eq 1 ]; then - mkdir -p -- "$(dirname "${ABLETON_CONFIG_FILE:?}")" - printf '# ableton-linux installer configuration; managed by the installer\nformat=1\nruntime_root=%s\nprefix=%s\nlive_major=%s\nlink_mode=%s\nlinkd=%s\n' \ - "${ABLETON_WINE_ROOT:?}" "${ABLETON_WINEPREFIX:?}" \ - "${ABLETON_LIVE_VERSION:-}" "${ABLETON_LINK_MODE:?}" \ - "${ABLETON_LINKD:?}" > "$ABLETON_CONFIG_FILE" - : > "${ABLETON_TEST_CONFIG_RECHECK_MARKER:?}" - fi - ;; - preflight-commit) - if [ "${ABLETON_TEST_FAIL_COMMIT_DOMAIN:-}" = link ]; then - echo '!! injected Link commit preflight failure' >&2 - exit 78 - fi ;; - commit) rm -rf -- "$2/link" ;; -esac -EOF - chmod 755 "$kit/scripts/install.sh" "$kit/scripts/setup-prefix.sh" \ - "$kit/scripts/setup-link.sh" -} - -for fail_domain in prefix link; do - base="$(new_env "aggregate-commit-${fail_domain}")" - make_commit_preflight_kit "$base" - call_log="$base/calls.log" - : > "$call_log" - if run_isolated "$base" env \ - ABLETON_TEST_CALL_LOG="$call_log" \ - ABLETON_TEST_FAIL_COMMIT_DOMAIN="$fail_domain" \ - bash "$base/commit-kit/scripts/installer.sh" link disable \ - >"$base/out" 2>"$base/err"; then - fail "outer installer accepted an injected $fail_domain commit-preflight failure" - fi - if [ "$fail_domain" = prefix ]; then - grep -qF 'injected prefix commit preflight failure' "$base/err" \ - || fail "prefix commit-preflight failure did not reach its intended validator" - else - grep -qF 'injected Link commit preflight failure' "$base/err" \ - || fail "Link commit-preflight failure did not reach its intended validator" - fi - if grep -Eq '^component --commit |^prefix --commit |^link commit ' "$call_log"; then - fail "$fail_domain preflight failure retired an earlier component snapshot" - fi - failed_txn="$(find "$base/xdg/state/ableton-wine/transactions" \ - -mindepth 1 -maxdepth 1 -type d -name 'installer.*' -print -quit)" - [ -n "$failed_txn" ] \ - && [ -f "$failed_txn/component-backup" ] \ - && [ -f "$failed_txn/prefix-backup" ] \ - || fail "$fail_domain preflight failure did not retain every rollback generation" -done -ok "outer commit preflights every domain before retiring any rollback material" - -# The Link helper owns the write and postcondition for its saved setting. The -# parent must not add another checksum/read gate after the requested Link action -# has already succeeded. -base="$(new_env link-parent-config-recheck)" -make_commit_preflight_kit "$base" -call_log="$base/calls.log" -: > "$call_log" -mkdir -p -- "$base/fakebin" -real_sha256sum="$(command -v sha256sum)" -cat > "$base/fakebin/sha256sum" < "$base/out" 2> "$base/err" \ - || fail "parent settings handling invented a failure after Link succeeded" -grep -qxF 'link_mode=session' "$base/xdg/config/ableton-wine/config" \ - || fail "Link helper did not publish the selected saved setting" -[ -e "$base/recheck-ready" ] \ - || fail "parent redundantly re-read the Link helper's generated setting" -! grep -qF 'saved setting could not be rechecked' "$base/err" \ - || fail "parent printed a redundant saved-setting diagnostic" -grep -qxF 'OK: Ableton Link is enabled (session)' "$base/out" \ - || fail "parent settings handling hid the successful Link outcome" -if grep -Eq -- '--rollback |^link rollback ' "$call_log"; then - fail "parent settings handling rolled back a successful Link action" -fi -ok "the parent adds no generated-setting gate after successful Link setup" -make_pr182_custom_link_fixture() -{ - local base="$1" mode="$2" data state config custom digest id backup - make_runtime_only_kit "$base" - install_fake_host_tools "$base" - # Model an unavailable user manager; Link disable must still complete from - # its file/config state. - printf '#!/bin/sh\nexit 1\n' > "$base/fakebin/systemctl" - chmod 755 "$base/fakebin/systemctl" - cp -- "$here/setup-link.sh" "$here/ableton-linkctl" \ - "$here/ableton-linkd.service" "$base/kit/scripts/" - cat > "$base/kit/bin/ableton-linkd" <<'EOF' -#!/bin/sh -case "${1:-}" in - --help) echo 'fixture Link daemon' ;; -esac -exit 0 -EOF - chmod 755 "$base/kit/bin/ableton-linkd" - cat > "$base/fakebin/readelf" <<'EOF' +# Link support is another fixed mapping set. Replacing one existing path moves +# that object into the per-run inert backup tree; it never invokes the Link +# lifecycle controller and never creates ownership or recovery journals. +base="$(new_env link-assets-fixed-paths)" +make_runtime_only_kit "$base" +cp -- "$here/setup-link.sh" "$here/ableton-linkctl" \ + "$here/ableton-linkd.service" "$base/kit/scripts/" +cat > "$base/kit/bin/ableton-linkd" <<'EOF' #!/bin/sh -exit 0 +echo 'fixture Link daemon' EOF - chmod 755 "$base/fakebin/readelf" - - data="$base/xdg/data/ableton-wine" - state="$base/xdg/state/ableton-wine" - config="$base/xdg/config/ableton-wine/config" - custom="$base/home/custom/ableton-linkd" - mkdir -p -- "$data" "$state" "$base/home/.local/bin" "$(dirname "$custom")" - printf 'format=1\nowner=ableton-linux\n' > "$state/.ableton-linux-state" - chmod 600 "$state/.ableton-linux-state" - printf '2026.08.08.1\n' > "$data/VERSION" - printf '#!/bin/sh\n# Ableton Live launcher for the patched Wine stack\n' \ - > "$base/home/.local/bin/ableton-live" - chmod 755 "$base/home/.local/bin/ableton-live" - write_valid_managed_config "$config" "$base/runtime" "$base/prefix" "$custom" - printf 'historical PR182 Link binary\n' > "$custom" - chmod 755 "$custom" - digest="$(sha256sum -- "$custom" | awk '{print $1}')" - case "$mode" in - owned-prestate) - printf 'file\t%s\t%s\n' "$custom" "$digest" > "$state/install-manifest.tsv" - id="$(printf '%s' "$custom" | sha256sum | awk '{print $1}')" - backup="$state/install-prestate/$id" - mkdir -p -- "$state/install-prestate" - printf 'foreign pre-PR182 Link binary\n' > "$backup" - chmod 755 "$backup" - printf 'present\t%s\t%s\n' "$custom" "$backup" \ - > "$state/install-prestate.tsv" - ;; - owned-stale-prestate) - # Recognition is still backed by the exact PR182 release evidence, - # but the old restoration journal is deliberately unusable. The - # helper must stay live while canonical Link support is repaired. - printf 'file\t%s\t%s\n' "$custom" "$digest" > "$state/install-manifest.tsv" - mkdir -p -- "$state/install-prestate" - printf 'stale recovery sentinel\n' > "$state/install-prestate/not-a-valid-slot" - printf 'present\t%s\t/tmp/untrusted-pr182-backup\n' "$custom" \ - > "$state/install-prestate.tsv" - ;; - modified) - printf 'file\t%s\t%s\n' "$custom" "$digest" > "$state/install-manifest.tsv" - printf 'user modified former PR182 Link binary\n' > "$custom" - chmod 755 "$custom" - ;; - unowned-off) - # A genuine PR182 --link=off install had the surrounding release - # evidence but no ownership row for the configured external path. - : > "$state/install-manifest.tsv" - printf 'external Link sentinel from --link=off\n' > "$custom" - chmod 755 "$custom" - ;; - *) fail "unknown PR182 custom-Link fixture mode $mode" ;; - esac - PR182_CUSTOM_LINK="$custom" - PR182_MANIFEST="$state/install-manifest.tsv" - PR182_PRESTATE_INDEX="$state/install-prestate.tsv" - PR182_CONFIG="$config" -} - -# Publishing generated Link support files does not need to stop or restart a -# running daemon. The following controller deliberately fails if invoked: the -# file update must still succeed, leaving lifecycle changes to setup-link.sh. -base="$(new_env link-assets-ignore-running-state)" -make_pr182_custom_link_fixture "$base" unowned-off +chmod 755 "$base/kit/bin/ableton-linkd" canonical="$base/xdg/data/ableton-wine/ableton-linkd" -cp -- "$base/kit/bin/ableton-linkd" "$canonical" +mkdir -p -- "$(dirname "$canonical")" +printf 'foreign Link helper\n' > "$canonical" chmod 755 "$canonical" -cat > "$base/kit/scripts/ableton-linkctl" <<'EOF' -#!/bin/sh -: > "${ABLETON_TEST_LINKCTL_CALLED:?}" -exit 73 -EOF -chmod 755 "$base/kit/scripts/ableton-linkctl" -txn="$base/link-assets-transaction" -mkdir -p -- "$txn" run_isolated "$base" env PATH="$base/fakebin:$PATH" \ + ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ ABLETON_LINKD="$canonical" \ - ABLETON_TEST_LINKCTL_CALLED="$base/link-controller-called" \ - bash "$base/kit/scripts/install.sh" --link-assets-only \ - --transaction-dir "$txn" >"$base/out" 2>"$base/err" \ - || fail "running Link state became a generated-file update gate" -[ ! -e "$base/link-controller-called" ] \ - || fail "Link asset publication invoked the lifecycle controller" -[ -x "$canonical" ] \ - && [ -x "$base/xdg/data/ableton-wine/ableton-linkctl" ] \ - && [ -x "$base/xdg/data/ableton-wine/setup-link.sh" ] \ - && [ -f "$base/xdg/data/ableton-wine/ableton-linkd.service" ] \ - || fail "Link asset publication did not replace its generated files" -ok "Link support files are replaced without a lifecycle failure gate" - -# A repair that still needs Link support enters install.sh with the narrowly -# proven historical custom path already canonicalized by the coordinator. Bad -# legacy restoration data may prevent retirement, but must not block canonical -# support or mutate the custom helper. -base="$(new_env pr182-custom-link-stale-repair)" -make_pr182_custom_link_fixture "$base" owned-stale-prestate -custom="$PR182_CUSTOM_LINK" -canonical="$base/xdg/data/ableton-wine/ableton-linkd" -txn="$base/link-repair-transaction" -mkdir -p -- "$txn" -run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - ABLETON_PR182_CUSTOM_LINKD="$custom" ABLETON_LINKD="$canonical" \ - bash "$base/kit/scripts/install.sh" --link-assets-only \ - --transaction-dir "$txn" >"$base/out" 2>"$base/err" \ - || fail "stale PR182 recovery data aborted canonical Link repair" -grep -qxF 'historical PR182 Link binary' "$custom" \ - || fail "stale PR182 recovery data changed the custom helper" -grep -qF 'Kept the older custom Link helper because its saved recovery data could not be used safely.' \ - "$base/err" || fail "stale PR182 recovery data did not produce its plain warning" -grep -qxF 'stale recovery sentinel' \ - "$(dirname "$PR182_PRESTATE_INDEX")/install-prestate/not-a-valid-slot" \ - || fail "PR182 repair consumed an unverifiable recovery object" + bash "$base/kit/scripts/install.sh" --link-assets-only --yes \ + >"$base/out" 2>"$base/err" \ + || fail "fixed Link support mappings failed" [ -x "$canonical" ] \ && [ -x "$base/xdg/data/ableton-wine/ableton-linkctl" ] \ && [ -x "$base/xdg/data/ableton-wine/setup-link.sh" ] \ && [ -f "$base/xdg/data/ableton-wine/ableton-linkd.service" ] \ - || fail "stale PR182 recovery data prevented canonical Link support repair" -if awk -F '\t' -v p="$custom" '$2==p { found=1 } END { exit !found }' \ - "$PR182_MANIFEST"; then - fail "stale PR182 helper retained an obsolete ownership row after repair" -fi -run_component_install "$base" "$base/runtime" --preflight-commit "$txn" \ - >"$base/preflight.out" 2>"$base/preflight.err" \ - || fail "stale PR182 recovery data poisoned canonical Link commit preflight" -ok "stale PR182 recovery data keeps the custom helper while canonical Link support is repaired" - -# Disabling Link no longer stages support files just to delete them. It keeps -# every external custom helper and its historical recovery object untouched, -# while pruning obsolete ownership rows and saving the canonical disabled -# configuration for a proven PR182 release. -for migration_mode in owned-prestate owned-stale-prestate modified unowned-off; do - base="$(new_env "pr182-custom-link-${migration_mode}")" - make_pr182_custom_link_fixture "$base" "$migration_mode" - custom="$PR182_CUSTOM_LINK" - if ! run_isolated "$base" env PATH="$base/fakebin:$PATH" \ - bash "$base/kit/scripts/installer.sh" link disable \ - >"$base/out" 2>"$base/err"; then - sed -n '1,160p' "$base/out" >&2 - sed -n '1,200p' "$base/err" >&2 - fail "public Link disable could not migrate PR182 custom-Link mode $migration_mode" - fi - case "$migration_mode" in - owned-prestate) - grep -qxF 'historical PR182 Link binary' "$custom" \ - || fail "Link disable changed the historical custom helper" - awk -F '\t' -v p="$custom" '$2==p { found=1 } END { exit !found }' \ - "$PR182_PRESTATE_INDEX" \ - || fail "Link disable consumed the custom helper's historical recovery row" - ;; - owned-stale-prestate) - grep -qxF 'historical PR182 Link binary' "$custom" \ - || fail "Link disable changed the helper with stale PR182 recovery data" - grep -qxF 'stale recovery sentinel' \ - "$(dirname "$PR182_PRESTATE_INDEX")/install-prestate/not-a-valid-slot" \ - || fail "Link disable consumed an unverifiable custom recovery object" - ;; - modified) - grep -qxF 'user modified former PR182 Link binary' "$custom" \ - || fail "Link disable overwrote a user-modified PR182 helper" - ;; - unowned-off) - grep -qxF 'external Link sentinel from --link=off' "$custom" \ - || fail "PR182 --link=off evidence falsely authorized external deletion" - grep -qxF "linkd=$custom" "$PR182_CONFIG" \ - || fail "unowned external Link configuration was unexpectedly canonicalized" - ;; - esac - if awk -F '\t' -v p="$custom" '$2==p { found=1 } END { exit !found }' \ - "$PR182_MANIFEST"; then - fail "PR182 custom-Link mode $migration_mode retained an obsolete ownership row" - fi - grep -qxF 'link_mode=off' "$PR182_CONFIG" \ - || fail "PR182 custom-Link mode $migration_mode did not finish disabled" - for generated_link_file in ableton-linkd ableton-linkctl setup-link.sh ableton-linkd.service; do - [ ! -e "$base/xdg/data/ableton-wine/$generated_link_file" ] \ - && [ ! -L "$base/xdg/data/ableton-wine/$generated_link_file" ] \ - || fail "Link disable retained canonical support file $generated_link_file" - done - if [ "$migration_mode" != unowned-off ]; then - grep -qxF "linkd=$base/xdg/data/ableton-wine/ableton-linkd" "$PR182_CONFIG" \ - || fail "verified PR182 custom-Link ownership was not migrated to the canonical path" - fi -done -ok "Link disable preserves PR182 custom helpers while removing canonical support and stale ownership rows" + && [ -f "$base/xdg/config/systemd/user/ableton-linkd.service" ] \ + || fail "Link support did not install every fixed destination" +link_backup="$(find "$base/xdg/state/ableton-wine/backups" \ + -type f -name 'ableton-linkd.bak-*' -print -quit)" +[ -n "$link_backup" ] && grep -qxF 'foreign Link helper' "$link_backup" \ + || fail "Link support did not retain the displaced helper as an inert backup" +ok "Link support uses fixed destinations and inert central backups" printf 'PASS: %s focused PipeASIO installer checks\n' "$pass" diff --git a/scripts/test-preflight-integration.sh b/scripts/test-preflight-integration.sh new file mode 100755 index 00000000..5450b01e --- /dev/null +++ b/scripts/test-preflight-integration.sh @@ -0,0 +1,1193 @@ +#!/usr/bin/env bash +# Tests for preflight command-line choices, write timing, launcher input, +# uninstall ownership, packaging, and documentation. +# Behaviour IDs map to notes/PREFLIGHT-SETTINGS-TEST-MATRIX.md. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +root="$(cd "$here/.." && pwd)" +work="$(mktemp -d "${TMPDIR:-/tmp}/ableton-preflight-integration-test.XXXXXX")" +trap 'rm -rf -- "$work"' EXIT + +pass=0 +ok() { pass=$((pass + 1)); printf 'ok - %s\n' "$1"; } +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } + +preferences_lib="$here/lib/preferences.sh" +[ -r "$preferences_lib" ] || fail "scripts/lib/preferences.sh exists before integration tests run" +. "$preferences_lib" + +# A complete but side-effect-light coordinator kit. The real dispatcher and +# lifecycle libraries run; only the expensive runtime/prefix/Link components +# are replaced. The prefix test program prints values from the main install step. +kit="$work/kit" +mkdir -p -- "$kit/scripts/lib" "$kit/bin" +cp -- "$here/installer.sh" "$kit/scripts/" +cp -- "$here/lib/config.sh" "$here/lib/lifecycle.sh" "$here/lib/live-options.sh" \ + "$here/lib/manifest.sh" "$here/lib/pipeasio.sh" "$here/lib/preferences.sh" \ + "$here/lib/ui.sh" "$kit/scripts/lib/" +cat > "$kit/bin/pipewire-version-probe" <<'EOF' +#!/bin/sh +printf 'client=1.4.2\ndaemon=1.4.2\n' +EOF +cat > "$kit/scripts/install.sh" <<'EOF' +#!/bin/sh +set -eu +assert_settings_unchanged() +{ + preferences="${XDG_CONFIG_HOME:-$HOME/.config}/ableton-wine/preferences" + buffer="${XDG_CONFIG_HOME:-$HOME/.config}/pipeasio/config.ini" + [ -z "${ABLETON_TEST_EXPECT_PREFERENCES_SHA:-}" ] \ + || [ "$(sha256sum "$preferences" | awk '{print $1}')" \ + = "$ABLETON_TEST_EXPECT_PREFERENCES_SHA" ] || exit 93 + [ -z "${ABLETON_TEST_EXPECT_BUFFER_SHA:-}" ] \ + || [ "$(sha256sum "$buffer" | awk '{print $1}')" \ + = "$ABLETON_TEST_EXPECT_BUFFER_SHA" ] || exit 94 +} +printf 'component args=%s\n' "$*" >> "${ABLETON_TEST_CALL_LOG:?}" +case " $* " in + *' --integration-only '*) exit "${ABLETON_TEST_INTEGRATION_EXIT:-0}" ;; + *' --commit '*) exit "${ABLETON_TEST_COMPONENT_COMMIT_EXIT:-0}" ;; + *' --preflight-commit '*) + assert_settings_unchanged + if [ "${ABLETON_TEST_ASSERT_SETTINGS_BEFORE_CORE:-0}" -eq 1 ] \ + && [ -e "${XDG_CONFIG_HOME:-$HOME/.config}/ableton-wine/preferences" ]; then + exit 91 + fi + exit "${ABLETON_TEST_COMPONENT_PREFLIGHT_EXIT:-0}" ;; + *' --preflight-rollback '*) + assert_settings_unchanged + exit "${ABLETON_TEST_COMPONENT_ROLLBACK_PREFLIGHT_EXIT:-0}" ;; + *' --rollback '*) exit "${ABLETON_TEST_COMPONENT_ROLLBACK_EXIT:-0}" ;; + *' --transaction-dir '*) + assert_settings_unchanged + exit "${ABLETON_TEST_COMPONENT_CORE_EXIT:-0}" ;; +esac +exit 0 +EOF +cat > "$kit/scripts/setup-prefix.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +. "$(cd "$(dirname "$0")" && pwd)/lib/preferences.sh" +printf 'prefix args=%s seed=%s dpi=%s\n' "$*" \ + "${ABLETON_PIPEASIO_BUFFER_SEED:-unset}" "${ABLETON_DPI_MODE:-unset}" \ + >> "${ABLETON_TEST_CALL_LOG:?}" +preferences="${XDG_CONFIG_HOME:-$HOME/.config}/ableton-wine/preferences" +buffer="${XDG_CONFIG_HOME:-$HOME/.config}/pipeasio/config.ini" +transaction="" +previous="" +for argument; do + if [ "$previous" = --transaction-dir ] \ + || [ "$previous" = --rollback ] \ + || [ "$previous" = --preflight-rollback ] \ + || [ "$previous" = --preflight-commit ] \ + || [ "$previous" = --commit ]; then + transaction="$argument" + fi + previous="$argument" +done +assert_settings_unchanged() +{ + allow_core_seed="${1:-0}" + [ -z "${ABLETON_TEST_EXPECT_PREFERENCES_SHA:-}" ] \ + || [ "$(sha256sum "$preferences" | awk '{print $1}')" \ + = "$ABLETON_TEST_EXPECT_PREFERENCES_SHA" ] || exit 93 + [ -z "${ABLETON_TEST_EXPECT_BUFFER_SHA:-}" ] \ + || [ "$(sha256sum "$buffer" | awk '{print $1}')" \ + = "$ABLETON_TEST_EXPECT_BUFFER_SHA" ] || exit 94 + if [ "${ABLETON_TEST_ASSERT_SETTINGS_BEFORE_CORE:-0}" -eq 1 ]; then + [ ! -e "$preferences" ] || exit 91 + [ "$allow_core_seed" -eq 1 ] || [ ! -e "$buffer" ] || exit 92 + fi +} +case " $* " in + *' --validate '*) assert_settings_unchanged; exit 0 ;; + *' --commit '*) ableton_pipeasio_seed_record_commit "$transaction"; exit 0 ;; + *' --preflight-commit '*) + # Prefix construction owns a fresh PipeASIO seed, so it may exist at + # this boundary. Launcher preferences and any pre-core write remain + # forbidden; existing buffers are still hash-checked above. + assert_settings_unchanged 1 + ableton_pipeasio_seed_record_commit_preflight "$transaction" + ableton_pipeasio_seed_record_load "$transaction" + if [ "$ABLETON_PIPEASIO_SEED_PRESENT" -eq 1 ]; then + recorded_token="$ABLETON_PIPEASIO_SEED_TOKEN" + current_token="$(ableton_preferences_object_token "$buffer")" + printf 'prefix preflight recorded-token=%s\n' "$recorded_token" \ + >> "${ABLETON_TEST_CALL_LOG:?}" + printf 'prefix preflight current-token=%s\n' "$current_token" \ + >> "${ABLETON_TEST_CALL_LOG:?}" + [ "$recorded_token" = "$current_token" ] \ + || [ "${ABLETON_TEST_REPLACE_SEED_SAME_VALUE:-0}" -eq 1 ] \ + || exit 95 + fi + exit "${ABLETON_TEST_PREFIX_PREFLIGHT_EXIT:-0}" ;; + *' --preflight-rollback '*) ableton_pipeasio_seed_record_preflight "$transaction"; exit 0 ;; + *' --rollback '*) + ableton_pipeasio_seed_record_rollback "$transaction" + rm -rf -- "${ABLETON_WINEPREFIX:?}" + exit 0 ;; +esac +assert_settings_unchanged +[ "${ABLETON_TEST_PREFIX_CORE_EXIT:-0}" -eq 0 ] \ + || exit "${ABLETON_TEST_PREFIX_CORE_EXIT}" +mkdir -p -- "${ABLETON_WINEPREFIX:?}" +printf 'WINE REGISTRY Version 2\n' > "$ABLETON_WINEPREFIX/system.reg" +pipeasio="${XDG_CONFIG_HOME:-$HOME/.config}/pipeasio/config.ini" +if [ ! -e "$pipeasio" ]; then + mkdir -p -- "$(dirname "$pipeasio")" + pipeasio_tmp="$(dirname "$pipeasio")/.config.ini.ABC123" + printf '[pipeasio]\nbuffer_size = %s\n' \ + "${ABLETON_PIPEASIO_BUFFER_SEED:-256}" > "$pipeasio_tmp" + chmod 600 "$pipeasio_tmp" + producer_token="$(ableton_pipeasio_seed_identity_token "$pipeasio_tmp")" + ableton_pipeasio_seed_record_publish "$transaction" "$pipeasio" \ + "$producer_token" "$pipeasio_tmp" + mv -T -n -- "$pipeasio_tmp" "$pipeasio" + if [ "${ABLETON_TEST_REPLACE_SEED_BEFORE_TOKEN:-0}" -eq 1 ]; then + replacement="$pipeasio.replacement" + cp -- "$pipeasio" "$replacement" + mv -T -f -- "$replacement" "$pipeasio" + replacement_token="$(ableton_preferences_object_token "$pipeasio")" + printf 'prefix pre-token-replacement-token=%s\n' "$replacement_token" \ + >> "${ABLETON_TEST_CALL_LOG:?}" + fi + if [ "${ABLETON_TEST_SKIP_SEED_PROMOTION:-0}" -eq 1 ]; then + printf 'prefix provisional-seed=%s\n' "$producer_token" \ + >> "${ABLETON_TEST_CALL_LOG:?}" + else + if ! ableton_pipeasio_seed_record_promote "$transaction" "$pipeasio" \ + "$producer_token"; then + ableton_pipeasio_seed_record_rollback "$transaction" + exit 79 + fi + seed_token="$(ableton_preferences_object_token "$pipeasio")" + printf 'prefix seed-token=%s\n' "$seed_token" >> "${ABLETON_TEST_CALL_LOG:?}" + fi +fi +if [ "${ABLETON_TEST_CAPTURE_RECOVERY_OBJECTS:-0}" -eq 1 ]; then + printf 'active recovery evidence\n' > "$transaction/active" + printf 'prefix captured-buffer-object=%s\n' \ + "$(ableton_preferences_object_token "$pipeasio")" \ + >> "${ABLETON_TEST_CALL_LOG:?}" + printf 'prefix captured-journal-object=%s\n' \ + "$(ableton_preferences_object_token "$transaction/pipeasio-seed.v1")" \ + >> "${ABLETON_TEST_CALL_LOG:?}" + printf 'prefix captured-active-object=%s\n' \ + "$(ableton_preferences_object_token "$transaction/active")" \ + >> "${ABLETON_TEST_CALL_LOG:?}" + printf 'prefix captured-prefix-object=%s\n' \ + "$(ableton_preferences_object_token "$ABLETON_WINEPREFIX/system.reg")" \ + >> "${ABLETON_TEST_CALL_LOG:?}" +fi +if [ "${ABLETON_TEST_PREFIX_FAIL_AFTER_SEED:-0}" -ne 0 ]; then + exit "$ABLETON_TEST_PREFIX_FAIL_AFTER_SEED" +fi +if [ "${ABLETON_TEST_REPLACE_SEED_SAME_VALUE:-0}" -eq 1 ]; then + replacement="$pipeasio.replacement" + cp -- "$pipeasio" "$replacement" + mv -T -f -- "$replacement" "$pipeasio" + replacement_token="$(ableton_preferences_object_token "$pipeasio")" + printf 'prefix replacement-token=%s\n' "$replacement_token" \ + >> "${ABLETON_TEST_CALL_LOG:?}" +fi +if [ "${ABLETON_TEST_MALFORMED_SEED_JOURNAL:-0}" -eq 1 ]; then + path_b64="$(printf '%s' "$pipeasio" | base64 | tr -d '\n')" + token_b64="$(printf '%s' 'file|garbage' | base64 | tr -d '\n')" + printf 'format=1\npath_b64=%s\ntoken_b64=%s\ntemp_b64=\n' \ + "$path_b64" "$token_b64" > "$transaction/pipeasio-seed.v1" + printf 'active recovery evidence\n' > "$transaction/active" + printf 'prefix malformed-buffer-object=%s\n' \ + "$(ableton_preferences_object_token "$pipeasio")" \ + >> "${ABLETON_TEST_CALL_LOG:?}" + printf 'prefix malformed-journal-object=%s\n' \ + "$(ableton_preferences_object_token "$transaction/pipeasio-seed.v1")" \ + >> "${ABLETON_TEST_CALL_LOG:?}" + printf 'prefix malformed-active-object=%s\n' \ + "$(ableton_preferences_object_token "$transaction/active")" \ + >> "${ABLETON_TEST_CALL_LOG:?}" +fi +if [ "${ABLETON_TEST_RACE_BUFFER:-0}" -eq 1 ]; then + printf 'outputs = 7\n' >> "$pipeasio" +fi +case "${ABLETON_TEST_RACE_PREFERENCES:-off}" in + replace|create) + preferences="${XDG_CONFIG_HOME:-$HOME/.config}/ableton-wine/preferences" + mkdir -p -- "$(dirname "$preferences")" + cat > "$preferences" <<'PREFERENCES' +# ableton-linux launcher preferences; managed by the installer +format=1 +shortcuts=preserve +dpi=100 +audio_threads=8 +rt=auto +power=off +PREFERENCES + chmod 600 "$preferences" ;; +esac +EOF +cat > "$kit/scripts/setup-link.sh" <<'EOF' +#!/bin/sh +set -eu +printf 'link args=%s\n' "$*" >> "${ABLETON_TEST_CALL_LOG:?}" +exit "${ABLETON_TEST_LINK_EXIT:-0}" +EOF +cat > "$kit/scripts/uninstall.sh" <<'EOF' +#!/bin/sh +printf 'uninstall args=%s\n' "$*" >> "${ABLETON_TEST_CALL_LOG:?}" +exit 0 +EOF +chmod 755 "$kit/scripts/"*.sh "$kit/bin/pipewire-version-probe" + +new_case() # name -> base +{ + local base="$work/$1" + mkdir -p -- "$base/home" "$base/config" "$base/data" "$base/state" \ + "$base/cache" "$base/run" "$base/tmp" "$base/runtime/bin" + printf '#!/bin/sh\nexit 0\n' > "$base/runtime/bin/wine" + printf '#!/bin/sh\nexit 0\n' > "$base/runtime/bin/wineserver" + chmod 755 "$base/runtime/bin/"* + : > "$base/calls.log" + printf '%s\n' "$base" +} + +run_public() # base [ENV=VALUE ...] -- installer arguments... +{ + local base="$1"; shift + local -a extra=() + while [ "${1:-}" != -- ]; do extra+=("$1"); shift; done + shift + env -i PATH="$PATH" HOME="$base/home" USER=test \ + XDG_CONFIG_HOME="$base/config" XDG_DATA_HOME="$base/data" \ + XDG_STATE_HOME="$base/state" XDG_CACHE_HOME="$base/cache" \ + XDG_RUNTIME_DIR="$base/run" TMPDIR="$base/tmp" LANG=C.UTF-8 \ + ABLETON_TEST_CALL_LOG="$base/calls.log" "${extra[@]}" \ + bash "$kit/scripts/installer.sh" "$@" +} + +write_preferences() # base shortcuts dpi threads rt power +{ + local base="$1" shortcuts="$2" dpi="$3" threads="$4" rt="$5" power="$6" + mkdir -p -- "$base/config/ableton-wine" + printf '%s\n' \ + '# ableton-linux launcher preferences; managed by the installer' \ + 'format=1' "shortcuts=$shortcuts" "dpi=$dpi" \ + "audio_threads=$threads" "rt=$rt" "power=$power" \ + > "$base/config/ableton-wine/preferences" + chmod 600 "$base/config/ableton-wine/preferences" +} + +make_update_state() # base +{ + local base="$1" + mkdir -p -- "$base/prefix" "$base/config/ableton-wine" + printf 'WINE REGISTRY Version 2\n' > "$base/prefix/system.reg" + printf 'format=1\nprefix=%s\n' "$base/prefix" \ + > "$base/prefix/.ableton-linux-prefix" +} + +mutable_token() # base: all user-mutated roots, excluding test logs +{ + local base="$1" + local -a entries=(home config data state cache run runtime) + [ ! -e "$base/prefix" ] || entries+=(prefix) + tar --sort=name --mtime='@0' --owner=0 --group=0 --numeric-owner \ + -cf - -C "$base" "${entries[@]}" 2>/dev/null \ + | sha256sum +} + +# CLI-SCHEMA: the six flags have exact grammars, reject duplicates, and are +# valid only for the coordinated install/update commands. +base="$(new_case cli-schema)" +valid=(--audio-buffer=1024 --shortcuts=preserve --dpi=fractional \ + --audio-threads=63 --rt=off --power=balanced) +run_public "$base" -- plan install --skip-live-install --link=off \ + --runtime-root "$base/runtime" --prefix "$base/prefix" "${valid[@]}" \ + >"$base/out" 2>"$base/err" \ + || fail "the six valid persistent flags are rejected by plan install" +make_update_state "$base" +run_public "$base" -- plan update --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" "${valid[@]}" >"$base/update.out" 2>"$base/update.err" \ + || fail "the six valid persistent flags are rejected by plan update" +allowed_flags=( + --audio-buffer=64 --audio-buffer=128 --audio-buffer=256 + --audio-buffer=512 --audio-buffer=1024 + --shortcuts=take --shortcuts=preserve + --dpi=auto --dpi=100 --dpi=fractional --dpi=preserve + --audio-threads=auto --audio-threads=off + --audio-threads=1 --audio-threads=63 + --rt=auto --rt=off + --power=performance --power=balanced --power=off +) +for worker in $(seq 2 62); do + allowed_flags+=("--audio-threads=$worker") +done +allowed_index=0 +for allowed in "${allowed_flags[@]}"; do + allowed_index=$((allowed_index + 1)) + allowed_base="$(new_case "allowed-$allowed_index")" + run_public "$allowed_base" -- plan install --skip-live-install --link=off \ + --runtime-root "$allowed_base/runtime" --prefix "$allowed_base/prefix" \ + "$allowed" >"$allowed_base/install.out" 2>"$allowed_base/install.err" \ + || fail "allowed persistent value is rejected by plan install: $allowed" + make_update_state "$allowed_base" + run_public "$allowed_base" -- plan update --link=off \ + --runtime-root "$allowed_base/runtime" --prefix "$allowed_base/prefix" \ + "$allowed" >"$allowed_base/update.out" 2>"$allowed_base/update.err" \ + || fail "allowed persistent value is rejected by plan update: $allowed" +done +for invalid in --audio-buffer=32 --audio-buffer=2048 --audio-buffer=064 \ + --audio-buffer=128x --shortcuts=other --shortcuts=take-now \ + --dpi=125 --dpi=Auto --dpi=dpi100 \ + --audio-threads=0 --audio-threads=64 --audio-threads=01 \ + --audio-threads=1x --rt=on --rt=automatic \ + --power=auto --power=fast --power=performance-mode; do + for invalid_command in install update; do + invalid_status=0 + invalid_extra=() + [ "$invalid_command" != install ] || invalid_extra=(--skip-live-install) + run_public "$base" -- plan "$invalid_command" "${invalid_extra[@]}" \ + --link=off --runtime-root "$base/runtime" --prefix "$base/prefix" \ + "$invalid" >"$base/invalid.out" 2>"$base/invalid.err" \ + || invalid_status=$? + [ "$invalid_status" -eq 2 ] \ + || fail "invalid $invalid_command flag has status $invalid_status, not parser status 2: $invalid" + grep -qF -- "${invalid%%=*}" "$base/invalid.err" \ + || fail "invalid $invalid_command flag diagnostic does not name ${invalid%%=*}" + done +done +invalid_forms=( + '--audio-buffer' '--audio-buffer=' '--audio-buffer 128' + '--shortcuts' '--shortcuts=' '--shortcuts take' + '--dpi' '--dpi=' '--dpi auto' + '--audio-threads' '--audio-threads=' '--audio-threads 8' + '--rt' '--rt=' '--rt auto' + '--power' '--power=' '--power balanced' +) +for invalid_form in "${invalid_forms[@]}"; do + read -r -a invalid_words <<< "$invalid_form" + invalid_name="${invalid_words[0]%%=*}" + for invalid_command in install update; do + invalid_status=0 + invalid_extra=() + [ "$invalid_command" != install ] || invalid_extra=(--skip-live-install) + run_public "$base" -- plan "$invalid_command" "${invalid_extra[@]}" \ + --link=off --runtime-root "$base/runtime" --prefix "$base/prefix" \ + "${invalid_words[@]}" >"$base/form.out" 2>"$base/form.err" \ + || invalid_status=$? + [ "$invalid_status" -eq 2 ] \ + || fail "$invalid_command accepts invalid flag form: $invalid_form" + grep -qF -- "$invalid_name" "$base/form.err" \ + || fail "invalid flag-form diagnostic does not name $invalid_name" + done +done +scoped_flags=(--audio-buffer=128 --shortcuts=take --dpi=auto \ + --audio-threads=auto --rt=auto --power=performance) +for duplicate in "${scoped_flags[@]}"; do + for duplicate_command in install update; do + duplicate_status=0 + duplicate_extra=() + [ "$duplicate_command" != install ] || duplicate_extra=(--skip-live-install) + run_public "$base" -- plan "$duplicate_command" "${duplicate_extra[@]}" \ + --link=off --runtime-root "$base/runtime" --prefix "$base/prefix" \ + "$duplicate" "$duplicate" >"$base/dup.out" 2>"$base/dup.err" \ + || duplicate_status=$? + [ "$duplicate_status" -eq 2 ] \ + || fail "repeated ${duplicate%%=*} with $duplicate_command has status $duplicate_status, not 2" + grep -qi "more than once\|specified twice\|duplicate" "$base/dup.err" \ + || fail "repeated ${duplicate%%=*} with $duplicate_command has no duplication diagnostic" + done +done +conflicting_duplicates=( + '--audio-buffer=64|--audio-buffer=128' + '--shortcuts=take|--shortcuts=preserve' + '--dpi=auto|--dpi=100' + '--audio-threads=1|--audio-threads=63' + '--rt=auto|--rt=off' + '--power=performance|--power=balanced' +) +for pair in "${conflicting_duplicates[@]}"; do + first="${pair%%|*}" + second="${pair#*|}" + for duplicate_command in install update; do + duplicate_status=0 + duplicate_extra=() + [ "$duplicate_command" != install ] || duplicate_extra=(--skip-live-install) + run_public "$base" -- plan "$duplicate_command" "${duplicate_extra[@]}" \ + --link=off --runtime-root "$base/runtime" --prefix "$base/prefix" \ + "$first" "$second" >"$base/conflict.out" 2>"$base/conflict.err" \ + || duplicate_status=$? + [ "$duplicate_status" -eq 2 ] \ + || fail "conflicting ${first%%=*} values with $duplicate_command do not fail as duplicates" + grep -qi "more than once\|specified twice\|duplicate" "$base/conflict.err" \ + || fail "conflicting ${first%%=*} values have no duplicate diagnostic" + done +done +for unrelated in 'runtime install' 'prefix create' 'link enable' 'uninstall'; do + read -r -a command_words <<< "$unrelated" + for scoped in "${scoped_flags[@]}"; do + unrelated_status=0 + run_public "$base" -- "${command_words[@]}" "$scoped" --dry-run \ + >"$base/unrelated.out" 2>"$base/unrelated.err" \ + || unrelated_status=$? + [ "$unrelated_status" -eq 2 ] \ + || fail "$scoped with $unrelated has status $unrelated_status, not parser status 2" + grep -qF -- "${scoped%%=*}" "$base/unrelated.err" \ + || fail "$unrelated rejection does not name ${scoped%%=*}: $unrelated" + done +done +ok "CLI-SCHEMA: exact setting grammars, duplication, and command scope are enforced" + +# CLI-COMMIT-MATRIX: every user-selectable equivalence class reaches its exact +# persistent representation through both coordinated Install and Update. +commit_rows=( + 'audio-buffer|64|buffer_size|64' 'audio-buffer|128|buffer_size|128' + 'audio-buffer|256|buffer_size|256' 'audio-buffer|512|buffer_size|512' + 'audio-buffer|1024|buffer_size|1024' + 'shortcuts|take|shortcuts|take' 'shortcuts|preserve|shortcuts|preserve' + 'dpi|auto|dpi|auto' 'dpi|100|dpi|100' 'dpi|fractional|dpi|fractional' + 'dpi|preserve|dpi|preserve' + 'audio-threads|auto|audio_threads|auto' 'audio-threads|off|audio_threads|off' + 'audio-threads|1|audio_threads|1' 'audio-threads|16|audio_threads|16' + 'audio-threads|63|audio_threads|63' + 'rt|auto|rt|auto' 'rt|off|rt|off' + 'power|performance|power|performance' 'power|balanced|power|balanced' + 'power|off|power|off' +) +commit_index=0 +for row in "${commit_rows[@]}"; do + IFS='|' read -r option value key expected <<< "$row" + for commit_command in install update; do + commit_index=$((commit_index + 1)) + commit_base="$(new_case "commit-$commit_index")" + commit_extra=() + if [ "$commit_command" = install ]; then + commit_extra=(--skip-live-install) + else + make_update_state "$commit_base" + mkdir -p -- "$commit_base/config/pipeasio" + printf '[pipeasio]\nbuffer_size = 2048\n' \ + > "$commit_base/config/pipeasio/config.ini" + case "$key:$expected" in + shortcuts:take) write_preferences "$commit_base" preserve auto auto auto performance ;; + shortcuts:preserve) write_preferences "$commit_base" take auto auto auto performance ;; + dpi:auto) write_preferences "$commit_base" take preserve auto auto performance ;; + dpi:*) write_preferences "$commit_base" take auto auto auto performance ;; + audio_threads:auto) write_preferences "$commit_base" take auto off auto performance ;; + audio_threads:*) write_preferences "$commit_base" take auto auto auto performance ;; + rt:auto) write_preferences "$commit_base" take auto auto off performance ;; + rt:off) write_preferences "$commit_base" take auto auto auto performance ;; + power:performance) write_preferences "$commit_base" take auto auto auto off ;; + power:*) write_preferences "$commit_base" take auto auto auto performance ;; + esac + fi + run_public "$commit_base" -- "$commit_command" "${commit_extra[@]}" \ + --link=off --runtime-root "$commit_base/runtime" \ + --prefix "$commit_base/prefix" --yes "--$option=$value" \ + >"$commit_base/out" 2>"$commit_base/err" \ + || fail "$commit_command rejects or cannot commit --$option=$value" + if [ "$key" = buffer_size ]; then + grep -qxF "buffer_size = $expected" \ + "$commit_base/config/pipeasio/config.ini" \ + || fail "$commit_command mis-commits --$option=$value" + [ "$(grep -Ec '^[[:space:]]*buffer_size[[:space:]]*=' \ + "$commit_base/config/pipeasio/config.ini")" -eq 1 ] \ + || fail "$commit_command duplicates --$option=$value" + else + preferences="$commit_base/config/ableton-wine/preferences" + grep -qxF "$key=$expected" "$preferences" \ + || fail "$commit_command mis-commits --$option=$value" + [ "$(grep -c "^$key=" "$preferences")" -eq 1 ] \ + || fail "$commit_command duplicates --$option=$value" + # shellcheck disable=SC2016 # child shell receives library/data as positional arguments + env -i PATH="$PATH" HOME="$commit_base/home" \ + XDG_CONFIG_HOME="$commit_base/config" bash -c \ + '. "$1"; ableton_preferences_valid "$2"' \ + _ "$preferences_lib" "$preferences" \ + || fail "$commit_command produces a non-exact preference record for --$option=$value" + fi + done +done +ok "CLI-COMMIT-MATRIX: every selectable value commits exactly through Install and Update" + +# DRY-RUN: planning validates and describes intent but does not create either +# mutable settings file or run a core mutation. +base="$(new_case dry-run)" +before="$(mutable_token "$base")" +run_public "$base" -- plan install --skip-live-install --link=off \ + --runtime-root "$base/runtime" --prefix "$base/prefix" \ + --audio-buffer=512 --shortcuts=preserve --dpi=fractional \ + --audio-threads=16 --rt=off --power=balanced \ + >"$base/out" 2>"$base/err" || fail "valid pre-flight plan fails" +[ ! -e "$base/config/ableton-wine/preferences" ] \ + && [ ! -e "$base/config/pipeasio/config.ini" ] \ + || fail "dry-run writes preferences or PipeASIO configuration" +[ "$(mutable_token "$base")" = "$before" ] \ + || fail "dry-run changes a mutable user root" +for planned in 'Audio buffer: 512 frames' \ + 'Keyboard shortcuts: Preserve desktop shortcuts' \ + 'Display scaling: Fractional' 'Audio workers: 16' \ + 'Real-time scheduling: Normal' 'Power profile: Balanced'; do + grep -qF "$planned" "$base/out" \ + || fail "dry-run does not describe: $planned" +done +! grep -q 'transaction-dir' "$base/calls.log" \ + || fail "dry-run reached a core mutation" +grep -q 'dpi=fractional' "$base/calls.log" \ + || fail "selected DPI is not present during prefix validation" + +base="$(new_case dry-run-option)" +before="$(mutable_token "$base")" +run_public "$base" -- install --skip-live-install --link=off --dry-run \ + --runtime-root "$base/runtime" --prefix "$base/prefix" --audio-buffer=64 \ + >"$base/out" 2>"$base/err" || fail "--dry-run setting plan fails" +[ "$(mutable_token "$base")" = "$before" ] \ + || fail "--dry-run option changes a mutable user root" +grep -qF 'Audio buffer: 64 frames' "$base/out" \ + || fail "--dry-run option does not describe its selected audio buffer" +! grep -q 'transaction-dir' "$base/calls.log" \ + || fail "--dry-run option reached a core mutation" + +base="$(new_case dry-run-update)" +make_update_state "$base" +mkdir -p -- "$base/config/pipeasio" +printf '[pipeasio]\nbuffer_size = 2048\n' > "$base/config/pipeasio/config.ini" +write_preferences "$base" preserve preserve off off balanced +before="$(mutable_token "$base")" +run_public "$base" -- plan update --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --audio-buffer=256 --power=performance \ + >"$base/out" 2>"$base/err" || fail "existing Update plan fails" +[ "$(mutable_token "$base")" = "$before" ] \ + || fail "Update plan changes existing preferences or PipeASIO data" +grep -qF 'Audio buffer: 256 frames' "$base/out" \ + || fail "Update plan does not describe its selected audio buffer" +grep -qF 'Power profile: Performance' "$base/out" \ + || fail "Update plan does not describe its selected power profile" +! grep -q 'transaction-dir' "$base/calls.log" \ + || fail "Update plan reached a core mutation" +ok "DRY-RUN: selected settings are validated and planned without mutation" + +# FRESH-SEED/NO-IMPLICIT-PREFS: the coordinated install seeds 128 when the +# PipeASIO file is absent. Launcher environment is per-run and is never saved +# without an explicit persistent flag. +base="$(new_case fresh-default)" +run_public "$base" ABLETON_POWER=balanced ABLETON_SHORTCUTS=preserve -- \ + install --skip-live-install --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes >"$base/out" 2>"$base/err" \ + || fail "fresh coordinated install fails" +grep -qxF 'buffer_size = 128' "$base/config/pipeasio/config.ini" \ + || fail "fresh coordinated install does not seed the confirmed 128 default" +[ ! -e "$base/config/ableton-wine/preferences" ] \ + || fail "launcher environment is silently persisted" +grep -Eq 'prefix args=.*--transaction-dir.* seed=128 dpi=' "$base/calls.log" \ + || fail "the 128 seed was not passed into the core prefix transaction" +seed_token="$(sed -n 's/^prefix seed-token=//p' "$base/calls.log" | tail -n 1)" +recorded_token="$(sed -n 's/^prefix preflight recorded-token=//p' "$base/calls.log" | tail -n 1)" +current_token="$(sed -n 's/^prefix preflight current-token=//p' "$base/calls.log" | tail -n 1)" +[ -n "$seed_token" ] && [ "$recorded_token" = "$seed_token" ] \ + && [ "$current_token" = "$seed_token" ] \ + || fail "prefix preflight does not verify the exact generation recorded by core" +ok "FRESH-SEED/NO-IMPLICIT-PREFS: install seeds 128 and does not persist environment" + +# EXPLICIT-COMMIT/DPI-CORE: every explicit choice is committed after a +# successful core; DPI is also available to prefix setup immediately. +base="$(new_case explicit-commit)" +run_public "$base" ABLETON_TEST_ASSERT_SETTINGS_BEFORE_CORE=1 -- \ + install --skip-live-install --link=off \ + --runtime-root "$base/runtime" --prefix "$base/prefix" --yes \ + --audio-buffer=512 --shortcuts=preserve --dpi=fractional \ + --audio-threads=16 --rt=off --power=balanced \ + >"$base/out" 2>"$base/err" || fail "explicit setting install fails" +grep -qxF 'buffer_size = 512' "$base/config/pipeasio/config.ini" \ + || fail "explicit fresh buffer is not seeded transactionally" +cat > "$base/preferences.expected" <<'EOF' +# ableton-linux launcher preferences; managed by the installer +format=1 +shortcuts=preserve +dpi=fractional +audio_threads=16 +rt=off +power=balanced +EOF +cmp -s -- "$base/preferences.expected" "$base/config/ableton-wine/preferences" \ + || fail "explicit launcher choices are not saved exactly" +[ "$(stat -c '%a' "$base/config/ableton-wine/preferences")" = 600 ] \ + || fail "committed launcher preferences are not private" +if ! grep -Eq 'prefix args=.*--validate.* seed=512 dpi=fractional' "$base/calls.log" \ + || ! grep -Eq 'prefix args=.*--transaction-dir.* seed=512 dpi=fractional' "$base/calls.log"; then + fail "buffer and DPI choices do not reach validation and core prefix setup" +fi +ok "EXPLICIT-COMMIT/DPI-CORE: successful core commits exact choices and applies DPI immediately" + +# INTENT-ORIGIN: environment values are one-run launcher overrides, not write +# intent. With no prior store, one CLI choice overlays compatibility defaults; +# unrelated environment values never leak into the new persistent record. +base="$(new_case intent-origin-absent)" +run_public "$base" ABLETON_SHORTCUTS=preserve ABLETON_DPI_MODE=preserve \ + ABLETON_MAX_AUDIO_THREADS=off ABLETON_RT=auto ABLETON_POWER=balanced -- \ + install --skip-live-install --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes --rt=off >"$base/out" 2>"$base/err" \ + || fail "one explicit choice with environment overrides fails" +cat > "$base/intent.expected" <<'EOF' +# ableton-linux launcher preferences; managed by the installer +format=1 +shortcuts=take +dpi=auto +audio_threads=auto +rt=off +power=performance +EOF +cmp -s -- "$base/intent.expected" "$base/config/ableton-wine/preferences" \ + || fail "environment overrides leak into an absent-store CLI merge" +ok "INTENT-ORIGIN: only explicit CLI intent overlays persistent defaults" + +# MERGE: one explicit choice updates only that field and preserves all saved +# values that the user did not edit. +base="$(new_case merge)" +make_update_state "$base" +mkdir -p -- "$base/config/pipeasio" +printf '[pipeasio]\nbuffer_size = 2048\n' > "$base/config/pipeasio/config.ini" +write_preferences "$base" preserve preserve off off balanced +expected_preferences_sha="$(sha256sum "$base/config/ableton-wine/preferences" | awk '{print $1}')" +expected_buffer_sha="$(sha256sum "$base/config/pipeasio/config.ini" | awk '{print $1}')" +run_public "$base" ABLETON_SHORTCUTS=take ABLETON_DPI_MODE=auto \ + ABLETON_MAX_AUDIO_THREADS=12 ABLETON_RT=auto ABLETON_POWER=off \ + "ABLETON_TEST_EXPECT_PREFERENCES_SHA=$expected_preferences_sha" \ + "ABLETON_TEST_EXPECT_BUFFER_SHA=$expected_buffer_sha" -- \ + update --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes --power=performance \ + >"$base/out" 2>"$base/err" || fail "single-field preference update fails" +cat > "$base/merge.expected" <<'EOF' +# ableton-linux launcher preferences; managed by the installer +format=1 +shortcuts=preserve +dpi=preserve +audio_threads=off +rt=off +power=performance +EOF +cmp -s -- "$base/merge.expected" "$base/config/ableton-wine/preferences" \ + || fail "saved merge resets fields, adds data, or persists environment overrides" +grep -qxF 'buffer_size = 2048' "$base/config/pipeasio/config.ini" \ + || fail "a preference-only update rewrites a custom audio buffer" +before="$(sha256sum "$base/config/ableton-wine/preferences")" +run_public "$base" ABLETON_SHORTCUTS=take ABLETON_DPI_MODE=auto \ + ABLETON_MAX_AUDIO_THREADS=12 ABLETON_RT=auto ABLETON_POWER=off -- \ + update --link=off --runtime-root "$base/runtime" --prefix "$base/prefix" \ + --yes >"$base/no-intent.out" 2>"$base/no-intent.err" \ + || fail "no-intent update with environment overrides fails" +[ "$(sha256sum "$base/config/ableton-wine/preferences")" = "$before" ] \ + || fail "no-flag update rewrites existing preferences from the environment" +ok "MERGE: explicit intent changes one field without synthesising unrelated writes" + +# EXISTING-BUFFER/RACE: an explicit update patches a stable unique object only +# after core commit; a concurrent change is retained and reported. +base="$(new_case existing-buffer)" +make_update_state "$base" +mkdir -p -- "$base/config/pipeasio" +printf '; keep\n[pipeasio]\nbuffer_size = 2048\noutputs = 4\n' \ + > "$base/config/pipeasio/config.ini" +run_public "$base" -- update --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes --audio-buffer=256 \ + >"$base/out" 2>"$base/err" || fail "stable existing buffer update fails" +printf '; keep\n[pipeasio]\nbuffer_size = 256\noutputs = 4\n' \ + > "$base/existing-buffer.expected" +cmp -s -- "$base/existing-buffer.expected" "$base/config/pipeasio/config.ini" \ + || fail "post-core buffer patch changes bytes beyond the selected value" + +base="$(new_case raced-buffer)" +make_update_state "$base" +mkdir -p -- "$base/config/pipeasio" +printf '[pipeasio]\nbuffer_size = 2048\n' > "$base/config/pipeasio/config.ini" +run_public "$base" ABLETON_TEST_RACE_BUFFER=1 -- update --link=off \ + --runtime-root "$base/runtime" --prefix "$base/prefix" --yes \ + --audio-buffer=128 --power=balanced >"$base/out" 2>"$base/err" \ + || fail "a raced optional buffer turns a committed update into failure" +if ! grep -qxF 'buffer_size = 2048' "$base/config/pipeasio/config.ini" \ + || ! grep -qxF 'outputs = 7' "$base/config/pipeasio/config.ini"; then + fail "concurrent PipeASIO change is overwritten" +fi +grep -qi 'audio buffer.*changed' "$base/err" \ + || fail "retained concurrent buffer change is not explained" +grep -qxF 'power=balanced' "$base/config/ableton-wine/preferences" \ + || fail "a raced audio buffer blocks independent launcher preferences" + +base="$(new_case raced-valid-preferences)" +make_update_state "$base" +write_preferences "$base" take auto auto auto performance +run_public "$base" ABLETON_TEST_RACE_PREFERENCES=replace -- update --link=off \ + --runtime-root "$base/runtime" --prefix "$base/prefix" --yes \ + --power=balanced >"$base/out" 2>"$base/err" \ + || fail "a valid preference race turns a committed update into failure" +if ! grep -qxF 'dpi=100' "$base/config/ableton-wine/preferences" \ + || ! grep -qxF 'power=off' "$base/config/ableton-wine/preferences"; then + fail "a valid preference record changed during core is overwritten" +fi +grep -qi 'preferences.*changed\|settings.*changed' "$base/err" \ + || fail "retained valid preference race is not explained" + +base="$(new_case raced-absent-preferences)" +run_public "$base" ABLETON_TEST_RACE_PREFERENCES=create -- install \ + --skip-live-install --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes --power=balanced \ + >"$base/out" 2>"$base/err" \ + || fail "a preference created during core turns install into failure" +if ! grep -qxF 'dpi=100' "$base/config/ableton-wine/preferences" \ + || ! grep -qxF 'power=off' "$base/config/ableton-wine/preferences"; then + fail "a preference created during core is overwritten" +fi +grep -qi 'preferences.*changed\|settings.*changed' "$base/err" \ + || fail "retained absent-to-present preference race is not explained" +ok "EXISTING-BUFFER/RACE: stable data patches post-core and concurrent edits are retained" + +# CORE-FAIL/POSTCORE-FAIL: no mutable setting commits before core success. +# Conversely a preference publication failure after core never rolls Ableton +# back and produces one actionable warning. +base="$(new_case core-failure)" +make_update_state "$base" +mkdir -p -- "$base/config/pipeasio" +printf '[pipeasio]\nbuffer_size = 2048\n' > "$base/config/pipeasio/config.ini" +write_preferences "$base" take auto auto auto performance +before_buffer="$(sha256sum "$base/config/pipeasio/config.ini")" +before_preferences="$(sha256sum "$base/config/ableton-wine/preferences")" +if run_public "$base" ABLETON_TEST_PREFIX_CORE_EXIT=79 -- update --link=off \ + --runtime-root "$base/runtime" --prefix "$base/prefix" --yes \ + --audio-buffer=512 --power=balanced >"$base/out" 2>"$base/err"; then + fail "a failed core update succeeds" +fi +[ "$(sha256sum "$base/config/pipeasio/config.ini")" = "$before_buffer" ] \ + && [ "$(sha256sum "$base/config/ableton-wine/preferences")" = "$before_preferences" ] \ + || fail "core failure commits pre-flight settings" + +base="$(new_case component-core-failure)" +make_update_state "$base" +mkdir -p -- "$base/config/pipeasio" +printf '[pipeasio]\nbuffer_size = 2048\n' > "$base/config/pipeasio/config.ini" +write_preferences "$base" take auto auto auto performance +before_buffer="$(sha256sum "$base/config/pipeasio/config.ini")" +before_preferences="$(sha256sum "$base/config/ableton-wine/preferences")" +if run_public "$base" ABLETON_TEST_COMPONENT_CORE_EXIT=78 -- update --link=off \ + --runtime-root "$base/runtime" --prefix "$base/prefix" --yes \ + --audio-buffer=512 --power=balanced >"$base/out" 2>"$base/err"; then + fail "a failed runtime component update succeeds" +fi +[ "$(sha256sum "$base/config/pipeasio/config.ini")" = "$before_buffer" ] \ + && [ "$(sha256sum "$base/config/ableton-wine/preferences")" = "$before_preferences" ] \ + || fail "runtime component failure commits pre-flight settings" + +base="$(new_case fresh-core-failure)" +if run_public "$base" ABLETON_TEST_PREFIX_CORE_EXIT=79 -- install \ + --skip-live-install --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes --audio-buffer=512 --power=balanced \ + >"$base/out" 2>"$base/err"; then + fail "a failed fresh core install succeeds" +fi +[ ! -e "$base/config/pipeasio/config.ini" ] \ + && [ ! -e "$base/config/ableton-wine/preferences" ] \ + || fail "fresh core failure creates pre-flight settings" + +base="$(new_case failure-after-fresh-seed)" +if run_public "$base" ABLETON_TEST_PREFIX_FAIL_AFTER_SEED=79 -- install \ + --skip-live-install --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes --audio-buffer=512 --power=balanced \ + >"$base/out" 2>"$base/err"; then + fail "a prefix failure after publishing its fresh seed succeeds" +fi +[ ! -e "$base/config/pipeasio/config.ini" ] \ + && [ ! -L "$base/config/pipeasio/config.ini" ] \ + && [ ! -e "$base/config/ableton-wine/preferences" ] \ + || fail "failure after seed publication retains uncommitted settings" + +base="$(new_case provisional-commit-boundary)" +if run_public "$base" ABLETON_TEST_SKIP_SEED_PROMOTION=1 -- install \ + --skip-live-install --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes --audio-buffer=512 --power=balanced \ + >"$base/out" 2>"$base/err"; then + fail "coordinator commit accepts rollback-only provisional seed provenance" +fi +[ ! -e "$base/config/pipeasio/config.ini" ] \ + && [ ! -L "$base/config/pipeasio/config.ini" ] \ + && ! find "$base/config/pipeasio" -maxdepth 1 -name '.config.ini.*' \ + -print -quit 2>/dev/null | grep -q . \ + && [ ! -e "$base/config/ableton-wine/preferences" ] \ + || fail "provisional commit rejection leaves seed, temp, or launcher settings" +grep -q 'component args=--preflight-commit ' "$base/calls.log" \ + && grep -q 'prefix args=--preflight-commit ' "$base/calls.log" \ + && grep -q 'prefix args=--preflight-rollback ' "$base/calls.log" \ + && grep -q 'component args=--preflight-rollback ' "$base/calls.log" \ + && grep -q 'prefix args=--rollback ' "$base/calls.log" \ + && grep -q 'component args=--rollback ' "$base/calls.log" \ + || fail "provisional commit rejection does not preflight and roll back both domains" +! grep -Eq '^(prefix|component) args=--commit ' "$base/calls.log" \ + || fail "a domain commit ran after provisional seed rejection" + +base="$(new_case malformed-seed-outer-rollback)" +if run_public "$base" ABLETON_TEST_MALFORMED_SEED_JOURNAL=1 -- install \ + --skip-live-install --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes --audio-buffer=512 --power=balanced \ + >"$base/out" 2>"$base/err"; then + fail "coordinator accepts malformed canonical seed recovery evidence" +fi +journal="$(find "$base/state" -type f -name pipeasio-seed.v1 -print -quit)" +active="$(find "$base/state" -type f -name active -print -quit)" +recorded_buffer_object="$(sed -n 's/^prefix malformed-buffer-object=//p' \ + "$base/calls.log" | tail -n 1)" +recorded_journal_object="$(sed -n 's/^prefix malformed-journal-object=//p' \ + "$base/calls.log" | tail -n 1)" +recorded_active_object="$(sed -n 's/^prefix malformed-active-object=//p' \ + "$base/calls.log" | tail -n 1)" +[ -n "$journal" ] && [ -n "$active" ] \ + && grep -qxF 'buffer_size = 512' "$base/config/pipeasio/config.ini" \ + && grep -qxF 'active recovery evidence' "$active" \ + && [ ! -e "$base/config/ableton-wine/preferences" ] \ + || fail "malformed recovery preflight discards journal, active evidence, or settings" +[ "$(ableton_preferences_object_token "$base/config/pipeasio/config.ini")" \ + = "$recorded_buffer_object" ] \ + && [ "$(ableton_preferences_object_token "$journal")" \ + = "$recorded_journal_object" ] \ + && [ "$(ableton_preferences_object_token "$active")" \ + = "$recorded_active_object" ] \ + || fail "malformed outer rollback changes recovery evidence or settings in place" +[ "$(sed -n 's/^token_b64=//p' "$journal" | base64 --decode)" = 'file|garbage' ] \ + || fail "malformed recovery evidence was rewritten" +grep -q 'prefix args=--preflight-rollback ' "$base/calls.log" \ + && grep -q 'component args=--preflight-rollback ' "$base/calls.log" \ + || fail "one malformed domain prevents the other rollback preflight" +! grep -Eq '^(prefix|component) args=--rollback ' "$base/calls.log" \ + || fail "a rollback domain mutated after malformed recovery preflight" +! grep -Eq '^(prefix|component) args=--commit ' "$base/calls.log" \ + || fail "a domain commit ran with malformed recovery evidence" + +base="$(new_case second-domain-rollback-preflight-failure)" +if run_public "$base" ABLETON_TEST_CAPTURE_RECOVERY_OBJECTS=1 \ + ABLETON_TEST_PREFIX_FAIL_AFTER_SEED=79 \ + ABLETON_TEST_COMPONENT_ROLLBACK_PREFLIGHT_EXIT=88 -- install \ + --skip-live-install --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes --audio-buffer=512 --power=balanced \ + >"$base/out" 2>"$base/err"; then + fail "coordinator accepts a second-domain rollback-preflight failure" +fi +journal="$(find "$base/state" -type f -name pipeasio-seed.v1 -print -quit)" +active="$(find "$base/state" -type f -name active -print -quit)" +recorded_buffer_object="$(sed -n 's/^prefix captured-buffer-object=//p' \ + "$base/calls.log" | tail -n 1)" +recorded_journal_object="$(sed -n 's/^prefix captured-journal-object=//p' \ + "$base/calls.log" | tail -n 1)" +recorded_active_object="$(sed -n 's/^prefix captured-active-object=//p' \ + "$base/calls.log" | tail -n 1)" +recorded_prefix_object="$(sed -n 's/^prefix captured-prefix-object=//p' \ + "$base/calls.log" | tail -n 1)" +[ -n "$journal" ] && [ -n "$active" ] \ + && [ "$(ableton_preferences_object_token "$base/config/pipeasio/config.ini")" \ + = "$recorded_buffer_object" ] \ + && [ "$(ableton_preferences_object_token "$journal")" \ + = "$recorded_journal_object" ] \ + && [ "$(ableton_preferences_object_token "$active")" \ + = "$recorded_active_object" ] \ + && [ "$(ableton_preferences_object_token "$base/prefix/system.reg")" \ + = "$recorded_prefix_object" ] \ + && [ ! -e "$base/config/ableton-wine/preferences" ] \ + || fail "second-domain preflight failure changes prefix or settings recovery evidence" +prefix_preflight_line="$(grep -n 'prefix args=--preflight-rollback ' \ + "$base/calls.log" | tail -n 1 | cut -d: -f1)" +component_preflight_line="$(grep -n 'component args=--preflight-rollback ' \ + "$base/calls.log" | tail -n 1 | cut -d: -f1)" +[ -n "$prefix_preflight_line" ] && [ -n "$component_preflight_line" ] \ + && [ "$prefix_preflight_line" -lt "$component_preflight_line" ] \ + || fail "rollback domains are not fully preflighted in order" +! grep -Eq '^(prefix|component) args=--rollback ' "$base/calls.log" \ + || fail "a rollback domain mutated before every preflight succeeded" +! grep -Eq '^(prefix|component) args=--commit ' "$base/calls.log" \ + || fail "a domain commit ran after a rollback-preflight failure" + +base="$(new_case replacement-before-seed-token)" +if run_public "$base" ABLETON_TEST_REPLACE_SEED_BEFORE_TOKEN=1 -- install \ + --skip-live-install --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes --audio-buffer=512 --power=balanced \ + >"$base/out" 2>"$base/err"; then + fail "a seed replaced before full-token capture succeeds as installer-owned" +fi +replacement_token="$(sed -n 's/^prefix pre-token-replacement-token=//p' \ + "$base/calls.log" | tail -n 1)" +[ -n "$replacement_token" ] \ + && grep -qxF 'buffer_size = 512' "$base/config/pipeasio/config.ini" \ + && [ ! -e "$base/config/ableton-wine/preferences" ] \ + || fail "pre-token same-value replacement is deleted or settings are committed" + +base="$(new_case same-value-seed-replacement)" +if run_public "$base" ABLETON_TEST_REPLACE_SEED_SAME_VALUE=1 \ + ABLETON_TEST_PREFIX_PREFLIGHT_EXIT=76 -- install \ + --skip-live-install --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes --audio-buffer=512 --power=balanced \ + >"$base/out" 2>"$base/err"; then + fail "a forced preflight failure after same-value replacement succeeds" +fi +seed_token="$(sed -n 's/^prefix seed-token=//p' "$base/calls.log" | tail -n 1)" +replacement_token="$(sed -n 's/^prefix replacement-token=//p' "$base/calls.log" | tail -n 1)" +recorded_token="$(sed -n 's/^prefix preflight recorded-token=//p' "$base/calls.log" | tail -n 1)" +current_token="$(sed -n 's/^prefix preflight current-token=//p' "$base/calls.log" | tail -n 1)" +[ -n "$seed_token" ] && [ -n "$replacement_token" ] \ + && [ "$seed_token" != "$replacement_token" ] \ + && [ "$recorded_token" = "$seed_token" ] \ + && [ "$current_token" = "$replacement_token" ] \ + || fail "same-value fixture does not prove distinct recorded/current generations" +grep -qxF 'buffer_size = 512' "$base/config/pipeasio/config.ini" \ + || fail "rollback deletes a byte-identical user replacement" +[ ! -e "$base/config/ableton-wine/preferences" ] \ + || fail "failed same-value replacement case commits launcher preferences" + +for preflight_owner in component prefix; do + base="$(new_case "$preflight_owner-preflight-failure")" + preflight_env=() + case "$preflight_owner" in + component) preflight_env=(ABLETON_TEST_COMPONENT_PREFLIGHT_EXIT=77) ;; + prefix) preflight_env=(ABLETON_TEST_PREFIX_PREFLIGHT_EXIT=76) ;; + esac + if run_public "$base" "${preflight_env[@]}" -- install \ + --skip-live-install --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes --audio-buffer=512 --power=balanced \ + >"$base/out" 2>"$base/err"; then + fail "$preflight_owner preflight-commit failure succeeds" + fi + [ ! -e "$base/config/pipeasio/config.ini" ] \ + && [ ! -e "$base/config/ableton-wine/preferences" ] \ + || fail "$preflight_owner preflight-commit failure retains uncommitted settings" +done + +base="$(new_case preference-publication-failure)" +mkdir -p -- "$base/config/ableton-wine/preferences" +run_public "$base" -- install --skip-live-install --link=off \ + --runtime-root "$base/runtime" --prefix "$base/prefix" --yes \ + --audio-buffer=512 --power=balanced >"$base/out" 2>"$base/err" \ + || fail "optional preference publication failure rejects a committed core" +[ -f "$base/prefix/system.reg" ] \ + || fail "preference publication failure rolls back the usable prefix" +[ -d "$base/config/ableton-wine/preferences" ] \ + || fail "unsafe preference destination is replaced" +grep -qi 'preferences.*could not be saved\|settings.*could not be saved' "$base/err" \ + || fail "preference publication failure has no repair warning" +grep -qxF 'buffer_size = 512' "$base/config/pipeasio/config.ini" \ + || fail "preference publication failure blocks an independent audio-buffer commit" + +base="$(new_case component-commit-failure)" +run_public "$base" ABLETON_TEST_COMPONENT_COMMIT_EXIT=77 -- install \ + --skip-live-install --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes --power=balanced \ + >"$base/out" 2>"$base/err" \ + || fail "component cleanup/commit failure rejects an already committed core" +if [ ! -f "$base/prefix/system.reg" ] \ + || ! grep -qxF 'power=balanced' "$base/config/ableton-wine/preferences"; then + fail "component cleanup/commit failure prevents post-core settings" +fi +ok "CORE-FAIL/POSTCORE-FAIL: writes wait for core and later failures keep usable Ableton" + +# MALFORMED-PRESERVE/INTEGRATION-FAIL: foreign or malformed mutable data is +# never guessed at. Optional launcher-support failure also cannot roll core. +base="$(new_case malformed-preferences)" +mkdir -p -- "$base/config/ableton-wine" +printf 'format=1\npower=balanced\nunknown=value\n' \ + > "$base/config/ableton-wine/preferences" +before="$(sha256sum "$base/config/ableton-wine/preferences")" +run_public "$base" -- install --skip-live-install --link=off \ + --runtime-root "$base/runtime" --prefix "$base/prefix" --yes \ + --audio-buffer=512 --power=performance >"$base/out" 2>"$base/err" \ + || fail "malformed optional preferences reject a committed install" +[ "$(sha256sum "$base/config/ableton-wine/preferences")" = "$before" ] \ + || fail "malformed preferences are overwritten" +grep -qi 'preferences.*malformed\|preferences.*not.*saved' "$base/err" \ + || fail "preserved malformed preferences are not explained" +grep -qxF 'buffer_size = 512' "$base/config/pipeasio/config.ini" \ + || fail "malformed preferences block an independent audio-buffer commit" + +base="$(new_case integration-failure)" +run_public "$base" ABLETON_TEST_INTEGRATION_EXIT=3 -- install \ + --skip-live-install --link=off --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes --rt=off >"$base/out" 2>"$base/err" \ + || fail "optional launcher support failure rejects a committed install" +if [ ! -f "$base/prefix/system.reg" ] \ + || grep -q -- '--rollback' "$base/calls.log"; then + fail "optional support failure rolls back core" +fi +grep -qxF 'rt=off' "$base/config/ableton-wine/preferences" \ + || fail "optional support failure blocks independent RT preference publication" +base="$(new_case link-failure)" +run_public "$base" ABLETON_TEST_LINK_EXIT=76 -- install --skip-live-install \ + --link=session --runtime-root "$base/runtime" --prefix "$base/prefix" \ + --yes --power=balanced >"$base/out" 2>"$base/err" \ + || fail "optional Link failure rejects a committed install" +if [ ! -f "$base/prefix/system.reg" ] \ + || ! grep -qxF 'power=balanced' "$base/config/ableton-wine/preferences" \ + || grep -q -- '--rollback' "$base/calls.log"; then + fail "optional Link failure rolls back core or blocks independent preferences" +fi +ok "MALFORMED-PRESERVE/INTEGRATION-FAIL: unsafe data is kept and optional support cannot roll back core" + +# STANDALONE-SEED: direct prefix creation is not silently redesigned by the +# coordinator questionnaire. Its historical 256 seed remains in force. +base="$(new_case standalone-prefix)" +run_public "$base" -- prefix create --runtime-root "$base/runtime" \ + --prefix "$base/prefix" >"$base/out" 2>"$base/err" \ + || fail "standalone prefix create fails" +grep -qxF 'buffer_size = 256' "$base/config/pipeasio/config.ini" \ + || fail "standalone prefix creation no longer retains its 256 default" +grep -q 'seed=unset' "$base/calls.log" \ + || fail "coordinator-only buffer seed leaked into prefix create" +ok "STANDALONE-SEED: direct prefix creation remains 256 unless coordinated" + +# LAUNCHER-CONSUMERS: both launchers load the optional store before consuming +# the five settings, but do not turn absence of that optional library into a +# hard support-file failure. Profile selection must use the validated value. +for launcher in ableton-live max9; do + file="$here/$launcher" + if ! grep -qF 'preferences.sh' "$file" \ + || ! grep -qF 'ableton_preferences_apply' "$file"; then + fail "$launcher does not consume saved pre-flight preferences" + fi + ! grep -E 'declare -F ableton_preferences_apply.*exit|preferences.*incomplete.*exit' "$file" \ + >/dev/null || fail "$launcher makes the optional preference library mandatory" + apply_line="$(grep -n -m1 'ableton_preferences_apply' "$file" | cut -d: -f1)" + consume_line="$(grep -n -m1 'ABLETON_MAX_AUDIO_THREADS:-\|ABLETON_RT:-\|ABLETON_POWER:-' "$file" | cut -d: -f1)" + [ "$apply_line" -lt "$consume_line" ] \ + || fail "$launcher applies saved preferences after consuming launcher settings" + grep -q 'powerprofilesctl launch -p .*power' "$file" \ + || fail "$launcher hard-codes performance instead of using the validated profile" +done +ok "LAUNCHER-CONSUMERS: Live and Max apply optional preferences early and use selected power profiles" + +# UNINSTALL/PACKAGING: mutable preferences use their own checked removal, while +# preferences.sh is ordinary authoritative support everywhere the kit/runtime +# can be assembled or audited. Exercise the packer's project-file stage rather +# than accepting a filename in a comment or dead allowlist. +grep -qF 'ableton_preferences_remove' "$here/uninstall.sh" \ + || fail "uninstall does not generation-check mutable preferences" +for file in "$here/install.sh" "$here/uninstall.sh" "$here/lib/manifest.sh"; do + grep -qF 'preferences.sh' "$file" \ + || fail "packaging/ownership surface omits preferences.sh: ${file#"$root"/}" +done +project_stage="$work/project-stage" +"$here/make-installer.sh" --stage-project-files "$project_stage" \ + >"$work/project-stage.out" 2>"$work/project-stage.err" \ + || fail "the packer's isolated project-file stage fails" +staged_preferences="$project_stage/scripts/lib/preferences.sh" +if [ ! -f "$staged_preferences" ] || [ -x "$staged_preferences" ] \ + || ! cmp -s -- "$here/lib/preferences.sh" "$staged_preferences"; then + fail "the .run project stage omits or changes preferences.sh" +fi +[ "$(stat -c '%a' "$staged_preferences")" = 644 ] \ + || fail "the .run project stage gives preferences.sh the wrong mode" +"$here/make-installer.sh" --audit-project-files "$project_stage" \ + >"$work/project-audit.out" 2>"$work/project-audit.err" \ + || fail "the packer's normal project-file audit rejects its own stage" +cp -- "$staged_preferences" "$work/staged-preferences.before" +printf 'corrupt staged preference helper\n' > "$staged_preferences" +if "$here/make-installer.sh" --audit-project-files "$project_stage" \ + >"$work/corrupt-audit.out" 2>"$work/corrupt-audit.err"; then + fail "the packer's project-file audit accepts a corrupted preferences.sh" +fi +cp -- "$work/staged-preferences.before" "$staged_preferences" +python3 - "$here/make-installer.sh" <<'PY' \ + || fail "normal .run packing does not share its exercised project stage and audit" +from pathlib import Path +import re +import sys + +text = "\n".join( + line for line in Path(sys.argv[1]).read_text().splitlines() + if not line.lstrip().startswith("#") +) +assert len(re.findall(r"^stage_project_files\(\)$", text, re.M)) == 1 +assert len(re.findall(r"^audit_project_files\(\)$", text, re.M)) == 1 +assert re.search(r'^stage_project_files "\$kit"$', text, re.M) +assert re.search(r'^audit_project_files "\$kit"$', text, re.M) +PY +python3 - "$root" <<'PY' \ + || fail "payload verifier does not include preferences.sh as mode 0644" +from pathlib import Path +from types import SimpleNamespace +import runpy +import sys + +root = Path(sys.argv[1]) +module = runpy.run_path(str(root / "scripts/verify-installer-payload.py")) +globals_ = module["expected_from_source"].__globals__ +real_add_source = globals_["add_source"] +globals_["add_source"] = lambda expected, source, destination, mode: ( + real_add_source(expected, source, destination, mode) + if destination == "scripts/lib/preferences.sh" + else None +) +globals_["tracked_paths"] = lambda *args: [] +globals_["subprocess"].run = lambda *args, **kwargs: SimpleNamespace(stdout=b"") +expected = module["expected_from_source"](root, root / "BUILD-INFO", root / "runtime.tar.zst") +entry = expected["scripts/lib/preferences.sh"] +assert entry.kind == "file" +assert entry.mode == 0o644 +assert entry.data == (root / "scripts/lib/preferences.sh").read_bytes() +PY +python3 - "$root/nix/ableton-wine.nix" <<'PY' \ + || fail "Nix does not source-stage and exact-audit every helper in both launcher locations" +from pathlib import Path +import re +import sys + +text = "\n".join( + line for line in Path(sys.argv[1]).read_text().splitlines() + if not line.lstrip().startswith("#") +) +stage_loops = re.findall( + r"for helper in \$\{\.\./scripts/lib\}/\*\.sh; do\s+" + r"install -m644 \$helper \$out/(libexec/lib|share/ableton-wine/scripts/lib)/\$\(basename \$helper\)\s+done", + text, +) +assert sorted(stage_loops) == ["libexec/lib", "share/ableton-wine/scripts/lib"] +audit = re.search( + r"for helper in \$\{\.\./scripts/lib\}/\*\.sh; do\s+" + r"(?P.*?)for d in libexec/lib share/ableton-wine/scripts/lib; do" + r"(?P.*?)done\s+done", + text, + re.S, +) +assert audit is not None +expected_config = re.search( + r"expected_config=.*?substituteInPlace \"\$expected_config\"" + r"(?P.*?)for helper in \$\{\.\./scripts/lib\}/\*\.sh; do", + text, + re.S, +) +assert expected_config +assert "$HOME/.local/opt/$ABLETON_RUNTIME_NAME" in expected_config.group("body") +assert "$ABLETON_DATA_HOME/ableton-linkd" in expected_config.group("body") +setup = audit.group("setup") +assert "expected=$helper" in setup +assert "expected=$expected_config" in setup +body = audit.group("body") +assert "cmp -s -- \"$expected\" \"$out/$d/$(basename \"$helper\")\"" in body +PY +ok "UNINSTALL/PACKAGING: mutable data is checked separately and support ships on every path" + +# DOCS: user-facing command documentation names every flag, its values, the +# confirmed 128 default, persistence, and Escape navigation. +for flag in audio-buffer shortcuts dpi audio-threads rt power; do + grep -q -- "--$flag" "$root/INSTALLER.md" "$root/README.md" \ + || fail "documentation omits --$flag" +done +for allowed_values in \ + '64.*128.*256.*512.*1024' \ + 'take.*preserve|Assign to Live.*Preserve' \ + 'auto.*100.*fractional.*preserve|Automatic.*100%.*Fractional.*Preserve' \ + 'auto.*off.*1.*63|Automatic.*Let Live decide.*1.*63' \ + 'rt.*auto.*off|Real-time.*Automatic.*Normal' \ + 'power.*performance.*balanced.*off|Power.*Performance.*Balanced.*change'; do + grep -Eqi -- "$allowed_values" "$root/INSTALLER.md" "$root/README.md" \ + || fail "documentation omits allowed values matching: $allowed_values" +done +grep -Eqi -- '128.*default|default.*128' "$root/INSTALLER.md" "$root/README.md" \ + || fail "documentation omits the 128-frame default" +grep -Eqi -- 'Esc.*back|back.*Esc' "$root/INSTALLER.md" "$root/README.md" \ + || fail "documentation omits one-question Escape navigation" +grep -Eqi -- 'preferences.*persistent|persistent.*preferences' \ + "$root/INSTALLER.md" "$root/README.md" \ + || fail "documentation omits launcher preference persistence" +ok "DOCS: installer flags, defaults, navigation and persistence are documented" + +printf '%s pre-flight integration checks passed\n' "$pass" diff --git a/scripts/test-preflight-menu.sh b/scripts/test-preflight-menu.sh new file mode 100755 index 00000000..a4ad67e7 --- /dev/null +++ b/scripts/test-preflight-menu.sh @@ -0,0 +1,1102 @@ +#!/usr/bin/env bash +# PTY coverage for the state-aware action menu and six sequential pre-flight +# questions. Behaviour IDs map to notes/PREFLIGHT-SETTINGS-TEST-MATRIX.md. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +work="$(mktemp -d "${TMPDIR:-/tmp}/ableton-preflight-menu-test.XXXXXX")" +trap 'rm -rf -- "$work"' EXIT + +pass=0 +ok() { pass=$((pass + 1)); printf 'ok - %s\n' "$1"; } +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } +command -v script >/dev/null 2>&1 || fail "script(1) is available" + +snapshot_helper="$work/snapshot-home" +cat > "$snapshot_helper" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +home="${1:?home is required}" +output="${2:?snapshot output is required}" +: > "$output" +while IFS= read -r -d '' object; do + relative="${object#"$home"}" + [ -n "$relative" ] || relative=/ + digest=- + target=- + if [ -f "$object" ] && [ ! -L "$object" ]; then + digest="$(sha256sum -- "$object" | awk '{print $1}')" + elif [ -L "$object" ]; then + target="$(readlink -z -- "$object" | base64 -w0)" + fi + # Capture identity and nanosecond timestamps after reading the object, so + # the snapshot's own reads cannot hide a same-byte in-place rewrite. + metadata="$(stat -c \ + 'type=%F|mode=%a|uid=%u|gid=%g|size=%s|dev=%d|ino=%i|links=%h|mtime=%y|ctime=%z|birth=%w' \ + -- "$object")" + printf '%q|%s|%s|%s\n' "$relative" "$metadata" "$digest" "$target" \ + >> "$output" +done < <(find -P "$home" -xdev -print0 | sort -z) +EOF +chmod +x "$snapshot_helper" + +kit="$work/kit" +mkdir -p -- "$kit/scripts/lib" +cp -- "$here/lib/ui.sh" "$kit/scripts/lib/ui.sh" +cat > "$kit/scripts/installer.sh" <<'EOF' +#!/usr/bin/env bash +"${STUB_SNAPSHOT_HELPER:?}" "${HOME:?}" "${STUB_HOME_SNAPSHOT_AT_DELEGATION:?}" +if ! cmp -s -- "${STUB_HOME_SNAPSHOT_BEFORE:?}" \ + "${STUB_HOME_SNAPSHOT_AT_DELEGATION:?}"; then + printf 'HOME changed before installer.sh received the request\n' >&2 + diff -u -- "${STUB_HOME_SNAPSHOT_BEFORE:?}" \ + "${STUB_HOME_SNAPSHOT_AT_DELEGATION:?}" >&2 || true + exit 97 +fi +printf '%s\n' "$*" > "${STUB_ARGS_FILE:?}" +printf '@@DELEGATED@@\n' >&"${ABLETON_UI_TTY_FD:-1}" +exit "${STUB_EXIT:-0}" +EOF +chmod +x "$kit/scripts/installer.sh" +tar -cf "$work/payload.tar" -C "$kit" . +"$here/make-installer.sh" --render-header --version preflight-test \ + --payload-sha "$(sha256sum "$work/payload.tar" | awk '{print $1}')" \ + > "$work/installer.run" +cat "$work/payload.tar" >> "$work/installer.run" +chmod +x "$work/installer.run" + +# A cancelled uninstall scope must return before the self-extracting wrapper +# asks tar to unpack anything. Delegated cases still use the real tar. +mkdir -p -- "$work/fakebin" +real_tar="$(type -P tar)" +cat > "$work/fakebin/tar" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >> "${STUB_TAR_LOG:?}" +exec "${STUB_REAL_TAR:?}" "$@" +EOF +chmod +x "$work/fakebin/tar" + +wait_for() # output literal [minimum count] +{ + local out="$1" literal="$2" minimum="${3:-1}" i=0 count=0 + while [ "$i" -lt 200 ]; do + count="$(grep -aoF "$literal" "$out" 2>/dev/null | wc -l || true)" + [ "$count" -lt "$minimum" ] || return 0 + sleep 0.05 + i=$((i + 1)) + done + printf 'timed out waiting for %s occurrence(s) of: %s\n' "$minimum" "$literal" >&2 + return 1 +} + +assert_straight_frontier() # raw-output current-index headings... +{ + local out="$1" current="$2" heading count expected i + shift 2 + local -a headings=("$@") + for i in "${!headings[@]}"; do + heading="${headings[$i]}" + count="$(grep -aoF "$heading" "$out" 2>/dev/null | wc -l || true)" + expected=0 + [ "$i" -gt "$current" ] || expected=1 + [ "$count" -eq "$expected" ] || fail \ + "before answering question $((current + 1)), $heading is visible $count time(s), expected $expected" + done + count="$(grep -aoF '@@DELEGATED@@' "$out" 2>/dev/null | wc -l || true)" + [ "$count" -eq 0 ] || fail \ + "installer.sh is delegated before question $((current + 1)) is answered" +} + +snapshot_home() # home output +{ + "$snapshot_helper" "$1" "$2" +} + +assert_home_unchanged() # name home before after +{ + local name="$1" home="$2" before="$3" after="$4" + snapshot_home "$home" "$after" + if ! cmp -s -- "$before" "$after"; then + diff -u -- "$before" "$after" >&2 || true + fail "$name changed HOME before installer.sh received the request" + fi +} + +normalize_terminal() # raw output normalized output +{ + LC_ALL=C sed -E \ + -e $'s/\033\\][^\a]*(\a|\033\\\\)//g' \ + -e $'s/\033\\[[0-?]*[ -\\/]*[@-~]//g' \ + -e $'s/\r//g' \ + -e 's/[[:space:]]+$//' "$1" > "$2" +} + +run_tty() # name home driver-function +{ + local name="$1" home="$2" driver="$3" command="${4:-}" scope="${5:-}" + local out before after delegation tar_log + out="$work/$name.raw" + before="$work/$name.home.before" + after="$work/$name.home.after" + delegation="$work/$name.home.delegation" + tar_log="$work/$name.tar" + rm -f -- "$work/$name.args" "$delegation" "$tar_log" + : > "$out" + snapshot_home "$home" "$before" + # shellcheck disable=SC2094,SC2016 # child expands path; driver polls transcript + env -i PATH="$work/fakebin:$PATH" HOME="$home" TMPDIR="$work" LANG=C.UTF-8 TERM=xterm \ + SHELL=/bin/bash NO_COLOR=1 \ + ABLETON_UI_PROMPT_TIMEOUT="${PREFLIGHT_PROMPT_TIMEOUT:-30}" \ + STUB_ARGS_FILE="$work/$name.args" PREFLIGHT_INSTALLER="$work/installer.run" \ + PREFLIGHT_COMMAND="$command" PREFLIGHT_SCOPE="$scope" \ + STUB_TAR_LOG="$tar_log" STUB_REAL_TAR="$real_tar" \ + STUB_SNAPSHOT_HELPER="$snapshot_helper" \ + STUB_HOME_SNAPSHOT_BEFORE="$before" \ + STUB_HOME_SNAPSHOT_AT_DELEGATION="$delegation" \ + script -qfec 'stty rows 80 cols 120; set --; [ -z "$PREFLIGHT_COMMAND" ] || set -- "$PREFLIGHT_COMMAND"; [ -z "$PREFLIGHT_SCOPE" ] || set -- "$@" "$PREFLIGHT_SCOPE"; status=0; sh "$PREFLIGHT_INSTALLER" "$@" || status=$?; echo @@DONE@@:$status; exit "$status"' /dev/null \ + < <("$driver" "$out") > "$out" 2>&1 || true + assert_home_unchanged "$name" "$home" "$before" "$after" + normalize_terminal "$out" "$work/$name.txt" + grep -qF '@@DONE@@:0' "$out" \ + || fail "$name did not finish through the expected successful/cancelled wrapper path" +} + +wait_for_uninstall_scope() # raw-output; stop quickly if an old wrapper delegates +{ + local out="$1" i=0 + while [ "$i" -lt 200 ]; do + grep -aqF '(Default)' "$out" 2>/dev/null && return 0 + if grep -aqF '@@DELEGATED@@' "$out" 2>/dev/null \ + || grep -aqF '@@DONE@@' "$out" 2>/dev/null; then + return 1 + fi + sleep 0.05 + i=$((i + 1)) + done + return 1 +} + +assert_uninstall_scope_set() # run-name +{ + local name="$1" start="${2:-after-action}" actual="$work/$1.uninstall-scopes" + local expected="$work/$1.uninstall-scopes.expected" + awk -v start="$start" ' + BEGIN { after_action=(start == "direct") } + /Choose an action:/ { after_action=1; next } + after_action && /@@(DELEGATED|DONE)@@/ { exit } + after_action && /> / && /\[[[:alpha:]]\]/ { + line=$0 + sub(/^.*> /, "", line) + print line + } + ' "$work/$name.txt" > "$actual" + printf '%s\n' '[R]untime only (Default)' '[P]refix only' '[A]ll' '[E]xit' > "$expected" + if ! cmp -s -- "$expected" "$actual"; then + diff -u -- "$expected" "$actual" >&2 || true + fail "$name does not expose exactly the four uninstall scopes in order" + fi +} + +run_notty() # name home [installer arguments...] +{ + local name="$1" home="$2" before after delegation tar_log status=0 + shift 2 + before="$work/$name.home.before" + after="$work/$name.home.after" + delegation="$work/$name.home.delegation" + tar_log="$work/$name.tar" + rm -f -- "$work/$name.args" "$delegation" "$tar_log" + snapshot_home "$home" "$before" + env -i PATH="$work/fakebin:$PATH" HOME="$home" TMPDIR="$work" LANG=C.UTF-8 TERM=xterm \ + STUB_ARGS_FILE="$work/$name.args" \ + STUB_SNAPSHOT_HELPER="$snapshot_helper" \ + STUB_HOME_SNAPSHOT_BEFORE="$before" \ + STUB_HOME_SNAPSHOT_AT_DELEGATION="$delegation" \ + STUB_TAR_LOG="$tar_log" STUB_REAL_TAR="$real_tar" \ + sh "$work/installer.run" "$@" \ + "$work/$name.out" 2>&1 || status=$? + assert_home_unchanged "$name" "$home" "$before" "$after" + [ "$status" -eq "${NOTTY_EXPECT_STATUS:-0}" ] \ + || fail "$name exited with status $status" +} + +answer_default_questions() # output; assumes action was already selected +{ + local out="$1" i + local -a questions=( + '1/6 SET DEFAULT AUDIO BUFFER' '2/6 ENABLE KEYBOARD SHORTCUTS' '3/6 SET LIVE DISPLAY SCALING' \ + '4/6 SET AUDIO WORKER LIMIT' '5/6 TOGGLE REAL-TIME SCHEDULING' '6/6 SET POWER MANAGEMENT' + ) + for i in "${!questions[@]}"; do + wait_for "$out" "${questions[$i]}" + # Wait for the complete current menu before inspecting the frontier or + # sending input. No later heading or delegation may already be visible. + wait_for "$out" '(Press Esc for previous setting)' "$((i + 1))" + assert_straight_frontier "$out" "$i" "${questions[@]}" + printf '\n' + done +} + +answer_defaults() # output; assumes action was already selected +{ + local out="$1" + answer_default_questions "$out" + wait_for "$out" '@@DELEGATED@@' + wait_for "$out" '@@DONE@@' +} + +drive_fresh_defaults() +{ + local out="$1" + wait_for "$out" 'Choose an action:'; printf '\n' + answer_defaults "$out" +} + +make_preferences() # home shortcuts dpi threads rt power +{ + local home="$1" shortcuts="$2" dpi="$3" threads="$4" rt="$5" power="$6" + mkdir -p -- "$home/.config/ableton-wine" + cat > "$home/.config/ableton-wine/preferences" < "$prefix/.ableton-linux-prefix" + [ "$state" != complete ] || printf 'WINE REGISTRY Version 2\n' > "$prefix/system.reg" + cat > "$config" < "$destination" < / { + line=$0 + sub(/^.*> /, "> ", line) + print line + } + ' "$work/$name.txt" > "$actual" + : > "$expected" + for entry in "$@"; do printf '%s\n' "$entry" >> "$expected"; done + if ! cmp -s -- "$expected" "$actual"; then + diff -u -- "$expected" "$actual" >&2 || true + fail "$name does not expose exactly the expected action set" + fi +} + +assert_expected_state() # run-name healthy|incomplete|fresh +{ + local name="$1" expected="$2" + case "$expected" in + healthy) + assert_action_set "$name" \ + '> [U]pdate (or press Enter)' '> [R]einstall' \ + '> Remo[v]e Ableton Linux' '> [Q]uit' ;; + incomplete) + assert_action_set "$name" \ + '> [R]einstall (or press Enter)' \ + '> Remo[v]e Ableton Linux' '> [Q]uit' ;; + fresh) + assert_action_set "$name" \ + '> [I]nstall (or press Enter)' '> [Q]uit' ;; + *) fail "test requested unknown expected state: $expected" ;; + esac +} + +assert_adjacent_question() # normalized-output heading explanation option... hint +{ + local transcript="$1" heading="$2" line expected index=-1 count=0 i + shift 2 + local -a lines=() + mapfile -t lines < <(sed -e '/^[[:space:]]*$/d' -e '/^│$/d' "$transcript") + for i in "${!lines[@]}"; do + case "${lines[$i]}" in + *"$heading") index="$i"; count=$((count + 1)) ;; + esac + done + [ "$count" -eq 1 ] || fail "$heading is not rendered exactly once on the straight path" + for expected in "$@"; do + index=$((index + 1)) + line="${lines[$index]:-}" + case "$line" in + *"$expected") ;; + *) fail "$heading is not immediately followed by: $expected" ;; + esac + done +} + +assert_semantic_count() # normalized-output semantic-line-suffix expected-count +{ + local transcript="$1" expected="$2" wanted="$3" line found=0 + while IFS= read -r line; do + case "$line" in *"$expected") found=$((found + 1)) ;; esac + done < "$transcript" + [ "$found" -eq "$wanted" ] \ + || fail "$expected is rendered $found time(s), expected $wanted" +} + +assert_strict_heading_order() # normalized-output +{ + local transcript="$1" heading line previous=0 delegated_line + local -a matches=() + local -a headings=( + '1/6 SET DEFAULT AUDIO BUFFER' '2/6 ENABLE KEYBOARD SHORTCUTS' '3/6 SET LIVE DISPLAY SCALING' + '4/6 SET AUDIO WORKER LIMIT' '5/6 TOGGLE REAL-TIME SCHEDULING' '6/6 SET POWER MANAGEMENT' + ) + for heading in "${headings[@]}"; do + mapfile -t matches < <(grep -nF -- "$heading" "$transcript" || true) + [ "${#matches[@]}" -eq 1 ] \ + || fail "$heading is not the sole straight-path heading at its position" + line="${matches[0]%%:*}" + [ "$line" -gt "$previous" ] \ + || fail "straight-path question headings are not in strict 1-to-6 line order" + previous="$line" + done + mapfile -t matches < <(grep -nF -- '@@DELEGATED@@' "$transcript" || true) + [ "${#matches[@]}" -eq 1 ] \ + || fail "straight-path delegation is not rendered exactly once" + delegated_line="${matches[0]%%:*}" + [ "$delegated_line" -gt "$previous" ] \ + || fail "straight-path delegation does not follow question six" +} + +assert_immediate_back_transition() # run-name target-index +{ + local name="$1" target="$2" + local transcript="$work/$name.txt" + local hint_occurrence=$((target + 1)) start end segment + local expected="${matrix_questions[$((target - 1))]}" + segment="$work/$name.back-transition" + start="$(grep -nF '(Press Esc for previous setting)' "$transcript" \ + | sed -n "${hint_occurrence}p" | cut -d: -f1 || true)" + end="$(grep -nF "$expected" "$transcript" \ + | sed -n '2p' | cut -d: -f1 || true)" + [ -n "$start" ] && [ -n "$end" ] && [ "$end" -gt "$start" ] \ + || fail "Escape from question $((target + 1)) does not reach question $target after its completed menu" + : > "$segment" + if [ "$end" -gt $((start + 1)) ]; then + sed -n "$((start + 1)),$((end - 1))p" "$transcript" > "$segment" + fi + if grep -Eqi \ + 'Ableton-Linux Installer Choice:|Choose an action:|Choose (a )?setting|[1-6]/6 |settings (submenu|menu|summary|review)|review (your )?settings|confirm (your )?settings|apply these settings' \ + "$segment"; then + fail "Escape from question $((target + 1)) shows an action, submenu, summary, or other question before question $target" + fi +} + +# ACT-FRESH + NAV-ORDER + NAV-EXPLANATIONS + DEFAULTS: a fresh action is named +# Install, then every setting is asked exactly once in the confirmed order. +fresh="$work/home-fresh" +mkdir -p -- "$fresh" +run_tty fresh-defaults "$fresh" drive_fresh_defaults +assert_action_set fresh-defaults \ + '> [I]nstall (or press Enter)' '> [Q]uit' +assert_strict_heading_order "$work/fresh-defaults.txt" +[ "$(cat "$work/fresh-defaults.args")" = install ] \ + || fail "fresh defaults do not route to installer install without synthetic overrides" +fresh_transcript="$work/fresh-defaults.txt" +assert_adjacent_question "$fresh_transcript" '1/6 SET DEFAULT AUDIO BUFFER' \ + 'A lower default audio buffer reduces audio latency but can cause distortion during playback.' \ + 'You can change this default later using pipeasio-settings.' \ + '[1] 64 frames' '[2] 128 frames (Default)' '[3] 256 frames' \ + '[4] 512 frames' '[5] 1024 frames' \ + '(Press Esc for previous setting)' + +# A typed choice must appear before Enter is pressed. Silent input makes some +# terminals display a padlock and gives no visible response to the key. +drive_visible_choice() +{ + local out="$1" i + local -a remaining=( + '2/6 ENABLE KEYBOARD SHORTCUTS' '3/6 SET LIVE DISPLAY SCALING' '4/6 SET AUDIO WORKER LIMIT' + '5/6 TOGGLE REAL-TIME SCHEDULING' '6/6 SET POWER MANAGEMENT' + ) + wait_for "$out" 'Choose an action:'; printf '\n' + wait_for "$out" '1/6 SET DEFAULT AUDIO BUFFER' + wait_for "$out" '(Press Esc for previous setting)' + printf '2' + wait_for "$out" 'Please choose [1-5]: 2' + : > "$work/visible-choice.seen" + printf '\n' + for i in "${!remaining[@]}"; do + wait_for "$out" "${remaining[$i]}" + wait_for "$out" '(Press Esc for previous setting)' "$((i + 2))" + printf '\n' + done + wait_for "$out" '@@DELEGATED@@' + wait_for "$out" '@@DONE@@' +} +run_tty visible-choice "$fresh" drive_visible_choice +[ -e "$work/visible-choice.seen" ] \ + || fail "a typed pre-flight choice stays hidden until Enter" +ok "pre-flight choices appear while the user types" +assert_adjacent_question "$fresh_transcript" '2/6 ENABLE KEYBOARD SHORTCUTS' \ + 'Determine whether Live can take over system shortcuts,' \ + 'to remain faithful to your Live keyboard shortcut muscle memory.' \ + '[A]ssign to Live (Default)' '[P]reserve desktop shortcuts' \ + '(Press Esc for previous setting)' +assert_adjacent_question "$fresh_transcript" '3/6 SET LIVE DISPLAY SCALING' \ + 'Changes how Live will be displayed on your montior.' \ + "You almost always want 'Automatic,' unless your system has a particular quirk." \ + '[A]utomatic (Default)' '[N]ormal (100%)' '[F]ractional' \ + "[P]reserve (Don't change under any circumstance)" \ + '(Press Esc for previous setting)' +assert_adjacent_question "$fresh_transcript" '4/6 SET AUDIO WORKER LIMIT' \ + 'Balances the available amount of computational power set aside for Live.' \ + 'Automatic is our optimisation work. Live is optimised for Windows.' \ + 'Depending on your CPU, more may not always be better!' \ + '[A]utomatic (Default)' '[L]et Live decide (Not recommended)' \ + '[1-63] Specify a custom limit (1 to 63)' \ + '(Press Esc for previous setting)' +assert_adjacent_question "$fresh_transcript" '5/6 TOGGLE REAL-TIME SCHEDULING' \ + 'Another optimisation that prioritises Live and audio on your system.' \ + 'Change this to normal if you are struggling with performance or issues with' \ + 'your system.' \ + '[A]utomatic (Default)' '[N]ormal scheduling' \ + '(Press Esc for previous setting)' +assert_adjacent_question "$fresh_transcript" '6/6 SET POWER MANAGEMENT' \ + "For convenience, this project can automatically manage your linux machine's power profile" \ + 'while Live is open and running.' \ + '[P]erformance (Default)' '[B]alanced' "[D]on't change" \ + '(Press Esc for previous setting)' +for semantic in \ + '1/6 SET DEFAULT AUDIO BUFFER' '2/6 ENABLE KEYBOARD SHORTCUTS' '3/6 SET LIVE DISPLAY SCALING' \ + '4/6 SET AUDIO WORKER LIMIT' '5/6 TOGGLE REAL-TIME SCHEDULING' '6/6 SET POWER MANAGEMENT' \ + 'A lower default audio buffer reduces audio latency but can cause distortion during playback.' \ + 'You can change this default later using pipeasio-settings.' \ + 'Determine whether Live can take over system shortcuts,' \ + 'to remain faithful to your Live keyboard shortcut muscle memory.' \ + 'Changes how Live will be displayed on your montior.' \ + "You almost always want 'Automatic,' unless your system has a particular quirk." \ + 'Balances the available amount of computational power set aside for Live.' \ + 'Automatic is our optimisation work. Live is optimised for Windows.' \ + 'Depending on your CPU, more may not always be better!' \ + 'Another optimisation that prioritises Live and audio on your system.' \ + 'Change this to normal if you are struggling with performance or issues with' \ + "For convenience, this project can automatically manage your linux machine's power profile" \ + 'while Live is open and running.' \ + '[1] 64 frames' '[2] 128 frames (Default)' '[3] 256 frames' \ + '[4] 512 frames' '[5] 1024 frames' \ + '[A]ssign to Live (Default)' '[P]reserve desktop shortcuts' \ + '[N]ormal (100%)' '[F]ractional' \ + "[P]reserve (Don't change under any circumstance)" \ + '[L]et Live decide (Not recommended)' '[1-63] Specify a custom limit (1 to 63)' \ + '[N]ormal scheduling' '[P]erformance (Default)' '[B]alanced' "[D]on't change"; do + assert_semantic_count "$fresh_transcript" "$semantic" 1 +done +assert_semantic_count "$fresh_transcript" '[A]utomatic (Default)' 3 +assert_semantic_count "$fresh_transcript" '(Press Esc for previous setting)' 6 +[ "$(grep -Ec '[[:digit:]]+/6 ' "$fresh_transcript")" -eq 6 ] \ + || fail "the straight path renders a missing or seventh numbered question" +[ "$(grep -cF '@@DELEGATED@@' "$fresh_transcript")" -eq 1 ] \ + || fail "the straight path does not delegate exactly once after question six" +delegated_line="$(grep -nF '@@DELEGATED@@' "$fresh_transcript" | cut -d: -f1)" +if sed -n "1,${delegated_line}p" "$fresh_transcript" \ + | grep -Eqi '7/6|settings (summary|review)|review (your )?settings|confirm (your )?settings|apply these settings'; then + fail "a seventh prompt or settings summary appears before delegation" +fi +ok "ACT-FRESH/NAV-ORDER/DEFAULTS: fresh Install asks six explained questions with 128 default" + +drive_delayed_choice() +{ + local out="$1" i + local -a questions=( + '2/6 ENABLE KEYBOARD SHORTCUTS' '3/6 SET LIVE DISPLAY SCALING' '4/6 SET AUDIO WORKER LIMIT' + '5/6 TOGGLE REAL-TIME SCHEDULING' '6/6 SET POWER MANAGEMENT' + ) + wait_for "$out" 'Choose an action:'; printf '\n' + wait_for "$out" '1/6 SET DEFAULT AUDIO BUFFER' + wait_for "$out" '(Press Esc for previous setting)' + sleep 2 + if grep -aqF '2/6 ENABLE KEYBOARD SHORTCUTS' "$out"; then + : > "$work/preflight-timed-out" + return + fi + printf '3\n' + for i in "${!questions[@]}"; do + wait_for "$out" "${questions[$i]}" + wait_for "$out" '(Press Esc for previous setting)' "$((i + 2))" + printf '\n' + done + wait_for "$out" '@@DONE@@' +} +rm -f -- "$work/preflight-timed-out" +PREFLIGHT_PROMPT_TIMEOUT=1 run_tty delayed-choice "$fresh" drive_delayed_choice +[ ! -e "$work/preflight-timed-out" ] \ + || fail "a pre-flight question advances when its old timer expires" +[ "$(cat "$work/delayed-choice.args")" = 'install --audio-buffer=256' ] \ + || fail "a pre-flight question does not accept an answer after its old timer expires" +ok "NAV-NO-TIMEOUT: pre-flight questions wait until the user answers" + +# ACT-HEALTHY: custom configured prefixes count; a healthy install offers +# Update/Reinstall/Remove/Quit, with Update as the default and no fresh Install. +healthy="$work/home-healthy" +custom_prefix="$work/custom-prefix" +mkdir -p -- "$healthy" +make_managed_install "$healthy" complete "$custom_prefix" +drive_healthy_quit() +{ + local out="$1" + wait_for "$out" 'Choose an action:'; printf 'q\n' + wait_for "$out" '@@DONE@@' +} +run_tty healthy "$healthy" drive_healthy_quit +assert_action_set healthy \ + '> [U]pdate (or press Enter)' '> [R]einstall' \ + '> Remo[v]e Ableton Linux' '> [Q]uit' +ok "ACT-HEALTHY: a configured custom-prefix install offers Update, Reinstall, Remove and Quit" + +# installer.run uninstall presents exactly the requested four choices. +drive_direct_uninstall() +{ + local out="$1" + wait_for_uninstall_scope "$out" || return 0 + case "$uninstall_scope_answer" in + runtime) printf '\n' ;; + prefix) printf 'Prefix only\n' ;; + all) printf 'All\n' ;; + exit) printf 'Exit\n' ;; + esac + if [ "$uninstall_scope_answer" = exit ]; then + wait_for "$out" '@@DONE@@' + else + wait_for "$out" '@@DELEGATED@@' + wait_for "$out" '@@DONE@@' + fi +} + +for uninstall_scope_answer in runtime prefix all exit; do + name="uninstall-scope-$uninstall_scope_answer" + run_tty "$name" "$healthy" drive_direct_uninstall uninstall + assert_uninstall_scope_set "$name" direct + case "$uninstall_scope_answer" in + runtime) expected='uninstall --keep-prefix' ;; + prefix) expected='uninstall --prefix-only' ;; + all) expected='uninstall --delete-prefix' ;; + exit) + [ ! -e "$work/$name.args" ] \ + || fail "Exit from uninstall scopes invokes the embedded installer" + [ ! -e "$work/$name.tar" ] \ + || fail "Exit from uninstall scopes extracts the embedded payload" + continue ;; + esac + [ "$(cat "$work/$name.args")" = "$expected" ] \ + || fail "$uninstall_scope_answer did not delegate $expected" +done +ok "REMOVE-SCOPES: installer.run uninstall routes Runtime, Prefix, All and Exit" + +# ACT-INCOMPLETE: recognised ownership without a complete registry can be +# reinstalled or removed, but cannot be updated as though healthy. +incomplete="$work/home-incomplete" +mkdir -p -- "$incomplete" +make_managed_install "$incomplete" incomplete +run_tty incomplete "$incomplete" drive_healthy_quit +assert_action_set incomplete \ + '> [R]einstall (or press Enter)' '> Remo[v]e Ableton Linux' '> [Q]uit' +ok "ACT-INCOMPLETE: incomplete managed state offers Reinstall, Remove and Quit only" + +# ACT-FOREIGN: a foreign default Wine prefix is not misclassified as managed. +foreign="$work/home-foreign" +mkdir -p -- "$foreign/.wine-ableton" +printf 'foreign registry\n' > "$foreign/.wine-ableton/system.reg" +run_tty foreign "$foreign" drive_healthy_quit +assert_action_set foreign '> [I]nstall (or press Enter)' '> [Q]uit' +ok "ACT-FOREIGN: unrelated Wine state is never treated as project ownership" + +# NAV-CHOICES + CLI-ROUTING: each question accepts its non-default directly; +# there are no nested submenus, and only deliberate changes reach installer.sh. +drive_all_choices() +{ + local out="$1" + wait_for "$out" 'Choose an action:'; printf 'i\n' + wait_for "$out" '1/6 SET DEFAULT AUDIO BUFFER'; printf '4\n' + wait_for "$out" '2/6 ENABLE KEYBOARD SHORTCUTS'; printf 'p\n' + wait_for "$out" '3/6 SET LIVE DISPLAY SCALING'; printf 'n\n' + wait_for "$out" '4/6 SET AUDIO WORKER LIMIT'; printf '16\n' + wait_for "$out" '5/6 TOGGLE REAL-TIME SCHEDULING'; printf 'n\n' + wait_for "$out" '6/6 SET POWER MANAGEMENT'; printf 'b\n' + wait_for "$out" '@@DONE@@' +} +run_tty all-choices "$fresh" drive_all_choices +[ "$(cat "$work/all-choices.args")" = \ + 'install --audio-buffer=512 --shortcuts=preserve --dpi=100 --audio-threads=16 --rt=off --power=balanced' ] \ + || fail "sequential choices do not reach the installer as exact validated intent" +! grep -qF 'Choose a setting' "$work/all-choices.raw" \ + || fail "the sequential questionnaire contains a summary/settings submenu" +ok "NAV-CHOICES/CLI-ROUTING: six direct answers become six exact installer flags" + +# NAV-OPTION-MATRIX: every selectable row is exercised through the PTY, not +# inferred from its label. Five paths cover all buffer, shortcut, display, +# worker, RT and power mappings, including Don't change and Let Live decide. +drive_option_matrix() +{ + local out="$1" i + wait_for "$out" 'Choose an action:'; printf 'i\n' + for ((i=0; i<6; i++)); do + wait_for "$out" "${matrix_questions[$i]}" + printf '%s\n' "${matrix_answers[$i]}" + done + wait_for "$out" '@@DONE@@' +} +matrix_questions=('1/6 SET DEFAULT AUDIO BUFFER' '2/6 ENABLE KEYBOARD SHORTCUTS' \ + '3/6 SET LIVE DISPLAY SCALING' '4/6 SET AUDIO WORKER LIMIT' \ + '5/6 TOGGLE REAL-TIME SCHEDULING' '6/6 SET POWER MANAGEMENT') +matrix_rows=( + '1 a a a a p|install --audio-buffer=64' + '2 p n l n b|install --shortcuts=preserve --dpi=100 --audio-threads=off --rt=off --power=balanced' + '3 a f 1 a d|install --audio-buffer=256 --dpi=fractional --audio-threads=1 --power=off' + '4 p p 63 n p|install --audio-buffer=512 --shortcuts=preserve --dpi=preserve --audio-threads=63 --rt=off' + '5 a a 16 a b|install --audio-buffer=1024 --audio-threads=16 --power=balanced' +) +matrix_index=0 +for matrix_row in "${matrix_rows[@]}"; do + matrix_index=$((matrix_index + 1)) + answers="${matrix_row%%|*}" + expected="${matrix_row#*|}" + read -r -a matrix_answers <<< "$answers" + run_tty "option-matrix-$matrix_index" "$fresh" drive_option_matrix + [ "$(cat "$work/option-matrix-$matrix_index.args")" = "$expected" ] \ + || fail "option matrix row $matrix_index maps to the wrong persistent intent" +done +ok "NAV-OPTION-MATRIX: every direct setting choice maps to its exact flag" + +# NAV-BACK: Escape goes back exactly one question and retains earlier answers. +# A replacement answer changes the final intent; later answers retain their +# prior selection as the default when revisited. +drive_back_one() +{ + local out="$1" + wait_for "$out" 'Choose an action:'; printf 'i\n' + wait_for "$out" '1/6 SET DEFAULT AUDIO BUFFER'; printf '4\n' + wait_for "$out" '2/6 ENABLE KEYBOARD SHORTCUTS'; printf 'p\n' + wait_for "$out" '3/6 SET LIVE DISPLAY SCALING'; printf '\033' + wait_for "$out" '2/6 ENABLE KEYBOARD SHORTCUTS' 2; printf 'a\n' + wait_for "$out" '3/6 SET LIVE DISPLAY SCALING' 2; printf 'f\n' + wait_for "$out" '4/6 SET AUDIO WORKER LIMIT'; printf '12\n' + wait_for "$out" '5/6 TOGGLE REAL-TIME SCHEDULING'; printf 'n\n' + wait_for "$out" '6/6 SET POWER MANAGEMENT'; printf 'b\n' + wait_for "$out" '@@DONE@@' +} +run_tty back-one "$fresh" drive_back_one +[ "$(grep -aoF '2/6 ENABLE KEYBOARD SHORTCUTS' "$work/back-one.raw" | wc -l)" -eq 2 ] \ + || fail "Escape does not return from question 3 to question 2 exactly once" +[ "$(cat "$work/back-one.args")" = \ + 'install --audio-buffer=512 --dpi=fractional --audio-threads=12 --rt=off --power=balanced' ] \ + || fail "back navigation loses retained answers or keeps the replaced shortcut answer" +ok "NAV-BACK: Escape moves back one question while retaining and revising answers" + +# NAV-BACK-MATRIX: Escape from every question after the first returns exactly +# to the immediately preceding question, never to a submenu or action menu. +drive_back_matrix() +{ + local out="$1" i minimum + wait_for "$out" 'Choose an action:'; printf 'i\n' + for ((i=0; i "$action_reset_home/.config/pipeasio/config.ini" +run_tty changed-action "$action_reset_home" drive_change_action +[ "$(cat "$work/changed-action.args")" = install ] \ + || fail "an abandoned Update draft follows the replacement Reinstall action" +if [ "$(grep -aoF '2048 frames (Current)' "$work/changed-action.raw" | wc -l)" -ne 2 ] \ + || [ "$(grep -aoF '512 frames (Current)' "$work/changed-action.raw" | wc -l)" -ne 1 ]; then + fail "replacement action does not reload the persisted/current buffer" +fi +ok "NAV-ACTION-RESET: changing action discards draft intent and reloads saved/current values" + +# NAV-EXPLICIT-DEFAULTS: choosing defaults is real reset intent when the saved +# current values differ. Enter retains; a typed default emits the exact flags. +drive_explicit_defaults() +{ + local out="$1" answer i=0 + wait_for "$out" 'Choose an action:'; printf 'u\n' + for answer in 2 a a a a p; do + wait_for "$out" "${matrix_questions[$i]}"; printf '%s\n' "$answer" + i=$((i + 1)) + done + wait_for "$out" '@@DONE@@' +} +run_tty explicit-defaults "$action_reset_home" drive_explicit_defaults +[ "$(cat "$work/explicit-defaults.args")" = \ + 'update --audio-buffer=128 --shortcuts=take --dpi=auto --audio-threads=auto --rt=auto --power=performance' ] \ + || fail "typed defaults do not reset differing saved/current settings" +ok "NAV-EXPLICIT-DEFAULTS: typed defaults reset non-default saved values" + +# NAV-VALIDATION: invalid worker values remain on the same question; one valid +# custom value proceeds without a secondary custom-value menu. +drive_worker_validation() +{ + local out="$1" + wait_for "$out" 'Choose an action:'; printf 'i\n' + wait_for "$out" '1/6 SET DEFAULT AUDIO BUFFER'; printf '\n' + wait_for "$out" '2/6 ENABLE KEYBOARD SHORTCUTS'; printf '\n' + wait_for "$out" '3/6 SET LIVE DISPLAY SCALING'; printf '\n' + wait_for "$out" '4/6 SET AUDIO WORKER LIMIT'; printf '0\n' + wait_for "$out" 'Choose Automatic, Let Live decide, or a number from 1 to 63.'; printf '64\n' + wait_for "$out" 'Choose Automatic, Let Live decide, or a number from 1 to 63.' 2; printf 'word\n' + wait_for "$out" 'Choose Automatic, Let Live decide, or a number from 1 to 63.' 3; printf '63\n' + wait_for "$out" '5/6 TOGGLE REAL-TIME SCHEDULING'; printf '\n' + wait_for "$out" '6/6 SET POWER MANAGEMENT'; printf '\n' + wait_for "$out" '@@DONE@@' +} +run_tty worker-validation "$fresh" drive_worker_validation +[ "$(cat "$work/worker-validation.args")" = 'install --audio-threads=63' ] \ + || fail "custom worker validation does not retain the eventual valid value" +ok "NAV-VALIDATION: invalid direct worker input reprompts without opening a submenu" + +# NAV-CURRENT: saved values and a valid custom PipeASIO value become the Enter +# defaults on Update. Continuing makes no synthetic preference arguments. +current="$work/home-current" +mkdir -p -- "$current/.config/pipeasio" +make_managed_install "$current" complete +make_preferences "$current" preserve preserve off off balanced +printf '[pipeasio]\ninputs = 2\nbuffer_size = 2048\noutputs = 2\n' \ + > "$current/.config/pipeasio/config.ini" +drive_update_defaults() +{ + local out="$1" + wait_for "$out" 'Choose an action:'; printf 'u\n' + answer_defaults "$out" +} +run_tty current "$current" drive_update_defaults +for expected in '2048 frames (Current)' '[P]reserve desktop shortcuts (Current)' \ + "[P]reserve (Don't change under any circumstance) (Current)" \ + '[L]et Live decide (Not recommended) (Current)' \ + '[N]ormal scheduling (Current)' '[B]alanced (Current)'; do + grep -qF "$expected" "$work/current.raw" || fail "saved/default menu omits $expected" +done +[ "$(cat "$work/current.args")" = update ] \ + || fail "accepting current values turns them into overwrite intent" +ok "NAV-CURRENT: current and custom values are displayed and retained without writes" + +# ACT-EVIDENCE: classifier ownership is based on strict project evidence, not +# one hard-coded prefix. Marker-only and config-only installs are recognised; +# an unsafe/malformed config plus foreign Wine files is not. +marker_only="$work/home-marker-only" +mkdir -p -- "$marker_only/.wine-ableton" +printf 'format=1\nprefix=%s\n' "$marker_only/.wine-ableton" \ + > "$marker_only/.wine-ableton/.ableton-linux-prefix" +printf 'WINE REGISTRY Version 2\n' > "$marker_only/.wine-ableton/system.reg" +run_tty marker-only "$marker_only" drive_healthy_quit +assert_action_set marker-only \ + '> [U]pdate (or press Enter)' '> [R]einstall' \ + '> Remo[v]e Ableton Linux' '> [Q]uit' + +config_only="$work/home-config-only" +config_only_prefix="$work/config-only-prefix" +mkdir -p -- "$config_only" +make_managed_install "$config_only" complete "$config_only_prefix" +rm -f -- "$config_only_prefix/.ableton-linux-prefix" +run_tty config-only "$config_only" drive_healthy_quit +assert_action_set config-only \ + '> [U]pdate (or press Enter)' '> [R]einstall' \ + '> Remo[v]e Ableton Linux' '> [Q]uit' + +marker_incomplete="$work/home-marker-incomplete" +mkdir -p -- "$marker_incomplete/.wine-ableton" +printf 'format=1\nprefix=%s\n' "$marker_incomplete/.wine-ableton" \ + > "$marker_incomplete/.wine-ableton/.ableton-linux-prefix" +run_tty marker-incomplete "$marker_incomplete" drive_healthy_quit +assert_action_set marker-incomplete \ + '> [R]einstall (or press Enter)' '> Remo[v]e Ableton Linux' '> [Q]uit' + +config_incomplete="$work/home-config-incomplete" +config_incomplete_prefix="$work/config-incomplete-prefix" +mkdir -p -- "$config_incomplete" +make_managed_install "$config_incomplete" incomplete "$config_incomplete_prefix" +rm -f -- "$config_incomplete_prefix/.ableton-linux-prefix" +run_tty config-incomplete "$config_incomplete" drive_healthy_quit +assert_action_set config-incomplete \ + '> [R]einstall (or press Enter)' '> Remo[v]e Ableton Linux' '> [Q]uit' + +missing_target="$work/home-missing-target" +mkdir -p -- "$missing_target" +make_managed_install "$missing_target" incomplete "$work/now-missing-prefix" +rm -rf -- "$work/now-missing-prefix" +run_tty missing-target "$missing_target" drive_healthy_quit +assert_action_set missing-target \ + '> [R]einstall (or press Enter)' '> Remo[v]e Ableton Linux' '> [Q]uit' + +for unsafe_kind in malformed symlink; do + unsafe="$work/home-unsafe-$unsafe_kind" + mkdir -p -- "$unsafe/.config/ableton-wine" "$unsafe/.wine-ableton" + printf 'foreign registry\n' > "$unsafe/.wine-ableton/system.reg" + if [ "$unsafe_kind" = malformed ]; then + printf 'format=1\nunknown=value\n' > "$unsafe/.config/ableton-wine/config" + else + write_valid_config "$unsafe" "$unsafe/external-config" "$unsafe/.wine-ableton" + ln -s -- "$unsafe/external-config" "$unsafe/.config/ableton-wine/config" + fi + run_tty "unsafe-$unsafe_kind" "$unsafe" drive_healthy_quit + assert_action_set "unsafe-$unsafe_kind" \ + '> [I]nstall (or press Enter)' '> [Q]uit' +done + +for unsafe_kind in malformed symlink; do + unsafe="$work/home-unsafe-marker-$unsafe_kind" + mkdir -p -- "$unsafe/.wine-ableton" + printf 'WINE REGISTRY Version 2\n' > "$unsafe/.wine-ableton/system.reg" + if [ "$unsafe_kind" = malformed ]; then + printf 'format=1\nunknown=value\n' \ + > "$unsafe/.wine-ableton/.ableton-linux-prefix" + else + printf 'format=1\nprefix=%s\n' "$unsafe/.wine-ableton" \ + > "$unsafe/external-marker" + ln -s -- "$unsafe/external-marker" \ + "$unsafe/.wine-ableton/.ableton-linux-prefix" + fi + run_tty "unsafe-marker-$unsafe_kind" "$unsafe" drive_healthy_quit + assert_action_set "unsafe-marker-$unsafe_kind" \ + '> [I]nstall (or press Enter)' '> [Q]uit' +done + +marker_precedence="$work/home-marker-precedence" +mkdir -p -- "$marker_precedence/.config/ableton-wine" \ + "$marker_precedence/.wine-ableton" +printf 'format=1\nprefix=%s\n' "$marker_precedence/.wine-ableton" \ + > "$marker_precedence/.wine-ableton/.ableton-linux-prefix" +printf 'WINE REGISTRY Version 2\n' > "$marker_precedence/.wine-ableton/system.reg" +printf 'format=1\nunknown=value\n' \ + > "$marker_precedence/.config/ableton-wine/config" +run_tty marker-precedence "$marker_precedence" drive_healthy_quit +assert_action_set marker-precedence \ + '> [U]pdate (or press Enter)' '> [R]einstall' \ + '> Remo[v]e Ableton Linux' '> [Q]uit' + +# A valid configuration is authoritative when the default-prefix marker points +# at a different valid state. Exercise both directions so source ordering cannot +# accidentally collapse to "any complete evidence wins". +config_complete_marker_incomplete="$work/home-config-complete-marker-incomplete" +config_complete_prefix="$work/config-complete-marker-incomplete-prefix" +mkdir -p -- "$config_complete_marker_incomplete/.wine-ableton" \ + "$config_complete_prefix" +write_valid_config "$config_complete_marker_incomplete" \ + "$config_complete_marker_incomplete/.config/ableton-wine/config" \ + "$config_complete_prefix" +printf 'WINE REGISTRY Version 2\n' > "$config_complete_prefix/system.reg" +printf 'format=1\nprefix=%s\n' "$config_complete_marker_incomplete/.wine-ableton" \ + > "$config_complete_marker_incomplete/.wine-ableton/.ableton-linux-prefix" +run_tty config-complete-marker-incomplete \ + "$config_complete_marker_incomplete" drive_healthy_quit +assert_expected_state config-complete-marker-incomplete healthy + +config_incomplete_marker_complete="$work/home-config-incomplete-marker-complete" +config_incomplete_winning_prefix="$work/config-incomplete-marker-complete-prefix" +mkdir -p -- "$config_incomplete_marker_complete/.wine-ableton" \ + "$config_incomplete_winning_prefix" +write_valid_config "$config_incomplete_marker_complete" \ + "$config_incomplete_marker_complete/.config/ableton-wine/config" \ + "$config_incomplete_winning_prefix" +printf 'format=1\nprefix=%s\n' "$config_incomplete_marker_complete/.wine-ableton" \ + > "$config_incomplete_marker_complete/.wine-ableton/.ableton-linux-prefix" +printf 'WINE REGISTRY Version 2\n' \ + > "$config_incomplete_marker_complete/.wine-ableton/system.reg" +run_tty config-incomplete-marker-complete \ + "$config_incomplete_marker_complete" drive_healthy_quit +assert_expected_state config-incomplete-marker-complete incomplete + +# Malformed marker evidence is ignored when a valid configuration selects the +# configured prefix; the configured prefix here is explicitly incomplete. +config_valid_marker_malformed="$work/home-config-valid-marker-malformed" +config_valid_marker_malformed_prefix="$work/config-valid-marker-malformed-prefix" +mkdir -p -- "$config_valid_marker_malformed/.wine-ableton" \ + "$config_valid_marker_malformed_prefix" +write_valid_config "$config_valid_marker_malformed" \ + "$config_valid_marker_malformed/.config/ableton-wine/config" \ + "$config_valid_marker_malformed_prefix" +printf 'format=1\nunknown=value\n' \ + > "$config_valid_marker_malformed/.wine-ableton/.ableton-linux-prefix" +printf 'WINE REGISTRY Version 2\n' \ + > "$config_valid_marker_malformed/.wine-ableton/system.reg" +run_tty config-valid-marker-malformed \ + "$config_valid_marker_malformed" drive_healthy_quit +assert_expected_state config-valid-marker-malformed incomplete + +preferences_only="$work/home-preferences-only" +mkdir -p -- "$preferences_only" +make_preferences "$preferences_only" take auto auto auto performance +run_tty preferences-only "$preferences_only" drive_healthy_quit +assert_action_set preferences-only '> [I]nstall (or press Enter)' '> [Q]uit' + +support_only="$work/home-support-only" +mkdir -p -- "$support_only/.local/bin" \ + "$support_only/.local/share/ableton-wine" \ + "$support_only/.local/share/applications" +printf '#!/bin/sh\n' > "$support_only/.local/bin/ableton-live" +chmod 755 "$support_only/.local/bin/ableton-live" +printf '[Desktop Entry]\nName=Ableton Live\n' \ + > "$support_only/.local/share/applications/ableton-live.desktop" +printf 'optional support only\n' \ + > "$support_only/.local/share/ableton-wine/preferences.sh" +run_tty support-only "$support_only" drive_healthy_quit +assert_action_set support-only '> [I]nstall (or press Enter)' '> [Q]uit' + +ok "ACT-EVIDENCE: exact action sets cover strict evidence and config-first state precedence" + +# ACT-DEFAULT-ROUTING: Enter and non-TTY use the state-specific default. Remove +# remains directly routable and never enters the settings questionnaire. +drive_default_action() +{ + local out="$1" + wait_for "$out" 'Choose an action:'; printf '\n' + answer_defaults "$out" +} +run_tty healthy-default "$healthy" drive_default_action +[ "$(cat "$work/healthy-default.args")" = update ] \ + || fail "Enter on a healthy install does not route to Update" +run_tty incomplete-default "$incomplete" drive_default_action +[ "$(cat "$work/incomplete-default.args")" = install ] \ + || fail "Enter on an incomplete install does not route to Reinstall" +drive_remove() +{ + local out="$1" + wait_for "$out" 'Choose an action:'; printf 'v\n' + wait_for "$out" '@@DONE@@' +} +run_tty remove-route "$healthy" drive_remove +[ "$(cat "$work/remove-route.args")" = uninstall ] \ + || fail "Remove does not route directly to uninstall" +! grep -qF '1/6 SET DEFAULT AUDIO BUFFER' "$work/remove-route.raw" \ + || fail "Remove enters the pre-flight settings questions" + +for state_name in healthy incomplete; do + state_home="$healthy" + expected_action=update + [ "$state_name" != incomplete ] \ + || { state_home="$incomplete"; expected_action=install; } + run_notty "notty-$state_name" "$state_home" + [ "$(cat "$work/notty-$state_name.args")" = "$expected_action" ] \ + || fail "non-TTY $state_name state does not route to $expected_action" +done +ok "ACT-DEFAULT-ROUTING: Enter/non-TTY defaults and Remove route by installation state" + +# ROUTE-EXPLICIT/NONTTY: an explicit command and a non-terminal invocation do +# not ask pre-flight questions. The installer remains the authoritative parser. +run_notty explicit "$current" update +[ "$(cat "$work/explicit.args")" = update ] || fail "explicit Update changes arguments" +! grep -qF '1/6 SET DEFAULT AUDIO BUFFER' "$work/explicit.out" \ + || fail "an explicit command enters the interactive pre-flight" +run_notty notty "$fresh" +[ "$(cat "$work/notty.args")" = install ] || fail "fresh non-TTY does not select Install" +if grep -qF 'Choose an action:' "$work/notty.out" \ + || grep -qF '1/6 SET DEFAULT AUDIO BUFFER' "$work/notty.out"; then + fail "non-TTY invocation prompts" +fi +preferences="$current/.config/ableton-wine/preferences" +before_preferences="$(sha256sum "$preferences")" +run_notty notty-saved "$current" +[ "$(cat "$work/notty-saved.args")" = update ] \ + || fail "saved-preference non-TTY invocation does not select Update" +[ "$(sha256sum "$preferences")" = "$before_preferences" ] \ + || fail "non-TTY routing rewrites saved preferences without explicit intent" +if grep -qF 'Choose an action:' "$work/notty-saved.out" \ + || grep -qF '1/6 SET DEFAULT AUDIO BUFFER' "$work/notty-saved.out"; then + fail "saved-preference non-TTY invocation prompts" +fi +ok "ROUTE-EXPLICIT/NONTTY: explicit and non-terminal runs never enter pre-flight UI" + +printf '%s pre-flight menu checks passed\n' "$pass" diff --git a/scripts/test-preflight-preferences.sh b/scripts/test-preflight-preferences.sh new file mode 100755 index 00000000..9366a7ab --- /dev/null +++ b/scripts/test-preflight-preferences.sh @@ -0,0 +1,960 @@ +#!/usr/bin/env bash +# Frozen contract for the installer pre-flight preference store and PipeASIO +# buffer editor. Behaviour IDs map to notes/PREFLIGHT-SETTINGS-TEST-MATRIX.md. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +preferences_lib="$here/lib/preferences.sh" +work="$(mktemp -d "${TMPDIR:-/tmp}/ableton-preferences-test.XXXXXX")" +trap 'rm -rf -- "$work"' EXIT + +pass=0 +ok() { pass=$((pass + 1)); printf 'ok - %s\n' "$1"; } +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } + +[ -r "$preferences_lib" ] || fail "scripts/lib/preferences.sh exists" + +preferences_api() # base function [arguments...] +{ + local base="$1" function="$2" + shift 2 + # Source and invoke the library only in a clean child context. This catches + # accidental source-time dependence on the caller's shell or environment. + # shellcheck disable=SC2016 # this is the isolated child shell program + env -i PATH="$PATH" LC_ALL=C HOME="$base/home" \ + XDG_CONFIG_HOME="$base/config" \ + bash -c ' + set -euo pipefail + cd "$HOME" + . "$1" + function="$2" + shift 2 + "$function" "$@" + ' _ "$preferences_lib" "$function" "$@" +} + +required_api=( + ableton_preferences_valid + ableton_preferences_object_token + ableton_preferences_apply + ableton_preferences_merge + ableton_preferences_write + ableton_preferences_remove + ableton_pipeasio_seed_identity_token + ableton_pipeasio_seed_record_preflight + ableton_pipeasio_seed_record_commit_preflight + ableton_pipeasio_seed_record_publish + ableton_pipeasio_seed_record_promote + ableton_pipeasio_seed_record_rollback + ableton_pipeasio_seed_record_commit + ableton_pipeasio_buffer_read + ableton_pipeasio_buffer_write +) +mkdir -p -- "$work/api/home" "$work/api/config" +# shellcheck disable=SC2016 # this is the isolated child shell program +env -i PATH="$PATH" LC_ALL=C HOME="$work/api/home" \ + XDG_CONFIG_HOME="$work/api/config" \ + bash -c ' + set -euo pipefail + cd "$HOME" + . "$1" + shift + for function; do + declare -F "$function" >/dev/null || exit 1 + done + ' _ "$preferences_lib" "${required_api[@]}" \ + || fail "preferences.sh does not define its complete isolated API" +! find "$work/api/home" "$work/api/config" -mindepth 1 -print -quit | grep -q . \ + || fail "sourcing preferences.sh changes its isolated home or config context" +ok "PREF-API: the side-effect-free preference library exposes its tested API" + +# Test-facing proxies keep every library load and invocation in that isolated +# child without obscuring the public function names used by the contract. +ableton_preferences_valid() { preferences_api "$base" ableton_preferences_valid "$@"; } +ableton_preferences_object_token() { preferences_api "$base" ableton_preferences_object_token "$@"; } +ableton_preferences_merge() { preferences_api "$base" ableton_preferences_merge "$@"; } +ableton_preferences_write() { preferences_api "$base" ableton_preferences_write "$@"; } +ableton_preferences_remove() { preferences_api "$base" ableton_preferences_remove "$@"; } +ableton_pipeasio_seed_identity_token() { preferences_api "$base" ableton_pipeasio_seed_identity_token "$@"; } +ableton_pipeasio_seed_record_preflight() { preferences_api "$base" ableton_pipeasio_seed_record_preflight "$@"; } +ableton_pipeasio_seed_record_commit_preflight() { preferences_api "$base" ableton_pipeasio_seed_record_commit_preflight "$@"; } +ableton_pipeasio_seed_record_publish() { preferences_api "$base" ableton_pipeasio_seed_record_publish "$@"; } +ableton_pipeasio_seed_record_promote() { preferences_api "$base" ableton_pipeasio_seed_record_promote "$@"; } +ableton_pipeasio_seed_record_rollback() { preferences_api "$base" ableton_pipeasio_seed_record_rollback "$@"; } +ableton_pipeasio_seed_record_commit() { preferences_api "$base" ableton_pipeasio_seed_record_commit "$@"; } +ableton_pipeasio_buffer_read() { preferences_api "$base" ableton_pipeasio_buffer_read "$@"; } +ableton_pipeasio_buffer_write() { preferences_api "$base" ableton_pipeasio_buffer_write "$@"; } + +new_home() # name -> path +{ + local base="$work/$1" + mkdir -p -- "$base/home" "$base/config" + printf '%s\n' "$base" +} + +apply_preferences() # base [environment assignments...] +{ + local base="$1"; shift + # shellcheck disable=SC2016 # this is the child shell program, not parent expansion + env -i PATH="$PATH" LC_ALL=C HOME="$base/home" \ + XDG_CONFIG_HOME="$base/config" \ + "$@" bash -c ' + set -euo pipefail + cd "$HOME" + . "$1" + ableton_preferences_apply + printf "%s|%s|%s|%s|%s\n" "$ABLETON_SHORTCUTS" "$ABLETON_DPI_MODE" \ + "$ABLETON_MAX_AUDIO_THREADS" "$ABLETON_RT" "$ABLETON_POWER" + ' _ "$preferences_lib" +} + +write_fixture() # base shortcuts dpi threads rt power +{ + local base="$1" shortcuts="$2" dpi="$3" threads="$4" rt="$5" power="$6" + local file="$base/config/ableton-wine/preferences" + mkdir -p -- "$(dirname "$file")" + cat > "$file" < stable description of object type/link/mode/bytes +{ + local path="$1" + if [ -L "$path" ]; then + printf 'type=symlink\nidentity=%s\nmode=%s\nlink=%s\n' \ + "$(stat -c '%d:%i|%w|%y|%z' -- "$path")" \ + "$(stat -c '%a' -- "$path")" "$(readlink -- "$path")" + if [ -f "$path" ]; then + printf 'target-identity=%s\ntarget-mode=%s\ntarget-bytes=%s\n' \ + "$(stat -Lc '%d:%i|%w|%y|%z' -- "$path")" \ + "$(stat -Lc '%a' -- "$path")" "$(sha256sum -- "$path" | awk '{print $1}')" + fi + elif [ -f "$path" ]; then + printf 'type=file\nidentity=%s\nmode=%s\nbytes=%s\n' \ + "$(stat -c '%d:%i|%w|%y|%z' -- "$path")" \ + "$(stat -c '%a' -- "$path")" "$(sha256sum -- "$path" | awk '{print $1}')" + elif [ -d "$path" ]; then + printf 'type=directory\nidentity=%s\nmode=%s\nentries=' \ + "$(stat -c '%d:%i|%w|%y|%z' -- "$path")" "$(stat -c '%a' -- "$path")" + find "$path" -mindepth 1 -maxdepth 1 -printf '%f\n' | LC_ALL=C sort + elif [ -e "$path" ]; then + printf 'type=other\nmode=%s\n' "$(stat -c '%a' -- "$path")" + else + printf 'type=absent\n' + fi +} + +# PREF-DEFAULTS: these are the compatibility defaults when no file exists. +base="$(new_home defaults)" +[ "$(apply_preferences "$base")" = 'take|auto|auto|auto|performance' ] \ + || fail "absent preferences use the confirmed launcher defaults" +[ ! -e "$base/config/ableton-wine/preferences" ] \ + || fail "reading absent preferences does not create them" +ok "PREF-DEFAULTS: absent preferences are read-only and use confirmed defaults" + +# PREF-LOAD: every persisted value is applied, including Live's existing +# audio-thread 'off' representation for the user-facing 'Let Live decide'. +base="$(new_home load)" +write_fixture "$base" preserve preserve off off balanced +[ "$(apply_preferences "$base")" = 'preserve|preserve|off|off|balanced' ] \ + || fail "valid preferences are not applied exactly" +ableton_preferences_valid "$base/config/ableton-wine/preferences" \ + || fail "the exact generated preferences schema is not valid" +ok "PREF-LOAD: all five saved launcher preferences load from strict data" + +# PREF-MERGE: the dispatcher asks the same parser to merge explicit CLI intent. +# Empty fields mean retain saved/default; environment is deliberately absent +# from this API so it cannot become persistent by accident. +base="$(new_home merge-api)" +missing="$base/config/ableton-wine/preferences" +[ "$(ableton_preferences_merge "$missing" '' '' '' off '')" = \ + 'take|auto|auto|off|performance' ] \ + || fail "absent-store merge does not overlay one explicit value on defaults" +write_fixture "$base" preserve preserve off off balanced +[ "$(ableton_preferences_merge "$missing" '' auto '' '' performance)" = \ + 'preserve|auto|off|off|performance' ] \ + || fail "saved-store merge resets retained fields or ignores explicit values" +before="$(object_snapshot "$missing")" +if ableton_preferences_merge "$missing" '' '' 64 '' '' \ + >"$base/merge-invalid.out" 2>"$base/merge-invalid.err"; then + fail "merge API accepts an invalid explicit value" +fi +[ "$(object_snapshot "$missing")" = "$before" ] \ + || fail "refusing an invalid merge changes stored preferences" +ok "PREF-MERGE: one strict API combines saved/default values with explicit intent" + +# PREF-ENV: non-empty launch environment wins; an empty variable retains the +# launchers' historical ${VAR:-default} meaning and therefore does not mask a +# saved preference. +base="$(new_home precedence)" +write_fixture "$base" preserve 100 12 off balanced +got="$(apply_preferences "$base" env \ + ABLETON_SHORTCUTS=take ABLETON_DPI_MODE= ABLETON_MAX_AUDIO_THREADS=8 \ + ABLETON_RT=auto ABLETON_POWER=off)" +[ "$got" = 'take|100|8|auto|off' ] \ + || fail "nonempty environment does not override saved values, or empty environment masks one" +ok "PREF-ENV: nonempty environment overrides saved values and empty means unset" + +# PREF-COMPAT: the historical power spellings remain one-run aliases for the +# explicit performance policy. Unknown values stop at validation instead of +# accidentally selecting a profile. +base="$(new_home compatibility)" +write_fixture "$base" take auto auto auto balanced +for legacy in on auto; do + [ "$(apply_preferences "$base" env ABLETON_POWER="$legacy")" = \ + 'take|auto|auto|auto|performance' ] \ + || fail "legacy ABLETON_POWER=$legacy is not mapped to performance" +done +if apply_preferences "$base" env ABLETON_POWER=fast \ + >"$base/out" 2>"$base/err"; then + fail "unknown ABLETON_POWER value is accepted" +fi +grep -qF 'ABLETON_POWER must be performance, balanced, or off' "$base/err" \ + || fail "unknown power policy has no actionable validation message" +ok "PREF-COMPAT: legacy power aliases map explicitly and unknown values fail validation" + +# PREF-MALFORMED: optional preferences can never make Live or Max unlaunchable. +# A malformed format-1 file is ignored with an actionable warning and is not +# rewritten by a launcher. +base="$(new_home malformed)" +write_fixture "$base" preserve auto auto auto performance +printf 'shortcuts=take\n' >> "$base/config/ableton-wine/preferences" +before="$(object_snapshot "$base/config/ableton-wine/preferences")" +got="$(apply_preferences "$base" 2>"$base/err")" +[ "$got" = 'take|auto|auto|auto|performance' ] \ + || fail "malformed preferences do not fall back to compatibility defaults" +grep -qF 'launcher preferences are malformed; using defaults' "$base/err" \ + || fail "malformed preferences do not name their safe fallback" +[ "$(object_snapshot "$base/config/ableton-wine/preferences")" = "$before" ] \ + || fail "a launcher rewrites malformed preferences" +ok "PREF-MALFORMED: malformed optional preferences warn, fall back, and remain untouched" + +# PREF-FUTURE: a future format is data owned by a newer installer. It is never +# accepted as current and never overwritten by a format-1 writer. +base="$(new_home future)" +file="$base/config/ableton-wine/preferences" +mkdir -p -- "$(dirname "$file")" +printf '# ableton-linux launcher preferences; managed by the installer\nformat=2\n' > "$file" +chmod 600 "$file" +before="$(object_snapshot "$file")" +! ableton_preferences_valid "$file" || fail "a future preference format is accepted as format 1" +token="$(ableton_preferences_object_token "$file")" +! ableton_preferences_write "$file" "$token" take auto auto auto performance \ + >"$base/out" 2>"$base/err" \ + || fail "the writer replaces an unknown future preference format" +[ "$(object_snapshot "$file")" = "$before" ] \ + || fail "the refused future preference file changed" +ok "PREF-FUTURE: unknown formats are preserved for a newer installer" + +# PREF-VALIDATION: the header and every schema key are present exactly once; +# unknown, binary and indirect objects also fail the strict validator. +base="$(new_home validation)" +write_fixture "$base" take auto auto auto performance +cp -- "$base/config/ableton-wine/preferences" "$base/valid-preferences" +valid="$base/valid-preferences" +for key in format shortcuts dpi audio_threads rt power; do + candidate="$base/missing-$key" + sed "/^$key=/d" "$valid" > "$candidate" + chmod --reference="$valid" "$candidate" + before="$(object_snapshot "$candidate")" + ! ableton_preferences_valid "$candidate" \ + || fail "preferences missing required key $key pass validation" + [ "$(object_snapshot "$candidate")" = "$before" ] \ + || fail "validating missing $key mutates the candidate" + candidate="$base/duplicate-$key" + cp -- "$valid" "$candidate" + grep -m1 "^$key=" "$valid" >> "$candidate" + chmod --reference="$valid" "$candidate" + before="$(object_snapshot "$candidate")" + ! ableton_preferences_valid "$candidate" \ + || fail "preferences duplicating required key $key pass validation" + [ "$(object_snapshot "$candidate")" = "$before" ] \ + || fail "validating duplicate $key mutates the candidate" +done +for kind in missing-header wrong-header duplicate-header unknown nul symlink directory; do + candidate="$base/$kind" + case "$kind" in + missing-header) sed '1d' "$valid" > "$candidate" ;; + wrong-header) + sed '1c# similar but unmanaged launcher preferences' "$valid" > "$candidate" + ;; + duplicate-header) + cp -- "$valid" "$candidate" + sed -n '1p' "$valid" >> "$candidate" + ;; + unknown) cp -- "$valid" "$candidate"; printf 'other=value\n' >> "$candidate" ;; + nul) cp -- "$valid" "$candidate"; printf '\0' >> "$candidate" ;; + symlink) ln -s -- "$valid" "$candidate" ;; + directory) mkdir -- "$candidate" ;; + esac + if [ -f "$candidate" ] && [ ! -L "$candidate" ]; then + chmod --reference="$valid" "$candidate" + [ "$(stat -c '%a' "$candidate")" = "$(stat -c '%a' "$valid")" ] \ + || fail "$kind validation candidate does not isolate schema from mode" + fi + before="$(object_snapshot "$candidate")" + ! ableton_preferences_valid "$candidate" \ + || fail "$kind preferences pass the strict validator" + [ "$(object_snapshot "$candidate")" = "$before" ] \ + || fail "validating $kind preferences mutates the candidate" +done +for kind in extra-comment reordered whitespace crlf displaced-header; do + candidate="$base/$kind" + case "$kind" in + extra-comment) cp -- "$valid" "$candidate"; printf '# user note\n' >> "$candidate" ;; + reordered) + { + sed -n '1,2p' "$valid" + sed -n '4p' "$valid" + sed -n '3p' "$valid" + sed -n '5,$p' "$valid" + } > "$candidate" ;; + whitespace) sed 's/^format=1$/format = 1/' "$valid" > "$candidate" ;; + crlf) sed 's/$/\r/' "$valid" > "$candidate" ;; + displaced-header) + { printf 'foreign text\n'; cat "$valid"; } > "$candidate" ;; + esac + chmod --reference="$valid" "$candidate" + [ "$(stat -c '%a' "$candidate")" = "$(stat -c '%a' "$valid")" ] \ + || fail "$kind exact-record candidate does not isolate layout from mode" + if [ "$kind" = reordered ]; then + ! cmp -s -- "$valid" "$candidate" \ + || fail "reordered fixture does not actually change record order" + LC_ALL=C sort "$valid" > "$base/valid.sorted" + LC_ALL=C sort "$candidate" > "$base/reordered.sorted" + cmp -s -- "$base/valid.sorted" "$base/reordered.sorted" \ + || fail "reordered fixture changes semantic records, not only order" + fi + before="$(object_snapshot "$candidate")" + ! ableton_preferences_valid "$candidate" \ + || fail "$kind preferences pass exact-record validation" + [ "$(object_snapshot "$candidate")" = "$before" ] \ + || fail "validating $kind preferences mutates the candidate" +done +ok "PREF-VALIDATION: the exact header/key schema and direct safe object are mandatory" + +# PREF-APPLY-UNSAFE: each unsafe optional-store shape warns, leaves the entire +# object unchanged, uses compatibility defaults, and still honours valid +# nonempty one-run environment overrides. +for kind in future nul symlink directory incomplete; do + base="$(new_home "apply-$kind")" + write_fixture "$base" preserve 100 12 off balanced + file="$base/config/ableton-wine/preferences" + case "$kind" in + future) sed -i 's/^format=1$/format=2/' "$file" ;; + nul) printf '\0' >> "$file" ;; + symlink) + mv -- "$file" "$base/preferences-target" + ln -s -- "$base/preferences-target" "$file" + ;; + directory) rm -f -- "$file"; mkdir -- "$file" ;; + incomplete) sed -i '/^audio_threads=/d' "$file" ;; + esac + before="$(object_snapshot "$file")" + got="$(apply_preferences "$base" env ABLETON_SHORTCUTS=preserve \ + ABLETON_RT=off ABLETON_POWER=balanced 2>"$base/apply.err")" + [ "$got" = 'preserve|auto|auto|off|balanced' ] \ + || fail "$kind optional preferences do not combine safe defaults with environment intent" + grep -qF 'launcher preferences are malformed; using defaults' "$base/apply.err" \ + || fail "$kind optional preferences do not issue the fallback warning" + [ "$(object_snapshot "$file")" = "$before" ] \ + || fail "applying $kind optional preferences changed its type, link, mode, or bytes" +done +ok "PREF-APPLY-UNSAFE: unsafe optional stores warn, remain exact, and cannot block environment intent" + +# PREF-HOSTILE: values are parsed as inert data, never sourced as shell. Every +# field rejects values outside its documented grammar; launcher application +# falls back safely without changing or executing the record. +base="$(new_home hostile)" +write_fixture "$base" take auto auto auto performance +cp -- "$base/config/ableton-wine/preferences" "$base/valid-preferences" +valid="$base/valid-preferences" +sentinel="$base/should-not-exist" +for field_value in \ + 'shortcuts=other' 'dpi=125' 'audio_threads=64' 'rt=on' 'power=fast' \ + "shortcuts=\$(touch $sentinel)"; do + candidate="$base/hostile-preferences" + cp -- "$valid" "$candidate" + field="${field_value%%=*}" + sed -i "s#^$field=.*#$field_value#" "$candidate" + ! ableton_preferences_valid "$candidate" \ + || fail "invalid stored $field_value passes validation" + mkdir -p -- "$base/config/ableton-wine" + cp -- "$candidate" "$base/config/ableton-wine/preferences" + before="$(object_snapshot "$base/config/ableton-wine/preferences")" + [ "$(apply_preferences "$base" 2>"$base/hostile.err")" = \ + 'take|auto|auto|auto|performance' ] \ + || fail "invalid stored $field_value does not fall back safely" + [ "$(object_snapshot "$base/config/ableton-wine/preferences")" = "$before" ] \ + || fail "validating hostile $field changed its bytes" + [ ! -e "$sentinel" ] || fail "preferences were executed as shell code" +done +environment_sentinel="$base/environment-should-not-exist" +for assignment in ABLETON_SHORTCUTS=other ABLETON_DPI_MODE=125 \ + ABLETON_MAX_AUDIO_THREADS=64 ABLETON_RT=on \ + ABLETON_POWER=fast \ + "ABLETON_SHORTCUTS=\$(touch $environment_sentinel)"; do + before="$(object_snapshot "$base/config/ableton-wine/preferences")" + if apply_preferences "$base" env "$assignment" \ + >"$base/env-invalid.out" 2>"$base/env-invalid.err"; then + fail "invalid environment override succeeds: $assignment" + fi + [ "$(object_snapshot "$base/config/ableton-wine/preferences")" = "$before" ] \ + || fail "refusing environment override $assignment mutates preferences" +done +[ ! -e "$environment_sentinel" ] \ + || fail "an environment preference value was executed as shell code" +ok "PREF-HOSTILE: invalid stored/environment values are inert and fail closed" + +# PREF-WRITE: an absent target is written atomically with exact bytes and mode. +base="$(new_home write)" +file="$base/config/ableton-wine/preferences" +token="$(ableton_preferences_object_token "$file")" +[ "$token" = absent ] || fail "an absent preference target has the wrong token" +ableton_preferences_write "$file" "$token" preserve fractional 16 off balanced \ + || fail "the preference writer cannot create a new record" +cat > "$base/expected" <<'EOF' +# ableton-linux launcher preferences; managed by the installer +format=1 +shortcuts=preserve +dpi=fractional +audio_threads=16 +rt=off +power=balanced +EOF +cmp -s -- "$base/expected" "$file" || fail "the preference writer emitted unexpected bytes" +[ "$(stat -c '%a' "$file")" = 600 ] || fail "preferences are not private mode 0600" +! find "$(dirname "$file")" -maxdepth 1 -name '.preferences.*' | grep -q . \ + || fail "an atomic preference temporary file remains" + +# Rewriting an existing valid record normalises a permissive mode to 0600. +chmod 644 "$file" +token="$(ableton_preferences_object_token "$file")" +old_inode="$(stat -c '%d:%i' "$file")" +ableton_preferences_write "$file" "$token" take 100 4 auto performance \ + || fail "an existing valid preference record cannot be rewritten" +cat > "$base/rewrite-expected" <<'EOF' +# ableton-linux launcher preferences; managed by the installer +format=1 +shortcuts=take +dpi=100 +audio_threads=4 +rt=auto +power=performance +EOF +cmp -s -- "$base/rewrite-expected" "$file" \ + || fail "rewriting existing preferences emitted unexpected bytes" +[ "$(stat -c '%a' "$file")" = 600 ] \ + || fail "rewriting mode-0644 preferences does not normalise them to 0600" +[ "$(stat -c '%d:%i' "$file")" != "$old_inode" ] \ + || fail "successful preference publication rewrites the old inode in place" +ok "PREF-WRITE: preferences are exact, private, and atomically published" + +# PREF-PUBLISH-FAIL: a failure while creating the replacement must leave the +# previously published target exact. A zero file-size limit is inherited by +# the isolated writer and deterministically interrupts any nonempty publish. +base="$(new_home publish-failure)" +write_fixture "$base" preserve auto off auto balanced +file="$base/config/ableton-wine/preferences" +token="$(ableton_preferences_object_token "$file")" +before="$(object_snapshot "$file")" +if (ulimit -f 0; ableton_preferences_write "$file" "$token" \ + take fractional 16 off performance) \ + >"$base/publish.out" 2>"$base/publish.err"; then + fail "preference publication succeeds despite a forced write failure" +fi +[ "$(object_snapshot "$file")" = "$before" ] \ + || fail "failed publication changed the old target's type, mode, or bytes" +! find "$(dirname "$file")" -maxdepth 1 -name '.preferences.*' | grep -q . \ + || fail "failed publication leaves its private temporary object behind" +ok "PREF-PUBLISH-FAIL: interrupted publication preserves the exact old target" + +# PREF-RACE: a stale token and a symlink target both preserve the newer object. +base="$(new_home race)" +write_fixture "$base" take auto auto auto performance +file="$base/config/ableton-wine/preferences" +token="$(ableton_preferences_object_token "$file")" +sed -i 's/power=performance/power=off/' "$file" +before="$(object_snapshot "$file")" +! ableton_preferences_write "$file" "$token" preserve 100 4 auto balanced \ + || fail "a stale preference generation is overwritten" +[ "$(object_snapshot "$file")" = "$before" ] \ + || fail "a race refusal changed the preference type, mode, or bytes" +mv -- "$file" "$base/real-preferences" +ln -s -- "$base/real-preferences" "$file" +token="$(ableton_preferences_object_token "$file")" +before="$(object_snapshot "$file")" +! ableton_preferences_write "$file" "$token" take auto auto auto performance \ + || fail "the preference writer follows a symlink" +[ "$(object_snapshot "$file")" = "$before" ] \ + || fail "a symlink refusal changed its type, link, mode, or target bytes" +ok "PREF-RACE: generation changes and symlinks are preserved" + +# PREF-ABA: tokens bind the inspected object generation, not merely its final +# bytes or size. Same-byte inode replacement and an in-place change restored to +# identical bytes must both refuse later writer/remover operations. +base="$(new_home aba)" +write_fixture "$base" take auto auto auto performance +file="$base/config/ableton-wine/preferences" +token="$(ableton_preferences_object_token "$file")" +cp -p -- "$file" "$base/same-bytes" +mv -f -- "$base/same-bytes" "$file" +before="$(object_snapshot "$file")" +! ableton_preferences_write "$file" "$token" preserve 100 4 off balanced \ + || fail "same-byte inode replacement is accepted as the inspected generation" +[ "$(object_snapshot "$file")" = "$before" ] \ + || fail "refusing same-byte inode replacement changes the newer object" + +token="$(ableton_preferences_object_token "$file")" +original_inode="$(stat -c '%d:%i' "$file")" +original_mtime="$(stat -c '%y' "$file")" +cp -p -- "$file" "$base/aba-bytes" +printf 'temporarily different\n' > "$file" +cp -- "$base/aba-bytes" "$file" +touch -r "$base/aba-bytes" "$file" +[ "$(stat -c '%d:%i' "$file")" = "$original_inode" ] \ + || fail "same-inode ABA fixture replaced the inspected inode" +[ "$(stat -c '%y' "$file")" = "$original_mtime" ] \ + || fail "same-inode ABA fixture did not restore the inspected mtime" +cmp -s -- "$base/aba-bytes" "$file" \ + || fail "same-inode ABA fixture did not restore the inspected bytes" +before="$(object_snapshot "$file")" +! ableton_preferences_remove "$file" "$token" \ + || fail "same-inode ABA restoration is accepted as the inspected generation" +[ "$(object_snapshot "$file")" = "$before" ] \ + || fail "refusing same-inode ABA restoration changes the restored object" +ok "PREF-ABA: same-byte replacement and restored-byte ABA generations are rejected" + +# PREF-REMOVE: the removal helper uses the inspected format-1 record. It leaves +# a malformed or changed file in place for review. +base="$(new_home remove)" +write_fixture "$base" preserve auto off auto balanced +file="$base/config/ableton-wine/preferences" +token="$(ableton_preferences_object_token "$file")" +ableton_preferences_remove "$file" "$token" \ + || fail "the exact valid preferences record cannot be removed" +[ ! -e "$file" ] || fail "valid unchanged preferences survive their requested removal" +write_fixture "$base" preserve auto off auto balanced +token="$(ableton_preferences_object_token "$file")" +sed -i 's/power=balanced/power=off/' "$file" +before="$(object_snapshot "$file")" +! ableton_preferences_remove "$file" "$token" \ + || fail "a changed preferences record is removed" +[ "$(object_snapshot "$file")" = "$before" ] || fail "changed preferences were modified" +printf 'unknown=value\n' >> "$file" +token="$(ableton_preferences_object_token "$file")" +before="$(object_snapshot "$file")" +! ableton_preferences_remove "$file" "$token" \ + || fail "malformed preferences are removed" +[ "$(object_snapshot "$file")" = "$before" ] \ + || fail "malformed preferences do not remain byte-for-byte for inspection" +ok "PREF-REMOVE: the helper removes a valid unchanged preferences record" + +# BUFFER-READ: PipeASIO accepts any unique integer from 32 through 8192. The +# menu may offer fewer presets, but must retain an existing custom value. +base="$(new_home buffer-read)" +pipeasio="$base/config/pipeasio/config.ini" +mkdir -p -- "$(dirname "$pipeasio")" +for value in 32 64 128 883 1024 2048 8192; do + printf '[pipeasio]\ninputs = 2\nbuffer_size = %s\noutputs = 2\n' "$value" > "$pipeasio" + [ "$(ableton_pipeasio_buffer_read "$pipeasio")" = "$value" ] \ + || fail "PipeASIO buffer $value is not read exactly" +done +for value in 0 31 8193 text; do + printf '[pipeasio]\nbuffer_size = %s\n' "$value" > "$pipeasio" + before="$(object_snapshot "$pipeasio")" + ! ableton_pipeasio_buffer_read "$pipeasio" >/dev/null \ + || fail "invalid PipeASIO buffer $value is accepted" + [ "$(object_snapshot "$pipeasio")" = "$before" ] \ + || fail "reading invalid PipeASIO buffer $value mutates its object" +done +ok "BUFFER-READ: every valid existing PipeASIO buffer is retained, including custom values" + +# BUFFER-SECTIONS: buffer_size keys in unrelated sections neither compete with +# nor get rewritten in place of the unique key owned by [pipeasio]. +base="$(new_home buffer-sections)" +pipeasio="$base/config/pipeasio/config.ini" +mkdir -p -- "$(dirname "$pipeasio")" +cat > "$pipeasio" <<'EOF' +[other] +buffer_size = 4096 + +[pipeasio] +inputs = 4 +buffer_size = 256 + +[next] +buffer_size = 1024 +EOF +[ "$(ableton_pipeasio_buffer_read "$pipeasio")" = 256 ] \ + || fail "buffer keys in other sections make the unique PipeASIO key unreadable" +token="$(ableton_preferences_object_token "$pipeasio")" +ableton_pipeasio_buffer_write "$pipeasio" "$token" 512 \ + || fail "buffer keys in other sections make the unique PipeASIO key unwritable" +cat > "$base/sections-expected" <<'EOF' +[other] +buffer_size = 4096 + +[pipeasio] +inputs = 4 +buffer_size = 512 + +[next] +buffer_size = 1024 +EOF +cmp -s -- "$base/sections-expected" "$pipeasio" \ + || fail "the PipeASIO editor rewrites a competing key in another section" +ok "BUFFER-SECTIONS: only the buffer key inside the unique PipeASIO section is authoritative" + +# BUFFER-WRITE: only confirmed menu presets can be requested. Updating one key +# preserves all unrelated sections, comments, order and values. +base="$(new_home buffer-write)" +pipeasio="$base/config/pipeasio/config.ini" +mkdir -p -- "$(dirname "$pipeasio")" +cat > "$pipeasio" <<'EOF' +; user comment +[other] +value = untouched + +[pipeasio] +inputs = 8 +buffer_size = 2048 +outputs = 10 +auto_connect = false +EOF +chmod 644 "$pipeasio" +token="$(ableton_preferences_object_token "$pipeasio")" +ableton_pipeasio_buffer_write "$pipeasio" "$token" 512 \ + || fail "a uniquely parseable PipeASIO buffer cannot be updated" +cat > "$base/pipeasio-expected" <<'EOF' +; user comment +[other] +value = untouched + +[pipeasio] +inputs = 8 +buffer_size = 512 +outputs = 10 +auto_connect = false +EOF +cmp -s -- "$base/pipeasio-expected" "$pipeasio" \ + || fail "the buffer editor changed unrelated PipeASIO bytes" +[ "$(stat -c '%a' "$pipeasio")" = 600 ] || fail "the updated PipeASIO file is not mode 0600" +for value in 64 128 256 512 1024; do + token="$(ableton_preferences_object_token "$pipeasio")" + ableton_pipeasio_buffer_write "$pipeasio" "$token" "$value" \ + || fail "confirmed buffer preset $value is rejected" + [ "$(ableton_pipeasio_buffer_read "$pipeasio")" = "$value" ] \ + || fail "confirmed buffer preset $value is not actually written" +done +token="$(ableton_preferences_object_token "$pipeasio")" +before="$(object_snapshot "$pipeasio")" +! ableton_pipeasio_buffer_write "$pipeasio" "$token" 883 \ + || fail "a non-menu buffer can be newly requested" +[ "$(object_snapshot "$pipeasio")" = "$before" ] \ + || fail "refusing a non-menu buffer changed its type, mode, or bytes" +ok "BUFFER-WRITE: buffer presets update one key without disturbing the user's configuration" + +# A final line without LF is unrelated topology and remains byte-exact apart +# from the selected number. +base="$(new_home buffer-no-final-newline)" +pipeasio="$base/config/pipeasio/config.ini" +mkdir -p -- "$(dirname "$pipeasio")" +printf '[pipeasio]\nbuffer_size = 256' > "$pipeasio" +token="$(ableton_preferences_object_token "$pipeasio")" +ableton_pipeasio_buffer_write "$pipeasio" "$token" 512 \ + || fail "a PipeASIO file without final LF cannot be updated" +printf '[pipeasio]\nbuffer_size = 512' > "$base/no-final-newline.expected" +cmp -s -- "$base/no-final-newline.expected" "$pipeasio" \ + || fail "the buffer editor changes a missing final newline" +ok "BUFFER-EOF: buffer updates preserve the original final-newline state" + +# PIPEASIO-SEED-PROVENANCE: provisional producer identity is durable before +# publication, then upgraded to the exact destination generation. Failure in +# either phase removes only the producer; same-byte replacements are kept. +base="$(new_home pipeasio-seed-provenance)" +pipeasio="$base/config/pipeasio/config.ini" +txn="$base/txn" +mkdir -p -- "$(dirname "$pipeasio")" "$txn" +seed_tmp="$base/config/pipeasio/.config.ini.ABC123" +printf '[pipeasio]\nbuffer_size = 128\n' > "$seed_tmp" +chmod 600 "$seed_tmp" +producer_token="$(ableton_pipeasio_seed_identity_token "$seed_tmp")" +ableton_pipeasio_seed_record_publish "$txn" "$pipeasio" "$producer_token" "$seed_tmp" \ + || fail "producer identity cannot be journalled before publication" +[ ! -e "$pipeasio" ] && [ -f "$txn/pipeasio-seed.v1" ] \ + || fail "provisional provenance is not durable before destination publication" +provisional_journal_before="$(object_snapshot "$txn/pipeasio-seed.v1")" +provisional_temp_before="$(object_snapshot "$seed_tmp")" +! ableton_pipeasio_seed_record_commit_preflight "$txn" \ + || fail "commit preflight accepts rollback-only provisional provenance" +! ableton_pipeasio_seed_record_commit "$txn" \ + || fail "commit retires rollback-only provisional provenance" +[ "$(object_snapshot "$txn/pipeasio-seed.v1")" = "$provisional_journal_before" ] \ + && [ "$(object_snapshot "$seed_tmp")" = "$provisional_temp_before" ] \ + || fail "refused provisional commit changes its journal or producer temp" +ableton_pipeasio_seed_record_rollback "$txn" \ + || fail "a crash before destination rename cannot recover its producer temp" +[ ! -e "$pipeasio" ] && [ ! -e "$txn/pipeasio-seed.v1" ] \ + && [ ! -e "$seed_tmp" ] && [ ! -L "$seed_tmp" ] \ + || fail "before-rename rollback leaves destination, journal, or producer temp" + +printf '[pipeasio]\nbuffer_size = 128\n' > "$seed_tmp" +chmod 600 "$seed_tmp" +producer_token="$(ableton_pipeasio_seed_identity_token "$seed_tmp")" +ableton_pipeasio_seed_record_publish "$txn" "$pipeasio" "$producer_token" "$seed_tmp" \ + || fail "post-rename crash fixture cannot publish provisional provenance" +mv -T -n -- "$seed_tmp" "$pipeasio" +ableton_pipeasio_seed_record_rollback "$txn" \ + || fail "a crash after rename but before token promotion cannot recover" +[ ! -e "$pipeasio" ] && [ ! -e "$txn/pipeasio-seed.v1" ] \ + || fail "pre-token failure retains the producer generation or journal" + +# A same-value file that wins the destination before the producer rename has +# a different producer identity and must survive provisional rollback. +printf '[pipeasio]\nbuffer_size = 128\n' > "$seed_tmp" +chmod 600 "$seed_tmp" +producer_token="$(ableton_pipeasio_seed_identity_token "$seed_tmp")" +ableton_pipeasio_seed_record_publish "$txn" "$pipeasio" "$producer_token" "$seed_tmp" \ + || fail "pre-token replacement fixture cannot publish provisional provenance" +printf '[pipeasio]\nbuffer_size = 128\n' > "$pipeasio" +replacement_token="$(ableton_preferences_object_token "$pipeasio")" +ableton_pipeasio_seed_record_rollback "$txn" \ + || fail "provisional rollback rejects a raced destination" +[ -f "$pipeasio" ] && [ ! -e "$txn/pipeasio-seed.v1" ] \ + && [ ! -e "$seed_tmp" ] && [ ! -L "$seed_tmp" ] \ + && grep -qxF 'buffer_size = 128' "$pipeasio" \ + || fail "provisional rollback deletes a same-value destination it did not publish" +rm -f -- "$pipeasio" + +printf '[pipeasio]\nbuffer_size = 128\n' > "$seed_tmp" +chmod 600 "$seed_tmp" +producer_token="$(ableton_pipeasio_seed_identity_token "$seed_tmp")" +ableton_pipeasio_seed_record_publish "$txn" "$pipeasio" "$producer_token" "$seed_tmp" \ + || fail "normal promotion fixture cannot publish provisional provenance" +mv -T -n -- "$seed_tmp" "$pipeasio" +ableton_pipeasio_seed_record_promote "$txn" "$pipeasio" "$producer_token" \ + || fail "producer identity cannot be promoted to the full destination token" +ableton_pipeasio_seed_record_preflight "$txn" \ + || fail "a promoted production seed journal fails strict preflight" +seed_token="$(ableton_preferences_object_token "$pipeasio")" +recorded_token="$(sed -n 's/^token_b64=//p' "$txn/pipeasio-seed.v1" | base64 --decode)" +[ "$recorded_token" = "$seed_token" ] \ + || fail "promotion does not journal the full destination generation" +ableton_pipeasio_seed_record_rollback "$txn" \ + || fail "an unchanged fresh PipeASIO generation cannot be rolled back" +[ ! -e "$pipeasio" ] && [ ! -L "$pipeasio" ] \ + && [ ! -e "$txn/pipeasio-seed.v1" ] \ + || fail "after-seed failure retains the installer-created generation or journal" + +printf '[pipeasio]\nbuffer_size = 128\n' > "$pipeasio" +seed_token="$(ableton_preferences_object_token "$pipeasio")" +ableton_pipeasio_seed_record_publish "$txn" "$pipeasio" "$seed_token" \ + || fail "same-value replacement fixture cannot journal its initial generation" +printf '[pipeasio]\nbuffer_size = 128\n' > "$base/replacement" +mv -T -f -- "$base/replacement" "$pipeasio" +replacement_token="$(ableton_preferences_object_token "$pipeasio")" +[ "$replacement_token" != "$seed_token" ] \ + || fail "same-value replacement fixture did not change the full object token" +ableton_pipeasio_seed_record_rollback "$txn" \ + || fail "rollback rejects a user replacement of a seeded file" +[ -f "$pipeasio" ] && [ ! -L "$pipeasio" ] \ + && grep -qxF 'buffer_size = 128' "$pipeasio" \ + && [ ! -e "$txn/pipeasio-seed.v1" ] \ + || fail "rollback deletes a byte-identical replacement it does not own" + +rm -f -- "$pipeasio" +printf '[pipeasio]\nbuffer_size = 128\n' > "$pipeasio" +seed_token="$(ableton_preferences_object_token "$pipeasio")" +ableton_pipeasio_seed_record_publish "$txn" "$pipeasio" "$seed_token" \ + || fail "commit fixture cannot journal its fresh generation" +printf '[pipeasio]\nbuffer_size = 128\n' > "$base/commit-replacement" +mv -T -f -- "$base/commit-replacement" "$pipeasio" +replacement_token="$(ableton_preferences_object_token "$pipeasio")" +[ "$replacement_token" != "$seed_token" ] \ + || fail "commit replacement fixture did not change the full object token" +commit_replacement_before="$(object_snapshot "$pipeasio")" +ableton_pipeasio_seed_record_commit "$txn" \ + || fail "successful prefix commit cannot retire its seed journal" +[ "$(object_snapshot "$pipeasio")" = "$commit_replacement_before" ] \ + && [ ! -e "$txn/pipeasio-seed.v1" ] \ + || fail "successful commit changes settings or retains seed ownership" + +ln -s -- "$base/foreign-record" "$txn/pipeasio-seed.v1" +before="$(object_snapshot "$pipeasio")" +! ableton_pipeasio_seed_record_preflight "$txn" \ + || fail "a symlinked seed journal passes recovery preflight" +! ableton_pipeasio_seed_record_rollback "$txn" \ + || fail "rollback acts through an unsafe seed journal" +! ableton_pipeasio_seed_record_commit "$txn" \ + || fail "commit acts through an unsafe seed journal" +[ "$(object_snapshot "$pipeasio")" = "$before" ] \ + || fail "unsafe seed recovery data changes the current PipeASIO object" +rm -f -- "$txn/pipeasio-seed.v1" + +# Canonically encoded but structurally malformed records are as unsafe as +# links. Every recovery entry point must refuse them without retiring evidence. +path_b64="$(printf '%s' "$pipeasio" | base64 | tr -d '\n')" +full_token="$(ableton_preferences_object_token "$pipeasio")" +token_b64="$(printf '%s' "$full_token" | base64 | tr -d '\n')" +wrong_path_b64="$(printf '%s' "$base/config/elsewhere.ini" | base64 | tr -d '\n')" +garbage_token_b64="$(printf '%s' 'file|garbage' | base64 | tr -d '\n')" +producer_for_malformed="$(ableton_pipeasio_seed_identity_token "$pipeasio")" +producer_b64="$(printf '%s' "$producer_for_malformed" | base64 | tr -d '\n')" +valid_temp_b64="$(printf '%s' "$base/config/pipeasio/.config.ini.XYZ789" | base64 | tr -d '\n')" +wrong_temp_b64="$(printf '%s' "$base/config/pipeasio/not-a-seed" | base64 | tr -d '\n')" +foreign_temp="$base/config/foreign/.config.ini.QWE456" +mkdir -p -- "$(dirname "$foreign_temp")" +printf 'foreign temp sentinel\n' > "$foreign_temp" +foreign_temp_b64="$(printf '%s' "$foreign_temp" | base64 | tr -d '\n')" +malformed_records=( + $'format=2\npath_b64='"$path_b64"$'\ntoken_b64='"$token_b64"$'\ntemp_b64=' + $'format=1\npath64='"$path_b64"$'\ntoken_b64='"$token_b64"$'\ntemp_b64=' + $'format=1\npath_b64='"$path_b64"$'\ntoken_b64='"$token_b64" + $'format=1\npath_b64=%%%\ntoken_b64='"$token_b64"$'\ntemp_b64=' + $'format=1\npath_b64=Zg\ntoken_b64='"$token_b64"$'\ntemp_b64=' + $'format=1\npath_b64='"$wrong_path_b64"$'\ntoken_b64='"$token_b64"$'\ntemp_b64=' + $'format=1\npath_b64='"$path_b64"$'\ntoken_b64='"$garbage_token_b64"$'\ntemp_b64=' + $'format=1\npath_b64='"$path_b64"$'\ntoken_b64=ZmlsZXwx\ntemp_b64=' + $'format=1\npath_b64='"$path_b64"$'\ntoken_b64='"$token_b64"$'\ntemp_b64='"$valid_temp_b64" + $'format=1\npath_b64='"$path_b64"$'\ntoken_b64='"$producer_b64"$'\ntemp_b64=' + $'format=1\npath_b64='"$path_b64"$'\ntoken_b64='"$producer_b64"$'\ntemp_b64='"$wrong_temp_b64" + $'format=1\npath_b64='"$path_b64"$'\ntoken_b64='"$producer_b64"$'\ntemp_b64='"$foreign_temp_b64" +) +assert_malformed_seed_record_preserved() +{ + local label="$1" journal_before config_before foreign_temp_before + journal_before="$(object_snapshot "$txn/pipeasio-seed.v1")" + config_before="$(object_snapshot "$pipeasio")" + foreign_temp_before="$(object_snapshot "$foreign_temp")" + ! ableton_pipeasio_seed_record_preflight "$txn" \ + || fail "$label passes preflight" + ! ableton_pipeasio_seed_record_commit_preflight "$txn" \ + || fail "$label passes commit preflight" + ! ableton_pipeasio_seed_record_rollback "$txn" \ + || fail "$label passes rollback" + ! ableton_pipeasio_seed_record_commit "$txn" \ + || fail "$label passes commit" + [ "$(object_snapshot "$txn/pipeasio-seed.v1")" = "$journal_before" ] \ + && [ "$(object_snapshot "$pipeasio")" = "$config_before" ] \ + && [ "$(object_snapshot "$foreign_temp")" = "$foreign_temp_before" ] \ + || fail "$label changes evidence or settings" +} +malformed_index=0 +for malformed_record in "${malformed_records[@]}"; do + malformed_index=$((malformed_index + 1)) + printf '%s\n' "$malformed_record" > "$txn/pipeasio-seed.v1" + assert_malformed_seed_record_preserved \ + "malformed regular journal $malformed_index" +done + +rm -f -- "$txn/pipeasio-seed.v1" +full_token="$(ableton_preferences_object_token "$pipeasio")" +ableton_pipeasio_seed_record_publish "$txn" "$pipeasio" "$full_token" \ + || fail "missing-terminal-LF fixture cannot publish a canonical journal" +truncate -s -1 -- "$txn/pipeasio-seed.v1" +assert_malformed_seed_record_preserved \ + "otherwise canonical journal without a terminal LF" + +rm -f -- "$txn/pipeasio-seed.v1" +ableton_pipeasio_seed_record_publish "$txn" "$pipeasio" "$full_token" \ + || fail "fifth-line fixture cannot publish a canonical journal" +printf 'extra=1\n' >> "$txn/pipeasio-seed.v1" +assert_malformed_seed_record_preserved "canonical journal with a fifth line" +ok "PIPEASIO-SEED-PROVENANCE: rollback uses durable full-generation ownership" + +# BUFFER-MISSING: a unique [pipeasio] section without a buffer gains one at the +# end of that section, before the following section. +base="$(new_home buffer-missing)" +pipeasio="$base/config/pipeasio/config.ini" +mkdir -p -- "$(dirname "$pipeasio")" +printf '[other]\nbuffer_size = 2048\n\n[pipeasio]\ninputs = 2\n\n[next]\nbuffer_size = 4096\n' > "$pipeasio" +! ableton_pipeasio_buffer_read "$pipeasio" >/dev/null \ + || fail "a buffer outside the PipeASIO section masks its missing key" +token="$(ableton_preferences_object_token "$pipeasio")" +ableton_pipeasio_buffer_write "$pipeasio" "$token" 128 \ + || fail "a unique PipeASIO section cannot gain a missing buffer" +cat > "$base/missing-expected" <<'EOF' +[other] +buffer_size = 2048 + +[pipeasio] +inputs = 2 + +buffer_size = 128 +[next] +buffer_size = 4096 +EOF +cmp -s -- "$base/missing-expected" "$pipeasio" \ + || fail "missing buffer insertion changes unrelated bytes or leaves its section" +[ "$(ableton_pipeasio_buffer_read "$pipeasio")" = 128 ] \ + || fail "the newly inserted PipeASIO buffer is not authoritative" +ok "BUFFER-MISSING: a unique section gains its own buffer despite unrelated keys elsewhere" + +# BUFFER-NO-SECTION: a config with no [pipeasio] section is foreign topology; +# the editor must not create, guess at, or change it. +base="$(new_home buffer-no-section)" +pipeasio="$base/config/pipeasio/config.ini" +mkdir -p -- "$(dirname "$pipeasio")" +printf '; user topology\n[other]\nbuffer_size = 2048\n' > "$pipeasio" +chmod 640 "$pipeasio" +token="$(ableton_preferences_object_token "$pipeasio")" +before="$(object_snapshot "$pipeasio")" +! ableton_pipeasio_buffer_read "$pipeasio" >/dev/null \ + || fail "a buffer outside any PipeASIO section is accepted by the reader" +! ableton_pipeasio_buffer_write "$pipeasio" "$token" 128 \ + || fail "the editor invents an absent PipeASIO section" +[ "$(object_snapshot "$pipeasio")" = "$before" ] \ + || fail "refusing an absent PipeASIO section changed its type, mode, or bytes" +ok "BUFFER-NO-SECTION: configurations without PipeASIO topology remain exact" + +# BUFFER-AMBIGUOUS: duplicate keys/sections, binary data, symlinks, directories, +# stale generations and files that change during the operation are untouched. +base="$(new_home buffer-ambiguous)" +mkdir -p -- "$base/config/pipeasio" +for kind in duplicate-key duplicate-section nul symlink directory stale; do + pipeasio="$base/config/pipeasio/$kind.ini" + printf '[pipeasio]\nbuffer_size = 256\n' > "$pipeasio" + case "$kind" in + duplicate-key) printf 'buffer_size = 512\n' >> "$pipeasio" ;; + duplicate-section) printf '[pipeasio]\ninputs = 4\n' >> "$pipeasio" ;; + nul) printf '\0' >> "$pipeasio" ;; + symlink) mv -- "$pipeasio" "$pipeasio.real"; ln -s -- "$pipeasio.real" "$pipeasio" ;; + directory) rm -f -- "$pipeasio"; mkdir -- "$pipeasio" ;; + esac + token="$(ableton_preferences_object_token "$pipeasio")" + if [ "$kind" = stale ]; then printf 'outputs = 4\n' >> "$pipeasio"; fi + before="$(object_snapshot "$pipeasio")" + if [ "$kind" = stale ]; then + [ "$(ableton_pipeasio_buffer_read "$pipeasio")" = 256 ] \ + || fail "a valid file changed after token capture is unreadable" + else + ! ableton_pipeasio_buffer_read "$pipeasio" >/dev/null \ + || fail "$kind PipeASIO configuration is accepted by the reader" + fi + ! ableton_pipeasio_buffer_write "$pipeasio" "$token" 128 \ + || fail "$kind PipeASIO configuration is overwritten" + [ "$(object_snapshot "$pipeasio")" = "$before" ] \ + || fail "$kind refusal changed PipeASIO type, link, mode, or bytes" +done +pipeasio="$base/config/pipeasio/hostile.ini" +sentinel="$base/pipeasio-should-not-exist" +# shellcheck disable=SC2016 # literal hostile data must not be expanded by this shell +printf '[pipeasio]\nbuffer_size = $(touch %s)\n' "$sentinel" > "$pipeasio" +before="$(object_snapshot "$pipeasio")" +! ableton_pipeasio_buffer_read "$pipeasio" >/dev/null \ + || fail "hostile PipeASIO buffer text is accepted" +[ "$(object_snapshot "$pipeasio")" = "$before" ] \ + || fail "reading hostile PipeASIO data changed its type, mode, or bytes" +[ ! -e "$sentinel" ] || fail "PipeASIO configuration was executed as shell code" +ok "BUFFER-AMBIGUOUS: unsafe, ambiguous and raced PipeASIO objects fail closed" + +printf '%s preference checks passed\n' "$pass" diff --git a/scripts/test-project-file-installer.sh b/scripts/test-project-file-installer.sh new file mode 100755 index 00000000..da4d460f --- /dev/null +++ b/scripts/test-project-file-installer.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +# The project-file copy loop: fixed destinations, one dated backup directory +# per run, and one question per run when a destination already exists. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/ableton-project-files-test.XXXXXX")" +trap 'rm -rf -- "$tmp"' EXIT + +fail() +{ + echo "not ok - $*" >&2 + exit 1 +} + +ok() +{ + echo "ok - $*" +} + +mkdir -p -- "$tmp/work" +mkdir -p -- "$tmp/home" "$tmp/config" "$tmp/data" "$tmp/cache" "$tmp/run" +cp -- "$here/lib/config.sh" "$tmp/work/new-a" +cp -- "$here/lib/pipeasio.sh" "$tmp/work/new-b" +cp -- "$here/lib/lifecycle.sh" "$tmp/work/old" +question='│ ├─ FILES FROM AN EARLIER INSTALLATION ALREADY EXIST' + +# Every case runs the copy loop the way install.sh does: the renderer and the +# file helpers are sourced, a step is open, and the answers arrive on stdin. +run_loop() +{ + env -i PATH="$PATH" HOME="$tmp/home" TMPDIR="$tmp" \ + LANG=C.UTF-8 LC_ALL=C.UTF-8 \ + XDG_CONFIG_HOME="$tmp/config" XDG_DATA_HOME="$tmp/data" \ + XDG_CACHE_HOME="$tmp/cache" XDG_RUNTIME_DIR="$tmp/run" \ + ABLETON_UI_ACTION=install ABLETON_UI_KIT=1 TERM=xterm \ + ABLETON_SIMPLE_PROJECT_FILES=1 \ + ABLETON_UI_PROMPT_TIMEOUT="${PROMPT_TIMEOUT:-30}" "$@" bash -c ' + . "$1/lib/ui.sh" + . "$1/lib/manifest.sh" + ui_step_begin s_launchers + shift 2 + eval "$@" + ' _ "$here" "$tmp" "${LOOP_BODY:?}" +} + +# --yes overwrites every existing destination into one run directory and +# never asks. +cp -- "$tmp/work/old" "$tmp/work/a" +cp -- "$tmp/work/old" "$tmp/work/b" +LOOP_BODY=' + ableton_install_project_file 644 "$tmp/work/new-a" "$tmp/work/a" + ableton_install_project_file 644 "$tmp/work/new-b" "$tmp/work/b" + test "$ABLETON_OPTIONAL_FILE_FAILURES" -eq 0 +' run_loop ABLETON_STATE_HOME="$tmp/state-overwrite" ABLETON_PROJECT_ASSUME_YES=1 tmp="$tmp" \ + > "$tmp/yes.out" 2>&1 || fail "--yes did not complete both copies" +cmp -s -- "$tmp/work/new-a" "$tmp/work/a" || fail "first overwrite was not installed" +cmp -s -- "$tmp/work/new-b" "$tmp/work/b" || fail "second overwrite was not installed" +! grep -qF "$question" "$tmp/yes.out" || fail "--yes asked the overwrite question" +mapfile -t overwrite_runs < <(find "$tmp/state-overwrite/backups" -mindepth 1 -maxdepth 1 -type d) +[ "${#overwrite_runs[@]}" -eq 1 ] || fail "overwrites did not share one run backup directory" +backup_a="$(find "${overwrite_runs[0]}" -type f -name 'a.bak-*' -print -quit)" +backup_b="$(find "${overwrite_runs[0]}" -type f -name 'b.bak-*' -print -quit)" +[ -n "$backup_a" ] && [ -n "$backup_b" ] || fail "dated overwrite backups are missing" +cmp -s -- "$tmp/work/old" "$backup_a" || fail "first backup changed" +cmp -s -- "$tmp/work/old" "$backup_b" || fail "second backup changed" +[ ! -e "$tmp/state-overwrite/install-manifest.tsv" ] \ + && [ ! -e "$tmp/state-overwrite/install-prestate.tsv" ] \ + || fail "simple overwrites created manifest or prestate records" +ok "overwrites use one inert per-run backup directory" + +# The question is asked once, as a QUESTION block in the open step, with +# Overwrite all as the default. Enter, EOF, and a timeout all pick it. +overwrite_case() +{ + local name="$1" feed="$2" + cp -- "$tmp/work/old" "$tmp/work/$name-a" + cp -- "$tmp/work/old" "$tmp/work/$name-b" + LOOP_BODY=' + ableton_install_project_file 644 "$tmp/work/new-a" "$tmp/work/'"$name"'-a" + ableton_install_project_file 644 "$tmp/work/new-b" "$tmp/work/'"$name"'-b" + test "$ABLETON_OPTIONAL_FILE_FAILURES" -eq 0 + test "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 0 + ' run_loop ABLETON_STATE_HOME="$tmp/state-$name" tmp="$tmp" \ + < <(eval "$feed") > "$tmp/$name.out" 2>&1 || fail "$name did not complete both copies" + cmp -s -- "$tmp/work/new-a" "$tmp/work/$name-a" || fail "$name did not replace its first destination" + cmp -s -- "$tmp/work/new-b" "$tmp/work/$name-b" || fail "$name did not replace a remaining destination" + [ "$(grep -cF "$question" "$tmp/$name.out")" -eq 1 ] || fail "$name asked more or less than one question" + grep -qF '│ │ > [O]verwrite all (Default)' "$tmp/$name.out" \ + && grep -qF '│ │ > [K]eep originals' "$tmp/$name.out" \ + && grep -qF '│ │ > [A]bort' "$tmp/$name.out" \ + || fail "$name did not offer the three answers" + grep -q '^│ │ 🛈 Existing files will be moved to .*backups/.* before they are replaced\.$' "$tmp/$name.out" \ + || fail "$name did not name the backup directory after the answer" + [ "$(find "$tmp/state-$name/backups" -type f -name "$name-*.bak-*" | wc -l)" -eq 2 ] \ + || fail "$name did not preserve both displaced files" +} +overwrite_case enter "printf '\n'" +overwrite_case letter "printf 'o\n'" +overwrite_case word "printf 'Overwrite all\n'" +overwrite_case eof ":" +PROMPT_TIMEOUT=1 overwrite_case timeout "sleep 4" +grep -qF '(Press Enter for default or wait 1 seconds)' "$tmp/timeout.out" \ + || fail "the question names its timeout" +ok "one Overwrite all question covers the run; Enter, EOF, and the timeout choose it" + +# Overwrite all remains selected when the coordinator moves to its next child. +mkdir -p -- "$tmp/state-cross/backups/run" +cp -- "$tmp/work/old" "$tmp/work/cross-a" +cp -- "$tmp/work/old" "$tmp/work/cross-b" +LOOP_BODY='ableton_install_project_file 644 "$tmp/work/new-a" "$tmp/work/cross-a"' \ + run_loop ABLETON_STATE_HOME="$tmp/state-cross" \ + ABLETON_PROJECT_BACKUP_DIR="$tmp/state-cross/backups/run" \ + ABLETON_PROJECT_BACKUP_STAMP=20260828 tmp="$tmp" \ + < <(printf 'o\n') > "$tmp/cross.out" 2>&1 || fail "Overwrite all was not recorded for the installer run" +LOOP_BODY='ableton_install_project_file 644 "$tmp/work/new-b" "$tmp/work/cross-b"' \ + run_loop ABLETON_STATE_HOME="$tmp/state-cross" \ + ABLETON_PROJECT_BACKUP_DIR="$tmp/state-cross/backups/run" \ + ABLETON_PROJECT_BACKUP_STAMP=20260828 tmp="$tmp" \ + < /dev/null >> "$tmp/cross.out" 2>&1 || fail "Overwrite all did not reach the next installer process" +[ "$(grep -cF "$question" "$tmp/cross.out")" -eq 1 ] \ + || fail "Overwrite all prompted again after an installer process boundary" +cmp -s -- "$tmp/work/new-a" "$tmp/work/cross-a" \ + && cmp -s -- "$tmp/work/new-b" "$tmp/work/cross-b" \ + || fail "cross-process Overwrite all did not replace both destinations" +ok "Overwrite all applies to the whole installer run" + +# A failed cross-process marker is an installer error, not a user cancellation. +printf 'not a directory\n' > "$tmp/bad-overwrite-state" +cp -- "$tmp/work/old" "$tmp/work/all-state-fail" +rm -f -- "$tmp/work/after-all-state-fail" +LOOP_BODY=' + ableton_install_project_file 644 "$tmp/work/new-a" "$tmp/work/all-state-fail" + ableton_install_project_file 644 "$tmp/work/new-b" "$tmp/work/after-all-state-fail" + test "$ABLETON_OPTIONAL_FILE_FAILURES" -eq 1 + test "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 0 +' run_loop ABLETON_STATE_HOME="$tmp/state-all-state-fail" \ + ABLETON_PROJECT_BACKUP_DIR="$tmp/bad-overwrite-state" \ + ABLETON_PROJECT_BACKUP_STAMP=20260828 tmp="$tmp" \ + < <(printf 'o\n') > "$tmp/all-state-fail.out" 2>&1 \ + || fail "Overwrite all state failure was not reported as a file error" +cmp -s -- "$tmp/work/old" "$tmp/work/all-state-fail" \ + || fail "Overwrite all state failure changed its destination" +cmp -s -- "$tmp/work/new-b" "$tmp/work/after-all-state-fail" \ + || fail "Overwrite all state failure stopped later independent copies" +grep -qF 'This file was left unchanged.' "$tmp/all-state-fail.out" \ + || fail "Overwrite all state failure omitted its exact outcome" +ok "Overwrite all state failures are errors, not cancellation" + +# Keep originals keeps every existing destination for the rest of the run, +# across processes, and still writes where nothing exists. +cp -- "$tmp/work/old" "$tmp/work/keep-a" +cp -- "$tmp/work/old" "$tmp/work/keep-b" +rm -f -- "$tmp/work/keep-new" +mkdir -p -- "$tmp/state-keep/backups/run" +LOOP_BODY=' + ableton_install_project_file 644 "$tmp/work/new-a" "$tmp/work/keep-a" + ableton_install_project_file 644 "$tmp/work/new-b" "$tmp/work/keep-b" + ableton_install_project_file 644 "$tmp/work/new-b" "$tmp/work/keep-new" + test "$ABLETON_OPTIONAL_FILES_KEPT" -eq 2 +' run_loop ABLETON_STATE_HOME="$tmp/state-keep" \ + ABLETON_PROJECT_BACKUP_DIR="$tmp/state-keep/backups/run" \ + ABLETON_PROJECT_BACKUP_STAMP=20260828 tmp="$tmp" \ + < <(printf 'Keep\n') > "$tmp/keep.out" 2>&1 || fail "Keep stopped the mapping loop" +[ "$(grep -cF "$question" "$tmp/keep.out")" -eq 1 ] || fail "Keep asked more than once" +grep -qF '│ │ 🛈 Existing files are kept unchanged; new files are written only where nothing exists.' "$tmp/keep.out" \ + || fail "Keep did not explain itself" +cmp -s -- "$tmp/work/old" "$tmp/work/keep-a" && cmp -s -- "$tmp/work/old" "$tmp/work/keep-b" \ + || fail "Keep changed an existing destination" +cmp -s -- "$tmp/work/new-b" "$tmp/work/keep-new" || fail "Keep prevented a copy to an empty destination" +grep -qF 'Kept existing file unchanged' "$tmp/keep.out" || fail "kept files are reported" +cp -- "$tmp/work/old" "$tmp/work/keep-c" +LOOP_BODY=' + ableton_install_project_file 644 "$tmp/work/new-a" "$tmp/work/keep-c" + test "$ABLETON_OPTIONAL_FILES_KEPT" -eq 1 +' run_loop ABLETON_STATE_HOME="$tmp/state-keep" \ + ABLETON_PROJECT_BACKUP_DIR="$tmp/state-keep/backups/run" \ + ABLETON_PROJECT_BACKUP_STAMP=20260828 tmp="$tmp" \ + < /dev/null > "$tmp/keep-cross.out" 2>&1 || fail "Keep did not reach the next installer process" +! grep -qF "$question" "$tmp/keep-cross.out" || fail "Keep prompted again after a process boundary" +cmp -s -- "$tmp/work/old" "$tmp/work/keep-c" || fail "cross-process Keep changed a destination" +ok "Keep originals applies to the whole installer run" + +# Abort keeps the current destination and stops all later mappings without +# unwinding what was already copied. +rm -f -- "$tmp/work/before-abort" "$tmp/work/after-abort" +cp -- "$tmp/work/old" "$tmp/work/abort-target" +LOOP_BODY=' + ableton_install_project_file 644 "$tmp/work/new-a" "$tmp/work/before-abort" + ableton_install_project_file 644 "$tmp/work/new-a" "$tmp/work/abort-target" + ableton_install_project_file 644 "$tmp/work/new-b" "$tmp/work/after-abort" + test "$ABLETON_OPTIONAL_FILE_CANCELLED" -eq 1 +' run_loop ABLETON_STATE_HOME="$tmp/state-abort" tmp="$tmp" \ + < <(printf 'a\n') > "$tmp/abort.out" 2>&1 || fail "Abort state was not retained" +grep -qF '│ │ 🛈 Stopping before any existing file is replaced.' "$tmp/abort.out" \ + || fail "Abort did not explain itself" +cmp -s -- "$tmp/work/new-a" "$tmp/work/before-abort" || fail "Abort unwound an earlier completed copy" +cmp -s -- "$tmp/work/old" "$tmp/work/abort-target" || fail "Abort changed its current destination" +[ ! -e "$tmp/work/after-abort" ] || fail "Abort did not stop later mappings" +ok "Abort stops later mappings without unwinding completed copies" + +# An unknown answer is asked again; a whole word maps to its key letter. +cp -- "$tmp/work/old" "$tmp/work/retry-target" +LOOP_BODY=' + ableton_install_project_file 644 "$tmp/work/new-a" "$tmp/work/retry-target" + test "$ABLETON_OPTIONAL_FILES_KEPT" -eq 1 +' run_loop ABLETON_STATE_HOME="$tmp/state-retry" tmp="$tmp" \ + < <(printf 'maybe\nkeep originals\n') > "$tmp/retry.out" 2>&1 || fail "an invalid answer stopped the loop" +cmp -s -- "$tmp/work/old" "$tmp/work/retry-target" || fail "the retried answer was not honoured" +[ "$(grep -c 'Please choose \[O/K/A\]:' "$tmp/retry.out")" -eq 2 ] \ + || fail "an unknown answer repeats the prompt once" +ok "an unknown answer is asked again" + +# A backup failure leaves its destination untouched and later copies continue. +cp -- "$tmp/work/old" "$tmp/work/backup-fail" +rm -f -- "$tmp/work/after-backup-fail" +LOOP_BODY=' + ableton_install_project_file 644 "$tmp/work/new-a" "$tmp/work/backup-fail" + ableton_install_project_file 644 "$tmp/work/new-b" "$tmp/work/after-backup-fail" + test "$ABLETON_OPTIONAL_FILE_FAILURES" -eq 1 +' run_loop ABLETON_STATE_HOME="$tmp/state-backup-fail" \ + ABLETON_PROJECT_BACKUP_DIR=/proc/ableton-project-file-test \ + ABLETON_PROJECT_BACKUP_STAMP=20260827 ABLETON_PROJECT_ASSUME_YES=1 tmp="$tmp" \ + > "$tmp/backup-fail.out" 2>&1 || fail "a backup failure was not counted as one failure" +cmp -s -- "$tmp/work/old" "$tmp/work/backup-fail" || fail "backup failure changed its destination" +cmp -s -- "$tmp/work/new-b" "$tmp/work/after-backup-fail" || fail "backup failure prevented a later copy" +grep -qF "$tmp/work/backup-fail" "$tmp/backup-fail.out" || fail "backup failure did not report its destination" +ok "backup failure leaves the destination untouched and continues" + +# Make the source and destination the same to force cp to fail only after mv. +cp -- "$tmp/work/old" "$tmp/work/copy-fail" +rm -f -- "$tmp/work/after-copy-fail" +LOOP_BODY=' + ableton_install_project_file 644 "$tmp/work/copy-fail" "$tmp/work/copy-fail" + ableton_install_project_file 644 "$tmp/work/new-b" "$tmp/work/after-copy-fail" + test "$ABLETON_OPTIONAL_FILE_FAILURES" -eq 1 +' run_loop ABLETON_STATE_HOME="$tmp/state-copy-fail" ABLETON_PROJECT_ASSUME_YES=1 tmp="$tmp" \ + > "$tmp/copy-fail.out" 2>&1 || fail "a copy failure was not counted as one failure" +[ ! -e "$tmp/work/copy-fail" ] || fail "copy failure restored its destination" +copy_backup="$(find "$tmp/state-copy-fail/backups" -type f -name 'copy-fail.bak-*' -print -quit)" +[ -n "$copy_backup" ] || fail "copy failure lost the inert backup" +cmp -s -- "$tmp/work/old" "$copy_backup" || fail "copy failure backup changed" +cmp -s -- "$tmp/work/new-b" "$tmp/work/after-copy-fail" || fail "copy failure prevented a later copy" +grep -qF "$tmp/work/copy-fail" "$tmp/copy-fail.out" || fail "copy failure did not report the actual path" +ok "copy failure is reported, left for manual recovery, and continues" + +# Publication replaces every historical runtime row with the configured +# runtime while preserving valid non-runtime rows this invocation did not touch. +publication="$tmp/publication" +mkdir -p -- "$publication/home" "$publication/config" "$publication/data" \ + "$publication/state" "$publication/cache" "$publication/run" \ + "$publication/runtime-current" +env -i PATH="$PATH" HOME="$publication/home" TMPDIR="$tmp" \ + XDG_CONFIG_HOME="$publication/config" XDG_DATA_HOME="$publication/data" \ + XDG_STATE_HOME="$publication/state" \ + XDG_CACHE_HOME="$publication/cache" XDG_RUNTIME_DIR="$publication/run" \ + ABLETON_WINE_ROOT="$publication/runtime-current" \ + bash -c ' + set -euo pipefail + . "$1/lib/config.sh" + . "$1/lib/manifest.sh" + ableton_config_init repair + ableton_mark_state_home + + untouched="$ABLETON_DATA_HOME/detect-scale.sh" + changed_path="$ABLETON_DATA_HOME/check-ntsync.sh" + added="$ABLETON_DATA_HOME/lib/config.sh" + outside="$HOME/not-an-ableton-output" + mkdir -p -- "$(dirname "$added")" + printf "untouched generation\n" > "$untouched" + printf "changed after an earlier installed-file list\n" > "$changed_path" + printf "new generation\n" > "$added" + printf "foreign\n" > "$outside" + untouched_digest="$(sha256sum -- "$untouched")" + untouched_digest="${untouched_digest%% *}" + cat > "$ABLETON_STATE_HOME/install-manifest.tsv" <&2 + exit 91 + fi + ableton_record_owned "$added" file + ABLETON_RUNTIME_INSTALLED=1 + ableton_write_ownership_manifest + ableton_validate_ownership_manifest + ' _ "$here" > "$publication/out" 2> "$publication/err" \ + || { sed -n '1,40p' "$publication/err" >&2; fail "ownership manifest publication failed"; } +publication_manifest="$publication/state/ableton-wine/install-manifest.tsv" +[ -f "$publication_manifest" ] || fail "ownership manifest was not published" +untouched="$publication/data/ableton-wine/detect-scale.sh" +added="$publication/data/ableton-wine/lib/config.sh" +changed_path="$publication/data/ableton-wine/check-ntsync.sh" +untouched_digest="$(sha256sum -- "$untouched" | awk '{print $1}')" +added_digest="$(sha256sum -- "$added" | awk '{print $1}')" +grep -qxF "$(printf 'file\t%s\t%s' "$untouched" "$untouched_digest")" \ + "$publication_manifest" \ + || fail "publication dropped an untouched allowed non-runtime row" +grep -qxF "$(printf 'file\t%s\t%s' "$changed_path" \ + 0000000000000000000000000000000000000000000000000000000000000000)" \ + "$publication_manifest" \ + || fail "publication replaced the recorded digest for a file left untouched by the current install" +grep -qxF "$(printf 'file\t%s\t%s' "$added" "$added_digest")" \ + "$publication_manifest" \ + || fail "publication omitted the newly owned file" +[ "$(awk -F '\t' '$1=="runtime" { n++ } END { print n+0 }' \ + "$publication_manifest")" -eq 1 ] \ + || fail "publication did not reduce runtime ownership to one row" +awk -F '\t' -v p="$publication/runtime-current" \ + '$1=="runtime" && $2==p && NF==3 { found=1 } END { exit !found }' \ + "$publication_manifest" \ + || fail "publication did not record the configured runtime" +! grep -qF "$publication/home/stale-runtime-" "$publication_manifest" \ + || fail "publication retained a stale runtime row" +ok "publication keeps recorded digests for untouched files and replaces old runtime rows" + +echo "All project-file installer tests passed." diff --git a/scripts/test-release-policy.sh b/scripts/test-release-policy.sh index 7657d65b..0531a01e 100755 --- a/scripts/test-release-policy.sh +++ b/scripts/test-release-policy.sh @@ -25,6 +25,42 @@ fail() exit 1 } +python3 - "$root" <<'PY' \ + || fail 'installer source allowlist omits scripts/lib/ui.sh mode 0644' +from pathlib import Path +from types import SimpleNamespace +import runpy +import sys + +root = Path(sys.argv[1]) +module = runpy.run_path(str(root / "scripts/verify-installer-payload.py")) +globals_ = module["expected_from_source"].__globals__ +real_add_source = globals_["add_source"] +globals_["add_source"] = lambda expected, source, destination, mode: ( + real_add_source(expected, source, destination, mode) + if destination == "scripts/lib/ui.sh" + else None +) +globals_["tracked_paths"] = lambda *args: [] +globals_["subprocess"].run = lambda *args, **kwargs: SimpleNamespace(stdout=b"") +expected = module["expected_from_source"](root, root / "BUILD-INFO", root / "runtime.tar.zst") +entry = expected["scripts/lib/ui.sh"] +assert entry.kind == "file" +assert entry.mode == 0o644 +assert entry.data == (root / "scripts/lib/ui.sh").read_bytes() +PY +printf 'ok - installer source allowlist includes the shared UI library\n' + +version="$(cat "$root/VERSION")" +first_changelog_heading="$(grep -m1 '^## ' "$root/CHANGELOG.md" || true)" +[ "$first_changelog_heading" = "## $version" ] \ + && [ "$(grep -cxF "## $version" "$root/CHANGELOG.md")" -eq 1 ] \ + || fail 'CHANGELOG does not start with exactly one heading for VERSION' +if grep -qE '[*][*]~~.*[*][*].*~~' "$root/CHANGELOG.md"; then + fail 'CHANGELOG contains crossed bold and strikethrough markers' +fi +printf 'ok - CHANGELOG names this release and nests emphasis correctly\n' + accepts() { bash "$checker" "$1" >/dev/null 2>&1 @@ -153,8 +189,7 @@ seal_kit() tar --sort=name --owner=0 --group=0 --numeric-owner \ -cf "$payload" -C "$source_kit" . digest="$(sha256sum "$payload" | awk '{print $1}')" - sed -e 's/@VERSION@/2026.08.12.1/g' -e "s/@PAYLOAD_SHA@/$digest/g" \ - "$root/scripts/setup-run-header.sh" > "$wrapper" + "$root/scripts/make-installer.sh" --render-header --version 2026.08.12.1 --payload-sha $digest > "$wrapper" cat "$payload" >> "$wrapper" chmod +x "$wrapper" } @@ -187,10 +222,15 @@ cleanup_rc=$? set -e [ "$cleanup_rc" -eq 0 ] \ || fail "installer wrapper changed child status 0 to $cleanup_rc after scratch cleanup failed" -grep -qF 'installer child reached status 0' "$tmp/cleanup-zero.out" \ +# The wrapper keeps the screen for the renderer; what the child and the +# wrapper print raw goes to the log beside the .run (rule D1). +newest_log() { ls -t "$tmp"/ableton-linux-installer-*.log 2>/dev/null | head -n 1; } +grep -qF 'installer child reached status 0' "$(newest_log)" \ || fail 'installer wrapper did not reach its child after early scratch cleanup failed' -grep -qF '!! The installer finished, but temporary files remain at' "$tmp/cleanup-zero.out" \ +grep -qF 'The installer finished, but temporary files remain at' "$(newest_log)" \ || fail 'installer wrapper did not name retained scratch after cleanup failed' +grep -q '│ Complete │' "$tmp/cleanup-zero.out" \ + || fail 'installer wrapper did not report a completed run after cleanup failed' set +e PATH="$tmp/failing-rm:$PATH" TMPDIR="$tmp/wrapper-tmp" \ @@ -200,15 +240,16 @@ cleanup_rc=$? set -e [ "$cleanup_rc" -eq 37 ] \ || fail "installer wrapper changed child status 37 to $cleanup_rc after scratch cleanup failed" -grep -qF 'installer child reached status 37' "$tmp/cleanup-nonzero.out" \ +grep -qF 'installer child reached status 37' "$(newest_log)" \ || fail 'installer wrapper did not preserve the failing child execution path' +grep -q '│ Failed │' "$tmp/cleanup-nonzero.out" \ + || fail 'installer wrapper did not report the failed run' printf 'ok - installer wrapper scratch cleanup is warning-only and preserves child status\n' tar --sort=name --owner=0 --group=0 --numeric-owner \ -cf "$tmp/payload.tar" -C "$tmp/kit" . payload_sha="$(sha256sum "$tmp/payload.tar" | awk '{print $1}')" -sed -e 's/@VERSION@/2026.08.12.1/g' -e "s/@PAYLOAD_SHA@/$payload_sha/g" \ - "$root/scripts/setup-run-header.sh" > "$tmp/installer.run" +"$root/scripts/make-installer.sh" --render-header --version 2026.08.12.1 --payload-sha $payload_sha > "$tmp/installer.run" cat "$tmp/payload.tar" >> "$tmp/installer.run" chmod +x "$tmp/installer.run" bash "$checker" "$tmp/official" --version 2026.08.12.1 \ @@ -221,8 +262,7 @@ payload_size="$(wc -c < "$tmp/nonzero-tail.tar")" printf X | dd of="$tmp/nonzero-tail.tar" bs=1 seek="$((payload_size - 1))" \ conv=notrunc status=none nonzero_tail_sha="$(sha256sum "$tmp/nonzero-tail.tar" | awk '{print $1}')" -sed -e 's/@VERSION@/2026.08.12.1/g' -e "s/@PAYLOAD_SHA@/$nonzero_tail_sha/g" \ - "$root/scripts/setup-run-header.sh" > "$tmp/nonzero-tail.run" +"$root/scripts/make-installer.sh" --render-header --version 2026.08.12.1 --payload-sha $nonzero_tail_sha > "$tmp/nonzero-tail.run" cat "$tmp/nonzero-tail.tar" >> "$tmp/nonzero-tail.run" if bash "$checker" "$tmp/official" --version 2026.08.12.1 \ --runtime "$tmp/runtime.tar.zst" --installer "$tmp/nonzero-tail.run" \ @@ -235,8 +275,7 @@ printf '\n# changed after the tag\n' >> "$tmp/tampered-kit/scripts/install.sh" tar --sort=name --owner=0 --group=0 --numeric-owner \ -cf "$tmp/tampered-payload.tar" -C "$tmp/tampered-kit" . tampered_sha="$(sha256sum "$tmp/tampered-payload.tar" | awk '{print $1}')" -sed -e 's/@VERSION@/2026.08.12.1/g' -e "s/@PAYLOAD_SHA@/$tampered_sha/g" \ - "$root/scripts/setup-run-header.sh" > "$tmp/tampered-installer.run" +"$root/scripts/make-installer.sh" --render-header --version 2026.08.12.1 --payload-sha $tampered_sha > "$tmp/tampered-installer.run" cat "$tmp/tampered-payload.tar" >> "$tmp/tampered-installer.run" if bash "$checker" "$tmp/official" --version 2026.08.12.1 \ --runtime "$tmp/runtime.tar.zst" --installer "$tmp/tampered-installer.run" \ @@ -280,8 +319,7 @@ cp -- "$tmp/skipped-runtime.tar.zst" "$tmp/kit/dist/runtime.tar.zst" tar --sort=name --owner=0 --group=0 --numeric-owner \ -cf "$tmp/mixed-payload.tar" -C "$tmp/kit" . mixed_payload_sha="$(sha256sum "$tmp/mixed-payload.tar" | awk '{print $1}')" -sed -e 's/@VERSION@/2026.08.12.1/g' -e "s/@PAYLOAD_SHA@/$mixed_payload_sha/g" \ - "$root/scripts/setup-run-header.sh" > "$tmp/mixed-installer.run" +"$root/scripts/make-installer.sh" --render-header --version 2026.08.12.1 --payload-sha $mixed_payload_sha > "$tmp/mixed-installer.run" cat "$tmp/mixed-payload.tar" >> "$tmp/mixed-installer.run" chmod +x "$tmp/mixed-installer.run" if bash "$checker" "$tmp/official" --version 2026.08.12.1 \ @@ -292,6 +330,14 @@ if bash "$checker" "$tmp/official" --version 2026.08.12.1 \ fi printf 'ok - installer payload must carry the exact release record and runtime\n' +dev_wrapper_gate="$(sed -n '/^echo "== \[4\/5\] wrapper self-check =="$/,/^fi$/p' \ + "$root/scripts/make-installer.sh")" +grep -qF 'bash "$out" extract "$stage/wrapper-self-check"' <<< "$dev_wrapper_gate" \ + && grep -qF '[ -f "$stage/wrapper-self-check/scripts/installer.sh" ]' <<< "$dev_wrapper_gate" \ + && ! grep -qF -- '--help' <<< "$dev_wrapper_gate" \ + || fail 'the dev wrapper gate does not extract and inspect the packed payload' +printf 'ok - dev packing validates the final wrapper payload by extraction\n' + grep -qF ' --version "$VERSION" --runtime "$tarball" --installer "$out"' \ "$root/scripts/make-installer.sh" \ || fail 'make-installer.sh does not bind the wrapper to its release inputs' diff --git a/scripts/test-uninstall-boundary.sh b/scripts/test-uninstall-boundary.sh index fcfbb3ba..87356638 100755 --- a/scripts/test-uninstall-boundary.sh +++ b/scripts/test-uninstall-boundary.sh @@ -1,1078 +1,685 @@ #!/usr/bin/env bash -set -euo pipefail +# Tests for the public uninstaller and its installed-file list. +set -u here="$(cd "$(dirname "$0")" && pwd)" -ABLETON_PROTOCOL_DESKTOP_ID=io.github.shibco.ableton-linux.protocol.desktop -ABLETON_AUZ_DESKTOP_ID=io.github.shibco.ableton-linux.auz.desktop -work="$(mktemp -d "${TMPDIR:-/tmp}/ableton-uninstall-boundary-test.XXXXXX")" +work="$(mktemp -d /tmp/ableton-uninstall-test.XXXXXX)" +failures=0 +cases=0 +reported_failures=0 +children=() +TEST_FAIL_TOOL= +TEST_LEAVE_TOOL= +RUN_STDIN=/dev/null +RUN_RUNTIME_SET=0 +RUN_PREFIX_SET=0 +RUN_RUNTIME= +RUN_PREFIX= + cleanup() { - case "${work:-}" in - "${TMPDIR:-/tmp}"/ableton-uninstall-boundary-test.*) rm -rf -- "$work" ;; - esac + local pid exe + for pid in "${children[@]}"; do + exe="$(readlink -f -- "/proc/$pid/exe" 2>/dev/null || true)" + case "$exe" in "$work"/*) kill "$pid" 2>/dev/null || true ;; esac + wait "$pid" 2>/dev/null || true + done + case "$work" in /tmp/ableton-uninstall-test.*) /bin/rm -rf -- "$work" ;; esac } -trap cleanup EXIT +trap cleanup EXIT HUP INT TERM -fail() +check() { - printf 'not ok - %s\n' "$1" >&2 - exit 1 + local message="$1" + shift + if ! "$@"; then + printf 'not ok - %s: %s\n' "$CASE" "$message" >&2 + failures=$((failures + 1)) + fi } -pass=0 -ok() +done_case() { - pass=$((pass + 1)) - printf 'ok - %s\n' "$1" + cases=$((cases + 1)) + if [ "$failures" -eq "$reported_failures" ]; then + printf 'ok - %s\n' "$CASE" + fi + reported_failures="$failures" } -kit="$work/kit/scripts" -mkdir -p -- "$kit/lib" -cp -- "$here/installer.sh" "$here/uninstall.sh" "$kit/" -cp -- "$here/detect-scale.sh" "$here/detect-theme.sh" \ - "$here/shortcut-hold.sh" "$here/check-ntsync.sh" \ - "$here/ableton-linkctl" "$here/ableton-linkd.service" "$kit/" -mkdir -p -- "$kit/../tools" -cp -- "$here/../tools/setsyscolors.exe" "$here/../tools/learnheal.exe" \ - "$kit/../tools/" -cp -- "$here/lib/config.sh" "$here/lib/lifecycle.sh" \ - "$here/lib/manifest.sh" "$here/lib/pipeasio.sh" "$kit/lib/" -cat > "$kit/setup-link.sh" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -case "${1:-}" in - plan-disable) exit "${TEST_LINK_PLAN_RC:-0}" ;; - disable) exit "${TEST_LINK_DISABLE_RC:-0}" ;; - *) exit 2 ;; -esac -EOF -cat > "$kit/install.sh" <<'EOF' -#!/usr/bin/env bash -exit 0 -EOF -chmod 755 "$kit/setup-link.sh" "$kit/install.sh" +contains() { grep -qiF -- "$2" "$1/out" || grep -qiF -- "$2" "$1/err"; } +not_in_log() { ! grep -qF -- "$2" "$1"; } + +mkdir -p -- "$work/corebin" +for tool in awk basename bash cat chmod cmp cp cut dirname env find flock grep \ + head id ln mkdir mktemp mv od readlink realpath rm sed sha256sum sleep \ + sort stat tail timeout tput tr wc; do + path="$(type -P "$tool" 2>/dev/null || true)" + [ -z "$path" ] || ln -s -- "$path" "$work/corebin/$tool" +done + +digest() +{ + if [ -L "$1" ]; then + { printf 'symlink\0'; readlink -n -- "$1"; } | sha256sum | awk '{print $1}' + else + sha256sum -- "$1" | awk '{print $1}' + fi +} -new_fixture() +fixture() { - local base="$work/$1" - mkdir -p -- "$base/home" "$base/tmp" "$base/xdg/config" \ - "$base/xdg/data" "$base/xdg/state/ableton-wine" \ - "$base/xdg/cache" "$base/xdg/run" "$base/runtime" "$base/fakebin" + local base="$work/$1" state="$work/$1/xdg/state/ableton-wine" + mkdir -p -- "$base/home/.local/bin" "$base/xdg/config/ableton-wine" \ + "$base/xdg/data/ableton-wine" "$state" "$base/xdg/cache" \ + "$base/xdg/run" "$base/tmp" "$base/runtime/bin" \ + "$base/prefix/drive_c" "$base/fakebin" "$base/trash" printf 'format=1\nname=wine-d2d1-nspa-11.13\n' \ > "$base/runtime/.ableton-linux-runtime" - printf 'format=1\nowner=ableton-linux\n' \ - > "$base/xdg/state/ableton-wine/.ableton-linux-state" - cat > "$base/fakebin/rm" <<'EOF' -#!/usr/bin/env bash -set -u -for argument in "$@"; do - if [ "${FAIL_ADOPTION_CLEANUP:-0}" -eq 1 ]; then - case "$argument" in - */transactions/uninstall-adopt.*) exit 75 ;; - esac - fi - if [ -n "${CORRUPT_STATE_AFTER_TARGET:-}" ] \ - && [ "$argument" = "$CORRUPT_STATE_AFTER_TARGET" ]; then - /bin/rm "$@" || exit $? - printf 'changed during uninstall\n' > "${CORRUPT_STATE_FILE:?}" - exit 0 - fi - if [ -n "${FAIL_RM_TARGET:-}" ] && [ "$argument" = "$FAIL_RM_TARGET" ]; then - if [ "${FAIL_RM_REMOVE_FIRST:-0}" -eq 1 ]; then - /bin/rm "$@" || exit $? - fi - exit "${FAIL_RM_RC:-73}" - fi -done -exec /bin/rm "$@" + printf 'runtime\n' > "$base/runtime/bin/payload" + printf 'format=1\nprefix=%s\n' "$base/prefix" \ + > "$base/prefix/.ableton-linux-prefix" + printf 'prefix\n' > "$base/prefix/drive_c/payload" + printf 'format=1\nowner=ableton-linux\n' > "$state/.ableton-linux-state" + cat > "$base/xdg/config/ableton-wine/config" < "$base/xdg/config/ableton-wine/preferences" <<'EOF' +# ableton-linux launcher preferences; managed by the installer +format=1 +shortcuts=preserve +dpi=preserve +audio_threads=off +rt=off +power=balanced EOF - cat > "$base/fakebin/find" <<'EOF' + printf '#!/bin/sh\n# Ableton Live launcher for the patched Wine stack\n' \ + > "$base/home/.local/bin/ableton-live" + chmod 755 "$base/home/.local/bin/ableton-live" + printf '2026.08.28.1\n' > "$base/xdg/data/ableton-wine/VERSION" + printf 'leave me\n' > "$base/home/.local/bin/user-tool" + ln -s -- "$base/runtime/bin/pipeasio-settings" \ + "$base/home/.local/bin/pipeasio-settings" + { + printf 'file\t%s\t%s\n' "$base/home/.local/bin/ableton-live" \ + "$(digest "$base/home/.local/bin/ableton-live")" + printf 'file\t%s\t%s\n' "$base/xdg/data/ableton-wine/VERSION" \ + "$(digest "$base/xdg/data/ableton-wine/VERSION")" + printf 'symlink\t%s\t%s\n' "$base/home/.local/bin/pipeasio-settings" \ + "$(digest "$base/home/.local/bin/pipeasio-settings")" + printf 'runtime\t%s\twine-d2d1-nspa-11.13\n' "$base/runtime" + } > "$state/install-manifest.tsv" + chmod 600 "$base/xdg/config/ableton-wine/"* "$state/"* + : > "$base/trash.log" + : > "$base/rm.log" + printf '%s\n' "$base" +} + +use_default_targets() +{ + local base="$1" + local runtime="$base/home/.local/opt/wine-d2d1-nspa-11.13" + local prefix="$base/home/.wine-ableton" state="$base/xdg/state/ableton-wine" + mkdir -p -- "$(dirname "$runtime")" + mv -- "$base/runtime" "$runtime" + mv -- "$base/prefix" "$prefix" + printf 'format=1\nprefix=%s\n' "$prefix" > "$prefix/.ableton-linux-prefix" + sed -i "s|^runtime_root=.*|runtime_root=$runtime|; s|^prefix=.*|prefix=$prefix|" \ + "$base/xdg/config/ableton-wine/config" + sed -i "s|^runtime\t[^\t]*\t|runtime\t$runtime\t|" "$state/install-manifest.tsv" +} + +write_invalid_config_with_paths() +{ + local base="$1" + mkdir -p -- "$base/invalid-config-runtime" "$base/invalid-config-prefix" + cat > "$base/xdg/config/ableton-wine/config" < "$base/fakebin/$name" <<'EOF' #!/usr/bin/env bash set -u -for argument in "$@"; do - if [ -n "${FAIL_FIND_TARGET:-}" ] && [ "$argument" = "$FAIL_FIND_TARGET" ]; then - exit "${FAIL_FIND_RC:-74}" - fi +tool="$(basename "$0")" +target="" +for arg in "$@"; do + case "$arg" in "$TEST_ROOT"|"$TEST_ROOT"/*) target="$arg"; break ;; esac done -exec /usr/bin/find "$@" +[ -n "$target" ] || exit 98 +case "$tool" in + gio) [ "$1" = trash ] || exit 96 ;; + trash-put) [ "$#" -eq 1 ] || exit 96 ;; + kioclient) [ "$1" = move ] && [ "$3" = trash:/ ] || exit 96 ;; + *) exit 96 ;; +esac +printf '%s\t%s\n' "$tool" "$target" >> "$TEST_TRASH_LOG" +if [ "$TEST_FAIL_TOOL" = "$tool" ]; then exit 73; fi +if [ "$TEST_LEAVE_TOOL" = "$tool" ]; then exit 0; fi +count="$(wc -l < "$TEST_TRASH_LOG")" +/bin/mv -- "$target" "$TEST_TRASH_SINK/$count" EOF - cat > "$base/fakebin/xdg-mime" <<'EOF' + chmod 755 "$base/fakebin/$name" +} + +guarded_rm() +{ + local base="$1" + cat > "$base/fakebin/rm" <<'EOF' #!/usr/bin/env bash set -u -if [ "${1:-}" = query ] && [ "${2:-}" = default ]; then - rc="${TEST_XDG_QUERY_RC:-0}" - [ "$rc" -eq 0 ] || exit "$rc" - printf '%s\n' "${TEST_XDG_DEFAULT:-foreign.desktop}" - exit 0 -fi -if [ "${1:-}" = default ]; then - exit "${TEST_XDG_SET_RC:-0}" -fi -exit 2 -EOF - cat > "$base/fakebin/update-mime-database" <<'EOF' -#!/usr/bin/env bash -exit "${TEST_MIME_CACHE_RC:-0}" -EOF - cat > "$base/fakebin/update-desktop-database" <<'EOF' -#!/usr/bin/env bash -exit "${TEST_DESKTOP_CACHE_RC:-0}" -EOF - cat > "$base/fakebin/systemctl" <<'EOF' -#!/usr/bin/env bash -exit 0 +for arg in "$@"; do + case "$arg" in + -*) ;; + "$TEST_ROOT"|"$TEST_ROOT"/*) ;; + *) printf 'rm escaped fixture: %s\n' "$arg" >&2; exit 97 ;; + esac +done +printf '%s\n' "$*" >> "$TEST_RM_LOG" +exec /bin/rm "$@" EOF - chmod 755 "$base/fakebin/"* - printf '%s\n' "$base" + chmod 755 "$base/fakebin/rm" } -run_uninstall() +run_uninstall_entry() { - local base="$1" mode="$2" + local base="$1" entry="$2" + local -a target_env=() shift 2 - env HOME="$base/home" \ - XDG_CONFIG_HOME="$base/xdg/config" \ - XDG_DATA_HOME="$base/xdg/data" \ - XDG_STATE_HOME="$base/xdg/state" \ - XDG_CACHE_HOME="$base/xdg/cache" \ - XDG_RUNTIME_DIR="$base/xdg/run" \ - TMPDIR="$base/tmp" \ - PATH="$base/fakebin:$PATH" \ - ABLETON_WINE_ROOT="$base/runtime" \ - ABLETON_WINEPREFIX="$base/prefix" \ - ABLETON_LINK_MODE=off \ - ABLETON_SHORTCUTS=preserve \ - "$@" bash "$kit/uninstall.sh" "$mode" --yes + [ "$RUN_RUNTIME_SET" -eq 0 ] || target_env+=("ABLETON_WINE_ROOT=$RUN_RUNTIME") + [ "$RUN_PREFIX_SET" -eq 0 ] || target_env+=("ABLETON_WINEPREFIX=$RUN_PREFIX") + RUN_RC=0 + env -i HOME="$base/home" USER=test LOGNAME=test \ + XDG_CONFIG_HOME="$base/xdg/config" XDG_DATA_HOME="$base/xdg/data" \ + XDG_STATE_HOME="$base/xdg/state" XDG_CACHE_HOME="$base/xdg/cache" \ + XDG_RUNTIME_DIR="$base/xdg/run" TMPDIR="$base/tmp" \ + PATH="$base/fakebin:$work/corebin" LANG=C.UTF-8 TERM=dumb SHELL=/bin/bash \ + ABLETON_UI_ACTION=uninstall ABLETON_UI_PROMPT_TIMEOUT=1 \ + TEST_ROOT="$base" TEST_TRASH_LOG="$base/trash.log" \ + TEST_TRASH_SINK="$base/trash" TEST_RM_LOG="$base/rm.log" \ + TEST_FAIL_TOOL="$TEST_FAIL_TOOL" TEST_LEAVE_TOOL="$TEST_LEAVE_TOOL" \ + ABLETON_INSTALLER_LOG="$base/ui.log" \ + "${target_env[@]}" /bin/bash "$here/$entry" "$@" \ + > "$base/out" 2> "$base/err" \ + < "$RUN_STDIN" \ + || RUN_RC=$? } -run_integration_install() +run_uninstall() { local base="$1" - env HOME="$base/home" \ - XDG_CONFIG_HOME="$base/xdg/config" \ - XDG_DATA_HOME="$base/xdg/data" \ - XDG_STATE_HOME="$base/xdg/state" \ - XDG_CACHE_HOME="$base/xdg/cache" \ - XDG_RUNTIME_DIR="$base/xdg/run" \ - TMPDIR="$base/tmp" \ - PATH="$base/fakebin:$PATH" \ - ABLETON_WINE_ROOT="$base/runtime" \ - ABLETON_WINEPREFIX="$base/prefix" \ - ABLETON_LINK_MODE=off \ - ABLETON_SHORTCUTS=preserve \ - bash "$here/install.sh" --integration-only + shift + run_uninstall_entry "$base" uninstall.sh "$@" } -assert_warning_contract() +run_uninstall_with_targets() { - local error_file="$1" retry_expected="${2:-yes}" retry_count - grep -q '^!! warning:' "$error_file" \ - || fail "optional failure did not produce a warning" - retry_count="$(grep -c '^ Retry:' "$error_file" || true)" - if [ "$retry_expected" = yes ]; then - [ "$retry_count" -eq 1 ] \ - || fail "retained retry support did not print exactly one final retry command" - elif [ "$retry_count" -ne 0 ]; then - fail "advisory cleanup warning printed a retry command without retained support" - fi - if grep -Eiw 'gate|journal|generation|CAS' "$error_file" >/dev/null; then - fail "warning exposed internal lifecycle terminology" - fi + local base="$1" + RUN_RUNTIME_SET=1 + RUN_PREFIX_SET=1 + RUN_RUNTIME="$2" + RUN_PREFIX="$3" + shift 3 + run_uninstall "$base" "$@" + RUN_RUNTIME_SET=0 + RUN_PREFIX_SET=0 } run_public_uninstall() { local base="$1" - env HOME="$base/home" \ - XDG_CONFIG_HOME="$base/xdg/config" \ - XDG_DATA_HOME="$base/xdg/data" \ - XDG_STATE_HOME="$base/xdg/state" \ - XDG_CACHE_HOME="$base/xdg/cache" \ - XDG_RUNTIME_DIR="$base/xdg/run" \ - TMPDIR="$base/tmp" \ - PATH="$base/fakebin:$PATH" \ - ABLETON_LINK_MODE=off ABLETON_SHORTCUTS=preserve \ - bash "$kit/installer.sh" uninstall \ - --runtime-root "$base/runtime" --prefix "$base/prefix" \ - --keep-prefix --yes + shift + run_uninstall_entry "$base" installer.sh uninstall "$@" } -state_path() +report_present() { - printf '%s/xdg/state/ableton-wine\n' "$1" + local label + check "report has its own heading" contains "$1" "What remains" + for label in Runtime Prefix "Linux integration" "Installer settings" "Shared state"; do + check "report includes $label status" contains "$1" "$label:" + done } -make_legacy_runtime_without_state() -{ - local base="$1" runtime pe_hash unix_hash - runtime="$base/home/.local/opt/wine-d2d1-nspa-11.13" - rm -f -- "$base/xdg/state/ableton-wine/.ableton-linux-state" - rmdir -- "$base/xdg/state/ableton-wine" - mkdir -p -- "$base/home/.local/share/ableton-wine" \ - "$base/home/.local/bin" "$runtime/bin" \ - "$runtime/lib/wine/x86_64-windows" \ - "$runtime/lib/wine/x86_64-unix" - printf '2025.01.01.1\n' > "$base/home/.local/share/ableton-wine/VERSION" - printf '#!/bin/sh\n# Ableton Live launcher for the patched Wine stack\n' \ - > "$base/home/.local/bin/ableton-live" - printf '#!/bin/sh\nexit 0\n' > "$runtime/bin/wine" - printf '#!/bin/sh\nexit 0\n' > "$runtime/bin/wineserver" - chmod 755 "$base/home/.local/bin/ableton-live" \ - "$runtime/bin/wine" "$runtime/bin/wineserver" - printf 'legacy PE fixture\n' \ - > "$runtime/lib/wine/x86_64-windows/pipeasio64.dll" - printf 'legacy Unix fixture\n' \ - > "$runtime/lib/wine/x86_64-unix/pipeasio64.dll.so" - pe_hash="$(sha256sum -- "$runtime/lib/wine/x86_64-windows/pipeasio64.dll")" - pe_hash="${pe_hash%% *}" - unix_hash="$(sha256sum -- "$runtime/lib/wine/x86_64-unix/pipeasio64.dll.so")" - unix_hash="${unix_hash%% *}" - printf 'dist-version: 2025.01.01.1\npipeasio-pe: %s\npipeasio-unix: %s\n' \ - "$pe_hash" "$unix_hash" > "$runtime/ABLETON-WINE-BUILD-INFO.txt" - printf '%s\n' "$runtime" -} +report_says() { check "$2 status is $3" contains "$1" "$2: $3"; } -prepare_optional_inventory_fixture() +before_in_log() { - local base="$1" digest state - state="$(state_path "$base")" - TEST_INVENTORY_DESKTOP="$base/xdg/data/applications/ableton-live.desktop" - TEST_EXTRA_RUNTIME="$base/older-recorded-runtime" - mkdir -p -- "$(dirname "$TEST_INVENTORY_DESKTOP")" "$TEST_EXTRA_RUNTIME" \ - "$base/xdg/config" - printf 'generated desktop fixture\n' > "$TEST_INVENTORY_DESKTOP" - printf '[Default Applications]\nx-scheme-handler/ableton=%s\n' \ - "$ABLETON_PROTOCOL_DESKTOP_ID" > "$base/xdg/config/mimeapps.list" - printf 'format=1\nname=wine-d2d1-nspa-11.13\n' \ - > "$TEST_EXTRA_RUNTIME/.ableton-linux-runtime" - digest="$(sha256sum -- "$TEST_INVENTORY_DESKTOP")" - digest="${digest%% *}" - printf 'file\t%s\t%s\nruntime\t%s\twine-d2d1-nspa-11.13\n' \ - "$TEST_INVENTORY_DESKTOP" "$digest" "$TEST_EXTRA_RUNTIME" \ - > "$state/install-manifest.tsv" + local log="$1" first="$2" second="$3" a b + a="$(awk -F '\t' -v p="$first" '$2==p { print NR; exit }' "$log")" + b="$(awk -F '\t' -v p="$second" '$2==p { print NR; exit }' "$log")" + [ -n "$a" ] && [ -n "$b" ] && [ "$a" -lt "$b" ] } -# Optional installer settings cannot veto removal when the public dispatcher -# has already supplied exact runtime/prefix paths. Runtime ownership remains -# the deletion authority; the malformed settings are preserved for inspection. -base="$(new_fixture malformed-installer-settings)" -config="$base/xdg/config/ableton-wine/config" -mkdir -p -- "$(dirname "$config")" -printf '# ableton-linux installer configuration; managed by the installer\nformat=1\nobsolete_key=value\n' \ - > "$config" -cp -- "$config" "$base/config.before" -run_public_uninstall "$base" > "$base/out" 2> "$base/err" \ - || fail "malformed optional settings blocked public uninstall with exact targets" -[ ! -e "$base/runtime" ] \ - && cmp -s -- "$config" "$base/config.before" \ - && [ -f "$(state_path "$base")/.ableton-linux-state" ] \ - || fail "public uninstall changed malformed settings or retained the exact runtime" -grep -qF "installer settings at $config" "$base/err" \ - || fail "preserved malformed installer settings were not reported" -assert_warning_contract "$base/err" -ok "malformed optional settings cannot veto public uninstall with exact core targets" - -# Cache cleanup is best effort and is not used by runtime installation. A -# non-directory cache object must therefore survive without blocking even a -# public runtime plan. -base="$(new_fixture cache-object-runtime-plan)" -printf 'foreign cache object\n' > "$base/xdg/cache/ableton-wine" -env HOME="$base/home" \ - XDG_CONFIG_HOME="$base/xdg/config" \ - XDG_DATA_HOME="$base/xdg/data" \ - XDG_STATE_HOME="$base/xdg/state" \ - XDG_CACHE_HOME="$base/xdg/cache" \ - XDG_RUNTIME_DIR="$base/xdg/run" \ - TMPDIR="$base/tmp" PATH="$base/fakebin:$PATH" \ - ABLETON_WINEPREFIX="$base/prefix" ABLETON_LINK_MODE=off \ - ABLETON_SHORTCUTS=preserve \ - bash "$kit/installer.sh" runtime install \ - --runtime-root "$base/runtime" --yes --dry-run \ - > "$base/out" 2> "$base/err" \ - || fail "foreign cache object blocked a public runtime plan" -grep -qxF 'foreign cache object' "$base/xdg/cache/ableton-wine" \ - || fail "public runtime plan changed the unrelated cache object" -ok "unrelated cache objects cannot gate public runtime work" - -# Current installs no longer save the desktop default they replaced. Their -# dry-run must say that only Ableton Linux defaults will be cleared, rather -# than claiming every file-opening default will be left unchanged. -base="$(new_fixture snapshot-free-mime-plan)" -printf 'runtime\t%s\twine-d2d1-nspa-11.13\n' "$base/runtime" \ - > "$(state_path "$base")/install-manifest.tsv" -env HOME="$base/home" \ - XDG_CONFIG_HOME="$base/xdg/config" \ - XDG_DATA_HOME="$base/xdg/data" \ - XDG_STATE_HOME="$base/xdg/state" \ - XDG_CACHE_HOME="$base/xdg/cache" \ - XDG_RUNTIME_DIR="$base/xdg/run" \ - TMPDIR="$base/tmp" PATH="$base/fakebin:$PATH" \ - ABLETON_WINE_ROOT="$base/runtime" ABLETON_WINEPREFIX="$base/prefix" \ - ABLETON_LINK_MODE=off ABLETON_SHORTCUTS=preserve \ - bash "$kit/uninstall.sh" --keep-prefix --yes --dry-run \ - > "$base/out" 2> "$base/err" \ - || fail "snapshot-free current uninstall plan failed" -grep -qxF ' clear only file-opening defaults that name Ableton Linux' "$base/out" \ - || fail "snapshot-free current uninstall plan misdescribed MIME cleanup" -if grep -qxF ' leave file-opening defaults unchanged' "$base/out"; then - fail "snapshot-free current uninstall plan claimed its own defaults were untouched" -fi -ok "snapshot-free current uninstall plans describe their narrow MIME cleanup truthfully" - -# Exercise the real integration manifest so an ordinary clean uninstall does -# not acquire a warning merely because a generated support path is present. -base="$(new_fixture clean-integration)" -run_integration_install "$base" > "$base/install.out" 2> "$base/install.err" \ - || fail "could not create a full integration fixture" -run_uninstall "$base" --keep-prefix > "$base/out" 2> "$base/err" \ - || fail "clean full integration uninstall failed" -[ ! -e "$base/runtime" ] \ - && [ ! -e "$(state_path "$base")" ] \ - && [ ! -e "$base/xdg/config/ableton-wine/config" ] \ - || fail "clean full integration uninstall retained managed state" -if grep -q '^!! warning:' "$base/err"; then - fail "clean full integration uninstall emitted an optional-failure warning" -fi -grep -qxF 'OK: uninstall complete' "$base/out" \ - || fail "clean full integration uninstall did not report complete success" -ok "clean full integration uninstall removes all managed state without warnings" - -# Legacy marker adoption creates a short-lived transaction under state. The -# uninstall must mark that directory before using it, so its own transaction -# cannot later be mistaken for foreign state after core removal succeeds. -base="$(new_fixture legacy-adoption-state)" -legacy_runtime="$(make_legacy_runtime_without_state "$base")" -run_uninstall "$base" --keep-prefix "ABLETON_WINE_ROOT=$legacy_runtime" \ - > "$base/out" 2> "$base/err" \ - || fail "legacy adoption's own support state made uninstall fail" -[ ! -e "$legacy_runtime" ] \ - || fail "legacy adoption support-state handling retained the runtime" -legacy_state="$(state_path "$base")" -if [ -e "$legacy_state" ] || [ -L "$legacy_state" ]; then - if [ ! -d "$legacy_state" ] || [ -L "$legacy_state" ]; then - fail "legacy adoption retained unsafe support state" - fi - grep -qxF 'owner=ableton-linux' "$legacy_state/.ableton-linux-state" \ - || fail "legacy adoption retained unmarked support state" -fi -ok "legacy adoption never rejects support state created by the uninstall itself" - -# Pre-manifest installers left their generated support bundle at the historical -# HOME path, independent of XDG_DATA_HOME. Exact packaged copies and the -# VERSION record corroborated by the legacy launcher are safe to retire. -base="$(new_fixture clean-legacy-support)" -legacy_runtime="$(make_legacy_runtime_without_state "$base")" -legacy_data="$base/home/.local/share/ableton-wine" -for legacy_helper in \ - detect-scale.sh detect-theme.sh shortcut-hold.sh check-ntsync.sh \ - setup-link.sh ableton-linkctl ableton-linkd.service; do - cp -- "$kit/$legacy_helper" "$legacy_data/$legacy_helper" -done -cp -- "$kit/../tools/setsyscolors.exe" "$legacy_data/setsyscolors.exe" -cp -- "$kit/../tools/learnheal.exe" "$legacy_data/learnheal.exe" -cat > "$legacy_data/wine-protocol-ableton.desktop" < "$base/out" 2> "$base/err" \ - || fail "clean legacy support-file removal failed" -[ ! -e "$legacy_runtime" ] && [ ! -L "$legacy_runtime" ] \ - && [ ! -e "$legacy_data" ] && [ ! -L "$legacy_data" ] \ - && [ ! -e "$base/home/.local/bin/ableton-live" ] \ - || fail "clean legacy uninstall retained recognised project support files" -if grep -q '^!! warning:' "$base/err"; then - fail "clean legacy support-file removal emitted a warning" -fi -grep -qxF 'OK: uninstall complete' "$base/out" \ - || fail "clean legacy support-file removal did not report complete success" -ok "clean legacy uninstall retires exactly recognised historical support files" - -# A same-path helper that differs from the packaged generation may contain -# local work. Legacy evidence authorises inspection, never deletion by name. -base="$(new_fixture modified-legacy-support)" -legacy_runtime="$(make_legacy_runtime_without_state "$base")" -legacy_data="$base/home/.local/share/ableton-wine" -cp -- "$kit/detect-scale.sh" "$legacy_data/detect-scale.sh" -printf '\n# local modification\n' >> "$legacy_data/detect-scale.sh" -cp -- "$legacy_data/detect-scale.sh" "$base/modified-helper.before" -run_uninstall "$base" --keep-prefix "ABLETON_WINE_ROOT=$legacy_runtime" \ - > "$base/out" 2> "$base/err" \ - || fail "modified legacy support file made core uninstall fail" -cmp -s -- "$base/modified-helper.before" "$legacy_data/detect-scale.sh" \ - || fail "legacy uninstall deleted or changed a modified support file" -[ ! -e "$legacy_runtime" ] \ - || fail "modified legacy support file retained the recognised runtime" -grep -qF "kept a modified or unrecognised older support file at $legacy_data/detect-scale.sh" \ - "$base/err" || fail "modified legacy support-file preservation was not reported" -assert_warning_contract "$base/err" -ok "legacy uninstall preserves modified historical support files" - -# Publishing the adopted ownership marker is the legacy migration commit. If -# its disposable transaction directory cannot be removed afterward, the exact -# core-complete record must make the residue non-blocking on the next run. -base="$(new_fixture legacy-adoption-cleanup-retry)" -legacy_runtime="$(make_legacy_runtime_without_state "$base")" -legacy_foreign_desktop="$base/xdg/data/applications/ableton-live.desktop" -mkdir -p -- "$(dirname "$legacy_foreign_desktop")" -printf 'foreign desktop fixture\n' > "$legacy_foreign_desktop" -run_uninstall "$base" --keep-prefix \ - "ABLETON_WINE_ROOT=$legacy_runtime" FAIL_ADOPTION_CLEANUP=1 \ - > "$base/first.out" 2> "$base/first.err" \ - || fail "legacy adoption cleanup residue made completed runtime removal fail" -[ ! -e "$legacy_runtime" ] \ - || fail "legacy adoption cleanup residue retained the adopted runtime" -legacy_state="$(state_path "$base")" -legacy_adoption_txn="$(find "$legacy_state/transactions" -mindepth 1 -maxdepth 1 \ - -type d -name 'uninstall-adopt.*' -print -quit)" -[ -n "$legacy_adoption_txn" ] \ - && [ -f "$legacy_adoption_txn/active" ] \ - && cmp -s -- "$legacy_adoption_txn/core-complete" \ - <(printf 'format=1\ncore=complete\n') \ - || fail "legacy adoption cleanup residue lacked its completed-core proof" -/bin/rm -f -- "$legacy_foreign_desktop" -run_uninstall "$base" --keep-prefix "ABLETON_WINE_ROOT=$legacy_runtime" \ - > "$base/retry.out" 2> "$base/retry.err" \ - || fail "completed legacy adoption cleanup residue blocked a direct retry" -[ ! -e "$legacy_state" ] && [ ! -L "$legacy_state" ] \ - || fail "legacy adoption retry retained disposable support state" -ok "completed legacy adoption cleanup residue cannot block a direct retry" - -# Installed-file lists authorize only optional cleanup. Malformed text and -# binary/unreadable content must preserve every listed object while independently -# validated configured runtime/prefix removal continues. -for inventory_case in \ - manifest-malformed manifest-binary manifest-unreadable \ - manifest-unmarked-extra \ - prestate-malformed prestate-binary prestate-unreadable; do - base="$(new_fixture "optional-$inventory_case")" - state="$(state_path "$base")" - prepare_optional_inventory_fixture "$base" - mode=--keep-prefix - case "$inventory_case" in - manifest-malformed) - printf 'file\t/\t%064d\n' 0 >> "$state/install-manifest.tsv" - mkdir -p -- "$base/prefix" - printf 'format=1\nprefix=%s\n' "$base/prefix" \ +CASE="Runtime only removes integration and runtime" +base="$(fixture runtime)" +trash_tool "$base" gio +TEST_FAIL_TOOL= +run_uninstall "$base" --keep-prefix --yes +check "succeeds" test "$RUN_RC" -eq 0 +check "runtime removed" test ! -e "$base/runtime" +check "launcher removed" test ! -e "$base/home/.local/bin/ableton-live" +check "prefix remains" test -d "$base/prefix" +check "settings remain" test -f "$base/xdg/config/ableton-wine/config" +check "state remains for partial scope" test -f \ + "$base/xdg/state/ableton-wine/install-manifest.tsv" +check "integration precedes runtime" before_in_log "$base/trash.log" \ + "$base/home/.local/bin/ableton-live" "$base/runtime" +check "manifest version removed" test ! -e "$base/xdg/data/ableton-wine/VERSION" +check "manifest symlink removed" test ! -L "$base/home/.local/bin/pipeasio-settings" +check "unmanifested file remains" test -f "$base/home/.local/bin/user-tool" +check "preferences remain" test -f "$base/xdg/config/ableton-wine/preferences" +report_present "$base" +report_says "$base" Runtime Removed +report_says "$base" Prefix Remains +report_says "$base" "Linux integration" Removed +report_says "$base" "Installer settings" Remains +report_says "$base" "Shared state" Remains +done_case + +CASE="Prefix only removes only the prefix" +base="$(fixture prefix)" +trash_tool "$base" gio +run_uninstall "$base" --prefix-only --yes +check "succeeds" test "$RUN_RC" -eq 0 +check "prefix removed" test ! -e "$base/prefix" +check "runtime remains" test -d "$base/runtime" +check "integration remains" test -e "$base/home/.local/bin/ableton-live" +check "preferences remain" test -f "$base/xdg/config/ableton-wine/preferences" +check "state remains" test -f "$base/xdg/state/ableton-wine/install-manifest.tsv" +report_present "$base" +report_says "$base" Runtime Remains +report_says "$base" Prefix Removed +report_says "$base" "Linux integration" Remains +done_case + +CASE="All removes state last" +base="$(fixture all)" +trash_tool "$base" gio +run_uninstall "$base" --delete-prefix --yes +check "succeeds" test "$RUN_RC" -eq 0 +check "runtime removed" test ! -e "$base/runtime" +check "prefix removed" test ! -e "$base/prefix" +check "integration removed" test ! -e "$base/home/.local/bin/ableton-live" +check "settings removed" test ! -e "$base/xdg/config/ableton-wine/config" +check "preferences removed" test ! -e "$base/xdg/config/ableton-wine/preferences" +check "state removed" test ! -e "$base/xdg/state/ableton-wine" +check "integration precedes runtime" before_in_log "$base/trash.log" \ + "$base/home/.local/bin/ableton-live" "$base/runtime" +check "runtime precedes prefix" before_in_log "$base/trash.log" \ + "$base/runtime" "$base/prefix" +check "settings precede shared state" before_in_log "$base/trash.log" \ + "$base/xdg/config/ableton-wine/config" "$base/xdg/state/ableton-wine" +check "prefix precedes shared state" before_in_log "$base/trash.log" \ + "$base/prefix" "$base/xdg/state/ableton-wine" +check "unmanifested file remains" test -f "$base/home/.local/bin/user-tool" +report_present "$base" +report_says "$base" Runtime Removed +report_says "$base" Prefix Removed +report_says "$base" "Linux integration" Removed +report_says "$base" "Installer settings" Removed +report_says "$base" "Shared state" Removed +done_case + +CASE="Direct uninstaller uses supplied paths when the config file is missing" +base="$(fixture direct-missing-config)" +trash_tool "$base" gio +rm -- "$base/xdg/config/ableton-wine/config" +run_uninstall_with_targets "$base" "$base/runtime" "$base/prefix" \ + --keep-prefix --yes +check "succeeds" test "$RUN_RC" -eq 0 +check "uninstaller removes supplied runtime" test ! -e "$base/runtime" +check "uninstaller leaves prefix in place" test -d "$base/prefix" +done_case + +CASE="Direct uninstaller uses supplied paths when the config file is invalid" +base="$(fixture direct-invalid-config)" +trash_tool "$base" gio +write_invalid_config_with_paths "$base" +run_uninstall_with_targets "$base" "$base/runtime" "$base/prefix" \ + --keep-prefix --yes +check "succeeds" test "$RUN_RC" -eq 0 +check "uninstaller removes supplied runtime" test ! -e "$base/runtime" +check "uninstaller leaves invalid config runtime in place" test -d "$base/invalid-config-runtime" +check "uninstaller leaves invalid config prefix in place" test -d "$base/invalid-config-prefix" +check "invalid config runtime path receives zero ownership markers" test ! -e \ + "$base/invalid-config-runtime/.ableton-linux-runtime" +check "invalid config prefix path receives zero ownership markers" test ! -e \ + "$base/invalid-config-prefix/.ableton-linux-prefix" +done_case + +CASE="Environment runtime path takes priority over config runtime path" +base="$(fixture explicit-overrides-valid-config)" +trash_tool "$base" gio +selected_runtime="$base/selected-runtime" +mkdir -p -- "$selected_runtime/bin" +printf 'format=1\nname=wine-d2d1-nspa-11.13\n' \ + > "$selected_runtime/.ableton-linux-runtime" +printf 'selected runtime\n' > "$selected_runtime/bin/payload" +sed -i "s|^runtime\t[^\t]*\t|runtime\t$selected_runtime\t|" \ + "$base/xdg/state/ableton-wine/install-manifest.tsv" +run_uninstall_with_targets "$base" "$selected_runtime" "$base/prefix" \ + --keep-prefix --yes +check "succeeds" test "$RUN_RC" -eq 0 +check "uninstaller removes environment runtime" test ! -e "$selected_runtime" +check "uninstaller leaves config runtime in place" test -d "$base/runtime" +done_case + +CASE="Installer uses command-line paths when the config file is missing" +base="$(fixture public-missing-config)" +trash_tool "$base" gio +rm -- "$base/xdg/config/ableton-wine/config" +run_public_uninstall "$base" --keep-prefix --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes +check "succeeds" test "$RUN_RC" -eq 0 +check "installer removes command-line runtime" test ! -e "$base/runtime" +check "installer leaves prefix in place" test -d "$base/prefix" +done_case + +CASE="Installer uses command-line paths when the config file is invalid" +base="$(fixture public-invalid-config)" +trash_tool "$base" gio +write_invalid_config_with_paths "$base" +run_public_uninstall "$base" --keep-prefix --runtime-root "$base/runtime" \ + --prefix "$base/prefix" --yes +check "succeeds" test "$RUN_RC" -eq 0 +check "installer removes command-line runtime" test ! -e "$base/runtime" +check "installer leaves invalid config runtime in place" test -d "$base/invalid-config-runtime" +check "installer leaves invalid config prefix in place" test -d "$base/invalid-config-prefix" +done_case + +CASE="Installer uses default paths when the config file is invalid" +base="$(fixture public-invalid-defaults)" +trash_tool "$base" gio +use_default_targets "$base" +default_runtime="$base/home/.local/opt/wine-d2d1-nspa-11.13" +write_invalid_config_with_paths "$base" +run_public_uninstall "$base" --keep-prefix --yes +check "succeeds" test "$RUN_RC" -eq 0 +check "installer removes default runtime" test ! -e "$default_runtime" +check "installer leaves invalid config runtime in place" test -d "$base/invalid-config-runtime" +check "installer leaves invalid config prefix in place" test -d "$base/invalid-config-prefix" +done_case + +CASE="Direct uninstaller uses default paths when the config file is invalid" +base="$(fixture invalid-defaults)" +trash_tool "$base" gio +use_default_targets "$base" +default_runtime="$base/home/.local/opt/wine-d2d1-nspa-11.13" +write_invalid_config_with_paths "$base" +run_uninstall "$base" --keep-prefix --yes +check "succeeds" test "$RUN_RC" -eq 0 +check "uninstaller removes default runtime" test ! -e "$default_runtime" +check "uninstaller leaves invalid config runtime in place" test -d "$base/invalid-config-runtime" +check "uninstaller leaves invalid config prefix in place" test -d "$base/invalid-config-prefix" +done_case + +for bad in missing-manifest unrelated-path runtime-mismatch runtime-marker \ + prefix-marker state-marker unsafe-runtime; do + CASE="Unsafe ownership data is refused: $bad" + base="$(fixture "bad-$bad")" + trash_tool "$base" gio + args=(--keep-prefix --yes) + case "$bad" in + missing-manifest) + rm -- "$base/xdg/state/ableton-wine/install-manifest.tsv" ;; + unrelated-path) + printf 'file\t%s\t%s\n' "$base/unrelated" \ + 0000000000000000000000000000000000000000000000000000000000000000 \ + >> "$base/xdg/state/ableton-wine/install-manifest.tsv" ;; + runtime-mismatch) + sed -i "s|^runtime\t$base/runtime\t|runtime\t$base/elsewhere\t|" \ + "$base/xdg/state/ableton-wine/install-manifest.tsv" ;; + runtime-marker) + printf 'format=1\nname=foreign\n' \ + > "$base/runtime/.ableton-linux-runtime" ;; + prefix-marker) + printf 'format=1\nprefix=/foreign\n' \ > "$base/prefix/.ableton-linux-prefix" - printf 'registry fixture\n' > "$base/prefix/system.reg" - mode=--delete-prefix - ;; - manifest-binary) - printf '\0binary installed-file data\n' >> "$state/install-manifest.tsv" - ;; - manifest-unreadable) - rm -f -- "$state/install-manifest.tsv" - ln -s -- /dev/null "$state/install-manifest.tsv" - ;; - manifest-unmarked-extra) - rm -f -- "$TEST_EXTRA_RUNTIME/.ableton-linux-runtime" - printf 'foreign runtime bytes\n' > "$TEST_EXTRA_RUNTIME/sentinel" - ;; - prestate-malformed) - printf 'present\t%s\t%s/not-a-saved-copy\n' \ - "$TEST_INVENTORY_DESKTOP" "$state" > "$state/install-prestate.tsv" - ;; - prestate-binary) - printf '\0binary saved-file data\n' > "$state/install-prestate.tsv" - ;; - prestate-unreadable) - ln -s -- /dev/null "$state/install-prestate.tsv" - ;; + args=(--prefix-only --yes) ;; + state-marker) + printf 'format=1\nowner=foreign\n' \ + > "$base/xdg/state/ableton-wine/.ableton-linux-state" ;; + unsafe-runtime) + sed -i 's|^runtime_root=.*|runtime_root=/|' \ + "$base/xdg/config/ableton-wine/config" + sed -i 's|^runtime\t[^\t]*\t|runtime\t/\t|' \ + "$base/xdg/state/ableton-wine/install-manifest.tsv" ;; esac - run_uninstall "$base" "$mode" TEST_LINK_DISABLE_RC=73 \ - > "$base/out" 2> "$base/err" \ - || fail "$inventory_case vetoed independently validated core removal" - [ ! -e "$base/runtime" ] \ - || fail "$inventory_case retained the configured runtime" - if [ "$mode" = --delete-prefix ] && [ -e "$base/prefix" ]; then - fail "$inventory_case retained the requested validated prefix" - fi - [ -e "$TEST_INVENTORY_DESKTOP" ] && [ -e "$TEST_EXTRA_RUNTIME" ] \ - || fail "$inventory_case removed an object from untrusted optional information" - grep -qxF "x-scheme-handler/ableton=$ABLETON_PROTOCOL_DESKTOP_ID" \ - "$base/xdg/config/mimeapps.list" \ - || fail "$inventory_case changed a desktop default from untrusted optional information" - grep -q "installer's file list could not be trusted" "$base/err" \ - || fail "$inventory_case warning did not identify why optional files remain" - if grep -q 'Link integration may still be enabled' "$base/err"; then - fail "$inventory_case attempted Link cleanup from untrusted optional information" - fi - assert_warning_contract "$base/err" + run_uninstall "$base" "${args[@]}" + check "fails" test "$RUN_RC" -ne 0 + check "nothing was sent to Trash" test ! -s "$base/trash.log" + check "runtime remains" test -d "$base/runtime" + check "prefix remains" test -d "$base/prefix" + done_case done -ok "malformed optional file lists never veto configured runtime or requested-prefix removal" - -# An unfinished core operation remains a real stop condition. -base="$(new_fixture active-core-recovery)" -mkdir -p -- "$(state_path "$base")/transactions/install.active" -: > "$(state_path "$base")/transactions/install.active/active" -if run_uninstall "$base" --keep-prefix > "$base/out" 2> "$base/err"; then - fail "uninstall ignored unfinished installer recovery" -fi -[ -e "$base/runtime/.ableton-linux-runtime" ] \ - || fail "active recovery refusal happened after runtime deletion" -grep -q 'earlier installation stopped before recovery finished' "$base/err" \ - || fail "active recovery refusal was not explicit" -ok "unfinished core recovery remains fatal before removal" - -# Unrecognised optional support state cannot invalidate an exact runtime target. -base="$(new_fixture malformed-support-state)" -printf 'foreign support state\n' > "$(state_path "$base")/.ableton-linux-state" -run_uninstall "$base" --keep-prefix > "$base/out" 2> "$base/err" \ - || fail "unrecognised optional support state made core removal fail" -[ ! -e "$base/runtime" ] || fail "unrecognised support state retained the runtime" -[ -e "$(state_path "$base")/.ableton-linux-state" ] \ - || fail "unrecognised support state was deleted" -grep -q 'support files were left unchanged' "$base/err" \ - || fail "unrecognised support state warning did not identify what remains" -assert_warning_contract "$base/err" -ok "unrecognised support state is warning-only" - -# The support path itself is optional cleanup. A regular file there is neither -# owned directory state nor a reason to retain an independently proven runtime. -base="$(new_fixture regular-support-state-root)" -state="$(state_path "$base")" -rm -rf -- "$state" -printf 'foreign support-state root\n' > "$state" -run_uninstall "$base" --keep-prefix > "$base/out" 2> "$base/err" \ - || fail "regular optional support-state root made core removal fail" -[ ! -e "$base/runtime" ] \ - || fail "regular optional support-state root retained the runtime" -grep -qxF 'foreign support-state root' "$state" \ - || fail "regular optional support-state root was changed" -grep -q 'support files were left unchanged' "$base/err" \ - || fail "regular optional support-state root was not reported" -assert_warning_contract "$base/err" -ok "a non-directory optional support path cannot veto runtime removal" - -# A configured support root may overlap a kept core tree after manual settings -# edits. Do not inspect its transaction-looking children as installer recovery, -# and never recursively clean the overlapping tree as optional state. -base="$(new_fixture overlapping-support-prefix)" -mkdir -p -- "$base/prefix/transactions/foreign-run" -printf 'format=1\nowner=ableton-linux\n' \ - > "$base/prefix/.ableton-linux-state" -printf 'kept prefix sentinel\n' > "$base/prefix/user-data" -: > "$base/prefix/transactions/foreign-run/active" -run_uninstall "$base" --keep-prefix "ABLETON_STATE_HOME=$base/prefix" \ - > "$base/out" 2> "$base/err" \ - || fail "overlapping optional support state made runtime removal fail" -[ ! -e "$base/runtime" ] \ - || fail "overlapping optional support state retained the runtime" -grep -qxF 'kept prefix sentinel' "$base/prefix/user-data" \ - && [ -e "$base/prefix/transactions/foreign-run/active" ] \ - || fail "optional state cleanup changed the overlapping kept prefix" -grep -q 'path overlaps the Wine runtime or prefix' "$base/err" \ - || fail "overlapping optional support state was not reported" -assert_warning_contract "$base/err" -ok "overlapping optional support state is skipped without gating core removal" - -# Current installs do not retain the default an authoritative project ID -# replaced. Uninstall still removes only those exact IDs and preserves every -# unrelated mimeapps.list entry. -base="$(new_fixture snapshot-free-mime)" -rm -f -- "$base/fakebin/xdg-mime" "$base/fakebin/update-mime-database" \ - "$base/fakebin/update-desktop-database" -mkdir -p -- "$base/xdg/data/applications" "$base/xdg/config" -cat > "$base/xdg/data/applications/third-party.desktop" <<'EOF' -[Desktop Entry] -Type=Application -Name=Third Party -Exec=/usr/bin/true %f -EOF -cat > "$base/xdg/config/mimeapps.list" <<'EOF' -[Default Applications] -x-scheme-handler/ableton=third-party.desktop -text/plain=third-party.desktop -EOF -run_integration_install "$base" > "$base/install.out" 2> "$base/install.err" \ - || fail "could not create a snapshot-free MIME fixture" -grep -qxF "x-scheme-handler/ableton=$ABLETON_PROTOCOL_DESKTOP_ID" \ - "$base/xdg/config/mimeapps.list" \ - || fail "integration fixture did not install its scheme-handler default" -run_uninstall "$base" --keep-prefix > "$base/out" 2> "$base/err" \ - || fail "snapshot-free MIME cleanup made uninstall fail" -if grep -Eq "=($ABLETON_PROTOCOL_DESKTOP_ID|$ABLETON_AUZ_DESKTOP_ID|ableton-live[.]desktop|max9[.]desktop|wine-protocol-c74max[.]desktop)$" \ - "$base/xdg/config/mimeapps.list"; then - fail "snapshot-free uninstall left a default naming a removed project entry" -fi -grep -qxF 'text/plain=third-party.desktop' "$base/xdg/config/mimeapps.list" \ - || fail "snapshot-free MIME cleanup changed an unrelated default" -! grep -q '^x-scheme-handler/ableton=third-party.desktop$' \ - "$base/xdg/config/mimeapps.list" \ - || fail "snapshot-free MIME cleanup invented restoration data it did not retain" -ok "snapshot-free uninstall clears exact project defaults and preserves unrelated MIME entries" - -# Link shutdown is helpful cleanup, not authority to remove a proven runtime. -base="$(new_fixture optional-link)" -run_uninstall "$base" --keep-prefix TEST_LINK_DISABLE_RC=73 \ - > "$base/out" 2> "$base/err" \ - || fail "Link cleanup failure made uninstall fail" -[ ! -e "$base/runtime" ] || fail "Link cleanup failure retained the runtime" -[ -e "$(state_path "$base")" ] \ - || fail "Link cleanup failure discarded the support files needed to retry" -grep -q 'Ableton Link integration may still be enabled' "$base/err" \ - || fail "Link cleanup warning did not identify what remains" -assert_warning_contract "$base/err" -ok "Link cleanup failure is warning-only and retains retry support" - -# Generated desktop helpers are optional. A readable helper with broken shell -# syntax must be reported without letting a bare source command abort uninstall. -base="$(new_fixture shortcut-helper-source)" -mkdir -p -- "$base/xdg/data/ableton-wine" -cat > "$base/xdg/data/ableton-wine/shortcut-hold.sh" <<'EOF' -# GNOME shortcut hold -if this helper is incomplete -EOF -: > "$(state_path "$base")/hold-v2" -cat > "$base/fakebin/gsettings" <<'EOF' -#!/usr/bin/env bash -exit 0 -EOF -chmod 755 "$base/fakebin/gsettings" -run_uninstall "$base" --keep-prefix > "$base/out" 2> "$base/err" \ - || fail "broken optional shortcut helper made uninstall fail" -[ ! -e "$base/runtime" ] || fail "broken shortcut helper retained the runtime" -grep -q 'shortcut restoration was skipped' "$base/err" \ - || fail "broken shortcut helper warning did not identify what remains" -assert_warning_contract "$base/err" -ok "broken generated shortcut helper is warning-only" - -# Failure to inspect a legacy panel shortcut is optional and must leave the -# shortcut untouched while core removal proceeds. -base="$(new_fixture panel-ownership-query)" -state="$(state_path "$base")" -panel="$base/home/.local/bin/pipeasio-settings" -mkdir -p -- "$(dirname "$panel")" "$base/xdg/data/ableton-wine" -ln -s -- "$base/runtime/bin/pipeasio-settings" "$panel" -printf '2026.01.01.1\n' > "$base/xdg/data/ableton-wine/VERSION" -printf 'runtime\t%s\twine-d2d1-nspa-11.13\n' "$base/runtime" \ - > "$state/install-manifest.tsv" -cat > "$base/fakebin/awk" <<'EOF' -#!/usr/bin/env bash -case "$*" in - *'$2==p && ($1=="file" || $1=="config" || $1=="symlink")'*) exit 77 ;; -esac -exec /usr/bin/awk "$@" -EOF -chmod 755 "$base/fakebin/awk" -run_uninstall "$base" --keep-prefix > "$base/out" 2> "$base/err" \ - || fail "panel ownership-query failure made uninstall fail" -[ ! -e "$base/runtime" ] || fail "panel ownership-query failure retained the runtime" -[ -L "$panel" ] || fail "panel ownership-query failure removed its optional shortcut" -grep -q 'PipeASIO Settings shortcuts were left unchanged' "$base/err" \ - || fail "panel ownership-query warning did not identify what remains" -assert_warning_contract "$base/err" -ok "legacy panel ownership-query failure is warning-only" - -# A corrupt optional MIME snapshot cannot veto removal of the runtime. -base="$(new_fixture invalid-mime-state)" -printf 'application/x-not-owned\tforeign.desktop\n' \ - > "$(state_path "$base")/mime-prestate.tsv" -run_uninstall "$base" --keep-prefix > "$base/out" 2> "$base/err" \ - || fail "invalid MIME state made uninstall fail" -[ ! -e "$base/runtime" ] || fail "invalid MIME state retained the runtime" -[ -e "$(state_path "$base")/mime-prestate.tsv" ] \ - || fail "invalid MIME state was discarded before it could be reviewed" -grep -q 'File-opening defaults were left unchanged because the saved settings' "$base/err" \ - || fail "invalid MIME state warning was not explicit" -assert_warning_contract "$base/err" -ok "invalid MIME restoration state is warning-only" - -# Backend query failure is also optional and must retain the exact saved value. -base="$(new_fixture mime-query)" -printf 'application/x-ableton-live-set\tforeign.desktop\n' \ - > "$(state_path "$base")/mime-prestate.tsv" -run_uninstall "$base" --keep-prefix TEST_XDG_QUERY_RC=77 \ - > "$base/out" 2> "$base/err" \ - || fail "MIME query failure made uninstall fail" -[ ! -e "$base/runtime" ] || fail "MIME query failure retained the runtime" -grep -qx $'application/x-ableton-live-set\tforeign.desktop' \ - "$(state_path "$base")/mime-prestate.tsv" \ - || fail "MIME query failure discarded the saved default" -grep -q 'could not be checked' "$base/err" \ - || fail "MIME query warning did not identify the failed operation" -assert_warning_contract "$base/err" -ok "MIME backend failure is warning-only" - -# A recognisable project file is removed authoritatively; an opaque changed -# file at another managed path is preserved without blocking core removal. -base="$(new_fixture authoritative-and-foreign)" -desktop="$base/xdg/data/applications/ableton-live.desktop" -opaque="$base/xdg/data/ableton-wine/ntsyncprobe.exe" -mkdir -p -- "$(dirname "$desktop")" "$(dirname "$opaque")" -cat > "$desktop" < "$opaque" -zero_digest="$(printf '%064d' 0)" -printf 'file\t%s\t%s\nfile\t%s\t%s\n' \ - "$desktop" "$zero_digest" "$opaque" "$zero_digest" \ - > "$(state_path "$base")/install-manifest.tsv" -run_uninstall "$base" --keep-prefix > "$base/out" 2> "$base/err" \ - || fail "preserved foreign integration file made uninstall fail" -[ ! -e "$desktop" ] \ - || fail "recognisable project desktop file was not removed authoritatively" -grep -qxF 'foreign opaque bytes' "$opaque" \ - || fail "unrecognised opaque file was removed" -[ ! -e "$base/runtime" ] \ - || fail "preserved foreign integration file retained the runtime" -grep -qF "kept an unrecognised or user-modified file at $opaque" "$base/err" \ - || fail "preserved foreign integration file was not reported" -assert_warning_contract "$base/err" -ok "recognisable project files are removed while foreign data is preserved" - -# Snapshot-free launcher replacement has one two-path ownership relation: the -# canonical path is generated, while its adjacent .bak contains the displaced -# user object. A failed cleanup must remain retryable without deleting either -# copy, and the retry must recognise an already-restored launcher. -base="$(new_fixture adjacent-launcher-retry)" -state="$(state_path "$base")" -panel_command="$base/home/.local/bin/pipeasio-settings" -panel_desktop="$base/xdg/data/applications/pipeasio-settings.desktop" -mkdir -p -- "$base/runtime/bin" "$(dirname "$panel_command")" \ - "$(dirname "$panel_desktop")" -printf '#!/bin/sh\nexit 0\n' > "$base/runtime/bin/pipeasio-settings" -chmod 755 "$base/runtime/bin/pipeasio-settings" -ln -s -- "$base/runtime/bin/pipeasio-settings" "$panel_command" -printf '[Desktop Entry]\nName=PipeASIO Settings\n' > "$panel_desktop" -printf '#!/bin/sh\necho displaced-user-command\n' > "${panel_command}.bak" -chmod 755 "${panel_command}.bak" -printf '[Desktop Entry]\nName=Displaced user desktop\n' > "${panel_desktop}.bak" -cp -- "${panel_command}.bak" "$base/command.before" -cp -- "${panel_desktop}.bak" "$base/desktop.before" -command_digest="$({ printf 'symlink\0'; readlink -n -- "$panel_command"; } \ - | sha256sum | awk '{print $1}')" -desktop_digest="$(sha256sum -- "$panel_desktop")" -desktop_digest="${desktop_digest%% *}" -command_backup_digest="$(sha256sum -- "${panel_command}.bak")" -command_backup_digest="${command_backup_digest%% *}" -desktop_backup_digest="$(sha256sum -- "${panel_desktop}.bak")" -desktop_backup_digest="${desktop_backup_digest%% *}" -# Put each backup row first to prove record order cannot delete the recovery -# copy before the canonical launcher is considered. -printf 'file\t%s\t%s\nsymlink\t%s\t%s\nfile\t%s\t%s\nfile\t%s\t%s\nruntime\t%s\twine-d2d1-nspa-11.13\n' \ - "${panel_command}.bak" "$command_backup_digest" \ - "$panel_command" "$command_digest" \ - "${panel_desktop}.bak" "$desktop_backup_digest" \ - "$panel_desktop" "$desktop_digest" "$base/runtime" \ - > "$state/install-manifest.tsv" -run_uninstall "$base" --keep-prefix FAIL_RM_TARGET="${panel_command}.bak" \ - > "$base/first.out" 2> "$base/first.err" \ - || fail "saved-launcher cleanup failure made uninstall fail" -cmp -s -- "$base/command.before" "$panel_command" \ - || fail "saved command was not restored before its cleanup failure" -cmp -s -- "$base/desktop.before" "$panel_desktop" \ - || fail "saved desktop was not restored" -[ -f "${panel_command}.bak" ] \ - || fail "failed saved-command cleanup did not retain its retry copy" -[ ! -e "${panel_desktop}.bak" ] && [ ! -L "${panel_desktop}.bak" ] \ - || fail "successful saved-desktop cleanup retained its extra copy" -[ ! -e "$base/runtime" ] \ - || fail "saved-launcher cleanup failure retained the configured runtime" -assert_warning_contract "$base/first.err" -run_uninstall "$base" --keep-prefix > "$base/retry.out" 2> "$base/retry.err" \ - || fail "saved-launcher cleanup retry failed" -if ! cmp -s -- "$base/command.before" "$panel_command" \ - || ! cmp -s -- "$base/desktop.before" "$panel_desktop"; then - fail "saved-launcher cleanup retry changed a restored user launcher" -fi -[ ! -e "${panel_command}.bak" ] && [ ! -L "${panel_command}.bak" ] \ - || fail "saved-launcher cleanup retry retained the extra command copy" -if grep -q '^!! warning:' "$base/retry.err"; then - fail "idempotent saved-launcher cleanup retry emitted a warning" -fi -ok "adjacent launcher backups restore user objects and retry idempotently" -# If the canonical launcher changed after installation, neither it nor its exact -# saved earlier launcher is disposable project state. -base="$(new_fixture modified-adjacent-launcher)" -state="$(state_path "$base")" -panel_desktop="$base/xdg/data/applications/pipeasio-settings.desktop" -mkdir -p -- "$(dirname "$panel_desktop")" -printf '[Desktop Entry]\nName=Generated panel\n' > "$panel_desktop" -generated_digest="$(sha256sum -- "$panel_desktop")" -generated_digest="${generated_digest%% *}" -printf '[Desktop Entry]\nName=Saved foreign panel\n' > "${panel_desktop}.bak" -saved_digest="$(sha256sum -- "${panel_desktop}.bak")" -saved_digest="${saved_digest%% *}" -printf 'file\t%s\t%s\nfile\t%s\t%s\n' \ - "$panel_desktop" "$generated_digest" \ - "${panel_desktop}.bak" "$saved_digest" \ - > "$state/install-manifest.tsv" -printf '[Desktop Entry]\nName=User modified after install\n' > "$panel_desktop" -cp -- "$panel_desktop" "$base/modified.before" -cp -- "${panel_desktop}.bak" "$base/saved.before" -run_uninstall "$base" --keep-prefix > "$base/out" 2> "$base/err" \ - || fail "modified canonical launcher made uninstall fail" -if ! cmp -s -- "$base/modified.before" "$panel_desktop" \ - || ! cmp -s -- "$base/saved.before" "${panel_desktop}.bak"; then - fail "modified launcher cleanup deleted or changed foreign data" -fi -[ ! -e "$base/runtime" ] \ - || fail "modified launcher retained the configured runtime" -[ "$(grep -cF "kept the user-modified launcher at $panel_desktop" "$base/err")" -eq 1 ] \ - || fail "modified launcher pair did not produce exactly one preservation warning" -assert_warning_contract "$base/err" -ok "modified canonical and saved launchers are both preserved with one warning" - -# Older installations may have a persistent pre-install copy for both the -# canonical launcher and a foreign object that already occupied .bak. -# Those explicit records take precedence over the snapshot-free pair fallback. -base="$(new_fixture persistent-launcher-prestate)" -state="$(state_path "$base")" -panel_desktop="$base/xdg/data/applications/pipeasio-settings.desktop" -mkdir -p -- "$(dirname "$panel_desktop")" "$state/install-prestate" -printf '[Desktop Entry]\nName=Generated panel\n' > "$panel_desktop" -printf '[Desktop Entry]\nName=Displaced canonical\n' > "${panel_desktop}.bak" -canonical_digest="$(sha256sum -- "$panel_desktop")" -canonical_digest="${canonical_digest%% *}" -adjacent_digest="$(sha256sum -- "${panel_desktop}.bak")" -adjacent_digest="${adjacent_digest%% *}" -canonical_id="$(printf '%s' "$panel_desktop" | sha256sum | awk '{print $1}')" -adjacent_id="$(printf '%s' "${panel_desktop}.bak" | sha256sum | awk '{print $1}')" -printf '[Desktop Entry]\nName=Original canonical\n' \ - > "$state/install-prestate/$canonical_id" -printf '[Desktop Entry]\nName=Original foreign backup\n' \ - > "$state/install-prestate/$adjacent_id" -printf 'present\t%s\t%s\npresent\t%s\t%s\n' \ - "$panel_desktop" "$state/install-prestate/$canonical_id" \ - "${panel_desktop}.bak" "$state/install-prestate/$adjacent_id" \ - > "$state/install-prestate.tsv" -printf 'file\t%s\t%s\nfile\t%s\t%s\n' \ - "$panel_desktop" "$canonical_digest" \ - "${panel_desktop}.bak" "$adjacent_digest" \ - > "$state/install-manifest.tsv" -run_uninstall "$base" --keep-prefix > "$base/out" 2> "$base/err" \ - || fail "persistent launcher prestate made uninstall fail" -grep -qxF 'Name=Original canonical' "$panel_desktop" \ - || fail "persistent prestate did not restore the original canonical launcher" -grep -qxF 'Name=Original foreign backup' "${panel_desktop}.bak" \ - || fail "persistent prestate did not restore a pre-existing foreign .bak" -[ ! -e "$base/runtime" ] \ - || fail "persistent launcher prestate retained the configured runtime" -ok "persistent launcher prestate preserves a pre-existing foreign backup" - -# A failed optional file removal is judged by its postcondition and reported; -# it must not be reclassified as a runtime failure. -base="$(new_fixture integration-removal)" -desktop="$base/xdg/data/applications/ableton-live.desktop" -mkdir -p -- "$(dirname "$desktop")" -printf 'managed desktop bytes\n' > "$desktop" -desktop_digest="$(sha256sum -- "$desktop")" -desktop_digest="${desktop_digest%% *}" -printf 'file\t%s\t%s\n' "$desktop" "$desktop_digest" \ - > "$(state_path "$base")/install-manifest.tsv" -run_uninstall "$base" --keep-prefix FAIL_RM_TARGET="$desktop" \ - > "$base/out" 2> "$base/err" \ - || fail "project file removal failure made uninstall fail" -[ -f "$desktop" ] || fail "file-removal failure fixture unexpectedly disappeared" -[ ! -e "$base/runtime" ] || fail "project file removal failure retained the runtime" -grep -qF "an Ableton Linux file remains at $desktop" "$base/err" \ - || fail "remaining project file was not identified" -assert_warning_contract "$base/err" -ok "project integration removal failure is warning-only" - -# Cache rebuild tools are advisory after the desktop files have been handled. -base="$(new_fixture desktop-cache)" -mkdir -p -- "$base/xdg/data/applications" -run_uninstall "$base" --keep-prefix TEST_DESKTOP_CACHE_RC=71 \ - > "$base/out" 2> "$base/err" \ - || fail "desktop cache failure made uninstall fail" -[ ! -e "$base/runtime" ] || fail "desktop cache failure retained the runtime" -grep -q 'desktop application cache could not be refreshed' "$base/err" \ - || fail "desktop cache warning did not identify what remains stale" -assert_warning_contract "$base/err" no -ok "desktop cache refresh failure is warning-only" +CASE="Keep answer leaves the changed installed file in place" +base="$(fixture changed)" +trash_tool "$base" gio +printf 'user replacement\n' > "$base/home/.local/bin/ableton-live" +run_uninstall "$base" --keep-prefix --yes +check "uninstaller completes the Keep answer" test "$RUN_RC" -eq 0 +check "uninstaller leaves replacement in place" grep -qxF "user replacement" \ + "$base/home/.local/bin/ableton-live" +check "Trash log contains zero entries for replacement" not_in_log "$base/trash.log" \ + "$base/home/.local/bin/ableton-live" +check "the user is asked about the changed path" contains "$base" \ + "REMOVE THE CHANGED FILE OR LINK" +check "change is reported" contains "$base" "changed" +check "uninstaller leaves state for the next attempt" test -d "$base/xdg/state/ableton-wine" +check "output omits the Link overwrite question" sh -c \ + '! grep -qiF "Overwrite it with the project unit?" "$1/out" "$1/err"' _ "$base" +check "source scan finds zero setup-link.sh invocations in uninstall.sh" sh -c \ + '! grep -qF "setup-link.sh" "$1/uninstall.sh"' _ "$here" +done_case + +CASE="Remove answer sends the changed file to Trash" +base="$(fixture changed-remove)" +trash_tool "$base" gio +printf 'user replacement\n' > "$base/home/.local/bin/ableton-live" +printf 'r\n' > "$base/input" +RUN_STDIN="$base/input" +run_uninstall "$base" --keep-prefix --yes +RUN_STDIN=/dev/null +check "uninstaller completes approved removal" test "$RUN_RC" -eq 0 +check "uninstaller removes replacement" test ! -e "$base/home/.local/bin/ableton-live" +check "uninstaller sends replacement to Trash" grep -qF \ + "$base/home/.local/bin/ableton-live" "$base/trash.log" +report_says "$base" "Linux integration" Removed +done_case + +CASE="Remove answer restores the saved earlier file" +base="$(fixture changed-saved-file)" +trash_tool "$base" gio +target="$base/home/.local/bin/ableton-live" +state="$base/xdg/state/ableton-wine" +backup="$state/install-prestate/$(printf '%s' "$target" | sha256sum | awk '{print $1}')" +mkdir -p -- "$state/install-prestate" +printf 'original user launcher\n' > "$backup" +printf 'present\t%s\t%s\n' "$target" "$backup" > "$state/install-prestate.tsv" +chmod 600 "$state/install-prestate.tsv" "$backup" +printf 'modified installed launcher\n' > "$target" +printf 'r\n' > "$base/input" +RUN_STDIN="$base/input" +run_uninstall "$base" --keep-prefix --yes +RUN_STDIN=/dev/null +check "uninstaller completes approved removal" test "$RUN_RC" -eq 0 +check "uninstaller restores saved user file" grep -qxF 'original user launcher' "$target" +check "uninstaller sends changed installed file to Trash" grep -qF "$target" "$base/trash.log" +done_case + +CASE="Trash failure stops removal after the chosen program" +base="$(fixture changed-trash-failure)" +trash_tool "$base" gio +trash_tool "$base" trash-put +printf 'user replacement\n' > "$base/home/.local/bin/ableton-live" +printf 'r\n' > "$base/input" +TEST_FAIL_TOOL=gio +RUN_STDIN="$base/input" +run_uninstall "$base" --keep-prefix --yes +RUN_STDIN=/dev/null +TEST_FAIL_TOOL= +check "uninstaller returns a failure status" test "$RUN_RC" -ne 0 +check "uninstaller leaves changed path in place" test -e "$base/home/.local/bin/ableton-live" +check "second Trash program stays unused" not_in_log "$base/trash.log" "trash-put" +check "runtime stays in place after Trash failure" test -d "$base/runtime" +check "log marks Linux integration as failed" grep -q \ + '\[ERR\].*Linux integration' "$base/ui.log" +done_case + +start_process() +{ + local image="$1" prefix="$2" + mkdir -p -- "$(dirname "$image")" + cp -- "$(type -P sleep)" "$image" + chmod 755 "$image" + WINEPREFIX="$prefix" "$image" 30 >/dev/null 2>&1 & + PROCESS_PID=$! + children+=("$PROCESS_PID") + for _ in 1 2 3 4 5 6 7 8 9 10; do + [ "$(readlink -f -- "/proc/$PROCESS_PID/exe" 2>/dev/null || true)" = "$image" ] \ + && tr '\0' '\n' < "/proc/$PROCESS_PID/environ" 2>/dev/null \ + | grep -qxF "WINEPREFIX=$prefix" && return 0 + sleep 0.02 + done + return 1 +} -# Cleanup of the manifest, state tree, and managed config is optional after the -# runtime has gone. Each injected command failure must leave a retryable object. -for cleanup_kind in manifest state config; do - base="$(new_fixture "cleanup-$cleanup_kind")" - state="$(state_path "$base")" - target="" - failure_setting="" - case "$cleanup_kind" in - manifest) - printf 'runtime\t%s\twine-d2d1-nspa-11.13\n' "$base/runtime" \ - > "$state/install-manifest.tsv" - target="$state/install-manifest.tsv" - ;; - state) - target="$state" - failure_setting="FAIL_FIND_TARGET=$target" - ;; - config) - mkdir -p -- "$base/xdg/config/ableton-wine" - cat > "$base/xdg/config/ableton-wine/config" </dev/null || true +wait "$PROCESS_PID" 2>/dev/null || true +done_case + +CASE="Unrelated Wine prefix process does not block removal" +base="$(fixture unrelated-process)" +trash_tool "$base" gio +start_process "$base/foreign/wine" "$base/other-prefix" +run_uninstall "$base" --prefix-only --yes +check "succeeds" test "$RUN_RC" -eq 0 +check "selected prefix removed" test ! -e "$base/prefix" +kill "$PROCESS_PID" 2>/dev/null || true +wait "$PROCESS_PID" 2>/dev/null || true +done_case + +CASE="Relevant prefix process refuses removal" +base="$(fixture busy-prefix)" +trash_tool "$base" gio +start_process "$base/foreign/wine" "$base/prefix" +run_uninstall "$base" --prefix-only --yes +check "fails" test "$RUN_RC" -ne 0 +check "nothing was trashed" test ! -s "$base/trash.log" +check "prefix remains" test -d "$base/prefix" +kill "$PROCESS_PID" 2>/dev/null || true +wait "$PROCESS_PID" 2>/dev/null || true +done_case + +for available in all trash-put kde; do + CASE="Trash selection: $available" + base="$(fixture "trash-$available")" + case "$available" in + all) + trash_tool "$base" gio + trash_tool "$base" trash-put + trash_tool "$base" kioclient + expected=gio ;; + trash-put) + trash_tool "$base" trash-put + trash_tool "$base" kioclient + expected=trash-put ;; + kde) + trash_tool "$base" kioclient + expected=kioclient ;; esac - [ -n "$failure_setting" ] || failure_setting="FAIL_RM_TARGET=$target" - run_uninstall "$base" --keep-prefix "$failure_setting" \ - > "$base/out" 2> "$base/err" \ - || fail "$cleanup_kind cleanup failure made uninstall fail" - [ ! -e "$base/runtime" ] \ - || fail "$cleanup_kind cleanup failure retained the runtime" - [ -e "$target" ] || [ -L "$target" ] \ - || fail "$cleanup_kind cleanup fixture did not remain for retry" - if [ "$cleanup_kind" = state ]; then - grep -qxF 'owner=ableton-linux' "$state/.ableton-linux-state" \ - || fail "partial state cleanup removed its retry ownership marker" - fi - assert_warning_contract "$base/err" + run_uninstall "$base" --prefix-only --yes + check "succeeds" test "$RUN_RC" -eq 0 + check "selects expected tool" test "$(cut -f1 "$base/trash.log" | head -n 1)" = "$expected" + check "uses only the selected tool" test "$(wc -l < "$base/trash.log")" -eq 1 + done_case done -ok "manifest, state, and configuration cleanup failures are warning-only" - -# Optional support-state ownership may change after core removal. That skips -# recursive support cleanup and reports residue without changing the core result. -base="$(new_fixture post-core-state-change)" -state="$(state_path "$base")" -run_uninstall "$base" --keep-prefix \ - CORRUPT_STATE_AFTER_TARGET="$base/runtime" \ - CORRUPT_STATE_FILE="$state/.ableton-linux-state" \ - > "$base/out" 2> "$base/err" \ - || fail "post-core support-state ownership change made uninstall fail" -[ ! -e "$base/runtime" ] || fail "post-core support-state change retained the runtime" -grep -qxF 'changed during uninstall' "$state/.ableton-linux-state" \ - || fail "post-core support-state fixture was not retained" -grep -q 'installer could not confirm that it created their directory' "$base/err" \ - || fail "post-core support-state warning did not identify why cleanup was skipped" -assert_warning_contract "$base/err" -ok "post-core support-state safety failures are warning-only" - -# An older runtime from valid optional information is best-effort. Its removal -# failure cannot displace successful removal of the configured runtime. -base="$(new_fixture extra-runtime-removal)" -state="$(state_path "$base")" -extra_runtime="$base/extra-runtime" -mkdir -p -- "$extra_runtime" -printf 'format=1\nname=wine-d2d1-nspa-11.13\n' \ - > "$extra_runtime/.ableton-linux-runtime" -printf 'runtime\t%s\twine-d2d1-nspa-11.13\nruntime\t%s\twine-d2d1-nspa-11.13\n' \ - "$base/runtime" "$extra_runtime" > "$state/install-manifest.tsv" -run_uninstall "$base" --keep-prefix FAIL_RM_TARGET="$extra_runtime" \ - > "$base/out" 2> "$base/err" \ - || fail "older recorded runtime removal failure made uninstall fail" -[ ! -e "$base/runtime" ] || fail "older runtime failure retained the configured runtime" -[ -e "$extra_runtime/.ableton-linux-runtime" ] \ - || fail "older runtime failure fixture unexpectedly disappeared" -grep -q 'older Wine runtime remains' "$base/err" \ - || fail "older runtime residue was not identified" -assert_warning_contract "$base/err" -ok "older recorded runtime removal failure is warning-only" - -# A command can report failure after reaching the requested end state (for -# example, a wrapper's bookkeeping error). The verified outcome is authoritative. -base="$(new_fixture runtime-status-only-failure)" -run_uninstall "$base" --keep-prefix \ - FAIL_RM_TARGET="$base/runtime" FAIL_RM_REMOVE_FIRST=1 \ - > "$base/out" 2> "$base/err" \ - || fail "runtime command status overrode a successful deletion outcome" -[ ! -e "$base/runtime" ] \ - || fail "status-only runtime failure fixture did not reach the deletion outcome" -grep -qxF 'OK: uninstall complete' "$base/out" \ - || fail "successful runtime deletion outcome was not reported as complete" -ok "runtime deletion is classified by postcondition, not command status alone" -# Output is presentation, not a removal gate. A broken stdout consumer must -# not interrupt the already-authorised runtime/prefix sequence or make its -# verified completion report failure. -base="$(new_fixture presentation-output-failure)" -extra_runtime="$base/extra-runtime" -mkdir -p -- "$extra_runtime" "$base/prefix" -printf 'format=1\nname=wine-d2d1-nspa-11.13\n' \ - > "$extra_runtime/.ableton-linux-runtime" -printf 'format=1\nprefix=%s\n' "$base/prefix" \ - > "$base/prefix/.ableton-linux-prefix" -printf 'registry fixture\n' > "$base/prefix/system.reg" -printf 'runtime\t%s\twine-d2d1-nspa-11.13\nruntime\t%s\twine-d2d1-nspa-11.13\n' \ - "$base/runtime" "$extra_runtime" \ - > "$(state_path "$base")/install-manifest.tsv" -run_uninstall "$base" --delete-prefix > /dev/full 2> "$base/err" \ - || fail "presentation output failure made verified removals fail" -[ ! -e "$base/runtime" ] && [ ! -L "$base/runtime" ] \ - && [ ! -e "$extra_runtime" ] && [ ! -L "$extra_runtime" ] \ - && [ ! -e "$base/prefix" ] && [ ! -L "$base/prefix" ] \ - || fail "presentation output failure interrupted verified removals" -ok "presentation output cannot interrupt or fail verified removals" - -# Ignoring presentation writes must not hide a core deletion failure. -base="$(new_fixture presentation-output-with-runtime-failure)" -if run_uninstall "$base" --keep-prefix FAIL_RM_TARGET="$base/runtime" \ - > /dev/full 2> "$base/err"; then - fail "presentation output handling hid a runtime deletion failure" -fi -[ -e "$base/runtime" ] \ - || fail "runtime failure fixture unexpectedly disappeared with broken output" -grep -q 'runtime removal is incomplete' "$base/err" \ - || fail "broken output obscured the real runtime deletion failure" -ok "presentation output handling preserves core deletion failures" - -# Runtime and requested-prefix deletion are the core operation and stay fatal. -base="$(new_fixture runtime-removal)" -if run_uninstall "$base" --keep-prefix FAIL_RM_TARGET="$base/runtime" \ - > "$base/out" 2> "$base/err"; then - fail "uninstall succeeded while the runtime remained" -fi -[ -e "$base/runtime" ] || fail "runtime failure fixture unexpectedly disappeared" -[ -e "$(state_path "$base")" ] \ - || fail "runtime failure discarded support files needed to retry" -grep -q 'runtime removal is incomplete' "$base/err" \ - || fail "runtime removal failure was not explicit" -grep -q '^ Retry:' "$base/err" \ - || fail "runtime removal failure did not print a retry command" -ok "runtime deletion failure remains fatal" - -base="$(new_fixture prefix-removal)" -mkdir -p -- "$base/prefix" -printf 'format=1\nprefix=%s\n' "$base/prefix" \ - > "$base/prefix/.ableton-linux-prefix" -printf 'registry fixture\n' > "$base/prefix/system.reg" -if run_uninstall "$base" --delete-prefix FAIL_RM_TARGET="$base/prefix" \ - > "$base/out" 2> "$base/err"; then - fail "uninstall succeeded while the requested prefix remained" +CASE="Detected Trash failure stops without fallback" +base="$(fixture trash-failure)" +trash_tool "$base" gio +trash_tool "$base" trash-put +TEST_FAIL_TOOL=gio +run_uninstall "$base" --keep-prefix --yes +TEST_FAIL_TOOL= +check "fails" test "$RUN_RC" -ne 0 +check "failed target remains" test -e "$base/home/.local/bin/ableton-live" +check "later runtime remains" test -d "$base/runtime" +check "prefix remains" test -d "$base/prefix" +check "fallback was not attempted" not_in_log "$base/trash.log" "trash-put" +check "state remains for retry" test -d "$base/xdg/state/ableton-wine" +report_present "$base" +done_case + +CASE="Trash success must actually remove the target" +base="$(fixture trash-left-target)" +trash_tool "$base" gio +trash_tool "$base" trash-put +TEST_LEAVE_TOOL=gio +run_uninstall "$base" --prefix-only --yes +TEST_LEAVE_TOOL= +check "fails" test "$RUN_RC" -ne 0 +check "prefix remains" test -d "$base/prefix" +check "fallback was not attempted" not_in_log "$base/trash.log" "trash-put" +check "state remains for retry" test -d "$base/xdg/state/ableton-wine" +done_case + +CASE="No Trash tool warns before permanent deletion" +base="$(fixture permanent)" +guarded_rm "$base" +run_uninstall "$base" --keep-prefix --yes +check "approved deletion succeeds" test "$RUN_RC" -eq 0 +check "runtime removed" test ! -e "$base/runtime" +check "warning says permanent" contains "$base" "permanent" +check "no Trash command ran" test ! -s "$base/trash.log" +done_case + +CASE="Permanent deletion can be declined" +base="$(fixture permanent-decline)" +guarded_rm "$base" +run_uninstall "$base" --keep-prefix < /dev/null +check "decline exits without an error" test "$RUN_RC" -eq 0 +check "runtime remains" test -d "$base/runtime" +check "warning says permanent" contains "$base" "permanent" +check "runtime was not passed to rm" not_in_log "$base/rm.log" "$base/runtime" +done_case + +CASE="Prefix deletion has a separate confirmation" +base="$(fixture prefix-confirm)" +trash_tool "$base" gio +run_uninstall "$base" --prefix-only < /dev/null +check "decline exits without an error" test "$RUN_RC" -eq 0 +check "prefix remains" test -d "$base/prefix" +check "a separate prefix question is shown" contains "$base" \ + "REMOVE LIVE, ITS AUTHORISATION, AND THE ABLETON-LINUX PREFIX" +check "nothing was trashed" test ! -s "$base/trash.log" +done_case + +if [ "$failures" -ne 0 ]; then + printf 'FAIL: %d failed assertions across %d cases\n' "$failures" "$cases" >&2 + exit 1 fi -[ -e "$base/prefix" ] || fail "prefix failure fixture unexpectedly disappeared" -[ ! -e "$base/runtime" ] || fail "prefix failure occurred before runtime removal" -[ -e "$(state_path "$base")" ] \ - || fail "prefix failure discarded support files needed to retry" -grep -q 'prefix removal is incomplete' "$base/err" \ - || fail "prefix removal failure was not explicit" -grep -q '^ Retry:' "$base/err" \ - || fail "prefix removal failure did not print a retry command" -ok "requested-prefix deletion failure remains fatal" - -# A suggestive sibling name is never delete authority, but the foreign sibling -# is optional and cannot veto removal of the exact configured runtime. -base="$(new_fixture rollback-ownership)" -mkdir -p -- "$base/runtime-rollback-unrecognised" -printf 'foreign saved runtime\n' > "$base/runtime-rollback-unrecognised/sentinel" -run_uninstall "$base" --keep-prefix > "$base/out" 2> "$base/err" \ - || fail "unmarked saved-runtime sibling made uninstall fail" -[ ! -e "$base/runtime" ] \ - || fail "unmarked saved-runtime sibling retained the configured runtime" -grep -qxF 'foreign saved runtime' "$base/runtime-rollback-unrecognised/sentinel" \ - || fail "unmarked saved-runtime sibling was removed" -grep -q 'unrecognised saved Wine runtime was left unchanged' "$base/err" \ - || fail "saved-runtime preservation warning was not explicit" -assert_warning_contract "$base/err" -ok "unrecognised saved-runtime siblings are preserved without vetoing core removal" - -printf '1..%d\n' "$pass" +printf 'PASS: %d minimal uninstall cases\n' "$cases" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index fe53cb65..de0fe6da 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -1,1988 +1,567 @@ #!/usr/bin/env bash -# Remove only project-owned installation state. Parsing, target validation, -# prefix confirmation, and running-client checks all precede the first mutation. -set -euo pipefail -here="$(cd "$(dirname "$0")" && pwd)" -for lib in "$here/lib/config.sh" "$here/config.sh" \ - "${XDG_DATA_HOME:-$HOME/.local/share}/ableton-wine/lib/config.sh"; do - if [ -r "$lib" ]; then . "$lib"; break; fi -done -declare -F ableton_config_init >/dev/null 2>&1 || { - echo "!! Uninstall support is incomplete. Run the latest installer again, then retry uninstall." >&2 - exit 1 -} -# Installer settings are cleanup/retry convenience, not deletion authority. -# Repair mode salvages valid path values (or accepts explicit caller values); -# the runtime/prefix markers and registry checks below remain the hard proof. -ABLETON_CONFIG_LAYOUT_ROOTS='runtime prefix' -export ABLETON_CONFIG_LAYOUT_ROOTS -ableton_config_init repair -for lib in "$here/lib/lifecycle.sh" "$ABLETON_DATA_HOME/lib/lifecycle.sh"; do - if [ -r "$lib" ]; then . "$lib"; break; fi -done -declare -F ableton_prefix_busy >/dev/null 2>&1 || { - echo "!! Uninstall support is incomplete. Run the latest installer again, then retry uninstall." >&2 - exit 1 -} -for lib in "$here/lib/manifest.sh" "$ABLETON_DATA_HOME/lib/manifest.sh"; do - if [ -r "$lib" ]; then . "$lib"; break; fi -done -declare -F ableton_legacy_owned_path >/dev/null 2>&1 || { - echo "!! Uninstall support is incomplete. Run the latest installer again, then retry uninstall." >&2; exit 1; } -for lib in "$here/lib/pipeasio.sh" "$ABLETON_DATA_HOME/lib/pipeasio.sh"; do - if [ -r "$lib" ]; then . "$lib"; break; fi -done -declare -F ableton_pipeasio_unregister >/dev/null 2>&1 || { - echo "!! PipeASIO removal support is incomplete. Run the latest installer again, then retry uninstall." >&2 - exit 1 -} - -delete_prefix=0 -keep_prefix=0 +# Manifest-driven removal for the standalone installer. +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$here/lib/config.sh" +. "$here/lib/lifecycle.sh" +. "$here/lib/manifest.sh" +. "$here/lib/preferences.sh" +. "$here/lib/ui.sh" + +scope=runtime +scope_seen="" assume_yes=0 dry_run=0 -while [ $# -gt 0 ]; do - case "$1" in - --help|-h) - cat <<'EOF' -Usage: uninstall.sh [--keep-prefix|--delete-prefix] [--yes] [--dry-run] -The prefix is kept by default. --delete-prefix removes Live and its Wine-side -authorisation after validating a project marker (or the legacy default path). +usage() +{ + cat <<'EOF' +Usage: uninstall.sh [--keep-prefix | --prefix-only | --delete-prefix] [--yes] [--dry-run] + + --keep-prefix remove Linux integration and the Wine runtime (default) + --prefix-only remove only the ableton-linux prefix + --delete-prefix remove Linux integration, runtime, prefix, and installer state EOF - exit 0 ;; - --keep-prefix) keep_prefix=1 ;; - --delete-prefix) delete_prefix=1 ;; - --prefix) delete_prefix=1; echo "WARNING: --prefix is deprecated; use --delete-prefix" >&2 ;; +} + +for argument in "$@"; do + case "$argument" in + --keep-prefix) + [ -z "$scope_seen" ] || [ "$scope_seen" = runtime ] || { + printf '!! uninstall scope flags conflict\n' >&2; exit 2; + } + scope=runtime; scope_seen=runtime ;; + --prefix-only) + [ -z "$scope_seen" ] || [ "$scope_seen" = prefix ] || { + printf '!! uninstall scope flags conflict\n' >&2; exit 2; + } + scope=prefix; scope_seen=prefix ;; + --delete-prefix) + [ -z "$scope_seen" ] || [ "$scope_seen" = all ] || { + printf '!! uninstall scope flags conflict\n' >&2; exit 2; + } + scope=all; scope_seen=all ;; --yes|-y) assume_yes=1 ;; --dry-run) dry_run=1 ;; - *) echo "!! unknown uninstall option: $1" >&2; exit 2 ;; + --help|-h) usage; exit 0 ;; + *) printf '!! unknown uninstall option: %s\n' "$argument" >&2; usage >&2; exit 2 ;; esac - shift done -[ "$delete_prefix" -eq 0 ] || [ "$keep_prefix" -eq 0 ] || { echo "!! --keep-prefix and --delete-prefix conflict" >&2; exit 2; } -retry_mode=--keep-prefix -[ "$delete_prefix" -eq 0 ] || retry_mode=--delete-prefix -optional_cleanup_incomplete=0 -retain_retry_support=0 +ABLETON_UI_ACTION=uninstall +export ABLETON_UI_ACTION -print_uninstall_retry() -{ - if [ -n "${ABLETON_INSTALLER_PATH:-}" ]; then - printf ' Retry: %q uninstall %s --yes\n' "$ABLETON_INSTALLER_PATH" "$retry_mode" >&2 || true - else - printf ' Retry: bash %q %s --yes\n' "$here/uninstall.sh" "$retry_mode" >&2 || true - fi -} +runtime_status=Remains +prefix_status=Remains +integration_status=Remains +settings_status=Remains +state_status=Remains +report_on_exit=0 +lock_held=0 -warn_optional_remaining() +path_exists() { - local message="$1" remedy="${2:-}" - printf '!! warning: %s\n' "$message" >&2 || true - [ -z "$remedy" ] || printf ' %s\n' "$remedy" >&2 || true - optional_cleanup_incomplete=1 - retain_retry_support=1 - return 0 + [ -e "$1" ] || [ -L "$1" ] } -warn_optional_notice() +print_report() { - local message="$1" remedy="${2:-}" - printf '!! warning: %s\n' "$message" >&2 || true - [ -z "$remedy" ] || printf ' %s\n' "$remedy" >&2 || true - optional_cleanup_incomplete=1 - return 0 + ui_heading u_report_heading + ui_note u_report_runtime "$runtime_status" + ui_note u_report_prefix "$prefix_status" + ui_note u_report_integration "$integration_status" + ui_note u_report_settings "$settings_status" + ui_note u_report_state "$state_status" } -# Progress text is not an uninstall precondition. In particular, a closed -# terminal or pipe must not stop the verified removals that follow it, or turn -# a completed removal into a failed command. -uninstall_note() +cleanup() { - printf '%s\n' "$@" || true + local status="$?" + trap - EXIT HUP INT TERM + if [ "$lock_held" -eq 1 ]; then + ableton_install_lock_release >/dev/null 2>&1 || true + fi + ui_cleanup "$status" || true + [ "$report_on_exit" -eq 0 ] || print_report + exit "$status" } +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' HUP TERM -manifest="$ABLETON_STATE_HOME/install-manifest.tsv" -prestate="$ABLETON_STATE_HOME/install-prestate.tsv" -mime_prestate="$ABLETON_STATE_HOME/mime-prestate.tsv" -uninstall_state_digest() +fail() { - if [ -e "$1" ] || [ -L "$1" ]; then - ableton_manifest_digest "$1" - else - printf 'absent\n' - fi + ableton_config_error "$*" + return 1 } -safe_runtime="$(ableton_path_is_safe_delete_target "$ABLETON_WINE_ROOT")" || { - echo "!! unsafe runtime target in configuration: $ABLETON_WINE_ROOT" >&2; exit 2; } -safe_prefix="$(ableton_path_is_safe_delete_target "$ABLETON_WINEPREFIX")" || { - echo "!! unsafe prefix target in configuration: $ABLETON_WINEPREFIX" >&2; exit 2; } -[ ! -L "$ABLETON_WINE_ROOT" ] || { echo "!! refusing symlink runtime: $ABLETON_WINE_ROOT" >&2; exit 2; } -[ ! -L "$ABLETON_WINEPREFIX" ] || { echo "!! refusing symlink prefix: $ABLETON_WINEPREFIX" >&2; exit 2; } -uninstall_paths_overlap() +raw_target_safe() { - local first="$1" second="$2" - [ "$first" = "$second" ] || [[ "$first" = "$second/"* ]] \ - || [[ "$second" = "$first/"* ]] + local label="$1" raw="$2" safe + [ -n "$raw" ] && [ ! -L "$raw" ] || { + fail "unsafe $label target: $raw" + return 1 + } + safe="$(ableton_path_is_safe_delete_target "$raw" 2>/dev/null || true)" + [ -n "$safe" ] || { + fail "unsafe $label target: $raw" + return 1 + } } -state_home_overlaps_core=0 -if uninstall_paths_overlap "$ABLETON_STATE_HOME" "$safe_runtime" \ - || uninstall_paths_overlap "$ABLETON_STATE_HOME" "$safe_prefix"; then - state_home_overlaps_core=1 -fi - -if [ "$state_home_overlaps_core" -eq 0 ] \ - && ableton_install_state_has_active_transaction; then - echo "!! An earlier installation stopped before recovery finished. Run the latest installer again before uninstalling." >&2 - exit 1 -fi -manifest_present=0 -[ "$state_home_overlaps_core" -eq 1 ] \ - || { [ ! -e "$manifest" ] && [ ! -L "$manifest" ]; } \ - || manifest_present=1 -optional_inventory_usable=1 -mime_prestate_usable=1 -if [ "$state_home_overlaps_core" -eq 1 ]; then - optional_inventory_usable=0 - mime_prestate_usable=0 -elif [ -e "$mime_prestate" ] || [ -L "$mime_prestate" ]; then - if [ ! -f "$mime_prestate" ] || [ -L "$mime_prestate" ] || [ ! -r "$mime_prestate" ]; then - mime_prestate_usable=0 - warn_optional_remaining \ - "File-opening defaults were left unchanged because the saved settings at $mime_prestate could not be read safely" \ - "Move that object aside only after checking that it is not your file." - elif ! ableton_validate_mime_prestate "$mime_prestate" >/dev/null 2>&1; then - mime_prestate_usable=0 - warn_optional_remaining \ - "File-opening defaults were left unchanged because the saved settings at $mime_prestate are not recognised" \ - "Move that file aside only if you no longer need those saved settings." +resolved_target_safe() +{ + local label="$1" raw="$2" resolved="$3" safe + safe="$(ableton_path_is_safe_delete_target "$raw" 2>/dev/null || true)" + [ "$safe" = "$resolved" ] || { + fail "unsafe $label target: $raw" + return 1 + } + if path_exists "$raw"; then + [ -d "$raw" ] && [ ! -L "$raw" ] || { + fail "unsafe $label target: $raw" + return 1 + } fi -else - mime_prestate_usable=0 -fi - -pr182_custom_link_adoption=0 -if [ "$ABLETON_LINKD" != "$ABLETON_DATA_HOME/ableton-linkd" ] \ - && ableton_pr182_custom_link_recorded "$ABLETON_LINKD"; then - pr182_custom_link_adoption=1 -fi +} -legacy_state_adoption=0 -state_home_usable=1 -if [ "$state_home_overlaps_core" -eq 1 ]; then - state_home_usable=0 - warn_optional_remaining \ - "Ableton Linux support files were not cleaned separately because their path overlaps the Wine runtime or prefix: $ABLETON_STATE_HOME" \ - "The requested Wine removal will still continue; inspect any remaining support path afterward." -elif [ -e "$ABLETON_STATE_HOME" ] || [ -L "$ABLETON_STATE_HOME" ]; then - if ableton_state_marker_valid "$ABLETON_STATE_HOME"; then - : - elif [ -d "$ABLETON_STATE_HOME" ] && [ ! -L "$ABLETON_STATE_HOME" ] \ - && ableton_legacy_shortcut_state_valid "$ABLETON_STATE_HOME"; then - legacy_state_adoption=1 - else - state_home_usable=0 - optional_inventory_usable=0 - warn_optional_remaining \ - "Ableton Linux support files were left unchanged because their directory is not recognised: $ABLETON_STATE_HOME" \ - "Inspect that directory and move it aside only if you are certain it belongs to this project." - fi -fi -legacy_prefix_adoption=0 -if [ "$delete_prefix" -eq 1 ] && [ -e "$safe_prefix" ]; then - if ! ableton_prefix_marker_valid "$safe_prefix" "$safe_prefix"; then - if ableton_legacy_default_prefix_valid "$safe_prefix"; then - legacy_prefix_adoption=1 - else - echo "!! The Wine prefix was not deleted because the installer could not confirm that it created it: $safe_prefix" >&2 - exit 2 - fi - fi - [ -f "$safe_prefix/system.reg" ] || { - echo "!! The Wine prefix was not deleted because required registry data is missing: $safe_prefix" >&2; exit 2; } -fi -legacy_runtime_adoption=0 -if [ -e "$safe_runtime" ] \ - && ! ableton_runtime_marker_valid "$safe_runtime" "$ABLETON_RUNTIME_NAME"; then - if ableton_legacy_default_runtime_valid "$safe_runtime"; then - legacy_runtime_adoption=1 - else - echo "!! The Wine runtime was not deleted because the installer could not confirm that it created it: $safe_runtime" >&2 - exit 2 - fi -fi +configured_path_matches() +{ + local key="$1" expected="$2" raw resolved + raw="$(ableton_config_file_value "$key" 2>/dev/null || true)" + [ -n "$raw" ] || return 1 + resolved="$(ableton_realpath_m "$raw" 2>/dev/null || true)" + [ "$resolved" = "$expected" ] +} -mime_prestate_before=unavailable -if [ "$mime_prestate_usable" -eq 1 ]; then - if ! mime_prestate_before="$(ableton_manifest_digest "$mime_prestate" 2>/dev/null)"; then - mime_prestate_usable=0 - warn_optional_remaining \ - "File-opening defaults were left unchanged because the saved settings at $mime_prestate could not be read" \ - "Check the file permissions before retrying." - fi -fi -managed_runtimes=("$safe_runtime") -declare -A managed_runtime_names=(["$safe_runtime"]="$ABLETON_RUNTIME_NAME") -manifest_runtime_records=() +manifest_parent_safe() +{ + local path="$1" parent resolved expected + parent="$(dirname -- "$path")" || return 1 + resolved="$(ableton_realpath_m "$parent")" || return 1 + expected="$resolved/$(basename -- "$path")" + [ "$expected" = "$path" ] +} -validate_uninstall_state() +validate_manifest() { - local kind path detail extra status backup expected digest candidate record - local path_hash_record path_hash - local -A claimed=() prestates=() - local -a manifest_lines=() prestate_lines=() - [ "$state_home_usable" -eq 1 ] || return 1 - ableton_validate_install_state_journals >/dev/null 2>&1 || return 1 - if [ -r "$manifest" ]; then - if ! mapfile -t manifest_lines < "$manifest"; then - echo "!! the installed-file list could not be read" >&2 - return 1 - fi - for record in "${manifest_lines[@]}"; do - if ! IFS=$'\t' read -r kind path detail extra <<< "$record"; then - echo "!! the installed-file list is invalid" >&2 - return 1 - fi - if [ -n "$extra" ] || [ -z "$path" ] || ! ableton_manifest_path_ok "$path" \ - || [ -n "${claimed[$path]+x}" ]; then - echo "!! the installed-file list is invalid or ambiguous" >&2 - return 1 - fi - claimed["$path"]="$kind" - case "$kind" in - file|config|symlink) - ableton_managed_path_allowed "$kind" "$path" \ - && [[ "$detail" =~ ^[0-9a-f]{64}$ ]] || { - echo "!! invalid installed-file entry for $path" >&2; return 1; } ;; - runtime) - [ "$detail" = "$ABLETON_RUNTIME_NAME" ] || { - echo "!! invalid Wine entry for $path" >&2; return 1; } - candidate="$(ableton_path_is_safe_delete_target "$path")" || { - echo "!! unsafe Wine path in the installed-file list: $path" >&2; return 1; } - [ "$candidate" = "$path" ] || { - echo "!! unexpected Wine path in the installed-file list: $path" >&2; return 1; } - [ ! -e "$path" ] || ableton_runtime_marker_valid "$path" "$detail" \ - || { [ "$path" = "$safe_runtime" ] \ - && [ "$legacy_runtime_adoption" -eq 1 ] \ - && ableton_legacy_default_runtime_valid "$path"; } || { - echo "!! Wine installation is not recognised at $path" >&2; return 1; } ;; - *) echo "!! unknown entry in the installed-file list: $kind" >&2; return 1 ;; - esac - done - fi + local manifest="$ABLETON_STATE_HOME/install-manifest.tsv" + local kind path detail extra runtime_count=0 + [ -f "$manifest" ] && [ ! -L "$manifest" ] && [ -r "$manifest" ] || { + fail "the installed-file list is missing or unsafe: $manifest" + return 1 + } + ableton_validate_ownership_manifest "$manifest" || return 1 + while IFS=$'\t' read -r kind path detail extra || [ -n "$kind$path$detail$extra" ]; do + case "$kind" in + runtime) + runtime_count=$((runtime_count + 1)) + [ "$path" = "$ABLETON_WINE_ROOT" ] \ + && [ "$detail" = "$ABLETON_RUNTIME_NAME" ] || { + fail "the installed-file list names a different Wine runtime" + return 1 + } ;; + file|config|symlink) + manifest_parent_safe "$path" || { + fail "the installed-file list reaches an unsafe parent: $path" + return 1 + } ;; + esac + done < "$manifest" + [ "$runtime_count" -eq 1 ] || { + fail "the installed-file list must include one Wine runtime entry" + return 1 + } + ableton_validate_prestate_store +} - if [ -e "$prestate" ] || [ -L "$prestate" ]; then - [ -f "$prestate" ] && [ ! -L "$prestate" ] || { - echo "!! the list of saved earlier files is unsafe" >&2; return 1; } - if ! mapfile -t prestate_lines < "$prestate"; then - echo "!! the list of saved earlier files could not be read" >&2 - return 1 - fi - for record in "${prestate_lines[@]}"; do - if ! IFS=$'\t' read -r status path backup extra <<< "$record"; then - echo "!! the list of saved earlier files is invalid" >&2 - return 1 - fi - if [ -n "$extra" ] || [ "$status" != present ] || [ -z "$path" ] \ - || ! ableton_manifest_path_ok "$path" || [ -n "${prestates[$path]+x}" ] \ - || [ -z "${claimed[$path]+x}" ] \ - || { [ "${claimed[$path]}" != file ] \ - && [ "${claimed[$path]}" != config ] \ - && [ "${claimed[$path]}" != symlink ]; }; then - echo "!! the list of saved earlier files is invalid or ambiguous" >&2 - return 1 - fi - path_hash_record="$(printf '%s' "$path" | sha256sum)" || { - echo "!! the saved earlier copy of $path could not be verified" >&2 - return 1 - } - path_hash="${path_hash_record%% *}" - [[ "$path_hash" =~ ^[0-9a-f]{64}$ ]] || { - echo "!! the saved earlier copy of $path could not be verified" >&2 - return 1 - } - expected="$ABLETON_STATE_HOME/install-prestate/$path_hash" - if [ "$backup" != "$expected" ] \ - || { [ ! -f "$backup" ] && [ ! -L "$backup" ]; }; then - echo "!! the saved earlier copy of $path cannot be restored safely" >&2 - return 1 - fi - digest="$(ableton_manifest_digest "$backup" 2>/dev/null || true)" - [ -n "$digest" ] || { - echo "!! the saved earlier copy of $path cannot be read" >&2 - return 1 - } - prestates["$path"]="$backup" - done - fi +verify_runtime() +{ + path_exists "$ABLETON_WINE_ROOT" || return 0 + [ -d "$ABLETON_WINE_ROOT" ] && [ ! -L "$ABLETON_WINE_ROOT" ] \ + && ableton_runtime_marker_valid "$ABLETON_WINE_ROOT" "$ABLETON_RUNTIME_NAME" || { + fail "The Wine runtime marker names a different runtime: $ABLETON_WINE_ROOT" + return 1 + } +} - return 0 +verify_prefix() +{ + path_exists "$ABLETON_WINEPREFIX" || return 0 + [ -d "$ABLETON_WINEPREFIX" ] && [ ! -L "$ABLETON_WINEPREFIX" ] \ + && ableton_prefix_marker_valid "$ABLETON_WINEPREFIX" "$ABLETON_WINEPREFIX" || { + fail "The ableton-linux prefix marker names a different prefix: $ABLETON_WINEPREFIX" + return 1 + } } -leave_optional_inventory_untouched() +runtime_idle() { - [ "$optional_inventory_usable" -eq 1 ] || return 0 - optional_inventory_usable=0 - manifest_runtime_records=() - managed_runtimes=("$safe_runtime") - warn_optional_remaining \ - "Desktop shortcuts, file-opening settings, and older Wine runtimes were left unchanged because the installer's file list could not be trusted" \ - "Inspect $manifest and $prestate, then retry after moving aside only a file you know is damaged." + local running + running="$(ableton_runtime_pids "$ABLETON_WINE_ROOT")" + [ -z "$running" ] || { + fail "Wine processes are still using the selected runtime: $running" + return 1 + } } -if [ "$optional_inventory_usable" -eq 1 ] \ - && ! validate_uninstall_state >/dev/null 2>&1; then - leave_optional_inventory_untouched -fi +prefix_idle() +{ + local running + running="$(ableton_prefix_wine_processes_any_runtime \ + "$ABLETON_WINEPREFIX" "$ABLETON_WINE_ROOT")" + [ -z "$running" ] || { + fail "Wine processes are still using the selected prefix: $running" + return 1 + } +} -manifest_before=unavailable -prestate_before=unavailable -if [ "$optional_inventory_usable" -eq 1 ]; then - if ! manifest_before="$(uninstall_state_digest "$manifest" 2>/dev/null)" \ - || ! prestate_before="$(uninstall_state_digest "$prestate" 2>/dev/null)"; then - leave_optional_inventory_untouched - fi -fi +trash_backend="" -if [ "$optional_inventory_usable" -eq 1 ] && [ -r "$manifest" ]; then - if ! mapfile -t manifest_runtime_records < "$manifest"; then - leave_optional_inventory_untouched +select_trash_backend() +{ + if command -v gio >/dev/null 2>&1; then + trash_backend=gio + elif command -v trash-put >/dev/null 2>&1; then + trash_backend=trash-put + elif command -v kioclient >/dev/null 2>&1; then + trash_backend=kioclient else - inventory_runtime_invalid=0 - for manifest_record in "${manifest_runtime_records[@]}"; do - if ! IFS=$'\t' read -r kind path detail extra <<< "$manifest_record" \ - || [ -n "$extra" ]; then - inventory_runtime_invalid=1 - break - fi - [ "$kind" = runtime ] || continue - [ "$path" != "$safe_runtime" ] || continue - if ! candidate="$(ableton_path_is_safe_delete_target "$path")" \ - || [ "$candidate" != "$path" ] || [ -L "$path" ] \ - || [ "$detail" != "$ABLETON_RUNTIME_NAME" ] \ - || { [ -e "$candidate" ] \ - && ! ableton_runtime_marker_valid "$candidate" "$detail"; }; then - inventory_runtime_invalid=1 - break - fi - managed_runtimes+=("$candidate") - managed_runtime_names["$candidate"]="$detail" - done - if [ "$inventory_runtime_invalid" -eq 1 ]; then - leave_optional_inventory_untouched - fi + trash_backend=permanent fi -fi +} -if [ "$dry_run" -eq 1 ]; then - echo "PLAN: uninstall Ableton Linux" - printf ' remove the installed Wine runtime and saved previous versions: %s\n' \ - "${managed_runtimes[*]}" - if [ "$optional_inventory_usable" -eq 0 ]; then - echo " leave desktop shortcuts, file-opening settings, and older Wine runtimes unchanged" - else - echo " remove Ableton Linux launchers, icons, file-opening support, and diagnostic tools" +remove_path() +{ + local path="$1" status=0 + path_exists "$path" || return 0 + case "$trash_backend" in + gio) gio trash -- "$path" || status=$? ;; + trash-put) trash-put "$path" || status=$? ;; + kioclient) kioclient move "$path" trash:/ || status=$? ;; + permanent) rm -rf -- "$path" || status=$? ;; + *) return 1 ;; + esac + if [ "$status" -ne 0 ] || path_exists "$path"; then + fail "removal stopped at $path after the chosen Trash program failed" + return 1 fi - if [ "$mime_prestate_usable" -eq 1 ]; then - echo " restore the file-opening defaults that were in use before installation" - elif [ "$optional_inventory_usable" -eq 1 ]; then - echo " clear only file-opening defaults that name Ableton Linux" + if [ "$trash_backend" = permanent ]; then + ui_status u_deleted_permanently "$path" else - echo " leave file-opening defaults unchanged" - fi - echo " remove installer settings and support files" - if [ "$delete_prefix" -eq 0 ] && [ -f "$safe_prefix/system.reg" ]; then - printf ' remove PipeASIO from the kept Wine prefix: %s\n' "$safe_prefix" - fi - if ! "$here/setup-link.sh" plan-disable; then - warn_optional_notice \ - "the Link shutdown plan could not be produced; Link settings may remain enabled" \ - "Close any Ableton Link helper before retrying." - fi - [ "$delete_prefix" -eq 0 ] || printf ' delete the Wine prefix, including Live and its authorisation: %s\n' "$safe_prefix" - exit 0 -fi - -# Gather all consent before stopping anything or deleting any file. -if [ "$delete_prefix" -eq 1 ] && [ -e "$safe_prefix" ] && [ "$assume_yes" -ne 1 ]; then - answer="" - if [ -t 0 ]; then - printf 'Delete %s? This removes Live and its Wine-side authorisation. [y/N] ' "$safe_prefix" >&2 - read -r -t 60 answer || answer="" - fi - case "$answer" in y|Y|yes|YES|Yes) ;; *) echo "!! prefix deletion was not confirmed; nothing was changed" >&2; exit 1 ;; esac -fi - -runtime_pids_all="" -runtime_stop_confirmed="$assume_yes" -for proc in /proc/[0-9]*; do - pid="${proc#/proc/}" - ableton_pid_uses_runtime "$pid" && runtime_pids_all="$runtime_pids_all $pid" -done -if [ -n "$runtime_pids_all" ]; then - scoped=" $(ableton_prefix_pids | tr '\n' ' ') " - for pid in $runtime_pids_all; do - case "$scoped" in *" $pid "*) ;; *) - echo "!! runtime is used by another prefix (PID $pid); close it before uninstalling" >&2 - exit 1 ;; - esac - done - if [ "$assume_yes" -ne 1 ]; then - answer="" - if [ -t 0 ]; then - printf 'Stop every running client in the selected prefix and uninstall? [y/N] ' >&2 - read -r -t 60 answer || answer="" - fi - case "$answer" in y|Y|yes|YES|Yes) ;; *) echo "!! nothing was changed" >&2; exit 1 ;; esac - runtime_stop_confirmed=1 - fi -fi - -# A previous configured runtime may also remain in the ownership manifest. -# Never delete it while any process still executes from it; those clients are -# outside the currently selected runtime coordinator and must be closed first. -declare -A skipped_managed_runtimes=() -for candidate in "${managed_runtimes[@]}"; do - [ "$candidate" != "$safe_runtime" ] || continue - [ -z "${skipped_managed_runtimes[$candidate]+x}" ] || continue - for proc in /proc/[0-9]*; do - pid="${proc#/proc/}" - exe="$(readlink -f "$proc/exe" 2>/dev/null || true)" - case "$exe" in "$candidate"/*) - skipped_managed_runtimes["$candidate"]=1 - warn_optional_remaining \ - "an older Wine runtime was left unchanged because PID $pid is using it: $candidate" \ - "Close that program before retrying if you want this older runtime removed." - break ;; - esac - done -done - -# All refusal checks and any interactive consent happen before the lock creates -# installer state. Once serialized, verify that the inspected installation did -# not change while this process was waiting for the lock. -ableton_install_lock_acquire -if [ "$state_home_overlaps_core" -eq 0 ] \ - && ableton_install_state_has_active_transaction; then - echo "!! unfinished installer recovery appeared while uninstall was waiting; finish or roll it back first" >&2 - exit 1 -fi -if [ "$optional_inventory_usable" -eq 1 ]; then - manifest_after=unavailable - prestate_after=unavailable - if ! manifest_after="$(uninstall_state_digest "$manifest" 2>/dev/null)" \ - || ! prestate_after="$(uninstall_state_digest "$prestate" 2>/dev/null)" \ - || [ "$manifest_before" != "$manifest_after" ] \ - || [ "$prestate_before" != "$prestate_after" ] \ - || ! validate_uninstall_state >/dev/null 2>&1; then - leave_optional_inventory_untouched - fi -fi -if [ "$mime_prestate_usable" -eq 1 ]; then - if ! mime_prestate_after="$(ableton_manifest_digest "$mime_prestate" 2>/dev/null)"; then - mime_prestate_usable=0 - warn_optional_remaining \ - "File-opening defaults were left unchanged because the saved settings at $mime_prestate could not be read again" \ - "Check the file permissions before retrying." - elif [ "$mime_prestate_before" != "$mime_prestate_after" ] \ - || ! ableton_validate_mime_prestate "$mime_prestate" >/dev/null 2>&1; then - mime_prestate_usable=0 - warn_optional_remaining \ - "File-opening defaults were left unchanged because their saved settings changed while uninstall was starting: $mime_prestate" \ - "Review that file, then retry if you still want the old defaults restored." - fi -fi -if [ "$state_home_usable" -eq 1 ] \ - && { [ -e "$ABLETON_STATE_HOME" ] || [ -L "$ABLETON_STATE_HOME" ]; } \ - && ! ableton_state_marker_valid "$ABLETON_STATE_HOME" \ - && ! { [ "$legacy_state_adoption" -eq 1 ] \ - && ableton_legacy_shortcut_state_valid "$ABLETON_STATE_HOME"; }; then - if [ "$legacy_runtime_adoption" -eq 1 ] || [ "$legacy_prefix_adoption" -eq 1 ]; then - echo "!! The Ableton Linux support directory changed while uninstall was starting, so the older runtime or prefix was left unchanged. Run uninstall again." >&2 - exit 1 + ui_status u_moved_to_trash "$path" fi - state_home_usable=0 - legacy_state_adoption=0 - leave_optional_inventory_untouched - warn_optional_remaining \ - "Ableton Linux support files were left unchanged because their directory changed while uninstall was starting: $ABLETON_STATE_HOME" \ - "Inspect that directory before retrying." -fi -[ ! -L "$ABLETON_WINE_ROOT" ] && [ ! -L "$ABLETON_WINEPREFIX" ] || { - echo "!! installation paths changed; retry uninstall" >&2 - exit 1 -} -[ ! -e "$safe_runtime" ] \ - || ableton_runtime_marker_valid "$safe_runtime" "$ABLETON_RUNTIME_NAME" \ - || { [ "$legacy_runtime_adoption" -eq 1 ] \ - && ableton_legacy_default_runtime_valid "$safe_runtime"; } || { - echo "!! The Wine runtime changed or is no longer recognised; nothing was deleted. Run uninstall again." >&2 - exit 1 } -if [ "$delete_prefix" -eq 1 ] && [ -e "$safe_prefix" ]; then - ableton_prefix_marker_valid "$safe_prefix" "$safe_prefix" \ - || { [ "$legacy_prefix_adoption" -eq 1 ] \ - && ableton_legacy_default_prefix_valid "$safe_prefix"; } || { - echo "!! The Wine prefix changed or is no longer recognised; nothing was deleted. Run uninstall again." >&2 - exit 1 - } -fi -for candidate in "${managed_runtimes[@]}"; do - [ "$candidate" = "$safe_runtime" ] || [ -z "${skipped_managed_runtimes[$candidate]+x}" ] \ - || continue - [ ! -e "$candidate" ] \ - || ableton_runtime_marker_valid "$candidate" "${managed_runtime_names[$candidate]}" \ - || { [ "$candidate" = "$safe_runtime" ] \ - && [ "$legacy_runtime_adoption" -eq 1 ] \ - && ableton_legacy_default_runtime_valid "$candidate"; } || { - if [ "$candidate" = "$safe_runtime" ]; then - echo "!! The Wine runtime at $candidate changed or is no longer recognised; nothing was deleted. Run uninstall again." >&2 - exit 1 - fi - skipped_managed_runtimes["$candidate"]=1 - warn_optional_remaining \ - "an older Wine runtime was left unchanged because the installer no longer recognises it: $candidate" \ - "Inspect that runtime before retrying." - } -done -if [ "$legacy_state_adoption" -eq 1 ] \ - && [ "$legacy_runtime_adoption" -eq 0 ] && [ "$legacy_prefix_adoption" -eq 0 ]; then - if ! ableton_mark_state_home; then - state_home_usable=0 - legacy_state_adoption=0 - leave_optional_inventory_untouched - warn_optional_remaining \ - "legacy shortcut support files were left unchanged because their directory could not be prepared safely" \ - "Inspect $ABLETON_STATE_HOME before retrying." - fi -fi -uninstall_adoption_transaction="" -uninstall_adoption_active=0 -uninstall_adoption_core_complete=0 -uninstall_adoption_cleanup() + +prestate_backup() { - local rc=$? restore_rc=0 - trap - EXIT - if [ "$uninstall_adoption_active" -eq 1 ] && [ "$rc" -ne 0 ]; then - ableton_txn_rollback_files "$uninstall_adoption_transaction" || restore_rc=1 - if [ "$restore_rc" -eq 0 ]; then - rm -f -- "$uninstall_adoption_transaction/active" 2>/dev/null || true - if [ -e "$uninstall_adoption_transaction/active" ] \ - || [ -L "$uninstall_adoption_transaction/active" ]; then - restore_rc=1 - fi - fi - if [ "$restore_rc" -eq 0 ]; then - rm -rf -- "$uninstall_adoption_transaction" 2>/dev/null || true - if [ -e "$uninstall_adoption_transaction" ] \ - || [ -L "$uninstall_adoption_transaction" ]; then - restore_rc=1 - fi - fi - if [ "$restore_rc" -ne 0 ]; then - echo "!! Uninstall stopped and the older installation may still need repair. Keep the files at $uninstall_adoption_transaction and report the problem before retrying." >&2 - print_uninstall_retry - fi - fi - exit "$rc" + local path="$1" index="$ABLETON_STATE_HOME/install-prestate.tsv" + [ -f "$index" ] && [ ! -L "$index" ] || return 1 + awk -F '\t' -v p="$path" '$1=="present" && $2==p { print $3; found=1 } END { exit !found }' \ + "$index" } -if [ "$legacy_runtime_adoption" -eq 1 ] || [ "$legacy_prefix_adoption" -eq 1 ]; then - # The adoption transaction itself creates project state. Establish that - # directory's ownership before creating transactions beneath it, otherwise - # a legacy install with no prior state would later look like foreign state - # created by the uninstall itself. - ableton_prepare_transactions_dir || { - echo "!! The older Ableton Linux installation could not be prepared safely, so nothing was deleted." >&2 - exit 1 - } - uninstall_adoption_transaction="$(mktemp -d "$ABLETON_STATE_HOME/transactions/uninstall-adopt.XXXXXX")" - ABLETON_TRANSACTION_DIR="$uninstall_adoption_transaction" - export ABLETON_TRANSACTION_DIR - uninstall_adoption_active=1 - trap uninstall_adoption_cleanup EXIT - ableton_txn_init - if [ "$legacy_runtime_adoption" -eq 1 ]; then - ableton_adopt_runtime_marker "$safe_runtime" "$ABLETON_RUNTIME_NAME" - fi - if [ "$legacy_prefix_adoption" -eq 1 ]; then - ableton_adopt_prefix_marker "$safe_prefix" "$safe_prefix" - fi - # Keep only these marker snapshots until uninstall succeeds. Later file - # lifecycle work has its own durable records and must not append to this - # narrow legacy-adoption transaction. - unset ABLETON_TRANSACTION_DIR -fi -locked_runtime_pids="$(ableton_runtime_pids)" -if [ -n "$locked_runtime_pids" ]; then - for pid in $locked_runtime_pids; do - if ! ableton_pid_uses_prefix "$pid"; then - echo "!! runtime is used by another prefix (PID $pid); close it before uninstalling" >&2 - exit 1 - fi - done - [ "$runtime_stop_confirmed" -eq 1 ] || { - echo "!! a Wine client started while uninstall was waiting; retry when it is closed" >&2 - exit 1 - } -fi -for candidate in "${managed_runtimes[@]}"; do - [ "$candidate" != "$safe_runtime" ] || continue - for proc in /proc/[0-9]*; do - pid="${proc#/proc/}" - exe="$(readlink -f "$proc/exe" 2>/dev/null || true)" - case "$exe" in "$candidate"/*) - skipped_managed_runtimes["$candidate"]=1 - warn_optional_remaining \ - "an older Wine runtime was left unchanged because PID $pid began using it: $candidate" \ - "Close that program before retrying if you want this older runtime removed." - break ;; - esac - done -done - -# Marker adoption is itself the migration commit. Finish its private journal -# before the first destructive uninstall action and retain the canonical -# markers on every later partial failure, so a retry no longer depends on -# legacy launcher/VERSION evidence that the first attempt may have removed. -if [ "$uninstall_adoption_active" -eq 1 ]; then - uninstall_adoption_active=0 - trap - EXIT - if ableton_mark_transaction_core_complete "$uninstall_adoption_transaction" \ - 2>/dev/null; then - uninstall_adoption_core_complete=1 - fi - rm -f -- "$uninstall_adoption_transaction/active" 2>/dev/null || true - rm -rf -- "$uninstall_adoption_transaction" 2>/dev/null || true - if [ -e "$uninstall_adoption_transaction" ] || [ -L "$uninstall_adoption_transaction" ]; then - if [ "$uninstall_adoption_core_complete" -eq 1 ] \ - || { [ ! -e "$uninstall_adoption_transaction/active" ] \ - && [ ! -L "$uninstall_adoption_transaction/active" ]; }; then - warn_optional_notice \ - "temporary legacy-install cleanup remains at $uninstall_adoption_transaction" \ - "Check its permissions; the recognised installation is already safe to retry." - else - warn_optional_remaining \ - "temporary legacy-install cleanup remains active at $uninstall_adoption_transaction" \ - "Keep those files for inspection before retrying." - fi - fi -fi - -# Resolve every recursive runtime target before deleting any of them. A failed -# or partial directory scan must not look like an empty rollback inventory. -managed_rollbacks=() -declare -A managed_rollback_names=() -declare -A managed_rollback_owners=() -declare -A skipped_managed_rollbacks=() -runtime_inventory="" -if ! runtime_inventory="$(mktemp "${TMPDIR:-/tmp}/ableton-uninstall-runtimes.XXXXXX")"; then - warn_optional_notice \ - "older saved Wine runtimes could not be inspected because a temporary uninstall file could not be created" \ - "Remove any older saved runtimes manually, or retry after checking the temporary-directory permissions." -fi -if [ -n "$runtime_inventory" ]; then - for candidate in "${managed_runtimes[@]}"; do - [ -z "${skipped_managed_runtimes[$candidate]+x}" ] || continue - if ! runtime_parent="$(dirname "$candidate")" \ - || ! runtime_base="$(basename "$candidate")"; then - warn_optional_remaining \ - "older saved Wine runtimes related to $candidate could not be located" \ - "Retry after checking that the standard path tools are working." - continue - fi - if [ -e "$runtime_parent" ] || [ -L "$runtime_parent" ]; then - if [ ! -d "$runtime_parent" ] || [ -L "$runtime_parent" ]; then - warn_optional_remaining \ - "older saved Wine runtimes were left unchanged because their parent directory is not safe to inspect: $runtime_parent" \ - "Inspect that path before retrying." - continue - fi - else - continue - fi - if ! : > "$runtime_inventory"; then - warn_optional_notice \ - "older saved Wine runtimes could not be inspected because the temporary uninstall file is not writable: $runtime_inventory" \ - "Check that file and the temporary-directory permissions before retrying." - break - fi - if ! find "$runtime_parent" -maxdepth 1 -mindepth 1 -type d -print0 \ - > "$runtime_inventory" 2>/dev/null; then - warn_optional_remaining \ - "older saved Wine runtimes were left unchanged because $runtime_parent could not be inspected" \ - "Check the directory permissions before retrying." - continue - fi - while IFS= read -r -d '' old_runtime; do - if ! old_runtime_base="$(basename "$old_runtime")"; then - warn_optional_remaining \ - "an older saved Wine runtime could not be identified and was left unchanged: $old_runtime" \ - "Inspect that path before retrying." - continue - fi - if [[ "$old_runtime_base" != "$runtime_base-rollback-"* \ - && "$old_runtime_base" != "$runtime_base.failed-"* \ - && "$old_runtime_base" != "$runtime_base.transaction-"* ]]; then - continue - fi - if ! old_runtime_safe="$(ableton_path_is_safe_delete_target "$old_runtime")" \ - || [ "$old_runtime_safe" != "$old_runtime" ] || [ -L "$old_runtime" ] \ - || ! ableton_runtime_marker_valid \ - "$old_runtime" "${managed_runtime_names[$candidate]}"; then - warn_optional_remaining \ - "an unrecognised saved Wine runtime was left unchanged at $old_runtime" \ - "Inspect it and remove it manually only if you are certain it belongs to this project." - continue - fi - managed_rollbacks+=("$old_runtime") - managed_rollback_names["$old_runtime"]="${managed_runtime_names[$candidate]}" - managed_rollback_owners["$old_runtime"]="$candidate" - done < "$runtime_inventory" - done - rm -f -- "$runtime_inventory" 2>/dev/null || true - if [ -e "$runtime_inventory" ] || [ -L "$runtime_inventory" ]; then - warn_optional_notice \ - "a temporary uninstall file remains at $runtime_inventory" \ - "Check its permissions, then remove it or retry uninstall." - fi -fi - -uninstall_note "== stop Ableton Linux background services ==" -uninstall_partial=0 -mime_backend_unavailable=0 -if [ "$optional_inventory_usable" -eq 1 ] \ - && ! "$here/setup-link.sh" disable; then - warn_optional_remaining \ - "Ableton Link integration may still be enabled" \ - "Close any Link helper and check the user service before retrying." -fi -# Not "busy && stop": under set -euo pipefail a straggler that survives the stop -# makes the compound fail, and the script exits here - after the trap is cleared, -# silently, skipping the PipeASIO unregister, the shortcut restore, the MIME -# cleanup and the runtime removal below. The stop is best-effort; the gates that -# follow decide what a straggler is allowed to block. -if ableton_prefix_busy "$safe_runtime" "$safe_prefix"; then - # A surviving process is recorded, not just announced. The gate below exits - # before any rm -rf, so marking the uninstall partial is what stops a live - # client having its runtime - and, under --delete-prefix, its prefix - removed - # from underneath it. None of the deletion sites re-check for processes. - ableton_stop_prefix "$safe_runtime" "$safe_prefix" || true - if ableton_prefix_busy "$safe_runtime" "$safe_prefix"; then - echo "!! A Wine program is still running, so the Wine runtime and prefix were left unchanged." >&2 - uninstall_partial=1 - fi -fi -if [ "$uninstall_partial" -eq 1 ]; then - echo "!! close the remaining Wine program, then run uninstall again" >&2 - exit 1 -fi -# A retained prefix must not keep a CLSID pointing into a runtime that is about -# to disappear. Refuse the runtime deletion unless both exact PipeASIO keys -# are gone and verified; deleting the whole prefix needs no registry mutation. -if [ "$delete_prefix" -eq 0 ] && [ -f "$safe_prefix/system.reg" ]; then - [ -x "$safe_runtime/bin/wine" ] && [ -x "$safe_runtime/bin/wineserver" ] || { - echo "!! cannot safely unregister PipeASIO; the selected runtime is incomplete" >&2 - exit 1 - } - export WINEPREFIX="$safe_prefix" - uninstall_wine() - { - ableton_run_bounded 60 "$safe_runtime/bin/wine" "$@" - } - uninstall_wineserver_wait() - { - ableton_prefix_wait "$safe_runtime" "$safe_prefix" - } - uninstall_note "== unregister PipeASIO from the retained prefix ==" - ableton_pipeasio_unregister uninstall_wine uninstall_wineserver_wait -fi +restore_previous() +{ + local path="$1" backup backup_digest current_digest + backup="$(prestate_backup "$path" 2>/dev/null || true)" + [ -n "$backup" ] || return 0 + backup_digest="$(ableton_manifest_digest "$backup")" || return 1 + current_digest="$(ableton_manifest_digest "$path" 2>/dev/null || true)" + [ "$current_digest" != "$backup_digest" ] || return 0 + path_exists "$path" && return 1 + ableton_atomic_restore_object "$backup" "$path" || return 1 + current_digest="$(ableton_manifest_digest "$path" 2>/dev/null || true)" + [ "$current_digest" = "$backup_digest" ] || return 1 + ui_status pa_restored_previous "$path" +} -shortcut_helper="$ABLETON_DATA_HOME/shortcut-hold.sh" -shortcut_state="$ABLETON_STATE_HOME/hold-v2" -legacy_shortcut_state="$safe_prefix/.ableton-shortcut-hold" -if [ "$optional_inventory_usable" -eq 1 ] \ - && { [ -e "$shortcut_state" ] || [ -e "$legacy_shortcut_state" ]; }; then - if [ -r "$shortcut_helper" ] && command -v gsettings >/dev/null 2>&1; then - # prepare with holding disabled performs both V1 migration and V2 crash - # recovery, while preserving any shortcut the user changed meanwhile. - if ! ableton_legacy_owned_path "$shortcut_helper" \ - || ! . "$shortcut_helper" 2>/dev/null \ - || ! declare -F ableton_shortcuts_prepare >/dev/null 2>&1; then - warn_optional_remaining \ - "GNOME shortcut restoration was skipped because its project helper could not be used: $shortcut_helper" \ - "Keep the saved shortcut values, repair or reinstall that helper, then retry." - elif ! ABLETON_SHORTCUTS=preserve \ - ableton_shortcuts_prepare "" "$legacy_shortcut_state" 0; then - warn_optional_remaining \ - "GNOME shortcut restoration did not finish; saved values remain under $ABLETON_STATE_HOME" \ - "Close Live and make sure gsettings is available before retrying." - fi - fi - if [ -e "$shortcut_state" ] || [ -e "$legacy_shortcut_state" ]; then - warn_optional_remaining \ - "GNOME shortcut restoration is incomplete; saved values remain at ${shortcut_state}${legacy_shortcut_state:+ or $legacy_shortcut_state}" \ - "Close Live and make sure gsettings can update your desktop shortcuts." - fi -fi +remove_installed_path() +{ + remove_path "$1" && restore_previous "$1" +} -# The launcher updates the name, icon, and window class in the managed Live -# desktop entry. The saved checksum then describes the earlier file. The shared -# recogniser confirms the template fields and project launcher path. The path -# check limits this instruction to the Live desktop entry. -live_entry_launcher_updated() +live_matches_manifest() { - local path="$1" - case "$path" in - */applications/ableton-live.desktop) ;; + local kind="$1" path="$2" expected="$3" actual + case "$kind" in + file|config) [ -f "$path" ] && [ ! -L "$path" ] || return 1 ;; + symlink) [ -L "$path" ] || return 1 ;; *) return 1 ;; esac - [ -f "$path" ] || return 1 - [ ! -L "$path" ] || return 1 - ableton_legacy_owned_path "$path" + actual="$(ableton_manifest_digest "$path" 2>/dev/null || true)" + [ "$actual" = "$expected" ] } -# A launcher replacement keeps the displaced object beside the generated -# launcher as .bak. New installations deliberately do not duplicate that -# object in the support directory, so the adjacent copy is the user's recovery -# copy. Resolve the two manifest records together: deleting them independently -# would delete both the generated launcher and the object it replaced. -restore_adjacent_launcher_backup() +live_matches_prestate() { - local path="$1" path_digest="$2" saved="$3" saved_digest="$4" - local saved_prestate="${5:-}" saved_prestate_digest="${6:-}" - local current="" saved_current="" restored="" project_launcher=0 - - if [ -e "$path" ] || [ -L "$path" ]; then - current="$(ableton_manifest_digest "$path" 2>/dev/null || true)" - fi - if [ -e "$saved" ] || [ -L "$saved" ]; then - saved_current="$(ableton_manifest_digest "$saved" 2>/dev/null || true)" - fi - - # A previous attempt may have restored the exact saved launcher and then - # failed to remove its adjacent copy. Finish that retry without mistaking - # the restored launcher for an unrecognised modification. - if [ -n "$current" ] && [ "$current" = "$saved_digest" ]; then - if [ -n "$saved_prestate" ]; then - restore_adjacent_backup_prestate \ - "$saved" "$saved_prestate" "$saved_prestate_digest" "$saved_digest" - return 0 - fi - if [ "$saved_current" = "$saved_digest" ]; then - rm -f -- "$saved" 2>/dev/null || true - if [ -e "$saved" ] || [ -L "$saved" ]; then - warn_optional_remaining \ - "the earlier launcher is restored at $path, but its extra saved copy remains at $saved" \ - "Check the saved copy's permissions before retrying." - else - uninstall_note "kept restored launcher $path" - fi - elif [ ! -e "$saved" ] && [ ! -L "$saved" ]; then - uninstall_note "kept restored launcher $path" - else - warn_optional_remaining \ - "kept the restored launcher at $path and a changed saved copy at $saved" \ - "Review the changed saved copy before moving or deleting it." - fi - return 0 - fi - - if [ ! -e "$path" ] && [ ! -L "$path" ]; then - project_launcher=1 - elif [ -n "$current" ] && [ "$current" = "$path_digest" ]; then - project_launcher=1 - elif live_entry_launcher_updated "$path" \ - || ableton_legacy_owned_path "$path"; then - project_launcher=1 - fi - - if [ "$project_launcher" -ne 1 ]; then - warn_optional_remaining \ - "kept the user-modified launcher at $path and its saved earlier launcher at $saved" \ - "Review both files and move your preferred launcher into place before retrying." - return 0 - fi - if [ -z "$saved_current" ] || [ "$saved_current" != "$saved_digest" ]; then - warn_optional_remaining \ - "the project launcher at $path was left unchanged because its saved earlier launcher is missing or changed: $saved" \ - "Restore or review the saved launcher before retrying." - return 0 - fi - - ableton_atomic_restore_object "$saved" "$path" 2>/dev/null || true - restored="$(ableton_manifest_digest "$path" 2>/dev/null || true)" - if [ "$restored" != "$saved_digest" ]; then - warn_optional_remaining \ - "the earlier launcher at $saved could not be restored at $path; both files were kept" \ - "Check the destination permissions before retrying." - return 0 - fi - - if [ -n "$saved_prestate" ]; then - restore_adjacent_backup_prestate \ - "$saved" "$saved_prestate" "$saved_prestate_digest" "$saved_digest" - return 0 - fi - - rm -f -- "$saved" 2>/dev/null || true - if [ -e "$saved" ] || [ -L "$saved" ]; then - warn_optional_remaining \ - "the earlier launcher was restored at $path, but its extra saved copy remains at $saved" \ - "Check the saved copy's permissions before retrying." - else - uninstall_note "restored earlier launcher $path" - fi - return 0 + local path="$1" backup current_digest backup_digest + backup="$(prestate_backup "$path" 2>/dev/null || true)" + [ -n "$backup" ] || return 1 + current_digest="$(ableton_manifest_digest "$path" 2>/dev/null || true)" + backup_digest="$(ableton_manifest_digest "$backup" 2>/dev/null || true)" + [ -n "$current_digest" ] && [ "$current_digest" = "$backup_digest" ] } -# After the adjacent launcher has returned to its canonical path, put back a -# foreign object that occupied .bak before installation. The current -# adjacent object may be replaced only when it is the managed displaced -# launcher; any other bytes are preserved alongside the durable saved copy. -restore_adjacent_backup_prestate() -{ - local path="$1" backup="$2" backup_digest="$3" replaceable_digest="$4" - local current="" - if [ -e "$path" ] || [ -L "$path" ]; then - current="$(ableton_manifest_digest "$path" 2>/dev/null || true)" - fi - if [ "$current" = "$backup_digest" ]; then - uninstall_note "kept your already-restored earlier file at $path" - return 0 - fi - if [ -n "$current" ] && [ "$current" != "$replaceable_digest" ]; then - warn_optional_remaining \ - "kept a changed saved launcher at $path and its earlier copy at $backup" \ - "Review both files and keep the one you want before retrying." - return 0 - fi - ableton_atomic_restore_object "$backup" "$path" 2>/dev/null || true - if [ "$(ableton_manifest_digest "$path" 2>/dev/null || true)" != "$backup_digest" ]; then - warn_optional_remaining \ - "the earlier file was not restored at $path; its saved copy remains at $backup" \ - "Check the destination permissions before retrying." - else - uninstall_note "restored your earlier file at $path" - fi - return 0 -} +integration_changed=0 -remove_owned_manifest_files() +remove_integration() { - local kind path digest extra current backup backup_digest backup_expected backup_count record - local launcher saved_path prestate_status prestate_path prestate_backup prestate_digest - local prestate="$ABLETON_STATE_HOME/install-prestate.tsv" path_hash_record path_hash - local -a owned_records=() prestate_records=() - local -A owned_kinds=() owned_digests=() prestate_paths=() - local -A prestate_backups=() prestate_digests=() - local -A launcher_backups=() backup_launchers=() - [ -r "$manifest" ] || return 1 - if ! mapfile -t owned_records < "$manifest"; then - echo "!! The list of installed support files could not be read; no other support files were removed." >&2 - return 1 - fi - # Build the complete relation before mutating either member. Pairing is a - # fallback only for current snapshot-free installs; an older persistent - # pre-install record remains authoritative for both paths. - for record in "${owned_records[@]}"; do - if ! IFS=$'\t' read -r kind path digest extra <<< "$record" \ - || [ -n "$extra" ]; then - echo "!! The list of installed support files is not recognised; no other support files were removed." >&2 - return 1 - fi - case "$kind" in - file|config|symlink) - owned_kinds["$path"]="$kind" - owned_digests["$path"]="$digest" ;; - runtime) ;; - esac - done - if [ -r "$prestate" ]; then - if ! mapfile -t prestate_records < "$prestate"; then - echo "!! The saved copies of files replaced during installation could not be read; no other support files were removed." >&2 - return 1 - fi - for record in "${prestate_records[@]}"; do - if ! IFS=$'\t' read -r prestate_status prestate_path prestate_backup extra \ - <<< "$record" \ - || [ -n "$extra" ] || [ "$prestate_status" != present ]; then - echo "!! The saved copies of files replaced during installation are not recognised; no other support files were removed." >&2 + local manifest="$ABLETON_STATE_HOME/install-manifest.tsv" + local kind path detail extra + ui_item_begin u_component_integration + while IFS=$'\t' read -r -u 3 kind path detail extra \ + || [ -n "$kind$path$detail$extra" ]; do + case "$kind" in file|config|symlink) ;; *) continue ;; esac + if ! path_exists "$path"; then + if ! restore_previous "$path"; then + ui_item_end fail + fail "restoring the earlier file or link failed at $path" return 1 fi - prestate_digest="$(ableton_manifest_digest "$prestate_backup" 2>/dev/null || true)" - if [ -z "$prestate_digest" ]; then - echo "!! A saved earlier file could not be read; no other support files were removed." >&2 + elif live_matches_manifest "$kind" "$path" "$detail"; then + if ! remove_installed_path "$path"; then + ui_item_end fail + fail "removal failed for the installed file or link at $path" return 1 fi - prestate_paths["$prestate_path"]=1 - prestate_backups["$prestate_path"]="$prestate_backup" - prestate_digests["$prestate_path"]="$prestate_digest" - done - fi - for saved_path in "${!owned_kinds[@]}"; do - case "$saved_path" in *.bak) ;; *) continue ;; esac - launcher="${saved_path%.bak}" - [ -n "${owned_kinds[$launcher]+x}" ] || continue - ableton_launcher_path_allowed "$launcher" || continue - case "${owned_kinds[$launcher]}:${owned_kinds[$saved_path]}" in - file:file|file:symlink|symlink:file|symlink:symlink) ;; - *) continue ;; - esac - [ -z "${prestate_paths[$launcher]+x}" ] || continue - # A launcher from an older release can be byte-identical to the new - # one while differing only in mode. Both manifest rows then have the - # same digest, so there is no distinct foreign object to resurrect. - # Let the normal per-record cleanup remove them (and independently - # restore anything that previously occupied the adjacent path). This - # also remains correct when a previous uninstall already removed one - # member of the pair. - [ "${owned_digests[$launcher]}" != "${owned_digests[$saved_path]}" ] \ - || continue - launcher_backups["$launcher"]="$saved_path" - backup_launchers["$saved_path"]="$launcher" - done - for record in "${owned_records[@]}"; do - if ! IFS=$'\t' read -r kind path digest extra <<< "$record" \ - || [ -n "$extra" ]; then - echo "!! The list of installed support files is not recognised; no other support files were removed." >&2 - return 1 - fi - case "$kind" in - file|config|symlink) - # The canonical record resolves a snapshot-free launcher pair. - # Skip the adjacent row so it can never be deleted first. - [ -z "${backup_launchers[$path]+x}" ] || continue - if [ -n "${launcher_backups[$path]+x}" ]; then - saved_path="${launcher_backups[$path]}" - restore_adjacent_launcher_backup \ - "$path" "$digest" "$saved_path" "${owned_digests[$saved_path]}" \ - "${prestate_backups[$saved_path]-}" \ - "${prestate_digests[$saved_path]-}" - continue - fi - backup="" - if [ -r "$prestate" ]; then - backup_count="$(awk -F '\t' -v p="$path" \ - '$1=="present" && $2==p { n++ } END { print n+0 }' "$prestate")" || { - echo "!! The saved copies of replaced files could not be checked; no other support files were removed." >&2 - return 1 - } - backup="$(awk -F '\t' -v p="$path" \ - '$1=="present" && $2==p { print $3; exit }' "$prestate")" || { - echo "!! The saved earlier copy of $path could not be checked; no other support files were removed." >&2 - return 1 - } - case "$backup_count" in ''|*[!0-9]*) - echo "!! The saved earlier copy of $path is not recognised; no other support files were removed." >&2 - return 1 ;; - esac - if [ "$backup_count" -gt 1 ]; then - echo "!! More than one earlier copy was recorded for $path, so it was left unchanged." >&2 - return 1 - fi - fi - if [ -n "$backup" ]; then - path_hash_record="$(printf '%s' "$path" | sha256sum)" || { - echo "!! The saved earlier copy of $path could not be verified; no other support files were removed." >&2 - return 1 - } - path_hash="${path_hash_record%% *}" - [[ "$path_hash" =~ ^[0-9a-f]{64}$ ]] || { - echo "!! The saved earlier copy of $path could not be verified; no other support files were removed." >&2 - return 1 - } - backup_expected="$ABLETON_STATE_HOME/install-prestate/$path_hash" - if [ "$backup" != "$backup_expected" ] \ - || { [ ! -f "$backup" ] && [ ! -L "$backup" ]; }; then - echo "!! The earlier copy of $path could not be restored safely; no other support files were removed." >&2 - return 1 - fi - backup_digest="$(ableton_manifest_digest "$backup" 2>/dev/null || true)" - if [ -z "$backup_digest" ]; then - echo "!! The earlier copy of $path could not be read; no other support files were removed." >&2 - return 1 - fi - fi - if [ ! -e "$path" ] && [ ! -L "$path" ]; then - if [ -n "$backup" ]; then - ableton_atomic_restore_object "$backup" "$path" || true - if [ "$(ableton_manifest_digest "$path" 2>/dev/null || true)" != "$backup_digest" ]; then - warn_optional_remaining \ - "the earlier file was not restored at $path; its saved copy remains at $backup" \ - "Check the destination permissions before retrying." - else - uninstall_note "restored your earlier file at $path" - fi - fi - continue - fi - current="$(ableton_manifest_digest "$path" 2>/dev/null || true)" - if [ "$current" = "$digest" ] || live_entry_launcher_updated "$path" \ - || ableton_legacy_owned_path "$path"; then - if [ -n "$backup" ]; then - ableton_atomic_restore_object "$backup" "$path" || true - if [ "$(ableton_manifest_digest "$path" 2>/dev/null || true)" != "$backup_digest" ]; then - warn_optional_remaining \ - "the Ableton Linux file at $path could not be replaced by its saved earlier copy at $backup" \ - "Check both paths and their permissions before retrying." - else - uninstall_note "restored your earlier file at $path" - fi - else - rm -f -- "$path" 2>/dev/null || true - if [ -e "$path" ] || [ -L "$path" ]; then - warn_optional_remaining \ - "an Ableton Linux file remains at $path" \ - "Check the file permissions before retrying." - else - uninstall_note "removed $path" - fi - fi - elif [ -n "$backup" ] && [ "$current" = "$backup_digest" ]; then - # A prior partial uninstall may already have restored this - # exact pre-install object before another path failed. - # Treat that as completed work so a retry can finish. - uninstall_note "kept your already-restored earlier file at $path" - else - if [ "$kind" = config ]; then - warn_optional_remaining \ - "kept user-modified configuration at $path" \ - "Move or delete it yourself only if you no longer need those settings." - elif [ "$kind" = symlink ]; then - warn_optional_remaining \ - "kept a link at $path because it was changed or points somewhere else" \ - "Remove it yourself only if you recognise it as obsolete." - elif [ "$pr182_custom_link_adoption" -eq 1 ] \ - && [ "$path" = "$ABLETON_LINKD" ]; then - warn_optional_remaining \ - "kept a modified custom Ableton Link helper at $path" \ - "Remove it yourself only if you no longer use that custom Link helper." - else - warn_optional_remaining \ - "kept an unrecognised or user-modified file at $path" \ - "Move it aside only if you are certain it belongs to this project." - fi - fi ;; - runtime) ;; - esac - done - return 0 -} - -remove_legacy_files() -{ - local data_root apps icons mime path source relative - local legacy_data_root legacy_version legacy_version_digest="" - local legacy_project_proven=0 - data_root="${XDG_DATA_HOME:-$HOME/.local/share}" - apps="$data_root/applications"; icons="$data_root/icons/hicolor"; mime="$data_root/mime/packages" - legacy_data_root="$(ableton_realpath_m "$HOME/.local/share/ableton-wine")" || legacy_data_root="" - legacy_version="$legacy_data_root/VERSION" - if [ -n "$legacy_data_root" ] && ableton_legacy_project_evidence; then - legacy_project_proven=1 - legacy_version_digest="$(ableton_manifest_digest "$legacy_version" 2>/dev/null || true)" - fi - - legacy_remove_if_owned() - { - local target="$1" original="${2:-}" owned_data_home="${3:-}" owned=0 - [ -e "$target" ] || [ -L "$target" ] || return 0 - if [ -n "$owned_data_home" ]; then - ABLETON_DATA_HOME="$owned_data_home" \ - ableton_legacy_owned_path "$target" && owned=1 - elif ableton_legacy_owned_path "$target"; then - owned=1 - fi - if [ "$owned" -eq 1 ] \ - || { [ -n "$original" ] && [ -f "$original" ] \ - && [ ! -L "$target" ] && cmp -s -- "$original" "$target"; }; then - rm -f -- "$target" 2>/dev/null || true - if [ -e "$target" ] || [ -L "$target" ]; then - warn_optional_remaining \ - "an Ableton Linux file from an older release remains at $target" \ - "Check its permissions before retrying." - else - uninstall_note "removed an Ableton Linux file from an older release: $target" - fi + elif live_matches_prestate "$path"; then + : else - # Wine and other packages install files under these names, and this - # branch has no digest to tell one of those from a file of ours that - # the user changed. Stop rather than guess. - warn_optional_remaining \ - "kept an unrecognised or modified file at $target" \ - "Move it aside only if you are certain it belongs to this project." - fi - } - - # Old installers wrote these files to the historical HOME path even when - # XDG_DATA_HOME pointed elsewhere. Delete only byte-identical packaged - # helpers after the launcher/VERSION pair has proved that this is one of - # those installations. A locally edited or merely same-named file stays. - legacy_remove_if_exact() - { - local target="$1" original exact=0 - shift - [ -e "$target" ] || [ -L "$target" ] || return 0 - if [ -f "$target" ] && [ ! -L "$target" ]; then - for original in "$@"; do - if [ -f "$original" ] && [ ! -L "$original" ] \ - && cmp -s -- "$original" "$target"; then - exact=1 - break + ui_status u_changed_path "$path" + ui_item_end ok + ui_question u_q_remove_changed k u_q_remove_changed_yes u_q_remove_changed_no + ui_item_begin u_component_integration + if [ "$UI_ANSWER" = r ]; then + if ! remove_installed_path "$path"; then + ui_item_end fail + fail "removal failed for the installed file or link at $path" + return 1 fi - done - fi - if [ "$exact" -eq 1 ]; then - rm -f -- "$target" 2>/dev/null || true - if [ -e "$target" ] || [ -L "$target" ]; then - warn_optional_remaining \ - "an Ableton Linux file from an older release remains at $target" \ - "Check its permissions before retrying." - else - uninstall_note "removed an Ableton Linux file from an older release: $target" - fi - else - warn_optional_remaining \ - "kept a modified or unrecognised older support file at $target" \ - "Remove it manually only if you no longer need it." - fi - } - - if [ "$legacy_project_proven" -eq 1 ]; then - legacy_remove_if_exact "$legacy_data_root/detect-scale.sh" \ - "$here/detect-scale.sh" - legacy_remove_if_exact "$legacy_data_root/detect-theme.sh" \ - "$here/detect-theme.sh" - legacy_remove_if_exact "$legacy_data_root/shortcut-hold.sh" \ - "$here/shortcut-hold.sh" - legacy_remove_if_exact "$legacy_data_root/check-ntsync.sh" \ - "$here/check-ntsync.sh" - legacy_remove_if_exact "$legacy_data_root/setup-link.sh" \ - "$here/setup-link.sh" - legacy_remove_if_exact "$legacy_data_root/ableton-linkctl" \ - "$here/ableton-linkctl" - legacy_remove_if_exact "$legacy_data_root/ableton-linkd.service" \ - "$here/ableton-linkd.service" - legacy_remove_if_exact "$legacy_data_root/setsyscolors.exe" \ - "$here/setsyscolors.exe" "$here/../tools/setsyscolors.exe" - legacy_remove_if_exact "$legacy_data_root/learnheal.exe" \ - "$here/learnheal.exe" "$here/../tools/learnheal.exe" - legacy_remove_if_exact "$legacy_data_root/ableton-linkd" \ - "$here/ableton-linkd" "$here/../bin/ableton-linkd" \ - "$here/../dist/ableton-linkd" - legacy_remove_if_owned "$legacy_data_root/$ABLETON_PROTOCOL_DESKTOP_ID" \ - "" "$legacy_data_root" - legacy_remove_if_owned "$legacy_data_root/$ABLETON_AUZ_DESKTOP_ID" \ - "" "$legacy_data_root" - legacy_remove_if_owned "$legacy_data_root/wine-protocol-ableton.desktop" \ - "" "$legacy_data_root" - legacy_remove_if_owned "$legacy_data_root/wine-extension-auz.desktop" \ - "" "$legacy_data_root" - fi - - for path in \ - "$ABLETON_BIN_HOME/ableton-live" "$ABLETON_BIN_HOME/max9" \ - "$ABLETON_DATA_HOME/$ABLETON_PROTOCOL_DESKTOP_ID" \ - "$ABLETON_DATA_HOME/$ABLETON_AUZ_DESKTOP_ID" \ - "$ABLETON_DATA_HOME/wine-protocol-ableton.desktop" \ - "$ABLETON_DATA_HOME/wine-extension-auz.desktop" \ - "$apps/ableton-live.desktop" "$apps/$ABLETON_PROTOCOL_DESKTOP_ID" \ - "$apps/$ABLETON_AUZ_DESKTOP_ID" "$apps/wine-protocol-ableton.desktop" \ - "$apps/wine-extension-auz.desktop" "$apps/max9.desktop" \ - "$apps/wine-protocol-c74max.desktop" \ - "$mime/x-wine-extension-auz.xml" "$mime/application-ableton-live.xml"; do - legacy_remove_if_owned "$path" - done - for source in "$here/../desktop/icons/scalable/apps"/*.svg \ - "$here/../desktop/icons/scalable/mimetypes"/*.svg \ - "$here/../desktop/icons/symbolic/apps"/*.svg; do - [ -f "$source" ] || continue - relative="${source#"$here/../desktop/icons/"}" - legacy_remove_if_owned "$icons/$relative" "$source" - done - if [ "$legacy_project_proven" -eq 1 ] \ - && [ -n "$legacy_version_digest" ] \ - && [ -f "$legacy_version" ] && [ ! -L "$legacy_version" ]; then - if [ "$(ableton_manifest_digest "$legacy_version" 2>/dev/null || true)" \ - = "$legacy_version_digest" ]; then - rm -f -- "$legacy_version" 2>/dev/null || true - if [ -e "$legacy_version" ] || [ -L "$legacy_version" ]; then - warn_optional_remaining \ - "the older Ableton Linux version record remains at $legacy_version" \ - "Check its permissions before retrying." else - uninstall_note "removed the older Ableton Linux version record: $legacy_version" + integration_changed=1 + ui_status u_kept_changed "$path" fi - else - warn_optional_remaining \ - "kept the older Ableton Linux version record because it changed during uninstall: $legacy_version" \ - "Inspect it and remove it manually only if you recognise it." fi + done 3< "$manifest" + if [ "$integration_changed" -eq 0 ]; then + integration_status=Removed + else + integration_status='Changed files remain' fi - if [ "$legacy_project_proven" -eq 1 ]; then - rmdir -- "$legacy_data_root" 2>/dev/null || true - fi + ui_item_end ok } -# PipeASIO 1.5 panel projections from the pre-manifest installer are adopted -# only when their exact legacy shape still proves project ownership. Run this -# before removing the legacy VERSION/runtime evidence used by that check. -remove_legacy_panel_files() +remove_runtime() { - local path manifest_count - for path in \ - "$ABLETON_BIN_HOME/pipeasio-settings" \ - "${XDG_DATA_HOME:-$HOME/.local/share}/applications/pipeasio-settings.desktop" \ - "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/scalable/apps/pipeasio.svg"; do - [ -e "$path" ] || [ -L "$path" ] || continue - if [ -r "$manifest" ]; then - manifest_count="$(awk -F '\t' -v p="$path" \ - '$2==p && ($1=="file" || $1=="config" || $1=="symlink") { n++ } END { print n+0 }' \ - "$manifest")" || return 1 - case "$manifest_count" in ''|*[!0-9]*) - return 1 ;; - esac - [ "$manifest_count" -eq 0 ] || continue - fi - if ableton_legacy_owned_path "$path"; then - rm -f -- "$path" 2>/dev/null || true - if [ -e "$path" ] || [ -L "$path" ]; then - warn_optional_remaining \ - "a PipeASIO Settings file from an older release remains at $path" \ - "Check its permissions before retrying." - else - uninstall_note "removed a PipeASIO Settings file from an older release: $path" - fi - else - warn_optional_remaining \ - "kept an independently installed PipeASIO panel file at $path" \ - "Remove it yourself only if you no longer use that panel installation." - fi - done + ui_item_begin u_component_runtime + verify_runtime && runtime_idle || { ui_item_end fail; return 1; } + remove_path "$ABLETON_WINE_ROOT" || { ui_item_end fail; return 1; } + runtime_status=Removed + ui_item_end ok } -# The explicit "[Default Applications]" line for a type, empty when the file has -# no such line. Group aware, so an "[Added Associations]" entry never supplies -# the default. A line before the first group header is malformed, but xdg-mime -# still honours it, so read that too: the clear below has to reach whatever the -# query can see. -mime_explicit_default() +remove_prefix() { - local file="$1" type="$2" - [ -f "$file" ] || return 0 - awk -v t="$type" ' - /^\[/ { section = $0; next } - section != "" && section != "[Default Applications]" { next } - { - eq = index($0, "=") - if (eq > 0 && substr($0, 1, eq - 1) == t) { print substr($0, eq + 1); exit } - } - ' "$file" + ui_item_begin u_component_prefix + verify_prefix && prefix_idle || { ui_item_end fail; return 1; } + remove_path "$ABLETON_WINEPREFIX" || { ui_item_end fail; return 1; } + prefix_status=Removed + ui_item_end ok } -discard_mime_scratch() -{ - local tmp="$1" - rm -f -- "$tmp" 2>/dev/null || true - if [ -e "$tmp" ] || [ -L "$tmp" ]; then - warn_optional_notice \ - "a temporary file-opening settings file remains at $tmp" \ - "Check its permissions, then remove it or retry uninstall." - fi -} +settings_changed=0 -# Drop one type's default. Group aware for the same reason as the reader: an -# "[Added Associations]" line is the user's own list of applications that may -# open the file, and this project never wrote it. -mime_clear_default() +remove_settings() { - local file="$1" type="$2" tmp explicit_after - [ -f "$file" ] || return 0 - tmp="$(mktemp "$(dirname "$file")/.mimeapps.XXXXXX")" || return 1 - if ! awk -v t="$type" ' - /^\[/ { section = $0; print; next } - section == "" || section == "[Default Applications]" { - eq = index($0, "=") - if (eq > 0 && substr($0, 1, eq - 1) == t) { next } - } - { print } - ' "$file" > "$tmp"; then - discard_mime_scratch "$tmp" - return 1 + local preferences="$ABLETON_CONFIG_HOME/preferences" preference_token + ui_item_begin u_component_settings + if path_exists "$ABLETON_CONFIG_FILE"; then + if ableton_managed_config_valid "$ABLETON_CONFIG_FILE"; then + remove_path "$ABLETON_CONFIG_FILE" || { ui_item_end fail; return 1; } + else + settings_changed=1 + ui_status u_kept_changed "$ABLETON_CONFIG_FILE" + fi fi - # Copy the original mode so mv is fully atomic and mode-preserving. The old - # sed -i also renamed into place; cat > "$file" truncates first, so a kill - # mid-write leaves the user's list empty. Surviving a symlinked - # mimeapps.list is a bonus, not the main reason. - if chmod --reference="$file" "$tmp"; then - mv -f -- "$tmp" "$file" 2>/dev/null || true + if path_exists "$preferences"; then + if ableton_preferences_valid "$preferences"; then + preference_token="$(ableton_preferences_object_token "$preferences")" \ + || { ui_item_end fail; return 1; } + ableton_preferences_remove "$preferences" "$preference_token" remove_path \ + || { ui_item_end fail; return 1; } + else + settings_changed=1 + ui_status u_kept_changed "$preferences" + fi fi - if explicit_after="$(mime_explicit_default "$file" "$type" 2>/dev/null)" \ - && ! mime_id_is_managed "$explicit_after"; then - discard_mime_scratch "$tmp" - return 0 + if [ "$settings_changed" -eq 0 ]; then + settings_status=Removed + else + settings_status='Changed files remain' fi - discard_mime_scratch "$tmp" - return 1 + ui_item_end ok } -mime_id_is_managed() +prestate_is_restored() { - [ -n "$1" ] || return 1 - case "$1" in - ableton-live.desktop|"$ABLETON_PROTOCOL_DESKTOP_ID"|"$ABLETON_AUZ_DESKTOP_ID"|\ - wine-protocol-ableton.desktop|wine-extension-auz.desktop|max9.desktop|\ - wine-protocol-c74max.desktop) return 0 ;; - esac - return 1 -} - -mime_type_label() -{ - case "$1" in - x-scheme-handler/ableton) printf '%s\n' 'Ableton browser links' ;; - application/x-wine-extension-auz) printf '%s\n' 'Ableton authorisation files (.auz)' ;; - application/x-ableton-live-set) printf '%s\n' 'Live Sets (.als)' ;; - application/x-ableton-live-clip) printf '%s\n' 'Live Clips (.alc)' ;; - application/x-ableton-live-pack) printf '%s\n' 'Live Packs (.alp)' ;; - application/x-ableton-live-max-device) printf '%s\n' 'Max for Live devices (.amxd)' ;; - x-scheme-handler/c74max) printf '%s\n' 'Max callback links' ;; - *) printf '%s\n' 'an Ableton file type' ;; - esac + local index="$ABLETON_STATE_HOME/install-prestate.tsv" + local status path backup extra live_digest backup_digest + [ -e "$index" ] || return 0 + while IFS=$'\t' read -r status path backup extra || [ -n "$status$path$backup$extra" ]; do + live_digest="$(ableton_manifest_digest "$path" 2>/dev/null || true)" + backup_digest="$(ableton_manifest_digest "$backup" 2>/dev/null || true)" + [ -n "$live_digest" ] && [ "$live_digest" = "$backup_digest" ] || return 1 + done < "$index" } -# Restoration takes two passes: one needs our desktop entries present, the other -# needs them gone. -# -# This is the first. Delete an entry and xdg-mime reports no default for a line -# that still names it, which is how stale lines survived. Clear here, and verify -# by re-reading mimeapps.list, because a query still resolves the live entries. -clear_mime_defaults() +state_has_only_removable_data() { - local type prior extra explicit current mimeapps record type_label - [ "${#mime_records[@]}" -gt 0 ] || return 0 - if ! command -v xdg-mime >/dev/null 2>&1; then - mime_backend_unavailable=1 - warn_optional_remaining \ - "some Ableton file-opening defaults could not be cleared because the xdg-utils tool is missing" \ - "Install the xdg-utils package before retrying." - return 0 - fi - mimeapps="${XDG_CONFIG_HOME:-$HOME/.config}/mimeapps.list" - uninstall_note "== restore previous file-opening defaults ==" - for record in "${mime_records[@]}"; do - IFS=$'\t' read -r type prior extra <<< "$record" - [ -n "$type" ] || continue - type_label="$(mime_type_label "$type")" - if ! current="$(xdg-mime query default "$type" 2>/dev/null)"; then - warn_optional_remaining \ - "the current app for $type_label could not be checked and may still point to Ableton Linux" \ - "Install xdg-utils or check that it can read your desktop settings, then retry." - continue - fi - if ! explicit="$(mime_explicit_default "$mimeapps" "$type")"; then - warn_optional_remaining \ - "the setting for $type_label was left unchanged because $mimeapps could not be read" \ - "Check the file permissions before retrying." - continue - fi - mime_id_is_managed "$explicit" || mime_id_is_managed "$current" || continue - if mime_id_is_managed "$explicit" \ - && ! mime_clear_default "$mimeapps" "$type"; then - warn_optional_remaining \ - "Ableton Linux is still set to open $type_label in $mimeapps" \ - "Check the file permissions before retrying." - continue - fi - if ! explicit="$(mime_explicit_default "$mimeapps" "$type")"; then - warn_optional_remaining \ - "the setting for $type_label could not be verified in $mimeapps" \ - "Check the file permissions before retrying." - continue - fi - if mime_id_is_managed "$explicit"; then - warn_optional_remaining \ - "Ableton Linux is still set to open $type_label in $mimeapps" \ - "Remove that default or make the file writable before retrying." - fi - done - return 0 + local name + ableton_state_marker_valid "$ABLETON_STATE_HOME" || return 1 + ableton_validate_ownership_manifest "$ABLETON_STATE_HOME/install-manifest.tsv" || return 1 + ableton_validate_prestate_store || return 1 + prestate_is_restored || return 1 + while IFS= read -r name; do + name="${name##*/}" + case "$name" in + .ableton-linux-state|install-manifest.tsv|install-prestate.tsv|install-prestate) ;; + *) return 1 ;; + esac + done < <(find "$ABLETON_STATE_HOME" -mindepth 1 -maxdepth 1 -print) } -# The second pass runs after the entries are gone and update-desktop-database -# has rebuilt its cache, so a query now reports what the type resolves to -# without this project. Write the recorded handler back only when the type does -# not already resolve to it. A default the install found implicit must not come -# back as an explicit line the user never had. -reconcile_mime_defaults() +remove_state() { - local type prior extra current explicit mimeapps record type_label - [ "$mime_prestate_usable" -eq 1 ] && [ -r "$restore_mime" ] || return 0 - [ "$mime_backend_unavailable" -eq 0 ] \ - && command -v xdg-mime >/dev/null 2>&1 || return 0 - mimeapps="${XDG_CONFIG_HOME:-$HOME/.config}/mimeapps.list" - for record in "${mime_records[@]}"; do - IFS=$'\t' read -r type prior extra <<< "$record" - [ -n "$type" ] || continue - type_label="$(mime_type_label "$type")" - if ! current="$(xdg-mime query default "$type" 2>/dev/null)"; then - warn_optional_remaining \ - "the current app for $type_label could not be checked after Ableton Linux files were removed" \ - "Install xdg-utils or check that it can read your desktop settings, then retry." - continue - fi - # A prior that is itself a managed id names a file this run deleted, so - # writing it back would recreate the dangling default the first pass - # cleared. Leaving it out is safe: a surviving entry still resolves. - if [ -z "$prior" ] || mime_id_is_managed "$prior"; then - if mime_id_is_managed "$current"; then - # Our own entries are out of the desktop database by now, so one - # of these names can only be a file this run deliberately left - # alone. Where it points is the user's business, and the first - # pass already proved no explicit line of ours survives. - uninstall_note "$type_label still uses an app that Ableton Linux did not install; leaving it unchanged" - fi - continue - fi - [ "$current" != "$prior" ] || continue - xdg-mime default "$prior" "$type" >/dev/null 2>&1 || true - if ! explicit="$(mime_explicit_default "$mimeapps" "$type")" \ - || [ "$explicit" != "$prior" ]; then - warn_optional_remaining \ - "the previous app for $type_label could not be restored" \ - "Check that $mimeapps is writable, then retry uninstall." - fi - done - return 0 -} - -restore_mime="$ABLETON_STATE_HOME/mime-prestate.tsv" -mime_records=() -if [ "$mime_prestate_usable" -eq 1 ]; then - if ! mapfile -t mime_records < "$restore_mime"; then - mime_prestate_usable=0 - mime_records=() - warn_optional_remaining \ - "File-opening defaults were left unchanged because the saved settings at $restore_mime could not be read" \ - "Check the file permissions before retrying." + ui_item_begin u_component_state + if [ "$integration_changed" -eq 0 ] && [ "$settings_changed" -eq 0 ] \ + && state_has_only_removable_data; then + remove_path "$ABLETON_STATE_HOME" || { ui_item_end fail; return 1; } + state_status=Removed else - declare -A mime_record_types=() - for mime_record in "${mime_records[@]}"; do - IFS=$'\t' read -r mime_type mime_prior mime_extra <<< "$mime_record" - mime_record_valid=1 - [ -z "$mime_extra" ] && [ -n "$mime_type" ] \ - && [ -z "${mime_record_types[$mime_type]+x}" ] \ - || mime_record_valid=0 - case "$mime_type" in - x-scheme-handler/ableton|application/x-wine-extension-auz|\ - application/x-ableton-live-set|application/x-ableton-live-clip|\ - application/x-ableton-live-pack|application/x-ableton-live-max-device|\ - x-scheme-handler/c74max) ;; - *) mime_record_valid=0 ;; - esac - [ -z "$mime_prior" ] \ - || [[ "$mime_prior" =~ ^[A-Za-z0-9_.+-]+[.]desktop$ ]] \ - || mime_record_valid=0 - if [ "$mime_record_valid" -ne 1 ]; then - mime_prestate_usable=0 - mime_records=() - warn_optional_remaining \ - "File-opening defaults were left unchanged because their saved settings changed before they could be used: $restore_mime" \ - "Review that file, then retry if you still want the old defaults restored." - break - fi - mime_record_types["$mime_type"]=1 - done - fi -fi -mime_cleanup_authorized="$mime_prestate_usable" -if [ "$optional_inventory_usable" -eq 0 ]; then - mime_cleanup_authorized=0 - mime_records=() -elif [ "$mime_cleanup_authorized" -eq 0 ]; then - apps="${XDG_DATA_HOME:-$HOME/.local/share}/applications" - for manifest_record in "${manifest_runtime_records[@]}"; do - IFS=$'\t' read -r manifest_kind manifest_path _ <<< "$manifest_record" - case "$manifest_kind:$manifest_path" in - file:"$apps/ableton-live.desktop"|file:"$apps/max9.desktop"|\ - file:"$apps/$ABLETON_PROTOCOL_DESKTOP_ID"|\ - file:"$apps/$ABLETON_AUZ_DESKTOP_ID"|\ - file:"$apps/wine-protocol-ableton.desktop"|\ - file:"$apps/wine-extension-auz.desktop"|\ - file:"$apps/wine-protocol-c74max.desktop") - mime_cleanup_authorized=1 - break ;; - esac - done -fi -if [ "$optional_inventory_usable" -eq 1 ] \ - && [ "$mime_cleanup_authorized" -eq 0 ]; then - for mime_project_entry in \ - "${XDG_DATA_HOME:-$HOME/.local/share}/applications/ableton-live.desktop" \ - "${XDG_DATA_HOME:-$HOME/.local/share}/applications/max9.desktop" \ - "${XDG_DATA_HOME:-$HOME/.local/share}/applications/$ABLETON_PROTOCOL_DESKTOP_ID" \ - "${XDG_DATA_HOME:-$HOME/.local/share}/applications/$ABLETON_AUZ_DESKTOP_ID" \ - "${XDG_DATA_HOME:-$HOME/.local/share}/applications/wine-protocol-c74max.desktop"; do - if ableton_legacy_owned_path "$mime_project_entry"; then - mime_cleanup_authorized=1 - break - fi - done -fi -if [ "$mime_cleanup_authorized" -eq 1 ] && [ "${#mime_records[@]}" -eq 0 ]; then - # Current installs own their default-handler IDs authoritatively and do not - # retain the default they replaced. Remove only our exact IDs; every other - # mimeapps.list line stays byte-for-byte in place. - mime_records=( - $'x-scheme-handler/ableton\t' - $'application/x-wine-extension-auz\t' - $'application/x-ableton-live-set\t' - $'application/x-ableton-live-clip\t' - $'application/x-ableton-live-pack\t' - $'application/x-ableton-live-max-device\t' - $'x-scheme-handler/c74max\t' - ) -fi -clear_mime_defaults -uninstall_note "== remove the Wine runtime, launchers, and support files ==" -if [ "$optional_inventory_usable" -eq 1 ]; then - if ! remove_legacy_panel_files; then - warn_optional_remaining \ - "PipeASIO Settings shortcuts were left unchanged because the installer could not confirm that it created them" \ - "Inspect those shortcuts and retry after repairing the installer's file list." + ui_status u_kept_changed "$ABLETON_STATE_HOME" + state_status=Remains fi -fi -if [ "$manifest_present" -eq 1 ] && [ "$optional_inventory_usable" -eq 1 ]; then - if ! remove_owned_manifest_files; then - leave_optional_inventory_untouched - warn_optional_remaining \ - "some Ableton Linux launchers or support files were left unchanged because the installer could not confirm that it created them" \ - "Inspect the remaining files and retry after repairing the installer's file list." - fi -elif [ "$manifest_present" -eq 0 ] && [ "$optional_inventory_usable" -eq 1 ]; then - remove_legacy_files -fi + ui_item_end ok +} -# Revalidate every recursive target as one complete set immediately before the -# first tree deletion. Integration cleanup above is intentionally non-fatal, -# but it must never weaken the ownership proof for a runtime removal. -if [ "$delete_prefix" -eq 1 ] \ - && { [ -e "$safe_prefix" ] || [ -L "$safe_prefix" ]; }; then - prefix_safe="$(ableton_path_is_safe_delete_target "$safe_prefix")" || { - echo "!! refusing an unsafe prefix target: $safe_prefix" >&2 - exit 1 - } - if [ "$prefix_safe" != "$safe_prefix" ] || [ -L "$safe_prefix" ] \ - || ! ableton_prefix_marker_valid "$safe_prefix" "$safe_prefix"; then - echo "!! The Wine prefix at $safe_prefix changed or is no longer recognised; the runtime and prefix were left unchanged." >&2 - exit 1 - fi +ui_step_begin s_remove + +ableton_require_home || exit 1 +: "${ABLETON_CONFIG_HOME:=${XDG_CONFIG_HOME:-$HOME/.config}/ableton-wine}" +: "${ABLETON_CONFIG_FILE:=$ABLETON_CONFIG_HOME/config}" + +config_valid=0 +runtime_from_config=0 +prefix_from_config=0 +ableton_managed_config_valid "$ABLETON_CONFIG_FILE" >/dev/null 2>&1 && config_valid=1 +if [ -n "${ABLETON_WINE_ROOT+x}" ]; then + raw_runtime="$ABLETON_WINE_ROOT" +elif [ "$config_valid" -eq 1 ]; then + raw_runtime="$(ableton_config_file_value runtime_root)" + runtime_from_config=1 +else + raw_runtime="$HOME/.local/opt/$ABLETON_RUNTIME_NAME" fi -for candidate in "${managed_runtimes[@]}"; do - [ -z "${skipped_managed_runtimes[$candidate]+x}" ] || continue - [ ! -e "$candidate" ] && [ ! -L "$candidate" ] && continue - candidate_safe="" - candidate_target_valid=1 - candidate_safe="$(ableton_path_is_safe_delete_target "$candidate")" \ - || candidate_target_valid=0 - if [ "$candidate_target_valid" -ne 1 ] \ - || [ "$candidate_safe" != "$candidate" ] || [ -L "$candidate" ] \ - || ! ableton_runtime_marker_valid "$candidate" "${managed_runtime_names[$candidate]}"; then - if [ "$candidate" = "$safe_runtime" ]; then - echo "!! The Wine runtime at $candidate changed or is no longer recognised; the runtime and prefix were left unchanged." >&2 - exit 1 - fi - skipped_managed_runtimes["$candidate"]=1 - warn_optional_remaining \ - "an older Wine runtime was left unchanged because its location is no longer recognised: $candidate" \ - "Inspect that runtime before retrying." - fi -done -for old_runtime in "${managed_rollbacks[@]}"; do - if [ "$optional_inventory_usable" -eq 0 ] \ - && [ "${managed_rollback_owners[$old_runtime]}" != "$safe_runtime" ]; then - skipped_managed_rollbacks["$old_runtime"]=1 - continue - fi - [ ! -e "$old_runtime" ] && [ ! -L "$old_runtime" ] && continue - old_runtime_safe="" - old_runtime_target_valid=1 - old_runtime_safe="$(ableton_path_is_safe_delete_target "$old_runtime")" \ - || old_runtime_target_valid=0 - if [ "$old_runtime_target_valid" -ne 1 ] \ - || [ "$old_runtime_safe" != "$old_runtime" ] || [ -L "$old_runtime" ] \ - || [ -z "${managed_rollback_names[$old_runtime]:-}" ] \ - || ! ableton_runtime_marker_valid \ - "$old_runtime" "${managed_rollback_names[$old_runtime]}"; then - skipped_managed_rollbacks["$old_runtime"]=1 - warn_optional_remaining \ - "an older saved Wine runtime was left unchanged because its target is no longer recognised: $old_runtime" \ - "Inspect that runtime before retrying." - fi -done - -for candidate in "${managed_runtimes[@]}"; do - [ -z "${skipped_managed_runtimes[$candidate]+x}" ] || continue - if [ -e "$candidate" ] || [ -L "$candidate" ]; then - candidate_safe="" - candidate_target_valid=1 - candidate_safe="$(ableton_path_is_safe_delete_target "$candidate")" \ - || candidate_target_valid=0 - if [ "$candidate_target_valid" -ne 1 ] \ - || [ "$candidate_safe" != "$candidate" ] || [ -L "$candidate" ] \ - || ! ableton_runtime_marker_valid \ - "$candidate" "${managed_runtime_names[$candidate]}"; then - if [ "$candidate" = "$safe_runtime" ]; then - echo "!! The Wine runtime at $candidate changed or is no longer recognised; it was left unchanged and deletion stopped." >&2 - exit 1 - fi - skipped_managed_runtimes["$candidate"]=1 - warn_optional_remaining \ - "an older Wine runtime was left unchanged because its location changed before removal: $candidate" \ - "Inspect that runtime before retrying." - continue - fi - rm -rf -- "$candidate" 2>/dev/null || true - if [ -e "$candidate" ] || [ -L "$candidate" ]; then - if [ "$candidate" = "$safe_runtime" ]; then - echo "!! could not remove configured runtime $candidate" >&2 - uninstall_partial=1 - else - warn_optional_remaining \ - "an older Wine runtime remains at $candidate" \ - "Check its permissions before retrying." - fi - else - uninstall_note "removed $candidate" - fi - fi -done -for old_runtime in "${managed_rollbacks[@]}"; do - [ -z "${skipped_managed_rollbacks[$old_runtime]+x}" ] || continue - if [ -e "$old_runtime" ] || [ -L "$old_runtime" ]; then - old_runtime_safe="" - old_runtime_target_valid=1 - old_runtime_safe="$(ableton_path_is_safe_delete_target "$old_runtime")" \ - || old_runtime_target_valid=0 - if [ "$old_runtime_target_valid" -ne 1 ] \ - || [ "$old_runtime_safe" != "$old_runtime" ] || [ -L "$old_runtime" ] \ - || ! ableton_runtime_marker_valid \ - "$old_runtime" "${managed_rollback_names[$old_runtime]}"; then - warn_optional_remaining \ - "an older saved Wine runtime was left unchanged because its target changed before removal: $old_runtime" \ - "Inspect that runtime before retrying." - continue - fi - rm -rf -- "$old_runtime" 2>/dev/null || true - if [ -e "$old_runtime" ] || [ -L "$old_runtime" ]; then - warn_optional_remaining \ - "an older saved Wine runtime remains at $old_runtime" \ - "Check its permissions before retrying." - else - uninstall_note "removed $old_runtime" - fi - fi -done - -if [ "$uninstall_partial" -eq 1 ]; then - echo "!! runtime removal is incomplete; prefix and uninstall support files were retained" >&2 - print_uninstall_retry +if [ -n "${ABLETON_WINEPREFIX+x}" ]; then + raw_prefix="$ABLETON_WINEPREFIX" +elif [ "$config_valid" -eq 1 ]; then + raw_prefix="$(ableton_config_file_value prefix)" + prefix_from_config=1 +else + raw_prefix="$HOME/.wine-ableton" +fi + +case "$scope" in + runtime) raw_target_safe runtime "$raw_runtime" || exit 1 ;; + prefix) raw_target_safe prefix "$raw_prefix" || exit 1 ;; + all) + raw_target_safe runtime "$raw_runtime" || exit 1 + raw_target_safe prefix "$raw_prefix" || exit 1 ;; +esac + +ABLETON_CONFIG_LAYOUT_ROOTS=none +ABLETON_WINE_ROOT="$raw_runtime" +ABLETON_WINEPREFIX="$raw_prefix" +export ABLETON_CONFIG_LAYOUT_ROOTS ABLETON_WINE_ROOT ABLETON_WINEPREFIX +ableton_config_init repair || exit 1 + +case "$scope" in + runtime) + resolved_target_safe runtime "$raw_runtime" "$ABLETON_WINE_ROOT" || exit 1 + layout_roots='runtime data config state bin' ;; + prefix) + resolved_target_safe prefix "$raw_prefix" "$ABLETON_WINEPREFIX" || exit 1 + layout_roots='prefix state' ;; + all) + resolved_target_safe runtime "$raw_runtime" "$ABLETON_WINE_ROOT" || exit 1 + resolved_target_safe prefix "$raw_prefix" "$ABLETON_WINEPREFIX" || exit 1 + layout_roots='runtime prefix data config state bin' ;; +esac + +if { [ "$runtime_from_config" -eq 1 ] \ + && ! configured_path_matches runtime_root "$ABLETON_WINE_ROOT"; } \ + || { [ "$prefix_from_config" -eq 1 ] \ + && ! configured_path_matches prefix "$ABLETON_WINEPREFIX"; }; then + fail "the installer configuration names a different runtime or prefix" exit 1 fi +ableton_config_validate_layout "$layout_roots" || exit 1 -mime_cache_dir="${XDG_DATA_HOME:-$HOME/.local/share}/mime" -if [ -d "$mime_cache_dir" ]; then - if ! command -v update-mime-database >/dev/null 2>&1; then - warn_optional_notice \ - "the desktop file-type cache was not refreshed under $mime_cache_dir" \ - "Install shared-mime-info, then rerun uninstall or update-mime-database for that directory." - elif ! update-mime-database "$mime_cache_dir" >/dev/null 2>&1; then - warn_optional_notice \ - "the desktop file-type cache could not be refreshed under $mime_cache_dir" \ - "Check the directory permissions, then rerun uninstall or update-mime-database for that directory." - fi -fi -desktop_cache_dir="${XDG_DATA_HOME:-$HOME/.local/share}/applications" -if [ -d "$desktop_cache_dir" ]; then - if ! command -v update-desktop-database >/dev/null 2>&1; then - warn_optional_notice \ - "the desktop application cache was not refreshed under $desktop_cache_dir" \ - "Install desktop-file-utils, then rerun uninstall or update-desktop-database for that directory." - elif ! update-desktop-database "$desktop_cache_dir" >/dev/null 2>&1; then - warn_optional_notice \ - "the desktop application cache could not be refreshed under $desktop_cache_dir" \ - "Check the directory permissions, then rerun uninstall or update-desktop-database for that directory." - fi -fi -reconcile_mime_defaults - -if [ "$delete_prefix" -eq 1 ] \ - && { [ -e "$safe_prefix" ] || [ -L "$safe_prefix" ]; }; then - prefix_safe="$(ableton_path_is_safe_delete_target "$safe_prefix")" || { - echo "!! refusing an unsafe prefix target: $safe_prefix" >&2 - exit 1 - } - if [ "$prefix_safe" != "$safe_prefix" ] || [ -L "$safe_prefix" ] \ - || ! ableton_prefix_marker_valid "$safe_prefix" "$safe_prefix"; then - echo "!! The Wine prefix at $safe_prefix changed or is no longer recognised; it was left unchanged." >&2 - exit 1 - fi - rm -rf -- "$safe_prefix" 2>/dev/null || true - if [ -e "$safe_prefix" ] || [ -L "$safe_prefix" ]; then - echo "!! The Wine prefix could not be removed: $safe_prefix" >&2 - uninstall_partial=1 - else - uninstall_note "removed $safe_prefix" - fi -elif [ -e "$safe_prefix" ] || [ -L "$safe_prefix" ]; then - uninstall_note "kept Wine prefix $safe_prefix" -else - uninstall_note "no Wine prefix to remove at $safe_prefix" -fi +ableton_install_lock_acquire || exit 1 +lock_held=1 -if [ "$uninstall_partial" -eq 1 ]; then - echo "!! prefix removal is incomplete; uninstall support files were retained" >&2 - print_uninstall_retry +[ -d "$ABLETON_STATE_HOME" ] && [ ! -L "$ABLETON_STATE_HOME" ] \ + && ableton_state_marker_valid "$ABLETON_STATE_HOME" || { + fail "the shared installer state needs a valid ownership marker: $ABLETON_STATE_HOME" exit 1 -fi - -safe_cache="$(ableton_path_is_safe_delete_target "$ABLETON_CACHE_HOME")" || safe_cache="" -[ ! -L "$ABLETON_CACHE_HOME" ] || safe_cache="" -if [ -n "$safe_cache" ]; then - rmdir -- "$safe_cache" 2>/dev/null || true - if [ -e "$safe_cache" ] || [ -L "$safe_cache" ]; then - warn_optional_notice \ - "the Ableton Linux cache directory remains at $safe_cache" \ - "Remove it after checking that it contains no files you need." - fi -elif [ -e "$ABLETON_CACHE_HOME" ] || [ -L "$ABLETON_CACHE_HOME" ]; then - warn_optional_notice \ - "the project cache path was left unchanged because it is not a safe empty directory: $ABLETON_CACHE_HOME" \ - "Inspect that path and remove it manually only if you recognise it." -fi +} +validate_manifest || exit 1 -# Keep enough project support information for a direct retry whenever an -# earlier optional operation left something behind. Otherwise retire each -# support object by outcome, never by a cleanup command's status alone. -if [ "$retain_retry_support" -eq 0 ]; then - rm -f -- "$restore_mime" 2>/dev/null || true - if [ -e "$restore_mime" ] || [ -L "$restore_mime" ]; then - warn_optional_remaining \ - "saved file-opening settings remain at $restore_mime" \ - "Check the file permissions before retrying." - fi -fi -if [ "$retain_retry_support" -eq 0 ]; then - rm -f -- "$manifest" 2>/dev/null || true - if [ -e "$manifest" ] || [ -L "$manifest" ]; then - warn_optional_remaining \ - "the Ableton Linux file list remains at $manifest" \ - "Check the file permissions before retrying." - fi -fi +case "$scope" in + runtime) + verify_runtime && runtime_idle || exit 1 ;; + prefix) + verify_prefix && prefix_idle || exit 1 ;; + all) + verify_runtime && verify_prefix && runtime_idle && prefix_idle || exit 1 ;; +esac -if [ "$retain_retry_support" -eq 0 ]; then - # Managed-file failures have already retained the manifest above. A data - # directory can stay nonempty because uninstall just restored the user's - # earlier files (or because it contains unrelated files). Preserve those - # contents without inventing a cleanup failure or retaining stale recovery - # state forever. - rmdir -- "$ABLETON_DATA_HOME/lib" "$ABLETON_DATA_HOME" 2>/dev/null || true +if [ "$dry_run" -eq 1 ]; then + report_on_exit=1 + exit 0 fi -if [ "$retain_retry_support" -eq 0 ] \ - && { [ -e "$ABLETON_CONFIG_FILE" ] || [ -L "$ABLETON_CONFIG_FILE" ]; }; then - if ableton_managed_config_valid "$ABLETON_CONFIG_FILE"; then - rm -f -- "$ABLETON_CONFIG_FILE" 2>/dev/null || true - if [ -e "$ABLETON_CONFIG_FILE" ] || [ -L "$ABLETON_CONFIG_FILE" ]; then - warn_optional_remaining \ - "installer settings remain at $ABLETON_CONFIG_FILE" \ - "Check the file permissions before retrying." - fi - else - warn_optional_remaining \ - "installer settings at $ABLETON_CONFIG_FILE changed during uninstall and were preserved" \ - "Inspect them and remove them manually only if you recognise them as Ableton Linux settings." +if [ "$scope" = prefix ] || [ "$scope" = all ]; then + if [ "$assume_yes" -eq 0 ]; then + ui_question u_q_delete_prefix k u_q_delete_yes u_q_delete_no + [ "$UI_ANSWER" = d ] || exit 0 fi fi -if [ "$retain_retry_support" -eq 0 ]; then - # As with the data directory, unrelated files beside the managed settings - # are not failed uninstall work and do not need installer recovery records. - rmdir -- "$ABLETON_CONFIG_HOME" 2>/dev/null || true -fi -if [ "$retain_retry_support" -eq 0 ] \ - && { [ -e "$ABLETON_STATE_HOME" ] || [ -L "$ABLETON_STATE_HOME" ]; }; then - safe_state="" - if ! safe_state="$(ableton_path_is_safe_delete_target "$ABLETON_STATE_HOME")"; then - warn_optional_remaining \ - "Ableton Linux support files were left unchanged because their directory is not safe to remove: $ABLETON_STATE_HOME" \ - "Inspect that path and remove it manually only if you recognise it." - elif [ "$safe_state" != "$ABLETON_STATE_HOME" ] || [ -L "$ABLETON_STATE_HOME" ] \ - || ! ableton_state_marker_valid "$safe_state"; then - warn_optional_remaining \ - "Ableton Linux support files were left unchanged because the installer could not confirm that it created their directory: $ABLETON_STATE_HOME" \ - "Inspect that directory before retrying." - else - state_marker="$safe_state/.ableton-linux-state" - # Keep the ownership marker until every other entry is gone. A partial - # recursive cleanup therefore remains safe and directly retryable. - find "$safe_state" -depth -mindepth 1 ! -path "$state_marker" \ - -delete >/dev/null 2>&1 || true - if ! state_remaining="$(find "$safe_state" -mindepth 1 \ - ! -path "$state_marker" -printf x -quit 2>/dev/null)"; then - warn_optional_remaining \ - "Ableton Linux support files could not be fully checked after cleanup at $safe_state" \ - "Check the directory permissions and contents before retrying." - elif [ -n "$state_remaining" ]; then - warn_optional_remaining \ - "Ableton Linux support files remain at $safe_state" \ - "Check the remaining files and directory permissions before retrying." - elif ! ableton_state_marker_valid "$safe_state"; then - warn_optional_remaining \ - "the Ableton Linux support directory changed during cleanup and was kept at $safe_state" \ - "Inspect that directory before retrying." - else - rm -f -- "$state_marker" 2>/dev/null || true - if [ -e "$state_marker" ] || [ -L "$state_marker" ]; then - warn_optional_remaining \ - "the final Ableton Linux support record remains at $state_marker" \ - "Check the file permissions before retrying." - else - rmdir -- "$safe_state" 2>/dev/null || true - if [ -e "$safe_state" ] || [ -L "$safe_state" ]; then - if [ -d "$safe_state" ] && [ ! -L "$safe_state" ]; then - state_marker_tmp="$(mktemp "$safe_state/.state-marker.XXXXXX" 2>/dev/null || true)" - if [ -n "$state_marker_tmp" ]; then - if ! printf 'format=1\nowner=ableton-linux\n' > "$state_marker_tmp" \ - || ! chmod 600 "$state_marker_tmp" \ - || ! mv -T -n -- "$state_marker_tmp" "$state_marker"; then - rm -f -- "$state_marker_tmp" 2>/dev/null || true - fi - fi - fi - if ableton_state_marker_valid "$safe_state"; then - warn_optional_remaining \ - "the empty Ableton Linux support directory remains at $safe_state" \ - "Check the parent-directory permissions before retrying." - else - warn_optional_remaining \ - "an empty Ableton Linux support path remains at $safe_state and could not be prepared for an automatic retry" \ - "Inspect it, then remove that empty path manually." - fi - fi - fi - fi +select_trash_backend +if [ "$trash_backend" = permanent ]; then + ui_host_warning u_no_trash + if [ "$assume_yes" -eq 0 ]; then + ui_question u_q_permanent k u_q_permanent_yes u_q_permanent_no + [ "$UI_ANSWER" = d ] || exit 0 fi fi -if [ "$optional_cleanup_incomplete" -eq 1 ]; then - uninstall_note "OK: runtime and requested prefix removal completed; the warnings above describe optional items that remain" -else - uninstall_note "OK: uninstall complete" -fi -[ "$retain_retry_support" -eq 0 ] || print_uninstall_retry +report_on_exit=1 +case "$scope" in + runtime) + remove_integration || exit 1 + remove_runtime || exit 1 ;; + prefix) + remove_prefix || exit 1 ;; + all) + remove_integration || exit 1 + remove_runtime || exit 1 + remove_prefix || exit 1 + remove_settings || exit 1 + remove_state || exit 1 ;; +esac + +exit 0 diff --git a/scripts/verify-installer-payload.py b/scripts/verify-installer-payload.py index 3beaec67..8dd7c236 100755 --- a/scripts/verify-installer-payload.py +++ b/scripts/verify-installer-payload.py @@ -127,7 +127,10 @@ def expected_from_source(root: Path, info: Path, runtime: Path) -> dict[str, Exp add_source(expected, root / "scripts" / name, f"scripts/{name}", 0o755) for name in ("ableton-linkd.service",): add_source(expected, root / "scripts" / name, f"scripts/{name}", 0o644) - for name in ("config.sh", "lifecycle.sh", "live-options.sh", "manifest.sh", "pipeasio.sh"): + for name in ( + "config.sh", "lifecycle.sh", "live-options.sh", "manifest.sh", + "pipeasio.sh", "preferences.sh", "ui.sh", + ): add_source(expected, root / "scripts/lib" / name, f"scripts/lib/{name}", 0o644) add_source(expected, root / "tools/setsyscolors.exe", "scripts/setsyscolors.exe", 0o644) add_source(expected, root / "tools/learnheal.exe", "scripts/learnheal.exe", 0o644) @@ -336,9 +339,13 @@ def verify(args: argparse.Namespace) -> None: info = args.info.resolve() runtime = args.runtime.resolve() template = root / "scripts/setup-run-header.sh" + renderer = root / "scripts/lib/ui.sh" + preferences = root / "scripts/lib/preferences.sh" for path, label in ( (installer, "installer"), (info, "BUILD-INFO"), (runtime, "runtime"), (Path(f"{runtime}.sha256"), "runtime checksum"), (template, "trusted installer header"), + (renderer, "trusted installer renderer"), + (preferences, "trusted installer preference reader"), ): regular_file(path, label) wrapper_size = installer.stat().st_size @@ -360,7 +367,18 @@ def verify(args: argparse.Namespace) -> None: payload.write(chunk) payload.flush() payload_sha = digest.hexdigest() - trusted_header = template.read_bytes().replace(b"@VERSION@", args.version.encode("ascii")) + # The packer inlines both pre-extraction libraries before it fills the + # two markers (make-installer.sh render_header). + trusted_header = template.read_bytes() + if trusted_header.count(b"@UI_LIB@\n") != 1: + fail("trusted installer header does not carry exactly one @UI_LIB@ line") + if trusted_header.count(b"@PREFERENCES_LIB@\n") != 1: + fail("trusted installer header does not carry exactly one @PREFERENCES_LIB@ line") + trusted_header = trusted_header.replace(b"@UI_LIB@\n", renderer.read_bytes()) + trusted_header = trusted_header.replace( + b"@PREFERENCES_LIB@\n", preferences.read_bytes() + ) + trusted_header = trusted_header.replace(b"@VERSION@", args.version.encode("ascii")) trusted_header = trusted_header.replace(b"@PAYLOAD_SHA@", payload_sha.encode("ascii")) with installer.open("rb") as candidate: candidate_header = candidate.read(payload_offset)