diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index ec7d8d3..0af2fb8 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -644,6 +644,17 @@ class PasswordManager: config_manager=getattr(self, "config_manager", None), parent_seed=getattr(self, "parent_seed", None), ) + if getattr(self, "manifest_id", None): + from nostr.backup_models import Manifest + + with self.nostr_client._state_lock: + self.nostr_client.current_manifest_id = self.manifest_id + self.nostr_client.current_manifest = Manifest( + ver=1, + algo="gzip", + chunks=[], + delta_since=self.delta_since or None, + ) logging.info( f"NostrClient re-initialized with seed profile {self.current_fingerprint}." ) @@ -1127,10 +1138,14 @@ class PasswordManager: relay_list = state.get("relays", list(DEFAULT_RELAYS)) self.last_bip85_idx = state.get("last_bip85_idx", 0) self.last_sync_ts = state.get("last_sync_ts", 0) + self.manifest_id = state.get("manifest_id") + self.delta_since = state.get("delta_since", 0) else: relay_list = list(DEFAULT_RELAYS) self.last_bip85_idx = 0 self.last_sync_ts = 0 + self.manifest_id = None + self.delta_since = 0 self.offline_mode = bool(config.get("offline_mode", False)) self.inactivity_timeout = config.get( "inactivity_timeout", INACTIVITY_TIMEOUT @@ -1149,6 +1164,18 @@ class PasswordManager: parent_seed=getattr(self, "parent_seed", None), ) + if getattr(self, "manifest_id", None): + from nostr.backup_models import Manifest + + with self.nostr_client._state_lock: + self.nostr_client.current_manifest_id = self.manifest_id + self.nostr_client.current_manifest = Manifest( + ver=1, + algo="gzip", + chunks=[], + delta_since=self.delta_since or None, + ) + logger.debug("Managers re-initialized for the new fingerprint.") except Exception as e: @@ -3684,6 +3711,14 @@ class PasswordManager: if manifest is not None: chunk_ids = [c.event_id for c in manifest.chunks if c.event_id] delta_ids = self.nostr_client.get_delta_events() + if manifest is not None and self.state_manager is not None: + ts = manifest.delta_since or int(time.time()) + self.state_manager.update_state( + manifest_id=event_id, + delta_since=ts, + last_sync_ts=ts, + ) + self.last_sync_ts = ts return { "manifest_id": event_id, "chunk_ids": chunk_ids, @@ -4062,6 +4097,18 @@ class PasswordManager: parent_seed=getattr(self, "parent_seed", None), ) + if getattr(self, "manifest_id", None): + from nostr.backup_models import Manifest + + with self.nostr_client._state_lock: + self.nostr_client.current_manifest_id = self.manifest_id + self.nostr_client.current_manifest = Manifest( + ver=1, + algo="gzip", + chunks=[], + delta_since=self.delta_since or None, + ) + # Push a fresh backup to Nostr so the newly encrypted index is # stored remotely. Include a tag to mark the password change. try: diff --git a/src/seedpass/core/state_manager.py b/src/seedpass/core/state_manager.py index 8d142f9..f2ca11e 100644 --- a/src/seedpass/core/state_manager.py +++ b/src/seedpass/core/state_manager.py @@ -23,6 +23,8 @@ class StateManager: return { "last_bip85_idx": 0, "last_sync_ts": 0, + "manifest_id": None, + "delta_since": 0, "relays": list(DEFAULT_RELAYS), } with shared_lock(self.state_path) as fh: @@ -32,6 +34,8 @@ class StateManager: return { "last_bip85_idx": 0, "last_sync_ts": 0, + "manifest_id": None, + "delta_since": 0, "relays": list(DEFAULT_RELAYS), } try: @@ -40,6 +44,8 @@ class StateManager: obj = {} obj.setdefault("last_bip85_idx", 0) obj.setdefault("last_sync_ts", 0) + obj.setdefault("manifest_id", None) + obj.setdefault("delta_since", 0) obj.setdefault("relays", list(DEFAULT_RELAYS)) return obj diff --git a/src/tests/test_manifest_state_restore.py b/src/tests/test_manifest_state_restore.py new file mode 100644 index 0000000..c28fad7 --- /dev/null +++ b/src/tests/test_manifest_state_restore.py @@ -0,0 +1,70 @@ +import asyncio +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, dummy_nostr_client, TEST_SEED + +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.state_manager import StateManager +from seedpass.core.manager import PasswordManager, EncryptionMode + + +def _init_pm(dir_path: Path, client) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + state_mgr = StateManager(dir_path) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.state_manager = state_mgr + pm.nostr_client = client + pm.fingerprint_dir = dir_path + pm.current_fingerprint = "fp" + pm.parent_seed = TEST_SEED + pm.is_dirty = False + return pm + + +def test_manifest_state_restored(monkeypatch, tmp_path): + client, relay = dummy_nostr_client.__wrapped__(tmp_path / "c1", monkeypatch) + with TemporaryDirectory() as tmpdir: + fp_dir = Path(tmpdir) + pm1 = _init_pm(fp_dir, client) + pm1.entry_manager.add_entry("site", 8) + result = pm1.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + state = pm1.state_manager.state + delta_ts = state["delta_since"] + assert state["manifest_id"] == manifest_id + assert delta_ts > 0 + assert result["manifest_id"] == manifest_id + + client2, _ = dummy_nostr_client.__wrapped__(tmp_path / "c2", monkeypatch) + monkeypatch.setattr( + "seedpass.core.manager.NostrClient", lambda *a, **k: client2 + ) + + pm2 = PasswordManager.__new__(PasswordManager) + pm2.encryption_mode = EncryptionMode.SEED_ONLY + vault2, enc_mgr2 = create_vault(fp_dir) + pm2.encryption_manager = enc_mgr2 + pm2.vault = vault2 + pm2.fingerprint_dir = fp_dir + pm2.current_fingerprint = "fp" + pm2.parent_seed = TEST_SEED + pm2.bip85 = None + pm2.initialize_managers() + + assert pm2.nostr_client is client2 + assert pm2.nostr_client.get_current_manifest_id() == manifest_id + assert pm2.nostr_client.get_current_manifest().delta_since == delta_ts + assert pm2.last_sync_ts == delta_ts diff --git a/src/tests/test_state_manager.py b/src/tests/test_state_manager.py index 0aef6d6..71abe25 100644 --- a/src/tests/test_state_manager.py +++ b/src/tests/test_state_manager.py @@ -12,15 +12,24 @@ def test_state_manager_round_trip(): assert state["relays"] == list(DEFAULT_RELAYS) assert state["last_bip85_idx"] == 0 assert state["last_sync_ts"] == 0 + assert state["manifest_id"] is None + assert state["delta_since"] == 0 sm.add_relay("wss://example.com") - sm.update_state(last_bip85_idx=5, last_sync_ts=123) + sm.update_state( + last_bip85_idx=5, + last_sync_ts=123, + manifest_id="mid", + delta_since=111, + ) sm2 = StateManager(Path(tmpdir)) state2 = sm2.state assert "wss://example.com" in state2["relays"] assert state2["last_bip85_idx"] == 5 assert state2["last_sync_ts"] == 123 + assert state2["manifest_id"] == "mid" + assert state2["delta_since"] == 111 sm2.remove_relay(1) # remove first default relay assert len(sm2.list_relays()) == len(DEFAULT_RELAYS)