From df104bc3e1ab7cbe875d98f121630c74f801e931 Mon Sep 17 00:00:00 2001 From: Patrick Demichiel Date: Wed, 28 Oct 2026 21:17:51 +0100 Subject: [PATCH 01/14] feat: build and run Plectrify on Linux Adds Linux (linux-x64) as a third platform beside Windows and macOS. The Debug app, the VST3 plugin and every native test now build and pass on Ubuntu, and `pnpm app` runs the same dev loop there. - CMake: WebKitGTK and libcurl on Linux (JUCE's socket fallback cannot do HTTPS), JACK for the standalone app - Paths: data root ~/.config/Plectrify, managed plugins in ~/.vst3 (shared with other vendors, like macOS), content root /var/lib/Plectrify (mode 1777, so patch packs' baked paths stay machine-independent) - Native title bar, JACK preferred over ALSA on first run, RSS readout from /proc, credentials file tightened to 0600 - First run opens at most 32 input channels: PipeWire's ALSA bridge advertises 128 and crashes its own audio thread if all are opened - UI: platform 'linux', file-manager wording, ASIO hints Windows-only - scripts/linux.ts + run.linux.ts, with an apt hint for missing build dependencies Still to come: catalogue tooling for linux-x64, release workflows, purge paths, docs, and a Linux build of Neural Amp Modeler. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 37 ++- Source/app/AppPaths.h | 13 +- Source/app/AudioSetupRules.h | 27 +- Source/app/CrashReporter.h | 9 +- Source/app/Main.cpp | 7 +- Source/app/MainComponent.cpp | 7 +- Source/app/PlectrifyEngine.cpp | 2 + Source/app/SystemStatsHelper.h | 9 + Source/plugins/Catalogue.h | 2 + Source/plugins/CatalogueInstaller.cpp | 18 +- Source/plugins/PluginManager.cpp | 6 + Source/plugins/PluginManager.h | 15 +- Source/tone3000/Tone3000Service.cpp | 8 +- Tests/app/AudioSetupRulesTests.cpp | 13 + scripts/linux.ts | 195 +++++++++++++++ scripts/run.linux.ts | 236 ++++++++++++++++++ scripts/run.ts | 6 +- scripts/shared.ts | 18 +- ui/src/features/info/diagnostics.ts | 7 +- ui/src/features/looper/LooperSessions.svelte | 4 +- ui/src/features/settings/SettingsPanel.svelte | 2 +- ui/src/features/setup/SetupWizard.svelte | 3 +- ui/src/lib/engine/backup.ts | 3 +- ui/src/lib/engine/types.ts | 2 +- ui/src/lib/platform.ts | 8 +- 25 files changed, 609 insertions(+), 48 deletions(-) create mode 100644 scripts/linux.ts create mode 100644 scripts/run.linux.ts diff --git a/CMakeLists.txt b/CMakeLists.txt index a27fee0..8eb2d4a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,7 +24,9 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) # CI-friendly builds. First configure downloads JUCE (a few minutes). # --------------------------------------------------------------------------- # The WebView2 SDK is Windows-only: macOS uses the OS-provided WKWebView, which -# needs no SDK package and no runtime installer. +# needs no SDK package and no runtime installer, and Linux uses WebKitGTK from +# the distribution (libwebkit2gtk-4.1-dev at build time, the runtime library at +# run time). if (WIN32) # Where JUCE's FindWebView2 looks for the Microsoft.Web.WebView2 NuGet package # (fetched into third_party/). A plain (non-cache) variable so it reliably @@ -67,7 +69,7 @@ FetchContent_Declare( # cycle on every configure; clean build trees still clone and patch once. UPDATE_DISCONNECTED TRUE # Small guarded host-side fixes JUCE 8.0.14 needs, applied at fetch - # time. Each platform-specific patch is applied on both OSes anyway, + # time. Each platform-specific patch is applied on every OS anyway, # because the patched tree is what the release scripts publish as # corresponding source: one tree, one answer to "what was this built from". # - webview2-zoom: 8.0.14 does not expose WebView2's zoom settings @@ -119,7 +121,11 @@ FetchContent_MakeAvailable(JUCE) # entry (without which macOS silently denies audio input — a guitar rig's whole # point), and the hardened runtime notarization requires. The # disable-library-validation entitlement is what lets a hardened-runtime -# process load third-party VST3 code not signed by our team. +# process load third-party VST3 code not signed by our team. Linux asks JUCE +# for its two optional system libraries: WebKitGTK for the embedded UI (without +# NEEDS_WEB_BROWSER JUCE compiles the browser out there) and libcurl, because +# JUCE's own socket fallback speaks plain HTTP and every URL Plectrify fetches — +# catalogue, packages, TONE3000 — is HTTPS. if (WIN32) set(PLECTRIFY_PLATFORM_APP_ARGS NEEDS_WEBVIEW2 TRUE) elseif (APPLE) @@ -130,7 +136,7 @@ elseif (APPLE) HARDENED_RUNTIME_ENABLED TRUE HARDENED_RUNTIME_OPTIONS com.apple.security.device.audio-input com.apple.security.cs.disable-library-validation) else() - set(PLECTRIFY_PLATFORM_APP_ARGS "") + set(PLECTRIFY_PLATFORM_APP_ARGS NEEDS_WEB_BROWSER TRUE NEEDS_CURL TRUE) endif() juce_add_gui_app(Plectrify @@ -357,12 +363,19 @@ function(plectrify_configure_target target) JUCE_USE_WINRT_MIDI=0) endif() + # JUCE's URL is curl-backed on Linux or HTTP-only; Windows and macOS use + # the OS stack and keep curl out (see NEEDS_CURL above). + if (CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_compile_definitions(${target} PRIVATE JUCE_USE_CURL=1) + else() + target_compile_definitions(${target} PRIVATE JUCE_USE_CURL=0) + endif() + target_compile_definitions(${target} PRIVATE JUCE_PLUGINHOST_VST3=1 # host VST3 plugins JUCE_PLUGINHOST_VST=0 # legacy VST2 needs the Steinberg SDK/licence JUCE_PLUGINHOST_LADSPA=0 JUCE_WEB_BROWSER=1 # host the embedded Svelte UI - JUCE_USE_CURL=0 # Debug-only source-tree fallback for serving ui/dist. Release binaries # must not bake a dev-machine path; they serve only the staged ui/ # directory — next to the executable on Windows (scripts/run.windows.ts @@ -445,6 +458,15 @@ if (WIN32) target_compile_definitions(Plectrify PRIVATE JUCE_ASIO=1) endif() +# JACK — Linux's ASIO, for the same reason and with the same app-only scope. +# JUCE dlopens libjack at run time, so a machine without it simply lists ALSA +# alone; on a current desktop the library is PipeWire's, which serves JACK +# clients alongside everything else on the sound server. ALSA is JUCE's +# default and needs no switch. +if (CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_compile_definitions(Plectrify PRIVATE JUCE_JACK=1) +endif() + # The window-chrome assets (title-bar wordmark font, icons) are the shell's # alone — the plugin has no JUCE-drawn chrome. target_link_libraries(Plectrify PRIVATE PlectrifyAssets) @@ -469,11 +491,12 @@ elseif (APPLE) HARDENED_RUNTIME_ENABLED TRUE HARDENED_RUNTIME_OPTIONS com.apple.security.device.audio-input com.apple.security.cs.disable-library-validation) else() - set(PLECTRIFY_PLATFORM_PLUGIN_ARGS "") + # The app's two Linux system libraries, for the same reasons. + set(PLECTRIFY_PLATFORM_PLUGIN_ARGS NEEDS_WEB_BROWSER TRUE NEEDS_CURL TRUE) endif() # AU exists for Logic and GarageBand, the mainstream hosts that load no other -# format; every other mac DAW takes the VST3. Windows has no AU at all. +# format; every other mac DAW takes the VST3. Windows and Linux have no AU at all. if (APPLE) set(PLECTRIFY_PLUGIN_FORMATS VST3 AU) else() diff --git a/Source/app/AppPaths.h b/Source/app/AppPaths.h index 893879b..3f2b96e 100644 --- a/Source/app/AppPaths.h +++ b/Source/app/AppPaths.h @@ -6,7 +6,10 @@ namespace plectrify { /** Per-user Plectrify data root — %APPDATA%\Plectrify on Windows, ~/Library/Application Support/Plectrify on macOS (JUCE's - userApplicationDataDirectory is bare ~/Library there). + userApplicationDataDirectory is bare ~/Library there), and + ~/.config/Plectrify on Linux (JUCE's answer there, honouring neither + XDG_CONFIG_HOME nor XDG_DATA_HOME — one fixed place is what + `pnpm purge` and a backup can name). This tree doubles as the web page's file sandbox (MainComponent::resolveAppFile), so nothing that must stay out of the @@ -38,7 +41,10 @@ namespace plectrify /Contents/x86_64-win/, and the resources mirror the mac layout at the sibling Contents/Resources. currentExecutableFile resolves the module that contains this code (the DLL, in a plugin), never the host - exe. */ + exe. + + Linux: the same as Windows, with the VST3 module directory named + x86_64-linux instead — the bundle layout is the SDK's on both. */ inline juce::File moduleResourceDir() { #if JUCE_MAC @@ -48,7 +54,8 @@ namespace plectrify const auto moduleDir = juce::File::getSpecialLocation (juce::File::currentExecutableFile) .getParentDirectory(); - if (moduleDir.getFileName().equalsIgnoreCase ("x86_64-win") + if ((moduleDir.getFileName().equalsIgnoreCase ("x86_64-win") + || moduleDir.getFileName().equalsIgnoreCase ("x86_64-linux")) && moduleDir.getParentDirectory().getFileName().equalsIgnoreCase ("Contents")) return moduleDir.getParentDirectory().getChildFile ("Resources"); diff --git a/Source/app/AudioSetupRules.h b/Source/app/AudioSetupRules.h index 49875de..c9ac0cb 100644 --- a/Source/app/AudioSetupRules.h +++ b/Source/app/AudioSetupRules.h @@ -26,10 +26,15 @@ namespace plectrify::audiosetup everything else on that list is a shared mixer with a buffer of its own on top of ours. macOS has no such split — CoreAudio *is* the low-latency path — so its single type is chosen by the fallback below rather than by - name. */ + name. Linux has the Windows split: JACK (served by PipeWire on a current + desktop) shares the interface at the latency the player sets, while ALSA + either opens the hardware exclusively — failing outright while a desktop + sound server holds it — or plays through that server's own buffer. A + JACK type with no server behind it fails to open, and the caller's + fallback then takes JUCE's default, exactly as a dead ASIO driver does. */ inline const juce::StringArray& preferredDeviceTypes() { - static const juce::StringArray types { "ASIO" }; + static const juce::StringArray types { "ASIO", "JACK" }; return types; } @@ -86,4 +91,22 @@ namespace plectrify::audiosetup // which is the closest this device can come to being safe. return sorted.getLast(); } + + /** The most input channels a device is ever opened with. + + Opening every input the device lists is what lets the wizard hear + which jack the guitar is in — but a device's list is not always a set + of jacks. PipeWire's ALSA bridge (the `default` device on a current + Linux desktop) advertises 128 virtual channels, and its audioconvert + stage handles at most 64 (SPA_AUDIO_MAX_CHANNELS): opening all of them + crashes the sound server's thread inside our process. No interface a + guitar is plugged into comes near the cap, so it costs nothing real. */ + constexpr int maxOpenedInputChannels = 32; + + /** How many of a device's listed inputs to switch on: all of them, up to + maxOpenedInputChannels. */ + inline int inputChannelsToOpen (int listedInputs) + { + return juce::jlimit (0, maxOpenedInputChannels, listedInputs); + } } diff --git a/Source/app/CrashReporter.h b/Source/app/CrashReporter.h index 66b3c77..a2a3946 100644 --- a/Source/app/CrashReporter.h +++ b/Source/app/CrashReporter.h @@ -91,11 +91,16 @@ namespace plectrify #if JUCE_WINDOWS SetUnhandledExceptionFilter (detail::writeCrashDump); #else - // No minidump machinery on macOS — the OS writes a .ips crash report - // itself. A log line still marks that we went down abnormally. + // No minidump machinery off Windows — macOS writes a .ips crash report + // itself, and Linux leaves a core to systemd-coredump or apport. A log + // line still marks that we went down abnormally. juce::SystemStats::setApplicationCrashHandler ([] (void*) { + #if JUCE_MAC juce::Logger::writeToLog ("CRASH: fatal signal — see the macOS crash report"); + #else + juce::Logger::writeToLog ("CRASH: fatal signal - see coredumpctl or /var/crash"); + #endif }); #endif } diff --git a/Source/app/Main.cpp b/Source/app/Main.cpp index 408b9be..f869d4e 100644 --- a/Source/app/Main.cpp +++ b/Source/app/Main.cpp @@ -62,7 +62,12 @@ class PlectrifyApplication : public juce::JUCEApplication // custom chrome is Windows-only. WindowLookAndFeel is still set on // both, but on macOS only its frame colour is ever consulted; the // title bar it can draw is never asked for. - #if JUCE_MAC + // + // Linux takes the native decoration too: the window manager's + // frame is what tiling, snapping, workspaces and Wayland's + // server-side decorations all hang off, and a borderless X11 + // window opts out of every one of them. + #if JUCE_MAC || JUCE_LINUX setUsingNativeTitleBar (true); #else setUsingNativeTitleBar (false); diff --git a/Source/app/MainComponent.cpp b/Source/app/MainComponent.cpp index c21426e..69e5ea4 100644 --- a/Source/app/MainComponent.cpp +++ b/Source/app/MainComponent.cpp @@ -496,7 +496,9 @@ void MainComponent::applyRecommendedDeviceSetup() buffer > 0) setup.bufferSize = buffer; - if (const auto inputs = device->getInputChannelNames().size(); inputs > 0) + if (const auto inputs = plectrify::audiosetup::inputChannelsToOpen ( + device->getInputChannelNames().size()); + inputs > 0) { setup.inputChannels.clear(); setup.inputChannels.setRange (0, inputs, true); @@ -516,7 +518,8 @@ void MainComponent::enableAllInputChannels() // than set generously and left to be clamped: JUCE's Windows backends size // their channel map from the highest set bit, so a mask claiming channels // that are not there is read as channels that are. - const auto inputs = device->getInputChannelNames().size(); + const auto inputs = plectrify::audiosetup::inputChannelsToOpen ( + device->getInputChannelNames().size()); auto setup = deviceManager.getAudioDeviceSetup(); if (inputs <= 0 || setup.inputChannels.countNumberOfSetBits() >= inputs) return; // already all on — and reopening a device costs a dropout diff --git a/Source/app/PlectrifyEngine.cpp b/Source/app/PlectrifyEngine.cpp index 2eebfdc..5c1e76a 100644 --- a/Source/app/PlectrifyEngine.cpp +++ b/Source/app/PlectrifyEngine.cpp @@ -630,6 +630,8 @@ juce::var PlectrifyEngine::buildAppInfoState() // worth stating) can branch without parsing a display string. #if JUCE_MAC info->setProperty ("platform", "macos"); + #elif JUCE_LINUX + info->setProperty ("platform", "linux"); #else info->setProperty ("platform", "windows"); #endif diff --git a/Source/app/SystemStatsHelper.h b/Source/app/SystemStatsHelper.h index cc34ed7..71784da 100644 --- a/Source/app/SystemStatsHelper.h +++ b/Source/app/SystemStatsHelper.h @@ -7,6 +7,8 @@ #include #elif JUCE_MAC #include +#elif JUCE_LINUX + #include #endif namespace plectrify @@ -26,6 +28,13 @@ namespace plectrify if (task_info (mach_task_self(), MACH_TASK_BASIC_INFO, reinterpret_cast (&info), &count) == KERN_SUCCESS) return static_cast (info.resident_size) / (1024.0 * 1024.0); + #elif JUCE_LINUX + // statm's second field is the resident set, in pages. + const auto fields = juce::StringArray::fromTokens ( + juce::File ("/proc/self/statm").loadFileAsString(), true); + if (fields.size() >= 2) + return fields[1].getDoubleValue() * static_cast (::sysconf (_SC_PAGESIZE)) + / (1024.0 * 1024.0); #endif return 0.0; } diff --git a/Source/plugins/Catalogue.h b/Source/plugins/Catalogue.h index cdb9d6b..476d5d8 100644 --- a/Source/plugins/Catalogue.h +++ b/Source/plugins/Catalogue.h @@ -56,6 +56,8 @@ enum class PackageKind the compiler proves every build has exactly one answer. */ #if JUCE_MAC inline const char* const catalogueRuntimePlatform = "macos-arm64"; +#elif JUCE_LINUX +inline const char* const catalogueRuntimePlatform = "linux-x64"; #else inline const char* const catalogueRuntimePlatform = "windows-x64"; #endif diff --git a/Source/plugins/CatalogueInstaller.cpp b/Source/plugins/CatalogueInstaller.cpp index d9d2176..c4a0ca0 100644 --- a/Source/plugins/CatalogueInstaller.cpp +++ b/Source/plugins/CatalogueInstaller.cpp @@ -2,7 +2,7 @@ #include "PluginManager.h" -#if JUCE_MAC +#if JUCE_MAC || JUCE_LINUX #include #endif @@ -128,7 +128,7 @@ bool isAvailableHere (const std::vector& packages, } // namespace -/** Create a content package's directory, and on macOS give it — and every level +/** Create a content package's directory, and on macOS and Linux give it — and every level of the shared content root above it — the mode /Users/Shared itself carries: writable by every account, sticky so each can still only remove what it owns. @@ -138,7 +138,8 @@ bool isAvailableHere (const std::vector& packages, installDir beneath it, and no second account could add a package there — or another patch beside an existing one — without elevation. ProgramData's inherited ACL already gives the Windows build exactly these rights, which is - why that branch needs nothing. + why that branch needs nothing. Linux behaves like macOS below the root, + which its installer has already created 1777 (see contentRootDirectory). Every level, because a 0755 root blocks a writable child just as thoroughly. And only ever widening: chmod on a directory another account created fails, @@ -150,7 +151,7 @@ void CatalogueInstaller::createSharedContentDirectory (const juce::File& directo { directory.createDirectory(); - #if JUCE_MAC + #if JUCE_MAC || JUCE_LINUX const auto root = CatalogueInstaller::contentRootDirectory(); for (auto level = directory; level == root || level.isAChildOf (root); @@ -570,6 +571,15 @@ juce::File CatalogueInstaller::contentRootDirectory() // creates). The plugin dir is per-user on macOS, so this cannot derive // from it the way the Windows branch does. return juce::File ("/Users/Shared/Plectrify"); + #elif JUCE_LINUX + // The same requirement, and Linux has no stock answer to it at all: no + // fixed directory every account may write to without elevation exists + // (/tmp is cleared, ~ differs per user). So the installer creates this one + // world-writable and sticky, the mode /Users/Shared carries, and the dev + // loop asks for it once. Its absence makes content installs fail with a + // write error rather than land somewhere a pack's baked paths do not + // point. + return juce::File ("/var/lib/Plectrify"); #else return installDirectory().getParentDirectory(); #endif diff --git a/Source/plugins/PluginManager.cpp b/Source/plugins/PluginManager.cpp index 7312c52..3d557fd 100644 --- a/Source/plugins/PluginManager.cpp +++ b/Source/plugins/PluginManager.cpp @@ -28,6 +28,12 @@ juce::File PluginManager::getManagedPluginDirectory() // model — and other hosts still find the plugins here. return juce::File::getSpecialLocation (juce::File::userHomeDirectory) .getChildFile ("Library/Audio/Plug-Ins/VST3"); + #elif JUCE_LINUX + // The per-user VST3 convention again, for the same reason: the + // machine-wide /usr/lib/vst3 needs root, and every Linux host already + // searches ~/.vst3. + return juce::File::getSpecialLocation (juce::File::userHomeDirectory) + .getChildFile (".vst3"); #else return juce::File::getSpecialLocation (juce::File::commonApplicationDataDirectory) .getChildFile ("Plectrify") diff --git a/Source/plugins/PluginManager.h b/Source/plugins/PluginManager.h index 4ac837c..35cc7d9 100644 --- a/Source/plugins/PluginManager.h +++ b/Source/plugins/PluginManager.h @@ -39,9 +39,11 @@ class PluginManager : private juce::Thread the same machine can add packages but not overwrite the first account's. On macOS the machine-wide /Library is admin-owned, so the per-user VST3 convention keeps installs elevation-free while other - hosts still find the plugins. Both are deliberately outside the web - view's file sandbox (%APPDATA%/Plectrify resp. ~/Library/Application - Support/Plectrify), which the page can read and delete within. + hosts still find the plugins. Linux follows macOS for the same reason: + /usr/lib/vst3 is root's, so it is the per-user ~/.vst3 every Linux host + searches. All are deliberately outside the web view's file sandbox + (%APPDATA%/Plectrify resp. ~/Library/Application Support/Plectrify + resp. ~/.config/Plectrify), which the page can read and delete within. Created on demand: the scanner needs the directory to exist before it will search it. */ @@ -49,14 +51,15 @@ class PluginManager : private juce::Thread /** Whether that directory is Plectrify's alone. It is on Windows, where it is a folder under Plectrify's own ProgramData root that nothing else - writes to; it is not on macOS, where it is the user's - ~/Library/Audio/Plug-Ins/VST3, shared with every other VST3 installer. + writes to; it is not on macOS or Linux, where it is the user's + ~/Library/Audio/Plug-Ins/VST3 resp. ~/.vst3, shared with every other + VST3 installer. The distinction decides what an unaccounted-for bundle in there means — our own debris, or somebody else's plugin — which is the whole of mayReplaceManagedPlugin()'s `directoryIsExclusive`. */ static constexpr bool managedPluginDirectoryIsExclusive = - #if JUCE_MAC + #if JUCE_MAC || JUCE_LINUX false; #else true; diff --git a/Source/tone3000/Tone3000Service.cpp b/Source/tone3000/Tone3000Service.cpp index b0646a8..72d2523 100644 --- a/Source/tone3000/Tone3000Service.cpp +++ b/Source/tone3000/Tone3000Service.cpp @@ -8,7 +8,7 @@ #include #include -#if JUCE_MAC +#if JUCE_MAC || JUCE_LINUX #include // ::chmod — the credentials file is tightened to 0600 #endif @@ -218,9 +218,9 @@ void Tone3000Service::saveCredentials() file.replaceWithText (juce::JSON::toString (Tone3000Auth::toVar (current))); - #if JUCE_MAC - // Windows' %APPDATA% is already per-user ACL'd; a mac home directory is - // world-readable by default, and a bearer token should not be. + #if JUCE_MAC || JUCE_LINUX + // Windows' %APPDATA% is already per-user ACL'd; a mac or Linux home + // directory is often world-readable, and a bearer token should not be. ::chmod (file.getFullPathName().toRawUTF8(), 0600); #endif } diff --git a/Tests/app/AudioSetupRulesTests.cpp b/Tests/app/AudioSetupRulesTests.cpp index d120841..81e0662 100644 --- a/Tests/app/AudioSetupRulesTests.cpp +++ b/Tests/app/AudioSetupRulesTests.cpp @@ -62,6 +62,10 @@ namespace expectEquals (preferredDeviceType ({ "CoreAudio" }), "CoreAudio", "a single type is chosen by the fallback"); + // Linux: JACK (PipeWire's, usually) over raw ALSA, whatever JUCE's order. + expectEquals (preferredDeviceType ({ "ALSA", "JACK" }), + "JACK", "JACK wins over ALSA"); + expectEquals (preferredDeviceType ({ "Windows Audio", "DirectSound" }), "Windows Audio", "no preferred family: the host's own first choice"); @@ -104,12 +108,21 @@ namespace expectEquals (preferredBufferSize (typical, 0.0), 2048, "an unknown sample rate falls back to the longest block"); } + + void testInputChannels() + { + expectEquals (inputChannelsToOpen (2), 2, "a real interface opens every jack"); + expectEquals (inputChannelsToOpen (128), maxOpenedInputChannels, + "PipeWire's 128 virtual inputs are capped"); + expectEquals (inputChannelsToOpen (0), 0, "no inputs, none opened"); + } } int main() { testDeviceType(); testBufferSize(); + testInputChannels(); if (failures == 0) std::cout << "AudioSetupRules tests passed\n"; diff --git a/scripts/linux.ts b/scripts/linux.ts new file mode 100644 index 0000000..9b96009 --- /dev/null +++ b/scripts/linux.ts @@ -0,0 +1,195 @@ +/** + * Linux plumbing for run.linux.ts and release.linux.ts: where the build lives, + * how the app is stopped, started and staged. The platform-neutral spawn + * helpers live in shared.ts; this is the twin of macos.ts, and like it carries + * none of Windows' hazards (MSBuild .tlog staleness, the WebView2 profile + * lock) — WebKitGTK keeps no per-profile lock a dead process can leak. + * + * The layout is Windows' rather than the Mac's: a bare executable with ui/ and + * plugins/ staged beside it (moduleResourceDir), not a bundle. + */ +import { spawn, spawnSync } from 'node:child_process'; +import { accessSync, constants, cpSync, existsSync, rmSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { ROOT, capture, fail, run } from './shared.ts'; + +export function assertLinux(script: string): void { + if (process.platform !== 'linux') { + fail(`${script} is the Linux path. On Windows or macOS use the matching counterpart.`); + } +} + +/** One build tree per configuration: Ninja and Makefiles are single-config, + exactly as on the Mac. */ +export function buildDir(config: string): string { + return join(ROOT, `build-linux-${config.toLowerCase()}`); +} + +/** JUCE inserts a $ segment even under single-config generators + (JUCEUtils.cmake's products_folder). */ +export function appBinary(config: string): string { + return join(buildDir(config), 'Plectrify_artefacts', config, 'Plectrify'); +} + +export function pluginBundle(config: string): string { + return join(buildDir(config), 'PlectrifyPlugin_artefacts', config, 'VST3', 'Plectrify.vst3'); +} + +/** The machine-wide content root (CatalogueInstaller::contentRootDirectory). + The installer creates it; a dev machine has to, once, by hand. */ +export const CONTENT_ROOT = '/var/lib/Plectrify'; + +/** Configure, and refuse early with the one apt line that fixes it when a + system library JUCE needs on Linux is missing — CMake's own pkg-config error + names a module, not a package, and arrives after JUCE has been cloned. */ +export function configure(config: string): void { + assertBuildDependencies(); + const generator = capture('which', ['ninja']).trim() !== '' ? ['-G', 'Ninja'] : []; + run(`Configuring (${config})`, 'cmake', [ + '-B', buildDir(config), + '-S', ROOT, + `-DCMAKE_BUILD_TYPE=${config}`, + ...generator, + ]); +} + +/** pkg-config module → the Debian/Ubuntu package that provides it. JUCE's + Linux backends: WebKitGTK + GTK for the web view, ALSA for audio, curl for + HTTPS, freetype/fontconfig for text, plus the X11 headers its windowing + includes directly (they have no pkg-config module, so they are checked by + header). JACK is dlopened at run time and needs only its header. */ +const PKG_CONFIG_DEPS: [module: string, aptPackage: string][] = [ + ['webkit2gtk-4.1', 'libwebkit2gtk-4.1-dev'], + ['gtk+-3.0', 'libgtk-3-dev'], + ['alsa', 'libasound2-dev'], + ['libcurl', 'libcurl4-openssl-dev'], + ['freetype2', 'libfreetype-dev'], + ['fontconfig', 'libfontconfig-dev'], +]; + +const HEADER_DEPS: [header: string, aptPackage: string][] = [ + ['/usr/include/X11/Xlib.h', 'libx11-dev'], + ['/usr/include/X11/extensions/Xrandr.h', 'libxrandr-dev'], + ['/usr/include/X11/extensions/Xinerama.h', 'libxinerama-dev'], + ['/usr/include/X11/Xcursor/Xcursor.h', 'libxcursor-dev'], + ['/usr/include/X11/extensions/XShm.h', 'libxext-dev'], + ['/usr/include/X11/extensions/Xcomposite.h', 'libxcomposite-dev'], + ['/usr/include/jack/jack.h', 'libjack-jackd2-dev'], + ['/usr/include/GL/gl.h', 'libgl-dev'], +]; + +const TOOLS: [tool: string, aptPackage: string][] = [ + ['cmake', 'cmake'], + ['pkg-config', 'pkg-config'], + ['g++', 'build-essential'], + ['git', 'git'], +]; + +export function assertBuildDependencies(): void { + const missing = new Set(); + + for (const [tool, pkg] of TOOLS) { + if (capture('which', [tool]).trim() === '') missing.add(pkg); + } + const havePkgConfig = capture('which', ['pkg-config']).trim() !== ''; + for (const [module, pkg] of PKG_CONFIG_DEPS) { + if (!havePkgConfig || spawnSync('pkg-config', ['--exists', module]).status !== 0) missing.add(pkg); + } + for (const [header, pkg] of HEADER_DEPS) { + if (!existsSync(header)) missing.add(pkg); + } + + if (missing.size > 0) { + fail( + 'missing build dependencies. Install them with:\n\n' + + ` sudo apt install ${[...missing].sort().join(' ')} ninja-build\n`, + ); + } +} + +/** Warn — never fail — when the shared content root is not there to install + into. Everything but content packages and TONE3000 downloads works without + it, and the dev loop should not demand sudo before it will build. */ +export function checkContentRoot(): void { + let ok = false; + try { + ok = statSync(CONTENT_ROOT).isDirectory(); + accessSync(CONTENT_ROOT, constants.W_OK); + } catch { + ok = false; + } + if (!ok) { + console.warn( + `\nwarning: ${CONTENT_ROOT} is missing or not writable, so content packages and TONE3000\n` + + ' downloads cannot install. The installer creates it; on a dev machine, once:\n\n' + + ` sudo install -d -m 1777 ${CONTENT_ROOT}\n`, + ); + } +} + +export function buildTarget(config: string, target: string, cleanFirst = false): void { + run(`Building ${target} (${config})`, 'cmake', [ + '--build', buildDir(config), + '--target', target, + ...(cleanFirst ? ['--clean-first'] : []), + ]); +} + +/** Ask any running instance to die and wait until it is gone, so the linker + can overwrite the binary and a relaunch never races the outgoing process. + Matched by exact process name, so the WebKit helper processes — which + die with their parent — are not what is waited on. */ +export async function stopApp(): Promise { + spawnSync('pkill', ['-x', 'Plectrify']); + for (let i = 0; i < 100; i++) { + if (spawnSync('pgrep', ['-x', 'Plectrify']).status !== 0) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + fail('a Plectrify process is still running and would not exit. Quit it and retry.'); +} + +/** Launch the app and confirm it is still alive a moment later, so a startup + crash is reported rather than printed as "Launching..." and exit 0. */ +export async function startApp( + config: string, + extraEnv: Record = {}, + note = '', +): Promise { + const binary = appBinary(config); + console.log(`==> Launching ${binary}${note}`); + + const child = spawn(binary, [], { + env: { ...process.env, ...extraEnv }, + detached: true, + stdio: 'ignore', + }); + child.unref(); + + const exited = await Promise.race([ + new Promise((resolve) => child.once('exit', () => resolve(true))), + new Promise((resolve) => setTimeout(() => resolve(false), 3000)), + ]); + + if (exited) { + fail( + 'Plectrify exited immediately after launch. Run the binary from a terminal to see why:\n' + + ` ${binary}\n` + + "A crash leaves a core with systemd-coredump ('coredumpctl list Plectrify') or under /var/crash.", + ); + } +} + +/** Stage ui/dist beside the executable, where moduleResourceDir() looks in a + Release build. Debug serves the source tree's ui/dist directly. */ +export function stageUiBesideBinary(config: string): void { + if (config === 'Debug') return; + + const dist = join(ROOT, 'ui/dist'); + if (!existsSync(join(dist, 'index.html'))) { + fail('ui/dist is missing — build the UI first (rerun without --no-ui).'); + } + + const target = join(appBinary(config), '..', 'ui'); + rmSync(target, { recursive: true, force: true }); + cpSync(dist, target, { recursive: true }); +} diff --git a/scripts/run.linux.ts b/scripts/run.linux.ts new file mode 100644 index 0000000..82e2901 --- /dev/null +++ b/scripts/run.linux.ts @@ -0,0 +1,236 @@ +/** + * Build the Svelte UI + the native app, then launch it — the Linux twin of + * run.macos.ts, with the same modes. Run from the repo root: + * + * pnpm install once, for tsx + * pnpm app Default HMR loop: start the Vite dev server + + * launch the Debug app pointed at it. + * pnpm app --ui-only Rebuild ui/dist + relaunch, skipping cmake. + * pnpm app --dist Full build + gate: UI (format + check + test + + * build), native (cmake), native tests (ctest), + * then run serving the baked ui/dist. + * pnpm app --dist --no-ui Full build, skip the UI build. + * pnpm app --dist --no-run Full build only, don't launch. + * pnpm app --clean Rebuild the native target from scratch. + * pnpm app --dist --config Release + * Release build; stages ui/ beside the binary. + * pnpm app --plugin Build the Debug VST3 instead of the app and + * install it into ~/.vst3 for this user's DAWs. + * Starts Vite like the default loop; launch the + * DAW with PLECTRIFY_DEV_URL set for live HMR. + * + * Needs the system libraries JUCE builds against on Linux; configure() names + * the exact `apt install` line when any are missing. + */ +import { existsSync, mkdirSync, rmSync, cpSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { parseArgs } from 'node:util'; +import { + ROOT, + TEST_TARGETS, + bundledPluginsMissingFor, + capture, + devServerStatus, + fail, + fetchBundledPluginArchives, + pnpm, + run, +} from './shared.ts'; +import { DEV_SERVER_PORT, DEV_SERVER_URL } from './devServer.ts'; +import { + appBinary, + assertLinux, + buildDir, + buildTarget, + checkContentRoot, + configure, + pluginBundle, + stageUiBesideBinary, + startApp, + stopApp, +} from './linux.ts'; + +assertLinux('run.linux.ts'); + +const PLATFORM = 'linux-x64'; + +const { values } = parseArgs({ + options: { + dist: { type: 'boolean', default: false }, + 'ui-only': { type: 'boolean', default: false }, + 'no-ui': { type: 'boolean', default: false }, + 'no-run': { type: 'boolean', default: false }, + clean: { type: 'boolean', default: false }, + plugin: { type: 'boolean', default: false }, + config: { type: 'string', default: 'Debug' }, + }, +}); + +const config = values.config!; +const uiDir = join(ROOT, 'ui'); + +if (values.plugin && (values.dist || values['ui-only'])) { + fail('--plugin is the Debug dev loop only; a Release .vst3 is staged by the release pipeline (pnpm release).'); +} + +/** Copies the built bundle into ~/.vst3, beside the catalogue installer's + plugins, where every Linux VST3 host looks. Replaced whole, never merged. */ +function installPluginForHosts(): void { + const built = pluginBundle(config); + if (!existsSync(join(built, 'Contents'))) fail(`plugin bundle not found: ${built}`); + + const destination = join(homedir(), '.vst3', 'Plectrify.vst3'); + console.log(`==> Installing ${destination}`); + mkdirSync(join(destination, '..'), { recursive: true }); + rmSync(destination, { recursive: true, force: true }); + cpSync(built, destination, { recursive: true, verbatimSymlinks: true }); +} + +function installUiDependencies(): void { + pnpm('Installing UI dependencies', ['install', '--frozen-lockfile'], { cwd: uiDir }); +} + +/** Put the plugins Plectrify ships where a Debug build looks for them + (third_party/plugins). A shipped plugin with no Linux build is reported and + skipped: the dev loop still runs, it just lacks what that plugin enables. */ +function stageBundledPlugins(): void { + const missing = bundledPluginsMissingFor(PLATFORM); + for (const plugin of missing) { + console.warn( + `warning: ${plugin.name} has no ${PLATFORM} build in packaging/bundled-plugins.json; ` + + 'the dev build runs without it.', + ); + } + if (missing.length > 0) return; + + const destination = join(ROOT, 'third_party', 'plugins'); + for (const { plugin, archive } of fetchBundledPluginArchives(PLATFORM)) { + if (existsSync(join(destination, plugin.bundleName))) continue; + + mkdirSync(destination, { recursive: true }); + console.log(`==> Staging ${plugin.name} ${plugin.version} for the dev build`); + // unzip keeps the mode bits the reproducible archiver records, so the + // shared object stays loadable. + run('stage plugin', 'unzip', ['-q', '-o', archive, '-d', destination]); + + if (!existsSync(join(destination, plugin.bundleName))) + fail(`${plugin.name}'s archive did not contain ${plugin.bundleName}.`); + } +} + +function devPortOwner(): string { + const owner = capture('ss', ['-ltnp', `sport = :${DEV_SERVER_PORT}`]).trim(); + return owner === '' ? 'unknown process' : owner; +} + +// --------------------------------------------------------------------------- +// --ui-only: rebuild the static UI and relaunch, skipping cmake entirely. +// --------------------------------------------------------------------------- +if (values['ui-only']) { + installUiDependencies(); + pnpm('Building UI (pnpm build)', ['build'], { cwd: uiDir }); + + if (!existsSync(appBinary(config))) { + fail(`app not found: ${appBinary(config)}. Run 'pnpm app --dist' once for a full build.`); + } + await stopApp(); + stageUiBesideBinary(config); + if (!values['no-run']) await startApp(config); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// --dist: full build serving the baked ui/dist (UI -> cmake -> tests -> run). +// --------------------------------------------------------------------------- +if (values.dist) { + if (!values['no-ui']) { + installUiDependencies(); + pnpm('UI formatting check', ['format:check'], { cwd: uiDir }); + pnpm('svelte-check', ['check'], { cwd: uiDir }); + pnpm('UI tests', ['test'], { cwd: uiDir }); + pnpm('Building UI', ['build'], { cwd: uiDir }); + } + + stageBundledPlugins(); + configure(config); + checkContentRoot(); + await stopApp(); + buildTarget(config, 'Plectrify', values.clean); + + // Every test target, or ctest re-runs whatever stale binaries exist. + for (const target of TEST_TARGETS) { + buildTarget(config, target); + } + run('Native tests', 'ctest', ['--test-dir', buildDir(config), '--output-on-failure']); + + stageUiBesideBinary(config); + if (!values['no-run']) { + await startApp(config); + } else { + console.log(`Built: ${appBinary(config)}`); + } + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Default: hot-reload loop. Vite serves the UI with HMR; the Debug app loads +// it over http with the native bridge still injected. +// --------------------------------------------------------------------------- +if (config !== 'Debug') { + fail(`the HMR dev loop needs a Debug build. Use 'pnpm app --dist --config ${config}' instead.`); +} + +if (!existsSync(join(uiDir, 'node_modules'))) { + installUiDependencies(); +} + +let status = await devServerStatus(); +if (status === 'foreign') { + fail( + `port ${DEV_SERVER_PORT} is held by something that is not Plectrify's Vite dev server, so ` + + `the app would load the wrong UI. Stop it and retry.\n${devPortOwner()}`, + ); +} + +if (status === 'free') { + console.log('==> Starting Vite dev server (pnpm dev)'); + const { spawn } = await import('node:child_process'); + const vite = spawn('pnpm', ['dev'], { cwd: uiDir, detached: true, stdio: 'ignore' }); + vite.unref(); + + for (let i = 0; i < 30 && status !== 'vite'; i++) { + await new Promise((resolve) => setTimeout(resolve, 500)); + status = await devServerStatus(); + } + if (status !== 'vite') fail(`Plectrify Vite dev server did not come up on ${DEV_SERVER_URL}`); +} else { + console.log(`==> Plectrify Vite dev server already running on :${DEV_SERVER_PORT}`); +} + +stageBundledPlugins(); +configure(config); +checkContentRoot(); + +if (values.plugin) { + buildTarget(config, 'PlectrifyPlugin_VST3', values.clean); + installPluginForHosts(); + console.log(` +==> Debug Plectrify.vst3 installed into ~/.vst3 for this user's DAWs. + The Vite dev server is running on ${DEV_SERVER_URL}. For live HMR the + DAW must inherit PLECTRIFY_DEV_URL — e.g.: + + PLECTRIFY_DEV_URL=${DEV_SERVER_URL} reaper + + Launched plainly, the plugin serves the last-built ui/dist instead.`); + process.exit(0); +} + +await stopApp(); +buildTarget(config, 'Plectrify', values.clean); + +if (!values['no-run']) { + await startApp(config, { PLECTRIFY_DEV_URL: DEV_SERVER_URL }, ` (UI from ${DEV_SERVER_URL}, HMR live)`); +} else { + console.log(`Built: ${appBinary(config)} (dev-server mode)`); +} diff --git a/scripts/run.ts b/scripts/run.ts index ba5af14..6990523 100644 --- a/scripts/run.ts +++ b/scripts/run.ts @@ -1,6 +1,6 @@ /** * `pnpm app` — one dev-loop command on every OS. Dispatches to the platform - * implementation (run.windows.ts / run.macos.ts), which share their modes: + * implementation (run.windows.ts / run.macos.ts / run.linux.ts), which share their modes: * no flag for the HMR loop, --ui-only, --dist, --no-ui, --no-run, --clean, * --plugin (build + install the Debug VST3 for a DAW to host instead of * launching the app), --config . The release pipelines dispatch @@ -12,7 +12,9 @@ if (process.platform === 'win32') { await import('./run.windows.ts'); } else if (process.platform === 'darwin') { await import('./run.macos.ts'); +} else if (process.platform === 'linux') { + await import('./run.linux.ts'); } else { - console.error('error: Plectrify builds on Windows and macOS only.'); + console.error('error: Plectrify builds on Windows, macOS and Linux only.'); process.exit(1); } diff --git a/scripts/shared.ts b/scripts/shared.ts index 48c21ef..000a280 100644 --- a/scripts/shared.ts +++ b/scripts/shared.ts @@ -205,6 +205,19 @@ export interface BundledPlugin { assets: Record; } +function readBundledPlugins(): BundledPlugin[] { + const path = join(ROOT, 'packaging', 'bundled-plugins.json'); + return (JSON.parse(readFileSync(path, 'utf8')) as { plugins?: BundledPlugin[] }).plugins ?? []; +} + +/** The shipped plugins that have no build for a platform yet. The dev loop asks + this so it can say what is missing and carry on, where a release must refuse + (see bundledPluginsFor). Linux is the case it exists for: Neural Amp + Modeler's iPlug2 source has no Linux VST3 target. */ +export function bundledPluginsMissingFor(platformSlug: string): BundledPlugin[] { + return readBundledPlugins().filter((plugin) => !plugin.assets?.[platformSlug]?.url); +} + /** The shipped-plugin manifest, for the platform a release is being built on. Both release scripts read this rather than the catalogue: these are not @@ -214,10 +227,7 @@ export interface BundledPlugin { export function bundledPluginsFor(platformSlug: string): (BundledPlugin & { asset: { url: string; sha256: string; downloadBytes?: number }; })[] { - const path = join(ROOT, 'packaging', 'bundled-plugins.json'); - const manifest = JSON.parse(readFileSync(path, 'utf8')) as { plugins?: BundledPlugin[] }; - - return (manifest.plugins ?? []).map((plugin) => { + return readBundledPlugins().map((plugin) => { const asset = plugin.assets?.[platformSlug]; // A shipped plugin with no build for this platform is not a smaller // release, it is a broken one: the app assumes it is there. Refuse rather diff --git a/ui/src/features/info/diagnostics.ts b/ui/src/features/info/diagnostics.ts index dc48037..6571895 100644 --- a/ui/src/features/info/diagnostics.ts +++ b/ui/src/features/info/diagnostics.ts @@ -196,12 +196,13 @@ function appSection(input: DiagnosticsInput): DiagSection { { label: 'Formats', // "no ASIO" is a Windows fact — its absence there explains a missing - // device type. On macOS ASIO does not exist, so stating it would be - // noise in every report. + // device type. On macOS and Linux ASIO does not exist, so stating it + // would be noise in every report. value: build ? facts( build.vst3 ? 'VST3' : 'no VST3', - info.platform !== 'macos' && (build.asio ? 'ASIO built in' : 'no ASIO'), + (info.platform ?? 'windows') === 'windows' && + (build.asio ? 'ASIO built in' : 'no ASIO'), ) : DASH, }, diff --git a/ui/src/features/looper/LooperSessions.svelte b/ui/src/features/looper/LooperSessions.svelte index a12c59a..1c10da3 100644 --- a/ui/src/features/looper/LooperSessions.svelte +++ b/ui/src/features/looper/LooperSessions.svelte @@ -7,7 +7,7 @@ import { FolderOpenIcon, PencilSimpleIcon, PushPinIcon, TrashSimpleIcon } from 'phosphor-svelte'; import type { EngineBridge } from '../../lib/engine/EngineBridge'; import type { LooperSession, StatusState } from '../../lib/engine/types'; - import { isMac } from '../../lib/platform'; + import { fileManagerName } from '../../lib/platform'; import { formatLoopSeconds } from './looperPosition'; import Button from '../../lib/components/Button.svelte'; import Card from '../../lib/components/Card.svelte'; @@ -117,7 +117,7 @@ engine.revealLooperSessions()} >