diff --git a/README.md b/README.md index b3c116d37..b7901f097 100644 --- a/README.md +++ b/README.md @@ -25,21 +25,16 @@ ls --reverse -clt ~/.local/share/hamster*/*.db ``` Backup the last file in the list. +### Upgrading -### Kill hamster daemons +When installed from source, it is recommended to uninstall before +installing a new version. When using system packages or flatpak, you +should be able to just install the new version. -When trying a different version, make sure to kill the running daemons: +After upgrading, the hamster background services might still be running +the old version. To replace them, you can either log out, or run: -```bash -# either step-by-step, totally safe -pkill -f hamster-service -pkill -f hamster-windows-service -# check (should be empty) -pgrep -af hamster - -# or be bold and kill them all at once: -pkill -ef hamster -``` + hamster --replace-all ### Install from packages @@ -206,22 +201,14 @@ flatpak uninstall org.gnome.Hamster #### Development During development (As explained above, backup `hamster.db` first !), -if only python files are changed +if only python files are changed (*deeper changes such as the migration to gsettings require a new install*) the changes can be quickly tested by ``` -# either -pgrep -af hamster -# and kill them one by one -# or be bold and kill all processes with "hamster" in their command line -pkill -ef hamster -python3 src/hamster-service.py & -python3 src/hamster-cli.py + ./src/hamster-cli.py --replace-all ``` -Advantage: running uninstalled is detected, and windows are *not* called via -D-Bus, so that all the traces are visible. -Note: You'll need recent version of hamster installed on your system (or +Note: You'll need recent version of hamster installed on your system (or [this workaround](https://github.com/projecthamster/hamster/issues/552#issuecomment-585166000)). #### Running tests diff --git a/src/hamster-cli.py b/src/hamster-cli.py index 9c6786787..c0e61e33c 100755 --- a/src/hamster-cli.py +++ b/src/hamster-cli.py @@ -24,7 +24,10 @@ import sys, os import argparse +import time import re +import pathlib +import subprocess import gi gi.require_version('Gdk', '3.0') # noqa: E402 @@ -148,8 +151,8 @@ def on_activate(self, data=None): def on_activate_window(self, action=None, data=None): self._open_window(action.get_name(), data) - def on_activate_quit(self, data=None): - self.on_activate_quit() + def on_activate_quit(self, action=None, data=None): + self.quit() def on_startup(self, data=None): logger.debug("startup") @@ -451,8 +454,6 @@ def version(self): """) hamster_client = HamsterCli() - app = Hamster() - logger.debug("app instanciated") import signal signal.signal(signal.SIGINT, signal.SIG_DFL) # gtk3 screws up ctrl+c @@ -467,6 +468,10 @@ def version(self): choices=('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'), default='WARNING', help="Set the logging level (default: %(default)s)") + parser.add_argument("--replace", action='store_true', + help="Replace an existing GUI process (if any) instead of activating it") + parser.add_argument("--replace-all", action='store_true', + help="Replace all existing hamster processes (if any)") parser.add_argument("action", nargs="?", default="overview") parser.add_argument('action_args', nargs=argparse.REMAINDER, default=[]) @@ -477,6 +482,41 @@ def version(self): # hamster_logger for the rest hamster_logger.setLevel(args.log_level) + if args.replace_all: + if hamster.installed: + from hamster import defs # only available when running installed + d = pathlib.Path(defs.LIBEXEC_DIR) + cmds = [d / 'hamster-service', d / 'hamster-windows-service'] + else: + d = pathlib.Path(__file__).parent + cmds = [d / 'hamster-service.py', d / 'hamster-windows-service.py'] + + for cmd in cmds: + subprocess.run((cmd, '--replace')) + + app = Hamster() + logger.debug("app instantiated") + if args.replace or args.replace_all: + app.register() + if app.get_is_remote(): + # This code is prone to race conditions (if processing the quit + # takes longer than the sleep below, or if another GUI is + # bus activated before the new app), but gio.Application + # does not offer any way to pass a pre-claimed name or dbus + # connection, and always passes DO_NOT_QUEUE when claiming + # the name, preventing properly handling this race + # condition. But it is only the GUI, so the user can always + # just manually quit any existing GUI. + logger.debug("sending quit") + app.activate_action("quit") + time.sleep(2) + app = Hamster() + logger.debug("app reinstantiated") + app.register() + if app.get_is_remote(): + logger.error("Failed to replace existing GUI") + sys.exit(1) + if not hamster.installed: logger.info("Running in devel mode") @@ -488,30 +528,29 @@ def version(self): else: action = args.action - if action in ("about", "add", "edit", "overview", "preferences"): - if action == "add" and args.action_args: - assert not unknown_args, "unknown options: {}".format(unknown_args) - # directly add fact from arguments - id_ = hamster_client.start(*args.action_args) - assert id_ > 0, "failed to add fact" - sys.exit(0) + if action == "add" and args.action_args: + assert not unknown_args, "unknown options: {}".format(unknown_args) + # directly add fact from arguments + id_ = hamster_client.start(*args.action_args) + assert id_ > 0, "failed to add fact" + sys.exit(0) + elif action in ("about", "add", "edit", "overview", "preferences"): + app.register() + if action == "edit": + assert len(args.action_args) == 1, ( + "edit requires exactly one argument, got {}" + .format(args.action_args)) + id_ = int(args.action_args[0]) + assert id_ > 0, "received non-positive id : {}".format(id_) + action_data = glib.Variant.new_int32(id_) else: - app.register() - if action == "edit": - assert len(args.action_args) == 1, ( - "edit requires exactly one argument, got {}" - .format(args.action_args)) - id_ = int(args.action_args[0]) - assert id_ > 0, "received non-positive id : {}".format(id_) - action_data = glib.Variant.new_int32(id_) - else: - action_data = None - app.activate_action(action, action_data) - run_args = [sys.argv[0]] + unknown_args - logger.debug("run {}".format(run_args)) - status = app.run(run_args) - logger.debug("app exited") - sys.exit(status) + action_data = None + app.activate_action(action, action_data) + run_args = [sys.argv[0]] + unknown_args + logger.debug("run {}".format(run_args)) + status = app.run(run_args) + logger.debug("app exited") + sys.exit(status) elif hasattr(hamster_client, action): getattr(hamster_client, action)(*args.action_args) else: diff --git a/src/hamster-service.py b/src/hamster-service.py index 29e7c1e06..61a80645d 100755 --- a/src/hamster-service.py +++ b/src/hamster-service.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 # nicked off gwibber +import sys + import dbus import dbus.service @@ -9,7 +11,7 @@ import hamster from hamster import logger as hamster_logger -from hamster.lib import i18n +from hamster.lib import i18n, stuff i18n.setup_i18n() # noqa: E402 from hamster.storage import db @@ -17,6 +19,7 @@ from hamster.lib import default_logger from hamster.lib.dbus import ( DBusMainLoop, + claim_bus_name, fact_signature, from_dbus_date, from_dbus_fact, @@ -33,20 +36,12 @@ DBusMainLoop(set_as_default=True) loop = glib.MainLoop() -if "org.gnome.Hamster" in dbus.SessionBus().list_names(): - print("Found hamster-service already running, exiting") - quit() - - class Storage(db.Storage, dbus.service.Object): __dbus_object_path__ = "/org/gnome/Hamster" - def __init__(self, loop): - self.bus = dbus.SessionBus() - bus_name = dbus.service.BusName("org.gnome.Hamster", bus=self.bus) - - - dbus.service.Object.__init__(self, bus_name, self.__dbus_object_path__) + def __init__(self, loop, bus, name_obj): + self.bus = bus + dbus.service.Object.__init__(self, name_obj, self.__dbus_object_path__) db.Storage.__init__(self, unsorted_localized="") self.mainloop = loop @@ -452,6 +447,8 @@ def Version(self): choices=('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'), default='WARNING', help="Set the logging level (default: %(default)s)") + parser.add_argument("--replace", action='store_true', + help="Replace an existing process (if any)") args = parser.parse_args() @@ -460,6 +457,19 @@ def Version(self): # hamster_logger for the rest hamster_logger.setLevel(args.log_level) - print("hamster-service up") - storage = Storage(loop) + quit_method = (Storage.__dbus_object_path__, 'org.gnome.Hamster', 'Quit') + (bus, name_obj) = claim_bus_name("org.gnome.Hamster", quit_method=quit_method, replace=args.replace) + if name_obj is None: + if args.replace: + logger.error("Failed to replace existing hamster-service (it did not quit within timeout), exiting") + else: + logger.error("Found hamster-service already running, exiting") + sys.exit(1) + + storage = Storage(loop, bus, name_obj) + logger.info("hamster-service up") + + # Daemonize once we're succesfully started up and registered on dbus + stuff.daemonize() + loop.run() diff --git a/src/hamster-windows-service.py b/src/hamster-windows-service.py index 37bcad907..ec496969a 100755 --- a/src/hamster-windows-service.py +++ b/src/hamster-windows-service.py @@ -5,21 +5,20 @@ import dbus.service import os.path import subprocess +import sys from dbus.mainloop.glib import DBusGMainLoop from gi.repository import GLib as glib import hamster +from hamster.lib import default_logger, stuff +from hamster.lib.dbus import claim_bus_name +logger = default_logger(__file__) DBusGMainLoop(set_as_default=True) loop = glib.MainLoop() -if "org.gnome.Hamster.WindowServer" in dbus.SessionBus().list_names(): - print("Found hamster-window-service already running, exiting") - quit() - - # Legacy server. Still used by the shell-extension. # New code _could_ access the org.gnome.Hamster.GUI actions directly, # although the exact action names/data are subject to change. @@ -30,12 +29,11 @@ class WindowServer(dbus.service.Object): __dbus_object_path__ = "/org/gnome/Hamster/WindowServer" - def __init__(self, loop): + def __init__(self, loop, bus, name_obj): self.app = True self.mainloop = loop - self.bus = dbus.SessionBus() - bus_name = dbus.service.BusName("org.gnome.Hamster.WindowServer", bus=self.bus) - dbus.service.Object.__init__(self, bus_name, self.__dbus_object_path__) + self.bus = bus + dbus.service.Object.__init__(self, name_obj, self.__dbus_object_path__) @dbus.service.method("org.gnome.Hamster") def Quit(self): @@ -83,8 +81,24 @@ def preferences(self): glib.set_prgname(str(_("hamster-windows-service"))) - window_server = WindowServer(loop) + import argparse + parser = argparse.ArgumentParser(description="Hamster time tracker D-Bus service") + parser.add_argument("--replace", action='store_true', + help="Replace an existing process (if any)") + args = parser.parse_args() + + quit_method = (WindowServer.__dbus_object_path__, 'org.gnome.Hamster', 'Quit') + (bus, name_obj) = claim_bus_name("org.gnome.Hamster.WindowServer", quit_method=quit_method, replace=args.replace) + if name_obj is None: + if args.replace: + logger.error("Failed to replace existing hamster-windows-service (it did not quit within timeout), exiting") + else: + logger.error("Found hamster-windows-service already running, exiting") + sys.exit(1) + window_server = WindowServer(loop, bus, name_obj) + logger.info("hamster-window-service up") - print("hamster-window-service up") + # Daemonize once we're succesfully started up and registered on dbus + stuff.daemonize() loop.run() diff --git a/src/hamster/client.py b/src/hamster/client.py index ab3d3ba04..8d0f0071b 100644 --- a/src/hamster/client.py +++ b/src/hamster/client.py @@ -94,15 +94,10 @@ def conn(self): server: {} client: {} - This is sometimes used during bisections, - but generally calls for trouble. - - Remember to kill hamster daemons after any version change - (this is safe): - pkill -f hamster-service - pkill -f hamster-windows-service - see also: - https://github.com/projecthamster/hamster#kill-hamster-daemons + To replace the running services, you can use: + + hamster --replace-all + """.format(server_version, client_version) ) ) diff --git a/src/hamster/defs.py.in b/src/hamster/defs.py.in index c74866049..0f1f8bd44 100644 --- a/src/hamster/defs.py.in +++ b/src/hamster/defs.py.in @@ -1,2 +1,3 @@ DATA_DIR = "@DATADIR@" +LIBEXEC_DIR = "@LIBEXECDIR@" VERSION = "@VERSION@" diff --git a/src/hamster/lib/dbus.py b/src/hamster/lib/dbus.py index f282e4fdc..27d44acba 100644 --- a/src/hamster/lib/dbus.py +++ b/src/hamster/lib/dbus.py @@ -1,3 +1,5 @@ +import time + import dbus from dbus.mainloop.glib import DBusGMainLoop as DBusMainLoop @@ -121,3 +123,46 @@ def to_dbus_fact(fact): dbus.Array(fact.tags, signature = 's'), to_dbus_date(fact.date), fact.delta.days * 24 * 60 * 60 + fact.delta.seconds) + + +def claim_bus_name(name, quit_method=None, quit_timeout=5, replace=False): + """ Claim a bus name. + + Returns (bus, name_obj), or (bus, None) when the name was already claimed. + + If replace is true, quit_method should be an (object_path, + interface, method) tuple of a method to call to make the existing + name owner quit. That method is called to (atomically) replace the + existing name owner, waiting up to quit_timeout seconds for the + existing owner to quit. + """ + bus = dbus.SessionBus() + con = bus.get_connection() + + try: + name_obj = dbus.service.BusName(name, bus=bus, do_not_queue=not replace) + except dbus.exceptions.NameExistsException as e: + return (bus, None) + + # If do_not_queue=False and the name is already taken, we get no + # exception or any other indication whether the name was claimed or + # queued (this is a todo in dbus-python), so we check manually by + # matching the connection names + def claimed_name(): + return bus.get_name_owner(name) == con.get_unique_name() + + if replace and not claimed_name(): + (object_path, interface_name, method_name) = quit_method + method = bus.get_object(name, object_path).get_dbus_method(method_name, dbus_interface=interface_name) + method() + + # It would be more elegant if this used NameOwnerChanged events + # rather than polling, but also more complicated, so stick to + # polling for now. + start = time.monotonic() + while not claimed_name() and time.monotonic() - start < quit_timeout: + time.sleep(0.1) + if not claimed_name(): + name_obj = None + + return (bus, name_obj) diff --git a/src/hamster/lib/stuff.py b/src/hamster/lib/stuff.py index df7cb4424..2b650be35 100644 --- a/src/hamster/lib/stuff.py +++ b/src/hamster/lib/stuff.py @@ -33,6 +33,7 @@ import re import locale import os +import sys from hamster.lib import datetime as dt @@ -261,3 +262,18 @@ def escape_pango(text): text = text.replace("<", "<") text = text.replace(">", ">") return text + +def daemonize(): + """ + Minimal daemonization by forking and letting the parent exit. + """ + pid = os.fork() + if pid == 0: + # Child, setsid to prevent getting signals sent to our parent + os.setsid() + else: + # Parent + sys.exit(0) + + # Close input, but leave output file descriptors open for logging and errors + sys.stdin.close()