429 lines
14 KiB
JavaScript
429 lines
14 KiB
JavaScript
|
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
|
||
|
//
|
||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||
|
|
||
|
import {watchService} from '../wl_clipboard.js';
|
||
|
|
||
|
import Gio from 'gi://Gio';
|
||
|
import GIRepository from 'gi://GIRepository';
|
||
|
import GLib from 'gi://GLib';
|
||
|
|
||
|
import Config from '../config.js';
|
||
|
import setup, {setupGettext} from '../utils/setup.js';
|
||
|
|
||
|
|
||
|
// Promise Wrappers
|
||
|
// We don't use top-level await since it returns control flow to importing module, causing bugs
|
||
|
import('gi://EBook').then(({default: EBook}) => {
|
||
|
Gio._promisify(EBook.BookClient, 'connect');
|
||
|
Gio._promisify(EBook.BookClient.prototype, 'get_view');
|
||
|
Gio._promisify(EBook.BookClient.prototype, 'get_contacts');
|
||
|
}).catch(console.debug);
|
||
|
import('gi://EDataServer').then(({default: EDataServer}) => {
|
||
|
Gio._promisify(EDataServer.SourceRegistry, 'new');
|
||
|
}).catch(console.debug);
|
||
|
|
||
|
Gio._promisify(Gio.AsyncInitable.prototype, 'init_async');
|
||
|
Gio._promisify(Gio.DBusConnection.prototype, 'call');
|
||
|
Gio._promisify(Gio.DBusProxy.prototype, 'call');
|
||
|
Gio._promisify(Gio.DataInputStream.prototype, 'read_line_async',
|
||
|
'read_line_finish_utf8');
|
||
|
Gio._promisify(Gio.File.prototype, 'delete_async');
|
||
|
Gio._promisify(Gio.File.prototype, 'enumerate_children_async');
|
||
|
Gio._promisify(Gio.File.prototype, 'load_contents_async');
|
||
|
Gio._promisify(Gio.File.prototype, 'mount_enclosing_volume');
|
||
|
Gio._promisify(Gio.File.prototype, 'query_info_async');
|
||
|
Gio._promisify(Gio.File.prototype, 'read_async');
|
||
|
Gio._promisify(Gio.File.prototype, 'replace_async');
|
||
|
Gio._promisify(Gio.File.prototype, 'replace_contents_bytes_async',
|
||
|
'replace_contents_finish');
|
||
|
Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async');
|
||
|
Gio._promisify(Gio.Mount.prototype, 'unmount_with_operation');
|
||
|
Gio._promisify(Gio.InputStream.prototype, 'close_async');
|
||
|
Gio._promisify(Gio.OutputStream.prototype, 'close_async');
|
||
|
Gio._promisify(Gio.OutputStream.prototype, 'splice_async');
|
||
|
Gio._promisify(Gio.OutputStream.prototype, 'write_all_async');
|
||
|
Gio._promisify(Gio.SocketClient.prototype, 'connect_async');
|
||
|
Gio._promisify(Gio.SocketListener.prototype, 'accept_async');
|
||
|
Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async');
|
||
|
Gio._promisify(Gio.Subprocess.prototype, 'wait_check_async');
|
||
|
Gio._promisify(Gio.TlsConnection.prototype, 'handshake_async');
|
||
|
Gio._promisify(Gio.DtlsConnection.prototype, 'handshake_async');
|
||
|
|
||
|
|
||
|
// User Directories
|
||
|
Config.CACHEDIR = GLib.build_filenamev([GLib.get_user_cache_dir(), 'gsconnect']);
|
||
|
Config.CONFIGDIR = GLib.build_filenamev([GLib.get_user_config_dir(), 'gsconnect']);
|
||
|
Config.RUNTIMEDIR = GLib.build_filenamev([GLib.get_user_runtime_dir(), 'gsconnect']);
|
||
|
|
||
|
// Bootstrap
|
||
|
const serviceFolder = GLib.path_get_dirname(GLib.filename_from_uri(import.meta.url)[0]);
|
||
|
const extensionFolder = GLib.path_get_dirname(serviceFolder);
|
||
|
setup(extensionFolder);
|
||
|
setupGettext();
|
||
|
|
||
|
if (Config.IS_USER) {
|
||
|
// Infer libdir by assuming gnome-shell shares a common prefix with gjs;
|
||
|
// assume the parent directory if it's not there
|
||
|
let libdir = GIRepository.Repository.get_search_path().find(path => {
|
||
|
return path.endsWith('/gjs/girepository-1.0');
|
||
|
}).replace('/gjs/girepository-1.0', '');
|
||
|
|
||
|
const gsdir = GLib.build_filenamev([libdir, 'gnome-shell']);
|
||
|
|
||
|
if (!GLib.file_test(gsdir, GLib.FileTest.IS_DIR)) {
|
||
|
const currentDir = `/${GLib.path_get_basename(libdir)}`;
|
||
|
libdir = libdir.replace(currentDir, '');
|
||
|
}
|
||
|
|
||
|
Config.GNOME_SHELL_LIBDIR = libdir;
|
||
|
}
|
||
|
|
||
|
|
||
|
// Load DBus interfaces
|
||
|
Config.DBUS = (() => {
|
||
|
const bytes = Gio.resources_lookup_data(
|
||
|
GLib.build_filenamev([Config.APP_PATH, `${Config.APP_ID}.xml`]),
|
||
|
Gio.ResourceLookupFlags.NONE
|
||
|
);
|
||
|
|
||
|
const xml = new TextDecoder().decode(bytes.toArray());
|
||
|
const dbus = Gio.DBusNodeInfo.new_for_xml(xml);
|
||
|
dbus.nodes.forEach(info => info.cache_build());
|
||
|
|
||
|
return dbus;
|
||
|
})();
|
||
|
|
||
|
|
||
|
// Init User Directories
|
||
|
for (const path of [Config.CACHEDIR, Config.CONFIGDIR, Config.RUNTIMEDIR])
|
||
|
GLib.mkdir_with_parents(path, 0o755);
|
||
|
|
||
|
|
||
|
globalThis.HAVE_GNOME = GLib.getenv('GSCONNECT_MODE')?.toLowerCase() !== 'cli' && (GLib.getenv('GNOME_SETUP_DISPLAY') !== null || GLib.getenv('XDG_CURRENT_DESKTOP')?.toUpperCase()?.includes('GNOME') || GLib.getenv('XDG_SESSION_DESKTOP')?.toLowerCase() === 'gnome');
|
||
|
|
||
|
|
||
|
/**
|
||
|
* A custom debug function that logs at LEVEL_MESSAGE to avoid the need for env
|
||
|
* variables to be set.
|
||
|
*
|
||
|
* @param {Error|string} message - A string or Error to log
|
||
|
* @param {string} [prefix] - An optional prefix for the warning
|
||
|
*/
|
||
|
const _debugCallerMatch = new RegExp(/([^@]*)@([^:]*):([^:]*)/);
|
||
|
// eslint-disable-next-line func-style
|
||
|
const _debugFunc = function (error, prefix = null) {
|
||
|
let caller, message;
|
||
|
|
||
|
if (error.stack) {
|
||
|
caller = error.stack.split('\n')[0];
|
||
|
message = `${error.message}\n${error.stack}`;
|
||
|
} else {
|
||
|
caller = (new Error()).stack.split('\n')[1];
|
||
|
message = JSON.stringify(error, null, 2);
|
||
|
}
|
||
|
|
||
|
if (prefix)
|
||
|
message = `${prefix}: ${message}`;
|
||
|
|
||
|
const [, func, file, line] = _debugCallerMatch.exec(caller);
|
||
|
const script = file.replace(Config.PACKAGE_DATADIR, '');
|
||
|
|
||
|
GLib.log_structured('GSConnect', GLib.LogLevelFlags.LEVEL_MESSAGE, {
|
||
|
'MESSAGE': `[${script}:${func}:${line}]: ${message}`,
|
||
|
'SYSLOG_IDENTIFIER': 'org.gnome.Shell.Extensions.GSConnect',
|
||
|
'CODE_FILE': file,
|
||
|
'CODE_FUNC': func,
|
||
|
'CODE_LINE': line,
|
||
|
});
|
||
|
};
|
||
|
|
||
|
// Swap the function out for a no-op anonymous function for speed
|
||
|
const settings = new Gio.Settings({
|
||
|
settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
|
||
|
});
|
||
|
|
||
|
settings.connect('changed::debug', (settings, key) => {
|
||
|
globalThis.debug = settings.get_boolean(key) ? _debugFunc : () => {};
|
||
|
});
|
||
|
|
||
|
if (settings.get_boolean('debug'))
|
||
|
globalThis.debug = _debugFunc;
|
||
|
else
|
||
|
globalThis.debug = () => {};
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Start wl_clipboard if not under Gnome
|
||
|
*/
|
||
|
if (!globalThis.HAVE_GNOME) {
|
||
|
debug('Not running as a Gnome extension');
|
||
|
watchService();
|
||
|
}
|
||
|
|
||
|
|
||
|
/**
|
||
|
* A simple (for now) pre-comparison sanitizer for phone numbers
|
||
|
* See: https://github.com/KDE/kdeconnect-kde/blob/master/smsapp/conversationlistmodel.cpp#L200-L210
|
||
|
*
|
||
|
* @return {string} Return the string stripped of leading 0, and ' ()-+'
|
||
|
*/
|
||
|
String.prototype.toPhoneNumber = function () {
|
||
|
const strippedNumber = this.replace(/^0*|[ ()+-]/g, '');
|
||
|
|
||
|
if (strippedNumber.length)
|
||
|
return strippedNumber;
|
||
|
|
||
|
return this;
|
||
|
};
|
||
|
|
||
|
|
||
|
/**
|
||
|
* A simple equality check for phone numbers based on `toPhoneNumber()`
|
||
|
*
|
||
|
* @param {string} number - A phone number string to compare
|
||
|
* @return {boolean} If `this` and @number are equivalent phone numbers
|
||
|
*/
|
||
|
String.prototype.equalsPhoneNumber = function (number) {
|
||
|
const a = this.toPhoneNumber();
|
||
|
const b = number.toPhoneNumber();
|
||
|
|
||
|
return (a.length && b.length && (a.endsWith(b) || b.endsWith(a)));
|
||
|
};
|
||
|
|
||
|
|
||
|
/**
|
||
|
* An implementation of `rm -rf` in Gio
|
||
|
*
|
||
|
* @param {Gio.File|string} file - a GFile or filepath
|
||
|
*/
|
||
|
Gio.File.rm_rf = function (file) {
|
||
|
try {
|
||
|
if (typeof file === 'string')
|
||
|
file = Gio.File.new_for_path(file);
|
||
|
|
||
|
try {
|
||
|
const iter = file.enumerate_children(
|
||
|
'standard::name',
|
||
|
Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
|
||
|
null
|
||
|
);
|
||
|
|
||
|
let info;
|
||
|
|
||
|
while ((info = iter.next_file(null)))
|
||
|
Gio.File.rm_rf(iter.get_child(info));
|
||
|
|
||
|
iter.close(null);
|
||
|
} catch (e) {
|
||
|
// Silence errors
|
||
|
}
|
||
|
|
||
|
file.delete(null);
|
||
|
} catch (e) {
|
||
|
// Silence errors
|
||
|
}
|
||
|
};
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Extend GLib.Variant with a static method to recursively pack a variant
|
||
|
*
|
||
|
* @param {*} [obj] - May be a GLib.Variant, Array, standard Object or literal.
|
||
|
* @return {GLib.Variant} The resulting GVariant
|
||
|
*/
|
||
|
function _full_pack(obj) {
|
||
|
let packed;
|
||
|
const type = typeof obj;
|
||
|
|
||
|
switch (true) {
|
||
|
case (obj instanceof GLib.Variant):
|
||
|
return obj;
|
||
|
|
||
|
case (type === 'string'):
|
||
|
return GLib.Variant.new('s', obj);
|
||
|
|
||
|
case (type === 'number'):
|
||
|
return GLib.Variant.new('d', obj);
|
||
|
|
||
|
case (type === 'boolean'):
|
||
|
return GLib.Variant.new('b', obj);
|
||
|
|
||
|
case (obj instanceof Uint8Array):
|
||
|
return GLib.Variant.new('ay', obj);
|
||
|
|
||
|
case (obj === null):
|
||
|
return GLib.Variant.new('mv', null);
|
||
|
|
||
|
case (typeof obj.map === 'function'):
|
||
|
return GLib.Variant.new(
|
||
|
'av',
|
||
|
obj.filter(e => e !== undefined).map(e => _full_pack(e))
|
||
|
);
|
||
|
|
||
|
case (obj instanceof Gio.Icon):
|
||
|
return obj.serialize();
|
||
|
|
||
|
case (type === 'object'):
|
||
|
packed = {};
|
||
|
|
||
|
for (const [key, val] of Object.entries(obj)) {
|
||
|
if (val !== undefined)
|
||
|
packed[key] = _full_pack(val);
|
||
|
}
|
||
|
|
||
|
return GLib.Variant.new('a{sv}', packed);
|
||
|
|
||
|
default:
|
||
|
throw Error(`Unsupported type '${type}': ${obj}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
GLib.Variant.full_pack = _full_pack;
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Extend GLib.Variant with a method to recursively deepUnpack() a variant
|
||
|
*
|
||
|
* @param {*} [obj] - May be a GLib.Variant, Array, standard Object or literal.
|
||
|
* @return {*} The resulting object
|
||
|
*/
|
||
|
function _full_unpack(obj) {
|
||
|
obj = (obj === undefined) ? this : obj;
|
||
|
const unpacked = {};
|
||
|
|
||
|
switch (true) {
|
||
|
case (obj === null):
|
||
|
return obj;
|
||
|
|
||
|
case (obj instanceof GLib.Variant):
|
||
|
return _full_unpack(obj.deepUnpack());
|
||
|
|
||
|
case (obj instanceof Uint8Array):
|
||
|
return obj;
|
||
|
|
||
|
case (typeof obj.map === 'function'):
|
||
|
return obj.map(e => _full_unpack(e));
|
||
|
|
||
|
case (typeof obj === 'object'):
|
||
|
for (const [key, value] of Object.entries(obj)) {
|
||
|
// Try to detect and deserialize GIcons
|
||
|
try {
|
||
|
if (key === 'icon' && value.get_type_string() === '(sv)')
|
||
|
unpacked[key] = Gio.Icon.deserialize(value);
|
||
|
else
|
||
|
unpacked[key] = _full_unpack(value);
|
||
|
} catch (e) {
|
||
|
unpacked[key] = _full_unpack(value);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return unpacked;
|
||
|
|
||
|
default:
|
||
|
return obj;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
GLib.Variant.prototype.full_unpack = _full_unpack;
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Creates a GTlsCertificate from the PEM-encoded data in @cert_path and
|
||
|
* @key_path. If either are missing a new pair will be generated.
|
||
|
*
|
||
|
* Additionally, the private key will be added using ssh-add to allow sftp
|
||
|
* connections using Gio.
|
||
|
*
|
||
|
* See: https://github.com/KDE/kdeconnect-kde/blob/master/core/kdeconnectconfig.cpp#L119
|
||
|
*
|
||
|
* @param {string} certPath - Absolute path to a x509 certificate in PEM format
|
||
|
* @param {string} keyPath - Absolute path to a private key in PEM format
|
||
|
* @param {string} commonName - A unique common name for the certificate
|
||
|
* @return {Gio.TlsCertificate} A TLS certificate
|
||
|
*/
|
||
|
Gio.TlsCertificate.new_for_paths = function (certPath, keyPath, commonName = null) {
|
||
|
// Check if the certificate/key pair already exists
|
||
|
const certExists = GLib.file_test(certPath, GLib.FileTest.EXISTS);
|
||
|
const keyExists = GLib.file_test(keyPath, GLib.FileTest.EXISTS);
|
||
|
|
||
|
// Create a new certificate and private key if necessary
|
||
|
if (!certExists || !keyExists) {
|
||
|
// If we weren't passed a common name, generate a random one
|
||
|
if (!commonName)
|
||
|
commonName = GLib.uuid_string_random();
|
||
|
|
||
|
const proc = new Gio.Subprocess({
|
||
|
argv: [
|
||
|
Config.OPENSSL_PATH, 'req',
|
||
|
'-new', '-x509', '-sha256',
|
||
|
'-out', certPath,
|
||
|
'-newkey', 'rsa:4096', '-nodes',
|
||
|
'-keyout', keyPath,
|
||
|
'-days', '3650',
|
||
|
'-subj', `/O=andyholmes.github.io/OU=GSConnect/CN=${commonName}`,
|
||
|
],
|
||
|
flags: (Gio.SubprocessFlags.STDOUT_SILENCE |
|
||
|
Gio.SubprocessFlags.STDERR_SILENCE),
|
||
|
});
|
||
|
proc.init(null);
|
||
|
proc.wait_check(null);
|
||
|
}
|
||
|
|
||
|
return Gio.TlsCertificate.new_from_files(certPath, keyPath);
|
||
|
};
|
||
|
|
||
|
Object.defineProperties(Gio.TlsCertificate.prototype, {
|
||
|
/**
|
||
|
* The common name of the certificate.
|
||
|
*/
|
||
|
'common_name': {
|
||
|
get: function () {
|
||
|
if (!this.__common_name) {
|
||
|
const proc = new Gio.Subprocess({
|
||
|
argv: [Config.OPENSSL_PATH, 'x509', '-noout', '-subject', '-inform', 'pem'],
|
||
|
flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE,
|
||
|
});
|
||
|
proc.init(null);
|
||
|
|
||
|
const stdout = proc.communicate_utf8(this.certificate_pem, null)[1];
|
||
|
this.__common_name = /(?:cn|CN) ?= ?([^,\n]*)/.exec(stdout)[1];
|
||
|
}
|
||
|
|
||
|
return this.__common_name;
|
||
|
},
|
||
|
configurable: true,
|
||
|
enumerable: true,
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Get just the pubkey as a DER ByteArray of a certificate.
|
||
|
*
|
||
|
* @return {GLib.Bytes} The pubkey as DER of the certificate.
|
||
|
*/
|
||
|
'pubkey_der': {
|
||
|
value: function () {
|
||
|
if (!this.__pubkey_der) {
|
||
|
let proc = new Gio.Subprocess({
|
||
|
argv: [Config.OPENSSL_PATH, 'x509', '-noout', '-pubkey', '-inform', 'pem'],
|
||
|
flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE,
|
||
|
});
|
||
|
proc.init(null);
|
||
|
|
||
|
const pubkey = proc.communicate_utf8(this.certificate_pem, null)[1];
|
||
|
proc = new Gio.Subprocess({
|
||
|
argv: [Config.OPENSSL_PATH, 'pkey', '-pubin', '-inform', 'pem', '-outform', 'der'],
|
||
|
flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE,
|
||
|
});
|
||
|
proc.init(null);
|
||
|
this.__pubkey_der = proc.communicate(new TextEncoder().encode(pubkey), null)[1];
|
||
|
}
|
||
|
|
||
|
return this.__pubkey_der;
|
||
|
},
|
||
|
configurable: true,
|
||
|
enumerable: false,
|
||
|
},
|
||
|
|
||
|
});
|