init.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. import {watchService} from '../wl_clipboard.js';
  5. import Gio from 'gi://Gio';
  6. import GIRepository from 'gi://GIRepository';
  7. import GLib from 'gi://GLib';
  8. import Config from '../config.js';
  9. import {setup, setupGettext} from '../utils/setup.js';
  10. import {MissingOpensslError} from '../utils/exceptions.js';
  11. // Promise Wrappers
  12. // We don't use top-level await since it returns control flow to importing module, causing bugs
  13. import('gi://EBook').then(({default: EBook}) => {
  14. Gio._promisify(EBook.BookClient, 'connect');
  15. Gio._promisify(EBook.BookClient.prototype, 'get_view');
  16. Gio._promisify(EBook.BookClient.prototype, 'get_contacts');
  17. }).catch(console.debug);
  18. import('gi://EDataServer').then(({default: EDataServer}) => {
  19. Gio._promisify(EDataServer.SourceRegistry, 'new');
  20. }).catch(console.debug);
  21. Gio._promisify(Gio.AsyncInitable.prototype, 'init_async');
  22. Gio._promisify(Gio.DBusConnection.prototype, 'call');
  23. Gio._promisify(Gio.DBusProxy.prototype, 'call');
  24. Gio._promisify(Gio.DataInputStream.prototype, 'read_line_async',
  25. 'read_line_finish_utf8');
  26. Gio._promisify(Gio.File.prototype, 'delete_async');
  27. Gio._promisify(Gio.File.prototype, 'enumerate_children_async');
  28. Gio._promisify(Gio.File.prototype, 'load_contents_async');
  29. Gio._promisify(Gio.File.prototype, 'mount_enclosing_volume');
  30. Gio._promisify(Gio.File.prototype, 'query_info_async');
  31. Gio._promisify(Gio.File.prototype, 'read_async');
  32. Gio._promisify(Gio.File.prototype, 'replace_async');
  33. Gio._promisify(Gio.File.prototype, 'replace_contents_bytes_async',
  34. 'replace_contents_finish');
  35. Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async');
  36. Gio._promisify(Gio.Mount.prototype, 'unmount_with_operation');
  37. Gio._promisify(Gio.InputStream.prototype, 'close_async');
  38. Gio._promisify(Gio.OutputStream.prototype, 'close_async');
  39. Gio._promisify(Gio.OutputStream.prototype, 'splice_async');
  40. Gio._promisify(Gio.OutputStream.prototype, 'write_all_async');
  41. Gio._promisify(Gio.SocketClient.prototype, 'connect_async');
  42. Gio._promisify(Gio.SocketListener.prototype, 'accept_async');
  43. Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async');
  44. Gio._promisify(Gio.Subprocess.prototype, 'wait_check_async');
  45. Gio._promisify(Gio.TlsConnection.prototype, 'handshake_async');
  46. Gio._promisify(Gio.DtlsConnection.prototype, 'handshake_async');
  47. // User Directories
  48. Config.CACHEDIR = GLib.build_filenamev([GLib.get_user_cache_dir(), 'gsconnect']);
  49. Config.CONFIGDIR = GLib.build_filenamev([GLib.get_user_config_dir(), 'gsconnect']);
  50. Config.RUNTIMEDIR = GLib.build_filenamev([GLib.get_user_runtime_dir(), 'gsconnect']);
  51. // Bootstrap
  52. const serviceFolder = GLib.path_get_dirname(GLib.filename_from_uri(import.meta.url)[0]);
  53. const extensionFolder = GLib.path_get_dirname(serviceFolder);
  54. setup(extensionFolder);
  55. setupGettext();
  56. if (Config.IS_USER) {
  57. // Infer libdir by assuming gnome-shell shares a common prefix with gjs;
  58. // assume the parent directory if it's not there
  59. let gir_paths;
  60. if (GIRepository.Repository.hasOwnProperty('get_search_path')) {
  61. // GNOME <= 48 / GIRepository 2.0
  62. gir_paths = GIRepository.Repository.get_search_path();
  63. } else {
  64. // GNOME 49+ / GIRepository 3.0
  65. const repo = GIRepository.Repository.dup_default();
  66. gir_paths = repo.get_search_path();
  67. }
  68. let libdir = gir_paths.find(path => {
  69. return path.endsWith('/gjs/girepository-1.0');
  70. }).replace('/gjs/girepository-1.0', '');
  71. const gsdir = GLib.build_filenamev([libdir, 'gnome-shell']);
  72. if (!GLib.file_test(gsdir, GLib.FileTest.IS_DIR)) {
  73. const currentDir = `/${GLib.path_get_basename(libdir)}`;
  74. libdir = libdir.replace(currentDir, '');
  75. }
  76. Config.GNOME_SHELL_LIBDIR = libdir;
  77. }
  78. // Load DBus interfaces
  79. Config.DBUS = (() => {
  80. const bytes = Gio.resources_lookup_data(
  81. GLib.build_filenamev([Config.APP_PATH, `${Config.APP_ID}.xml`]),
  82. Gio.ResourceLookupFlags.NONE
  83. );
  84. const xml = new TextDecoder().decode(bytes.toArray());
  85. const dbus = Gio.DBusNodeInfo.new_for_xml(xml);
  86. dbus.nodes.forEach(info => info.cache_build());
  87. return dbus;
  88. })();
  89. // Init User Directories
  90. for (const path of [Config.CACHEDIR, Config.CONFIGDIR, Config.RUNTIMEDIR])
  91. GLib.mkdir_with_parents(path, 0o755);
  92. 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');
  93. /**
  94. * A custom debug function that logs at LEVEL_MESSAGE to avoid the need for env
  95. * variables to be set.
  96. *
  97. * @param {Error|string} message - A string or Error to log
  98. * @param {string} [prefix] - An optional prefix for the warning
  99. */
  100. const _debugCallerMatch = new RegExp(/^([^@]+)@(.*):(\d+):(\d+)$/);
  101. // eslint-disable-next-line func-style
  102. const _debugFunc = function (error, prefix = null) {
  103. let caller, message;
  104. if (error.stack) {
  105. caller = error.stack.split('\n')[0];
  106. message = `${error.message}\n${error.stack}`;
  107. } else {
  108. caller = (new Error()).stack.split('\n')[1];
  109. message = JSON.stringify(error, null, 2);
  110. }
  111. if (prefix)
  112. message = `${prefix}: ${message}`;
  113. const [, func, file, line] = _debugCallerMatch.exec(caller);
  114. let script = file.replace(Config.PACKAGE_DATADIR, '');
  115. if (script.startsWith('file:///'))
  116. script = script.slice(8);
  117. GLib.log_structured('GSConnect', GLib.LogLevelFlags.LEVEL_MESSAGE, {
  118. 'MESSAGE': `[${script}:${func}:${line}]: ${message}`,
  119. 'SYSLOG_IDENTIFIER': 'org.gnome.Shell.Extensions.GSConnect',
  120. 'CODE_FILE': file,
  121. 'CODE_FUNC': func,
  122. 'CODE_LINE': line,
  123. });
  124. };
  125. globalThis._debugFunc = _debugFunc;
  126. const settings = new Gio.Settings({
  127. settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
  128. });
  129. if (settings.get_boolean('debug')) {
  130. globalThis.debug = globalThis._debugFunc;
  131. } else {
  132. // Swap the function out for a no-op anonymous function for speed
  133. globalThis.debug = () => {};
  134. }
  135. /**
  136. * Start wl_clipboard if not under Gnome
  137. */
  138. if (!globalThis.HAVE_GNOME) {
  139. debug('Not running as a Gnome extension');
  140. watchService();
  141. }
  142. /**
  143. * A simple (for now) pre-comparison sanitizer for phone numbers
  144. * See: https://github.com/KDE/kdeconnect-kde/blob/master/smsapp/conversationlistmodel.cpp#L200-L210
  145. *
  146. * @returns {string} Return the string stripped of leading 0, and ' ()-+'
  147. */
  148. String.prototype.toPhoneNumber = function () {
  149. const strippedNumber = this.replace(/^0*|[ ()+-]/g, '');
  150. if (strippedNumber.length)
  151. return strippedNumber;
  152. return this;
  153. };
  154. /**
  155. * A simple equality check for phone numbers based on `toPhoneNumber()`
  156. *
  157. * @param {string} number - A phone number string to compare
  158. * @returns {boolean} If `this` and @number are equivalent phone numbers
  159. */
  160. String.prototype.equalsPhoneNumber = function (number) {
  161. const a = this.toPhoneNumber();
  162. const b = number.toPhoneNumber();
  163. return (a.length && b.length && (a.endsWith(b) || b.endsWith(a)));
  164. };
  165. /**
  166. * An implementation of `rm -rf` in Gio
  167. *
  168. * @param {Gio.File|string} file - a GFile or filepath
  169. */
  170. Gio.File.rm_rf = function (file) {
  171. try {
  172. if (typeof file === 'string')
  173. file = Gio.File.new_for_path(file);
  174. try {
  175. const iter = file.enumerate_children(
  176. 'standard::name',
  177. Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
  178. null
  179. );
  180. let info;
  181. while ((info = iter.next_file(null)))
  182. Gio.File.rm_rf(iter.get_child(info));
  183. iter.close(null);
  184. } catch {
  185. // Silence errors
  186. }
  187. file.delete(null);
  188. } catch {
  189. // Silence errors
  190. }
  191. };
  192. /**
  193. * Extend GLib.Variant with a static method to recursively pack a variant
  194. *
  195. * @param {*} [obj] - May be a GLib.Variant, Array, standard Object or literal.
  196. * @returns {GLib.Variant} The resulting GVariant
  197. */
  198. function _full_pack(obj) {
  199. let packed;
  200. const type = typeof obj;
  201. switch (true) {
  202. case (obj instanceof GLib.Variant):
  203. return obj;
  204. case (type === 'string'):
  205. return GLib.Variant.new('s', obj);
  206. case (type === 'number'):
  207. return GLib.Variant.new('d', obj);
  208. case (type === 'boolean'):
  209. return GLib.Variant.new('b', obj);
  210. case (obj instanceof Uint8Array):
  211. return GLib.Variant.new('ay', obj);
  212. case (obj === null):
  213. return GLib.Variant.new('mv', null);
  214. case (typeof obj.map === 'function'):
  215. return GLib.Variant.new(
  216. 'av',
  217. obj.filter(e => e !== undefined).map(e => _full_pack(e))
  218. );
  219. case (obj instanceof Gio.Icon):
  220. return obj.serialize();
  221. case (type === 'object'):
  222. packed = {};
  223. for (const [key, val] of Object.entries(obj)) {
  224. if (val !== undefined)
  225. packed[key] = _full_pack(val);
  226. }
  227. return GLib.Variant.new('a{sv}', packed);
  228. default:
  229. throw Error(`Unsupported type '${type}': ${obj}`);
  230. }
  231. }
  232. GLib.Variant.full_pack = _full_pack;
  233. /**
  234. * Extend GLib.Variant with a method to recursively deepUnpack() a variant
  235. *
  236. * @param {*} [obj] - May be a GLib.Variant, Array, standard Object or literal.
  237. * @returns {*} The resulting object
  238. */
  239. function _full_unpack(obj) {
  240. obj = (obj === undefined) ? this : obj;
  241. const unpacked = {};
  242. switch (true) {
  243. case (obj === null):
  244. return obj;
  245. case (obj instanceof GLib.Variant):
  246. return _full_unpack(obj.deepUnpack());
  247. case (obj instanceof Uint8Array):
  248. return obj;
  249. case (typeof obj.map === 'function'):
  250. return obj.map(e => _full_unpack(e));
  251. case (typeof obj === 'object'):
  252. for (const [key, value] of Object.entries(obj)) {
  253. // Try to detect and deserialize GIcons
  254. try {
  255. if (key === 'icon' && value.get_type_string() === '(sv)')
  256. unpacked[key] = Gio.Icon.deserialize(value);
  257. else
  258. unpacked[key] = _full_unpack(value);
  259. } catch {
  260. unpacked[key] = _full_unpack(value);
  261. }
  262. }
  263. return unpacked;
  264. default:
  265. return obj;
  266. }
  267. }
  268. GLib.Variant.prototype.full_unpack = _full_unpack;
  269. /**
  270. * Creates a GTlsCertificate from the PEM-encoded data in %cert_path and
  271. * %key_path. If either are missing a new pair will be generated.
  272. *
  273. * Additionally, the private key will be added using ssh-add to allow sftp
  274. * connections using Gio.
  275. *
  276. * See: https://github.com/KDE/kdeconnect-kde/blob/master/core/kdeconnectconfig.cpp#L119
  277. *
  278. * @param {string} certPath - Absolute path to a x509 certificate in PEM format
  279. * @param {string} keyPath - Absolute path to a private key in PEM format
  280. * @param {string} commonName - A unique common name for the certificate
  281. * @returns {Gio.TlsCertificate} A TLS certificate
  282. * @throws MissingOpensslError on missing openssl binary
  283. */
  284. Gio.TlsCertificate.new_for_paths = function (certPath, keyPath, commonName = null) {
  285. if (GLib.find_program_in_path(Config.OPENSSL_PATH) === null) {
  286. const error = new MissingOpensslError();
  287. error.name = _('OpenSSL not found');
  288. error.url = `${Config.PACKAGE_URL}/wiki/Error#openssl-not-found`;
  289. throw error;
  290. }
  291. // Check if the certificate/key pair already exists
  292. const certExists = GLib.file_test(certPath, GLib.FileTest.EXISTS);
  293. const keyExists = GLib.file_test(keyPath, GLib.FileTest.EXISTS);
  294. // Create a new certificate and private key if necessary
  295. if (!certExists || !keyExists) {
  296. // If we weren't passed a common name, generate a random one
  297. if (!commonName)
  298. commonName = GLib.uuid_string_random().replaceAll('-', '');
  299. const proc = new Gio.Subprocess({
  300. argv: [
  301. Config.OPENSSL_PATH, 'req',
  302. '-newkey', 'ec',
  303. '-pkeyopt', 'ec_paramgen_curve:prime256v1',
  304. '-keyout', keyPath,
  305. '-new', '-x509', '-nodes',
  306. '-days', '3650',
  307. '-subj', `/O=andyholmes.github.io/OU=GSConnect/CN=${commonName}`,
  308. '-out', certPath,
  309. ],
  310. flags: (Gio.SubprocessFlags.STDOUT_SILENCE |
  311. Gio.SubprocessFlags.STDERR_SILENCE),
  312. });
  313. proc.init(null);
  314. proc.wait_check(null);
  315. }
  316. return Gio.TlsCertificate.new_from_files(certPath, keyPath);
  317. };
  318. Object.defineProperties(Gio.TlsCertificate.prototype, {
  319. /**
  320. * The common name of the certificate.
  321. */
  322. 'common_name': {
  323. get: function () {
  324. if (!this.__common_name) {
  325. const proc = new Gio.Subprocess({
  326. argv: [Config.OPENSSL_PATH, 'x509', '-noout', '-subject', '-inform', 'pem'],
  327. flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE,
  328. });
  329. proc.init(null);
  330. const stdout = proc.communicate_utf8(this.certificate_pem, null)[1];
  331. this.__common_name = /(?:cn|CN) ?= ?([^,\n]*)/.exec(stdout)[1];
  332. }
  333. return this.__common_name;
  334. },
  335. configurable: true,
  336. enumerable: true,
  337. },
  338. /**
  339. * Get just the pubkey as a DER ByteArray of a certificate.
  340. *
  341. * @returns {GLib.Bytes} The pubkey as DER of the certificate.
  342. */
  343. 'pubkey_der': {
  344. value: function () {
  345. if (!this.__pubkey_der) {
  346. let proc = new Gio.Subprocess({
  347. argv: [Config.OPENSSL_PATH, 'x509', '-noout', '-pubkey', '-inform', 'pem'],
  348. flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE,
  349. });
  350. proc.init(null);
  351. const pubkey = proc.communicate_utf8(this.certificate_pem, null)[1];
  352. proc = new Gio.Subprocess({
  353. argv: [Config.OPENSSL_PATH, 'pkey', '-pubin', '-inform', 'pem', '-outform', 'der'],
  354. flags: Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE,
  355. });
  356. proc.init(null);
  357. this.__pubkey_der = proc.communicate(new TextEncoder().encode(pubkey), null)[1];
  358. }
  359. return this.__pubkey_der;
  360. },
  361. configurable: true,
  362. enumerable: false,
  363. },
  364. });