summaryrefslogtreecommitdiffstats
path: root/js/misc/util.js
diff options
context:
space:
mode:
Diffstat (limited to 'js/misc/util.js')
-rw-r--r--js/misc/util.js617
1 files changed, 617 insertions, 0 deletions
diff --git a/js/misc/util.js b/js/misc/util.js
new file mode 100644
index 0000000..e6065c4
--- /dev/null
+++ b/js/misc/util.js
@@ -0,0 +1,617 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported findUrls, spawn, spawnCommandLine, spawnApp, trySpawnCommandLine,
+ formatTime, formatTimeSpan, createTimeLabel, insertSorted,
+ ensureActorVisibleInScrollView, wiggle, lerp, GNOMEversionCompare,
+ DBusSenderChecker, Highlighter */
+
+const { Clutter, Gio, GLib, Shell, St, GnomeDesktop } = imports.gi;
+const Gettext = imports.gettext;
+
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+
+var SCROLL_TIME = 100;
+
+const WIGGLE_OFFSET = 6;
+const WIGGLE_DURATION = 65;
+const N_WIGGLES = 3;
+
+// http://daringfireball.net/2010/07/improved_regex_for_matching_urls
+const _balancedParens = '\\([^\\s()<>]+\\)';
+const _leadingJunk = '[\\s`(\\[{\'\\"<\u00AB\u201C\u2018]';
+const _notTrailingJunk = '[^\\s`!()\\[\\]{};:\'\\".,<>?\u00AB\u00BB\u200E\u200F\u201C\u201D\u2018\u2019\u202A\u202C]';
+
+const _urlRegexp = new RegExp(
+ `(^|${_leadingJunk})` +
+ '(' +
+ '(?:' +
+ '(?:http|https|ftp)://' + // scheme://
+ '|' +
+ 'www\\d{0,3}[.]' + // www.
+ '|' +
+ '[a-z0-9.\\-]+[.][a-z]{2,4}/' + // foo.xx/
+ ')' +
+ '(?:' + // one or more:
+ '[^\\s()<>]+' + // run of non-space non-()
+ '|' + // or
+ `${_balancedParens}` + // balanced parens
+ ')+' +
+ '(?:' + // end with:
+ `${_balancedParens}` + // balanced parens
+ '|' + // or
+ `${_notTrailingJunk}` + // last non-junk char
+ ')' +
+ ')', 'gi');
+
+let _desktopSettings = null;
+
+// findUrls:
+// @str: string to find URLs in
+//
+// Searches @str for URLs and returns an array of objects with %url
+// properties showing the matched URL string, and %pos properties indicating
+// the position within @str where the URL was found.
+//
+// Return value: the list of match objects, as described above
+function findUrls(str) {
+ let res = [], match;
+ while ((match = _urlRegexp.exec(str)))
+ res.push({ url: match[2], pos: match.index + match[1].length });
+ return res;
+}
+
+// spawn:
+// @argv: an argv array
+//
+// Runs @argv in the background, handling any errors that occur
+// when trying to start the program.
+function spawn(argv) {
+ try {
+ trySpawn(argv);
+ } catch (err) {
+ _handleSpawnError(argv[0], err);
+ }
+}
+
+// spawnCommandLine:
+// @commandLine: a command line
+//
+// Runs @commandLine in the background, handling any errors that
+// occur when trying to parse or start the program.
+function spawnCommandLine(commandLine) {
+ try {
+ let [success_, argv] = GLib.shell_parse_argv(commandLine);
+ trySpawn(argv);
+ } catch (err) {
+ _handleSpawnError(commandLine, err);
+ }
+}
+
+// spawnApp:
+// @argv: an argv array
+//
+// Runs @argv as if it was an application, handling startup notification
+function spawnApp(argv) {
+ try {
+ let app = Gio.AppInfo.create_from_commandline(argv.join(' '), null,
+ Gio.AppInfoCreateFlags.SUPPORTS_STARTUP_NOTIFICATION);
+
+ let context = global.create_app_launch_context(0, -1);
+ app.launch([], context);
+ } catch (err) {
+ _handleSpawnError(argv[0], err);
+ }
+}
+
+// trySpawn:
+// @argv: an argv array
+//
+// Runs @argv in the background. If launching @argv fails,
+// this will throw an error.
+function trySpawn(argv) {
+ var success_, pid;
+ try {
+ [success_, pid] = GLib.spawn_async(
+ null, argv, null,
+ GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.DO_NOT_REAP_CHILD,
+ () => {
+ try {
+ global.context.restore_rlimit_nofile();
+ } catch (err) {
+ }
+ }
+ );
+ } catch (err) {
+ /* Rewrite the error in case of ENOENT */
+ if (err.matches(GLib.SpawnError, GLib.SpawnError.NOENT)) {
+ throw new GLib.SpawnError({
+ code: GLib.SpawnError.NOENT,
+ message: _('Command not found'),
+ });
+ } else if (err instanceof GLib.Error) {
+ // The exception from gjs contains an error string like:
+ // Error invoking GLib.spawn_command_line_async: Failed to
+ // execute child process "foo" (No such file or directory)
+ // We are only interested in the part in the parentheses. (And
+ // we can't pattern match the text, since it gets localized.)
+ let message = err.message.replace(/.*\((.+)\)/, '$1');
+ throw new err.constructor({ code: err.code, message });
+ } else {
+ throw err;
+ }
+ }
+
+ // Async call, we don't need the reply though
+ GnomeDesktop.start_systemd_scope(argv[0], pid, null, null, null, () => {});
+
+ // Dummy child watch; we don't want to double-fork internally
+ // because then we lose the parent-child relationship, which
+ // can break polkit. See https://bugzilla.redhat.com//show_bug.cgi?id=819275
+ GLib.child_watch_add(GLib.PRIORITY_DEFAULT, pid, () => {});
+}
+
+// trySpawnCommandLine:
+// @commandLine: a command line
+//
+// Runs @commandLine in the background. If launching @commandLine
+// fails, this will throw an error.
+function trySpawnCommandLine(commandLine) {
+ let success_, argv;
+
+ try {
+ [success_, argv] = GLib.shell_parse_argv(commandLine);
+ } catch (err) {
+ // Replace "Error invoking GLib.shell_parse_argv: " with
+ // something nicer
+ err.message = err.message.replace(/[^:]*: /, `${_('Could not parse command:')}\n`);
+ throw err;
+ }
+
+ trySpawn(argv);
+}
+
+function _handleSpawnError(command, err) {
+ let title = _("Execution of ā€œ%sā€ failed:").format(command);
+ Main.notifyError(title, err.message);
+}
+
+function formatTimeSpan(date) {
+ let now = GLib.DateTime.new_now_local();
+
+ let timespan = now.difference(date);
+
+ let minutesAgo = timespan / GLib.TIME_SPAN_MINUTE;
+ let hoursAgo = timespan / GLib.TIME_SPAN_HOUR;
+ let daysAgo = timespan / GLib.TIME_SPAN_DAY;
+ let weeksAgo = daysAgo / 7;
+ let monthsAgo = daysAgo / 30;
+ let yearsAgo = weeksAgo / 52;
+
+ if (minutesAgo < 5)
+ return _("Just now");
+ if (hoursAgo < 1) {
+ return Gettext.ngettext("%d minute ago",
+ "%d minutes ago", minutesAgo).format(minutesAgo);
+ }
+ if (daysAgo < 1) {
+ return Gettext.ngettext("%d hour ago",
+ "%d hours ago", hoursAgo).format(hoursAgo);
+ }
+ if (daysAgo < 2)
+ return _("Yesterday");
+ if (daysAgo < 15) {
+ return Gettext.ngettext("%d day ago",
+ "%d days ago", daysAgo).format(daysAgo);
+ }
+ if (weeksAgo < 8) {
+ return Gettext.ngettext("%d week ago",
+ "%d weeks ago", weeksAgo).format(weeksAgo);
+ }
+ if (yearsAgo < 1) {
+ return Gettext.ngettext("%d month ago",
+ "%d months ago", monthsAgo).format(monthsAgo);
+ }
+ return Gettext.ngettext("%d year ago",
+ "%d years ago", yearsAgo).format(yearsAgo);
+}
+
+function formatTime(time, params) {
+ let date;
+ // HACK: The built-in Date type sucks at timezones, which we need for the
+ // world clock; it's often more convenient though, so allow either
+ // Date or GLib.DateTime as parameter
+ if (time instanceof Date)
+ date = GLib.DateTime.new_from_unix_local(time.getTime() / 1000);
+ else
+ date = time;
+
+ let now = GLib.DateTime.new_now_local();
+
+ let daysAgo = now.difference(date) / (24 * 60 * 60 * 1000 * 1000);
+
+ let format;
+
+ if (_desktopSettings == null)
+ _desktopSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
+ let clockFormat = _desktopSettings.get_string('clock-format');
+
+ params = Params.parse(params, {
+ timeOnly: false,
+ ampm: true,
+ });
+
+ if (clockFormat == '24h') {
+ // Show only the time if date is on today
+ if (daysAgo < 1 || params.timeOnly)
+ /* Translators: Time in 24h format */
+ format = N_("%H\u2236%M");
+ // Show the word "Yesterday" and time if date is on yesterday
+ else if (daysAgo < 2)
+ /* Translators: this is the word "Yesterday" followed by a
+ time string in 24h format. i.e. "Yesterday, 14:30" */
+ // xgettext:no-c-format
+ format = N_("Yesterday, %H\u2236%M");
+ // Show a week day and time if date is in the last week
+ else if (daysAgo < 7)
+ /* Translators: this is the week day name followed by a time
+ string in 24h format. i.e. "Monday, 14:30" */
+ // xgettext:no-c-format
+ format = N_("%A, %H\u2236%M");
+ else if (date.get_year() == now.get_year())
+ /* Translators: this is the month name and day number
+ followed by a time string in 24h format.
+ i.e. "May 25, 14:30" */
+ // xgettext:no-c-format
+ format = N_("%B %-d, %H\u2236%M");
+ else
+ /* Translators: this is the month name, day number, year
+ number followed by a time string in 24h format.
+ i.e. "May 25 2012, 14:30" */
+ // xgettext:no-c-format
+ format = N_("%B %-d %Y, %H\u2236%M");
+ } else {
+ // Show only the time if date is on today
+ if (daysAgo < 1 || params.timeOnly) // eslint-disable-line no-lonely-if
+ /* Translators: Time in 12h format */
+ format = N_("%l\u2236%M %p");
+ // Show the word "Yesterday" and time if date is on yesterday
+ else if (daysAgo < 2)
+ /* Translators: this is the word "Yesterday" followed by a
+ time string in 12h format. i.e. "Yesterday, 2:30 pm" */
+ // xgettext:no-c-format
+ format = N_("Yesterday, %l\u2236%M %p");
+ // Show a week day and time if date is in the last week
+ else if (daysAgo < 7)
+ /* Translators: this is the week day name followed by a time
+ string in 12h format. i.e. "Monday, 2:30 pm" */
+ // xgettext:no-c-format
+ format = N_("%A, %l\u2236%M %p");
+ else if (date.get_year() == now.get_year())
+ /* Translators: this is the month name and day number
+ followed by a time string in 12h format.
+ i.e. "May 25, 2:30 pm" */
+ // xgettext:no-c-format
+ format = N_("%B %-d, %l\u2236%M %p");
+ else
+ /* Translators: this is the month name, day number, year
+ number followed by a time string in 12h format.
+ i.e. "May 25 2012, 2:30 pm"*/
+ // xgettext:no-c-format
+ format = N_("%B %-d %Y, %l\u2236%M %p");
+ }
+
+ // Time in short 12h format, without the equivalent of "AM" or "PM"; used
+ // when it is clear from the context
+ if (!params.ampm)
+ format = format.replace(/\s*%p/g, '');
+
+ let formattedTime = date.format(Shell.util_translate_time_string(format));
+ // prepend LTR-mark to colon/ratio to force a text direction on times
+ return formattedTime.replace(/([:\u2236])/g, '\u200e$1');
+}
+
+function createTimeLabel(date, params) {
+ if (_desktopSettings == null)
+ _desktopSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
+
+ let label = new St.Label({ text: formatTime(date, params) });
+ _desktopSettings.connectObject(
+ 'changed::clock-format', () => (label.text = formatTime(date, params)),
+ label);
+ return label;
+}
+
+// lowerBound:
+// @array: an array or array-like object, already sorted
+// according to @cmp
+// @val: the value to add
+// @cmp: a comparator (or undefined to compare as numbers)
+//
+// Returns the position of the first element that is not
+// lower than @val, according to @cmp.
+// That is, returns the first position at which it
+// is possible to insert @val without violating the
+// order.
+// This is quite like an ordinary binary search, except
+// that it doesn't stop at first element comparing equal.
+
+function lowerBound(array, val, cmp) {
+ let min, max, mid, v;
+ cmp ||= (a, b) => a - b;
+
+ if (array.length == 0)
+ return 0;
+
+ min = 0;
+ max = array.length;
+ while (min < (max - 1)) {
+ mid = Math.floor((min + max) / 2);
+ v = cmp(array[mid], val);
+
+ if (v < 0)
+ min = mid + 1;
+ else
+ max = mid;
+ }
+
+ return min == max || cmp(array[min], val) < 0 ? max : min;
+}
+
+// insertSorted:
+// @array: an array sorted according to @cmp
+// @val: a value to insert
+// @cmp: the sorting function
+//
+// Inserts @val into @array, preserving the
+// sorting invariants.
+// Returns the position at which it was inserted
+function insertSorted(array, val, cmp) {
+ let pos = lowerBound(array, val, cmp);
+ array.splice(pos, 0, val);
+
+ return pos;
+}
+
+function ensureActorVisibleInScrollView(scrollView, actor) {
+ let adjustment = scrollView.vscroll.adjustment;
+ let [value, lower_, upper, stepIncrement_, pageIncrement_, pageSize] = adjustment.get_values();
+
+ let offset = 0;
+ let vfade = scrollView.get_effect("fade");
+ if (vfade)
+ offset = vfade.fade_margins.top;
+
+ let box = actor.get_allocation_box();
+ let y1 = box.y1, y2 = box.y2;
+
+ let parent = actor.get_parent();
+ while (parent != scrollView) {
+ if (!parent)
+ throw new Error("actor not in scroll view");
+
+ box = parent.get_allocation_box();
+ y1 += box.y1;
+ y2 += box.y1;
+ parent = parent.get_parent();
+ }
+
+ if (y1 < value + offset)
+ value = Math.max(0, y1 - offset);
+ else if (y2 > value + pageSize - offset)
+ value = Math.min(upper, y2 + offset - pageSize);
+ else
+ return;
+
+ adjustment.ease(value, {
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: SCROLL_TIME,
+ });
+}
+
+function wiggle(actor, params) {
+ if (!St.Settings.get().enable_animations)
+ return;
+
+ params = Params.parse(params, {
+ offset: WIGGLE_OFFSET,
+ duration: WIGGLE_DURATION,
+ wiggleCount: N_WIGGLES,
+ });
+ actor.translation_x = 0;
+
+ // Accelerate before wiggling
+ actor.ease({
+ translation_x: -params.offset,
+ duration: params.duration,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ onComplete: () => {
+ // Wiggle
+ actor.ease({
+ translation_x: params.offset,
+ duration: params.duration,
+ mode: Clutter.AnimationMode.LINEAR,
+ repeatCount: params.wiggleCount,
+ autoReverse: true,
+ onComplete: () => {
+ // Decelerate and return to the original position
+ actor.ease({
+ translation_x: 0,
+ duration: params.duration,
+ mode: Clutter.AnimationMode.EASE_IN_QUAD,
+ });
+ },
+ });
+ },
+ });
+}
+
+function lerp(start, end, progress) {
+ return start + progress * (end - start);
+}
+
+// _GNOMEversionToNumber:
+// @version: a GNOME version element
+//
+// Like Number() but returns sortable values for special-cases
+// 'alpha' and 'beta'. Returns NaN for unhandled 'versions'.
+function _GNOMEversionToNumber(version) {
+ let ret = Number(version);
+ if (!isNaN(ret))
+ return ret;
+ if (version === 'alpha')
+ return -2;
+ if (version === 'beta')
+ return -1;
+ return ret;
+}
+
+// GNOMEversionCompare:
+// @version1: a string containing a GNOME version
+// @version2: a string containing another GNOME version
+//
+// Returns an integer less than, equal to, or greater than
+// zero, if version1 is older, equal or newer than version2
+function GNOMEversionCompare(version1, version2) {
+ const v1Array = version1.split('.');
+ const v2Array = version2.split('.');
+
+ for (let i = 0; i < Math.max(v1Array.length, v2Array.length); i++) {
+ let elemV1 = _GNOMEversionToNumber(v1Array[i] || '0');
+ let elemV2 = _GNOMEversionToNumber(v2Array[i] || '0');
+ if (elemV1 < elemV2)
+ return -1;
+ if (elemV1 > elemV2)
+ return 1;
+ }
+
+ return 0;
+}
+
+var DBusSenderChecker = class {
+ /**
+ * @param {string[]} allowList - list of allowed well-known names
+ */
+ constructor(allowList) {
+ this._allowlistMap = new Map();
+
+ this._uninitializedNames = new Set(allowList);
+ this._initializedPromise = new Promise(resolve => {
+ this._resolveInitialized = resolve;
+ });
+
+ this._watchList = allowList.map(name => {
+ return Gio.DBus.watch_name(Gio.BusType.SESSION,
+ name,
+ Gio.BusNameWatcherFlags.NONE,
+ (conn_, name_, owner) => {
+ this._allowlistMap.set(name, owner);
+ this._checkAndResolveInitialized(name);
+ },
+ () => {
+ this._allowlistMap.delete(name);
+ this._checkAndResolveInitialized(name);
+ });
+ });
+ }
+
+ /**
+ * @param {string} name - bus name for which the watcher got initialized
+ */
+ _checkAndResolveInitialized(name) {
+ if (this._uninitializedNames.delete(name) &&
+ this._uninitializedNames.size === 0)
+ this._resolveInitialized();
+ }
+
+ /**
+ * @async
+ * @param {string} sender - the bus name that invoked the checked method
+ * @returns {bool}
+ */
+ async _isSenderAllowed(sender) {
+ await this._initializedPromise;
+ return [...this._allowlistMap.values()].includes(sender);
+ }
+
+ /**
+ * Check whether the bus name that invoked @invocation maps
+ * to an entry in the allow list.
+ *
+ * @async
+ * @throws
+ * @param {Gio.DBusMethodInvocation} invocation - the invocation
+ * @returns {void}
+ */
+ async checkInvocation(invocation) {
+ if (global.context.unsafe_mode)
+ return;
+
+ if (await this._isSenderAllowed(invocation.get_sender()))
+ return;
+
+ throw new GLib.Error(Gio.DBusError,
+ Gio.DBusError.ACCESS_DENIED,
+ `${invocation.get_method_name()} is not allowed`);
+ }
+
+ /**
+ * @returns {void}
+ */
+ destroy() {
+ for (const id in this._watchList)
+ Gio.DBus.unwatch_name(id);
+ this._watchList = [];
+ }
+};
+
+/* @class Highlighter Highlight given terms in text using markup. */
+var Highlighter = class {
+ /**
+ * @param {?string[]} terms - list of terms to highlight
+ */
+ constructor(terms) {
+ if (!terms)
+ return;
+
+ const escapedTerms = terms
+ .map(term => Shell.util_regex_escape(term))
+ .filter(term => term.length > 0);
+
+ if (escapedTerms.length === 0)
+ return;
+
+ this._highlightRegex = new RegExp(
+ `(${escapedTerms.join('|')})`, 'gi');
+ }
+
+ /**
+ * Highlight all occurences of the terms defined for this
+ * highlighter in the provided text using markup.
+ *
+ * @param {string} text - text to highlight the defined terms in
+ * @returns {string}
+ */
+ highlight(text) {
+ if (!this._highlightRegex)
+ return GLib.markup_escape_text(text, -1);
+
+ let escaped = [];
+ let lastMatchEnd = 0;
+ let match;
+ while ((match = this._highlightRegex.exec(text))) {
+ if (match.index > lastMatchEnd) {
+ let unmatched = GLib.markup_escape_text(
+ text.slice(lastMatchEnd, match.index), -1);
+ escaped.push(unmatched);
+ }
+ let matched = GLib.markup_escape_text(match[0], -1);
+ escaped.push(`<b>${matched}</b>`);
+ lastMatchEnd = match.index + match[0].length;
+ }
+ let unmatched = GLib.markup_escape_text(
+ text.slice(lastMatchEnd), -1);
+ escaped.push(unmatched);
+
+ return escaped.join('');
+ }
+};