diff --git a/data/com.github.amezin.ddterm.desktop.in.in b/data/com.github.amezin.ddterm.desktop.in.in index 934a08b4..a1118515 100644 --- a/data/com.github.amezin.ddterm.desktop.in.in +++ b/data/com.github.amezin.ddterm.desktop.in.in @@ -16,6 +16,7 @@ Categories=GNOME;GTK;System;TerminalEmulator; StartupWMClass=Com.github.amezin.ddterm StartupNotify=true DBusActivatable=true +SingleMainWindow=true Actions=toggle;preferences; OnlyShowIn=GNOME; X-ExecArg=-- diff --git a/ddterm/shell/appcontrol.js b/ddterm/shell/appcontrol.js index 7b885d32..f462490f 100644 --- a/ddterm/shell/appcontrol.js +++ b/ddterm/shell/appcontrol.js @@ -2,7 +2,6 @@ // // SPDX-License-Identifier: GPL-3.0-or-later -import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import Gio from 'gi://Gio'; @@ -11,54 +10,7 @@ import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import { Service } from './service.js'; import { WindowGeometry } from './geometry.js'; import { WindowMatch } from './windowmatch.js'; - -async function wait_timeout(message, timeout_ms, cancellable = null) { - await new Promise(resolve => { - const source = GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout_ms, () => { - cancellable?.disconnect(cancel_handler); - resolve(); - return GLib.SOURCE_REMOVE; - }); - - const cancel_handler = cancellable?.connect(() => { - GLib.Source.remove(source); - resolve(); - }); - }); - - cancellable?.set_error_if_cancelled(); - throw GLib.Error.new_literal(Gio.io_error_quark(), Gio.IOErrorEnum.TIMED_OUT, message); -} - -async function wait_property(object, property, predicate, cancellable = null) { - const result = await new Promise(resolve => { - let value = object[property]; - - if (predicate(value)) { - resolve(value); - return; - } - - const handler = object.connect(`notify::${property}`, () => { - value = object[property]; - - if (!predicate(value)) - return; - - cancellable?.disconnect(cancel_handler); - object.disconnect(handler); - resolve(value); - }); - - const cancel_handler = cancellable?.connect(() => { - object.disconnect(handler); - resolve(); - }); - }); - - cancellable?.set_error_if_cancelled(); - return result; -} +import { wait_timeout, wait_property } from '../util/promise.js'; export const AppControl = GObject.registerClass({ Properties: { diff --git a/ddterm/shell/extension.js b/ddterm/shell/extension.js index 29d06267..dfb25240 100644 --- a/ddterm/shell/extension.js +++ b/ddterm/shell/extension.js @@ -136,7 +136,7 @@ function create_panel_icon(settings, window_matcher, app_control, icon, gettext_ function install(extension, rollback) { const installer = new Installer(extension.launcher_path); - installer.install(); + const app_info = installer.install(); if (GObject.signal_lookup('shutdown', Shell.Global)) { const shutdown_handler = global.connect('shutdown', () => { @@ -161,6 +161,8 @@ function install(extension, rollback) { installer.uninstall(); }); + + return app_info; } function bind_keys(settings, app_control, rollback) { @@ -235,6 +237,8 @@ class EnabledExtension { ) )); + const app_info = install(this.extension, rollback); + this.notifications = new Notifications({ icon: this.symbolic_icon, gettext_domain: this.extension, @@ -247,7 +251,7 @@ class EnabledExtension { this.service = new Service({ bus: Gio.DBus.session, bus_name: APP_ID, - executable: this.extension.launcher_path, + app_info, subprocess: this.extension.app_process, }); @@ -283,19 +287,14 @@ class EnabledExtension { }); this.service.connect('error', (service, ex) => { - const log_collector = service.subprocess?.log_collector; - - if (!log_collector) { - this.notifications.show_error(ex); - return; - } - - log_collector.collect().then(output => { - this.notifications.show_error(ex, output); - }).catch(ex2 => { - logError(ex2, 'Failed to collect logs'); - this.notifications.show_error(ex); - }); + (service.subprocess?.get_logs() ?? Promise.resolve()).then( + output => { + this.notifications.show_error(ex, output); + }, ex2 => { + logError(ex2, 'Failed to collect logs'); + this.notifications.show_error(ex); + } + ); }); this.window_geometry = new WindowGeometry(); @@ -408,8 +407,6 @@ class EnabledExtension { this.extension, rollback ); - - install(this.extension, rollback); } #set_skip_taskbar() { diff --git a/ddterm/shell/install.js b/ddterm/shell/install.js index 08cef7f9..a818bbc6 100644 --- a/ddterm/shell/install.js +++ b/ddterm/shell/install.js @@ -7,6 +7,20 @@ import GLib from 'gi://GLib'; import Gio from 'gi://Gio'; import Shell from 'gi://Shell'; +import Gi from 'gi'; + +function try_require(namespace, version = undefined) { + try { + return Gi.require(namespace, version); + } catch (ex) { + logError(ex); + return null; + } +} + +const GioUnix = GLib.check_version(2, 79, 2) === null ? try_require('GioUnix') : null; +const DesktopAppInfo = GioUnix?.DesktopAppInfo ?? Gio.DesktopAppInfo; + class File { constructor(source_url, target_file, fallback_files = []) { const [source_file] = GLib.filename_from_uri( @@ -26,21 +40,24 @@ class File { get_existing_content() { for (const existing_file of [this.target_file, ...this.fallback_files]) { try { - return Shell.get_file_contents_utf8_sync(existing_file); + return { + filename: existing_file, + content: Shell.get_file_contents_utf8_sync(existing_file), + }; } catch (ex) { if (!ex.matches(GLib.file_error_quark(), GLib.FileError.NOENT)) logError(ex, `Can't read ${JSON.stringify(existing_file)}`); } } - return null; + return { filename: this.target_file, content: null }; } install() { - const existing_content = this.get_existing_content(); + const { filename, content } = this.get_existing_content(); - if (this.content === existing_content) - return false; + if (this.content === content) + return { filename, changed: false }; GLib.mkdir_with_parents( GLib.path_get_dirname(this.target_file), @@ -56,7 +73,7 @@ class File { 0o600 ); - return true; + return { filename: this.target_file, changed: true }; } uninstall() { @@ -109,9 +126,11 @@ export class Installer { } install() { - this.desktop_entry.install(); + const dbus_service = this.dbus_service.install(); + const desktop_entry = this.desktop_entry.install(); + const app_info = DesktopAppInfo.new_from_filename(desktop_entry.filename); - if (this.dbus_service.install()) { + if (dbus_service.changed) { Gio.DBus.session.call( 'org.freedesktop.DBus', '/org/freedesktop/DBus', @@ -125,6 +144,8 @@ export class Installer { null ); } + + return app_info; } uninstall() { diff --git a/ddterm/shell/service.js b/ddterm/shell/service.js index 55935f30..1338477a 100644 --- a/ddterm/shell/service.js +++ b/ddterm/shell/service.js @@ -2,10 +2,12 @@ // // SPDX-License-Identifier: GPL-3.0-or-later +import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import Gio from 'gi://Gio'; import { Subprocess, WaylandSubprocess } from './subprocess.js'; +import { wait_property } from '../util/promise.js'; export const Service = GObject.registerClass({ Properties: { @@ -23,12 +25,12 @@ export const Service = GObject.registerClass({ GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, null ), - 'executable': GObject.ParamSpec.string( - 'executable', + 'app-info': GObject.ParamSpec.object( + 'app-info', null, null, GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY, - null + Gio.AppInfo ), 'wayland': GObject.ParamSpec.boolean( 'wayland', @@ -117,8 +119,8 @@ export const Service = GObject.registerClass({ this.bus, this.bus_name, Gio.BusNameWatcherFlags.NONE, - (connection, name, owner) => this.#update_bus_name_owner(owner), - () => this.#update_bus_name_owner(null) + (connection, name, owner) => this.#update_bus_name_owner(name, owner), + (connection, name) => this.#update_bus_name_owner(name, null) ); } @@ -168,49 +170,62 @@ export const Service = GObject.registerClass({ } #create_subprocess() { - const argv = [ - this.executable, + const [, argv] = GLib.shell_parse_argv(this.app_info.get_commandline()); + + argv.push( '--gapplication-service', this.wayland ? '--allowed-gdk-backends=wayland' : '--allowed-gdk-backends=x11', - ...this.extra_argv, - ]; + ...this.extra_argv + ); + + const launch_context = global.create_app_launch_context(0, -1); + + for (const extra_env of this.extra_env) { + const split_pos = extra_env.indexOf('='); + const name = extra_env.slice(0, split_pos); + const value = extra_env.slice(split_pos + 1); + + launch_context.setenv(name, value); + } const params = { journal_identifier: this.bus_name, argv, - environ: this.extra_env, + environ: launch_context.get_environment(), }; - if (this.wayland) - return new WaylandSubprocess(params); - else - return new Subprocess(params); - } + launch_context.emit('launch-started', this.app_info, null); - #wait_subprocess() { - this.#subprocess_wait_cancel = new Gio.Cancellable(); + const proc = this.wayland ? new WaylandSubprocess(params) : new Subprocess(params); - return this.subprocess.wait_check(this.#subprocess_wait_cancel).catch(ex => { - if (this.starting) - return; + const platform_data = GLib.VariantDict.new(null); + platform_data.insert_value('pid', GLib.Variant.new_int32(proc.get_pid())); + launch_context.emit('launched', this.app_info, platform_data.end()); - if (ex.matches(Gio.io_error_quark(), Gio.IOErrorEnum.CANCELLED)) - return; + return proc; + } + + async #wait_subprocess(cancellable) { + this.#subprocess_wait_cancel = new Gio.Cancellable(); - this.emit('error', ex); - }).finally(() => { + try { + await this.subprocess.wait_check(cancellable); + } catch (ex) { + if (!this.starting && !ex.matches(Gio.io_error_quark(), Gio.IOErrorEnum.CANCELLED)) + this.emit('error', ex); + } finally { this.#subprocess_running = false; this.notify('is-running'); - }); + } } - #update_bus_name_owner(owner) { + #update_bus_name_owner(name, owner) { if (this.#bus_name_owner === owner) return; const prev_registered = this.is_registered; - log(`${this.bus_name}: name owner changed to ${JSON.stringify(owner)}`); + log(`${name}: name owner changed to ${JSON.stringify(owner)}`); this.#bus_name_owner = owner; this.notify('bus-name-owner'); @@ -220,15 +235,22 @@ export const Service = GObject.registerClass({ } async start(cancellable = null) { - if (this.is_registered) - return; - - this.#starting = true; - this.notify('starting'); + const inner_cancellable = Gio.Cancellable.new(); + const cancellable_chain = cancellable?.connect(() => inner_cancellable.cancel()); try { - const inner_cancellable = Gio.Cancellable.new(); - const cancellable_chain = cancellable?.connect(() => inner_cancellable.cancel()); + inner_cancellable.set_error_if_cancelled(); + + while (this.starting) { + // eslint-disable-next-line no-await-in-loop + await wait_property(this, 'starting', starting => !starting, inner_cancellable); + } + + if (this.is_registered) + return; + + this.#starting = true; + this.notify('starting'); try { if (!this.is_running) { @@ -239,34 +261,31 @@ export const Service = GObject.registerClass({ this.#subprocess_wait = this.#wait_subprocess(); } - const registered = new Promise(resolve => { - const handler = this.connect('notify::is-registered', () => { - if (this.is_registered) - resolve(); - }); + await Promise.race([ + wait_property(this, 'is-registered', Boolean, inner_cancellable), + this.#subprocess_wait, + ]); - inner_cancellable.connect(() => { - this.disconnect(handler); - }); - }); + inner_cancellable.set_error_if_cancelled(); - await Promise.race([registered, this.#subprocess_wait]); - } finally { - cancellable?.disconnect(cancellable_chain); - inner_cancellable.cancel(); - } + if (!this.is_running) { + throw new Error( + `${this.bus_name}: subprocess terminated without registering on D-Bus` + ); + } - if (!this.is_registered) { - throw new Error( - `${this.bus_name}: subprocess terminated without registering on D-Bus` - ); + if (!this.is_registered) + throw new Error(`${this.bus_name}: subprocess failed to register on D-Bus`); + } catch (ex) { + this.emit('error', ex); + throw ex; + } finally { + this.#starting = false; + this.notify('starting'); } - } catch (ex) { - this.emit('error', ex); - throw ex; } finally { - this.#starting = false; - this.notify('starting'); + cancellable?.disconnect(cancellable_chain); + inner_cancellable.cancel(); } } }); diff --git a/ddterm/shell/subprocess.js b/ddterm/shell/subprocess.js index f03eea32..c15f3a99 100644 --- a/ddterm/shell/subprocess.js +++ b/ddterm/shell/subprocess.js @@ -5,12 +5,12 @@ import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import Gio from 'gi://Gio'; -import GnomeDesktop from 'gi://GnomeDesktop'; import Meta from 'gi://Meta'; import Gi from 'gi'; import { sd_journal_stream_fd } from './sd_journal.js'; +import { promisify } from '../util/promise.js'; function try_require(namespace, version = undefined) { try { @@ -60,115 +60,102 @@ function shell_join(argv) { return argv.map(arg => GLib.shell_quote(arg)).join(' '); } -class JournalctlLogCollector { - constructor(journalctl, since, pid) { - this._argv = [ - journalctl, - '--user', - '-b', - `--since=${since.format('%C%y-%m-%d %H:%M:%S UTC')}`, - '-ocat', - `-n${KEEP_LOG_LINES}`, - `_PID=${pid}`, - ]; - } +async function collect_journald_logs(journalctl, since, pid) { + const argv = [ + journalctl, + '--user', + '-b', + `--since=${since.format('%C%y-%m-%d %H:%M:%S UTC')}`, + '-ocat', + `-n${KEEP_LOG_LINES}`, + ]; + + if (pid) + argv.push(`_PID=${pid}`); + + const proc = Gio.Subprocess.new( + argv, + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_MERGE + ); - _begin(resolve, reject) { - const proc = Gio.Subprocess.new( - this._argv, - Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_MERGE - ); + const communicate = promisify(proc.communicate_async, proc.communicate_finish); + const [, stdout_buf] = await communicate.call(proc, null, null); - proc.communicate_utf8_async(null, null, this._finish.bind(this, resolve, reject)); - } + return new TextDecoder().decode(stdout_buf); +} - _finish(resolve, reject, source, result) { - try { - const [, stdout_buf] = source.communicate_utf8_finish(result); - resolve(stdout_buf); - } catch (ex) { - reject(ex); - } - } +async function *read_chunks(input_stream) { + const read_bytes = + promisify(input_stream.read_bytes_async, input_stream.read_bytes_finish); - collect() { - return new Promise(this._begin.bind(this)); - } -} + try { + for (;;) { + // eslint-disable-next-line no-await-in-loop + const chunk = await read_bytes.call(input_stream, 4096, GLib.PRIORITY_DEFAULT, null); -class TeeLogCollector { - constructor(stream) { - this._input = stream; - this._output = new UnixOutputStream({ fd: STDERR_FD, close_fd: false }); - this._collected = []; - this._collected_lines = 0; - this._promise = new Promise((resolve, reject) => { - this._resolve = resolve; - this._reject = reject; - }); - - this._read_more(); - } + if (chunk.get_size() === 0) + return; - _read_more() { - this._input.read_bytes_async(4096, GLib.PRIORITY_DEFAULT, null, this._read_done.bind(this)); + yield chunk.toArray(); + } + } finally { + input_stream.close(null); } +} - _read_done(source, result) { - try { - const chunk = source.read_bytes_finish(result).toArray(); +function *split_array_keep_delimiter(bytes, delimiter) { + let start = 0; - if (chunk.length === 0) { - this._input.close(null); - this._output.close(null); - this._resolve(); - return; - } + for (;;) { + let end = bytes.indexOf(delimiter, start); - const delimiter = '\n'.charCodeAt(0); - let start = 0; + if (end === -1) + break; - for (;;) { - let end = chunk.indexOf(delimiter, start); + yield bytes.subarray(start, end + 1); - if (end === -1) { - if (start < chunk.length) - this._collected.push(chunk.subarray(start)); + start = end + 1; + } - break; - } + yield bytes.subarray(start); +} - this._collected.push(chunk.subarray(start, end + 1)); - this._collected_lines += 1; +async function collect_stdio_logs(input_stream) { + const delimiter = '\n'.charCodeAt(0); + const collected = []; + let lines = 0; + const stderr = new UnixOutputStream({ fd: STDERR_FD, close_fd: false }); - start = end + 1; - } + for await (const chunk of read_chunks(input_stream)) { + // I hope sync/blocking writes to stderr are fine. + // After all, this is the same thing that printerr() does. + stderr.write_all(chunk, null); - let remove = 0; + for (const sub_chunk of split_array_keep_delimiter(chunk, delimiter)) { + collected.push(sub_chunk); + + if (sub_chunk.at(-1) === delimiter) + lines += 1; + } - while (this._collected_lines > KEEP_LOG_LINES) { - const remove_chunk = this._collected[remove]; + let remove = 0; - remove += 1; + while (lines > KEEP_LOG_LINES) { + const remove_chunk = collected[remove]; - if (remove_chunk[remove_chunk.length - 1] === delimiter) - this._collected_lines -= 1; - } + remove += 1; - this._collected.splice(0, remove); - this._output.write(chunk, null); - this._read_more(); - } catch (ex) { - this._reject(ex); + if (remove_chunk.at(-1) === delimiter) + lines -= 1; } + + if (remove > 0) + collected.splice(0, remove); } - async collect() { - await this._promise; + const decoder = new TextDecoder(); - const decoder = new TextDecoder(); - return this._collected.map(line => decoder.decode(line)).join('\n'); - } + return collected.map(v => decoder.decode(v)).join(''); } export const Subprocess = GObject.registerClass({ @@ -213,36 +200,19 @@ export const Subprocess = GObject.registerClass({ ? make_subprocess_launcher_journald(this.journal_identifier) : make_subprocess_launcher_fallback(); - const launch_context = global.create_app_launch_context(0, -1); - - subprocess_launcher.set_environ(launch_context.get_environment()); - - for (const extra_env of this.environ) { - const split_pos = extra_env.indexOf('='); - const name = extra_env.slice(0, split_pos); - const value = extra_env.slice(split_pos + 1); - - subprocess_launcher.setenv(name, value, true); - } - try { + subprocess_launcher.set_environ(this.environ); + this._subprocess = this._spawn(subprocess_launcher); } finally { subprocess_launcher.close(); } - this.log_collector = logging_to_journald - ? new JournalctlLogCollector(journalctl, start_date, this._subprocess.get_identifier()) - : new TeeLogCollector(this._subprocess.get_stdout_pipe()); + const pid = this._subprocess.get_identifier(); - GnomeDesktop.start_systemd_scope( - this.journal_identifier, - parseInt(this._subprocess.get_identifier(), 10), - null, - null, - null, - null - ); + this._get_logs = logging_to_journald + ? collect_journald_logs.bind(globalThis, journalctl, start_date, pid) + : collect_stdio_logs(this._subprocess.get_stdout_pipe()).catch(logError); } get g_subprocess() { @@ -261,33 +231,34 @@ export const Subprocess = GObject.registerClass({ } wait(cancellable = null) { - return new Promise((resolve, reject) => { - this.g_subprocess.wait_async(cancellable, (source, result) => { - try { - resolve(source.wait_finish(result)); - } catch (ex) { - reject(ex); - } - }); - }); + const { wait_async, wait_finish } = this.g_subprocess; + + return promisify(wait_async, wait_finish).call(this.g_subprocess, cancellable); } wait_check(cancellable = null) { - return new Promise((resolve, reject) => { - this.g_subprocess.wait_check_async(cancellable, (source, result) => { - try { - resolve(source.wait_check_finish(result)); - } catch (ex) { - reject(ex); - } - }); - }); + const { wait_check_async, wait_check_finish } = this.g_subprocess; + + return promisify(wait_check_async, wait_check_finish).call(this.g_subprocess, cancellable); } terminate() { this.g_subprocess.send_signal(SIGTERM); } + get_pid() { + const pid = this.g_subprocess.get_identifier(); + + return pid ? parseInt(pid, 10) : null; + } + + get_logs() { + if (this._get_logs instanceof Function) + return this._get_logs(); + + return this._get_logs; + } + _spawn(subprocess_launcher) { log(`Starting subprocess: ${shell_join(this.argv)}`); return subprocess_launcher.spawnv(this.argv); diff --git a/ddterm/util/meson.build b/ddterm/util/meson.build index 7b3f936d..0ee8c90e 100644 --- a/ddterm/util/meson.build +++ b/ddterm/util/meson.build @@ -3,7 +3,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later util_js_out_files = [] -util_js_src_files = files('displayconfig.js') +util_js_src_files = files('displayconfig.js', 'promise.js') foreach util_js_src_file : util_js_src_files util_js_out_files += fs.copyfile( diff --git a/ddterm/util/promise.js b/ddterm/util/promise.js new file mode 100644 index 00000000..8c09ce9a --- /dev/null +++ b/ddterm/util/promise.js @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2025 Aleksandr Mezin +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; + +export function promisify(start, finish) { + return function (...args) { + return new Promise((resolve, reject) => { + // eslint-disable-next-line no-invalid-this + start.call(this, ...args, (source, result) => { + try { + resolve(finish.call(source, result)); + } catch (error) { + reject(error); + } + }); + }); + }; +} + +export async function wait_timeout(message, timeout_ms, cancellable = null) { + let source, cancel_handler; + + try { + await new Promise(resolve => { + source = GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout_ms, () => { + resolve(); + source = null; + return GLib.SOURCE_REMOVE; + }); + + cancel_handler = cancellable?.connect(() => { + resolve(); + }); + }); + } finally { + if (source) + GLib.Source.remove(source); + + if (cancel_handler) + cancellable.disconnect(cancel_handler); + } + + cancellable?.set_error_if_cancelled(); + + throw GLib.Error.new_literal(Gio.io_error_quark(), Gio.IOErrorEnum.TIMED_OUT, message); +} + +export async function wait_property(object, property, predicate, cancellable = null) { + let value = object[property]; + + if (predicate(value)) + return value; + + let handler, cancel_handler; + + try { + await new Promise((resolve, reject) => { + handler = object.connect(`notify::${property}`, () => { + try { + value = object[property]; + + if (predicate(value)) + resolve(); + } catch (error) { + reject(error); + } + }); + + cancel_handler = cancellable?.connect(() => { + resolve(); + }); + }); + } finally { + if (handler) + object.disconnect(handler); + + if (cancel_handler) + cancellable.disconnect(cancel_handler); + } + + cancellable?.set_error_if_cancelled(); + + return value; +}