diff options
| author | Crucible <crucible@localhost> | 2026-10-22 22:40:22 -0500 |
|---|---|---|
| committer | Crucible <crucible@localhost> | 2026-10-22 22:40:22 -0500 |
| commit | 1a78ac84633b6b754b4ef29bb97da13cd6e7bf77 (patch) | |
| tree | 7a6c66033e249d8784786baaa017c0b9ab42a227 | |
| parent | c025061935147d6ead94b9bd0bb420580e2c4895 (diff) | |
| download | srcux-1a78ac84633b6b754b4ef29bb97da13cd6e7bf77.tar.gz srcux-1a78ac84633b6b754b4ef29bb97da13cd6e7bf77.tar.xz srcux-1a78ac84633b6b754b4ef29bb97da13cd6e7bf77.zip | |
Publish srcux candidate 0000000843-20261022-223642
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/" |
