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.
- desktop_lifecycle/__init__.py +56 -0
- desktop_lifecycle/__main__.py +80 -0
- desktop_lifecycle/_util.py +133 -0
- desktop_lifecycle/autostart.py +222 -0
- desktop_lifecycle/desktop_entry.py +132 -0
- desktop_lifecycle/lifecycle.py +150 -0
- desktop_lifecycle/logging_setup.py +61 -0
- desktop_lifecycle/paths.py +146 -0
- desktop_lifecycle/py.typed +0 -0
- desktop_lifecycle/session.py +59 -0
- desktop_lifecycle/single_instance.py +142 -0
- desktop_lifecycle/tray.py +238 -0
- desktop_lifecycle-0.1.0.dist-info/METADATA +196 -0
- desktop_lifecycle-0.1.0.dist-info/RECORD +18 -0
- desktop_lifecycle-0.1.0.dist-info/WHEEL +5 -0
- desktop_lifecycle-0.1.0.dist-info/entry_points.txt +2 -0
- desktop_lifecycle-0.1.0.dist-info/licenses/LICENSE +21 -0
- desktop_lifecycle-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|