breezy-desktop/gnome/src/monitormanager.js

575 lines
28 KiB
JavaScript

// Taken from https://github.com/jkitching/soft-brightness-plus
//
// Copyright (C) 2019, 2021 Philippe Troin (F-i-f on Github)
// Copyright (C) 2023 Joel Kitching (jkitching on Github)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import Globals from './globals.js';
export const NESTED_MONITOR_PRODUCT = 'MetaMonitor';
export const VIRTUAL_MONITOR_PRODUCT = 'Virtual remote monitor';
export const SUPPORTED_MONITOR_PRODUCTS = [
'VITURE',
'nreal air',
'Air',
'Air 2',
'Air 2 Pro',
'Air 2 Ultra',
'SmartGlasses', // TCL/RayNeo
'Rokid Max',
'Rokid Air',
NESTED_MONITOR_PRODUCT
];
let cachedDisplayConfigProxy = null;
function getDisplayConfigProxy(extPath) {
if (cachedDisplayConfigProxy == null) {
let xml = null;
const file = Gio.File.new_for_path(extPath + '/dbus-interfaces/org.gnome.Mutter.DisplayConfig.xml');
try {
const [ok, bytes] = file.load_contents(null);
if (ok) {
xml = new TextDecoder().decode(bytes);
}
} catch (e) {
Globals.logger.log('[ERROR] failed to load DisplayConfig interface XML');
throw e;
}
cachedDisplayConfigProxy = Gio.DBusProxy.makeProxyWrapper(xml);
}
return cachedDisplayConfigProxy;
}
export function newDisplayConfig(extPath, callback) {
const DisplayConfigProxy = getDisplayConfigProxy(extPath);
new DisplayConfigProxy(
Gio.DBus.session,
'org.gnome.Mutter.DisplayConfig',
'/org/gnome/Mutter/DisplayConfig',
callback
);
}
function getMonitorConfig(displayConfigProxy, callback) {
displayConfigProxy.GetCurrentStateRemote((result, error) => {
if (error) {
callback(null, `GetCurrentState failed: ${error}`);
} else {
Globals.logger.log_debug(`monitormanager.js getMonitorConfig GetCurrentState result: ${JSON.stringify(result)}`);
const allMonitors = [];
const [serial, monitors, logicalMonitors, properties] = result;
for (let monitor of monitors) {
const [details, modes, monProperties] = monitor;
const [connector, vendor, product, monitorSerial] = details;
const displayName = monProperties['display-name'].get_string()[0];
for (let mode of modes) {
const [modeId, width, height, refreshRate, preferredScale, supportedScales, modeProperites] = mode;
const isCurrent = !!modeProperites['is-current'];
if (isCurrent) {
allMonitors.push([displayName, connector, vendor, product, serial, refreshRate]);
}
}
}
callback(allMonitors, null);
}
});
}
// triggers callback with true result if an an async monitor config change was triggered, false if no config change needed
function performOptimalModeCheck(displayConfigProxy, connectorName, headsetAsPrimary, useHighestRefreshRate,
disablePhysicalDisplays, callback, allowConfigUpdateFn) {
Globals.logger.log_debug(`monitormanager.js performOptimalModeCheck for ${connectorName}`);
displayConfigProxy.GetCurrentStateRemote((result, error) => {
if (!allowConfigUpdateFn()) {
// other requests are in progress, this monitor state may be stale, do nothing
Globals.logger.log_debug('MonitorManager performOptimalModeCheck: allowConfigUpdate is false');
callback(false, null);
return;
}
if (error) {
callback(null, `GetCurrentState failed: ${error}`);
} else {
Globals.logger.log_debug(`monitormanager.js performOptimalModeCheck GetCurrentState result: ${JSON.stringify(result)}`);
const [serial, monitors, logicalMonitors, properties] = result;
// iterate over all monitors at least once, collecting the best fit mode for our monitor, and mode information
// for each monitor
let ourMonitor = undefined;
let monitorToCurrentModeMap = {};
let bestFitMode = undefined;
const skipScaleUpdate = !!properties['global-scale-required'];
for (let monitor of monitors) {
const [details, availableModes, monProperties] = monitor;
const [connector, vendor, product, monitorSerial] = details;
const isOurMonitor = connector == connectorName;
let modes = availableModes;
if (isOurMonitor) {
ourMonitor = monitor;
if (!useHighestRefreshRate) {
const currentMode = modes.find((mode) => !!mode[6]['is-current']);
// filter modes to only include the current refresh rate
modes = availableModes.filter((mode) => mode[3] === currentMode[3]);
}
}
for (let mode of modes) {
const [modeId, width, height, refreshRate, preferredScale, supportedScales, modeProperites] = mode;
const isCurrent = !!modeProperites['is-current'];
if (isCurrent) monitorToCurrentModeMap[connector] = mode;
if (isOurMonitor && (!bestFitMode || (
width >= bestFitMode.width &&
height >= bestFitMode.height &&
refreshRate >= bestFitMode.refreshRate))) {
// find the scale that is closest to 1.0
const bestScale = supportedScales.reduce((prev, curr) => {
return Math.abs(curr - 1.0) < Math.abs(prev - 1.0) ? curr : prev;
});
bestFitMode = {
modeId,
width,
height,
refreshRate,
bestScale
};
}
}
}
if (!!ourMonitor) {
let anyMonitorsChanged = false;
if (!!bestFitMode) {
// this will hold how much the width of the monitor has changed,
// and what range of y values is affected
let deltaX = 0;
let rangeY = [0, 0];
// sort logicalMonitors by x ascending, so we can tell if any are affected by a width change
logicalMonitors.sort((a, b) => a[0] - b[0]);
// map from original logical monitors schema to a(iiduba(ssa{sv})) for ApplyMonitorsConfig call
const removeMonitorIndexes = [];
const updatedLogicalMonitors = logicalMonitors.map((logicalMonitor, index) => {
const [x, y, scale, transform, primary, monitors, logMonProperties] = logicalMonitor;
const hasOurMonitor = !!monitors.some((monitor) => monitor[0] === connectorName);
const hasVirtualMonitor = monitors.some((monitor) => monitor[2] === VIRTUAL_MONITOR_PRODUCT);
const newScale = (!skipScaleUpdate && hasOurMonitor) ? bestFitMode.bestScale : scale;
anyMonitorsChanged |= newScale !== scale;
// there can only be one primary monitor, so we need to set all other monitors to not primary and glasses to primary,
// if headsetAsPrimary is true
anyMonitorsChanged |= headsetAsPrimary && ((hasOurMonitor && !primary) || (!hasOurMonitor && primary));
if (disablePhysicalDisplays && !hasVirtualMonitor && !hasOurMonitor) {
removeMonitorIndexes.push(index);
anyMonitorsChanged = true;
}
// we need to figure out if the deltaX applies to this logical monitor,
// i.e. if it is within the same row as our monitor and to the right of it
let thisDeltaX = deltaX;
if (thisDeltaX !== 0) {
// find the monitor with the largest height
const maxMonitorHeight = monitors.reduce((maxHeight, monitor) => {
const monitorConnector = monitor[0];
const currentMode = monitorToCurrentModeMap[monitorConnector];
const currentHeight = currentMode[2];
return Math.max(maxHeight, currentHeight);
}, 0);
if (y >= rangeY[1] || y + maxMonitorHeight <= rangeY[0]) {
// monitors outside the y range are not affected by the width change
thisDeltaX = 0;
} else {
anyMonitorsChanged = true;
}
}
return [
x + thisDeltaX,
y,
newScale,
transform,
headsetAsPrimary ? hasOurMonitor : primary,
monitors.map((monitor) => {
const monitorConnector = monitor[0];
const isOurMonitor = monitorConnector === connectorName;
const [currentModeId, currentWidth, currentHeight] = monitorToCurrentModeMap[monitorConnector];
if (isOurMonitor) {
deltaX = bestFitMode.width - currentWidth;
rangeY = [y, y + currentHeight];
}
anyMonitorsChanged |= isOurMonitor && bestFitMode.modeId !== currentModeId;
return [
monitorConnector,
isOurMonitor ? bestFitMode.modeId : currentModeId,
{} // properties
];
})
];
});
// if our monitor is already properly configured, we can skip the ApplyMonitorsConfig call
if (anyMonitorsChanged) {
if (removeMonitorIndexes.length > 0) {
let removedPrimary = false;
// remove monitors that are not virtual or our monitor
removeMonitorIndexes.reverse().forEach((index) => {
const [x, y, scale, transform, primary, monitors, logMonProperties] = updatedLogicalMonitors[index];
if (primary) removedPrimary = true;
updatedLogicalMonitors.splice(index, 1);
});
// collect sizes based on modes of attached monitors
const logicalMonitorsWithSizes = updatedLogicalMonitors.map((logicalMonitor) => {
const [x, y, scale, transform, primary, monitors, logMonProperties] = logicalMonitor;
const {width, height} = monitors.reduce(({width, height}, monitor) => {
const monitorConnector = monitor[0];
const currentMode = monitorToCurrentModeMap[monitorConnector];
const currentWidth = currentMode[1];
const currentHeight = currentMode[2];
return {
width: Math.max(width, currentWidth),
height: Math.max(height, currentHeight)
};
}, {width: 0, height: 0});
return {
logicalMonitor,
width,
height,
xEnd: x + width,
yEnd: y + height
}
});
logicalMonitorsWithSizes.sort((a, b) => a.x === b.x ? a.y - b.y : a.x - b.x);
// this array will track monitors we've already corrected, but we'll toss it out since we're modifying the
// objects in the original array
const processedLogicalMonitors = [];
// make sure all monitors have a monitor adjacent
for (let i = 0; i < logicalMonitorsWithSizes.length; i++) {
const thisMonitor = logicalMonitorsWithSizes[i];
const [x, y, scale, transform, primary, monitors, logMonProperties] = thisMonitor.logicalMonitor;
const {xEnd, yEnd} = thisMonitor;
const hasOurMonitor = !!monitors.some((monitor) => monitor[0] === connectorName);
if (removedPrimary && hasOurMonitor) {
// if we removed the primary monitor, we need to set the glasses monitor as the new primary
thisMonitor.logicalMonitor[4] = true;
}
if (logicalMonitorsWithSizes.some((monitor, index) => {
if (index === i) return false;
const [monX, monY, monScale, monTransform, monPrimary, monMonitors, monLogMonProperties] = monitor.logicalMonitor;
const monXEnd = monitor.xEnd;
const monYEnd = monitor.yEnd;
const xOverlap = x < monXEnd && xEnd > monX;
const yOverlap = y < monYEnd && yEnd > monY;
// use top or left sides to determine if it's already adjacent
return (x === monXEnd && yOverlap) || (y === monYEnd && xOverlap);
})) {
// this monitor is already adjacent to another monitor, leave it as-is
processedLogicalMonitors.push(thisMonitor);
} else {
let newX = undefined;
let newY = undefined;
// move the monitor left until it runs into one
const procMonitorsByXEndDesc = [...processedLogicalMonitors].sort((a, b) => b.xEnd - a.xEnd);
for (let j = 0; j < procMonitorsByXEndDesc.length; j++) {
const procMonitor = procMonitorsByXEndDesc[j];
const [procX, procY, procScale, procTransform, procPrimary, procMonitors, procLogMonProperties] = procMonitor.logicalMonitor;
if (procMonitor.xEnd <= x && procY < yEnd && procMonitor.yEnd > y) {
newX = procMonitor.xEnd;
newY = y;
break;
}
}
if (newX === undefined) {
newX = 0;
// we didn't find an adjacent monitor to the left, now move it up until it runs into one
const procMonitorsByYEndDesc = [...processedLogicalMonitors].sort((a, b) => b.yEnd - a.yEnd);
for (let j = 0; j < procMonitorsByYEndDesc.length; j++) {
const procMonitor = procMonitorsByYEndDesc[j];
const [procX, procY, procScale, procTransform, procPrimary, procMonitors, procLogMonProperties] = procMonitor.logicalMonitor;
if (procMonitor.yEnd <= y && procX < thisMonitor.width && procMonitor.xEnd > 0) {
newY = procMonitor.yEnd;
break;
}
}
// if nothing found, put at origin
if (newY === undefined) newY = 0;
}
thisMonitor.logicalMonitor[0] = newX;
thisMonitor.logicalMonitor[1] = newY;
thisMonitor.xEnd = newX + thisMonitor.width;
thisMonitor.yEnd = newY + thisMonitor.height;
processedLogicalMonitors.push(thisMonitor);
}
}
}
Globals.logger.log_debug(`monitormanager.js performOptimalModeCheck updatedLogicalMonitors: ${JSON.stringify(updatedLogicalMonitors)}`);
displayConfigProxy.ApplyMonitorsConfigRemote(
serial,
1, // "temporary" config -- "permanent" might be pointless since we always do this check
updatedLogicalMonitors,
{}, // properties
(_result, error) => {
if (error) {
callback(null, `ApplyMonitorsConfig failed: ${error}`);
} else {
callback(true, null);
}
}
);
}
}
if (!anyMonitorsChanged) callback(false, null);
} else {
callback(null, `Monitor ${connectorName} not found in GetCurrentState result`);
}
}
});
}
// Monitor change handling
export const MonitorManager = GObject.registerClass({
Properties: {
'use-optimal-monitor-config': GObject.ParamSpec.boolean(
'use-optimal-monitor-config',
'Use optimal monitor configuration',
'Automatically set the optimal monitor configuration upon connection',
GObject.ParamFlags.READWRITE,
true
),
'use-highest-refresh-rate': GObject.ParamSpec.boolean(
'use-highest-refresh-rate',
'Use highest refresh rate',
'Set the highest refresh rate which choosing optimal configs',
GObject.ParamFlags.READWRITE,
true
),
'headset-as-primary': GObject.ParamSpec.boolean(
'headset-as-primary',
'Use headset as primary monitor',
'Automatically set the headset as the primary display upon connection',
GObject.ParamFlags.READWRITE,
false
),
'disable-physical-displays': GObject.ParamSpec.boolean(
'disable-physical-displays',
'Disable physical displays',
'Disable physical displays when a virtual display is connected',
GObject.ParamFlags.READWRITE,
true
),
'extension-path': GObject.ParamSpec.string(
'extension-path',
'Extension path',
'Path to the extension directory',
GObject.ParamFlags.READWRITE,
''
)
}
}, class MonitorManager extends GObject.Object {
constructor(params = {}) {
super(params);
this._monitorsChangedConnection = null;
this._displayConfigProxy = null;
this._monitorProperties = null;
this._changeHookFn = null;
this._needsConfigCheck = this.use_optimal_monitor_config || this.headset_as_primary || this.disable_physical_displays;
// help prevent certain actions from taking place multiple times in the event of rapid monitor updates
this._asyncRequestsInFlight = 0;
this._configCheckRequestsCount = 0;
this._enabled = false;
}
enable() {
Globals.logger.log_debug('MonitorManager enable');
newDisplayConfig(this.extension_path, ((proxy, error) => {
if (error) {
return;
}
this._displayConfigProxy = proxy;
this._on_monitors_change();
}).bind(this));
this._monitorsChangedConnection = Main.layoutManager.connect('monitors-changed', this._on_monitors_change.bind(this));
this._disable_physical_displays_connection = this.connect('notify::disable-physical-displays', this._on_disable_physical_displays_change.bind(this));
this._enabled = true;
}
disable() {
Globals.logger.log_debug('MonitorManager disable');
this.disconnect(this._disable_physical_displays_connection);
Main.layoutManager.disconnect(this._monitorsChangedConnection);
this._enabled = false;
this._disable_physical_displays_connection = null;
this._monitorsChangedConnection = null;
this._displayConfigProxy = null;
this._monitorProperties = null;
this._changeHookFn = null;
}
setChangeHook(fn) {
this._changeHookFn = fn;
}
getMonitors() {
return Main.layoutManager.monitors;
}
getMonitorPropertiesList() {
return this._monitorProperties;
}
// returns true if an async check is needed, caller should wait for the next change hook call
needsOptimalModeCheck(monitorConnector) {
Globals.logger.log_debug(`MonitorManager needsOptimalModeCheck: ${monitorConnector}`);
if (this._displayConfigProxy == null) {
Globals.logger.log('MonitorManager needsOptimalModeCheck: _displayConfigProxy not set!');
return false;
}
const isCheckingConfig = this._needsConfigCheck;
if (this._needsConfigCheck && this._asyncRequestsInFlight === 0) {
this._asyncRequestsInFlight++;
const configCheckCountSnapshot = this._configCheckRequestsCount;
const allowConfigUpdateFn = (() => {
// allow updates to the config if this is the only in-flight request and no more requests
// were made while we were waiting for the previous request to complete
return this._asyncRequestsInFlight === 1 && this._configCheckRequestsCount === configCheckCountSnapshot;
}).bind(this);
performOptimalModeCheck(this._displayConfigProxy, monitorConnector, this.headset_as_primary, this.use_highest_refresh_rate, this.disable_physical_displays, ((configChanged, error) => {
if (--this._asyncRequestsInFlight > 0) {
Globals.logger.log_debug(`MonitorManager needsOptimalModeCheck: ${this._asyncRequestsInFlight} async requests still pending, skipping change hook`);
return;
} else if (this._configCheckRequestsCount !== configCheckCountSnapshot) {
Globals.logger.log_debug('MonitorManager needsOptimalModeCheck: config checks requested while in-flight, skipping change hook');
return;
}
this._needsConfigCheck = false;
if (error) {
Globals.logger.log(`[ERROR] Failed to switch to optimal mode for monitor ${monitorConnector}: ${error}`);
// tell the extension to proceed, this should result in another config check
this._changeHookFn();
} else {
if (configChanged) {
Globals.logger.log(`Switched to optimal mode for monitor ${monitorConnector}`);
} else if (!!this._changeHookFn) {
Globals.logger.log_debug('MonitorManager needsOptimalModeCheck: no config change');
// no config change means this won't be triggered automatically, so trigger it manually
this._changeHookFn();
} else {
Globals.logger.log('MonitorManager needsOptimalModeCheck: can\'t trigger change hook, no hook set!');
}
}
}).bind(this), allowConfigUpdateFn);
} else if (!this._needsConfigCheck) {
Globals.logger.log_debug('MonitorManager needsOptimalModeCheck: skipping config check');
} else {
Globals.logger.log_debug(`MonitorManager needsOptimalModeCheck: skipping due to async requests ${this._asyncRequestsInFlight}`);
}
return isCheckingConfig;
}
_on_monitors_change() {
if (!this._enabled) return;
Globals.logger.log_debug('MonitorManager _on_monitors_change');
if (this._displayConfigProxy == null) {
return;
}
if (this.use_optimal_monitor_config || this.headset_as_primary || this.disable_physical_displays) {
this._needsConfigCheck = true;
this._configCheckRequestsCount++;
}
this._asyncRequestsInFlight++;
getMonitorConfig(this._displayConfigProxy, ((result, error) => {
this._asyncRequestsInFlight--;
if (error) {
Globals.logger.log(`[ERROR] Failed _on_monitors_change getMonitorConfig: ${error}`);
return;
}
const monitorProperties = [];
for (let i = 0; i < result.length; i++) {
const [monitorName, connectorName, vendor, product, serial, refreshRate] = result[i];
const monitorIndex = global.backend.get_monitor_manager().get_monitor_for_connector(connectorName);
Globals.logger.log_debug(`Found monitor ${monitorName}, vendor ${vendor}, product ${product}, serial ${serial}, connector ${connectorName}, index ${monitorIndex}`);
if (monitorIndex >= 0) {
monitorProperties[monitorIndex] = {
index: monitorIndex,
name: monitorName,
vendor: vendor,
product: product,
serial: serial,
connector: connectorName,
refreshRate: refreshRate
};
}
}
this._monitorProperties = monitorProperties;
if (!!this._changeHookFn) {
if (this._asyncRequestsInFlight === 0) {
this._changeHookFn();
} else {
Globals.logger.log_debug(`MonitorManager _on_monitors_change: ${this._asyncRequestsInFlight} requests still pending, skipping change hook`);
}
} else {
Globals.logger.log('MonitorManager _on_monitors_change: can\'t trigger change hook, no hook set!');
}
}).bind(this));
}
_on_disable_physical_displays_change() {
if (this._enabled && this.disable_physical_displays && !!this._changeHookFn) {
Globals.logger.log_debug('MonitorManager _on_disable_physical_displays_change triggering change hook');
this._needsConfigCheck = true;
this._configCheckRequestsCount++;
this._changeHookFn();
}
}
});