Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ encrypted file. There is no account and no service behind it: it works
entirely offline, and if you want two PCs in step you run the sync server
yourself, on your own hardware.

**Version 0.8.0 — early.** It installs from a `.deb` on Ubuntu 22.04 or
**Version 0.9.0 — early.** It installs from a `.deb` on Ubuntu 22.04 or
newer, or runs from source. It has so far been tested in CI only, on a
virtual display, not yet on a real desktop.

Expand All @@ -19,8 +19,8 @@ virtual display, not yet on a real desktop.
| [PassMan-Server-Linux](https://github.com/usermaneGermane/PassMan-Server-Linux) | the sync server for Linux, run by systemd |

**Same vaults as on Windows.** The vault file, the sync protocol and the
features match PassMan for Windows 2.10.0, including its black-and-green
window and dialogs and 2FA codes (see the version table under *What is different from
features match PassMan for Windows 2.11.0, including its black-and-green
window and dialogs, 2FA codes and sync on unlock (see the version table under *What is different from
Windows*).
A vault moves between a Windows PC and a Linux PC unchanged, and both sync
with either server.
Expand Down Expand Up @@ -121,7 +121,7 @@ screen if you sync.
from a copy run from source — copy the `vault` folder into
`~/.local/share/passman` yourself.

Everything else — syncing, the archive, revision history, 2FA codes,
Everything else — syncing (and sync on unlock), the archive, revision history, 2FA codes,
importing from other password managers, and the security notes and limits — works and
reads exactly as in [PassMan for Windows's
README](https://github.com/usermaneGermane/PassMan-Client-Windows#syncing-two-pcs).
Expand All @@ -137,6 +137,7 @@ Where that README names a Windows version, read the Linux one beside it:
| 2.8.0 | 0.6.0 | locking no longer clears a copied password |
| 2.9.0 | 0.7.0 | the dialogs redesigned to match |
| 2.10.0 | 0.8.0 | 2FA codes, behind the master password |
| 2.11.0 | 0.9.0 | sync on unlock, a setting that is off by default |

(0.2.0 added the `.deb` package, which has no Windows counterpart.)

Expand Down
21 changes: 21 additions & 0 deletions sourceFiles/dialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,27 @@ def set_autolock(choice):
lambda: (top.destroy(), app.sync_settings_dialog()),
width=150).pack()

if configured:
def set_on_unlock():
on = on_unlock.get() == 1
sync_client.set_sync_on_unlock(app.vault, on)
if app._save():
app.toast("This vault now syncs each time you unlock it" if on
else "Sync on unlock is off — press Sync yourself")

on_unlock = ctk.CTkSwitch(
setting_row(syn, "Sync when I unlock",
"Off: nothing is sent until you press Sync. On: "
"PassMan syncs right after you unlock; a conflict "
"still waits for your choice.", hint),
text="", width=46, switch_width=42, switch_height=22,
progress_color=GREEN, button_color=TEXT,
button_hover_color=TEXT, fg_color=BORDER,
command=set_on_unlock)
on_unlock.pack()
if sync_client.sync_on_unlock(app.vault):
on_unlock.select()

# ---- archive ----
arc = section(wrap, "Archive")

Expand Down
33 changes: 32 additions & 1 deletion sourceFiles/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import dialogs
import history_ui
import lockout
import sync_client
import sync_ui
import totp
from theme import (F, BG, HEADER_BG, CARD, CARD_HOVER, SELECTED, INPUT_BG,
Expand Down Expand Up @@ -114,6 +115,9 @@ def __init__(self, root):
self._last_activity = time.monotonic()
self._autolock_seconds = appconfig.DEFAULT_AUTOLOCK_MINUTES * 60
self._syncing = False
# Counts unlocks and locks, so a sync that finishes after its
# vault was locked can tell (sync_ui._still_open).
self._session = 0
# The entry shown in the details pane, by id, so it survives the list
# being rebuilt after an edit, a revert or a sync.
self._selected_id = None
Expand Down Expand Up @@ -589,6 +593,7 @@ def unlock_vault(self):
return

lockout.reset(username)
self._session += 1
self.vault = loaded
self.master_password = master_password
self.current_user = username
Expand All @@ -608,6 +613,26 @@ def unlock_vault(self):
self.toast(f"Deleted {purged} archived "
f"entr{'y' if purged == 1 else 'ies'} older than "
f"{archive_purge_days(self.vault)} days", "warn")
self._sync_after_unlock()

def _sync_after_unlock(self):
"""
Settings -> Sync -> "Sync when I unlock", off by default. Started a
moment after the main window is up, so the window draws first; a
server that cannot be reached is a warning, not an error -- the vault
is open and usable either way.
"""
if not sync_client.sync_on_unlock(self.vault):
return
session = self._session

def go():
if self._session == session and self.vault is not None:
self.sync_now(offline_hint="Not synced — the server can't be "
"reached. Press Sync once you "
"are connected.")

self.root.after(400, go)


# ==================================================================
Expand Down Expand Up @@ -1222,7 +1247,13 @@ def sync_now(self, **options):
def lock_vault(self, auto=False):
self._cancel_idle_timer()
self._cancel_totp()
if self.master_password and self.current_user:
self._session += 1
# Every change is saved as it is made, so this save is only a safety
# net -- and while a sync runs it would be a hazard: it could land
# after the sync wrote the merged vault and put the pre-merge copy
# back on disk, which the next sync would read as edits undoing the
# other PC's changes.
if self.master_password and self.current_user and not self._syncing:
self._save()
# A copied password outlives the lock: the usual auto-clear timer
# keeps running, so it can still be pasted until that fires.
Expand Down
23 changes: 23 additions & 0 deletions sourceFiles/sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,29 @@ def is_configured(vault_data) -> bool:
return bool(settings.get("url") and settings.get("token"))


#: The switch in Settings -> Sync. Off unless the user turns it on (owner,
#: 2026-11-06): pressing Sync is deliberate, and a conflict dialog nobody
#: asked for is worse than a button.
ON_UNLOCK_KEY = "on_unlock"


def sync_on_unlock(vault_data) -> bool:
"""Whether this vault syncs by itself right after it is unlocked."""
return (is_configured(vault_data)
and get_settings(vault_data).get(ON_UNLOCK_KEY) is True)


def set_sync_on_unlock(vault_data: dict, on: bool) -> None:
"""
Kept with the other sync settings, so it is per vault and per PC: a
merge keeps this PC's own settings (``sync.merged_vault``), and a vault
restored onto a new PC starts with it off.
"""
settings = get_settings(vault_data)
settings[ON_UNLOCK_KEY] = bool(on)
vault_data[SETTINGS_KEY] = settings


# ==============================
# Transport
# ==============================
Expand Down
39 changes: 37 additions & 2 deletions sourceFiles/sync_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,26 @@ def run_sync(app, server_password=None, drop_history=False, offline_hint=""):
app.toast("Syncing…")
_sync_step(app, {"retry_allowed": True, "server_password": server_password,
"drop_history": drop_history, "from_prompt": False,
"offline_hint": offline_hint})
"offline_hint": offline_hint,
"session": getattr(app, "_session", 0)})


def _still_open(app, attempt) -> bool:
"""
Whether the vault this sync started on is still the one open.

The network work takes seconds, and the vault can be locked (by hand or
after inactivity) in the meantime -- most likely with sync on unlock,
which starts one the moment the vault opens. A late result must then do
nothing: putting the merged vault back into ``app.vault`` would hold it in
memory behind the login screen, or hand it to another vault unlocked
since. The merge itself is already saved and pushed by then, so the next
unlock reads it from disk.
"""
if app.vault is not None and getattr(app, "_session", 0) == attempt["session"]:
return True
app._syncing = False
return False


def _sync_step(app, attempt):
Expand All @@ -81,6 +100,8 @@ def work():
return session, session.prepare(app.vault, attempt["server_password"])

def done(payload):
if not _still_open(app, attempt):
return
session, result = payload
if not session.has_work():
return _finish(app, "Already up to date")
Expand All @@ -102,6 +123,8 @@ def work():
return session.commit(app.vault, choices, attempt["drop_history"])

def done(merged):
if not _still_open(app, attempt):
return
app.vault = merged
summary = f"Synced — {result.summary()}"
if session.password_changed:
Expand Down Expand Up @@ -136,6 +159,8 @@ def _sync_failed(app, error, attempt):
not yet the other. Ask for the server's password and go again; the
session works out from there which way to catch up.
"""
if not _still_open(app, attempt):
return
if isinstance(error, sync_client.Conflict409) and attempt["retry_allowed"]:
app.toast("The server moved on — merging again")
return _sync_step(app, dict(attempt, retry_allowed=False))
Expand Down Expand Up @@ -734,6 +759,16 @@ def save_offline():
# ==========================================================================
# First run on a second PC
# ==========================================================================
def _settings_for_restored(fetched, url, token):
"""
The sync settings a vault restored onto this PC starts with: the address
and token just typed, and sync on unlock off -- the vault carries the
other PC's choice, and a new PC decides for itself.
"""
sync_client.set_settings(fetched, url, token)
sync_client.set_sync_on_unlock(fetched, False)


def restore_from_server_dialog(app):
"""
Pull a vault down onto a PC that has none.
Expand Down Expand Up @@ -791,7 +826,7 @@ def work():
if fetched is None:
raise sync_client.SyncError(
f"The server holds no vault for \u201c{username}\u201d.")
sync_client.set_settings(fetched, url, token)
_settings_for_restored(fetched, url, token)
vault.create_new_user(username, password)
vault.save_vault(password, fetched, username)
# Both sides agree right now, so this download is the base for
Expand Down
2 changes: 2 additions & 0 deletions sourceFiles/tests/test_clipboard_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ def __init__(self):
self._clip_job = "clear-job"
self.cleared = []
self.totp_hidden = False
self._session = 0
self._syncing = False

def _cancel_idle_timer(self):
pass
Expand Down
Loading
Loading