feat: bug fixes

This commit is contained in:
Ivan 2026-07-24 07:57:39 -05:00
parent 86acc89f90
commit 68e6d65c53
No known key found for this signature in database
16 changed files with 452 additions and 29 deletions

View file

@ -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

Binary file not shown.

View file

@ -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,

View file

@ -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",

View file

@ -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",
},
)

View file

@ -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})

View file

@ -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

View file

@ -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(() => {});
},

View file

@ -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");

View file

@ -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
}

View file

@ -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) {

View file

@ -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) {

View file

@ -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

View file

@ -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

View file

@ -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);
});
});

View file

@ -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,
});
});
});