From b18cf3139db35fb948117b11d27156237d54bd54 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sat, 17 Oct 2026 14:19:51 +0300 Subject: [PATCH 1/6] Add Linux X11 runtime and Studio Standalone preview --- .gitattributes | 1 + Demos/LinuxSmoke/LinuxSmoke.swift | 49 ++ Documentation/Linux.md | 174 ++++++ Editor/AGENTS.md | 6 + Editor/README.md | 10 + Editor/Sources/AdaEditor/AdaEditorApp.swift | 2 +- .../AdaEditor/Assets/Localization/en.json | 2 + .../AdaEditor/Assets/Localization/ru.json | 2 + .../AdaEditor/EditorDistribution.swift | 4 +- Editor/Sources/AdaEditor/EditorMenuBar.swift | 2 +- Editor/Sources/AdaEditor/ProjectSystem.swift | 1 + .../AdaEditor/Tooling/EditorBuildSDK.swift | 2 +- .../Tooling/EditorEngineSource.swift | 2 +- .../AdaEditor/Tooling/SwiftTooling.swift | 9 +- .../UI/Editor/EditorPlatformFileActions.swift | 29 + .../UI/Editor/EditorViewModel+Commands.swift | 4 +- ...EditorViewModel+ProjectConfiguration.swift | 1 + .../Editor/EditorViewModel+ProjectTree.swift | 1 + .../AdaEditor/UI/Editor/EditorViewModel.swift | 2 + .../AdaEditor/UI/LinuxProjectFilePicker.swift | 68 +++ .../AdaEditor/UI/ProjectOpenPicker.swift | 20 +- .../AdaEditorTests/EditorLinuxTests.swift | 44 ++ .../AdaEditorTests/EditorLinuxUTF8Tests.swift | 30 + Package.swift | 27 +- .../AdaPlatform/Linux/LinuxApplication.swift | 104 +++- Sources/AdaPlatform/Linux/LinuxInput.swift | 215 ++++++++ .../Linux/LinuxScreenManager.swift | 29 + .../AdaPlatform/Linux/LinuxSystemWindow.swift | 101 ++++ .../Linux/LinuxWindowManager.swift | 307 ++++++++++- .../Backends/WebGPU/Misc/WGPUGPUTexture.swift | 5 + .../Misc/WGPURenderCommandEncoder.swift | 1 + .../Backends/WebGPU/WGPUContext.swift | 7 +- .../Backends/WebGPU/WebGPURenderBackend.swift | 2 +- Sources/AdaRender/RenderSurface.swift | 9 + .../Modifiers/KeyboardShortcutModifier.swift | 4 +- Sources/AdaUtils/FileSystem/FileWatcher.swift | 516 +----------------- Sources/AdaUtils/FileSystem/Inotify.swift | 165 ++++++ Sources/AdaUtils/Time.swift | 6 +- Sources/X11/module.modulemap | 2 +- Sources/X11/x11-umbrella | 23 +- .../AdaPlatformTests/LinuxPlatformTests.swift | 98 ++++ .../WGPUBindingStateTests.swift | 59 ++ .../AdaScriptNewlineTests.swift | 31 ++ Tests/AdaUtilsTests/LinuxRuntimeTests.swift | 48 ++ script/package_studio_linux.py | 115 ++++ script/prepare_swan_linux.py | 51 ++ script/run_example_linux.py | 55 ++ script/run_studio_linux.sh | 26 + script/test_prepare_swan_linux.py | 42 ++ 49 files changed, 1945 insertions(+), 568 deletions(-) create mode 100644 Demos/LinuxSmoke/LinuxSmoke.swift create mode 100644 Documentation/Linux.md create mode 100644 Editor/Sources/AdaEditor/UI/LinuxProjectFilePicker.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorLinuxTests.swift create mode 100644 Editor/Tests/AdaEditorTests/EditorLinuxUTF8Tests.swift create mode 100644 Sources/AdaPlatform/Linux/LinuxInput.swift create mode 100644 Sources/AdaPlatform/Linux/LinuxScreenManager.swift create mode 100644 Sources/AdaPlatform/Linux/LinuxSystemWindow.swift create mode 100644 Sources/AdaUtils/FileSystem/Inotify.swift create mode 100644 Tests/AdaPlatformTests/LinuxPlatformTests.swift create mode 100644 Tests/AdaScriptCompilerCoreTests/AdaScriptNewlineTests.swift create mode 100644 Tests/AdaUtilsTests/LinuxRuntimeTests.swift create mode 100644 script/package_studio_linux.py create mode 100644 script/prepare_swan_linux.py create mode 100644 script/run_example_linux.py create mode 100644 script/run_studio_linux.sh create mode 100644 script/test_prepare_swan_linux.py diff --git a/.gitattributes b/.gitattributes index 838c4f501..9512df42e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,7 @@ *.frag text eol=lf *.vert text eol=lf *.glsl text eol=lf +*.sh text eol=lf /Sources/AdaEngine/AdaEngine.docc/*.png -diff -merge /Sources/AdaEngine/AdaEngine.docc/*.jpg -diff -merge diff --git a/Demos/LinuxSmoke/LinuxSmoke.swift b/Demos/LinuxSmoke/LinuxSmoke.swift new file mode 100644 index 000000000..d32df8ad8 --- /dev/null +++ b/Demos/LinuxSmoke/LinuxSmoke.swift @@ -0,0 +1,49 @@ +import AdaEngine +@_spi(Internal) import AdaPlatform +import Foundation + +@main +struct LinuxSmoke: App { + var body: some AppScene { + WindowGroup { + LinuxSmokeView() + } + .window( + with: UIWindow.Configuration( + title: "AdaEngine Linux", + frame: Rect(x: 80, y: 80, width: 800, height: 600), + minimumSize: Size(width: 480, height: 360) + ) + ) + } +} + +private struct LinuxSmokeView: View { + @State private var clicks = 0 + @State private var text = "Hello, Linux!" + + var body: some View { + VStack(spacing: 24) { + Text("AdaEngine on Linux") + .font(.system(size: 36)) + Text("Resize the window, type a message and press the button.") + TextField("Message", text: $text) + .frame(width: 360, height: 44) + Button("Clicks: \(clicks)") { + clicks += 1 + smokeLog("LINUX_SMOKE_CLICK \(clicks) text=\(text)") + } + Text(text) + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.white) + .task { + smokeLog("LINUX_SMOKE_READY backend=\(RenderEngine.shared.type)") + } + } +} + +private func smokeLog(_ message: String) { + FileHandle.standardOutput.write(Data((message + "\n").utf8)) +} diff --git a/Documentation/Linux.md b/Documentation/Linux.md new file mode 100644 index 000000000..6d5dfa198 --- /dev/null +++ b/Documentation/Linux.md @@ -0,0 +1,174 @@ +# Linux development + +The Linux backend uses X11 (including XWayland and WSLg) and the existing WebGPU +renderer. `LinuxSmoke` exercises window/input support; Studio Standalone and +AdaExamples games use the same backend. + +## Ubuntu 24.04 / WSL 2 + +Use Swift 6.3.2 for compatibility with the current Swan/Dawn dependency. The +engine's package remains in Swift 6.2 language mode. Install the Linux toolchain +using [Swiftly](https://www.swift.org/install/linux/), then: + +```sh +sudo apt-get update +sudo apt-get install -y clang cmake ninja-build pkg-config git python3 unzip \ + libx11-dev libasound2-dev libpulse0 libvulkan-dev mesa-vulkan-drivers vulkan-tools \ + libcurl4-openssl-dev libicu-dev libxml2-dev libsqlite3-dev libncurses-dev \ + libedit-dev libblocksruntime-dev libpython3-dev zip gnupg2 +``` + +Start a desktop session and check `DISPLAY`. WSLg supplies an X11 display without +installing a complete Linux desktop. `vulkaninfo --summary` identifies the GPU; +llvmpipe is a software renderer, useful for correctness checks but not GPU +performance measurements. + +Use a Linux scratch directory rather than the Windows `.build` directory: + +```sh +export ADAENGINE_SKIP_SWIFTLINT=1 +export ADAENGINE_LINUX_SCRATCH="$HOME/adaengine-linux-build" +ADAENGINE_HEADLESS=1 swift build --scratch-path "$ADAENGINE_LINUX_SCRATCH" --target AdaPlatform -j 6 +``` + +`ADAENGINE_SKIP_SWIFTLINT=1` bypasses the build-tool lint plugin only. It is useful +while diagnosing platform compilation when unrelated sources have existing lint +errors. Run lint separately on changed sources before submitting changes. + +## Swan compatibility + +The current Swan checkout provides a Linux Dawn artifact but omits Linux from +the native `WebGPU` target dependency condition. Stage an independent copy: + +```sh +mkdir -p "$HOME/adaengine-linux-tools" +git clone https://github.com/adobe/swan.git "$HOME/adaengine-linux-tools/swan-source" +git -C "$HOME/adaengine-linux-tools/swan-source" checkout --detach 1eb45a22c79439b50e9cde774454450e50147149 +python3 script/prepare_swan_linux.py "$HOME/adaengine-linux-tools/swan-source" "$HOME/adaengine-linux-tools/swan" +export ADAENGINE_SWAN_PACKAGE_PATH="$HOME/adaengine-linux-tools/swan" +export ADAENGINE_TINT_CACHE="$HOME/adaengine-linux-tools/tint" +export TINT_EXECUTABLE="$(python3 script/ensure_tint.py)" +swift run --scratch-path "$ADAENGINE_LINUX_SCRATCH" -j 6 LinuxSmoke +``` + +The checkout used for this port is Swan commit +`1eb45a22c79439b50e9cde774454450e50147149`, compatible with Swift 6.3.2 and +SwiftSyntax 602 (the same compatibility requirement as `Tools/Tint/README.md`). +An existing compatible checkout can also be passed to the staging helper. +The helper changes +the platform list in its destination and preserves the original dependency. +It refuses unrecognized manifests and unrelated existing destinations. + +An already downloaded, checksum-verified Linux Dawn `.artifactbundle` can be +copied into the staged Swan directory and selected with +`SWAN_LOCAL_DAWN=DawnLinux.artifactbundle` (relative to that package). This avoids +downloading it again through SwiftPM, including when Windows and WSL use different +proxy settings. + +## Validation + +```sh +ADAENGINE_LINUX_WINDOW_TESTS=1 swift test --scratch-path "$ADAENGINE_LINUX_SCRATCH" \ + --filter 'LinuxPlatformTests|LinuxRuntimeTests' -j 6 +python3 -m unittest discover -s script -p test_prepare_swan_linux.py +python3 script/ensure_tint.py --check +ADAENGINE_SWIFT_JOBS=4 bash script/validate_webgpu.sh +``` + +The window test requires a working X11 display. Headless rendering can validate +native window creation, resizing and teardown; it does not prove rendered pixels. +For graphical verification run `LinuxSmoke`, edit its message, press its button, +resize the native window and close it through the window manager. The executable +prints its render backend and button actions. + +The initial screen implementation reports the primary display at scale 1. +Per-monitor scaling, native Wayland, custom cursor textures and native alert +dialogs require further work. Alerts currently appear in the engine log. + +## Verified on 2026-10-17 + +Ubuntu 24.04 under WSL 2 / WSLg, Swift 6.3.2 and Mesa llvmpipe Vulkan: + +- `AdaPlatform` and `LinuxSmoke` build successfully. +- All 5 focused Linux runtime/platform tests pass, including real X11 resize, + keyboard/text delivery, focus-loss releases and `WM_DELETE_WINDOW` teardown. +- All 70 `AdaUtilsTests` / `AdaInputTests` tests pass. +- All 27 focused WebGPU tests pass, including fresh Tint conversion, sprite + pixel comparisons, picking, tile occlusion and transient attachment lifetime. +- Both Swan staging helper tests pass; the Windows package manifest still loads. +- The native demo displays its UI, accepts Latin and Cyrillic text, responds to + button clicks, resizes and exits on a native close request. + +This is software Vulkan validation. Hardware GPU behavior and a standalone Linux +desktop installation have not yet been verified. The complete engine test suite +has not been run; the module and platform checks above are the tested scope. + +## Studio Standalone and games + +Install `zenity` for the desktop file/folder chooser. Studio uses the shared AdaUI +workbench, native X11 windows and the Linux run destination. Swift projects and +AdaScript Run in a separate game window are enabled in this Standalone distribution. + +The compiler bundled under `Sources/gravity-lang` includes the UTF-8 source-length +and VM-owned string fixes. Use it for Studio and script games: + +```sh +export ADAENGINE_GRAVITY_PACKAGE_PATH="$PWD/Sources/gravity-lang" +sudo apt-get install -y zenity +bash script/run_studio_linux.sh +``` + +Keep the Swan, `SWAN_LOCAL_DAWN` and Tint overrides from the earlier setup. +The Studio launcher selects the bundled compiler by default; the environment +override is useful for running standalone sample builds with the same compiler. + +The shared AdaScript lexer recognizes CRLF as one newline. This preserves +declarations after `//` comments in Windows-authored projects and keeps diagnostic +line positions correct for LF, CRLF and CR sources. + +The launcher builds Release by default, then packages `AdaEditor`, `gravity-lsp`, +`AdaPackageTool`, resources, Swift runtime libraries, Tint, the AdaScript compiler +and a source `BuildSDK` containing Linux Swan/Dawn. The resulting directory, +`.tar.gz` and checksum are under `~/.cache/adaengine/studio-linux/distributions`. +Run the extracted directory's `./ada-studio`; building Swift games additionally +requires the host Swift toolchain and development packages. Override +`ADA_STUDIO_SCRATCH_PATH`, `ADA_STUDIO_CONFIGURATION`, `ADA_STUDIO_VERSION` or +`ADAENGINE_SWIFT_JOBS` when needed. Use an ext4 scratch directory under WSL. + +To run catalog games with the current engine without changing their upstream +checkout: + +```sh +git clone https://github.com/AdaEngine/AdaExamples.git "$HOME/AdaExamples" +python3 script/run_example_linux.py "$HOME/AdaExamples/Samples/StarQuest" --product StarQuest +python3 script/run_example_linux.py "$HOME/AdaExamples/Samples/Fold" --product Fold +``` + +The helper makes a separate editable copy under `~/.cache/adaengine/examples-linux`, +selects this engine by local path and migrates Fold's older reflection API names. +Open AdaScript-only samples such as UnfoldTheWorld through Studio's Open Project +dialog. Under WSLg, `GDK_BACKEND=x11` can select X11 for Zenity as well. + +The Linux Studio validation passes 13 focused tests across distribution, Linux +destination/SDK discovery, UTF-8/CRLF AdaScript compilation, localization, scene +script loading, hot reload and project input delivery. Another 23 compiler tests +pass in an isolated package built from the production compiler sources, including +newline positions and scriptable schema discovery after comments. +StarQuest and Fold render native windows; Fold's Jump and Turn controls execute +its AdaScript controller. A 45-second Fold run held shared graphics memory near +105 MiB after excluding transient render attachments from the persistent WebGPU +binding cache. This measurement is a short regression check, not a long-run +performance guarantee. + +The packaged debug preview launches from `/tmp` with only `/usr/bin:/bin` in +`PATH`, opens UnfoldTheWorld and runs its scripts in a separate game window: the +drone moves to the closed airlock without UI binding errors. The inline scene +viewport's Play currently stays at the authored positions for this sample; use +the top toolbar's Run button. Inline simulation parity is still incomplete. + +Linux scope remains limited: native Wayland, remote AdaPlayer, local ACP agents, +debugger, Cloud sign-in and Web/Android export parity have not been established. +The current MCP dependency logs HTTP startup on Linux but does not open its +listener there. Skeletal Garden's 3D preview reproduces a crash on a llvmpipe worker +inside Mesa's `libvulkan_lvp.so`; this sample is not validated as playable on this +software renderer. A hardware Vulkan desktop still needs separate verification. diff --git a/Editor/AGENTS.md b/Editor/AGENTS.md index f62bdfbd8..e830dfa0b 100644 --- a/Editor/AGENTS.md +++ b/Editor/AGENTS.md @@ -42,6 +42,12 @@ and assess gaps before proposing new features. open browser guides; they do not perform unattended installation. - Keep mocks, fixtures, and QA harnesses in test targets or separate executables. Do not add test-only objects or QA launch hooks to the main app, including Debug builds. +- Linux Standalone uses X11/XWayland and Vulkan. Follow + [../Documentation/Linux.md](../Documentation/Linux.md) and launch through + `../script/run_studio_linux.sh` to stage resources, runtime libraries and BuildSDK. + Use the bundled Gravity compiler and staged Linux Swan overrides for builds/tests; + native dialogs require Zenity. The initial Linux workbench/local Run port does + not establish remote AdaPlayer, ACP, debugger, Cloud or cross-export parity. - Use an isolated scratch/module cache when necessary; never delete the shared `.build`. Preserve unrelated engine, demo, and editor changes. - Debug macOS builds accept `--editor-project=` to open the diff --git a/Editor/README.md b/Editor/README.md index fb0f5a8f3..db6e798fb 100644 --- a/Editor/README.md +++ b/Editor/README.md @@ -25,6 +25,16 @@ swift build swift test ``` +### Linux + +Studio Standalone can be built for X11/XWayland, including WSLg, with Swift 6.3.2 +and Vulkan. See [Linux setup and validation](../Documentation/Linux.md) for the +pinned Swan/Dawn, Tint and bundled compiler setup, then run +`bash script/run_studio_linux.sh` from the engine root. It builds and stages the +editor, resources, Swift runtime and source BuildSDK before launching. File +dialogs require Zenity; Linux is the local Run destination. This initial port +does not establish parity for every desktop service or export target. + ### Windows Use Swift 6.3 or newer for the Swan/WebGPU dependency, Visual Studio C++ Build diff --git a/Editor/Sources/AdaEditor/AdaEditorApp.swift b/Editor/Sources/AdaEditor/AdaEditorApp.swift index 318bcb7dc..7ce87f550 100644 --- a/Editor/Sources/AdaEditor/AdaEditorApp.swift +++ b/Editor/Sources/AdaEditor/AdaEditorApp.swift @@ -137,7 +137,7 @@ struct AdaEditorApp: App { .transformAppWorlds { worlds in worlds.insertResource(ApplicationFramePacing(maximumFramesPerSecond: 120, minimumFramesPerSecond: 120)) } - #elseif os(Windows) + #elseif os(Windows) || os(Linux) .transformAppWorlds { worlds in worlds.insertResource(ApplicationFramePacing( maximumFramesPerSecond: 60, diff --git a/Editor/Sources/AdaEditor/Assets/Localization/en.json b/Editor/Sources/AdaEditor/Assets/Localization/en.json index 88d25e784..e4cbdc2b4 100644 --- a/Editor/Sources/AdaEditor/Assets/Localization/en.json +++ b/Editor/Sources/AdaEditor/Assets/Localization/en.json @@ -1,4 +1,6 @@ { + "The Linux file dialog could not be opened.": "The Linux file dialog could not be opened.", + "Install Zenity to use Linux file dialogs.": "Install Zenity to use Linux file dialogs.", " · Stopped": " · Stopped", "#RRGGBB": "#RRGGBB", "#RRGGBBAA": "#RRGGBBAA", diff --git a/Editor/Sources/AdaEditor/Assets/Localization/ru.json b/Editor/Sources/AdaEditor/Assets/Localization/ru.json index ab1b6d75a..9703b693c 100644 --- a/Editor/Sources/AdaEditor/Assets/Localization/ru.json +++ b/Editor/Sources/AdaEditor/Assets/Localization/ru.json @@ -1,4 +1,6 @@ { + "The Linux file dialog could not be opened.": "Не удалось открыть окно выбора файлов в Linux.", + "Install Zenity to use Linux file dialogs.": "Установи Zenity, чтобы выбирать файлы и папки в Linux.", " · Stopped": " · Остановлено", "#RRGGBB": "#RRGGBB", "#RRGGBBAA": "#RRGGBBAA", diff --git a/Editor/Sources/AdaEditor/EditorDistribution.swift b/Editor/Sources/AdaEditor/EditorDistribution.swift index 270047dec..5aaf8db27 100644 --- a/Editor/Sources/AdaEditor/EditorDistribution.swift +++ b/Editor/Sources/AdaEditor/EditorDistribution.swift @@ -7,7 +7,9 @@ public enum EditorDistribution: String, Sendable { case standalone public static let current: Self = { - #if os(iOS) || os(tvOS) || os(visionOS) || os(Android) + #if os(Linux) + return .standalone + #elseif os(iOS) || os(tvOS) || os(visionOS) || os(Android) return .appStore #else #if os(macOS) diff --git a/Editor/Sources/AdaEditor/EditorMenuBar.swift b/Editor/Sources/AdaEditor/EditorMenuBar.swift index fb90afe37..86cf0b0a4 100644 --- a/Editor/Sources/AdaEditor/EditorMenuBar.swift +++ b/Editor/Sources/AdaEditor/EditorMenuBar.swift @@ -304,7 +304,7 @@ enum EditorMenuBar { ) -> MenuItem { var key = key var modifiers = modifiers - #if os(Windows) + #if os(Windows) || os(Linux) if key == .plus { key = .equals } if modifiers?.contains(.main) == true { modifiers?.remove(.main) diff --git a/Editor/Sources/AdaEditor/ProjectSystem.swift b/Editor/Sources/AdaEditor/ProjectSystem.swift index a08b998ea..b703428a0 100644 --- a/Editor/Sources/AdaEditor/ProjectSystem.swift +++ b/Editor/Sources/AdaEditor/ProjectSystem.swift @@ -659,6 +659,7 @@ public struct AdaProjectRun: Codable, Equatable, Sendable { public enum AdaProjectRunDestination: String, Codable, CaseIterable, Equatable, Sendable { case macOS = "macos" case windows + case linux case iPadOS = "ipados" case visionOS = "visionos" case web diff --git a/Editor/Sources/AdaEditor/Tooling/EditorBuildSDK.swift b/Editor/Sources/AdaEditor/Tooling/EditorBuildSDK.swift index e9c78f18e..3deecdd7d 100644 --- a/Editor/Sources/AdaEditor/Tooling/EditorBuildSDK.swift +++ b/Editor/Sources/AdaEditor/Tooling/EditorBuildSDK.swift @@ -27,7 +27,7 @@ struct EditorBuildSDK: Sendable { if let explicit = override ?? environment["ADA_STUDIO_SDK"] { return try load(at: URL(fileURLWithPath: explicit)) } - #if os(Windows) + #if os(Windows) || os(Linux) let installed = executable.deletingLastPathComponent().appendingPathComponent("BuildSDK") if FileManager.default.fileExists(atPath: installed.path) { return try load(at: installed) diff --git a/Editor/Sources/AdaEditor/Tooling/EditorEngineSource.swift b/Editor/Sources/AdaEditor/Tooling/EditorEngineSource.swift index 9e408f938..357d3caa4 100644 --- a/Editor/Sources/AdaEditor/Tooling/EditorEngineSource.swift +++ b/Editor/Sources/AdaEditor/Tooling/EditorEngineSource.swift @@ -22,7 +22,7 @@ enum EditorEngineSource { return nil #endif } - #elseif os(Windows) + #elseif os(Windows) || os(Linux) if let executable, FileManager.default.fileExists(atPath: executable.deletingLastPathComponent().appendingPathComponent("BuildSDK").path) { root = executable.deletingLastPathComponent().appendingPathComponent("BuildSDK/AdaEngine") diff --git a/Editor/Sources/AdaEditor/Tooling/SwiftTooling.swift b/Editor/Sources/AdaEditor/Tooling/SwiftTooling.swift index 0726d6191..9b011f0ab 100644 --- a/Editor/Sources/AdaEditor/Tooling/SwiftTooling.swift +++ b/Editor/Sources/AdaEditor/Tooling/SwiftTooling.swift @@ -430,6 +430,7 @@ enum EditorRunDestination: String, CaseIterable, Equatable, Sendable { case player = "AdaPlayer" case macOS = "macOS" case windows = "Windows" + case linux = "Linux" case iPadOS = "iPadOS" case visionOS = "visionOS" case web = "Web" @@ -437,13 +438,15 @@ enum EditorRunDestination: String, CaseIterable, Equatable, Sendable { static var availableCases: [Self] { #if os(macOS) - allCases.filter { $0 != .windows && (EditorDistribution.current.supportsSwiftProjects || $0 != .android) } + allCases.filter { $0 != .windows && $0 != .linux && (EditorDistribution.current.supportsSwiftProjects || $0 != .android) } #elseif os(Windows) [.windows, .web, .android, .player] + #elseif os(Linux) + [.linux] #elseif !canImport(Network) - allCases.filter { $0 != .android && $0 != .player && $0 != .windows } + allCases.filter { $0 != .android && $0 != .player && $0 != .windows && $0 != .linux } #else - allCases.filter { $0 != .android && $0 != .windows } + allCases.filter { $0 != .android && $0 != .windows && $0 != .linux } #endif } diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorPlatformFileActions.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorPlatformFileActions.swift index 41050ebea..6c5c99871 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorPlatformFileActions.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorPlatformFileActions.swift @@ -2,6 +2,8 @@ import Foundation #if canImport(AppKit) import AppKit +#elseif os(Linux) + import AdaEngine #elseif os(Windows) import AdaEngine import WinSDK @@ -17,6 +19,9 @@ enum EditorPlatformFileActions { #elseif os(Windows) UIClipboard.setString(value) return UIClipboard.getString() == value + #elseif os(Linux) + UIClipboard.setString(value) + return UIClipboard.getString() == value #else return false #endif @@ -30,6 +35,9 @@ enum EditorPlatformFileActions { #elseif os(Windows) guard url.isFileURL else { return false } return shellOpen("explorer.exe", arguments: "/select,\"\(url.path)\"") + #elseif os(Linux) + guard url.isFileURL else { return false } + return linuxOpen("xdg-open", arguments: [(url.hasDirectoryPath ? url : url.deletingLastPathComponent()).path]) #else return false #endif @@ -41,6 +49,8 @@ enum EditorPlatformFileActions { return NSWorkspace.shared.open(url) #elseif os(Windows) return shellOpen(url.isFileURL ? url.path : url.absoluteString) + #elseif os(Linux) + return linuxOpen("xdg-open", arguments: [url.isFileURL ? url.path : url.absoluteString]) #else return false #endif @@ -63,11 +73,30 @@ enum EditorPlatformFileActions { guard url.isFileURL else { return false } let directory = url.hasDirectoryPath ? url : url.deletingLastPathComponent() return shellOpen("powershell.exe", arguments: "-NoExit", directory: directory.path) + #elseif os(Linux) + guard url.isFileURL else { return false } + return linuxOpen("x-terminal-emulator", directory: url.hasDirectoryPath ? url : url.deletingLastPathComponent()) #else return false #endif } + #if os(Linux) + private static func linuxOpen(_ name: String, arguments: [String] = [], directory: URL? = nil) -> Bool { + guard let executable = LinuxProjectFilePicker.executable(named: name) else { return false } + let process = Process() + process.executableURL = executable + process.arguments = arguments + process.currentDirectoryURL = directory + do { + try process.run() + return true + } catch { + return false + } + } + #endif + #if os(Windows) private static func shellOpen(_ target: String, arguments: String? = nil, directory: String? = nil) -> Bool { target.withCString(encodedAs: UTF16.self) { target in diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift index bf231783e..02857d438 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+Commands.swift @@ -262,7 +262,7 @@ extension EditorViewModel { if let projectURL, let settings = projectSettings, settings.build.system == .adaScript, - (selectedRunDestination == .macOS || selectedRunDestination == .windows || selectedRunDestination == .visionOS) { + (selectedRunDestination == .macOS || selectedRunDestination == .windows || selectedRunDestination == .linux || selectedRunDestination == .visionOS) { let projectName = settings.project.displayName ?? settings.project.name ?? project?.name ?? "AdaScript Project" buildAdaScriptProject( settings, @@ -296,7 +296,7 @@ extension EditorViewModel { exportAndroidProject(launch: true) case .player: runOnAdaPlayer() - case .macOS, .windows: + case .macOS, .windows, .linux: executeWorkspaceCommand( .run(target: product, arguments: projectSettings?.run.arguments ?? []), statusTitle: product.map { "Run \($0) on \(selectedRunDestination.rawValue)" } ?? "Run on \(selectedRunDestination.rawValue)" diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectConfiguration.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectConfiguration.swift index d20583ced..c0c50b1aa 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectConfiguration.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectConfiguration.swift @@ -434,6 +434,7 @@ extension EditorViewModel { switch destination { case .macOS: .macOS case .windows: .windows + case .linux: .linux case .visionOS: .visionOS case .iPadOS: .iPadOS case .web: .web diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectTree.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectTree.swift index 0dbd8a55d..a7021b367 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectTree.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel+ProjectTree.swift @@ -474,6 +474,7 @@ extension EditorRunDestination { switch self { case .macOS: .macOS case .windows: .windows + case .linux: .linux case .visionOS: .visionOS case .iPadOS, .player: diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel.swift index a3c54b575..745f6839d 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorViewModel.swift @@ -263,6 +263,8 @@ final class EditorViewModel { self.selectedRunDestination = selectedRunDestination ?? savedDestination.flatMap { EditorRunDestination.availableCases.contains($0) ? $0 : nil } ?? .windows + #elseif os(Linux) + self.selectedRunDestination = selectedRunDestination.flatMap { EditorRunDestination.availableCases.contains($0) ? $0 : nil } ?? .linux #else self.selectedRunDestination = selectedRunDestination ?? Self.editorRunDestination(from: savedProject?.run.destination ?? .macOS) #endif diff --git a/Editor/Sources/AdaEditor/UI/LinuxProjectFilePicker.swift b/Editor/Sources/AdaEditor/UI/LinuxProjectFilePicker.swift new file mode 100644 index 000000000..34c3f8f8e --- /dev/null +++ b/Editor/Sources/AdaEditor/UI/LinuxProjectFilePicker.swift @@ -0,0 +1,68 @@ +#if os(Linux) +import Foundation + +/// Zenity uses the desktop's GTK file chooser without linking GTK into the engine. +@MainActor +enum LinuxProjectFilePicker { + static func folder(title: String) async -> ProjectLocationPickerResult { + switch await select(title: title, options: ["--directory"]) { + case let .selected(urls): urls.first.map(ProjectLocationPickerResult.selected) ?? .cancelled + case .cancelled: .cancelled + case let .unavailable(message): .unavailable(message) + } + } + + static func files( + title: String, + extensions: [String] = [], + allowsMultipleSelection: Bool = true, + directory: URL? = nil + ) async -> AssetFilePickerResult { + var options: [String] = [] + if allowsMultipleSelection { options += ["--multiple", "--separator=\u{1F}"] } + if let directory { options += ["--filename=\(directory.path)/"] } + if !extensions.isEmpty { options += ["--file-filter=\(extensions.map { "*.\($0)" }.joined(separator: " "))"] } + return await select(title: title, options: options) + } + + private static func select(title: String, options: [String]) async -> AssetFilePickerResult { + guard let executable = executable(named: "zenity") else { + return .unavailable(L10n.text("Install Zenity to use Linux file dialogs.")) + } + let process = Process() + process.executableURL = executable + process.arguments = ["--file-selection", "--title=\(title)"] + options + // A temporary output file avoids pipe backpressure for large selections. + let output = FileManager.default.temporaryDirectory.appendingPathComponent("ada-picker-\(UUID().uuidString)") + guard FileManager.default.createFile(atPath: output.path, contents: nil), + let handle = try? FileHandle(forWritingTo: output) else { + return .cancelled + } + defer { + try? handle.close() + try? FileManager.default.removeItem(at: output) + } + process.standardOutput = handle + process.standardError = FileHandle.nullDevice + let status: Int32 = await withCheckedContinuation { continuation in + process.terminationHandler = { continuation.resume(returning: $0.terminationStatus) } + do { try process.run() } catch { continuation.resume(returning: -1) } + } + guard status == 0 else { + return status == 1 ? .cancelled : .unavailable(L10n.text("The Linux file dialog could not be opened.")) + } + guard var selection = try? String(contentsOf: output, encoding: .utf8) else { + return .cancelled + } + if selection.hasSuffix("\n") { selection.removeLast() } + let urls = selection.split(separator: "\u{1F}", omittingEmptySubsequences: true).map { URL(fileURLWithPath: String($0)) } + return urls.isEmpty ? .cancelled : .selected(urls) + } + + static func executable(named name: String) -> URL? { + let path = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/local/bin:/usr/bin:/bin" + return path.split(separator: ":").map { URL(fileURLWithPath: String($0)).appendingPathComponent(name) } + .first { FileManager.default.isExecutableFile(atPath: $0.path) } + } +} +#endif diff --git a/Editor/Sources/AdaEditor/UI/ProjectOpenPicker.swift b/Editor/Sources/AdaEditor/UI/ProjectOpenPicker.swift index 17c8ae621..b0f41e429 100644 --- a/Editor/Sources/AdaEditor/UI/ProjectOpenPicker.swift +++ b/Editor/Sources/AdaEditor/UI/ProjectOpenPicker.swift @@ -55,7 +55,7 @@ enum ProjectOpenPicker { static func presentProjectPicker(completion: @escaping @MainActor (URL?) -> Void) { #if canImport(AppKit) Task { completion(await pickProjectURL()) } - #elseif os(Windows) + #elseif os(Windows) || os(Linux) Task { completion(await pickProjectURL()) } #elseif canImport(UIKit) guard let presenter = activeViewController() else { @@ -107,6 +107,11 @@ enum ProjectOpenPicker { return projectDirectoryURL(fromPickerSelection: url) } return nil + #elseif os(Linux) + if case let .selected(url) = await LinuxProjectFilePicker.folder(title: L10n.tr(title)) { + return projectDirectoryURL(fromPickerSelection: url) + } + return nil #else return nil #endif @@ -138,6 +143,8 @@ enum ProjectOpenPicker { completion(.selected(retainSecurityScopedAccess(to: projectLocationURL(fromPickerSelection: selectedURL)))) #elseif os(Windows) Task { completion(await WindowsProjectFilePicker.folder(title: L10n.tr(projectLocationTitle))) } + #elseif os(Linux) + Task { completion(await LinuxProjectFilePicker.folder(title: L10n.tr(projectLocationTitle))) } #elseif canImport(UIKit) guard let presenter = activeViewController() else { completion(.unavailable("AdaEditor has no active window from which to open Files.")) @@ -178,6 +185,9 @@ enum ProjectOpenPicker { #elseif os(Windows) if case let .selected(urls) = await WindowsProjectFilePicker.files(title: L10n.tr(assetImportTitle)) { return urls } return nil + #elseif os(Linux) + if case let .selected(urls) = await LinuxProjectFilePicker.files(title: L10n.tr(assetImportTitle)) { return urls } + return nil #else return nil #endif @@ -209,6 +219,8 @@ enum ProjectOpenPicker { completion(.selected(panel.urls)) #elseif os(Windows) Task { completion(await WindowsProjectFilePicker.files(title: L10n.tr(agentContextTitle))) } + #elseif os(Linux) + Task { completion(await LinuxProjectFilePicker.files(title: L10n.tr(agentContextTitle))) } #elseif canImport(UIKit) guard let presenter = activeViewController() else { completion(.unavailable("AdaEditor has no active window from which to open Files.")) @@ -298,6 +310,8 @@ enum ProjectOpenPicker { } #elseif os(Windows) Task { completion(await WindowsProjectFilePicker.files(title: L10n.text("Choose Build Files"), directory: directoryURL)) } + #elseif os(Linux) + Task { completion(await LinuxProjectFilePicker.files(title: L10n.text("Choose Build Files"), directory: directoryURL)) } #elseif canImport(UIKit) guard let presenter = activeViewController() else { completion(.unavailable("AdaEditor has no active window from which to open Files.")) @@ -342,6 +356,8 @@ enum ProjectOpenPicker { completion(.selected(panel.urls)) #elseif os(Windows) Task { completion(await WindowsProjectFilePicker.files(title: L10n.tr(atlasImageTitle), extensions: ["png"])) } + #elseif os(Linux) + Task { completion(await LinuxProjectFilePicker.files(title: L10n.tr(atlasImageTitle), extensions: ["png"])) } #elseif canImport(UIKit) guard let presenter = activeViewController() else { completion(.unavailable("AdaEditor has no active window from which to open Files.")) @@ -382,6 +398,8 @@ enum ProjectOpenPicker { } #elseif os(Windows) Task { completion(await WindowsProjectFilePicker.files(title: L10n.text("Link Tile Source"), extensions: ["tileset"], allowsMultipleSelection: false, directory: directoryURL)) } + #elseif os(Linux) + Task { completion(await LinuxProjectFilePicker.files(title: L10n.text("Link Tile Source"), extensions: ["tileset"], allowsMultipleSelection: false, directory: directoryURL)) } #elseif canImport(UIKit) guard let presenter = activeViewController() else { completion(.unavailable("AdaEditor has no active window from which to open Files.")) diff --git a/Editor/Tests/AdaEditorTests/EditorLinuxTests.swift b/Editor/Tests/AdaEditorTests/EditorLinuxTests.swift new file mode 100644 index 000000000..f51e05a85 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorLinuxTests.swift @@ -0,0 +1,44 @@ +#if os(Linux) +import AdaEngine +import Foundation +import Testing + +@testable import AdaEditor + +@Suite("Linux Studio Standalone") +struct EditorLinuxTests { + @Test @MainActor func nativeDestinationAndDistribution() throws { + #expect(EditorDistribution.current == .standalone) + #expect(EditorDistribution.current.supportsSwiftProjects) + #expect(EditorRunDestination.availableCases == [.linux]) + #expect(KeyModifier.command == .control) + #expect(EditorRunDestination.linux.adaProjectDestination == .linux) + #expect(EditorViewModel.editorRunDestination(from: .linux) == .linux) + let data = try JSONEncoder().encode(AdaProjectRunDestination.linux) + #expect(try JSONDecoder().decode(AdaProjectRunDestination.self, from: data) == .linux) + } + + @Test func packagedSDKUsesExecutableDirectory() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("Studio Linux \(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let sdk = root.appendingPathComponent("BuildSDK") + for directory in ["AdaEngine", "AdaScript/tools"] { + try FileManager.default.createDirectory(at: sdk.appendingPathComponent(directory), withIntermediateDirectories: true) + } + try Data("{\"schemaVersion\":1}".utf8).write(to: sdk.appendingPathComponent("sdk.json")) + for file in ["AdaEngine/Package.swift", "AdaScript/Package.swift", "AdaScript/tools/aot_build.py"] { + try Data().write(to: sdk.appendingPathComponent(file)) + } + let compiler = sdk.appendingPathComponent("AdaScript/gravity") + try Data("#!/bin/sh\nprintf 'Linux compiler\\n'\n".utf8).write(to: compiler) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: compiler.path) + let executable = root.appendingPathComponent("AdaEditor") + let installed = try EditorBuildSDK.locate(executable: executable, environment: [:]) + #expect(installed.engineRoot == sdk.appendingPathComponent("AdaEngine")) + #expect(EditorEngineSource.locate(executable: executable, environment: [:]) == installed.engineRoot) + let result = await EditorProcessRunner().run(.init(executablePath: compiler.path, arguments: [], workingDirectory: root)) + #expect(result.succeeded) + #expect(result.standardOutput == "Linux compiler\n") + } +} +#endif diff --git a/Editor/Tests/AdaEditorTests/EditorLinuxUTF8Tests.swift b/Editor/Tests/AdaEditorTests/EditorLinuxUTF8Tests.swift new file mode 100644 index 000000000..225da7a20 --- /dev/null +++ b/Editor/Tests/AdaEditorTests/EditorLinuxUTF8Tests.swift @@ -0,0 +1,30 @@ +#if os(Linux) +import AdaEngine +import AdaScriptCompilerCore +import Foundation +import Testing + +@Suite("Linux AdaScript UTF-8") +struct EditorLinuxUTF8Tests { + @Test @MainActor func compilesWindowsLineEndingsAndUnicode() throws { + let source = (String(repeating: "// A Windows-authored project\n", count: 80) + """ + @system(id: "linux.utf8") + class UnicodeSystem { + func update(context) { + var message = "Привет, мир 🌍"; + System.print(message); + } + } + """).replacingOccurrences(of: "\n", with: "\r\n") + let systems = try AdaScriptSchemaParser.parseSystemCapabilities(sources: [ + AdaScriptCompilerSource(path: "Unicode.ada", source: source) + ]) + #expect(systems.count == 1) + let plugin = try AdaScriptPlugin( + sources: [AdaScriptSource(path: "Unicode.ada", source: source)], + name: "LinuxUTF8" + ) + #expect(plugin.diagnostics.isEmpty) + } +} +#endif diff --git a/Package.swift b/Package.swift index 5496dd830..b7197f95a 100644 --- a/Package.swift +++ b/Package.swift @@ -177,9 +177,10 @@ products.append(.library(name: "AndroidDemo", type: .dynamic, targets: ["Android // MARK: Editor Target var commonPlugins: [Target.PluginUsage] = [] +let shouldSkipSwiftLint = ProcessInfo.processInfo.environment["ADAENGINE_SKIP_SWIFTLINT"] == "1" #if os(macOS) || os(Linux) -if !isWebExportEnabled && !isAndroidBuildEnabled { +if !isWebExportEnabled && !isAndroidBuildEnabled && !shouldSkipSwiftLint { commonPlugins.append( .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins") ) @@ -247,10 +248,6 @@ var adaEngineDependencies: [Target.Dependency] = [ "AdaSpatial" ] -#if os(Linux) -adaEngineDependencies += [.target(name: "X11", condition: .when(platforms: [.linux]))] -#endif - var adaRenderDependencies: [Target.Dependency] = [ .target(name: "CAndroid", condition: .when(platforms: [.android])), "AdaApp", @@ -413,6 +410,7 @@ var targets: [Target] = [ .adaTarget( name: "AdaPlatform", dependencies: [ + .target(name: "X11", condition: .when(platforms: [.linux])), "AdaUtils", "AdaECS", "AdaApp", @@ -903,19 +901,24 @@ if isWGPUEnabled { // MARK: Extra +#if os(Linux) +products.append(.executable(name: "LinuxSmoke", targets: ["LinuxSmoke"])) +targets += [ + .executableTarget(name: "LinuxSmoke", dependencies: ["AdaEngine", "AdaPlatform"], path: "Demos/LinuxSmoke", swiftSettings: swiftSettings), + .testTarget(name: "AdaPlatformTests", dependencies: ["AdaPlatform", "AdaECS", "AdaInput", "AdaRender", "AdaUI", "AdaUtils", "Math", "X11"], swiftSettings: swiftSettings), +] +#endif + #if os(Android) || os(Linux) targets += [ .testTarget(name: "AdaEngineEmbeddableTests", dependencies: ["AdaEngineEmbeddable"]), .testTarget(name: "AdaScriptCompilerCoreTests", dependencies: ["AdaScriptCompilerCore"]), - .systemLibrary( - name: "X11", - pkgConfig: "x11", - providers: [ - .apt(["libx11-dev"]) - ]), ] #endif +// The target must exist on every host because AdaPlatform's dependency is conditional. +targets.append(.systemLibrary(name: "X11", pkgConfig: "x11", providers: [.apt(["libx11-dev"])])) + // MARK: - CXX Internal Targets targets += [ @@ -940,6 +943,7 @@ targets += [ ], publicHeadersPath: "include", cSettings: [ + .define("_POSIX_C_SOURCE", to: "200809L", .when(platforms: [.linux])), .unsafeFlags(["-w"]) ] ), @@ -967,6 +971,7 @@ targets += [ ], publicHeadersPath: "include", cSettings: [ + .define("_POSIX_C_SOURCE", to: "200809L", .when(platforms: [.linux])), .unsafeFlags(["-w", "-std=c17"]) ] ), diff --git a/Sources/AdaPlatform/Linux/LinuxApplication.swift b/Sources/AdaPlatform/Linux/LinuxApplication.swift index 1f9ecf0f8..ebfc4d582 100644 --- a/Sources/AdaPlatform/Linux/LinuxApplication.swift +++ b/Sources/AdaPlatform/Linux/LinuxApplication.swift @@ -1,19 +1,103 @@ -// -// LinuxApplication.swift -// AdaEngine -// -// Created by v.prusakov on 9/2/22. -// - -#if LINUX +#if os(Linux) + import AdaApp + import AdaECS + @_spi(Internal) import AdaInput + @_spi(Internal) import AdaUI + import AdaUtils + import Foundation + import Glibc + import Logging import X11 + @MainActor final class LinuxApplication: Application { + private let display: LinuxDisplay + private var isRunning = true + override init(argc: Int32, argv: UnsafeMutablePointer?>) throws { - try super.init(argc: argc, argv: argv) + display = try LinuxDisplay() + unsafe try super.init(argc: argc, argv: argv) + windowManager = LinuxWindowManager(display: display) + UIWindowManager.setShared(windowManager) + AdaUI.Screen.screenManager = LinuxScreenManager(display: display) + } + + override func run(_ appWorlds: AppWorlds) async throws { + windowManager.inputRef = appWorlds.main.getRefResource(Input.self) + while isRunning { + try Task.checkCancellation() + let startedAt = Time.absolute + (windowManager as? LinuxWindowManager)?.pollEvents() + guard isRunning else { + break + } + try await appWorlds.update() + let duration = appWorlds.getResource(ApplicationFramePacing.self)?.minimumFrameDuration ?? (1.0 / 60.0) + let remaining = duration - (Time.absolute - startedAt) + if remaining > 0 { + try await Task.sleep(for: .seconds(Double(remaining))) + } else { + await Task.yield() + } + } + } + + override func terminate() { isRunning = false } + + override func openURL(_ url: URL) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/xdg-open") + process.arguments = [url.absoluteString] + do { + try process.run() + return true + } catch { + Logger(label: "org.adaengine.linux").error("Cannot open URL: \(error)") + return false + } + } + + override func showAlert(_ alert: Alert) { + // Keep the message visible even on minimal Linux desktops without a dialog utility. + Logger(label: "org.adaengine.linux").error("\(alert.title): \(alert.message ?? "")") + } + } + + /// Event access is main-actor isolated. XInitThreads also permits Dawn to use Xlib on its GPU thread. + @MainActor + final class LinuxDisplay { + let pointer: OpaquePointer + let inputMethod: XIM? + + init() throws { + guard unsafe XInitThreads() != 0 else { + throw LinuxPlatformError.threadInitializationFailed + } + unsafe setlocale(LC_CTYPE, "") + unsafe XSetLocaleModifiers("") + guard let pointer = unsafe XOpenDisplay(nil) else { + throw LinuxPlatformError.displayUnavailable + } + unsafe self.pointer = pointer + inputMethod = unsafe XOpenIM(pointer, nil, nil, nil) + var supported: Int32 = 0 + unsafe XkbSetDetectableAutoRepeat(pointer, 1, &supported) + } - self.windowManager = LinuxWindowManager() + isolated deinit { + if let inputMethod { unsafe XCloseIM(inputMethod) } + unsafe XCloseDisplay(pointer) } } + enum LinuxPlatformError: LocalizedError { + case threadInitializationFailed, displayUnavailable + + var errorDescription: String? { + switch self { + case .threadInitializationFailed: "Cannot initialize Xlib threading." + case .displayUnavailable: "Cannot open the X11 display. Start a desktop session (or WSLg) and check DISPLAY." + } + } + } #endif diff --git a/Sources/AdaPlatform/Linux/LinuxInput.swift b/Sources/AdaPlatform/Linux/LinuxInput.swift new file mode 100644 index 000000000..65c2d0e0a --- /dev/null +++ b/Sources/AdaPlatform/Linux/LinuxInput.swift @@ -0,0 +1,215 @@ +#if os(Linux) + import AdaECS + @_spi(Internal) import AdaInput + @_spi(Internal) import AdaUI + import AdaUtils + import Foundation + import Math + import X11 + + @MainActor + final class LinuxInput { + private var pressedKeys: [UIWindow.ID: [UInt32: AdaInput.KeyCode]] = [:] + private var pressedButtons: [UIWindow.ID: Set] = [:] + + func receive(_ event: inout XEvent, for window: UIWindow, native: LinuxSystemWindow, inputRef: Ref?) { + guard let inputRef else { + return + } + switch event.type { + case KeyPress, KeyRelease: + let down = event.type == KeyPress + let symbol = unsafe XkbKeycodeToKeysym(native.display.pointer, UInt8(event.xkey.keycode), 0, 0) + let key = Self.keyCode(for: UInt(symbol)) + let modifiers = Self.modifiers(for: event.xkey.state) + let repeated = pressedKeys[window.id]?[event.xkey.keycode] != nil + if down { + pressedKeys[window.id, default: [:]][event.xkey.keycode] = key + } else { + pressedKeys[window.id]?[event.xkey.keycode] = nil + } + inputRef.wrappedValue.receiveEvent( + KeyEvent( + window: window.id, + keyCode: key, + modifiers: modifiers, + status: down ? .down : .up, + time: Float(Time.absolute), + isRepeated: down && repeated + ) + ) + if down, !modifiers.contains(.control), !modifiers.contains(.main) { + let text = lookupText(event: &event.xkey, context: native.inputContext) + if key == .backspace { + inputRef.wrappedValue.receiveEvent(TextInputEvent(window: window.id, text: "", action: .deleteBackward, time: Float(Time.absolute))) + } else if !text.isEmpty, !text.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) { + inputRef.wrappedValue.receiveEvent(TextInputEvent(window: window.id, text: text, action: .insert, time: Float(Time.absolute))) + } + } + case ButtonPress, ButtonRelease: + let button = event.xbutton.button + let position = Point(x: Float(event.xbutton.x), y: Float(event.xbutton.y)) + inputRef.wrappedValue.mousePosition = position + if (4...7).contains(button) { + guard event.type == ButtonPress else { + return + } + let delta = Point(x: button == 6 ? -1 : (button == 7 ? 1 : 0), y: button == 4 ? 1 : (button == 5 ? -1 : 0)) + inputRef.wrappedValue.receiveEvent( + MouseEvent( + window: window.id, + button: .scrollWheel, + scrollDelta: delta, + mousePosition: position, + phase: .changed, + modifierKeys: Self.modifiers(for: event.xbutton.state), + time: Float(Time.absolute) + ) + ) + } else { + let mapped = Self.mouseButton(for: button) + if event.type == ButtonPress { pressedButtons[window.id, default: []].insert(mapped) } else { pressedButtons[window.id]?.remove(mapped) } + inputRef.wrappedValue.receiveEvent( + MouseEvent( + window: window.id, + button: mapped, + mousePosition: position, + phase: event.type == ButtonPress ? .began : .ended, + modifierKeys: Self.modifiers(for: event.xbutton.state), + time: Float(Time.absolute) + ) + ) + } + case MotionNotify: + let position = Point(x: Float(event.xmotion.x), y: Float(event.xmotion.y)) + inputRef.wrappedValue.mousePosition = position + let state = event.xmotion.state + let button: MouseButton = + state & UInt32(Button1Mask) != 0 + ? .left + : (state & UInt32(Button3Mask) != 0 ? .right : (state & UInt32(Button2Mask) != 0 ? .middle : .none)) + inputRef.wrappedValue.receiveEvent( + MouseEvent( + window: window.id, + button: button, + mousePosition: position, + phase: .changed, + modifierKeys: Self.modifiers(for: state), + time: Float(Time.absolute) + ) + ) + default: break + } + } + + func releaseAll(for window: UIWindow, inputRef: Ref?) { + defer { + pressedKeys[window.id] = nil + pressedButtons[window.id] = nil + } + guard let inputRef else { + return + } + for key in pressedKeys[window.id, default: [:]].values { + inputRef.wrappedValue.receiveEvent(KeyEvent(window: window.id, keyCode: key, modifiers: [], status: .up, time: Float(Time.absolute), isRepeated: false)) + } + for button in pressedButtons[window.id, default: []] { + inputRef.wrappedValue.receiveEvent( + MouseEvent( + window: window.id, + button: button, + mousePosition: inputRef.wrappedValue.mousePosition, + phase: .cancelled, + modifierKeys: [], + time: Float(Time.absolute) + ) + ) + } + } + + private func lookupText(event: inout XKeyEvent, context: XIC?) -> String { + var buffer = [CChar](repeating: 0, count: 64) + var symbol: KeySym = 0 + var status: Int32 = 0 + if let context { + var count = unsafe Xutf8LookupString(context, &event, &buffer, Int32(buffer.count), &symbol, &status) + if status == XBufferOverflow, count > 0 { + buffer = [CChar](repeating: 0, count: Int(count) + 1) + count = unsafe Xutf8LookupString(context, &event, &buffer, Int32(buffer.count), &symbol, &status) + } + guard status == XLookupChars || status == XLookupBoth, count > 0, count <= buffer.count else { + return "" + } + return String(bytes: buffer.prefix(Int(count)).map { UInt8(bitPattern: $0) }, encoding: .utf8) ?? "" + } + let count = unsafe XLookupString(&event, &buffer, Int32(buffer.count), &symbol, nil) + guard count > 0, count <= buffer.count else { + return "" + } + return String(bytes: buffer.prefix(Int(count)).map { UInt8(bitPattern: $0) }, encoding: .utf8) ?? "" + } + + static func modifiers(for state: UInt32) -> KeyModifier { + var modifiers: KeyModifier = [] + if state & UInt32(ShiftMask) != 0 { modifiers.insert(.shift) } + if state & UInt32(ControlMask) != 0 { modifiers.insert(.control) } + if state & UInt32(Mod1Mask) != 0 { modifiers.insert(.alt) } + if state & UInt32(Mod4Mask) != 0 { modifiers.insert(.main) } + if state & UInt32(LockMask) != 0 { modifiers.insert(.capsLock) } + return modifiers + } + + static func mouseButton(for button: UInt32) -> MouseButton { + switch button { + case 1: .left + case 2: .middle + case 3: .right + default: .none + } + } + + static func keyCode(for symbol: UInt) -> AdaInput.KeyCode { + switch symbol { + case UInt(XK_Return), UInt(XK_KP_Enter): return .enter + case UInt(XK_Escape): return .escape + case UInt(XK_BackSpace): return .backspace + case UInt(XK_Tab), UInt(XK_ISO_Left_Tab): return .tab + case UInt(XK_Delete), UInt(XK_KP_Delete): return .delete + case UInt(XK_Insert), UInt(XK_KP_Insert): return .insert + case UInt(XK_Home), UInt(XK_KP_Home): return .home + case UInt(XK_Page_Up), UInt(XK_KP_Page_Up): return .pageUp + case UInt(XK_Page_Down), UInt(XK_KP_Page_Down): return .pageDown + case UInt(XK_Left), UInt(XK_KP_Left): return .arrowLeft + case UInt(XK_Right), UInt(XK_KP_Right): return .arrowRight + case UInt(XK_Up), UInt(XK_KP_Up): return .arrowUp + case UInt(XK_Down), UInt(XK_KP_Down): return .arrowDown + case UInt(XK_Shift_L), UInt(XK_Shift_R): return .shift + case UInt(XK_Control_L), UInt(XK_Control_R): return .ctrl + case UInt(XK_Alt_L), UInt(XK_Alt_R): return .alt + case UInt(XK_Super_L), UInt(XK_Super_R): return .meta + case UInt(XK_Caps_Lock): return .capslock + case UInt(XK_F1)...UInt(XK_F20): return AdaInput.KeyCode(rawValue: "F\(symbol - UInt(XK_F1) + 1)") ?? .none + default: + guard symbol >= 32, symbol < 127, let scalar = UnicodeScalar(UInt32(symbol)) else { + return .none + } + return AdaInput.KeyCode(rawValue: String(scalar).lowercased()) ?? .none + } + } + + static func cursorFont(for shape: Input.CursorShape) -> UInt32 { + switch shape { + case .arrow: UInt32(XC_left_ptr) + case .pointingHand: UInt32(XC_hand2) + case .iBeam: UInt32(XC_xterm) + case .wait, .busy: UInt32(XC_watch) + case .cross: UInt32(XC_crosshair) + case .drag, .drop, .move: UInt32(XC_fleur) + case .resizeLeft, .resizeRight, .resizeLeftRight: UInt32(XC_sb_h_double_arrow) + case .resizeUp, .resizeDown, .resizeUpDown: UInt32(XC_sb_v_double_arrow) + case .forbidden: UInt32(XC_X_cursor) + case .help: UInt32(XC_question_arrow) + } + } + } +#endif diff --git a/Sources/AdaPlatform/Linux/LinuxScreenManager.swift b/Sources/AdaPlatform/Linux/LinuxScreenManager.swift new file mode 100644 index 000000000..53e40b93b --- /dev/null +++ b/Sources/AdaPlatform/Linux/LinuxScreenManager.swift @@ -0,0 +1,29 @@ +#if os(Linux) + @_spi(Internal) import AdaUI + import Math + import X11 + + /// Immutable primary-screen snapshot; safe to read from layout and render workers. + final class LinuxScreenManager: ScreenManager { + private let systemScreen = LinuxSystemScreen() + private let size: Size + + @MainActor + init(display: LinuxDisplay) { + let screen = unsafe XDefaultScreen(display.pointer) + size = unsafe Size( + width: Float(XDisplayWidth(display.pointer, screen)), + height: Float(XDisplayHeight(display.pointer, screen)) + ) + } + + func getMainScreen() -> AdaUI.Screen? { makeScreen(from: systemScreen) } + func getScreens() -> [AdaUI.Screen] { [makeScreen(from: systemScreen)] } + func getScreenScale(for _: AdaUI.Screen) -> Float { 1 } + func getSize(for _: AdaUI.Screen) -> Size { size } + func getBrightness(for _: AdaUI.Screen) -> Float { 1 } + func makeScreen(from systemScreen: SystemScreen) -> AdaUI.Screen { AdaUI.Screen(systemScreen: systemScreen, screenManager: self) } + } + + private final class LinuxSystemScreen: SystemScreen {} +#endif diff --git a/Sources/AdaPlatform/Linux/LinuxSystemWindow.swift b/Sources/AdaPlatform/Linux/LinuxSystemWindow.swift new file mode 100644 index 000000000..96ed3096a --- /dev/null +++ b/Sources/AdaPlatform/Linux/LinuxSystemWindow.swift @@ -0,0 +1,101 @@ +#if os(Linux) + import AdaRender + @_spi(Internal) import AdaUI + import Math + import X11 + + @MainActor + final class LinuxSystemWindow: SystemWindow, LinuxXlibRenderSurface { + let display: LinuxDisplay + private(set) var handle: X11.Window + private(set) var inputContext: XIC? + private var storedTitle = "AdaEngine" + var onClose: (() -> Void)? + + init(display: LinuxDisplay, handle: X11.Window) { + self.display = display + self.handle = handle + if let method = display.inputMethod { + inputContext = unsafe ada_x11_create_input_context(method, handle) + } + } + + isolated deinit { + if let inputContext { unsafe XDestroyIC(inputContext) } + if handle != 0 { unsafe XDestroyWindow(display.pointer, handle) } + } + + func destroy() { + if let inputContext { unsafe XDestroyIC(inputContext) } + inputContext = nil + if handle != 0 { unsafe XDestroyWindow(display.pointer, handle) } + handle = 0 + } + + var xlibDisplay: UnsafeMutableRawPointer { unsafe UnsafeMutableRawPointer(display.pointer) } + var xlibWindow: UInt64 { UInt64(handle) } + var scaleFactor: Float { 1 } + var prefferedPixelFormat: PixelFormat { .bgra8 } + + func performCaptionAction(_ action: WindowCaptionAction) { + switch action { + case .minimize: + unsafe XIconifyWindow(display.pointer, handle, XDefaultScreen(display.pointer)) + case .close: onClose?() + case .toggleMaximize: + var event = XEvent() + event.xclient.type = ClientMessage + event.xclient.window = handle + event.xclient.message_type = unsafe XInternAtom(display.pointer, "_NET_WM_STATE", 0) + event.xclient.format = 32 + event.xclient.data.l.0 = 2 + event.xclient.data.l.1 = unsafe Int(XInternAtom(display.pointer, "_NET_WM_STATE_MAXIMIZED_VERT", 0)) + event.xclient.data.l.2 = unsafe Int(XInternAtom(display.pointer, "_NET_WM_STATE_MAXIMIZED_HORZ", 0)) + event.xclient.data.l.3 = 1 + unsafe XSendEvent(display.pointer, XDefaultRootWindow(display.pointer), 0, SubstructureRedirectMask | SubstructureNotifyMask, &event) + } + unsafe XFlush(display.pointer) + } + + var title: String { + get { storedTitle } + set { + storedTitle = newValue + unsafe XStoreName(display.pointer, handle, newValue) + let property = unsafe XInternAtom(display.pointer, "_NET_WM_NAME", 0) + let utf8 = unsafe XInternAtom(display.pointer, "UTF8_STRING", 0) + let bytes = Array(newValue.utf8) + unsafe bytes.withUnsafeBufferPointer { buffer in + unsafe XChangeProperty(display.pointer, handle, property, utf8, 8, PropModeReplace, buffer.baseAddress, Int32(buffer.count)) + } + unsafe XFlush(display.pointer) + } + } + + var size: Size { + get { + var attributes = XWindowAttributes() + unsafe XGetWindowAttributes(display.pointer, handle, &attributes) + return Size(width: Float(attributes.width), height: Float(attributes.height)) + } + set { + unsafe XResizeWindow(display.pointer, handle, UInt32(max(newValue.width, 1)), UInt32(max(newValue.height, 1))) + unsafe XFlush(display.pointer) + } + } + + var position: Point { + get { + var x: Int32 = 0 + var y: Int32 = 0 + var child: X11.Window = 0 + unsafe XTranslateCoordinates(display.pointer, handle, XDefaultRootWindow(display.pointer), 0, 0, &x, &y, &child) + return Point(x: Float(x), y: Float(y)) + } + set { + unsafe XMoveWindow(display.pointer, handle, Int32(newValue.x), Int32(newValue.y)) + unsafe XFlush(display.pointer) + } + } + } +#endif diff --git a/Sources/AdaPlatform/Linux/LinuxWindowManager.swift b/Sources/AdaPlatform/Linux/LinuxWindowManager.swift index 2f33238b6..bc6890bcf 100644 --- a/Sources/AdaPlatform/Linux/LinuxWindowManager.swift +++ b/Sources/AdaPlatform/Linux/LinuxWindowManager.swift @@ -1,17 +1,302 @@ -// -// LinuxWindowManager.swift -// AdaEngine -// -// Created by v.prusakov on 9/2/22. -// - -#if LINUX +#if os(Linux) + @_spi(Internal) import AdaInput + import AdaRender + @_spi(Internal) import AdaUI + import AdaUtils + import Foundation + import Logging + import Math import X11 - final class LinuxWindowManager: WindowManager { - override func createWindow(for _: Window) { - fatalErrorMethodNotImplemented() + @MainActor + final class LinuxWindowManager: UIWindowManager { + let display: LinuxDisplay + private var nativeWindows: [UIWindow.ID: LinuxSystemWindow] = [:] + private var windowIDs: [X11.Window: UIWindow.ID] = [:] + private var renderedSizes: [UIWindow.ID: Size] = [:] + private var synchronizingWindows: Set = [] + private var cursorShape: Input.CursorShape = .arrow + private var mouseMode: Input.MouseMode = .visible + private var cursor: X11.Cursor = 0 + private let deleteWindow: Atom + private let protocols: Atom + private let input = LinuxInput() + + init(display: LinuxDisplay) { + self.display = display + deleteWindow = unsafe XInternAtom(display.pointer, "WM_DELETE_WINDOW", 0) + protocols = unsafe XInternAtom(display.pointer, "WM_PROTOCOLS", 0) + super.init() + } + + isolated deinit { + if cursor != 0 { unsafe XFreeCursor(display.pointer, cursor) } + } + + override func createWindow(for window: UIWindow) { + let minimum = window.configuration.minimumSize + let size = Size(width: max(window.frame.width, minimum.width, 1), height: max(window.frame.height, minimum.height, 1)) + let handle = unsafe XCreateSimpleWindow( + display.pointer, + XDefaultRootWindow(display.pointer), + Int32(window.frame.origin.x), + Int32(window.frame.origin.y), + UInt32(size.width), + UInt32(size.height), + 0, + 0, + 0 + ) + guard handle != 0 else { + Logger(label: "org.adaengine.linux").error("Cannot create X11 window") + return + } + let native = LinuxSystemWindow(display: display, handle: handle) + nativeWindows[window.id] = native + windowIDs[handle] = window.id + native.title = window.configuration.title ?? "AdaEngine" + unsafe ada_x11_set_decorated(display.pointer, handle, window.configuration.chrome == .borderless ? 0 : 1) + native.onClose = { [weak self, weak window] in + if let window { self?.closeWindow(window) } + } + unsafe XSelectInput( + display.pointer, + handle, + KeyPressMask | KeyReleaseMask | ButtonPressMask | ButtonReleaseMask + | PointerMotionMask | FocusChangeMask | StructureNotifyMask | ExposureMask | EnterWindowMask | LeaveWindowMask + ) + var closeAtom = deleteWindow + unsafe XSetWMProtocols(display.pointer, handle, &closeAtom, 1) + setMinimumSize(minimum, for: window) + synchronizingWindows.insert(window.id) + window.frame = Rect(origin: .zero, size: size) + synchronizingWindows.remove(window.id) + window.systemWindow = native + do { + guard let renderer = unsafe RenderEngine.shared else { + throw LinuxWindowError.rendererUnavailable + } + try renderer.createWindow(window.id, for: native, size: size.toSizeInt()) + renderedSizes[window.id] = size + } catch { + Logger(label: "org.adaengine.linux").error("Cannot create render window: \(error)") + windowIDs[handle] = nil + nativeWindows[window.id] = nil + window.systemWindow = nil + native.destroy() + return + } + super.createWindow(for: window) + setWindowMode(window, mode: window.configuration.mode) + } + + override func showWindow(_ window: UIWindow, isFocused: Bool) { + guard let native = nativeWindows[window.id] else { + return + } + unsafe XMapWindow(display.pointer, native.handle) + if isFocused { + unsafe XRaiseWindow(display.pointer, native.handle) + unsafe XFlush(display.pointer) + // Focus is assigned by the window manager when MapNotify/FocusIn arrive. + setActiveWindow(window) + } + unsafe XFlush(display.pointer) + window.windowDidAppear() + } + + override func closeWindow(_ window: UIWindow) { + guard window.windowShouldClose(), let native = nativeWindows[window.id] else { + return + } + input.releaseAll(for: window, inputRef: inputRef) + resignActiveWindow(window) + removeWindow(window) + // Destroy GPU resources before the Xlib window and its input context. + guard windows[window.id] == nil else { + return + } + windowIDs[native.handle] = nil + nativeWindows[window.id] = nil + renderedSizes[window.id] = nil + window.systemWindow = nil + native.destroy() + unsafe XFlush(display.pointer) + } + + override func resizeWindow(_ window: UIWindow, size: Size) { + guard !synchronizingWindows.contains(window.id), let native = nativeWindows[window.id] else { + return + } + native.size = size + } + + override func setMinimumSize(_ size: Size, for window: UIWindow) { + guard let native = nativeWindows[window.id] else { + return + } + var hints = XSizeHints() + hints.flags = Int(PMinSize) + hints.min_width = Int32(max(size.width, 1)) + hints.min_height = Int32(max(size.height, 1)) + if !window.configuration.isResizable { + hints.flags |= Int(PMaxSize) + hints.min_width = Int32(max(window.frame.width, size.width, 1)) + hints.min_height = Int32(max(window.frame.height, size.height, 1)) + hints.max_width = hints.min_width + hints.max_height = hints.min_height + } + unsafe XSetWMNormalHints(display.pointer, native.handle, &hints) + } + + override func setWindowMode(_ window: UIWindow, mode: UIWindow.Mode) { + guard let native = nativeWindows[window.id] else { + return + } + var attributes = XWindowAttributes() + unsafe XGetWindowAttributes(display.pointer, native.handle, &attributes) + if attributes.map_state == IsUnmapped { + let property = unsafe XInternAtom(display.pointer, "_NET_WM_STATE", 0) + var fullscreen = unsafe XInternAtom(display.pointer, "_NET_WM_STATE_FULLSCREEN", 0) + unsafe withUnsafePointer(to: &fullscreen) { pointer in + unsafe XChangeProperty( + display.pointer, + native.handle, + property, + Atom(XA_ATOM), + 32, + PropModeReplace, + UnsafeRawPointer(pointer).assumingMemoryBound(to: UInt8.self), + mode == .windowed ? 0 : 1 + ) + } + return + } + var event = XEvent() + event.xclient.type = ClientMessage + event.xclient.window = native.handle + event.xclient.message_type = unsafe XInternAtom(display.pointer, "_NET_WM_STATE", 0) + event.xclient.format = 32 + event.xclient.data.l.0 = mode == .windowed ? 0 : 1 + event.xclient.data.l.1 = unsafe Int(XInternAtom(display.pointer, "_NET_WM_STATE_FULLSCREEN", 0)) + event.xclient.data.l.3 = 1 + unsafe XSendEvent(display.pointer, XDefaultRootWindow(display.pointer), 0, SubstructureRedirectMask | SubstructureNotifyMask, &event) + unsafe XFlush(display.pointer) + } + + func pollEvents() { + // Bound each batch so a busy pointer or another X11 client cannot starve frame updates. + for _ in 0..<1024 { + guard unsafe XPending(display.pointer) > 0 else { + break + } + var event = XEvent() + unsafe XNextEvent(display.pointer, &event) + if event.type == MappingNotify { + unsafe XRefreshKeyboardMapping(&event.xmapping) + continue + } + guard unsafe XFilterEvent(&event, 0) == 0, + let id = windowIDs[event.xany.window], let window = windows[id], let native = nativeWindows[id] + else { + continue + } + switch event.type { + case ClientMessage: + if event.xclient.message_type == protocols, event.xclient.format == 32, event.xclient.data.l.0 == Int(deleteWindow) { + closeWindow(window) + } + case ConfigureNotify: + let size = Size(width: Float(event.xconfigure.width), height: Float(event.xconfigure.height)) + guard size.width > 0, size.height > 0, renderedSizes[id] != size else { + continue + } + synchronizingWindows.insert(id) + window.frame.size = size + synchronizingWindows.remove(id) + window.setNeedsLayout() + do { + unsafe try RenderEngine.shared.resizeWindow(id, newSize: size.toSizeInt(), scaleFactor: 1) + renderedSizes[id] = size + } catch { + Logger(label: "org.adaengine.linux").error("Cannot resize render window: \(error)") + } + case FocusIn: + setActiveWindow(window) + if let context = native.inputContext { unsafe XSetICFocus(context) } + applyMouseMode() + case FocusOut: + if let context = native.inputContext { unsafe XUnsetICFocus(context) } + input.releaseAll(for: window, inputRef: inputRef) + unsafe XUngrabPointer(display.pointer, UInt(CurrentTime)) + resignActiveWindow(window) + case MapNotify: window.canDraw = true + case UnmapNotify: window.canDraw = false + default: input.receive(&event, for: window, native: native, inputRef: inputRef) + } + } + } + + override func setCursorShape(_ shape: Input.CursorShape) { + guard cursorShape != shape || cursor == 0 else { + return + } + cursorShape = shape + if cursor != 0 { unsafe XFreeCursor(display.pointer, cursor) } + cursor = unsafe XCreateFontCursor(display.pointer, LinuxInput.cursorFont(for: cursorShape)) + updateCursor() + } + override func getCursorShape() -> Input.CursorShape { cursorShape } + override func setCursorImage(for _: Input.CursorShape, texture _: Texture2D?, hotspot _: Vector2) {} + override func setMouseMode(_ mode: Input.MouseMode) { + mouseMode = mode + applyMouseMode() + } + override func getMouseMode() -> Input.MouseMode { mouseMode } + + override func updateCursor() { + guard mouseMode == .visible || mouseMode == .confined else { + return + } + if cursor == 0 { cursor = unsafe XCreateFontCursor(display.pointer, LinuxInput.cursorFont(for: cursorShape)) } + for native in nativeWindows.values { unsafe XDefineCursor(display.pointer, native.handle, cursor) } + unsafe XFlush(display.pointer) + } + + private func applyMouseMode() { + unsafe XUngrabPointer(display.pointer, UInt(CurrentTime)) + guard let window = activeWindow, let native = nativeWindows[window.id] else { + return + } + if mouseMode == .captured || mouseMode == .confined || mouseMode == .confinedHidden { + unsafe XGrabPointer( + display.pointer, + native.handle, + 1, + UInt32(ButtonPressMask | ButtonReleaseMask | PointerMotionMask), + GrabModeAsync, + GrabModeAsync, + native.handle, + 0, + UInt(CurrentTime) + ) + } + // Cursor visibility is provided by a transparent native bitmap. + if mouseMode == .hidden || mouseMode == .confinedHidden || mouseMode == .captured { + var bits: CChar = 0 + let bitmap = unsafe XCreateBitmapFromData(display.pointer, native.handle, &bits, 1, 1) + var color = XColor() + var background = XColor() + let hidden = unsafe XCreatePixmapCursor(display.pointer, bitmap, bitmap, &color, &background, 0, 0) + unsafe XDefineCursor(display.pointer, native.handle, hidden) + unsafe XFreeCursor(display.pointer, hidden) + unsafe XFreePixmap(display.pointer, bitmap) + } else { + updateCursor() + } + unsafe XFlush(display.pointer) } } + private enum LinuxWindowError: Error { case rendererUnavailable } #endif diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUGPUTexture.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUGPUTexture.swift index 417ce5137..d82671460 100644 --- a/Sources/AdaRender/Backends/WebGPU/Misc/WGPUGPUTexture.swift +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPUGPUTexture.swift @@ -25,12 +25,16 @@ public let textureView: WebGPU.GPUTextureView private let device: WebGPU.GPUDevice? private let allowsReadback: Bool + // Render attachments can be replaced every frame. A long-lived bind-group + // cache must not keep their full GPU allocations alive through its owners. + let allowsBindingCache: Bool init(texture: WebGPU.GPUTexture, textureView: WebGPU.GPUTextureView, device: WebGPU.GPUDevice? = nil) { self.texture = texture self.size = SizeInt(width: Int(texture.width), height: Int(texture.height)) self.textureView = textureView self.device = device + self.allowsBindingCache = false #if WASM self.allowsReadback = false #else @@ -75,6 +79,7 @@ } init(descriptor: TextureDescriptor, device: WebGPU.GPUDevice) { + self.allowsBindingCache = !descriptor.textureUsage.contains(.renderTarget) self.size = SizeInt(width: descriptor.width, height: descriptor.height) var wgpuUsage: WebGPU.GPUTextureUsage = [] diff --git a/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderCommandEncoder.swift b/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderCommandEncoder.swift index d7103c416..34b294ff3 100644 --- a/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderCommandEncoder.swift +++ b/Sources/AdaRender/Backends/WebGPU/Misc/WGPURenderCommandEncoder.swift @@ -423,6 +423,7 @@ } case .texture: if let texture = resources.textures[slot] { + cacheable = cacheable && texture.allowsBindingCache keys.append(.init(slot: slot, owner: ObjectIdentifier(texture))) owners.append(texture) } diff --git a/Sources/AdaRender/Backends/WebGPU/WGPUContext.swift b/Sources/AdaRender/Backends/WebGPU/WGPUContext.swift index b1bad395e..9f0a5dcfd 100644 --- a/Sources/AdaRender/Backends/WebGPU/WGPUContext.swift +++ b/Sources/AdaRender/Backends/WebGPU/WGPUContext.swift @@ -369,9 +369,12 @@ } surfaceDescriptor.nextInChain = unsafe WebGPU.GPUSurfaceSourceAndroidNativeWindow(window: androidSurface.nativeWindow) #elseif os(Linux) + guard let surface = self as? any LinuxXlibRenderSurface else { + preconditionFailure("Linux WebGPU requires an Xlib window surface.") + } surfaceDescriptor.nextInChain = unsafe WebGPU.GPUSurfaceSourceXlibWindow( - display: UnsafeMutableRawPointer(glfwGetX11Display()), - window: UInt64(glfwGetX11Window(handle)) + display: surface.xlibDisplay, + window: surface.xlibWindow ) #elseif os(Windows) let surface = (self as! WindowsSurface) diff --git a/Sources/AdaRender/Backends/WebGPU/WebGPURenderBackend.swift b/Sources/AdaRender/Backends/WebGPU/WebGPURenderBackend.swift index 6abfcdea5..c352c6a53 100644 --- a/Sources/AdaRender/Backends/WebGPU/WebGPURenderBackend.swift +++ b/Sources/AdaRender/Backends/WebGPU/WebGPURenderBackend.swift @@ -128,7 +128,7 @@ #elseif os(Android) WebGPU.GPURequestAdapterOptions(powerPreference: .highPerformance, backendType: .vulkan) #elseif os(Linux) - WebGPU.GPURequestAdapterOptions(powerPreference: .highPerformance) + WebGPU.GPURequestAdapterOptions(powerPreference: .highPerformance, backendType: .vulkan) #else WebGPU.GPURequestAdapterOptions(powerPreference: .highPerformance) #endif diff --git a/Sources/AdaRender/RenderSurface.swift b/Sources/AdaRender/RenderSurface.swift index 68088ad3e..bfe5b8121 100644 --- a/Sources/AdaRender/RenderSurface.swift +++ b/Sources/AdaRender/RenderSurface.swift @@ -19,6 +19,15 @@ public protocol RenderSurface { var prefferedPixelFormat: PixelFormat { get } } +#if os(Linux) + /// An Xlib window whose owner keeps the display and window alive until rendering finishes. + @MainActor + public protocol LinuxXlibRenderSurface: RenderSurface { + var xlibDisplay: UnsafeMutableRawPointer { get } + var xlibWindow: UInt64 { get } + } +#endif + #if WASM && canImport(JavaScriptKit) @MainActor public protocol BrowserCanvasRenderSurface: RenderSurface { diff --git a/Sources/AdaUI/DSL/Modifiers/KeyboardShortcutModifier.swift b/Sources/AdaUI/DSL/Modifiers/KeyboardShortcutModifier.swift index 2e76d51cf..b45b08ae1 100644 --- a/Sources/AdaUI/DSL/Modifiers/KeyboardShortcutModifier.swift +++ b/Sources/AdaUI/DSL/Modifiers/KeyboardShortcutModifier.swift @@ -10,8 +10,8 @@ import Math // MARK: - KeyModifier alias (SwiftUI-style naming) extension KeyModifier { - /// Primary shortcut modifier: Ctrl on Windows and Command on Apple platforms. - #if os(Windows) + /// Primary shortcut modifier: Ctrl on Windows/Linux and Command on Apple platforms. + #if os(Windows) || os(Linux) public static let command = KeyModifier.control #else public static let command = KeyModifier.main diff --git a/Sources/AdaUtils/FileSystem/FileWatcher.swift b/Sources/AdaUtils/FileSystem/FileWatcher.swift index 4173cd4fc..a2a05f95d 100644 --- a/Sources/AdaUtils/FileSystem/FileWatcher.swift +++ b/Sources/AdaUtils/FileSystem/FileWatcher.swift @@ -70,15 +70,12 @@ public final class FSWatch: @unchecked Sendable { #elseif canImport(Glibc) || canImport(Musl) var ipaths: [AbsolutePath: Inotify.WatchOptions] = [:] - // FIXME: We need to recurse here. for path in paths { - if localFileSystem.isDirectory(path) { + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: path.pathString, isDirectory: &isDirectory), isDirectory.boolValue { ipaths[path] = .defaultDirectoryWatchOptions - } else if localFileSystem.isFile(path) { - ipaths[path] = .defaultFileWatchOptions - // Watch files. } else { - // FIXME: Report errors + ipaths[path] = .defaultFileWatchOptions } } @@ -337,513 +334,6 @@ private protocol _FileWatcher { } } -#elseif canImport(Glibc) || canImport(Musl) - - /// The delegate for receiving inotify events. - public protocol InotifyDelegate: AnyObject { - func pathsDidReceiveEvent(_ paths: [AbsolutePath]) - } - - /// Bindings for inotify C APIs. - public final class Inotify { - /// The errors encountered during inotify operations. - public enum Error: Swift.Error { - case invalidFD - case failedToWatch(AbsolutePath) - } - - /// The available options for a particular path. - public struct WatchOptions: OptionSet { - public let rawValue: Int32 - - public init(rawValue: Int32) { - self.rawValue = rawValue - } - - // File/directory created in watched directory (e.g., open(2) - // O_CREAT, mkdir(2), link(2), symlink(2), bind(2) on a UNIX - // domain socket). - public static let create = Self(rawValue: IN_CREATE) - - // File/directory deleted from watched directory. - public static let delete = Self(rawValue: IN_DELETE) - - // Watched file/directory was itself deleted. (This event - // also occurs if an object is moved to another filesystem, - // since mv(1) in effect copies the file to the other - // filesystem and then deletes it from the original filesys‐ - // tem.) In addition, an IN_IGNORED event will subsequently - // be generated for the watch descriptor. - public static let deleteSelf = Self(rawValue: IN_DELETE_SELF) - - public static let move = Self(rawValue: IN_MOVE) - - /// Watched file/directory was itself moved. - public static let moveSelf = Self(rawValue: IN_MOVE_SELF) - - /// File was modified (e.g., write(2), truncate(2)). - public static let modify = Self(rawValue: IN_MODIFY) - - // File or directory was opened. - public static let open = Self(rawValue: IN_OPEN) - - // Metadata changed—for example, permissions (e.g., - // chmod(2)), timestamps (e.g., utimensat(2)), extended - // attributes (setxattr(2)), link count (since Linux 2.6.25; - // e.g., for the target of link(2) and for unlink(2)), and - // user/group ID (e.g., chown(2)). - public static let attrib = Self(rawValue: IN_ATTRIB) - - // File opened for writing was closed. - public static let closeWrite = Self(rawValue: IN_CLOSE_WRITE) - - // File or directory not opened for writing was closed. - public static let closeNoWrite = Self(rawValue: IN_CLOSE_NOWRITE) - - // File was accessed (e.g., read(2), execve(2)). - public static let access = Self(rawValue: IN_ACCESS) - - /// The list of default options that can be used for watching files. - public static let defaultFileWatchOptions: WatchOptions = [.deleteSelf, .moveSelf, .modify] - - /// The list of default options that can be used for watching directories. - public static let defaultDirectoryWatchOptions: WatchOptions = [.create, .delete, .deleteSelf, .move, .moveSelf] - - /// List of all available events. - public static let all: [Self] = [ - .create, - .delete, - .deleteSelf, - .move, - .moveSelf, - .modify, - .open, - .attrib, - .closeWrite, - .closeNoWrite, - .access, - ] - } - - // Sizeof inotify_event + max len of filepath + 1 (for null char). - private static let eventSize = MemoryLayout.size + Int(NAME_MAX) + 1 - - /// The paths being watched. - public let paths: [AbsolutePath: WatchOptions] - - /// The delegate. - private weak var delegate: InotifyDelegate? - - /// The settle period (in seconds). - public let settle: Double - - /// Internal properties. - private var fd: Int32? - - /// The list of watched directories/files. - private var wds: [Int32: AbsolutePath] = [:] - - /// The queue on which we read the events. - private let readQueue = DispatchQueue(label: "org.adaengine.\(Inotify.self).read") - - /// Callback queue for the delegate. - private let callbacksQueue = DispatchQueue(label: "org.adaengine.\(Inotify.self).callback") - - /// Condition for handling event reporting. - private var reportCondition = Condition() - - // Should be read or written to using the report condition only. - private var collectedEvents: [AbsolutePath] = [] - - // Should be read or written to using the report condition only. - private var lastEventTime: Date? - - // Should be read or written to using the report condition only. - private var cancelled = false - - /// Pipe for waking up the read loop. - private var cancellationPipe: [Int32] = [0, 0] - - /// Create a inotify instance. - /// - /// The paths are not watched recursively. - public init(paths: [AbsolutePath: WatchOptions], latency: Double, delegate: InotifyDelegate? = nil) { - self.paths = paths - self.delegate = delegate - self.settle = latency - } - - /// Start the watch operation. - public func start() throws { - // All paths need to exist. - for (path, _) in paths { - guard localFileSystem.exists(path) else { - throw Error.failedToWatch(path) - } - } - - // Create the file descriptor. - let fd = inotify_init1(Int32(IN_NONBLOCK)) - - guard fd != -1 else { - throw Error.invalidFD - } - self.fd = fd - - /// Add watch for each path. - for (path, options) in paths { - let wd = inotify_add_watch(fd, path.description, UInt32(options.rawValue)) - guard wd != -1 else { - throw Error.failedToWatch(path) - } - - self.wds[wd] = path - } - - // Start the report thread. - startReportThread() - - readQueue.async { - self.startRead() - } - } - - /// End the watch operation. - public func stop() { - // FIXME: Write precondition to ensure this is called only once. - guard let fd else { - assertionFailure("end called without a fd") - return - } - - // Shutdown the report thread. - reportCondition.whileLocked { - cancelled = true - reportCondition.signal() - } - - // Wakeup the read loop by writing on the cancellation pipe. - let writtenData = write(cancellationPipe[1], "", 1) - assert(writtenData == 1) - - // FIXME: We need to remove the watches. - close(fd) - } - - private func startRead() { - guard let fd else { - fatalError("unexpected call to startRead without fd") - } - - // Create a pipe that we can use to get notified when we're cancelled. - let pipeRv = pipe(&cancellationPipe) - // FIXME: We don't see pipe2 for some reason. - let f = fcntl(cancellationPipe[0], F_SETFL, O_NONBLOCK) - assert(f != -1) - assert(pipeRv == 0) - - while true { - // The read fd set. Contains the inotify and cancellation fd. - var rfds = fd_set() - FD_ZERO(&rfds) - - FD_SET(fd, &rfds) - FD_SET(cancellationPipe[0], &rfds) - - let nfds = [fd, cancellationPipe[0]].reduce(0, max) + 1 - // num fds, read fds, write fds, except fds, timeout - let selectRet = select(nfds, &rfds, nil, nil, nil) - // FIXME: Check for int signal. - assert(selectRet != -1) - - // Return if we're cancelled. - if FD_ISSET(cancellationPipe[0], &rfds) { - return - } - assert(FD_ISSET(fd, &rfds)) - - let buf = UnsafeMutablePointer.allocate(capacity: Self.eventSize) - // FIXME: We need to free the buffer. - - let readLength = read(fd, buf, Self.eventSize) - // FIXME: Check for int signal. - - // Consume events. - var idx = 0 - while idx < readLength { - let event = withUnsafePointer(to: &buf[idx]) { - $0.withMemoryRebound(to: inotify_event.self, capacity: 1) { - $0.pointee - } - } - - // Get the associated with the event. - guard var path = wds[event.wd] else { - continue - } - - // FIXME: We need extract information from the event mask and - // create a data structure. - // FIXME: Do we need to detect and remove watch for directories - // that are deleted? - - // Get the relative base name from the event if present. - if event.len > 0 { - // Get the basename of the file that had the event. - let basename = String(cString: buf + idx + MemoryLayout.size) - - // Construct the full path. - // FIXME: We should report this path separately. - path = path.appending(component: basename) - } - - // Signal the reporter. - reportCondition.whileLocked { - lastEventTime = Date() - collectedEvents.append(path) - reportCondition.signal() - } - - idx += MemoryLayout.size + Int(event.len) - } - } - } - - /// Spawns a thread that collects events and reports them after the settle period. - private func startReportThread() { - let thread = TSCBasic.Thread { - var endLoop = false - while !endLoop { - // Block until we timeout or get signalled. - self.reportCondition.whileLocked { - var performReport = false - - // Block until timeout expires or wait forever until we get some event. - if let lastEventTime = self.lastEventTime { - let timeout = lastEventTime + Double(self.settle) - let timeLimitReached = !self.reportCondition.wait(until: timeout) - - if timeLimitReached { - self.lastEventTime = nil - performReport = true - } - } else { - self.reportCondition.wait() - } - - // If we're cancelled, just return. - if self.cancelled { - endLoop = true - return - } - - // Report the events if we're asked to. - if performReport && !self.collectedEvents.isEmpty { - let events = self.collectedEvents - self.collectedEvents = [] - self.callbacksQueue.async { - self.report(events) - } - } - } - } - } - - thread.start() - } - - private func report(_ paths: [AbsolutePath]) { - delegate?.pathsDidReceiveEvent(paths) - } - } - - // FIXME: Swift should provide shims for FD_ macros - - private func FD_ZERO(_ set: inout fd_set) { - #if os(Android) || canImport(Musl) - #if arch(arm) - set.fds_bits = ( - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ) - #else - set.fds_bits = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) - #endif - #else - #if arch(arm) - set.__fds_bits = ( - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ) - #else - set.__fds_bits = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) - #endif - #endif - } - - private func FD_SET(_ fd: Int32, _ set: inout fd_set) { - let intOffset = Int(fd / 16) - let bitOffset = Int(fd % 16) - #if os(Android) || canImport(Musl) - var fd_bits = set.fds_bits - let mask: UInt = 1 << bitOffset - #else - var fd_bits = set.__fds_bits - let mask = 1 << bitOffset - #endif - switch intOffset { - case 0: fd_bits.0 = fd_bits.0 | mask - case 1: fd_bits.1 = fd_bits.1 | mask - case 2: fd_bits.2 = fd_bits.2 | mask - case 3: fd_bits.3 = fd_bits.3 | mask - case 4: fd_bits.4 = fd_bits.4 | mask - case 5: fd_bits.5 = fd_bits.5 | mask - case 6: fd_bits.6 = fd_bits.6 | mask - case 7: fd_bits.7 = fd_bits.7 | mask - case 8: fd_bits.8 = fd_bits.8 | mask - case 9: fd_bits.9 = fd_bits.9 | mask - case 10: fd_bits.10 = fd_bits.10 | mask - case 11: fd_bits.11 = fd_bits.11 | mask - case 12: fd_bits.12 = fd_bits.12 | mask - case 13: fd_bits.13 = fd_bits.13 | mask - case 14: fd_bits.14 = fd_bits.14 | mask - case 15: fd_bits.15 = fd_bits.15 | mask - #if arch(arm) - case 16: fd_bits.16 = fd_bits.16 | mask - case 17: fd_bits.17 = fd_bits.17 | mask - case 18: fd_bits.18 = fd_bits.18 | mask - case 19: fd_bits.19 = fd_bits.19 | mask - case 20: fd_bits.20 = fd_bits.20 | mask - case 21: fd_bits.21 = fd_bits.21 | mask - case 22: fd_bits.22 = fd_bits.22 | mask - case 23: fd_bits.23 = fd_bits.23 | mask - case 24: fd_bits.24 = fd_bits.24 | mask - case 25: fd_bits.25 = fd_bits.25 | mask - case 26: fd_bits.26 = fd_bits.26 | mask - case 27: fd_bits.27 = fd_bits.27 | mask - case 28: fd_bits.28 = fd_bits.28 | mask - case 29: fd_bits.29 = fd_bits.29 | mask - case 30: fd_bits.30 = fd_bits.30 | mask - case 31: fd_bits.31 = fd_bits.31 | mask - #endif - default: break - } - #if os(Android) || canImport(Musl) - set.fds_bits = fd_bits - #else - set.__fds_bits = fd_bits - #endif - } - - private func FD_ISSET(_ fd: Int32, _ set: inout fd_set) -> Bool { - let intOffset = Int(fd / 32) - let bitOffset = Int(fd % 32) - #if os(Android) || canImport(Musl) - let fd_bits = set.fds_bits - let mask: UInt = 1 << bitOffset - #else - let fd_bits = set.__fds_bits - let mask = 1 << bitOffset - #endif - switch intOffset { - case 0: return fd_bits.0 & mask != 0 - case 1: return fd_bits.1 & mask != 0 - case 2: return fd_bits.2 & mask != 0 - case 3: return fd_bits.3 & mask != 0 - case 4: return fd_bits.4 & mask != 0 - case 5: return fd_bits.5 & mask != 0 - case 6: return fd_bits.6 & mask != 0 - case 7: return fd_bits.7 & mask != 0 - case 8: return fd_bits.8 & mask != 0 - case 9: return fd_bits.9 & mask != 0 - case 10: return fd_bits.10 & mask != 0 - case 11: return fd_bits.11 & mask != 0 - case 12: return fd_bits.12 & mask != 0 - case 13: return fd_bits.13 & mask != 0 - case 14: return fd_bits.14 & mask != 0 - case 15: return fd_bits.15 & mask != 0 - #if arch(arm) - case 16: return fd_bits.16 & mask != 0 - case 17: return fd_bits.17 & mask != 0 - case 18: return fd_bits.18 & mask != 0 - case 19: return fd_bits.19 & mask != 0 - case 20: return fd_bits.20 & mask != 0 - case 21: return fd_bits.21 & mask != 0 - case 22: return fd_bits.22 & mask != 0 - case 23: return fd_bits.23 & mask != 0 - case 24: return fd_bits.24 & mask != 0 - case 25: return fd_bits.25 & mask != 0 - case 26: return fd_bits.26 & mask != 0 - case 27: return fd_bits.27 & mask != 0 - case 28: return fd_bits.28 & mask != 0 - case 29: return fd_bits.29 & mask != 0 - case 30: return fd_bits.30 & mask != 0 - case 31: return fd_bits.31 & mask != 0 - #endif - default: return false - } - } - #endif // MARK: - FSEventStream diff --git a/Sources/AdaUtils/FileSystem/Inotify.swift b/Sources/AdaUtils/FileSystem/Inotify.swift new file mode 100644 index 000000000..8daed4a7e --- /dev/null +++ b/Sources/AdaUtils/FileSystem/Inotify.swift @@ -0,0 +1,165 @@ +#if os(Linux) && !os(Android) + import Dispatch + import Foundation + import Synchronization + #if canImport(Glibc) + import Glibc + #elseif canImport(Musl) + import Musl + #endif + + public protocol InotifyDelegate: AnyObject, Sendable { + func pathsDidReceiveEvent(_ paths: [AbsolutePath]) + } + + /// Nonblocking Linux file watching. State is locked; descriptor cleanup runs after the read source is cancelled. + public final class Inotify: Sendable { + public enum Error: Swift.Error { + case invalidFD + case failedToWatch(AbsolutePath) + } + + public struct WatchOptions: OptionSet, Sendable { + public let rawValue: Int32 + public init(rawValue: Int32) { self.rawValue = rawValue } + public static let create = Self(rawValue: IN_CREATE) + public static let delete = Self(rawValue: IN_DELETE) + public static let deleteSelf = Self(rawValue: IN_DELETE_SELF) + public static let move = Self(rawValue: IN_MOVE) + public static let moveSelf = Self(rawValue: IN_MOVE_SELF) + public static let modify = Self(rawValue: IN_MODIFY) + public static let open = Self(rawValue: IN_OPEN) + public static let attrib = Self(rawValue: IN_ATTRIB) + public static let closeWrite = Self(rawValue: IN_CLOSE_WRITE) + public static let closeNoWrite = Self(rawValue: IN_CLOSE_NOWRITE) + public static let access = Self(rawValue: IN_ACCESS) + public static let defaultFileWatchOptions: Self = [.deleteSelf, .moveSelf, .modify] + public static let defaultDirectoryWatchOptions: Self = [.create, .delete, .deleteSelf, .move, .moveSelf, .modify, .closeWrite] + public static let all: [Self] = [.create, .delete, .deleteSelf, .move, .moveSelf, .modify, .open, .attrib, .closeWrite, .closeNoWrite, .access] + } + + public let paths: [AbsolutePath: WatchOptions] + public let settle: Double + private let queue = DispatchQueue(label: "org.adaengine.inotify") + private struct State { + weak var delegate: (any InotifyDelegate)? + var source: (any DispatchSourceRead)? + var watched: [Int32: AbsolutePath] = [:] + var pending: Set = [] + var report: DispatchWorkItem? + } + private let state: Mutex + + public init(paths: [AbsolutePath: WatchOptions], latency: Double, delegate: (any InotifyDelegate)? = nil) { + self.paths = paths + settle = max(latency, 0) + state = Mutex(State(delegate: delegate)) + } + + deinit { stop() } + + public func start() throws { + try state.withLock { state in + guard state.source == nil else { + return + } + let fd = inotify_init1(Int32(IN_NONBLOCK | IN_CLOEXEC)) + guard fd >= 0 else { + throw Error.invalidFD + } + var watched: [Int32: AbsolutePath] = [:] + do { + for (path, options) in paths { + try add(path, options: options, fd: fd, watched: &watched) + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: path.pathString, isDirectory: &isDirectory), isDirectory.boolValue, + let entries = FileManager.default.enumerator(atPath: path.pathString) { + for case let entry as String in entries { + let child = path.appending(RelativePath(entry)) + if FileManager.default.fileExists(atPath: child.pathString, isDirectory: &isDirectory), isDirectory.boolValue { + try add(child, options: options, fd: fd, watched: &watched) + } + } + } + } + } catch { + unsafe close(fd) + throw error + } + let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + source.setEventHandler { [weak self] in self?.readEvents(fd: fd) } + source.setCancelHandler { unsafe close(fd) } + state.watched = watched + state.source = source + source.resume() + } + } + + public func stop() { + state.withLock { state in + state.source?.cancel() + state.source = nil + state.report?.cancel() + state.report = nil + state.pending.removeAll() + state.watched.removeAll() + } + } + + private func add(_ path: AbsolutePath, options: WatchOptions, fd: Int32, watched: inout [Int32: AbsolutePath]) throws { + let wd = unsafe inotify_add_watch(fd, path.pathString, UInt32(options.rawValue)) + guard wd >= 0 else { + throw Error.failedToWatch(path) + } + watched[wd] = path + } + + private func readEvents(fd: Int32) { + var bytes = [UInt8](repeating: 0, count: 65536) + let count = unsafe read(fd, &bytes, bytes.count) + guard count > 0 else { + return + } + state.withLock { state in + guard state.source?.handle == UInt(fd) else { + return + } + var offset = 0 + let headerSize = MemoryLayout.size + while offset + headerSize <= count { + let event = unsafe bytes.withUnsafeBytes { unsafe $0.loadUnaligned(fromByteOffset: offset, as: inotify_event.self) } + let end = offset + headerSize + Int(event.len) + guard end <= count else { + break + } + if let root = state.watched[event.wd] { + let nameBytes = bytes[(offset + headerSize).. ((any InotifyDelegate)?, [AbsolutePath]) in + let events = Array(state.pending) + state.pending.removeAll() + return (state.delegate, events) + } + if !delivery.1.isEmpty { delivery.0?.pathsDidReceiveEvent(delivery.1) } + } + } +#endif diff --git a/Sources/AdaUtils/Time.swift b/Sources/AdaUtils/Time.swift index 9f05c046c..a1537f387 100644 --- a/Sources/AdaUtils/Time.swift +++ b/Sources/AdaUtils/Time.swift @@ -45,9 +45,11 @@ public struct Time { return LongTimeInterval(Date().timeIntervalSince1970) #else var time = timespec() - clock_gettime(CLOCK_MONOTONIC, &time) + guard unsafe clock_gettime(CLOCK_MONOTONIC, &time) == 0 else { + return ProcessInfo.processInfo.systemUptime + } - return LongTimeInterval(time.tv_sec) + LongTimeInterval(time.tv_nsec) / LongTimeInterval(1.0e-9) + return LongTimeInterval(time.tv_sec) + LongTimeInterval(time.tv_nsec) * 1.0e-9 #endif } diff --git a/Sources/X11/module.modulemap b/Sources/X11/module.modulemap index 3370c9cd4..a7404ec47 100644 --- a/Sources/X11/module.modulemap +++ b/Sources/X11/module.modulemap @@ -1,5 +1,5 @@ module X11 [system] { - umbrella header "x11-umbrella.h" + umbrella header "x11-umbrella" link "X11" export * module * { export * } diff --git a/Sources/X11/x11-umbrella b/Sources/X11/x11-umbrella index 383c0a299..4da8cb8c9 100644 --- a/Sources/X11/x11-umbrella +++ b/Sources/X11/x11-umbrella @@ -6,9 +6,24 @@ // -#ifndef XLIB_ILLEGAL_ACCESS -#define XLIB_ILLEGAL_ACCESS 1 -#endif - #include #include +#include +#include +#include +#include +#include + +// Swift cannot call the variadic XCreateIC API directly. +static inline XIC ada_x11_create_input_context(XIM method, Window window) { + return XCreateIC(method, XNInputStyle, XIMPreeditNothing | XIMStatusNothing, + XNClientWindow, window, XNFocusWindow, window, NULL); +} + +static inline void ada_x11_set_decorated(Display *display, Window window, int decorated) { + struct { unsigned long flags, functions, decorations; long inputMode; unsigned long status; } hints = {0}; + hints.flags = 2; // MWM_HINTS_DECORATIONS + hints.decorations = decorated ? 1 : 0; + Atom property = XInternAtom(display, "_MOTIF_WM_HINTS", False); + XChangeProperty(display, window, property, property, 32, PropModeReplace, (unsigned char *)&hints, 5); +} diff --git a/Tests/AdaPlatformTests/LinuxPlatformTests.swift b/Tests/AdaPlatformTests/LinuxPlatformTests.swift new file mode 100644 index 000000000..75d37dd68 --- /dev/null +++ b/Tests/AdaPlatformTests/LinuxPlatformTests.swift @@ -0,0 +1,98 @@ +#if os(Linux) + import AdaECS + @_spi(Internal) @testable import AdaInput + @testable import AdaPlatform + @_spi(Internal) @testable import AdaRender + @_spi(Internal) import AdaUI + import AdaUtils + import Foundation + import Math + import Testing + import X11 + + @Suite("Linux platform", .serialized) + @MainActor + struct LinuxPlatformTests { + @Test func mapsKeyboardAndPointer() { + #expect(LinuxInput.keyCode(for: UInt(XK_a)) == .a) + #expect(LinuxInput.keyCode(for: UInt(XK_Left)) == .arrowLeft) + #expect(LinuxInput.keyCode(for: UInt(XK_F12)) == .f12) + #expect(LinuxInput.keyCode(for: UInt(XK_Control_R)) == .ctrl) + #expect(LinuxInput.mouseButton(for: 2) == .middle) + #expect(LinuxInput.mouseButton(for: 3) == .right) + #expect(LinuxInput.modifiers(for: UInt32(ShiftMask | ControlMask | Mod1Mask | Mod4Mask | LockMask)) == [.shift, .control, .alt, .main, .capsLock]) + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_LINUX_WINDOW_TESTS"] == "1")) + func nativeWindowLifecycle() async throws { + try RenderEngine.setupRenderEngine() + let display = try LinuxDisplay() + let manager = LinuxWindowManager(display: display) + let window = UIWindow( + frame: Rect(x: 10, y: 10, width: 640, height: 480), + configuration: .init(title: "AdaEngine Linux Test", minimumSize: Size(width: 100, height: 100)), + windowManager: manager + ) + let native = try #require(window.systemWindow as? LinuxSystemWindow) + let world = World() + world.insertResource(Input(gameControllerEngine: nil)) + let input = world.getRefResource(Input.self) + manager.inputRef = input + #expect(manager.windows[window.id] === window) + #expect(native.xlibWindow != 0) + window.showWindow(makeFocused: false) + window.frame.size = Size(width: 700, height: 500) + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + repeat { + unsafe XSync(display.pointer, 0) + manager.pollEvents() + if native.size == Size(width: 700, height: 500), unsafe RenderEngine.shared.getRenderWindow(for: window.id)?.width == 700 { + break + } + try await Task.sleep(for: .milliseconds(10)) + } while clock.now < deadline + #expect(native.size == Size(width: 700, height: 500)) + #expect(window.frame.size == native.size) + #expect(unsafe RenderEngine.shared.getRenderWindow(for: window.id)?.width == 700) + + var key = XEvent() + key.xkey.type = KeyPress + key.xkey.display = display.pointer + key.xkey.window = native.handle + key.xkey.root = unsafe XDefaultRootWindow(display.pointer) + key.xkey.keycode = unsafe UInt32(XKeysymToKeycode(display.pointer, UInt(XK_a))) + key.xkey.same_screen = 1 + unsafe XSendEvent(display.pointer, native.handle, 0, KeyPressMask, &key) + unsafe XSync(display.pointer, 0) + manager.pollEvents() + input.wrappedValue.flushPendingEvents() + #expect(input.wrappedValue.getInputEvents().contains { ($0 as? KeyEvent)?.keyCode == .a }) + #expect(input.wrappedValue.getTextInputEvents().contains { $0.text == "a" }) + + var focus = XEvent() + focus.xfocus.type = FocusOut + focus.xfocus.display = display.pointer + focus.xfocus.window = native.handle + focus.xfocus.mode = NotifyNormal + focus.xfocus.detail = NotifyNonlinear + unsafe XSendEvent(display.pointer, native.handle, 0, FocusChangeMask, &focus) + unsafe XSync(display.pointer, 0) + manager.pollEvents() + input.wrappedValue.flushPendingEvents() + #expect(input.wrappedValue.getInputEvents().contains { ($0 as? KeyEvent)?.status == .up }) + var close = XEvent() + close.xclient.type = ClientMessage + close.xclient.window = native.handle + close.xclient.message_type = unsafe XInternAtom(display.pointer, "WM_PROTOCOLS", 0) + close.xclient.format = 32 + close.xclient.data.l.0 = unsafe Int(XInternAtom(display.pointer, "WM_DELETE_WINDOW", 0)) + unsafe XSendEvent(display.pointer, native.handle, 0, 0, &close) + unsafe XSync(display.pointer, 0) + manager.pollEvents() + #expect(manager.windows.isEmpty) + #expect(window.systemWindow == nil) + #expect(native.xlibWindow == 0) + } + } +#endif diff --git a/Tests/AdaRenderTests/WGPUBindingStateTests.swift b/Tests/AdaRenderTests/WGPUBindingStateTests.swift index 386d2c87a..e25ef9fc3 100644 --- a/Tests/AdaRenderTests/WGPUBindingStateTests.swift +++ b/Tests/AdaRenderTests/WGPUBindingStateTests.swift @@ -127,6 +127,65 @@ struct WGPUBindingStateTests { #expect(image.getPixel(x: 7, y: 3) == .init(red: 1, green: 1, blue: 0, alpha: 1)) } + @Test(.enabled(if: ProcessInfo.processInfo.environment["ADAENGINE_SPRITE_WGPU_SMOKE"] == "1")) + func transientRenderAttachmentsAreNotRetainedByBindings() async throws { + let device = try makeDevice() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("transient.glsl") + try """ + #version 450 core + #pragma stage : vert + [[main]] + void transient_vertex() { + vec2 p[3] = vec2[](vec2(-1,-1), vec2(3,-1), vec2(-1,3)); + gl_Position = vec4(p[gl_VertexIndex], 0, 1); + } + #version 450 core + #pragma stage : frag + layout(binding=0) uniform texture2D u_Texture; + layout(binding=1) uniform sampler u_Sampler; + layout(location=0) out vec4 o_Color; + [[main]] + void transient_fragment() { o_Color = texture(sampler2D(u_Texture, u_Sampler), vec2(0.5)); } + """.write(to: source, atomically: true, encoding: .utf8) + let module = try ShaderCompiler(from: source).compileShaderModule() + let pipeline = device.createRenderPipeline(from: RenderPipelineDescriptor( + vertex: try #require(module.getShader(for: .vertex)), + fragment: try module.getShader(for: .fragment), + debugName: "Transient attachments", + backfaceCulling: false, + colorAttachments: [.init(format: .rgba8)] + )) + let native = try #require(pipeline as? WGPURenderPipeline) + let target = RenderTexture(size: [2, 2], scaleFactor: 1, format: .rgba8) + weak var lastAttachment: WGPUGPUTexture? + for _ in 0..<8 { + let sampled = RenderTexture(size: [2, 2], scaleFactor: 1, format: .rgba8) + lastAttachment = sampled.gpuTexture as? WGPUGPUTexture + let command = device.createCommandQueue().makeCommandBuffer() + let clear = command.beginRenderPass(.init(colorAttachments: [ + .init(texture: sampled, operation: .init(loadAction: .clear, storeAction: .store), clearColor: .red) + ])) + clear.endRenderPass() + let pass = command.beginRenderPass(.init(colorAttachments: [ + .init(texture: target, operation: .init(loadAction: .clear, storeAction: .store), clearColor: .black) + ])) + pass.setViewport(Rect(x: 0, y: 0, width: 2, height: 2)) + pass.setRenderPipelineState(pipeline) + pass.setFragmentTexture(sampled, slot: 0) + pass.setFragmentSamplerState(sampled.sampler, slot: 1) + pass.draw(type: .triangle, vertexStart: 0, vertexCount: 3, instanceCount: 1) + pass.endRenderPass() + command.commit() + let image = try #require(try await device.readImage(from: target)) + #expect(image.getPixel(x: 0, y: 0) == .red) + } + #expect(native.cachedBindGroupCount == 0) + #expect(lastAttachment == nil) + } + private func makeDevice() throws -> any RenderDevice { unsafe RenderEngine.configurations.preferredBackend = .webgpu try RenderEngine.setupRenderEngine() diff --git a/Tests/AdaScriptCompilerCoreTests/AdaScriptNewlineTests.swift b/Tests/AdaScriptCompilerCoreTests/AdaScriptNewlineTests.swift new file mode 100644 index 000000000..fc81c4110 --- /dev/null +++ b/Tests/AdaScriptCompilerCoreTests/AdaScriptNewlineTests.swift @@ -0,0 +1,31 @@ +import AdaScriptCompilerCore +import Testing + +@Suite("AdaScript newline handling") +struct AdaScriptNewlineTests { + @Test(arguments: ["\n", "\r\n", "\r"]) + func commentsPreserveDeclarationsAndPositions(newline: String) throws { + let source = [ + "// Привет 🌍", + "@scriptable(id: \"test.newlines\")", + "class Controller {", + " @export var title = \"Мир\";", + "}" + ].joined(separator: newline) + var lexer = AdaScriptAnalysisLexer(source: source) + let tokens = lexer.lex() + let comment = try #require(tokens.first) + #expect(comment.kind == .comment) + #expect(comment.text == "// Привет 🌍") + let annotation = try #require(tokens.first { $0.text == "@" }) + #expect(annotation.range.start.line == 1) + #expect(annotation.range.start.utf16Column == 0) + let declaration = try #require(tokens.first { $0.text == "class" }) + #expect(declaration.range.start.line == 2) + #expect(Array(source)[annotation.startOffset] == "@") + let schemas = try AdaScriptSchemaParser.parseScriptables(sources: [ + AdaScriptCompilerSource(path: "Controller.ada", source: source) + ]) + #expect(schemas.map(\.id) == ["test.newlines"]) + } +} diff --git a/Tests/AdaUtilsTests/LinuxRuntimeTests.swift b/Tests/AdaUtilsTests/LinuxRuntimeTests.swift new file mode 100644 index 000000000..3836164e5 --- /dev/null +++ b/Tests/AdaUtilsTests/LinuxRuntimeTests.swift @@ -0,0 +1,48 @@ +#if os(Linux) && !os(Android) + import AdaUtils + import Foundation + import Synchronization + import Testing + + @Suite("Linux runtime") + struct LinuxRuntimeTests { + @Test func clockMeasuresSeconds() async throws { + let clock = ContinuousClock() + let start = clock.now + let engineStart = Time.absolute + try await Task.sleep(for: .milliseconds(30)) + let elapsed = start.duration(to: clock.now).components + let seconds = Double(elapsed.seconds) + Double(elapsed.attoseconds) / 1e18 + #expect(abs((Time.absolute - engineStart) - seconds) < 0.01) + } + + @Test func watchesNestedFilesAndStopsTwice() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let nested = root.appendingPathComponent("nested/deeper") + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let received = Mutex>([]) + let watcher = FSWatch(paths: [AbsolutePath(root.path)], latency: 0.01) { paths in + received.withLock { $0.formUnion(paths.map(\.pathString)) } + } + try watcher.start() + defer { + watcher.stop() + watcher.stop() + } + let file = nested.appendingPathComponent("hello.txt") + try Data("Linux".utf8).write(to: file) + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(3)) + while !received.withLock({ $0.contains(file.path) }), clock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(received.withLock { $0.contains(file.path) }) + } + + @Test func missingWatchPathThrows() { + let watcher = FSWatch(paths: [AbsolutePath("/adaengine-missing-\(UUID().uuidString)")]) { _ in } + #expect(throws: Inotify.Error.self) { try watcher.start() } + } + } +#endif diff --git a/script/package_studio_linux.py b/script/package_studio_linux.py new file mode 100644 index 000000000..afc497d6a --- /dev/null +++ b/script/package_studio_linux.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Package a relocatable Linux Studio with resources, Swift runtime and source SDK.""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile + +ROOT = Path(__file__).resolve().parents[1] + + +def package(binary_directory, compiler, tint, swan, dawn, output, version): + if not version or any(c not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_" for c in version): + raise ValueError("Version must be a filename component") + metadata = json.loads(subprocess.check_output(["swift", "-print-target-info"], text=True)) + triple = metadata["target"]["triple"] + if "linux" not in triple: + raise RuntimeError("Run this packager with a Linux Swift toolchain") + if Path(dawn).is_absolute() or ".." in Path(dawn).parts: + raise ValueError("Dawn artifact must be relative to Swan") + output.mkdir(parents=True, exist_ok=True) + name = "AdaStudio-" + version + "-" + triple.split("-")[0] + "-linux" + destination = output / name + archive = output / (name + ".tar.gz") + if destination.exists() or archive.exists(): + raise FileExistsError("Distribution already exists: " + str(destination)) + with tempfile.TemporaryDirectory(prefix=".ada-linux-", dir=output) as work: + directory = Path(work) / name + directory.mkdir() + for executable in ("AdaEditor", "gravity-lsp", "AdaPackageTool"): + shutil.copy2(binary_directory / executable, directory / executable) + for resource in binary_directory.glob("*.resources"): + shutil.copytree(resource, directory / resource.name) + for required in ("AdaEditor_AdaEditor.resources/Assets", "AdaEngine_AdaRender.resources"): + if not (directory / required).is_dir(): + raise RuntimeError("Missing SwiftPM resources: " + required) + shutil.copy2(tint, directory / "AdaEngine_AdaRender.resources/tint") + runtime = directory / "lib" + runtime.mkdir() + for path in metadata["paths"]["runtimeLibraryPaths"]: + for library in Path(path).glob("*.so*"): + if not (runtime / library.name).exists(): + shutil.copy2(library, runtime / library.name) + spec = importlib.util.spec_from_file_location("stage_build_sdk", ROOT / "Editor/scripts/stage-build-sdk.py") + sdk = importlib.util.module_from_spec(spec) + spec.loader.exec_module(sdk) + sdk.stage(ROOT, compiler, directory / "BuildSDK") + sdk.copy_inputs(swan, directory / "BuildSDK/Swan", ["Package.swift", "LICENSE", "Sources"]) + shutil.copytree(swan / dawn, directory / "BuildSDK/Swan/DawnLinux.artifactbundle") + (directory / "ada-studio").write_text('''#!/usr/bin/env bash +set -euo pipefail +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +export LD_LIBRARY_PATH="$root/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +export ADA_STUDIO_SDK="$root/BuildSDK" +export ADAENGINE_PACKAGE_PATH="$root/BuildSDK/AdaEngine" +export ADAENGINE_GRAVITY_PACKAGE_PATH="$root/BuildSDK/AdaScript" +export ADAENGINE_SWAN_PACKAGE_PATH="$root/BuildSDK/Swan" +export SWAN_LOCAL_DAWN=DawnLinux.artifactbundle +export ADAENGINE_SKIP_SWIFTLINT=1 +export TINT_EXECUTABLE="$root/AdaEngine_AdaRender.resources/tint" +exec "$root/AdaEditor" "$@" +''', encoding="utf-8") + (directory / "ada-studio").chmod(0o755) + shutil.copy2(ROOT / "LICENSE", directory / "LICENSE-AdaEngine") + # Swiftly's `swift` is a launcher, so derive the actual toolchain from compiler metadata. + toolchain = Path(metadata["paths"]["runtimeResourcePath"]).parent.parent + swift_license = toolchain / "share/swift/LICENSE.txt" + shutil.copy2(swift_license, directory / "LICENSE-Swift.txt") + (directory / "README.txt").write_text( + "Ada Studio Standalone for Linux\n\nRun ./ada-studio from this folder.\n" + "Requires an X11 desktop (or XWayland), Vulkan drivers, libX11, libpulse and Zenity.\n" + "Built on the current host: use a distribution with compatible glibc/libstdc++.\n" + "Swift runtime, resources, Tint, AdaScript compiler and source BuildSDK are bundled.\n" + "Building Swift games also requires Swift 6.3.2, clang, pkg-config, libx11-dev and libasound2-dev.\n" + "Web/Android export, remote AdaPlayer, local agents and debugger parity are not validated on Linux.\n", + encoding="utf-8", + ) + # Resolve every executable with the bundled runtime, detecting missing host dependencies. + environment = os.environ | {"LD_LIBRARY_PATH": str(runtime)} + for executable in (directory / "AdaEditor", directory / "gravity-lsp", directory / "AdaPackageTool", + directory / "BuildSDK/AdaScript/gravity", directory / "AdaEngine_AdaRender.resources/tint"): + result = subprocess.run(["ldd", str(executable)], env=environment, text=True, capture_output=True) + if "not found" in result.stdout or result.returncode not in (0, 1): + raise RuntimeError("Unresolved Linux dependencies: " + result.stdout + result.stderr) + subprocess.run([str(directory / "BuildSDK/AdaScript/gravity"), "--help"], env=environment, check=True, stdout=subprocess.DEVNULL) + shutil.move(str(directory), destination) + with tarfile.open(archive, "w:gz") as bundle: + bundle.add(destination, arcname=name) + digest = hashlib.sha256() + with archive.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + checksum = digest.hexdigest() + archive.with_suffix(archive.suffix + ".sha256").write_text(checksum + " " + archive.name + "\n", encoding="utf-8") + return destination + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for option in ("binary-directory", "compiler", "tint", "swan", "output"): + parser.add_argument("--" + option, type=Path, required=True) + parser.add_argument("--dawn", default="DawnLinux.artifactbundle") + parser.add_argument("--version", required=True) + args = parser.parse_args() + print(package(args.binary_directory.resolve(), args.compiler.resolve(), args.tint.resolve(), args.swan.resolve(), + args.dawn, args.output.resolve(), args.version)) + + +if __name__ == "__main__": + main() diff --git a/script/prepare_swan_linux.py b/script/prepare_swan_linux.py new file mode 100644 index 000000000..74d79698b --- /dev/null +++ b/script/prepare_swan_linux.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Stage a local Swan copy with its existing Dawn backend enabled on Linux. + +The upstream artifact index includes Linux Dawn binaries, but Swan 0.0.8's +WebGPU target does not declare its WebGPUDawn dependency on that platform. +Keep the original checkout intact and pass the printed path through +ADAENGINE_SWAN_PACKAGE_PATH when building AdaEngine. +""" +import argparse +from pathlib import Path +import shutil +import stat + + +def stage(source: Path, destination: Path) -> Path: + source = source.resolve() + destination = destination.resolve() + if destination == source or destination.is_relative_to(source) or source.is_relative_to(destination): + raise ValueError("Source and destination must be separate directories") + manifest = (source / "Package.swift").read_text(encoding="utf-8") + old = "let supportedNativePlatforms: [Platform] = [.macOS, .windows]" + new = "let supportedNativePlatforms: [Platform] = [.macOS, .windows, .linux]" + if old not in manifest and new not in manifest: + raise ValueError("Unrecognized Swan manifest; inspect Linux support before applying this compatibility adjustment") + marker = destination / ".adaengine-linux-swan" + if destination.exists() and not marker.is_file(): + raise ValueError("Destination exists and is not an AdaEngine Swan staging directory") + destination.mkdir(parents=True, exist_ok=True) + marker.write_text(str(source) + "\n", encoding="utf-8") + + def copy_writable(src, dst): + target = Path(dst) + if target.exists(): + target.chmod(target.stat().st_mode | stat.S_IWUSR) + shutil.copy2(src, dst) + target.chmod(target.stat().st_mode | stat.S_IWUSR) + return dst + + shutil.copytree(source, destination, dirs_exist_ok=True, + ignore=shutil.ignore_patterns(".git", ".build", ".swiftpm", "Package.resolved"), copy_function=copy_writable) + (destination / "Package.swift").write_text(manifest.replace(old, new), encoding="utf-8") + marker.write_text(str(source) + "\n", encoding="utf-8") + return destination + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path) + parser.add_argument("destination", type=Path) + args = parser.parse_args() + print(stage(args.source, args.destination)) diff --git a/script/run_example_linux.py b/script/run_example_linux.py new file mode 100644 index 000000000..ef1812e95 --- /dev/null +++ b/script/run_example_linux.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Build a private Linux copy of a Swift AdaExamples sample with this engine.""" +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import subprocess + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("project", type=Path, help="AdaExamples sample folder containing Package.swift") + parser.add_argument("--cache", type=Path, default=Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "adaengine/examples-linux") + parser.add_argument("--product", required=True) + parser.add_argument("--build-only", action="store_true") + args = parser.parse_args() + source = args.project.resolve() + # Pin the exact dependency declaration before copying. Never rewrite user projects in place. + manifest = (source / "Package.swift").read_text(encoding="utf-8") + pattern = r'\.package\(\s*url:\s*"https://github\.com/AdaEngine/AdaEngine(?:\.git)?",\s*revision:\s*"[0-9a-f]{40}"\s*\)' + manifest, count = re.subn(pattern, lambda _: ".package(path: " + json.dumps(str(ROOT)) + ")", manifest) + if count != 1: + parser.error("Expected one pinned AdaEngine dependency in the sample manifest") + cache = args.cache.resolve() + project = cache / source.name + marker = project / ".adaengine-linux-example" + if project.exists(): + if not marker.is_file() or marker.read_text(encoding="utf-8").strip() != str(source): + parser.error("Cache folder already exists and is not this sample's Linux copy") + else: + shutil.copytree(source, project, ignore=shutil.ignore_patterns(".git", ".build", ".swiftpm")) + marker.write_text(str(source) + "\n", encoding="utf-8") + (project / "Package.swift").write_text(manifest, encoding="utf-8") + # The catalog's pinned Fold predates the engine's editor-independent reflection API. + # Migrate only the private working copy; the downloaded example stays untouched. + for file in (project / "Sources").rglob("*.swift"): + text = file.read_text(encoding="utf-8") + if "EditorComponentFieldDescriptor" in text or "EditorComponentReflection" in text: + text = text.replace("EditorComponentFieldDescriptor", "ReflectedComponentField") + text = text.replace("EditorComponentReflection", "ComponentReflection").replace("isEditable:", "isWritable:") + file.write_text(text, encoding="utf-8") + scratch = cache / "build" + subprocess.run(["swift", "build", "--package-path", str(project), "--scratch-path", str(scratch), + "--product", args.product, "--jobs", os.environ.get("ADAENGINE_SWIFT_JOBS", "4")], check=True) + if not args.build_only: + binary = subprocess.check_output(["swift", "build", "--package-path", str(project), "--scratch-path", str(scratch), "--show-bin-path"], text=True).strip() + subprocess.run([str(Path(binary) / args.product)], cwd=project, check=True) + + +if __name__ == "__main__": + main() diff --git a/script/run_studio_linux.sh b/script/run_studio_linux.sh new file mode 100644 index 000000000..02e5b3feb --- /dev/null +++ b/script/run_studio_linux.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Build and stage the complete Linux Standalone distribution, then open it. +set -euo pipefail +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cache="${XDG_CACHE_HOME:-$HOME/.cache}/adaengine/studio-linux" +scratch="${ADA_STUDIO_SCRATCH_PATH:-$cache/build}" +configuration="${ADA_STUDIO_CONFIGURATION:-release}" +version="${ADA_STUDIO_VERSION:-development-$(date +%Y%m%d-%H%M%S)}" +: "${ADAENGINE_SWAN_PACKAGE_PATH:?Prepare Linux Swan first; see Documentation/Linux.md}" +: "${SWAN_LOCAL_DAWN:?Set SWAN_LOCAL_DAWN to the Linux Dawn artifact relative to Swan}" +: "${TINT_EXECUTABLE:?Set TINT_EXECUTABLE to the pinned Linux Tint executable}" +export ADAENGINE_GRAVITY_PACKAGE_PATH="${ADAENGINE_GRAVITY_PACKAGE_PATH:-$root/Sources/gravity-lang}" +export ADAENGINE_PACKAGE_PATH="$root" +export ADAENGINE_SKIP_SWIFTLINT=1 +if [[ -z "${ADA_MCP_LOCAL_PATH:-}" ]]; then + ADA_MCP_LOCAL_PATH="$(python3 "$root/script/ensure_windows_mcp.py")" + export ADA_MCP_LOCAL_PATH +fi +swift build --package-path "$root/Editor" --scratch-path "$scratch" -c "$configuration" --jobs "${ADAENGINE_SWIFT_JOBS:-4}" +binary="$(swift build --package-path "$root/Editor" --scratch-path "$scratch" -c "$configuration" --show-bin-path)" +compiler="$ADAENGINE_GRAVITY_PACKAGE_PATH" +distribution="$(python3 "$root/script/package_studio_linux.py" \ + --binary-directory "$binary" --compiler "$compiler" --tint "$TINT_EXECUTABLE" \ + --swan "$ADAENGINE_SWAN_PACKAGE_PATH" --dawn "$SWAN_LOCAL_DAWN" \ + --output "$cache/distributions" --version "$version" | tail -n 1)" +exec "$distribution/ada-studio" "$@" diff --git a/script/test_prepare_swan_linux.py b/script/test_prepare_swan_linux.py new file mode 100644 index 000000000..0fb4a4712 --- /dev/null +++ b/script/test_prepare_swan_linux.py @@ -0,0 +1,42 @@ +from pathlib import Path +import stat +import tempfile +import unittest + +from prepare_swan_linux import stage + + +class SwanLinuxStagingTests(unittest.TestCase): + def test_stages_readonly_checkout_and_keeps_source_unchanged(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + source = root / "source" + source.mkdir() + original = "let supportedNativePlatforms: [Platform] = [.macOS, .windows]\n" + manifest = source / "Package.swift" + manifest.write_text(original, encoding="utf-8") + manifest.chmod(stat.S_IREAD) + try: + destination = stage(source, root / "linux-swan") + self.assertIn(".linux", (destination / "Package.swift").read_text(encoding="utf-8")) + self.assertEqual(manifest.read_text(encoding="utf-8"), original) + stage(source, destination) + finally: + manifest.chmod(stat.S_IREAD | stat.S_IWRITE) + + def test_rejects_unrelated_destination_and_overlapping_paths(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + source = root / "source" + source.mkdir() + (source / "Package.swift").write_text("let supportedNativePlatforms: [Platform] = [.macOS, .windows]\n", encoding="utf-8") + destination = root / "existing" + destination.mkdir() + with self.assertRaises(ValueError): + stage(source, destination) + with self.assertRaises(ValueError): + stage(source, source / "nested") + + +if __name__ == "__main__": + unittest.main() From 957b5e1b3cd0b83e2a7e5f5d623cd8ea3fb268af Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sat, 17 Oct 2026 14:21:37 +0300 Subject: [PATCH 2/6] Normalize navigation C status values across Linux and Apple hosts --- Sources/AdaNavigation/NavigationMesh.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Sources/AdaNavigation/NavigationMesh.swift b/Sources/AdaNavigation/NavigationMesh.swift index 66887105b..f008ba7d0 100644 --- a/Sources/AdaNavigation/NavigationMesh.swift +++ b/Sources/AdaNavigation/NavigationMesh.swift @@ -61,20 +61,21 @@ public actor NavigationMesh { } } let status: NavigationPathStatus - switch result { - case AE_NAV_SUCCESS.rawValue: + // C enum signedness varies by host; normalize both sides before matching. + switch Int(result) { + case Int(AE_NAV_SUCCESS.rawValue): status = .complete - case AE_NAV_PARTIAL.rawValue: + case Int(AE_NAV_PARTIAL.rawValue): guard options.allowsPartialPath else { throw .unreachable } status = .partial - case AE_NAV_SEARCH_LIMIT.rawValue: + case Int(AE_NAV_SEARCH_LIMIT.rawValue): guard options.allowsPartialPath else { throw .searchLimitReached } status = .searchLimitReached - case AE_NAV_START_OUTSIDE.rawValue: throw .startOutsideMesh - case AE_NAV_TARGET_OUTSIDE.rawValue: throw .targetOutsideMesh - case AE_NAV_OUT_OF_MEMORY.rawValue: throw .outOfMemory - case AE_NAV_BUFFER_LIMIT.rawValue: throw .pathCapacityExceeded - case AE_NAV_INVALID_INPUT.rawValue: throw .invalidQuery + case Int(AE_NAV_START_OUTSIDE.rawValue): throw .startOutsideMesh + case Int(AE_NAV_TARGET_OUTSIDE.rawValue): throw .targetOutsideMesh + case Int(AE_NAV_OUT_OF_MEMORY.rawValue): throw .outOfMemory + case Int(AE_NAV_BUFFER_LIMIT.rawValue): throw .pathCapacityExceeded + case Int(AE_NAV_INVALID_INPUT.rawValue): throw .invalidQuery default: throw .queryFailed } let corners = (0.. Date: Sat, 17 Oct 2026 14:26:30 +0300 Subject: [PATCH 3/6] Remove duplicated localization call blocking Studio compilation --- .../UI/Editor/EditorInspectorSidebar+PropertyControls.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebar+PropertyControls.swift b/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebar+PropertyControls.swift index dc1aaa76b..558425802 100644 --- a/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebar+PropertyControls.swift +++ b/Editor/Sources/AdaEditor/UI/Editor/EditorInspectorSidebar+PropertyControls.swift @@ -226,7 +226,7 @@ extension EditorInspectorSidebar { Text("\u{F720}") .font(AdaEditorMaterialSymbolFont.font(size: 17)) .foregroundColor(theme.editorColors.blue) - Text(value.isEmpty ? L10n.text(L10n.text("Choose scene prefab…")) : value) + Text(value.isEmpty ? L10n.text("Choose scene prefab…") : value) .font(.system(size: 10, weight: .bold)) .foregroundColor(value.isEmpty ? theme.editorColors.muted : theme.editorColors.text) .lineLimit(1) From 6c7942a662a18788232fc80d75ba8e4f86cfa977 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sat, 17 Oct 2026 14:28:02 +0300 Subject: [PATCH 4/6] Record rebased Linux WebGPU validation coverage --- Documentation/Linux.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Documentation/Linux.md b/Documentation/Linux.md index 6d5dfa198..bce1779ce 100644 --- a/Documentation/Linux.md +++ b/Documentation/Linux.md @@ -93,8 +93,10 @@ Ubuntu 24.04 under WSL 2 / WSLg, Swift 6.3.2 and Mesa llvmpipe Vulkan: - All 5 focused Linux runtime/platform tests pass, including real X11 resize, keyboard/text delivery, focus-loss releases and `WM_DELETE_WINDOW` teardown. - All 70 `AdaUtilsTests` / `AdaInputTests` tests pass. -- All 27 focused WebGPU tests pass, including fresh Tint conversion, sprite - pixel comparisons, picking, tile occlusion and transient attachment lifetime. +- All 47 checks selected by the current WebGPU validation script pass, including + fresh Tint conversion, sprite pixels, picking, tile occlusion, render graph + diagnostics and transient attachment lifetime. The 3D golden-frame cases are + excluded by their Linux compile guards and are not covered by this result. - Both Swan staging helper tests pass; the Windows package manifest still loads. - The native demo displays its UI, accepts Latin and Cyrillic text, responds to button clicks, resizes and exits on a native close request. From d08cd3a1e864cb6febf168452cea356725e2afbd Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sat, 17 Oct 2026 14:34:12 +0300 Subject: [PATCH 5/6] Record serial Studio test results and WSL runner limitation --- Documentation/Linux.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Documentation/Linux.md b/Documentation/Linux.md index bce1779ce..23f5e2341 100644 --- a/Documentation/Linux.md +++ b/Documentation/Linux.md @@ -151,11 +151,14 @@ selects this engine by local path and migrates Fold's older reflection API names Open AdaScript-only samples such as UnfoldTheWorld through Studio's Open Project dialog. Under WSLg, `GDK_BACKEND=x11` can select X11 for Zenity as well. -The Linux Studio validation passes 13 focused tests across distribution, Linux +The Linux Studio validation passes 13 focused tests serially across distribution, Linux destination/SDK discovery, UTF-8/CRLF AdaScript compilation, localization, scene script loading, hot reload and project input delivery. Another 23 compiler tests pass in an isolated package built from the production compiler sources, including newline positions and scriptable schema discovery after comments. +The rebased Studio executable and test product build successfully. SwiftPM's +post-build test invocation stalled under WSL; the compiled Swift Testing runner +completed these 13 checks with `--testing-library swift-testing --no-parallel`. StarQuest and Fold render native windows; Fold's Jump and Turn controls execute its AdaScript controller. A 45-second Fold run held shared graphics memory near 105 MiB after excluding transient render attachments from the persistent WebGPU From 03a3089f7698a791f536ace8302a2be361efc8c0 Mon Sep 17 00:00:00 2001 From: Vladislav Prusakov Date: Sat, 17 Oct 2026 14:38:17 +0300 Subject: [PATCH 6/6] Record final GPU validation after upstream 3D merge --- Documentation/Linux.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Documentation/Linux.md b/Documentation/Linux.md index 23f5e2341..d61baedcf 100644 --- a/Documentation/Linux.md +++ b/Documentation/Linux.md @@ -93,9 +93,9 @@ Ubuntu 24.04 under WSL 2 / WSLg, Swift 6.3.2 and Mesa llvmpipe Vulkan: - All 5 focused Linux runtime/platform tests pass, including real X11 resize, keyboard/text delivery, focus-loss releases and `WM_DELETE_WINDOW` teardown. - All 70 `AdaUtilsTests` / `AdaInputTests` tests pass. -- All 47 checks selected by the current WebGPU validation script pass, including +- All 50 checks selected by the current WebGPU validation script pass, including fresh Tint conversion, sprite pixels, picking, tile occlusion, render graph - diagnostics and transient attachment lifetime. The 3D golden-frame cases are + diagnostics, visible-list equivalence and transient attachment lifetime. The 3D golden-frame cases are excluded by their Linux compile guards and are not covered by this result. - Both Swan staging helper tests pass; the Windows package manifest still loads. - The native demo displays its UI, accepts Latin and Cyrillic text, responds to