Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
#include "my_application.h"

#include <errno.h>
#include <flutter_linux/flutter_linux.h>
#include <glib-unix.h>
#include <glib/gstdio.h>
#include <signal.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
Expand Down Expand Up @@ -28,6 +35,252 @@ 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;

// 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;
}
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");
if (launch_path == nullptr || *launch_path == '\0') {
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* 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;
}
}

// The bundle's own entry is in a system data directory only when a wrapper
// added its `share/` to XDG_DATA_DIRS, which the desktop doesn't see.
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) &&
!same_file(system_entry, source)) {
if (owner == ENTRY_OWN) {
g_remove(user_entry);
}
return;
}
}

// The marker comes first, so an instance exiting meanwhile keeps the entry.
g_autofree gchar* app_runtime_dir = g_build_filename(
g_get_user_runtime_dir(), "flet", APPLICATION_ID, nullptr);
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);
Expand Down Expand Up @@ -65,9 +318,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);
Expand Down Expand Up @@ -193,6 +451,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);
}
Expand Down
51 changes: 32 additions & 19 deletions website/docs/publish/linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<bundle_id>.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/<bundle_id>.desktop
Expand Down Expand Up @@ -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) — `<org_name>.<project_name>` 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.
Expand Down Expand Up @@ -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
Expand Down
Loading