summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCrucible <crucible@localhost>2026-10-22 22:40:22 -0500
committerCrucible <crucible@localhost>2026-10-22 22:40:22 -0500
commit1a78ac84633b6b754b4ef29bb97da13cd6e7bf77 (patch)
tree7a6c66033e249d8784786baaa017c0b9ab42a227
parentc025061935147d6ead94b9bd0bb420580e2c4895 (diff)
downloadsrcux-1a78ac84633b6b754b4ef29bb97da13cd6e7bf77.tar.gz
srcux-1a78ac84633b6b754b4ef29bb97da13cd6e7bf77.tar.xz
srcux-1a78ac84633b6b754b4ef29bb97da13cd6e7bf77.zip
Publish srcux candidate 0000000843-20261022-223642
-rw-r--r--gui-apps/ambient-background/ambient-background-1.0.0-r19.ebuild (renamed from gui-apps/ambient-background/ambient-background-1.0.0-r18.ebuild)2
-rw-r--r--gui-apps/ambient-background/files/ambient-background-1.0.0-fade-idle.patch97
-rw-r--r--gui-apps/wayfire-applications/files/wayfire-applications-0.1.2-desktop-trust.patch207
-rw-r--r--gui-apps/wayfire-applications/files/wayfire-applications-0.1.2-detach-launch.patch235
-rw-r--r--gui-apps/wayfire-applications/wayfire-applications-0.1.2-r1.ebuild (renamed from gui-apps/wayfire-applications/wayfire-applications-0.1.2.ebuild)1
-rw-r--r--gui-apps/wayfire-ctl/files/wayfire-ctl-0.1.4-json-shape.patch364
-rw-r--r--gui-apps/wayfire-ctl/wayfire-ctl-0.1.4-r1.ebuild (renamed from gui-apps/wayfire-ctl/wayfire-ctl-0.1.4.ebuild)1
-rw-r--r--gui-apps/wayfire-dock/files/wayfire-dock-0.1.1-material-clamp.patch20
-rw-r--r--gui-apps/wayfire-dock/wayfire-dock-0.1.1-r3.ebuild (renamed from gui-apps/wayfire-dock/wayfire-dock-0.1.1-r2.ebuild)1
-rw-r--r--gui-apps/wayfire-file-index/files/wayfire-file-index-0.1.0-audit-fixes.patch434
-rw-r--r--gui-apps/wayfire-file-index/wayfire-file-index-0.1.0-r2.ebuild (renamed from gui-apps/wayfire-file-index/wayfire-file-index-0.1.0-r1.ebuild)2
-rw-r--r--gui-apps/wayfire-files/files/wayfire-files-0.1.2-audit-fixes.patch312
-rw-r--r--gui-apps/wayfire-files/wayfire-files-0.1.2-r7.ebuild (renamed from gui-apps/wayfire-files/wayfire-files-0.1.2-r6.ebuild)2
-rw-r--r--gui-apps/wayfire-panel/files/wayfire-panel-0.1.0-audit-fixes.patch1604
-rw-r--r--gui-apps/wayfire-panel/wayfire-panel-0.1.0-r5.ebuild (renamed from gui-apps/wayfire-panel/wayfire-panel-0.1.0-r4.ebuild)3
-rw-r--r--gui-apps/wayfire-preferences/files/wayfire-preferences-0.1.0-audit-fixes.patch657
-rw-r--r--gui-apps/wayfire-preferences/wayfire-preferences-0.1.0-r2.ebuild (renamed from gui-apps/wayfire-preferences/wayfire-preferences-0.1.0-r1.ebuild)3
-rw-r--r--gui-apps/wayfire-session/files/wayfire-session-0.1.11-audit-fixes.patch775
-rw-r--r--gui-apps/wayfire-session/wayfire-session-0.1.11-r1.ebuild (renamed from gui-apps/wayfire-session/wayfire-session-0.1.11.ebuild)0
-rw-r--r--gui-apps/wf-kill/files/wf-kill-9999-lock-cancel.patch116
-rw-r--r--gui-apps/wf-kill/wf-kill-9999-r3.ebuild (renamed from gui-apps/wf-kill/wf-kill-9999-r2.ebuild)1
-rw-r--r--gui-libs/wayfire-ui/files/wayfire-ui-0.1.6-audit-fixes.patch348
-rw-r--r--gui-libs/wayfire-ui/wayfire-ui-0.1.6-r1.ebuild (renamed from gui-libs/wayfire-ui/wayfire-ui-0.1.6.ebuild)0
-rw-r--r--gui-wm/wayfire/files/wayfire-0.11.0-r72-review-followups.patch258
-rw-r--r--gui-wm/wayfire/wayfire-0.11.0-r72.ebuild (renamed from gui-wm/wayfire/wayfire-0.11.0-r71.ebuild)1
-rw-r--r--www-apps/jellyfin-bin/Manifest12
-rw-r--r--www-apps/jellyfin-bin/jellyfin-bin-10.11.11.ebuild104
-rw-r--r--www-apps/jellyfin-bin/jellyfin-bin-12.2.ebuild104
-rw-r--r--www-apps/jellyfin-bin/jellyfin-bin-12.3.ebuild (renamed from www-apps/jellyfin-bin/jellyfin-bin-12.2-r1.ebuild)0
-rw-r--r--x11-drivers/nvidia-drivers/Manifest9
-rw-r--r--x11-drivers/nvidia-drivers/nvidia-drivers-595.44.16.ebuild (renamed from x11-drivers/nvidia-drivers/nvidia-drivers-595.44.15.ebuild)2
31 files changed, 5446 insertions, 229 deletions
diff --git a/gui-apps/ambient-background/ambient-background-1.0.0-r18.ebuild b/gui-apps/ambient-background/ambient-background-1.0.0-r19.ebuild
index 38d2e9f66be3..2edc5ff27ad0 100644
--- a/gui-apps/ambient-background/ambient-background-1.0.0-r18.ebuild
+++ b/gui-apps/ambient-background/ambient-background-1.0.0-r19.ebuild
@@ -33,7 +33,7 @@ RDEPEND="
>=gui-wm/wayfire-0.11.0-r60:0=
"
-PATCHES=( "${FILESDIR}/${P}-idle-timer.patch" )
+PATCHES=( "${FILESDIR}/${P}-idle-timer.patch" "${FILESDIR}/${P}-fade-idle.patch" )
src_unpack() {
mkdir -p "${S}" || die
diff --git a/gui-apps/ambient-background/files/ambient-background-1.0.0-fade-idle.patch b/gui-apps/ambient-background/files/ambient-background-1.0.0-fade-idle.patch
new file mode 100644
index 000000000000..9529ab865004
--- /dev/null
+++ b/gui-apps/ambient-background/files/ambient-background-1.0.0-fade-idle.patch
@@ -0,0 +1,97 @@
+ambient-background: stop frames behind a black fade; react to lock and
+output-layout changes without waiting for the idle poll.
+Apply after ambient-background-1.0.0-idle-timer.patch.
+
+- effect_animating(): when not in manual mode and every zone of an output has
+ effect <= 0, paint() covers the effect with an opaque black mask, so the
+ output is not animating. The timer drops to the 1 s poll and tick() stops
+ calling damage_whole() (previously 30-120 fps of black frames forever).
+ ambient/sync already damages the zone and calls schedule() when effect
+ rises again, which resumes full-rate frames.
+- refresh(): re-reads zones (damaging outputs whose zone geometry changed),
+ handles a lock-state change (leave manual mode, damage) and updates the
+ cursor/shield, then reschedules. Run from an idle call on
+ output_layout_configuration_changed_signal (idle so stitch zones, which
+ are assigned after the signal, are current) and on root_node_update_signal
+ when locked() differs from the last seen state (session-lock emits no
+ signal, but always adds lock surfaces to the scene after inhibiting the
+ outputs). The 1 s poll remains as a fallback.
+
+diff --git a/core/ambient-background.cpp b/core/ambient-background.cpp
+index 60a9213..ce0c944 100644
+--- a/core/ambient-background.cpp
++++ b/core/ambient-background.cpp
+@@ -96,9 +96,9 @@ class ambient_plugin_t : public wf::plugin_interface_t {
+ std::map<wf::output_t*, output_state_t> outputs;
+ wf::scene::node_ptr shield;
+ wf::wl_timer<true> timer;
+- wf::wl_idle_call rearm;
++ wf::wl_idle_call rearm, refresh_idle;
+ unsigned timer_interval = 0;
+- bool in_tick = false;
++ bool in_tick = false, last_locked = false;
+ bool manual = false, suspended = false, daemon_started = false, cursor_hidden = false;
+ unsigned reset_generation = 0;
+ double last_tick = now(), heartbeat = 0, restore_until = 0, last_launch = 0;
+@@ -354,6 +354,10 @@ class ambient_plugin_t : public wf::plugin_interface_t {
+ s.hyperspace->set_active(visible && b=="hyperspace");
+ s.nebula->set_active(visible && b=="nebula");
+ if (!visible) return false;
++ // Faded to black: every zone covers the effect with an opaque mask.
++ bool hidden=!manual && !s.zones.empty();
++ for (auto& z:s.zones) hidden &= z.effect <= 0;
++ if (hidden) return false;
+ if (b=="hyperspace") return s.hyperspace->animated();
+ if (b=="nebula") return s.nebula->animated();
+ return true; // Digital rain frames come from its client buffers.
+@@ -419,6 +423,18 @@ class ambient_plugin_t : public wf::plugin_interface_t {
+ timer_interval=0; rearm.run_once([this]{schedule();});
+ return false;
+ }
++ // Lock, unlock and output geometry changes, outside the slow poll.
++ void refresh() {
++ bool visible=active() && !locked();
++ for (auto& [o,s]:outputs) if (update_zones(o,s) && visible) o->render->damage_whole();
++ if (locked() != last_locked) {
++ last_locked=locked();
++ if (last_locked && manual) set_manual(false);
++ if (active()) for (auto& [o,s]:outputs) o->render->damage_whole();
++ }
++ update_shield();
++ schedule();
++ }
+ wf::json_t status() {
+ wf::json_t j=wf::ipc::json_ok();
+ j["enabled"]=bool(enabled);j["backend"]=std::string(backend);j["manual"]=manual;
+@@ -508,6 +524,14 @@ class ambient_plugin_t : public wf::plugin_interface_t {
+ if (active()) for (auto& [o,s]:outputs) o->render->damage_whole();
+ schedule();
+ };
++ // Stitch zones are assigned after the layout signal; read them when idle.
++ wf::signal::connection_t<wf::output_layout_configuration_changed_signal> layout_changed = [this] (auto) {
++ refresh_idle.run_once([this] { refresh(); });
++ };
++ // Session lock has no signal, but lock surfaces always change the scene.
++ wf::signal::connection_t<wf::scene::root_node_update_signal> scene_changed = [this] (auto) {
++ if (locked() != last_locked) refresh_idle.run_once([this] { refresh(); });
++ };
+ wf::signal::connection_t<wf::output_added_signal> added = [this] (auto ev) { add_output(ev->output); schedule(); };
+ wf::signal::connection_t<wf::output_pre_remove_signal> removed = [this] (auto ev) {
+ if (outputs.count(ev->output)) {
+@@ -534,6 +558,8 @@ class ambient_plugin_t : public wf::plugin_interface_t {
+ option(restore_binding,"restore",wf::option_type::from_string<wf::activatorbinding_t>("KEY_ESC").value());
+ for (auto o:wf::get_core().output_layout->get_outputs()) add_output(o);
+ wf::get_core().output_layout->connect(&added);wf::get_core().output_layout->connect(&removed);
++ wf::get_core().output_layout->connect(&layout_changed);wf::get_core().scene()->connect(&scene_changed);
++ last_locked=locked();
+ wf::get_core().connect(&query);wf::get_core().connect(&render);wf::get_core().connect(&transition);
+ wf::get_core().connect(&on_key);wf::get_core().connect(&reloaded);
+ enabled.set_callback([this]{suspended=false;changed();});backend.set_callback([this]{changed();});
+@@ -569,6 +595,7 @@ class ambient_plugin_t : public wf::plugin_interface_t {
+ }
+ void fini() override {
+ enabled.set_callback(nullptr);backend.set_callback(nullptr);
++ layout_changed.disconnect();scene_changed.disconnect();refresh_idle.disconnect();
+ set_manual(false);timer.disconnect();rearm.disconnect();timer_interval=0;
+ if(cursor_hidden) { wf::get_core().unhide_cursor();cursor_hidden=false; }
+ if(shield) {wf::scene::remove_child(shield);shield.reset();}
diff --git a/gui-apps/wayfire-applications/files/wayfire-applications-0.1.2-desktop-trust.patch b/gui-apps/wayfire-applications/files/wayfire-applications-0.1.2-desktop-trust.patch
new file mode 100644
index 000000000000..23955446c5c6
--- /dev/null
+++ b/gui-apps/wayfire-applications/files/wayfire-applications-0.1.2-desktop-trust.patch
@@ -0,0 +1,207 @@
+wayfire-applications: audit fixes
+
+1. A FIFO (or device, symlink, oversized file) named *.desktop in ~/Desktop
+ no longer hangs discovery: the file is opened O_NONBLOCK|O_NOFOLLOW,
+ fstat()ed, required to be a regular file <= 1 MiB and parsed from that
+ descriptor (g_key_file_load_from_data + g_desktop_app_info_new_from_keyfile).
+2. ~/Desktop launchers need a trust mark. Policy: owned by the user, not
+ group- or world-writable, and owner-executable (chmod u+x; the KDE /
+ Nautilus "Allow Launching" convention). Untrusted files are not listed.
+ The stamp (dev/ino/size/mtime/ctime) is recorded at discovery; each launch
+ of a desktop:<name> entry re-opens the file the same way, re-checks trust
+ and the stamp, and fails without running Exec if either changed.
+ Documented in README.md; tests/service.py covers FIFO, symlink,
+ non-executable, group-writable and revoked-trust cases.
+diff --git a/README.md b/README.md
+index a6fc5e7..b208a7f 100644
+--- a/README.md
++++ b/README.md
+@@ -28,3 +28,10 @@ that observation and verify device/inode/size/mtime/ctime before and after loadi
+ an explicit file. Changed or removed files fail without execution or replay.
+ Catalogue entries also carry startup_wm_class, desktop_id, pin_id and filename.
+ The existing Appmenu and search clients ignore additive fields safely.
++
++Top-level ~/Desktop/*.desktop files are listed as desktop:<name> only when they are
++trusted launchers: a regular file (opened O_NONBLOCK|O_NOFOLLOW, never a FIFO,
++device or symlink) up to 1 MiB, owned by the user, not group- or world-writable,
++and executable by its owner (chmod u+x, as KDE and Nautilus "Allow Launching" do).
++The observation is stamped at discovery; each launch re-opens the file, re-checks
++trust and the stamp, and fails without execution if either changed.
+diff --git a/src/main.cpp b/src/main.cpp
+index 0ba8c98..cdcf48e 100644
+--- a/src/main.cpp
++++ b/src/main.cpp
+@@ -66,10 +66,36 @@ struct Stamp
+ a.st_ctim.tv_sec==b.st_ctim.tv_sec && a.st_ctim.tv_nsec==b.st_ctim.tv_nsec;
+ }
+ };
++// ~/Desktop launchers are untrusted input: a file there only becomes a catalogue
++// entry when it is a regular file owned by the user, not group/world-writable,
++// and marked executable by its owner (the freedesktop/KDE "trusted launcher"
++// convention; Nautilus' "Allow Launching" sets the same bit). It is opened with
++// O_NONBLOCK|O_NOFOLLOW and parsed from that descriptor, so a FIFO, device or
++// symlink is never read, and its stamp is re-checked before every launch.
++static GDesktopAppInfo* load_trusted_desktop(const std::string& path,std::optional<Stamp>* stamp)
++{
++ const int fd=open(path.c_str(),O_RDONLY|O_NONBLOCK|O_NOFOLLOW|O_NOCTTY|O_CLOEXEC);
++ if(fd<0)return nullptr;
++ Stamp s;std::string data;
++ if(!fstat(fd,&s.value) && S_ISREG(s.value.st_mode) && s.value.st_size<=1024*1024 && s.value.st_uid==geteuid() &&
++ !(s.value.st_mode&(S_IWGRP|S_IWOTH)) && (s.value.st_mode&S_IXUSR)) {
++ char bytes[8192];ssize_t n;
++ while((n=read(fd,bytes,sizeof(bytes)))>0 && data.size()<=1024*1024)data.append(bytes,n);
++ if(n<0)data.clear();
++ }
++ close(fd);
++ if(data.empty() || data.size()>1024*1024)return nullptr;
++ auto key=g_key_file_new();GDesktopAppInfo* app=nullptr;
++ if(g_key_file_load_from_data(key,data.data(),data.size(),G_KEY_FILE_NONE,nullptr))app=g_desktop_app_info_new_from_keyfile(key);
++ g_key_file_free(key);
++ if(app && stamp)*stamp=s;
++ return app;
++}
+ struct Record { Json value; std::string filename; std::vector<std::string> search; std::optional<Stamp> stamp; };
+-static std::unique_ptr<Record> describe(GDesktopAppInfo* app,const std::string& id)
++static std::unique_ptr<Record> describe(GDesktopAppInfo* app,const std::string& id,const char* path=nullptr)
+ {
+- if(!app || id.empty() || !g_desktop_app_info_get_filename(app))return {};
++ if(!path && app)path=g_desktop_app_info_get_filename(app);
++ if(!app || id.empty() || !path)return {};
+ Json value;value["id"]=id;auto info=G_APP_INFO(app);
+ value["name"]=text(g_app_info_get_name(info));value["display_name"]=text(g_app_info_get_display_name(info));
+ value["description"]=text(g_app_info_get_description(info));value["executable"]=text(g_app_info_get_executable(info));
+@@ -78,7 +104,7 @@ static std::unique_ptr<Record> describe(GDesktopAppInfo* app,const std::string&
+ value["categories"]=text(g_desktop_app_info_get_categories(app));
+ value["startup_wm_class"]=text(g_desktop_app_info_get_startup_wm_class(app));
+ value["desktop_id"]=text(g_app_info_get_id(info));value["pin_id"]=id;
+- value["filename"]=text(g_desktop_app_info_get_filename(app),4096);
++ value["filename"]=text(path,4096);
+ value["terminal"]=bool(g_desktop_app_info_get_boolean(app,"Terminal"));
+ value["nodisplay"]=bool(g_desktop_app_info_get_nodisplay(app));value["visible"]=bool(g_app_info_should_show(info));
+ value["actions"]=Json::array();auto actions=g_desktop_app_info_list_actions(app);
+@@ -87,7 +113,7 @@ static std::unique_ptr<Record> describe(GDesktopAppInfo* app,const std::string&
+ action["name"]=text(label);g_free(label);value["actions"].append(action);
+ }
+ if(value.serialize().size()>12*1024)return {};
+- auto record=std::make_unique<Record>();record->value=value;record->filename=g_desktop_app_info_get_filename(app);
++ auto record=std::make_unique<Record>();record->value=value;record->filename=path;
+ for(const auto field:{"id","name","display_name","description","executable"})record->search.push_back(folded(value[field].as_string()));
+ return record;
+ }
+@@ -95,25 +121,27 @@ struct Snapshot { std::map<std::string,Record> records; bool limited=false; };
+ static Snapshot* discover()
+ {
+ auto snapshot=std::make_unique<Snapshot>();
+- auto add=[&](GDesktopAppInfo* app,const std::string& id) {
++ auto add=[&](GDesktopAppInfo* app,const std::string& id,const char* path=nullptr,std::optional<Stamp> stamp={}) {
+ if(snapshot->records.size()>=4096){snapshot->limited=true;return;}
+- auto record=describe(app,id);
+- if(record)snapshot->records.emplace(id,std::move(*record));
++ auto record=describe(app,id,path);
++ if(record){record->stamp=stamp;snapshot->records.emplace(id,std::move(*record));}
+ else if(app)snapshot->limited=true;
+ };
+ auto list=g_app_info_get_all();
+ for(auto node=list;node;node=node->next)
+ if(G_IS_DESKTOP_APP_INFO(node->data))add(G_DESKTOP_APP_INFO(node->data),text(g_app_info_get_id(G_APP_INFO(node->data))));
+ g_list_free_full(list,g_object_unref);
+- // Preserve Appmenu's existing top-level ~/Desktop entries as well as XDG IDs.
++ // Preserve Appmenu's existing top-level ~/Desktop entries as well as XDG IDs,
++ // but only trusted launchers (see load_trusted_desktop); others are skipped.
+ const std::string desktop=std::string(g_get_home_dir())+"/Desktop";
+ auto dir=g_dir_open(desktop.c_str(),0,nullptr);
+ if(dir){size_t visited=0;while(auto name=g_dir_read_name(dir)) {
+ if(++visited>4096){snapshot->limited=true;break;}
+ const std::string filename=name;
+ if(filename.empty() || filename[0]=='.' || !g_str_has_suffix(name,".desktop") || !g_utf8_validate(name,-1,nullptr))continue;
+- auto app=g_desktop_app_info_new_from_filename((desktop+"/"+name).c_str());
+- add(app,"desktop:"+filename);if(app)g_object_unref(app);
++ const auto path=desktop+"/"+filename;std::optional<Stamp> stamp;
++ auto app=load_trusted_desktop(path,&stamp);
++ if(app){add(app,"desktop:"+filename,path.c_str(),stamp);g_object_unref(app);}
+ }g_dir_close(dir);}
+ return snapshot.release();
+ }
+@@ -122,6 +150,7 @@ struct LaunchCall : std::enable_shared_from_this<LaunchCall>
+ using Ref=std::shared_ptr<LaunchCall>;
+ std::string path, action;
+ std::optional<Stamp> stamp;
++ bool trusted_only=false;
+ GCancellable* cancel;
+ GDesktopAppInfo* app=nullptr;
+ launch::Owner::Begin begin;
+@@ -151,11 +180,19 @@ struct LaunchCall : std::enable_shared_from_this<LaunchCall>
+ else {g_desktop_app_info_launch_action(self->app,self->action.c_str(),context);self->done({true,"Action handed to GIO."});}
+ g_object_unref(context);
+ },new Ref(shared_from_this()));
+- using Input=std::pair<std::string,std::optional<Stamp>>;
+- g_task_set_task_data(task,new Input(path,stamp),[](gpointer data){delete static_cast<Input*>(data);});
++ struct Input { std::string first; std::optional<Stamp> second; bool trusted_only; };
++ g_task_set_task_data(task,new Input{path,stamp,trusted_only},[](gpointer data){delete static_cast<Input*>(data);});
+ g_task_run_in_thread(task,[](GTask* task,gpointer,gpointer data,GCancellable*) {
+ if(g_task_return_error_if_cancelled(task))return;
+ const auto& input=*static_cast<Input*>(data);
++ if(input.trusted_only) {
++ // ~/Desktop launcher: re-validate trust and identity from the opened file.
++ std::optional<Stamp> now;auto app=load_trusted_desktop(input.first,&now);
++ if(app && (!input.second || !now || !(*now==*input.second))){g_object_unref(app);app=nullptr;}
++ if(app)g_task_return_pointer(task,app,g_object_unref);
++ else g_task_return_new_error(task,G_IO_ERROR,G_IO_ERROR_FAILED,"Desktop launcher changed or is not trusted. Choose it again.");
++ return;
++ }
+ auto before=Stamp::read(input.first);
+ if(input.second && (!before || !(*before==*input.second))) {
+ g_task_return_new_error(task,G_IO_ERROR,G_IO_ERROR_FAILED,"Pinned application changed. Choose it again.");return;
+@@ -203,7 +240,8 @@ struct Backend : api::PreferenceBackend
+ if(auto found=catalogue->snapshot->records.find(request.application);found!=catalogue->snapshot->records.end())record=&found->second;
+ else if(auto found=catalogue->resolved.find(request.application);found!=catalogue->resolved.end())record=&found->second;
+ if(!record){done({false,"Application no longer present."});return;}
+- auto call=std::make_shared<LaunchCall>(record->filename,record->stamp,request.content_type,cancel,std::move(begin),std::move(done));call->start();
++ auto call=std::make_shared<LaunchCall>(record->filename,record->stamp,request.content_type,cancel,std::move(begin),std::move(done));
++ call->trusted_only=request.application.rfind("desktop:",0)==0;call->start();
+ }}};
+ GAppInfoMonitor* monitor=g_app_info_monitor_get();
+ GFileMonitor* desktop=nullptr;
+diff --git a/tests/service.py b/tests/service.py
+index c4811b2..1ddbcce 100644
+--- a/tests/service.py
++++ b/tests/service.py
+@@ -22,7 +22,11 @@ with tempfile.TemporaryDirectory(prefix='wfd-applications-') as d:
+ (data/'nodisplay.desktop').write_text(entry('Fixture Invisible').replace('Icon=', 'NoDisplay=true\nIcon='))
+ (data/'other.desktop').write_text(entry('Fixture Other desktop').replace('Icon=', 'OnlyShowIn=Other;\nIcon='))
+ for i in range(33):(data/f'fixture{i}.desktop').write_text(entry('Fixture '+str(i)))
+- (root/'Desktop/local.desktop').write_text(entry('Desktop Fixture'))
++ (root/'Desktop/local.desktop').write_text(entry('Desktop Fixture'));(root/'Desktop/local.desktop').chmod(0o700)
++ # Untrusted ~/Desktop launchers: not executable, group-writable, symlink, FIFO (must not block discovery).
++ (root/'Desktop/plain.desktop').write_text(entry('Desktop Untrusted'));(root/'Desktop/plain.desktop').chmod(0o644)
++ (root/'Desktop/shared.desktop').write_text(entry('Desktop Shared'));(root/'Desktop/shared.desktop').chmod(0o770)
++ os.symlink(root/'Desktop/local.desktop',root/'Desktop/link.desktop');os.mkfifo(root/'Desktop/pipe.desktop')
+ env=dict(os.environ,HOME=d,XDG_CONFIG_HOME=str(root/'config'),XDG_DATA_HOME=str(root/'data'),XDG_DATA_DIRS=str(root/'empty'),XDG_CURRENT_DESKTOP='Wayfire')
+ assert subprocess.run([exe,'--channel','3'],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode==2
+ parent, child=socket.socketpair();parent.settimeout(5)
+@@ -52,7 +56,7 @@ with tempfile.TemporaryDirectory(prefix='wfd-applications-') as d:
+ rows=list(first['entries']);page=first
+ while page['next_offset'] is not None:
+ page=call('applications.list',dict(epoch=first['epoch'],revision=first['revision'],offset=page['next_offset']))['applications'];rows+=page['entries']
+- assert len(rows)==38 and any(r['id']=='desktop:local.desktop' for r in rows)
++ assert len(rows)==38 and any(r['id']=='desktop:local.desktop' for r in rows),[r['id'] for r in rows if r['id'].startswith('desktop:')]
+ same=call('applications.list',dict(epoch=first['epoch'],revision=first['revision'],offset=0))['applications'];assert same['unchanged'] and not same['entries']
+ assert call('applications.list',dict(epoch='stale',revision=first['revision'],offset=20))['error']=='stale-catalogue'
+ for params in [dict(epoch='',revision=0,offset=-1),dict(epoch='',revision=0,offset=0,extra=True)]:assert call('applications.list',params)['error']=='invalid-request'
+@@ -129,7 +133,15 @@ with tempfile.TemporaryDirectory(prefix='wfd-applications-') as d:
+ assert call('applications.launch',params)['error']=='stale-catalogue'
+ assert call('applications.status',dict(job='01234567-89ab-cdef-0123-456789abcdef'))['error']=='unknown-job'
+ assert len(log.read_text().splitlines())==4
+- print('PASS private owner Unicode search, visibility, pagination, schema, stale continuation, read-only effects, discovery, bounds, Desktop entries, refresh/epochs, default/action/file launches, association and no replay')
++ cur=wait(lambda:(r:=call('applications.list',dict(epoch='',revision=0,offset=0)))['status']=='ok' and r)['applications']
++ desktop_params=dict(epoch=cur['epoch'],revision=cur['revision'],id='desktop:local.desktop',action='')
++ assert result(call('applications.launch',desktop_params))['state']=='started'
++ wait(lambda:len(log.read_text().splitlines())==5)
++ (root/'Desktop/local.desktop').chmod(0o600) # trust revoked: never runs again
++ r=call('applications.launch',desktop_params)
++ assert r['status']=='error' or result(r)['state']=='failed',r
++ time.sleep(.5);assert len(log.read_text().splitlines())==5
++ print('PASS private owner Unicode search, visibility, pagination, schema, stale continuation, read-only effects, discovery, bounds, trusted Desktop entries, refresh/epochs, default/action/file launches, association and no replay')
+ finally:
+ parent.close()
+ try:process.wait(timeout=3)
diff --git a/gui-apps/wayfire-applications/files/wayfire-applications-0.1.2-detach-launch.patch b/gui-apps/wayfire-applications/files/wayfire-applications-0.1.2-detach-launch.patch
new file mode 100644
index 000000000000..df98055f6603
--- /dev/null
+++ b/gui-apps/wayfire-applications/files/wayfire-applications-0.1.2-detach-launch.patch
@@ -0,0 +1,235 @@
+wayfire-applications: launch applications in their own session
+(apply after wayfire-applications-0.1.2-desktop-trust.patch)
+
+The resident runs wayfire-applications in its own process group and the
+supervisor stops it with kill(-pgid). Launched applications previously
+inherited that process group and session (GIO forks directly from this
+service), plus its ignored signals and blocked mask, so stopping or
+restarting the resident killed the user's applications.
+
+- New launch::launch_detached(): g_desktop_app_info_launch_uris_as_manager()
+ (the same spawn as g_app_info_launch_uris) with a child-setup function that
+ calls setsid(), resets every signal disposition to SIG_DFL (incl. SIGPIPE)
+ and clears the signal mask before exec.
+- Used for catalogue launches, ~/Desktop launchers, resolved pins and
+ files.launch.request "open" (non-desktop GAppInfo keeps the old GIO path).
+- Desktop actions: GIO has no child-setup hook for g_desktop_app_info_launch_action,
+ so an action with an Exec line is launched as an application whose Exec is
+ the action's Exec, through launch_detached(). The key file comes from the
+ same verified read as the entry. Only D-Bus-activatable actions without Exec
+ still go through g_desktop_app_info_launch_action (spawned by the bus, not
+ by this process).
+- tests/service.py: the service runs in its own process group with SIGPIPE and
+ SIGUSR2 ignored and SIGUSR1 blocked; every launched process (default,
+ action, pin, file open, Desktop launcher) must have pgid == sid == pid,
+ differ from the service's, have SIGUSR2 not ignored and an empty mask.
+diff --git a/private/file-launch-gio.cpp b/private/file-launch-gio.cpp
+index 6b53f09..2c91b5a 100644
+--- a/private/file-launch-gio.cpp
++++ b/private/file-launch-gio.cpp
+@@ -1,10 +1,20 @@
+ #include "file-launch-gio.hpp"
+ #include <gio/gdesktopappinfo.h>
++#include <signal.h>
++#include <unistd.h>
+
+ namespace shell::files::launch
+ {
+ namespace
+ {
++// Runs in the forked child before exec: async-signal-safe calls only.
++void detach_child(gpointer)
++{
++ setsid();
++ struct sigaction value{};value.sa_handler=SIG_DFL;sigemptyset(&value.sa_mask);
++ for(int signal=1;signal<NSIG;++signal)if(signal!=SIGKILL && signal!=SIGSTOP)sigaction(signal,&value,nullptr);
++ sigset_t none;sigemptyset(&none);sigprocmask(SIG_SETMASK,&none,nullptr);
++}
+ struct Call : std::enable_shared_from_this<Call>
+ {
+ using Ref=std::shared_ptr<Call>;
+@@ -34,6 +44,11 @@ struct Call : std::enable_shared_from_this<Call>
+ "WAYFIRE_PREFERENCE_UI_FD","WAYFIRE_PREFERENCE_CONTROL_FD","WAYFIRE_FILES_QUERY_FD","WAYFIRE_FILES_ACTIVATION_FD","WAYFIRE_FILES_UI",
+ "WAYFIRE_APPMENU_QUERY_FD","WAYFIRE_APPMENU_UI","WAYFIRE_DOCK_UI"})g_app_launch_context_unsetenv(context,name);
+ uris.data=const_cast<char*>(request.uri.c_str());
++ if(G_IS_DESKTOP_APP_INFO(app))
++ {
++ GError* error=nullptr;const bool okay=launch_detached(G_DESKTOP_APP_INFO(app),&uris,context,&error);
++ g_object_unref(context);finish(okay,error);return;
++ }
+ g_app_info_launch_uris_async(app,&uris,context,cancelled,[](GObject* source,GAsyncResult* result,gpointer data)
+ {
+ auto self=take(data);GError* error=nullptr;
+@@ -103,6 +118,11 @@ struct Call : std::enable_shared_from_this<Call>
+ }
+ };
+ }
++bool launch_detached(GDesktopAppInfo* app,GList* uris,GAppLaunchContext* context,GError** error)
++{
++ // Same spawn as g_app_info_launch_uris(), plus the child setup hook.
++ return g_desktop_app_info_launch_uris_as_manager(app,uris,context,G_SPAWN_SEARCH_PATH,detach_child,nullptr,nullptr,nullptr,error);
++}
+ void gio_request(const Request& request,GCancellable* cancel,Owner::Begin begin,Owner::RequestComplete complete)
+ {auto call=std::make_shared<Call>(request,cancel,std::move(begin),std::move(complete));call->start();}
+ }
+diff --git a/private/file-launch-gio.hpp b/private/file-launch-gio.hpp
+index daf4a88..4beffb7 100644
+--- a/private/file-launch-gio.hpp
++++ b/private/file-launch-gio.hpp
+@@ -1,6 +1,11 @@
+ #pragma once
+ #include "file-launch-owner.hpp"
++#include <gio/gdesktopappinfo.h>
+ namespace shell::files::launch
+ {
+ void gio_request(const Request&,GCancellable*,Owner::Begin,Owner::RequestComplete);
++// Launch a desktop application so that every spawned process starts its own
++// session (setsid) with default signal dispositions and an empty signal mask:
++// it must survive this service and its supervisor's process-group signals.
++bool launch_detached(GDesktopAppInfo*,GList* uris,GAppLaunchContext*,GError**);
+ }
+diff --git a/src/main.cpp b/src/main.cpp
+index cdcf48e..ccea316 100644
+--- a/src/main.cpp
++++ b/src/main.cpp
+@@ -72,25 +72,59 @@ struct Stamp
+ // convention; Nautilus' "Allow Launching" sets the same bit). It is opened with
+ // O_NONBLOCK|O_NOFOLLOW and parsed from that descriptor, so a FIFO, device or
+ // symlink is never read, and its stamp is re-checked before every launch.
+-static GDesktopAppInfo* load_trusted_desktop(const std::string& path,std::optional<Stamp>* stamp)
++static GKeyFile* read_desktop_file(const std::string& path,bool trusted,Stamp* stamp=nullptr)
+ {
+- const int fd=open(path.c_str(),O_RDONLY|O_NONBLOCK|O_NOFOLLOW|O_NOCTTY|O_CLOEXEC);
++ const int fd=open(path.c_str(),O_RDONLY|O_NONBLOCK|O_NOCTTY|O_CLOEXEC|(trusted?O_NOFOLLOW:0));
+ if(fd<0)return nullptr;
+ Stamp s;std::string data;
+- if(!fstat(fd,&s.value) && S_ISREG(s.value.st_mode) && s.value.st_size<=1024*1024 && s.value.st_uid==geteuid() &&
+- !(s.value.st_mode&(S_IWGRP|S_IWOTH)) && (s.value.st_mode&S_IXUSR)) {
++ if(!fstat(fd,&s.value) && S_ISREG(s.value.st_mode) && s.value.st_size<=1024*1024 && (!trusted || (s.value.st_uid==geteuid() &&
++ !(s.value.st_mode&(S_IWGRP|S_IWOTH)) && (s.value.st_mode&S_IXUSR)))) {
+ char bytes[8192];ssize_t n;
+ while((n=read(fd,bytes,sizeof(bytes)))>0 && data.size()<=1024*1024)data.append(bytes,n);
+ if(n<0)data.clear();
+ }
+ close(fd);
+ if(data.empty() || data.size()>1024*1024)return nullptr;
+- auto key=g_key_file_new();GDesktopAppInfo* app=nullptr;
+- if(g_key_file_load_from_data(key,data.data(),data.size(),G_KEY_FILE_NONE,nullptr))app=g_desktop_app_info_new_from_keyfile(key);
+- g_key_file_free(key);
+- if(app && stamp)*stamp=s;
++ auto key=g_key_file_new();
++ if(!g_key_file_load_from_data(key,data.data(),data.size(),G_KEY_FILE_NONE,nullptr)){g_key_file_unref(key);return nullptr;}
++ if(stamp)*stamp=s;
++ return key;
++}
++// The parsed key file travels with the app so a desktop action can be
++// launched with the same detached spawn as the main entry (see launch_action).
++static const char* keyfile_key="wayfire-applications-keyfile";
++static GDesktopAppInfo* load_trusted_desktop(const std::string& path,std::optional<Stamp>* stamp)
++{
++ Stamp s;auto key=read_desktop_file(path,true,&s);
++ if(!key)return nullptr;
++ auto app=g_desktop_app_info_new_from_keyfile(key);
++ if(app){g_object_set_data_full(G_OBJECT(app),keyfile_key,key,GDestroyNotify(g_key_file_unref));if(stamp)*stamp=s;}
++ else g_key_file_unref(key);
+ return app;
+ }
++// GIO has no child-setup hook for desktop actions. Launch an action as an
++// application whose Exec is the action's Exec, through the detached spawn.
++// D-Bus activatable actions without Exec are activated by the bus instead.
++static bool launch_action(GDesktopAppInfo* app,const std::string& action,GAppLaunchContext* context,GError** error)
++{
++ auto key=static_cast<GKeyFile*>(g_object_get_data(G_OBJECT(app),keyfile_key));
++ const auto group="Desktop Action "+action;
++ if(!key){g_set_error(error,G_IO_ERROR,G_IO_ERROR_FAILED,"Application action is no longer available.");return false;}
++ auto* exec=g_key_file_get_string(key,group.c_str(),"Exec",nullptr);
++ if(!exec) {
++ if(!g_key_file_get_boolean(key,"Desktop Entry","DBusActivatable",nullptr))
++ {g_set_error(error,G_IO_ERROR,G_IO_ERROR_FAILED,"Application action has no command.");return false;}
++ g_desktop_app_info_launch_action(app,action.c_str(),context);return true;
++ }
++ auto copy=g_key_file_new();gsize size=0;auto* data=g_key_file_to_data(key,&size,nullptr);
++ g_key_file_load_from_data(copy,data,size,G_KEY_FILE_NONE,nullptr);g_free(data);
++ g_key_file_set_string(copy,"Desktop Entry","Exec",exec);g_free(exec);
++ g_key_file_remove_key(copy,"Desktop Entry","DBusActivatable",nullptr);
++ auto* action_app=g_desktop_app_info_new_from_keyfile(copy);g_key_file_unref(copy);
++ if(!action_app){g_set_error(error,G_IO_ERROR,G_IO_ERROR_FAILED,"Application action is no longer available.");return false;}
++ const bool ok=launch::launch_detached(action_app,nullptr,context,error);
++ g_object_unref(action_app);return ok;
++}
+ struct Record { Json value; std::string filename; std::vector<std::string> search; std::optional<Stamp> stamp; };
+ static std::unique_ptr<Record> describe(GDesktopAppInfo* app,const std::string& id,const char* path=nullptr)
+ {
+@@ -170,18 +204,15 @@ struct LaunchCall : std::enable_shared_from_this<LaunchCall>
+ if(!valid){self->done({false,"Application action is no longer available."});return;}
+ }
+ if(!self->begin()){self->done({false,"Launch expired before dispatch."});return;}
+- auto context=g_app_launch_context_new();
+- if(self->action.empty())
+- g_app_info_launch_uris_async(G_APP_INFO(self->app),nullptr,context,self->cancel,[](GObject* app,GAsyncResult* result,gpointer data) {
+- std::unique_ptr<Ref> ref(static_cast<Ref*>(data));GError* error=nullptr;
+- const bool ok=g_app_info_launch_uris_finish(G_APP_INFO(app),result,&error);
+- (*ref)->done({ok,text(error?error->message:"")});g_clear_error(&error);
+- },new Ref(self));
+- else {g_desktop_app_info_launch_action(self->app,self->action.c_str(),context);self->done({true,"Action handed to GIO."});}
++ auto context=g_app_launch_context_new();GError* error2=nullptr;
++ // Spawned applications get their own session (see launch_detached).
++ const bool ok=self->action.empty()?launch::launch_detached(self->app,nullptr,context,&error2):
++ launch_action(self->app,self->action,context,&error2);
++ self->done({ok,text(error2?error2->message:(self->action.empty()?"":"Action handed to GIO."))});g_clear_error(&error2);
+ g_object_unref(context);
+ },new Ref(shared_from_this()));
+- struct Input { std::string first; std::optional<Stamp> second; bool trusted_only; };
+- g_task_set_task_data(task,new Input{path,stamp,trusted_only},[](gpointer data){delete static_cast<Input*>(data);});
++ struct Input { std::string first; std::optional<Stamp> second; bool trusted_only, action; };
++ g_task_set_task_data(task,new Input{path,stamp,trusted_only,!action.empty()},[](gpointer data){delete static_cast<Input*>(data);});
+ g_task_run_in_thread(task,[](GTask* task,gpointer,gpointer data,GCancellable*) {
+ if(g_task_return_error_if_cancelled(task))return;
+ const auto& input=*static_cast<Input*>(data);
+@@ -198,6 +229,8 @@ struct LaunchCall : std::enable_shared_from_this<LaunchCall>
+ g_task_return_new_error(task,G_IO_ERROR,G_IO_ERROR_FAILED,"Pinned application changed. Choose it again.");return;
+ }
+ auto app=g_desktop_app_info_new_from_filename(input.first.c_str());
++ if(app && input.action)
++ if(auto key=read_desktop_file(input.first,false))g_object_set_data_full(G_OBJECT(app),keyfile_key,key,GDestroyNotify(g_key_file_unref));
+ auto after=Stamp::read(input.first);
+ if(input.second && (!after || !(*after==*input.second))){if(app)g_object_unref(app);app=nullptr;}
+ if(app)g_task_return_pointer(task,app,g_object_unref);
+diff --git a/tests/service.py b/tests/service.py
+index 1ddbcce..ce02344 100644
+--- a/tests/service.py
++++ b/tests/service.py
+@@ -14,7 +14,7 @@ with tempfile.TemporaryDirectory(prefix='wfd-applications-') as d:
+ root=Path(d); data=root/'data/applications';data.mkdir(parents=True)
+ (root/'config').mkdir();(root/'Desktop').mkdir()
+ helper=root/'record.py';log=root/'launches'
+- helper.write_text('import os,sys\nf=os.open('+repr(str(log))+',os.O_WRONLY|os.O_CREAT|os.O_APPEND,0o600)\nos.write(f,(repr(sys.argv[1:])+"\\n").encode());os.close(f)\n')
++ helper.write_text('import os,sys\nf=os.open('+repr(str(log))+',os.O_WRONLY|os.O_CREAT|os.O_APPEND,0o600)\nst=dict(l.split(":\\t",1) for l in open("/proc/self/status").read().splitlines() if ":\\t" in l)\nos.write(f,(repr(sys.argv[1:])+" "+repr([os.getpid(),os.getpgid(0),os.getsid(0),int(st["SigIgn"],16),int(st["SigBlk"],16)])+"\\n").encode());os.close(f)\n')
+ def entry(name='Fixture Application'):
+ return '[Desktop Entry]\nType=Application\nName='+name+'\nExec=/usr/bin/python3 '+str(helper)+' default %U\nIcon=utilities-terminal\nStartupWMClass=FixtureClass\nCategories=Utility;\nMimeType=text/plain;\nActions=Second;\n\n[Desktop Action Second]\nName=Second\nExec=/usr/bin/python3 '+str(helper)+' action\n'
+ (data/'fixture.desktop').write_text(entry())
+@@ -31,6 +31,8 @@ with tempfile.TemporaryDirectory(prefix='wfd-applications-') as d:
+ assert subprocess.run([exe,'--channel','3'],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode==2
+ parent, child=socket.socketpair();parent.settimeout(5)
+ def bootstrap():
++ # Like the resident: own process group, SIGPIPE/SIGUSR2 ignored, SIGUSR1 blocked.
++ os.setpgid(0,0);signal.signal(signal.SIGPIPE,signal.SIG_IGN);signal.signal(signal.SIGUSR2,signal.SIG_IGN);signal.pthread_sigmask(signal.SIG_BLOCK,{signal.SIGUSR1})
+ os.dup2(child.fileno(),3)
+ if child.fileno()!=3:os.close(child.fileno())
+ process=subprocess.Popen([exe,'--channel','3'],pass_fds=tuple({3,child.fileno()}),preexec_fn=bootstrap,env=env)
+@@ -141,7 +143,14 @@ with tempfile.TemporaryDirectory(prefix='wfd-applications-') as d:
+ r=call('applications.launch',desktop_params)
+ assert r['status']=='error' or result(r)['state']=='failed',r
+ time.sleep(.5);assert len(log.read_text().splitlines())==5
+- print('PASS private owner Unicode search, visibility, pagination, schema, stale continuation, read-only effects, discovery, bounds, trusted Desktop entries, refresh/epochs, default/action/file launches, association and no replay')
++ # Every launched process runs in its own session/process group with
++ # default signal dispositions and an empty mask (survives kill(-pgid)).
++ for line in log.read_text().splitlines():
++ pid,pgid,sid,ignored,blocked=eval(line[line.rindex(' [')+1:])
++ assert pgid==pid and sid==pid and sid!=os.getsid(process.pid) and pgid!=os.getpgid(process.pid),line
++ # The Python helper re-ignores SIGPIPE/SIGXFSZ itself, so check SIGUSR2.
++ assert not ignored & (1<<(signal.SIGUSR2-1)) and not blocked,line
++ print('PASS private owner Unicode search, visibility, pagination, schema, stale continuation, read-only effects, discovery, bounds, trusted Desktop entries, refresh/epochs, default/action/file launches in their own session, association and no replay')
+ finally:
+ parent.close()
+ try:process.wait(timeout=3)
diff --git a/gui-apps/wayfire-applications/wayfire-applications-0.1.2.ebuild b/gui-apps/wayfire-applications/wayfire-applications-0.1.2-r1.ebuild
index 7ccf913faf0c..15e12b9b428c 100644
--- a/gui-apps/wayfire-applications/wayfire-applications-0.1.2.ebuild
+++ b/gui-apps/wayfire-applications/wayfire-applications-0.1.2-r1.ebuild
@@ -4,6 +4,7 @@ inherit meson
DESCRIPTION="SrcUX components for Wayfire: applications"
HOMEPAGE="https://www.srcux.com/"
SRC_URI="https://www.srcux.com/distfiles/${P}.tar.gz"
+PATCHES=( "${FILESDIR}/${P}-desktop-trust.patch" "${FILESDIR}/${P}-detach-launch.patch" )
LICENSE="MIT"
SLOT="0"
KEYWORDS="~amd64"
diff --git a/gui-apps/wayfire-ctl/files/wayfire-ctl-0.1.4-json-shape.patch b/gui-apps/wayfire-ctl/files/wayfire-ctl-0.1.4-json-shape.patch
new file mode 100644
index 000000000000..b0b4a1d3cff1
--- /dev/null
+++ b/gui-apps/wayfire-ctl/files/wayfire-ctl-0.1.4-json-shape.patch
@@ -0,0 +1,364 @@
+wayfire-ctl 0.1.4: non-aborting JSON wrapper (port of the wayfire-panel fix)
+
+- private/json.cpp: the wf::json wrapper no longer assert()s on type
+ mismatch (asserts are compiled in on Gentoo's plain buildtype): indexing
+ the wrong shape (key into a non-object, index out of range or into a
+ non-array) yields a detached null, size() of a non-array is 0, and
+ wrong-type extraction/append throws std::domain_error.
+- Callers: the SDK reply trampoline (noexcept C callback) re-delivers a
+ clean "invalid-response" error if a handler throws and, should that also
+ throw, prints the error and exits 1 instead of std::terminate. main()
+ also wraps the directory/services/components path. Status checks are
+ type-checked, and a hello without a string instance is reported as an
+ error (exit 1) instead of printing the reply with exit 0.
+- tests/json-shape.cpp regression test (built with -Dtests=true).
+
+diff --git a/meson.build b/meson.build
+index f7cdb8b..146f2e2 100644
+--- a/meson.build
++++ b/meson.build
+@@ -11,6 +11,9 @@ if get_option('tests')
+ adapter = executable('adapter-test', ['tests/adapter.cpp', 'private/json.cpp'],
+ include_directories: include_directories('private'), dependencies: [sdk, yyjson], cpp_args: ['-UNDEBUG'])
+ test('adapter-immediate-error', adapter)
++ json_shape = executable('json-shape-test', ['tests/json-shape.cpp', 'private/json.cpp'],
++ include_directories: include_directories('private'), dependencies: [yyjson], cpp_args: ['-UNDEBUG'])
++ test('json-shape', json_shape)
+ python = find_program('python3')
+ test('application-search-cli', python, args: [files('tests/search-cli.py'), cli], timeout: 20)
+ test('directory-cli', python, args: [files('tests/directory-cli.py'), cli], timeout: 20)
+diff --git a/private/json.cpp b/private/json.cpp
+index f02d40e..0829d6b 100644
+--- a/private/json.cpp
++++ b/private/json.cpp
+@@ -1,10 +1,21 @@
+ #include <wayfire/nonstd/json.hpp>
+ #include <yyjson.h>
+ #include <limits>
+-#include <cassert>
++#include <stdexcept>
+
+ namespace wf
+ {
++// Values come from IPC peers. A type mismatch must be recoverable instead of
++// aborting the process: indexing the wrong shape yields a detached null and
++// extracting the wrong type throws std::domain_error.
++static void require(bool condition, const char *message)
++{
++ if (!condition)
++ {
++ throw std::domain_error(message);
++ }
++}
++
+ bool json_reference_t::is_array() const
+ {
+ return yyjson_mut_is_arr(v);
+@@ -12,69 +23,71 @@ bool json_reference_t::is_array() const
+
+ json_reference_t json_reference_t::operator [](const size_t& idx) const
+ {
+- assert(is_array());
+- assert(size() > idx);
++ if (!is_array() || (idx >= size()))
++ {
++ return json_reference_t{doc, yyjson_mut_null(doc)};
++ }
++
+ return json_reference_t{doc, yyjson_mut_arr_get(v, idx)};
+ }
+
+ void json_reference_t::append(const json_reference_t& elem)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_append(v, yyjson_mut_val_mut_copy(doc, elem.v));
+ }
+
+ void json_reference_t::append(int value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_int(doc, v, value);
+ }
+
+ void json_reference_t::append(unsigned int value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_uint(doc, v, value);
+ }
+
+ void json_reference_t::append(int64_t value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_sint(doc, v, value);
+ }
+
+ void json_reference_t::append(uint64_t value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_uint(doc, v, value);
+ }
+
+ void json_reference_t::append(double value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_real(doc, v, value);
+ }
+
+ void json_reference_t::append(bool value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_bool(doc, v, value);
+ }
+
+ void json_reference_t::append(const std::string_view& value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_strncpy(doc, v, value.data(), value.size());
+ }
+
+ void json_reference_t::append(const char *value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_strcpy(doc, v, value);
+ }
+
+ size_t json_reference_t::size() const
+ {
+- assert(is_array());
+- return yyjson_mut_arr_size(v);
++ return is_array() ? yyjson_mut_arr_size(v) : 0;
+ }
+
+ bool json_reference_t::has_member(const std::string_view& key) const
+@@ -99,7 +112,11 @@ json_reference_t json_reference_t::operator [](const char *key) const
+
+ json_reference_t json_reference_t::operator [](const std::string_view& key) const
+ {
+- assert((is_object() || is_null()) && "Trying to access into JSON value that is not an object!");
++ if (!is_object() && !is_null())
++ {
++ return json_reference_t{doc, yyjson_mut_null(doc)};
++ }
++
+ if (is_null())
+ {
+ yyjson_mut_set_obj(v);
+@@ -202,7 +219,7 @@ static void copy_yyjson(yyjson_mut_doc *dst_doc, yyjson_mut_val *dst, yyjson_mut
+ return;
+ }
+
+- assert(false && "Unsupported JSON type?");
++ throw std::domain_error("Unsupported JSON type");
+ }
+
+ json_reference_t& json_reference_t::operator =(const json_reference_t& other)
+@@ -289,7 +306,7 @@ bool json_reference_t::is_int() const
+
+ json_reference_t::operator int() const
+ {
+- assert(is_int());
++ require(is_int(), "JSON value is not an integer");
+ return yyjson_mut_get_int(v);
+ }
+
+@@ -310,7 +327,7 @@ bool json_reference_t::is_int64() const
+
+ json_reference_t::operator int64_t() const
+ {
+- assert(is_int64());
++ require(is_int64(), "JSON value is not a 64-bit integer");
+ if (yyjson_mut_is_uint(v))
+ {
+ return yyjson_mut_get_uint(v);
+@@ -333,7 +350,7 @@ bool json_reference_t::is_uint() const
+
+ json_reference_t::operator unsigned int() const
+ {
+- assert(is_uint());
++ require(is_uint(), "JSON value is not an unsigned integer");
+ return yyjson_mut_get_uint(v);
+ }
+
+@@ -349,7 +366,7 @@ bool json_reference_t::is_uint64() const
+
+ json_reference_t::operator uint64_t() const
+ {
+- assert(is_uint64());
++ require(is_uint64(), "JSON value is not an unsigned 64-bit integer");
+ return yyjson_mut_get_uint(v);
+ }
+
+@@ -365,7 +382,7 @@ bool json_reference_t::is_bool() const
+
+ json_reference_t::operator bool() const
+ {
+- assert(is_bool());
++ require(is_bool(), "JSON value is not a boolean");
+ return yyjson_mut_get_bool(v);
+ }
+
+@@ -381,7 +398,7 @@ bool json_reference_t::is_double() const
+
+ json_reference_t::operator double() const
+ {
+- assert(is_double());
++ require(is_double(), "JSON value is not a number");
+ return yyjson_mut_get_num(v);
+ }
+
+@@ -396,7 +413,7 @@ bool json_reference_t::is_string() const
+ }
+
+ json_reference_t::operator std::string() const {
+- assert(is_string());
++ require(is_string(), "JSON value is not a string");
+ return std::string(yyjson_mut_get_str(v));
+ }
+
+diff --git a/src/adapter.hpp b/src/adapter.hpp
+index 12226ae..213fe54 100644
+--- a/src/adapter.hpp
++++ b/src/adapter.hpp
+@@ -2,6 +2,7 @@
+ #include <wayfire-desktop/client.h>
+ #include "companion-dock-values.hpp"
+ #include <cstdlib>
++#include <iostream>
+ #include <map>
+ #include <memory>
+ #include <stdexcept>
+@@ -67,7 +68,18 @@ class Client
+ if (wf::json_t::parse_string(std::string_view(bytes, length), result))
+ result = local_error("invalid-response");
+ callback(std::move(result));
+- } catch (...) { callback(local_error("invalid-response")); }
++ } catch (...)
++ {
++ // A wrong-shaped reply made the handler throw: deliver a clean
++ // error instead. Never let an exception leave this noexcept C
++ // callback (std::terminate); if even that fails, exit cleanly.
++ try { callback(local_error("invalid-response")); }
++ catch (...)
++ {
++ std::cout << local_error("invalid-response").serialize() << std::endl;
++ std::exit(1);
++ }
++ }
+ }
+ public:
+ explicit Client(const std::string& endpoint)
+diff --git a/src/cli.cpp b/src/cli.cpp
+index e8d612a..b72d6d1 100644
+--- a/src/cli.cpp
++++ b/src/cli.cpp
+@@ -44,8 +44,9 @@ int run(int argc, char **argv)
+ loop->quit();
+ };
+ client.request("hello", {}, "", 0, [&](wf::json_t hello) {
+- if (!hello["status"].is_string() || hello["status"].as_string() != "ok" || !hello["instance"].is_string())
++ if (!hello["status"].is_string() || hello["status"].as_string() != "ok")
+ { done(hello); return; }
++ if (!hello["instance"].is_string()) { done(api::local_error("invalid-response")); return; }
+ client.request(applications ? "applications.search" : "files.search", params, hello["instance"].as_string(), 0, done);
+ });
+ loop->run(); return status;
+@@ -79,8 +80,9 @@ int run(int argc, char **argv)
+ };
+ client.request("hello", {}, "", 0, [&](wf::json_t hello)
+ {
+- if (!hello["status"].is_string() || hello["status"].as_string() != "ok" ||
+- !hello["instance"].is_string()) { done(hello); return; }
++ if (!hello["status"].is_string() || hello["status"].as_string() != "ok")
++ { done(hello); return; }
++ if (!hello["instance"].is_string()) { done(api::local_error("invalid-response")); return; }
+ const auto instance = hello["instance"].as_string();
+ client.request("appearance.read", {}, instance, 0,
+ [&, instance](wf::json_t result)
+@@ -202,11 +204,12 @@ int run(int argc, char **argv)
+ auto done = [&](wf::json_t result)
+ {
+ std::cout << result.serialize() << std::endl;
+- status = result["status"].as_string() == "ok" ? 0 : 1; loop->quit();
++ status = result["status"].is_string() && result["status"].as_string() == "ok" ? 0 : 1; loop->quit();
+ };
+ client.request("hello", {}, "", 0, [&](wf::json_t hello)
+ {
+- if (hello["status"].as_string() != "ok") { done(hello); return; }
++ if (!hello["status"].is_string() || hello["status"].as_string() != "ok") { done(hello); return; }
++ if (!hello["instance"].is_string()) { done(api::local_error("invalid-response")); return; }
+ if (command == "read")
+ client.request("state.read", {}, hello["instance"].as_string(), 0, [&, hello](wf::json_t result)
+ { result["capabilities"] = hello["capabilities"]; done(result); });
+@@ -215,9 +218,11 @@ int run(int argc, char **argv)
+ else if (command == "toggle-dock-enabled")
+ client.request("state.read", {}, hello["instance"].as_string(), 0, [&](wf::json_t result)
+ {
+- if (result["status"].as_string() != "ok") { done(result); return; }
++ if (!result["status"].is_string() || result["status"].as_string() != "ok") { done(result); return; }
+ if (!result["dock"]["enabled"].is_bool())
+ { result["status"] = "error"; result["error"] = "unavailable"; result["outcome"] = "not-applied"; done(result); return; }
++ if (!explicit_revision && !result["instance"].is_string())
++ { done(api::local_error("invalid-response")); return; }
+ params["enabled"] = !result["dock"]["enabled"].as_bool();
+ client.request(method, params, explicit_revision ? argv[base] : result["instance"].as_string(),
+ explicit_revision ? expected : api::unsigned_number(result["revision"]), done);
+@@ -231,8 +236,12 @@ int run(int argc, char **argv)
+ int directory_cli(int argc, char **argv);
+ int main(int argc, char **argv)
+ {
+- if (argc > 1 && (std::string(argv[1]) == "directory" || std::string(argv[1]) == "services" || std::string(argv[1]) == "components"))
+- return directory_cli(argc, argv);
+- try { return run(argc, argv); }
++ // Wrong-shaped replies throw catchable errors: report them, never terminate.
++ try
++ {
++ if (argc > 1 && (std::string(argv[1]) == "directory" || std::string(argv[1]) == "services" || std::string(argv[1]) == "components"))
++ return directory_cli(argc, argv);
++ return run(argc, argv);
++ }
+ catch (...) { std::cout << api::local_error("invalid-response").serialize() << std::endl; return 1; }
+ }
+diff --git a/tests/json-shape.cpp b/tests/json-shape.cpp
+new file mode 100644
+index 0000000..ab39379
+--- /dev/null
++++ b/tests/json-shape.cpp
+@@ -0,0 +1,29 @@
++// Wrong-shaped peer JSON must be a recoverable error, never assert()/abort().
++#include <wayfire/nonstd/json.hpp>
++#include <cassert>
++#include <iostream>
++#include <stdexcept>
++
++template<class F> static bool throws(F f)
++{
++ try { f(); } catch (const std::domain_error&) { return true; }
++ return false;
++}
++
++int main()
++{
++ wf::json_t array, object, text;
++ assert(!wf::json_t::parse_string("[1, \"x\"]", array));
++ assert(!wf::json_t::parse_string(R"({"status":"ok","instance":7,"items":[1, [2]]})", object));
++ assert(!wf::json_t::parse_string("\"x\"", text));
++ assert(array["status"].is_null() && !array.has_member("status") && array.size() == 2);
++ assert(text["x"].is_null() && text.size() == 0 && text[size_t(0)].is_null());
++ assert(object["items"][size_t(9)].is_null() && object["items"][size_t(0)]["id"].is_null());
++ assert(throws([&] { (void)object["instance"].as_string(); }));
++ assert(throws([&] { (void)array["status"].as_string(); }));
++ assert(throws([&] { (void)object["status"].as_bool(); }));
++ assert(throws([&] { text.append(1); }));
++ assert(object["status"].as_string() == "ok");
++ std::cout << "PASS wrong-shaped JSON is recoverable (null indexing, typed exceptions)" << std::endl;
++ return 0;
++}
diff --git a/gui-apps/wayfire-ctl/wayfire-ctl-0.1.4.ebuild b/gui-apps/wayfire-ctl/wayfire-ctl-0.1.4-r1.ebuild
index 970c289b3a4b..b1e48ecc9091 100644
--- a/gui-apps/wayfire-ctl/wayfire-ctl-0.1.4.ebuild
+++ b/gui-apps/wayfire-ctl/wayfire-ctl-0.1.4-r1.ebuild
@@ -4,6 +4,7 @@ inherit meson
DESCRIPTION="SrcUX components for Wayfire: ctl"
HOMEPAGE="https://www.srcux.com/"
SRC_URI="https://www.srcux.com/distfiles/${P}.tar.gz"
+PATCHES=( "${FILESDIR}/${P}-json-shape.patch" )
LICENSE="MIT"
SLOT="0"
KEYWORDS="~amd64"
diff --git a/gui-apps/wayfire-dock/files/wayfire-dock-0.1.1-material-clamp.patch b/gui-apps/wayfire-dock/files/wayfire-dock-0.1.1-material-clamp.patch
new file mode 100644
index 000000000000..20395dbae094
--- /dev/null
+++ b/gui-apps/wayfire-dock/files/wayfire-dock-0.1.1-material-clamp.patch
@@ -0,0 +1,20 @@
+dock-material: clamp the fragment colour before premultiplying.
+
+With a white or near-white tint the top face (tint + 0.07 sheen + 0.19/0.15
+accent rim) and the lip (tint * 0.65 + up to 0.48 accent) reach about 1.4,
+so color * alpha exceeded alpha and GSK's "over" brightened the rim and lip.
+Alpha is already bounded to [0,1] (opacities are sanitized on the CPU side).
+
+diff --git a/private/src/panel/dock-material.cpp b/private/src/panel/dock-material.cpp
+index c781c8e..ee208a9 100644
+--- a/private/src/panel/dock-material.cpp
++++ b/private/src/panel/dock-material.cpp
+@@ -104,6 +104,8 @@ void main()
+ color += u_accent.rgb * (0.18 + 0.3 * gleam) * (v2 * v2 * v2);
+ alpha = u_opacity.y;
+ }
++ // Bright tints plus sheen and rim exceed 1; keep the output premultiplied.
++ color = clamp(color, 0.0, 1.0);
+ frag_color = vec4(color * alpha, alpha);
+ }
+ )";
diff --git a/gui-apps/wayfire-dock/wayfire-dock-0.1.1-r2.ebuild b/gui-apps/wayfire-dock/wayfire-dock-0.1.1-r3.ebuild
index 684ea05e47bf..77ca383e335b 100644
--- a/gui-apps/wayfire-dock/wayfire-dock-0.1.1-r2.ebuild
+++ b/gui-apps/wayfire-dock/wayfire-dock-0.1.1-r3.ebuild
@@ -6,6 +6,7 @@ DESCRIPTION="SrcUX components for Wayfire: dock"
HOMEPAGE="https://www.srcux.com/"
SRC_URI="https://www.srcux.com/distfiles/${P}-r2.tar.gz"
S="${WORKDIR}/${P}-r2"
+PATCHES=( "${FILESDIR}/${P}-material-clamp.patch" )
LICENSE="MIT"
SLOT="0"
KEYWORDS="~amd64"
diff --git a/gui-apps/wayfire-file-index/files/wayfire-file-index-0.1.0-audit-fixes.patch b/gui-apps/wayfire-file-index/files/wayfire-file-index-0.1.0-audit-fixes.patch
new file mode 100644
index 000000000000..f451f7e06eb3
--- /dev/null
+++ b/gui-apps/wayfire-file-index/files/wayfire-file-index-0.1.0-audit-fixes.patch
@@ -0,0 +1,434 @@
+wayfire-file-index: audit fixes (applies to the 0.1.0-r1 tarball)
+
+1. Hung network/FUSE mount no longer stalls the index or restart-loops it.
+ - The scan reads /proc/self/mountinfo (refreshed at most once a second)
+ and never lstat/stat/opendirs a mount point below a root; mount points
+ were already excluded as other devices, now they are not even touched.
+ - browse_page() reports mount points as folders without stat()ing them.
+ - Browse requests run on their own thread instead of the catalogue thread,
+ so search and scanning continue. A folder that does not answer within
+ 2 s fails with "Folder is not responding" (inside the host's 3 s channel
+ deadline, so the host no longer kills the index); at most 4 stuck browse
+ threads may exist, beyond that browse fails immediately.
+2. One unreadable folder or broken symlink no longer stops pruning.
+ - Dangling/looping symlinks are skipped entries, not root failures.
+ - Unreadable folders (opendir/readdir errors), lstat errors, depth/queue
+ limits and the entry limit keep the previous rows of that subtree
+ (path and path/...) and the rest of the root is pruned normally.
+ - Only entries seen in the current pass count toward max_entries, so stale
+ rows awaiting the prune cannot stop new files being indexed.
+ - An unreadable root, and the 128 MiB SQLITE_FULL case, behave as before.
+diff --git a/private/files-model.cpp b/private/files-model.cpp
+index 12f6559..333147f 100644
+--- a/private/files-model.cpp
++++ b/private/files-model.cpp
+@@ -10,6 +10,7 @@
+ #include <filesystem>
+ #include <fcntl.h>
+ #include <memory>
++#include <set>
+ #include <stdexcept>
+ #include <sys/stat.h>
+ #include <sys/file.h>
+@@ -102,6 +103,33 @@ struct QueryCancellation
+ ~QueryCancellation() { sqlite3_progress_handler(db, 0, nullptr, nullptr); }
+ };
+ struct Directory { DIR *value = nullptr; ~Directory() { if (value) closedir(value); } };
++// Mount points from /proc/self/mountinfo. Any stat/opendir of a mount point
++// enters that filesystem, and a dead sshfs/NFS/FUSE server blocks it without
++// a timeout. Consulting the mount table first lets callers skip such entries.
++std::set<std::string> mount_points()
++{
++ std::set<std::string> result;
++ gchar *data = nullptr; gsize size = 0;
++ if (!g_file_get_contents("/proc/self/mountinfo", &data, &size, nullptr)) return result;
++ std::string text(data, size); g_free(data);
++ size_t line = 0;
++ while (line < text.size())
++ {
++ auto end = text.find('\n', line); if (end == std::string::npos) end = text.size();
++ size_t field = line;
++ for (int i = 0; i < 4 && field < end; ++i)
++ { field = text.find(' ', field); field = field == std::string::npos || field >= end ? end : field + 1; }
++ auto stop = text.find(' ', field); if (stop == std::string::npos || stop > end) stop = end;
++ std::string point;
++ for (size_t i = field; i < stop; ++i)
++ if (text[i] == '\\' && i + 3 < stop && text[i + 1] >= '0' && text[i + 1] <= '3')
++ { point += char((text[i + 1] - '0') * 64 + (text[i + 2] - '0') * 8 + (text[i + 3] - '0')); i += 3; }
++ else point += text[i];
++ if (!point.empty()) result.insert(point);
++ line = end + 1;
++ }
++ return result;
++}
+ }
+
+ std::vector<Entry> browse_page(const std::string& directory, size_t offset, bool hidden,
+@@ -112,6 +140,7 @@ std::vector<Entry> browse_page(const std::string& directory, size_t offset, bool
+ throw std::invalid_argument("Enter an absolute directory and valid page offset");
+ Directory dir{opendir(directory.c_str())};
+ if (!dir.value) throw std::runtime_error("Cannot read " + display(directory));
++ const auto mounts = mount_points();
+ std::vector<Entry> entries;
+ size_t passed = 0;
+ for (;;)
+@@ -129,8 +158,11 @@ std::vector<Entry> browse_page(const std::string& directory, size_t offset, bool
+ name.rfind(prefix, 0) != 0) continue;
+ auto path = (std::filesystem::path(directory) / name).native();
+ struct stat meta{};
+- if (stat(path.c_str(), &meta) || (!S_ISDIR(meta.st_mode) && !S_ISREG(meta.st_mode))) continue;
+- bool folder = S_ISDIR(meta.st_mode);
++ // A mount point is a folder; do not stat it (an unreachable network
++ // filesystem would block this listing indefinitely).
++ const bool mounted = mounts.count(path) != 0;
++ if (!mounted && (stat(path.c_str(), &meta) || (!S_ISDIR(meta.st_mode) && !S_ISREG(meta.st_mode)))) continue;
++ bool folder = mounted || S_ISDIR(meta.st_mode);
+ if (folders && !folder) continue;
+ if (passed++ < offset) continue;
+ entries.push_back({path, display(name), folder});
+@@ -188,8 +220,13 @@ struct Catalogue::State
+ Directory current;
+ Pending active;
+ std::vector<std::string> failed;
++ // Subtrees that could not be (fully) read this pass. Their previous rows
++ // are retained instead of blocking pruning of the whole root.
++ std::vector<std::string> kept;
++ std::set<std::string> mounts;
++ gint64 mounts_read = 0;
+ sqlite3_int64 generation = 0;
+- size_t total = 0;
++ size_t total = 0, seen = 0;
+ State(std::shared_ptr<CacheLease> lease, Policy policy) : lease(std::move(lease)),
+ cache(this->lease ? this->lease->path() : ""), policy(std::move(policy))
+ { if (!this->lease) throw std::invalid_argument("Filename cache needs an owner lease"); }
+@@ -247,6 +284,19 @@ struct Catalogue::State
+ Statement q(db, "SELECT count(*) FROM files"); q.row(); return q.integer(0);
+ }
+ void fail(const std::string& root) { failed.push_back(root); ++progress.skipped; }
++ void keep(const std::string& path)
++ {
++ // An unreadable root still blocks pruning of that root as before.
++ if (std::find(policy.roots.begin(), policy.roots.end(), path) != policy.roots.end()) failed.push_back(path);
++ else kept.push_back(path);
++ ++progress.skipped;
++ }
++ bool mounted(const std::string& path)
++ {
++ const auto now = g_get_monotonic_time();
++ if (now - mounts_read >= G_USEC_PER_SEC) { mounts = mount_points(); mounts_read = now; }
++ return mounts.count(path) != 0;
++ }
+ };
+
+ Catalogue::Catalogue(const std::string& cache, Policy policy) :
+@@ -287,7 +337,7 @@ void Catalogue::rescan()
+ auto& s = *state;
+ s.queue.clear();
+ if (s.current.value) { closedir(s.current.value); s.current.value = nullptr; }
+- s.failed.clear();
++ s.failed.clear(); s.kept.clear(); s.seen = 0; s.mounts_read = 0;
+ s.generation = std::max<sqlite3_int64>(s.generation + 1, g_get_real_time());
+ s.progress = {}; s.progress.stale = true; s.total = s.count();
+ for (const auto& root : s.policy.roots)
+@@ -313,14 +363,15 @@ void Catalogue::scan_turn(const std::function<bool()>& cancelled)
+ {
+ if (s.queue.empty()) break;
+ s.active = std::move(s.queue.front()); s.queue.pop_front();
++ if (s.active.path != s.active.root && s.mounted(s.active.path)) { s.keep(s.active.path); continue; }
+ s.current.value = opendir(s.active.path.c_str());
+- if (!s.current.value) { s.fail(s.active.root); continue; }
++ if (!s.current.value) { s.keep(s.active.path); continue; }
+ }
+ errno = 0;
+ auto item = readdir(s.current.value);
+ if (!item)
+ {
+- if (errno) s.fail(s.active.root);
++ if (errno) s.keep(s.active.path);
+ closedir(s.current.value); s.current.value = nullptr; continue;
+ }
+ std::string name = item->d_name;
+@@ -328,27 +379,37 @@ void Catalogue::scan_turn(const std::function<bool()>& cancelled)
+ name == ".git" || name == "node_modules" || name == "target" || name == "__pycache__") continue;
+ auto path = (std::filesystem::path(s.active.path) / name).native();
+ if (!allowed(path, s.policy)) continue;
++ // Mount points are other filesystems (skipped below anyway); never
++ // stat them, so a hung network/FUSE mount cannot block the scan.
++ if (s.mounted(path)) continue;
+ struct stat link{}, meta{};
+- if (lstat(path.c_str(), &link) || stat(path.c_str(), &meta)) { s.fail(s.active.root); continue; }
++ if (lstat(path.c_str(), &link))
++ { if (errno != ENOENT) s.keep(path); continue; }
++ // Dangling or looping symlinks are skipped, not root failures.
++ if (stat(path.c_str(), &meta))
++ { if (S_ISLNK(link.st_mode) && (errno == ENOENT || errno == ELOOP || errno == ENOTDIR)) ++s.progress.skipped; else s.keep(path); continue; }
+ if (meta.st_dev != s.active.device || (!S_ISDIR(meta.st_mode) && !S_ISREG(meta.st_mode))) continue;
+ Statement existing(s.db, "SELECT 1 FROM files WHERE path=?"); existing.bytes(1, path);
+ bool known = existing.row();
+- if (!known && s.total >= max_entries)
++ // Only entries seen in this pass count: stale rows awaiting the
++ // prune must not stop new files from being indexed.
++ if (s.seen >= max_entries)
+ {
+ s.progress.message = "Catalogue limit reached; narrow the search roots";
+- for (const auto& root : s.policy.roots) s.failed.push_back(root);
++ s.keep(s.active.path);
++ for (const auto& pending : s.queue) s.keep(pending.path);
+ s.queue.clear(); closedir(s.current.value); s.current.value = nullptr; break;
+ }
+ Statement put(s.db, "INSERT INTO files(path,name,directory,root,seen) VALUES(?,?,?,?,?) "
+ "ON CONFLICT(path) DO UPDATE SET name=excluded.name,directory=excluded.directory,root=excluded.root,seen=excluded.seen");
+ put.bytes(1, path); put.bytes(2, display(name), true);
+ put.number(3, S_ISDIR(meta.st_mode)); put.bytes(4, s.active.root); put.number(5, s.generation); put.row();
+- ++s.progress.discovered; if (!known) ++s.total;
++ ++s.progress.discovered; ++s.seen; if (!known) ++s.total;
+ if (S_ISDIR(meta.st_mode) && !S_ISLNK(link.st_mode))
+ {
+ if (s.active.depth < max_depth && s.queue.size() < max_pending)
+ s.queue.push_back({path, s.active.root, s.active.device, s.active.depth + 1});
+- else { s.fail(s.active.root); s.progress.message = "Directory depth or queue limit reached"; }
++ else { s.keep(path); s.progress.message = "Directory depth or queue limit reached"; }
+ }
+ }
+ exec(s.db, "COMMIT");
+@@ -367,13 +428,26 @@ void Catalogue::scan_turn(const std::function<bool()>& cancelled)
+ s.progress.pending = s.queue.size() + bool(s.current.value);
+ if (!s.progress.pending && !cancelled())
+ {
+- for (const auto& root : s.policy.roots)
++ exec(s.db, "BEGIN IMMEDIATE");
++ try
+ {
+- if (std::find(s.failed.begin(), s.failed.end(), root) != s.failed.end()) continue;
+- Statement prune(s.db, "DELETE FROM files WHERE root=? AND seen!=?");
+- prune.bytes(1, root); prune.number(2, s.generation); prune.row();
+- }
+- s.total = s.count(); s.progress.finished = true; s.progress.stale = !s.failed.empty();
++ // Retain previous rows of unreadable subtrees (path and path/...),
++ // then prune everything else that was not seen in this pass.
++ for (const auto& path : s.kept)
++ {
++ Statement retain(s.db, "UPDATE files SET seen=? WHERE seen!=? AND (path=? OR (path>? AND path<?))");
++ retain.number(1, s.generation); retain.number(2, s.generation); retain.bytes(3, path);
++ retain.bytes(4, path + "/"); retain.bytes(5, path + "0"); retain.row();
++ }
++ for (const auto& root : s.policy.roots)
++ {
++ if (std::find(s.failed.begin(), s.failed.end(), root) != s.failed.end()) continue;
++ Statement prune(s.db, "DELETE FROM files WHERE root=? AND seen!=?");
++ prune.bytes(1, root); prune.number(2, s.generation); prune.row();
++ }
++ exec(s.db, "COMMIT");
++ } catch (...) { if (!sqlite3_get_autocommit(s.db)) exec(s.db, "ROLLBACK"); throw; }
++ s.total = s.count(); s.progress.finished = true; s.progress.stale = !s.failed.empty() || !s.kept.empty();
+ if (!s.progress.stale && s.progress.message == "Filename cache rebuilt; indexing files")
+ s.progress.message.clear();
+ }
+diff --git a/private/files-worker.cpp b/private/files-worker.cpp
+index a3e7be5..5d93a0c 100644
+--- a/private/files-worker.cpp
++++ b/private/files-worker.cpp
+@@ -33,6 +33,88 @@ struct Delivery
+ std::function<void(Response)> callback;
+ Response response;
+ };
++void post(GMainContext *context, Request& request, Response response)
++{
++ if (*request.cancelled) return;
++ response.generation = request.generation;
++ auto delivery = new Delivery{request.cancelled, std::move(request.callback), std::move(response)};
++ // invoke_full() can run inline on this worker when the context is idle.
++ // Attaching a source always defers GTK callbacks to context dispatch.
++ auto *source = g_idle_source_new();
++ g_source_set_priority(source, G_PRIORITY_DEFAULT);
++ g_source_set_callback(source, [] (gpointer data) -> gboolean
++ {
++ auto& task = *static_cast<Delivery*>(data);
++ if (!*task.cancelled) task.callback(std::move(task.response));
++ return G_SOURCE_REMOVE;
++ }, delivery, [] (gpointer data) { delete static_cast<Delivery*>(data); });
++ g_source_attach(source, context);
++ g_source_unref(source);
++}
++// Browsing runs on its own thread so a folder on an unresponsive network or
++// FUSE mount cannot stall searches and scanning on the catalogue thread. If
++// the folder does not answer within browse_deadline, the request fails (well
++// inside the host's channel deadline) and the stuck thread is abandoned; at
++// most max_stuck_browses such threads may exist at once.
++constexpr guint browse_deadline_ms = 2000;
++constexpr int max_stuck_browses = 4;
++std::atomic<int> live_browses{0};
++struct BrowseCall
++{
++ std::mutex mutex;
++ bool answered = false;
++ GMainContext *context;
++ Request request;
++ BrowseCall(GMainContext *context, Request request) :
++ context(g_main_context_ref(context)), request(std::move(request)) {}
++ ~BrowseCall() { g_main_context_unref(context); }
++ void answer(Response response)
++ {
++ std::lock_guard lock(mutex);
++ if (answered) return;
++ answered = true; post(context, request, std::move(response));
++ }
++ bool settled() { std::lock_guard lock(mutex); return answered || *request.cancelled; }
++};
++void browse_async(GMainContext *context, Request request, const Progress& progress)
++{
++ if (live_browses.load() >= max_stuck_browses)
++ {
++ Response response; response.progress = progress;
++ response.error = "Folders are not responding; try again later";
++ post(context, request, std::move(response)); return;
++ }
++ auto call = std::make_shared<BrowseCall>(context, std::move(request));
++ auto *timer = g_timeout_source_new(browse_deadline_ms);
++ g_source_set_callback(timer, [] (gpointer data) -> gboolean
++ {
++ auto& call = *static_cast<std::shared_ptr<BrowseCall>*>(data);
++ Response response; response.error = "Folder is not responding: " + call->request.input;
++ call->answer(std::move(response)); return G_SOURCE_REMOVE;
++ }, new std::shared_ptr<BrowseCall>(call), [] (gpointer data) { delete static_cast<std::shared_ptr<BrowseCall>*>(data); });
++ g_source_attach(timer, context);
++ g_source_unref(timer);
++ ++live_browses;
++ try
++ {
++ std::thread([call, progress]
++ {
++ Response response; response.progress = progress;
++ try
++ {
++ const auto& r = call->request;
++ response.entries = browse_page(r.input, r.offset, r.hidden, r.folders,
++ [&] { return call->settled(); }, r.prefix);
++ } catch (const std::exception& e) { response.error = e.what(); }
++ call->answer(std::move(response));
++ --live_browses;
++ }).detach();
++ } catch (const std::exception& e)
++ {
++ --live_browses;
++ Response response; response.error = e.what(); call->answer(std::move(response));
++ }
++}
+ }
+ struct Worker::State
+ {
+@@ -51,24 +133,7 @@ struct Worker::State
+ State(std::string cache, Policy policy) : cache(std::move(cache)), policy(std::move(policy)),
+ context(g_main_context_ref_thread_default()) {}
+ ~State() { g_main_context_unref(context); }
+- void deliver(Request& request, Response response)
+- {
+- if (*request.cancelled) return;
+- response.generation = request.generation;
+- auto delivery = new Delivery{request.cancelled, std::move(request.callback), std::move(response)};
+- // invoke_full() can run inline on this worker when the context is idle.
+- // Attaching a source always defers GTK callbacks to context dispatch.
+- auto *source = g_idle_source_new();
+- g_source_set_priority(source, G_PRIORITY_DEFAULT);
+- g_source_set_callback(source, [] (gpointer data) -> gboolean
+- {
+- auto& task = *static_cast<Delivery*>(data);
+- if (!*task.cancelled) task.callback(std::move(task.response));
+- return G_SOURCE_REMOVE;
+- }, delivery, [] (gpointer data) { delete static_cast<Delivery*>(data); });
+- g_source_attach(source, context);
+- g_source_unref(source);
+- }
++ void deliver(Request& request, Response response) { post(context, request, std::move(response)); }
+ void run()
+ {
+ // The lease outlives every database incarnation and is released only
+@@ -99,16 +164,18 @@ struct Worker::State
+ request = std::move(pending.front()); pending.pop_front();
+ }
+ }
++ if (request && !*request->cancelled && request->kind == browse_request)
++ {
++ browse_async(context, std::move(*request), catalogue ? catalogue->progress() : Progress{});
++ request.reset();
++ }
+ if (request && !*request->cancelled)
+ {
+ Response response;
+ try
+ {
+ auto cancel = [&] { return request->cancelled->load(); };
+- if (request->kind == browse_request)
+- response.entries = browse_page(request->input, request->offset,
+- request->hidden, request->folders, cancel, request->prefix);
+- else if (request->kind == rebuild_request)
++ if (request->kind == rebuild_request)
+ {
+ if (catalogue) catalogue->rebuild();
+ else catalogue = create(policy);
+diff --git a/tests/stage4-files-worker.cpp b/tests/stage4-files-worker.cpp
+index f808dfa..0471144 100644
+--- a/tests/stage4-files-worker.cpp
++++ b/tests/stage4-files-worker.cpp
+@@ -1,5 +1,6 @@
+ #include "files-worker.hpp"
+ #include <glib.h>
++#include <algorithm>
+ #include <cassert>
+ #include <atomic>
+ #include <chrono>
+@@ -177,5 +178,54 @@ int main()
+ assert(browsed->entries.size() == 1 && browsed->error.empty());
+ assert(!unavailable->error.empty() && unavailable->entries.empty());
+ }
++ // A dangling symlink or one unreadable folder must not stop pruning of
++ // deleted files elsewhere; the unreadable subtree keeps its old rows.
++ {
++ auto tree = base / "prune"; fs::create_directories(tree / "locked");
++ std::ofstream(tree / "gone-alpha.txt").put('x');
++ std::ofstream(tree / "locked" / "inside-alpha.txt").put('x');
++ Worker worker((base / "prune-cache" / "files.sqlite3").native(), Policy{{tree.native()}, {}, false});
++ auto client = worker.open_client();
++ auto settle = [&]
++ {
++ for (int i = 0; i < 100; ++i)
++ {
++ std::optional<Response> status;
++ worker.status(client, [&] (Response result) { status = std::move(result); });
++ until([&] { return status.has_value(); });
++ if (status->progress.finished) return *status;
++ assert(i != 99);
++ }
++ return Response{};
++ };
++ auto names = [&] (const std::string& query)
++ {
++ std::optional<Response> found;
++ worker.search(client, query, false, 0, [&] (Response result) { found = std::move(result); });
++ until([&] { return found.has_value(); });
++ std::vector<std::string> result;
++ for (const auto& e : found->entries) result.push_back(e.display);
++ std::sort(result.begin(), result.end()); return result;
++ };
++ settle();
++ assert((names("alpha") == std::vector<std::string>{"gone-alpha.txt", "inside-alpha.txt"}));
++ fs::create_symlink(tree / "missing", tree / "dangling-alpha");
++ fs::create_symlink(tree / "loop-alpha", tree / "loop-alpha");
++ fs::remove(tree / "gone-alpha.txt");
++ assert(chmod((tree / "locked").c_str(), 0) == 0);
++ std::optional<Response> rescanned;
++ worker.rescan(client, [&] (Response result) { rescanned = std::move(result); });
++ until([&] { return rescanned.has_value(); });
++ settle();
++ assert((names("alpha") == std::vector<std::string>{"inside-alpha.txt"}));
++ assert(chmod((tree / "locked").c_str(), 0700) == 0);
++ fs::remove(tree / "locked" / "inside-alpha.txt");
++ rescanned.reset();
++ worker.rescan(client, [&] (Response result) { rescanned = std::move(result); });
++ until([&] { return rescanned.has_value(); });
++ auto done = settle();
++ assert(names("alpha").empty() && !done.progress.stale);
++ worker.close_client(client);
++ }
+ fs::remove_all(base);
+ }
diff --git a/gui-apps/wayfire-file-index/wayfire-file-index-0.1.0-r1.ebuild b/gui-apps/wayfire-file-index/wayfire-file-index-0.1.0-r2.ebuild
index fc940fa3e5ca..919d0970fd98 100644
--- a/gui-apps/wayfire-file-index/wayfire-file-index-0.1.0-r1.ebuild
+++ b/gui-apps/wayfire-file-index/wayfire-file-index-0.1.0-r2.ebuild
@@ -2,7 +2,7 @@ EAPI=8
inherit meson
DESCRIPTION="SrcUX independently supervised file catalogue for Wayfire"
HOMEPAGE="https://www.srcux.com/"
-SRC_URI="https://www.srcux.com/distfiles/${PF}.tar.gz"
+SRC_URI="https://www.srcux.com/distfiles/${P}-r1.tar.gz"
LICENSE="MIT"
SLOT="0"
KEYWORDS="~amd64"
diff --git a/gui-apps/wayfire-files/files/wayfire-files-0.1.2-audit-fixes.patch b/gui-apps/wayfire-files/files/wayfire-files-0.1.2-audit-fixes.patch
new file mode 100644
index 000000000000..370a7c535f82
--- /dev/null
+++ b/gui-apps/wayfire-files/files/wayfire-files-0.1.2-audit-fixes.patch
@@ -0,0 +1,312 @@
+wayfire-files-0.1.2: audit fixes (apply after the r6 PATCHES).
+
+1. Jobs panel (controls.patch regression): with "Show File Operations" off,
+ conflict prompts, failed/uncertain jobs and submission errors were only
+ shown in the collapsed panel, so a conflicting job waited forever. The
+ panel now emits "attention" for these and the view reveals it
+ regardless of the auto-open preference (the preference is not changed).
+2. Folder loading (viewport.patch regression): the selection-restore idle no
+ longer rescans every row per 256-item batch when there is no selection to
+ restore; the scroll reset runs once when loading ends. reconcile() removes
+ vanished rows in contiguous runs, or with one splice when they are
+ scattered, instead of one O(n) removal per row.
+3. Job journal: per-file events no longer rewrite the whole record durably
+ under the job mutex. A new top-level temporary path and the start of
+ source removal are saved at once; other progress is coalesced to one save
+ per second; the final result is saved as before. Saves (including conflict
+ waits) run on a copied snapshot outside the mutex.
+4. Directory Tree: the TreeListModel create function (also called by GTK to
+ test expandability of every visible row) only creates an empty model.
+ Enumeration and the monitor thread start when a row is expanded and the
+ monitor stops when it is collapsed.
+
+diff --git a/src/files-jobs-ui.cpp b/src/files-jobs-ui.cpp
+index 9fb293d..caa974d 100644
+--- a/src/files-jobs-ui.cpp
++++ b/src/files-jobs-ui.cpp
+@@ -92,14 +92,14 @@ FilesJobsPanel::~FilesJobsPanel(){client.reset();rows.clear();}
+ void FilesJobsPanel::connect()
+ {
+ if(client)return;
+- client=std::make_unique<jobs::Client>([this](jobs::Snapshot record){changed(std::move(record));},[this](std::string message){loading=false;older.set_sensitive(true);update_clear();notice.set_text(message);});
++ client=std::make_unique<jobs::Client>([this](jobs::Snapshot record){changed(std::move(record));},[this](std::string message){loading=false;older.set_sensitive(true);update_clear();notice.set_text(message);attention.emit();});
+ client->on_removed([this](std::vector<std::string> ids)
+ {for(const auto& id:ids)if(auto found=rows.find(id);found!=rows.end()){items.remove(*found->second);rows.erase(found);}update_clear();});
+ }
+ std::string FilesJobsPanel::submit(jobs::Request request)
+ {
+ try{connect();notice.set_text("Submitting file operation…");return client->submit(std::move(request));}
+- catch(const std::exception& error){notice.set_text(error.what());return {};}
++ catch(const std::exception& error){notice.set_text(error.what());attention.emit();return {};}
+ }
+ void FilesJobsPanel::refresh()
+ {
+@@ -147,6 +147,7 @@ void FilesJobsPanel::changed(jobs::Snapshot record)
+ auto found=rows.find(record.request.id);
+ if(found==rows.end() && !cursor.empty() && !active(record.state))return;
+ const bool was_active=found==rows.end() || active(found->second->record.state);
++ const bool was_waiting=found!=rows.end() && found->second->record.waiting;
+ if(found==rows.end())
+ {
+ auto row=std::make_unique<Row>();auto id=record.request.id;
+@@ -166,8 +167,11 @@ void FilesJobsPanel::changed(jobs::Snapshot record)
+ items.prepend(*row);found=rows.emplace(id,std::move(row)).first;
+ }
+ const bool finished=!active(record.state);
++ const bool failed=record.state=="failed" || record.state=="uncertain" || record.result.failed;
++ const bool prompt=record.waiting && !was_waiting;
+ found->second->update(std::move(record));
+ trim();update_clear();
+ notice.set_text("File operations");notice.set_tooltip_text("File operations continue when this window closes.");
+ if(was_active && finished && !applying_page)settled.emit();
++ if(prompt || (was_active && finished && failed && !applying_page))attention.emit();
+ }
+diff --git a/src/files-jobs-ui.hpp b/src/files-jobs-ui.hpp
+index 75a7138..55b122c 100644
+--- a/src/files-jobs-ui.hpp
++++ b/src/files-jobs-ui.hpp
+@@ -17,7 +17,7 @@ class FilesJobsPanel : public Gtk::Box
+ Gtk::Button older{"Older jobs"};
+ Gtk::ScrolledWindow scroll;
+ Gtk::Box items{Gtk::Orientation::VERTICAL,8};
+- sigc::signal<void()> settled;
++ sigc::signal<void()> settled,attention;
+ std::string cursor,next_cursor;
+ uint64_t page_generation=0;
+ bool loading=false,applying_page=false;
+@@ -32,4 +32,7 @@ class FilesJobsPanel : public Gtk::Box
+ std::string submit(shell::files::jobs::Request);
+ void refresh();
+ sigc::signal<void()>& signal_settled(){return settled;}
++ // Emitted when a job needs a decision or failed, or a request could not
++ // be submitted; the view must show the panel even if it is collapsed.
++ sigc::signal<void()>& signal_attention(){return attention;}
+ };
+diff --git a/src/files-jobs-worker.cpp b/src/files-jobs-worker.cpp
+index 7efb567..68bae6f 100644
+--- a/src/files-jobs-worker.cpp
++++ b/src/files-jobs-worker.cpp
+@@ -36,6 +36,7 @@ struct Job
+ jobs::Snapshot snapshot;
+ std::optional<jobs::Decision> decision;
+ size_t temporary_bytes=0;
++ gint64 last_save=0;
+ GCancellable* cancelled=g_cancellable_new();
+ bool dirty=true,active=false,details_on_disk=false;
+ ~Job(){g_object_unref(cancelled);}
+@@ -150,12 +151,19 @@ struct Server : std::enable_shared_from_this<Server>
+ {
+ if(event.source.size()>16384 || event.destination.size()>16384 || event.temporary.size()>16384 || event.message.size()>16384)
+ throw std::runtime_error("Operation path exceeds the checkpoint limit. Inspect source and destination before retrying.");
+- std::lock_guard lock(job->mutex);job->snapshot.event=event;job->dirty=true;
++ std::unique_lock lock(job->mutex);job->snapshot.event=event;job->dirty=true;
+ auto& paths=job->snapshot.temporaries;
++ // Checkpoints are durable writes of the whole record. Write at
++ // once only when new recovery evidence appears: a temporary not
++ // inside an already recorded one, or the start of source removal.
++ // Other per-file progress is coalesced to one write per second;
++ // the final result is always saved when the job ends.
++ bool urgent=event.phase=="removing-source" || event.phase=="restoring-source";
+ if((event.phase=="prepare" || event.phase=="prepare-link") && !event.temporary.empty())
+ {
+ if(paths.size()>=4096 || job->temporary_bytes+event.temporary.size()>1024*1024)
+ throw std::runtime_error("Temporary-path history is full. Inspect this job before retrying in smaller groups.");
++ urgent|=std::none_of(paths.begin(),paths.end(),[&](const std::string& path){return event.temporary.rfind(path+"/",0)==0;});
+ paths.push_back(event.temporary);job->temporary_bytes+=event.temporary.size();
+ }
+ if(event.phase=="copied" || event.phase=="linked")
+@@ -163,17 +171,24 @@ struct Server : std::enable_shared_from_this<Server>
+ for(auto it=paths.begin();it!=paths.end();)
+ {if(*it==event.temporary){job->temporary_bytes-=it->size();it=paths.erase(it);}else ++it;}
+ }
+- if(event.phase!="copying")self->save(job->snapshot);
++ const auto now=g_get_monotonic_time();
++ if(event.phase=="copying" || (!urgent && now-job->last_save<G_USEC_PER_SEC))return;
++ job->last_save=now;auto record=job->snapshot;lock.unlock();
++ // Encoding and fsync run outside the mutex so the main loop's
++ // status timer and D-Bus methods are not blocked by disk I/O.
++ self->save(record);
+ };
+ auto ask=[self,job](const jobs::Conflict& conflict)
+ {
+ if(conflict.source.size()>16384 || conflict.destination.size()>16384)
+ throw std::runtime_error("Conflict path exceeds the checkpoint limit. Source and destination were retained.");
+ std::unique_lock lock(job->mutex);job->snapshot.state="waiting";job->snapshot.waiting=true;job->snapshot.conflict=conflict;
+- job->decision.reset();job->dirty=true;self->save(job->snapshot);
++ job->decision.reset();job->dirty=true;
++ {auto record=job->snapshot;lock.unlock();self->save(record);lock.lock();}
+ job->condition.wait(lock,[&]{return job->decision.has_value() || g_cancellable_is_cancelled(job->cancelled);});
+ auto choice=job->decision.value_or(jobs::Decision::cancel);job->snapshot.waiting=false;job->snapshot.state="running";job->dirty=true;
+- self->save(job->snapshot);return choice;
++ job->last_save=g_get_monotonic_time();auto record=job->snapshot;lock.unlock();
++ self->save(record);return choice;
+ };
+ auto result=engine.run(job->snapshot.request,job->cancelled,notify,ask);
+ std::lock_guard lock(job->mutex);job->snapshot.result=std::move(result);job->snapshot.state=job->snapshot.result.state;
+diff --git a/src/files-places.cpp b/src/files-places.cpp
+index dc34d55..cfc561b 100644
+--- a/src/files-places.cpp
++++ b/src/files-places.cpp
+@@ -41,6 +41,7 @@ class Folder:public Glib::Object
+ public:
+ std::string uri,label;
+ unsigned depth=0;
++ bool loaded=false;
+ Glib::RefPtr<Gio::ListStore<Folder>> children;
+ dir::GioSource source;
+ dir::Monitor monitor;
+@@ -50,6 +51,10 @@ class Folder:public Glib::Object
+ static Glib::RefPtr<Folder> create(std::string uri,std::string label,unsigned depth=0)
+ {auto item=Glib::make_refptr_for_instance(new Folder);item->uri=std::move(uri);item->label=std::move(label);item->depth=depth;return item;}
+ };
++struct TreeRow:Gtk::TreeExpander
++{
++ sigc::connection expanded;
++};
+ std::string display_name(const std::string& uri)
+ {
+ auto file=Gio::File::create_for_uri(uri);auto name=file->get_basename();
+@@ -134,22 +139,17 @@ struct FilesPlaces::Impl
+ },false);list.add_controller(keys);
+ tree_model=Gtk::TreeListModel::create(roots,[this](const Glib::RefPtr<Glib::ObjectBase>& value)->Glib::RefPtr<Gio::ListModel>
+ {
++ // GTK also calls this to decide whether a visible row is expandable,
++ // so only create the (empty) model here. Enumeration and the
++ // monitor thread start when the row is actually expanded.
+ auto node=std::dynamic_pointer_cast<Folder>(value);if(!node || node->uri.empty() || node->depth>=64)return {};
+- if(!node->children)
+- {
+- node->children=Gio::ListStore<Folder>::create();load_branch(node.get());
+- node->monitor.watch(node->uri,[this,weak=std::weak_ptr<int>(life),node_weak=std::weak_ptr<int>(node->life),node=node.get()](std::string error)
+- {
+- if(!weak.lock() || !node_weak.lock() || !error.empty())return;
+- node->refresh.disconnect();node->refresh=Glib::signal_timeout().connect([this,node]{load_branch(node);return false;},250);
+- });
+- }
++ if(!node->children)node->children=Gio::ListStore<Folder>::create();
+ return node->children;
+ });
+ tree.add_css_class("files-tree");tree.set_model(Gtk::SingleSelection::create(tree_model));tree.set_factory(tree_factory);tree.set_single_click_activate(true);
+ tree_factory->signal_setup().connect([this](const Glib::RefPtr<Gtk::ListItem>& item)
+ {
+- auto* expander=Gtk::make_managed<Gtk::TreeExpander>();auto* label=Gtk::make_managed<Gtk::Label>();label->set_xalign(0);label->set_ellipsize(Pango::EllipsizeMode::END);label->set_max_width_chars(16);label->set_width_chars(1);
++ auto* expander=Gtk::make_managed<TreeRow>();auto* label=Gtk::make_managed<Gtk::Label>();label->set_xalign(0);label->set_ellipsize(Pango::EllipsizeMode::END);label->set_max_width_chars(16);label->set_width_chars(1);
+ expander->set_child(*label);item->set_child(*expander);
+ if(setup_drop)setup_drop(*expander,[expander]
+ {
+@@ -157,13 +157,18 @@ struct FilesPlaces::Impl
+ auto folder=std::dynamic_pointer_cast<Folder>(row->get_item());return folder?folder->uri:std::string{};
+ });
+ });
+- tree_factory->signal_bind().connect([](const Glib::RefPtr<Gtk::ListItem>& item)
++ tree_factory->signal_bind().connect([this](const Glib::RefPtr<Gtk::ListItem>& item)
+ {
+ auto row=std::dynamic_pointer_cast<Gtk::TreeListRow>(item->get_item());auto folder=std::dynamic_pointer_cast<Folder>(row->get_item());
+- auto* expander=dynamic_cast<Gtk::TreeExpander*>(item->get_child());expander->set_list_row(row);
++ auto* expander=dynamic_cast<TreeRow*>(item->get_child());expander->set_list_row(row);
+ dynamic_cast<Gtk::Label*>(expander->get_child())->set_text(folder->label);expander->set_tooltip_text(folder->uri.empty()?folder->label:dir::editable(folder->uri));
++ auto* raw=row.get();expander->expanded.disconnect();
++ expander->expanded=row->property_expanded().signal_changed().connect([this,raw]
++ {if(auto node=std::dynamic_pointer_cast<Folder>(raw->get_item()))expanded(node.get(),raw->get_expanded());});
++ if(row->get_expanded())expanded(folder.get(),true);
+ });
+- tree_factory->signal_unbind().connect([](const Glib::RefPtr<Gtk::ListItem>& item){dynamic_cast<Gtk::TreeExpander*>(item->get_child())->unset_list_row();});
++ tree_factory->signal_unbind().connect([](const Glib::RefPtr<Gtk::ListItem>& item)
++ {auto* expander=dynamic_cast<TreeRow*>(item->get_child());expander->expanded.disconnect();expander->unset_list_row();});
+ tree.signal_activate().connect([this](unsigned index)
+ {auto row=tree_model->get_row(index);if(!row)return;auto folder=std::dynamic_pointer_cast<Folder>(row->get_item());if(!folder->uri.empty())outer.navigate.emit(folder->uri);});
+ stack.set_hhomogeneous(false);stack.set_vhomogeneous(false);stack.add(list,"places");stack.add(tree,"tree");
+@@ -234,6 +239,19 @@ struct FilesPlaces::Impl
+ }
+ void reset_tree()
+ {roots->remove_all();roots->append(Folder::create(dir::resolve(g_get_home_dir()),"Home"));roots->append(Folder::create("file:///","File System"));}
++ void expanded(Folder* node,bool open)
++ {
++ // Only expanded folders are enumerated and watched; collapsing stops
++ // the watch so the number of monitor threads stays bounded.
++ if(!open){node->refresh.disconnect();node->monitor.cancel();node->source.cancel();node->loaded=false;return;}
++ if(node->loaded || !node->children)return;
++ node->loaded=true;load_branch(node);
++ node->monitor.watch(node->uri,[this,weak=std::weak_ptr<int>(life),node_weak=std::weak_ptr<int>(node->life),node](std::string error)
++ {
++ if(!weak.lock() || !node_weak.lock() || !error.empty())return;
++ node->refresh.disconnect();node->refresh=Glib::signal_timeout().connect([this,node]{load_branch(node);return false;},250);
++ });
++ }
+ void load_branch(Folder* node)
+ {
+ auto values=std::make_shared<std::vector<dir::Item>>();auto weak=std::weak_ptr<int>(life),node_weak=std::weak_ptr<int>(node->life);
+diff --git a/src/files-view-presentation.cpp b/src/files-view-presentation.cpp
+index 882a882..b06b3ee 100644
+--- a/src/files-view-presentation.cpp
++++ b/src/files-view-presentation.cpp
+@@ -314,7 +314,9 @@ void FilesView::set_zoom(unsigned value,bool save)
+
+ void FilesView::restore_items_selection()
+ {
+- if(restore_selection.empty() && !reset_scroll)return;
++ // Without a selection to restore, the scroll reset only applies once the
++ // directory has finished loading; per-batch idles would rescan every row.
++ if(restore_selection.empty() && (!reset_scroll || browsing))return;
+ restore_idle.disconnect();
+ // Restore after the model/selection notification transaction, ahead of the
+ // next input event. The URI set also survives a concurrent same-directory
+@@ -323,7 +325,7 @@ void FilesView::restore_items_selection()
+ {
+ if(sorted->get_pending())return false;
+ unsigned reveal=GTK_INVALID_LIST_POSITION;
+- for(unsigned i=0;i<sorted->get_n_items();++i)
++ if(!restore_selection.empty())for(unsigned i=0;i<sorted->get_n_items();++i)
+ if(restore_selection.count(uri_at(i)))
+ {selection->select_item(i,false);if(reveal==GTK_INVALID_LIST_POSITION)reveal=i;}
+ if(reset_scroll && !browsing)
+diff --git a/src/files-view.cpp b/src/files-view.cpp
+index ab3ed5d..2a29b6d 100644
+--- a/src/files-view.cpp
++++ b/src/files-view.cpp
+@@ -229,6 +229,9 @@ FilesView::FilesView(Gtk::Window& owner, bool show_hidden,
+ });
+ jobs_toggle.set_active(prefs::get("file-operations","auto")=="true");
+ jobs_panel.signal_settled().connect([this]{refresh_soon();});
++ // Conflicts block the worker until answered and failures are reported only
++ // in the panel, so show it regardless of the automatic-opening preference.
++ jobs_panel.signal_attention().connect([this]{jobs_toggle.set_active(true);});
+ clipboard_changed=get_clipboard()->signal_changed().connect([this]{++clipboard_generation;});
+ hidden.set_active(show_hidden);scroll.set_min_content_height(80);scroll.set_vexpand(true);
+ auto wheel=Gtk::EventControllerScroll::create();
+@@ -486,14 +489,31 @@ void FilesView::reconcile(std::vector<dir::Item> values)
+ for(const auto& uri:selected_uris())restore_selection.insert(uri);
+ std::unordered_map<std::string,dir::Item> fresh;
+ for(auto& item:values){auto uri=item.uri;fresh.emplace(std::move(uri),std::move(item));}
+- for(unsigned i=rows->get_n_items();i>0;--i)
++ const unsigned count=rows->get_n_items();
++ std::vector<Glib::ustring> kept;kept.reserve(count);std::vector<unsigned> updated;unsigned runs=0;bool removing=false;
++ for(unsigned i=0;i<count;++i)
+ {
+- const std::string uri=rows->get_string(i-1);auto found=fresh.find(uri);
+- if(found==fresh.end()){rows->remove(i-1);entries.erase(uri);continue;}
++ const std::string uri=rows->get_string(i);auto found=fresh.find(uri);
++ if(found==fresh.end()){entries.erase(uri);runs+=!removing;removing=true;continue;}
++ removing=false;
+ const auto& before=entries.at(uri);const auto& after=found->second;
+ const bool changed=before.display!=after.display || before.size!=after.size || before.modified!=after.modified || before.modified_usec!=after.modified_usec || before.folder!=after.folder || before.content_type!=after.content_type;
+ entries[uri]=std::move(found->second);fresh.erase(found);
+- if(changed)rows->splice(i-1,1,{uri});
++ if(changed)updated.push_back(kept.size());
++ kept.emplace_back(uri);
++ }
++ // Each removal shifts the remaining rows, so many scattered removals are
++ // replaced by one model splice (selection is restored by URI afterwards).
++ if(runs>64)rows->splice(0,count,kept);
++ else
++ {
++ for(unsigned i=count;i>0;)
++ {
++ if(entries.count(rows->get_string(i-1))){--i;continue;}
++ const unsigned end=i;while(i>0 && !entries.count(rows->get_string(i-1)))--i;
++ rows->splice(i,end-i,{});
++ }
++ for(const auto position:updated)rows->splice(position,1,{kept[position]});
+ }
+ std::vector<dir::Item> additions;for(auto& [uri,item]:fresh)additions.push_back(std::move(item));append(std::move(additions));
+ }
diff --git a/gui-apps/wayfire-files/wayfire-files-0.1.2-r6.ebuild b/gui-apps/wayfire-files/wayfire-files-0.1.2-r7.ebuild
index 9ec7fc4ad582..9b104638c61a 100644
--- a/gui-apps/wayfire-files/wayfire-files-0.1.2-r6.ebuild
+++ b/gui-apps/wayfire-files/wayfire-files-0.1.2-r7.ebuild
@@ -27,7 +27,7 @@ RDEPEND="${DEPEND}
>=app-arch/file-roller-44.7-r1
app-arch/7zip
"
-PATCHES=( "${FILESDIR}/${P}-files-layout.patch" "${FILESDIR}/${P}-viewport.patch" "${FILESDIR}/${P}-controls.patch" "${FILESDIR}/${P}-search-first.patch" "${FILESDIR}/${P}-wheel-zoom.patch" "${FILESDIR}/${P}-ctrl-wheel-zoom.patch" )
+PATCHES=( "${FILESDIR}/${P}-files-layout.patch" "${FILESDIR}/${P}-viewport.patch" "${FILESDIR}/${P}-controls.patch" "${FILESDIR}/${P}-search-first.patch" "${FILESDIR}/${P}-wheel-zoom.patch" "${FILESDIR}/${P}-ctrl-wheel-zoom.patch" "${FILESDIR}/${P}-audit-fixes.patch" )
BDEPEND="virtual/pkgconfig"
src_configure() {
local emesonargs=( -Dtests=false )
diff --git a/gui-apps/wayfire-panel/files/wayfire-panel-0.1.0-audit-fixes.patch b/gui-apps/wayfire-panel/files/wayfire-panel-0.1.0-audit-fixes.patch
new file mode 100644
index 000000000000..dcee645da89e
--- /dev/null
+++ b/gui-apps/wayfire-panel/files/wayfire-panel-0.1.0-audit-fixes.patch
@@ -0,0 +1,1604 @@
+wayfire-panel 0.1.0: code-audit fixes (applies after shared-ui-popups.patch)
+
+- Icons: names chosen by other programs (Wayland app_id in the window list,
+ tray IconName) go through IconProvider::image_set_untrusted_icon(), which
+ keeps only plain icon-name tokens ([A-Za-z0-9][A-Za-z0-9._+@:-]*), so no
+ absolute/"~/" path or serialized ". GFileIcon ..." form reaches
+ wfd_ui_icon_resolve(); themed names, config mappings and .desktop lookups
+ still work.
+- Launchers: a .desktop entry without Icon= falls back to
+ application-x-executable instead of dereferencing a NULL GIcon.
+- Autohide: hotspot callback structs are allocated once and only their
+ members are updated; adjacent-edge hotspots no longer keep a pointer to a
+ freed struct after a position/size change (monitor hotplug use-after-free).
+ reinit_ext_hotspots()/setup_hotspot() also tolerate a missing wf output.
+- Window list: re-parenting removes the child from the old parent's list and
+ skips duplicates; removal checks the find result; on_clicked() looks
+ children up with find() and skips missing/null entries.
+- Live previews: the GBM mapping is released only after scale_simple() has
+ copied the pixels. Preview handles are never paired on view ID 0 (default
+ app_id_mode), and a closing ext handle is cleared from every button.
+- JSON: the wf::json wrapper no longer aborts on type mismatch (asserts are
+ compiled in on Gentoo): indexing the wrong shape yields a detached null,
+ size() of a non-array is 0, and wrong-type extraction throws
+ std::domain_error. Wayfire IPC reply/event dispatch catches handler
+ exceptions. Call sites validate shapes (window title, workspace switcher,
+ cursor position, services page, network/audio/brightness/notifications,
+ logout, app-desktop) and synthesized error replies always carry "error".
+- command-output: a periodic tick no longer replaces a run that is still in
+ progress (wayfire-ui >= 0.1.6-r1 additionally kills the whole process
+ group on cancel/timeout).
+- Tests: json-shape and ipc-shape regression tests (built with -Dtests=true).
+
+diff --git a/meson.build b/meson.build
+index 89acaae..c035937 100644
+--- a/meson.build
++++ b/meson.build
+@@ -52,7 +52,7 @@ if get_option('tests')
+ 'private/src/panel/widgets/system-monitor-model.cpp'],
+ include_directories: include_directories('private/src/panel/widgets'),
+ dependencies: gtkmm, cpp_args: ['-UNDEBUG'])
+- foreach name : ['adapter', 'preference-ui', 'preference-ui-channel']
++ foreach name : ['adapter', 'preference-ui', 'preference-ui-channel', 'json-shape', 'ipc-shape']
+ check = executable('panel-' + name + '-test', 'tests/' + name + '.cpp',
+ dependencies: support_dep, cpp_args: ['-UNDEBUG'])
+ test('panel-' + name, check, timeout: 30)
+diff --git a/private/json.cpp b/private/json.cpp
+index f02d40e..0829d6b 100644
+--- a/private/json.cpp
++++ b/private/json.cpp
+@@ -1,10 +1,21 @@
+ #include <wayfire/nonstd/json.hpp>
+ #include <yyjson.h>
+ #include <limits>
+-#include <cassert>
++#include <stdexcept>
+
+ namespace wf
+ {
++// Values come from IPC peers. A type mismatch must be recoverable instead of
++// aborting the process: indexing the wrong shape yields a detached null and
++// extracting the wrong type throws std::domain_error.
++static void require(bool condition, const char *message)
++{
++ if (!condition)
++ {
++ throw std::domain_error(message);
++ }
++}
++
+ bool json_reference_t::is_array() const
+ {
+ return yyjson_mut_is_arr(v);
+@@ -12,69 +23,71 @@ bool json_reference_t::is_array() const
+
+ json_reference_t json_reference_t::operator [](const size_t& idx) const
+ {
+- assert(is_array());
+- assert(size() > idx);
++ if (!is_array() || (idx >= size()))
++ {
++ return json_reference_t{doc, yyjson_mut_null(doc)};
++ }
++
+ return json_reference_t{doc, yyjson_mut_arr_get(v, idx)};
+ }
+
+ void json_reference_t::append(const json_reference_t& elem)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_append(v, yyjson_mut_val_mut_copy(doc, elem.v));
+ }
+
+ void json_reference_t::append(int value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_int(doc, v, value);
+ }
+
+ void json_reference_t::append(unsigned int value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_uint(doc, v, value);
+ }
+
+ void json_reference_t::append(int64_t value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_sint(doc, v, value);
+ }
+
+ void json_reference_t::append(uint64_t value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_uint(doc, v, value);
+ }
+
+ void json_reference_t::append(double value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_real(doc, v, value);
+ }
+
+ void json_reference_t::append(bool value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_bool(doc, v, value);
+ }
+
+ void json_reference_t::append(const std::string_view& value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_strncpy(doc, v, value.data(), value.size());
+ }
+
+ void json_reference_t::append(const char *value)
+ {
+- assert(is_array());
++ require(is_array(), "JSON value is not an array");
+ yyjson_mut_arr_add_strcpy(doc, v, value);
+ }
+
+ size_t json_reference_t::size() const
+ {
+- assert(is_array());
+- return yyjson_mut_arr_size(v);
++ return is_array() ? yyjson_mut_arr_size(v) : 0;
+ }
+
+ bool json_reference_t::has_member(const std::string_view& key) const
+@@ -99,7 +112,11 @@ json_reference_t json_reference_t::operator [](const char *key) const
+
+ json_reference_t json_reference_t::operator [](const std::string_view& key) const
+ {
+- assert((is_object() || is_null()) && "Trying to access into JSON value that is not an object!");
++ if (!is_object() && !is_null())
++ {
++ return json_reference_t{doc, yyjson_mut_null(doc)};
++ }
++
+ if (is_null())
+ {
+ yyjson_mut_set_obj(v);
+@@ -202,7 +219,7 @@ static void copy_yyjson(yyjson_mut_doc *dst_doc, yyjson_mut_val *dst, yyjson_mut
+ return;
+ }
+
+- assert(false && "Unsupported JSON type?");
++ throw std::domain_error("Unsupported JSON type");
+ }
+
+ json_reference_t& json_reference_t::operator =(const json_reference_t& other)
+@@ -289,7 +306,7 @@ bool json_reference_t::is_int() const
+
+ json_reference_t::operator int() const
+ {
+- assert(is_int());
++ require(is_int(), "JSON value is not an integer");
+ return yyjson_mut_get_int(v);
+ }
+
+@@ -310,7 +327,7 @@ bool json_reference_t::is_int64() const
+
+ json_reference_t::operator int64_t() const
+ {
+- assert(is_int64());
++ require(is_int64(), "JSON value is not a 64-bit integer");
+ if (yyjson_mut_is_uint(v))
+ {
+ return yyjson_mut_get_uint(v);
+@@ -333,7 +350,7 @@ bool json_reference_t::is_uint() const
+
+ json_reference_t::operator unsigned int() const
+ {
+- assert(is_uint());
++ require(is_uint(), "JSON value is not an unsigned integer");
+ return yyjson_mut_get_uint(v);
+ }
+
+@@ -349,7 +366,7 @@ bool json_reference_t::is_uint64() const
+
+ json_reference_t::operator uint64_t() const
+ {
+- assert(is_uint64());
++ require(is_uint64(), "JSON value is not an unsigned 64-bit integer");
+ return yyjson_mut_get_uint(v);
+ }
+
+@@ -365,7 +382,7 @@ bool json_reference_t::is_bool() const
+
+ json_reference_t::operator bool() const
+ {
+- assert(is_bool());
++ require(is_bool(), "JSON value is not a boolean");
+ return yyjson_mut_get_bool(v);
+ }
+
+@@ -381,7 +398,7 @@ bool json_reference_t::is_double() const
+
+ json_reference_t::operator double() const
+ {
+- assert(is_double());
++ require(is_double(), "JSON value is not a number");
+ return yyjson_mut_get_num(v);
+ }
+
+@@ -396,7 +413,7 @@ bool json_reference_t::is_string() const
+ }
+
+ json_reference_t::operator std::string() const {
+- assert(is_string());
++ require(is_string(), "JSON value is not a string");
+ return std::string(yyjson_mut_get_str(v));
+ }
+
+diff --git a/private/src/core/app-desktop.cpp b/private/src/core/app-desktop.cpp
+index d3a9e6a..709d179 100644
+--- a/private/src/core/app-desktop.cpp
++++ b/private/src/core/app-desktop.cpp
+@@ -77,7 +77,7 @@ std::optional<Rect> window_area(const wf::json_reference_t& v, const Snapshot& s
+ for (size_t i = 0; i < s.outputs.size(); ++i)
+ {
+ auto o = s.outputs[i];
+- if (o["id"].as_int() != v["output-id"].as_int() ||
++ if (!o["id"].is_int() || !o["wset-index"].is_int() || o["id"].as_int() != v["output-id"].as_int() ||
+ o["wset-index"].as_int() != v["wset-index"].as_int()) continue;
+ try
+ {
+diff --git a/private/src/panel/audio-client.cpp b/private/src/panel/audio-client.cpp
+index 12d982d..3877403 100644
+--- a/private/src/panel/audio-client.cpp
++++ b/private/src/panel/audio-client.cpp
+@@ -12,7 +12,9 @@ namespace shell::audio
+ namespace
+ {
+ constexpr uint64_t max_integer = (uint64_t{1} << 53) - 1;
+-bool okay(const wf::json_t& reply) { return reply["status"].as_string() == "ok"; }
++// Replies are untrusted: a missing or non-string field reads as empty.
++std::string text(const wf::json_reference_t& value) { return value.is_string() ? value.as_string() : ""; }
++bool okay(const wf::json_t& reply) { return text(reply["status"]) == "ok"; }
+ uint64_t number(const wf::json_reference_t& value)
+ {
+ const auto n = companion::unsigned_number(value);
+@@ -100,7 +102,7 @@ struct Client::Impl : std::enable_shared_from_this<Impl>
+ auto self = weak.lock(); if (!self) return;
+ self->reading = false;
+ if (serial != self->generation) return;
+- if (!okay(result) && result["error"].as_string() == "busy" && result["outcome"].as_string() == "not-applied")
++ if (!okay(result) && text(result["error"]) == "busy" && text(result["outcome"]) == "not-applied")
+ { self->next_read = g_get_monotonic_time() + 500000; return; }
+ try
+ {
+@@ -121,9 +123,9 @@ struct Client::Impl : std::enable_shared_from_this<Impl>
+ {
+ pending = false; state.writable = false; ++generation; next_read = 0; feedback = true;
+ if (okay(result)) state.message = "Sound change accepted.";
+- else if (result["outcome"].as_string() == "uncertain")
++ else if (text(result["outcome"]) == "uncertain")
+ state.message = "Sound change completion unknown. Check the current level before trying again.";
+- else if (result["error"].as_string() == "stale-observation" || result["error"].as_string() == "stale-instance")
++ else if (text(result["error"]) == "stale-observation" || text(result["error"]) == "stale-instance")
+ state.message = "Sound changed elsewhere. Try again from the current controls.";
+ else state.message = "Sound change unavailable. Try again when controls are ready.";
+ // Reconnect/reset happens on our timer, never in a transport callback.
+@@ -157,10 +159,11 @@ struct Client::Impl : std::enable_shared_from_this<Impl>
+ if (okay(result) && result["capabilities"].is_array())
+ for (size_t i = 0; i < result["capabilities"].size(); ++i)
+ {
+- const auto cap = result["capabilities"][i].as_string();
++ const auto cap = text(result["capabilities"][i]);
+ supported |= cap == "audio.control-v1" || cap == "audio.control-v2";
+ }
+ if (!supported) { self->failed_read(); return; }
++ if (!result["instance"].is_string()) { self->failed_read(); return; }
+ self->instance = result["instance"].as_string(); self->reading = false;
+ });
+ } else read();
+@@ -204,7 +207,7 @@ bool Client::act(const Target& target, const std::string& action, int percent)
+ { if (auto self = weak.lock()) self->finish(result); });
+ } catch (...)
+ {
+- wf::json_t result; result["status"] = "error"; result["outcome"] = "not-applied";
++ wf::json_t result; result["status"] = "error"; result["error"] = "unavailable"; result["outcome"] = "not-applied";
+ self->finish(result);
+ }
+ };
+diff --git a/private/src/panel/brightness-client.cpp b/private/src/panel/brightness-client.cpp
+index 462f507..32104be 100644
+--- a/private/src/panel/brightness-client.cpp
++++ b/private/src/panel/brightness-client.cpp
+@@ -12,7 +12,9 @@ namespace shell::brightness
+ namespace
+ {
+ constexpr uint64_t max_integer = (uint64_t{1} << 53) - 1;
+-bool okay(const wf::json_t& reply) { return reply["status"].as_string() == "ok"; }
++// Replies are untrusted: a missing or non-string field reads as empty.
++std::string text(const wf::json_reference_t& value) { return value.is_string() ? value.as_string() : ""; }
++bool okay(const wf::json_t& reply) { return text(reply["status"]) == "ok"; }
+ uint64_t number(const wf::json_reference_t& value)
+ {
+ // Zero is a valid observation while the worker is busy, but cannot be used.
+@@ -106,7 +108,7 @@ struct Client::Impl : std::enable_shared_from_this<Impl>
+ auto self = weak.lock(); if (!self) return;
+ self->reading = false;
+ if (serial != self->generation) return;
+- if (!okay(result) && result["error"].as_string() == "busy" && result["outcome"].as_string() == "not-applied")
++ if (!okay(result) && text(result["error"]) == "busy" && text(result["outcome"]) == "not-applied")
+ { self->next_read = g_get_monotonic_time() + 500000; return; }
+ try
+ {
+@@ -126,10 +128,10 @@ struct Client::Impl : std::enable_shared_from_this<Impl>
+ {
+ pending = false; state.writable = false; ++generation; next_read = 0; feedback = true;
+ if (okay(result)) state.message = scan ? "Display scan accepted." : "Brightness change accepted.";
+- else if (result["outcome"].as_string() == "uncertain")
++ else if (text(result["outcome"]) == "uncertain")
+ state.message = scan ? "Display scan completion unknown. Refresh when ready." :
+ "Brightness change completion unknown. Check the current level before trying again.";
+- else if (result["error"].as_string() == "stale-observation" || result["error"].as_string() == "stale-instance")
++ else if (text(result["error"]) == "stale-observation" || text(result["error"]) == "stale-instance")
+ state.message = "Brightness changed elsewhere. Try again from the current controls.";
+ else state.message = "Brightness change unavailable. Try again when controls are ready.";
+ // Reconnect/reset happens on our timer, never in a transport callback.
+@@ -163,10 +165,11 @@ struct Client::Impl : std::enable_shared_from_this<Impl>
+ if (okay(result) && result["capabilities"].is_array())
+ for (size_t i = 0; i < result["capabilities"].size(); ++i)
+ {
+- const auto cap = result["capabilities"][i].as_string();
++ const auto cap = text(result["capabilities"][i]);
+ supported |= cap == "brightness.control-v1";
+ }
+ if (!supported) { self->failed_read(); return; }
++ if (!result["instance"].is_string()) { self->failed_read(); return; }
+ self->instance = result["instance"].as_string(); self->reading = false;
+ });
+ } else read();
+@@ -188,7 +191,7 @@ struct Client::Impl : std::enable_shared_from_this<Impl>
+ { if (auto self = weak.lock()) self->finish(result, scan); });
+ } catch (...)
+ {
+- wf::json_t result; result["status"] = "error"; result["outcome"] = "not-applied";
++ wf::json_t result; result["status"] = "error"; result["error"] = "unavailable"; result["outcome"] = "not-applied";
+ self->finish(result, scan);
+ }
+ };
+diff --git a/private/src/panel/notifications-client.cpp b/private/src/panel/notifications-client.cpp
+index 4934eaa..5d27962 100644
+--- a/private/src/panel/notifications-client.cpp
++++ b/private/src/panel/notifications-client.cpp
+@@ -13,7 +13,9 @@ namespace shell::notifications
+ namespace
+ {
+ constexpr uint64_t max_integer = (uint64_t{1} << 53) - 1;
+-bool okay(const wf::json_t& reply) { return reply["status"].as_string() == "ok"; }
++// Replies are untrusted: a missing or non-string field reads as empty.
++std::string text(const wf::json_reference_t& value) { return value.is_string() ? value.as_string() : ""; }
++bool okay(const wf::json_t& reply) { return text(reply["status"]) == "ok"; }
+ uint64_t number(const wf::json_reference_t& value)
+ {
+ // Counts, action indices and urgency can be zero; identities cannot.
+@@ -138,7 +140,7 @@ struct Client::Impl : std::enable_shared_from_this<Impl>
+ auto self = weak.lock(); if (!self) return;
+ self->reading = false;
+ if (serial != self->generation) return;
+- if (!okay(result) && result["error"].as_string() == "busy" && result["outcome"].as_string() == "not-applied")
++ if (!okay(result) && text(result["error"]) == "busy" && text(result["outcome"]) == "not-applied")
+ { self->draft.clear(); self->next_read = g_get_monotonic_time() + 500000; return; }
+ try
+ {
+@@ -151,9 +153,9 @@ struct Client::Impl : std::enable_shared_from_this<Impl>
+ {
+ pending = false; state.writable = false; ++generation; next_read = 0; feedback = true;
+ if (okay(result)) state.message = "Notification change applied.";
+- else if (result["outcome"].as_string() == "uncertain")
++ else if (text(result["outcome"]) == "uncertain")
+ state.message = "Notification change completion unknown. Check the current history before trying again.";
+- else if (result["error"].as_string() == "stale-observation" || result["error"].as_string() == "stale-instance")
++ else if (text(result["error"]) == "stale-observation" || text(result["error"]) == "stale-instance")
+ state.message = "Notifications changed elsewhere. Review the current history and try again.";
+ else state.message = "Notification change unavailable. Try again when controls are ready.";
+ reconnect = true; notify = true;
+@@ -186,10 +188,11 @@ struct Client::Impl : std::enable_shared_from_this<Impl>
+ if (okay(result) && result["capabilities"].is_array())
+ for (size_t i = 0; i < result["capabilities"].size(); ++i)
+ {
+- const auto cap = result["capabilities"][i].as_string();
++ const auto cap = text(result["capabilities"][i]);
+ supported |= cap == "notifications.control-v1";
+ }
+ if (!supported) { self->failed_read(); return; }
++ if (!result["instance"].is_string()) { self->failed_read(); return; }
+ self->instance = result["instance"].as_string(); self->reading = false;
+ });
+ } else read();
+@@ -210,7 +213,7 @@ struct Client::Impl : std::enable_shared_from_this<Impl>
+ { if (auto self = weak.lock()) self->finish(result); });
+ } catch (...)
+ {
+- wf::json_t result; result["status"] = "error"; result["outcome"] = "not-applied";
++ wf::json_t result; result["status"] = "error"; result["error"] = "unavailable"; result["outcome"] = "not-applied";
+ self->finish(result);
+ }
+ };
+diff --git a/private/src/panel/panel.cpp b/private/src/panel/panel.cpp
+index 8d2d0fe..783bb23 100644
+--- a/private/src/panel/panel.cpp
++++ b/private/src/panel/panel.cpp
+@@ -543,7 +543,7 @@ void WayfirePanelApp::request_menu_toggle(const std::string& fallback_output)
+ (priv->output_tracker && observation != priv->output_tracker->revision()) ||
+ response.has_member("ipc-error")) return;
+ std::optional<double> x, y;
+- if (response["pos"].is_object())
++ if (response.is_object() && response["pos"].is_object())
+ {
+ x = ipc_number(response["pos"]["x"]);
+ y = ipc_number(response["pos"]["y"]);
+diff --git a/private/src/panel/widgets/command-output.cpp b/private/src/panel/widgets/command-output.cpp
+index 5a93841..0f8ccab 100644
+--- a/private/src/panel/widgets/command-output.cpp
++++ b/private/src/panel/widgets/command-output.cpp
+@@ -16,11 +16,14 @@
+
+ static void label_set_from_command(shell::apps::Jobs& job, std::string command_line,
+ Gtk::Label& label, Gtk::Image *icon = nullptr,
+- const std::string& icon_name = "")
++ const std::string& icon_name = "", bool *running = nullptr)
+ {
+ // Drain output while the command runs, away from GTK. Jobs bounds pending
+ // work and suppresses delivery after replacement or widget destruction.
+- job.submit([command_line = std::move(command_line), &label, icon, icon_name]
++ // `running` is owned by the widget, which cancels the job (and so this
++ // delivery) before it is destroyed. Delivery runs on this (GTK) thread,
++ // so marking the run after submit() cannot race with its completion.
++ job.submit([command_line = std::move(command_line), &label, icon, icon_name, running]
+ (shell::apps::Cancel cancel)
+ {
+ std::string output;
+@@ -37,8 +40,9 @@ static void label_set_from_command(shell::apps::Jobs& job, std::string command_l
+ for (auto& ch : output) if (!ch) ch = '?';
+ output = shell::apps::display_path(output);
+ while (!output.empty() && g_ascii_isspace(output.back())) output.pop_back();
+- shell::apps::Jobs::deliver(cancel, [output = std::move(output), failed, &label, icon, icon_name]
++ shell::apps::Jobs::deliver(cancel, [output = std::move(output), failed, &label, icon, icon_name, running]
+ {
++ if (running) *running = false;
+ // Configured commands may intentionally return Pango markup.
+ // Malformed markup stays readable without emitting GTK warnings.
+ if (!failed && pango_parse_markup(output.c_str(), -1, 0, nullptr, nullptr, nullptr, nullptr))
+@@ -49,6 +53,7 @@ static void label_set_from_command(shell::apps::Jobs& job, std::string command_l
+ IconProvider::image_set_icon(*icon, icon_name, IconProvider::mappings(WayfireShellApp::get().config, "panel"));
+ });
+ });
++ if (running) *running = true;
+ }
+
+ WfCommandOutputButtons::CommandOutput::CommandOutput(const std::string & name,
+@@ -114,7 +119,7 @@ WfCommandOutputButtons::CommandOutput::CommandOutput(const std::string & name,
+
+ const auto update_output = [=] ()
+ {
+- label_set_from_command(command_job, command, main_label, &icon, icon_name);
++ label_set_from_command(command_job, command, main_label, &icon, icon_name, &command_running);
+ };
+
+ signals.push_back(signal_clicked().connect(update_output));
+@@ -123,7 +128,9 @@ WfCommandOutputButtons::CommandOutput::CommandOutput(const std::string & name,
+ {
+ timeout_connection = Glib::signal_timeout().connect_seconds([=] ()
+ {
+- update_output();
++ // Never replace a periodic run that is still going: a slow command
++ // would otherwise be killed every tick and never show output.
++ if (!command_running) update_output();
+ return true;
+ }, period);
+ }
+diff --git a/private/src/panel/widgets/command-output.hpp b/private/src/panel/widgets/command-output.hpp
+index ccebbd7..be9619d 100644
+--- a/private/src/panel/widgets/command-output.hpp
++++ b/private/src/panel/widgets/command-output.hpp
+@@ -31,6 +31,7 @@ class WfCommandOutputButtons : public WayfireWidget
+
+ Gtk::Label tooltip_label;
+ time_t last_tooltip_update = 0;
++ bool command_running = false;
+
+ std::string tooltip_command;
+
+diff --git a/private/src/panel/widgets/launchers.cpp b/private/src/panel/widgets/launchers.cpp
+index bec3712..1a21b04 100644
+--- a/private/src/panel/widgets/launchers.cpp
++++ b/private/src/panel/widgets/launchers.cpp
+@@ -72,7 +72,10 @@ bool WfLauncherButton::initialize(std::string name, std::string icon, std::strin
+
+ void WfLauncherButton::update_icon()
+ {
+- IconProvider::image_set_icon(m_icon, app_info->get_icon()->to_string(), IconProvider::mappings(WayfireShellApp::get().config, "panel"));
++ // A .desktop entry without Icon= has no GIcon; fall back instead of crashing.
++ const auto app_icon = app_info->get_icon();
++ IconProvider::image_set_icon(m_icon, app_icon ? app_icon->to_string() : "application-x-executable",
++ IconProvider::mappings(WayfireShellApp::get().config, "panel"));
+ }
+
+ void WfLauncherButton::launch()
+diff --git a/private/src/panel/widgets/menu/logout.cpp b/private/src/panel/widgets/menu/logout.cpp
+index c25d0ac..9517089 100644
+--- a/private/src/panel/widgets/menu/logout.cpp
++++ b/private/src/panel/widgets/menu/logout.cpp
+@@ -91,7 +91,7 @@ void WayfireLogoutUI::request(const std::string& method, wf::json_t params,
+ try { client->request(method, std::move(params), instance, 0, deliver); }
+ catch (const std::exception&)
+ {
+- wf::json_t reply; reply["status"] = "error"; reply["outcome"] = "not-applied";
++ wf::json_t reply; reply["status"] = "error"; reply["error"] = "unavailable"; reply["outcome"] = "not-applied";
+ deliver(std::move(reply));
+ }
+ }
+@@ -138,7 +138,7 @@ void WayfireLogoutUI::submit_power()
+ client = std::make_unique<shell::companion::Client>(shell::companion::published_endpoint());
+ request("hello", {}, "", [this, action] (wf::json_t hello)
+ {
+- if (!hello["status"].is_string() || hello["status"].as_string() != "ok")
++ if (!hello["status"].is_string() || hello["status"].as_string() != "ok" || !hello["instance"].is_string())
+ { failure("Resident unavailable. No power action submitted."); return; }
+ const auto instance = hello["instance"].as_string();
+ bool supported = false;
+diff --git a/private/src/panel/widgets/network-client.cpp b/private/src/panel/widgets/network-client.cpp
+index 94421f9..9345e98 100644
+--- a/private/src/panel/widgets/network-client.cpp
++++ b/private/src/panel/widgets/network-client.cpp
+@@ -20,6 +20,11 @@ std::string text(const wf::json_reference_t& value, size_t limit)
+ throw std::invalid_argument("network text bounds");
+ return result;
+ }
++// Reply status fields are untrusted: missing or non-string reads as empty.
++std::string field(const wf::json_reference_t& value)
++{
++ return value.is_string() ? value.as_string() : "";
++}
+ unsigned number(const wf::json_reference_t& value, unsigned limit)
+ {
+ if ((!value.is_uint64() && !value.is_int64()) || value.as_double() < 0 || value.as_double() > limit)
+@@ -139,12 +144,17 @@ void WayfireNetworkClient::request(const std::string& method, api::Client::Callb
+ auto deliver = [weak, callback = std::move(callback)] (wf::json_t reply)
+ {
+ Glib::signal_idle().connect_once([weak, callback, reply = std::move(reply)] () mutable
+- { if (!weak.expired()) callback(std::move(reply)); });
++ {
++ if (weak.expired()) return;
++ try { callback(std::move(reply)); }
++ catch (const std::exception& e) { g_warning("Network reply handler failed: %s", e.what()); }
++ });
+ };
+ try { client->request(method, {}, instance, 0, deliver); }
+ catch (const std::exception&)
+ {
+- wf::json_t error; error["status"] = "error"; deliver(std::move(error));
++ wf::json_t error; error["status"] = "error"; error["error"] = "unavailable"; error["outcome"] = "not-applied";
++ deliver(std::move(error));
+ }
+ }
+ void WayfireNetworkClient::poll()
+@@ -169,7 +179,7 @@ void WayfireNetworkClient::poll()
+ const auto caps = reply["capabilities"];
+ if (caps.is_array()) for (size_t i = 0; i < caps.size(); ++i)
+ supported |= caps[i].is_string() && caps[i].as_string() == "services.observe-v2";
+- if (reply["status"].as_string() != "ok" || !supported || !reply["instance"].is_string())
++ if (field(reply["status"]) != "ok" || !supported || !reply["instance"].is_string())
+ { unavailable(); return; }
+ instance = reply["instance"].as_string(); next_read = 0;
+ });
+@@ -178,9 +188,9 @@ void WayfireNetworkClient::poll()
+ request("services.observe", [this] (wf::json_t reply)
+ {
+ pending = false;
+- if (reply["status"].as_string() != "ok")
++ if (field(reply["status"]) != "ok")
+ {
+- if (reply["error"].as_string() != "busy" || reply["outcome"].as_string() != "not-applied") unavailable();
++ if (field(reply["error"]) != "busy" || field(reply["outcome"]) != "not-applied") unavailable();
+ return;
+ }
+ try
+diff --git a/private/src/panel/widgets/tray/item.cpp b/private/src/panel/widgets/tray/item.cpp
+index 8221c61..1b9e9d5 100644
+--- a/private/src/panel/widgets/tray/item.cpp
++++ b/private/src/panel/widgets/tray/item.cpp
+@@ -245,7 +245,8 @@ void StatusNotifierItem::update_icon()
+ gtk_accessible_update_property(GTK_ACCESSIBLE(trigger.gobj()), GTK_ACCESSIBLE_PROPERTY_LABEL, title.c_str(), -1);
+ const auto icon_name = get_item_property<Glib::ustring>(icon_type_name + "Name");
+
+- if (!IconProvider::image_set_icon(icon, icon_name, IconProvider::mappings(WayfireShellApp::get().config, "panel")))
++ if (!IconProvider::image_set_untrusted_icon(icon, icon_name,
++ IconProvider::mappings(WayfireShellApp::get().config, "panel")))
+ {
+ const auto pixmap_data = extract_pixbuf(get_item_property<IconData>(icon_type_name + "Pixmap"));
+ if (pixmap_data)
+diff --git a/private/src/panel/widgets/window-list/toplevel.cpp b/private/src/panel/widgets/window-list/toplevel.cpp
+index 8ba8d41..bfc2092 100644
+--- a/private/src/panel/widgets/window-list/toplevel.cpp
++++ b/private/src/panel/widgets/window-list/toplevel.cpp
+@@ -8,6 +8,7 @@
+ #include <cmath>
+
+ #include <glibmm.h>
++#include <algorithm>
+ #include <cassert>
+ #include <sys/mman.h>
+
+@@ -114,13 +115,19 @@ static void frame_handle_ready(void *data,
+ tooltip_media->height,
+ stride);
+
+- gbm_bo_unmap(tooltip_media->bo, map_data);
+-
+ uint32_t w = 500;
+ uint32_t h = tooltip_media->height * (500.0f / tooltip_media->width);
+
++ // create_from_data() only wraps the mapping: scale_simple() copies the
++ // pixels into a new pixbuf, and only then may the mapping be released.
+ auto scaled_pixbuf = pixbuf->scale_simple(
+ w, h, Gdk::InterpType::BILINEAR);
++ pixbuf.reset();
++ gbm_bo_unmap(tooltip_media->bo, map_data);
++ if (!scaled_pixbuf)
++ {
++ return;
++ }
+
+ w = scaled_pixbuf->get_width();
+ h = scaled_pixbuf->get_height();
+@@ -652,7 +659,15 @@ class WayfireToplevel::impl
+ bool child_activated = false;
+ for (auto c : get_children())
+ {
+- if (window_list->toplevels[c]->get_state() & WF_TOPLEVEL_STATE_ACTIVATED)
++ // Never use operator[] here: it would insert a null entry for a
++ // child that already closed and then dereference it.
++ auto child = window_list->toplevels.find(c);
++ if ((child == window_list->toplevels.end()) || !child->second)
++ {
++ continue;
++ }
++
++ if (child->second->get_state() & WF_TOPLEVEL_STATE_ACTIVATED)
+ {
+ child_activated = true;
+ break;
+@@ -707,7 +722,8 @@ class WayfireToplevel::impl
+ WfOption<int> minimal_panel_height{"panel/minimal_height"};
+
+ this->app_id = app_id;
+- IconProvider::image_set_icon(image, app_id, IconProvider::mappings(WayfireShellApp::get().config, "panel"));
++ IconProvider::image_set_untrusted_icon(image, app_id,
++ IconProvider::mappings(WayfireShellApp::get().config, "panel"));
+ this->view_id = this->window_list->get_view_id_from_full_app_id(app_id);
+ if (this->view_id == 0)
+ {
+@@ -988,8 +1004,20 @@ static void handle_toplevel_done(void *data, toplevel_t)
+ auto window_list = impl->window_list;
+
+ auto wf_id = window_list->get_view_id_from_full_app_id(impl->get_app_id());
++ // ID 0 means "unknown" (app_id_mode is not "full"): never pair on it,
++ // or every button would share (and outlive) one preview handle.
++ if (wf_id == 0)
++ {
++ return;
++ }
++
+ for (auto & list_toplevel : window_list->list_toplevels)
+ {
++ if (!list_toplevel.second)
++ {
++ continue;
++ }
++
+ auto id = window_list->get_view_id_from_full_app_id(list_toplevel.second->app_id);
+ if (wf_id == id)
+ {
+@@ -1002,11 +1030,16 @@ static void handle_toplevel_done(void *data, toplevel_t)
+ static void remove_child_from_parent(WayfireToplevel::impl *impl, toplevel_t child)
+ {
+ auto parent = impl->get_parent();
+- auto& parent_toplevel = impl->window_list->toplevels[parent];
+- if (child && parent && parent_toplevel)
++ if (!child || !parent)
++ {
++ return;
++ }
++
++ auto parent_toplevel = impl->window_list->toplevels.find(parent);
++ if ((parent_toplevel != impl->window_list->toplevels.end()) && parent_toplevel->second)
+ {
+- auto& children = parent_toplevel->get_children();
+- children.erase(std::find(children.begin(), children.end(), child));
++ auto& children = parent_toplevel->second->get_children();
++ children.erase(std::remove(children.begin(), children.end(), child), children.end());
+ }
+ }
+
+@@ -1033,10 +1066,21 @@ static void handle_toplevel_parent(void *data, toplevel_t handle, toplevel_t par
+ return;
+ }
+
+- if (impl->window_list->toplevels[parent])
++ // Leave the previous parent's child list before joining the new one, so a
++ // re-parented dialog never leaves a stale handle behind.
++ if (impl->get_parent() != parent)
+ {
+- auto& children = impl->window_list->toplevels[parent]->get_children();
+- children.push_back(handle);
++ remove_child_from_parent(impl, handle);
++ }
++
++ auto parent_toplevel = impl->window_list->toplevels.find(parent);
++ if ((parent_toplevel != impl->window_list->toplevels.end()) && parent_toplevel->second)
++ {
++ auto& children = parent_toplevel->second->get_children();
++ if (std::find(children.begin(), children.end(), handle) == children.end())
++ {
++ children.push_back(handle);
++ }
+ }
+
+ impl->set_parent(parent);
+diff --git a/private/src/panel/widgets/window-list/window-list.cpp b/private/src/panel/widgets/window-list/window-list.cpp
+index 6cfdc18..cb0430c 100644
+--- a/private/src/panel/widgets/window-list/window-list.cpp
++++ b/private/src/panel/widgets/window-list/window-list.cpp
+@@ -38,10 +38,11 @@ static void toplevel_handle_closed(void *data,
+ continue;
+ }
+
++ // Several buttons may have been paired with this handle; clear all of
++ // them before it is destroyed so none keeps a dangling pointer.
+ if (toplevel.second->get_ext_handle() == handle)
+ {
+ toplevel.second->set_ext_handle(NULL);
+- break;
+ }
+ }
+
+@@ -62,6 +63,11 @@ static void toplevel_handle_done(void *data,
+ }
+
+ auto wf_id = window_list->get_view_id_from_full_app_id(toplevel.second->get_app_id());
++ if (wf_id == 0)
++ {
++ continue; // Unknown view ID: never pair on 0.
++ }
++
+ for (auto & list_toplevel : window_list->list_toplevels)
+ {
+ if (!list_toplevel.second)
+diff --git a/private/src/panel/widgets/window-title.cpp b/private/src/panel/widgets/window-title.cpp
+index bdbf3f8..384cd20 100644
+--- a/private/src/panel/widgets/window-title.cpp
++++ b/private/src/panel/widgets/window-title.cpp
+@@ -164,7 +164,8 @@ void WayfireWindowTitle::refresh()
+ {
+ const auto owner = observation.capabilities.owner;
+ if (target.owner.connection != owner.connection || target.owner.revision != owner.revision ||
+- target.owner.domain != owner.domain || target.instance != observation.state["instance"].as_string())
++ target.owner.domain != owner.domain || !observation.state["instance"].is_string() ||
++ target.instance != observation.state["instance"].as_string())
+ return {};
+ for (size_t i = 0; i < rows.size(); ++i)
+ if (rows[i]["token"].is_string() && rows[i]["token"].as_string() == target.token &&
+diff --git a/private/src/panel/widgets/workspace-switcher.cpp b/private/src/panel/widgets/workspace-switcher.cpp
+index 75d0c0e..12ee5d6 100644
+--- a/private/src/panel/widgets/workspace-switcher.cpp
++++ b/private/src/panel/widgets/workspace-switcher.cpp
+@@ -9,6 +9,75 @@
+ #include "wf-popover.hpp"
+ #include "workspace-switcher.hpp"
+
++namespace
++{
++// Wayfire IPC replies and events are untrusted input: validate their shape
++// before reading typed fields, so a mismatched peer cannot abort the panel.
++bool geometry_valid(const wf::json_reference_t& geometry, bool position)
++{
++ if (!geometry.is_object())
++ {
++ return false;
++ }
++
++ const bool ints = geometry["width"].is_int();
++ for (auto key : {"x", "y", "width", "height"})
++ {
++ if (!position && ((key[0] == 'x') || (key[0] == 'y')))
++ {
++ continue;
++ }
++
++ if (ints ? !geometry[key].is_int() : !geometry[key].is_double())
++ {
++ return false;
++ }
++ }
++
++ return true;
++}
++
++bool workspace_entry_valid(const wf::json_reference_t& entry)
++{
++ const auto workspace = entry["workspace"];
++ return entry.is_object() && entry["output-id"].is_int() && workspace.is_object() &&
++ workspace["x"].is_int() && workspace["y"].is_int() &&
++ workspace["grid_width"].is_int() && workspace["grid_height"].is_int();
++}
++
++bool workspaces_valid(const wf::json_reference_t& workspaces)
++{
++ if (!workspaces.is_array())
++ {
++ return false;
++ }
++
++ for (size_t i = 0; i < workspaces.size(); i++)
++ {
++ if (!workspace_entry_valid(workspaces[i]))
++ {
++ return false;
++ }
++ }
++
++ return true;
++}
++
++bool output_valid(const wf::json_reference_t& output)
++{
++ return output.is_object() && output["name"].is_string() && output["id"].is_int() &&
++ geometry_valid(output["geometry"], false);
++}
++
++bool view_valid(const wf::json_reference_t& view)
++{
++ return view.is_object() && view["type"].is_string() && view["output-name"].is_string() &&
++ view["minimized"].is_bool() && view["app-id"].is_string() && view["id"].is_int() &&
++ view["activated"].is_bool() && view["output-id"].is_int() &&
++ geometry_valid(view["geometry"], true);
++}
++}
++
+ void WayfireWorkspaceSwitcher::init(Gtk::Box *container)
+ {
+ ipc_client = WayfirePanelApp::get().get_ipc_server_instance()->create_client();
+@@ -413,7 +482,7 @@ void WayfireWorkspaceSwitcher::render_workspace(wf::json_t workspace, int j, int
+
+ void WayfireWorkspaceSwitcher::process_workspaces(wf::json_t workspace_data)
+ {
+- if (!workspace_data.is_array() || workspace_data.size() == 0)
++ if (!workspaces_valid(workspace_data) || workspace_data.size() == 0)
+ return;
+
+ size_t i = 0;
+@@ -437,6 +506,12 @@ void WayfireWorkspaceSwitcher::process_workspaces(wf::json_t workspace_data)
+ return;
+ }
+
++ if (!output_valid(output_data))
++ {
++ std::cerr << "Invalid output information for workspace-switcher widget!" << std::endl;
++ return;
++ }
++
+ if (this->output_name == output_data["name"].as_string())
+ {
+ auto output_id = output_data["id"].as_int();
+@@ -471,7 +546,7 @@ void WayfireWorkspaceSwitcher::process_workspaces(wf::json_t workspace_data)
+
+ void WayfireWorkspaceSwitcher::grid_process_workspaces(wf::json_t workspace_data)
+ {
+- if (!workspace_data.is_array() || workspace_data.size() == 0)
++ if (!workspaces_valid(workspace_data) || workspace_data.size() == 0)
+ return;
+
+ size_t i = 0;
+@@ -495,6 +570,12 @@ void WayfireWorkspaceSwitcher::grid_process_workspaces(wf::json_t workspace_data
+ return;
+ }
+
++ if (!output_valid(output_data))
++ {
++ std::cerr << "Invalid output information for workspace-switcher widget!" << std::endl;
++ return;
++ }
++
+ if (this->output_name == output_data["name"].as_string())
+ {
+ if (output_data["geometry"]["width"].is_int())
+@@ -597,7 +678,7 @@ void WayfireWorkspaceSwitcher::grid_process_workspaces(wf::json_t workspace_data
+
+ void WayfireWorkspaceSwitcher::add_view(wf::json_t view_data)
+ {
+- if (view_data["type"].as_string() != "toplevel")
++ if (!view_valid(view_data) || (view_data["type"].as_string() != "toplevel"))
+ {
+ return;
+ }
+@@ -684,7 +765,7 @@ void WayfireWorkspaceSwitcher::add_view(wf::json_t view_data)
+
+ void WayfireWorkspaceSwitcher::grid_add_view(wf::json_t view_data)
+ {
+- if (view_data["type"].as_string() != "toplevel")
++ if (!view_valid(view_data) || (view_data["type"].as_string() != "toplevel"))
+ {
+ return;
+ }
+@@ -765,6 +846,11 @@ void WayfireWorkspaceSwitcher::grid_add_view(wf::json_t view_data)
+
+ void WayfireWorkspaceSwitcher::remove_view(wf::json_t view_data)
+ {
++ if (!view_data["id"].is_int())
++ {
++ return;
++ }
++
+ for (auto w : this->windows)
+ {
+ if (w->id == view_data["id"].as_int())
+@@ -786,6 +872,11 @@ void WayfireWorkspaceSwitcher::remove_view(wf::json_t view_data)
+
+ void WayfireWorkspaceSwitcher::grid_remove_view(wf::json_t view_data)
+ {
++ if (!view_data["id"].is_int())
++ {
++ return;
++ }
++
+ for (auto w : this->windows)
+ {
+ if (w->id == view_data["id"].as_int())
+@@ -863,8 +954,18 @@ void WayfireWorkspaceSwitcher::on_event(wf::json_t data)
+
+ void WayfireWorkspaceSwitcher::switcher_on_event(wf::json_t data)
+ {
++ if (!data["event"].is_string())
++ {
++ return;
++ }
++
+ if (data["event"].as_string() == "view-geometry-changed")
+ {
++ if (!view_valid(data["view"]))
++ {
++ return;
++ }
++
+ for (auto child : box.get_children())
+ {
+ WayfireWorkspaceBox *ws = (WayfireWorkspaceBox*)child;
+@@ -884,7 +985,7 @@ void WayfireWorkspaceSwitcher::switcher_on_event(wf::json_t data)
+ } else if (data["event"].as_string() == "view-mapped")
+ {
+ add_view(data["view"]);
+- } else if ((data["event"].as_string() == "view-focused") && data["view"].is_object())
++ } else if ((data["event"].as_string() == "view-focused") && view_valid(data["view"]))
+ {
+ if (data["view"]["type"].as_string() != "toplevel")
+ {
+@@ -939,8 +1040,18 @@ void WayfireWorkspaceSwitcher::switcher_on_event(wf::json_t data)
+
+ void WayfireWorkspaceSwitcher::grid_on_event(wf::json_t data)
+ {
++ if (!data["event"].is_string())
++ {
++ return;
++ }
++
+ if (data["event"].as_string() == "view-geometry-changed")
+ {
++ if (!view_valid(data["view"]))
++ {
++ return;
++ }
++
+ for (auto widget : overlay.get_children())
+ {
+ WayfireWorkspaceWindow *w = (WayfireWorkspaceWindow*)widget;
+@@ -956,7 +1067,7 @@ void WayfireWorkspaceSwitcher::grid_on_event(wf::json_t data)
+ } else if (data["event"].as_string() == "view-mapped")
+ {
+ grid_add_view(data["view"]);
+- } else if ((data["event"].as_string() == "view-focused") && data["view"].is_object())
++ } else if ((data["event"].as_string() == "view-focused") && view_valid(data["view"]))
+ {
+ if (data["view"]["type"].as_string() != "toplevel")
+ {
+diff --git a/private/src/shared-ui/services-page.cpp b/private/src/shared-ui/services-page.cpp
+index 273d6c5..c857417 100644
+--- a/private/src/shared-ui/services-page.cpp
++++ b/private/src/shared-ui/services-page.cpp
+@@ -10,6 +10,11 @@
+
+ namespace
+ {
++// Service replies are untrusted: a missing or wrong-typed field reads as
++// empty/false/0 instead of aborting the panel.
++std::string text_of(const wf::json_reference_t& value) { return value.is_string() ? value.as_string() : ""; }
++bool flag_of(const wf::json_reference_t& value) { return value.is_bool() && value.as_bool(); }
++uint64_t token_of(const wf::json_reference_t& value) { return value.is_uint64() ? value.as_uint64() : 0; }
+ std::string traffic_amount(double amount, bool rate)
+ {
+ static constexpr const char *units[] = {"B", "kB", "MB", "GB", "TB"};
+@@ -245,13 +250,15 @@ void ServicesPage::request(const std::string& method, wf::json_t params,
+ {
+ Glib::signal_idle().connect_once([weak, callback, result = std::move(result)] () mutable
+ {
+- if (!weak.expired()) callback(std::move(result));
++ if (weak.expired()) return;
++ try { callback(std::move(result)); }
++ catch (const std::exception& e) { g_warning("Services reply handler failed: %s", e.what()); }
+ });
+ };
+ try { client->request(method, std::move(params), instance, 0, deliver); }
+ catch (const std::exception&)
+ {
+- wf::json_t error; error["status"] = "error"; error["outcome"] = "not-applied";
++ wf::json_t error; error["status"] = "error"; error["error"] = "unavailable"; error["outcome"] = "not-applied";
+ deliver(std::move(error));
+ }
+ }
+@@ -296,14 +303,14 @@ void ServicesPage::show_prompt(const wf::json_t& prompt)
+ if (!id) return;
+ prompt_title.set_text(prompt["title"].is_string() ? prompt["title"].as_string() : "Service prompt");
+ if (prompt["confirmation"].is_string() && !prompt["confirmation"].as_string().empty())
+- prompt_title.set_text(prompt_title.get_text() + "\n" + prompt["confirmation"].as_string());
++ prompt_title.set_text(prompt_title.get_text() + "\n" + text_of(prompt["confirmation"]));
+ if (prompt["fields"].is_array())
+ for (size_t i = 0; i < prompt["fields"].size(); ++i)
+ {
+ const auto source = prompt["fields"][i];
+ if (!source["name"].is_string()) continue;
+ auto field = std::make_unique<PromptField>();
+- field->name = source["name"].as_string();
++ field->name = text_of(source["name"]);
+ field->label.set_text(field->name);
+ field->label.set_xalign(0);
+ field->label.set_mnemonic_widget(field->entry);
+@@ -376,8 +383,8 @@ void ServicesPage::submit(const std::string& method, wf::json_t params)
+ request(method, params, [this, method] (wf::json_t reply)
+ {
+ action_busy = false;
+- if (reply["status"].as_string() != "ok")
+- action_error = reply["outcome"].as_string() == "uncertain" ?
++ if (text_of(reply["status"]) != "ok")
++ action_error = text_of(reply["outcome"]) == "uncertain" ?
+ "Service action completion unknown. Check the current state before trying again." :
+ method == "services.answer" ?
+ "Prompt answer was not accepted. Check its fields or refresh." :
+@@ -454,18 +461,18 @@ void ServicesPage::activate()
+ }, compact ? 3000 : 600);
+ request("hello", {}, [this] (wf::json_t result)
+ {
+- if (result["status"].as_string() != "ok")
++ if (text_of(result["status"]) != "ok" || !result["instance"].is_string())
+ {
+ busy = false; refresh.set_sensitive(true);
+ status.set_text("Connectivity service unavailable. Retrying…");
+ if (compact) status.set_visible(true);
+ return;
+ }
+- instance = result["instance"].as_string();
++ instance = text_of(result["instance"]);
+ detailed = false;
+ if (result["capabilities"].is_array())
+ for (size_t i = 0; i < result["capabilities"].size(); ++i)
+- detailed |= result["capabilities"][i].as_string() == "services.observe-v2";
++ detailed |= text_of(result["capabilities"][i]) == "services.observe-v2";
+ busy = false;
+ observe();
+ });
+@@ -498,9 +505,9 @@ void ServicesPage::observe(bool passive)
+ {
+ busy = false;
+ if (!passive) refresh.set_sensitive(true);
+- if (reply["status"].as_string() != "ok" || !reply["services"].is_object())
++ if (text_of(reply["status"]) != "ok" || !reply["services"].is_object())
+ {
+- if (reply["error"].as_string() == "busy" && reply["outcome"].as_string() == "not-applied")
++ if (text_of(reply["error"]) == "busy" && text_of(reply["outcome"]) == "not-applied")
+ {
+ // Another client can own the resident's single observation slot.
+ // Retain this connection and its prompt lease while retrying reads.
+@@ -537,7 +544,7 @@ void ServicesPage::observe(bool passive)
+ {
+ if (is_bluetooth != bluetooth_page) return;
+ if (!row["path"].is_string() || !row["token"].is_uint64()) return;
+- const auto key = std::string(is_bluetooth ? "bluez/" : "connman/") + row["path"].as_string();
++ const auto key = std::string(is_bluetooth ? "bluez/" : "connman/") + text_of(row["path"]);
+ present_connections.insert(key);
+ auto found = connection_rows.find(key);
+ if (found == connection_rows.end())
+@@ -553,11 +560,11 @@ void ServicesPage::observe(bool passive)
+ (is_bluetooth ? bluetooth_connections : network_connections).append(control->button);
+ found = connection_rows.emplace(key, std::move(control)).first;
+ }
+- found->second->token = row["token"].as_uint64();
+- const bool connected = is_bluetooth ? row["connected"].as_bool() :
+- row["state"].as_string() == "online" || row["state"].as_string() == "ready";
++ found->second->token = token_of(row["token"]);
++ const bool connected = is_bluetooth ? flag_of(row["connected"]) :
++ text_of(row["state"]) == "online" || text_of(row["state"]) == "ready";
+ found->second->action = connected ? "disconnect" :
+- is_bluetooth && !row["paired"].as_bool() ? "pair" : "connect";
++ is_bluetooth && !flag_of(row["paired"]) ? "pair" : "connect";
+ const auto label = (connected ? "Disconnect · " :
+ found->second->action == "pair" ? "Pair · " : "Connect · ") + name;
+ set_changed(found->second->button, label);
+@@ -580,17 +587,17 @@ void ServicesPage::observe(bool passive)
+ if (operation["status"].is_string() && operation["status"].as_string() == "failed" &&
+ operation["error"].is_string())
+ {
+- status.set_text(domain + " action failed: " + operation["error"].as_string());
++ status.set_text(domain + " action failed: " + text_of(operation["error"]));
+ if (compact) status.set_visible(true);
+ }
+ else if (operation["notice"].is_string())
+ {
+- status.set_text(operation["notice"].as_string());
++ status.set_text(text_of(operation["notice"]));
+ if (compact) status.set_visible(true);
+ }
+ if (operation["browser_url"].is_string())
+ {
+- browser_link.set_uri(operation["browser_url"].as_string());
++ browser_link.set_uri(text_of(operation["browser_url"]));
+ browser_link.set_visible(true);
+ }
+ if (operation["prompt"].is_object()) show_prompt(operation["prompt"]);
+@@ -604,14 +611,14 @@ void ServicesPage::observe(bool passive)
+ const auto row = data["addresses"][i];
+ if (row["interface"].is_string() && row["family"].is_string() &&
+ row["address"].is_string())
+- addresses += "\n" + row["interface"].as_string() + " · " +
+- row["family"].as_string() + " · " + row["address"].as_string();
++ addresses += "\n" + text_of(row["interface"]) + " · " +
++ text_of(row["family"]) + " · " + text_of(row["address"]);
+ }
+ if (addresses == "Local addresses") addresses += "\nNone observed";
+ const bool connman = data["connman"]["available"].is_bool() &&
+- data["connman"]["available"].as_bool();
++ flag_of(data["connman"]["available"]);
+ const bool connman_readable = data["connman"]["readable"].is_bool() &&
+- data["connman"]["readable"].as_bool();
++ flag_of(data["connman"]["readable"]);
+ const auto bounded_total = [] (const wf::json_reference_t& value) -> unsigned
+ {
+ if (value.is_uint64()) return std::min<uint64_t>(value.as_uint64(), 256);
+@@ -629,11 +636,11 @@ void ServicesPage::observe(bool passive)
+ {
+ const auto row = technology_rows[i];
+ if (row["type"].is_string())
+- networks += "\n" + row["type"].as_string() + ": " +
+- (row["powered"].as_bool() ? "powered" : "off");
++ networks += "\n" + text_of(row["type"]) + ": " +
++ (flag_of(row["powered"]) ? "powered" : "off");
+ if (row["type"].is_string() && row["type"].as_string() == "wifi" &&
+ row["token"].is_uint64())
+- { network_token = row["token"].as_uint64(); network_powered = row["powered"].as_bool(); }
++ { network_token = token_of(row["token"]); network_powered = flag_of(row["powered"]); }
+ }
+ const auto network_page = data["connman"]["networks"];
+ const unsigned network_total = bounded_total(network_page["total"]);
+@@ -651,12 +658,12 @@ void ServicesPage::observe(bool passive)
+ const auto row = rows[i];
+ if (!row["path"].is_string()) continue;
+ const auto name = row["name"].is_string() && !row["name"].as_string().empty() ?
+- row["name"].as_string() : row["path"].as_string();
++ text_of(row["name"]) : text_of(row["path"]);
+ networks += "\n" + name;
+ if (row["state"].is_string() && !row["state"].as_string().empty())
+- networks += " · " + row["state"].as_string();
++ networks += " · " + text_of(row["state"]);
+ if (row["state"].is_string() &&
+- (row["state"].as_string() == "online" || row["state"].as_string() == "ready") &&
++ (text_of(row["state"]) == "online" || text_of(row["state"]) == "ready") &&
+ active_network.empty())
+ {
+ active_network = name;
+@@ -680,9 +687,9 @@ void ServicesPage::observe(bool passive)
+ if (address["interface"].is_string() && address["interface"].as_string() != "lo" &&
+ address["family"].is_string() && address["address"].is_string())
+ {
+- const auto device = address["interface"].as_string();
+- const auto ip = address["address"].as_string();
+- if (address["family"].as_string() == "IPv4" &&
++ const auto device = text_of(address["interface"]);
++ const auto ip = text_of(address["address"]);
++ if (text_of(address["family"]) == "IPv4" &&
+ (fallback_interface.empty() || device == active_interface))
+ { fallback_interface = device; fallback_ip = ip; }
+ observed_addresses.emplace_back(device, ip);
+@@ -730,14 +737,14 @@ void ServicesPage::observe(bool passive)
+ if (!extra_addresses.empty()) set_changed(address_list, extra_addresses);
+ } else if (!compact) network.set_text(networks + "\n" + addresses);
+ const bool bluez = data["bluez"]["available"].is_bool() &&
+- data["bluez"]["available"].as_bool();
++ flag_of(data["bluez"]["available"]);
+ if (compact)
+ {
+ network_actions.set_visible(!bluetooth_page && connman);
+ bluetooth_actions.set_visible(bluetooth_page && bluez);
+ }
+ const bool bluez_readable = data["bluez"]["readable"].is_bool() &&
+- data["bluez"]["readable"].as_bool();
++ flag_of(data["bluez"]["readable"]);
+ auto devices = std::string("BlueZ bus name: ") + (bluez ? "present" : "missing");
+ unsigned connected_devices = 0;
+ if (bluez && detailed && !bluez_readable)
+@@ -748,12 +755,12 @@ void ServicesPage::observe(bool passive)
+ const auto row = adapter_rows[i];
+ if (!row["path"].is_string()) continue;
+ const auto name = row["alias"].is_string() && !row["alias"].as_string().empty() ?
+- row["alias"].as_string() : row["path"].as_string();
+- devices += "\nAdapter: " + name + (row["powered"].as_bool() ? " · powered" : " · off") +
+- (row["discovering"].as_bool() ? " · discovering" : "");
++ text_of(row["alias"]) : text_of(row["path"]);
++ devices += "\nAdapter: " + name + (flag_of(row["powered"]) ? " · powered" : " · off") +
++ (flag_of(row["discovering"]) ? " · discovering" : "");
+ if (!bluetooth_token && row["token"].is_uint64())
+- { bluetooth_token = row["token"].as_uint64(); bluetooth_powered = row["powered"].as_bool();
+- bluetooth_discovering = row["discovering"].as_bool(); }
++ { bluetooth_token = token_of(row["token"]); bluetooth_powered = flag_of(row["powered"]);
++ bluetooth_discovering = flag_of(row["discovering"]); }
+ }
+ const auto device_page = data["bluez"]["devices"];
+ const unsigned bluetooth_total = bounded_total(device_page["total"]);
+@@ -771,10 +778,10 @@ void ServicesPage::observe(bool passive)
+ const auto row = rows[i];
+ if (!row["path"].is_string()) continue;
+ const auto name = row["alias"].is_string() && !row["alias"].as_string().empty() ?
+- row["alias"].as_string() : row["path"].as_string();
+- devices += "\n" + name + (row["connected"].as_bool() ? " · connected" : "") +
+- (row["paired"].as_bool() ? " · paired" : "");
+- if (row["connected"].as_bool()) ++connected_devices;
++ text_of(row["alias"]) : text_of(row["path"]);
++ devices += "\n" + name + (flag_of(row["connected"]) ? " · connected" : "") +
++ (flag_of(row["paired"]) ? " · paired" : "");
++ if (flag_of(row["connected"])) ++connected_devices;
+ add_connection(true, row, name);
+ }
+ }
+diff --git a/private/src/util/gtk-utils.cpp b/private/src/util/gtk-utils.cpp
+index a18a9d2..2af698f 100644
+--- a/private/src/util/gtk-utils.cpp
++++ b/private/src/util/gtk-utils.cpp
+@@ -2,7 +2,9 @@
+ #include "gtk-utils.hpp"
+ #include <wayfire-desktop/icons.h>
+ #include <wayfire/config/compound-option.hpp>
++#include <cstring>
+ #include <iostream>
++#include <sstream>
+ Glib::RefPtr<Gtk::CssProvider> load_css_from_path(std::string path)
+ {
+ try {
+@@ -41,6 +43,49 @@ static std::vector<wfd_ui_icon_mapping> values(const Mappings& mappings)
+ for (const auto& [key, value] : mappings) result.push_back({key.c_str(), value.c_str()});
+ return result;
+ }
++static bool icon_name_token(const std::string& token)
++{
++ if (token.empty() || (token.size() > 255) || !g_ascii_isalnum(token[0]))
++ {
++ return false;
++ }
++
++ for (unsigned char c : token)
++ {
++ if (!g_ascii_isalnum(c) && !std::strchr("._-+@:", c))
++ {
++ return false;
++ }
++ }
++
++ return true;
++}
++
++bool image_set_untrusted_icon(Gtk::Image& image, const Glib::ustring& spec, const Mappings& mappings)
++{
++ // Keep only plain name tokens. wfd_ui_icon_resolve() treats a leading '/',
++ // "~/" or ". " as a file or serialized icon; no surviving token can start
++ // that way, and whitespace-separated candidates stay name lookups.
++ std::string safe;
++ std::istringstream candidates(spec.raw());
++ std::string token;
++ while (candidates >> token)
++ {
++ if (icon_name_token(token))
++ {
++ safe += (safe.empty() ? "" : " ") + token;
++ }
++ }
++
++ if (safe.empty())
++ {
++ g_object_set_data(G_OBJECT(image.gobj()), "wfd-app-icon-spec", nullptr);
++ return false;
++ }
++
++ return image_set_icon(image, safe, mappings);
++}
++
+ bool image_set_icon(Gtk::Image& image, const Glib::ustring& spec, const Mappings& mappings)
+ {
+ g_object_set_data_full(G_OBJECT(image.gobj()), "wfd-app-icon-spec", g_strdup(spec.c_str()), g_free);
+diff --git a/private/src/util/gtk-utils.hpp b/private/src/util/gtk-utils.hpp
+index 58d39da..cee376e 100644
+--- a/private/src/util/gtk-utils.hpp
++++ b/private/src/util/gtk-utils.hpp
+@@ -9,6 +9,10 @@ namespace IconProvider {
+ using Mappings = std::map<std::string, std::string>;
+ Mappings mappings(wf::config::config_manager_t& config, const std::string& section);
+ bool image_set_icon(Gtk::Image& image, const Glib::ustring& spec, const Mappings& mappings);
++// For names chosen by other programs (Wayland app_id, tray IconName): only
++// themed icon names, mappings and desktop-entry lookups; never file paths,
++// "~/" paths or serialized GIcons, which would make the panel open files.
++bool image_set_untrusted_icon(Gtk::Image& image, const Glib::ustring& spec, const Mappings& mappings);
+ Glib::RefPtr<Gio::Icon> override_icon(const std::string& id, const Mappings& mappings);
+ void refresh_images(wf::config::config_manager_t& config, const std::string& section);
+ }
+diff --git a/private/src/util/wf-autohide-window.cpp b/private/src/util/wf-autohide-window.cpp
+index b069b88..4f0f978 100644
+--- a/private/src/util/wf-autohide-window.cpp
++++ b/private/src/util/wf-autohide-window.cpp
+@@ -410,6 +410,12 @@ void WayfireAutohidingWindow::reinit_ext_hotspots()
+ }
+
+ adjacent_edges_hotspots.clear();
++ // Adjacent hotspots borrow adjacent_edge_callbacks as listener data; it is
++ // created by setup_hotspot() and lives as long as this window.
++ if (!adjacent_edge_callbacks || !output->output)
++ {
++ return;
++ }
+
+ std::cout << "Monitor reinit hotspots " << this->output->monitor->get_connector() << std::endl;
+
+@@ -509,7 +515,7 @@ void WayfireAutohidingWindow::reinit_ext_hotspots()
+
+ void WayfireAutohidingWindow::setup_hotspot()
+ {
+- if (independent_edge) return;
++ if (independent_edge || !output->output) return;
+ if (this->edge_hotspot)
+ {
+ zwf_hotspot_v2_destroy(edge_hotspot);
+@@ -542,12 +548,18 @@ void WayfireAutohidingWindow::setup_hotspot()
+ this->panel_hotspot = zwf_output_v2_create_hotspot(output->output,
+ edge, (this->*get_allocated_height_or_width)(), 0); // immediate
+
+- this->edge_callbacks =
+- std::make_unique<WayfireAutohidingWindowHotspotCallbacks>();
+- this->adjacent_edge_callbacks =
+- std::make_unique<WayfireAutohidingWindowHotspotCallbacks>();
+- this->panel_callbacks =
+- std::make_unique<WayfireAutohidingWindowHotspotCallbacks>();
++ // Live hotspots (including the adjacent-output ones) keep raw pointers to
++ // these structs as listener data. Allocate them once and only update their
++ // members; replacing them would leave those hotspots with freed pointers.
++ if (!this->edge_callbacks)
++ {
++ this->edge_callbacks =
++ std::make_unique<WayfireAutohidingWindowHotspotCallbacks>();
++ this->adjacent_edge_callbacks =
++ std::make_unique<WayfireAutohidingWindowHotspotCallbacks>();
++ this->panel_callbacks =
++ std::make_unique<WayfireAutohidingWindowHotspotCallbacks>();
++ }
+
+ edge_callbacks->on_enter = [=] ()
+ {
+diff --git a/private/src/util/wf-ipc.cpp b/private/src/util/wf-ipc.cpp
+index 56ca475..5695b6f 100644
+--- a/private/src/util/wf-ipc.cpp
++++ b/private/src/util/wf-ipc.cpp
+@@ -7,6 +7,7 @@
+ #include <cerrno>
+ #include <cstring>
+ #include <cstdlib>
++#include <iostream>
+ #include <stdexcept>
+
+ namespace
+@@ -260,7 +261,10 @@ void WayfireIPC::complete(Request request, wf::json_t response)
+ {
+ if (request.watch) return;
+ auto client = request.lifetime.lock();
+- if (client && request.callback) request.callback(std::move(response));
++ // A wrong-shaped reply must not take the panel down from a GLib source.
++ if (client && request.callback)
++ try { request.callback(std::move(response)); }
++ catch (const std::exception& e) { std::cerr << "wf-ipc: reply handler failed: " << e.what() << std::endl; }
+ }
+
+ void WayfireIPC::dispatch(wf::json_t message)
+@@ -277,7 +281,8 @@ void WayfireIPC::dispatch(wf::json_t message)
+ if (!connected || generation != epoch) break;
+ if (subscriptions.count(id) && !sub.lifetime.expired() && !sub.client_lifetime.expired() &&
+ (sub.all || sub.events.count(event)) && delivered.insert(sub.subscriber).second)
+- sub.subscriber->on_event(message);
++ try { sub.subscriber->on_event(message); }
++ catch (const std::exception& e) { std::cerr << "wf-ipc: event handler failed: " << e.what() << std::endl; }
+ }
+ return;
+ }
+diff --git a/private/src/util/window-title.cpp b/private/src/util/window-title.cpp
+index b44910b..1d61a98 100644
+--- a/private/src/util/window-title.cpp
++++ b/private/src/util/window-title.cpp
+@@ -12,7 +12,7 @@ bool desktop_is_shown(const wf::json_reference_t& states, const std::string& des
+ for (size_t i = 0; i < states.size(); ++i)
+ {
+ auto state = states[i];
+- if (!state["output"].is_string() || state["output"].as_string() != desktop ||
++ if (!state.is_object() || !state["output"].is_string() || state["output"].as_string() != desktop ||
+ !state["hidden"].is_bool() || !state["hidden"].as_bool()) continue;
+ if (!region)
+ {
+@@ -41,7 +41,7 @@ WindowTitleSelection select_window_title_view(const wf::json_reference_t& output
+ for (size_t i = 0; i < outputs.size(); ++i)
+ {
+ const auto output = outputs[i];
+- if (!output["name"].is_string() || output["name"].as_string() != desktop)
++ if (!output.is_object() || !output["name"].is_string() || output["name"].as_string() != desktop)
+ continue;
+ auto id = ipc_number(output["id"]), wset = ipc_number(output["wset-index"]);
+ auto width = ipc_number(output["geometry"]["width"]);
+@@ -54,9 +54,11 @@ WindowTitleSelection select_window_title_view(const wf::json_reference_t& output
+ for (size_t j = 0; j < views.size(); ++j)
+ {
+ const auto view = views[j];
++ if (!view.is_object())
++ continue;
+ const bool restore = minimized_view && ipc_number(view["id"]) == *minimized_view &&
+ flag(view["minimized"]);
+- if (!view.is_object() || !flag(view["mapped"]) || (flag(view["minimized"]) && !restore) ||
++ if (!flag(view["mapped"]) || (flag(view["minimized"]) && !restore) ||
+ ipc_number(view["output-id"]) != id || ipc_number(view["wset-index"]) != wset ||
+ !view["role"].is_string() || view["role"].as_string() != "toplevel" ||
+ !view["type"].is_string() || view["type"].as_string() != "toplevel" ||
+diff --git a/tests/ipc-shape.cpp b/tests/ipc-shape.cpp
+new file mode 100644
+index 0000000..02e4af4
+--- /dev/null
++++ b/tests/ipc-shape.cpp
+@@ -0,0 +1,79 @@
++// A handler tripping over a wrong-shaped Wayfire reply or event must not take
++// the panel down; the transport keeps serving later replies.
++#include "wf-ipc.hpp"
++#include <cassert>
++#include <cstring>
++#include <iostream>
++#include <thread>
++#include <sys/socket.h>
++#include <sys/un.h>
++#include <unistd.h>
++
++static void send_frame(int fd, const std::string& body)
++{
++ uint32_t size = body.size();
++ assert(::write(fd, &size, 4) == 4 && ::write(fd, body.data(), body.size()) == ssize_t(body.size()));
++}
++static std::string read_frame(int fd)
++{
++ uint32_t size = 0;
++ assert(::recv(fd, &size, 4, MSG_WAITALL) == 4);
++ std::string body(size, '\0');
++ assert(::recv(fd, body.data(), size, MSG_WAITALL) == ssize_t(size));
++ return body;
++}
++struct Thrower : IIPCSubscriber
++{
++ int events = 0;
++ void on_event(wf::json_t event) override { ++events; (void)event["view"]["id"].as_int(); }
++};
++int main()
++{
++ char *dir = g_dir_make_tmp("wf-ipc-shape-XXXXXX", nullptr);
++ // A relative name keeps the socket path short whatever TMPDIR is.
++ assert(dir && !::chdir(dir));
++ const std::string path = "socket";
++ int server = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
++ sockaddr_un address{};
++ address.sun_family = AF_UNIX;
++ std::strcpy(address.sun_path, path.c_str());
++ assert(!::bind(server, reinterpret_cast<sockaddr*>(&address), sizeof(address)) && !::listen(server, 1));
++ std::thread peer([server]
++ {
++ int fd = ::accept(server, nullptr, nullptr);
++ assert(read_frame(fd).find("events/watch") != std::string::npos);
++ send_frame(fd, R"({"result":"ok"})");
++ read_frame(fd);
++ send_frame(fd, "[1]"); // array where an object is expected
++ send_frame(fd, R"({"event":"view-focused","view":[1]})");
++ read_frame(fd);
++ send_frame(fd, R"({"ok":true})");
++ ::close(fd);
++ });
++ auto main_loop = Glib::MainLoop::create();
++ auto ipc = WayfireIPC::create(path);
++ auto client = ipc->create_client();
++ Thrower subscriber;
++ assert(client->subscribe_all(&subscriber));
++ int replies = 0;
++ client->send(R"({"method":"first"})", [&] (wf::json_t reply)
++ {
++ ++replies;
++ (void)reply["error"].as_string(); // throws: reply is an array
++ });
++ client->send(R"({"method":"second"})", [&] (wf::json_t reply)
++ {
++ ++replies;
++ assert(reply["ok"].is_bool() && reply["ok"].as_bool());
++ main_loop->quit();
++ });
++ auto timeout = Glib::signal_timeout().connect([&] { main_loop->quit(); return false; }, 5000);
++ main_loop->run();
++ timeout.disconnect();
++ peer.join();
++ assert(replies == 2 && subscriber.events == 1);
++ client.reset(); ipc.reset();
++ ::close(server); ::unlink(path.c_str()); ::rmdir(dir); g_free(dir);
++ std::cout << "PASS wrong-shaped IPC reply/event handlers fail without aborting; later replies still arrive" << std::endl;
++ return 0;
++}
+diff --git a/tests/json-shape.cpp b/tests/json-shape.cpp
+new file mode 100644
+index 0000000..6d25a7e
+--- /dev/null
++++ b/tests/json-shape.cpp
+@@ -0,0 +1,42 @@
++// Wrong-shaped peer JSON must be a recoverable error, never assert()/abort().
++#include <wayfire/nonstd/json.hpp>
++#include <cassert>
++#include <iostream>
++#include <stdexcept>
++#include "window-title.hpp"
++
++template<class F> static bool throws(F f)
++{
++ try { f(); } catch (const std::domain_error&) { return true; }
++ return false;
++}
++
++int main()
++{
++ wf::json_t array, object, text;
++ assert(!wf::json_t::parse_string("[1, \"x\"]", array));
++ assert(!wf::json_t::parse_string(R"({"status":"error","views":[1, [2], "v"]})", object));
++ assert(!wf::json_t::parse_string("\"x\"", text));
++ // Indexing the wrong shape yields null instead of aborting.
++ assert(array["error"].is_null() && !array.has_member("error") && array.size() == 2);
++ assert(text["x"].is_null() && text.size() == 0 && text[size_t(0)].is_null());
++ assert(object[size_t(3)].is_null() && object["views"][size_t(9)].is_null());
++ assert(object["views"][size_t(0)]["id"].is_null());
++ // Extracting the wrong type throws a catchable exception.
++ assert(throws([&] { (void)object["error"].as_string(); }));
++ assert(throws([&] { (void)object["status"].as_int(); }));
++ assert(throws([&] { (void)object["views"].as_bool(); }));
++ assert(throws([&] { text.append(1); }));
++ assert(object["status"].as_string() == "error");
++ // The window-title selector skips non-object rows from a mismatched peer.
++ wf::json_t outputs, views;
++ assert(!wf::json_t::parse_string(R"([1, {"name":"DP-1","id":1,"wset-index":1,
++ "geometry":{"width":100,"height":100}}])", outputs));
++ assert(!wf::json_t::parse_string(R"([1, [2], {"id":1,"mapped":true,"output-id":1,"wset-index":1,
++ "role":"toplevel","type":"toplevel","layer":"workspace","title":"T",
++ "geometry":{"x":0,"y":0,"width":10,"height":10}}])", views));
++ assert(shell::select_window_title(outputs, views, "DP-1", std::nullopt) == "T");
++ assert(!shell::desktop_is_shown(views, "DP-1", std::nullopt));
++ std::cout << "PASS wrong-shaped JSON is recoverable (null indexing, typed exceptions)" << std::endl;
++ return 0;
++}
diff --git a/gui-apps/wayfire-panel/wayfire-panel-0.1.0-r4.ebuild b/gui-apps/wayfire-panel/wayfire-panel-0.1.0-r5.ebuild
index edd87e2d225d..65c7148c0887 100644
--- a/gui-apps/wayfire-panel/wayfire-panel-0.1.0-r4.ebuild
+++ b/gui-apps/wayfire-panel/wayfire-panel-0.1.0-r5.ebuild
@@ -10,7 +10,7 @@ KEYWORDS="~amd64"
RESTRICT="mirror test"
DEPEND=" >=dev-cpp/gtkmm-4.12:4.0
>=gui-libs/wayfire-core-0.1.3
- >=gui-libs/wayfire-ui-0.1.6
+ >=gui-libs/wayfire-ui-0.1.6-r1
>=gui-libs/gtk4-layer-shell-1.3.0
dev-libs/wayland
>=gui-libs/wf-config-0.7.0
@@ -37,6 +37,7 @@ src_prepare() {
eapply "${FILESDIR}/${P}-shared-ui-jobs.patch"
eapply "${FILESDIR}/${P}-shared-ui-icons.patch"
eapply "${FILESDIR}/${P}-shared-ui-popups.patch"
+ eapply "${FILESDIR}/${P}-audit-fixes.patch"
}
src_configure() {
local emesonargs=( -Dtests=false )
diff --git a/gui-apps/wayfire-preferences/files/wayfire-preferences-0.1.0-audit-fixes.patch b/gui-apps/wayfire-preferences/files/wayfire-preferences-0.1.0-audit-fixes.patch
new file mode 100644
index 000000000000..798b0d4f564a
--- /dev/null
+++ b/gui-apps/wayfire-preferences/files/wayfire-preferences-0.1.0-audit-fixes.patch
@@ -0,0 +1,657 @@
+wayfire-preferences 0.1.0: audit fixes
+
+1. Compare inotify sdev in the kernel's dev_t encoding (input-authority.cpp).
+ watched() matched /proc/<pid>/fdinfo "sdev:" (kernel (major << 20) | minor)
+ against a new_encode_dev()-style conversion of stat().st_dev. They only
+ agree for major 0 (ZFS, tmpfs), so input/frame saves were always refused
+ ("Cannot verify Wayfire watches this configuration file") on ext4/xfs/LVM.
+ Adds shell::input::inotify_device() and tests/input-authority.cpp.
+
+2. Unsafe or unwritable configs are read-only, never a startup failure.
+ A symlinked, hard-linked or read-only wf-shell.ini / wayfire.ini /
+ workspaces.ini, or a group/world-writable ~/.config, used to make every
+ observation fail, and input_backend() could refuse startup (the supervisor
+ then failed readiness under --preferences-service). Now:
+ - preferences::inspect_readable() falls back to a bounded read of the
+ same-owner regular file and marks the Snapshot writable = false;
+ shell, workspace and input observations use it, so values are served.
+ - Observation::writable(); Authority refuses a commit of a read-only
+ observation with error code "read-only" before apply (revision consumed).
+ - Every write path keeps the strict checks: Transaction refuses a
+ non-writable snapshot and still rereads with O_NOFOLLOW/owner/nlink/mode/
+ parent checks; persist_live and Names::save still verify strictly.
+ - input_backend(): if Session's pinned wayfire.ini cannot be verified at
+ startup, keep that pin (after checking the compositor identity) and let
+ each observation verify it; a changed path/compositor still fails.
+
+3. Retry a busy writer lock briefly (20 x 25 ms) in Transaction and the
+ legacy workspaces.lock instead of failing immediately.
+
+Tests: preference-backends (read-only cases, busy-lock retry),
+preference-commit ("read-only" code), preference-service-process (symlinked
+shell config starts ready and refuses commits with "read-only").
+
+diff --git a/meson.build b/meson.build
+index 4ed066e..d7ea572 100644
+--- a/meson.build
++++ b/meson.build
+@@ -9,7 +9,7 @@ service = executable('wayfire-preferences', 'src/main.cpp', dependencies: privat
+ install: true, install_dir: get_option('libexecdir') / 'wayfire-preferences')
+ install_data('README.md', 'LICENSE', 'source-manifest.json', install_dir: get_option('datadir') / 'doc/wayfire-preferences')
+ if get_option('tests')
+- foreach name : ['preference-commit', 'preference-backends', 'preference-provision']
++ foreach name : ['preference-commit', 'preference-backends', 'preference-provision', 'input-authority']
+ check = executable(name + '-test', 'tests/' + name + '.cpp', dependencies: private_dep,
+ cpp_args: ['-UNDEBUG'], link_args: ['-Wl,--wrap=fsync'])
+ test(name, check, timeout: 30)
+diff --git a/private/input-authority.cpp b/private/input-authority.cpp
+index 2363c34..416691d 100644
+--- a/private/input-authority.cpp
++++ b/private/input-authority.cpp
+@@ -201,9 +201,7 @@ std::string process_config(pid_t pid)
+ }
+ bool watched(pid_t pid, const Snapshot& config)
+ {
+- const uint64_t device = (minor(config.stamp.st_dev) & 0xff) |
+- (uint64_t(major(config.stamp.st_dev)) << 8) |
+- ((uint64_t(minor(config.stamp.st_dev)) & ~uint64_t(0xff)) << 12);
++ const uint64_t device = inotify_device(config.stamp.st_dev);
+ unsigned entries = 0;
+ for (const auto& fd : std::filesystem::directory_iterator("/proc/" + std::to_string(pid) + "/fdinfo"))
+ {
+@@ -248,6 +246,13 @@ bool watched(pid_t pid, const Snapshot& config)
+ }
+ }
+
++uint64_t inotify_device(dev_t device)
++{
++ // fdinfo prints the superblock's internal kernel dev_t (MKDEV: 12-bit
++ // major above a 20-bit minor), not the userspace stat() encoding.
++ return (uint64_t(major(device)) << 20) | minor(device);
++}
++
+ ProcessIdentity peer_identity(int connected_socket)
+ {
+ ucred peer{};
+@@ -258,11 +263,11 @@ ProcessIdentity peer_identity(int connected_socket)
+ return {peer.pid, peer.uid, start_time(peer.pid)};
+ }
+
+-Authority verified_config(const std::string& socket_path)
++Authority verified_config(const std::string& socket_path, bool readable)
+ {
+ const auto before = socket_peer(socket_path);
+ const auto path = process_config(before.pid);
+- auto file = inspect(path, before.uid);
++ auto file = inspect(path, before.uid, readable);
+ if (!watched(before.pid, file))
+ throw std::runtime_error("Cannot verify Wayfire watches this configuration file");
+ if (!(socket_peer(socket_path) == before))
+diff --git a/private/input-authority.hpp b/private/input-authority.hpp
+index 22e787a..145aca8 100644
+--- a/private/input-authority.hpp
++++ b/private/input-authority.hpp
+@@ -3,6 +3,7 @@
+ #include "input-config.hpp"
+ #include <cstdint>
+ #include <string>
++#include <sys/types.h>
+
+ namespace shell::input
+ {
+@@ -22,9 +23,13 @@ struct Authority
+ // The caller can compare this with the peer of its already-open resident IPC
+ // connection. A path alone is never a compositor identity.
+ ProcessIdentity peer_identity(int connected_socket);
++// Encode a stat() st_dev the way /proc/<pid>/fdinfo prints an inotify sdev.
++uint64_t inotify_device(dev_t device);
+ ProcessIdentity socket_identity(const std::string& wayfire_socket);
+ // Inspect the process on a freshly connected Wayfire IPC socket. Only the
+ // process's arguments/environment select the file. Editing requires the loaded
+ // default backend and its inotify close-write watch of that exact inode.
+-Authority verified_config(const std::string& wayfire_socket);
++// With readable, an unsafe/unwritable file is returned with config.writable
++// false; the backend and watch checks still apply. Writers use the default.
++Authority verified_config(const std::string& wayfire_socket, bool readable = false);
+ }
+diff --git a/private/input-config.cpp b/private/input-config.cpp
+index 278ef9f..44c2c99 100644
+--- a/private/input-config.cpp
++++ b/private/input-config.cpp
+@@ -147,9 +147,9 @@ std::string edit(std::string_view source, std::string_view key, std::string_view
+ return output;
+ }
+
+-Snapshot inspect(const std::string& path, uid_t uid)
++Snapshot inspect(const std::string& path, uid_t uid, bool readable)
+ {
+- auto result = preferences::inspect(path, uid);
++ auto result = readable ? preferences::inspect_readable(path, uid) : preferences::inspect(path, uid);
+ parse(lines_of(result.bytes));
+ return result;
+ }
+diff --git a/private/input-config.hpp b/private/input-config.hpp
+index 5b60120..a52c957 100644
+--- a/private/input-config.hpp
++++ b/private/input-config.hpp
+@@ -20,7 +20,8 @@ std::string edit(std::string_view source, std::string_view key, std::string_view
+ using Snapshot = preferences::Snapshot;
+ // The caller must supply the path and uid established from the compositor peer.
+ // Neither function consults the caller's environment for a configuration path.
+-Snapshot inspect(const std::string& absolute_path, uid_t compositor_uid);
++// readable: fall back to preferences::inspect_readable (writable = false).
++Snapshot inspect(const std::string& absolute_path, uid_t compositor_uid, bool readable = false);
+ enum class SaveResult { saved, saved_sync_uncertain };
+ // The guard verifies the same compositor process, backend, watch and live target
+ // value. It runs before work and again immediately before replacement. Throws on
+diff --git a/private/input-live.cpp b/private/input-live.cpp
+index 467fca8..fcadfda 100644
+--- a/private/input-live.cpp
++++ b/private/input-live.cpp
+@@ -197,7 +197,7 @@ LiveObservation observe_live(const std::string& socket_path, const std::string&
+ if (std::none_of(settings().begin(), settings().end(), [&] (const Setting& row)
+ { return row.key == key; })) throw std::runtime_error("Unsupported input setting");
+ Connection live(socket_path);
+- auto authority = verified_config(socket_path);
++ auto authority = verified_config(socket_path, true);
+ if (!(authority.process == live.peer())) throw std::runtime_error("Resident Wayfire peer changed");
+ if (!auto_reload(live)) throw std::runtime_error("Automatic configuration reload is disabled");
+ require_plugin(live, key);
+diff --git a/private/preference-backends.cpp b/private/preference-backends.cpp
+index 1d3bf21..2394d0a 100644
+--- a/private/preference-backends.cpp
++++ b/private/preference-backends.cpp
+@@ -50,6 +50,7 @@ struct Shell : Attempt
+ require(this->current);
+ }
+ size_t retained_bytes() const override { return seen.bytes.size(); }
++ bool writable() const override { return seen.writable; }
+ wf::json_t values() const override
+ {
+ auto result = object();
+@@ -113,6 +114,7 @@ struct InputBackend : Attempt
+ require(this->current);
+ }
+ size_t retained_bytes() const override { return seen.authority.config.bytes.size(); }
++ bool writable() const override { return seen.authority.config.writable; }
+ wf::json_t values() const override
+ {
+ auto result = object(); result["key"] = seen.key;
+@@ -155,7 +157,8 @@ struct WorkspaceBackend : Attempt
+ workspaces::Names names;
+ workspaces::NamesSnapshot seen;
+ WorkspaceBackend(std::string config_home, Current current) : Attempt(std::move(current)),
+- names(std::move(config_home)), seen(names.read()) { require(this->current); }
++ names(std::move(config_home)), seen(names.read(true)) { require(this->current); }
++ bool writable() const override { return seen.writable; }
+ size_t retained_bytes() const override
+ { return (seen.bytes ? seen.bytes->size() : 0) + (seen.file ? seen.file->bytes.size() : 0); }
+ wf::json_t values() const override { auto result = object(); result["names"] = seen.names; return result; }
+@@ -206,14 +209,23 @@ Registration input_backend(std::string file, std::string socket, input::ProcessI
+ throw std::invalid_argument("Invalid provisioned compositor");
+ // Do not silently rebind an opaque file ID if the provider changes its
+ // configuration location later in the same authority epoch.
+- auto initial = input::verified_config(socket);
+- auto path = initial.config.path;
+- if (!expected_config.empty())
++ // An unsafe or unwritable INI only makes input read-only. If Session's pinned
++ // file cannot be verified right now, keep that pin and let each observation
++ // verify it; only a different compositor or path still fails startup.
++ std::string path;
++ bool changed = false;
++ if (!expected_config.empty()) location(expected_config);
++ try
+ {
+- location(expected_config);
+- if (path != expected_config || !(initial.process == compositor))
+- throw std::runtime_error("Provisioned compositor configuration changed");
++ auto initial = input::verified_config(socket, true);
++ path = initial.config.path;
++ changed = !expected_config.empty() && (path != expected_config || !(initial.process == compositor));
++ } catch (const std::exception&)
++ {
++ if (expected_config.empty() || !(input::socket_identity(socket) == compositor)) throw;
++ path = expected_config;
+ }
++ if (changed) throw std::runtime_error("Provisioned compositor configuration changed");
+ require(current);
+ return {Domain::input, std::move(file), [socket = std::move(socket), path = std::move(path),
+ compositor, current = std::move(current)](const Query& query)
+diff --git a/private/preference-commit.cpp b/private/preference-commit.cpp
+index 32038ae..f0a119b 100644
+--- a/private/preference-commit.cpp
++++ b/private/preference-commit.cpp
+@@ -333,6 +333,7 @@ struct Authority::Impl
+ auto it = peer.drafts.find(params["revision"].as_string());
+ if (it == peer.drafts.end() || it->second.domain != entry.domain) throw Error{"stale-revision"};
+ const auto change = patch(entry.domain, params["patch"]);
++ if (!it->second.observation->writable()) { peer.drafts.erase(it); throw Error{"read-only"}; }
+ const auto selection = it->second.query;
+ if (entry.domain == Domain::input && std::get<Input>(change).key != it->second.query.key)
+ throw Error{"stale-revision"};
+diff --git a/private/preference-commit.hpp b/private/preference-commit.hpp
+index 0dcc163..437b0ca 100644
+--- a/private/preference-commit.hpp
++++ b/private/preference-commit.hpp
+@@ -44,6 +44,9 @@ public:
+ virtual wf::json_t values() const = 0;
+ // Observation-only metadata, never a mutation receipt or caller authority.
+ virtual wf::json_t context() const { wf::json_t v; wf::json_t::parse_string("{}", v); return v; }
++ // False for a read-only observation (unsafe or unwritable file). A commit
++ // is then refused with "read-only" before apply; apply keeps its checks.
++ virtual bool writable() const { return true; }
+ // One call only. Revalidate the exact observation, typed domain constraints
+ // and final authority guard. Release all file leases before returning.
+ // A throw has UNKNOWN effects; use rejected/conflict only with proof no
+diff --git a/private/preference-transaction.cpp b/private/preference-transaction.cpp
+index 7fe8188..e2f7554 100644
+--- a/private/preference-transaction.cpp
++++ b/private/preference-transaction.cpp
+@@ -6,6 +6,7 @@
+ #include <filesystem>
+ #include <fcntl.h>
+ #include <stdexcept>
++#include <poll.h>
+ #include <sys/file.h>
+ #include <unistd.h>
+
+@@ -112,6 +113,36 @@ Snapshot inspect_optional(const std::string& path, uid_t uid)
+ FD directory(open(parsed.parent_path().c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW));
+ return read_at(directory.value, path, uid, true);
+ }
++Snapshot inspect_readable(const std::string& path, uid_t uid, bool optional)
++{
++ try { return optional ? inspect_optional(path, uid) : inspect(path, uid); }
++ catch (const std::exception&) {}
++ const auto parsed = checked_path(path);
++ Snapshot result; result.path = path; result.writable = false;
++ if (stat(parsed.parent_path().c_str(), &result.directory_stamp))
++ fail("Cannot observe configuration directory");
++ FD file(open(path.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOCTTY));
++ if (file.value < 0 && errno == ENOENT && optional) { result.exists = false; return result; }
++ if (file.value < 0 || fstat(file.value, &result.stamp) || !S_ISREG(result.stamp.st_mode) ||
++ result.stamp.st_uid != uid || result.stamp.st_size < 0 ||
++ result.stamp.st_size > static_cast<off_t>(max_file_size))
++ fail("Configuration file is unsafe or unavailable");
++ char buffer[8192];
++ for (;;)
++ {
++ const auto count = read(file.value, buffer, sizeof(buffer));
++ if (count < 0 && errno == EINTR) continue;
++ if (count < 0) fail("Cannot read configuration");
++ if (!count) break;
++ if (result.bytes.size() + count > max_file_size) fail("Configuration exceeds limit");
++ result.bytes.append(buffer, count);
++ }
++ struct stat after{};
++ if (fstat(file.value, &after) || !same_stamp(result.stamp, after) ||
++ result.bytes.size() != size_t(result.stamp.st_size) || result.bytes.find('\0') != result.bytes.npos)
++ fail("Configuration changed while reading");
++ return result;
++}
+ bool same_snapshot(const Snapshot& a, const Snapshot& b)
+ {
+ return identity(a.directory_stamp, b.directory_stamp) && a.exists == b.exists &&
+@@ -136,6 +167,7 @@ struct Transaction::Impl
+ directory(open(parent.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)),
+ runtime_fd(open(runtime.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC))
+ {
++ if (!expected.writable) fail("Configuration is read-only");
+ if (!std::filesystem::path(runtime).is_absolute() || runtime.find('\0') != runtime.npos ||
+ expected.stamp.st_uid != geteuid()) fail("Preference owner/runtime mismatch");
+ check_directory(directory.value, parent, geteuid(), false);
+@@ -155,8 +187,11 @@ struct Transaction::Impl
+ if (!hash) fail("Cannot identify preference lock");
+ lock_name = std::string("ini-v1-") + hash + ".lock"; g_free(hash);
+ lock.value = openat(locks.value, lock_name.c_str(), O_CREAT | O_RDWR | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC, 0600);
+- if (lock.value < 0 || fstat(lock.value, &st) || !safe_lock(st) || flock(lock.value, LOCK_EX | LOCK_NB))
+- fail("Preference lock is unsafe or busy");
++ if (lock.value < 0 || fstat(lock.value, &st) || !safe_lock(st)) fail("Preference lock is unsafe");
++ // Another writer (Session or a sibling save) holds it only briefly.
++ for (int attempt = 0; flock(lock.value, LOCK_EX | LOCK_NB); ++attempt)
++ if ((errno != EWOULDBLOCK && errno != EINTR) || attempt >= 20) fail("Preference lock is busy");
++ else poll(nullptr, 0, 25);
+ validate();
+ observed = read_at(directory.value, expected.path, geteuid(), !expected.exists);
+ if (!same_snapshot(observed, expected)) fail("Configuration changed on disk");
+diff --git a/private/preference-transaction.hpp b/private/preference-transaction.hpp
+index e12db4d..752079c 100644
+--- a/private/preference-transaction.hpp
++++ b/private/preference-transaction.hpp
+@@ -15,11 +15,18 @@ struct Snapshot
+ std::string path, bytes;
+ struct stat stamp{}, directory_stamp{};
+ bool exists = true;
++ // False when only inspect_readable() could read it: values may be served,
++ // but every write is refused before any lock or temporary file.
++ bool writable = true;
+ };
+ Snapshot inspect(const std::string& absolute_path, uid_t owner);
+ // Parent must exist and pass the same checks. Missing is distinct from empty;
+ // a first save uses an atomic no-replace operation, never overwriting a creator.
+ Snapshot inspect_optional(const std::string& absolute_path, uid_t owner);
++// Read-only fallback for observation: the strict result when it passes, else
++// the same-owner regular file's bounded bytes (symlink, hard link, read-only
++// file or unsafe parent tolerated) marked writable = false.
++Snapshot inspect_readable(const std::string& absolute_path, uid_t owner, bool optional = false);
+ bool same_snapshot(const Snapshot&, const Snapshot&);
+ // Opaque equality proof for the private domain adapter; contains no file bytes.
+ std::string snapshot_key(const Snapshot&);
+diff --git a/private/shell-config.cpp b/private/shell-config.cpp
+index bab849c..104bb31 100644
+--- a/private/shell-config.cpp
++++ b/private/shell-config.cpp
+@@ -13,7 +13,7 @@ std::string trim(std::string text)
+ Observation observe(const std::string& path)
+ {
+ Observation result;
+- try { static_cast<preferences::Snapshot&>(result) = preferences::inspect(path, geteuid()); }
++ try { static_cast<preferences::Snapshot&>(result) = preferences::inspect_readable(path, geteuid()); }
+ catch (...) { return result; }
+ // Require an explicit, unambiguous field. Do not invent a serialization of
+ // wf-config's defaults, includes or nonstandard syntax.
+diff --git a/private/workspace-names.cpp b/private/workspace-names.cpp
+index 0a64ce8..719e20f 100644
+--- a/private/workspace-names.cpp
++++ b/private/workspace-names.cpp
+@@ -2,6 +2,7 @@
+ #include <glib.h>
+ #include <fcntl.h>
+ #include <filesystem>
++#include <poll.h>
+ #include <sys/file.h>
+ #include <sys/stat.h>
+ #include <unistd.h>
+@@ -55,11 +56,14 @@ bool valid_key(const std::string& key)
+ roundtrip.erase(roundtrip.find_last_not_of('=') + 1);
+ return encoded == roundtrip;
+ }
+-void check_directory(int fd)
++bool safe_directory(int fd)
+ {
+ struct stat st{};
+- if (fstat(fd, &st) || st.st_uid != geteuid() || (st.st_mode & 0700) != 0700 || (st.st_mode & 0022))
+- throw std::runtime_error("Unsafe workspace preferences directory");
++ return !fstat(fd, &st) && st.st_uid == geteuid() && (st.st_mode & 0700) == 0700 && !(st.st_mode & 0022);
++}
++void check_directory(int fd)
++{
++ if (!safe_directory(fd)) throw std::runtime_error("Unsafe workspace preferences directory");
+ }
+ std::string trim(std::string value)
+ {
+@@ -123,21 +127,30 @@ Names::Names(std::string config_home) : directory(std::move(config_home) + "/wf-
+ if (directory.front() != '/' || directory.find('\0') != directory.npos)
+ throw std::runtime_error("Invalid workspace preferences location");
+ }
+-NamesSnapshot Names::read() const
++NamesSnapshot Names::read(bool readable) const
+ {
+ const auto anchor = std::filesystem::path(directory).parent_path().string();
+ Fd base(open(anchor.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC));
+- check_directory(base.value);
+ NamesSnapshot result;
++ if (!safe_directory(base.value)) { if (!readable) check_directory(base.value); result.writable = false; }
+ if (fstat(base.value, &result.anchor)) throw std::runtime_error("Cannot observe preferences directory");
+ int raw = openat(base.value, "wf-shell", O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW);
++ if (raw < 0 && readable && (errno == ELOOP || errno == ENOTDIR))
++ { raw = openat(base.value, "wf-shell", O_RDONLY | O_DIRECTORY | O_CLOEXEC); result.writable = false; }
+ if (raw < 0 && errno == ENOENT) { result.names = parse("").names; return result; }
+- Fd dir(raw); check_directory(dir.value);
+- result.file = preferences::inspect_optional(directory + "/workspaces.ini", geteuid());
++ Fd dir(raw);
++ if (!safe_directory(dir.value)) { if (!readable) check_directory(dir.value); result.writable = false; }
++ result.file = readable ? preferences::inspect_readable(directory + "/workspaces.ini", geteuid(), true) :
++ preferences::inspect_optional(directory + "/workspaces.ini", geteuid());
++ result.writable &= result.file->writable;
+ if (result.file->exists)
+ {
+- if ((result.file->stamp.st_mode & 0022) || result.file->bytes.size() > limit)
+- throw std::runtime_error("Unsafe workspace preferences file");
++ if (result.file->bytes.size() > limit) throw std::runtime_error("Unsafe workspace preferences file");
++ if (result.file->stamp.st_mode & 0022)
++ {
++ if (!readable) throw std::runtime_error("Unsafe workspace preferences file");
++ result.writable = false;
++ }
+ result.bytes = result.file->bytes;
+ }
+ result.names = parse(result.bytes.value_or("")).names;
+@@ -170,8 +183,11 @@ Names::SaveResult Names::save(const NamesSnapshot& observed, const std::string&
+ Fd lock(openat(dir.value, "workspaces.lock", O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK, 0600));
+ struct stat st{};
+ if (fstat(lock.value, &st) || !S_ISREG(st.st_mode) || st.st_uid != geteuid() || st.st_nlink != 1 ||
+- (st.st_mode & 0077) || flock(lock.value, LOCK_EX | LOCK_NB))
+- throw std::runtime_error("Workspace preferences lock unavailable");
++ (st.st_mode & 0077)) throw std::runtime_error("Workspace preferences lock unavailable");
++ for (int attempt = 0; flock(lock.value, LOCK_EX | LOCK_NB); ++attempt)
++ if ((errno != EWOULDBLOCK && errno != EINTR) || attempt >= 20)
++ throw std::runtime_error("Workspace preferences lock unavailable");
++ else poll(nullptr, 0, 25);
+ auto before = observed.file.value_or(preferences::Snapshot{});
+ if (!observed.file)
+ {
+diff --git a/private/workspace-names.hpp b/private/workspace-names.hpp
+index e7cca32..594c386 100644
+--- a/private/workspace-names.hpp
++++ b/private/workspace-names.hpp
+@@ -16,6 +16,8 @@ struct NamesSnapshot
+ // Private exact file and parent identities; never serialized to a UI client.
+ std::optional<preferences::Snapshot> file;
+ struct stat anchor{};
++ // False when read() was allowed to tolerate an unsafe directory or file.
++ bool writable = true;
+ // In a provisioned resident, the service owns bytes/identities. Only this
+ // private scoped draft is retained here and released on client closure.
+ std::shared_ptr<const preferences::Draft> draft;
+@@ -26,7 +28,9 @@ class Names
+ public:
+ enum class SaveResult { saved, conflict, sync_uncertain };
+ explicit Names(std::string config_home);
+- NamesSnapshot read() const;
++ // readable: serve names from an unsafe/unwritable location as read-only
++ // (writable = false) instead of throwing. save() always rereads strictly.
++ NamesSnapshot read(bool readable = false) const;
+ // Optional local owner-epoch guard, checked before creation and at the
+ // transaction's final pre-replacement boundary. Never calls a UI/provider.
+ SaveResult save(const NamesSnapshot&, const std::string& key, const std::string& name,
+diff --git a/tests/input-authority.cpp b/tests/input-authority.cpp
+new file mode 100644
+index 0000000..2732b61
+--- /dev/null
++++ b/tests/input-authority.cpp
+@@ -0,0 +1,52 @@
++#include "input-authority.hpp"
++#include <cassert>
++#include <cstdlib>
++#include <fstream>
++#include <sstream>
++#include <string>
++#include <sys/inotify.h>
++#include <sys/stat.h>
++#include <sys/sysmacros.h>
++#include <unistd.h>
++
++using shell::input::inotify_device;
++// Tests link with --wrap=fsync; this one needs no fault injection.
++extern "C" int __real_fsync(int);
++extern "C" int __wrap_fsync(int fd) { return __real_fsync(fd); }
++// The kernel prints sdev in fdinfo as (major << 20) | minor; stat() does not.
++static uint64_t watched_sdev(const char *path)
++{
++ const int fd = inotify_init1(IN_CLOEXEC);
++ assert(fd >= 0);
++ assert(inotify_add_watch(fd, path, IN_CLOSE_WRITE) >= 0);
++ std::ifstream info("/proc/self/fdinfo/" + std::to_string(fd));
++ std::string token;
++ uint64_t sdev = ~uint64_t(0);
++ while (info >> token)
++ if (token.rfind("sdev:", 0) == 0) sdev = std::strtoull(token.c_str() + 5, nullptr, 16);
++ close(fd);
++ return sdev;
++}
++int main(int argc, char **argv)
++{
++ assert(inotify_device(makedev(0, 28)) == 0x1c);
++ assert(inotify_device(makedev(259, 6)) == 0x10300006);
++ assert(inotify_device(makedev(8, 1)) == 0x800001);
++ assert(inotify_device(makedev(253, 0x12345)) == 0xfd12345);
++ assert(inotify_device(makedev(0xfff, 0xfffff)) == 0xffffffff);
++ // Cross-check against the running kernel for this file and any given paths.
++ std::string paths[] = {argv[0], "/", "/proc/self"};
++ for (const auto& path : paths)
++ {
++ struct stat st{};
++ assert(!stat(path.c_str(), &st));
++ assert(watched_sdev(path.c_str()) == inotify_device(st.st_dev));
++ }
++ for (int i = 1; i < argc; ++i)
++ {
++ struct stat st{};
++ if (stat(argv[i], &st)) continue;
++ assert(watched_sdev(argv[i]) == inotify_device(st.st_dev));
++ }
++ return 0;
++}
+diff --git a/tests/preference-backends.cpp b/tests/preference-backends.cpp
+index 7eb961c..ecb430b 100644
+--- a/tests/preference-backends.cpp
++++ b/tests/preference-backends.cpp
+@@ -1,10 +1,13 @@
+ #include "preference-backends.hpp"
+ #include "preference-transaction.hpp"
+ #include "shell-config.hpp"
++#include <atomic>
+ #include <cassert>
++#include <chrono>
+ #include <filesystem>
+ #include <fstream>
+ #include <iostream>
++#include <thread>
+ #include <unistd.h>
+
+ namespace pc = shell::preferences::commit;
+@@ -161,5 +164,63 @@ int main()
+ // Both legacy and common leases are gone on return.
+ assert(workspaces.observe({})->apply(edit).state == pc::State::persisted);
+ std::cout << "PASS workspace creation, canonical keys, exact copies, epoch revocation and retained rollback lease\n";
++
++ // A briefly held writer lock is waited for instead of failing the save.
++ reset();
++ {
++ std::atomic<bool> held{false};
++ std::thread holder([&]
++ {
++ prefs::Transaction lease(prefs::inspect(path, geteuid()), runtime);
++ held = true; std::this_thread::sleep_for(std::chrono::milliseconds(150));
++ });
++ while (!held) std::this_thread::yield();
++ auto waited = panel.observe({});
++ assert(waited->apply(pc::Panel{true}).state == pc::State::persisted);
++ holder.join();
++ }
++ std::cout << "PASS busy writer lock is retried briefly\n";
++
++ // Unsafe or unwritable configurations are observed read-only: values are
++ // served, writes are refused before any lock and the strict checks remain.
++ const std::string shell_bytes = "[panel]\nautohide = true\n";
++ for (auto kind : {"symlink", "hardlink", "read-only", "group-writable"})
++ {
++ const auto home = root / (std::string("unsafe-") + kind), target = home / "shell.ini";
++ fs::create_directory(home); chmod(home.c_str(), 0700);
++ if (std::string(kind) == "symlink" || std::string(kind) == "hardlink")
++ {
++ write(home / "real.ini", shell_bytes); chmod((home / "real.ini").c_str(), 0600);
++ if (std::string(kind) == "symlink") fs::create_symlink(home / "real.ini", target);
++ else fs::create_hard_link(home / "real.ini", target);
++ } else write(target, shell_bytes);
++ if (std::string(kind) == "read-only") chmod(target.c_str(), 0400);
++ if (std::string(kind) == "group-writable") chmod(home.c_str(), 0770);
++ assert(throws([&] { prefs::inspect(target, geteuid()); }));
++ const auto readable = prefs::inspect_readable(target, geteuid());
++ assert(!readable.writable && readable.bytes == shell_bytes);
++ assert(throws([&] { prefs::Transaction refused(readable, runtime); }));
++ auto shell = pc::panel_backend("shell", target, guard);
++ auto seen = shell.observe({});
++ assert(!seen->writable() && seen->values()["autohide"].as_bool());
++ assert(seen->apply(pc::Panel{false}).state == pc::State::rejected);
++ assert(read(target) == shell_bytes);
++ auto docked = pc::dock_backend("shell", target, guard).observe({});
++ assert(!docked->writable());
++ if (std::string(kind) == "group-writable")
++ {
++ fs::create_directory(home / "wf-shell"); chmod((home / "wf-shell").c_str(), 0700);
++ write(home / "wf-shell/workspaces.ini", "[names]\nRFAtMQ-0-0=Web\n");
++ chmod((home / "wf-shell/workspaces.ini").c_str(), 0600);
++ auto names = pc::workspace_backend("workspaces", home, guard).observe({});
++ assert(!names->writable() && names->values()["names"]["RFAtMQ-0-0"].as_string() == "Web");
++ assert(names->apply(edit).state == pc::State::rejected);
++ assert(read(home / "wf-shell/workspaces.ini") == "[names]\nRFAtMQ-0-0=Web\n");
++ }
++ chmod(home.c_str(), 0700);
++ }
++ // A safe file still observes writable.
++ reset(); assert(panel.observe({})->writable() && workspaces.observe({})->writable());
++ std::cout << "PASS symlinked, hard-linked, read-only and group-writable configs observe read-only\n";
+ fs::remove_all(root);
+ }
+diff --git a/tests/preference-commit.cpp b/tests/preference-commit.cpp
+index 6930124..fa9989a 100644
+--- a/tests/preference-commit.cpp
++++ b/tests/preference-commit.cpp
+@@ -15,7 +15,7 @@ struct Backend
+ {
+ unsigned reads = 0, applies = 0;
+ size_t retained = 1;
+- bool observe_throws = false, apply_throws = false;
++ bool observe_throws = false, apply_throws = false, writable = true;
+ pc::State state = pc::State::persisted;
+ std::optional<Json> override_values;
+ pc::Patch last = pc::Panel{false};
+@@ -27,6 +27,7 @@ struct Observation : pc::Observation
+ pc::Query query;
+ Observation(Backend& backend, pc::Domain domain, pc::Query query) : backend(backend), domain(domain), query(query) {}
+ size_t retained_bytes() const override { return backend.retained; }
++ bool writable() const override { return backend.writable; }
+ Json values() const override
+ {
+ if (backend.override_values) return *backend.override_values;
+@@ -138,6 +139,10 @@ int main()
+ token = first.observe("workspaces");
+ error(first.commit(token, "workspaces", json(R"({"key":"RFAx-0-0","name":"bad\nname"})")), "invalid-patch");
+ first.commit(token, "workspaces", json(R"({"key":"RFAx-0-0","name":"Work"})"));
++ // A read-only observation is served but its commit never reaches apply.
++ b.writable = false; const auto applied = b.applies; token = first.observe();
++ error(first.commit(token), "read-only"); error(first.commit(token), "stale-revision");
++ assert(b.applies == applied); b.writable = true;
+ assert(std::get<pc::Workspace>(b.last).name == "Work");
+ for (auto state : {pc::State::sync_uncertain, pc::State::conflict, pc::State::rejected, pc::State::unknown})
+ {
+diff --git a/tests/preference-service-process.py b/tests/preference-service-process.py
+index cc3ede3..37ebaae 100644
+--- a/tests/preference-service-process.py
++++ b/tests/preference-service-process.py
+@@ -123,6 +123,23 @@ def main(service, fixture):
+ peer.close()
+ print('PASS production host framing, real file saves, conflicts, scoped receipts and no replay', flush=True)
+
++ # A symlinked (dotfile-managed) shell config starts read-only: ready,
++ # values served, every commit refused without touching either file.
++ linked = home/'linked'; linked.mkdir(mode=0o700)
++ (linked/'real.ini').write_bytes(original); (linked/'wf-shell.ini').symlink_to(linked/'real.ini')
++ peer = Peer(fixture, linked, env)
++ try:
++ observed = peer.observe_reply('panel'); assert observed['status'] == 'ok', observed
++ assert observed['data']['values']['autohide'] is False, observed
++ refused = peer.save('panel', observed['data']['revision'], {'autohide': True})
++ assert refused['status'] == 'error' and refused['data']['code'] == 'read-only', refused
++ assert peer.save('dock', peer.observe('dock'), {'enabled': True})['data']['code'] == 'read-only'
++ assert (linked/'real.ini').read_bytes() == original and (linked/'wf-shell.ini').is_symlink()
++ assert peer.save('workspaces', peer.observe('workspaces'), {'key':'RFAtMQ-0-0','name':'Work'})['outcome'] == 'persisted'
++ finally:
++ peer.close()
++ print('PASS symlinked shell config starts read-only and refuses commits with read-only', flush=True)
++
+ peer = Peer(fixture, home, env, grants=['panel'])
+ try:
+ assert peer.observe_reply('dock')['data']['code'] == 'unregistered-domain'
diff --git a/gui-apps/wayfire-preferences/wayfire-preferences-0.1.0-r1.ebuild b/gui-apps/wayfire-preferences/wayfire-preferences-0.1.0-r2.ebuild
index 2c4360386f61..b19d3f70f338 100644
--- a/gui-apps/wayfire-preferences/wayfire-preferences-0.1.0-r1.ebuild
+++ b/gui-apps/wayfire-preferences/wayfire-preferences-0.1.0-r2.ebuild
@@ -3,7 +3,8 @@ EAPI=8
inherit meson
DESCRIPTION="SrcUX components for Wayfire: preferences"
HOMEPAGE="https://www.srcux.com/"
-SRC_URI="https://www.srcux.com/distfiles/${PF}.tar.gz"
+SRC_URI="https://www.srcux.com/distfiles/${PN}-0.1.0-r1.tar.gz"
+PATCHES=( "${FILESDIR}/${PN}-0.1.0-audit-fixes.patch" )
LICENSE="MIT"
SLOT="0"
KEYWORDS="~amd64"
diff --git a/gui-apps/wayfire-session/files/wayfire-session-0.1.11-audit-fixes.patch b/gui-apps/wayfire-session/files/wayfire-session-0.1.11-audit-fixes.patch
new file mode 100644
index 000000000000..9955e42dc0b3
--- /dev/null
+++ b/gui-apps/wayfire-session/files/wayfire-session-0.1.11-audit-fixes.patch
@@ -0,0 +1,775 @@
+wayfire-session 0.1.11: fixes from the October 2026 code audit (notif-1966).
+Applies with -p1 to the unmodified 0.1.11 tarball (the ebuild has no other PATCHES).
+
+- Activities: launched apps get their own session (POSIX_SPAWN_SETSID for argv
+ recipes; GIO .desktop launches go through g_desktop_app_info_launch_uris_as_manager
+ with a child-setup that calls setsid()). The supervisor's process-group
+ SIGTERM/SIGKILL of the resident no longer kills the user's apps. Launched apps
+ and the session command also get SIGPIPE reset to SIG_DFL (no longer ignored).
+- companion API: audio.act rejects non-object params instead of aborting on the
+ json assert. The other handlers were checked; none touch client input with
+ operator[]/as_* before checking its type.
+- companion server: an oversized reply gets a "response-too-large" error for that
+ request id, not an exception. The deadline timer, the idle dispatcher and the
+ per-peer IO handlers catch anything else and drop only the affected peer, so
+ GLib no longer destroys the async delivery/deadline timer.
+- Notifications: image-data/icon_data are checked before use (type, 8 bits per
+ sample, channels match alpha, positive sizes, rowstride, exact length, at most
+ 1 MiB). Data that fails the checks is dropped with no copy kept, and the
+ notification itself is still delivered. The pixbuf uses GBytes, so nothing
+ leaks on failure. Text is capped by truncating at a UTF-8 boundary: app_name
+ and summary at 4 KiB, body at 16 KiB, action labels at 4 KiB. Identifiers,
+ paths and sound hints over 4 KiB are dropped. At most 16 action pairs are
+ kept, and a pair whose key is over 4 KiB is dropped. Each sender may hold at
+ most 32 notifications: its own oldest expires first.
+- Resident startup: a wf-shell.ini that is a symlink, a hard link or read-only,
+ or whose ~/.config is group-writable, no longer stops the desktop. The resident
+ logs "preferences are read-only", reports domains.preferences="read-only" and
+ shows panel/dock editing as unavailable. Writes still run every strict check. A
+ busy lock is retried (10 x 50 ms).
+- Activities: request identities, accepted/cancelled sets and journal "requests"
+ are pruned along with the 32-entry jobs ring. Attempts for deleted activities
+ are dropped too. Cancels for requests that were never admitted are not stored.
+- Wallpaper workers: std::thread is now declared last, so run() never sees
+ members that are not yet initialised.
+- Input authority: inotify sdev matching uses the kernel's internal dev_t
+ encoding (major<<20|minor) (hunk supplied by the wayfire-preferences fix).
+- Tests: new companion-server-oversize test; headless-resident now covers
+ audio.act with non-object params, an invalid image-data hint and the per-sender
+ cap.
+
+diff --git a/meson.build b/meson.build
+index c01fccc..4d14ae9 100644
+--- a/meson.build
++++ b/meson.build
+@@ -52,7 +52,7 @@ configure_file(input: 'data/wayfire-sessiond.json.in', output: 'wayfire-sessiond
+ install_data('README.md', 'LICENSE', 'source-manifest.json', install_dir: get_option('datadir') / 'doc/wayfire-session')
+ if get_option('tests')
+ python = find_program('python3')
+- foreach name : ['session-endpoint', 'preference-ui-channel', 'preference-ui-control', 'files-host', 'files-filemanager', 'dock-host', 'file-launch-owner']
++ foreach name : ['session-endpoint', 'preference-ui-channel', 'preference-ui-control', 'files-host', 'files-filemanager', 'dock-host', 'file-launch-owner', 'companion-server-oversize']
+ test_sources = ['tests/' + name + '.cpp']
+ test_deps = [owner_dep]
+ if name == 'files-host'
+diff --git a/private/src/control/desktop-session.cpp b/private/src/control/desktop-session.cpp
+index 7b01841..1638475 100644
+--- a/private/src/control/desktop-session.cpp
++++ b/private/src/control/desktop-session.cpp
+@@ -332,6 +332,7 @@ int supervisor_main(int argc, char **argv)
+ if (!command_child.pid)
+ {
+ child_setup(parent);
++ signal(SIGPIPE, SIG_DFL); // The user's session command must not inherit our ignored SIGPIPE.
+ execvp(argv[command], argv + command); _exit(127);
+ }
+ if (command_child.pid < 0) return 1;
+diff --git a/private/src/control/desktop-sessiond.cpp b/private/src/control/desktop-sessiond.cpp
+index 6948729..b0ca4b3 100644
+--- a/private/src/control/desktop-sessiond.cpp
++++ b/private/src/control/desktop-sessiond.cpp
+@@ -77,11 +77,30 @@ int readiness_fd()
+ unsetenv("WF_SHELL_READY_FD");
+ return static_cast<int>(value);
+ }
+-void check_preferences(const std::string& config, const std::string& runtime)
++bool check_preferences(const std::string& config, const std::string& runtime)
+ {
+ // Reserve/release the real shared writer lock without writing or contacting
+ // the UI. Actual requests still validate fresh bytes and domain semantics.
+- shell::preferences::Transaction reservation(shell::preferences::inspect(config, geteuid()), runtime);
++ // An unsafe or unwritable file (symlink, hard link, read-only, group-writable
++ // parent) only makes preferences read-only: every write keeps the strict
++ // checks and editing is reported unavailable. Never refuse the desktop.
++ std::string error;
++ try
++ {
++ shell::preferences::inspect(config, geteuid());
++ // The lock is nonblocking and wayfire-preferences may briefly hold it.
++ for (int attempt = 0; attempt < 10; ++attempt)
++ {
++ try
++ {
++ shell::preferences::Transaction reservation(shell::preferences::inspect(config, geteuid()), runtime);
++ return true;
++ } catch (const std::exception& e) { error = e.what(); }
++ g_usleep(50 * 1000);
++ }
++ } catch (const std::exception& e) { error = e.what(); }
++ std::cerr << "wayfire-sessiond: preferences are read-only: " << error << '\n';
++ return false;
+ }
+ bool bootstrap(ui::Remote& remote, const std::shared_ptr<WayfireIPC>& compositor,
+ Signals& signals, std::string& failure, bool separate_dock)
+@@ -164,7 +183,7 @@ int main(int argc, char **argv)
+ auto endpoint = std::make_unique<api::SessionEndpoint>(runtime, *inherited);
+ endpoint->claim_serving(); // No providers or API admission before exclusion.
+ api::close_inherited_endpoint(*inherited);
+- check_preferences(argv[2], runtime);
++ bool writable = check_preferences(argv[2], runtime);
+ Gio::init(); Gdk::wrap_init(); // Register image wrappers; no GTK/display initialization.
+ signal(SIGPIPE, SIG_IGN);
+ auto loop = Glib::MainLoop::create(); Signals signals(loop);
+@@ -183,7 +202,7 @@ int main(int argc, char **argv)
+ recovery = std::make_unique<ui::control::Receiver>(control.value, nullptr, remote);
+ control.reset();
+ }
+- check_preferences(argv[2], runtime);
++ if (writable) writable = check_preferences(argv[2], runtime);
+ // Owner construction follows required-domain checks. Optional providers
+ // advertise availability through the existing v1 observation contract.
+ auto notifications = Daemon::Launch();
+@@ -249,7 +268,7 @@ int main(int argc, char **argv)
+ }
+ if (recovery) record["ui_recovery"] = 1u;
+ record["profile"] = "headless-staging-v1";
+- record["domains"]["preferences"] = "ready";
++ record["domains"]["preferences"] = writable ? "ready" : "read-only";
+ record["domains"]["compositor"] = "ready";
+ record["domains"]["ui_registration"] = "observed";
+ const auto bytes = record.serialize() + "\n";
+diff --git a/private/src/core/activities-owner.cpp b/private/src/core/activities-owner.cpp
+index de7f93e..df1277c 100644
+--- a/private/src/core/activities-owner.cpp
++++ b/private/src/core/activities-owner.cpp
+@@ -8,6 +8,7 @@
+ #include <sys/stat.h>
+ #include <sys/wait.h>
+ #include <fcntl.h>
++#include <signal.h>
+ #include <spawn.h>
+ #include <unistd.h>
+ #include <cerrno>
+@@ -264,6 +265,14 @@ bool positive_process(const J& view, const std::string& attempt)
+ }
+ return false;
+ }
++// Launched applications belong to the user, not to the resident: the
++// supervisor stops the resident with process-group signals. Run them in their
++// own session and undo the resident's ignored SIGPIPE before exec.
++void detach_child(gpointer)
++{
++ setsid();
++ signal(SIGPIPE, SIG_DFL);
++}
+ void launch(const J& recipe, const std::string& attempt)
+ {
+ validate_launch(recipe);
+@@ -274,12 +283,18 @@ void launch(const J& recipe, const std::string& attempt)
+ require(app != nullptr, "Desktop entry unavailable");
+ GAppLaunchContext* context = g_app_launch_context_new();
+ g_app_launch_context_setenv(context, "WF_SHELL_LAUNCH_ID", attempt.c_str());
+- GList* files = nullptr;
++ GList* uris = nullptr;
+ for (const auto& path : recipe["files"])
+- files = g_list_append(files, g_file_new_for_path(path.get<std::string>().c_str()));
++ {
++ GFile* file = g_file_new_for_path(path.get<std::string>().c_str());
++ uris = g_list_append(uris, g_file_get_uri(file));
++ g_object_unref(file);
++ }
+ GError* error = nullptr;
+- const bool okay = g_app_info_launch(G_APP_INFO(app), files, context, &error);
+- g_list_free_full(files, g_object_unref);
++ // Same launch as g_app_info_launch(), plus a child setup hook.
++ const bool okay = g_desktop_app_info_launch_uris_as_manager(app, uris, context,
++ G_SPAWN_SEARCH_PATH, detach_child, nullptr, nullptr, nullptr, &error);
++ g_list_free_full(uris, g_free);
+ g_object_unref(context); g_object_unref(app);
+ if (!okay)
+ {
+@@ -308,8 +323,16 @@ void launch(const J& recipe, const std::string& attempt)
+ if (recipe.contains("cwd") && recipe["cwd"].is_string())
+ require(!posix_spawn_file_actions_addchdir_np(&actions,
+ recipe["cwd"].get<std::string>().c_str()), "Cannot use launch directory");
++ posix_spawnattr_t attributes;
++ posix_spawnattr_init(&attributes);
++ sigset_t defaults;
++ sigemptyset(&defaults);
++ sigaddset(&defaults, SIGPIPE);
++ posix_spawnattr_setsigdefault(&attributes, &defaults);
++ posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETSID | POSIX_SPAWN_SETSIGDEF);
+ pid_t pid = 0;
+- const int result = posix_spawnp(&pid, argv[0], &actions, nullptr, argv.data(), envp.data());
++ const int result = posix_spawnp(&pid, argv[0], &actions, &attributes, argv.data(), envp.data());
++ posix_spawnattr_destroy(&attributes);
+ posix_spawn_file_actions_destroy(&actions);
+ require(!result, "Application launch failed");
+ std::thread([pid] { int status; waitpid(pid, &status, 0); }).detach();
+@@ -335,6 +358,7 @@ struct Owner::Impl
+ wf::json_t control(const wf::json_t& request);
+ void loop();
+ void save_journal();
++ void prune_journal();
+ void load_recipes();
+ void publish(std::optional<J> recipe, const std::string& id, uint64_t expected);
+ J snapshot() const { return compositor(socket, "window-state/spatial-observe"); }
+@@ -376,6 +400,7 @@ Owner::Impl::Impl()
+ for (auto& job : journal["jobs"])
+ if (job.value("state","")=="running")
+ { job["state"] = "uncertain"; job["message"] = "Resident restarted; inspect before retrying"; }
++ prune_journal();
+ for (auto& [id, request] : journal["requests"].items()) accepted[id] = request;
+ save_journal();
+ worker = std::thread([this] { loop(); });
+@@ -393,6 +418,35 @@ void Owner::Impl::save_journal()
+ std::lock_guard<std::mutex> guard(mutex);
+ public_jobs = journal["jobs"];
+ }
++// Request identities, cancellations and attempts live only as long as the
++// 32-entry jobs ring (or the activity) that can still report them. Worker
++// thread or constructor only; accepted/cancelled are guarded by the mutex.
++void Owner::Impl::prune_journal()
++{
++ std::set<std::string> live;
++ for (const auto& job : journal["jobs"]) live.insert(job.value("id",""));
++ for (auto it = journal["requests"].begin(); it != journal["requests"].end();)
++ it = live.count(it.key()) ? std::next(it) : journal["requests"].erase(it);
++ std::set<std::string> activities;
++ {
++ std::lock_guard<std::mutex> guard(mutex);
++ for (const auto& [id, value] : recipes) activities.insert(id);
++ for (const auto& request : queued) live.insert(request.value("request-id",""));
++ for (auto it = accepted.begin(); it != accepted.end();)
++ it = live.count(it->first) ? std::next(it) : accepted.erase(it);
++ for (auto it = cancelled.begin(); it != cancelled.end();)
++ it = live.count(*it) ? std::next(it) : cancelled.erase(it);
++ }
++ for (auto it = journal["attempts"].begin(); it != journal["attempts"].end();)
++ {
++ // Binding keys are "activity/<id>/<role>"; ids never contain '/'.
++ const auto& binding = it.key();
++ const auto end = binding.find('/', 9);
++ const bool known = binding.rfind("activity/", 0) == 0 && end != std::string::npos &&
++ activities.count(binding.substr(9, end - 9));
++ it = known ? std::next(it) : journal["attempts"].erase(it);
++ }
++}
+ void Owner::Impl::load_recipes()
+ {
+ auto data = load_json(recipes_path, 512*1024, {{"format",1},{"recipes",J::object()}});
+@@ -458,7 +512,8 @@ wf::json_t Owner::Impl::control(const wf::json_t& input)
+ {
+ const auto id=text(req,"id");
+ require(!id.empty() && cancelled.size()<1024,"Invalid cancellation identity");
+- cancelled.insert(id);
++ // Only an admitted request can be cancelled; pruning follows its job.
++ if (accepted.count(id)) cancelled.insert(id);
+ return to_wf(J{{"requested",true}});
+ }
+ require(std::set<std::string>{"create","edit","delete","activate","restore",
+@@ -499,6 +554,7 @@ void Owner::Impl::loop()
+ if (journal["jobs"].size()>=32) journal["jobs"].erase(journal["jobs"].begin());
+ journal["jobs"].push_back({{"id",id},{"op",request.at("op")},
+ {"activity",request.value("id","")},{"state","running"}});
++ prune_journal();
+ save_journal();
+ require(!is_cancelled(id),"Cancelled before dispatch");
+ auto result=run(request);
+diff --git a/private/src/core/companion-server-contract.cpp b/private/src/core/companion-server-contract.cpp
+index 5d19cb5..5e50016 100644
+--- a/private/src/core/companion-server-contract.cpp
++++ b/private/src/core/companion-server-contract.cpp
+@@ -770,7 +770,8 @@ std::vector<wf::json_t> Session::submit(const wf::json_t& request)
+ }
+ if (method == "audio.act")
+ {
+- if (!fields(request, {"version", "id", "method", "params", "instance"}))
++ if (!fields(request, {"version", "id", "method", "params", "instance"}) ||
++ !request["params"].is_object())
+ return reject("invalid-request");
+ const auto& params = request["params"];
+ if (!params["action"].is_string() || !integer(params["token"]))
+diff --git a/private/src/core/companion-server.cpp b/private/src/core/companion-server.cpp
+index e1432e8..6e9cc70 100644
+--- a/private/src/core/companion-server.cpp
++++ b/private/src/core/companion-server.cpp
+@@ -69,13 +69,18 @@ struct Server::Impl
+ {
+ auto& p = *it->second;
+ bool okay = true, delivered = false;
+- while (auto result = p.session.take_ready())
++ // An exception must not escape: GLib would destroy this timer,
++ // which is the only async delivery and deadline enforcement.
++ try
+ {
+- delivered = true;
+- if (!queue(p, *result)) { okay = false; break; }
+- }
+- if (okay && delivered) okay = flush(p);
+- if (okay && delivered) watch(p);
++ while (auto result = p.session.take_ready())
++ {
++ delivered = true;
++ if (!queue(p, *result)) { okay = false; break; }
++ }
++ if (okay && delivered) okay = flush(p);
++ if (okay && delivered) watch(p);
++ } catch (...) { okay = false; }
+ if (!okay) { it = peers.erase(it); continue; }
+ if (!p.session.async_queued()) p.asynchronous = {};
+ if ((!p.session.handshaken() && expired(p.opened)) || expired(p.partial) ||
+@@ -96,9 +101,13 @@ struct Server::Impl
+ {
+ auto it = peers.find(fd);
+ if (it == peers.end()) return false;
+- if ((cond & (Glib::IOCondition::IO_HUP | Glib::IOCondition::IO_ERR)) != Glib::IOCondition{} ||
+- !receive(*it->second) || !flush(*it->second))
+- { peers.erase(it); return false; }
++ bool okay = false;
++ try
++ {
++ okay = (cond & (Glib::IOCondition::IO_HUP | Glib::IOCondition::IO_ERR)) == Glib::IOCondition{} &&
++ receive(*it->second) && flush(*it->second);
++ } catch (...) {}
++ if (!okay) { peers.erase(it); return false; }
+ watch(*it->second);
+ schedule();
+ return false; // watch() installed the replacement, with current OUT interest.
+@@ -107,7 +116,14 @@ struct Server::Impl
+ bool queue(Peer& p, const wf::json_t& reply)
+ {
+ if (p.outbox.front().empty()) p.stalled = Clock::now();
+- return p.outbox.push(reply);
++ try { return p.outbox.push(reply); }
++ catch (const std::exception&) {}
++ // Oversized reply: answer that request with a bounded error instead.
++ wf::json_t error;
++ for (const char *key : {"version", "id", "instance", "revision"})
++ if (reply.has_member(key)) error[key] = reply[key];
++ error["status"] = "error"; error["outcome"] = "not-applied"; error["error"] = "response-too-large";
++ try { return p.outbox.push(error); } catch (const std::exception&) { return false; }
+ }
+ bool receive(Peer& p)
+ {
+@@ -164,11 +180,15 @@ struct Server::Impl
+ for (auto it = peers.begin(); it != peers.end();)
+ {
+ auto& p = *it->second;
+- // Check EOF and pending cancel/read traffic before a queued write.
+- bool okay = receive(p);
+- if (okay && p.input.empty())
+- if (auto result = p.session.dispatch_one()) okay = queue(p, *result);
+- if (okay) okay = flush(p);
++ bool okay = false;
++ try
++ {
++ // Check EOF and pending cancel/read traffic before a queued write.
++ okay = receive(p);
++ if (okay && p.input.empty())
++ if (auto result = p.session.dispatch_one()) okay = queue(p, *result);
++ if (okay) okay = flush(p);
++ } catch (...) { okay = false; } // Drop only this peer; keep the idle source.
+ if (!okay) { it = peers.erase(it); continue; }
+ if (!p.session.queued()) p.queued = {};
+ // Dispatch can now start an asynchronous preference mutation.
+diff --git a/private/src/core/input-authority.cpp b/private/src/core/input-authority.cpp
+index 2363c34..ea2f97a 100644
+--- a/private/src/core/input-authority.cpp
++++ b/private/src/core/input-authority.cpp
+@@ -201,9 +201,7 @@ std::string process_config(pid_t pid)
+ }
+ bool watched(pid_t pid, const Snapshot& config)
+ {
+- const uint64_t device = (minor(config.stamp.st_dev) & 0xff) |
+- (uint64_t(major(config.stamp.st_dev)) << 8) |
+- ((uint64_t(minor(config.stamp.st_dev)) & ~uint64_t(0xff)) << 12);
++ const uint64_t device = inotify_device(config.stamp.st_dev);
+ unsigned entries = 0;
+ for (const auto& fd : std::filesystem::directory_iterator("/proc/" + std::to_string(pid) + "/fdinfo"))
+ {
+@@ -248,6 +246,13 @@ bool watched(pid_t pid, const Snapshot& config)
+ }
+ }
+
++uint64_t inotify_device(dev_t device)
++{
++ // fdinfo prints the superblock's internal kernel dev_t (MKDEV: 12-bit
++ // major above a 20-bit minor), not the userspace stat() encoding.
++ return (uint64_t(major(device)) << 20) | minor(device);
++}
++
+ ProcessIdentity peer_identity(int connected_socket)
+ {
+ ucred peer{};
+diff --git a/private/src/core/input-authority.hpp b/private/src/core/input-authority.hpp
+index 22e787a..e7a4c3b 100644
+--- a/private/src/core/input-authority.hpp
++++ b/private/src/core/input-authority.hpp
+@@ -3,6 +3,7 @@
+ #include "input-config.hpp"
+ #include <cstdint>
+ #include <string>
++#include <sys/types.h>
+
+ namespace shell::input
+ {
+@@ -22,6 +23,8 @@ struct Authority
+ // The caller can compare this with the peer of its already-open resident IPC
+ // connection. A path alone is never a compositor identity.
+ ProcessIdentity peer_identity(int connected_socket);
++// Encode a stat() st_dev the way /proc/<pid>/fdinfo prints an inotify sdev.
++uint64_t inotify_device(dev_t device);
+ ProcessIdentity socket_identity(const std::string& wayfire_socket);
+ // Inspect the process on a freshly connected Wayfire IPC socket. Only the
+ // process's arguments/environment select the file. Editing requires the loaded
+diff --git a/private/src/core/wallpaper-worker.hpp b/private/src/core/wallpaper-worker.hpp
+index e9b00ad..2c2e6b8 100644
+--- a/private/src/core/wallpaper-worker.hpp
++++ b/private/src/core/wallpaper-worker.hpp
+@@ -35,9 +35,9 @@ class WallpaperWorker
+ std::condition_variable wake;
+ Request requested;
+ WallpaperState published;
+- std::thread thread;
+ std::atomic<uint64_t> generation{0};
+ std::atomic<bool> stopping{false};
++ std::thread thread; // Last: run() reads every member above.
+
+ void run();
+ public:
+@@ -72,9 +72,9 @@ class WallpaperEditWorker
+ std::optional<Request> requested;
+ WallpaperPreparation result;
+ bool failure_delivered = false;
+- std::thread thread;
+ std::atomic<uint64_t> generation{0};
+ std::atomic<bool> stopping{false};
++ std::thread thread; // Last: run() reads every member above.
+ void run();
+ public:
+ explicit WallpaperEditWorker(std::string manifest_path);
+diff --git a/private/src/panel/widgets/notifications/daemon.cpp b/private/src/panel/widgets/notifications/daemon.cpp
+index fc4286b..dec939a 100644
+--- a/private/src/panel/widgets/notifications/daemon.cpp
++++ b/private/src/panel/widgets/notifications/daemon.cpp
+@@ -6,6 +6,7 @@
+
+ #include <iostream>
+ #include <algorithm>
++#include <optional>
+
+ #define FDN_PATH "/org/freedesktop/Notifications"
+ #define FDN_NAME "org.freedesktop.Notifications"
+@@ -89,6 +90,18 @@ try {
+
+ notifications.insert({id, notification});
+ notification_tokens[id] = ++state_revision;
++ // One sender may hold at most 32 entries, so a flood expires its own
++ // oldest notification instead of pushing out other applications'.
++ std::optional<std::pair<uint64_t, Notification::id_type>> own_oldest;
++ size_t own = 0;
++ for (const auto& [other, entry] : notifications)
++ {
++ if (entry.additional_info.sender != sender) continue;
++ ++own;
++ const auto token = notification_tokens[other];
++ if (!own_oldest || token < own_oldest->first) own_oldest = {token, other};
++ }
++ if (own > 32 && own_oldest) closeNotification(own_oldest->second, Expired);
+ if (notifications.size() > 100)
+ {
+ auto oldest = std::min_element(notification_tokens.begin(), notification_tokens.end(),
+diff --git a/private/src/panel/widgets/notifications/notification-info.cpp b/private/src/panel/widgets/notifications/notification-info.cpp
+index a646637..5df87a6 100644
+--- a/private/src/panel/widgets/notifications/notification-info.cpp
++++ b/private/src/panel/widgets/notifications/notification-info.cpp
+@@ -27,37 +27,83 @@ K getHint(const std::map<std::string, Glib::VariantBase> & map, const std::strin
+ return K();
+ }
+
++// Any session-bus client controls these values; bound what is retained.
++constexpr size_t max_image_bytes = 1024 * 1024; // 512x512 RGBA
++constexpr size_t max_text_bytes = 4096, max_body_bytes = 16 * 1024, max_hint_bytes = 4096;
++constexpr size_t max_actions = 32; // 16 key/label pairs; only those are reachable.
++
++// Cut at a character boundary; D-Bus strings are valid UTF-8.
++Glib::ustring truncated(const Glib::ustring & text, size_t limit)
++{
++ if (text.bytes() <= limit)
++ {
++ return text;
++ }
++
++ const char *begin = text.data();
++ const char *end = begin + limit;
++ while (end > begin && (static_cast<unsigned char>(*end) & 0xC0) == 0x80)
++ {
++ --end;
++ }
++
++ return Glib::ustring(begin, end);
++}
++
++// Identifiers and paths are useless when cut; drop them instead.
++Glib::ustring bounded(const Glib::ustring & text, size_t limit)
++{
++ return text.bytes() <= limit ? text : Glib::ustring();
++}
++
++// Returns no image for malformed or oversized data rather than failing the
++// notification. gdk-pixbuf owns its own reference to the copied bytes.
+ Glib::RefPtr<Gdk::Pixbuf> pixbufFromVariant(const Glib::VariantBase & variant)
+ {
+- gint32 width;
+- gint32 height;
+- gint32 rowstride;
+- bool has_alpha;
+- gint32 bits_per_sample;
+- gint32 channels;
+- std::vector<guint8> data;
+- extractValues(variant, width, height, rowstride, has_alpha, bits_per_sample, channels, data);
+-
+- // for integer positive A, floor((A + 7) / 8) = ceil(A / 8)
+- gulong pixel_size = (channels * bits_per_sample + 7) / 8;
+- if (data.size() != ((gulong)height - 1) * (gulong)rowstride + (gulong)width * pixel_size)
++ auto *value = const_cast<GVariant*>(variant.gobj());
++ if (!g_variant_is_of_type(value, G_VARIANT_TYPE("(iiibiiay)")))
++ {
++ return {};
++ }
++
++ // Read the header and borrow the pixel array without copying it.
++ gint32 width, height, rowstride, bits_per_sample, channels;
++ gboolean has_alpha;
++ GVariant *pixels = nullptr;
++ g_variant_get(value, "(iiibii@ay)", &width, &height, &rowstride, &has_alpha,
++ &bits_per_sample, &channels, &pixels);
++ gsize size = 0;
++ const auto *data = static_cast<const guint8*>(g_variant_get_fixed_array(pixels, &size, 1));
++ if ((size == 0) || (size > max_image_bytes) || (bits_per_sample != 8) ||
++ (channels != (has_alpha ? 4 : 3)) || (width <= 0) || (height <= 0) || (rowstride <= 0) ||
++ (uint64_t(rowstride) < uint64_t(width) * uint64_t(channels)))
++ {
++ g_variant_unref(pixels);
++ return {};
++ }
++
++ // All factors are positive 31-bit values: no 64-bit overflow.
++ const uint64_t expected = (uint64_t(height) - 1) * uint64_t(rowstride) + uint64_t(width) * uint64_t(channels);
++ if (expected != size)
+ {
+- throw std::invalid_argument(
+- "Cannot create pixbuf from variant: expected data size doesn't equal actual one.");
++ g_variant_unref(pixels);
++ return {};
+ }
+
+- auto *data_ptr = new auto(std::move(data));
+- return Gdk::Pixbuf::create_from_data(data_ptr->data(),
+- Gdk::Colorspace::RGB, has_alpha, bits_per_sample, width, height,
+- rowstride, [data_ptr] (auto*) { delete data_ptr; });
++ GBytes *bytes = g_bytes_new(data, size);
++ g_variant_unref(pixels);
++ GdkPixbuf *pixbuf = gdk_pixbuf_new_from_bytes(bytes, GDK_COLORSPACE_RGB, has_alpha, 8,
++ width, height, rowstride);
++ g_bytes_unref(bytes);
++ return pixbuf ? Glib::wrap(pixbuf) : Glib::RefPtr<Gdk::Pixbuf>();
+ }
+ } // namespace
+
+ Notification::Hints::Hints(const std::map<std::string, Glib::VariantBase> & map)
+ {
+ action_icons = getHint<bool>(map, "actions-icons");
+- category = getHint<Glib::ustring>(map, "category");
+- desktop_entry = getHint<Glib::ustring>(map, "desktop-entry");
++ category = bounded(getHint<Glib::ustring>(map, "category"), max_hint_bytes);
++ desktop_entry = bounded(getHint<Glib::ustring>(map, "desktop-entry"), max_hint_bytes);
+ if (map.count("image-data") != 0)
+ {
+ image_data = pixbufFromVariant(map.at("image-data"));
+@@ -66,15 +112,15 @@ Notification::Hints::Hints(const std::map<std::string, Glib::VariantBase> & map)
+ image_data = pixbufFromVariant(map.at("icon_data"));
+ }
+
+- image_path = getHint<Glib::ustring>(map, "image-path");
++ image_path = bounded(getHint<Glib::ustring>(map, "image-path"), max_hint_bytes);
+ if (image_path.empty())
+ {
+- image_path = getHint<Glib::ustring>(map, "image_path");
++ image_path = bounded(getHint<Glib::ustring>(map, "image_path"), max_hint_bytes);
+ }
+
+ resident = getHint<bool>(map, "resident");
+- sound_file = getHint<Glib::ustring>(map, "sound-file");
+- sound_name = getHint<Glib::ustring>(map, "sound-name");
++ sound_file = bounded(getHint<Glib::ustring>(map, "sound-file"), max_hint_bytes);
++ sound_name = bounded(getHint<Glib::ustring>(map, "sound-name"), max_hint_bytes);
+ suppress_sound = getHint<bool>(map, "suppress-sound");
+ transient = getHint<bool>(map, "transient");
+ x = getHint<gint32>(map, "x");
+@@ -86,6 +132,23 @@ Notification::Notification(const Glib::VariantContainerBase & parameters, const
+ {
+ std::map<std::string, Glib::VariantBase> hints_map;
+ extractValues(parameters, app_name, id, app_icon, summary, body, actions, hints_map, expire_time);
++ app_name = truncated(app_name, max_text_bytes);
++ app_icon = bounded(app_icon, max_hint_bytes);
++ summary = truncated(summary, max_text_bytes);
++ body = truncated(body, max_body_bytes);
++ // Action keys are echoed back in ActionInvoked, so an oversized key drops
++ // its pair rather than being altered. Labels are display text.
++ std::vector<Glib::ustring> kept;
++ for (size_t index = 0; index + 1 < actions.size() && kept.size() < max_actions; index += 2)
++ {
++ if (actions[index].bytes() <= max_text_bytes)
++ {
++ kept.push_back(actions[index]);
++ kept.push_back(truncated(actions[index + 1], max_text_bytes));
++ }
++ }
++
++ actions = std::move(kept);
+ requested_replacement = id != 0;
+ if (id == 0)
+ {
+diff --git a/tests/companion-server-oversize.cpp b/tests/companion-server-oversize.cpp
+new file mode 100644
+index 0000000..ab0c57c
+--- /dev/null
++++ b/tests/companion-server-oversize.cpp
+@@ -0,0 +1,100 @@
++// An oversized asynchronous reply must answer only its request; the server's
++// delivery/deadline timer must keep serving later asynchronous replies.
++#include "companion-server.hpp"
++#include "companion-server-contract.hpp"
++#include "companion-wire.hpp"
++#include <glib.h>
++#include <cassert>
++#include <cstdlib>
++#include <cstring>
++#include <filesystem>
++#include <iostream>
++#include <string>
++#include <sys/socket.h>
++#include <sys/stat.h>
++#include <sys/un.h>
++#include <unistd.h>
++namespace api = shell::companion;
++using Json = wf::json_t;
++struct Backend : api::PreferenceBackend
++{
++ std::function<void(Json)> pending;
++ api::State read() const override { return {1, false, false}; }
++ api::CommitResult set_panel_autohide(bool, uint64_t) override { return api::CommitResult::rejected; }
++ bool can_control_applications() const override { return true; }
++ bool applications_call(const std::string&, const Json&, std::function<void(Json)> done) override
++ { pending = std::move(done); return true; }
++};
++void spin(unsigned ms = 20)
++{
++ auto until = g_get_monotonic_time() + ms * 1000;
++ do { while (g_main_context_iteration(nullptr, false)) {} g_usleep(1000); } while (g_get_monotonic_time() < until);
++}
++void send_frame(int fd, Json value)
++{
++ const auto bytes = api::frame(value);
++ assert(send(fd, bytes.data(), bytes.size(), MSG_NOSIGNAL) == ssize_t(bytes.size()));
++}
++Json receive_frame(int fd)
++{
++ std::string buffer;
++ for (int n = 0; n < 300; ++n)
++ {
++ char chunk[4096];
++ auto count = recv(fd, chunk, sizeof(chunk), MSG_DONTWAIT);
++ if (count > 0) buffer.append(chunk, count);
++ if (buffer.size() >= 4)
++ {
++ uint32_t length = 0;
++ for (int i = 0; i < 4; ++i) length = (length << 8) | uint8_t(buffer[i]);
++ if (buffer.size() >= 4 + length)
++ {
++ Json value; assert(!Json::parse_string(buffer.substr(4, length), value)); return value;
++ }
++ }
++ spin();
++ }
++ assert(false && "no reply"); return {};
++}
++Json request(uint64_t id, const char* method, const std::string& instance)
++{
++ Json r;
++ r["version"] = 1; r["id"] = id; r["method"] = method;
++ Json params; assert(!Json::parse_string("{}", params)); r["params"] = params;
++ if (std::string(method) != "hello") r["instance"] = instance;
++ return r;
++}
++int main()
++{
++ char pattern[] = "/dev/shm/wfd-oversize-XXXXXX";
++ assert(mkdtemp(pattern)); chmod(pattern, 0700);
++ {
++ Backend backend; api::Server server(backend, pattern);
++ int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
++ sockaddr_un address{}; address.sun_family = AF_UNIX;
++ std::strncpy(address.sun_path, server.socket_path().c_str(), sizeof(address.sun_path) - 1);
++ assert(!connect(fd, reinterpret_cast<sockaddr*>(&address), sizeof(address)));
++ send_frame(fd, request(1, "hello", ""));
++ const auto instance = receive_frame(fd)["instance"].as_string();
++ for (uint64_t id : {2, 3})
++ {
++ send_frame(fd, request(id, "applications.list", instance));
++ for (int n = 0; n < 100 && !backend.pending; ++n) spin();
++ assert(backend.pending);
++ Json result; result["status"] = "ok"; result["outcome"] = "observed";
++ // The first reply exceeds the frame limit only after the envelope rewrite.
++ result["applications"]["blob"] = std::string(id == 2 ? api::max_body : 16, 'x');
++ auto done = std::move(backend.pending); backend.pending = {};
++ done(result);
++ auto reply = receive_frame(fd);
++ assert(reply["id"].as_uint64() == id);
++ if (id == 2)
++ assert(reply["status"].as_string() == "error" && reply["error"].as_string() == "response-too-large");
++ else
++ assert(reply["status"].as_string() == "ok");
++ }
++ close(fd);
++ }
++ std::filesystem::remove_all(pattern);
++ std::cout << "PASS oversized async reply answers response-too-large and later async replies still arrive\n";
++}
+diff --git a/tests/headless-resident.py b/tests/headless-resident.py
+index 9b77732..508d400 100644
+--- a/tests/headless-resident.py
++++ b/tests/headless-resident.py
+@@ -211,8 +211,9 @@ while not Path(sys.argv[2]).exists(): time.sleep(.02)
+ self.log.close()
+
+
+-def notify(connection, summary='Headless notification', image=True):
+- hints = {'image-data': GLib.Variant('(iiibiiay)', (1, 1, 4, True, 8, 4, [255, 0, 0, 255]))} if image else {}
++def notify(connection, summary='Headless notification', image=True, hints=None):
++ if hints is None:
++ hints = {'image-data': GLib.Variant('(iiibiiay)', (1, 1, 4, True, 8, 4, [255, 0, 0, 255]))} if image else {}
+ reply = connection.call_sync('org.freedesktop.Notifications', '/org/freedesktop/Notifications',
+ 'org.freedesktop.Notifications', 'Notify', GLib.Variant('(susssasa{sv}i)',
+ ('Fixture', 0, '', summary, 'Body\nwith newline', ['open', 'Open'], hints, -1)),
+@@ -297,6 +298,29 @@ def exercise_services(s, peer, bus, passed):
+ assert peer.call('notifications.dismiss', {'id': notification_id, 'token': row['token']})['outcome'] == 'applied'
+ assert peer.call('notifications.dismiss', {'id': notification_id, 'token': row['token']})['error'] == 'stale-observation'
+
++ host = s.host_pid()
++ for params in ([1], 'x', 5):
++ assert peer.call('audio.act', params)['error'] == 'invalid-request'
++ assert s.host_pid() == host and peer.call('state.read')['status'] == 'ok'
++ passed('Non-object audio.act params reject without aborting the resident')
++
++ other = Gio.DBusConnection.new_for_address_sync(os.environ['DBUS_SESSION_BUS_ADDRESS'],
++ Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None)
++ kept = notify(other, 'Other sender', image=False)
++ # 16-bit samples were silently rejected by gdk-pixbuf and leaked the copy.
++ bad = notify(bus, 'x' * 100000, hints={'image-data': GLib.Variant('(iiibiiay)', (1, 1, 2, False, 16, 1, [0, 0]))})
++ assert bad
++ for index in range(40):
++ notify(bus, 'flood %d' % index, image=False)
++ value = peer.call('notifications.read', {'offset': 0})['notifications']
++ ids = set()
++ for offset in range(0, value['total'], 4):
++ ids |= {row['id'] for row in peer.call('notifications.read', {'offset': offset})['notifications']['entries']}
++ assert value['total'] == 33 and kept in ids and bad not in ids, (value['total'], kept in ids)
++ assert len(value['entries'][0]['summary']) <= 160
++ assert s.host_pid() == host
++ passed('Invalid image-data is dropped, and one sender is capped at 32 notifications without evicting others')
++
+
+ def main():
+ binaries = [Path(p).resolve() for p in sys.argv[1:4]]
diff --git a/gui-apps/wayfire-session/wayfire-session-0.1.11.ebuild b/gui-apps/wayfire-session/wayfire-session-0.1.11-r1.ebuild
index 1e4d983998b7..1e4d983998b7 100644
--- a/gui-apps/wayfire-session/wayfire-session-0.1.11.ebuild
+++ b/gui-apps/wayfire-session/wayfire-session-0.1.11-r1.ebuild
diff --git a/gui-apps/wf-kill/files/wf-kill-9999-lock-cancel.patch b/gui-apps/wf-kill/files/wf-kill-9999-lock-cancel.patch
new file mode 100644
index 000000000000..b37436590ace
--- /dev/null
+++ b/gui-apps/wf-kill/files/wf-kill-9999-lock-cancel.patch
@@ -0,0 +1,116 @@
+wf-kill: cancel a pending selection when the output is inhibited (session lock)
+and restore the cursor when the last grabbed output is removed.
+
+- grab_interface becomes a member of wf_kill_view with a .cancel handler.
+ output_t::set_inhibited(true) (session lock) calls cancel_active_plugins();
+ previously wf-kill had no cancel callback, so its grab and activation
+ survived the lock and the first left click after unlock closed (and with
+ -k SIGKILLed) the window under the pointer.
+- New wf_kill_view::cancel_grab(): release all grabs, drop the pending
+ pirate-cursor idle call, restore the default cursor, send cancel to the
+ requester. Used by the cancel handler and by the output pre-remove path,
+ which previously did not restore the cursor.
+
+diff --git a/src/plugin/wayfire-kill-view.cpp b/src/plugin/wayfire-kill-view.cpp
+index 3c657b7..64fb13b 100644
+--- a/src/plugin/wayfire-kill-view.cpp
++++ b/src/plugin/wayfire-kill-view.cpp
+@@ -38,11 +38,6 @@
+ #include "wayfire-kill-view.hpp"
+ #include "wayfire-kill-view-server-protocol.h"
+
+-wf::plugin_activation_data_t grab_interface{
+- .name = "wf-kill",
+- .capabilities = wf::CAPABILITY_GRAB_INPUT,
+-};
+-
+ static void bind_manager(wl_client *client, void *data,
+ uint32_t version, uint32_t id);
+
+@@ -101,6 +96,24 @@ void wf_kill_view::release_grabs()
+ grab_active = false;
+ }
+
++/* Abort a live selection without closing anything (lock, output loss). */
++void wf_kill_view::cancel_grab()
++{
++ if (!grab_active)
++ {
++ return;
++ }
++
++ release_grabs();
++ idle_set_cursor.disconnect();
++ wf::get_core().set_cursor("default");
++ if (requester)
++ {
++ send_cancel(requester);
++ requester = nullptr;
++ }
++}
++
+ void wf_kill_view::start_grab(wl_resource *resource)
+ {
+ /* One selection at a time; replacing a live grab orphans its scene node. */
+@@ -196,6 +209,13 @@ void wf_kill_view::set_base_ptr(wf::pointer_interaction_t *base)
+
+ wf_kill_view::wf_kill_view()
+ {
++ /* Output inhibition (session lock) cancels active plugins; without this
++ * the selection would survive the lock and fire on the next click. */
++ grab_interface.cancel = [=] ()
++ {
++ cancel_grab();
++ };
++
+ manager = wl_global_create(wf::get_core().display,
+ &wf_kill_view_base_interface, 1, this, bind_manager);
+
+@@ -216,15 +236,9 @@ wf_kill_view::wf_kill_view()
+ it->second->ungrab_input();
+ ev->output->deactivate_plugin(&grab_interface);
+ input_grabs.erase(it);
+- if (input_grabs.empty() && grab_active)
++ if (input_grabs.empty())
+ {
+- grab_active = false;
+- idle_set_cursor.disconnect();
+- if (requester)
+- {
+- send_cancel(requester);
+- requester = nullptr;
+- }
++ cancel_grab();
+ }
+ };
+ wf::get_core().output_layout->connect(&on_output_pre_remove);
+diff --git a/src/plugin/wayfire-kill-view.hpp b/src/plugin/wayfire-kill-view.hpp
+index b7e5696..8e8210c 100644
+--- a/src/plugin/wayfire-kill-view.hpp
++++ b/src/plugin/wayfire-kill-view.hpp
+@@ -27,12 +27,17 @@
+ #include <wayfire/plugins/common/input-grab.hpp>
+ #include <wayfire/output-layout.hpp>
+ #include <wayfire/view.hpp>
++#include <wayfire/plugin.hpp>
+
+ class wf_kill_view
+ {
+ wl_global *manager;
+
+ public:
++ wf::plugin_activation_data_t grab_interface{
++ .name = "wf-kill",
++ .capabilities = wf::CAPABILITY_GRAB_INPUT,
++ };
+ wf::pointer_interaction_t *base;
+ void set_base_ptr(wf::pointer_interaction_t *base);
+ std::vector<wl_resource*> client_resources;
+@@ -41,6 +46,7 @@ class wf_kill_view
+ bool grab_active = false;
+ void start_grab(wl_resource *resource);
+ void release_grabs();
++ void cancel_grab();
+ void client_destroyed(wl_resource *resource);
+ void close_view(wayfire_view view, wl_resource *resource);
+ void send_view_info(wl_resource *resource);
diff --git a/gui-apps/wf-kill/wf-kill-9999-r2.ebuild b/gui-apps/wf-kill/wf-kill-9999-r3.ebuild
index f0fafd60c390..e37cbbff30a6 100644
--- a/gui-apps/wf-kill/wf-kill-9999-r2.ebuild
+++ b/gui-apps/wf-kill/wf-kill-9999-r3.ebuild
@@ -31,6 +31,7 @@ BDEPEND="
PATCHES=(
"${FILESDIR}/killtopview.patch"
"${FILESDIR}/${PN}-grab-safety.patch"
+ "${FILESDIR}/${P}-lock-cancel.patch"
)
src_install() {
diff --git a/gui-libs/wayfire-ui/files/wayfire-ui-0.1.6-audit-fixes.patch b/gui-libs/wayfire-ui/files/wayfire-ui-0.1.6-audit-fixes.patch
new file mode 100644
index 000000000000..cdd4d5fa33f1
--- /dev/null
+++ b/gui-libs/wayfire-ui/files/wayfire-ui-0.1.6-audit-fixes.patch
@@ -0,0 +1,348 @@
+wayfire-ui 0.1.6: code-audit fixes
+
+- Icons: file icons (absolute/"~/" paths, serialized GIcons, .desktop Icon=)
+ resolve only for local paths that are missing or regular files of at most
+ 16 MiB, so FIFOs, devices and directories can no longer hang or exhaust
+ the GTK thread; non-local URIs (GVfs fetches) and other serialized icon
+ types are rejected. Themed and bytes icons are unchanged.
+- Jobs: helpers are spawned as process-group leaders (POSIX_SPAWN_SETPGROUP)
+ and cancellation, timeouts and failures SIGKILL the whole group via
+ pidfd_send_signal(PIDFD_SIGNAL_PROCESS_GROUP) (fallback: kill(-pgid) while
+ the leader is unreaped), so shell pipelines and background children no
+ longer pile up. A normally exiting helper keeps its detached children.
+- Tests: ui-process-group, extra ui-icons cases.
+No ABI change (same 36 exported symbols).
+
+diff --git a/include/wayfire-desktop/icons.h b/include/wayfire-desktop/icons.h
+index 928c950..64b9fde 100644
+--- a/include/wayfire-desktop/icons.h
++++ b/include/wayfire-desktop/icons.h
+@@ -23,7 +23,11 @@ typedef struct {
+ * (original/ASCII-lowercase ID, optional org.kde. prefix and .desktop suffix).
+ * Mappings contain an icon name, path or serialized GIcon and win even if the
+ * theme lacks that name. The first duplicate key wins; empty mappings are ignored.
+- * A NULL theme disables theme-name lookup; desktop/file resolution still works. */
++ * A NULL theme disables theme-name lookup; desktop/file resolution still works.
++ * File icons (from paths, serialized GIcons or desktop entries) resolve only
++ * for local paths that are missing or regular files of at most 16 MiB; other
++ * serialized icon types are rejected. Names chosen by other programs (app_id, tray IconName) should be
++ * reduced to plain icon-name tokens by the caller before resolving. */
+ WFD_UI_API GIcon *wfd_ui_icon_resolve(GtkIconTheme *theme, const char *spec,
+ const wfd_ui_icon_mapping *mappings, size_t count);
+ /* Resolve with the image's display theme. 1 means set, 0 unresolved (unchanged),
+diff --git a/meson.build b/meson.build
+index 9d082ff..2afa8f2 100644
+--- a/meson.build
++++ b/meson.build
+@@ -34,6 +34,9 @@ if get_option('tests')
+ jobs_cpp = executable('ui-jobs', 'tests/jobs.cpp', include_directories: inc,
+ dependencies: [gtk, threads], link_with: ui, cpp_args: ['-UNDEBUG'])
+ test('ui-jobs', jobs_cpp, timeout: 30)
++ process_group = executable('ui-process-group', 'tests/process-group.c', include_directories: inc,
++ dependencies: [gtk, threads], link_with: ui, c_args: ['-UNDEBUG'])
++ test('ui-process-group', process_group, timeout: 30)
+ consumer = executable('ui-consumer-c', 'tests/consumer.c',
+ include_directories: inc, dependencies: gtk, link_with: ui, c_args: ['-UNDEBUG'])
+ test('ui-consumer-c', consumer)
+diff --git a/private/app-jobs.cpp b/private/app-jobs.cpp
+index 6fd2198..21de682 100644
+--- a/private/app-jobs.cpp
++++ b/private/app-jobs.cpp
+@@ -12,6 +12,9 @@
+ #include <sys/syscall.h>
+ #include <unistd.h>
+ extern char **environ;
++#ifndef PIDFD_SIGNAL_PROCESS_GROUP
++#define PIDFD_SIGNAL_PROCESS_GROUP (1UL << 2)
++#endif
+
+ namespace wfd_ui_jobs_private
+ {
+@@ -19,15 +22,30 @@ namespace wfd_ui_jobs_private
+ // thread stop the exact helper without racing PID reuse or waiting for a worker.
+ // Detached filesystem workers can finish during process teardown. Keep this
+ // process-lifetime registry valid until the OS releases it.
++// Each helper leads its own process group, so cancellation and timeouts also
++// stop shell pipelines and other children it started, not only the leader.
++struct Child { int pidfd; pid_t pid; bool reaped; };
+ static auto& children_mutex = *new std::mutex;
+-static auto& children = *new std::map<const void*, int>;
++static auto& children = *new std::map<const void*, Child>;
++// Call with children_mutex held (it orders this against reaping the leader).
++static void kill_group(const Child& child)
++{
++ // The pidfd pins the group's identity even after the leader is reaped.
++ if (!syscall(SYS_pidfd_send_signal, child.pidfd, SIGKILL, nullptr, PIDFD_SIGNAL_PROCESS_GROUP) ||
++ errno != EINVAL)
++ return;
++ // Kernels before 6.9: an unreaped leader keeps its PID, and so the group
++ // ID, from being reused. Once reaped, only the leader can be addressed.
++ if (!child.reaped) kill(-child.pid, SIGKILL);
++ else syscall(SYS_pidfd_send_signal, child.pidfd, SIGKILL, nullptr, 0);
++}
+ void cancel_token(const Cancel& token)
+ {
+ if (!token) return;
+ *token = true;
+ std::lock_guard lock(children_mutex);
+ auto child = children.find(token.get());
+- if (child != children.end()) syscall(SYS_pidfd_send_signal, child->second, SIGKILL, nullptr, 0);
++ if (child != children.end()) kill_group(child->second);
+ }
+ Jobs::Jobs()
+ {
+@@ -101,21 +119,26 @@ ProcessResult process(const std::vector<std::string>& argv, const std::string& i
+ std::vector<char*> args;
+ for (auto& a : argv) args.push_back(const_cast<char*>(a.c_str()));
+ args.push_back(nullptr);
++ posix_spawnattr_t attributes;
++ posix_spawnattr_init(&attributes);
++ posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETPGROUP);
++ posix_spawnattr_setpgroup(&attributes, 0);
+ pid_t pid;
+- auto result = posix_spawnp(&pid, args[0], &actions, nullptr, args.data(), environ);
++ auto result = posix_spawnp(&pid, args[0], &actions, &attributes, args.data(), environ);
++ posix_spawnattr_destroy(&attributes);
+ posix_spawn_file_actions_destroy(&actions);
+ if (result) { close_all(); throw std::runtime_error(argv[0] + ": " + strerror(result)); }
+ int pidfd = syscall(SYS_pidfd_open, pid, 0);
+ if (pidfd < 0)
+ {
+- kill(pid, SIGKILL); while (waitpid(pid, nullptr, 0) < 0 && errno == EINTR) {}
++ kill(-pid, SIGKILL); while (waitpid(pid, nullptr, 0) < 0 && errno == EINTR) {}
+ close_all(); throw std::runtime_error("Cannot track capture helper lifetime");
+ }
+ if (pidfd >= 0)
+ {
+ std::lock_guard lock(children_mutex);
+- children[cancel.get()] = pidfd;
+- if (*cancel) syscall(SYS_pidfd_send_signal, pidfd, SIGKILL, nullptr, 0);
++ children[cancel.get()] = Child{pidfd, pid, false};
++ if (*cancel) kill_group(children.at(cancel.get()));
+ }
+ auto unregister = [&]
+ {
+@@ -163,12 +186,22 @@ ProcessResult process(const std::vector<std::string>& argv, const std::string& i
+ } else if (!n || (errno != EAGAIN && errno != EINTR))
+ { close(pipes[i][0]); pipes[i][0] = -1; }
+ }
+- if (!reaped) reaped = waitpid(pid, &status, WNOHANG) == pid;
++ if (!reaped)
++ {
++ std::lock_guard lock(children_mutex);
++ reaped = waitpid(pid, &status, WNOHANG) == pid;
++ auto child = children.find(cancel.get());
++ if (reaped && child != children.end()) child->second.reaped = true;
++ }
+ if (reaped && quiet_error) break;
+ }
+ } catch (...)
+ {
+- if (!reaped) { kill(pid, SIGKILL); while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {} }
++ {
++ std::lock_guard lock(children_mutex);
++ kill_group(Child{pidfd, pid, reaped});
++ }
++ if (!reaped) while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {}
+ unregister(); close_all(); throw;
+ }
+ unregister(); close_all();
+diff --git a/src/icons.cpp b/src/icons.cpp
+index 80caf3d..f5a83e5 100644
+--- a/src/icons.cpp
++++ b/src/icons.cpp
+@@ -1,5 +1,7 @@
+ #include <wayfire-desktop/icons.h>
+ #include <gio/gdesktopappinfo.h>
++#include <sys/stat.h>
++#include <cerrno>
+ #include <cmath>
+ #include <memory>
+ #include <sstream>
+@@ -15,6 +17,28 @@ bool valid(const wfd_ui_icon_mapping *mappings, size_t count)
+ if (!mappings[i].key || !mappings[i].value) return false;
+ return true;
+ }
++// GTK loads file icons synchronously on the UI thread. Accept only local
++// paths that are bounded regular files: never FIFOs, devices or directories
++// (which block or never end), and never non-local URIs (which would make GVfs
++// fetch them). A missing file stays a file icon, as before: it fails fast.
++constexpr off_t max_icon_file = 16 * 1024 * 1024;
++bool loadable_file(GFile *file)
++{
++ std::unique_ptr<char, decltype(&g_free)> path(g_file_get_path(file), g_free);
++ if (!path || !g_file_is_native(file)) return false;
++ struct stat info;
++ if (stat(path.get(), &info) != 0) return errno == ENOENT;
++ return S_ISREG(info.st_mode) && info.st_size <= max_icon_file;
++}
++// Themed and in-memory icons need no I/O; file icons must pass loadable_file();
++// anything else (emblemed, custom serialized types) is rejected. Consumes icon.
++GIcon *vetted(GIcon *icon)
++{
++ if (!icon || G_IS_THEMED_ICON(icon) || G_IS_BYTES_ICON(icon)) return icon;
++ if (G_IS_FILE_ICON(icon) && loadable_file(g_file_icon_get_file(G_FILE_ICON(icon)))) return icon;
++ g_object_unref(icon);
++ return nullptr;
++}
+ GIcon *literal(const std::string& value)
+ {
+ if (value.empty()) return nullptr;
+@@ -22,9 +46,9 @@ GIcon *literal(const std::string& value)
+ {
+ auto path = value.front() == '~' ? std::string(g_get_home_dir()) + value.substr(1) : value;
+ Object<GFile> file(g_file_new_for_path(path.c_str()), g_object_unref);
+- return G_ICON(g_file_icon_new(file.get()));
++ return vetted(G_ICON(g_file_icon_new(file.get())));
+ }
+- return g_icon_new_for_string(value.c_str(), nullptr);
++ return vetted(g_icon_new_for_string(value.c_str(), nullptr));
+ }
+ GIcon *desktop(const std::string& id)
+ {
+@@ -39,7 +63,7 @@ GIcon *desktop(const std::string& id)
+ if (info)
+ {
+ auto icon = g_app_info_get_icon(G_APP_INFO(info.get()));
+- if (icon) return G_ICON(g_object_ref(icon));
++ if (icon) return vetted(G_ICON(g_object_ref(icon)));
+ }
+ }
+ return nullptr;
+diff --git a/tests/icons.c b/tests/icons.c
+index 818198e..421ab82 100644
+--- a/tests/icons.c
++++ b/tests/icons.c
+@@ -3,6 +3,8 @@
+ #include <math.h>
+ #include <stdio.h>
+ #include <string.h>
++#include <sys/stat.h>
++#include <unistd.h>
+
+ static void write_file(const char *path, const char *value)
+ {
+@@ -65,6 +67,23 @@ int main(int argc, char **argv)
+ const char *ids[] = {"IconFixture", "iconfixture", "org.kde.iconfixture", "org.kde.iconfixture.desktop"};
+ file_icon(wfd_ui_icon_resolve(NULL, ids[i], NULL, 0), image_path);
+ }
++ /* Only bounded regular local files load as file icons. */
++ char *fifo_path = g_build_filename(root, "fifo.png", NULL);
++ assert(mkfifo(fifo_path, 0600) == 0);
++ assert(!wfd_ui_icon_resolve(NULL, fifo_path, NULL, 0));
++ assert(!wfd_ui_icon_resolve(NULL, root, NULL, 0));
++ assert(!wfd_ui_icon_resolve(NULL, "/dev/zero", NULL, 0));
++ char *serialized_fifo = g_strconcat(". GFileIcon file://", fifo_path, NULL);
++ assert(!wfd_ui_icon_resolve(NULL, serialized_fifo, NULL, 0));
++ assert(!wfd_ui_icon_resolve(NULL, ". GFileIcon http://example.invalid/icon.png", NULL, 0));
++ wfd_ui_icon_mapping fifo_mapping = {"fifo", fifo_path};
++ assert(!wfd_ui_icon_resolve(NULL, "fifo", &fifo_mapping, 1));
++ GFile *regular = g_file_new_for_path(image_path);
++ GIcon *regular_icon = g_file_icon_new(regular);
++ char *serialized = g_icon_to_string(regular_icon);
++ file_icon(wfd_ui_icon_resolve(NULL, serialized, NULL, 0), image_path);
++ g_free(serialized); g_object_unref(regular_icon); g_object_unref(regular);
++ g_free(serialized_fifo); assert(unlink(fifo_path) == 0); g_free(fifo_path);
+ assert(!wfd_ui_icon_resolve(NULL, "custom", NULL, 0));
+ mappings[0].value = "replacement";
+ named(wfd_ui_icon_resolve(NULL, "custom", mappings, 4), "replacement");
+diff --git a/tests/process-group.c b/tests/process-group.c
+new file mode 100644
+index 0000000..f0691f4
+--- /dev/null
++++ b/tests/process-group.c
+@@ -0,0 +1,94 @@
++#include <wayfire-desktop/jobs.h>
++#include <assert.h>
++#include <pthread.h>
++#include <signal.h>
++#include <stdio.h>
++#include <stdlib.h>
++#include <string.h>
++#include <unistd.h>
++
++/* A cancelled or timed-out helper must not leave its pipeline or background
++ * children running: the whole process group is killed, not only /bin/sh. */
++static int gone(pid_t pid)
++{
++ for (int i = 0; i < 200; ++i)
++ {
++ char path[64], state = 0, line[512];
++ snprintf(path, sizeof path, "/proc/%d/stat", (int)pid);
++ FILE *stat = fopen(path, "r");
++ if (!stat) return 1;
++ const int read = fgets(line, sizeof line, stat) != NULL;
++ fclose(stat);
++ const char *end = read ? strrchr(line, ')') : NULL;
++ if (end && sscanf(end + 1, " %c", &state) == 1 && (state == 'Z' || state == 'X')) return 1;
++ g_usleep(10000);
++ }
++ return 0;
++}
++static pid_t read_pid(const char *path)
++{
++ for (int i = 0; i < 200; ++i)
++ {
++ char *text = NULL;
++ if (g_file_get_contents(path, &text, NULL, NULL) && strchr(text, '\n'))
++ {
++ const pid_t pid = (pid_t)strtol(text, NULL, 10);
++ g_free(text);
++ return pid;
++ }
++ g_free(text);
++ g_usleep(10000);
++ }
++ return 0;
++}
++static void *cancel_later(void *data)
++{
++ g_usleep(300000);
++ wfd_ui_cancel_request(data);
++ return NULL;
++}
++static void run(const char *pidfile, int cancel_it)
++{
++ char *script = g_strdup_printf("sleep 60 | sleep 61 & echo $! > '%s'; wait", pidfile);
++ const char *args[] = {"/bin/sh", "-c", script, NULL};
++ wfd_ui_cancel *cancel = wfd_ui_cancel_create();
++ wfd_ui_process_result *result = NULL;
++ GError *error = NULL;
++ pthread_t thread;
++ if (cancel_it) assert(!pthread_create(&thread, NULL, cancel_later, cancel));
++ const gint64 start = g_get_monotonic_time();
++ assert(wfd_ui_process_run(args, NULL, 0, cancel, 1024, cancel_it ? 0 : 300, 0, &result, &error) == -1);
++ assert(!result && error);
++ assert(g_get_monotonic_time() - start < 5 * G_USEC_PER_SEC);
++ g_clear_error(&error);
++ if (cancel_it) pthread_join(thread, NULL);
++ const pid_t background = read_pid(pidfile);
++ assert(background > 0);
++ assert(gone(background));
++ wfd_ui_cancel_unref(cancel);
++ g_free(script);
++ unlink(pidfile);
++}
++int main(void)
++{
++ char *root = g_dir_make_tmp("wfd-group-XXXXXX", NULL); assert(root);
++ char *pidfile = g_build_filename(root, "pid", NULL);
++ run(pidfile, 0);
++ run(pidfile, 1);
++ /* A helper that exits normally keeps its detached children (wl-copy style). */
++ char *script = g_strdup_printf("sleep 2 >/dev/null 2>&1 & echo $! > '%s'", pidfile);
++ const char *args[] = {"/bin/sh", "-c", script, NULL};
++ wfd_ui_cancel *cancel = wfd_ui_cancel_create();
++ wfd_ui_process_result *result = NULL;
++ GError *error = NULL;
++ assert(!wfd_ui_process_run(args, NULL, 0, cancel, 1024, 5000, 1, &result, &error));
++ wfd_ui_process_result_free(result);
++ const pid_t survivor = read_pid(pidfile);
++ assert(survivor > 0 && kill(survivor, 0) == 0);
++ kill(survivor, SIGKILL);
++ wfd_ui_cancel_unref(cancel);
++ unlink(pidfile); rmdir(root);
++ g_free(script); g_free(pidfile); g_free(root);
++ puts("PASS timeout and cancellation kill the helper's whole process group; normal exit keeps detached children");
++ return 0;
++}
diff --git a/gui-libs/wayfire-ui/wayfire-ui-0.1.6.ebuild b/gui-libs/wayfire-ui/wayfire-ui-0.1.6-r1.ebuild
index 5bb664048b0e..5bb664048b0e 100644
--- a/gui-libs/wayfire-ui/wayfire-ui-0.1.6.ebuild
+++ b/gui-libs/wayfire-ui/wayfire-ui-0.1.6-r1.ebuild
diff --git a/gui-wm/wayfire/files/wayfire-0.11.0-r72-review-followups.patch b/gui-wm/wayfire/files/wayfire-0.11.0-r72-review-followups.patch
new file mode 100644
index 000000000000..0e0ec4214fb1
--- /dev/null
+++ b/gui-wm/wayfire/files/wayfire-0.11.0-r72-review-followups.patch
@@ -0,0 +1,258 @@
+Review follow-ups for the r71 patches (apply after r71-stitch-noop-reload).
+
+1. output-layout: stitched members kept one stitch_power_off flag for both
+ power owners, so wlr-output-power requests and plugin calls through
+ output_layout_t::set_output_power() (digitalrain-power) undid each other.
+ Keep a flag per source; the member's slice is off while either asks.
+2. vswitch: vswitch/set-workspace over IPC returned ok when the switch was
+ refused (plugin exclusivity); return an error instead.
+3. vswitch: the fallback slide (no cube) set the workspace before starting the
+ wall, so the first frame showed the destination. Point the wall at the
+ old position as soon as the slide starts.
+4. vswitch: when columns wrap, slide by add_direction()'s shortest delta
+ instead of the raw workspace difference (fallback and active slides).
+5. ipc: a client whose send queue never fully drained kept every sent byte.
+ Drop the sent prefix once it is at least 64 KiB and half of the queue.
+6. window-scope: mark fullscreen requests from an explicit window-scope
+ reflow like tile requests, so tile moves the node to that workspace.
+
+No installed header or class layout changes; plugin ABI is unchanged.
+
+diff --git a/plugins/ipc/ipc.cpp b/plugins/ipc/ipc.cpp
+index 9d0b3d4..fd4f63f 100644
+--- a/plugins/ipc/ipc.cpp
++++ b/plugins/ipc/ipc.cpp
+@@ -149,6 +149,8 @@ int wl_loop_handle_ipc_client_fd_event(int, uint32_t mask, void *data)
+ static constexpr int MAX_MESSAGE_LEN = (1 << 20);
+ static constexpr int HEADER_LEN = 4;
+ static constexpr size_t MAX_OUTPUT_QUEUE = 16u * 1024 * 1024;
++/** Sent bytes kept at the front of a client's output queue before compacting. */
++static constexpr size_t OUTPUT_COMPACT_MIN = 64u * 1024;
+
+ wf::ipc::client_t::client_t(server_t *ipc, int fd)
+ {
+@@ -335,6 +337,15 @@ void wf::ipc::client_t::flush_output()
+ {
+ // Reader is slow: keep the remainder queued and flush when writable
+ // instead of dropping the connection on transient backpressure.
++ // Drop the sent prefix once it is most of the buffer, so a reader
++ // which never fully catches up does not grow it without bound.
++ // Copying at most out_sent bytes keeps this amortized linear.
++ if ((out_sent >= OUTPUT_COMPACT_MIN) && (out_sent >= out_buffer.size() / 2))
++ {
++ out_buffer.erase(out_buffer.begin(), out_buffer.begin() + out_sent);
++ out_sent = 0;
++ }
++
+ set_writable_watch(true);
+ return;
+ }
+diff --git a/plugins/vswitch/vswitch.cpp b/plugins/vswitch/vswitch.cpp
+index 3ad44e8..0cfd201 100644
+--- a/plugins/vswitch/vswitch.cpp
++++ b/plugins/vswitch/vswitch.cpp
+@@ -164,10 +164,18 @@ class workspace_switch_t
+ */
+ virtual void set_target_workspace(point_t workspace)
+ {
+- point_t cws = output->wset()->get_current_workspace();
++ set_target_workspace(workspace, workspace - output->wset()->get_current_workspace());
++ }
+
+- animation.dx.set(animation.dx + cws.x - workspace.x, 0);
+- animation.dy.set(animation.dy + cws.y - workspace.y, 0);
++ /**
++ * Like set_target_workspace(workspace), but animate by @delta workspaces,
++ * which differs from the difference to the current workspace when the
++ * columns wrap around.
++ */
++ void set_target_workspace(point_t workspace, point_t delta)
++ {
++ animation.dx.set(animation.dx - delta.x, 0);
++ animation.dy.set(animation.dy - delta.y, 0);
+ animation.start();
+
+ std::vector<wayfire_toplevel_view> fixed_views;
+@@ -293,7 +301,8 @@ class workspace_switch_t
+ tmanager->end_transform_update();
+ }
+
+- wf::effect_hook_t post_render = [=] ()
++ /** Point the wall at the current workspace, offset by the animation. */
++ void update_wall_viewport()
+ {
+ auto start = wall->get_workspace_rectangle(
+ output->wset()->get_current_workspace());
+@@ -305,6 +314,11 @@ class workspace_switch_t
+ start.height,
+ };
+ wall->set_viewport(viewport);
++ }
++
++ wf::effect_hook_t post_render = [=] ()
++ {
++ update_wall_viewport();
+ update_overlay_fb();
+
+ output->render->damage_whole();
+@@ -387,13 +401,19 @@ class vswitch : public wf::per_output_plugin_instance_t
+ on_done();
+ }
+
+- /** The workspace has already changed: slide in from @old_ws. */
+- void slide_from(wf::point_t old_ws)
++ /**
++ * The workspace has already changed by @delta (the shortest way when
++ * columns wrap): slide in from the previous workspace.
++ */
++ void slide_from(wf::point_t delta)
+ {
+- auto cws = output->wset()->get_current_workspace();
+- animation.dx.set(old_ws.x - cws.x, 0);
+- animation.dy.set(old_ws.y - cws.y, 0);
++ animation.dx.set(-delta.x, 0);
++ animation.dy.set(-delta.y, 0);
+ animation.start();
++ // post_render only moves the wall after a frame is drawn; start
++ // at the old position so the first frame does not show the
++ // destination.
++ update_wall_viewport();
+ }
+
+ private:
+@@ -701,7 +721,7 @@ class vswitch : public wf::per_output_plugin_instance_t
+ // Without a cube to animate it, use vswitch's own slide.
+ if (!ev.carried_out && start_switch())
+ {
+- algorithm->slide_from(cws);
++ algorithm->slide_from(animation_delta);
+ }
+
+ return true;
+@@ -713,7 +733,7 @@ class vswitch : public wf::per_output_plugin_instance_t
+ }
+
+ algorithm->set_overlay_view(toplevel_cast(view));
+- algorithm->set_target_workspace(target_ws);
++ algorithm->set_target_workspace(target_ws, animation_delta);
+
+ return true;
+ }
+@@ -902,12 +922,13 @@ class wf_vswitch_global_plugin_t : public wf::per_output_plugin_t<vswitch>
+ switch_with_view = view;
+ }
+
+- if (output_instance[wo]->set_capabilities(wf::CAPABILITY_MANAGE_COMPOSITOR))
++ wf::point_t new_viewport = {int(x), int(y)};
++ wf::point_t cur_viewport = wo->wset()->get_current_workspace();
++ wf::point_t delta = new_viewport - cur_viewport;
++ if (!output_instance[wo]->set_capabilities(wf::CAPABILITY_MANAGE_COMPOSITOR) ||
++ !output_instance[wo]->add_direction(delta, switch_with_view))
+ {
+- wf::point_t new_viewport = {int(x), int(y)};
+- wf::point_t cur_viewport = wo->wset()->get_current_workspace();
+- wf::point_t delta = new_viewport - cur_viewport;
+- output_instance[wo]->add_direction(delta, switch_with_view);
++ return wf::ipc::json_error("workspace switch refused");
+ }
+
+ return wf::ipc::json_ok();
+diff --git a/src/core/output-layout.cpp b/src/core/output-layout.cpp
+index d481851..23c0406 100644
+--- a/src/core/output-layout.cpp
++++ b/src/core/output-layout.cpp
+@@ -478,7 +478,17 @@ struct output_layout_output_t
+ std::optional<wlr_box> stitch_source_box;
+ std::optional<output_state_t> stitch_restore_state;
+ std::vector<wf::output_stitch_zone_t> stitch_zones;
+- bool stitch_power_off = false;
++ /* A stitched member has two independent power owners: plugins through
++ * output_layout_t::set_output_power() (digitalrain-power), and clients of
++ * wlr-output-power-management. Each only changes its own flag; the slice
++ * is off while either asks for it. */
++ bool stitch_power_off_plugin = false;
++ bool stitch_power_off_protocol = false;
++
++ bool stitch_power_off() const
++ {
++ return stitch_power_off_plugin || stitch_power_off_protocol;
++ }
+
+ std::unique_ptr<wf::output_impl_t> output;
+ wl_listener_wrapper on_destroy, on_commit, on_request_state;
+@@ -1168,7 +1178,7 @@ struct output_layout_output_t
+
+ void setup_mirror()
+ {
+- if (stitch_source_box && stitch_power_off)
++ if (stitch_source_box && stitch_power_off())
+ {
+ set_enabled(false);
+ return;
+@@ -1333,7 +1343,7 @@ struct output_layout_output_t
+ teardown_mirror();
+
+ if (state.source == OUTPUT_IMAGE_SOURCE_NONE || state.source == OUTPUT_IMAGE_SOURCE_MASKED_DPMS ||
+- (stitch_source_box && stitch_power_off))
++ (stitch_source_box && stitch_power_off()))
+ {
+ /* output is OFF */
+ destroy_wayfire_output();
+@@ -2096,7 +2106,8 @@ class output_layout_t::impl
+ for (auto& [handle, lo] : outputs)
+ {
+ lo->stitch_restore_state.reset();
+- lo->stitch_power_off = false;
++ lo->stitch_power_off_plugin = false;
++ lo->stitch_power_off_protocol = false;
+ }
+
+ destroy_stitch_output();
+@@ -2413,9 +2424,9 @@ class output_layout_t::impl
+ wlr_configuration);
+ }
+
+- void set_stitch_member_power(output_layout_output_t& lo, bool enabled)
++ void set_stitch_member_power(output_layout_output_t& lo, bool enabled, bool from_protocol)
+ {
+- lo.stitch_power_off = !enabled;
++ (from_protocol ? lo.stitch_power_off_protocol : lo.stitch_power_off_plugin) = !enabled;
+ lo.apply_state(lo.current_state);
+ if (auto source = find_output(lo.current_state.mirror_from))
+ {
+@@ -2444,7 +2455,7 @@ class output_layout_t::impl
+ {
+ // Power only this member's slice. wlroots reports the member's
+ // new mode to clients when the connector commits enabled/disabled.
+- set_stitch_member_power(*outputs.at(target), !wants_dpms);
++ set_stitch_member_power(*outputs.at(target), !wants_dpms, true);
+ return;
+ }
+
+@@ -2473,7 +2484,7 @@ class output_layout_t::impl
+
+ if (lo->stitch_source_box)
+ {
+- set_stitch_member_power(*lo, enabled);
++ set_stitch_member_power(*lo, enabled, false);
+ return true;
+ }
+
+diff --git a/src/core/window-scope.cpp b/src/core/window-scope.cpp
+index 74749d7..8ddd27d 100644
+--- a/src/core/window-scope.cpp
++++ b/src/core/window-scope.cpp
+@@ -211,7 +211,12 @@ void apply_window_scope(wayfire_toplevel_view view, std::optional<wf::point_t> w
+ auto grid = output->wset()->get_workspace_grid_size();
+ ws.x = std::clamp(ws.x, 0, grid.width - 1);
+ ws.y = std::clamp(ws.y, 0, grid.height - 1);
+- if (view->pending_fullscreen()) get_core().default_wm->fullscreen_request(view, output, true, ws);
++ if (view->pending_fullscreen())
++ {
++ if (workspace) view->get_data_safe<window_scope_reflow_t>();
++ get_core().default_wm->fullscreen_request(view, output, true, ws);
++ if (workspace) view->erase_data<window_scope_reflow_t>();
++ }
+ else if (view->pending_tiled_edges())
+ {
+ if (workspace) view->get_data_safe<window_scope_reflow_t>();
diff --git a/gui-wm/wayfire/wayfire-0.11.0-r71.ebuild b/gui-wm/wayfire/wayfire-0.11.0-r72.ebuild
index ce38e25cb609..a418c09f3486 100644
--- a/gui-wm/wayfire/wayfire-0.11.0-r71.ebuild
+++ b/gui-wm/wayfire/wayfire-0.11.0-r72.ebuild
@@ -159,6 +159,7 @@ PATCHES=(
"${FILESDIR}"/${P}-r71-switch-tile-exclusivity.patch
"${FILESDIR}"/${P}-r71-cube-teardown-backgrounds.patch
"${FILESDIR}"/${P}-r71-stitch-noop-reload.patch
+ "${FILESDIR}"/${P}-r72-review-followups.patch
)
# The local patches change the plugin ABI (scene layer indices, class layouts).
diff --git a/www-apps/jellyfin-bin/Manifest b/www-apps/jellyfin-bin/Manifest
index de09dd43e9f5..100ef9ac36ba 100644
--- a/www-apps/jellyfin-bin/Manifest
+++ b/www-apps/jellyfin-bin/Manifest
@@ -1,8 +1,4 @@
-DIST jellyfin_10.11.11-amd64-musl.tar.xz 85687656 BLAKE2B f164e2e0a5026ad15e04bb6be91d15bb9df4c6018a2c90f96cb16445470343bb9be13f2eeb77543f6508f9100fc95ebaf09f2a9dabe810774c5d50caaa062d0b SHA512 ddacb6f3e09589066bfdc0c365b350815ff0bda0b56e1607df3426999f501ac82f8d0a4d991b293b609524fbd32f9a5ce6d0630fa036ca20139063694438b3a2
-DIST jellyfin_10.11.11-amd64.tar.xz 85594608 BLAKE2B 2b6f0e0bd699fc106050d3707f1f904339df5f39652314c8e8414971a8d8ebc50ad6cd8b7f1000913a2f813e35f2e0fb6c79cc526da9896870e67f8081f7ddcd SHA512 31d84d98f528bc321f1dbb5ee0ad1eb4fdcfd49ba41b0f4476a4bf89d626ea232f7a468ad401ee79f7b3e87e2fb9c5269acf548768fab85d3903fe88d268f384
-DIST jellyfin_10.11.11-arm64-musl.tar.xz 81333968 BLAKE2B f7beb697c44a650264fbc8123bf08a9573e1e3c083b0522041b242012affde858df69f6409ed485afb5b9fea6c7c55013dc5254dd46ddaa3297ae65cde221db4 SHA512 f631f952bdbf4a87bed5ca41be68c1dc651f2ca670753ac56fb235d16604105833bd2e1b2a5c389f295339c59f6478e23b98778ec120cbb1c42ab7af90bf6abc
-DIST jellyfin_10.11.11-arm64.tar.xz 81317864 BLAKE2B 99842f670be9c6d4388f8021afbff7ecdc4bddb227b5398c6818d6bbcf162163c9d41d9a980bf44afc18a41967162a83ac10806bfd1e9a326fd74c622049ae0b SHA512 d33d8e39aa731f7402c89935ebc565fc435203e8ea60239b8a680997a949857c229be9299d8154a7ba021e0519bc466efa89793478d0ac886c2410f33fb4f4a5
-DIST jellyfin_12.2-amd64-musl.tar.xz 93073124 BLAKE2B 41c50e17bb118d42b94bd2d31ccb76e4f92f7f1ac69fdc01f9aed04fc87af32f8d19fe60804b8787102e6de3ab70c45da33c107142aa912ffeb35b9b00823db3 SHA512 29ecfe9484bd5e6c8d6f73e097ed20a63dcd45eadfafd7171153456fb028e24e8ba1929d8d434a820c24cbb1e081d28b18f0e60d3b93a46dee626e696d07c2e3
-DIST jellyfin_12.2-amd64.tar.xz 88684340 BLAKE2B 3f4beebc095152654e019580b18bc20a248e3cdba41958f5ebd2615c16c43da2590706add487ae9bfc94f01ca79d4f96b151b6dae30d37594e2c08a6fcd22094 SHA512 e05d2a524da933c1d5cc174169660cb2d1c0d7968d0ecb66fb5f1fcca130cfd861d91a7c5c3085982feb14bf528536dc47d076456222884dc19134bc5fc1c70e
-DIST jellyfin_12.2-arm64-musl.tar.xz 88565520 BLAKE2B a3853e9a925969f6ed4e163175a4500d5dc87a7afd8f8f6725fd2ed6dd62b43c93dc47ea01622010c4aeee38676a90895635522e1f17e5257472e984a4aa55b9 SHA512 25f51c3dd6a5509273cae808c2ad2a37127a45e72129528f0bcd6d88196c58fe966e064a6682a8e29bc749dffd2a777067f64205b05d7e19579927c00068c532
-DIST jellyfin_12.2-arm64.tar.xz 84124080 BLAKE2B dca8733ba47396aab953be7b46aac163bc32b57bc55dcf90069051bab543cb59b1ecad5391ff917f0b0e82daffe187de7b561d1fc78cc5fd2a3bebeffc8c0aec SHA512 884f46ddbeea676a3ef991e43ebd9b5c1a89fe2fdab5cd70eefb64b5bb4402f391a971de8b2e00fb1dbfc293bcebcb27b15782a212eb1fc625fa5285c347c46e
+DIST jellyfin_12.3-amd64-musl.tar.xz 93090656 BLAKE2B 706bfe7a355a661ab19d99f591f14a95d3a15f55a21bd23cef8c03a57e07d9b8e95c59b9ce67f8c913e135b822a87086a878047f35c41719c1b09a7d2448d190 SHA512 a231c0f27fce993408c6478141d4f6d28e046ff8a35865d70c49e4fbde251ccad84e1adaef453bad766fdf6b0bced65764c36548447ed7120e124658927bfd43
+DIST jellyfin_12.3-amd64.tar.xz 88663840 BLAKE2B 9e53bd7172792144fa204df92e76a1e5364766e19095ea21c321b1677fe2a80217e8292fe1572f93626eeccbe13919018039bcfef65322db9a4cad90cad60d7d SHA512 556d1a3cfbe526523600f6d031e770efc3d94a0acedd975da46846efb82e17e6bd9ed7854d382194cf159b9925063c9179058dd981b812a3a68cce424c518109
+DIST jellyfin_12.3-arm64-musl.tar.xz 88631988 BLAKE2B 76e206182ff797cd9878d8c613b56a1ecb5b8df566d3198217bc18700828d7f78fdb0fec1bb650dacede91dcc1a0c41f0e546521e0550b99cf86b23317e5f11c SHA512 8b092b4f701c1f178445980c451af66441b1df0706a9b4af82a28dd7007b65193a1878425d5d6b4b0ee8fa1b5a4d41e32786c557122453e9a35b1de77221fef5
+DIST jellyfin_12.3-arm64.tar.xz 84154196 BLAKE2B 361be56b237a0846c4328230a15601ab31afa1636ff2b8fddd54ad83341b99591514194baa15f4bc72f81c0f2ce11306293d0a4075f41f636cbf60ae7be6c301 SHA512 3ab11215e2929d45b1989e901c7411da3e36946c27ba28ac254af45f20557f1990c9f00e1dfbf219b1c479a3eb73a2af00601333f5fb408bca32aa4466f0474d
diff --git a/www-apps/jellyfin-bin/jellyfin-bin-10.11.11.ebuild b/www-apps/jellyfin-bin/jellyfin-bin-10.11.11.ebuild
deleted file mode 100644
index 7e747d77779b..000000000000
--- a/www-apps/jellyfin-bin/jellyfin-bin-10.11.11.ebuild
+++ /dev/null
@@ -1,104 +0,0 @@
-# Copyright 2022-2026 Gentoo Authors
-# Distributed under the terms of the GNU General Public License v2
-
-EAPI=8
-
-inherit pax-utils systemd tmpfiles eapi9-ver
-
-DESCRIPTION="Jellyfin puts you in control of managing and streaming your media"
-HOMEPAGE="https://jellyfin.org/
- https://github.com/jellyfin/jellyfin/"
-MY_PV="${PV//_rc/-rc}"
-MINOR_VER=$(ver_cut 1-2)
-if [[ ${PV} == *rc* ]]; then
- MY_TYPE="preview"
-else
- MY_TYPE="stable"
- KEYWORDS="-* ~amd64 ~arm64"
-fi
-SRC_URI="
- arm64? (
- elibc_glibc? (
- https://repo.jellyfin.org/files/server/linux/${MY_TYPE}/v${MY_PV}/arm64/jellyfin_${MY_PV}-arm64.tar.xz
- )
- elibc_musl? (
- https://repo.jellyfin.org/files/server/linux/${MY_TYPE}/v${MY_PV}/arm64-musl/jellyfin_${MY_PV}-arm64-musl.tar.xz
- )
- )
- amd64? (
- elibc_glibc? (
- https://repo.jellyfin.org/files/server/linux/${MY_TYPE}/v${MY_PV}/amd64/jellyfin_${MY_PV}-amd64.tar.xz
- )
- elibc_musl? (
- https://repo.jellyfin.org/files/server/linux/${MY_TYPE}/v${MY_PV}/amd64-musl/jellyfin_${MY_PV}-amd64-musl.tar.xz
- )
- )"
-
-LICENSE="GPL-2"
-SLOT="0"
-RESTRICT="mirror test"
-
-DEPEND="acct-user/jellyfin
- media-libs/fontconfig
- virtual/zlib:="
-RDEPEND="${DEPEND}
- dev-libs/icu
- media-video/ffmpeg[vpx,x264]"
-BDEPEND="acct-user/jellyfin"
-
-INST_DIR="/opt/jellyfin"
-QA_PREBUILT="${INST_DIR#/}/*.so ${INST_DIR#/}/*.so.* ${INST_DIR#/}/jellyfin ${INST_DIR#/}/createdump"
-
-src_unpack() {
- unpack ${A}
- mv jellyfin ${P} || die
-}
-
-src_prepare() {
- default
-
- # https://github.com/jellyfin/jellyfin/issues/7471
- # https://github.com/dotnet/runtime/issues/57784
- rm libcoreclrtraceptprovider.so || die
-}
-
-src_install() {
- keepdir /var/log/jellyfin
- fowners jellyfin:jellyfin /var/log/jellyfin
- keepdir /etc/jellyfin
- fowners jellyfin:jellyfin /etc/jellyfin
- insinto ${INST_DIR}
- dodir ${INST_DIR}
- doins -r "${S}"/*
- newtmpfiles - jellyfin.conf <<<"d /var/cache/jellyfin 0775 jellyfin jellyfin -"
- chmod 755 "${D}${INST_DIR}/jellyfin"
- newinitd "${FILESDIR}/jellyfin.init-r1" "jellyfin"
- newconfd "${FILESDIR}"/jellyfin.confd "jellyfin"
- systemd_dounit "${FILESDIR}/jellyfin.service"
- pax-mark -m "${ED}${INST_DIR}/jellyfin"
-}
-
-pkg_preinst() {
- if ver_replacing -gt $MINOR_VER.99; then
- eerror "Downgrading jellyfin from one minor version to a previous one is not supported."
- eerror "If you wish to downgrade you must uninstall jellyfin-bin, restore the database"
- eerror "from a backup and then reinstall jellyfin-bin."
- die "Downgrade path not supported"
- fi
-}
-
-pkg_postinst() {
- tmpfiles_process jellyfin.conf
-
- if ver_replacing -lt $MINOR_VER; then
- ewarn "Jellyfin usually makes backward incompatible database changes in new minor"
- ewarn "releases. At first startup after an upgrade jellyfin will start a database"
- ewarn "migration. This may take a long time but must not be aborted or the database"
- ewarn "could be left in an inconsistant state and must be recreated or restored from"
- ewarn "backup. Once the migration has started it is no longer possible to downgrade"
- ewarn "jellyfin without restoring the database from a backup."
- ewarn ""
- ewarn "The migration progress can be followed in the startup UI in the web browser"
- ewarn "or in the jellyfin logs."
- fi
-}
diff --git a/www-apps/jellyfin-bin/jellyfin-bin-12.2.ebuild b/www-apps/jellyfin-bin/jellyfin-bin-12.2.ebuild
deleted file mode 100644
index 7e747d77779b..000000000000
--- a/www-apps/jellyfin-bin/jellyfin-bin-12.2.ebuild
+++ /dev/null
@@ -1,104 +0,0 @@
-# Copyright 2022-2026 Gentoo Authors
-# Distributed under the terms of the GNU General Public License v2
-
-EAPI=8
-
-inherit pax-utils systemd tmpfiles eapi9-ver
-
-DESCRIPTION="Jellyfin puts you in control of managing and streaming your media"
-HOMEPAGE="https://jellyfin.org/
- https://github.com/jellyfin/jellyfin/"
-MY_PV="${PV//_rc/-rc}"
-MINOR_VER=$(ver_cut 1-2)
-if [[ ${PV} == *rc* ]]; then
- MY_TYPE="preview"
-else
- MY_TYPE="stable"
- KEYWORDS="-* ~amd64 ~arm64"
-fi
-SRC_URI="
- arm64? (
- elibc_glibc? (
- https://repo.jellyfin.org/files/server/linux/${MY_TYPE}/v${MY_PV}/arm64/jellyfin_${MY_PV}-arm64.tar.xz
- )
- elibc_musl? (
- https://repo.jellyfin.org/files/server/linux/${MY_TYPE}/v${MY_PV}/arm64-musl/jellyfin_${MY_PV}-arm64-musl.tar.xz
- )
- )
- amd64? (
- elibc_glibc? (
- https://repo.jellyfin.org/files/server/linux/${MY_TYPE}/v${MY_PV}/amd64/jellyfin_${MY_PV}-amd64.tar.xz
- )
- elibc_musl? (
- https://repo.jellyfin.org/files/server/linux/${MY_TYPE}/v${MY_PV}/amd64-musl/jellyfin_${MY_PV}-amd64-musl.tar.xz
- )
- )"
-
-LICENSE="GPL-2"
-SLOT="0"
-RESTRICT="mirror test"
-
-DEPEND="acct-user/jellyfin
- media-libs/fontconfig
- virtual/zlib:="
-RDEPEND="${DEPEND}
- dev-libs/icu
- media-video/ffmpeg[vpx,x264]"
-BDEPEND="acct-user/jellyfin"
-
-INST_DIR="/opt/jellyfin"
-QA_PREBUILT="${INST_DIR#/}/*.so ${INST_DIR#/}/*.so.* ${INST_DIR#/}/jellyfin ${INST_DIR#/}/createdump"
-
-src_unpack() {
- unpack ${A}
- mv jellyfin ${P} || die
-}
-
-src_prepare() {
- default
-
- # https://github.com/jellyfin/jellyfin/issues/7471
- # https://github.com/dotnet/runtime/issues/57784
- rm libcoreclrtraceptprovider.so || die
-}
-
-src_install() {
- keepdir /var/log/jellyfin
- fowners jellyfin:jellyfin /var/log/jellyfin
- keepdir /etc/jellyfin
- fowners jellyfin:jellyfin /etc/jellyfin
- insinto ${INST_DIR}
- dodir ${INST_DIR}
- doins -r "${S}"/*
- newtmpfiles - jellyfin.conf <<<"d /var/cache/jellyfin 0775 jellyfin jellyfin -"
- chmod 755 "${D}${INST_DIR}/jellyfin"
- newinitd "${FILESDIR}/jellyfin.init-r1" "jellyfin"
- newconfd "${FILESDIR}"/jellyfin.confd "jellyfin"
- systemd_dounit "${FILESDIR}/jellyfin.service"
- pax-mark -m "${ED}${INST_DIR}/jellyfin"
-}
-
-pkg_preinst() {
- if ver_replacing -gt $MINOR_VER.99; then
- eerror "Downgrading jellyfin from one minor version to a previous one is not supported."
- eerror "If you wish to downgrade you must uninstall jellyfin-bin, restore the database"
- eerror "from a backup and then reinstall jellyfin-bin."
- die "Downgrade path not supported"
- fi
-}
-
-pkg_postinst() {
- tmpfiles_process jellyfin.conf
-
- if ver_replacing -lt $MINOR_VER; then
- ewarn "Jellyfin usually makes backward incompatible database changes in new minor"
- ewarn "releases. At first startup after an upgrade jellyfin will start a database"
- ewarn "migration. This may take a long time but must not be aborted or the database"
- ewarn "could be left in an inconsistant state and must be recreated or restored from"
- ewarn "backup. Once the migration has started it is no longer possible to downgrade"
- ewarn "jellyfin without restoring the database from a backup."
- ewarn ""
- ewarn "The migration progress can be followed in the startup UI in the web browser"
- ewarn "or in the jellyfin logs."
- fi
-}
diff --git a/www-apps/jellyfin-bin/jellyfin-bin-12.2-r1.ebuild b/www-apps/jellyfin-bin/jellyfin-bin-12.3.ebuild
index 7c6bba63106c..7c6bba63106c 100644
--- a/www-apps/jellyfin-bin/jellyfin-bin-12.2-r1.ebuild
+++ b/www-apps/jellyfin-bin/jellyfin-bin-12.3.ebuild
diff --git a/x11-drivers/nvidia-drivers/Manifest b/x11-drivers/nvidia-drivers/Manifest
index 688fcf7bb7c1..679f79c23c03 100644
--- a/x11-drivers/nvidia-drivers/Manifest
+++ b/x11-drivers/nvidia-drivers/Manifest
@@ -12,7 +12,7 @@ DIST NVIDIA-Linux-x86_64-580.178.04.run 397214534 BLAKE2B 53ea96e4d1342003238aaa
DIST NVIDIA-Linux-x86_64-580.196.07.run 397209845 BLAKE2B 69315e16040d32ffa922dfbc0a9dc96625ccc42306e1f724cadeb0f597d434a13e7a391e54ea88949c095c4e29a7f9af220b6722711efb4316c0220fab8fc63f SHA512 dabd0b33fae2c4f05501202a4bcac3b8dd8dc1e5121b6e0991b8ba47eec36beb33af5b3fde2994567e0133a55910a2dab80facca682e14ac8c09990fcf9166e4
DIST NVIDIA-Linux-x86_64-595.104.02.run 423378754 BLAKE2B 02cf64ce916433e00afbb82c6e7bbc4e7e6723ebaa84a90b431641ed918a21a527545e844666067df29bb1bf097b064842ebe4737336a54074fed55e5f5bceaf SHA512 8c6bbc638a0192c43595dda9db70c86da0dc1f7d768838b452af7070449c4967d7e1abfc969ec4d2505e460fb8bbf9e261c4c918f1fc3efe6d56be33f8a14b16
DIST NVIDIA-Linux-x86_64-595.112.07.run 423351953 BLAKE2B d25c45818c41bebf1e1e4cc6eb7effb06144ab7ea5d7ebff4e623bfc698354852f2600b91af56040c344ab7319a7f1f5430791b5766ab5f48e0d4a8bd808805b SHA512 f23912837701d11fb1dedd1d99ef466f24e450706ed2df68bc85617c91864b3f6902d36d6e4a0aee2cf02217fd5e9abbbbac54ed4536b5219580a3f4d0eb8eb8
-DIST NVIDIA-Linux-x86_64-595.44.15.run 423974279 BLAKE2B 9a13bfbe7612a95041c43a14d7b579675504b28953449c037900b53488ab9f3391d357ef4bc9e642da54c9dee8f3d34a22655daaf9a1fb7de816e5ac9957a788 SHA512 b36af6233c795a6538bc0d1af678546b35330545b82bb7d2f70dcea59f9fd596861acb0cdca68404321716c1657589ba22d37c8cd6c513b320a80c36fb6a4130
+DIST NVIDIA-Linux-x86_64-595.44.16.run 423843600 BLAKE2B 6eda7e68069f29e4eea11fe0a54fcf6cf80f30f7a29fe2bdfb0fa57823c729e3165c8ef97b2dcb8585d58ea4a03c5b3fe0b6f7a79b8a71e1f6bff0fd30233172 SHA512 16df35e8fe64c1110bd22706f9acf282e07ada98bbfb881e6747f395e1e99fc48d60229a4c8388b950d1075001a8145dbc4a33aef6e8e556b0bff71017870ca7
DIST NVIDIA-Linux-x86_64-615.78.08.run 529834675 BLAKE2B 0d866b034ce9ca496fc60fff606dad264b35dd6895fb13314ba5071e265de45dd7376950720c2b1033e065cbe34ca09bc8565e04eab83c7d08d06f68037bbe92 SHA512 fb0c16c9812e60ac1f5ca663f03dd84f386ae4ad55beab4655c65fe8d094c98246c5acbcf9e385e93e38dbfb340472712fc80593a8bf0be47f34ff871bf4d83d
DIST NVIDIA-kernel-module-source-535.309.01.tar.xz 12537052 BLAKE2B 2bff6a129c7c407de95d140fefefd50c266c7c8dcf938a71690545cff65b3502872645e79d47f9091deac54b7e140bd93a321b18af0b9cb24ca958f033461c53 SHA512 5bd08038823f3f9c39307b441cadc6231d71195adf1835f33e5af794df24be8abed83d3b246edbf5cbaae2379b52ecdd5c975c722001e19fc96054528bada773
DIST NVIDIA-kernel-module-source-580.178.04.tar.xz 22267204 BLAKE2B 7566451a6b13c24bb80476c68bbcd93c0d8dbd0bc4eae2ca5fcda06690a2d386b6a0782aa1836e07061e84bf923fab479746294f229acd4c53e7a6da4a309b9f SHA512 38b5ad1895946287073e5951a88733898a805d02325eb7f23169e61663d43b73bb4db65b0d9648daef11edcc50646320a4e18af6e7584d63cec960fcd3b75de5
@@ -27,7 +27,6 @@ DIST nvidia-installer-580.178.04.tar.bz2 169996 BLAKE2B 231762680ac9dfb88ff553ad
DIST nvidia-installer-580.196.07.tar.bz2 169967 BLAKE2B 66832b2e873fb548878904a7861b1970817fc06a39d9e5fecbc10e8db101163f5441f1c37b11954a1d52bec3744df2b61098367afc489c7f2e0f35418df61d62 SHA512 89c6dd0e0999d09049cb34a2609c3f28226ba5d95d7d728d9bf2f72a837752e0f51c144443b763d7f4ee4f6728121f80feb8c66b65c6b9e028f2063cef0fb4c2
DIST nvidia-installer-595.104.02.tar.bz2 172685 BLAKE2B a15646a5b40c51a55417df1570c257b40d591a20fe4bf5c1658c64e597de448cc2b0807ce47b4ef5c654161ebb5ce82184631966e7fb6885c8bfcba5d7eb24b6 SHA512 90adf5b1d6af339d46ee82217b7c1187316be8268577b776e5313907119fd9b8a796e6da33ed687c2b9baa2124dff624f584dcc5ec4a29dcc733e1b27975088f
DIST nvidia-installer-595.112.07.tar.bz2 172363 BLAKE2B 93d1b5d6f6fbf8043903ccccf648000dce23290f348095447e3205a614f67b8304b132fb165077010a41dcc713d91c5b03ca0b774d1ff2f53b514ac79ac898fe SHA512 fc219aed406d9751691b7525f5edb4b3ef29cdf2d6c36c6eddd5ac968a387dbec0fcc5dfc6884f01da14a1539a195060bcfe47c9ec18ed09d04af202b8fd079f
-DIST nvidia-installer-595.91.07.tar.bz2 172429 BLAKE2B b4128b9075a5b302f825ff70af8ad210030292b920c5f9cb88930c6853e9fa915c9033b80d7ba1ac729a8a8c6c8787f61444419ae0aae47ceeefdefb4984fc19 SHA512 316d278f3f3500f33a92c993accb62333cbd6d719a8e730e28a387d5c80987f4bc7d6b05f0d2897f766d51cb067736e254057a0984d72207c67accb8510233e4
DIST nvidia-installer-615.78.08.tar.bz2 172150 BLAKE2B 72cbbc1faf2406bf1d5b36c5b01e91475b785182389ddeff77e76070a67aa20b519a12fbc409389817453f39173c0253a5dc926adbbc71d80c7ec1e248764bed SHA512 03e2ad38bb07dca362ba3308e2d5a55557b30fa8b7923b7d0ec8a0147e8c59b5d0fff2cf9393ef2b1d66690ac16cb826de308c006c5b4c644e9be0055c190108
DIST nvidia-modprobe-390.157.tar.bz2 35306 BLAKE2B 80d202b39c2f95ec0f909712c2e101483af50124092c32efb33bd98ff58ddfb97b737ff07f1ddc941f688c1b4c26a15f392566d522e18506771f10422fcd571c SHA512 109bbe24a3758b568ea65fe1e9d78f69fae6108ec0497b796c885a3912825fe04ffd7389a3e22987f1a10a2926bf6eaa384faabc59478aa3ee244dd3ac91c6d3
DIST nvidia-modprobe-470.256.02.tar.bz2 38769 BLAKE2B 0db444b09b0b6d24c04877fae95249e781d82b3141f90eb05d869b64d2b6e65ad00bf44c4e427c64dbd4765fa99a8699f80a1d95b00c66137a54dc878d7ea298 SHA512 c1cbfa6c8e188e5a5eb5d390b9a903406cbff103d212fc30e611e4023be3fde896ad84e8bb45b030beb5d1bcd8155c9e55a8f9636c45ce1e2a5c03f672989a31
@@ -36,7 +35,6 @@ DIST nvidia-modprobe-580.178.04.tar.bz2 41668 BLAKE2B 9d7f239a3189db7528a5a9c0f2
DIST nvidia-modprobe-580.196.07.tar.bz2 41669 BLAKE2B 490ca02f5027b4a73cdb610ef49c29e20bc2f3b2e11694eb2725891476d3040f5f9fb95692ccab7d9a844aa430955d9faf63b2270ac407206fc0abfea32f03ef SHA512 b975ea7d9d7d6afed625402930999f09085735fb169a735d445f7c7e61ab451d955d40bea6f7785d05296af9998f02c2bab22373056da30ed4e1639446e6012e
DIST nvidia-modprobe-595.104.02.tar.bz2 41729 BLAKE2B ac72388d9c628cf601f4f298ec0d7a571e4ddfe3c19a9289c5c4e531c62e82f197d7b1d27f77d4d2f71fe9940f127271929a1c0dfea398fe5eb55d5c31ab740c SHA512 c4e6f843030406eb4cd3f8fff44f574b4cafaa422b7789381f83b991ea32806dde6e0a7c6edd17fab34e5de634669b83a781b16b653d0d3ed599abc4a879aac3
DIST nvidia-modprobe-595.112.07.tar.bz2 41716 BLAKE2B 1f95b41e195ddde8bcfcc82688f0d0db8f525ad652fb2abfa0730bfd3434e55e399acaf3b0dec2647aeec2f088467f968e116e2dac462570836f18a35e4374e7 SHA512 8e287999c45cab710165ecbd46fb983f8f6daa1b2b0a6578dd2dc1f6833ba5e238401d0f1c9e72e0c52598d978d2d81896566ea5d5b7d694cee4782af34d8dfe
-DIST nvidia-modprobe-595.91.07.tar.bz2 41725 BLAKE2B 0ceec46af414ea6232014b25c384b01390fd85ca81c39f994f5bafcdc2e3bbf670ed6416fc7d8b8d0ae56bc689106356f956b5f9c4b7e69ff66b5fe3d05729c9 SHA512 9ad2780218119eaa55d2da0c1007ffa73c3262b0e692cae25260f18d993d531e4cacfe83356b4f4f923e62cc80903915073d0225810656798a9462c75a769a98
DIST nvidia-modprobe-615.78.08.tar.bz2 41943 BLAKE2B 7ee4f83738020491488b12e861ba4bfcd8c1106583360caa6cb766051e3d75cc42f3aa804dc0e2962ac66fb5f93458ad1948c739a0942a1d0c6ca1a59c9f934e SHA512 eb5bb4535dcc5298d345b16a8d4a9772d2b18f342b1790bbf455774049c1dc1f8aacd2b40d9285dc56952b3a04281cb620b0ec83f11ef72f616f51e506f38ebb
DIST nvidia-persistenced-390.157.tar.bz2 48654 BLAKE2B 2cf6c92da90acff55e8c180a06b0c6a29d48be9fd8ca3c541202fd14f0697220fba6b66452942aa7ae7cc8821f5c5eb9fa8f7a31d2248624e5f9141d83a176b8 SHA512 70f0707ed6f2b877e69ef90cf782f66cbc9d2071db53ca09ceb7b89427b0fe176708517340621fa251539b7d481b238adeeb60261674eed74de1f62db6dbb72e
DIST nvidia-persistenced-470.256.02.tar.bz2 46567 BLAKE2B 8f837322a3b88412fd2f6acf38721b49a6cb444fbd842d652519e5596f2e545d8f06bbdf017f46a22301ce87455f96147fab2829ca5fbf26131ec3e4a772e282 SHA512 4fd19258649b7d39945fb5c8578bf11ab118617dd14f3d11cdf15cb3fe77daf7b4719e4ae57af59031b1b809d02f4e8e8afed0ec60b9d0aed4d2488bd3013cd9
@@ -45,7 +43,6 @@ DIST nvidia-persistenced-580.178.04.tar.bz2 62230 BLAKE2B 387f778b3ec3e48112e35b
DIST nvidia-persistenced-580.196.07.tar.bz2 62228 BLAKE2B 5743acd5bfb20a55b86312460a71bda0d73e6422390457839f809862f0a7cbdf87bc80255e9da11a08317ee109c44f6c5b830be5520648ea0ef1da82d9456b93 SHA512 4876712dcefd691dcee018fa71c09bfbbfced420c5e8661de96fddf3f52384a88d853a4a61fe861478d3f6603f4b339530f0d2439b62a505b36721c9d58aeec4
DIST nvidia-persistenced-595.104.02.tar.bz2 62545 BLAKE2B 7212c5fe14af130825f60290b324afd711d4ec664a3f5b3c37adc40f02887cf639a555ad8c087117b36e0aff212c5312e97afc0d87d494e8fde88a0059c72e6d SHA512 ac88efaa447106c3205468fdeb2513ef87048579ebd21ac9b19e006606523f653ac58147597ed1a26a69df7885868316ca355686b1418a0e2479451070762282
DIST nvidia-persistenced-595.112.07.tar.bz2 62504 BLAKE2B a3748640935050a2f4eef05d1f2ff7a502c57bc0f99489a30bf156c9a2b37dceb9f0a328346de6e376a5ad01670471d04ee0d51dbb6b85aac87166161f5d2bdd SHA512 23572177113c5de96125fa9b266f1ce93b08e5653ee6527292f3444f056acdb26570a83a15b8fc28d31bb253f4b01d3facd16e0bafea00137c2a4c491450834f
-DIST nvidia-persistenced-595.91.07.tar.bz2 62519 BLAKE2B 9da9fbacebc6c3c1b56621d7676ec78cf61081d824dbcabf8599168ce673444adf736b6cd62129b0f87d619f79c6d5f0bbce311f5ecba346cda8881bb7bce6f8 SHA512 b9f9c69fb85ce22676789f5d88c460dee1961c42e1bf4ad3e677ac800715f888ded41590fe287b2c5972e3b89982e7b4d6290ccdf7ff2fa870f3a5ef3a7ae9ae
DIST nvidia-persistenced-615.78.08.tar.bz2 64040 BLAKE2B 21c49bc244d8ec6e1734a366c74b525807bbea71b719ebee9c9c4f7af4937fdedbf0e814566855848fbedf7165a375f69626691358941e58ae51ea2c1e5f372b SHA512 f0b8b02cd16875404974e55411aa6750872962b8b518ac522b10c6a67778a404a721d19b80beca80ac91d0b0363b9bd92591f1ea14e99278e4478d1d29103ebe
DIST nvidia-settings-390.157.tar.bz2 1108938 BLAKE2B d9ec1ab5d7f157d74627272adf752a671dd17e2ede02069437b6180b927fe315b15cc6416d584ab2f038b76fe01e5b64514a834f24d88ac16676e55205fdecdd SHA512 b16699009bf56a1ff3b623a528c58061c4f5a2c5c4a373756a07bcd667743a1df7c733a572d2d0baa835a904ac2fda8a5c85890dcc00c3a0f223e0467aea902d
DIST nvidia-settings-470.256.02.tar.bz2 1062273 BLAKE2B 8e66201e7861469b12932595ef8f9fd29d4cfd570c2576ae7fbb7383f61c55ac71fe721cc431e79d6d69f86d998b5b4c3cafe531b573f439e6b499b0fc047a19 SHA512 626e66118ae2e62eacfdf44e37529c409b945a6f33637b9690d74abd87a2afb581aaca5f90328d280da99ceb659959d0e5dc5c22ef8013b2205b2b1c72e08007
@@ -54,7 +51,6 @@ DIST nvidia-settings-580.178.04.tar.bz2 1132866 BLAKE2B d17b698a73be1c1346eb2ba1
DIST nvidia-settings-580.196.07.tar.bz2 1132924 BLAKE2B 4c045ec4b743528d2004fe8dc38058805493512a4d3ae1c02174f7c06314f98b6a1c9d5ccbfa65c0ed34500a40b180ba87cb7aba30aa4f3820adb0bc36c12264 SHA512 4a9ad25d84efc099055e031c846b32bb277f1d012c5b688e51eea552d6ede9feafe3089f73f3b3bec6704d43b54c39def7f2bbf35bf26eca1115f65ba2b00e07
DIST nvidia-settings-595.104.02.tar.bz2 1136465 BLAKE2B 78735592dee4b0131a646880b8c0ab8acea65153941e3a49ac095ce7fe4523c3adf45deb5eb24363a14f0e76d22bceb6e95d1904232f61153fbaa82d08383d4d SHA512 0fd8a2488dff64e682ccbb2c596a1304041467e3d87f8b093b7dec81ccdadde1dd582a1329c768b0520ca850f2925eb07e370ed0f5e9e3e779b99d151bf561c3
DIST nvidia-settings-595.112.07.tar.bz2 1136323 BLAKE2B c9a5113f052f03717f2b1fd81cae69cac645a8c380c1ce4bb758fdca90ae4043f2fa2b4d7205cc021593435d4a681a3c09c83f35b10012ed06d8b9279a38b08c SHA512 8d04f9b0558c41db2e5cc00b1759af26c12c1d871e22e075a589cd3050819e40b0ecefdd516d7ef2c2c14bc359b1fa0ae2b9df56ec35c167d18f805870b084a7
-DIST nvidia-settings-595.91.07.tar.bz2 1136346 BLAKE2B 56e4ed3d51b3210935f49918e9975a8ef7ae0a199ff45f9512ed5058a379fb8e7402e599965da071481e3501fa8081b0ef034e36981ab8816636e7b6b49e8ee7 SHA512 0a412f7923c4b8227b154106280165f9930b41c137beddf5f69e49e70b348ede664f859a51159b7d3e5eb6c0c271ba9a1c1a5930c7f652dbc861cdea9c59265d
DIST nvidia-settings-615.78.08.tar.bz2 1148466 BLAKE2B 62e698e984847b941ccdf08904c98fd0811653b66a3731b8990635c4ebdfa9a5a20f6e1298188f36cfa6170f2ee895c6fc812bbf79e6f8f86e82b09caf5ef760 SHA512 fa01f1f2a1b3931d7ab16f7625cc6bf472053246dbbd41301b8ff697f004d854c69551586bc4d2b3e9511c981c738d4e24a0979efcb1b855962393f475fa4bfd
DIST nvidia-xconfig-390.157.tar.bz2 107399 BLAKE2B 0cbb3b1ba508ddc3ba4a161c290b31bf7a67f67880bdbd01171a962c5fa97ca945b1ef17932403f8589309380743f1da632c728d8accb6d4e56dbd895e7486bb SHA512 50b390811c9bf091b14add41f886925b17496b41006b7d3981d2398caa65cf5db8a579c8cebed78278384bf3acc629befb07b54339c61abcc75852c0dcb8b8ca
DIST nvidia-xconfig-470.256.02.tar.bz2 108698 BLAKE2B e468574e4bc57e7279d4120e2b08a201671b50d408be0c530eabe5da7d45daaf21cbf5c7fa31410d0d732fe8ea1551cbfb21d1ae193c1b3f5b5c50c764c9e236 SHA512 9dcec1e0c2b56b4d38a5f2c76086302f8bc0860cc15012cce29c3a5062f801a48b6a2bb78344361b07a2717b58502783441a2daf402d9973a17526175b1f15ca
@@ -63,6 +59,5 @@ DIST nvidia-xconfig-580.178.04.tar.bz2 111557 BLAKE2B 0ad4c36edafa3086ccd0189ad0
DIST nvidia-xconfig-580.196.07.tar.bz2 111524 BLAKE2B b27d13e5ed5f9aa198b4437bf8ccdeaafe513c4331b93e77d4c0d475c7ebc2516e658fe1bfe5d54a196da55c5d3e683438e396da577af44173a67b074f747388 SHA512 f60e76aa615675084ce2d8fe8586f05df604d848b636b75769eb4f9da24a3b92c8a3c7eb174e7e5aa747092fbb580d8b46884a87d2470c7845fe3730094e5e17
DIST nvidia-xconfig-595.104.02.tar.bz2 111065 BLAKE2B 962277dd2e663a60e935d41bdfd32f9a6fdf0cd67a8c28b2b816c0034fa81ce9b62e696357a3f30863d997d5bb69b28595d1fd6e10cf22f4a06fa48461dbf523 SHA512 27cfcdbb7f4a2b5adc6c7de17a9d8e9fe47ba048dac2d665cfb03b039bc5b7903ee5f9988b9ee8566086ec9424eabf5b2ff4e5a1dcc8139e068328fd6fc4a429
DIST nvidia-xconfig-595.112.07.tar.bz2 111095 BLAKE2B 83695b7eb9067419877244d75545935c61c986c57c06998f887d5b57680637d0ad0f59721b2b59f88ed5e0282e19821c41151ea9f6d0a65ddd28519d67c13cc2 SHA512 80706fe0790bb88353fde50a628805e49a15f50a516f79ebc77ca4aa4e994d8313768d7368a6b623211bab265b2de8120c0efc3be15c0de87d074b99f4f4cd35
-DIST nvidia-xconfig-595.91.07.tar.bz2 111007 BLAKE2B 30f556a5b8c41cedc637f52064c402e39590d5df0381a8b2b74cd5bfa3c94a42bb6b95ff68898e240eef36736205a592cacf1595e8ea054b5290314bce8bcdea SHA512 6675a2f56fb2574f55b7342c1c3da221f5b1dc55329018075f71903706cbbf8b9e2ba91190d0c021e66a03e758c7cad1f5a1f1b266653849fe85773092ebc6e1
DIST nvidia-xconfig-615.78.08.tar.bz2 111311 BLAKE2B 0d1d934f9f27fad53a7a4f50882edf33932f4ada19483c299f8c5521a0d16dc0a998f91ecd59915e61672f3653139e1ffe71566d46c2d3838e4de1b13ceb3bf7 SHA512 bd779fa9ef0edea8985bd7d7bcc742cd1c648411715a7fa3e5bca7db780dc84b2d48ee62323120eeba1ceca9f7454515409cfc51453ad1821c19757956bf950d
-DIST open-gpu-kernel-modules-595.44.15.tar.gz 24814978 BLAKE2B 896ff15e29469cf358765aa378d7c8fcbaf0dbaba78cf1815b0b2c49b0e67a9dacd6756356007eff9c1d899a832a3d703bae3e8bf527330db4a2b80fdc0d2173 SHA512 d3893088fdeecc6e128fed548df87b18d87c38d11274333e4f21b5ecf1a912a6a058f76fdcda4d969b670789f3fdc3eba54283a506caf043696a778a98388611
+DIST open-gpu-kernel-modules-595.44.16.tar.gz 24847376 BLAKE2B 3d60e02e37a02fe5dd02772ba2fe69508d56351a4529c955c4d65f8e6cd0703ef138e97b1a3af96476e2b6f20cf19af38cd78c8c9fd4bb70c7aac889ab8dc484 SHA512 428e7a56576ff6883d3b2e937341f8e81c7c433eb345f87ae0fb08dea733bd0eaf83587190671227159a0b82722f4895bb7d5c5d9cb727506ce291f505e282e8
diff --git a/x11-drivers/nvidia-drivers/nvidia-drivers-595.44.15.ebuild b/x11-drivers/nvidia-drivers/nvidia-drivers-595.44.16.ebuild
index c4e529d1834b..53a798818e04 100644
--- a/x11-drivers/nvidia-drivers/nvidia-drivers-595.44.15.ebuild
+++ b/x11-drivers/nvidia-drivers/nvidia-drivers-595.44.16.ebuild
@@ -8,7 +8,7 @@ inherit desktop dot-a eapi9-pipestatus eapi9-ver flag-o-matic linux-mod-r1
inherit readme.gentoo-r1 systemd toolchain-funcs unpacker user-info
MODULES_KERNEL_MAX=7.3
-NV_PIN=595.91.07
+NV_PIN=595.112.07
DESCRIPTION="NVIDIA Accelerated Graphics Driver"
HOMEPAGE="https://developer.nvidia.com/vulkan-driver/"