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
12 changes: 7 additions & 5 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.6.0 — early.** It installs from a `.deb` on Ubuntu 22.04 or
**Version 0.7.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,9 @@ 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.8.0, except the 2.7.0 redesign, which is
not ported yet (see *What is different from Windows*).
features match PassMan for Windows 2.9.0, including its black-and-green
window and dialogs (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 @@ -132,16 +133,17 @@ Where that README names a Windows version, read the Linux one beside it:
| 2.4.0 | 0.3.0 | deleting archived entries after N days |
| 2.5.0 | 0.4.0 | switching servers no longer removes entries silently |
| 2.6.0 | 0.5.0 | a copy that is not installed says LAB in its title |
| 2.7.0 | not yet | the redesigned window |
| 2.7.0 | 0.7.0 | the redesigned window |
| 2.8.0 | 0.6.0 | locking no longer clears a copied password |
| 2.9.0 | 0.7.0 | the dialogs redesigned to match |

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

## Important: your master password cannot be recovered

The master password *is* the encryption key. Nobody can decrypt your vault
without it, and there is no reset and no recovery. **Make backups:**
Settings → *Save encrypted backup* writes a copy that is still protected by
Settings → *Encrypted backup* → *Save backup…* writes a copy that is still protected by
your master password.

## Project layout
Expand Down
484 changes: 201 additions & 283 deletions sourceFiles/dialogs.py

Large diffs are not rendered by default.

799 changes: 546 additions & 253 deletions sourceFiles/gui.py

Large diffs are not rendered by default.

73 changes: 31 additions & 42 deletions sourceFiles/history_ui.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
The revision history drop-down under each account row.
The revision history section of the entry details.

Presentation only: recording, reverting and clearing revisions all live in
vault.py (update_entry, revert_entry, clear_history), and merging them across
Expand Down Expand Up @@ -34,6 +34,28 @@ def local_time(stamp: str) -> str:
return "unknown time"


def short_when(stamp: str, now=None) -> str:
"""
A stored UTC timestamp, short enough for a list row: the time for today,
the day and month for this year, else the month and year.
"""
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
try:
moment = datetime.strptime(stamp, fmt).replace(tzinfo=timezone.utc)
break
except (TypeError, ValueError):
continue
else:
return ""
moment = moment.astimezone()
now = (now or datetime.now(timezone.utc)).astimezone()
if moment.date() == now.date():
return moment.strftime("%H:%M")
if moment.year == now.year:
return f"{moment.day} {moment.strftime('%b')}"
return moment.strftime("%b %Y")


def describe(rev: dict) -> str:
"""What the edit that replaced this version changed, in a few words."""
changed = rev.get("changed") or []
Expand Down Expand Up @@ -64,47 +86,12 @@ def _small_button(parent, text, command, accent=False, danger=False, width=62):
font=F(11, "bold"))


def attach(app, row, acc, buttons, column):
def panel(app, parent, acc):
"""
Give an account row its History button (in ``buttons`` at ``column``)
and the drop-down beneath the row.

Which rows are open is remembered on the app, so the panel stays open
across the list refresh that a revert or a sync triggers.
Build the history of ``acc`` into ``parent`` and return the frame, for
the caller to place. Revert and Clear refresh the app, which rebuilds it.
"""
count = len(acc.get("history") or [])
toggle_btn = ctk.CTkButton(
buttons, text="History", width=70, height=34, corner_radius=8,
fg_color="transparent", border_width=1, border_color=BORDER,
hover_color=CARD_HOVER, text_color=TEXT if count else MUTED,
font=F(13, "bold"))
toggle_btn.grid(row=0, column=column, padx=4)
Tooltip(toggle_btn,
f"History — {count} earlier version{'s' if count != 1 else ''}"
if count else "History — no earlier versions yet")

state = {"panel": None}

def open_panel():
state["panel"] = _build_panel(app, row, acc)
app._history_open.add(acc["id"])

def close_panel():
if state["panel"] is not None:
state["panel"].destroy()
state["panel"] = None
app._history_open.discard(acc["id"])

toggle_btn.configure(
command=lambda: close_panel() if state["panel"] else open_panel())
if acc["id"] in app._history_open:
open_panel()


def _build_panel(app, row, acc):
panel = ctk.CTkFrame(row, fg_color=HEADER_BG, corner_radius=10)
panel.grid(row=1, column=0, columnspan=5, sticky="ew",
padx=14, pady=(0, 12))
panel = ctk.CTkFrame(parent, fg_color=HEADER_BG, corner_radius=14)
panel.grid_columnconfigure(1, weight=1)

history = acc.get("history") or []
Expand Down Expand Up @@ -138,11 +125,13 @@ def _revision_line(app, panel, line, acc, rev):
ctk.CTkLabel(panel, text=local_time(rev.get("replaced_at", "")), width=150,
anchor="w", font=F(12), text_color=TEXT).grid(
row=line, column=0, sticky="w", padx=(12, 8), pady=3)
# Wrapped: the details pane is narrower than the old full-width row.
ctk.CTkLabel(panel, text=describe(rev), anchor="w", font=F(11),
text_color=MUTED).grid(row=line, column=1, sticky="w", pady=3)
text_color=MUTED, wraplength=170, justify="left").grid(
row=line, column=1, sticky="w", pady=3)

secret = rev.get("password", "")
pwd = ctk.CTkLabel(panel, text="•" * 8, width=140, anchor="e",
pwd = ctk.CTkLabel(panel, text="•" * 8, width=110, anchor="e",
font=ctk.CTkFont(family=MONO, size=13),
text_color=MUTED)
pwd.grid(row=line, column=2, padx=(8, 8), pady=3)
Expand Down
Loading
Loading