diff --git a/CHANGELOG.md b/CHANGELOG.md index a848682650..da18ea2203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ ### Improvements +* `flet build linux` apps install a hidden desktop entry for the current user while they run, so GNOME's dock shows the app's icon and name instead of a generic icon and the app id, on Wayland and on GNOME 46+ under X11. Turn it off with `[tool.flet.linux] desktop_entry = false`. See [App icon](https://flet.dev/docs/publish/linux#app-icon) ([#7001](https://github.com/flet-dev/flet/pull/7001)) by @ndonkoHenri. * `flet build` always generates app icons and the splash screen. An app without an icon of its own gets them generated from the Flet logo, which `flet-platform-assets` now bundles as `DEFAULT_ICON`, instead of the build template's committed icons. Previews of the icons a build will produce now match the build for every app. The default web icons are the bare logo rather than the logo on a white tile by @FeodorFitsner. * Add `min_width`, `max_width`, `min_height` and `max_height` to every layout control. They are applied by the client on every layout pass, so a capped, centered column (e.g. `Container(max_width=760)` in a `Column(horizontal_alignment=CENTER)`) stays correct while the window resizes - no more computing widths in Python from `page.width` or `on_size_change`, which always lagged a frame or more behind by @FeodorFitsner. * Add `ft.MaterialTapTargetSize` with `Switch.material_tap_target_size` and `Theme.material_tap_target_size`: `SHRINK_WRAP` drops Material's 48px minimum tappable area for dense, pointer-driven layouts by @FeodorFitsner. diff --git a/sdk/python/packages/flet-cli/tests/test_linux_desktop_entry.py b/sdk/python/packages/flet-cli/tests/test_linux_desktop_entry.py new file mode 100644 index 0000000000..fd1e781de7 --- /dev/null +++ b/sdk/python/packages/flet-cli/tests/test_linux_desktop_entry.py @@ -0,0 +1,264 @@ +"""Exercise the Linux runner's desktop-entry helpers without Flutter or GTK.""" + +import os +import select +import shlex +import shutil +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +APP_ID = "org.flet.DesktopEntryTest" +RUNNER = ( + Path(__file__).resolve().parents[3] + / "templates/build/{{cookiecutter.out_dir}}/linux/my_application.cc" +) + + +@pytest.fixture(scope="module") +def desktop_entry_probe(tmp_path_factory): + """Compile the actual helpers, adding barriers for concurrent processes.""" + compiler = shutil.which("c++") + pkg_config = shutil.which("pkg-config") + if os.name != "posix" or compiler is None or pkg_config is None: + pytest.skip("Requires a POSIX C++ compiler and GLib development files") + flags = subprocess.run( + [pkg_config, "--cflags", "--libs", "glib-2.0"], + capture_output=True, + text=True, + timeout=10, + ) + if flags.returncode: + pytest.skip("Requires GLib development files") + + runner = RUNNER.read_text() + includes = "\n".join( + line + for line in runner.split("#ifdef GDK_WINDOWING_X11", 1)[0].splitlines() + if line.startswith("#include <") and "flutter_linux" not in line + ) + helpers = runner.split("#define FLET_GENERATED_KEY", 1)[1].split( + "// Implements GApplication::activate.", 1 + )[0] + source = ( + includes + + f'\n#define APPLICATION_ID "{APP_ID}"\n' + + r""" +#include +#include +static bool pause_read = false; +static bool pause_remove = false; + +static void barrier(const char* event) { + puts(event); + fflush(stdout); + if (getchar() == EOF) exit(2); +} + +static gboolean load_source(GKeyFile* entry, const gchar* path, + GKeyFileFlags flags, GError** error) { + gboolean loaded = g_key_file_load_from_file(entry, path, flags, error); + if (loaded && pause_read) { + pause_read = false; + barrier("snapshot"); + } + return loaded; +} + +static int remove_file(const gchar* path) { + if (pause_remove && g_str_has_suffix(path, ".desktop")) { + pause_remove = false; + barrier("removing"); + } + return g_remove(path); +} +#define g_key_file_load_from_file load_source +#undef g_remove +#define g_remove remove_file +#define FLET_GENERATED_KEY""" + + helpers + + r""" +int main(int argc, char** argv) { + pause_read = argc > 2 && strcmp(argv[2], "pause-read") == 0; + pause_remove = argc > 2 && strcmp(argv[2], "pause-remove") == 0; + install_desktop_entry(argv[1]); + barrier("ready"); + puts("stopping"); + fflush(stdout); + remove_desktop_entry(); + puts("stopped"); + fflush(stdout); +} +""" + ) + directory = tmp_path_factory.mktemp("desktop-entry-probe") + cpp = directory / "probe.cc" + cpp.write_text(source) + executable = directory / "probe" + result = subprocess.run( + [ + compiler, + "-std=c++14", + "-Wall", + "-Werror", + str(cpp), + "-o", + str(executable), + *shlex.split(flags.stdout), + ], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + return executable + + +class Probe: + """Control a runner instance through explicit startup/cleanup barriers.""" + + def __init__(self, executable, app, mode): + self.process = subprocess.Popen( + [str(executable), str(app.exe), mode], + env=app.env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + bufsize=0, + ) + + def expect(self, event): + assert select.select([self.process.stdout], [], [], 5)[0], event + assert self.process.stdout.readline().decode().strip() == event + + def resume(self): + self.process.stdin.write(b"\n") + self.process.stdin.flush() + + def stop(self): + self.resume() + self.expect("stopping") + self.expect("stopped") + assert self.process.wait(timeout=5) == 0 + + +@pytest.fixture +def app(tmp_path): + """Use isolated XDG directories and a prefix/bin + prefix/share bundle.""" + prefix = tmp_path / "prefix" + exe = prefix / "bin/app" + exe.parent.mkdir(parents=True) + exe.touch() + source = prefix / f"share/applications/{APP_ID}.desktop" + source.parent.mkdir(parents=True) + source.write_text( + "[Desktop Entry]\nType=Application\nName=Probe\nExec=app\nIcon=probe\n" + ) + icon = exe.parent / "data/app_icon.png" + icon.parent.mkdir() + icon.write_bytes(b"icon") + runtime = tmp_path / "runtime" + runtime.mkdir(mode=0o700) + env = os.environ.copy() + for variable in ("APPIMAGE", "FLATPAK_ID", "SNAP"): + env.pop(variable, None) + env.update( + XDG_DATA_HOME=str(tmp_path / "data"), + XDG_DATA_DIRS=str(tmp_path / "system-share"), + XDG_RUNTIME_DIR=str(runtime), + ) + return SimpleNamespace( + exe=exe, + source=source, + env=env, + entry=tmp_path / f"data/applications/{APP_ID}.desktop", + runtime=runtime / "flet" / APP_ID, + ) + + +@pytest.fixture +def start_probe(desktop_entry_probe, app): + """Always reap children, including when a concurrency assertion fails.""" + probes = [] + + def start(mode=""): + probe = Probe(desktop_entry_probe, app, mode) + probes.append(probe) + return probe + + yield start + for probe in probes: + if probe.process.poll() is None: + probe.process.kill() + probe.process.communicate(timeout=5) + + +@pytest.mark.parametrize("stale_entry", [False, True]) +def test_preserve_system_entry_when_it_is_the_source(app, start_probe, stale_entry): + """A packaged bin/share layout must never acquire a hidden user override.""" + app.env["XDG_DATA_DIRS"] = str(app.source.parent.parent) + original = app.source.read_bytes() + if stale_entry: + app.entry.parent.mkdir(parents=True) + app.entry.write_text("[Desktop Entry]\nX-Flet-Generated=true\nNoDisplay=true\n") + probe = start_probe() + probe.expect("ready") + assert not app.entry.exists() + assert not app.runtime.exists() + probe.stop() + assert app.source.read_bytes() == original + + +def test_appimage_source_in_process_data_dirs_still_registers(app, start_probe): + """An AppImage wrapper's own mounted entry is not a system installation.""" + app.env["APPIMAGE"] = str(app.exe.parent.parent / "app.AppImage") + app.env["XDG_DATA_DIRS"] = str(app.source.parent.parent) + probe = start_probe() + probe.expect("ready") + assert "NoDisplay=true" in app.entry.read_text() + assert app.env["APPIMAGE"] in app.entry.read_text() + probe.stop() + assert not app.entry.exists() + assert not app.runtime.exists() + + +def test_shutdown_waits_for_startup_to_publish_its_marker(app, start_probe): + """Cleanup cannot delete an entry a new instance has already read.""" + first = start_probe() + first.expect("ready") + second = start_probe("pause-read") + second.expect("snapshot") + first.resume() + first.expect("stopping") + with pytest.raises(subprocess.TimeoutExpired): + first.process.wait(timeout=0.2) + second.resume() + second.expect("ready") + first.expect("stopped") + assert first.process.wait(timeout=5) == 0 + assert app.entry.exists() + assert (app.runtime / "icon.png").exists() + second.stop() + assert not app.entry.exists() + assert not app.runtime.exists() + + +def test_startup_waits_for_last_instance_cleanup(app, start_probe): + """A new entry cannot be published between the last-instance scan/unlink.""" + first = start_probe("pause-remove") + first.expect("ready") + first.resume() + first.expect("stopping") + first.expect("removing") + second = start_probe() + assert not select.select([second.process.stdout], [], [], 0.2)[0] + first.resume() + first.expect("stopped") + assert first.process.wait(timeout=5) == 0 + second.expect("ready") + assert app.entry.exists() + assert (app.runtime / "icon.png").exists() + second.stop() + assert not app.entry.exists() + assert not app.runtime.exists() diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/linux/my_application.cc b/sdk/python/templates/build/{{cookiecutter.out_dir}}/linux/my_application.cc index a14245581c..f8fde13d09 100644 --- a/sdk/python/templates/build/{{cookiecutter.out_dir}}/linux/my_application.cc +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/linux/my_application.cc @@ -1,6 +1,15 @@ #include "my_application.h" +#include +#include #include +#include +#include +#include +#include +#include +#include +#include #ifdef GDK_WINDOWING_X11 #include #endif @@ -28,6 +37,301 @@ static gboolean no_impeller_requested() { return g_strcmp0(g_getenv("FLUTTER_LINUX_RENDERER"), "software") == 0; } +// Marks a desktop entry this app installed for itself, so it is only ever +// rewritten or removed when it is the app's own. +#define FLET_GENERATED_KEY "X-Flet-Generated" + +// While the app runs with its desktop entry installed: the entry, the +// directory holding the app's runtime files, and this instance's marker in it. +static gchar* installed_entry = nullptr; +static gchar* runtime_dir = nullptr; +static gchar* instance_marker = nullptr; + +// Who a desktop entry in the user's data directory belongs to. +typedef enum { ENTRY_ABSENT, ENTRY_OWN, ENTRY_FOREIGN } EntryOwner; + +// Serializes entry updates and the last instance's cleanup. Keep the lock +// beside the runtime directory, and never unlink it: replacing its inode +// would let a new instance bypass a lock another instance still holds. +class DesktopEntryLock { + public: + explicit DesktopEntryLock(const gchar* dir) { + g_autofree gchar* parent = g_path_get_dirname(dir); + if (g_mkdir_with_parents(parent, 0700) != 0) { + return; + } + g_autofree gchar* path = g_strconcat(dir, ".lock", nullptr); + fd_ = g_open(path, O_CREAT | O_RDWR | O_CLOEXEC, 0600); + if (fd_ < 0) { + return; + } + while (flock(fd_, LOCK_EX) != 0) { + if (errno != EINTR) { + close(fd_); + fd_ = -1; + return; + } + } + } + + ~DesktopEntryLock() { + if (fd_ >= 0) { + close(fd_); + } + } + + bool acquired() const { return fd_ >= 0; } + + private: + int fd_ = -1; +}; + +// Tells whose entry is at `path`, and returns an own entry's `contents`. +// +// Only a regular file this app can read and that carries FLET_GENERATED_KEY +// is its own. Anything else that is there, such as an unreadable file or a +// symlink, is the user's, and must not be replaced. +static EntryOwner user_entry_owner(const gchar* path, gchar** contents) { + GStatBuf status; + if (g_lstat(path, &status) != 0) { + return errno == ENOENT ? ENTRY_ABSENT : ENTRY_FOREIGN; + } + g_autofree gchar* data = nullptr; + g_autoptr(GKeyFile) entry = g_key_file_new(); + if (!S_ISREG(status.st_mode) || + !g_file_get_contents(path, &data, nullptr, nullptr) || + !g_key_file_load_from_data(entry, data, -1, G_KEY_FILE_NONE, nullptr) || + !g_key_file_has_key(entry, G_KEY_FILE_DESKTOP_GROUP, FLET_GENERATED_KEY, + nullptr)) { + return ENTRY_FOREIGN; + } + if (contents != nullptr) { + *contents = g_steal_pointer(&data); + } + return ENTRY_OWN; +} + +// Returns whether `a` and `b` are the same file. +static gboolean same_file(const gchar* a, const gchar* b) { + GStatBuf a_status, b_status; + return g_stat(a, &a_status) == 0 && g_stat(b, &b_status) == 0 && + a_status.st_dev == b_status.st_dev && + a_status.st_ino == b_status.st_ino; +} + +// Returns whether another instance of the app runs, going by the markers in +// `dir`; markers of instances that have exited are removed. +static gboolean other_instance_running(const gchar* dir) { + g_autoptr(GDir) markers = g_dir_open(dir, 0, nullptr); + if (markers == nullptr) { + return FALSE; + } + gboolean running = FALSE; + const gchar* name; + while ((name = g_dir_read_name(markers)) != nullptr) { + gchar* end = nullptr; + gint64 pid = g_ascii_strtoll(name, &end, 10); + if (end == name || *end != '\0' || pid <= 0 || pid == getpid()) { + continue; + } + if (kill((pid_t)pid, 0) == 0 || errno == EPERM) { + running = TRUE; + } else { + g_autofree gchar* marker = g_build_filename(dir, name, nullptr); + g_remove(marker); + } + } + return running; +} + +// Quotes `path` as one argument of a desktop entry's Exec key: inside double +// quotes, `"`, `` ` ``, `$` and `\` are backslash-escaped. +static gchar* quote_exec_argument(const gchar* path) { + GString* quoted = g_string_new("\""); + for (const gchar* c = path; *c != '\0'; c++) { + if (*c == '"' || *c == '`' || *c == '$' || *c == '\\') { + g_string_append_c(quoted, '\\'); + } + g_string_append_c(quoted, *c); + } + g_string_append_c(quoted, '"'); + return g_string_free(quoted, FALSE); +} + +// Removes the desktop entry install_desktop_entry() installed, once no other +// instance of the app runs. +static void remove_desktop_entry() { + if (instance_marker == nullptr) { + return; + } + DesktopEntryLock lock(runtime_dir); + if (!lock.acquired()) { + return; + } + g_remove(instance_marker); + if (!other_instance_running(runtime_dir)) { + if (user_entry_owner(installed_entry, nullptr) == ENTRY_OWN) { + g_remove(installed_entry); + } + g_autofree gchar* icon = g_build_filename(runtime_dir, "icon.png", nullptr); + g_remove(icon); + g_rmdir(runtime_dir); + } + g_clear_pointer(&instance_marker, g_free); + g_clear_pointer(&installed_entry, g_free); + g_clear_pointer(&runtime_dir, g_free); +} + +// Removes the desktop entry when the app is told to terminate, as at logout, +// then terminates as the signal would have. +static gboolean on_terminate_signal(gpointer signal_number) { + remove_desktop_entry(); + signal(GPOINTER_TO_INT(signal_number), SIG_DFL); + raise(GPOINTER_TO_INT(signal_number)); + return G_SOURCE_REMOVE; +} + +// Installs the bundle's desktop entry for the current user while the app +// runs, so desktops that take an app's icon and name only from an installed +// entry show them. +// +// GNOME Shell does that on Wayland, and from version 46 on X11 too, and so do +// KDE Plasma and COSMIC on Wayland: without an entry the dock shows a generic +// icon and the app id as its name. The entry must be named after the app id, +// as the desktop finds it by that name. It is hidden from the application +// launcher (`NoDisplay`), and points at this executable, or at the AppImage +// the app runs from, and at a copy of the bundle's icon that stays put while +// any instance runs. +// +// The last instance to exit removes it, so it never outlives the app: an +// entry in the user's directory takes precedence over a package's, and would +// hide a version of the app installed later from the launcher. +// +// An entry the user installed is left alone, and so are sandboxed apps, which +// Flatpak and Snap register, and an app a package installed with its own entry. +static void install_desktop_entry(const gchar* exe_path) { + if (g_getenv("FLATPAK_ID") != nullptr || g_getenv("SNAP") != nullptr) { + return; + } + // An AppImage runs from a mount that goes away with it. + const gchar* launch_path = g_getenv("APPIMAGE"); + const gboolean is_appimage = launch_path != nullptr && *launch_path != '\0'; + if (!is_appimage) { + launch_path = exe_path; + } + // GLib checks that an entry's program exists using the path as written, + // but expands `%` codes in it to launch it, so no Exec works for a path + // with a `%`. + if (strchr(launch_path, '%') != nullptr) { + return; + } + + g_autofree gchar* app_runtime_dir = g_build_filename( + g_get_user_runtime_dir(), "flet", APPLICATION_ID, nullptr); + DesktopEntryLock lock(app_runtime_dir); + if (!lock.acquired()) { + return; + } + + g_autofree gchar* entry_name = g_strconcat(APPLICATION_ID, ".desktop", nullptr); + g_autofree gchar* user_entry = g_build_filename( + g_get_user_data_dir(), "applications", entry_name, nullptr); + g_autofree gchar* installed = nullptr; + EntryOwner owner = user_entry_owner(user_entry, &installed); + if (owner == ENTRY_FOREIGN) { + return; + } + + // The bundle keeps its entry in `share/` next to the executable; an AppImage + // or another `prefix/bin` + `prefix/share` layout keeps it a level up. + g_autofree gchar* exe_dir = g_path_get_dirname(exe_path); + g_autofree gchar* source = g_build_filename(exe_dir, "share", "applications", + entry_name, nullptr); + g_autoptr(GKeyFile) entry = g_key_file_new(); + if (!g_key_file_load_from_file(entry, source, G_KEY_FILE_NONE, nullptr)) { + g_free(source); + source = g_build_filename(exe_dir, "..", "share", "applications", + entry_name, nullptr); + if (!g_key_file_load_from_file(entry, source, G_KEY_FILE_NONE, nullptr)) { + return; + } + } + + // A prefix/bin + prefix/share install can use the source itself as its + // system entry. Preserve it too; same_file() alone cannot distinguish an + // installed entry from a wrapper's process-local XDG_DATA_DIRS addition. + // Only an AppImage's own entry is known to live on a temporary mount. + for (const gchar* const* dir = g_get_system_data_dirs(); *dir != nullptr; + dir++) { + g_autofree gchar* system_entry = + g_build_filename(*dir, "applications", entry_name, nullptr); + if (g_file_test(system_entry, G_FILE_TEST_EXISTS) && + !(is_appimage && same_file(system_entry, source))) { + if (owner == ENTRY_OWN) { + g_remove(user_entry); + } + return; + } + } + + // The lock keeps cleanup from removing the entry between the ownership + // check above and this instance publishing its marker and entry. + g_autofree gchar* pid = g_strdup_printf("%d", (int)getpid()); + g_autofree gchar* marker = g_build_filename(app_runtime_dir, pid, nullptr); + if (g_mkdir_with_parents(app_runtime_dir, 0700) != 0 || + !g_file_set_contents(marker, "", 0, nullptr)) { + return; + } + + g_autofree gchar* exec_path = quote_exec_argument(launch_path); + g_autofree gchar* exec = g_strconcat(exec_path, " %U", nullptr); + g_key_file_set_string(entry, G_KEY_FILE_DESKTOP_GROUP, + G_KEY_FILE_DESKTOP_KEY_EXEC, exec); + g_autofree gchar* bundle_icon = + g_build_filename(exe_dir, "data", "app_icon.png", nullptr); + g_autofree gchar* icon_data = nullptr; + gsize icon_size = 0; + if (g_file_get_contents(bundle_icon, &icon_data, &icon_size, nullptr)) { + g_autofree gchar* icon = + g_build_filename(app_runtime_dir, "icon.png", nullptr); + g_key_file_set_string( + entry, G_KEY_FILE_DESKTOP_GROUP, G_KEY_FILE_DESKTOP_KEY_ICON, + g_file_set_contents(icon, icon_data, icon_size, nullptr) ? icon + : bundle_icon); + } + g_key_file_set_boolean(entry, G_KEY_FILE_DESKTOP_GROUP, + G_KEY_FILE_DESKTOP_KEY_NO_DISPLAY, TRUE); + g_key_file_set_boolean(entry, G_KEY_FILE_DESKTOP_GROUP, FLET_GENERATED_KEY, + TRUE); + // The bundle's comment explains installing the entry by hand. + g_key_file_remove_comment(entry, nullptr, nullptr, nullptr); + + g_autofree gchar* contents = g_key_file_to_data(entry, nullptr, nullptr); + if (g_strcmp0(contents, installed) != 0) { + g_autofree gchar* user_dir = g_path_get_dirname(user_entry); + g_autoptr(GError) error = nullptr; + if (g_mkdir_with_parents(user_dir, 0700) != 0 || + !g_file_set_contents(user_entry, contents, -1, &error)) { + g_warning("Failed to install desktop entry %s: %s", user_entry, + error != nullptr ? error->message : g_strerror(errno)); + g_remove(marker); + return; + } + } + + installed_entry = g_steal_pointer(&user_entry); + runtime_dir = g_steal_pointer(&app_runtime_dir); + instance_marker = g_steal_pointer(&marker); + // Closing the window runs the app's shutdown, which removes the entry; + // `sys.exit()` ends the process at once, leaving it to the next start. + atexit(remove_desktop_entry); + const int terminate_signals[] = {SIGTERM, SIGINT, SIGHUP}; + for (int signal_number : terminate_signals) { + g_unix_signal_add(signal_number, on_terminate_signal, + GINT_TO_POINTER(signal_number)); + } +} + // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); @@ -65,9 +369,14 @@ static void my_application_activate(GApplication* application) { // XWayland) it is exported as the _NET_WM_ICON property read by taskbars // and window switchers. Native Wayland sessions have no window-icon // protocol GTK 3 can use; there the desktop environment resolves the icon - // from an installed APPLICATION_ID.desktop entry instead. + // from an installed APPLICATION_ID.desktop entry instead, which + // install_desktop_entry() provides. g_autofree gchar* exe_path = g_file_read_link("/proc/self/exe", nullptr); if (exe_path != nullptr) { + // `[tool.flet.linux].desktop_entry=false` turns the entry off. + if ({{ "FALSE" if get_pyproject("tool.flet.linux.desktop_entry") == false else "TRUE" }}) { + install_desktop_entry(exe_path); + } g_autofree gchar* exe_dir = g_path_get_dirname(exe_path); g_autofree gchar* icon_path = g_build_filename(exe_dir, "data", "app_icon.png", nullptr); @@ -193,6 +502,7 @@ static void my_application_shutdown(GApplication* application) { //MyApplication* self = MY_APPLICATION(object); // Perform any actions required at application shutdown. + remove_desktop_entry(); G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); } diff --git a/website/docs/publish/linux.md b/website/docs/publish/linux.md index 50f3bbd52a..8dcfdb49f4 100644 --- a/website/docs/publish/linux.md +++ b/website/docs/publish/linux.md @@ -89,20 +89,37 @@ default Flet icon) in the `assets` directory of your Flet app — see [Icons](index.md#icons). `flet build linux` renders it into the bundle as `data/app_icon.png`, and the app sets it as its window icon on startup. -How the icon shows up depends on the display server (see +How the icon shows up depends on the desktop (see [Window positioning on Wayland](#window-positioning-on-wayland) for checking the session type): -- **X11** (and XWayland): taskbars and window switchers read the window icon - directly — it works out of the box, no installation needed. +- **X11** (and XWayland): most taskbars and window switchers read the window + icon directly. GNOME Shell 46 and later (Ubuntu 24.04) doesn't, even on X11. - **Wayland** (the default session on modern GNOME/Ubuntu): the protocol has no window-icon concept. The desktop environment resolves the app's name and icon from an installed `.desktop` entry matching the app id, and shows a generic icon until one is installed. -For Wayland (and for listing the app in the application launcher on any -session), the bundle ships a ready-to-install desktop entry and icon under -`share/`: +So that the dock shows the app's icon and name everywhere, the app installs a +desktop entry for the current user while it runs, at +`applications/.desktop` in `$XDG_DATA_HOME` (`~/.local/share` by +default), and the last running instance removes it when it closes. The entry +is hidden from the application launcher (`NoDisplay=true`), so it doesn't add +the app to the user's app grid, and as it doesn't outlive the app, pinning the +app to the dock lasts only while it runs. The app leaves alone an entry with +the same name that a package installed system-wide, or that the user +installed themselves, and installs none when it runs in Flatpak or Snap, or +from a path containing `%`. + +To turn this off, set `desktop_entry` to `false`: + +```toml title="pyproject.toml" +[tool.flet.linux] +desktop_entry = false +``` + +To list the app in the application launcher, or pin it for good, install the +desktop entry and icon the bundle ships under `share/`: ``` share/applications/.desktop @@ -133,10 +150,11 @@ arguments and the launcher fails. Linux packaging tools The desktop entry's name comes from `--product` and its comment from `--description` (or the corresponding `pyproject.toml` settings); the app id is the [bundle ID](index.md#bundle-id) — `.` by default. -Until the entry is installed the desktop environment has no name for the app -and falls back to that id, so the dock tooltip reads `com.example.my_app` -rather than your product name; installing the entry fixes the name and the -Wayland icon together, since both are resolved from it. +Without an entry the desktop environment has no name for the app and falls back +to that id, so the dock tooltip reads `com.example.my_app` rather than your +product name; an installed entry, the app's own or this one, fixes the name and +the icon together, since both are resolved from it. Installing this entry +replaces the one the app installs for itself, which then leaves it alone. Its [application categories](#application-categories) decide where the app is filed in application menus. @@ -374,15 +392,10 @@ executable's own path, so `AppRun` only has to `exec` it. `desktop-file-validate` over it, failing on any error. Both are satisfied by the generated entry. -:::note[An AppImage registers nothing with the desktop] -The desktop entry travels *inside* the image, where nothing scans it. So the -app grid has no entry for the app, and hovering its icon shows the app id -rather than the name — the shell has no `Name=` to read and falls back to what -the window calls itself. - -The window and dock icons still work on X11, because those come from the -window's own `_NET_WM_ICON` rather than from an entry. On Wayland, where the -icon is resolved through the entry too, an unregistered AppImage gets neither. +:::note[An AppImage isn't listed in the app grid] +The desktop entry travels *inside* the image, where nothing scans it. While the +app runs, the hidden entry it installs for itself (see [App icon](#app-icon)) +gives the dock its icon and name. But the app grid has no entry for the app. Users can register it themselves with a tool like [AppImageLauncher](https://github.com/TheAssassin/AppImageLauncher). If you