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..d61baedcf --- /dev/null +++ b/Documentation/Linux.md @@ -0,0 +1,179 @@ +# 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 50 checks selected by the current WebGPU validation script pass, including + fresh Tint conversion, sprite pixels, picking, tile occlusion, render graph + 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 + 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 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 +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/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) 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()