mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: bug fixes
This commit is contained in:
parent
86acc89f90
commit
68e6d65c53
16 changed files with 452 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
22
meshchatx/src/backend/interface_enabled_flag.py
Normal file
22
meshchatx/src/backend/interface_enabled_flag.py
Normal 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
|
||||
|
|
@ -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(() => {});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
41
tests/backend/test_interface_enabled_flag_oracle.py
Normal file
41
tests/backend/test_interface_enabled_flag_oracle.py
Normal 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
|
||||
88
tests/backend/test_settings_interfaces_followup_oracle.py
Normal file
88
tests/backend/test_settings_interfaces_followup_oracle.py
Normal 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
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
181
tests/frontend/settingsAboutInterfacesIdentitiesOracle.test.js
Normal file
181
tests/frontend/settingsAboutInterfacesIdentitiesOracle.test.js
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue