[gnome] Update extensions for v46
This commit is contained in:
@ -0,0 +1,857 @@
|
||||
/**
|
||||
* Driver for handling thresholds in Lenovo ThinkPad series for models since 2011
|
||||
*
|
||||
* Based on information from https://linrunner.de/tlp/faq/battery.html
|
||||
*
|
||||
* Original sources: https://github.com/linrunner/TLP/blob/main/bat.d/05-thinkpad
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
import GLib from 'gi://GLib';
|
||||
import GObject from 'gi://GObject';
|
||||
import Gio from 'gi://Gio';
|
||||
|
||||
// Driver constants
|
||||
const BASE_PATH = '/sys/class/power_supply';
|
||||
const START_FILE_OLD = 'charge_start_threshold'; // kernel 4.17 and newer
|
||||
const END_FILE_OLD = 'charge_stop_threshold'; // kernel 4.17 and newer
|
||||
const START_FILE_NEW = 'charge_control_start_threshold'; // kernel 5.9 and newer
|
||||
const END_FILE_NEW = 'charge_control_end_threshold'; // kernel 5.9 and newer
|
||||
|
||||
/**
|
||||
* Read file contents
|
||||
*
|
||||
* @param {string} path Path of file
|
||||
* @returns {string} File contents
|
||||
*/
|
||||
const readFile = function(path) {
|
||||
try {
|
||||
const f = Gio.File.new_for_path(path);
|
||||
const [, contents,] = f.load_contents(null);
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
return decoder.decode(contents);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read integer value from file
|
||||
*
|
||||
* @param {string} path Path of file
|
||||
* @returns {number|null} Return a integer or null
|
||||
*/
|
||||
const readFileInt = function(path) {
|
||||
try {
|
||||
const v = readFile(path);
|
||||
if (v) {
|
||||
return parseInt(v);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test file/direcory exists
|
||||
*
|
||||
* @param {string} path File/directory path
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const fileExists = function(path) {
|
||||
try {
|
||||
const f = Gio.File.new_for_path(path);
|
||||
return f.query_exists(null);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment object
|
||||
*/
|
||||
const Environment = GObject.registerClass({
|
||||
GTypeName: 'Environment',
|
||||
}, class Environment extends GObject.Object {
|
||||
|
||||
get productVersion() {
|
||||
if (this._productVersion === undefined) {
|
||||
const tmp = readFile('/sys/class/dmi/id/product_version');
|
||||
if (tmp) {
|
||||
// Remove non-alphanumeric characters
|
||||
const sanitize = /([^ A-Za-z0-9])+/g;
|
||||
this._productVersion = tmp.replace(sanitize, '');
|
||||
} else {
|
||||
this._productVersion = null;
|
||||
}
|
||||
}
|
||||
return this._productVersion;
|
||||
}
|
||||
|
||||
get kernelRelease() {
|
||||
if (this._kernelRelease === undefined) {
|
||||
try {
|
||||
let proc = Gio.Subprocess.new(
|
||||
['uname', '-r'],
|
||||
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
|
||||
);
|
||||
const [ok, stdout, stderr] = proc.communicate_utf8(null, null);
|
||||
proc = null;
|
||||
this._kernelRelease = stdout.trim();
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
return this._kernelRelease;
|
||||
}
|
||||
|
||||
get kernelMajorVersion() {
|
||||
if (this._kernelMajorVersion === undefined) {
|
||||
[this._kernelMajorVersion , this._kernelMinorVersion] = this.kernelRelease.split('.', 2);
|
||||
}
|
||||
return this._kernelMajorVersion;
|
||||
}
|
||||
|
||||
get kernelMinorVersion() {
|
||||
if (this._kernelMinorVersion === undefined) {
|
||||
[this._kernelMajorVersion , this._kernelMinorVersion] = this.kernelRelease.split('.', 2);
|
||||
}
|
||||
return this._kernelMinorVersion;
|
||||
}
|
||||
|
||||
checkMinKernelVersion(major, minor) {
|
||||
return (
|
||||
this.kernelMajorVersion > major ||
|
||||
(this,this.kernelMajorVersion === major && this.kernelMinorVersion >= minor)
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* Execute a command and generate events based on its result
|
||||
*
|
||||
* @param {string} command Command to execute
|
||||
* @param {boolean} asRoot True to run with elevated permissions
|
||||
*/
|
||||
const Runnable = GObject.registerClass({
|
||||
GTypeName: 'Runnable',
|
||||
Signals: {
|
||||
'command-completed': {
|
||||
param_types: [GObject.TYPE_JSOBJECT /* erro */]
|
||||
}
|
||||
}
|
||||
}, class Runnable extends GObject.Object {
|
||||
constructor(command, asRoot = false) {
|
||||
super();
|
||||
if (!command) {
|
||||
throw Error('The command cannot be an empty string');
|
||||
}
|
||||
this._command = command;
|
||||
this._asRoot = asRoot;
|
||||
}
|
||||
|
||||
run() {
|
||||
const argv = ['sh', '-c', this._command];
|
||||
|
||||
if (this._asRoot) {
|
||||
argv.unshift('pkexec');
|
||||
}
|
||||
|
||||
try {
|
||||
const [, pid] = GLib.spawn_async(null, argv, null, GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.DO_NOT_REAP_CHILD, null);
|
||||
|
||||
GLib.child_watch_add(GLib.PRIORITY_DEFAULT_IDLE, pid, (pid, status) => {
|
||||
try {
|
||||
GLib.spawn_check_exit_status(status);
|
||||
this.emit('command-completed', null);
|
||||
} catch(e) {
|
||||
if (e.code == 126) {
|
||||
// Cancelled
|
||||
} else {
|
||||
logError(e);
|
||||
this.emit('command-completed', e);
|
||||
}
|
||||
}
|
||||
GLib.spawn_close_pid(pid);
|
||||
});
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
this.emit('command-completed', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* ThinkPad battery object
|
||||
*/
|
||||
export const ThinkPadBattery = GObject.registerClass({
|
||||
GTypeName: 'ThinkPadBattery',
|
||||
Properties: {
|
||||
'environment': GObject.ParamSpec.object(
|
||||
'environment',
|
||||
'Environment',
|
||||
'Environment',
|
||||
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
|
||||
Environment.$gtype
|
||||
),
|
||||
'name': GObject.ParamSpec.string(
|
||||
'name',
|
||||
'Name',
|
||||
'Battery name',
|
||||
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
|
||||
null
|
||||
),
|
||||
'auth-required': GObject.ParamSpec.boolean(
|
||||
'auth-required',
|
||||
'Auth required',
|
||||
'Authorization is required to write the values',
|
||||
GObject.ParamFlags.READABLE,
|
||||
false
|
||||
),
|
||||
'is-active': GObject.ParamSpec.boolean(
|
||||
'is-active',
|
||||
'Is active',
|
||||
'Indicates if the thresholds are active or not',
|
||||
GObject.ParamFlags.READABLE,
|
||||
false
|
||||
),
|
||||
'is-available': GObject.ParamSpec.boolean(
|
||||
'is-available',
|
||||
'Is available',
|
||||
'Indicates if the battery are available or not',
|
||||
GObject.ParamFlags.READABLE,
|
||||
false
|
||||
),
|
||||
'pending-changes': GObject.ParamSpec.boolean(
|
||||
'pending-changes',
|
||||
'Pending changes',
|
||||
'Indicates if the current values do not match the configured values',
|
||||
GObject.ParamFlags.READABLE,
|
||||
false
|
||||
),
|
||||
'start-value': GObject.ParamSpec.int(
|
||||
'start-value',
|
||||
'Start value',
|
||||
'Current start value',
|
||||
GObject.ParamFlags.READABLE,
|
||||
0, 100,
|
||||
0
|
||||
),
|
||||
'end-value': GObject.ParamSpec.int(
|
||||
'end-value',
|
||||
'End value',
|
||||
'Current end value',
|
||||
GObject.ParamFlags.READABLE,
|
||||
0, 100,
|
||||
100
|
||||
),
|
||||
'settings': GObject.ParamSpec.object(
|
||||
'settings',
|
||||
'Settings',
|
||||
'Settings',
|
||||
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
|
||||
Gio.Settings.$gtype
|
||||
),
|
||||
},
|
||||
Signals: {
|
||||
'enable-completed': {
|
||||
param_types: [GObject.TYPE_JSOBJECT /* error */]
|
||||
},
|
||||
'disable-completed': {
|
||||
param_types: [GObject.TYPE_JSOBJECT /* error */]
|
||||
},
|
||||
},
|
||||
}, class ThinkPadBattery extends GObject.Object {
|
||||
constructor(constructProperties = {}) {
|
||||
super(constructProperties);
|
||||
|
||||
if (!this.name) {
|
||||
throw Error('Battery name not defined');
|
||||
}
|
||||
|
||||
// Signals handlers IDs
|
||||
this._monitorId = null;
|
||||
this._settingStartId = null;
|
||||
this._settingEndId = null;
|
||||
this._startId = null;
|
||||
this._endId = null;
|
||||
|
||||
// Battery directory
|
||||
this._baseDirectoryPath = `${BASE_PATH}/${this.name}`;
|
||||
this._baseDirectory = Gio.File.new_for_path(this._baseDirectoryPath);
|
||||
|
||||
// Set paths
|
||||
if (this.environment.checkMinKernelVersion(5, 9)) { // kernel 5.9 and newer
|
||||
this._startFilePath = `${BASE_PATH}/${this.name}/${START_FILE_NEW}`;
|
||||
this._endFilePath = `${BASE_PATH}/${this.name}/${END_FILE_NEW}`;
|
||||
} else if (this.environment.checkMinKernelVersion(4, 17)) { // kernel 4.17 and newer
|
||||
this._startFilePath = `${BASE_PATH}/${this.name}/${START_FILE_OLD}`;
|
||||
this._endFilePath = `${BASE_PATH}/${this.name}/${END_FILE_OLD}`;
|
||||
} else { // Unsupported kernel
|
||||
throw Error(`Unsupported kernel version (${this.environment.kernelRelease}). A kernel version greater than or equal to 4.17 is required.`);
|
||||
}
|
||||
|
||||
// Activate the directory monitor
|
||||
try {
|
||||
this._baseMonitor = this._baseDirectory.monitor_directory(Gio.FileMonitorFlags.NONE, null);
|
||||
this._monitorId = this._baseMonitor.connect(
|
||||
'changed', (obj, file, otherFile, eventType) => {
|
||||
const filePath = file.get_path();
|
||||
switch (eventType) {
|
||||
case Gio.FileMonitorEvent.CHANGES_DONE_HINT:
|
||||
case Gio.FileMonitorEvent.CREATED:
|
||||
case Gio.FileMonitorEvent.DELETED:
|
||||
switch (filePath) {
|
||||
case this._startFilePath:
|
||||
this.startValue = readFileInt(this._startFilePath);
|
||||
break;
|
||||
case this._endFilePath:
|
||||
this.endValue = readFileInt(this._endFilePath);
|
||||
break;
|
||||
case this._baseDirectoryPath:
|
||||
this.startValue = readFileInt(this._startFilePath);
|
||||
this.endValue = readFileInt(this._endFilePath);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case Gio.FileMonitorEvent.ATTRIBUTE_CHANGED:
|
||||
this.authRequired = this._checkAuthRequired();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
logError(e, this.name);
|
||||
}
|
||||
|
||||
// Update flags on changes in threshold values
|
||||
this._startId = this.connect(
|
||||
'notify::start-value', () => {
|
||||
this.isActive = this._checkActive();
|
||||
this.isAvailable = this.startValue !== null || this.endValue !== null;
|
||||
this.pendingChanges = this._checkPendingChanges();
|
||||
}
|
||||
);
|
||||
this._endId = this.connect(
|
||||
'notify::end-value', () => {
|
||||
this.isActive = this._checkActive();
|
||||
this.isAvailable = this.startValue !== null || this.endValue !== null;
|
||||
this.pendingChanges = this._checkPendingChanges();
|
||||
}
|
||||
);
|
||||
|
||||
// Update pending changes flag on changes to setting values
|
||||
this._settingStartId = this.settings.connect(
|
||||
`changed::start-${this.name.toLowerCase()}`, () => {
|
||||
this.pendingChanges = this._checkPendingChanges();
|
||||
}
|
||||
);
|
||||
this._settingEndId = this.settings.connect(
|
||||
`changed::end-${this.name.toLowerCase()}`, () => {
|
||||
this.pendingChanges = this._checkPendingChanges();
|
||||
}
|
||||
);
|
||||
|
||||
// Load initial values
|
||||
this.startValue = readFileInt(this._startFilePath);
|
||||
this.endValue = readFileInt(this._endFilePath);
|
||||
this.authRequired = this._checkAuthRequired();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if thresholds is active
|
||||
*
|
||||
* @returns {boolean} Returns true if the battery has active thresholds
|
||||
*/
|
||||
_checkActive() {
|
||||
// RegExs
|
||||
/* On some models the start threshold 0 is not allowed, instead 95 is used (Ex: E14 Gen 3)
|
||||
* See issue #8: https://gitlab.com/marcosdalvarez/thinkpad-battery-threshold-extension/-/issues/8
|
||||
*/
|
||||
const disableStart95 = /(Think[pP]ad) (E14 Gen 3)/;
|
||||
|
||||
if (this.environment.productVersion.search(disableStart95) >= 0) {
|
||||
return (this.startValue !== 95 && this.startValue !== null) || (this.endValue !== 100 && this.endValue !== null);
|
||||
} else {
|
||||
return (this.startValue !== 0 && this.startValue !== null) || (this.endValue !== 100 && this.endValue !== null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are changes to the settings that need to be applied
|
||||
*
|
||||
* @returns {boolean} Returns true if there are changes to the settings that need to be applied
|
||||
*/
|
||||
_checkPendingChanges() {
|
||||
const startSetting = this.settings.get_int(`start-${this.name.toLowerCase()}`);
|
||||
const endSetting = this.settings.get_int(`end-${this.name.toLowerCase()}`);
|
||||
return ((this.startValue != null && this.startValue !== startSetting) || (this.endValue != null && this.endValue !== endSetting));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if authentication is required to apply the changes
|
||||
*
|
||||
* @returns {boolean} Returns true if authentication is required to apply the changes
|
||||
*/
|
||||
_checkAuthRequired() {
|
||||
try {
|
||||
const f = Gio.File.new_for_path(this._startFilePath);
|
||||
const info = f.query_info('access::*', Gio.FileQueryInfoFlags.NONE, null);
|
||||
if (!info.get_attribute_boolean('access::can-write')) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignored
|
||||
}
|
||||
|
||||
try {
|
||||
const f = Gio.File.new_for_path(this._endFilePath);
|
||||
const info = f.query_info('access::*', Gio.FileQueryInfoFlags.NONE, null);
|
||||
if (!info.get_attribute_boolean('access::can-write')) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignored
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get authRequired() {
|
||||
return this._authRequired;
|
||||
}
|
||||
|
||||
set authRequired(value) {
|
||||
if (this.authRequired !== value) {
|
||||
this._authRequired = value;
|
||||
this.notify('auth-required');
|
||||
}
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
return this._isActive;
|
||||
}
|
||||
|
||||
set isActive(value) {
|
||||
if (this.isActive !== value) {
|
||||
this._isActive = value;
|
||||
this.notify('is-active');
|
||||
}
|
||||
}
|
||||
|
||||
get isAvailable() {
|
||||
return this._isAvaialble;
|
||||
}
|
||||
|
||||
set isAvailable(value) {
|
||||
if (this.isAvailable !== value) {
|
||||
this._isAvaialble = value;
|
||||
this.notify('is-available');
|
||||
}
|
||||
}
|
||||
|
||||
get startValue() {
|
||||
return this._startValue;
|
||||
}
|
||||
|
||||
set startValue(value) {
|
||||
if (this.startValue !== value) {
|
||||
this._startValue = value;
|
||||
this.notify('start-value');
|
||||
}
|
||||
}
|
||||
|
||||
get endValue() {
|
||||
return this._endValue;
|
||||
}
|
||||
|
||||
set endValue(value) {
|
||||
if (this.endValue !== value) {
|
||||
this._endValue = value;
|
||||
this.notify('end-value');
|
||||
}
|
||||
}
|
||||
|
||||
get pendingChanges() {
|
||||
return this._pendingChanges;
|
||||
}
|
||||
|
||||
set pendingChanges(value) {
|
||||
if (this.pendingChanges !== value) {
|
||||
this._pendingChanges = value;
|
||||
this.notify('pending-changes');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the command to set the thresholds.
|
||||
* This function does not run the command, it just returns the string corresponding to the command.
|
||||
* Passed values are not validated by the function, it is your responsibility to validate them first.
|
||||
*
|
||||
* @param {number} start Start value
|
||||
* @param {number} end End value
|
||||
* @returns {string|null} Command to modify the thresholds
|
||||
*/
|
||||
_getSetterCommand(start, end) {
|
||||
if (!this.isAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end)) {
|
||||
throw TypeError(`Thresholds (${start}/${end}) on battery (${this.name}) are not integer`);
|
||||
}
|
||||
|
||||
if (start < 0 || end > 100 || start >= end) {
|
||||
throw RangeError(`Thresholds (${start}/${end}) on battery (${this.name}) out of range`);
|
||||
}
|
||||
|
||||
// Commands
|
||||
const setStart = `echo ${start.toString()} > ${this._startFilePath}`;
|
||||
const setEnd = `echo ${end.toString()} > ${this._endFilePath}`;
|
||||
|
||||
let oldStart = this.startValue;
|
||||
let oldEnd = this.endValue;
|
||||
|
||||
if ((oldStart === start || oldStart === null) && (oldEnd === end || oldEnd === null)) {
|
||||
// Same thresholds
|
||||
return null;
|
||||
}
|
||||
|
||||
if (oldStart >= oldEnd) {
|
||||
// Invalid threshold reading, happens on ThinkPad E/L series
|
||||
oldStart = null;
|
||||
oldEnd = null;
|
||||
}
|
||||
|
||||
let command = null;
|
||||
|
||||
if (fileExists(this._startFilePath) && fileExists(this._endFilePath)) {
|
||||
if (oldStart === start) { // Same start, apply only stop
|
||||
command = setEnd;
|
||||
} else if (oldEnd === end) { // Same stop, apply only start
|
||||
command = setStart;
|
||||
} else {
|
||||
// Determine sequence
|
||||
let startStopSequence = true;
|
||||
if (oldEnd != null && start > oldEnd) {
|
||||
startStopSequence = false;
|
||||
}
|
||||
if (startStopSequence) {
|
||||
command = setStart + ' && ' + setEnd;
|
||||
} else {
|
||||
command = setEnd + ' && ' + setStart;
|
||||
}
|
||||
}
|
||||
} else if (fileExists(this._startFilePath)) { // Only start available
|
||||
command = setStart;
|
||||
} else if (fileExists(this._endFilePath)) { // Only stop available
|
||||
command = setEnd;
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the command string to enable the configured thresholds
|
||||
*
|
||||
* @returns {string|null} Enable thresholds command string
|
||||
*/
|
||||
get enableCommand() {
|
||||
const startSetting = this.settings.get_int(`start-${this.name.toLowerCase()}`);
|
||||
const endSetting = this.settings.get_int(`end-${this.name.toLowerCase()}`);
|
||||
return this._getSetterCommand(startSetting, endSetting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the command string to disable the thresholds
|
||||
*
|
||||
* @returns {string|null} Enable thresholds command string
|
||||
*/
|
||||
get disableCommand() {
|
||||
return this._getSetterCommand(0, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the configured thresholds
|
||||
*/
|
||||
enable() {
|
||||
const command = this.enableCommand;
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
const runnable = new Runnable(command, this.authRequired);
|
||||
runnable.connect('command-completed', (obj, error) => {
|
||||
this.emit('enable-completed', error);
|
||||
})
|
||||
runnable.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable thresholds
|
||||
*/
|
||||
disable() {
|
||||
const command = this.disableCommand;
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
const runnable = new Runnable(command, this.authRequired);
|
||||
runnable.connect('command-completed', (obj, error) => {
|
||||
this.emit('disable-completed', error);
|
||||
})
|
||||
runnable.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle thresholds status
|
||||
*/
|
||||
toggle() {
|
||||
this.isActive ? this.disable() : this.enable();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._startId) {
|
||||
this.disconnect(this._startId);
|
||||
}
|
||||
if (this._endId) {
|
||||
this.disconnect(this._endId);
|
||||
}
|
||||
if (this._settingStartId) {
|
||||
this.settings.disconnect(this._settingStartId);
|
||||
}
|
||||
if (this._settingEndId) {
|
||||
this.settings.disconnect(this._settingEndId);
|
||||
}
|
||||
if (this._monitorId) {
|
||||
this._baseMonitor.disconnect(this._monitorId);
|
||||
}
|
||||
this._baseMonitor.cancel();
|
||||
this._baseMonitor = null;
|
||||
this._baseDirectory = null;
|
||||
}
|
||||
});
|
||||
|
||||
export const ThinkPad = GObject.registerClass({
|
||||
GTypeName: 'ThinkPad',
|
||||
Properties: {
|
||||
'environment': GObject.ParamSpec.object(
|
||||
'environment',
|
||||
'Environment',
|
||||
'Environment',
|
||||
GObject.ParamFlags.READABLE,
|
||||
Environment.$gtype
|
||||
),
|
||||
'is-active': GObject.ParamSpec.boolean(
|
||||
'is-active',
|
||||
'Is active',
|
||||
'Indicates if the thresholds are active or not',
|
||||
GObject.ParamFlags.READABLE,
|
||||
false
|
||||
),
|
||||
'is-available': GObject.ParamSpec.boolean(
|
||||
'is-available',
|
||||
'Is available',
|
||||
'Indicates if the battery are available or not',
|
||||
GObject.ParamFlags.READABLE,
|
||||
false
|
||||
),
|
||||
'batteries': GObject.ParamSpec.jsobject(
|
||||
'batteries',
|
||||
'Batteries',
|
||||
'Batteries object',
|
||||
GObject.ParamFlags.READWRITE,
|
||||
[]
|
||||
),
|
||||
'settings': GObject.ParamSpec.object(
|
||||
'settings',
|
||||
'Settings',
|
||||
'Settings',
|
||||
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
|
||||
Gio.Settings.$gtype
|
||||
),
|
||||
},
|
||||
Signals: {
|
||||
'enable-battery-completed': {
|
||||
param_types: [GObject.TYPE_OBJECT /* battery */, GObject.TYPE_JSOBJECT /* error */]
|
||||
},
|
||||
'disable-battery-completed': {
|
||||
param_types: [GObject.TYPE_OBJECT /* battery */, GObject.TYPE_JSOBJECT /* error */]
|
||||
},
|
||||
'enable-all-completed': {
|
||||
param_types: [GObject.TYPE_JSOBJECT /* error */]
|
||||
},
|
||||
'disable-all-completed': {
|
||||
param_types: [GObject.TYPE_JSOBJECT /* error */]
|
||||
},
|
||||
},
|
||||
}, class ThinkPad extends GObject.Object {
|
||||
constructor(constructProperties = {}) {
|
||||
super(constructProperties);
|
||||
|
||||
if (!this.environment.checkMinKernelVersion(4, 17)) {
|
||||
logError(new Error(`Kernel ${this.environment.kernelRelease} not supported`));
|
||||
this.batteries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Signals handlers IDs
|
||||
this._batteriesId = {};
|
||||
|
||||
// Define batteries
|
||||
this.batteries = [
|
||||
new ThinkPadBattery({
|
||||
'environment': this.environment,
|
||||
'name': 'BAT0',
|
||||
'settings': this.settings
|
||||
}),
|
||||
new ThinkPadBattery({
|
||||
'environment': this.environment,
|
||||
'name': 'BAT1',
|
||||
'settings': this.settings
|
||||
}),
|
||||
];
|
||||
|
||||
// Connect the signals from the batteries to update the flags and notify commands
|
||||
this.batteries.forEach(battery => {
|
||||
const isAvailableId = battery.connect(
|
||||
'notify::is-available', (bat) => {
|
||||
this.isAvailable = this._checkAvailable();
|
||||
}
|
||||
);
|
||||
const isActiveId = battery.connect(
|
||||
'notify::is-active', (bat) => {
|
||||
this.isActive = this._checkActive();
|
||||
}
|
||||
);
|
||||
const enableCompletedId = battery.connect(
|
||||
'enable-completed', (bat, error) => {
|
||||
this.emit('enable-battery-completed', bat, error);
|
||||
}
|
||||
);
|
||||
const disableCompletedId = battery.connect(
|
||||
'disable-completed', (bat, error) => {
|
||||
this.emit('disable-battery-completed', bat, error);
|
||||
}
|
||||
);
|
||||
this._batteriesId[battery.name] = [isAvailableId, isActiveId, enableCompletedId, disableCompletedId];
|
||||
});
|
||||
|
||||
// Load initial values
|
||||
this.isAvailable = this._checkAvailable();
|
||||
this.isActive = this._checkActive();
|
||||
}
|
||||
|
||||
get environment() {
|
||||
if (this._environment === undefined) {
|
||||
this._environment = new Environment();
|
||||
}
|
||||
return this._environment;
|
||||
}
|
||||
|
||||
get isAvailable() {
|
||||
return this._isAvaialble;
|
||||
}
|
||||
|
||||
set isAvailable(value) {
|
||||
if (this.isAvailable !== value) {
|
||||
this._isAvaialble = value;
|
||||
this.notify('is-available');
|
||||
}
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
return this._isActive;
|
||||
}
|
||||
|
||||
set isActive(value) {
|
||||
if (this.isActive !== value) {
|
||||
this._isActive = value;
|
||||
this.notify('is-active');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if at least one battery has the thresholds active
|
||||
*
|
||||
* @returns {boolean} Returns true if at least one battery has the thresholds active
|
||||
*/
|
||||
_checkActive() {
|
||||
return this.batteries.some(bat => {
|
||||
return bat.isActive && bat.isAvailable;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if at least one battery is available to apply the thresholds
|
||||
*
|
||||
* @returns {boolean} Returns true if at least one battery is available to apply the thresholds
|
||||
*/
|
||||
_checkAvailable() {
|
||||
return this.batteries.some(bat => {
|
||||
return bat.isAvailable;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable all configured thresholds
|
||||
*/
|
||||
enableAll() {
|
||||
let command = '';
|
||||
let authRequired = false;
|
||||
this.batteries.forEach(battery => {
|
||||
const batteryCommand = battery.enableCommand;
|
||||
if (batteryCommand) {
|
||||
command = `${command}${command ? ' && ' : ''}${batteryCommand}`;
|
||||
authRequired = authRequired || battery.authRequired;
|
||||
}
|
||||
});
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
const runnable = new Runnable(command, authRequired);
|
||||
runnable.connect('command-completed', (obj, error) => {
|
||||
this.emit('enable-all-completed', error);
|
||||
})
|
||||
runnable.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all thresholds
|
||||
*/
|
||||
disableAll() {
|
||||
let command = '';
|
||||
let authRequired = false;
|
||||
this.batteries.forEach(battery => {
|
||||
const batteryCommand = battery.disableCommand;
|
||||
if (batteryCommand) {
|
||||
command = `${command}${command ? ' && ' : ''}${batteryCommand}`;
|
||||
authRequired = authRequired || battery.authRequired;
|
||||
}
|
||||
});
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
const runnable = new Runnable(command, authRequired);
|
||||
runnable.connect('command-completed', (obj, error) => {
|
||||
this.emit('disable-all-completed', error);
|
||||
})
|
||||
runnable.run();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._batteriesId) {
|
||||
this.batteries.forEach(battery => {
|
||||
const ids = this._batteriesId[battery.name];
|
||||
ids.forEach(id => {
|
||||
battery.disconnect(id);
|
||||
});
|
||||
battery.destroy()
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,431 @@
|
||||
'use strict';
|
||||
|
||||
import Clutter from 'gi://Clutter';
|
||||
import St from 'gi://St';
|
||||
import GObject from 'gi://GObject';
|
||||
import GLib from 'gi://GLib';
|
||||
import Gio from 'gi://Gio';
|
||||
|
||||
import {gettext as _, ngettext} from 'resource:///org/gnome/shell/extensions/extension.js';
|
||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js';
|
||||
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
||||
import * as QuickSettings from 'resource:///org/gnome/shell/ui/quickSettings.js';
|
||||
import * as Config from 'resource://org/gnome/shell/misc/config.js';
|
||||
|
||||
import { ThinkPad } from './driver.js';
|
||||
|
||||
const ICONS_PATH = GLib.Uri.resolve_relative(import.meta.url, '../icons', GLib.UriFlags.NONE);
|
||||
const SHELL_VERSION = Number(Config.PACKAGE_VERSION.split('.', 1));
|
||||
|
||||
/**
|
||||
* Get icon
|
||||
*
|
||||
* @param {string} iconName Icon name
|
||||
* @param {boolean} colorMode Color mode or symbolic
|
||||
* @returns {Gio.Icon}
|
||||
*/
|
||||
function getIcon(iconName, colorMode = false) {
|
||||
return Gio.icon_new_for_string(`${ICONS_PATH}/${iconName}${colorMode ? '' : '-symbolic'}.svg`);
|
||||
}
|
||||
|
||||
const BatteryItem = GObject.registerClass({
|
||||
GTypeName: 'BatteryItem',
|
||||
}, class BatteryItem extends PopupMenu.PopupImageMenuItem {
|
||||
constructor(battery, settings) {
|
||||
super('', null);
|
||||
|
||||
this._settings = settings;
|
||||
this._battery = battery;
|
||||
|
||||
// Flag to prevent the PopupImageMenuItem from being activated when the reload icon is clicked
|
||||
this._reloading = false;
|
||||
|
||||
const box = new St.BoxLayout({
|
||||
'opacity': 128,
|
||||
'x_expand': true,
|
||||
'x_align': Clutter.ActorAlign.END,
|
||||
'style': 'spacing: 5px;',
|
||||
});
|
||||
|
||||
this.add_child(box);
|
||||
|
||||
// Reload icon
|
||||
this._reload = new St.Icon({
|
||||
'icon-size': 16,
|
||||
'reactive': true,
|
||||
'icon-name': 'view-refresh-symbolic',
|
||||
});
|
||||
this._reload.connectObject(
|
||||
'button-press-event', () => {
|
||||
this._reloading = true;
|
||||
this._battery.enable();
|
||||
return true; // Does not prevent event propagation???
|
||||
},
|
||||
this
|
||||
);
|
||||
box.add_child(this._reload);
|
||||
|
||||
this._valuesLabel = new St.Label({
|
||||
'y_align': Clutter.ActorAlign.CENTER,
|
||||
'style': 'font-size: 0.75em;',
|
||||
});
|
||||
box.add_child(this._valuesLabel);
|
||||
|
||||
// Battery signals
|
||||
this._battery.connectObject(
|
||||
'notify', () => {
|
||||
this._update();
|
||||
},
|
||||
this
|
||||
);
|
||||
|
||||
// Settings changes
|
||||
this._settings.connectObject(
|
||||
'changed', () => {
|
||||
this._update();
|
||||
},
|
||||
this
|
||||
);
|
||||
|
||||
// Menu item action
|
||||
this.connectObject(
|
||||
'activate', () => {
|
||||
if (!this._reloading) {
|
||||
this._battery.toggle();
|
||||
}
|
||||
this._reloading = false;
|
||||
},
|
||||
'destroy', () => {
|
||||
this._settings.disconnectObject(this);
|
||||
this._battery.disconnectObject(this);
|
||||
this._reload.disconnectObject(this);
|
||||
this.disconnectObject(this);
|
||||
this._valuesLabel.destroy();
|
||||
this._valuesLabel = null;
|
||||
this._reload.destroy();
|
||||
this._reload = null;
|
||||
this._battery = null;
|
||||
this._settings = null;
|
||||
},
|
||||
this
|
||||
);
|
||||
|
||||
this._update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI
|
||||
*/
|
||||
_update() {
|
||||
const colorMode = this._settings.get_boolean('color-mode');
|
||||
// Menu text and icon
|
||||
if (this._battery.isActive) {
|
||||
// TRANSLATORS: %s is the name of the battery.
|
||||
this.label.text = _('Disable thresholds (%s)').format(this._battery.name);
|
||||
this.setIcon(getIcon('threshold-active', colorMode));
|
||||
// Status text
|
||||
const showCurrentValues = this._settings.get_boolean('show-current-values');
|
||||
if (showCurrentValues) {
|
||||
// TRANSLATORS: %d/%d are the [start/end] threshold values. The string %% is the percent symbol (may need to be escaped depending on the language)
|
||||
this._valuesLabel.text = _('%d/%d %%').format(this._battery.startValue || 0, this._battery.endValue || 100);
|
||||
this._valuesLabel.visible = true;
|
||||
} else {
|
||||
this._valuesLabel.visible = false;
|
||||
}
|
||||
} else {
|
||||
// TRANSLATORS: %s is the name of the battery.
|
||||
this.label.text = _('Enable thresholds (%s)').format(this._battery.name);
|
||||
this.setIcon(getIcon('threshold-inactive', colorMode ));
|
||||
this._valuesLabel.visible = false;
|
||||
}
|
||||
// Reload 'button'
|
||||
this._reload.visible = this._battery.pendingChanges && this._battery.isActive;
|
||||
// Menu item visibility
|
||||
this.visible = this._battery.isAvailable;
|
||||
}
|
||||
});
|
||||
|
||||
const ThresholdToggle = GObject.registerClass({
|
||||
GTypeName: 'ThresholdToggle',
|
||||
}, class ThresholdToggle extends QuickSettings.QuickMenuToggle {
|
||||
constructor(driver, extensionObject) {
|
||||
super({
|
||||
'title': _('Thresholds'),
|
||||
'gicon': getIcon('threshold-app'),
|
||||
'toggle-mode': false,
|
||||
//'subtitle': 'subtitle'
|
||||
});
|
||||
|
||||
// Header
|
||||
this.menu.setHeader(
|
||||
getIcon('threshold-app'), // Icon
|
||||
_('Battery Threshold'), // Title
|
||||
driver.environment.productVersion ? driver.environment.productVersion : _('Unknown model')// Subtitle
|
||||
);
|
||||
|
||||
// Unavailable
|
||||
this.unavailableMenuItem = new PopupMenu.PopupImageMenuItem(_('Thresholds not available'), getIcon('threshold-unknown'));
|
||||
this.unavailableMenuItem.sensitive = false;
|
||||
this.unavailableMenuItem.visible = false;
|
||||
this.menu.addMenuItem(this.unavailableMenuItem);
|
||||
|
||||
// Batteries
|
||||
driver.batteries.forEach(battery => {
|
||||
// Battery menu item
|
||||
const item = new BatteryItem(battery, extensionObject.getSettings());
|
||||
this.menu.addMenuItem(item);
|
||||
});
|
||||
|
||||
// Unavailable status
|
||||
this.unavailableMenuItem.visible = !driver.isAvailable;
|
||||
|
||||
// Checked status
|
||||
this.checked = driver.isActive;
|
||||
|
||||
// Driver signals
|
||||
driver.connectObject(
|
||||
'notify::is-active', () => {
|
||||
this.checked = driver.isActive;
|
||||
},
|
||||
'notify::is-available', () => {
|
||||
this.unavailableMenuItem.visible = !driver.isAvailable;
|
||||
},
|
||||
this
|
||||
);
|
||||
|
||||
// Signals
|
||||
this.connectObject(
|
||||
'clicked', () => {
|
||||
if (driver.isActive) {
|
||||
driver.disableAll();
|
||||
} else {
|
||||
driver.enableAll();
|
||||
}
|
||||
},
|
||||
'destroy', () => {
|
||||
this.disconnectObject(this);
|
||||
driver.disconnectObject(this);
|
||||
this.menu.removeAll();
|
||||
},
|
||||
this
|
||||
);
|
||||
|
||||
// Add an entry-point for more getSettings()
|
||||
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||
const settingsItem = this.menu.addAction(_('Thresholds settings'),
|
||||
() => extensionObject.openPreferences());
|
||||
|
||||
// Ensure the getSettings() are unavailable when the screen is locked
|
||||
settingsItem.visible = Main.sessionMode.allowSettings;
|
||||
this.menu._settingsActions[extensionObject.uuid] = settingsItem;
|
||||
}
|
||||
});
|
||||
|
||||
export const ThresholdIndicator = GObject.registerClass({
|
||||
GTypeName: 'ThresholdIndicator',
|
||||
}, class ThresholdIndicator extends QuickSettings.SystemIndicator {
|
||||
constructor(extensionObject) {
|
||||
super();
|
||||
|
||||
this._settings = extensionObject.getSettings();
|
||||
this._driver = new ThinkPad({'settings': this._settings})
|
||||
this._name = extensionObject.metadata.name;
|
||||
|
||||
this._indicator = this._addIndicator();
|
||||
this._indicator.gicon = getIcon('threshold-unknown');
|
||||
|
||||
this.quickSettingsItems.push(new ThresholdToggle(this._driver, extensionObject));
|
||||
|
||||
Main.panel.statusArea.quickSettings.addExternalIndicator(this);
|
||||
|
||||
this._updateIndicator();
|
||||
|
||||
// Driver signals
|
||||
this._driver.connectObject(
|
||||
'notify::is-available', () => {
|
||||
this._updateIndicator();
|
||||
},
|
||||
'notify::is-active', () => {
|
||||
this._updateIndicator();
|
||||
},
|
||||
'enable-battery-completed', (driver, battery, error) => {
|
||||
if (!error) {
|
||||
this._notifyEnabled(
|
||||
// TRANSLATORS: %s is the name of the battery. %d/%d are the [start/end] threshold values. The string %% is the percent symbol (may need to be escaped depending on the language)
|
||||
_('Battery (%s) charge thresholds enabled at %d/%d %%').format(
|
||||
battery.name, battery.startValue || 0, battery.endValue || 100
|
||||
)
|
||||
);
|
||||
} else {
|
||||
this._notifyError(
|
||||
// TRANSLATORS: The first %s is the name of the battery. The second %s is the error message. \n is new line.
|
||||
_('Failed to enable thresholds on battery %s. \nError: %s').format(
|
||||
battery.name, error.message
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
'disable-battery-completed', (driver, battery, error) => {
|
||||
if (!error) {
|
||||
this._notifyDisabled(
|
||||
// TRANSLATORS: %s is the name of the battery.
|
||||
_('Battery (%s) charge thresholds disabled').format(
|
||||
battery.name
|
||||
)
|
||||
);
|
||||
} else {
|
||||
this._notifyError(
|
||||
// TRANSLATORS: The first %s is the name of the battery. The second %s is the error message. \n is new line.
|
||||
_('Failed to disable thresholds on battery %s. \nError: %s').format(
|
||||
battery.name, error.message
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
'enable-all-completed', (driver, error) => {
|
||||
if (!error) {
|
||||
this._notifyEnabled(_('Thresholds enabled for all batteries'))
|
||||
} else {
|
||||
this._notifyError(
|
||||
// TRANSLATORS: %s is the error message. \n is new line.
|
||||
_('Failed to enable thresholds for all batteries. \nError: %s').format(
|
||||
error.message
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
'disable-all-completed', (driver, error) => {
|
||||
if (!error) {
|
||||
this._notifyDisabled(_('Thresholds disabled for all batteries'));
|
||||
} else {
|
||||
this._notifyError(
|
||||
// TRANSLATORS: %s is the error message. \n is new line.
|
||||
_('Failed to disable thresholds for all batteries. \nError: %s').format(
|
||||
error.message
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
this
|
||||
);
|
||||
|
||||
// Settings signals
|
||||
this._settings.connectObject(
|
||||
'changed::color-mode', () => {
|
||||
this._updateIndicator();
|
||||
},
|
||||
'changed::indicator-mode', () => {
|
||||
this._updateIndicator();
|
||||
},
|
||||
this
|
||||
);
|
||||
|
||||
this.connect('destroy', () => {
|
||||
this.quickSettingsItems.forEach(item => item.destroy());
|
||||
this._settings.disconnectObject(this);
|
||||
this._settings = null;
|
||||
this._driver.disconnectObject(this);
|
||||
this._driver.destroy();
|
||||
this._driver = null;
|
||||
this._extension = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update indicator (tray-icon)
|
||||
*/
|
||||
_updateIndicator() {
|
||||
const colorMode = this._settings.get_boolean('color-mode');
|
||||
if (this._driver.isAvailable) {
|
||||
if (this._driver.isActive) {
|
||||
this._indicator.gicon = getIcon('threshold-active', colorMode);
|
||||
} else {
|
||||
this._indicator.gicon = getIcon('threshold-inactive', colorMode);
|
||||
}
|
||||
} else {
|
||||
this._indicator.gicon = getIcon('threshold-unknown', colorMode);
|
||||
}
|
||||
|
||||
const indicatorMode = this._settings.get_enum('indicator-mode');
|
||||
switch (indicatorMode) {
|
||||
case 0: // Active
|
||||
this._indicator.visible = this._driver.isActive;
|
||||
break;
|
||||
case 1: // Inactive
|
||||
this._indicator.visible = !this._driver.isActive;
|
||||
break;
|
||||
case 2: // Always
|
||||
this._indicator.visible = true;
|
||||
break;
|
||||
case 3: // Never
|
||||
this._indicator.visible = false;
|
||||
break;
|
||||
default:
|
||||
this._indicator.visible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notificaion.
|
||||
*
|
||||
* @param {string} msg Title
|
||||
* @param {string} details Message
|
||||
* @param {string} iconName Icon name
|
||||
*/
|
||||
_notify(msg, details, iconName) {
|
||||
if (!this._settings.get_boolean('show-notifications')) return;
|
||||
const colorMode = this._settings.get_boolean('color-mode');
|
||||
if (SHELL_VERSION === 45) {
|
||||
const source = new MessageTray.Source(this._name);
|
||||
Main.messageTray.add(source);
|
||||
const notification = new MessageTray.Notification(
|
||||
source,
|
||||
msg,
|
||||
details,
|
||||
{gicon: getIcon(iconName, colorMode)}
|
||||
);
|
||||
notification.setTransient(true);
|
||||
source.showNotification(notification);
|
||||
} else {
|
||||
const source = new MessageTray.Source({'title': this._name});
|
||||
Main.messageTray.add(source);
|
||||
const notification = new MessageTray.Notification({
|
||||
source: source,
|
||||
title: msg,
|
||||
body: details,
|
||||
isTransient: true,
|
||||
gicon: getIcon(iconName, colorMode)
|
||||
});
|
||||
source.addNotification(notification);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error notification
|
||||
*
|
||||
* @param {string} message Message
|
||||
*/
|
||||
_notifyError(message) {
|
||||
this._notify(_('Battery Threshold'), message, 'threshold-error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show enabled notification
|
||||
*
|
||||
* @param {string} message Message
|
||||
*/
|
||||
_notifyEnabled(message) {
|
||||
this._notify(_('Battery Threshold'), message, 'threshold-active');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show disabled notification
|
||||
*
|
||||
* @param {string} message Message
|
||||
*/
|
||||
_notifyDisabled(message) {
|
||||
this._notify(_('Battery Threshold'), message, 'threshold-inactive');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user