freemyid 0.2.0.dev1__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.
Files changed (100) hide show
  1. freemyid/__init__.py +2 -0
  2. freemyid/_data/brokers/LICENSE-DATA.md +28 -0
  3. freemyid/_data/brokers/registry.sample.yaml +674 -0
  4. freemyid/_data/brokers/state_laws.yaml +448 -0
  5. freemyid/_data/schema/registry.schema.json +668 -0
  6. freemyid/_data/schema/setup-config.schema.json +538 -0
  7. freemyid/_data/schema/state_laws.schema.json +222 -0
  8. freemyid/cli.py +725 -0
  9. freemyid/cli_json.py +198 -0
  10. freemyid/config.py +43 -0
  11. freemyid/consent.py +82 -0
  12. freemyid/crypto.py +197 -0
  13. freemyid/doctor.py +374 -0
  14. freemyid/engine/__init__.py +0 -0
  15. freemyid/engine/_dev/__init__.py +101 -0
  16. freemyid/engine/_dev/devrun.py +101 -0
  17. freemyid/engine/_dev/inbox/devauto.eml +7 -0
  18. freemyid/engine/_dev/registry.yaml +106 -0
  19. freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
  20. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
  21. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
  22. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
  23. freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
  24. freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
  25. freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
  26. freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
  27. freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
  28. freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
  29. freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
  30. freemyid/engine/_dev/site/devfill.example/done.html +3 -0
  31. freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
  32. freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
  33. freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
  34. freemyid/engine/_dev/site/devfill.example/late.html +22 -0
  35. freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
  36. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
  37. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
  38. freemyid/engine/_dev/site/devfind.example/results.html +11 -0
  39. freemyid/engine/_dev/site/devfind.example/search.html +16 -0
  40. freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
  41. freemyid/engine/browser.py +430 -0
  42. freemyid/engine/fill_for_me.py +630 -0
  43. freemyid/engine/filler.py +523 -0
  44. freemyid/engine/handoff.py +237 -0
  45. freemyid/engine/letters.py +125 -0
  46. freemyid/engine/listing_finder.py +538 -0
  47. freemyid/engine/mail.py +406 -0
  48. freemyid/engine/mail_tls.py +198 -0
  49. freemyid/engine/matcher.py +208 -0
  50. freemyid/engine/outbox.py +421 -0
  51. freemyid/engine/plugins.py +68 -0
  52. freemyid/engine/runner.py +791 -0
  53. freemyid/engine/tiers.py +107 -0
  54. freemyid/interfaces.py +164 -0
  55. freemyid/logging_setup.py +152 -0
  56. freemyid/models.py +700 -0
  57. freemyid/netpolicy.py +145 -0
  58. freemyid/notify.py +58 -0
  59. freemyid/paths.py +83 -0
  60. freemyid/registry.py +275 -0
  61. freemyid/scheduler/__init__.py +203 -0
  62. freemyid/scheduler/render.py +91 -0
  63. freemyid/secret_backends.py +562 -0
  64. freemyid/secrets.py +25 -0
  65. freemyid/setup_config.py +311 -0
  66. freemyid/state.py +151 -0
  67. freemyid/storage/__init__.py +3 -0
  68. freemyid/storage/db.py +394 -0
  69. freemyid/submitters/__init__.py +0 -0
  70. freemyid/submitters/assisted.py +171 -0
  71. freemyid/submitters/base.py +119 -0
  72. freemyid/submitters/email.py +40 -0
  73. freemyid/submitters/web_form.py +276 -0
  74. freemyid/templates/_identity.txt.j2 +19 -0
  75. freemyid/templates/_signature.txt.j2 +7 -0
  76. freemyid/templates/ccpa.txt.j2 +16 -0
  77. freemyid/templates/follow_up.txt.j2 +15 -0
  78. freemyid/templates/generic_removal.txt.j2 +12 -0
  79. freemyid/templates/legal_demand.txt.j2 +20 -0
  80. freemyid/templates/phone_script.txt.j2 +25 -0
  81. freemyid/templates/postal_letter.txt.j2 +20 -0
  82. freemyid/templates/state_privacy.txt.j2 +17 -0
  83. freemyid/wizard/__init__.py +0 -0
  84. freemyid/wizard/dashboard.py +401 -0
  85. freemyid/wizard/desktop.py +188 -0
  86. freemyid/wizard/draft.py +627 -0
  87. freemyid/wizard/firstrun.py +42 -0
  88. freemyid/wizard/flows.py +283 -0
  89. freemyid/wizard/report.py +86 -0
  90. freemyid/wizard/server.py +834 -0
  91. freemyid/wizard/static/__init__.py +0 -0
  92. freemyid/wizard/static/app.js +98 -0
  93. freemyid/wizard/static/freemyid.png +0 -0
  94. freemyid/wizard/static/style.css +85 -0
  95. freemyid/wizard/ui.py +343 -0
  96. freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
  97. freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
  98. freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
  99. freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
  100. freemyid-0.2.0.dev1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,401 @@
