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:
+1267
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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user