diff --git a/.gitignore b/.gitignore
index aabdaaf7c..55257c5c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -87,3 +87,6 @@ crates/canvas-napi/bench/results/
# Internal notes (local only)
crates/canvas-napi/PORTING.md
crates/vendor/napi-sys/PATCHED.md
+
+# The packed @nativescript/linux runtime apps/demo-linux installs (copied in, not committed)
+tools/linux/*.tgz
diff --git a/apps/demo-linux/.gitignore b/apps/demo-linux/.gitignore
new file mode 100644
index 000000000..407ded969
--- /dev/null
+++ b/apps/demo-linux/.gitignore
@@ -0,0 +1,42 @@
+# NativeScript
+hooks/
+node_modules/
+platforms/
+
+# NativeScript Template
+*.js.map
+*.js
+!webpack.config.js
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+.idea
+.cloud
+.project
+tmp/
+typings/
+
+# misc
+npm-debug.log
+
+# app
+!*.d.ts
+!src/assets/fontawesome.min.css
+/report/
+.nsbuildinfo
+/temp/
+/src/tns_modules/
+
+# app uses platform specific scss which can inadvertently get renamed which will cause problems
+app/app.scss
+
+package-lock.json
diff --git a/apps/demo-linux/nativescript.config.ts b/apps/demo-linux/nativescript.config.ts
new file mode 100644
index 000000000..951b86477
--- /dev/null
+++ b/apps/demo-linux/nativescript.config.ts
@@ -0,0 +1,10 @@
+import { NativeScriptConfig } from '@nativescript/core';
+
+export default {
+ id: 'org.nativescript.canvaslinuxdemo',
+ appPath: 'src',
+ appResourcesPath: '../../tools/assets/App_Resources',
+ cli: {
+ packageManager: 'npm',
+ },
+} as NativeScriptConfig;
diff --git a/apps/demo-linux/package.json b/apps/demo-linux/package.json
new file mode 100644
index 000000000..3ed3d1724
--- /dev/null
+++ b/apps/demo-linux/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "demo-linux",
+ "main": "./src/app.ts",
+ "description": "Canvas 2D, WebGL and Web Audio on NativeScript Linux",
+ "private": true,
+ "dependencies": {
+ "@nativescript/audio-context": "file:../../packages/audio-context",
+ "@nativescript/canvas": "file:../../packages/canvas",
+ "@nativescript/core": "https://pkg.pr.new/@nativescript/core@11620",
+ "@nativescript/font-manager": "file:../../node_modules/@nativescript/font-manager"
+ },
+ "devDependencies": {
+ "@nativescript/linux": "file:../../tools/linux/nativescript-linux-0.1.0-alpha.5.tgz",
+ "@nativescript/webpack": "https://pkg.pr.new/@nativescript/webpack@11620",
+ "nativescript": "9.1.2-dev.2026-11-07-42333414597",
+ "typescript": "~5.9.0"
+ },
+ "scripts": {
+ "start": "ns run linux --no-hmr"
+ }
+}
diff --git a/apps/demo-linux/references.d.ts b/apps/demo-linux/references.d.ts
new file mode 100644
index 000000000..e46d153f1
--- /dev/null
+++ b/apps/demo-linux/references.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/demo-linux/src/app.ts b/apps/demo-linux/src/app.ts
new file mode 100644
index 000000000..7592a89a0
--- /dev/null
+++ b/apps/demo-linux/src/app.ts
@@ -0,0 +1,162 @@
+import { Application, Button, GridLayout, ItemSpec, Label } from '@nativescript/core';
+import { Canvas } from '@nativescript/canvas';
+import { AudioContext } from '@nativescript/audio-context';
+
+function canvasIn(grid: GridLayout, row: number, col: number, onReady: (canvas: Canvas) => void) {
+ const canvas = new Canvas();
+ canvas.style.width = '100%' as any;
+ canvas.style.height = '100%' as any;
+ GridLayout.setRow(canvas, row);
+ GridLayout.setColumn(canvas, col);
+ grid.addChild(canvas);
+ // Usable right away; the drawing buffer follows the view's size once laid out.
+ onReady(canvas);
+ return canvas;
+}
+
+/** A gradient, shapes, text and an orbiting dot (requestAnimationFrame). */
+function draw2D(canvas: Canvas) {
+ const ctx = canvas.getContext('2d') as any;
+ let logged = '';
+ const frame = (time: number) => {
+ const { width, height } = canvas;
+ if (logged !== `${width}x${height}`) {
+ logged = `${width}x${height}`;
+ console.log(`2d: ${logged}`);
+ }
+ const gradient = ctx.createLinearGradient(0, 0, width, height);
+ gradient.addColorStop(0, '#1e3c72');
+ gradient.addColorStop(1, '#2a5298');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, width, height);
+
+ ctx.fillStyle = 'rgba(255, 200, 0, 0.9)';
+ ctx.beginPath();
+ ctx.arc(width * 0.3, height * 0.5, Math.min(width, height) * 0.25, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.strokeStyle = '#ff4f6d';
+ ctx.lineWidth = 8;
+ ctx.strokeRect(width * 0.55, height * 0.25, width * 0.3, height * 0.5);
+
+ const t = time / 1000;
+ ctx.fillStyle = 'white';
+ ctx.beginPath();
+ ctx.arc(width * 0.3 + Math.cos(t * 2) * width * 0.15, height * 0.5 + Math.sin(t * 2) * height * 0.3, 10, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.font = `bold ${Math.round(width * 0.07)}px sans-serif`;
+ ctx.fillStyle = 'white';
+ ctx.fillText('Canvas 2D on GTK 4', 16, height * 0.15);
+ requestAnimationFrame(frame);
+ };
+ requestAnimationFrame(frame);
+}
+
+/** A clear colour and an RGB triangle. */
+function drawWebGL(canvas: Canvas) {
+ const gl = canvas.getContext('webgl2') as WebGL2RenderingContext;
+ if (!gl) {
+ console.log('webgl2: unavailable');
+ return;
+ }
+ console.log(`webgl2: ${gl.getParameter(gl.VERSION)} / ${gl.getParameter(gl.RENDERER)}`);
+ const shader = (type: number, source: string) => {
+ const s = gl.createShader(type);
+ gl.shaderSource(s, source);
+ gl.compileShader(s);
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
+ console.log('shader:', gl.getShaderInfoLog(s));
+ }
+ return s;
+ };
+ const program = gl.createProgram();
+ gl.attachShader(
+ program,
+ shader(
+ gl.VERTEX_SHADER,
+ `#version 300 es
+ in vec2 position; in vec3 color; out vec3 vColor;
+ void main() { vColor = color; gl_Position = vec4(position, 0.0, 1.0); }`,
+ ),
+ );
+ gl.attachShader(
+ program,
+ shader(
+ gl.FRAGMENT_SHADER,
+ `#version 300 es
+ precision mediump float; in vec3 vColor; out vec4 outColor;
+ void main() { outColor = vec4(vColor, 1.0); }`,
+ ),
+ );
+ gl.linkProgram(program);
+ gl.useProgram(program);
+ const buffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0.8, 1, 0, 0, -0.8, -0.8, 0, 1, 0, 0.8, -0.8, 0, 0, 1]), gl.STATIC_DRAW);
+ const position = gl.getAttribLocation(program, 'position');
+ const color = gl.getAttribLocation(program, 'color');
+ gl.enableVertexAttribArray(position);
+ gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 20, 0);
+ gl.enableVertexAttribArray(color);
+ gl.vertexAttribPointer(color, 3, gl.FLOAT, false, 20, 8);
+ const frame = (time: number) => {
+ gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
+ gl.clearColor(0.1, 0.1, 0.12 + 0.08 * Math.sin(time / 500), 1);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
+ requestAnimationFrame(frame);
+ };
+ requestAnimationFrame(frame);
+}
+
+let audio: AudioContext | null = null;
+
+/** A 440 Hz tone, faded in and out over a second. */
+function playTone(status: Label) {
+ audio ??= new AudioContext();
+ const now = audio.currentTime;
+ const oscillator = audio.createOscillator();
+ const gain = audio.createGain();
+ oscillator.type = 'sine';
+ oscillator.frequency.setValueAtTime(440, now);
+ gain.gain.setValueAtTime(0, now);
+ gain.gain.linearRampToValueAtTime(0.3, now + 0.05);
+ gain.gain.linearRampToValueAtTime(0, now + 1);
+ oscillator.connect(gain);
+ gain.connect(audio.destination);
+ oscillator.start(now);
+ oscillator.stop(now + 1);
+ status.text = `AudioContext: ${audio.state}, ${audio.sampleRate} Hz`;
+ console.log(`audio: ${audio.state} ${audio.sampleRate} Hz, base latency ${audio.baseLatency}`);
+}
+
+Application.run({
+ create: () => {
+ const grid = new GridLayout();
+ grid.addRow(new ItemSpec(1, 'star'));
+ grid.addRow(new ItemSpec(1, 'auto'));
+ grid.addColumn(new ItemSpec(1, 'star'));
+ grid.addColumn(new ItemSpec(1, 'star'));
+ canvasIn(grid, 0, 0, draw2D);
+ canvasIn(grid, 0, 1, drawWebGL);
+
+ const status = new Label();
+ status.text = 'AudioContext: not started';
+ status.margin = 12;
+ GridLayout.setRow(status, 1);
+ grid.addChild(status);
+
+ const play = new Button();
+ play.text = 'Play 440 Hz';
+ play.margin = 8;
+ play.on('tap', () => playTone(status));
+ GridLayout.setRow(play, 1);
+ GridLayout.setColumn(play, 1);
+ grid.addChild(play);
+
+ // Once on start, so a run without input still plays.
+ setTimeout(() => playTone(status), 1500);
+ return grid;
+ },
+});
diff --git a/apps/demo-linux/tsconfig.json b/apps/demo-linux/tsconfig.json
new file mode 100644
index 000000000..0aa378313
--- /dev/null
+++ b/apps/demo-linux/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "strict": false,
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ESNext", "DOM"],
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["src/*"]
+ },
+ "noEmit": true,
+ "skipLibCheck": true,
+ "experimentalDecorators": true,
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true
+ },
+ "include": ["src/**/*.ts", "references.d.ts"]
+}
diff --git a/apps/demo-linux/webpack.config.js b/apps/demo-linux/webpack.config.js
new file mode 100644
index 000000000..4adda34fc
--- /dev/null
+++ b/apps/demo-linux/webpack.config.js
@@ -0,0 +1,16 @@
+const webpack = require('@nativescript/webpack');
+const { resolve } = require('path');
+
+module.exports = (env) => {
+ webpack.init(env);
+ webpack.useConfig('typescript');
+
+ webpack.chainWebpack((config) => {
+ // packages/ ship every platform's sources; they were never type-checked together.
+ config.plugins.delete('ForkTsCheckerWebpackPlugin');
+ // The plugins are linked from packages/: keep one @nativescript/core, the app's.
+ config.resolve.alias.set('@nativescript/core', resolve(__dirname, 'node_modules', '@nativescript', 'core'));
+ });
+
+ return webpack.resolveConfig();
+};
diff --git a/apps/demo/nativescript.config.ts b/apps/demo/nativescript.config.ts
index 11e5fb0ea..c0001c37e 100644
--- a/apps/demo/nativescript.config.ts
+++ b/apps/demo/nativescript.config.ts
@@ -8,6 +8,10 @@ export default {
markingMode: 'none',
discardUncaughtJsExceptions: false,
},
+ // Light like the Android app theme (Theme.AppCompat.Light), whatever the desktop uses
+ linux: {
+ appearance: 'light',
+ },
appPath: 'src',
ios: {
discardUncaughtJsExceptions: false,
diff --git a/apps/demo/package.json b/apps/demo/package.json
index 5dce04007..f06909dbc 100644
--- a/apps/demo/package.json
+++ b/apps/demo/package.json
@@ -16,7 +16,7 @@
"@nativescript/canvas-polyfill": "file:../../packages/canvas-polyfill",
"@nativescript/canvas-svg": "file:../../packages/canvas-svg",
"@nativescript/canvas-three": "file:../../packages/canvas-three",
- "@nativescript/core": "https://pkg.pr.new/@nativescript/core@54125e1",
+ "@nativescript/core": "https://pkg.pr.new/@nativescript/core@11620",
"@nativescript/font-manager": "file:../../node_modules/@nativescript/font-manager",
"@pixi-essentials/svg": "file:../../node_modules/@pixi-essentials/svg",
"babylonjs": "file:../../node_modules/babylonjs",
@@ -37,9 +37,11 @@
"@nativescript/android": "9.1.1",
"@nativescript/devtools": "~0.0.1",
"@nativescript/ios": "9.0.4-next.2026-08-13-31742124335",
+ "@nativescript/linux": "file:../../tools/linux/nativescript-linux-0.1.0-alpha.5.tgz",
"@nativescript/visionos": "~9.0.1",
- "@nativescript/webpack": "https://pkg.pr.new/@nativescript/webpack@54125e1",
+ "@nativescript/webpack": "https://pkg.pr.new/@nativescript/webpack@11620",
"@nativescript/windows": "beta",
+ "nativescript": "9.1.2-dev.2026-11-07-42333414597",
"typescript": "~5.6.0"
}
}
diff --git a/apps/demo/src/launch-args.ts b/apps/demo/src/launch-args.ts
index 8a4c6dc13..f430a8bf2 100644
--- a/apps/demo/src/launch-args.ts
+++ b/apps/demo/src/launch-args.ts
@@ -16,13 +16,15 @@
*
* echo {"demo":"canvas-spec","suite":"2d"} > %LOCALAPPDATA%\Packages\\LocalState\launch-args.json
*
+ * Linux: after the app folder, e.g. `platforms/linux/demo/bin/demo app --demo=canvas-spec`.
+ *
* Captured at launch rather than read in the page, because the first page
* navigates during bootstrap -- before Application.android.foregroundActivity
* is assigned.
*/
import { getFileAccess, knownFolders, path } from '@nativescript/core';
-declare const __WINDOWS__: boolean, NSWinRT: any, Windows: any;
+declare const __WINDOWS__: boolean, __LINUX__: boolean, NSWinRT: any, Windows: any;
export const launchArgs: { demo?: string; profile?: string; frames?: number; suite?: string; surface?: string } = {};
@@ -40,7 +42,10 @@ export function captureLaunchArgs(androidIntent?: any) {
if (__ANDROID__) {
captureFromIntent(androidIntent);
} else if (__APPLE__) {
- captureFromProcessArguments();
+ const args = NSProcessInfo.processInfo.arguments;
+ captureFromArguments(args ? Array.from({ length: args.count }, (_, i) => String(args.objectAtIndex(i))) : []);
+ } else if (typeof __LINUX__ !== 'undefined' && __LINUX__) {
+ captureFromArguments((global as any).process?.argv ?? []);
} else if (typeof __WINDOWS__ !== 'undefined' && __WINDOWS__) {
captureFromLaunchFile();
}
@@ -69,13 +74,8 @@ function captureFromIntent(intent: any) {
}
/** `--demo=canvas-spec`, as simctl and Xcode's scheme arguments pass them. */
-function captureFromProcessArguments() {
- const args = NSProcessInfo.processInfo.arguments;
- if (!args) {
- return;
- }
- for (let i = 0; i < args.count; i++) {
- const arg = String(args.objectAtIndex(i));
+function captureFromArguments(args: string[]) {
+ for (const arg of args) {
const eq = arg.indexOf('=');
if (arg.indexOf('--') !== 0 || eq === -1) {
continue;
diff --git a/apps/demo/webpack.config.js b/apps/demo/webpack.config.js
index 6b0c0892d..2dea5caf5 100644
--- a/apps/demo/webpack.config.js
+++ b/apps/demo/webpack.config.js
@@ -12,6 +12,11 @@ module.exports = (env) => {
// shared demo code
config.resolve.alias.set('@demo/shared', resolve(__dirname, '..', '..', 'tools', 'demo'));
+
+ if (webpack.Utils.platform.getPlatformName() === 'linux') {
+ // The plugins are linked from packages/: keep one @nativescript/core, the app's.
+ config.resolve.alias.set('@nativescript/core', resolve(__dirname, 'node_modules', '@nativescript', 'core'));
+ }
//config.resolve.alias.set('three', 'three/webgpu');
// config.resolve.alias.set('three/tsl', 'three/tsl');
diff --git a/crates/audio-context-napi/Cargo.toml b/crates/audio-context-napi/Cargo.toml
index 706cd1465..e9b6f85e2 100644
--- a/crates/audio-context-napi/Cargo.toml
+++ b/crates/audio-context-napi/Cargo.toml
@@ -3,7 +3,7 @@ name = "audio-context-napi"
version = "0.1.0"
edition = "2021"
publish = false
-description = "Node-API binding for @nativescript/audio-context: a Web Audio graph rendered by the web-audio-api crate and played through WASAPI (cpal), for Node-API hosts (NativeScript Windows, Node)."
+description = "Node-API binding for @nativescript/audio-context: a Web Audio graph rendered by the web-audio-api crate and played through cpal (WASAPI on Windows, ALSA on Linux), for Node-API hosts (NativeScript Windows and Linux, Node)."
[lib]
crate-type = ["cdylib"]
diff --git a/crates/canvas-2d/src/context/surface_gl.rs b/crates/canvas-2d/src/context/surface_gl.rs
index 96022407b..654d20cc5 100644
--- a/crates/canvas-2d/src/context/surface_gl.rs
+++ b/crates/canvas-2d/src/context/surface_gl.rs
@@ -7,14 +7,14 @@ use skia_safe::{gpu, surfaces, AlphaType, Color, ColorType, ISize, ImageInfo, Pi
use std::ffi::c_void;
use std::ptr::NonNull;
-/// Skia's GL entry points. On Windows GL is ANGLE, so they come from its `eglGetProcAddress`;
-/// `new_native` would bind WGL (desktop GL), which is not the context that is current.
+/// Skia's GL entry points. On Windows (ANGLE) and Linux (Mesa) GL is EGL's GLES, so they come
+/// from its `eglGetProcAddress`; `new_native` would bind WGL / GLX desktop GL.
fn gl_interface() -> Option {
- #[cfg(target_os = "windows")]
+ #[cfg(any(target_os = "windows", target_os = "linux"))]
{
Interface::new_load_with(|name| canvas_core::gpu::gl::get_proc_address(name))
}
- #[cfg(not(target_os = "windows"))]
+ #[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
Interface::new_native()
}
@@ -186,11 +186,14 @@ impl Context {
Some(&surface_props),
)?;
- let (surface, window_surface) = if draws_to_window(&gl_context) {
+ let (mut surface, window_surface) = if draws_to_window(&gl_context) {
with_offscreen(&mut ctx, surface, alpha, color_space.into())
} else {
(surface, None)
};
+ // Linux: a new pbuffer holds whatever was in its memory; a canvas starts transparent.
+ #[cfg(target_os = "linux")]
+ surface.canvas().clear(Color::TRANSPARENT);
let direct_context = Some(ctx);
@@ -393,7 +396,9 @@ impl Context {
surface
};
- if let Some(surface) = surface {
+ if let Some(mut surface) = surface {
+ #[cfg(target_os = "linux")]
+ surface.canvas().clear(Color::TRANSPARENT);
context.window_surface = window_surface;
context.direct_context = direct_context;
context.surface_state = SurfaceState::None;
diff --git a/crates/canvas-c/Cargo.toml b/crates/canvas-c/Cargo.toml
index 44d18d0f7..cbe1cceff 100644
--- a/crates/canvas-c/Cargo.toml
+++ b/crates/canvas-c/Cargo.toml
@@ -67,6 +67,12 @@ wgpu-hal = { workspace = true, features = ["dx12"] }
windows = { workspace = true, features = ["Win32_Foundation", "Win32_Graphics_Direct3D11", "Win32_Graphics_Direct3D12", "Win32_System_Com", "Win32_System_Threading"] }
skia-safe.workspace = true
+# Linux: WebGPU on Vulkan, frames shown through exportable image memory.
+[target.'cfg(target_os="linux")'.dependencies]
+wgpu-core = { workspace = true, features = ["wgsl", "vulkan"] }
+wgpu-hal = { workspace = true, features = ["vulkan"] }
+ash = "0.38"
+
[target.'cfg(target_os="android")'.dependencies]
ndk = { version = "0.7.0", features = ["bitmap"] }
wgpu-core = { workspace = true, features = ["wgsl", "vulkan"] }
diff --git a/crates/canvas-c/src/c2d/context.rs b/crates/canvas-c/src/c2d/context.rs
index 4e70ebf38..97a3cc2bd 100644
--- a/crates/canvas-c/src/c2d/context.rs
+++ b/crates/canvas-c/src/c2d/context.rs
@@ -132,9 +132,9 @@ pub fn resize_gl(context: &mut CanvasRenderingContext2D, width: f32, height: f32
}
// A pbuffer keeps its size.
- #[cfg(target_os = "android")]
+ #[cfg(any(target_os = "android", target_os = "linux"))]
let color_space = context.surface_data().color_space();
- #[cfg(target_os = "android")]
+ #[cfg(any(target_os = "android", target_os = "linux"))]
if let Some(gl) = context.gl_context.as_mut() {
if gl.is_pbuffer() {
let mut attr = canvas_core::context_attributes::ContextAttributes::new(
@@ -464,6 +464,14 @@ impl CanvasRenderingContext2D {
}
}
+ // Linux: ends the frame, so a transferred canvas's view gets it.
+ #[cfg(all(feature = "gl", target_os = "linux"))]
+ if self.engine == Engine::GL {
+ if let Some(context) = self.context.gl_context.as_ref() {
+ context.swap_buffers();
+ }
+ }
+
#[cfg(feature = "metal")]
if let Some(context) = self.context.metal_context.as_mut() {
if context.is_offscreen() {
@@ -789,6 +797,26 @@ pub extern "C" fn canvas_native_d3d_simulate_device_removal() -> bool {
here || render_thread.unwrap_or(false)
}
+/// Linux: the EGL context a GL 2D context renders with (null for CPU or threaded contexts).
+#[cfg(all(feature = "gl", target_os = "linux"))]
+#[no_mangle]
+pub extern "C" fn canvas_native_context_get_egl_context(
+ context: *const CanvasRenderingContext2D,
+) -> *mut c_void {
+ if context.is_null() {
+ return std::ptr::null_mut();
+ }
+ let context = unsafe { &*context };
+ if context.threaded.is_some() {
+ return std::ptr::null_mut();
+ }
+ context
+ .context
+ .gl_context
+ .as_ref()
+ .map_or(std::ptr::null_mut(), |gl| gl.egl_context())
+}
+
#[no_mangle]
pub extern "C" fn canvas_native_context_create(
width: f32,
diff --git a/crates/canvas-c/src/lib.rs b/crates/canvas-c/src/lib.rs
index 886e5e3ad..b3558e2af 100644
--- a/crates/canvas-c/src/lib.rs
+++ b/crates/canvas-c/src/lib.rs
@@ -38,6 +38,8 @@ mod webgl;
pub use webgl::*;
pub mod offscreen;
pub mod impl_test;
+#[cfg(target_os = "linux")]
+pub mod linux_video_frame;
/* Raf */
#[cfg(any(target_os = "android", target_os = "ios", target_os = "visionos", target_os = "tvos"))]
#[derive(Clone)]
diff --git a/crates/canvas-c/src/linux_video_frame.rs b/crates/canvas-c/src/linux_video_frame.rs
new file mode 100644
index 000000000..b511e0a33
--- /dev/null
+++ b/crates/canvas-c/src/linux_video_frame.rs
@@ -0,0 +1,524 @@
+//! Linux: decoded video frames from @nativescript/canvas-media (`NSCVideoFrame.address`).
+//!
+//! A GL frame is a texture on GDK's EGL display, which canvases share: WebGL copies it on the
+//! GPU through its EGLImage; WebGPU has the producer blit it into an exportable Vulkan image
+//! (`webgpu::linux_video_frame`). Other frames are RGBA rows in memory, uploaded once.
+
+use std::cell::RefCell;
+use std::collections::HashMap;
+use std::ffi::c_void;
+
+use crate::webgl::WebGLState;
+
+/// Same layout as `LinuxVideoFrame` in canvas-media's module.
+#[repr(C)]
+pub struct CanvasLinuxVideoFrame {
+ pub size: u32,
+ pub kind: u32,
+ pub width: u32,
+ pub height: u32,
+ pub producer: u64,
+ pub egl_display: *mut c_void,
+ pub egl_image: *mut c_void,
+ pub image_id: u64,
+ pub data: *const u8,
+ pub stride: u32,
+ pub reserved: u32,
+ pub device_uuid: [u8; 16],
+ pub copy_to: Option<
+ unsafe extern "C" fn(*mut CanvasLinuxVideoFrame, *const CanvasLinuxFrameTarget) -> bool,
+ >,
+ /// Set by a GL reader: an EGLSync the producer waits for before reusing the texture.
+ pub release_sync: *mut c_void,
+ pub user: *mut c_void,
+}
+
+pub const KIND_GL: u32 = 1;
+pub const KIND_MEMORY: u32 = 2;
+
+/// Same layout as `LinuxFrameTarget` in canvas-media's module: an exportable RGBA8 Vulkan image.
+#[repr(C)]
+pub struct CanvasLinuxFrameTarget {
+ pub size: u32,
+ pub memory_fd: i32,
+ pub id: u64,
+ pub allocation_size: u64,
+ pub width: u32,
+ pub height: u32,
+ pub ready_fd: i32,
+ pub release_fd: i32,
+ pub wait_release: u32,
+ pub reserved: u32,
+}
+
+pub(crate) unsafe fn frame<'a>(handle: *mut c_void) -> Option<&'a mut CanvasLinuxVideoFrame> {
+ let frame = unsafe { (handle as *mut CanvasLinuxVideoFrame).as_mut() }?;
+ (frame.size as usize == std::mem::size_of::()
+ && frame.width > 0
+ && frame.height > 0)
+ .then_some(frame)
+}
+
+const GL_RGBA8: i32 = 0x8058;
+const GL_SRGB8_ALPHA8: i32 = 0x8C43;
+const EGL_SYNC_FENCE_KHR: u32 = 0x30F9;
+
+type ImageTargetTexture = unsafe extern "C" fn(u32, *mut c_void);
+type GetCurrentContext = unsafe extern "C" fn() -> *mut c_void;
+type CreateSync = unsafe extern "C" fn(*mut c_void, u32, *const i32) -> *mut c_void;
+type DestroySync = unsafe extern "C" fn(*mut c_void, *mut c_void) -> u32;
+
+struct Egl {
+ target_texture: ImageTargetTexture,
+ current_context: GetCurrentContext,
+ create_sync: CreateSync,
+ destroy_sync: DestroySync,
+}
+
+fn egl() -> Option<&'static Egl> {
+ static EGL: std::sync::OnceLock