1
+ """Dashboard page rendering (status, to-dos, outbox, evidence)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from urllib.parse import quote
6
+
7
+ from ..models import (
8
+ BrokerStatus,
9
+ EvidenceKind,
10
+ ListingState,
11
+ Profile,
12
+ Request,
13
+ Todo,
14
+ TodoKind,
15
+ )
16
+ from .ui import _check, chip, csrf_field, esc, layout
17
+
18
+ SUMMARY_ORDER = [ListingState.NEEDS_HUMAN, ListingState.FOUND, ListingState.REAPPEARED,
19
+ ListingState.AWAITING_VERIFICATION, ListingState.SUBMITTED,
20
+ ListingState.CONFIRMED, ListingState.REMOVED]
21
+
22
+
23
+ def _dt(d) -> str:
24
+ return d.strftime("%Y-%m-%d") if d else "-"
25
+
26
+
27
+ DONE_STATES = (ListingState.REMOVED, ListingState.CONFIRMED)
28
+ WAITING_STATES = (ListingState.SUBMITTED, ListingState.AWAITING_VERIFICATION,
29
+ ListingState.FOUND, ListingState.REAPPEARED)
30
+ COMPOSE = {
31
+ "gmail": ("Gmail", "https://mail.google.com/mail/?view=cm&fs=1&to={to}&su={su}&body={body}"),
32
+ "outlook": ("Outlook", ("https://outlook.live.com/mail/0/deeplink/compose?to={to}"
33
+ "&subject={su}&body={body}")),
34
+ "yahoo": ("Yahoo Mail", "https://compose.mail.yahoo.com/?to={to}&subject={su}&body={body}"),
35
+ }
36
+ CANT = "I couldn't" # escaped by _post_btn
37
+ _MAX_COMPOSE_BODY = 1800 # long URLs get cut by some mail sites; Copy is the fallback
38
+
39
+
40
+ def compose_url(provider: str, to: str, subject: str, body: str) -> str | None:
41
+ if provider not in COMPOSE:
42
+ return None
43
+ return COMPOSE[provider][1].format(to=quote(to, safe=""), su=quote(subject, safe=""),
44
+ body=quote(body[:_MAX_COMPOSE_BODY], safe=""))
45
+
46
+
47
+ def email_parts(t: Todo) -> tuple[str, str, str]:
48
+ """(to, subject, letter) of a SEND_EMAIL_MANUALLY todo. Keys in copy_fields: To, Subject."""
49
+ low = {k.strip().lower(): v for k, v in t.copy_fields.items()}
50
+ return low.get("to", ""), low.get("subject", ""), t.body
51
+
52
+
53
+ def open_url_of(t: Todo, index: int = 0) -> str | None:
54
+ urls = [u for u in t.links if u.startswith("https://")]
55
+ return urls[index] if 0 <= index < len(urls) else None
56
+
57
+
58
+ def status_sentence(statuses: list[BrokerStatus], n_todos: int, running: bool,
59
+ has_people: bool = True) -> tuple[str, bool]:
60
+ """(sentence, all_good)."""
61
+ if running:
62
+ return "Free My ID is checking now. This page updates by itself.", True
63
+ done = sum(1 for s in statuses if s.state in DONE_STATES)
64
+ waiting = sum(1 for s in statuses if s.state in WAITING_STATES)
65
+ if not statuses and not n_todos:
66
+ return "Free My ID is set up. Nothing to do yet.", True
67
+ if done == 0 and not any(s.state in (ListingState.SUBMITTED, ListingState.AWAITING_VERIFICATION,
68
+ ListingState.REAPPEARED) for s in statuses):
69
+ return ("Free My ID has made a list of sites to work through. Start with the first one "
70
+ "below when you have a few minutes." if n_todos else
71
+ "Free My ID has made a list of sites to work through. Nothing needs you yet.",
72
+ n_todos == 0)
73
+ sites = f"{done} site{'s have' if done != 1 else ' has'} confirmed or no longer list{'s' if done == 1 else ''} you"
74
+ wait = f"{waiting} {'is' if waiting == 1 else 'are'} still waiting"
75
+ if n_todos == 0:
76
+ return f"Free My ID is working. {sites}, {wait}. Nothing needs you right now.", True
77
+ return f"Free My ID is working. {sites}, {wait}, {n_todos} need{'s' if n_todos == 1 else ''} you.", False
78
+
79
+
80
+ def _steps_list(body: str) -> list[str]:
81
+ out = []
82
+ for ln in body.splitlines():
83
+ ln = re.sub(r"^\s*(\d+[.)]|[-*])\s*", "", ln).strip()
84
+ if ln:
85
+ out.append(ln)
86
+ return out
87
+
88
+
89
+ def _copy_rows(fields: dict[str, str]) -> str:
90
+ return "".join(
91
+ f'<div class="copyrow"><span class="lbl">{esc(k)}</span><span class="val">{esc(v)}</span>'
92
+ f'<button type="button" class="secondary" data-copy="{esc(v)}">Copy</button></div>'
93
+ for k, v in fields.items())
94
+
95
+
96
+ def _post_btn(action: str, csrf: str, label: str, cls: str = "", extra: str = "") -> str:
97
+ return (f'<form method="post" action="{esc(action)}" class="inline">{csrf_field(csrf)}'
98
+ f'{extra}<button type="submit" class="{cls}">{esc(label)}</button></form>')
99
+
100
+
101
+ def todo_card(t: Todo, who: str, bname: str, csrf: str, show_who: bool = False,
102
+ fill: bool = False) -> str:
103
+ tid = esc(t.id)
104
+ head = (f'<h2>{esc(bname) or esc(t.title)}</h2>'
105
+ + (f'<p class="hint">For {esc(who)}</p>' if show_who else ""))
106
+ done = f"/todo/{tid}/done"
107
+ couldnt = f"/todo/{tid}/couldnt"
108
+ if t.kind == TodoKind.ASSISTED_OPT_OUT:
109
+ steps = _steps_list(t.body)
110
+ ol = ("<ol>" + "".join(f"<li>{esc(x)}</li>" for x in steps) + "</ol>") if steps else ""
111
+ url = open_url_of(t)
112
+ primary = (_post_btn(f"/todo/{tid}/open", csrf, "Open the site", "primary")
113
+ if url else "")
114
+ if fill and t.broker_id:
115
+ primary = (_post_btn(f"/todo/{tid}/fill", csrf, "Fill it in for me", "primary big")
116
+ + (_post_btn(f"/todo/{tid}/open", csrf, "Open the site myself",
117
+ "secondary") if url else ""))
118
+ copies = _copy_rows(t.copy_fields)
119
+ if copies:
120
+ copies = ("<p><b>Copy and paste these when the site asks:</b></p>" + copies)
121
+ return (f'<div class="card todo" data-kind="assisted_opt_out">{head}'
122
+ f'<p>{esc(t.title)}</p>{ol}{copies}<div class="actions">{primary}'
123
+ f'{_post_btn(done, csrf, "I did it", "secondary")}'
124
+ f'{_post_btn(couldnt, csrf, CANT, "secondary")}</div></div>')
125
+ if t.kind == TodoKind.SEND_EMAIL_MANUALLY:
126
+ letter = email_parts(t)[2]
127
+ rest = dict(t.copy_fields)
128
+ buttons = "".join(
129
+ _post_btn(f"/todo/{tid}/compose/{k}", csrf, f"Open in {COMPOSE[k][0]}",
130
+ "primary" if k == "gmail" else "secondary") for k in COMPOSE)
131
+ return (f'<div class="card todo" data-kind="send_email_manually">{head}'
132
+ f'<p>{esc(t.title)}</p><p><b>This is the message. Send it from your own '
133
+ f'email.</b></p>'
134
+ f'<div class="letter" aria-label="Message preview">{esc(letter)}</div>'
135
+ f'<div class="actions"><button type="button" class="secondary" '
136
+ f'data-copy="{esc(letter)}">Copy the message</button></div>'
137
+ f'{_copy_rows(rest)}<div class="actions">{buttons}</div>'
138
+ f'<div class="actions">{_post_btn(done, csrf, "I sent it", "secondary")}'
139
+ f'{_post_btn(couldnt, csrf, CANT, "secondary")}</div></div>')
140
+ # everything else: one primary button
141
+ url = open_url_of(t)
142
+ opener = _post_btn(f"/todo/{tid}/open", csrf, "Open the page", "secondary") if url else ""
143
+ if t.resume_token:
144
+ primary = _post_btn(f"/todo/{tid}/resolve", csrf, "I finished it", "primary")
145
+ else:
146
+ primary = _post_btn(done, csrf, "Done", "primary")
147
+ return (f'<div class="card todo" data-kind="{esc(t.kind.value)}">{head}<p>{esc(t.title)}</p>'
148
+ f'<p class="hint">{esc(t.body)}</p><div class="actions">{opener}{primary}</div></div>')
149
+
150
+
151
+ def home_page(*, statuses: list[BrokerStatus], todos: list[tuple[Todo, str, str]],
152
+ people: list[dict], outbox_count: int, csrf: str, flash: str = "",
153
+ practice: bool = False, running: bool = False, fill: bool = False,
154
+ filling: bool = False) -> str:
155
+ """todos = (todo, person label, broker name)."""
156
+ sentence, good = status_sentence(statuses, len(todos), running)
157
+ body = (f'{_flash(flash)}<p class="status-line{" ok" if good else ""}" role="status">'
158
+ f'{esc(sentence)}</p>')
159
+ if practice:
160
+ body += ('<div class="banner"><b>Practice mode.</b> Free My ID is looking but is not sending '
161
+ 'anything yet. <a href="/settings">Change this in Settings</a> when you are '
162
+ 'ready.</div>')
163
+ for r in people:
164
+ if r["consent_problem"]:
165
+ body += (f'<div class="note">Not running yet for {esc(r["profile"].label)}: '
166
+ f'{esc(r["consent_problem"])} <a href="/profile/{esc(r["profile"].id)}">'
167
+ 'Fix it</a></div>')
168
+ if outbox_count:
169
+ body += (f'<div class="card todo"><h2>{outbox_count} email(s) waiting for your OK</h2>'
170
+ '<p>Look at each one, then press Approve. Nothing is sent until you do.</p><div class="actions">'
171
+ '<a class="btn primary" href="/outbox">Look at them</a></div></div>')
172
+ if filling:
173
+ body += ('<div class="banner">A form is being filled in. <a href="/fill">'
174
+ 'Go to that window</a></div>')
175
+ multi = len({w for _, w, _ in todos}) > 1
176
+ for t, who, bname in todos:
177
+ body += todo_card(t, who, bname, csrf, multi, fill and not filling)
178
+ if not todos and not outbox_count and not running:
179
+ body += '<div class="card"><p>You have nothing to do right now. That is good.</p></div>'
180
+ body += '<h2>More</h2><div class="card">'
181
+ for r in people:
182
+ p = r["profile"]
183
+ body += (f'<p><a class="btn secondary" href="/profile/{esc(p.id)}">All the details for '
184
+ f'{esc(p.label)}</a></p>')
185
+ body += ('<p class="hint">A site that says it deleted you has done that for that one site only. '
186
+ 'Details can reappear, which is why Free My ID keeps checking. Free My ID does '
187
+ 'not change anything on Facebook or Google.</p></div>')
188
+ return layout("Home", body, refresh=6 if running else 0)
189
+
190
+
191
+ FILL_WAIT = ("A window opened. Free My ID filled in the form. Tick the \"I'm not a robot\" box "
192
+ "and press Submit.")
193
+
194
+
195
+ def fill_page(state: str, tid: str, csrf: str, bname: str = "") -> str:
196
+ """state: waiting | pressed (Submit seen but not confirmed) | none."""
197
+ who = f" for {esc(bname)}" if bname else ""
198
+ if state == "waiting":
199
+ return layout("Filling in the form", (
200
+ f'<div class="card"><h1>Your turn{who}</h1><p class="big">{esc(FILL_WAIT)}</p>'
201
+ '<p>This page will update by itself when you are done. You can take your time. '
202
+ 'Free My ID never presses Submit for you.</p></div>'), refresh=3)
203
+ if state == "pressed":
204
+ return layout("Did it go through?", (
205
+ f'<div class="card"><h1>Did it go through{who}?</h1>'
206
+ '<p>Free My ID could not see a confirmation on the page. If you pressed Submit and the '
207
+ 'site thanked you, tell us here. Otherwise your to-do stays on the list.</p>'
208
+ f'<div class="actions">{_post_btn(f"/todo/{esc(tid)}/done", csrf, "I did it", "primary")}'
209
+ '<a class="btn secondary" href="/">Not yet</a></div></div>'))
210
+ return layout("Fill it in", (
211
+ '<div class="card"><h1>Nothing is being filled in</h1><div class="actions">'
212
+ '<a class="btn primary" href="/">Back to home</a></div></div>'))
213
+
214
+
215
+ def fill_confirm_page(tid: str, csrf: str) -> str:
216
+ return layout("Before we fill it in", (
217
+ '<div class="card"><h1>This sends a real request to the site.</h1>'
218
+ '<p class="big">You are in practice mode, but this step is different. Free My ID will '
219
+ 'open the site and fill in the form. When you press Submit on the site, your request '
220
+ 'is real.</p><p>We only ask this once. Free My ID never presses Submit for you.</p>'
221
+ f'<form method="post" action="/todo/{esc(tid)}/fill" class="inline">{csrf_field(csrf)}'
222
+ '<input type="hidden" name="confirm" value="yes"><div class="actions">'
223
+ '<button type="submit" class="primary big">I understand, continue</button>'
224
+ '<a class="btn secondary" href="/">Not now</a></div></form></div>'))
225
+
226
+
227
+ def _flash(msg: str) -> str:
228
+ return f'<div class="note" role="status">{esc(msg)}</div>' if msg else ""
229
+
230
+
231
+ def profile_page(p: Profile, statuses: list[BrokerStatus], names: dict[str, str],
232
+ todos: list[Todo], evidence_by_broker: dict[str, list], csrf: str,
233
+ consent_problem: str, flash: str = "", fill: bool = False) -> str:
234
+ body = f'<h1>{esc(p.label)}</h1>{_flash(flash)}'
235
+ if consent_problem:
236
+ body += f'<div class="note">Not running yet: {esc(consent_problem)}</div>'
237
+ if "authorization" in consent_problem:
238
+ body += (f'<form class="card" method="post" action="/profile/{esc(p.id)}/authorization"'
239
+ f' enctype="multipart/form-data">{csrf_field(csrf)}'
240
+ '<label for="authorization">Attach the signed authorization (photo or PDF)'
241
+ '</label><input id="authorization" name="authorization" type="file" '
242
+ 'required><div class="actions"><button type="submit">Attach</button></div>'
243
+ '</form>')
244
+ body += (f'<p><a class="btn secondary" href="/profile/{esc(p.id)}/edit">Edit details</a> '
245
+ f'<a class="btn secondary" href="/profile/{esc(p.id)}/report">Download report</a></p>')
246
+ # todos
247
+ body += "<h2>Needs you</h2><div>"
248
+ if not todos:
249
+ body += "<p>Nothing right now.</p>"
250
+ for t in todos:
251
+ body += todo_card(t, p.label, names.get(t.broker_id or "", ""), csrf, fill=fill)
252
+ body += "</div>"
253
+ # per-broker table
254
+ body += ('<div class="card"><h2>Sites</h2><table><tr><th>Site</th><th>State</th>'
255
+ '<th>Updated</th><th>Next check</th></tr>')
256
+ for st in sorted(statuses, key=lambda s: (names.get(s.broker_id, s.broker_id)).lower()):
257
+ extra = (f'<br><span class="hint">{esc(st.needs_human_reason)}</span>'
258
+ if st.needs_human_reason else "")
259
+ body += (f'<tr><td>{esc(names.get(st.broker_id, st.broker_id))}</td>'
260
+ f'<td>{chip(st.state)}{extra}</td><td>{_dt(st.updated_at)}</td>'
261
+ f'<td>{_dt(st.next_check_at)}</td></tr>')
262
+ if not statuses:
263
+ body += '<tr><td colspan="4">No sites checked yet.</td></tr>'
264
+ body += "</table></div>"
265
+ # evidence
266
+ body += '<div class="card"><h2>Receipts</h2><p class="hint">Pictures of what each site showed and replied. ' \
267
+ 'Kept on this computer only, encrypted.</p>'
268
+ any_ev = False
269
+ for bid, evs in sorted(evidence_by_broker.items()):
270
+ shots = [e for e in evs if e.kind == EvidenceKind.SCREENSHOT]
271
+ others = [e for e in evs if e.kind != EvidenceKind.SCREENSHOT]
272
+ any_ev = True
273
+ body += f'<h3>{esc(names.get(bid, bid))}</h3><div class="gallery">'
274
+ for e in shots:
275
+ body += (f'<figure><a href="/evidence/{esc(p.id)}/{esc(e.id)}" target="_blank">'
276
+ f'<img loading="lazy" alt="{esc(e.caption or "screenshot")}" '
277
+ f'src="/evidence/{esc(p.id)}/{esc(e.id)}"></a>'
278
+ f'<figcaption class="hint">{_dt(e.created_at)} {esc(e.caption)}'
279
+ '</figcaption></figure>')
280
+ for e in others:
281
+ body += (f'<a class="btn secondary" href="/evidence/{esc(p.id)}/{esc(e.id)}">'
282
+ f'{esc(e.kind.value)} {_dt(e.created_at)}</a>')
283
+ body += "</div>"
284
+ if not any_ev:
285
+ body += "<p>No receipts yet.</p>"
286
+ body += "</div>"
287
+ return layout(p.label, body)
288
+
289
+
290
+ def outbox_page(items: list[tuple[Request, str, str, tuple[str, str, str]]], csrf: str, flash: str = "",
291
+ releases: dict[str, list[str]] | None = None) -> str:
292
+ """`releases` maps a request id to the site names of the OTHER emails its approval would
293
+ also send (SEC-47, first-email release). Every value is escaped here."""
294
+ releases = releases or {}
295
+ body = f'<h1>Emails waiting for your approval</h1>{_flash(flash)}'
296
+ if not items:
297
+ body += "<p>Nothing is waiting.</p>"
298
+ for r, who, bname, (to, subj, text) in items:
299
+ others = releases.get(r.id) or []
300
+ n = len(others)
301
+ note = ""
302
+ ask = "Send this email now?"
303
+ if n:
304
+ s_ = "s" if n != 1 else ""
305
+ note = (f'<p class="note">This is the first email for {esc(who)}. Approving it also '
306
+ f'sends the {n} other email{s_} waiting (to: {esc(", ".join(sorted(set(others))))}). '
307
+ 'After that, routine emails go out by themselves. Formal demands still wait '
308
+ 'for you.</p>')
309
+ ask = f"Send this email and {n} other{s_} now?"
310
+ body += (f'<div class="card"><b>{esc(who)}</b> to <b>{esc(bname)}</b> '
311
+ f'<span class="chip">{esc(r.kind.value.replace("_", " "))}</span>'
312
+ f'<p><b>To:</b> {esc(to)}<br><b>Subject:</b> {esc(subj)}</p>'
313
+ f'<pre class="letter">{esc(text)}</pre>'
314
+ f'<p class="hint">Drafted {_dt(r.created_at)}. Approving sends this email from '
315
+ 'the removal mailbox.</p>'
316
+ f'{note}'
317
+ f'<form method="post" action="/outbox/{esc(r.id)}/approve" class="actions">'
318
+ f'{csrf_field(csrf)}<button type="submit" data-confirm="{esc(ask)}">'
319
+ f'Approve and send</button>'
320
+ f'<button type="submit" class="secondary" '
321
+ f'formaction="/outbox/{esc(r.id)}/cancel">Cancel it</button></form></div>')
322
+ return layout("Emails", body)
323
+
324
+
325
+ def confirm_real_page(people: list[Profile], csrf: str) -> str:
326
+ names = ", ".join(esc(p.label) for p in people) or "nobody yet"
327
+ body = ('<h1>Start sending real requests?</h1>'
328
+ f'<div class="card"><p>This is for: <b>{names}</b>.</p>'
329
+ '<p>Free My ID will now send your requests to people-search sites by email and, where a '
330
+ 'site allows it, fill in their forms. Each request names the person and only the '
331
+ 'details the site already shows, plus the separate email address. Nothing is paid for.</p>'
332
+ '<p>Emails for people who chose "approve each" wait for your OK first. The first '
333
+ 'email for every person always waits for your OK.</p>'
334
+ '<p>Some sites need steps by hand, and details can come back. You can switch '
335
+ 'back to practice mode in Settings at any time. Requests already sent cannot be '
336
+ 'taken back.</p>'
337
+ '<form method="post" action="/settings/submissions/on" class="actions">'
338
+ f'{csrf_field(csrf)}<input type="hidden" name="confirm" value="yes">'
339
+ '<button type="submit">Yes, start sending real requests</button>'
340
+ '<a class="btn secondary" href="/settings">No, stay in practice mode</a>'
341
+ '</form></div>')
342
+ return layout("Real requests", body)
343
+
344
+
345
+ def settings_page(*, people: list[Profile], schedule_status: str, practice: bool, csrf: str,
346
+ flash: str = "", can_quit: bool = False) -> str:
347
+ body = f'<h1>Settings</h1>{_flash(flash)}'
348
+ body += ('<div class="card"><h2>Run automatically</h2><p>Once a day Free My ID can check '
349
+ 'quietly in the background. If the computer was off, it catches up when it is '
350
+ f'next on.</p><p><b>Right now:</b> {esc(schedule_status)}</p>'
351
+ f'<form method="post" action="/schedule/install" class="actions">{csrf_field(csrf)}'
352
+ '<button type="submit">Turn on</button>'
353
+ '<button type="submit" class="secondary" formaction="/schedule/remove">Turn off'
354
+ '</button></form></div>')
355
+ if practice:
356
+ body += ('<div class="card"><h2>Practice mode is on</h2><p>Free My ID looks for listings but '
357
+ 'does not send any request. When you have looked at the results and are happy, '
358
+ f'turn on real requests.</p><form method="post" '
359
+ f'action="/settings/submissions/on" class="actions">{csrf_field(csrf)}'
360
+ '<a class="btn primary" href="/settings/submissions/confirm">'
361
+ 'Start sending real requests</a></form></div>')
362
+ else:
363
+ body += ('<div class="card"><h2>Real requests are on</h2>'
364
+ f'<form method="post" action="/settings/submissions/off" class="actions">'
365
+ f'{csrf_field(csrf)}<button type="submit" class="secondary">Go back to '
366
+ 'practice mode</button></form></div>')
367
+ for p in people:
368
+ mb = (f"{p.mailbox.address}" if p.mailbox else "Not set up (you send messages by hand)")
369
+ perms = p.permissions
370
+ body += (f'<div class="card"><h2>{esc(p.label)}</h2>'
371
+ f'<h3>Email for removals</h3><p>{esc(mb)}</p>'
372
+ f'<p><a class="btn secondary" href="/settings/{esc(p.id)}/mailbox">Change it'
373
+ '</a></p><h3>What Free My ID may do</h3>'
374
+ '<p class="hint">Everything here is off unless you tick it. If a website asks '
375
+ 'for something you did not allow, Free My ID stops and asks you. It never pays. It will never ask for a Social Security number or bank or card details.</p>'
376
+ f'<form method="post" action="/settings/{esc(p.id)}/permissions">'
377
+ f'{csrf_field(csrf)}'
378
+ + _check("allow_id_upload", "Free My ID may upload a copy of an ID if a website "
379
+ "requires it", perms.allow_id_upload)
380
+ + _check("allow_account_creation", "Free My ID may create an account on a website "
381
+ "if required", perms.allow_account_creation)
382
+ + _check("allow_phone_disclosure", "Free My ID may give a phone number if "
383
+ "required", perms.allow_phone_disclosure)
384
+ + '<div class="actions"><button type="submit">Save</button></div></form></div>')
385
+ if not people:
386
+ body += "<p>No one has been added yet.</p>"
387
+ if can_quit:
388
+ body += (f'<div class="card"><h2>Close Free My ID</h2><form method="post" action="/quit" '
389
+ f'class="actions">{csrf_field(csrf)}<button type="submit" class="secondary" '
390
+ 'data-confirm="Close Free My ID now?">Close Free My ID</button></form></div>')
391
+ return layout("Settings", body)
392
+
393
+
394
+ def mailbox_settings_page(p: Profile, body_html: str, csrf: str, errs: list[str]) -> str:
395
+ from .ui import errors_html
396
+ return layout("Email for removals", (
397
+ f'<h1>Email for removals: {esc(p.label)}</h1>{errors_html(errs)}'
398
+ f'<form method="post" action="/settings/{esc(p.id)}/mailbox" class="card" '
399
+ f'autocomplete="off">{csrf_field(csrf)}{body_html}<div class="actions">'
400
+ '<a class="btn secondary" href="/settings">Back</a><button type="submit" class="big">'
401
+ 'Save</button></div></form>'))
@@ -0,0 +1,188 @@
1
+ """Desktop launcher: the app window users double-click (Start Menu / Desktop shortcut).
2
+
3
+ Starts the local server on a free 127.0.0.1 port in a background thread and shows it in a
4
+ native window (pywebview, which uses Edge WebView2 on Windows). If pywebview or its browser
5
+ engine is missing, falls back to the default web browser. The user never sees "localhost".
6
+
7
+ First launch with no profile lands in the wizard, otherwise the dashboard (the "/" route
8
+ already decides). A second launch finds the first through an instance file in FREEMYID_HOME,
9
+ asks it to come to the front, and exits.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import hashlib
15
+ import hmac
16
+ import json
17
+ import logging
18
+ import os
19
+ import socket
20
+ import sys
21
+ import threading
22
+ import urllib.request
23
+ import webbrowser
24
+ from collections.abc import Callable
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from .. import paths
29
+
30
+ log = logging.getLogger(__name__)
31
+ INSTANCE_FILE = "app-instance.json"
32
+ TITLE = "Free My ID"
33
+
34
+
35
+ def _instance_path() -> Path:
36
+ return paths.home() / INSTANCE_FILE
37
+
38
+
39
+ def _alive(port: int) -> bool:
40
+ try:
41
+ with socket.create_connection(("127.0.0.1", port), timeout=1):
42
+ return True
43
+ except OSError:
44
+ return False
45
+
46
+
47
+ def read_instance() -> dict[str, Any] | None:
48
+ """The running instance's {port, token}, or None (missing file or stale after a crash)."""
49
+ try:
50
+ info = json.loads(_instance_path().read_text())
51
+ port, token = int(info["port"]), str(info["token"])
52
+ except (OSError, ValueError, KeyError, TypeError):
53
+ return None
54
+ return {"port": port, "token": token} if _alive(port) else None
55
+
56
+
57
+ def write_instance(port: int, token: str) -> None:
58
+ p = _instance_path()
59
+ tmp = p.with_suffix(".tmp")
60
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
61
+ with os.fdopen(fd, "w") as f:
62
+ f.write(json.dumps({"port": port, "token": token}))
63
+ os.replace(tmp, p)
64
+
65
+
66
+ def clear_instance(port: int) -> None:
67
+ try:
68
+ if json.loads(_instance_path().read_text()).get("port") == port:
69
+ _instance_path().unlink()
70
+ except (OSError, ValueError):
71
+ pass
72
+
73
+
74
+ def ask_running_instance_to_focus(info: dict[str, Any]) -> bool:
75
+ """POST /_focus to the first instance (same token + CSRF scheme as the browser)."""
76
+ csrf = hmac.new(info["token"].encode(), b"freemyid-csrf", hashlib.sha256).hexdigest()
77
+ req = urllib.request.Request(
78
+ f"http://127.0.0.1:{info['port']}/_focus", data=b"", method="POST",
79
+ headers={"Cookie": f"freemyid_session={info['token']}", "X-CSRF": csrf})
80
+ try:
81
+ with urllib.request.urlopen(req, timeout=3) as r:
82
+ return r.status == 200
83
+ except OSError:
84
+ return False
85
+
86
+
87
+ def _wait_forever(stop: threading.Event) -> None:
88
+ try:
89
+ while not stop.wait(0.5):
90
+ pass
91
+ except KeyboardInterrupt:
92
+ pass
93
+
94
+
95
+ def window_geometry(screen_w: int | None, screen_h: int | None, want_w: int = 1100,
96
+ want_h: int = 820, min_w: int = 760, min_h: int = 560
97
+ ) -> tuple[int, int, int | None, int | None]:
98
+ """(width, height, x, y) that fits the screen: at most 90% of it, centered.
99
+
100
+ x/y are None when the screen size is unknown (let the toolkit choose). Never larger than
101
+ the screen, even below the usual minimum size.
102
+ """
103
+ if not screen_w or not screen_h or screen_w < 300 or screen_h < 300:
104
+ return want_w, want_h, None, None
105
+ w = min(want_w, int(screen_w * 0.9))
106
+ h = min(want_h, int(screen_h * 0.9))
107
+ return w, h, (screen_w - w) // 2, max(0, (screen_h - h) // 2 - 10)
108
+
109
+
110
+ def _primary_screen_size(webview: Any) -> tuple[int | None, int | None]:
111
+ try:
112
+ scr = webview.screens[0]
113
+ return int(scr.width), int(scr.height)
114
+ except Exception as e: # noqa: BLE001 older pywebview or no screens
115
+ log.debug("webview.screens unavailable (%s)", type(e).__name__)
116
+ if sys.platform == "win32":
117
+ try:
118
+ import ctypes
119
+ u = ctypes.windll.user32 # type: ignore[attr-defined]
120
+ return int(u.GetSystemMetrics(0)), int(u.GetSystemMetrics(1))
121
+ except Exception as e: # noqa: BLE001
122
+ log.debug("GetSystemMetrics failed (%s)", type(e).__name__)
123
+ return None, None
124
+
125
+
126
+ def _show_window(url: str, on_ready: Callable[[Any], None]) -> bool:
127
+ """Open a pywebview window and block until it closes. False if pywebview is unusable."""
128
+ try:
129
+ import webview
130
+ except ImportError:
131
+ return False
132
+ try:
133
+ with contextlib.suppress(Exception):
134
+ webview.settings["ALLOW_DOWNLOADS"] = True
135
+ sw, sh = _primary_screen_size(webview)
136
+ w, h, x, y = window_geometry(sw, sh)
137
+ # Taskbar takes some height; 90% leaves room. Small screens get a smaller minimum.
138
+ mins = (min(760, w), min(560, h))
139
+ extra: dict[str, Any] = {} if x is None else {"x": x, "y": y}
140
+ win = webview.create_window(TITLE, url, width=w, height=h, min_size=mins, **extra)
141
+ on_ready(win)
142
+ webview.start()
143
+ return True
144
+ except Exception as e: # noqa: BLE001 no WebView2 / no GUI toolkit
145
+ log.warning("native window unavailable (%s); using the web browser", type(e).__name__)
146
+ return False
147
+
148
+
149
+ def main(window: bool = True) -> int:
150
+ """Run the app. Returns a process exit code."""
151
+ from .server import start_background
152
+
153
+ paths.home()
154
+ if (info := read_instance()) is not None:
155
+ if not ask_running_instance_to_focus(info):
156
+ webbrowser.open(f"http://127.0.0.1:{info['port']}/?t={info['token']}")
157
+ return 0
158
+
159
+ running = start_background(0)
160
+ write_instance(running.port, running.token)
161
+ stop = threading.Event()
162
+ try:
163
+ shown = False
164
+ if window and os.environ.get("FREEMYID_NO_WINDOW") != "1":
165
+ def on_ready(win: Any) -> None:
166
+ def focus() -> None:
167
+ for fn in ("restore", "show"):
168
+ with contextlib.suppress(Exception):
169
+ getattr(win, fn)()
170
+ with contextlib.suppress(Exception): # nudge to front
171
+ win.on_top = True
172
+ win.on_top = False
173
+ running.app.state.on_focus = focus
174
+ shown = _show_window(running.url, on_ready)
175
+ if not shown:
176
+ # Browser fallback: the program keeps running until "Close Free My ID" in Settings.
177
+ running.app.state.on_focus = lambda: webbrowser.open(running.url)
178
+ running.app.state.on_quit = stop.set
179
+ webbrowser.open(running.url)
180
+ _wait_forever(stop)
181
+ finally:
182
+ clear_instance(running.port)
183
+ running.stop()
184
+ return 0
185
+
186
+
187
+ if __name__ == "__main__": # python -m freemyid.wizard.desktop
188
+ sys.exit(main())