From d4d13e616cb15ef22479d9cb97922af93317a01a Mon Sep 17 00:00:00 2001 From: Berke Güzel Date: Tue, 13 Oct 2026 17:05:59 +0300 Subject: Add desktop services: open URI, notify, appearance, bell, and paths wsap_term keeps per-platform code for opening links, notifications, the bell, high-contrast detection, the executable and bundle paths, and the default locale. These now live behind . On Linux, src/platform/freedesktop serves both backends. Opening a URI runs xdg-open from PATH through a double fork, so the opener gets default signal dispositions, an empty mask, a new session, and none of the application's descriptors. Notifications and the Settings portal use the session bus through libdbus-1, an optional dependency behind -Ddbus. Appearance falls back to GTK_THEME without a portal. The bell uses xdg-system-bell on Wayland and the XKB bell on X11. Cocoa implements the same calls with NSWorkspace, UserNotifications, the effective appearance, NSBeep, the bundle, and CFLocale. That code is not built or run here; it needs a macOS check. The desktop test runs under dbus-run-session against a fake notification server and portal, and uses a fake xdg-open to check argument passing, signal state, and descriptor inheritance. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 3 + README.md | 3 +- docs/getting-started.md | 4 +- docs/guide.md | 41 ++++- include/yasl/desktop.h | 113 ++++++++++++ meson.build | 9 +- meson.options | 1 + src/common/backend.h | 4 + src/common/desktop.c | 81 ++++++++ src/common/desktop.h | 19 ++ src/common/meson.build | 2 +- src/platform/cocoa/desktop.m | 145 +++++++++++++++ src/platform/cocoa/meson.build | 2 +- src/platform/cocoa/window.m | 10 + src/platform/freedesktop/dbus.c | 187 +++++++++++++++++++ src/platform/freedesktop/dbus.h | 16 ++ src/platform/freedesktop/desktop.c | 150 +++++++++++++++ src/platform/freedesktop/meson.build | 1 + src/platform/meson.build | 4 + src/platform/wayland/api.h | 1 + src/platform/wayland/backend.c | 6 + src/platform/wayland/internal.h | 2 + src/platform/wayland/shell.c | 9 + src/platform/wayland/surface.c | 2 + src/platform/x11/window.c | 14 ++ tests/common/test_desktop.c | 347 +++++++++++++++++++++++++++++++++++ tests/meson.build | 18 ++ tests/test_api.cpp | 6 + 28 files changed, 1193 insertions(+), 7 deletions(-) create mode 100644 include/yasl/desktop.h create mode 100644 src/common/desktop.c create mode 100644 src/common/desktop.h create mode 100644 src/platform/cocoa/desktop.m create mode 100644 src/platform/freedesktop/dbus.c create mode 100644 src/platform/freedesktop/dbus.h create mode 100644 src/platform/freedesktop/desktop.c create mode 100644 src/platform/freedesktop/meson.build create mode 100644 tests/common/test_desktop.c diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30432ec..788425e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,9 @@ windowing implementations, key mappings, and typed native accessors live in OS support shared by multiple backends lives separately in `src/platform/posix/` and is selected by `src/platform/meson.build`. It currently supplies the event loop and the private mutex implementation used through `platform/sync.h`. +Linux desktop services shared by Wayland and X11, such as xdg-open and the +D-Bus session bus, live in `src/platform/freedesktop/`; Cocoa keeps its own in +`src/platform/cocoa/desktop.m`. Both implement `src/common/desktop.h`. Common code should depend on these internal interfaces rather than native OS headers. Keep public headers in `include/yasl/` and tests in `tests//`. diff --git a/README.md b/README.md index a02ba9e..992b331 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ for platform capabilities and limits. Use a C23 compiler, Meson 1.6 or newer, and Ninja. Linux needs the development packages for Wayland (`wayland-client`, `wayland-cursor`, `xkbcommon`) or X11 -(`x11`, `xi`, `xkbcommon`, `xext`; optional `xrandr`). Cocoa requires macOS 14 or +(`x11`, `xi`, `xkbcommon`, `xext`; optional `xrandr`). Optional `dbus-1` enables +desktop notifications and appearance queries on Linux. Cocoa requires macOS 14 or newer and the system SDK, with a Metal-capable device for window drawing. ```sh diff --git a/docs/getting-started.md b/docs/getting-started.md index 73af4ab..5e569fe 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -20,7 +20,8 @@ Use a C23 compiler, Meson 1.6 or newer, and Ninja. GCC and Clang can build the l | --- | --- | | Wayland | `wayland-client`, `wayland-cursor`, `xkbcommon` | | X11 | `x11`, `xi`, `xkbcommon`, `xext`; optional `xrandr` | -| Cocoa | macOS 14+ SDK with AppKit, Metal, and QuartzCore; Metal-capable device | +| Linux desktop services | Optional `dbus-1` for notifications and appearance | +| Cocoa | macOS 14+ SDK with AppKit, Metal, QuartzCore, and UserNotifications; Metal-capable device | ```sh meson setup build @@ -32,6 +33,7 @@ meson test -C build --print-errorlogs Each Linux backend defaults to `auto`, meaning build it when its dependencies are available. Require one with `-Dwayland=enabled` or `-Dx11=enabled`; omit one with `-Dwayland=disabled` or `-Dx11=disabled`. At least one backend is required. +`-Ddbus` works the same way for the optional D-Bus desktop services. Cocoa is selected on macOS and requires no extra third-party dependencies. Its minimum deployment target is macOS 14. Set `MACOSX_DEPLOYMENT_TARGET=14.0` (or newer) before configuring if your toolchain defaults to an older target. diff --git a/docs/guide.md b/docs/guide.md index 61583b1..7f02ab7 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -1,8 +1,8 @@ # Application guide The contracts in [yasl.h](../include/yasl/yasl.h), [loop.h](../include/yasl/loop.h), -[timing.h](../include/yasl/timing.h), and [native.h](../include/yasl/native.h) are -the reference for individual calls. All examples here use the display's opening +[timing.h](../include/yasl/timing.h), [desktop.h](../include/yasl/desktop.h), and +[native.h](../include/yasl/native.h) are the reference for individual calls. All examples here use the display's opening thread, which must be the main thread on macOS. Userdata stays owned by the application. Copy borrowed data before retaining it beyond its documented life. @@ -363,6 +363,43 @@ and restores the previous action on unwatch. YASL leaves thread signal masks alone: a watched signal must stay unblocked in at least one thread. Only one loop can watch a given signal at a time. +## Desktop services + +Include `` for services that sit next to windowing. Display and +surface calls use the display thread; path and locale calls work from any +thread. + +| Call | Linux | Cocoa | +| --- | --- | --- | +| `yasl_display_open_uri()` | `xdg-open` from `PATH` | NSWorkspace | +| `yasl_display_notify()` | Notifications service over D-Bus | User notifications; app bundle required | +| `yasl_display_appearance()` | Settings portal over D-Bus, then `GTK_THEME` | Effective appearance and increase-contrast setting | +| `yasl_surface_bell()` | Wayland xdg-system-bell; X11 XKB bell | `NSBeep` | +| `yasl_executable_path()` | `/proc/self/exe` | Executable path, resolved | +| `yasl_resource_dir()` | Always zero | App bundle resources | +| `yasl_default_locale()` | `LC_ALL`, `LC_CTYPE`, `LANG`, then `C.UTF-8` | Current language and region | + +`yasl_display_open_uri()` checks only that the URI has a scheme. Decide which +schemes and hosts your application trusts before calling it, for example +before opening a link from untrusted text. On Linux the opener starts in its +own session with default signal handling and none of the application's +descriptors above stderr. D-Bus services need YASL built with `dbus-1`; without +it, notifications return false and appearance falls back to `GTK_THEME`. +Appearance changes send no notification, so query again when a window gains +focus. + +Functions that fill a buffer return the full length, excluding the NUL. A +result less than the buffer size means the string was written. Otherwise the +buffer is unchanged, so call again with a larger one: + +```c +char path[4096]; +size_t len = yasl_executable_path(path, sizeof(path)); +if (len == 0 || len >= sizeof(path)) { + /* No path, or a longer buffer is needed. */ +} +``` + ## Native interoperability Portable CPU drawing needs no native headers. Opt into `` and diff --git a/include/yasl/desktop.h b/include/yasl/desktop.h new file mode 100644 index 0000000..3e72a50 --- /dev/null +++ b/include/yasl/desktop.h @@ -0,0 +1,113 @@ +/** + * @file + * Desktop services next to windowing: opening URIs, notifications, the bell, + * appearance preferences, and process paths. + * + * Display and surface functions use the display's opening thread (the main + * thread on Cocoa). Path and locale functions may be called from any thread. + * On Linux, notifications and appearance use the D-Bus session bus when YASL + * is built with libdbus-1; without it they report unsupported. + * + * Functions that fill a buffer return the full string length in bytes, + * excluding the NUL, or zero on failure. A result less than cap means out + * holds the NUL-terminated string. Otherwise out is unchanged; call again with + * a buffer of at least the result plus one. + */ +#ifndef YASL_DESKTOP_H +#define YASL_DESKTOP_H + +#include "yasl/yasl.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** Appearance preference flags returned by yasl_display_appearance(). */ +enum yasl_appearance : uint32_t { + YASL_APPEARANCE_DARK = 1 << 0, /**< The user prefers dark colors. */ + YASL_APPEARANCE_LIGHT = 1 << 1, /**< The user prefers light colors. */ + YASL_APPEARANCE_HIGH_CONTRAST = 1 << 2, /**< The user asks for higher contrast. */ +}; + +/** + * Open a URI with the user's default handler. + * @param d Display, or NULL. + * @param uri NUL-terminated absolute URI with a scheme, such as https: or file:. + * @return true if the request was handed to the system; false for NULL + * arguments, a missing scheme, or a failed launch. + * YASL checks only the URI syntax. The application decides which schemes and + * hosts it trusts. Success does not confirm that a handler opened the URI. + * Linux runs xdg-open from PATH in a new session, with default signal + * dispositions, an empty signal mask, stdin on /dev/null, and no descriptors + * above stderr. It forks a short-lived helper first, which raises SIGCHLD; + * YASL reaps it unless the application's reaper does. Cocoa uses NSWorkspace. + */ +YASL_API bool yasl_display_open_uri(struct yasl_display *d, const char *uri); +/** + * Show a desktop notification. + * @param d Display, or NULL. + * @param title NUL-terminated UTF-8 summary. + * @param body NUL-terminated UTF-8 body, or NULL for none. + * @return true if the request was sent; false for NULL arguments or when no + * notification service is available. + * Linux sends org.freedesktop.Notifications.Notify with the display's app_id + * as the application name and desktop-entry hint. Cocoa uses the user + * notification center, which needs an app bundle with a bundle identifier, + * and asks for permission on first use. Success does not confirm that the + * notification was shown. + */ +YASL_API bool yasl_display_notify(struct yasl_display *d, const char *title, const char *body); +/** + * Read the user's appearance preferences. + * @param d Display, or NULL. + * @return A yasl_appearance bitmask, or zero for NULL, no preference, or no + * way to read it. + * Linux reads color-scheme and contrast from the XDG Settings portal, which + * waits briefly for a reply, then falls back to the GTK_THEME variable. Cocoa + * reads the effective appearance and the increase-contrast setting. The + * result is not cached and no change notification is sent; query again when + * the window is focused. + */ +YASL_API uint32_t yasl_display_appearance(struct yasl_display *d); +/** + * Ring the system bell for a surface. + * @param s Surface, or NULL. + * @return true if the request was sent; false for NULL or when unsupported. + * The system decides whether the bell is audible, visual, or muted. Wayland + * needs the xdg-system-bell protocol; X11 uses the XKB bell for the window + * and Cocoa uses NSBeep. + */ +YASL_API bool yasl_surface_bell(struct yasl_surface *s); +/** + * Get the absolute path of the running executable, with symbolic links resolved. + * @param out Output buffer, or NULL when cap is zero. + * @param cap Size of out in bytes. + * @return Path length, or zero on failure. See the buffer rule in this file. + */ +YASL_API size_t yasl_executable_path(char *out, size_t cap); +/** + * Get the resource directory of the app bundle that contains the executable. + * @param out Output buffer, or NULL when cap is zero. + * @param cap Size of out in bytes. + * @return Path length, or zero when the executable is not in an app bundle. + * Only Cocoa has app bundles; other platforms return zero. + */ +YASL_API size_t yasl_resource_dir(char *out, size_t cap); +/** + * Get the user's preferred locale as a POSIX locale name, such as "en_US.UTF-8". + * @param out Output buffer, or NULL when cap is zero. + * @param cap Size of out in bytes. + * @return Name length, or zero on failure. + * Linux returns the first nonempty LC_ALL, LC_CTYPE, or LANG, and "C.UTF-8" + * when none is set. Cocoa builds the name from the current locale's language + * and region with a ".UTF-8" codeset, because apps started from Finder have no + * locale variables. Use the result to set LANG for child processes; YASL does + * not change the process locale. + */ +YASL_API size_t yasl_default_locale(char *out, size_t cap); + +#ifdef __cplusplus +} +#endif + +#endif /* YASL_DESKTOP_H */ diff --git a/meson.build b/meson.build index 8e396d8..5049872 100644 --- a/meson.build +++ b/meson.build @@ -20,6 +20,7 @@ sources = [] has_wayland = false has_x11 = false +has_dbus = false has_cocoa = system == 'darwin' if system == 'linux' @@ -40,6 +41,11 @@ if system == 'linux' add_project_arguments('-DYASL_HAS_XRANDR=1', language: 'c') endif endif + dbus = dependency('dbus-1', required: get_option('dbus')) + has_dbus = dbus.found() + if has_dbus + deps += [dbus] + endif m_dep = cc.find_library('m', required: false) rt_dep = cc.find_library('rt', required: false) deps += [m_dep, rt_dep] @@ -57,7 +63,7 @@ elif system == 'darwin' error('Cocoa requires macOS 14 or newer. Set MACOSX_DEPLOYMENT_TARGET=14.0 or a newer target before configuring.') endif deps += [ - dependency('appleframeworks', modules: ['AppKit', 'Metal', 'QuartzCore']), + dependency('appleframeworks', modules: ['AppKit', 'Metal', 'QuartzCore', 'UserNotifications']), ] else error('YASL supports Linux and macOS.') @@ -71,6 +77,7 @@ add_project_arguments( '-DYASL_HAS_WAYLAND=' + (has_wayland ? '1' : '0'), '-DYASL_HAS_X11=' + (has_x11 ? '1' : '0'), '-DYASL_HAS_COCOA=' + (has_cocoa ? '1' : '0'), + '-DYASL_HAS_DBUS=' + (has_dbus ? '1' : '0'), language: has_cocoa ? ['c', 'objc'] : ['c'], ) if has_cocoa diff --git a/meson.options b/meson.options index 5bf2334..9f7995a 100644 --- a/meson.options +++ b/meson.options @@ -1,4 +1,5 @@ option('wayland', type: 'feature', value: 'auto', description: 'Build the Wayland backend.') option('x11', type: 'feature', value: 'auto', description: 'Build the X11 backend.') +option('dbus', type: 'feature', value: 'auto', description: 'Use the D-Bus session bus on Linux for notifications and desktop settings.') option('integration_tests', type: 'boolean', value: false, description: 'Test windows with Linux display servers or the native macOS display.') option('x11_tablet_tests', type: 'boolean', value: false, description: 'Test native pressure drawing with Xorg dummy and inputtest drivers (no tablet hardware required).') diff --git a/src/common/backend.h b/src/common/backend.h index f0fae03..fd60418 100644 --- a/src/common/backend.h +++ b/src/common/backend.h @@ -15,6 +15,8 @@ struct yasl_display { struct yasl_display_cbs callbacks; struct yasl_loop *loop; void *app_runner; + /* Backend's copy of yasl_display_spec.app_id, or NULL; for desktop services. */ + const char *app_id; }; struct yasl_surface { @@ -89,6 +91,8 @@ struct yasl_backend_ops { bool (*surface_show_window_menu)(struct yasl_surface *s, struct yasl_vector2i pos); bool (*surface_drag_carry)(struct yasl_surface *s, struct yasl_vector2i offset); bool (*surface_place_under_pointer)(struct yasl_surface *s, struct yasl_vector2i at); + /* Optional; NULL means the bell is unsupported. */ + bool (*surface_bell)(struct yasl_surface *s); void *(*display_get_native_handle)(struct yasl_display *d); void *(*surface_get_native_handle)(struct yasl_surface *s); bool (*surface_get_native_frame)(struct yasl_surface *s, diff --git a/src/common/desktop.c b/src/common/desktop.c new file mode 100644 index 0000000..23c62d7 --- /dev/null +++ b/src/common/desktop.c @@ -0,0 +1,81 @@ +#include "yasl/desktop.h" +#include "common/backend.h" +#include "common/desktop.h" + +#include + +/* Large enough for any path the platform APIs return. */ +#define DESKTOP_STR_MAX 4096 + +/* An RFC 3986 scheme, then ':', and no control characters anywhere. */ +static bool uri_valid(const char *uri) +{ + unsigned char c = (unsigned char)uri[0]; + if ((unsigned)((c | 32) - 'a') >= 26) return false; + const char *p = uri + 1; + for (; *p != ':'; p++) { + c = (unsigned char)*p; + bool alpha = (unsigned)((c | 32) - 'a') < 26; + bool digit = (unsigned)(c - '0') < 10; + if (!alpha && !digit && c != '+' && c != '-' && c != '.') return false; + } + for (; *p; p++) { + c = (unsigned char)*p; + if (c < 0x20 || c == 0x7f) return false; + } + return true; +} + +/* Copy len bytes and a NUL when they fit, and report the full length. */ +static size_t copy_out(char *out, size_t cap, const char *s, size_t len) +{ + if (len && len < cap) { + memcpy(out, s, len); + out[len] = '\0'; + } + return len; +} + +bool yasl_display_open_uri(struct yasl_display *d, const char *uri) +{ + if (!d || !uri || !uri_valid(uri)) return false; + return desktop_open_uri(uri); +} + +bool yasl_display_notify(struct yasl_display *d, const char *title, const char *body) +{ + if (!d || !title) return false; + return desktop_notify(d->app_id, title, body ? body : ""); +} + +uint32_t yasl_display_appearance(struct yasl_display *d) +{ + return d ? desktop_appearance() : 0; +} + +bool yasl_surface_bell(struct yasl_surface *s) +{ + if (!s || !s->display->ops->surface_bell) return false; + return s->display->ops->surface_bell(s); +} + +size_t yasl_executable_path(char *out, size_t cap) +{ + if (!out && cap) return 0; + char buf[DESKTOP_STR_MAX]; + return copy_out(out, cap, buf, desktop_executable_path(buf, sizeof(buf))); +} + +size_t yasl_resource_dir(char *out, size_t cap) +{ + if (!out && cap) return 0; + char buf[DESKTOP_STR_MAX]; + return copy_out(out, cap, buf, desktop_resource_dir(buf, sizeof(buf))); +} + +size_t yasl_default_locale(char *out, size_t cap) +{ + if (!out && cap) return 0; + char buf[DESKTOP_STR_MAX]; + return copy_out(out, cap, buf, desktop_default_locale(buf, sizeof(buf))); +} diff --git a/src/common/desktop.h b/src/common/desktop.h new file mode 100644 index 0000000..9af5952 --- /dev/null +++ b/src/common/desktop.h @@ -0,0 +1,19 @@ +#ifndef YASL_COMMON_DESKTOP_H +#define YASL_COMMON_DESKTOP_H + +#include +#include +#include + +/* Operating-system desktop services behind yasl/desktop.h. Common code + * validates arguments first. Path and locale functions write a NUL-terminated + * string to buf and return its length, or zero on failure or when it does not + * fit in cap. */ +bool desktop_open_uri(const char *uri); +bool desktop_notify(const char *app_id, const char *title, const char *body); +uint32_t desktop_appearance(void); +size_t desktop_executable_path(char *buf, size_t cap); +size_t desktop_resource_dir(char *buf, size_t cap); +size_t desktop_default_locale(char *buf, size_t cap); + +#endif diff --git a/src/common/meson.build b/src/common/meson.build index 015c61c..26565ee 100644 --- a/src/common/meson.build +++ b/src/common/meson.build @@ -1 +1 @@ -sources += files('app.c', 'backend.c', 'input.c', 'native.c', 'timing.c', 'utf8.c') +sources += files('app.c', 'backend.c', 'desktop.c', 'input.c', 'native.c', 'timing.c', 'utf8.c') diff --git a/src/platform/cocoa/desktop.m b/src/platform/cocoa/desktop.m new file mode 100644 index 0000000..526cbb9 --- /dev/null +++ b/src/platform/cocoa/desktop.m @@ -0,0 +1,145 @@ +#import +#import + +#include "common/desktop.h" +#include "yasl/desktop.h" + +#include +#include +#include +#include +#include +#include + +static size_t copy_str(char *buf, size_t cap, const char *s) +{ + size_t len = s ? strlen(s) : 0; + if (!len || len >= cap) return 0; + memcpy(buf, s, len + 1); + return len; +} + +bool desktop_open_uri(const char *uri) +{ + @autoreleasepool { + NSString *str = [NSString stringWithUTF8String:uri]; + NSURL *url = str ? [NSURL URLWithString:str] : nil; + return url && [[NSWorkspace sharedWorkspace] openURL:url]; + } +} + +/* Show banners while the application is in front, too. */ +@interface YaslNotificationDelegate : NSObject +@end + +@implementation YaslNotificationDelegate +- (void)userNotificationCenter:(UNUserNotificationCenter *)center + willPresentNotification:(UNNotification *)notification + withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler +{ + (void)center; + (void)notification; + completionHandler(UNNotificationPresentationOptionBanner); +} +@end + +static YaslNotificationDelegate *notification_delegate; +static bool authorization_requested; + +/* UNUserNotificationCenter raises an exception outside an app bundle. */ +static bool notifications_available(void) +{ + NSBundle *bundle = [NSBundle mainBundle]; + return bundle.bundleIdentifier && [bundle.bundlePath.pathExtension isEqualToString:@"app"]; +} + +bool desktop_notify(const char *app_id, const char *title, const char *body) +{ + (void)app_id; + if (!notifications_available()) return false; + @autoreleasepool { + NSString *heading = [NSString stringWithUTF8String:title]; + NSString *message = [NSString stringWithUTF8String:body]; + if (!heading || !message) return false; + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + if (!notification_delegate) { + notification_delegate = [[YaslNotificationDelegate alloc] init]; + center.delegate = notification_delegate; + } + if (!authorization_requested) { + authorization_requested = true; + [center requestAuthorizationWithOptions:UNAuthorizationOptionAlert + completionHandler:^(BOOL granted, NSError *error) { + (void)granted; + (void)error; + }]; + } + UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init]; + content.title = heading; + content.body = message; + UNNotificationRequest *request = + [UNNotificationRequest requestWithIdentifier:[[NSUUID UUID] UUIDString] + content:content + trigger:nil]; + [content release]; + [center addNotificationRequest:request withCompletionHandler:nil]; + return true; + } +} + +uint32_t desktop_appearance(void) +{ + @autoreleasepool { + uint32_t flags = 0; + NSAppearanceName name = [NSApp.effectiveAppearance + bestMatchFromAppearancesWithNames:@[NSAppearanceNameAqua, NSAppearanceNameDarkAqua]]; + if ([name isEqualToString:NSAppearanceNameDarkAqua]) + flags |= YASL_APPEARANCE_DARK; + else if (name) + flags |= YASL_APPEARANCE_LIGHT; + if ([NSWorkspace sharedWorkspace].accessibilityDisplayShouldIncreaseContrast) + flags |= YASL_APPEARANCE_HIGH_CONTRAST; + return flags; + } +} + +size_t desktop_executable_path(char *buf, size_t cap) +{ + char raw[PATH_MAX]; + uint32_t size = sizeof(raw); + char resolved[PATH_MAX]; + if (_NSGetExecutablePath(raw, &size) != 0 || !realpath(raw, resolved)) return 0; + return copy_str(buf, cap, resolved); +} + +size_t desktop_resource_dir(char *buf, size_t cap) +{ + @autoreleasepool { + NSBundle *bundle = [NSBundle mainBundle]; + if (!bundle.bundleIdentifier || ![bundle.bundlePath.pathExtension isEqualToString:@"app"]) + return 0; + NSString *path = bundle.resourcePath; + return path ? copy_str(buf, cap, path.fileSystemRepresentation) : 0; + } +} + +size_t desktop_default_locale(char *buf, size_t cap) +{ + /* Identifiers such as "zh-Hans_CN" are not POSIX names, so build the name + * from the language and region codes alone. */ + char name[64] = "en_US.UTF-8"; + CFLocaleRef locale = CFLocaleCopyCurrent(); + if (locale) { + CFStringRef language = CFLocaleGetValue(locale, kCFLocaleLanguageCode); + CFStringRef region = CFLocaleGetValue(locale, kCFLocaleCountryCode); + char lang[16], reg[16]; + if (language && CFStringGetCString(language, lang, sizeof(lang), kCFStringEncodingUTF8) && lang[0]) { + if (region && CFStringGetCString(region, reg, sizeof(reg), kCFStringEncodingUTF8) && reg[0]) + snprintf(name, sizeof(name), "%s_%s.UTF-8", lang, reg); + else + snprintf(name, sizeof(name), "%s.UTF-8", lang); + } + CFRelease(locale); + } + return copy_str(buf, cap, name); +} diff --git a/src/platform/cocoa/meson.build b/src/platform/cocoa/meson.build index 8230376..5fc5f5e 100644 --- a/src/platform/cocoa/meson.build +++ b/src/platform/cocoa/meson.build @@ -1 +1 @@ -sources += files('window.m', 'keymap.c') +sources += files('window.m', 'desktop.m', 'keymap.c') diff --git a/src/platform/cocoa/window.m b/src/platform/cocoa/window.m index 811a7c6..75dce54 100644 --- a/src/platform/cocoa/window.m +++ b/src/platform/cocoa/window.m @@ -1366,6 +1366,7 @@ struct cocoa_display *cocoa_display_open(struct yasl_loop *loop, const struct ya darwin_ctx.cbs = &darwin_ctx.base.callbacks; darwin_ctx.ud = spec->ud; if (spec->app_id) darwin_ctx.app_id = strdup(spec->app_id); + darwin_ctx.base.app_id = darwin_ctx.app_id; } return &darwin_ctx; } @@ -2191,6 +2192,14 @@ static bool impl_surface_place_under_pointer(struct yasl_surface *s, struct yasl return cocoa_surface_place_under_pointer((struct cocoa_surface *)s, at); } +static bool impl_surface_bell(struct yasl_surface *s) +{ + assert([NSThread isMainThread]); + (void)s; + NSBeep(); + return true; +} + static void *impl_display_get_native_handle(struct yasl_display *d) { return d ? (void *)NSApp : NULL; @@ -2249,6 +2258,7 @@ const struct yasl_backend_ops yasl_cocoa_backend = { .surface_show_window_menu = impl_surface_show_window_menu, .surface_drag_carry = impl_surface_drag_carry, .surface_place_under_pointer = impl_surface_place_under_pointer, + .surface_bell = impl_surface_bell, .display_get_native_handle = impl_display_get_native_handle, .surface_get_native_handle = impl_surface_get_native_handle, .surface_get_native_frame = impl_surface_get_native_frame, diff --git a/src/platform/freedesktop/dbus.c b/src/platform/freedesktop/dbus.c new file mode 100644 index 0000000..abca900 --- /dev/null +++ b/src/platform/freedesktop/dbus.c @@ -0,0 +1,187 @@ +#define _GNU_SOURCE +#include "platform/freedesktop/dbus.h" + +#if YASL_HAS_DBUS + +#include +#include +#include +#include +#include + +/* The portal may need activation on first use; bound how long a query blocks. */ +#define PORTAL_TIMEOUT_MS 1000 + +static DBusConnection *bus; + +/* The session bus address, without libdbus's X11 autolaunch fallback. */ +static char *session_address(void) +{ + const char *env = getenv("DBUS_SESSION_BUS_ADDRESS"); + if (env && *env) return strdup(env); + const char *run = getenv("XDG_RUNTIME_DIR"); + if (!run || !*run) return NULL; + char path[4096]; + struct stat st; + if (snprintf(path, sizeof(path), "%s/bus", run) >= (int)sizeof(path) || + stat(path, &st) != 0 || !S_ISSOCK(st.st_mode)) + return NULL; + char *escaped = dbus_address_escape_value(path); + if (!escaped) return NULL; + char *address = NULL; + if (asprintf(&address, "unix:path=%s", escaped) < 0) address = NULL; + dbus_free(escaped); + return address; +} + +static DBusConnection *session_bus(void) +{ + if (bus && dbus_connection_get_is_connected(bus)) return bus; + if (bus) { + dbus_connection_close(bus); + dbus_connection_unref(bus); + bus = NULL; + } + char *address = session_address(); + if (!address) return NULL; + DBusError err; + dbus_error_init(&err); + DBusConnection *c = dbus_connection_open_private(address, &err); + free(address); + if (!c) { + dbus_error_free(&err); + return NULL; + } + dbus_connection_set_exit_on_disconnect(c, FALSE); + if (!dbus_bus_register(c, &err)) { + dbus_error_free(&err); + dbus_connection_close(c); + dbus_connection_unref(c); + return NULL; + } + bus = c; + return bus; +} + +bool bus_notify(const char *app, const char *title, const char *body) +{ + DBusConnection *c = session_bus(); + if (!c) return false; + DBusMessage *msg = dbus_message_new_method_call("org.freedesktop.Notifications", + "/org/freedesktop/Notifications", "org.freedesktop.Notifications", "Notify"); + if (!msg) return false; + + const char *icon = ""; + dbus_uint32_t replaces = 0; + dbus_int32_t expire = -1; + DBusMessageIter it, sub, entry, value; + dbus_message_iter_init_append(msg, &it); + bool ok = dbus_message_iter_append_basic(&it, DBUS_TYPE_STRING, &app) && + dbus_message_iter_append_basic(&it, DBUS_TYPE_UINT32, &replaces) && + dbus_message_iter_append_basic(&it, DBUS_TYPE_STRING, &icon) && + dbus_message_iter_append_basic(&it, DBUS_TYPE_STRING, &title) && + dbus_message_iter_append_basic(&it, DBUS_TYPE_STRING, &body); + /* No actions. */ + ok = ok && dbus_message_iter_open_container(&it, DBUS_TYPE_ARRAY, "s", &sub) && + dbus_message_iter_close_container(&it, &sub); + /* Hints: desktop-entry names the application's .desktop file. */ + const char *hint = "desktop-entry"; + ok = ok && dbus_message_iter_open_container(&it, DBUS_TYPE_ARRAY, "{sv}", &sub) && + dbus_message_iter_open_container(&sub, DBUS_TYPE_DICT_ENTRY, NULL, &entry) && + dbus_message_iter_append_basic(&entry, DBUS_TYPE_STRING, &hint) && + dbus_message_iter_open_container(&entry, DBUS_TYPE_VARIANT, "s", &value) && + dbus_message_iter_append_basic(&value, DBUS_TYPE_STRING, &app) && + dbus_message_iter_close_container(&entry, &value) && + dbus_message_iter_close_container(&sub, &entry) && + dbus_message_iter_close_container(&it, &sub); + ok = ok && dbus_message_iter_append_basic(&it, DBUS_TYPE_INT32, &expire); + + if (ok) { + dbus_message_set_no_reply(msg, TRUE); + ok = dbus_connection_send(c, msg, NULL); + if (ok) dbus_connection_flush(c); + } + dbus_message_unref(msg); + return ok; +} + +/* Read an integer from it, unwrapping variants: Settings.Read nests one more. */ +static bool iter_int(DBusMessageIter *it, int64_t *out) +{ + DBusMessageIter inner[4]; + int depth = 0; + while (dbus_message_iter_get_arg_type(it) == DBUS_TYPE_VARIANT && depth < 4) { + dbus_message_iter_recurse(it, &inner[depth]); + it = &inner[depth++]; + } + DBusBasicValue v; + switch (dbus_message_iter_get_arg_type(it)) { + case DBUS_TYPE_BYTE: dbus_message_iter_get_basic(it, &v); *out = v.byt; return true; + case DBUS_TYPE_BOOLEAN: dbus_message_iter_get_basic(it, &v); *out = v.bool_val; return true; + case DBUS_TYPE_INT16: dbus_message_iter_get_basic(it, &v); *out = v.i16; return true; + case DBUS_TYPE_UINT16: dbus_message_iter_get_basic(it, &v); *out = v.u16; return true; + case DBUS_TYPE_INT32: dbus_message_iter_get_basic(it, &v); *out = v.i32; return true; + case DBUS_TYPE_UINT32: dbus_message_iter_get_basic(it, &v); *out = v.u32; return true; + case DBUS_TYPE_INT64: dbus_message_iter_get_basic(it, &v); *out = v.i64; return true; + case DBUS_TYPE_UINT64: + dbus_message_iter_get_basic(it, &v); + if (v.u64 > INT64_MAX) return false; + *out = (int64_t)v.u64; + return true; + default: return false; + } +} + +bool portal_read_int(const char *ns, const char *key, int64_t *out) +{ + DBusConnection *c = session_bus(); + if (!c) return false; + /* ReadOne is portal version 2; older portals only have Read. */ + static const char *const methods[] = {"ReadOne", "Read"}; + for (int i = 0; i < 2; i++) { + DBusMessage *msg = dbus_message_new_method_call("org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings", methods[i]); + if (!msg) return false; + if (!dbus_message_append_args(msg, DBUS_TYPE_STRING, &ns, DBUS_TYPE_STRING, &key, + DBUS_TYPE_INVALID)) { + dbus_message_unref(msg); + return false; + } + DBusError err; + dbus_error_init(&err); + DBusMessage *reply = dbus_connection_send_with_reply_and_block(c, msg, + PORTAL_TIMEOUT_MS, &err); + dbus_message_unref(msg); + if (!reply) { + bool retry = dbus_error_has_name(&err, DBUS_ERROR_UNKNOWN_METHOD); + dbus_error_free(&err); + if (retry) continue; + return false; + } + DBusMessageIter it; + bool ok = dbus_message_iter_init(reply, &it) && iter_int(&it, out); + dbus_message_unref(reply); + return ok; + } + return false; +} + +#else + +bool bus_notify(const char *app, const char *title, const char *body) +{ + (void)app; + (void)title; + (void)body; + return false; +} + +bool portal_read_int(const char *ns, const char *key, int64_t *out) +{ + (void)ns; + (void)key; + (void)out; + return false; +} + +#endif diff --git a/src/platform/freedesktop/dbus.h b/src/platform/freedesktop/dbus.h new file mode 100644 index 0000000..3b1662b --- /dev/null +++ b/src/platform/freedesktop/dbus.h @@ -0,0 +1,16 @@ +#ifndef YASL_FREEDESKTOP_DBUS_H +#define YASL_FREEDESKTOP_DBUS_H + +#include +#include + +/* Session-bus helpers. The first call opens a private connection that later + * calls reuse. Each returns false when YASL is built without libdbus-1 or no + * session bus is reachable. Call from one thread at a time. */ + +/* Send org.freedesktop.Notifications.Notify without waiting for a reply. */ +bool bus_notify(const char *app, const char *title, const char *body); +/* Read an integer setting through the XDG Settings portal, waiting briefly. */ +bool portal_read_int(const char *ns, const char *key, int64_t *out); + +#endif diff --git a/src/platform/freedesktop/desktop.c b/src/platform/freedesktop/desktop.c new file mode 100644 index 0000000..a8b9bc7 --- /dev/null +++ b/src/platform/freedesktop/desktop.c @@ -0,0 +1,150 @@ +#define _GNU_SOURCE +#include "common/desktop.h" +#include "platform/freedesktop/dbus.h" +#include "yasl/desktop.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; + +static size_t copy_str(char *buf, size_t cap, const char *s) +{ + size_t len = strlen(s); + if (!len || len >= cap) return 0; + memcpy(buf, s, len + 1); + return len; +} + +/* Find an executable in PATH, like execvp but before forking. */ +static bool find_program(const char *name, char *out, size_t cap) +{ + const char *path = getenv("PATH"); + if (!path || !*path) path = "/usr/local/bin:/usr/bin:/bin"; + size_t name_len = strlen(name); + while (*path) { + const char *end = strchr(path, ':'); + size_t dir_len = end ? (size_t)(end - path) : strlen(path); + if (dir_len && dir_len + 1 + name_len < cap) { + memcpy(out, path, dir_len); + out[dir_len] = '/'; + memcpy(out + dir_len + 1, name, name_len + 1); + if (access(out, X_OK) == 0) return true; + } + if (!end) break; + path = end + 1; + } + return false; +} + +bool desktop_open_uri(const char *uri) +{ + char program[4096]; + if (!find_program("xdg-open", program, sizeof(program))) return false; + char *argv[] = {"xdg-open", (char *)uri, NULL}; + + /* Prepare everything before fork: the children of a threaded process may + * only make async-signal-safe calls. */ + struct sigaction dfl = {.sa_handler = SIG_DFL}; + sigemptyset(&dfl.sa_mask); + sigset_t none; + sigemptyset(&none); + long max_fd = sysconf(_SC_OPEN_MAX); + if (max_fd < 0 || max_fd > 65536) max_fd = 65536; + + /* The middle child exits at once, so the opener is reparented away from + * the application and never needs reaping here. */ + pid_t pid = fork(); + if (pid < 0) return false; + if (pid == 0) { + setsid(); + pid_t opener = fork(); + if (opener != 0) _exit(opener < 0 ? 1 : 0); + for (int sig = 1; sig < NSIG; sig++) + if (sig != SIGKILL && sig != SIGSTOP) sigaction(sig, &dfl, NULL); + sigprocmask(SIG_SETMASK, &none, NULL); + int null = open("/dev/null", O_RDONLY); + if (null > 0) { + dup2(null, STDIN_FILENO); + close(null); + } + /* Keep application descriptors, such as pty masters, out of the opener. */ +#ifdef SYS_close_range + if (syscall(SYS_close_range, 3u, ~0u, 0u) != 0) +#endif + for (int fd = 3; fd < max_fd; fd++) close(fd); + execve(program, argv, environ); + _exit(127); + } + + int status; + pid_t done; + do { + done = waitpid(pid, &status, 0); + } while (done < 0 && errno == EINTR); + /* An application reaper can collect the middle child first. */ + if (done < 0) return errno == ECHILD; + return WIFEXITED(status) && WEXITSTATUS(status) == 0; +} + +bool desktop_notify(const char *app_id, const char *title, const char *body) +{ + return bus_notify(app_id ? app_id : "yasl", title, body); +} + +uint32_t desktop_appearance(void) +{ + uint32_t flags = 0; + bool portal = false; + int64_t value; + if (portal_read_int("org.freedesktop.appearance", "color-scheme", &value)) { + portal = true; + if (value == 1) flags |= YASL_APPEARANCE_DARK; + else if (value == 2) flags |= YASL_APPEARANCE_LIGHT; + } + if (portal_read_int("org.freedesktop.appearance", "contrast", &value)) { + portal = true; + if (value == 1) flags |= YASL_APPEARANCE_HIGH_CONTRAST; + } + if (portal) return flags; + + /* GTK_THEME is "Name" or "Name:variant", as in "Adwaita:dark". */ + const char *theme = getenv("GTK_THEME"); + if (!theme) return 0; + if (strstr(theme, "HighContrast")) flags |= YASL_APPEARANCE_HIGH_CONTRAST; + if (strstr(theme, ":dark") || strstr(theme, "HighContrastInverse")) + flags |= YASL_APPEARANCE_DARK; + return flags; +} + +size_t desktop_executable_path(char *buf, size_t cap) +{ + /* readlink truncates silently, so a full buffer may hold a cut path. */ + ssize_t len = readlink("/proc/self/exe", buf, cap); + if (len <= 0 || (size_t)len >= cap) return 0; + buf[len] = '\0'; + return (size_t)len; +} + +size_t desktop_resource_dir(char *buf, size_t cap) +{ + (void)buf; + (void)cap; + return 0; +} + +size_t desktop_default_locale(char *buf, size_t cap) +{ + static const char *const vars[] = {"LC_ALL", "LC_CTYPE", "LANG"}; + for (size_t i = 0; i < sizeof(vars) / sizeof(vars[0]); i++) { + const char *v = getenv(vars[i]); + if (v && *v) return copy_str(buf, cap, v); + } + return copy_str(buf, cap, "C.UTF-8"); +} diff --git a/src/platform/freedesktop/meson.build b/src/platform/freedesktop/meson.build new file mode 100644 index 0000000..6f43895 --- /dev/null +++ b/src/platform/freedesktop/meson.build @@ -0,0 +1 @@ +sources += files('desktop.c', 'dbus.c') diff --git a/src/platform/meson.build b/src/platform/meson.build index 892969f..593353c 100644 --- a/src/platform/meson.build +++ b/src/platform/meson.build @@ -3,6 +3,10 @@ if system == 'linux' or system == 'darwin' subdir('posix') endif +# Desktop services shared by the Linux backends. +if system == 'linux' + subdir('freedesktop') +endif if has_wayland subdir('wayland') diff --git a/src/platform/wayland/api.h b/src/platform/wayland/api.h index e5e933e..6c3154c 100644 --- a/src/platform/wayland/api.h +++ b/src/platform/wayland/api.h @@ -38,5 +38,6 @@ bool wayland_surface_start_interactive_resize(struct wayland_surface *s, enum ya bool wayland_surface_show_window_menu(struct wayland_surface *s, struct yasl_vector2i pos); bool wayland_surface_drag_carry(struct wayland_surface *s, struct yasl_vector2i offset); bool wayland_surface_place_under_pointer(struct wayland_surface *s, struct yasl_vector2i at); +bool wayland_surface_bell(struct wayland_surface *s); #endif diff --git a/src/platform/wayland/backend.c b/src/platform/wayland/backend.c index c3c7bed..c6272ce 100644 --- a/src/platform/wayland/backend.c +++ b/src/platform/wayland/backend.c @@ -177,6 +177,11 @@ static bool impl_surface_place_under_pointer(struct yasl_surface *s, struct yasl return wayland_surface_place_under_pointer((struct wayland_surface *)s, at); } +static bool impl_surface_bell(struct yasl_surface *s) +{ + return wayland_surface_bell((struct wayland_surface *)s); +} + static void *impl_display_get_native_handle(struct yasl_display *d) { struct wayland_display *w = (struct wayland_display *)d; @@ -225,6 +230,7 @@ const struct yasl_backend_ops yasl_wayland_backend = { .surface_show_window_menu = impl_surface_show_window_menu, .surface_drag_carry = impl_surface_drag_carry, .surface_place_under_pointer = impl_surface_place_under_pointer, + .surface_bell = impl_surface_bell, .display_get_native_handle = impl_display_get_native_handle, .surface_get_native_handle = impl_surface_get_native_handle, }; diff --git a/src/platform/wayland/internal.h b/src/platform/wayland/internal.h index c35db7f..84a603d 100644 --- a/src/platform/wayland/internal.h +++ b/src/platform/wayland/internal.h @@ -25,6 +25,7 @@ #include "protocols/primary-selection-unstable-v1-client-protocol.h" #include "protocols/relative-pointer-unstable-v1-client-protocol.h" #include "protocols/pointer-constraints-unstable-v1-client-protocol.h" +#include "protocols/xdg-system-bell-v1-client-protocol.h" #include #include @@ -217,6 +218,7 @@ struct wayland_display { struct wp_cursor_shape_manager_v1 *cursor_shape_mgr; struct zwp_relative_pointer_manager_v1 *relative_pointer_mgr; struct zwp_pointer_constraints_v1 *pointer_constraints; + struct xdg_system_bell_v1 *system_bell; struct wl_cursor_theme *cursor_theme; const char *cursor_theme_name; struct zwp_tablet_manager_v2 *tablet_mgr; diff --git a/src/platform/wayland/shell.c b/src/platform/wayland/shell.c index a12bd4d..8795989 100644 --- a/src/platform/wayland/shell.c +++ b/src/platform/wayland/shell.c @@ -214,6 +214,8 @@ static void registry_global(void *data, struct wl_registry *registry, } else if (strcmp(interface, zwp_pointer_constraints_v1_interface.name) == 0) { c->pointer_constraints = wl_registry_bind(registry, name, &zwp_pointer_constraints_v1_interface, 1); + } else if (strcmp(interface, xdg_system_bell_v1_interface.name) == 0) { + c->system_bell = wl_registry_bind(registry, name, &xdg_system_bell_v1_interface, 1); } else if (strcmp(interface, wp_fractional_scale_manager_v1_interface.name) == 0) { c->scale_mgr = wl_registry_bind(registry, name, &wp_fractional_scale_manager_v1_interface, 1); @@ -403,3 +405,10 @@ int surface_init_shell(struct wayland_surface *w, const char *title) } return 0; } + +bool wayland_surface_bell(struct wayland_surface *w) +{ + if (!w || w->closed || !w->conn || !w->conn->system_bell || !w->surface) return false; + xdg_system_bell_v1_ring(w->conn->system_bell, w->surface); + return true; +} diff --git a/src/platform/wayland/surface.c b/src/platform/wayland/surface.c index 0f6d7d7..cf1877d 100644 --- a/src/platform/wayland/surface.c +++ b/src/platform/wayland/surface.c @@ -202,6 +202,7 @@ static void display_free(struct wayland_display *c) if (c->cursor_shape_mgr) wp_cursor_shape_manager_v1_destroy(c->cursor_shape_mgr); if (c->relative_pointer_mgr) zwp_relative_pointer_manager_v1_destroy(c->relative_pointer_mgr); if (c->pointer_constraints) zwp_pointer_constraints_v1_destroy(c->pointer_constraints); + if (c->system_bell) xdg_system_bell_v1_destroy(c->system_bell); if (c->cursor_theme) wl_cursor_theme_destroy(c->cursor_theme); if (c->tablet_mgr) zwp_tablet_manager_v2_destroy(c->tablet_mgr); if (c->text_input_mgr) zwp_text_input_manager_v3_destroy(c->text_input_mgr); @@ -246,6 +247,7 @@ struct wayland_display *wayland_display_open(struct yasl_loop *loop, const struc c->cbs = &c->base.callbacks; c->ud = spec->ud; if (spec->app_id) c->app_id = strdup(spec->app_id); + c->base.app_id = c->app_id; } c->xkb_ctx = xkb_context_new(XKB_CONTEXT_NO_FLAGS); c->wl = c->xkb_ctx ? wl_display_connect(NULL) : NULL; diff --git a/src/platform/x11/window.c b/src/platform/x11/window.c index f60a878..be1ff32 100644 --- a/src/platform/x11/window.c +++ b/src/platform/x11/window.c @@ -1601,6 +1601,7 @@ static struct yasl_display *display_open(struct yasl_loop *loop, } d->base.loop = d->loop; d->app_id = strdup(spec && spec->app_id ? spec->app_id : "YASL"); + d->base.app_id = d->app_id; if (!d->loop || !d->app_id) { if (d->owns_loop) yasl_loop_destroy(d->loop); free(d->app_id); @@ -1803,6 +1804,18 @@ static bool place(struct yasl_surface *base, struct yasl_vector2i at) return true; } +static bool bell(struct yasl_surface *base) +{ + struct x11_surface *s = (struct x11_surface *)base; + struct x11_display *d = (struct x11_display *)base->display; + if (s->destroyed || !d->native) return false; + /* The XKB bell names the window, so the window manager can flash it. */ + if (!XkbBell(d->native, s->window, 0, None)) + XBell(d->native, 0); + XFlush(d->native); + return true; +} + static void drag_end(struct x11_display *d, enum yasl_drag_end how) { if (!d || !d->drag.active) return; @@ -2045,6 +2058,7 @@ const struct yasl_backend_ops yasl_x11_backend = { .surface_set_cursor = cursor, .surface_drag_carry = drag_carry, .surface_place_under_pointer = place, + .surface_bell = bell, .surface_start_interactive_move = interactive_move, .surface_start_interactive_resize = interactive_resize, .surface_show_window_menu = show_window_menu, diff --git a/tests/common/test_desktop.c b/tests/common/test_desktop.c new file mode 100644 index 0000000..f65a9cd --- /dev/null +++ b/tests/common/test_desktop.c @@ -0,0 +1,347 @@ +#define _GNU_SOURCE +#include "common/backend.h" +#include "yasl/desktop.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if YASL_HAS_DBUS +#include +#endif + +static void sleep_ms(int ms) +{ + struct timespec ts = {.tv_sec = ms / 1000, .tv_nsec = (long)(ms % 1000) * 1000000L}; + nanosleep(&ts, NULL); +} + +static char *read_file(const char *path) +{ + FILE *f = fopen(path, "r"); + if (!f) return NULL; + static char buf[16384]; + size_t n = fread(buf, 1, sizeof(buf) - 1, f); + fclose(f); + buf[n] = '\0'; + return buf; +} + +static void test_buffers(void) +{ + char exe[PATH_MAX], real[PATH_MAX]; + size_t len = yasl_executable_path(NULL, 0); + assert(len > 0); + assert(yasl_executable_path(exe, len) == len); + assert(yasl_executable_path(exe, sizeof(exe)) == len && exe[len] == '\0'); + assert(realpath("/proc/self/exe", real) && strcmp(exe, real) == 0); + /* A short buffer stays untouched. */ + char small[4] = "abc"; + assert(yasl_executable_path(small, sizeof(small)) == len); + assert(strcmp(small, "abc") == 0); + assert(yasl_executable_path(NULL, 8) == 0); + + assert(yasl_resource_dir(exe, sizeof(exe)) == 0); + + char locale[64]; + setenv("LC_ALL", "", 1); + setenv("LC_CTYPE", "de_DE.UTF-8", 1); + setenv("LANG", "fr_FR.UTF-8", 1); + assert(yasl_default_locale(locale, sizeof(locale)) == 11); + assert(strcmp(locale, "de_DE.UTF-8") == 0); + unsetenv("LC_CTYPE"); + assert(yasl_default_locale(locale, sizeof(locale)) == 11); + assert(strcmp(locale, "fr_FR.UTF-8") == 0); + unsetenv("LANG"); + unsetenv("LC_ALL"); + assert(yasl_default_locale(locale, sizeof(locale)) == 7); + assert(strcmp(locale, "C.UTF-8") == 0); +} + +/* A fake xdg-open records its argument, signal state, and descriptors. */ +static void test_open_uri(const char *dir) +{ + char script[PATH_MAX], out[PATH_MAX]; + snprintf(script, sizeof(script), "%s/xdg-open", dir); + snprintf(out, sizeof(out), "%s/opened", dir); + FILE *f = fopen(script, "w"); + assert(f); + fprintf(f, "#!/bin/sh\n" + "{ printf '%%s\\n' \"$1\"; grep -E '^Sig(Blk|Ign)' /proc/self/status;\n" + " ls /proc/$$/fd; } > '%s.tmp'\n" + "mv '%s.tmp' '%s'\n", out, out, out); + fclose(f); + assert(chmod(script, 0755) == 0); + const char *old_path = getenv("PATH"); + char path[8192]; + snprintf(path, sizeof(path), "%s:%s", dir, old_path ? old_path : "/usr/bin:/bin"); + setenv("PATH", path, 1); + + struct yasl_display display = {0}; + assert(!yasl_display_open_uri(NULL, "https://example.com")); + assert(!yasl_display_open_uri(&display, NULL)); + assert(!yasl_display_open_uri(&display, "")); + assert(!yasl_display_open_uri(&display, "example.com")); + assert(!yasl_display_open_uri(&display, "-x:option")); + assert(!yasl_display_open_uri(&display, "1http://example.com")); + assert(!yasl_display_open_uri(&display, "http://example.com/\n")); + assert(access(out, F_OK) != 0); + + /* The opener must not inherit blocked or ignored signals or descriptors. */ + sigset_t block, old_mask; + sigemptyset(&block); + sigaddset(&block, SIGUSR1); + pthread_sigmask(SIG_BLOCK, &block, &old_mask); + struct sigaction ignore = {.sa_handler = SIG_IGN}, old_pipe; + sigemptyset(&ignore.sa_mask); + sigaction(SIGPIPE, &ignore, &old_pipe); + int null = open("/dev/null", O_RDONLY); + int leaked = fcntl(null, F_DUPFD, 200); + assert(null >= 0 && leaked >= 200); + close(null); + + const char *uri = "https://example.com/a b?c='d'&e=\"f\""; + assert(yasl_display_open_uri(&display, uri)); + char *text = NULL; + for (int i = 0; i < 500 && !(text = read_file(out)); i++) sleep_ms(10); + assert(text); + char *line = strtok(text, "\n"); + assert(line && strcmp(line, uri) == 0); + int fds = 0; + while ((line = strtok(NULL, "\n"))) { + if (strncmp(line, "Sig", 3) == 0) { + const char *mask = strchr(line, ':') + 1; + while (*mask == '\t' || *mask == ' ') mask++; + assert(strspn(mask, "0") == strlen(mask)); + } else { + assert(atoi(line) != leaked); + fds++; + } + } + assert(fds >= 3); + + close(leaked); + sigaction(SIGPIPE, &old_pipe, NULL); + pthread_sigmask(SIG_SETMASK, &old_mask, NULL); + unlink(out); + unlink(script); + + /* Without xdg-open in PATH, nothing launches. */ + setenv("PATH", dir, 1); + assert(!yasl_display_open_uri(&display, "https://example.com")); + if (old_path) setenv("PATH", old_path, 1); +} + +static void test_appearance_fallback(void) +{ + struct yasl_display display = {0}; + assert(yasl_display_appearance(NULL) == 0); + unsetenv("GTK_THEME"); + assert(yasl_display_appearance(&display) == 0); + setenv("GTK_THEME", "Adwaita:dark", 1); + assert(yasl_display_appearance(&display) == YASL_APPEARANCE_DARK); + setenv("GTK_THEME", "HighContrast", 1); + assert(yasl_display_appearance(&display) == YASL_APPEARANCE_HIGH_CONTRAST); + setenv("GTK_THEME", "HighContrastInverse", 1); + assert(yasl_display_appearance(&display) == + (YASL_APPEARANCE_HIGH_CONTRAST | YASL_APPEARANCE_DARK)); + unsetenv("GTK_THEME"); + assert(!yasl_display_notify(NULL, "title", "body")); + assert(!yasl_display_notify(&display, NULL, "body")); + assert(!yasl_display_notify(&display, "title", "body")); +} + +#if YASL_HAS_DBUS +/* A fake notification server and Settings portal on a private bus. */ +struct fake_bus { + DBusConnection *conn; + pthread_mutex_t lock; + char app[64], title[64], body[64], entry[64]; + int notifications; + bool read_one; + _Atomic bool stop; +}; + +static void reply_setting(DBusConnection *c, DBusMessage *msg, bool nested) +{ + const char *ns = NULL, *key = NULL; + dbus_message_get_args(msg, NULL, DBUS_TYPE_STRING, &ns, DBUS_TYPE_STRING, &key, + DBUS_TYPE_INVALID); + dbus_uint32_t value; + if (ns && key && strcmp(ns, "org.freedesktop.appearance") == 0 && + strcmp(key, "color-scheme") == 0) { + value = 1; + } else if (ns && key && strcmp(ns, "org.freedesktop.appearance") == 0 && + strcmp(key, "contrast") == 0) { + value = 1; + } else { + DBusMessage *err = dbus_message_new_error(msg, + "org.freedesktop.portal.Error.NotFound", "Requested setting not found"); + dbus_connection_send(c, err, NULL); + dbus_message_unref(err); + return; + } + DBusMessage *reply = dbus_message_new_method_return(msg); + DBusMessageIter it, outer, inner; + dbus_message_iter_init_append(reply, &it); + if (nested) { + dbus_message_iter_open_container(&it, DBUS_TYPE_VARIANT, "v", &outer); + dbus_message_iter_open_container(&outer, DBUS_TYPE_VARIANT, "u", &inner); + dbus_message_iter_append_basic(&inner, DBUS_TYPE_UINT32, &value); + dbus_message_iter_close_container(&outer, &inner); + dbus_message_iter_close_container(&it, &outer); + } else { + dbus_message_iter_open_container(&it, DBUS_TYPE_VARIANT, "u", &outer); + dbus_message_iter_append_basic(&outer, DBUS_TYPE_UINT32, &value); + dbus_message_iter_close_container(&it, &outer); + } + dbus_connection_send(c, reply, NULL); + dbus_message_unref(reply); +} + +static void record_notify(struct fake_bus *fb, DBusMessage *msg) +{ + DBusMessageIter it, sub, entry, value; + const char *app, *icon, *title, *body, *hint; + dbus_uint32_t replaces; + dbus_int32_t expire; + assert(dbus_message_has_signature(msg, "susssasa{sv}i")); + dbus_message_iter_init(msg, &it); + dbus_message_iter_get_basic(&it, &app); + dbus_message_iter_next(&it); + dbus_message_iter_get_basic(&it, &replaces); + dbus_message_iter_next(&it); + dbus_message_iter_get_basic(&it, &icon); + dbus_message_iter_next(&it); + dbus_message_iter_get_basic(&it, &title); + dbus_message_iter_next(&it); + dbus_message_iter_get_basic(&it, &body); + dbus_message_iter_next(&it); + dbus_message_iter_next(&it); + dbus_message_iter_recurse(&it, &sub); + dbus_message_iter_recurse(&sub, &entry); + dbus_message_iter_get_basic(&entry, &hint); + assert(strcmp(hint, "desktop-entry") == 0); + dbus_message_iter_next(&entry); + dbus_message_iter_recurse(&entry, &value); + const char *desktop_entry; + dbus_message_iter_get_basic(&value, &desktop_entry); + dbus_message_iter_next(&it); + dbus_message_iter_get_basic(&it, &expire); + assert(replaces == 0 && expire == -1 && icon[0] == '\0'); + + pthread_mutex_lock(&fb->lock); + snprintf(fb->app, sizeof(fb->app), "%s", app); + snprintf(fb->title, sizeof(fb->title), "%s", title); + snprintf(fb->body, sizeof(fb->body), "%s", body); + snprintf(fb->entry, sizeof(fb->entry), "%s", desktop_entry); + fb->notifications++; + pthread_mutex_unlock(&fb->lock); +} + +static void *serve(void *ud) +{ + struct fake_bus *fb = ud; + while (!fb->stop && dbus_connection_read_write(fb->conn, 50)) { + DBusMessage *msg; + while ((msg = dbus_connection_pop_message(fb->conn))) { + if (dbus_message_is_method_call(msg, "org.freedesktop.Notifications", "Notify")) { + record_notify(fb, msg); + } else if (dbus_message_is_method_call(msg, "org.freedesktop.portal.Settings", "ReadOne")) { + if (fb->read_one) { + reply_setting(fb->conn, msg, false); + } else { + DBusMessage *err = dbus_message_new_error(msg, DBUS_ERROR_UNKNOWN_METHOD, "old"); + dbus_connection_send(fb->conn, err, NULL); + dbus_message_unref(err); + } + } else if (dbus_message_is_method_call(msg, "org.freedesktop.portal.Settings", "Read")) { + reply_setting(fb->conn, msg, true); + } + dbus_message_unref(msg); + } + } + return NULL; +} + +static void test_bus(const char *address) +{ + DBusError err; + dbus_error_init(&err); + struct fake_bus fb = {.read_one = true}; + pthread_mutex_init(&fb.lock, NULL); + fb.conn = dbus_connection_open_private(address, &err); + assert(fb.conn && dbus_bus_register(fb.conn, &err)); + assert(dbus_bus_request_name(fb.conn, "org.freedesktop.Notifications", 0, &err) == + DBUS_REQUEST_NAME_REPLY_PRIMARY_OWNER); + assert(dbus_bus_request_name(fb.conn, "org.freedesktop.portal.Desktop", 0, &err) == + DBUS_REQUEST_NAME_REPLY_PRIMARY_OWNER); + pthread_t thread; + assert(pthread_create(&thread, NULL, serve, &fb) == 0); + + struct yasl_display display = {.app_id = "org.example.Test"}; + assert(yasl_display_notify(&display, "Build done", NULL)); + for (int i = 0; i < 500; i++) { + pthread_mutex_lock(&fb.lock); + int n = fb.notifications; + pthread_mutex_unlock(&fb.lock); + if (n) break; + sleep_ms(10); + } + pthread_mutex_lock(&fb.lock); + assert(fb.notifications == 1); + assert(strcmp(fb.app, "org.example.Test") == 0); + assert(strcmp(fb.entry, "org.example.Test") == 0); + assert(strcmp(fb.title, "Build done") == 0 && fb.body[0] == '\0'); + pthread_mutex_unlock(&fb.lock); + + /* Portal settings win over GTK_THEME, through ReadOne or the older Read. */ + setenv("GTK_THEME", "Adwaita", 1); + uint32_t want = YASL_APPEARANCE_DARK | YASL_APPEARANCE_HIGH_CONTRAST; + assert(yasl_display_appearance(&display) == want); + fb.read_one = false; + assert(yasl_display_appearance(&display) == want); + unsetenv("GTK_THEME"); + + fb.stop = true; + pthread_join(thread, NULL); + dbus_connection_close(fb.conn); + dbus_connection_unref(fb.conn); + pthread_mutex_destroy(&fb.lock); +} +#endif + +int main(void) +{ + char dir[] = "/tmp/yasl-desktop-XXXXXX"; + assert(mkdtemp(dir)); + test_buffers(); + test_open_uri(dir); + + /* Hide the session bus so that desktop services fall back. */ + char *address = getenv("DBUS_SESSION_BUS_ADDRESS"); + address = address ? strdup(address) : NULL; + unsetenv("DBUS_SESSION_BUS_ADDRESS"); + setenv("XDG_RUNTIME_DIR", dir, 1); + test_appearance_fallback(); +#if YASL_HAS_DBUS + if (address) { + setenv("DBUS_SESSION_BUS_ADDRESS", address, 1); + test_bus(address); + } else { + printf("Skipping D-Bus checks: no session bus.\n"); + } +#endif + free(address); + rmdir(dir); + printf("Desktop tests passed.\n"); + return 0; +} diff --git a/tests/meson.build b/tests/meson.build index f072d3f..84b6847 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -52,6 +52,7 @@ if system == 'linux' or system == 'darwin' exported_headers = files( '../include/yasl/yasl.h', '../include/yasl/app.h', + '../include/yasl/desktop.h', '../include/yasl/timing.h', '../include/yasl/loop.h', '../include/yasl/native.h', @@ -117,6 +118,23 @@ loop_test = executable( ) test('loop', loop_test) +if system == 'linux' + desktop_test = executable( + 'yasl-desktop-test', + 'common/test_desktop.c', + include_directories: inc, + dependencies: [yasl_dep, dependency('threads')], + ) + # A private session bus keeps the notification and portal checks away + # from the user's desktop. + dbus_run_session = find_program('dbus-run-session', required: false) + if has_dbus and dbus_run_session.found() + test('desktop', dbus_run_session, args: ['--', desktop_test]) + else + test('desktop', desktop_test) + endif +endif + app_test = executable( 'yasl-app-test', 'common/test_app.c', diff --git a/tests/test_api.cpp b/tests/test_api.cpp index c5921e6..bc54323 100644 --- a/tests/test_api.cpp +++ b/tests/test_api.cpp @@ -8,6 +8,7 @@ #if YASL_HAS_COCOA #include "yasl/native/cocoa.h" #endif +#include "yasl/desktop.h" #include "yasl/loop.h" #include #include @@ -63,5 +64,10 @@ int main() assert(!yasl_cocoa_surface_get_view(nullptr)); #endif + assert(!yasl_display_open_uri(nullptr, "https://example.com")); + assert(yasl_display_appearance(nullptr) == 0); + assert(!yasl_surface_bell(nullptr)); + assert(yasl_executable_path(nullptr, 0) > 0); + return 0; } -- cgit v1.2.3