diff --git a/CHANGELOG.md b/CHANGELOG.md index 8334c7ed..75e1cf99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,14 @@ All notable changes to this project will be documented in this file. - Map remote overlay loads undo layers from a stale generation, and TileCache map view keys are identity-scoped - Nomad micron LXMF links route to Messages via onLxmfAddress instead of being ignored - RNSH session config_path and identity_path are jailed under storage or the shared Reticulum config dir, and free-form extra_args are rejected +- Failed identity switch no longer emits identity-switched (which wiped keep-alive UI) and clears the switching overlay via identity-switching-abort +- Settings, About, and Interfaces refresh identity-scoped state on identity-switched +- About snapshot/backup restore guards concurrent restores and reloads the web UI after a successful restore +- Interfaces enable/disable write interface_enabled when neither legacy key exists, and disable returns the correct success message +- Interfaces enable/disable roll back in-memory config when the Reticulum config write fails +- Disabling block-all-from-strangers restores a prior inbound stamp cost of 0 instead of forcing 8 +- Discovery settings PATCH returns 500 when RNS reload fails after a successful disk write +- Interfaces stats map is replaced each poll so deleted interfaces cannot stay Connected - Desktop AppImage: main-process logs always append to the storage logs folder (meshchatx.log). Stdout is only used when a terminal is attached, with broken-pipe guards as a fallback, so background launches no longer raise write EPIPE dialogs - Android: lxmfy packaging, flock soft-lock, splash/logo clipping, Landlock skipped on Android - Android RNode BLE/USB via Chaquopy diff --git a/meshchatx.rsm b/meshchatx.rsm index bc897128..c8876941 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 9b06dbc0..5d79775b 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -6057,8 +6057,13 @@ class ReticulumMeshChat: if new_value and not old_value: # Enabling block strangers: save current stamp cost and set to max current_cost = self.config.lxmf_inbound_stamp_cost.get() - if current_cost < 254: - self.config.lxmf_inbound_stamp_cost_before_block.set(current_cost) + if not isinstance(current_cost, int): + current_cost = 0 + if current_cost < 0: + current_cost = 0 + elif current_cost > 254: + current_cost = 254 + self.config.lxmf_inbound_stamp_cost_before_block.set(current_cost) self.config.lxmf_inbound_stamp_cost.set(254) if self.message_router and self.local_lxmf_destination: self.message_router.set_inbound_stamp_cost( @@ -6073,14 +6078,16 @@ class ReticulumMeshChat: destination_hash=self.local_lxmf_destination.hash, ) elif not new_value and old_value: - # Disabling block strangers: restore previous stamp cost + # Disabling block strangers: restore previous stamp cost. + # Zero is a valid prior cost (stamps off). Only fall back to 8 + # when no prior cost was saved (sentinel outside 0..254). saved = self.config.lxmf_inbound_stamp_cost_before_block.get() - if saved > 0 and saved < 255: + if isinstance(saved, int) and 0 <= saved <= 254: restore_cost = saved else: restore_cost = 8 self.config.lxmf_inbound_stamp_cost.set(restore_cost) - self.config.lxmf_inbound_stamp_cost_before_block.set(0) + self.config.lxmf_inbound_stamp_cost_before_block.set(-1) if self.message_router and self.local_lxmf_destination: self.message_router.set_inbound_stamp_cost( self.local_lxmf_destination.hash, diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py index 96012ec3..ce1f358c 100644 --- a/meshchatx/src/backend/config_manager.py +++ b/meshchatx/src/backend/config_manager.py @@ -124,8 +124,8 @@ class ConfigManager: self.lxmf_inbound_stamp_cost_before_block = self.IntConfig( self, "lxmf_inbound_stamp_cost_before_block", - 0, - ) # saved stamp cost before block strangers was enabled + -1, + ) # prior stamp cost while block-all is on (-1 means unset) self.lxmf_flood_protection_enabled = self.BoolConfig( self, "lxmf_flood_protection_enabled", diff --git a/meshchatx/src/backend/http/routes/interfaces.py b/meshchatx/src/backend/http/routes/interfaces.py index 8b9a6f42..2c654fe2 100644 --- a/meshchatx/src/backend/http/routes/interfaces.py +++ b/meshchatx/src/backend/http/routes/interfaces.py @@ -131,6 +131,8 @@ from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401 zipfile, ) +from meshchatx.src.backend.interface_enabled_flag import apply_interface_enabled_flag + def register_interfaces_routes(routes, app): @@ -485,6 +487,7 @@ def register_interfaces_routes(routes, app): ) # enable interface + interfaces_before_write = app._get_interfaces_snapshot() interfaces = app._get_interfaces_section() if interface_name not in interfaces: return web.json_response( @@ -502,10 +505,7 @@ def register_interfaces_routes(routes, app): if i2p_error is not None: return web.json_response({"message": i2p_error}, status=422) - if "enabled" in interface: - interface["enabled"] = "true" - if "interface_enabled" in interface: - interface["interface_enabled"] = "true" + apply_interface_enabled_flag(interface, enabled=True) keys_to_remove = [] for key, value in interface.items(): @@ -515,7 +515,9 @@ def register_interfaces_routes(routes, app): del interface[key] # save config - if not app._write_reticulum_config(): + if not app._write_reticulum_config( + rollback_interfaces=interfaces_before_write, + ): return web.json_response( { "message": "Failed to write Reticulum config", @@ -547,6 +549,7 @@ def register_interfaces_routes(routes, app): ) # disable interface + interfaces_before_write = app._get_interfaces_snapshot() interfaces = app._get_interfaces_section() if interface_name not in interfaces: return web.json_response( @@ -556,10 +559,7 @@ def register_interfaces_routes(routes, app): status=404, ) interface = interfaces[interface_name] - if "enabled" in interface: - interface["enabled"] = "false" - if "interface_enabled" in interface: - interface["interface_enabled"] = "false" + apply_interface_enabled_flag(interface, enabled=False) keys_to_remove = [] for key, value in interface.items(): @@ -569,7 +569,9 @@ def register_interfaces_routes(routes, app): del interface[key] # save config - if not app._write_reticulum_config(): + if not app._write_reticulum_config( + rollback_interfaces=interfaces_before_write, + ): return web.json_response( { "message": "Failed to write Reticulum config", @@ -579,7 +581,7 @@ def register_interfaces_routes(routes, app): return web.json_response( { - "message": "Interface deleted", + "message": "Interface is now disabled", }, ) diff --git a/meshchatx/src/backend/http/routes/reticulum_instance.py b/meshchatx/src/backend/http/routes/reticulum_instance.py index ddd49613..881113b6 100644 --- a/meshchatx/src/backend/http/routes/reticulum_instance.py +++ b/meshchatx/src/backend/http/routes/reticulum_instance.py @@ -242,9 +242,24 @@ def register_reticulum_instance_routes(routes, app): ) try: - await app.reload_reticulum() + reloaded = await app.reload_reticulum() + if reloaded is False: + return web.json_response( + { + "message": "Discovery settings saved but RNS reload failed", + "reloaded": False, + }, + status=500, + ) except Exception as e: logger.debug(f"Failed to reload RNS after discovery config update: {e}") + return web.json_response( + { + "message": f"Discovery settings saved but RNS reload failed: {e}", + "reloaded": False, + }, + status=500, + ) discovery_config = { "discover_interfaces": reticulum_config.get("discover_interfaces"), @@ -270,6 +285,7 @@ def register_reticulum_instance_routes(routes, app): else False, ), "network_identity": reticulum_config.get("network_identity"), + "reloaded": True, } return web.json_response({"discovery": discovery_config}) diff --git a/meshchatx/src/backend/interface_enabled_flag.py b/meshchatx/src/backend/interface_enabled_flag.py new file mode 100644 index 00000000..3cdb32c9 --- /dev/null +++ b/meshchatx/src/backend/interface_enabled_flag.py @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: 0BSD + +"""Helpers for Reticulum interface config flag mutations.""" + +from __future__ import annotations + + +def apply_interface_enabled_flag(interface: dict, *, enabled: bool) -> None: + """Set enabled/interface_enabled consistently, including missing-key configs. + + Older or hand-edited configs may omit both keys. Enable/disable must still + write a flag so the next RNS reload honors the change. + """ + if not isinstance(interface, dict): + raise TypeError("interface must be a dict") + value = "true" if enabled else "false" + if "enabled" in interface: + interface["enabled"] = value + if "interface_enabled" in interface: + interface["interface_enabled"] = value + if "enabled" not in interface and "interface_enabled" not in interface: + interface["interface_enabled"] = value diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index e8e05eda..618db363 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -1072,6 +1072,7 @@ export default { this.startClientHeapMemoryWatch(); GlobalEmitter.on("toast-dismissed", this.onToastDismissedShell); GlobalEmitter.on("identity-switching-start", this.onIdentitySwitchingStartShell); + GlobalEmitter.on("identity-switching-abort", this.onIdentitySwitchingAbortShell); GlobalEmitter.on("identity-switched-apply", this.onIdentitySwitchedApplyShell); GlobalEmitter.on("sync-propagation-node", this.onSyncPropagationNodeShell); GlobalEmitter.on("config-updated", this.onConfigUpdatedExternally); @@ -1179,6 +1180,7 @@ export default { WebSocketConnection.off("connected", this.onWsShellConnected); this.unregisterShellWsHandlers(); GlobalEmitter.off("identity-switching-start", this.onIdentitySwitchingStartShell); + GlobalEmitter.off("identity-switching-abort", this.onIdentitySwitchingAbortShell); GlobalEmitter.off("identity-switched-apply", this.onIdentitySwitchedApplyShell); GlobalEmitter.off("sync-propagation-node", this.onSyncPropagationNodeShell); GlobalEmitter.off("config-updated", this.onConfigUpdatedExternally); @@ -1373,6 +1375,9 @@ export default { } }, 45000); }, + onIdentitySwitchingAbortShell() { + this.isSwitchingIdentity = false; + }, onIdentitySwitchedApplyShell(payload) { this.applyIdentitySwitched(payload).catch(() => {}); }, diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue index 5f3e2c97..389ec6fe 100644 --- a/meshchatx/src/frontend/components/about/AboutPage.vue +++ b/meshchatx/src/frontend/components/about/AboutPage.vue @@ -1420,6 +1420,7 @@ export default { this.restartAboutPollIntervals(); }; GlobalEmitter.on(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler); + GlobalEmitter.on("identity-switched", this.onIdentitySwitched); this.sessionsWsHandler = (payload) => { this.applyActiveSessionsPayload(payload); }; @@ -1436,12 +1437,22 @@ export default { if (this._batterySaverPrefsHandler) { GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler); } + GlobalEmitter.off("identity-switched", this.onIdentitySwitched); if (this.sessionsWsHandler) { offWsEvent("app.sessions.updated", this.sessionsWsHandler); this.sessionsWsHandler = null; } }, methods: { + onIdentitySwitched() { + this.getAppInfo(); + this.getActiveSessions(); + this.getDatabaseHealth(); + this.snapshotsOffset = 0; + this.autoBackupsOffset = 0; + this.listSnapshots(); + this.listAutoBackups(); + }, restartAboutPollIntervals() { if (this.updateInterval) { clearInterval(this.updateInterval); @@ -1578,21 +1589,34 @@ export default { } }, async restoreFromSnapshot(path) { - if (!(await DialogUtils.confirm(this.$t("about.restore_snapshot_confirm")))) { + if (this.restoreInProgress) { return; } + this.restoreInProgress = true; try { + if (!(await DialogUtils.confirm(this.$t("about.restore_snapshot_confirm")))) { + return; + } const response = await window.api.post("/api/v1/database/restore", { path }); if (response.data.status === "success") { ToastUtils.success(this.$t("about.database_restored")); - if (this.isElectron) { - setTimeout(() => ElectronUtils.relaunch(), 2000); - } + this.scheduleRestoreRelaunch(); } } catch { ToastUtils.error(this.$t("about.failed_restore_snapshot")); + } finally { + this.restoreInProgress = false; } }, + scheduleRestoreRelaunch() { + if (this.isElectron) { + setTimeout(() => ElectronUtils.relaunch(), 2000); + return; + } + setTimeout(() => { + window.location.reload(); + }, 2000); + }, async getAppInfo() { try { const response = await window.api.get("/api/v1/app/info"); @@ -1745,9 +1769,7 @@ export default { this.databaseHealth = response.data.database?.health || this.databaseHealth; this.databaseRecoveryActions = response.data.database?.actions || this.databaseRecoveryActions; ToastUtils.success(this.$t("about.database_restored")); - if (this.isElectron) { - setTimeout(() => ElectronUtils.relaunch(), 2000); - } + this.scheduleRestoreRelaunch(); await this.getDatabaseHealth(); } catch (e) { this.restoreError = this.$t("about.failed_restore_file"); diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue index 2c3d5f7d..6ac9b707 100644 --- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue +++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue @@ -939,6 +939,7 @@ export default { if (this._batterySaverPrefsHandler) { GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler); } + GlobalEmitter.off("identity-switched", this.onIdentitySwitched); }, mounted() { try { @@ -962,9 +963,16 @@ export default { this.startInterfacePollIntervals(); }; GlobalEmitter.on(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler); + GlobalEmitter.on("identity-switched", this.onIdentitySwitched); this.startInterfacePollIntervals(); }, methods: { + onIdentitySwitched() { + this.loadInterfaces(); + this.updateInterfaceStats(); + this.loadDiscoveryConfig(); + this.loadDiscoveredInterfaces(); + }, startInterfacePollIntervals() { clearInterval(this.reloadInterval); clearInterval(this.discoveryInterval); @@ -1011,14 +1019,17 @@ export default { // fetch interface stats const response = await window.api.get(`/api/v1/interface-stats`); - // update data + // Replace the map so deleted/renamed interfaces do not keep + // stale Connected indicators for discovery peers. + const nextStats = {}; const interfaces = response.data.interface_stats?.interfaces ?? []; for (const iface of interfaces) { const key = iface.interface_name ?? iface.short_name; if (key) { - this.interfaceStats[key] = iface; + nextStats[key] = iface; } } + this.interfaceStats = nextStats; } catch { // do nothing if failed to load interfaces } diff --git a/meshchatx/src/frontend/components/settings/IdentitiesPage.vue b/meshchatx/src/frontend/components/settings/IdentitiesPage.vue index eb0b41db..b9bcf24f 100644 --- a/meshchatx/src/frontend/components/settings/IdentitiesPage.vue +++ b/meshchatx/src/frontend/components/settings/IdentitiesPage.vue @@ -697,7 +697,9 @@ export default { e.response?.data?.message || this.$t("identities.failed_switch") || "Failed to switch identity"; ToastUtils.error(errorMsg); this.isCreating = false; - GlobalEmitter.emit("identity-switched"); + // Do not emit identity-switched on failure. Keep-alive pages treat + // that event as a real switch and clear identity-scoped UI. + GlobalEmitter.emit("identity-switching-abort"); } }, async deleteIdentity(identity) { diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue index 0ee4ff2b..c7d06110 100644 --- a/meshchatx/src/frontend/components/settings/SettingsPage.vue +++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue @@ -3280,10 +3280,12 @@ export default { beforeUnmount() { // stop listening for websocket messages WebSocketConnection.off("message", this.onWebsocketMessage); + GlobalEmitter.off("identity-switched", this.onIdentitySwitched); }, mounted() { // listen for websocket messages WebSocketConnection.on("message", this.onWebsocketMessage); + GlobalEmitter.on("identity-switched", this.onIdentitySwitched); this.getConfig(); this.getServerSecurity(); @@ -3300,6 +3302,16 @@ export default { this.loadAndroidShellPrivacy(); }, methods: { + onIdentitySwitched() { + this.getConfig(); + this.getServerSecurity(); + this.getTrustedTelemetryPeers(); + this.loadStickerCount(); + this.loadGifCount(); + this.loadBatteryInterfaceRows(); + this.loadReticulumInstanceSettings(); + this.loadAndroidShellPrivacy(); + }, loadBatterySaverPrefsFromStorage() { this.batterySaver = loadBatterySaverPrefs(); if (!this.batterySaver.interfaceBitrateLimits) { diff --git a/tests/backend/test_interface_enabled_flag_oracle.py b/tests/backend/test_interface_enabled_flag_oracle.py new file mode 100644 index 00000000..47d20928 --- /dev/null +++ b/tests/backend/test_interface_enabled_flag_oracle.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: 0BSD + +"""Oracle tests for interface enable/disable flag mutation.""" + +from __future__ import annotations + +from meshchatx.src.backend.interface_enabled_flag import apply_interface_enabled_flag + + +def test_oracle_enable_writes_flag_when_neither_key_present(): + iface = {"type": "TCPClientInterface", "target_host": "127.0.0.1"} + apply_interface_enabled_flag(iface, enabled=True) + assert iface.get("interface_enabled") == "true" + assert "enabled" not in iface or iface["enabled"] == "true" + + +def test_oracle_disable_writes_flag_when_neither_key_present(): + iface = {"type": "TCPClientInterface"} + apply_interface_enabled_flag(iface, enabled=False) + assert iface["interface_enabled"] == "false" + + +def test_oracle_enable_updates_both_legacy_keys(): + iface = {"enabled": "false", "interface_enabled": "false"} + apply_interface_enabled_flag(iface, enabled=True) + assert iface["enabled"] == "true" + assert iface["interface_enabled"] == "true" + + +def test_oracle_disable_route_message_is_not_deleted(): + from pathlib import Path + + src = Path("meshchatx/src/backend/http/routes/interfaces.py").read_text( + encoding="utf-8", + ) + # The disable handler historically returned "Interface deleted". + assert 'message": "Interface is now disabled"' in src + disable_idx = src.index("async def reticulum_interfaces_disable") + delete_idx = src.index("async def reticulum_interfaces_delete") + disable_body = src[disable_idx:delete_idx] + assert "Interface deleted" not in disable_body diff --git a/tests/backend/test_settings_interfaces_followup_oracle.py b/tests/backend/test_settings_interfaces_followup_oracle.py new file mode 100644 index 00000000..ebc2e708 --- /dev/null +++ b/tests/backend/test_settings_interfaces_followup_oracle.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: 0BSD + +"""Oracles for settings stamp restore and interface enable rollback.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from meshchatx.meshchat import ReticulumMeshChat + + +@pytest.mark.asyncio +async def test_oracle_block_all_restore_preserves_zero_stamp_cost(): + """Disabling block-all must restore stamps-off (0), not fall back to 8.""" + app = MagicMock(spec=ReticulumMeshChat) + app.config = MagicMock() + app.message_router = None + app.local_lxmf_destination = None + app._parse_bool = staticmethod(ReticulumMeshChat._parse_bool) + app.sync_telephone_call_policy = MagicMock() + app.send_config_to_websocket_clients = AsyncMock() + app.send_active_sessions_to_websocket_clients = AsyncMock() + + stamp_cost = {"v": 0} + before = {"v": -1} + block = {"v": False} + + app.config.lxmf_inbound_stamp_cost.get.side_effect = lambda: stamp_cost["v"] + app.config.lxmf_inbound_stamp_cost.set.side_effect = lambda v: stamp_cost.__setitem__( + "v", + v, + ) + app.config.lxmf_inbound_stamp_cost_before_block.get.side_effect = ( + lambda: before["v"] + ) + app.config.lxmf_inbound_stamp_cost_before_block.set.side_effect = ( + lambda v: before.__setitem__("v", v) + ) + app.config.block_all_from_strangers.get.side_effect = lambda: block["v"] + app.config.block_all_from_strangers.set.side_effect = lambda v: block.__setitem__( + "v", + v, + ) + + await ReticulumMeshChat.update_config(app, {"block_all_from_strangers": True}) + assert stamp_cost["v"] == 254 + assert before["v"] == 0 + + await ReticulumMeshChat.update_config(app, {"block_all_from_strangers": False}) + assert stamp_cost["v"] == 0 + assert before["v"] == -1 + + +def test_oracle_enable_disable_pass_rollback_interfaces(): + src = Path("meshchatx/src/backend/http/routes/interfaces.py").read_text( + encoding="utf-8", + ) + enable_idx = src.index("async def reticulum_interfaces_enable") + disable_idx = src.index("async def reticulum_interfaces_disable") + delete_idx = src.index("async def reticulum_interfaces_delete") + enable_body = src[enable_idx:disable_idx] + disable_body = src[disable_idx:delete_idx] + assert "rollback_interfaces=interfaces_before_write" in enable_body + assert "rollback_interfaces=interfaces_before_write" in disable_body + assert "_get_interfaces_snapshot()" in enable_body + assert "_get_interfaces_snapshot()" in disable_body + + +def test_oracle_discovery_patch_reports_reload_failure(): + src = Path( + "meshchatx/src/backend/http/routes/reticulum_instance.py", + ).read_text(encoding="utf-8") + patch_idx = src.index("async def reticulum_discovery_patch") + next_idx = src.index("async def reticulum_discovered_interfaces") + body = src[patch_idx:next_idx] + assert "RNS reload failed" in body + assert "status=500" in body + + +def test_oracle_interface_stats_replace_map(): + src = Path( + "meshchatx/src/frontend/components/interfaces/InterfacesPage.vue", + ).read_text(encoding="utf-8") + assert "this.interfaceStats = nextStats" in src + assert "const nextStats = {}" in src diff --git a/tests/frontend/AppIdentitySwitch.test.js b/tests/frontend/AppIdentitySwitch.test.js index 01ad42ab..faa6d5a1 100644 --- a/tests/frontend/AppIdentitySwitch.test.js +++ b/tests/frontend/AppIdentitySwitch.test.js @@ -169,4 +169,10 @@ describe("App.vue applyIdentitySwitched", () => { await Promise.resolve(); expect(inner).toHaveBeenCalledWith({ identity_hash: "x", display_name: "Y" }); }); + + it("onIdentitySwitchingAbortShell clears the switching overlay", () => { + const ctx = { isSwitchingIdentity: true }; + App.methods.onIdentitySwitchingAbortShell.call(ctx); + expect(ctx.isSwitchingIdentity).toBe(false); + }); }); diff --git a/tests/frontend/settingsAboutInterfacesIdentitiesOracle.test.js b/tests/frontend/settingsAboutInterfacesIdentitiesOracle.test.js new file mode 100644 index 00000000..1e12fe71 --- /dev/null +++ b/tests/frontend/settingsAboutInterfacesIdentitiesOracle.test.js @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: 0BSD + +import { readFileSync } from "fs"; +import { join } from "path"; +import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; +import { mount } from "@vue/test-utils"; +import IdentitiesPage from "../../meshchatx/src/frontend/components/settings/IdentitiesPage.vue"; +import AboutPage from "../../meshchatx/src/frontend/components/about/AboutPage.vue"; +import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter"; +import DialogUtils from "../../meshchatx/src/frontend/js/DialogUtils"; +import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils"; +import ElectronUtils from "../../meshchatx/src/frontend/js/ElectronUtils"; + +vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({ + default: { + confirm: vi.fn().mockResolvedValue(true), + alert: vi.fn(), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/DownloadUtils", () => ({ + default: { + downloadFromApiResponse: vi.fn(), + downloadFile: vi.fn(), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({ + default: { + isElectron: () => false, + relaunch: vi.fn(), + getMemoryUsage: vi.fn(), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/registries/wsEventRegistry.js", () => ({ + onWsEvent: vi.fn(), + offWsEvent: vi.fn(), +})); + +vi.mock("../../meshchatx/src/frontend/js/deviceBattery.js", () => ({ + appBatteryUsageToneClass: () => "", + batteryStatusIconName: () => "battery", + formatAppBatteryShareLabel: () => "", + formatAppBatteryUsageLabel: () => "", + formatProcessUptime: () => "", + getDeviceBatteryStatus: vi.fn().mockResolvedValue(null), + isNativeBatteryStatus: () => false, +})); + +describe("settings/about/interfaces/identities exploratory oracles", () => { + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + delete window.api; + }); + + it("failed identity switch does not emit identity-switched (avoids wiping keep-alive UI)", async () => { + const emitSpy = vi.spyOn(GlobalEmitter, "emit"); + window.api = { + get: vi.fn().mockResolvedValue({ + data: { + identities: [ + { hash: "hash1", display_name: "A", is_current: true }, + { hash: "hash2", display_name: "B", is_current: false }, + ], + }, + }), + post: vi.fn().mockRejectedValue({ response: { data: { message: "busy" } } }), + }; + + const wrapper = mount(IdentitiesPage, { + global: { + stubs: { + MaterialDesignIcon: true, + LxmfUserIcon: true, + }, + mocks: { + $t: (k) => k, + }, + }, + }); + await wrapper.vm.$nextTick(); + await wrapper.vm.getIdentities(); + emitSpy.mockClear(); + + await wrapper.vm.switchIdentity({ + hash: "hash2", + display_name: "B", + is_current: false, + }); + + const emitted = emitSpy.mock.calls.map((c) => c[0]); + expect(emitted).toContain("identity-switching-start"); + expect(emitted).toContain("identity-switching-abort"); + expect(emitted).not.toContain("identity-switched"); + expect(emitted).not.toContain("identity-switched-apply"); + expect(ToastUtils.error).toHaveBeenCalled(); + wrapper.unmount(); + emitSpy.mockRestore(); + }); + + it("SettingsPage listens for identity-switched and refreshes config", () => { + const src = readFileSync( + join(process.cwd(), "meshchatx/src/frontend/components/settings/SettingsPage.vue"), + "utf8" + ); + expect(src).toContain('GlobalEmitter.on("identity-switched"'); + expect(src).toContain("onIdentitySwitched()"); + expect(src).toMatch(/onIdentitySwitched\(\)\s*\{[\s\S]*getConfig\(\)/); + }); + + it("AboutPage listens for identity-switched and refreshes backups/snapshots", () => { + const src = readFileSync( + join(process.cwd(), "meshchatx/src/frontend/components/about/AboutPage.vue"), + "utf8" + ); + expect(src).toContain('GlobalEmitter.on("identity-switched"'); + expect(src).toMatch(/onIdentitySwitched\(\)\s*\{[\s\S]*listSnapshots\(\)/); + expect(src).toMatch(/onIdentitySwitched\(\)\s*\{[\s\S]*listAutoBackups\(\)/); + }); + + it("InterfacesPage listens for identity-switched and reloads interface lists", () => { + const src = readFileSync( + join(process.cwd(), "meshchatx/src/frontend/components/interfaces/InterfacesPage.vue"), + "utf8" + ); + expect(src).toContain('GlobalEmitter.on("identity-switched"'); + expect(src).toMatch(/onIdentitySwitched\(\)\s*\{[\s\S]*loadInterfaces\(\)/); + }); + + it("About restoreFromSnapshot guards restoreInProgress and reloads web UI", async () => { + vi.useFakeTimers(); + const reloadSpy = vi.fn(); + const originalLocation = window.location; + Object.defineProperty(window, "location", { + configurable: true, + value: { reload: reloadSpy }, + }); + + const ctx = { + restoreInProgress: false, + isElectron: false, + $t: (k) => k, + scheduleRestoreRelaunch: AboutPage.methods.scheduleRestoreRelaunch, + }; + ctx.scheduleRestoreRelaunch = AboutPage.methods.scheduleRestoreRelaunch.bind(ctx); + + window.api = { + post: vi.fn().mockResolvedValue({ data: { status: "success" } }), + }; + + const first = AboutPage.methods.restoreFromSnapshot.call(ctx, "/storage/snapshots/a.zip"); + expect(ctx.restoreInProgress).toBe(true); + const second = AboutPage.methods.restoreFromSnapshot.call(ctx, "/storage/snapshots/b.zip"); + await Promise.all([first, second]); + + expect(window.api.post).toHaveBeenCalledTimes(1); + expect(DialogUtils.confirm).toHaveBeenCalledTimes(1); + expect(ToastUtils.success).toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(2000); + expect(reloadSpy).toHaveBeenCalled(); + expect(ElectronUtils.relaunch).not.toHaveBeenCalled(); + expect(ctx.restoreInProgress).toBe(false); + + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); + }); +});