zecret 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zecret/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Zecret: a modern, encrypted terminal diary."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ # Read from the installed distribution, so pyproject.toml stays the one
7
+ # place the version is written. A second copy here would eventually
8
+ # disagree with it, and the help screen shows this one to the user.
9
+ __version__ = version("zecret")
10
+ except PackageNotFoundError: # pragma: no cover - only when run uninstalled
11
+ __version__ = "0.0.0+unknown"
zecret/__main__.py ADDED
@@ -0,0 +1,41 @@
1
+ """CLI entry point: `zecret` launches the Textual app.
2
+
3
+ Supports:
4
+ zecret # use default diary path
5
+ zecret --path /custom/diary.enc # override diary location
6
+ (or env var ZECRET_DIARY_PATH)
7
+
8
+ Only the diary is configurable here. Preferences live at a fixed per-user
9
+ path (see config.py): they are settings for the person, not for the file,
10
+ so a diary opened with --path still uses the same theme.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import os
17
+ from pathlib import Path
18
+
19
+ from zecret.app import ZecretApp
20
+ from zecret.storage import DEFAULT_DIARY_PATH
21
+
22
+
23
+ def main() -> None:
24
+ """Parse args/env, construct ZecretApp, and run it."""
25
+ parser = argparse.ArgumentParser(
26
+ prog="zecret", description="A modern, encrypted terminal diary."
27
+ )
28
+ parser.add_argument(
29
+ "--path",
30
+ type=Path,
31
+ default=Path(os.environ.get("ZECRET_DIARY_PATH", DEFAULT_DIARY_PATH)),
32
+ help="Path to the encrypted diary file (default: ~/.zecret/diary.enc).",
33
+ )
34
+ args = parser.parse_args()
35
+
36
+ app = ZecretApp(diary_path=args.path)
37
+ app.run()
38
+
39
+
40
+ if __name__ == "__main__":
41
+ main()
zecret/app.py ADDED
@@ -0,0 +1,197 @@
1
+ """The Zecret Textual application: screen management and app-level state.
2
+
3
+ Screen flow:
4
+ UnlockScreen -> EntryListScreen <-> EditorScreen (one day's entry)
5
+ -> DatePromptScreen -> EditorScreen
6
+ -> SearchScreen
7
+ -> SettingsScreen (theme, password)
8
+ -> HelpScreen
9
+
10
+ The unlocked DiaryFile and derived key live on the App instance for the
11
+ duration of the session and are never persisted in plaintext. Preferences
12
+ (the theme, and how long the diary may sit idle) are the one thing that
13
+ outlives the session, in a separate plaintext file -- see config.py for why
14
+ they are not in the diary.
15
+
16
+ They do not live there indefinitely, though. A diary left open is a diary
17
+ anyone at that terminal can read, so this module watches for a spell of
18
+ quiet and puts it away again: lock() forgets both, tears the screens down
19
+ and goes back to UnlockScreen. Everything to do with that is here rather
20
+ than in a screen, for the same reason routing is -- it is about the session
21
+ as a whole, not about anything on show.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import time
27
+ from pathlib import Path
28
+
29
+ from textual import events
30
+ from textual.app import App
31
+
32
+ from zecret.config import DEFAULT_CONFIG_PATH, DEFAULT_THEME, Config
33
+ from zecret.screens.base import ZecretScreen
34
+ from zecret.screens.entry_list import EntryListScreen
35
+ from zecret.screens.unlock import UnlockScreen
36
+ from zecret.storage import DEFAULT_DIARY_PATH, DiaryFile
37
+
38
+ #: Said when the diary locks itself, so a screen that suddenly wants a
39
+ #: password is not a mystery.
40
+ LOCKED_BY_TIMEOUT = "Locked after a spell of quiet."
41
+ LOCKED_BY_HAND = "Locked."
42
+
43
+
44
+ class ZecretApp(App[None]):
45
+ """Root Textual application for Zecret."""
46
+
47
+ CSS_PATH = "app.tcss"
48
+ TITLE = "Zecret"
49
+
50
+ # No command palette. It offers a search over commands this app does
51
+ # not have, and its one useful entry -- the theme picker -- now lives
52
+ # on the settings screen where it can be saved. Switching it off also
53
+ # drops the ctrl+p binding and the footer's "^p palette" entry.
54
+ ENABLE_COMMAND_PALETTE = False
55
+
56
+ #: How often to ask whether the diary has been left alone long enough
57
+ #: to lock. Coarse on purpose: the check costs a subtraction, and
58
+ #: locking within half a minute of the deadline is close enough for a
59
+ #: timeout measured in minutes. Tests turn it right down.
60
+ IDLE_CHECK_SECONDS = 30.0
61
+
62
+ def __init__(
63
+ self,
64
+ diary_path: Path = DEFAULT_DIARY_PATH,
65
+ config_path: Path | None = None,
66
+ ) -> None:
67
+ """Store the paths; the diary itself is unlocked via UnlockScreen.
68
+
69
+ Args:
70
+ diary_path: Path to the encrypted diary file. Defaults to
71
+ ~/.zecret/diary.enc, overridable via --path / ZECRET_DIARY_PATH.
72
+ config_path: Path to the preferences file, defaulting to
73
+ DEFAULT_CONFIG_PATH. Deliberately a separate argument
74
+ rather than derived from diary_path: preferences belong to
75
+ the person, not the file, so pointing --path at a scratch
76
+ diary must not give it a theme of its own. Resolved here
77
+ rather than as a default value so the fallback can be
78
+ redirected in tests, which must never read or write the
79
+ real one.
80
+ """
81
+ super().__init__()
82
+ self.diary_path = diary_path
83
+ self.config = Config.load(DEFAULT_CONFIG_PATH if config_path is None else config_path)
84
+ # Both are None until UnlockScreen succeeds, and live only in memory
85
+ # for the session. Screens reach the diary exclusively through these.
86
+ self.diary: DiaryFile | None = None
87
+ self.key: bytes | None = None
88
+ # When something last happened. Monotonic, so the diary does not
89
+ # stay open an hour longer because a clock went backwards.
90
+ self.last_activity = time.monotonic()
91
+
92
+ def on_mount(self) -> None:
93
+ """Apply the saved theme, then push UnlockScreen, which prompts to
94
+ unlock or to create a diary depending on whether diary_path exists.
95
+
96
+ Routing lives here rather than in the screens: UnlockScreen reports
97
+ success by dismissing, and this decides what comes next.
98
+ """
99
+ self.apply_theme(self.config.theme)
100
+ creating = not self.diary_path.exists()
101
+ self.push_screen(UnlockScreen(creating=creating), self._on_unlocked)
102
+ self.set_interval(self.IDLE_CHECK_SECONDS, self.lock_if_idle)
103
+
104
+ async def on_event(self, event: events.Event) -> None:
105
+ """Every event the app sees, which is where activity is noticed.
106
+
107
+ Key events reach here even when a widget goes on to consume them --
108
+ typing into the editor's TextArea counts, and has to, or writing a
109
+ long entry would be the one thing that looks like being away.
110
+ """
111
+ if isinstance(event, events.Key | events.MouseDown):
112
+ self.last_activity = time.monotonic()
113
+ await super().on_event(event)
114
+
115
+ def apply_theme(self, theme: str) -> None:
116
+ """Switch to `theme`, or to the default if it is not a real theme.
117
+
118
+ Config holds whatever string was in the file, which may name a
119
+ theme this Textual no longer ships; setting it directly would raise
120
+ and take the app down over a preference.
121
+ """
122
+ self.theme = theme if theme in self.available_themes else DEFAULT_THEME
123
+
124
+ def _on_unlocked(self, _result: None) -> None:
125
+ """Called once UnlockScreen dismisses, i.e. the diary is open."""
126
+ self.push_screen(EntryListScreen())
127
+
128
+ def lock_if_idle(self) -> None:
129
+ """Lock the diary if it has been left alone long enough.
130
+
131
+ Runs on a timer whether or not anything is unlocked, since the
132
+ setting can be changed mid-session and the lock screen itself has
133
+ nothing to lock.
134
+
135
+ Not named check_idle: Textual's MessagePump already has one, it
136
+ means "the message queue has drained" rather than "nobody is
137
+ there", and it is called from refresh() -- so taking that name
138
+ replaces a framework method with one that wants attributes the app
139
+ has not set yet, and every test dies in App.__init__.
140
+ """
141
+ minutes = self.config.lock_after_minutes
142
+ if minutes <= 0 or not self.is_unlocked:
143
+ return
144
+ if self.locking_would_lose_work():
145
+ # Half-written entry on the screen. Treat waiting on the writer
146
+ # as activity rather than merely postponing: locking the moment
147
+ # they saved would be the same ambush a beat later.
148
+ self.last_activity = time.monotonic()
149
+ return
150
+ if time.monotonic() - self.last_activity >= minutes * 60:
151
+ self.lock(LOCKED_BY_TIMEOUT)
152
+
153
+ def locking_would_lose_work(self) -> bool:
154
+ """Whether any screen on the stack is holding something unsaved.
155
+
156
+ The whole stack, not just the top one: a confirmation modal over a
157
+ half-written entry is still a half-written entry.
158
+ """
159
+ return any(
160
+ isinstance(screen, ZecretScreen) and screen.blocks_lock for screen in self.screen_stack
161
+ )
162
+
163
+ def lock(self, message: str = LOCKED_BY_HAND) -> None:
164
+ """Forget the diary and the key, and go back to the lock screen.
165
+
166
+ Everything above the lock screen is torn down first, while the
167
+ diary is still open: screens rebuild themselves as they resume, and
168
+ one resuming into a locked app has nothing to draw from.
169
+ """
170
+ if not self.is_unlocked:
171
+ return
172
+ while len(self.screen_stack) > 1:
173
+ self.pop_screen()
174
+ self.diary = None
175
+ self.key = None
176
+ self.push_screen(UnlockScreen(creating=False), self._on_unlocked)
177
+ self.notify(message)
178
+
179
+ @property
180
+ def is_unlocked(self) -> bool:
181
+ """Whether there is an open diary to be working in."""
182
+ return self.diary is not None and self.key is not None
183
+
184
+ @property
185
+ def unlocked(self) -> tuple[DiaryFile, bytes]:
186
+ """The open diary and the key it was opened with.
187
+
188
+ Every screen after UnlockScreen needs both, and needs them non-None.
189
+ Reaching here before unlocking is a routing bug, not a user error,
190
+ so it raises rather than returning a half-open state.
191
+
192
+ Raises:
193
+ RuntimeError: if the diary has not been unlocked yet.
194
+ """
195
+ if self.diary is None or self.key is None:
196
+ raise RuntimeError("the diary is not unlocked")
197
+ return self.diary, self.key
zecret/app.tcss ADDED
@@ -0,0 +1,288 @@
1
+ /* Zecret Textual stylesheet.
2
+
3
+ Conventions, so the screens stay consistent as they grow:
4
+
5
+ - Colors come from Textual's theme variables ($surface, $accent, $error,
6
+ $text-muted, ...), never hard-coded. The app then follows whichever
7
+ theme the user picks, light or dark, with no second palette to keep in
8
+ sync.
9
+ - $accent frames anything you are working in; $error is reserved for
10
+ errors and destructive actions and is used nowhere else.
11
+ - Spacing is 1 cell vertically, 2 horizontally. Terminal cells are about
12
+ twice as tall as they are wide, so that pair reads as even.
13
+ - Rules are grouped by the role a widget plays (panel, title, hint,
14
+ error, list), not by the screen it happens to live on -- which is why
15
+ selectors here list several ids at once.
16
+ */
17
+
18
+ /* --- Title bar ---------------------------------------------------------- */
19
+
20
+ /* Zecret's own header, not Textual's: one line, no icon, nothing to click.
21
+ $panel keeps it distinct from the screen behind it without competing with
22
+ $accent, which is reserved for what you are working in. */
23
+ DiaryHeader {
24
+ dock: top;
25
+ width: 100%;
26
+ height: 1;
27
+ background: $panel;
28
+ color: $foreground;
29
+ content-align: center middle;
30
+ text-wrap: nowrap;
31
+ text-overflow: ellipsis;
32
+ }
33
+
34
+ /* --- Panels: the centred card used by unlock and settings --------------- */
35
+
36
+ UnlockScreen,
37
+ SettingsScreen {
38
+ align: center middle;
39
+ }
40
+
41
+ #unlock-box,
42
+ #settings-box {
43
+ width: 62;
44
+ height: auto;
45
+ /* Never taller than the terminal: settings scrolls inside its border
46
+ rather than running off the bottom of a short window. */
47
+ max-height: 100%;
48
+ padding: 1 2;
49
+ border: round $accent;
50
+ background: $surface;
51
+ }
52
+
53
+ #unlock-title,
54
+ .section-title {
55
+ width: 100%;
56
+ content-align: center middle;
57
+ text-style: bold;
58
+ color: $text-accent;
59
+ padding-bottom: 1;
60
+ }
61
+
62
+ #unlock-hint,
63
+ .section-hint {
64
+ width: 100%;
65
+ color: $text-muted;
66
+ padding-bottom: 1;
67
+ }
68
+
69
+ #unlock-box Input,
70
+ #settings-box Input,
71
+ /* The gap under the theme picker is what separates the two sections of
72
+ the settings screen; they need no rule of their own. */
73
+ #settings-box Select {
74
+ margin-bottom: 1;
75
+ }
76
+
77
+ /* --- Error lines -------------------------------------------------------- */
78
+
79
+ /* Reserve the row whether or not there is a message, so showing one never
80
+ shifts the fields under the user's cursor. */
81
+ #unlock-error,
82
+ #settings-error,
83
+ #editor-error,
84
+ #date-error {
85
+ width: 100%;
86
+ height: 1;
87
+ color: $text-error;
88
+ }
89
+
90
+ /* --- Lists: entry list and search results ------------------------------- */
91
+
92
+ #entries,
93
+ #results {
94
+ height: 1fr;
95
+ /* Same 2-cell gutter as the search box above it and the editor's
96
+ fields, so text lines up down the left edge of every screen. */
97
+ margin: 0 2;
98
+ background: $surface;
99
+ border: none;
100
+
101
+ & > ListItem {
102
+ padding: 0 2;
103
+ background: transparent;
104
+ }
105
+
106
+ /* The selected row is the one thing the eye should find instantly. */
107
+ & > ListItem.-highlight {
108
+ background: $accent-muted;
109
+ text-style: bold;
110
+ }
111
+
112
+ &:focus > ListItem.-highlight {
113
+ background: $accent;
114
+ color: $block-cursor-foreground;
115
+ }
116
+
117
+ /* Month headings. Disabled so the cursor skips them, which is a
118
+ navigation decision, not a visual one -- they read as headings, not
119
+ as something unavailable. */
120
+ & > ListItem.group-heading {
121
+ padding: 1 2 0 2;
122
+ color: $text-accent;
123
+ text-style: bold;
124
+ }
125
+ }
126
+
127
+ /* --- Empty states ------------------------------------------------------- */
128
+
129
+ #entries-empty,
130
+ #search-empty {
131
+ height: 1fr;
132
+ width: 100%;
133
+ content-align: center middle;
134
+ color: $text-muted;
135
+ }
136
+
137
+ /* --- SearchScreen ------------------------------------------------------- */
138
+
139
+ #query {
140
+ margin: 0 2;
141
+ border: round $border-blurred;
142
+
143
+ &:focus {
144
+ border: round $accent;
145
+ }
146
+ }
147
+
148
+ /* --- EditorScreen ------------------------------------------------------- */
149
+
150
+ #editor-box {
151
+ height: 1fr;
152
+ padding: 1 2;
153
+ }
154
+
155
+ #editor-box #body {
156
+ height: 1fr;
157
+ margin-bottom: 1;
158
+ border: round $border-blurred;
159
+
160
+ &:focus {
161
+ border: round $accent;
162
+ }
163
+ }
164
+
165
+ /* --- HelpScreen (modal) ------------------------------------------------- */
166
+
167
+ /* Wide enough for the logo, and never taller than the terminal: the help
168
+ scrolls inside its own border rather than off the bottom of the screen. */
169
+ #help-box {
170
+ width: 62;
171
+ max-width: 100%;
172
+ height: auto;
173
+ max-height: 100%;
174
+ padding: 1 2;
175
+ border: round $accent;
176
+ background: $surface;
177
+ }
178
+
179
+ #help-logo {
180
+ width: auto;
181
+ color: $text-accent;
182
+ /* The art is drawn, not written: wrapping it would make nonsense of
183
+ it. Narrow terminals hide it instead -- see HelpScreen.fit_logo. */
184
+ text-wrap: nowrap;
185
+ }
186
+
187
+ #help-tagline {
188
+ width: 100%;
189
+ color: $text-muted;
190
+ padding-bottom: 1;
191
+ }
192
+
193
+ /* Section titles are centred where they head a narrow card; here they head
194
+ a list, so they read as headings only if they sit at the left edge of
195
+ what they introduce, with air above rather than below. */
196
+ #help-box .section-title {
197
+ content-align: left middle;
198
+ text-align: left;
199
+ padding: 1 0 0 0;
200
+ }
201
+
202
+ /* The key rows are aligned into columns by the screen itself, since only
203
+ it knows how wide the widest key in a section is. */
204
+ .help-key {
205
+ width: 100%;
206
+ }
207
+
208
+ /* A tight list, like the key rows above: each note is one line, and the
209
+ section heading already gives the group its air. */
210
+ .help-note {
211
+ width: 100%;
212
+ color: $text-muted;
213
+ }
214
+
215
+ /* No padding above it: this is the one line someone who feels stuck needs
216
+ to see, so it earns its place inside the fold rather than a row of air. */
217
+ #help-close-hint {
218
+ width: 100%;
219
+ content-align: right middle;
220
+ text-align: right;
221
+ color: $text-muted;
222
+ }
223
+
224
+ /* --- Modals: confirm, date prompt and help ------------------------------ */
225
+
226
+ /* Dim what is behind them: the question is the only thing to answer, and
227
+ help sits over the diary rather than replacing it. */
228
+ ConfirmScreen,
229
+ DatePromptScreen,
230
+ HelpScreen {
231
+ align: center middle;
232
+ background: $background 60%;
233
+ }
234
+
235
+ #confirm-box {
236
+ width: 60;
237
+ height: auto;
238
+ padding: 1 2;
239
+ /* Red framing, because every use of this modal is destructive. */
240
+ border: round $error;
241
+ background: $surface;
242
+ }
243
+
244
+ #confirm-question {
245
+ width: 100%;
246
+ padding-bottom: 1;
247
+ }
248
+
249
+ #confirm-buttons {
250
+ height: auto;
251
+ align-horizontal: right;
252
+ }
253
+
254
+ #confirm-buttons Button {
255
+ margin-left: 2;
256
+ }
257
+
258
+ /* --- DatePromptScreen (modal) ------------------------------------------- */
259
+
260
+ /* $accent, not $error: picking a day is where you start writing, not a
261
+ destructive step. */
262
+ #date-box {
263
+ width: 44;
264
+ height: auto;
265
+ padding: 1 2;
266
+ border: round $accent;
267
+ background: $surface;
268
+ }
269
+
270
+ #date-title {
271
+ width: 100%;
272
+ text-style: bold;
273
+ color: $text-accent;
274
+ padding-bottom: 1;
275
+ }
276
+
277
+ #date-hint {
278
+ width: 100%;
279
+ color: $text-muted;
280
+ }
281
+
282
+ #date-box MaskedInput {
283
+ border: round $border-blurred;
284
+
285
+ &:focus {
286
+ border: round $accent;
287
+ }
288
+ }
zecret/config.py ADDED
@@ -0,0 +1,112 @@
1
+ """User preferences: small, non-secret, and kept out of the diary.
2
+
3
+ The diary file is for what you wrote. A theme is neither secret nor
4
+ writing, and it has to be known *before* the diary is unlocked -- the lock
5
+ screen is themed too -- so it cannot live inside a file that needs a
6
+ password to read. The same goes for how long the diary waits before
7
+ locking itself: the timer has to be running before anything is unlocked.
8
+ Both get their own plaintext JSON file instead:
9
+
10
+ {"theme": "gruvbox", "lock_after_minutes": 15}
11
+
12
+ This is the one place besides storage.py that touches the filesystem, and
13
+ the one file Zecret writes in the clear. It may hold preferences and
14
+ nothing else: no entry text, no dates, no password, nothing derived from
15
+ the key. Anything that would tell a reader of this file something about
16
+ the diary's contents belongs in the diary.
17
+
18
+ Preferences are regenerable, so this module is forgiving where storage.py
19
+ is strict. A missing, unreadable, or malformed config falls back to
20
+ defaults rather than raising: a mangled preference must never be the
21
+ reason you cannot get to your diary. For the same reason the write is a
22
+ plain write rather than the atomic dance storage.py performs -- the worst
23
+ outcome is a file that fails to parse next launch and is replaced by
24
+ defaults.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from typing import Any, Self
33
+
34
+ DEFAULT_CONFIG_PATH = Path.home() / ".zecret" / "config.json"
35
+
36
+ #: What the app uses until told otherwise, and the fallback for anything
37
+ #: unreadable. Matches Textual's own default theme.
38
+ DEFAULT_THEME = "textual-dark"
39
+
40
+ #: Minutes of no typing before the diary locks itself. Long enough not to
41
+ #: interrupt someone thinking about what to write, short enough that a
42
+ #: walked-away-from terminal does not stay open all afternoon. Zero turns
43
+ #: it off, which is a choice the settings screen offers rather than the
44
+ #: default it ships.
45
+ DEFAULT_LOCK_AFTER_MINUTES = 15
46
+
47
+ DIR_MODE = 0o700
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class Config:
52
+ """The user's preferences, and where they came from.
53
+
54
+ Attributes:
55
+ path: The file these were read from, and where save() writes back.
56
+ theme: Name of the Textual theme to use. Not validated here --
57
+ config.py has no opinion on which themes exist; the app checks
58
+ the name against the themes actually registered and falls back
59
+ if it does not know it.
60
+ lock_after_minutes: Minutes of inactivity before the diary locks
61
+ itself, or 0 to never lock. How long a minute is and what
62
+ counts as inactivity are the app's business; this only
63
+ remembers the number, and only a whole non-negative one.
64
+ """
65
+
66
+ path: Path
67
+ theme: str = DEFAULT_THEME
68
+ lock_after_minutes: int = DEFAULT_LOCK_AFTER_MINUTES
69
+
70
+ @classmethod
71
+ def load(cls, path: Path) -> Self:
72
+ """Read preferences from `path`, falling back to defaults.
73
+
74
+ Never raises: a missing file is the normal first-run case, and a
75
+ corrupt or unreadable one is treated the same way, since the diary
76
+ must stay reachable regardless.
77
+ """
78
+ config = cls(path=path)
79
+ try:
80
+ document = json.loads(path.read_bytes().decode("utf-8"))
81
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
82
+ return config
83
+ if not isinstance(document, dict):
84
+ return config
85
+
86
+ theme = document.get("theme")
87
+ if isinstance(theme, str) and theme:
88
+ config.theme = theme
89
+
90
+ lock_after = document.get("lock_after_minutes")
91
+ # bool is an int in Python, and `true` in the file is not a number
92
+ # of minutes. A negative one would lock the diary the instant it
93
+ # opened, so it falls back rather than being clamped -- the file is
94
+ # not saying anything we can act on.
95
+ if isinstance(lock_after, int) and not isinstance(lock_after, bool) and lock_after >= 0:
96
+ config.lock_after_minutes = lock_after
97
+ return config
98
+
99
+ def save(self) -> None:
100
+ """Write preferences back to self.path, creating the directory.
101
+
102
+ Raises:
103
+ OSError: if the file cannot be written. The caller decides what
104
+ to say about it -- the setting still applies to this
105
+ session, it just will not outlive it.
106
+ """
107
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=DIR_MODE)
108
+ self.path.write_text(json.dumps(self.to_dict(), indent=2) + "\n", encoding="utf-8")
109
+
110
+ def to_dict(self) -> dict[str, Any]:
111
+ """The JSON shape written to disk."""
112
+ return {"theme": self.theme, "lock_after_minutes": self.lock_after_minutes}