Add native self-hosted instance connection to fluxer_desktop

Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 +
tools/ci) with a "Connect to a Different Server" menu item and popout
that lets the desktop app switch to any self-hosted Fluxer instance,
plus fixes for well-known discovery on single-domain self-hosted
deployments and a false-positive ERR_ABORTED on same-origin client
redirects during the switch. Defaults to chat.fluxr.chat and uses an
isolated userData directory from the official build.
This commit is contained in:
2026-07-01 18:22:43 -04:00
commit 682afacd30
1763 changed files with 613720 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
[package]
name = "fluxer_linux_portals"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
napi = {version = "3.9.1", default-features = false, features = ["dyn-symbols", "napi8"]}
napi-derive = "3.5.6"
[target.'cfg(target_os = "linux")'.dependencies]
async-io = "2.6.0"
futures-lite = "2.6.1"
x11rb = "0.13.2"
zbus = "5.16.0"
[build-dependencies]
napi-build = "2.3.2"
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
fn main() {
napi_build::setup();
}
+121
View File
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface ResolveWindowPidSpec {
backend: 'gnome-shell-eval';
token: string;
}
export declare function resolveWindowPid(spec: ResolveWindowPidSpec): Promise<number | null>;
export declare function resolveKwinWindowPid(token: string): Promise<number | null>;
export declare function resolveX11WindowPid(token: string): Promise<number | null>;
export type FileChooserFilterKind = 0 | 1;
export interface FileChooserFilterRule {
kind: FileChooserFilterKind;
pattern: string;
}
export interface FileChooserFilter {
name: string;
rules: ReadonlyArray<FileChooserFilterRule>;
}
export interface FileChooserOptions {
parentWindow?: string;
title?: string;
acceptLabel?: string;
modal?: boolean;
multiple?: boolean;
directory?: boolean;
currentFolder?: string;
currentName?: string;
currentFile?: string;
filters?: ReadonlyArray<FileChooserFilter>;
currentFilter?: FileChooserFilter;
}
export interface FileChooserResult {
cancelled: boolean;
uris: Array<string>;
}
export declare function openFile(options: FileChooserOptions): Promise<FileChooserResult>;
export declare function saveFile(options: FileChooserOptions): Promise<FileChooserResult>;
export interface BackgroundOptions {
reason?: string;
autostart?: boolean;
commandline?: ReadonlyArray<string>;
dbusActivatable?: boolean;
}
export interface BackgroundResult {
response: number;
cancelled: boolean;
background: boolean;
autostart: boolean;
}
export declare function requestBackground(options: BackgroundOptions): Promise<BackgroundResult>;
export interface GlobalShortcutEntry {
id: string;
description: string;
preferredTrigger?: string;
}
export interface BoundGlobalShortcut {
id: string;
description?: string;
triggerDescription?: string;
}
export type GlobalShortcutPortalAction = 'listed' | 'bound' | 'cancelled';
export interface GlobalShortcutsConfigureResult {
action: GlobalShortcutPortalAction;
shortcuts: Array<BoundGlobalShortcut>;
}
export type GlobalShortcutPortalEvent =
| {type: 'activated'; id: string}
| {type: 'deactivated'; id: string}
| {type: 'shortcuts-changed'; shortcuts: Array<BoundGlobalShortcut>}
| {type: 'closed'};
export declare class GlobalShortcutsPortal {
constructor(onEvent: (event: GlobalShortcutPortalEvent) => void, appId?: string | null);
configure(entries: ReadonlyArray<GlobalShortcutEntry>): Promise<GlobalShortcutsConfigureResult>;
close(): void;
}
export declare function isAvailable(): boolean;
export declare function getPortalVersion(): number | null;
export declare function readColorScheme(): 'no-preference' | 'prefer-dark' | 'prefer-light';
export declare function readContrast(): 'no-preference' | 'high';
export declare function readAccentColor(): {r: number; g: number; b: number} | null;
export interface SettingsChangeEvent {
namespace: string;
key: string;
uint32?: number;
accent?: {r: number; g: number; b: number};
}
export declare class Settings {
constructor(onChange: (event: SettingsChangeEvent) => void);
close(): void;
}
export declare const loadError: Error | null;
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const {existsSync} = require('node:fs');
const {join, sep} = require('node:path');
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
const MODULE_NAME = '@fluxer/linux-portals';
const SKIP_NATIVE_PROBE_ENV = 'FLUXER_LINUX_PORTALS_SKIP_NATIVE_PROBE';
function resolveNativeRoot() {
const asarSegment = `${sep}app.asar${sep}`;
if (!__dirname.includes(asarSegment)) return __dirname;
const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
return existsSync(unpackedDir) ? unpackedDir : __dirname;
}
function nativeFileName() {
if (process.platform !== 'linux') {
throw new Error(`@fluxer/linux-portals is only supported on Linux, got ${process.platform}`);
}
switch (process.arch) {
case 'x64':
return 'linux-portals.linux-x64-gnu.node';
case 'arm64':
return 'linux-portals.linux-arm64-gnu.node';
default:
throw new Error(`Unsupported Linux architecture: ${process.arch}`);
}
}
let binding = null;
let loadError = null;
if (process.platform === 'linux') {
try {
const nativeRoot = resolveNativeRoot();
const nativePath = join(nativeRoot, nativeFileName());
const loaded = loadNativeBinding({
moduleName: MODULE_NAME,
nativePath,
nativeRoot,
packageDir: __dirname,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
});
binding = loaded.binding;
loadError = loaded.loadError;
if (loadError) throw loadError;
} catch (error) {
loadError = createNativeLoadError({
moduleName: MODULE_NAME,
nativeRoot: resolveNativeRoot(),
packageDir: __dirname,
reason: 'native loader threw before binding load completed',
cause: error,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
});
throw loadError;
}
}
module.exports = {
resolveKwinWindowPid: binding ? binding.resolveKwinWindowPid : null,
resolveX11WindowPid: binding ? binding.resolveX11WindowPid : null,
resolveWindowPid: binding ? binding.resolveWindowPid : null,
openFile: binding ? binding.openFile : null,
saveFile: binding ? binding.saveFile : null,
requestBackground: binding ? binding.requestBackground : null,
GlobalShortcutsPortal: binding ? binding.GlobalShortcutsPortal : null,
isAvailable: binding ? binding.isAvailable : () => false,
getPortalVersion: binding ? binding.getPortalVersion : () => null,
readColorScheme: binding ? binding.readColorScheme : () => 'no-preference',
readContrast: binding ? binding.readContrast : () => 'no-preference',
readAccentColor: binding ? binding.readAccentColor : () => null,
Settings: binding ? binding.Settings : null,
loadError,
};
@@ -0,0 +1,524 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const {existsSync, readdirSync, readFileSync, statSync} = require('node:fs');
const os = require('node:os');
const {basename} = require('node:path');
const {spawnSync} = require('node:child_process');
const NATIVE_LOAD_ERROR_MARKER = Symbol.for('fluxer.nativeLoadError');
const MAX_TEXT_LENGTH = 6000;
const MAX_DIRECTORY_ENTRIES = 80;
function trimText(value, limit = MAX_TEXT_LENGTH) {
const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
const trimmed = text.trim();
if (!trimmed) return null;
return trimmed.length > limit ? `${trimmed.slice(0, limit)}\n...<truncated>` : trimmed;
}
function errorDiagnostic(error) {
if (!error) return null;
if (error instanceof Error) {
return {
name: error.name || 'Error',
message: error.message,
code: error.code || null,
stack: trimText(error.stack || error.message),
};
}
return {
name: typeof error,
message: trimText(String(error)),
code: null,
stack: null,
};
}
function formatErrorDiagnostic(diagnostic) {
if (!diagnostic) return null;
const lines = [];
if (diagnostic.code) lines.push(`code=${diagnostic.code}`);
if (diagnostic.stack) lines.push(diagnostic.stack);
else if (diagnostic.message) lines.push(diagnostic.message);
return trimText(lines.join('\n'));
}
function fileDiagnostic(filePath) {
if (!filePath) return {path: null, exists: false, error: 'not resolved'};
try {
const stat = statSync(filePath);
return {
path: filePath,
exists: true,
size: stat.size,
mode: `0${(stat.mode & 0o777).toString(8)}`,
mtime: stat.mtime.toISOString(),
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
};
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return {path: filePath, exists: false, error: reason};
}
}
function formatFileDiagnostic(diagnostic) {
if (!diagnostic) return 'not resolved';
if (!diagnostic.exists) return `exists=false, statError=${diagnostic.error || '<unknown>'}`;
return [
`exists=true`,
`size=${diagnostic.size}`,
`mode=${diagnostic.mode}`,
`mtime=${diagnostic.mtime}`,
`isFile=${diagnostic.isFile}`,
].join(', ');
}
function directoryDiagnostic(dirPath) {
if (!dirPath) return {path: null, ok: false, error: 'not resolved', entries: [], total: 0, omitted: 0};
try {
const entries = readdirSync(dirPath, {withFileTypes: true}).map((entry) => ({
name: entry.name,
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other',
}));
entries.sort((a, b) => a.name.localeCompare(b.name));
const visible = entries.slice(0, MAX_DIRECTORY_ENTRIES);
return {
path: dirPath,
ok: true,
entries: visible,
total: entries.length,
omitted: Math.max(0, entries.length - visible.length),
};
} catch (error) {
return {
path: dirPath,
ok: false,
error: error instanceof Error ? error.message : String(error),
entries: [],
total: 0,
omitted: 0,
};
}
}
function formatDirectoryDiagnostic(diagnostic) {
if (!diagnostic) return '<unavailable>';
if (!diagnostic.ok) return `directory listing failed: ${diagnostic.error || '<unknown>'}`;
const entries = diagnostic.entries.map((entry) => `${entry.name}${entry.type === 'directory' ? '/' : ''}`);
const suffix = diagnostic.omitted > 0 ? [`...<${diagnostic.omitted} more entries>`] : [];
return [...entries, ...suffix].join('\n') || '<empty>';
}
function selectedEnvironmentNames(skipNativeProbeEnv) {
const names = [
'ELECTRON_RUN_AS_NODE',
'FLUXER_NATIVE_MODULE_PREFLIGHT_CHILD',
'LD_LIBRARY_PATH',
'DYLD_LIBRARY_PATH',
'DISPLAY',
'WAYLAND_DISPLAY',
'XDG_CURRENT_DESKTOP',
'XDG_SESSION_TYPE',
'DBUS_SESSION_BUS_ADDRESS',
'PULSE_SERVER',
'PIPEWIRE_REMOTE',
'PATH',
];
if (skipNativeProbeEnv) names.push(skipNativeProbeEnv);
return names;
}
function environmentDiagnostics(skipNativeProbeEnv) {
return Object.fromEntries(
selectedEnvironmentNames(skipNativeProbeEnv).map((name) => [name, process.env[name] ?? null]),
);
}
function formatEnvironment(diagnostic) {
return Object.entries(diagnostic)
.map(([name, value]) => `${name}=${value ?? '<unset>'}`)
.join('\n');
}
function runtimeDiagnostics() {
const versions = process.versions || {};
let reportHeader = null;
if (process.report && typeof process.report.getReport === 'function') {
try {
reportHeader = process.report.getReport().header || null;
} catch {
reportHeader = null;
}
}
const glibcRuntime = versions.glibcVersionRuntime || reportHeader?.glibcVersionRuntime || '<unknown>';
const glibcCompiler = versions.glibcVersionCompiler || reportHeader?.glibcVersionCompiler || '<unknown>';
return {
node: versions.node || null,
electron: versions.electron || null,
modules: versions.modules || null,
napi: versions.napi || null,
v8: versions.v8 || null,
uv: versions.uv || null,
openssl: versions.openssl || null,
glibcRuntime,
glibcCompiler,
platform: process.platform,
arch: process.arch,
osType: os.type(),
osRelease: os.release(),
osVersion: typeof os.version === 'function' ? os.version() : null,
execPath: process.execPath,
resourcesPath: process.resourcesPath || null,
cwd: process.cwd(),
};
}
function formatRuntimeDiagnostics(diagnostic) {
return [
`node=${diagnostic.node || '<unknown>'}`,
`electron=${diagnostic.electron || '<none>'}`,
`modules=${diagnostic.modules || '<unknown>'}`,
`napi=${diagnostic.napi || '<unknown>'}`,
`v8=${diagnostic.v8 || '<unknown>'}`,
`uv=${diagnostic.uv || '<unknown>'}`,
`openssl=${diagnostic.openssl || '<unknown>'}`,
`glibcRuntime=${diagnostic.glibcRuntime || '<unknown>'}`,
`glibcCompiler=${diagnostic.glibcCompiler || '<unknown>'}`,
`process=${diagnostic.platform}/${diagnostic.arch}`,
`os=${diagnostic.osType} ${diagnostic.osRelease} ${diagnostic.osVersion || '<unknown>'}`,
`execPath=${diagnostic.execPath}`,
`resourcesPath=${diagnostic.resourcesPath || '<unknown>'}`,
`cwd=${diagnostic.cwd}`,
].join('\n');
}
const REDISTRIBUTABLE_RUNTIME_PATTERNS = [
/^vcruntime\d+(?:_\d+)?\.dll$/i,
/^msvcp\d+(?:_\d+)?\.dll$/i,
/^msvcr\d+(?:_\d+)?\.dll$/i,
/^concrt\d+\.dll$/i,
/^vcamp\d+\.dll$/i,
/^vcomp\d+\.dll$/i,
];
function readPeImports(filePath) {
let buffer;
try {
buffer = readFileSync(filePath);
} catch {
return null;
}
if (buffer.length < 0x40) return null;
const peOffset = buffer.readUInt32LE(0x3c);
if (peOffset <= 0 || peOffset + 24 >= buffer.length) return null;
if (buffer.readUInt32LE(peOffset) !== 0x4550) return null;
const coffOffset = peOffset + 4;
const numberOfSections = buffer.readUInt16LE(coffOffset + 2);
const sizeOfOptionalHeader = buffer.readUInt16LE(coffOffset + 16);
const optionalHeaderOffset = coffOffset + 20;
if (optionalHeaderOffset + sizeOfOptionalHeader > buffer.length) return null;
const magic = buffer.readUInt16LE(optionalHeaderOffset);
if (magic !== 0x10b && magic !== 0x20b) return null;
const dataDirectoriesOffset = optionalHeaderOffset + (magic === 0x20b ? 112 : 96);
const importEntryOffset = dataDirectoriesOffset + 8;
if (importEntryOffset + 8 > buffer.length) return null;
const importRva = buffer.readUInt32LE(importEntryOffset);
if (importRva === 0) return [];
const sections = [];
const sectionTableOffset = optionalHeaderOffset + sizeOfOptionalHeader;
for (let i = 0; i < numberOfSections; i++) {
const base = sectionTableOffset + i * 40;
if (base + 40 > buffer.length) return null;
sections.push({
virtualSize: buffer.readUInt32LE(base + 8),
virtualAddress: buffer.readUInt32LE(base + 12),
rawSize: buffer.readUInt32LE(base + 16),
rawPointer: buffer.readUInt32LE(base + 20),
});
}
const rvaToOffset = (rva) => {
for (const s of sections) {
const span = Math.max(s.virtualSize, s.rawSize);
if (rva >= s.virtualAddress && rva < s.virtualAddress + span) {
return rva - s.virtualAddress + s.rawPointer;
}
}
return -1;
};
const readCString = (offset) => {
let end = offset;
while (end < buffer.length && buffer[end] !== 0) end++;
return buffer.toString('ascii', offset, end);
};
const importTableOffset = rvaToOffset(importRva);
if (importTableOffset < 0) return [];
const imports = new Set();
for (let i = 0; i < 1024; i++) {
const base = importTableOffset + i * 20;
if (base + 20 > buffer.length) break;
const lookupRva = buffer.readUInt32LE(base);
const nameRva = buffer.readUInt32LE(base + 12);
const iatRva = buffer.readUInt32LE(base + 16);
if (lookupRva === 0 && nameRva === 0 && iatRva === 0) break;
const nameOffset = rvaToOffset(nameRva);
if (nameOffset < 0) continue;
const name = readCString(nameOffset);
if (name) imports.add(name);
}
return Array.from(imports);
}
function windowsImportProbe(nativePath) {
const imports = readPeImports(nativePath);
if (imports === null) return null;
const sortedImports = [...imports].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
const redistributable = sortedImports.filter((dll) =>
REDISTRIBUTABLE_RUNTIME_PATTERNS.some((pattern) => pattern.test(dll)),
);
return {
command: ['pe-imports', nativePath],
status: 0,
signal: null,
error: null,
stdout: sortedImports.join('\n') || null,
stderr: null,
missing: [],
redistributable,
};
}
function dependencyProbe(nativePath) {
if (!nativePath || !existsSync(nativePath)) return null;
if (process.platform === 'win32') return windowsImportProbe(nativePath);
const command =
process.platform === 'linux'
? ['ldd', nativePath]
: process.platform === 'darwin'
? ['otool', '-L', nativePath]
: null;
if (!command) return null;
const [bin, ...args] = command;
const result = spawnSync(bin, args, {
encoding: 'utf8',
timeout: 4000,
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdout = trimText(result.stdout);
const stderr = trimText(result.stderr);
const missing =
process.platform === 'linux' && stdout
? stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.includes('not found'))
: [];
return {
command,
status: result.status,
signal: result.signal || null,
error: result.error ? result.error.message : null,
stdout,
stderr,
missing,
redistributable: [],
};
}
function formatDependencyProbe(diagnostic) {
if (!diagnostic) return null;
const status = diagnostic.error
? `error=${diagnostic.error}`
: diagnostic.signal
? `signal=${diagnostic.signal}`
: `status=${diagnostic.status}`;
return [
`$ ${diagnostic.command.join(' ')}`,
status,
diagnostic.missing?.length ? `missing:\n${diagnostic.missing.join('\n')}` : null,
diagnostic.redistributable?.length
? `redistributableRuntimeImports (require VC++ redist on host):\n${diagnostic.redistributable.join('\n')}`
: null,
diagnostic.stdout ? `stdout:\n${diagnostic.stdout}` : null,
diagnostic.stderr ? `stderr:\n${diagnostic.stderr}` : null,
]
.filter(Boolean)
.join('\n');
}
function formatExtraDiagnostic(diagnostic) {
if (!diagnostic) return null;
if (typeof diagnostic === 'string') return diagnostic;
if (typeof diagnostic === 'object' && diagnostic.name && diagnostic.text) {
return `${diagnostic.name}:\n${diagnostic.text}`;
}
return `extra:\n${trimText(JSON.stringify(diagnostic, null, 2))}`;
}
function collectNativeDiagnostics({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics = [],
}) {
return {
schemaVersion: 1,
moduleName,
reason,
target: {
platform: process.platform,
arch: process.arch,
},
packageDir: packageDir || null,
nativeRoot: nativeRoot || null,
nativePath: nativePath || null,
nativeFile: nativePath ? basename(nativePath) : null,
nativeFileStat: fileDiagnostic(nativePath),
runtime: runtimeDiagnostics(),
environment: environmentDiagnostics(skipNativeProbeEnv),
nativeRootEntries: directoryDiagnostic(nativeRoot),
dependencyProbe: dependencyProbe(nativePath),
extraDiagnostics: extraDiagnostics.filter(Boolean),
cause: errorDiagnostic(cause),
};
}
function formatNativeDiagnostics(diagnostics) {
const sections = [
`module=${diagnostics.moduleName}`,
`reason=${diagnostics.reason}`,
`target=${diagnostics.target.platform}/${diagnostics.target.arch}`,
`packageDir=${diagnostics.packageDir || '<unknown>'}`,
`nativeRoot=${diagnostics.nativeRoot || '<unknown>'}`,
`nativePath=${diagnostics.nativePath || '<unknown>'}`,
`nativeFile=${diagnostics.nativeFile || '<unknown>'}`,
`nativeFileStat=${formatFileDiagnostic(diagnostics.nativeFileStat)}`,
`runtime:\n${formatRuntimeDiagnostics(diagnostics.runtime)}`,
`environment:\n${formatEnvironment(diagnostics.environment)}`,
`nativeRootEntries:\n${formatDirectoryDiagnostic(diagnostics.nativeRootEntries)}`,
...diagnostics.extraDiagnostics.map(formatExtraDiagnostic).filter(Boolean),
];
const dependencyOutput = formatDependencyProbe(diagnostics.dependencyProbe);
if (dependencyOutput) sections.push(`dependencyProbe:\n${dependencyOutput}`);
const causeText = formatErrorDiagnostic(diagnostics.cause);
if (causeText) sections.push(`cause:\n${causeText}`);
return sections.join('\n');
}
function isNativeLoadError(error) {
return Boolean(error?.[NATIVE_LOAD_ERROR_MARKER]);
}
function createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics = [],
}) {
if (isNativeLoadError(cause)) return cause;
const diagnostics = collectNativeDiagnostics({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics,
});
const error = new Error(`${moduleName} native module failed to load.\n${formatNativeDiagnostics(diagnostics)}`);
error.name = 'NativeModuleLoadError';
error[NATIVE_LOAD_ERROR_MARKER] = true;
error.nativeDiagnostics = diagnostics;
error.toJSON = () => ({
name: error.name,
message: error.message,
nativeDiagnostics: diagnostics,
});
if (cause) error.cause = cause;
return error;
}
function probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, timeoutMs = 5000}) {
if (!skipNativeProbeEnv || process.env[skipNativeProbeEnv] === '1') {
return null;
}
const result = spawnSync(process.execPath, ['-e', 'require(process.argv[1])', nativePath], {
env: {...process.env, ELECTRON_RUN_AS_NODE: '1', [skipNativeProbeEnv]: '1'},
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: timeoutMs,
});
if (result.status === 0) return null;
const reason = result.error
? result.error.message
: result.signal
? `safety probe terminated by signal ${result.signal}`
: `safety probe exited with code ${result.status}`;
return createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
skipNativeProbeEnv,
extraDiagnostics: [
result.stdout ? {name: 'probeStdout', text: trimText(result.stdout)} : null,
result.stderr ? {name: 'probeStderr', text: trimText(result.stderr)} : null,
],
});
}
function loadNativeBinding({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, probe = true}) {
if (!existsSync(nativePath)) {
return {
binding: null,
loadError: createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason: 'native binary not found',
skipNativeProbeEnv,
}),
};
}
const nativeProbeError = probe
? probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv})
: null;
if (nativeProbeError) {
return {binding: null, loadError: nativeProbeError};
}
try {
return {binding: require(nativePath), loadError: null};
} catch (error) {
return {
binding: null,
loadError: createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason: 'require(nativePath) threw',
cause: error,
skipNativeProbeEnv,
}),
};
}
}
module.exports = {
collectNativeDiagnostics,
createNativeLoadError,
formatNativeDiagnostics,
isNativeLoadError,
loadNativeBinding,
probeNativeBinary,
};
@@ -0,0 +1,27 @@
{
"name": "@fluxer/linux-portals",
"version": "0.0.0",
"description": "",
"private": true,
"license": "AGPL-3.0-or-later",
"os": [
"linux"
],
"cpu": [
"x64",
"arm64"
],
"main": "index.js",
"types": "index.d.ts",
"files": [
"index.js",
"index.d.ts",
"loader-diagnostics.cjs",
"linux-portals.linux-x64-gnu.node",
"linux-portals.linux-arm64-gnu.node"
],
"scripts": {
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
"test": "cargo test --manifest-path Cargo.toml"
}
}
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[cfg(target_os = "linux")]
use std::{collections::HashMap, time::Duration};
#[cfg(target_os = "linux")]
use futures_lite::{FutureExt, StreamExt, future};
#[cfg(target_os = "linux")]
use zbus::{
MatchRule, MessageStream, Proxy,
message::Type as MessageType,
zvariant::{OwnedObjectPath, Value},
};
#[cfg(target_os = "linux")]
use crate::portal::{REQUEST_INTERFACE, mint_token, request_path};
pub const PORTAL_DESTINATION: &str = "org.freedesktop.portal.Desktop";
pub const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
pub const BACKGROUND_INTERFACE: &str = "org.freedesktop.portal.Background";
#[cfg(target_os = "linux")]
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(5 * 60);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RequestOptions {
pub reason: Option<String>,
pub autostart: bool,
pub commandline: Vec<String>,
pub dbus_activatable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestResult {
pub response: u32,
pub background: bool,
pub autostart: bool,
}
impl RequestResult {
pub fn cancelled(&self) -> bool {
self.response != 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BackgroundError {
DbusError,
PortalTimeout,
InvalidReply,
SendFailed,
}
impl std::fmt::Display for BackgroundError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::DbusError => "DbusError",
Self::PortalTimeout => "PortalTimeout",
Self::InvalidReply => "InvalidReply",
Self::SendFailed => "SendFailed",
};
f.write_str(s)
}
}
#[cfg(target_os = "linux")]
pub fn request_background(options: RequestOptions) -> Result<RequestResult, BackgroundError> {
future::block_on(request_background_async(options))
}
#[cfg(target_os = "linux")]
async fn request_background_async(
options: RequestOptions,
) -> Result<RequestResult, BackgroundError> {
let conn = zbus::Connection::session()
.await
.map_err(|_| BackgroundError::DbusError)?;
let unique_owned = conn
.unique_name()
.ok_or(BackgroundError::DbusError)?
.to_owned();
let unique_name = unique_owned.as_str().to_string();
let handle_token = mint_token("fluxer_bg");
let expected_path = request_path(&unique_name, &handle_token);
let rule = MatchRule::builder()
.msg_type(MessageType::Signal)
.interface(REQUEST_INTERFACE)
.map_err(|_| BackgroundError::DbusError)?
.member("Response")
.map_err(|_| BackgroundError::DbusError)?
.path(expected_path.clone())
.map_err(|_| BackgroundError::DbusError)?
.build();
let mut stream = MessageStream::for_match_rule(rule, &conn, Some(8))
.await
.map_err(|_| BackgroundError::DbusError)?;
send_call(&conn, &handle_token, &options, &expected_path).await?;
loop {
let timeout = async {
async_io::Timer::after(REQUEST_TIMEOUT).await;
None::<zbus::Result<zbus::Message>>
};
match stream.next().or(timeout).await {
Some(Ok(message)) => {
if let Some(parsed) = parse_request_response(&message) {
return Ok(parsed);
}
}
Some(Err(_)) => return Err(BackgroundError::DbusError),
None => return Err(BackgroundError::PortalTimeout),
}
}
}
#[cfg(target_os = "linux")]
fn parse_request_response(message: &zbus::Message) -> Option<RequestResult> {
let body = message.body();
let (response, results): (u32, HashMap<String, zbus::zvariant::OwnedValue>) =
body.deserialize().ok()?;
Some(RequestResult {
response,
background: bool_result(&results, "background").unwrap_or(false),
autostart: bool_result(&results, "autostart").unwrap_or(false),
})
}
#[cfg(target_os = "linux")]
fn bool_result(results: &HashMap<String, zbus::zvariant::OwnedValue>, key: &str) -> Option<bool> {
results
.get(key)
.and_then(|value| bool_from_value(crate::kwin::value_of_owned(value)))
}
#[cfg(target_os = "linux")]
fn bool_from_value(value: &Value<'_>) -> Option<bool> {
match value {
Value::Bool(v) => Some(*v),
Value::Value(inner) => bool_from_value(inner),
_ => None,
}
}
#[cfg(target_os = "linux")]
async fn send_call(
conn: &zbus::Connection,
handle_token: &str,
options: &RequestOptions,
expected_path: &str,
) -> Result<(), BackgroundError> {
let proxy = Proxy::new(conn, PORTAL_DESTINATION, PORTAL_PATH, BACKGROUND_INTERFACE)
.await
.map_err(|_| BackgroundError::DbusError)?;
let mut vardict: HashMap<&str, Value<'_>> = HashMap::new();
vardict.insert("handle_token", Value::new(handle_token));
vardict.insert("autostart", Value::new(options.autostart));
if let Some(reason) = options.reason.as_deref() {
vardict.insert("reason", Value::new(reason));
}
if !options.commandline.is_empty() {
let commandline: Vec<&str> = options.commandline.iter().map(String::as_str).collect();
vardict.insert("commandline", Value::new(commandline));
}
if options.dbus_activatable {
vardict.insert("dbus-activatable", Value::new(true));
}
let reply_path: OwnedObjectPath = proxy
.call("RequestBackground", &("", vardict))
.await
.map_err(|_| BackgroundError::SendFailed)?;
if !reply_path.as_str().is_empty() && reply_path.as_str() != expected_path {
return Err(BackgroundError::InvalidReply);
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub fn request_background(_options: RequestOptions) -> Result<RequestResult, BackgroundError> {
Err(BackgroundError::DbusError)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_request_does_not_autostart() {
let opts = RequestOptions::default();
assert!(!opts.autostart);
assert!(opts.commandline.is_empty());
}
#[test]
fn nonzero_response_is_cancelled() {
let result = RequestResult {
response: 1,
background: false,
autostart: false,
};
assert!(result.cancelled());
}
}
@@ -0,0 +1,360 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DesktopSession {
Kde,
Gnome,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayServer {
X11,
Wayland,
WaylandWithXwayland,
Unknown,
}
pub fn has_dbus_session() -> bool {
has_dbus_session_from(
std::env::var("DBUS_SESSION_BUS_ADDRESS").ok().as_deref(),
std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
|path| std::path::Path::new(path).exists(),
)
}
fn has_dbus_session_from(
bus_address: Option<&str>,
xdg_runtime_dir: Option<&str>,
path_exists: impl Fn(&str) -> bool,
) -> bool {
if bus_address.is_some_and(|v| !v.is_empty()) {
return true;
}
if let Some(dir) = xdg_runtime_dir
&& !dir.is_empty()
{
let candidate = format!("{}/bus", dir.trim_end_matches('/'));
if path_exists(&candidate) {
return true;
}
}
false
}
impl DisplayServer {
pub fn x11_reachable(self) -> bool {
matches!(self, Self::X11 | Self::WaylandWithXwayland)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindowPidBackend {
Kwin,
GnomeShellEval,
X11,
}
pub fn detect_desktop_session() -> DesktopSession {
detect_desktop_session_from(
std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(),
std::env::var("XDG_SESSION_DESKTOP").ok().as_deref(),
std::env::var("DESKTOP_SESSION").ok().as_deref(),
)
}
fn detect_desktop_session_from(
xdg_current_desktop: Option<&str>,
xdg_session_desktop: Option<&str>,
desktop_session: Option<&str>,
) -> DesktopSession {
let candidates = [xdg_current_desktop, xdg_session_desktop, desktop_session];
for raw in candidates.into_iter().flatten() {
for token in raw.split(':') {
let token = token.trim().to_ascii_lowercase();
match token.as_str() {
"kde" | "plasma" | "kde-plasma" => return DesktopSession::Kde,
"gnome" | "gnome-classic" | "gnome-xorg" | "ubuntu" | "pop" => {
return DesktopSession::Gnome;
}
"sway" | "hyprland" | "wlroots" | "cosmic" | "wayfire" | "river" | "niri" => {
return DesktopSession::Other;
}
_ => {}
}
}
}
DesktopSession::Other
}
pub fn detect_display_server() -> DisplayServer {
detect_display_server_from(
std::env::var("XDG_SESSION_TYPE").ok().as_deref(),
std::env::var("DISPLAY").ok().as_deref(),
std::env::var("WAYLAND_DISPLAY").ok().as_deref(),
)
}
fn detect_display_server_from(
xdg_session_type: Option<&str>,
display: Option<&str>,
wayland_display: Option<&str>,
) -> DisplayServer {
let has_x11 = display.is_some_and(|v| !v.is_empty());
let has_wayland = wayland_display.is_some_and(|v| !v.is_empty());
match (has_x11, has_wayland) {
(true, true) => DisplayServer::WaylandWithXwayland,
(true, false) => DisplayServer::X11,
(false, true) => DisplayServer::Wayland,
(false, false) => match xdg_session_type {
Some("x11") => DisplayServer::X11,
Some("wayland") => DisplayServer::Wayland,
_ => DisplayServer::Unknown,
},
}
}
pub fn window_pid_backend_precedence() -> Vec<WindowPidBackend> {
backend_precedence_for(
detect_desktop_session(),
detect_display_server(),
has_dbus_session(),
)
}
fn backend_precedence_for(
session: DesktopSession,
display: DisplayServer,
dbus_available: bool,
) -> Vec<WindowPidBackend> {
let mut out = Vec::with_capacity(3);
match session {
DesktopSession::Kde => {
if dbus_available {
out.push(WindowPidBackend::Kwin);
}
if display.x11_reachable() {
out.push(WindowPidBackend::X11);
}
}
DesktopSession::Gnome => {
if dbus_available {
out.push(WindowPidBackend::GnomeShellEval);
}
if display.x11_reachable() {
out.push(WindowPidBackend::X11);
}
}
DesktopSession::Other => {
if dbus_available {
out.push(WindowPidBackend::Kwin);
out.push(WindowPidBackend::GnomeShellEval);
}
if display.x11_reachable() {
out.push(WindowPidBackend::X11);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_kde_from_xdg_current_desktop() {
assert_eq!(
detect_desktop_session_from(Some("KDE"), None, None),
DesktopSession::Kde
);
assert_eq!(
detect_desktop_session_from(Some("plasma"), None, None),
DesktopSession::Kde
);
}
#[test]
fn detect_gnome_handles_colon_list_and_ubuntu_pop_overrides() {
assert_eq!(
detect_desktop_session_from(Some("ubuntu:GNOME"), None, None),
DesktopSession::Gnome
);
assert_eq!(
detect_desktop_session_from(Some("pop:GNOME"), None, None),
DesktopSession::Gnome
);
}
#[test]
fn detect_other_for_xfce_and_unset() {
assert_eq!(
detect_desktop_session_from(Some("XFCE"), None, None),
DesktopSession::Other
);
assert_eq!(
detect_desktop_session_from(None, None, None),
DesktopSession::Other
);
}
#[test]
fn precedence_kde_session_tries_kwin_first_then_x11() {
assert_eq!(
backend_precedence_for(DesktopSession::Kde, DisplayServer::X11, true),
vec![WindowPidBackend::Kwin, WindowPidBackend::X11]
);
assert_eq!(
backend_precedence_for(DesktopSession::Kde, DisplayServer::Wayland, true),
vec![WindowPidBackend::Kwin]
);
}
#[test]
fn precedence_gnome_session_tries_gnome_shell_first_then_x11() {
assert_eq!(
backend_precedence_for(
DesktopSession::Gnome,
DisplayServer::WaylandWithXwayland,
true
),
vec![WindowPidBackend::GnomeShellEval, WindowPidBackend::X11]
);
}
#[test]
fn precedence_unknown_de_tries_all_three_in_order() {
assert_eq!(
backend_precedence_for(DesktopSession::Other, DisplayServer::X11, true),
vec![
WindowPidBackend::Kwin,
WindowPidBackend::GnomeShellEval,
WindowPidBackend::X11,
]
);
}
#[test]
fn precedence_unknown_de_pure_wayland_skips_x11() {
assert_eq!(
backend_precedence_for(DesktopSession::Other, DisplayServer::Wayland, true),
vec![WindowPidBackend::Kwin, WindowPidBackend::GnomeShellEval]
);
}
#[test]
fn precedence_without_dbus_skips_all_dbus_backends() {
assert_eq!(
backend_precedence_for(DesktopSession::Kde, DisplayServer::X11, false),
vec![WindowPidBackend::X11]
);
assert_eq!(
backend_precedence_for(
DesktopSession::Gnome,
DisplayServer::WaylandWithXwayland,
false
),
vec![WindowPidBackend::X11]
);
assert_eq!(
backend_precedence_for(DesktopSession::Other, DisplayServer::X11, false),
vec![WindowPidBackend::X11]
);
}
#[test]
fn precedence_headless_container_returns_empty_list() {
assert_eq!(
backend_precedence_for(DesktopSession::Other, DisplayServer::Unknown, false),
Vec::<WindowPidBackend>::new()
);
assert_eq!(
backend_precedence_for(DesktopSession::Gnome, DisplayServer::Wayland, false),
Vec::<WindowPidBackend>::new()
);
}
#[test]
fn wlroots_compositors_bucket_as_other() {
for token in [
"sway", "Hyprland", "wlroots", "cosmic", "wayfire", "river", "niri",
] {
assert_eq!(
detect_desktop_session_from(Some(token), None, None),
DesktopSession::Other,
"expected {token} to bucket as Other",
);
}
}
#[test]
fn wlroots_compositor_precedence_skips_kwin_and_gnome_shell_when_no_dbus() {
assert_eq!(
backend_precedence_for(
detect_desktop_session_from(Some("sway"), None, None),
DisplayServer::Wayland,
false,
),
Vec::<WindowPidBackend>::new()
);
assert_eq!(
backend_precedence_for(
detect_desktop_session_from(Some("Hyprland"), None, None),
DisplayServer::WaylandWithXwayland,
true,
),
vec![
WindowPidBackend::Kwin,
WindowPidBackend::GnomeShellEval,
WindowPidBackend::X11,
]
);
}
#[test]
fn dbus_session_detected_via_bus_address() {
assert!(has_dbus_session_from(
Some("unix:path=/run/user/1000/bus"),
None,
|_| false
));
}
#[test]
fn dbus_session_detected_via_xdg_runtime_dir_socket() {
assert!(has_dbus_session_from(
None,
Some("/run/user/1000"),
|path| path == "/run/user/1000/bus"
));
assert!(has_dbus_session_from(
None,
Some("/run/user/1000/"),
|path| path == "/run/user/1000/bus"
));
}
#[test]
fn dbus_session_absent_when_neither_var_set() {
assert!(!has_dbus_session_from(None, None, |_| false));
assert!(!has_dbus_session_from(Some(""), Some(""), |_| true));
}
#[test]
fn dbus_session_absent_when_runtime_dir_has_no_bus_socket() {
assert!(!has_dbus_session_from(None, Some("/tmp/xdg"), |_| false));
}
#[test]
fn display_server_xwayland_counts_as_x11_reachable() {
let ds = detect_display_server_from(Some("wayland"), Some(":0"), Some("wayland-0"));
assert_eq!(ds, DisplayServer::WaylandWithXwayland);
assert!(ds.x11_reachable());
}
#[test]
fn display_server_pure_wayland_blocks_x11() {
let ds = detect_display_server_from(Some("wayland"), None, Some("wayland-0"));
assert!(!ds.x11_reachable());
}
}
@@ -0,0 +1,334 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[cfg(target_os = "linux")]
use std::{collections::HashMap, sync::mpsc, time::Duration};
#[cfg(target_os = "linux")]
use futures_lite::{FutureExt, StreamExt, future};
#[cfg(target_os = "linux")]
use zbus::{
MatchRule, MessageStream,
blocking::{Connection as BlockingConnection, Proxy as BlockingProxy},
message::Type as MessageType,
zvariant::{OwnedObjectPath, Value},
};
#[cfg(target_os = "linux")]
use crate::portal::{REQUEST_INTERFACE, mint_token, request_path};
pub const PORTAL_DESTINATION: &str = "org.freedesktop.portal.Desktop";
pub const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
pub const FILE_CHOOSER_INTERFACE: &str = "org.freedesktop.portal.FileChooser";
#[cfg(target_os = "linux")]
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(5 * 60);
#[cfg(target_os = "linux")]
pub const SIGNAL_POLL_INTERVAL: Duration = Duration::from_millis(200);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Open,
Save,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilterRule {
pub kind: u32,
pub pattern: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Filter {
pub name: String,
pub rules: Vec<FilterRule>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Options {
pub parent_window: String,
pub title: String,
pub accept_label: Option<String>,
pub modal: bool,
pub multiple: bool,
pub directory: bool,
pub current_folder: Option<String>,
pub current_name: Option<String>,
pub current_file: Option<String>,
pub filters: Vec<Filter>,
pub current_filter: Option<Filter>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileChooserResult {
pub cancelled: bool,
pub uris: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileChooserError {
DbusError,
PortalTimeout,
InvalidReply,
SendFailed,
}
impl std::fmt::Display for FileChooserError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::DbusError => "DbusError",
Self::PortalTimeout => "PortalTimeout",
Self::InvalidReply => "InvalidReply",
Self::SendFailed => "SendFailed",
};
f.write_str(s)
}
}
#[cfg(target_os = "linux")]
pub fn invoke(mode: Mode, options: Options) -> Result<FileChooserResult, FileChooserError> {
let conn = zbus::blocking::connection::Builder::session()
.map_err(|_| FileChooserError::DbusError)?
.method_timeout(Duration::from_secs(30))
.build()
.map_err(|_| FileChooserError::DbusError)?;
let unique_owned = conn
.unique_name()
.ok_or(FileChooserError::DbusError)?
.to_owned();
let unique_name = unique_owned.as_str().to_string();
let token_prefix = match mode {
Mode::Open => "fluxer_fc_open",
Mode::Save => "fluxer_fc_save",
};
let handle_token = mint_token(token_prefix);
let expected_path = request_path(&unique_name, &handle_token);
let (tx, rx) = mpsc::sync_channel::<FileChooserResponse>(1);
let stop_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop_for_thread = stop_flag.clone();
let expected_for_thread = expected_path.clone();
let listener = std::thread::Builder::new()
.name("fluxer-linux-portals-fc".to_string())
.spawn(move || {
response_listener(&expected_for_thread, tx, stop_for_thread);
})
.map_err(|_| FileChooserError::DbusError)?;
let send_result = send_call(&conn, mode, &handle_token, &options, &expected_path);
let result = match send_result {
Ok(()) => match rx.recv_timeout(REQUEST_TIMEOUT) {
Ok(response) => {
if response.code != 0 {
Ok(FileChooserResult {
cancelled: true,
uris: vec![],
})
} else {
Ok(FileChooserResult {
cancelled: false,
uris: response.uris,
})
}
}
Err(_) => Err(FileChooserError::PortalTimeout),
},
Err(err) => Err(err),
};
stop_flag.store(true, std::sync::atomic::Ordering::Release);
let _ = listener.join();
result
}
#[cfg(target_os = "linux")]
struct FileChooserResponse {
code: u32,
uris: Vec<String>,
}
#[cfg(target_os = "linux")]
fn response_listener(
expected_path: &str,
tx: mpsc::SyncSender<FileChooserResponse>,
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
) {
let setup = future::block_on(async {
let conn = zbus::Connection::session().await?;
let rule = MatchRule::builder()
.msg_type(MessageType::Signal)
.interface(REQUEST_INTERFACE)?
.member("Response")?
.path(expected_path.to_string())?
.build();
let stream = MessageStream::for_match_rule(rule, &conn, Some(8)).await?;
zbus::Result::Ok((conn, stream))
});
let (_conn, mut stream) = match setup {
Ok(parts) => parts,
Err(_) => return,
};
while !stop.load(std::sync::atomic::Ordering::Acquire) {
let timeout = async {
async_io::Timer::after(SIGNAL_POLL_INTERVAL).await;
None::<zbus::Result<zbus::Message>>
};
match future::block_on(stream.next().or(timeout)) {
Some(Ok(message)) => {
if let Some(parsed) = parse_filechooser_response(&message) {
let _ = tx.send(parsed);
return;
}
}
Some(Err(_)) => return,
None => {}
}
}
}
#[cfg(target_os = "linux")]
fn parse_filechooser_response(message: &zbus::Message) -> Option<FileChooserResponse> {
let body = message.body();
let (code, results): (u32, HashMap<String, zbus::zvariant::OwnedValue>) =
body.deserialize().ok()?;
let mut uris: Vec<String> = Vec::new();
if let Some(v) = results.get("uris") {
let val = crate::kwin::value_of_owned(v);
if let Value::Array(arr) = val {
for element in arr.iter() {
let inner: &Value<'_> = match element {
Value::Value(b) => b.as_ref(),
other => other,
};
if let Value::Str(s) = inner {
uris.push(s.as_str().to_string());
}
}
}
}
Some(FileChooserResponse { code, uris })
}
#[cfg(target_os = "linux")]
fn send_call(
conn: &BlockingConnection,
mode: Mode,
handle_token: &str,
options: &Options,
expected_path: &str,
) -> Result<(), FileChooserError> {
let proxy = BlockingProxy::new(
conn,
PORTAL_DESTINATION,
PORTAL_PATH,
FILE_CHOOSER_INTERFACE,
)
.map_err(|_| FileChooserError::DbusError)?;
let member = match mode {
Mode::Open => "OpenFile",
Mode::Save => "SaveFile",
};
let mut vardict: HashMap<&str, Value<'_>> = HashMap::new();
vardict.insert("handle_token", Value::new(handle_token));
vardict.insert("modal", Value::new(options.modal));
vardict.insert("multiple", Value::new(options.multiple));
if matches!(mode, Mode::Open) && options.directory {
vardict.insert("directory", Value::new(true));
}
if let Some(label) = options.accept_label.as_deref() {
vardict.insert("accept_label", Value::new(label));
}
if !options.filters.is_empty() {
vardict.insert("filters", Value::new(serialize_filters(&options.filters)));
}
if let Some(cf) = options.current_filter.as_ref() {
vardict.insert("current_filter", Value::new(serialize_filter(cf)));
}
if let Some(folder) = options.current_folder.as_deref() {
vardict.insert("current_folder", Value::new(folder.as_bytes()));
}
if matches!(mode, Mode::Save)
&& let Some(name) = options.current_name.as_deref()
{
vardict.insert("current_name", Value::new(name));
}
if matches!(mode, Mode::Save)
&& let Some(file) = options.current_file.as_deref()
{
vardict.insert("current_file", Value::new(file.as_bytes()));
}
let reply_path: OwnedObjectPath = proxy
.call(
member,
&(
options.parent_window.as_str(),
options.title.as_str(),
vardict,
),
)
.map_err(|_| FileChooserError::SendFailed)?;
if !reply_path.as_str().is_empty() && reply_path.as_str() != expected_path {
return Err(FileChooserError::InvalidReply);
}
Ok(())
}
#[cfg(target_os = "linux")]
fn serialize_filters(filters: &[Filter]) -> Vec<(&str, Vec<(u32, &str)>)> {
filters
.iter()
.map(|f| {
let rules: Vec<(u32, &str)> = f
.rules
.iter()
.map(|r| (r.kind, r.pattern.as_str()))
.collect();
(f.name.as_str(), rules)
})
.collect()
}
#[cfg(target_os = "linux")]
fn serialize_filter(filter: &Filter) -> (&str, Vec<(u32, &str)>) {
let rules: Vec<(u32, &str)> = filter
.rules
.iter()
.map(|r| (r.kind, r.pattern.as_str()))
.collect();
(filter.name.as_str(), rules)
}
#[cfg(not(target_os = "linux"))]
pub fn invoke(_mode: Mode, _options: Options) -> Result<FileChooserResult, FileChooserError> {
Err(FileChooserError::DbusError)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn options_default_is_open_safe() {
let opts = Options::default();
assert!(!opts.directory);
assert!(!opts.multiple);
assert!(opts.parent_window.is_empty());
}
#[test]
fn filter_rule_kinds_match_typescript_union() {
let glob = FilterRule {
kind: 0,
pattern: "*.png".into(),
};
let mime = FilterRule {
kind: 1,
pattern: "image/png".into(),
};
assert_eq!(glob.kind, 0);
assert_eq!(mime.kind, 1);
}
}
@@ -0,0 +1,646 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[cfg(target_os = "linux")]
use std::{
collections::{HashMap, HashSet},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread::{self, JoinHandle},
time::Duration,
};
#[cfg(target_os = "linux")]
use futures_lite::{FutureExt, StreamExt, future};
#[cfg(target_os = "linux")]
use zbus::{
MatchRule, MessageStream, Proxy,
message::Type as MessageType,
zvariant::{OwnedObjectPath, OwnedValue, Value},
};
#[cfg(target_os = "linux")]
use crate::portal::{REQUEST_INTERFACE, mint_token, request_path};
pub const PORTAL_DESTINATION: &str = "org.freedesktop.portal.Desktop";
pub const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
pub const GLOBAL_SHORTCUTS_INTERFACE: &str = "org.freedesktop.portal.GlobalShortcuts";
pub const SESSION_INTERFACE: &str = "org.freedesktop.portal.Session";
#[cfg(target_os = "linux")]
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(5 * 60);
#[cfg(target_os = "linux")]
pub const SIGNAL_POLL_INTERVAL: Duration = Duration::from_millis(200);
#[cfg(target_os = "linux")]
pub const SIGNAL_THREAD_START_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(target_os = "linux")]
type ShortcutProperties = HashMap<String, OwnedValue>;
#[cfg(target_os = "linux")]
type ShortcutsChangedBody = (OwnedObjectPath, Vec<(String, ShortcutProperties)>);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShortcutEntry {
pub id: String,
pub description: String,
pub preferred_trigger: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoundShortcut {
pub id: String,
pub description: Option<String>,
pub trigger_description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigureResult {
pub action: String,
pub shortcuts: Vec<BoundShortcut>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShortcutEvent {
Activated { id: String },
Deactivated { id: String },
ShortcutsChanged { shortcuts: Vec<BoundShortcut> },
Closed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GlobalShortcutsError {
DbusError,
PortalTimeout,
InvalidReply,
SendFailed,
Cancelled,
ThreadStartFailed,
LockPoisoned,
}
impl std::fmt::Display for GlobalShortcutsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::DbusError => "DbusError",
Self::PortalTimeout => "PortalTimeout",
Self::InvalidReply => "InvalidReply",
Self::SendFailed => "SendFailed",
Self::Cancelled => "Cancelled",
Self::ThreadStartFailed => "ThreadStartFailed",
Self::LockPoisoned => "LockPoisoned",
};
f.write_str(s)
}
}
#[cfg(target_os = "linux")]
type ShortcutCallback = Arc<dyn Fn(ShortcutEvent) + Send + Sync + 'static>;
#[cfg(target_os = "linux")]
pub struct Subscription {
stop_flag: Arc<AtomicBool>,
thread: Mutex<Option<JoinHandle<()>>>,
}
#[cfg(target_os = "linux")]
impl Subscription {
pub fn configure(
entries: Vec<ShortcutEntry>,
callback: ShortcutCallback,
) -> Result<(Self, ConfigureResult), GlobalShortcutsError> {
let stop_flag = Arc::new(AtomicBool::new(false));
let stop_for_thread = stop_flag.clone();
let (ready_tx, ready_rx) =
mpsc::sync_channel::<Result<ConfigureReady, GlobalShortcutsError>>(1);
let thread = thread::Builder::new()
.name("fluxer-linux-portals-global-shortcuts".to_string())
.spawn(move || {
let setup = future::block_on(async {
let conn = zbus::Connection::session()
.await
.map_err(|_| GlobalShortcutsError::DbusError)?;
let rule = MatchRule::builder().msg_type(MessageType::Signal).build();
let stream = MessageStream::for_match_rule(rule, &conn, Some(64))
.await
.map_err(|_| GlobalShortcutsError::DbusError)?;
let ready = configure_session(&conn, &entries).await?;
Ok::<_, GlobalShortcutsError>((conn, stream, ready))
});
let (conn, mut stream, ready) = match setup {
Ok(parts) => parts,
Err(err) => {
let _ = ready_tx.send(Err(err));
return;
}
};
let session_handle = ready.session_handle.clone();
if ready_tx.send(Ok(ready)).is_err() {
let _ = future::block_on(close_session(&conn, &session_handle));
return;
}
while !stop_for_thread.load(Ordering::Acquire) {
let timeout = async {
async_io::Timer::after(SIGNAL_POLL_INTERVAL).await;
None::<zbus::Result<zbus::Message>>
};
match future::block_on(stream.next().or(timeout)) {
Some(Ok(message)) => {
if let Some(event) = parse_signal(&message, &session_handle) {
let closed = matches!(event, ShortcutEvent::Closed);
callback(event);
if closed {
return;
}
}
}
Some(Err(_)) => break,
None => {}
}
}
let _ = future::block_on(close_session(&conn, &session_handle));
})
.map_err(|_| GlobalShortcutsError::ThreadStartFailed)?;
match ready_rx.recv_timeout(SIGNAL_THREAD_START_TIMEOUT) {
Ok(Ok(ready)) => Ok((
Self {
stop_flag,
thread: Mutex::new(Some(thread)),
},
ready.result,
)),
Ok(Err(err)) => {
let _ = thread.join();
Err(err)
}
Err(err) => {
stop_flag.store(true, Ordering::Release);
if matches!(err, mpsc::RecvTimeoutError::Disconnected) {
let _ = thread.join();
}
Err(GlobalShortcutsError::ThreadStartFailed)
}
}
}
pub fn close(&self) {
self.stop_flag.store(true, Ordering::Release);
if let Ok(mut thread) = self.thread.lock()
&& let Some(t) = thread.take()
{
let _ = t.join();
}
}
}
#[cfg(target_os = "linux")]
impl Drop for Subscription {
fn drop(&mut self) {
self.close();
}
}
#[cfg(target_os = "linux")]
struct ConfigureReady {
session_handle: String,
result: ConfigureResult,
}
#[cfg(target_os = "linux")]
struct PortalResponse {
code: u32,
results: HashMap<String, OwnedValue>,
}
#[cfg(target_os = "linux")]
async fn configure_session(
conn: &zbus::Connection,
entries: &[ShortcutEntry],
) -> Result<ConfigureReady, GlobalShortcutsError> {
let session_handle = create_session(conn).await?;
let persisted = list_shortcuts(conn, &session_handle)
.await
.unwrap_or_default();
if shortcut_ids_match(entries, &persisted) {
return Ok(ConfigureReady {
session_handle,
result: ConfigureResult {
action: "listed".to_string(),
shortcuts: persisted,
},
});
}
let bound = bind_shortcuts(conn, &session_handle, entries).await?;
Ok(ConfigureReady {
session_handle,
result: bound,
})
}
#[cfg(target_os = "linux")]
fn shortcut_ids_match(entries: &[ShortcutEntry], persisted: &[BoundShortcut]) -> bool {
if entries.is_empty() || persisted.is_empty() {
return false;
}
let requested: HashSet<&str> = entries.iter().map(|entry| entry.id.as_str()).collect();
let existing: HashSet<&str> = persisted
.iter()
.map(|shortcut| shortcut.id.as_str())
.collect();
requested == existing
}
#[cfg(target_os = "linux")]
async fn create_session(conn: &zbus::Connection) -> Result<String, GlobalShortcutsError> {
let handle_token = mint_token("fluxer_gs_create");
let session_handle_token = mint_token("fluxer_gs_session");
let mut stream = request_stream(conn, &handle_token).await?;
let proxy = global_shortcuts_proxy(conn).await?;
let mut options: HashMap<&str, Value<'_>> = HashMap::new();
options.insert("handle_token", Value::new(handle_token.as_str()));
options.insert(
"session_handle_token",
Value::new(session_handle_token.as_str()),
);
let _reply_path: OwnedObjectPath = proxy
.call("CreateSession", &(options,))
.await
.map_err(|_| GlobalShortcutsError::SendFailed)?;
let response = wait_for_response(&mut stream).await?;
if response.code != 0 {
return Err(GlobalShortcutsError::Cancelled);
}
response
.results
.get("session_handle")
.and_then(|value| string_or_object_path(crate::kwin::value_of_owned(value)))
.ok_or(GlobalShortcutsError::InvalidReply)
}
#[cfg(target_os = "linux")]
async fn list_shortcuts(
conn: &zbus::Connection,
session_handle: &str,
) -> Result<Vec<BoundShortcut>, GlobalShortcutsError> {
let session_path = owned_path(session_handle)?;
let handle_token = mint_token("fluxer_gs_list");
let mut stream = request_stream(conn, &handle_token).await?;
let proxy = global_shortcuts_proxy(conn).await?;
let mut options: HashMap<&str, Value<'_>> = HashMap::new();
options.insert("handle_token", Value::new(handle_token.as_str()));
let _reply_path: OwnedObjectPath = proxy
.call("ListShortcuts", &(&session_path, options))
.await
.map_err(|_| GlobalShortcutsError::SendFailed)?;
let response = wait_for_response(&mut stream).await?;
if response.code != 0 {
return Err(GlobalShortcutsError::Cancelled);
}
Ok(shortcuts_from_results(&response.results))
}
#[cfg(target_os = "linux")]
async fn bind_shortcuts(
conn: &zbus::Connection,
session_handle: &str,
entries: &[ShortcutEntry],
) -> Result<ConfigureResult, GlobalShortcutsError> {
let session_path = owned_path(session_handle)?;
let handle_token = mint_token("fluxer_gs_bind");
let mut stream = request_stream(conn, &handle_token).await?;
let proxy = global_shortcuts_proxy(conn).await?;
let shortcuts = serialize_shortcuts(entries);
let mut options: HashMap<&str, Value<'_>> = HashMap::new();
options.insert("handle_token", Value::new(handle_token.as_str()));
let _reply_path: OwnedObjectPath = proxy
.call("BindShortcuts", &(&session_path, shortcuts, "", options))
.await
.map_err(|_| GlobalShortcutsError::SendFailed)?;
let response = wait_for_response(&mut stream).await?;
if response.code != 0 {
return Ok(ConfigureResult {
action: "cancelled".to_string(),
shortcuts: Vec::new(),
});
}
Ok(ConfigureResult {
action: "bound".to_string(),
shortcuts: shortcuts_from_results(&response.results),
})
}
#[cfg(target_os = "linux")]
async fn global_shortcuts_proxy(
conn: &zbus::Connection,
) -> Result<Proxy<'_>, GlobalShortcutsError> {
Proxy::new(
conn,
PORTAL_DESTINATION,
PORTAL_PATH,
GLOBAL_SHORTCUTS_INTERFACE,
)
.await
.map_err(|_| GlobalShortcutsError::DbusError)
}
async fn request_stream(
conn: &zbus::Connection,
handle_token: &str,
) -> Result<MessageStream, GlobalShortcutsError> {
let unique_owned = conn
.unique_name()
.ok_or(GlobalShortcutsError::DbusError)?
.to_owned();
let unique_name = unique_owned.as_str().to_string();
let expected_path = request_path(&unique_name, handle_token);
let rule = MatchRule::builder()
.msg_type(MessageType::Signal)
.interface(REQUEST_INTERFACE)
.map_err(|_| GlobalShortcutsError::DbusError)?
.member("Response")
.map_err(|_| GlobalShortcutsError::DbusError)?
.path(expected_path.clone())
.map_err(|_| GlobalShortcutsError::DbusError)?
.build();
MessageStream::for_match_rule(rule, conn, Some(8))
.await
.map_err(|_| GlobalShortcutsError::DbusError)
}
async fn wait_for_response(
stream: &mut MessageStream,
) -> Result<PortalResponse, GlobalShortcutsError> {
loop {
let timeout = async {
async_io::Timer::after(REQUEST_TIMEOUT).await;
None::<zbus::Result<zbus::Message>>
};
match stream.next().or(timeout).await {
Some(Ok(message)) => {
if let Some(parsed) = parse_request_response(&message) {
return Ok(parsed);
}
}
Some(Err(_)) => return Err(GlobalShortcutsError::DbusError),
None => return Err(GlobalShortcutsError::PortalTimeout),
}
}
}
#[cfg(target_os = "linux")]
fn parse_request_response(message: &zbus::Message) -> Option<PortalResponse> {
let body = message.body();
let (code, results): (u32, HashMap<String, OwnedValue>) = body.deserialize().ok()?;
Some(PortalResponse { code, results })
}
#[cfg(target_os = "linux")]
fn serialize_shortcuts(entries: &[ShortcutEntry]) -> Vec<(&str, HashMap<&str, Value<'_>>)> {
entries
.iter()
.map(|entry| {
let mut options: HashMap<&str, Value<'_>> = HashMap::new();
options.insert("description", Value::new(entry.description.as_str()));
if let Some(trigger) = entry.preferred_trigger.as_deref() {
options.insert("preferred_trigger", Value::new(trigger));
}
(entry.id.as_str(), options)
})
.collect()
}
#[cfg(target_os = "linux")]
fn shortcuts_from_results(results: &HashMap<String, OwnedValue>) -> Vec<BoundShortcut> {
results
.get("shortcuts")
.and_then(|value| shortcuts_from_value(crate::kwin::value_of_owned(value)))
.unwrap_or_default()
}
#[cfg(target_os = "linux")]
fn shortcuts_from_value(value: &Value<'_>) -> Option<Vec<BoundShortcut>> {
let inner = unbox_value(value);
let Value::Array(array) = inner else {
return None;
};
let mut shortcuts = Vec::new();
for value in array.inner() {
if let Some(shortcut) = bound_shortcut_from_value(value) {
shortcuts.push(shortcut);
}
}
Some(shortcuts)
}
#[cfg(target_os = "linux")]
fn bound_shortcut_from_value(value: &Value<'_>) -> Option<BoundShortcut> {
let Value::Structure(structure) = unbox_value(value) else {
return None;
};
let fields = structure.fields();
if fields.len() < 2 {
return None;
}
let id = string_or_object_path(&fields[0])?;
let dict = match unbox_value(&fields[1]) {
Value::Dict(dict) => Some(dict),
_ => None,
};
Some(BoundShortcut {
id,
description: dict.and_then(|d| dict_string(d, "description")),
trigger_description: dict.and_then(|d| dict_string(d, "trigger_description")),
})
}
#[cfg(target_os = "linux")]
fn dict_string(dict: &zbus::zvariant::Dict<'_, '_>, key: &str) -> Option<String> {
dict.iter().find_map(|(k, v)| {
if string_or_object_path(k).as_deref() == Some(key) {
string_or_object_path(unbox_value(v))
} else {
None
}
})
}
#[cfg(target_os = "linux")]
fn parse_signal(message: &zbus::Message, session_handle: &str) -> Option<ShortcutEvent> {
let header = message.header();
let interface = header.interface()?.as_str();
let member = header.member()?.as_str();
match (interface, member) {
(GLOBAL_SHORTCUTS_INTERFACE, "Activated") => {
let (session, id, _timestamp, _options): (
OwnedObjectPath,
String,
u64,
HashMap<String, OwnedValue>,
) = message.body().deserialize().ok()?;
if session.as_str() == session_handle {
Some(ShortcutEvent::Activated { id })
} else {
None
}
}
(GLOBAL_SHORTCUTS_INTERFACE, "Deactivated") => {
let (session, id, _timestamp, _options): (
OwnedObjectPath,
String,
u64,
HashMap<String, OwnedValue>,
) = message.body().deserialize().ok()?;
if session.as_str() == session_handle {
Some(ShortcutEvent::Deactivated { id })
} else {
None
}
}
(GLOBAL_SHORTCUTS_INTERFACE, "ShortcutsChanged") => {
let (session, shortcuts): ShortcutsChangedBody = message.body().deserialize().ok()?;
if session.as_str() != session_handle {
return None;
}
Some(ShortcutEvent::ShortcutsChanged {
shortcuts: shortcuts
.into_iter()
.map(|(id, properties)| bound_shortcut_from_parts(id, &properties))
.collect(),
})
}
(SESSION_INTERFACE, "Closed") => {
if header.path()?.as_str() == session_handle {
Some(ShortcutEvent::Closed)
} else {
None
}
}
_ => None,
}
}
#[cfg(target_os = "linux")]
fn bound_shortcut_from_parts(
id: String,
properties: &HashMap<String, OwnedValue>,
) -> BoundShortcut {
BoundShortcut {
id,
description: properties
.get("description")
.and_then(|value| string_or_object_path(crate::kwin::value_of_owned(value))),
trigger_description: properties
.get("trigger_description")
.and_then(|value| string_or_object_path(crate::kwin::value_of_owned(value))),
}
}
#[cfg(target_os = "linux")]
async fn close_session(
conn: &zbus::Connection,
session_handle: &str,
) -> Result<(), GlobalShortcutsError> {
let proxy = Proxy::new(conn, PORTAL_DESTINATION, session_handle, SESSION_INTERFACE)
.await
.map_err(|_| GlobalShortcutsError::DbusError)?;
proxy
.call::<_, _, ()>("Close", &())
.await
.map_err(|_| GlobalShortcutsError::SendFailed)
}
#[cfg(target_os = "linux")]
fn owned_path(path: &str) -> Result<OwnedObjectPath, GlobalShortcutsError> {
OwnedObjectPath::try_from(path.to_string()).map_err(|_| GlobalShortcutsError::InvalidReply)
}
#[cfg(target_os = "linux")]
fn unbox_value<'a>(value: &'a Value<'a>) -> &'a Value<'a> {
match value {
Value::Value(inner) => unbox_value(inner),
other => other,
}
}
#[cfg(target_os = "linux")]
fn string_or_object_path(value: &Value<'_>) -> Option<String> {
match unbox_value(value) {
Value::Str(v) => Some(v.as_str().to_string()),
Value::ObjectPath(v) => Some(v.as_str().to_string()),
_ => None,
}
}
#[cfg(target_os = "linux")]
pub fn get_portal_version() -> Option<u32> {
let conn = zbus::blocking::connection::Builder::session()
.ok()?
.method_timeout(Duration::from_millis(1_500))
.build()
.ok()?;
let proxy = zbus::blocking::Proxy::new(
&conn,
PORTAL_DESTINATION,
PORTAL_PATH,
GLOBAL_SHORTCUTS_INTERFACE,
)
.ok()?;
proxy.get_property("version").ok()
}
#[cfg(target_os = "linux")]
pub fn is_available() -> bool {
get_portal_version().is_some()
}
#[cfg(not(target_os = "linux"))]
pub struct Subscription;
#[cfg(not(target_os = "linux"))]
impl Subscription {
pub fn configure(
_entries: Vec<ShortcutEntry>,
_callback: Arc<dyn Fn(ShortcutEvent) + Send + Sync + 'static>,
) -> Result<(Self, ConfigureResult), GlobalShortcutsError> {
Err(GlobalShortcutsError::DbusError)
}
pub fn close(&self) {}
}
#[cfg(not(target_os = "linux"))]
pub fn get_portal_version() -> Option<u32> {
None
}
#[cfg(not(target_os = "linux"))]
pub fn is_available() -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shortcut_id_match_requires_same_ids() {
let entries = vec![ShortcutEntry {
id: "one".into(),
description: "One".into(),
preferred_trigger: Some("CTRL+o".into()),
}];
let persisted = vec![BoundShortcut {
id: "one".into(),
description: None,
trigger_description: None,
}];
assert!(shortcut_ids_match(&entries, &persisted));
}
#[test]
fn shortcut_id_match_rejects_empty() {
assert!(!shortcut_ids_match(&[], &[]));
}
}
@@ -0,0 +1,153 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[cfg(target_os = "linux")]
use std::time::Duration;
#[cfg(target_os = "linux")]
pub const GNOME_SHELL_DESTINATION: &str = "org.gnome.Shell";
#[cfg(target_os = "linux")]
pub const GNOME_SHELL_PATH: &str = "/org/gnome/Shell";
#[cfg(target_os = "linux")]
pub const GNOME_SHELL_INTERFACE: &str = "org.gnome.Shell";
#[cfg(target_os = "linux")]
pub const GNOME_SHELL_EVAL_TIMEOUT: Duration = Duration::from_millis(1_500);
pub fn is_safe_shell_eval_token(token: &str) -> bool {
if token.is_empty() || token.len() > 128 {
return false;
}
token
.bytes()
.all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
}
pub fn is_gnome_eval_disabled_via_env() -> bool {
match std::env::var("FLUXER_PORTALS_GNOME_EVAL") {
Ok(v) => {
let lower = v.to_ascii_lowercase();
lower == "0" || lower == "false" || lower == "no"
}
Err(_) => false,
}
}
pub fn parse_shell_eval_pid_payload(payload: &str) -> Option<u32> {
let trimmed = payload.trim();
if trimmed.is_empty() {
return None;
}
let stripped = trimmed
.trim_start_matches(['[', ' ', '\t'])
.trim_end_matches([']', ' ', '\t', ','])
.trim();
if stripped.is_empty() {
return None;
}
let mut end = 0;
for (i, ch) in stripped.char_indices() {
if ch.is_ascii_digit() {
end = i + ch.len_utf8();
} else {
break;
}
}
if end == 0 {
return None;
}
let number: u64 = stripped[..end].parse().ok()?;
if number == 0 || number > u32::MAX as u64 {
return None;
}
Some(number as u32)
}
pub fn build_window_pid_script(token: &str) -> String {
format!(
"global.get_window_actors().map(a=>a.meta_window).filter(w=>w.get_id&&w.get_id().toString()===\"{token}\").map(w=>w.get_pid())[0]"
)
}
#[cfg(target_os = "linux")]
pub fn resolve_gnome_shell_window_pid(token: &str) -> Result<Option<u32>, String> {
if is_gnome_eval_disabled_via_env() {
return Ok(None);
}
if !is_safe_shell_eval_token(token) {
return Err("resolveWindowPid: token failed validation".into());
}
let conn = zbus::blocking::connection::Builder::session()
.map_err(|err| format!("openSessionBus failed: {err}"))?
.method_timeout(GNOME_SHELL_EVAL_TIMEOUT)
.build()
.map_err(|err| format!("openSessionBus failed: {err}"))?;
let proxy = zbus::blocking::Proxy::new(
&conn,
GNOME_SHELL_DESTINATION,
GNOME_SHELL_PATH,
GNOME_SHELL_INTERFACE,
)
.map_err(|err| format!("shell Eval failed: {err}"))?;
let script = build_window_pid_script(token);
let (success, payload): (bool, String) = proxy
.call("Eval", &(script.as_str(),))
.map_err(|err| format!("shell Eval failed: {err}"))?;
if !success {
return Ok(None);
}
Ok(parse_shell_eval_pid_payload(&payload))
}
#[cfg(not(target_os = "linux"))]
pub fn resolve_gnome_shell_window_pid(_token: &str) -> Result<Option<u32>, String> {
Err("not supported on this platform".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_safe_shell_eval_token_accepts_simple() {
assert!(is_safe_shell_eval_token("abc"));
assert!(is_safe_shell_eval_token("Window_123"));
}
#[test]
fn is_safe_shell_eval_token_rejects_metacharacters() {
assert!(!is_safe_shell_eval_token(""));
assert!(!is_safe_shell_eval_token("\"; system('rm -rf'); \""));
assert!(!is_safe_shell_eval_token("abc def"));
assert!(!is_safe_shell_eval_token("abc-def"));
}
#[test]
fn is_safe_shell_eval_token_rejects_overlong() {
assert!(!is_safe_shell_eval_token(&"a".repeat(129)));
assert!(is_safe_shell_eval_token(&"a".repeat(128)));
}
#[test]
fn parse_shell_eval_pid_payload_plain_integer() {
assert_eq!(parse_shell_eval_pid_payload("12345"), Some(12345));
}
#[test]
fn parse_shell_eval_pid_payload_array_wrapped() {
assert_eq!(parse_shell_eval_pid_payload("[12345]"), Some(12345));
assert_eq!(parse_shell_eval_pid_payload("[ 12345 ]"), Some(12345));
}
#[test]
fn parse_shell_eval_pid_payload_rejects_garbage() {
assert_eq!(parse_shell_eval_pid_payload(""), None);
assert_eq!(parse_shell_eval_pid_payload("undefined"), None);
assert_eq!(parse_shell_eval_pid_payload("0"), None);
}
#[test]
fn build_window_pid_script_splices_token() {
let s = build_window_pid_script("Window_42");
assert!(s.contains("===\"Window_42\""));
assert!(s.contains("global.get_window_actors()"));
}
}
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[cfg(target_os = "linux")]
use std::time::Duration;
#[cfg(target_os = "linux")]
use zbus::{
blocking::{Connection, Proxy},
zvariant::{OwnedValue, Value},
};
#[cfg(target_os = "linux")]
pub(crate) fn value_of_owned(value: &OwnedValue) -> &Value<'_> {
use std::ops::Deref as _;
value.deref()
}
pub const KWIN_DESTINATION: &str = "org.kde.KWin";
pub const KWIN_WINDOW_INTERFACE: &str = "org.kde.KWin.Window";
pub const PROPERTIES_INTERFACE: &str = "org.freedesktop.DBus.Properties";
#[cfg(target_os = "linux")]
pub const REQUEST_TIMEOUT: Duration = Duration::from_millis(1_500);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolveError {
InvalidToken,
DbusOpenFailed,
DbusCallFailed,
}
impl std::fmt::Display for ResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::InvalidToken => "InvalidToken",
Self::DbusOpenFailed => "DbusOpenFailed",
Self::DbusCallFailed => "DbusCallFailed",
};
f.write_str(s)
}
}
pub fn is_safe_kwin_path_segment(token: &str) -> bool {
if token.is_empty() {
return false;
}
token
.bytes()
.all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
}
pub fn build_kwin_window_path(token: &str) -> String {
let mut out = String::with_capacity("/org/kde/KWin/Window/".len() + token.len());
out.push_str("/org/kde/KWin/Window/");
out.push_str(token);
out
}
#[cfg(target_os = "linux")]
pub fn resolve_kwin_window_pid(token: &str) -> Result<Option<u32>, ResolveError> {
if !is_safe_kwin_path_segment(token) {
return Err(ResolveError::InvalidToken);
}
let conn = zbus::blocking::connection::Builder::session()
.map_err(|_| ResolveError::DbusOpenFailed)?
.method_timeout(REQUEST_TIMEOUT)
.build()
.map_err(|_| ResolveError::DbusOpenFailed)?;
Ok(resolve_kwin_window_pid_on(&conn, token))
}
#[cfg(target_os = "linux")]
fn resolve_kwin_window_pid_on(conn: &Connection, token: &str) -> Option<u32> {
let path = build_kwin_window_path(token);
let proxy = Proxy::new(conn, KWIN_DESTINATION, path.as_str(), PROPERTIES_INTERFACE).ok()?;
let reply: OwnedValue = proxy.call("Get", &(KWIN_WINDOW_INTERFACE, "pid")).ok()?;
integer_from_variant(value_of_owned(&reply))
}
#[cfg(target_os = "linux")]
pub(crate) fn integer_from_variant(value: &Value<'_>) -> Option<u32> {
let raw: i64 = match value {
Value::U8(v) => *v as i64,
Value::U16(v) => *v as i64,
Value::U32(v) => *v as i64,
Value::U64(v) => *v as i64,
Value::I16(v) => *v as i64,
Value::I32(v) => *v as i64,
Value::I64(v) => *v,
Value::Value(inner) => return integer_from_variant(inner),
_ => return None,
};
if raw <= 0 || raw > u32::MAX as i64 {
return None;
}
Some(raw as u32)
}
#[cfg(not(target_os = "linux"))]
pub fn resolve_kwin_window_pid(_token: &str) -> Result<Option<u32>, ResolveError> {
Err(ResolveError::DbusOpenFailed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_safe_kwin_path_segment_accepts_plain_alnum_underscore() {
assert!(is_safe_kwin_path_segment("abc"));
assert!(is_safe_kwin_path_segment("123"));
assert!(is_safe_kwin_path_segment("aZ_9"));
}
#[test]
fn is_safe_kwin_path_segment_rejects_empty() {
assert!(!is_safe_kwin_path_segment(""));
}
#[test]
fn is_safe_kwin_path_segment_rejects_traversal_and_shell_meta() {
assert!(!is_safe_kwin_path_segment("../etc"));
assert!(!is_safe_kwin_path_segment("a/b"));
assert!(!is_safe_kwin_path_segment("$(rm -rf)"));
assert!(!is_safe_kwin_path_segment("a;b"));
assert!(!is_safe_kwin_path_segment("a-b"));
assert!(!is_safe_kwin_path_segment("a.b"));
}
#[test]
fn build_kwin_window_path_shapes_path_correctly() {
assert_eq!(
build_kwin_window_path("abc123"),
"/org/kde/KWin/Window/abc123"
);
}
}
@@ -0,0 +1,712 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod background;
pub mod env;
pub mod filechooser;
pub mod global_shortcuts;
pub mod gnome_shell;
pub mod kwin;
#[cfg(target_os = "linux")]
pub mod portal;
pub mod settings;
pub mod x11;
#[cfg(target_os = "linux")]
pub use napi_bindings::*;
#[cfg(target_os = "linux")]
mod napi_bindings {
use std::sync::Arc;
use napi::{
Env, Status,
bindgen_prelude::{Array, AsyncTask, Function, Object, Result, Task, ToNapiValue},
sys,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue},
};
use napi_derive::napi;
use crate::{
background::{self, RequestOptions, RequestResult},
filechooser::{self, FileChooserResult, Filter, FilterRule, Mode, Options},
global_shortcuts::{self, BoundShortcut, ConfigureResult, ShortcutEntry, ShortcutEvent},
gnome_shell, kwin,
settings::{self, ChangeEvent, ChangePayload, ColorScheme, Contrast},
x11,
};
const SETTINGS_EVENT_QUEUE_LIMIT: usize = 128;
const SHORTCUT_EVENT_QUEUE_LIMIT: usize = 128;
fn generic_error(reason: impl Into<String>) -> napi::Error {
napi::Error::new(Status::GenericFailure, reason.into())
}
fn invalid_arg(reason: impl Into<String>) -> napi::Error {
napi::Error::new(Status::InvalidArg, reason.into())
}
fn read_string_field(object: &Object, key: &str) -> Option<String> {
object.get::<String>(key).ok().flatten()
}
fn read_string_field_or_empty(object: &Object, key: &str) -> String {
read_string_field(object, key).unwrap_or_default()
}
fn read_bool_field(object: &Object, key: &str) -> Option<bool> {
object.get::<bool>(key).ok().flatten()
}
fn read_object_field<'a>(object: &Object<'a>, key: &str) -> Option<Object<'a>> {
object.get::<Object>(key).ok().flatten()
}
fn read_array_field<'a>(object: &Object<'a>, key: &str) -> Option<Array<'a>> {
object.get::<Array>(key).ok().flatten()
}
pub struct ResolveKwinTask {
token: String,
}
impl Task for ResolveKwinTask {
type Output = Option<u32>;
type JsValue = Option<u32>;
fn compute(&mut self) -> Result<Self::Output> {
kwin::resolve_kwin_window_pid(&self.token)
.map_err(|err| generic_error(format!("resolveKwinWindowPid: {err}")))
}
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
#[napi(js_name = "resolveKwinWindowPid")]
pub fn resolve_kwin_window_pid(token: String) -> Result<AsyncTask<ResolveKwinTask>> {
Ok(AsyncTask::new(ResolveKwinTask { token }))
}
pub struct ResolveX11Task {
token: String,
}
impl Task for ResolveX11Task {
type Output = Option<u32>;
type JsValue = Option<u32>;
fn compute(&mut self) -> Result<Self::Output> {
x11::resolve_x11_window_pid(&self.token)
.map_err(|err| generic_error(format!("resolveX11WindowPid: {err}")))
}
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
#[napi(js_name = "resolveX11WindowPid")]
pub fn resolve_x11_window_pid(token: String) -> Result<AsyncTask<ResolveX11Task>> {
Ok(AsyncTask::new(ResolveX11Task { token }))
}
pub struct ResolveWindowPidTask {
token: String,
}
impl Task for ResolveWindowPidTask {
type Output = Option<u32>;
type JsValue = Option<u32>;
fn compute(&mut self) -> Result<Self::Output> {
gnome_shell::resolve_gnome_shell_window_pid(&self.token).map_err(generic_error)
}
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
#[napi(js_name = "resolveWindowPid")]
pub fn resolve_window_pid(spec: Object) -> Result<AsyncTask<ResolveWindowPidTask>> {
let backend = read_string_field(&spec, "backend")
.ok_or_else(|| invalid_arg("spec.backend must be a string"))?;
if backend != "gnome-shell-eval" {
return Err(invalid_arg("spec.backend must be 'gnome-shell-eval'"));
}
let token = read_string_field(&spec, "token")
.ok_or_else(|| invalid_arg("spec.token must be a string"))?;
Ok(AsyncTask::new(ResolveWindowPidTask { token }))
}
fn parse_filter_rule(object: &Object) -> Result<FilterRule> {
let kind = object
.get::<u32>("kind")
.map_err(|err| invalid_arg(err.reason.clone()))?
.ok_or_else(|| invalid_arg("rule.kind must be a number"))?;
if kind > 1 {
return Err(invalid_arg("rule.kind must be 0 (glob) or 1 (mime-type)"));
}
let pattern = read_string_field(object, "pattern")
.ok_or_else(|| invalid_arg("rule.pattern must be a string"))?;
Ok(FilterRule { kind, pattern })
}
fn parse_filter(object: &Object) -> Result<Filter> {
let name = read_string_field(object, "name")
.ok_or_else(|| invalid_arg("filter.name must be a string"))?;
let rules_array = read_array_field(object, "rules")
.ok_or_else(|| invalid_arg("filter.rules must be an array"))?;
let mut rules = Vec::with_capacity(rules_array.len() as usize);
for i in 0..rules_array.len() {
let rule_obj = rules_array
.get::<Object>(i)
.map_err(|err| invalid_arg(err.reason.clone()))?
.ok_or_else(|| invalid_arg("rule must be an object"))?;
rules.push(parse_filter_rule(&rule_obj)?);
}
Ok(Filter { name, rules })
}
fn parse_filechooser_options(object: &Object) -> Result<Options> {
let parent_window = read_string_field_or_empty(object, "parentWindow");
let title = read_string_field_or_empty(object, "title");
let accept_label = read_string_field(object, "acceptLabel");
let modal = read_bool_field(object, "modal").unwrap_or(true);
let multiple = read_bool_field(object, "multiple").unwrap_or(false);
let directory = read_bool_field(object, "directory").unwrap_or(false);
let current_folder = read_string_field(object, "currentFolder");
let current_name = read_string_field(object, "currentName");
let current_file = read_string_field(object, "currentFile");
let filters = if let Some(array) = read_array_field(object, "filters") {
let mut out = Vec::with_capacity(array.len() as usize);
for i in 0..array.len() {
let f_obj = array
.get::<Object>(i)
.map_err(|err| invalid_arg(err.reason.clone()))?
.ok_or_else(|| invalid_arg("filter must be an object"))?;
out.push(parse_filter(&f_obj)?);
}
out
} else {
Vec::new()
};
let current_filter = if let Some(obj) = read_object_field(object, "currentFilter") {
Some(parse_filter(&obj)?)
} else {
None
};
Ok(Options {
parent_window,
title,
accept_label,
modal,
multiple,
directory,
current_folder,
current_name,
current_file,
filters,
current_filter,
})
}
pub struct FileChooserTask {
mode: Mode,
options: Options,
}
impl Task for FileChooserTask {
type Output = FileChooserResult;
type JsValue = Object<'static>;
fn compute(&mut self) -> Result<Self::Output> {
filechooser::invoke(self.mode, self.options.clone())
.map_err(|err| generic_error(format!("FileChooser portal: {err}")))
}
fn resolve(&mut self, env: Env, output: Self::Output) -> Result<Self::JsValue> {
let mut obj = Object::new(&env)?;
obj.set("cancelled", output.cancelled)?;
let mut array = env.create_array(output.uris.len() as u32)?;
for (i, uri) in output.uris.iter().enumerate() {
array.set(i as u32, uri.as_str())?;
}
obj.set("uris", array)?;
Ok(unsafe { std::mem::transmute::<Object<'_>, Object<'static>>(obj) })
}
}
#[napi(js_name = "openFile")]
pub fn open_file(options: Object) -> Result<AsyncTask<FileChooserTask>> {
let parsed = parse_filechooser_options(&options)?;
Ok(AsyncTask::new(FileChooserTask {
mode: Mode::Open,
options: parsed,
}))
}
#[napi(js_name = "saveFile")]
pub fn save_file(options: Object) -> Result<AsyncTask<FileChooserTask>> {
let parsed = parse_filechooser_options(&options)?;
Ok(AsyncTask::new(FileChooserTask {
mode: Mode::Save,
options: parsed,
}))
}
fn parse_string_array_field(object: &Object, key: &str) -> Result<Vec<String>> {
let Some(array) = read_array_field(object, key) else {
return Ok(Vec::new());
};
let mut out = Vec::with_capacity(array.len() as usize);
for i in 0..array.len() {
let value = array
.get::<String>(i)
.map_err(|err| invalid_arg(err.reason.clone()))?
.ok_or_else(|| invalid_arg(format!("{key} entries must be strings")))?;
out.push(value);
}
Ok(out)
}
fn parse_background_options(object: &Object) -> Result<RequestOptions> {
Ok(RequestOptions {
reason: read_string_field(object, "reason"),
autostart: read_bool_field(object, "autostart").unwrap_or(false),
commandline: parse_string_array_field(object, "commandline")?,
dbus_activatable: read_bool_field(object, "dbusActivatable").unwrap_or(false),
})
}
pub struct BackgroundTask {
options: RequestOptions,
}
impl Task for BackgroundTask {
type Output = RequestResult;
type JsValue = Object<'static>;
fn compute(&mut self) -> Result<Self::Output> {
background::request_background(self.options.clone())
.map_err(|err| generic_error(format!("Background portal: {err}")))
}
fn resolve(&mut self, env: Env, output: Self::Output) -> Result<Self::JsValue> {
let mut obj = Object::new(&env)?;
obj.set("response", output.response)?;
obj.set("cancelled", output.cancelled())?;
obj.set("background", output.background)?;
obj.set("autostart", output.autostart)?;
Ok(unsafe { std::mem::transmute::<Object<'_>, Object<'static>>(obj) })
}
}
#[napi(js_name = "requestBackground")]
pub fn request_background_js(options: Object) -> Result<AsyncTask<BackgroundTask>> {
let parsed = parse_background_options(&options)?;
Ok(AsyncTask::new(BackgroundTask { options: parsed }))
}
#[napi(js_name = "isAvailable")]
pub fn is_available_js() -> bool {
global_shortcuts::is_available()
}
#[napi(js_name = "getPortalVersion")]
pub fn get_portal_version_js() -> Option<u32> {
global_shortcuts::get_portal_version()
}
fn parse_shortcut_entries(array: Array) -> Result<Vec<ShortcutEntry>> {
let mut entries = Vec::with_capacity(array.len() as usize);
for i in 0..array.len() {
let object = array
.get::<Object>(i)
.map_err(|err| invalid_arg(err.reason.clone()))?
.ok_or_else(|| invalid_arg("shortcut entries must be objects"))?;
let id = read_string_field(&object, "id")
.ok_or_else(|| invalid_arg("shortcut.id must be a string"))?;
let description = read_string_field(&object, "description")
.ok_or_else(|| invalid_arg("shortcut.description must be a string"))?;
entries.push(ShortcutEntry {
id,
description,
preferred_trigger: read_string_field(&object, "preferredTrigger"),
});
}
Ok(entries)
}
fn bound_shortcuts_to_array(env: &Env, shortcuts: &[BoundShortcut]) -> Result<Array<'static>> {
let mut array = env.create_array(shortcuts.len() as u32)?;
for (i, shortcut) in shortcuts.iter().enumerate() {
let mut obj = Object::new(env)?;
obj.set("id", shortcut.id.as_str())?;
if let Some(description) = shortcut.description.as_deref() {
obj.set("description", description)?;
}
if let Some(trigger) = shortcut.trigger_description.as_deref() {
obj.set("triggerDescription", trigger)?;
}
array.set(i as u32, obj)?;
}
Ok(unsafe { std::mem::transmute::<Array<'_>, Array<'static>>(array) })
}
pub enum NapiShortcutEvent {
Activated { id: String },
Deactivated { id: String },
ShortcutsChanged { shortcuts: Vec<BoundShortcut> },
Closed,
}
impl From<ShortcutEvent> for NapiShortcutEvent {
fn from(event: ShortcutEvent) -> Self {
match event {
ShortcutEvent::Activated { id } => Self::Activated { id },
ShortcutEvent::Deactivated { id } => Self::Deactivated { id },
ShortcutEvent::ShortcutsChanged { shortcuts } => {
Self::ShortcutsChanged { shortcuts }
}
ShortcutEvent::Closed => Self::Closed,
}
}
}
impl ToNapiValue for NapiShortcutEvent {
unsafe fn to_napi_value(raw_env: sys::napi_env, event: Self) -> Result<sys::napi_value> {
let env = Env::from_raw(raw_env);
let mut obj = Object::new(&env)?;
match event {
Self::Activated { id } => {
obj.set("type", "activated")?;
obj.set("id", id)?;
}
Self::Deactivated { id } => {
obj.set("type", "deactivated")?;
obj.set("id", id)?;
}
Self::ShortcutsChanged { shortcuts } => {
obj.set("type", "shortcuts-changed")?;
obj.set("shortcuts", bound_shortcuts_to_array(&env, &shortcuts)?)?;
}
Self::Closed => {
obj.set("type", "closed")?;
}
}
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, obj) }
}
}
type ShortcutTsfn = Arc<
ThreadsafeFunction<
NapiShortcutEvent,
UnknownReturnValue,
NapiShortcutEvent,
Status,
false,
true,
SHORTCUT_EVENT_QUEUE_LIMIT,
>,
>;
pub struct ConfigureShortcutsTask {
entries: Vec<ShortcutEntry>,
state: Arc<std::sync::Mutex<Option<global_shortcuts::Subscription>>>,
callback: ShortcutTsfn,
}
impl Task for ConfigureShortcutsTask {
type Output = ConfigureResult;
type JsValue = Object<'static>;
fn compute(&mut self) -> Result<Self::Output> {
let tsfn_for_cb = self.callback.clone();
let callback = Arc::new(move |event: ShortcutEvent| {
let _ = tsfn_for_cb.call(
NapiShortcutEvent::from(event),
ThreadsafeFunctionCallMode::NonBlocking,
);
});
let (subscription, result) =
global_shortcuts::Subscription::configure(self.entries.clone(), callback)
.map_err(|err| generic_error(format!("GlobalShortcuts portal: {err}")))?;
let mut guard = self
.state
.lock()
.map_err(|_| generic_error("global shortcuts lock poisoned"))?;
if let Some(previous) = guard.replace(subscription) {
previous.close();
}
Ok(result)
}
fn resolve(&mut self, env: Env, output: Self::Output) -> Result<Self::JsValue> {
let mut obj = Object::new(&env)?;
obj.set("action", output.action)?;
obj.set(
"shortcuts",
bound_shortcuts_to_array(&env, &output.shortcuts)?,
)?;
Ok(unsafe { std::mem::transmute::<Object<'_>, Object<'static>>(obj) })
}
}
#[napi]
pub struct GlobalShortcutsPortal {
subscription: Arc<std::sync::Mutex<Option<global_shortcuts::Subscription>>>,
callback: ShortcutTsfn,
#[allow(dead_code)]
app_id: Option<String>,
}
#[napi]
impl GlobalShortcutsPortal {
#[napi(constructor)]
pub fn new(
on_event: Function<NapiShortcutEvent, UnknownReturnValue>,
app_id: Option<String>,
) -> Result<Self> {
let callback: ShortcutTsfn = Arc::new(
on_event
.build_threadsafe_function::<NapiShortcutEvent>()
.weak::<true>()
.callee_handled::<false>()
.max_queue_size::<SHORTCUT_EVENT_QUEUE_LIMIT>()
.build()
.map_err(|err| {
generic_error(format!(
"failed to create global shortcuts callback: {}",
err.reason
))
})?,
);
Ok(Self {
subscription: Arc::new(std::sync::Mutex::new(None)),
callback,
app_id,
})
}
#[napi]
pub fn configure(&self, entries: Array) -> Result<AsyncTask<ConfigureShortcutsTask>> {
let parsed = parse_shortcut_entries(entries)?;
Ok(AsyncTask::new(ConfigureShortcutsTask {
entries: parsed,
state: self.subscription.clone(),
callback: self.callback.clone(),
}))
}
#[napi]
pub fn close(&self) -> Result<()> {
if let Some(subscription) = self
.subscription
.lock()
.map_err(|_| generic_error("global shortcuts lock poisoned"))?
.take()
{
subscription.close();
}
Ok(())
}
}
impl Drop for GlobalShortcutsPortal {
fn drop(&mut self) {
if let Ok(mut guard) = self.subscription.lock()
&& let Some(subscription) = guard.take()
{
subscription.close();
}
}
}
#[napi(js_name = "readColorScheme")]
pub fn read_color_scheme_js() -> &'static str {
settings::read_color_scheme().as_str()
}
#[napi(js_name = "readContrast")]
pub fn read_contrast_js() -> &'static str {
settings::read_contrast().as_str()
}
#[napi(object)]
pub struct AccentColorJs {
pub r: f64,
pub g: f64,
pub b: f64,
}
#[napi(js_name = "readAccentColor")]
pub fn read_accent_color_js() -> Option<AccentColorJs> {
settings::read_accent_color().map(|a| AccentColorJs {
r: a.r,
g: a.g,
b: a.b,
})
}
pub enum NapiSettingsEvent {
Uint32 {
namespace: String,
key: String,
value: u32,
},
Accent {
namespace: String,
key: String,
r: f64,
g: f64,
b: f64,
},
Unknown {
namespace: String,
key: String,
},
}
impl From<ChangeEvent> for NapiSettingsEvent {
fn from(event: ChangeEvent) -> Self {
match event.payload {
ChangePayload::Uint32(v) => Self::Uint32 {
namespace: event.namespace,
key: event.key,
value: v,
},
ChangePayload::Accent(a) => Self::Accent {
namespace: event.namespace,
key: event.key,
r: a.r,
g: a.g,
b: a.b,
},
ChangePayload::Unknown => Self::Unknown {
namespace: event.namespace,
key: event.key,
},
}
}
}
impl ToNapiValue for NapiSettingsEvent {
unsafe fn to_napi_value(raw_env: sys::napi_env, event: Self) -> Result<sys::napi_value> {
let env = Env::from_raw(raw_env);
let mut obj = Object::new(&env)?;
match event {
Self::Uint32 {
namespace,
key,
value,
} => {
obj.set("namespace", namespace)?;
obj.set("key", key)?;
obj.set("uint32", value)?;
}
Self::Accent {
namespace,
key,
r,
g,
b,
} => {
obj.set("namespace", namespace)?;
obj.set("key", key)?;
let mut accent = Object::new(&env)?;
accent.set("r", r)?;
accent.set("g", g)?;
accent.set("b", b)?;
obj.set("accent", accent)?;
}
Self::Unknown { namespace, key } => {
obj.set("namespace", namespace)?;
obj.set("key", key)?;
}
}
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, obj) }
}
}
type SettingsTsfn = Arc<
ThreadsafeFunction<
NapiSettingsEvent,
UnknownReturnValue,
NapiSettingsEvent,
Status,
false,
true,
SETTINGS_EVENT_QUEUE_LIMIT,
>,
>;
#[napi]
pub struct Settings {
subscription: std::sync::Mutex<Option<settings::Subscription>>,
}
#[napi]
impl Settings {
#[napi(constructor)]
pub fn new(on_change: Function<NapiSettingsEvent, UnknownReturnValue>) -> Result<Self> {
let tsfn: SettingsTsfn = Arc::new(
on_change
.build_threadsafe_function::<NapiSettingsEvent>()
.weak::<true>()
.callee_handled::<false>()
.max_queue_size::<SETTINGS_EVENT_QUEUE_LIMIT>()
.build()
.map_err(|err| {
generic_error(format!(
"failed to create settings callback: {}",
err.reason
))
})?,
);
let tsfn_for_cb = tsfn.clone();
let callback = Arc::new(move |event: ChangeEvent| {
let _ = tsfn_for_cb.call(
NapiSettingsEvent::from(event),
ThreadsafeFunctionCallMode::NonBlocking,
);
});
let sub = settings::Subscription::new(callback)
.map_err(|err| generic_error(format!("Settings subscribe failed: {err}")))?;
Ok(Self {
subscription: std::sync::Mutex::new(Some(sub)),
})
}
#[napi]
pub fn close(&self) -> Result<()> {
if let Some(sub) = self
.subscription
.lock()
.map_err(|_| generic_error("settings lock poisoned"))?
.take()
{
sub.close();
}
Ok(())
}
}
impl Drop for Settings {
fn drop(&mut self) {
if let Ok(mut guard) = self.subscription.lock()
&& let Some(sub) = guard.take()
{
sub.close();
}
}
}
#[allow(dead_code)]
fn _link_unused(_c: Contrast, _s: ColorScheme) {}
}
#[cfg(not(target_os = "linux"))]
mod napi_bindings {}
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
pub const REQUEST_INTERFACE: &str = "org.freedesktop.portal.Request";
pub fn request_path(unique_bus_name: &str, handle_token: &str) -> String {
let trimmed = unique_bus_name.strip_prefix(':').unwrap_or(unique_bus_name);
let mut out = String::with_capacity(40 + trimmed.len() + handle_token.len());
out.push_str("/org/freedesktop/portal/desktop/request/");
for ch in trimmed.chars() {
out.push(if ch == '.' { '_' } else { ch });
}
out.push('/');
out.push_str(handle_token);
out
}
static TOKEN_SEQ: AtomicU64 = AtomicU64::new(1);
pub fn mint_token(prefix: &str) -> String {
let seq = TOKEN_SEQ.fetch_add(1, Ordering::Relaxed);
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
format!("{prefix}_{ms:x}_{seq:x}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_path_sanitizes_unique_bus_name() {
assert_eq!(
request_path(":1.42", "fluxer_fc_open_1"),
"/org/freedesktop/portal/desktop/request/1_42/fluxer_fc_open_1"
);
}
#[test]
fn mint_token_is_distinct_and_prefixed() {
let a = mint_token("fluxer_fc_open");
let b = mint_token("fluxer_fc_open");
assert_ne!(a, b);
assert!(a.starts_with("fluxer_fc_open_"));
}
}
@@ -0,0 +1,339 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[cfg(target_os = "linux")]
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread::{self, JoinHandle},
time::Duration,
};
#[cfg(target_os = "linux")]
use futures_lite::{FutureExt, StreamExt, future};
#[cfg(target_os = "linux")]
use zbus::{
MatchRule, MessageStream,
blocking::{Connection as BlockingConnection, Proxy as BlockingProxy},
message::Type as MessageType,
zvariant::{OwnedValue, Value},
};
pub const PORTAL_DESTINATION: &str = "org.freedesktop.portal.Desktop";
pub const PORTAL_PATH: &str = "/org/freedesktop/portal/desktop";
pub const SETTINGS_INTERFACE: &str = "org.freedesktop.portal.Settings";
pub const APPEARANCE_NAMESPACE: &str = "org.freedesktop.appearance";
#[cfg(target_os = "linux")]
pub const READ_TIMEOUT: Duration = Duration::from_millis(1_500);
#[cfg(target_os = "linux")]
pub const SIGNAL_POLL_INTERVAL: Duration = Duration::from_millis(200);
#[cfg(target_os = "linux")]
pub const SIGNAL_THREAD_START_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorScheme {
NoPreference,
PreferDark,
PreferLight,
}
impl ColorScheme {
pub fn from_u32(value: u32) -> Self {
match value {
1 => Self::PreferDark,
2 => Self::PreferLight,
_ => Self::NoPreference,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::NoPreference => "no-preference",
Self::PreferDark => "prefer-dark",
Self::PreferLight => "prefer-light",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Contrast {
NoPreference,
High,
}
impl Contrast {
pub fn from_u32(value: u32) -> Self {
match value {
1 => Self::High,
_ => Self::NoPreference,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::NoPreference => "no-preference",
Self::High => "high",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AccentColor {
pub r: f64,
pub g: f64,
pub b: f64,
}
pub fn classify_accent_color(r: f64, g: f64, b: f64) -> Option<AccentColor> {
if r < 0.0 || g < 0.0 || b < 0.0 {
return None;
}
Some(AccentColor { r, g, b })
}
#[derive(Debug, Clone, PartialEq)]
pub struct ChangeEvent {
pub namespace: String,
pub key: String,
pub payload: ChangePayload,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ChangePayload {
Uint32(u32),
Accent(AccentColor),
Unknown,
}
#[cfg(target_os = "linux")]
fn open_blocking_connection() -> zbus::Result<BlockingConnection> {
zbus::blocking::connection::Builder::session()?
.method_timeout(READ_TIMEOUT)
.build()
}
#[cfg(target_os = "linux")]
fn read_uint32_setting(key: &str) -> Option<u32> {
let conn = open_blocking_connection().ok()?;
let proxy =
BlockingProxy::new(&conn, PORTAL_DESTINATION, PORTAL_PATH, SETTINGS_INTERFACE).ok()?;
let value: OwnedValue = proxy.call("Read", &(APPEARANCE_NAMESPACE, key)).ok()?;
crate::kwin::integer_from_variant(crate::kwin::value_of_owned(&value))
}
#[cfg(target_os = "linux")]
pub fn read_color_scheme() -> ColorScheme {
read_uint32_setting("color-scheme")
.map(ColorScheme::from_u32)
.unwrap_or(ColorScheme::NoPreference)
}
#[cfg(target_os = "linux")]
pub fn read_contrast() -> Contrast {
read_uint32_setting("contrast")
.map(Contrast::from_u32)
.unwrap_or(Contrast::NoPreference)
}
#[cfg(target_os = "linux")]
pub fn read_accent_color() -> Option<AccentColor> {
let conn = open_blocking_connection().ok()?;
let proxy =
BlockingProxy::new(&conn, PORTAL_DESTINATION, PORTAL_PATH, SETTINGS_INTERFACE).ok()?;
let value: OwnedValue = proxy
.call("Read", &(APPEARANCE_NAMESPACE, "accent-color"))
.ok()?;
extract_accent_from_variant(crate::kwin::value_of_owned(&value))
}
#[cfg(target_os = "linux")]
fn extract_accent_from_variant(value: &Value<'_>) -> Option<AccentColor> {
let inner: &Value<'_> = match value {
Value::Value(b) => b.as_ref(),
other => other,
};
let Value::Structure(structure) = inner else {
return None;
};
let fields = structure.fields();
if fields.len() < 3 {
return None;
}
let r = double_from_value(&fields[0])?;
let g = double_from_value(&fields[1])?;
let b = double_from_value(&fields[2])?;
classify_accent_color(r, g, b)
}
#[cfg(target_os = "linux")]
fn double_from_value(value: &Value<'_>) -> Option<f64> {
match value {
Value::F64(v) => Some(*v),
Value::Value(b) => double_from_value(b.as_ref()),
_ => None,
}
}
#[cfg(not(target_os = "linux"))]
pub fn read_color_scheme() -> ColorScheme {
ColorScheme::NoPreference
}
#[cfg(not(target_os = "linux"))]
pub fn read_contrast() -> Contrast {
Contrast::NoPreference
}
#[cfg(not(target_os = "linux"))]
pub fn read_accent_color() -> Option<AccentColor> {
None
}
#[cfg(target_os = "linux")]
type ChangeCallback = Arc<dyn Fn(ChangeEvent) + Send + Sync + 'static>;
#[cfg(target_os = "linux")]
pub struct Subscription {
stop_flag: Arc<AtomicBool>,
thread: Mutex<Option<JoinHandle<()>>>,
}
#[cfg(target_os = "linux")]
impl Subscription {
pub fn new(callback: ChangeCallback) -> Result<Self, String> {
let stop_flag = Arc::new(AtomicBool::new(false));
let (ready_tx, ready_rx) = mpsc::sync_channel(1);
let stop_for_thread = stop_flag.clone();
let thread = thread::Builder::new()
.name("fluxer-linux-portals-settings".to_string())
.spawn(move || {
let setup = future::block_on(async {
let conn = zbus::Connection::session().await?;
let rule = MatchRule::builder()
.msg_type(MessageType::Signal)
.interface(SETTINGS_INTERFACE)?
.member("SettingChanged")?
.build();
let stream = MessageStream::for_match_rule(rule, &conn, Some(32)).await?;
zbus::Result::Ok((conn, stream))
});
let (_conn, mut stream) = match setup {
Ok(parts) => {
let _ = ready_tx.send(Ok(()));
parts
}
Err(err) => {
let _ = ready_tx.send(Err(err.to_string()));
return;
}
};
while !stop_for_thread.load(Ordering::Acquire) {
let timeout = async {
async_io::Timer::after(SIGNAL_POLL_INTERVAL).await;
None::<zbus::Result<zbus::Message>>
};
match future::block_on(stream.next().or(timeout)) {
Some(Ok(message)) => {
if let Some(event) = parse_setting_changed(&message)
&& event.namespace == APPEARANCE_NAMESPACE
{
callback(event);
}
}
Some(Err(_)) => break,
None => {}
}
}
})
.map_err(|err| err.to_string())?;
match ready_rx.recv_timeout(SIGNAL_THREAD_START_TIMEOUT) {
Ok(Ok(())) => Ok(Self {
stop_flag,
thread: Mutex::new(Some(thread)),
}),
Ok(Err(err)) => {
let _ = thread.join();
Err(err)
}
Err(err) => {
stop_flag.store(true, Ordering::Release);
if matches!(err, mpsc::RecvTimeoutError::Disconnected) {
let _ = thread.join();
}
Err(err.to_string())
}
}
}
pub fn close(&self) {
self.stop_flag.store(true, Ordering::Release);
if let Ok(mut thread) = self.thread.lock()
&& let Some(t) = thread.take()
{
let _ = t.join();
}
}
}
#[cfg(target_os = "linux")]
impl Drop for Subscription {
fn drop(&mut self) {
self.close();
}
}
#[cfg(target_os = "linux")]
fn parse_setting_changed(message: &zbus::Message) -> Option<ChangeEvent> {
let body = message.body();
let (namespace, key, value): (String, String, OwnedValue) = body.deserialize().ok()?;
let value_ref = crate::kwin::value_of_owned(&value);
let payload = classify_payload(&key, value_ref);
Some(ChangeEvent {
namespace,
key,
payload,
})
}
#[cfg(target_os = "linux")]
fn classify_payload(_key: &str, value: &Value<'_>) -> ChangePayload {
if let Some(n) = crate::kwin::integer_from_variant(value) {
return ChangePayload::Uint32(n);
}
if let Some(accent) = extract_accent_from_variant(value) {
return ChangePayload::Accent(accent);
}
ChangePayload::Unknown
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn color_scheme_maps_to_strings_per_typescript_union() {
assert_eq!(ColorScheme::from_u32(0).as_str(), "no-preference");
assert_eq!(ColorScheme::from_u32(1).as_str(), "prefer-dark");
assert_eq!(ColorScheme::from_u32(2).as_str(), "prefer-light");
assert_eq!(ColorScheme::from_u32(99).as_str(), "no-preference");
}
#[test]
fn contrast_maps_to_strings_per_typescript_union() {
assert_eq!(Contrast::from_u32(0).as_str(), "no-preference");
assert_eq!(Contrast::from_u32(1).as_str(), "high");
assert_eq!(Contrast::from_u32(99).as_str(), "no-preference");
}
#[test]
fn classify_accent_color_treats_negative_as_no_preference() {
assert_eq!(classify_accent_color(-1.0, -1.0, -1.0), None);
assert_eq!(classify_accent_color(-0.0001, 0.5, 0.5), None);
let accent = classify_accent_color(0.1, 0.2, 0.3).unwrap();
assert_eq!(accent.r, 0.1);
assert_eq!(accent.g, 0.2);
assert_eq!(accent.b, 0.3);
}
}
@@ -0,0 +1,127 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::os::raw::c_long;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolveError {
InvalidToken,
LibX11Unavailable,
MissingSymbol,
DisplayUnavailable,
}
impl std::fmt::Display for ResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::InvalidToken => "InvalidToken",
Self::LibX11Unavailable => "LibX11Unavailable",
Self::MissingSymbol => "MissingSymbol",
Self::DisplayUnavailable => "DisplayUnavailable",
};
f.write_str(s)
}
}
pub fn parse_window_token(token: &str) -> Option<u32> {
if token.is_empty() {
return None;
}
let parsed: u64 = if let Some(stripped) = token
.strip_prefix("0x")
.or_else(|| token.strip_prefix("0X"))
{
u64::from_str_radix(stripped, 16).ok()?
} else {
token.parse::<u64>().ok()?
};
if parsed == 0 {
return None;
}
u32::try_from(parsed).ok()
}
pub fn pid_from_long(value: c_long) -> Option<u32> {
if value <= 0 {
return None;
}
if (value as u64) > u32::MAX as u64 {
return None;
}
Some(value as u32)
}
#[cfg(target_os = "linux")]
pub fn resolve_x11_window_pid(token: &str) -> Result<Option<u32>, ResolveError> {
use x11rb::protocol::xproto::{AtomEnum, ConnectionExt};
use x11rb::rust_connection::RustConnection;
let window = parse_window_token(token).ok_or(ResolveError::InvalidToken)?;
let (conn, _screen) =
RustConnection::connect(None).map_err(|_| ResolveError::DisplayUnavailable)?;
let atom_cookie = conn
.intern_atom(true, b"_NET_WM_PID")
.map_err(|_| ResolveError::DisplayUnavailable)?;
let atom = atom_cookie
.reply()
.map_err(|_| ResolveError::DisplayUnavailable)?
.atom;
if atom == 0 {
return Ok(None);
}
let reply = conn
.get_property(false, window, atom, AtomEnum::CARDINAL, 0, 1)
.map_err(|_| ResolveError::DisplayUnavailable)?
.reply()
.map_err(|_| ResolveError::DisplayUnavailable)?;
if reply.type_ != u32::from(AtomEnum::CARDINAL) || reply.format != 32 || reply.value_len < 1 {
return Ok(None);
}
let Some(values) = reply.value32() else {
return Ok(None);
};
let pid = values.collect::<Vec<u32>>();
let Some(&first) = pid.first() else {
return Ok(None);
};
Ok(pid_from_long(first as c_long))
}
#[cfg(not(target_os = "linux"))]
pub fn resolve_x11_window_pid(_token: &str) -> Result<Option<u32>, ResolveError> {
Err(ResolveError::LibX11Unavailable)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_window_token_accepts_decimal_and_hexadecimal_xids() {
assert_eq!(parse_window_token("123"), Some(123));
assert_eq!(parse_window_token("0x3a00007"), Some(0x3a00007));
assert_eq!(parse_window_token("0X3a00007"), Some(0x3a00007));
}
#[test]
fn parse_window_token_rejects_invalid_or_zero_xids() {
assert_eq!(parse_window_token(""), None);
assert_eq!(parse_window_token("0"), None);
assert_eq!(parse_window_token("0x"), None);
assert_eq!(parse_window_token("0xG"), None);
assert_eq!(parse_window_token("../123"), None);
assert_eq!(parse_window_token("123abc"), None);
}
#[test]
fn pid_from_long_validates_positive_uint32_process_ids() {
assert_eq!(pid_from_long(1), Some(1));
assert_eq!(pid_from_long(42_424), Some(42_424));
assert_eq!(pid_from_long(0), None);
assert_eq!(pid_from_long(-1), None);
assert_eq!(pid_from_long(u32::MAX as c_long + 1), None);
}
}