pendify 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.
- pendify/__init__.py +1 -0
- pendify/__main__.py +265 -0
- pendify/alert.py +132 -0
- pendify/autostart.py +105 -0
- pendify/client.py +223 -0
- pendify/codes.py +38 -0
- pendify/config.py +223 -0
- pendify/icon.py +14 -0
- pendify/page.py +1136 -0
- pendify/pairing.py +192 -0
- pendify/pendify.ico +0 -0
- pendify/plate_almena.py +348 -0
- pendify/qr.py +433 -0
- pendify/runfile.py +246 -0
- pendify/watcher.py +348 -0
- pendify/worker.py +159 -0
- pendify-0.1.0.dist-info/METADATA +155 -0
- pendify-0.1.0.dist-info/RECORD +21 -0
- pendify-0.1.0.dist-info/WHEEL +5 -0
- pendify-0.1.0.dist-info/licenses/LICENSE +21 -0
- pendify-0.1.0.dist-info/top_level.txt +1 -0
pendify/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pairs this PC with a phone account by a QR on a loopback page, standard library only."""
|
pendify/__main__.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""The commands.
|
|
2
|
+
|
|
3
|
+
python -m <package> the pairing page and the watcher of the game client, together
|
|
4
|
+
python -m <package> --dry the same, but the watcher never accepts: it alerts at the queue pop
|
|
5
|
+
python -m <package> --quiet the same, started by the system at logon: no browser, and no message box
|
|
6
|
+
unless the start fails
|
|
7
|
+
python -m <package> ping <kind> one alert with the stored pair, one fixed line per answer
|
|
8
|
+
|
|
9
|
+
The page and the watcher stop together on Ctrl+C, on Ctrl+Break and on the page's quit button, which
|
|
10
|
+
removes the run file and exits 0. One instance per config folder: a second start opens the running page
|
|
11
|
+
and exits 0, and one made while that program is still closing after its quit waits for it to end and starts.
|
|
12
|
+
Where no console is attached (a pythonw start), the line a start ends on is also shown in a
|
|
13
|
+
message box. `--data-dir`, `--worker` and `--client-lockfile` are the
|
|
14
|
+
test overrides, loopback only, so a test run reads neither the profile, the Worker nor the client's files.
|
|
15
|
+
"""
|
|
16
|
+
import argparse
|
|
17
|
+
import os
|
|
18
|
+
import signal
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
import webbrowser
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from . import alert, client, config, page, pairing, runfile, watcher, worker
|
|
26
|
+
from .autostart import Autostart
|
|
27
|
+
|
|
28
|
+
TICK_SECONDS = 1.0
|
|
29
|
+
STOP_SECONDS = 5.0
|
|
30
|
+
# How long a start waits for a copy that is closing to let its run file go: one tick and the two STOP_SECONDS waits
|
|
31
|
+
# of its stop, with room.
|
|
32
|
+
CLOSING_WAIT_SECONDS = 15.0
|
|
33
|
+
REFUSED_LINE = "refused: the pairing of this PC was not accepted; link it again from the page"
|
|
34
|
+
NOT_LINKED_LINE = "not linked: start the program without arguments and link this PC first"
|
|
35
|
+
ALREADY_RUNNING_LINE = "already running: opening the page of the program that runs"
|
|
36
|
+
# A quiet second start opens nothing, so its line promises nothing.
|
|
37
|
+
QUIET_RUNNING_LINE = "already running"
|
|
38
|
+
PAGE_NOT_KNOWN_LINE = ("already running: the page of the program that runs is not known yet; start it again in a "
|
|
39
|
+
"moment to open it")
|
|
40
|
+
CLAIM_FAILED_LINE = "cannot start: the run file cannot be replaced: {path}"
|
|
41
|
+
FOLDER_FAILED_LINE = "cannot start: the config folder cannot be written: {path}"
|
|
42
|
+
_MB_ICONINFORMATION = 0x40
|
|
43
|
+
_MB_SETFOREGROUND = 0x10000
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _data_dir(value):
|
|
47
|
+
if not value.strip():
|
|
48
|
+
raise argparse.ArgumentTypeError("the data directory is empty")
|
|
49
|
+
return Path(value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _client_lockfile(value):
|
|
53
|
+
if not value.strip():
|
|
54
|
+
raise argparse.ArgumentTypeError("the lockfile path is empty")
|
|
55
|
+
return Path(value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _worker_address(value):
|
|
59
|
+
try:
|
|
60
|
+
return worker.loopback_base(value)
|
|
61
|
+
except ValueError as refused:
|
|
62
|
+
raise argparse.ArgumentTypeError(str(refused)) from None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _arguments(argv):
|
|
66
|
+
# The options are accepted before and after the command; SUPPRESS keeps one side from erasing the other.
|
|
67
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
68
|
+
common.add_argument("--data-dir", type=_data_dir, default=argparse.SUPPRESS,
|
|
69
|
+
help="a directory used instead of %%APPDATA%%, for test runs")
|
|
70
|
+
common.add_argument("--worker", type=_worker_address, default=argparse.SUPPRESS,
|
|
71
|
+
help="http://127.0.0.1:<port> or http://localhost:<port> instead of the Worker, for test runs")
|
|
72
|
+
common.add_argument("--client-lockfile", type=_client_lockfile, default=argparse.SUPPRESS,
|
|
73
|
+
help="a lockfile read instead of the client's, its client reached over plain http on "
|
|
74
|
+
"127.0.0.1, for test runs")
|
|
75
|
+
parser = argparse.ArgumentParser(prog=f"python -m {__package__}", parents=[common],
|
|
76
|
+
description="Pairs this PC by a QR on a loopback page, watches the game "
|
|
77
|
+
"client and sends alerts.")
|
|
78
|
+
parser.add_argument("--dry", action="store_true", help="watch and alert, but never accept")
|
|
79
|
+
parser.add_argument("--quiet", action="store_true",
|
|
80
|
+
help="a start by the system: no browser, and no message box for a start that ends well")
|
|
81
|
+
commands = parser.add_subparsers(dest="command")
|
|
82
|
+
ping = commands.add_parser("ping", parents=[common], help="send one alert with the stored pair")
|
|
83
|
+
ping.add_argument("kind", choices=worker.KINDS)
|
|
84
|
+
return parser.parse_args(argv)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _ping_line(result):
|
|
88
|
+
if isinstance(result, worker.Sent):
|
|
89
|
+
return "sent"
|
|
90
|
+
if isinstance(result, worker.Refused):
|
|
91
|
+
return REFUSED_LINE
|
|
92
|
+
if isinstance(result, worker.NotDelivered):
|
|
93
|
+
return f"not delivered (HTTP {result.status})"
|
|
94
|
+
return f"failed: {result.reason}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ping(store, base, kind, timeout):
|
|
98
|
+
pair = store.read() # never a first load: a ping mints nothing
|
|
99
|
+
if pair is None or pair.link_id is None:
|
|
100
|
+
print(NOT_LINKED_LINE)
|
|
101
|
+
return 2
|
|
102
|
+
result = worker.ping(pair.link_id, pair.secret, kind, base=base, timeout=timeout)
|
|
103
|
+
print(_ping_line(result))
|
|
104
|
+
return 0 if isinstance(result, worker.Sent) else 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _watcher(args, store, state, base, timeout, stop, delay, beep):
|
|
108
|
+
"""The watcher and its alert. The test override reads its one lockfile, with no process read, and
|
|
109
|
+
reaches its client over plain http on 127.0.0.1."""
|
|
110
|
+
lockfile = getattr(args, "client_lockfile", None)
|
|
111
|
+
if lockfile is None:
|
|
112
|
+
credentials, addresses = client.ClientCredentials(), client.real_addresses
|
|
113
|
+
else:
|
|
114
|
+
credentials, addresses = client.ClientCredentials((str(lockfile),), run=None), client.loopback_addresses
|
|
115
|
+
alerter = alert.Alerter(store, state, lambda link_id, secret, kind: worker.ping(
|
|
116
|
+
link_id, secret, kind, base=base, timeout=timeout), beep=beep)
|
|
117
|
+
return watcher.Watcher(credentials, alerter, accept=not args.dry, addresses=addresses, delay=delay,
|
|
118
|
+
stop=stop), alerter
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _console_attached():
|
|
122
|
+
"""False only on Windows with no terminal on stdout and no console window: a pythonw start, where a
|
|
123
|
+
printed line reaches nobody."""
|
|
124
|
+
if os.name != "nt":
|
|
125
|
+
return True
|
|
126
|
+
if sys.stdout is not None and sys.stdout.isatty():
|
|
127
|
+
return True
|
|
128
|
+
import ctypes
|
|
129
|
+
from ctypes import wintypes
|
|
130
|
+
|
|
131
|
+
kernel32 = ctypes.WinDLL("kernel32")
|
|
132
|
+
kernel32.GetConsoleWindow.restype = wintypes.HWND
|
|
133
|
+
return bool(kernel32.GetConsoleWindow())
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _message_box(line):
|
|
137
|
+
"""The line in a Windows message box, the one place a start with no console can show it."""
|
|
138
|
+
import ctypes
|
|
139
|
+
from ctypes import wintypes
|
|
140
|
+
|
|
141
|
+
user32 = ctypes.WinDLL("user32")
|
|
142
|
+
user32.MessageBoxW.argtypes = (wintypes.HWND, wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.UINT)
|
|
143
|
+
user32.MessageBoxW(None, line, __package__, _MB_ICONINFORMATION | _MB_SETFOREGROUND)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _ends(line, code, show):
|
|
147
|
+
"""The line a start ends on: printed as ever, and shown where no console is attached."""
|
|
148
|
+
print(line, flush=True)
|
|
149
|
+
show(line)
|
|
150
|
+
return code
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _serve(args, store, base, timeout, opener, stop, delay, beep, show, clock, sleep, autostart):
|
|
154
|
+
# A quiet start is the system's, at logon: it never opens the browser, and a start that ends well shows no box.
|
|
155
|
+
calm = (lambda line: None) if args.quiet else show
|
|
156
|
+
run = runfile.RunFile(store.path.parent, clock=clock, sleep=sleep)
|
|
157
|
+
try:
|
|
158
|
+
holder = run.claim()
|
|
159
|
+
if holder is not None and holder["closing"] and run.wait_released(holder, CLOSING_WAIT_SECONDS):
|
|
160
|
+
holder = run.claim() # the closing copy let its file go: this start claims it as a first one does
|
|
161
|
+
except runfile.FolderNotWritable as refused:
|
|
162
|
+
return _ends(FOLDER_FAILED_LINE.format(path=refused.filename), 1, show)
|
|
163
|
+
except OSError:
|
|
164
|
+
return _ends(CLAIM_FAILED_LINE.format(path=run.path), 1, show)
|
|
165
|
+
if holder is not None:
|
|
166
|
+
if holder["closing"]: # still closing at the bound: its page is going, so it is never opened
|
|
167
|
+
return _ends(QUIET_RUNNING_LINE if args.quiet else PAGE_NOT_KNOWN_LINE, 0, calm)
|
|
168
|
+
port = holder["port"] if holder["port"] is not None else run.holder_port(holder)
|
|
169
|
+
if port is None: # a winner with no page yet: nothing is opened; a quiet start promises no page either
|
|
170
|
+
return _ends(QUIET_RUNNING_LINE if args.quiet else PAGE_NOT_KNOWN_LINE, 0, calm)
|
|
171
|
+
if args.quiet: # the system's start at logon: nothing opened, nothing shown, a line of its own
|
|
172
|
+
print(QUIET_RUNNING_LINE, flush=True)
|
|
173
|
+
return 0
|
|
174
|
+
print(ALREADY_RUNNING_LINE, flush=True)
|
|
175
|
+
opener(f"http://{page.ADDRESS}:{port}/")
|
|
176
|
+
show(ALREADY_RUNNING_LINE)
|
|
177
|
+
return 0
|
|
178
|
+
try:
|
|
179
|
+
state = pairing.PairingState(store, lambda secret: worker.check(secret, base=base, timeout=timeout))
|
|
180
|
+
watch, alerter = _watcher(args, store, state, base, timeout, stop, delay, beep)
|
|
181
|
+
|
|
182
|
+
def quit_page():
|
|
183
|
+
# The page's quit marks the record closing at once, since a tick can be held in a slow check before
|
|
184
|
+
# the finally runs, then sets the same stop event as Ctrl+C: the watcher, the page and the run file
|
|
185
|
+
# end below.
|
|
186
|
+
try:
|
|
187
|
+
run.mark_closing()
|
|
188
|
+
except OSError:
|
|
189
|
+
pass # unmarked, the finally marks it again; the stop goes on
|
|
190
|
+
stop.set()
|
|
191
|
+
|
|
192
|
+
pairing_page = page.PairingPage(state, watch=watch.snapshot, on_quit=quit_page, on_pause=watch.pause,
|
|
193
|
+
on_resume=watch.resume, autostart=autostart, events=watch.events)
|
|
194
|
+
url = pairing_page.start()
|
|
195
|
+
watching = threading.Thread(target=watch.run, daemon=True)
|
|
196
|
+
try:
|
|
197
|
+
run.publish(pairing_page.port)
|
|
198
|
+
print(f"page: {url}", flush=True)
|
|
199
|
+
if not args.quiet and not opener(url): # a start by a person opens the page, linked or not
|
|
200
|
+
print("open the address above in a browser", flush=True)
|
|
201
|
+
watching.start()
|
|
202
|
+
while True:
|
|
203
|
+
state.tick()
|
|
204
|
+
if stop.wait(TICK_SECONDS):
|
|
205
|
+
break
|
|
206
|
+
except KeyboardInterrupt:
|
|
207
|
+
pass
|
|
208
|
+
finally:
|
|
209
|
+
stop.set()
|
|
210
|
+
# First, so a start made while this one stops waits for it instead of opening a closing page: Ctrl+C
|
|
211
|
+
# and Ctrl+Break are marked only here, and the page's quit, marked already, is marked again.
|
|
212
|
+
try:
|
|
213
|
+
run.mark_closing()
|
|
214
|
+
except OSError:
|
|
215
|
+
pass # unmarked, a start meanwhile opens this page as before; the stop goes on
|
|
216
|
+
if watching.is_alive():
|
|
217
|
+
watching.join(STOP_SECONDS)
|
|
218
|
+
alerter.flush(STOP_SECONDS)
|
|
219
|
+
pairing_page.close()
|
|
220
|
+
finally:
|
|
221
|
+
run.release()
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _break_as_interrupt():
|
|
226
|
+
"""Ctrl+Break stops the program as Ctrl+C does; only on Windows and only on the main thread."""
|
|
227
|
+
if not hasattr(signal, "SIGBREAK") or threading.current_thread() is not threading.main_thread():
|
|
228
|
+
return None
|
|
229
|
+
return signal.signal(signal.SIGBREAK, signal.default_int_handler)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main(argv=None, *, opener=webbrowser.open, stop=None, timeout=worker.TIMEOUT_SECONDS, delay=None,
|
|
233
|
+
beep=alert.beep, box=_message_box, autostart=None, console=_console_attached, clock=time.monotonic,
|
|
234
|
+
sleep=time.sleep):
|
|
235
|
+
"""`beep`, `box` and `autostart` are the seams of the sound, of the message box and of the start with Windows
|
|
236
|
+
(the per-user Run value when None): a test run passes silent ones and a fake. `console` says whether a printed
|
|
237
|
+
line reaches anybody; `clock` and `sleep` time the run file's re-reads."""
|
|
238
|
+
args = _arguments(sys.argv[1:] if argv is None else argv)
|
|
239
|
+
store = config.ConfigStore(getattr(args, "data_dir", None) or config.default_base_dir())
|
|
240
|
+
base = getattr(args, "worker", worker.BASE_URL)
|
|
241
|
+
|
|
242
|
+
def show(line): # a start's last line, also in a message box where no console is attached
|
|
243
|
+
if not console():
|
|
244
|
+
box(line)
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
if args.command == "ping":
|
|
248
|
+
return _ping(store, base, args.kind, timeout)
|
|
249
|
+
previous = _break_as_interrupt()
|
|
250
|
+
try:
|
|
251
|
+
return _serve(args, store, base, timeout, opener, stop or threading.Event(),
|
|
252
|
+
delay or watcher.accept_delay, beep, show, clock, sleep,
|
|
253
|
+
autostart if autostart is not None else Autostart())
|
|
254
|
+
finally:
|
|
255
|
+
if previous is not None:
|
|
256
|
+
signal.signal(signal.SIGBREAK, previous)
|
|
257
|
+
except config.ConfigError as failure: # at start or mid-run: its one sentence, never a traceback
|
|
258
|
+
print(failure)
|
|
259
|
+
if args.command != "ping": # a start ends on it
|
|
260
|
+
show(str(failure))
|
|
261
|
+
return 1
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
sys.exit(main())
|
pendify/alert.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""An alert: the beep of S:161-169 and, with a stored link id, the ping of its kind off the watcher's thread.
|
|
2
|
+
|
|
3
|
+
Every alert beeps and names a kind (S:336-355). The ping's result goes to the pairing state through record_ping,
|
|
4
|
+
as lane lnk5a's ping command answers it, so three refusals in a row offer the relink, and its name and time stay
|
|
5
|
+
for the page as the last ping. With no link id nothing is sent and nothing is queued. sound() is the beep
|
|
6
|
+
alone, with no kind and no ping. The console gets fixed lines only. listen() names the one callback told each ping's
|
|
7
|
+
result name and time right after they are recorded (the watcher's log, lane pclog).
|
|
8
|
+
"""
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from . import worker
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import winsound
|
|
16
|
+
except ImportError: # not Windows: the beep is silent, as in the script
|
|
17
|
+
winsound = None
|
|
18
|
+
|
|
19
|
+
BEEP_NOTES = (988, 1319) # S:167
|
|
20
|
+
BEEP_ROUNDS = 3 # S:166
|
|
21
|
+
BEEP_MILLISECONDS = 160 # S:168
|
|
22
|
+
PING_LINES = {"sent": "alert: sent to the phone", "refused": "alert: refused, link this PC again from the page",
|
|
23
|
+
"not_delivered": "alert: not delivered", "failed": "alert: failed"}
|
|
24
|
+
CONFIG_UNREADABLE_LINE = "alert: the config file could not be read, nothing was sent"
|
|
25
|
+
BEEP_FAILED_LINE = "alert: the sound device refused the beep"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _print(line):
|
|
29
|
+
print(line, flush=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _start_daemon(target):
|
|
33
|
+
thread = threading.Thread(target=target, daemon=True)
|
|
34
|
+
thread.start()
|
|
35
|
+
return thread
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def beep(sound=winsound, start=_start_daemon):
|
|
39
|
+
"""The script's siren, three rounds of two notes, on a thread of its own; None without the module."""
|
|
40
|
+
if sound is None:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
def play():
|
|
44
|
+
try:
|
|
45
|
+
for _ in range(BEEP_ROUNDS):
|
|
46
|
+
for frequency in BEEP_NOTES:
|
|
47
|
+
sound.Beep(frequency, BEEP_MILLISECONDS)
|
|
48
|
+
except RuntimeError: # winsound's answer when the system cannot play it
|
|
49
|
+
_print(BEEP_FAILED_LINE)
|
|
50
|
+
|
|
51
|
+
return start(play)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _result_name(result):
|
|
55
|
+
if isinstance(result, worker.Sent):
|
|
56
|
+
return "sent"
|
|
57
|
+
if isinstance(result, worker.Refused):
|
|
58
|
+
return "refused"
|
|
59
|
+
if isinstance(result, worker.NotDelivered):
|
|
60
|
+
return "not_delivered"
|
|
61
|
+
return "failed"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Alerter:
|
|
65
|
+
def __init__(self, store, state, ping, *, beep=beep, start=None, log=None, wall=time.time):
|
|
66
|
+
self._store, self._state, self._ping, self._beep = store, state, ping, beep
|
|
67
|
+
self._start = start or _start_daemon
|
|
68
|
+
self._log = log or _print
|
|
69
|
+
self._wall = wall
|
|
70
|
+
self._lock = threading.Lock()
|
|
71
|
+
self._pending = []
|
|
72
|
+
self._last_ping = (None, None)
|
|
73
|
+
self._listener = None
|
|
74
|
+
|
|
75
|
+
def __call__(self, kind):
|
|
76
|
+
"""Beeps; with a stored link id, sends the ping of `kind` on a thread of its own."""
|
|
77
|
+
self._beep()
|
|
78
|
+
try:
|
|
79
|
+
pair = self._store.read() # read at each alert: a link made on the page counts at once
|
|
80
|
+
except OSError:
|
|
81
|
+
self._log(CONFIG_UNREADABLE_LINE)
|
|
82
|
+
return
|
|
83
|
+
if pair is None or pair.link_id is None:
|
|
84
|
+
return
|
|
85
|
+
done = threading.Event()
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._pending = [event for event in self._pending if not event.is_set()] + [done]
|
|
88
|
+
self._start(lambda: self._send(pair, kind, done))
|
|
89
|
+
|
|
90
|
+
def listen(self, callback):
|
|
91
|
+
"""`callback(name, at)` is told each ping's result name and wall time on the ping's thread, right after the
|
|
92
|
+
last ping records them; a later call replaces it. A callback that raises is swallowed: the record stands and
|
|
93
|
+
the ping's line is said."""
|
|
94
|
+
with self._lock:
|
|
95
|
+
self._listener = callback
|
|
96
|
+
|
|
97
|
+
def sound(self):
|
|
98
|
+
"""Beeps and nothing else: no ping, nothing queued, the last ping as it was."""
|
|
99
|
+
self._beep()
|
|
100
|
+
|
|
101
|
+
def _send(self, pair, kind, done):
|
|
102
|
+
try:
|
|
103
|
+
try:
|
|
104
|
+
result = self._ping(pair.link_id, pair.secret, kind)
|
|
105
|
+
except Exception as failure: # a refused input raises a fixed sentence; it still ends as failed
|
|
106
|
+
result = worker.Failed(type(failure).__name__)
|
|
107
|
+
self._state.record_ping(result)
|
|
108
|
+
name, at = _result_name(result), self._wall()
|
|
109
|
+
with self._lock:
|
|
110
|
+
self._last_ping = (name, at)
|
|
111
|
+
listener = self._listener
|
|
112
|
+
if listener is not None:
|
|
113
|
+
try:
|
|
114
|
+
listener(name, at)
|
|
115
|
+
except Exception: # the log's listener never costs the ping its record or its line
|
|
116
|
+
pass
|
|
117
|
+
self._log(PING_LINES[name])
|
|
118
|
+
finally:
|
|
119
|
+
done.set()
|
|
120
|
+
|
|
121
|
+
def last_ping(self):
|
|
122
|
+
"""The last ping's result name (sent, refused, not_delivered or failed) and its wall time; (None, None)
|
|
123
|
+
before any ping was made."""
|
|
124
|
+
with self._lock:
|
|
125
|
+
return self._last_ping
|
|
126
|
+
|
|
127
|
+
def flush(self, timeout):
|
|
128
|
+
"""Waits up to `timeout` seconds for the pings in flight; True when none is left."""
|
|
129
|
+
deadline = time.monotonic() + timeout
|
|
130
|
+
with self._lock:
|
|
131
|
+
pending = list(self._pending)
|
|
132
|
+
return all(event.wait(max(0.0, deadline - time.monotonic())) for event in pending)
|
pendify/autostart.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""The start with Windows: the per-user Run value, no administrator.
|
|
2
|
+
|
|
3
|
+
The value `PendiFy` under HKEY_CURRENT_USER's Run key holds the start line of install.ps1 plus --quiet, so the
|
|
4
|
+
program starts at logon with no browser. Only the page writes it, on its person's ask; a start never writes it,
|
|
5
|
+
and install.ps1 only rewrites one that exists. The registry is a seam of three calls on one value under one key
|
|
6
|
+
path; the real one imports winreg inside itself and takes the key path as an argument, so a test points it at a
|
|
7
|
+
scratch key. Where winreg cannot be imported the start with Windows is not available, and the page shows and
|
|
8
|
+
routes nothing of it. A registry failure is one sentence on stderr, by its type only, and changes nothing.
|
|
9
|
+
"""
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
|
14
|
+
VALUE_NAME = "PendiFy"
|
|
15
|
+
START_ARGS = "-m pendify --quiet"
|
|
16
|
+
FAILED_LINE = "autostart: the start with Windows could not be {what} ({kind})"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WindowsRegistry:
|
|
20
|
+
"""One value under one key path of HKEY_CURRENT_USER: read, write and delete, nothing else. Built, it reads
|
|
21
|
+
nothing; an import of winreg that fails is the caller's ImportError."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, key_path=RUN_KEY, name=VALUE_NAME):
|
|
24
|
+
import winreg
|
|
25
|
+
|
|
26
|
+
self._winreg, self.key_path, self.name = winreg, key_path, name
|
|
27
|
+
|
|
28
|
+
def read(self):
|
|
29
|
+
"""The value's text, or None when the key or the value is missing."""
|
|
30
|
+
winreg = self._winreg
|
|
31
|
+
try:
|
|
32
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, self.key_path) as key:
|
|
33
|
+
return winreg.QueryValueEx(key, self.name)[0]
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
def write(self, text):
|
|
38
|
+
winreg = self._winreg
|
|
39
|
+
with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, self.key_path, 0, winreg.KEY_SET_VALUE) as key:
|
|
40
|
+
winreg.SetValueEx(key, self.name, 0, winreg.REG_SZ, text)
|
|
41
|
+
|
|
42
|
+
def delete(self):
|
|
43
|
+
"""The value removed; a missing key or value is nothing to remove, no error."""
|
|
44
|
+
winreg = self._winreg
|
|
45
|
+
try:
|
|
46
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, self.key_path, 0, winreg.KEY_SET_VALUE) as key:
|
|
47
|
+
winreg.DeleteValue(key, self.name)
|
|
48
|
+
except FileNotFoundError:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _say_failure(what, failure):
|
|
53
|
+
print(FAILED_LINE.format(what=what, kind=type(failure).__name__), file=sys.stderr, flush=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Autostart:
|
|
57
|
+
def __init__(self, registry=None, executable=None):
|
|
58
|
+
"""`registry` is the seam of the three calls, the real Run value when None; `executable` is the
|
|
59
|
+
interpreter whose start line is written, this one when None."""
|
|
60
|
+
if registry is None:
|
|
61
|
+
try:
|
|
62
|
+
registry = WindowsRegistry()
|
|
63
|
+
except ImportError: # no winreg on this system: no Run value to keep
|
|
64
|
+
registry = None
|
|
65
|
+
self.registry = registry
|
|
66
|
+
self.available = registry is not None
|
|
67
|
+
self._executable = executable or sys.executable
|
|
68
|
+
|
|
69
|
+
def enabled(self):
|
|
70
|
+
"""True when the value exists; False when it does not, when not available, or when it cannot be read."""
|
|
71
|
+
if not self.available:
|
|
72
|
+
return False
|
|
73
|
+
try:
|
|
74
|
+
return self.registry.read() is not None
|
|
75
|
+
except OSError as failure:
|
|
76
|
+
_say_failure("read", failure)
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
def enable(self):
|
|
80
|
+
"""The value written with command(); True when written."""
|
|
81
|
+
return self._change("written", lambda: self.registry.write(self.command()))
|
|
82
|
+
|
|
83
|
+
def disable(self):
|
|
84
|
+
"""The value deleted, a missing one being no error; True when it is gone."""
|
|
85
|
+
return self._change("removed", lambda: self.registry.delete())
|
|
86
|
+
|
|
87
|
+
def command(self):
|
|
88
|
+
"""install.ps1's start line plus --quiet: pythonw.exe beside the interpreter when that file exists, else
|
|
89
|
+
the interpreter, quoted when its path holds a space (install.ps1's Format-Arg)."""
|
|
90
|
+
interpreter = Path(self._executable)
|
|
91
|
+
windowless = interpreter.with_name("pythonw.exe")
|
|
92
|
+
chosen = str(windowless if windowless.is_file() else interpreter)
|
|
93
|
+
if any(character.isspace() for character in chosen):
|
|
94
|
+
chosen = f'"{chosen}"'
|
|
95
|
+
return f"{chosen} {START_ARGS}"
|
|
96
|
+
|
|
97
|
+
def _change(self, what, change):
|
|
98
|
+
if not self.available:
|
|
99
|
+
return False
|
|
100
|
+
try:
|
|
101
|
+
change()
|
|
102
|
+
except OSError as failure:
|
|
103
|
+
_say_failure(what, failure)
|
|
104
|
+
return False
|
|
105
|
+
return True
|