desktop-lifecycle 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ """The boring parts of a small Python desktop application.
2
+
3
+ Single-instance guard, XDG paths, autostart, menu entries, session detection,
4
+ tray capability and rotating logs - without a framework and without a
5
+ dependency.
6
+
7
+ from desktop_lifecycle import App
8
+
9
+ app = App("my-tool").ensure_dirs()
10
+ app.setup_logging()
11
+ if not app.acquire():
12
+ raise SystemExit(0) # asked the running copy to show itself
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from ._util import validate_app_name
18
+ from .autostart import Autostart
19
+ from .desktop_entry import (
20
+ build_entry,
21
+ install_icon,
22
+ install_menu_entry,
23
+ remove_menu_entry,
24
+ )
25
+ from .lifecycle import App
26
+ from .logging_setup import setup_logging
27
+ from .paths import Paths
28
+ from .session import has_gui, is_linux, is_wayland, is_x11, session_type
29
+ from .single_instance import AlreadyRunning, SingleInstance
30
+ from .tray import TrayUnavailable, available as tray_available, backend as tray_backend, create as create_tray
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ __all__ = [
35
+ "App",
36
+ "Autostart",
37
+ "AlreadyRunning",
38
+ "Paths",
39
+ "SingleInstance",
40
+ "TrayUnavailable",
41
+ "build_entry",
42
+ "create_tray",
43
+ "has_gui",
44
+ "install_icon",
45
+ "install_menu_entry",
46
+ "is_linux",
47
+ "is_wayland",
48
+ "is_x11",
49
+ "remove_menu_entry",
50
+ "session_type",
51
+ "setup_logging",
52
+ "tray_available",
53
+ "tray_backend",
54
+ "validate_app_name",
55
+ "__version__",
56
+ ]
@@ -0,0 +1,80 @@
1
+ """``python -m desktop_lifecycle`` - a read-only environment report.
2
+
3
+ Paste this into a bug report instead of pasting ``ls -la ~``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ from pathlib import Path
11
+ import platform
12
+ import sys
13
+
14
+ from . import __version__
15
+ from .lifecycle import App
16
+ from .tray import _INSTALL_HINT, backend as tray_backend
17
+
18
+
19
+ def _build_parser() -> argparse.ArgumentParser:
20
+ parser = argparse.ArgumentParser(
21
+ prog="desktop-lifecycle",
22
+ description="Report where a desktop application would write and which "
23
+ "tray backend this session can use.",
24
+ )
25
+ parser.add_argument(
26
+ "--app",
27
+ default="desktop-lifecycle-example",
28
+ help="application name to resolve paths for (default: %(default)s)",
29
+ )
30
+ parser.add_argument("--json", action="store_true", help="machine-readable output")
31
+ parser.add_argument(
32
+ "--init",
33
+ action="store_true",
34
+ help="create the directories (all other runs are read-only)",
35
+ )
36
+ return parser
37
+
38
+
39
+ def main(argv=None) -> int:
40
+ arguments = _build_parser().parse_args(argv)
41
+ try:
42
+ app = App(arguments.app, argv=[])
43
+ except ValueError as error:
44
+ print(f"error: {error}", file=sys.stderr)
45
+ return 2
46
+
47
+ if arguments.init:
48
+ app.ensure_dirs()
49
+
50
+ report = app.describe()
51
+ report["tray_install_hint"] = "" if report["tray_backend"] != "none" else _INSTALL_HINT
52
+ report["directories_exist"] = {
53
+ key: Path(value).is_dir()
54
+ for key, value in report["paths"].items()
55
+ if key != "app"
56
+ }
57
+
58
+ if arguments.json:
59
+ print(json.dumps(report, indent=2, sort_keys=True))
60
+ return 0
61
+
62
+ print(f"desktop-lifecycle {__version__}")
63
+ print(f" python {platform.python_version()}")
64
+ print(f" platform {report['platform']}")
65
+ print(f" session {report['session']}")
66
+ print(f" tray backend {report['tray_backend']}")
67
+ print(f" app name {report['name']}")
68
+ print(" directories")
69
+ for key, value in report["paths"].items():
70
+ if key == "app":
71
+ continue
72
+ state = "exists" if report["directories_exist"].get(key) else "missing"
73
+ print(f" {key:<7} {value} [{state}]")
74
+ if report["tray_install_hint"]:
75
+ print(f" note {report['tray_install_hint']}")
76
+ return 0
77
+
78
+
79
+ if __name__ == "__main__": # pragma: no cover
80
+ raise SystemExit(main())
@@ -0,0 +1,133 @@
1
+ """Internal helpers: name validation, atomic writes and Exec= quoting.
2
+
3
+ Everything in here is deliberately small and dependency-free. The quoting
4
+ rules matter: a wrong ``Exec=`` line is the number one reason a desktop entry
5
+ silently refuses to launch, and an unescaped ``$`` or backtick in a path can be
6
+ executed by the shell that some launchers still use.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+ import re
14
+ import tempfile
15
+
16
+ __all__ = [
17
+ "validate_app_name",
18
+ "ensure_dir",
19
+ "atomic_write",
20
+ "quote_exec_arg",
21
+ "quote_exec",
22
+ "escape_entry_value",
23
+ ]
24
+
25
+ # 1-64 characters, must start with a letter or digit, no path separators.
26
+ # Reversed-DNS names (com.example.app) are accepted on purpose.
27
+ _APP_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
28
+
29
+
30
+ def validate_app_name(name: str) -> str:
31
+ """Return a safe application name or raise ``ValueError``.
32
+
33
+ The name becomes a directory name, a file name and a registry value, so it
34
+ must not be able to escape any of those places.
35
+ """
36
+ if not isinstance(name, str):
37
+ raise TypeError("application name must be a string")
38
+ candidate = name.strip()
39
+ if (
40
+ not candidate
41
+ or not _APP_NAME_RE.match(candidate)
42
+ or ".." in candidate
43
+ or candidate.endswith(".")
44
+ ):
45
+ raise ValueError(
46
+ f"invalid application name {name!r}: use 1-64 characters from "
47
+ "[A-Za-z0-9._-], starting with a letter or digit"
48
+ )
49
+ return candidate
50
+
51
+
52
+ def ensure_dir(path, mode: int = 0o700) -> Path:
53
+ """Create ``path`` (and parents) if needed and return it as a ``Path``."""
54
+ target = Path(path)
55
+ target.mkdir(parents=True, exist_ok=True, mode=mode)
56
+ return target
57
+
58
+
59
+ def atomic_write(path, text: str, mode: int = 0o644) -> Path:
60
+ """Write ``text`` to ``path`` atomically with an explicit mode.
61
+
62
+ A half-written autostart entry or settings file is worse than no file at
63
+ all, so the data goes to a temporary file in the same directory first and
64
+ is then renamed into place.
65
+ """
66
+ target = Path(path)
67
+ ensure_dir(target.parent, mode=0o700)
68
+ fd, temporary = tempfile.mkstemp(
69
+ dir=str(target.parent), prefix=f".{target.name}.", suffix=".tmp"
70
+ )
71
+ try:
72
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
73
+ handle.write(text)
74
+ handle.flush()
75
+ os.fsync(handle.fileno())
76
+ os.chmod(temporary, mode)
77
+ os.replace(temporary, target)
78
+ except BaseException:
79
+ try:
80
+ os.unlink(temporary)
81
+ except OSError:
82
+ pass
83
+ raise
84
+ return target
85
+
86
+
87
+ def escape_entry_value(value: str) -> str:
88
+ """Escape a desktop-entry *value* (``Name=``, ``Comment=``, ...).
89
+
90
+ The specification reserves backslash and newline inside values.
91
+ """
92
+ return (
93
+ str(value)
94
+ .replace("\\", "\\\\")
95
+ .replace("\r", "")
96
+ .replace("\n", "\\n")
97
+ )
98
+
99
+
100
+ def quote_exec_arg(value: str, *, windows=None) -> str:
101
+ """Quote a single argument for an ``Exec=`` field or a Windows command.
102
+
103
+ ``windows`` defaults to the host platform, but callers that produce a file
104
+ for a *different* platform pass it explicitly: a ``.desktop`` file uses
105
+ POSIX quoting even when it is written from Windows, and the Windows
106
+ ``Run`` registry value uses Windows quoting even when the test suite runs
107
+ on Linux.
108
+
109
+ POSIX quoting follows the rules every mainstream launcher applies:
110
+ backslash escapes ``"``, `````, ``$`` and ``\\`` inside a double-quoted
111
+ argument, and ``%`` is doubled because a single ``%`` starts a field code
112
+ (``%f``, ``%U``).
113
+ """
114
+ text = str(value).replace("\r", " ").replace("\n", " ")
115
+ if (os.name == "nt") if windows is None else windows:
116
+ # Windows command lines use their own quoting rules; only quote when
117
+ # the argument actually needs it.
118
+ if text and not any(character in text for character in ' \t"'):
119
+ return text
120
+ return '"' + text.replace('"', '\\"') + '"'
121
+ escaped = (
122
+ text.replace("\\", "\\\\")
123
+ .replace('"', '\\"')
124
+ .replace("`", "\\`")
125
+ .replace("$", "\\$")
126
+ .replace("%", "%%")
127
+ )
128
+ return f'"{escaped}"'
129
+
130
+
131
+ def quote_exec(parts, *, windows=None) -> str:
132
+ """Quote a whole command line for ``Exec=`` / autostart use."""
133
+ return " ".join(quote_exec_arg(part, windows=windows) for part in parts)
@@ -0,0 +1,222 @@
1
+ """Start at login, on every platform, without asking for root.
2
+
3
+ Linux uses an XDG autostart entry, Windows uses the per-user ``Run`` registry
4
+ key, macOS uses a LaunchAgent. All three are user-scoped: no administrator
5
+ rights, no system-wide changes, and turning it off leaves nothing behind.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import platform
12
+ import plistlib
13
+ from pathlib import Path
14
+
15
+ from ._util import (
16
+ atomic_write,
17
+ ensure_dir,
18
+ escape_entry_value,
19
+ quote_exec,
20
+ validate_app_name,
21
+ )
22
+ from .paths import Paths
23
+
24
+ __all__ = ["Autostart"]
25
+
26
+
27
+ class Autostart:
28
+ """Toggle "start when I log in" for one application.
29
+
30
+ >>> autostart = Autostart("my-app", ["/usr/bin/python3", "-m", "my_app"])
31
+ >>> autostart.enable()
32
+ True
33
+ >>> autostart.enabled
34
+ True
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ app: str,
40
+ exec_parts,
41
+ *,
42
+ args=(),
43
+ comment: str = "",
44
+ icon=None,
45
+ hidden_arg: str | None = "--background",
46
+ paths=None,
47
+ system=None,
48
+ home=None,
49
+ env=None,
50
+ ):
51
+ self.app = validate_app_name(app)
52
+ if isinstance(exec_parts, (str, Path)):
53
+ raise TypeError("exec_parts must be a sequence, not a single string")
54
+ self.exec_parts = [str(part) for part in exec_parts]
55
+ if not self.exec_parts:
56
+ raise ValueError("exec_parts must contain at least the program to run")
57
+ self.args = [str(arg) for arg in args]
58
+ if hidden_arg and hidden_arg not in self.args:
59
+ self.args.append(hidden_arg)
60
+ self.comment = comment
61
+ self.icon = icon
62
+ self.system = platform.system() if system is None else system
63
+ self.env = os.environ if env is None else env
64
+ self.home = Path(home) if home is not None else Path.home()
65
+ self.paths = paths or Paths.for_app(
66
+ self.app, env=self.env, system=self.system, home=self.home
67
+ )
68
+
69
+ # -- locations -------------------------------------------------------
70
+ @property
71
+ def command(self) -> list[str]:
72
+ return [*self.exec_parts, *self.args]
73
+
74
+ @property
75
+ def target(self) -> Path:
76
+ """The file (or registry value) that represents the autostart entry."""
77
+ if self.system == "Windows":
78
+ return Path(f"HKCU:Software/Microsoft/Windows/CurrentVersion/Run/{self.app}")
79
+ if self.system == "Darwin":
80
+ return (
81
+ self.home
82
+ / "Library"
83
+ / "LaunchAgents"
84
+ / f"{self._label}.plist"
85
+ )
86
+ return Path(self.paths.config_home) / "autostart" / f"{self.app}.desktop"
87
+
88
+ @property
89
+ def _label(self) -> str:
90
+ """Reverse-DNS-ish launchd label; ``my-app`` becomes ``my-app``."""
91
+ return self.app
92
+
93
+ # -- state -----------------------------------------------------------
94
+ @property
95
+ def enabled(self) -> bool:
96
+ if self.system == "Windows":
97
+ return self._windows_value() is not None
98
+ return self.target.exists()
99
+
100
+ # -- public API ------------------------------------------------------
101
+ def enable(self) -> bool:
102
+ """Create the entry. Returns ``True`` when it is in place afterwards."""
103
+ if self.system == "Windows":
104
+ return self._windows_set(quote_exec(self.command))
105
+ if self.system == "Darwin":
106
+ return self._macos_set()
107
+ return self._linux_set()
108
+
109
+ def disable(self) -> bool:
110
+ """Remove the entry. Returns ``True`` when nothing is left behind."""
111
+ if self.system == "Windows":
112
+ self._windows_delete()
113
+ return not self.enabled
114
+ try:
115
+ self.target.unlink()
116
+ except FileNotFoundError:
117
+ return True
118
+ except OSError:
119
+ return False
120
+ return True
121
+
122
+ def toggle(self) -> bool:
123
+ return self.disable() if self.enabled else self.enable()
124
+
125
+ # -- Linux -----------------------------------------------------------
126
+ def _linux_set(self) -> bool:
127
+ lines = [
128
+ "[Desktop Entry]",
129
+ "Type=Application",
130
+ f"Name={escape_entry_value(self.app)}",
131
+ ]
132
+ if self.comment:
133
+ lines.append(f"Comment={escape_entry_value(self.comment)}")
134
+ lines.append(f"Exec={quote_exec(self.command, windows=False)}")
135
+ if self.icon:
136
+ # Desktop entries want an absolute path here; a bare name has to be
137
+ # looked up through the icon theme and often silently fails.
138
+ lines.append(f"Icon={escape_entry_value(str(self.icon))}")
139
+ lines.extend(
140
+ [
141
+ "Terminal=false",
142
+ "X-GNOME-Autostart-enabled=true",
143
+ "Hidden=false",
144
+ "",
145
+ ]
146
+ )
147
+ try:
148
+ atomic_write(self.target, "\n".join(lines), mode=0o644)
149
+ except OSError:
150
+ return False
151
+ return True
152
+
153
+ # -- macOS -----------------------------------------------------------
154
+ def _macos_set(self) -> bool:
155
+ payload = {
156
+ "Label": self._label,
157
+ "ProgramArguments": list(self.command),
158
+ "RunAtLoad": True,
159
+ "ProcessType": "Interactive",
160
+ }
161
+ try:
162
+ ensure_dir(self.target.parent)
163
+ data = plistlib.dumps(payload, fmt=plistlib.FMT_XML)
164
+ self.target.write_bytes(data)
165
+ self.target.chmod(0o644)
166
+ except OSError:
167
+ return False
168
+ return True
169
+
170
+ # -- Windows ---------------------------------------------------------
171
+ def _windows_key(self):
172
+ import winreg # noqa: PLC0415 - Windows-only import
173
+
174
+ return winreg.OpenKey(
175
+ winreg.HKEY_CURRENT_USER,
176
+ r"Software\Microsoft\Windows\CurrentVersion\Run",
177
+ 0,
178
+ winreg.KEY_SET_VALUE | winreg.KEY_QUERY_VALUE,
179
+ )
180
+
181
+ def _windows_value(self):
182
+ try:
183
+ import winreg # noqa: PLC0415 - Windows-only import
184
+
185
+ with self._windows_key() as key:
186
+ value, _ = winreg.QueryValueEx(key, self.app)
187
+ return value
188
+ except (ImportError, OSError):
189
+ return None
190
+
191
+ def _windows_set(self, command: str) -> bool:
192
+ try:
193
+ import winreg # noqa: PLC0415 - Windows-only import
194
+
195
+ with self._windows_key() as key:
196
+ winreg.SetValueEx(key, self.app, 0, winreg.REG_SZ, command)
197
+ except (ImportError, OSError):
198
+ return False
199
+ return True
200
+
201
+ def _windows_delete(self) -> bool:
202
+ try:
203
+ import winreg # noqa: PLC0415 - Windows-only import
204
+
205
+ with self._windows_key() as key:
206
+ winreg.DeleteValue(key, self.app)
207
+ except FileNotFoundError:
208
+ return True
209
+ except (ImportError, OSError):
210
+ return False
211
+ return True
212
+
213
+ # -- diagnostics -----------------------------------------------------
214
+ def describe(self) -> dict:
215
+ return {
216
+ "app": self.app,
217
+ "system": self.system,
218
+ "enabled": self.enabled,
219
+ "target": str(self.target),
220
+ "command": self.command,
221
+ "exec_line": quote_exec(self.command, windows=self.system == "Windows"),
222
+ }
@@ -0,0 +1,132 @@
1
+ """Menu entries and icons, written the way the specification asks for them.
2
+
3
+ This module is pure and testable: :func:`build_entry` returns text, and
4
+ :func:`install_menu_entry` only does file I/O. Nothing here runs a shell.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+ from ._util import (
14
+ atomic_write,
15
+ ensure_dir,
16
+ escape_entry_value,
17
+ quote_exec,
18
+ validate_app_name,
19
+ )
20
+
21
+ __all__ = [
22
+ "build_entry",
23
+ "install_menu_entry",
24
+ "remove_menu_entry",
25
+ "install_icon",
26
+ "applications_dir",
27
+ ]
28
+
29
+
30
+ def build_entry(
31
+ *,
32
+ name: str,
33
+ exec_parts,
34
+ comment: str = "",
35
+ icon=None,
36
+ terminal: bool = False,
37
+ categories=("Utility",),
38
+ startup_notify: bool = True,
39
+ extra: dict | None = None,
40
+ ) -> str:
41
+ """Return a complete ``.desktop`` file as text.
42
+
43
+ ``exec_parts`` is a sequence (command, arg, arg...). Each part is quoted
44
+ separately, so paths with spaces, ``$``, backticks or quotes survive.
45
+ """
46
+ if isinstance(exec_parts, (str, Path)):
47
+ raise TypeError("exec_parts must be a sequence, not a single string")
48
+ parts = [str(part) for part in exec_parts]
49
+ if not parts or not parts[0]:
50
+ raise ValueError("exec_parts must contain at least the program to run")
51
+
52
+ lines = [
53
+ "[Desktop Entry]",
54
+ "Type=Application",
55
+ "Version=1.0",
56
+ f"Name={escape_entry_value(name)}",
57
+ ]
58
+ if comment:
59
+ lines.append(f"Comment={escape_entry_value(comment)}")
60
+ lines.append(f"Exec={quote_exec(parts, windows=False)}")
61
+ if icon:
62
+ lines.append(f"Icon={escape_entry_value(str(icon))}")
63
+ lines.append(f"Terminal={'true' if terminal else 'false'}")
64
+ lines.append(f"Categories={';'.join(categories)};")
65
+ if startup_notify:
66
+ lines.append("StartupNotify=true")
67
+ for key, value in (extra or {}).items():
68
+ lines.append(f"{key}={escape_entry_value(value)}")
69
+ return "\n".join(lines) + "\n"
70
+
71
+
72
+ def applications_dir(paths) -> Path:
73
+ """``$XDG_DATA_HOME/applications`` for the given :class:`Paths`."""
74
+ return Path(paths.data_home) / "applications"
75
+
76
+
77
+ def install_menu_entry(paths, name: str, exec_parts, **kwargs) -> Path:
78
+ """Write ``<data_home>/applications/<name>.desktop`` and refresh the cache."""
79
+ safe_name = validate_app_name(name)
80
+ target = applications_dir(paths) / f"{safe_name}.desktop"
81
+ text = build_entry(name=kwargs.pop("display_name", name), exec_parts=exec_parts, **kwargs)
82
+ atomic_write(target, text, mode=0o644)
83
+ _refresh_desktop_database(target.parent)
84
+ return target
85
+
86
+
87
+ def remove_menu_entry(paths, name: str) -> bool:
88
+ """Delete the menu entry. Returns ``True`` when a file was removed."""
89
+ target = applications_dir(paths) / f"{validate_app_name(name)}.desktop"
90
+ try:
91
+ target.unlink()
92
+ except FileNotFoundError:
93
+ return False
94
+ _refresh_desktop_database(target.parent)
95
+ return True
96
+
97
+
98
+ def install_icon(paths, icon_source, name: str) -> Path:
99
+ """Copy an icon into ``<data_home>/icons/hicolor/scalable/apps``.
100
+
101
+ The returned path is what belongs in ``Icon=``. SVG stays SVG; anything
102
+ else is copied byte-for-byte with its own suffix.
103
+ """
104
+ safe_name = validate_app_name(name)
105
+ source = Path(icon_source)
106
+ if not source.is_file():
107
+ raise FileNotFoundError(f"icon not found: {source}")
108
+ suffix = source.suffix.lower() or ".png"
109
+ target_dir = ensure_dir(
110
+ Path(paths.data_home) / "icons" / "hicolor" / "scalable" / "apps"
111
+ )
112
+ target = target_dir / f"{safe_name}{suffix}"
113
+ shutil.copyfile(source, target)
114
+ target.chmod(0o644)
115
+ return target
116
+
117
+
118
+ def _refresh_desktop_database(directory: Path) -> None:
119
+ """Best-effort ``update-desktop-database``; never required, never a shell."""
120
+ executable = shutil.which("update-desktop-database")
121
+ if not executable:
122
+ return
123
+ try:
124
+ subprocess.run(
125
+ [executable, str(directory)],
126
+ check=False,
127
+ stdout=subprocess.DEVNULL,
128
+ stderr=subprocess.DEVNULL,
129
+ timeout=10,
130
+ )
131
+ except (OSError, subprocess.SubprocessError):
132
+ pass