From 2a24df56cf9d6c21b45874b6d19d52f2bc9f7cb2 Mon Sep 17 00:00:00 2001 From: mateoalfaro Date: Fri, 4 Sep 2026 13:33:30 -0600 Subject: [PATCH 1/2] feat: add settings-safe recovery mode --- meson.build | 7 ++ src/components/dock/dock.vala | 3 +- .../overview/app_launcher_grid.vala | 3 +- .../sidebar/pages/desktop_page.vala | 29 ++++++++- src/core/hand_control_manager.vala | 10 ++- src/core/main.vala | 36 +++++++++-- src/core/plugin_manager.vala | 6 +- src/core/safe_mode.vala | 64 +++++++++++++++++++ src/core/tiling_manager.vala | 16 ++++- tests/safe_mode_test.vala | 47 ++++++++++++++ 10 files changed, 206 insertions(+), 15 deletions(-) create mode 100644 src/core/safe_mode.vala create mode 100644 tests/safe_mode_test.vala diff --git a/meson.build b/meson.build index 389137f..13374ff 100644 --- a/meson.build +++ b/meson.build @@ -325,6 +325,7 @@ singularity_core_sources = files( 'src/components/sidebar/pages/performance_page.vala', 'src/core/display_manager.vala', 'src/core/hot_corner_manager.vala', + 'src/core/safe_mode.vala', 'src/core/hand_control_manager.vala', 'src/core/layer_window.vala', 'src/core/monitor_osd.vala', @@ -435,3 +436,9 @@ bar_layout_test = executable('bar-layout-test', dependencies: [dependency('gobject-2.0'), gee_dep], ) test('bar-layout', bar_layout_test) + +safe_mode_test = executable('safe-mode-test', + sources: ['src/core/safe_mode.vala', 'tests/safe_mode_test.vala'], + dependencies: [dependency('gobject-2.0')], +) +test('safe-mode', safe_mode_test) diff --git a/src/components/dock/dock.vala b/src/components/dock/dock.vala index a9fc7e6..76f23ce 100644 --- a/src/components/dock/dock.vala +++ b/src/components/dock/dock.vala @@ -817,7 +817,8 @@ namespace Singularity { public signal void dock_visibility_changed(bool hidden); private bool scrolling_tiling_active() { - return _settings.get_boolean("tiling-enabled") + return SafeMode.get_default().allows(SafeFeature.TILING) + && _settings.get_boolean("tiling-enabled") && _settings.get_string("tiling-layout") == "scrolling"; } diff --git a/src/components/overview/app_launcher_grid.vala b/src/components/overview/app_launcher_grid.vala index c4028e5..fc51ff1 100644 --- a/src/components/overview/app_launcher_grid.vala +++ b/src/components/overview/app_launcher_grid.vala @@ -133,7 +133,8 @@ namespace Singularity { // Listen to widget registry changes (in case a plugin or manifest // arrives after the overview is built). OverviewWidgetRegistry.get_default().changed.connect(on_apps_changed); - OverviewWidgetRegistry.get_default().load_manifests(); + if (SafeMode.get_default().allows(SafeFeature.CUSTOM_WIDGETS)) + OverviewWidgetRegistry.get_default().load_manifests(); // Container-level drop: dragged item is reordered relative to // whatever child is under the pointer. diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index d01dfad..e4716f4 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -82,6 +82,29 @@ namespace Singularity { back_clicked.connect(() => { view.go_home(); }); + + var safe_mode = SafeMode.get_default(); + if (safe_mode.active) { + var recovery_group = new PreferencesGroup( + _("Safe Mode"), + _("The desktop recovered from repeated startup crashes. Your settings were not erased; optional startup features are temporarily inactive.")); + recovery_group.add_row(new ActionRow( + _("Recovery session active"), safe_mode.reason, + "dialog-warning-symbolic")); + var restart_normal = new ActionRow( + _("Restart Normal Session"), + _("Use your repaired settings on the next login"), + "view-refresh-symbolic"); + restart_normal.activated.connect(() => { + if (safe_mode.clear_marker()) { + SessionManager.get_default().logout(); + } else { + restart_normal.subtitle = _("Could not clear the Safe Mode marker; see the desktop log"); + } + }); + recovery_group.add_row(restart_normal); + add_group(recovery_group); + } var reset_btn = new Button.from_icon_name("edit-undo-symbolic"); reset_btn.has_frame = false; reset_btn.tooltip_text = _("Reset to Default"); @@ -950,7 +973,11 @@ namespace Singularity { }); wm_group.add_row(rounded_row); - var tile_row = new SwitchRow(_("Tiling"), _("Automatically arrange windows using the selected layout"), settings.get_boolean("tiling-enabled")); + string tiling_description = safe_mode.active + ? _("Configured value is shown here, but tiling is inactive until you restart normally") + : _("Automatically arrange windows using the selected layout"); + var tile_row = new SwitchRow(_("Tiling"), tiling_description, + settings.get_boolean("tiling-enabled")); tile_row.switch_btn.notify["active"].connect(() => { settings.set_boolean("tiling-enabled", tile_row.switch_btn.active); }); diff --git a/src/core/hand_control_manager.vala b/src/core/hand_control_manager.vala index 5e2510b..5dde6a4 100644 --- a/src/core/hand_control_manager.vala +++ b/src/core/hand_control_manager.vala @@ -33,6 +33,7 @@ namespace Singularity { } public void calibrate() { + if (!SafeMode.get_default().allows(SafeFeature.HAND_CONTROL)) return; if (!available) return; launch_command({ resolve_binary(), "--calibrate" }); } @@ -47,6 +48,12 @@ namespace Singularity { } private void sync() { + if (!SafeMode.get_default().allows(SafeFeature.HAND_CONTROL)) { + if (process != null) process.force_exit(); + process = null; + availability_changed(); + return; + } if (settings.get_boolean("hand-control-enabled")) { start(); } else { @@ -79,7 +86,8 @@ namespace Singularity { started.get_exit_status() != 0); bool was_primary = GLib.get_monotonic_time() - started_at > 1000000; - if (settings.get_boolean("hand-control-enabled") && + if (SafeMode.get_default().allows(SafeFeature.HAND_CONTROL) + && settings.get_boolean("hand-control-enabled") && (failed || was_primary)) { Timeout.add_seconds(1, () => { start(); diff --git a/src/core/main.vala b/src/core/main.vala index 577404b..e206cb9 100644 --- a/src/core/main.vala +++ b/src/core/main.vala @@ -184,7 +184,13 @@ public class SingularityApp : Singularity.ShellApplication, Singularity.Shell.Sh on_desktop_gesture, this); // Launch the user's autostart entries once the session has settled // (bus, portals); nothing else in the shell did this before (#170). - Timeout.add_seconds(1, () => { launch_autostart_apps(); return Source.REMOVE; }); + if (Singularity.SafeMode.get_default().allows( + Singularity.SafeFeature.AUTOSTART)) { + Timeout.add_seconds(1, () => { + launch_autostart_apps(); + return Source.REMOVE; + }); + } var cal_manager = Singularity.Calendar.CalendarManager.get_default(); cal_manager.register_provider(new Singularity.Calendar.LocalProvider()); Bus.own_name( @@ -279,11 +285,14 @@ public class SingularityApp : Singularity.ShellApplication, Singularity.Shell.Sh // Session recovery: snapshot windows on session end; offer to reopen // them (via a dialog) at the next login. - session_recovery = new Singularity.SessionRecovery(); - Singularity.SessionManager.get_default().session_ending.connect(() => { - session_recovery.capture(); - }); - maybe_offer_session_restore(); + if (Singularity.SafeMode.get_default().allows( + Singularity.SafeFeature.SESSION_RESTORE)) { + session_recovery = new Singularity.SessionRecovery(); + Singularity.SessionManager.get_default().session_ending.connect(() => { + session_recovery.capture(); + }); + maybe_offer_session_restore(); + } // Deferred non-critical module creation Idle.add(() => { @@ -298,7 +307,9 @@ public class SingularityApp : Singularity.ShellApplication, Singularity.Shell.Sh _resources.start(); // Pre-warm widget modules (dlopen) at login so the first overview // open isn't stalled loading them. - Singularity.OverviewWidgetRegistry.get_default().load_manifests(); + if (Singularity.SafeMode.get_default().allows( + Singularity.SafeFeature.CUSTOM_WIDGETS)) + Singularity.OverviewWidgetRegistry.get_default().load_manifests(); // Pre-create the search manager so the file provider's Tracker // connection is established at login, not on the first search. Singularity.SearchManager.get_default(); @@ -499,6 +510,17 @@ public class SingularityApp : Singularity.ShellApplication, Singularity.Shell.Sh return Source.REMOVE; }); + if (Singularity.SafeMode.get_default().active) { + warning("Safe mode active: %s", + Singularity.SafeMode.get_default().reason); + // Open the repair surface once the panel/sidebar has settled. This + // is non-modal and remains available through the Settings entry. + Timeout.add(800, () => { + open_settings_page("desktop"); + return Source.REMOVE; + }); + } + } private async void init_goa() { diff --git a/src/core/plugin_manager.vala b/src/core/plugin_manager.vala index 3b9bc8e..99eb0d0 100644 --- a/src/core/plugin_manager.vala +++ b/src/core/plugin_manager.vala @@ -24,7 +24,8 @@ namespace Singularity { context = new PluginContext(); settings = new GLib.Settings("dev.sinty.desktop"); settings.changed["enabled-plugins"].connect(() => { - enable_configured_plugins(); + if (SafeMode.get_default().allows(SafeFeature.PLUGINS)) + enable_configured_plugins(); }); } @@ -78,6 +79,7 @@ namespace Singularity { */ public void load_plugins() { + if (!SafeMode.get_default().allows(SafeFeature.PLUGINS)) return; if (settings.get_strv("enabled-plugins").length == 0 && Environment.get_variable("SINGULARITY_PLUGIN_PATH") == null) { return; @@ -180,6 +182,7 @@ namespace Singularity { } private void enable_configured_plugins() { + if (!SafeMode.get_default().allows(SafeFeature.PLUGINS)) return; if (settings.get_strv("enabled-plugins").length == 0 && !engine_ready) return; ensure_engine(); var model = (GLib.ListModel)engine; @@ -204,6 +207,7 @@ namespace Singularity { } private void update_plugin_state(string module_name, bool enabled) { + if (!SafeMode.get_default().allows(SafeFeature.PLUGINS)) return; ensure_engine(); var info = engine.get_plugin_info(module_name); if (info == null) return; diff --git a/src/core/safe_mode.vala b/src/core/safe_mode.vala new file mode 100644 index 0000000..9c54cc5 --- /dev/null +++ b/src/core/safe_mode.vala @@ -0,0 +1,64 @@ +namespace Singularity { + + /** Optional startup features suppressed while recovering from a crash loop. */ + public enum SafeFeature { + TILING, + PLUGINS, + CUSTOM_WIDGETS, + HAND_CONTROL, + SESSION_RESTORE, + AUTOSTART + } + + /** + * Process-wide recovery policy. + * + * Safe mode deliberately leaves the normal GSettings backend in place so + * Settings can repair persisted values. Callers use allows() for runtime + * activation rather than replacing or rewriting the configured value. + */ + public class SafeMode : Object { + private static SafeMode? instance; + + public bool active { get; private set; } + public string marker_path { get; private set; } + public string reason { get; private set; } + + public static SafeMode get_default() { + if (instance == null) { + string? marker = Environment.get_variable( + "SINGULARITY_SAFE_MODE_MARKER"); + if (marker == null || marker == "") { + marker = Path.build_filename(Environment.get_user_state_dir(), + "singularity", "safe-mode"); + } + string? configured_reason = Environment.get_variable( + "SINGULARITY_SAFE_MODE_REASON"); + instance = new SafeMode( + Environment.get_variable("SINGULARITY_SAFE_MODE") == "1", + marker, + configured_reason ?? "Repeated startup failures"); + } + return instance; + } + + public SafeMode(bool active, string marker_path, + string reason = "Repeated startup failures") { + this.active = active; + this.marker_path = marker_path; + this.reason = reason; + } + + public bool allows(SafeFeature feature) { + return !active; + } + + /** Remove the persistent recovery marker before ending this session. */ + public bool clear_marker() { + if (!FileUtils.test(marker_path, FileTest.EXISTS)) return true; + if (FileUtils.unlink(marker_path) == 0) return true; + warning("SafeMode: could not remove recovery marker %s", marker_path); + return false; + } + } +} diff --git a/src/core/tiling_manager.vala b/src/core/tiling_manager.vala index 05a290a..3236be3 100644 --- a/src/core/tiling_manager.vala +++ b/src/core/tiling_manager.vala @@ -99,6 +99,7 @@ namespace Singularity { private static TilingManager? instance; private AppSystem app_system; private GLib.Settings settings; + private SafeMode safe_mode; private bool enabled = true; private bool shell_overview_active = false; private uint apply_timeout_id = 0; @@ -140,9 +141,11 @@ namespace Singularity { public TilingManager(AppSystem app_system) { instance = this; this.app_system = app_system; + safe_mode = SafeMode.get_default(); settings = new GLib.Settings("dev.sinty.desktop"); setup_close_gesture_indicator(); - enabled = settings.get_boolean("tiling-enabled"); + enabled = settings.get_boolean("tiling-enabled") + && safe_mode.allows(SafeFeature.TILING); if (scrolling_active()) { foreach (var win in app_system.get_windows()) startup_windows.add(win); @@ -180,7 +183,7 @@ namespace Singularity { on_tiling_interaction, this); Singularity.wayland_set_cursor_position_callback( on_cursor_position, this); - sync_compositor_mode(); + if (safe_mode.allows(SafeFeature.TILING)) sync_compositor_mode(); if (enabled) schedule_apply_layout(); } @@ -238,11 +241,16 @@ namespace Singularity { } private void sync_compositor_mode() { + if (!safe_mode.allows(SafeFeature.TILING)) return; Singularity.wayland_set_scrolling_mode(scrolling_active() ? 1u : 0u); } private void on_mode_changed() { - enabled = settings.get_boolean("tiling-enabled"); + enabled = settings.get_boolean("tiling-enabled") + && safe_mode.allows(SafeFeature.TILING); + // Persist configuration changes in recovery mode, but do not send + // any tiling protocol requests while the feature is blocked. + if (!safe_mode.allows(SafeFeature.TILING)) return; bool is_scrolling = scrolling_active(); sync_compositor_mode(); hide_drop_preview(); @@ -395,6 +403,7 @@ namespace Singularity { } private void hide_drop_preview() { + if (!safe_mode.allows(SafeFeature.TILING)) return; Singularity.wayland_set_tiling_drop_preview(0, 0, 0, 0, 0); } @@ -1709,6 +1718,7 @@ namespace Singularity { } public void apply_layout() { + if (!safe_mode.allows(SafeFeature.TILING)) return; if (scrolling_active() && shell_overview_active) return; var tileable = get_tileable_windows(); if (scrolling_active()) { diff --git a/tests/safe_mode_test.vala b/tests/safe_mode_test.vala new file mode 100644 index 0000000..6106d2c --- /dev/null +++ b/tests/safe_mode_test.vala @@ -0,0 +1,47 @@ +using Singularity; + +private void test_policy() { + var normal = new SafeMode(false, "/tmp/not-used"); + var safe = new SafeMode(true, "/tmp/not-used"); + SafeFeature[] features = { + SafeFeature.TILING, + SafeFeature.PLUGINS, + SafeFeature.CUSTOM_WIDGETS, + SafeFeature.HAND_CONTROL, + SafeFeature.SESSION_RESTORE, + SafeFeature.AUTOSTART + }; + + foreach (var feature in features) { + assert(normal.allows(feature)); + assert(!safe.allows(feature)); + } +} + +private void test_marker_clear() { + string dir; + try { + dir = DirUtils.make_tmp("singularity-safe-mode-test-XXXXXX"); + } catch (FileError e) { + assert_not_reached(); + } + string marker = Path.build_filename(dir, "safe-mode"); + try { + FileUtils.set_contents(marker, "version=1\n"); + } catch (FileError e) { + assert_not_reached(); + } + + var safe = new SafeMode(true, marker); + assert(safe.clear_marker()); + assert(!FileUtils.test(marker, FileTest.EXISTS)); + assert(safe.clear_marker()); + DirUtils.remove(dir); +} + +public int main(string[] args) { + Test.init(ref args); + Test.add_func("/safe-mode/policy", test_policy); + Test.add_func("/safe-mode/marker-clear", test_marker_clear); + return Test.run(); +} From 23a4da4b219df762c67443d92dd0aef060728182 Mon Sep 17 00:00:00 2001 From: mateoalfaro Date: Fri, 4 Sep 2026 15:56:32 -0600 Subject: [PATCH 2/2] feat: link safe mode recovery row to issue tracker --- src/components/sidebar/pages/desktop_page.vala | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/sidebar/pages/desktop_page.vala b/src/components/sidebar/pages/desktop_page.vala index e4716f4..9b15549 100644 --- a/src/components/sidebar/pages/desktop_page.vala +++ b/src/components/sidebar/pages/desktop_page.vala @@ -88,9 +88,20 @@ namespace Singularity { var recovery_group = new PreferencesGroup( _("Safe Mode"), _("The desktop recovered from repeated startup crashes. Your settings were not erased; optional startup features are temporarily inactive.")); - recovery_group.add_row(new ActionRow( - _("Recovery session active"), safe_mode.reason, - "dialog-warning-symbolic")); + var recovery_row = new ActionRow( + _("Recovery session active"), + safe_mode.reason + ". " + _("Click to report a crash on GitHub if this seems relevant."), + "dialog-warning-symbolic"); + recovery_row.activated.connect(() => { + try { + AppInfo.launch_default_for_uri( + "https://github.com/singularityos-lab/singularity-desktop/issues", + null); + } catch (Error e) { + warning("Could not open the issue tracker: %s", e.message); + } + }); + recovery_group.add_row(recovery_row); var restart_normal = new ActionRow( _("Restart Normal Session"), _("Use your repaired settings on the next login"),