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/watcher.py ADDED
@@ -0,0 +1,348 @@
1
+ """The watcher of the game client: the loop of S:728-800 as a class.
2
+
3
+ Everything that touches the world is injected: the credentials reader, the address builder, the HTTP
4
+ getter and poster, the clock, the wall clock, the sleep, the random delay, the alert and the console.
5
+ Its `alert` is an Alerter: snapshot() reads its last_ping(). One step() is one turn of the script's loop
6
+ and answers the pause before the next; run() is the loop around it. The script's 15 s sleep after a ready
7
+ check (S:778, S:793) is a hold on the injected clock, so a stop never waits for it. The arrival of
8
+ InProgress after a read of another phase is the loading screen: it beeps on the PC only, once per game, and
9
+ starts a watch that asks the game's own loopback port its clock at most once a second. The clock above zero
10
+ is the match's true start: a beep and match_started. A game that never answers its clock is announced after
11
+ 120 s of the watch with InProgress read; a read of a boundary phase, or 120 s with no client, ends the watch
12
+ with no alert. A reconnect is the same game. The console gets fixed lines only: no phase, no port, no clock,
13
+ no token.
14
+
15
+ pause() and resume() come from the page's thread and only set or clear an event; the loop honors it on its own
16
+ thread. While paused run() makes no step, so nothing reads the client's files or process, its port or the game's
17
+ port; the first paused turn forgets the client, the watch, the hold and the start latch, and the first turn after
18
+ a resume is a fresh connection, so a game already under way then alerts nothing. Each pause is counted, and the loop
19
+ rests for every count it has not honored, so a pause and a resume that both land inside one sleep or one step still
20
+ rest it once. A pause that lands inside a step posts no accept after the delay and starts no alert, and its started
21
+ line is not said. The pause lives in memory only: every start is active.
22
+
23
+ The log (lane pclog): a ring of the last LOG_LINES events, each (seq, at, kind, detail), noted where the console line
24
+ of the same moment is said, the ping's result through the Alerter's listen at the ping's own time; seq rises by one
25
+ from 1 and never repeats in a run. events() answers a copy for the page. It lives in memory only, as the pause does.
26
+ """
27
+ import collections
28
+ import json
29
+ import random
30
+ import threading
31
+ import time
32
+
33
+ from . import client, worker
34
+
35
+ IN_PROGRESS, READY_CHECK, RECONNECT = "InProgress", "ReadyCheck", "Reconnect"
36
+ # The phases that end or precede a game: only a read of one of them resets the start latch.
37
+ GAME_BOUNDARY_PHASES = frozenset({"None", "Lobby", "Matchmaking", READY_CHECK, "ChampSelect", "EndOfGame",
38
+ "PreEndOfGame", "WaitingForStats"})
39
+ ACCEPT_DELAY = (1.0, 2.5) # S:136
40
+ HOLD_SECONDS = 15.0 # S:778, S:793
41
+ STEP_PAUSE = 0.3 # S:799
42
+ NO_CLIENT_PAUSE = 3.0 # S:739
43
+ LIVE_POLL_SECONDS = 1.0 # the game's clock is asked at most once a second during the watch
44
+ LIVE_FALLBACK_SECONDS = 120.0 # restarted by each clock not above zero, the wait announces a game giving no clock
45
+ QUEUE_FOUND, MATCH_STARTED = worker.KINDS
46
+ LOG_LINES = 50 # the events the log keeps, the newest
47
+
48
+ CONNECTED_LINE = "connected to the game client"
49
+ LOST_LINE = "lost the game client, looking for it again"
50
+ ACCEPTING_LINE = "match found: accepting"
51
+ ACCEPTED_LINE = "match found: accepted"
52
+ DRY_LINE = "match found: not accepting (--dry)"
53
+ STEP_FAILED_LINE = "watcher: a step failed ({}), looking for the game client again"
54
+ LOADING_LINE = "loading screen: waiting for the match to start"
55
+ STARTED_LINE = "match started: alerting"
56
+ STARTED_ON_WAIT_LINE = "match started: the game gave no clock, alerting on the wait"
57
+ PAUSED_LINE = "paused: not reading the game client"
58
+ RESUMED_LINE = "resumed: looking for the game client again"
59
+
60
+ WAITING, CONNECTED = "waiting", "connected"
61
+ _ALERT_NAMES = {QUEUE_FOUND: "queue", MATCH_STARTED: "started"}
62
+ LOADING = "loading" # the last alert's name at the loading screen, said on the PC only
63
+
64
+
65
+ def accept_delay():
66
+ """A delay drawn inside the script's two bounds (S:780)."""
67
+ return random.uniform(*ACCEPT_DELAY)
68
+
69
+
70
+ def _print(line):
71
+ print(line, flush=True)
72
+
73
+
74
+ def _phase(raw):
75
+ """The phase the client answered, a JSON string, or None for anything else."""
76
+ try:
77
+ phase = json.loads(raw.decode("utf-8"))
78
+ except ValueError: # a body that is not JSON; UnicodeDecodeError is a ValueError
79
+ return None
80
+ return phase if isinstance(phase, str) else None
81
+
82
+
83
+ class Watcher:
84
+ def __init__(self, credentials, alert, *, accept=True, addresses=client.real_addresses, get=client.get,
85
+ post=client.post, clock=time.monotonic, wall=time.time, sleep=None, delay=accept_delay,
86
+ log=None, stop=None, live=client.real_live_address, game_clock=client.game_clock):
87
+ self._credentials, self._alert, self._accept = credentials, alert, accept
88
+ self._addresses, self._get, self._post = addresses, get, post
89
+ self._live, self._game_clock = live, game_clock
90
+ self._clock, self._wall, self._delay = clock, wall, delay
91
+ self._stop = stop if stop is not None else threading.Event()
92
+ self._sleep = sleep if sleep is not None else self._stop.wait
93
+ self._log = log if log is not None else _print
94
+ self._lock = threading.Lock()
95
+ self._found = None
96
+ self._last_phase = None
97
+ self._start_alerted = False
98
+ self._hold_until = None
99
+ self._client_state = WAITING
100
+ self._last_alert = None
101
+ self._watch_since = None # the injected clock when the loading screen opened; None with no watch on
102
+ self._next_live = None # the injected clock from which the game's clock may be asked again
103
+ self._paused = threading.Event() # set and cleared by the page's thread, read by the loop
104
+ self._pauses = 0 # every pause counted by the page's thread, under the lock; the loop compares it
105
+ self._honored = 0 # the count of the last pause the loop has rested for; read and written by it only
106
+ self._resting = False # the loop's own record that it has acted on a pause; read and written by it only
107
+ self._events = collections.deque(maxlen=LOG_LINES) # the log, (seq, at, kind, detail); under the lock
108
+ self._seq = 0 # the last event's seq, under the lock; never reset in a run of the program
109
+ self._ran = False # run() has noted the start; read and written by the loop only
110
+ self._waiting_due = True # the next turn with no client notes waiting: after the start and after a resume
111
+ listen = getattr(alert, "listen", None)
112
+ if callable(listen): # an Alerter tells each ping's result; the tests' plain fakes have no listen
113
+ listen(self._on_ping)
114
+
115
+ def stop(self):
116
+ self._stop.set()
117
+
118
+ def pause(self):
119
+ """Stop reading the game; callable from any thread, honored by the loop at its next turn. The event is set
120
+ before the count moves, so a loop that reads the new count reads the event of this pause or of a later
121
+ resume."""
122
+ self._paused.set()
123
+ with self._lock:
124
+ self._pauses += 1
125
+
126
+ def resume(self):
127
+ """Read the game again from the next turn, as a fresh connection; callable from any thread."""
128
+ self._paused.clear()
129
+
130
+ @property
131
+ def paused(self):
132
+ return self._paused.is_set()
133
+
134
+ def run(self):
135
+ """The loop: a step, then its pause, until stopped. A step that breaks is said by its type and the
136
+ client is looked for again, so the watcher never ends while the page says it runs."""
137
+ while not self._stop.is_set():
138
+ if not self._ran:
139
+ self._ran = True
140
+ self._note("started")
141
+ with self._lock:
142
+ pauses = self._pauses
143
+ paused = self._paused.is_set()
144
+ # A pause not yet honored rests the loop even when a resume has already cleared the event.
145
+ if (paused or pauses != self._honored) and not self._resting:
146
+ self._rest()
147
+ self._honored = pauses
148
+ if paused: # no step: nothing is read while paused
149
+ pause = STEP_PAUSE
150
+ else:
151
+ if self._resting:
152
+ self._resting = False
153
+ self._log(RESUMED_LINE)
154
+ self._note("resumed")
155
+ self._waiting_due = True
156
+ try:
157
+ pause = self.step()
158
+ except Exception as failure:
159
+ connected = self._found is not None # the log says lost only of a client this turn had
160
+ self._log(STEP_FAILED_LINE.format(type(failure).__name__))
161
+ if connected:
162
+ self._note("lost")
163
+ self._forget()
164
+ pause = NO_CLIENT_PAUSE
165
+ if self._stop.is_set():
166
+ break
167
+ self._sleep(pause)
168
+
169
+ def step(self):
170
+ """One turn of S:733-799; the pause before the next turn."""
171
+ if self._hold_until is not None:
172
+ if self._clock() < self._hold_until:
173
+ return STEP_PAUSE
174
+ self._hold_until = None
175
+ if self._found is None:
176
+ found = self._credentials.read()
177
+ if found is None:
178
+ if self._waiting_due: # once per waiting period; never right after lost, whose line says the search
179
+ self._waiting_due = False
180
+ self._note(WAITING)
181
+ self._watch(None) # the watch outlives a lost client
182
+ return NO_CLIENT_PAUSE
183
+ self._found = found
184
+ self._set_client(CONNECTED)
185
+ self._log(CONNECTED_LINE)
186
+ self._note(CONNECTED)
187
+ self._waiting_due = False
188
+ base = self._addresses(self._found.port)
189
+ token = self._found.token
190
+ phase = None
191
+ try:
192
+ status, raw = self._get(base + client.PHASE_PATH, token, client.CLIENT_TIMEOUT)
193
+ if status == 200:
194
+ phase = _phase(raw)
195
+ if phase is None: # requests raises a RequestException on a body that is not JSON (S:750)
196
+ raise client.ClientUnreachable("ValueError")
197
+ self._on_phase(phase, base, token)
198
+ except client.ClientUnreachable: # S:796-798
199
+ self._log(LOST_LINE)
200
+ self._note("lost")
201
+ self._forget()
202
+ phase = None
203
+ self._watch(phase)
204
+ return STEP_PAUSE
205
+
206
+ def snapshot(self):
207
+ """The watcher's state in plain values: the client waiting or connected, the last phase read as the
208
+ client names it (None before any), the last alert and when, and the last ping's result and when."""
209
+ ping, ping_at = self._alert.last_ping()
210
+ with self._lock:
211
+ name, at = self._last_alert or (None, None)
212
+ return {"client": self._client_state, "phase": self._last_phase, "alert": name, "at": at,
213
+ "pingResult": ping, "pingAt": ping_at, "paused": self._paused.is_set()}
214
+
215
+ def events(self):
216
+ """The log: the last LOG_LINES events of this run, oldest first, as (seq, at, kind, detail) in a list the
217
+ caller may keep or change."""
218
+ with self._lock:
219
+ return list(self._events)
220
+
221
+ def _note(self, kind, detail=None, at=None):
222
+ """One event in the log: the next seq, the wall time unless the event brings its own, the kind and its
223
+ detail; under the lock events() reads with, since a ping is noted from the Alerter's thread."""
224
+ at = self._wall() if at is None else at
225
+ with self._lock:
226
+ self._seq += 1
227
+ self._events.append((self._seq, at, kind, detail))
228
+
229
+ def _on_ping(self, name, at):
230
+ """The Alerter's listener: a ping's result name and its own time."""
231
+ self._note("ping", name, at)
232
+
233
+ def _on_phase(self, phase, base, token):
234
+ # An arrival is InProgress after a read of another phase. The first read is not one: an alert for a game
235
+ # already under way says nothing (S:755-757). A first read of InProgress or of Reconnect is such a game, so
236
+ # it sets the latch, and the InProgress that follows (after a Reconnect of that same game, or the one the
237
+ # Reconnect ends in) says nothing either. Reconnect, InProgress or an unknown phase keeps the latch.
238
+ # A boundary ends a watch with no alert; a first read of InProgress or Reconnect leaves a running one on.
239
+ # The log notes a phase that differs from the last read, before what it leads to (the accept, the loading).
240
+ if phase != self._last_phase:
241
+ self._note("phase", phase)
242
+ if phase in GAME_BOUNDARY_PHASES:
243
+ self._start_alerted = False
244
+ self._watch_since = None
245
+ elif self._last_phase is None and phase in (IN_PROGRESS, RECONNECT):
246
+ self._start_alerted = True
247
+ elif self._last_phase not in (None, IN_PROGRESS) and not self._start_alerted:
248
+ self._start_alerted = True # before the alert: an alert that breaks is not made again for this game
249
+ self._loading()
250
+ with self._lock:
251
+ self._last_phase = phase
252
+ if phase == READY_CHECK:
253
+ self._ready_check(base, token)
254
+
255
+ def _ready_check(self, base, token):
256
+ if not self._accept: # the script's --dry (S:773-777)
257
+ self._log(DRY_LINE)
258
+ self._fire(QUEUE_FOUND) # S:776
259
+ self._hold()
260
+ return
261
+ wait = self._delay() if ACCEPT_DELAY[1] else 0
262
+ self._log(ACCEPTING_LINE)
263
+ if wait:
264
+ self._sleep(wait)
265
+ if self._stop.is_set(): # stopped during the delay: the script's Ctrl+C posts nothing either
266
+ return
267
+ if self._paused.is_set(): # paused during the delay: no accept, and the next turn rests
268
+ return
269
+ status = self._post(base + client.ACCEPT_PATH, token, client.CLIENT_TIMEOUT)
270
+ if status in (200, 204):
271
+ self._log(ACCEPTED_LINE)
272
+ self._note("accepted")
273
+ self._fire(QUEUE_FOUND) # S:790
274
+ self._hold()
275
+
276
+ def _loading(self):
277
+ """The loading screen, said on the PC only; the watch for the true start begins, its first read due now.
278
+ Nothing while paused."""
279
+ if self._paused.is_set():
280
+ return
281
+ self._alert.sound()
282
+ with self._lock:
283
+ self._last_alert = (LOADING, self._wall())
284
+ self._log(LOADING_LINE)
285
+ self._note(LOADING)
286
+ self._watch_since = self._next_live = self._clock()
287
+
288
+ def _watch(self, phase):
289
+ """One turn of the watch: the game's clock asked when due, its value above zero the start and any other
290
+ number a restart of the wait; then the wait, which fires on a step whose phase read was InProgress and ends
291
+ with no alert with no client."""
292
+ if self._watch_since is None:
293
+ return
294
+ now = self._clock()
295
+ if now >= self._next_live:
296
+ self._next_live = now + LIVE_POLL_SECONDS
297
+ seconds = self._game_clock(self._live())
298
+ if seconds is not None and seconds > 0:
299
+ self._watch_since = None
300
+ self._fire(MATCH_STARTED, STARTED_LINE)
301
+ return
302
+ if seconds is not None:
303
+ self._watch_since = now
304
+ if now >= self._watch_since + LIVE_FALLBACK_SECONDS:
305
+ if phase == IN_PROGRESS:
306
+ self._watch_since = None
307
+ self._fire(MATCH_STARTED, STARTED_ON_WAIT_LINE)
308
+ elif self._found is None:
309
+ self._watch_since = None
310
+
311
+ def _hold(self):
312
+ self._hold_until = self._clock() + HOLD_SECONDS
313
+
314
+ def _fire(self, kind, line=None):
315
+ """The alert of `kind`, its console `line` said first when one is given; nothing, the line included,
316
+ while paused."""
317
+ if self._paused.is_set(): # an alert handed to the Alerter before the pause is delivered; none starts in it
318
+ return
319
+ if line is not None:
320
+ self._log(line)
321
+ if kind == MATCH_STARTED: # noted on the kind, so a start fired with no line is still in the log
322
+ self._note("match_started")
323
+ with self._lock:
324
+ self._last_alert = (_ALERT_NAMES[kind], self._wall())
325
+ self._alert(kind)
326
+
327
+ def _forget(self):
328
+ # S:755-757 is per connection: a fresh client already InProgress at its first read alerts nothing.
329
+ # A running watch is left on: the game's clock is proof by itself.
330
+ self._found = None
331
+ with self._lock:
332
+ self._last_phase = None
333
+ self._set_client(WAITING)
334
+
335
+ def _rest(self):
336
+ """The first paused turn: the client, the watch, the hold and the start latch are dropped, so the turn
337
+ after a resume is a fresh connection."""
338
+ self._resting = True
339
+ self._forget()
340
+ self._watch_since = self._next_live = None
341
+ self._hold_until = None
342
+ self._start_alerted = False
343
+ self._log(PAUSED_LINE)
344
+ self._note("paused")
345
+
346
+ def _set_client(self, value):
347
+ with self._lock:
348
+ self._client_state = value
pendify/worker.py ADDED
@@ -0,0 +1,159 @@
1
+ """The Worker's two unauthenticated link routes, check (design P5) and ping (S:282-308), on urllib only.
2
+
3
+ Nothing either function prints, logs or raises carries the secret or the link id: failures are named by
4
+ an HTTP status or an exception type, results hide their values from repr, and a refused input is refused
5
+ with a fixed sentence before any call.
6
+ """
7
+ import importlib.metadata
8
+ import json
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ from dataclasses import dataclass, field
13
+
14
+ from . import codes
15
+
16
+ # The host of the ping address the owner's script uses (S:84).
17
+ BASE_URL = "https://followapp-ai-proxy.niklerk23.workers.dev"
18
+ CHECK_PATH = "/v1/link-check"
19
+ PING_PATH = "/v1/link-ping"
20
+ TIMEOUT_SECONDS = 5
21
+ # The two kinds this program sends, the contract with the Worker; the only place they are written.
22
+ KINDS = ("lol_queue_found", "lol_match_started")
23
+ _MAX_ANSWER_BYTES = 64 * 1024
24
+ _LOOPBACK_HOSTS = ("127.0.0.1", "localhost")
25
+
26
+
27
+ def _user_agent():
28
+ try:
29
+ version = importlib.metadata.version("pendify")
30
+ except Exception: # never stop the import: not installed (the tests, a checkout) or a half-written dist-info
31
+ version = None
32
+ # The fallback when the lookup failed or read no version (None or blank from a broken dist-info).
33
+ return f"pendify/{version.strip()}" if isinstance(version, str) and version.strip() else "pendify/source"
34
+
35
+
36
+ # The program names itself in every request: the edge in front of the Worker refuses urllib's default
37
+ # signature (Python-urllib/<version>) with error 1010 before the Worker reads the request.
38
+ USER_AGENT = _user_agent()
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Linked:
43
+ link_id: str = field(repr=False)
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Refused:
48
+ pass
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Throttled:
53
+ pass
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Failed:
58
+ reason: str
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Sent:
63
+ pass
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class NotDelivered:
68
+ status: int
69
+
70
+
71
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
72
+ """A redirect answer is returned as it is (an HTTPError), never followed."""
73
+
74
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
75
+ return None
76
+
77
+
78
+ def loopback_base(value):
79
+ """A replacement Worker address for test runs: only http://127.0.0.1:<port> or http://localhost:<port>."""
80
+ parts = urllib.parse.urlsplit(value) if isinstance(value, str) else None
81
+ try:
82
+ port = parts.port if parts else None
83
+ except ValueError:
84
+ port = None
85
+ if (parts is None or parts.scheme != "http" or parts.hostname not in _LOOPBACK_HOSTS or port is None
86
+ or parts.username is not None or parts.path not in ("", "/") or parts.query or parts.fragment
87
+ or parts.netloc != f"{parts.hostname}:{port}"):
88
+ raise ValueError("a replacement Worker address must be http://127.0.0.1:<port> or http://localhost:<port>")
89
+ return f"http://{parts.hostname}:{port}"
90
+
91
+
92
+ def _post(base, path, payload, timeout):
93
+ """(status, answer dict or None), or a Failed naming the exception type."""
94
+ request = urllib.request.Request(
95
+ base + path, data=json.dumps(payload, separators=(",", ":")).encode("utf-8"), method="POST",
96
+ headers={"Content-Type": "application/json", "Accept": "application/json", "User-Agent": USER_AGENT})
97
+ handlers = [_NoRedirect()]
98
+ if urllib.parse.urlsplit(base).hostname in _LOOPBACK_HOSTS:
99
+ handlers.append(urllib.request.ProxyHandler({})) # a loopback address never goes through a proxy
100
+ opener = urllib.request.build_opener(*handlers)
101
+ try:
102
+ with opener.open(request, timeout=timeout) as response:
103
+ status, raw = response.status, response.read(_MAX_ANSWER_BYTES)
104
+ except urllib.error.HTTPError as answer:
105
+ with answer:
106
+ status, raw = answer.code, answer.read(_MAX_ANSWER_BYTES)
107
+ except urllib.error.URLError as failure:
108
+ reason = failure.reason
109
+ return Failed(type(reason).__name__ if isinstance(reason, BaseException) else type(failure).__name__)
110
+ except (OSError, ValueError) as failure: # a timeout while reading, a reset, a malformed answer
111
+ return Failed(type(failure).__name__)
112
+ try:
113
+ body = json.loads(raw.decode("utf-8"))
114
+ except ValueError:
115
+ body = None
116
+ return status, body if isinstance(body, dict) else None
117
+
118
+
119
+ def _error_code(body):
120
+ error = body.get("error") if body else None
121
+ return error.get("code") if isinstance(error, dict) else None
122
+
123
+
124
+ def check(secret, *, base=BASE_URL, timeout=TIMEOUT_SECONDS):
125
+ """POST {secret} to the link check: Linked(link id), Refused, Throttled or Failed."""
126
+ normal = codes.normalize(secret)
127
+ if normal is None:
128
+ raise ValueError("the secret is not twelve symbols")
129
+ answer = _post(base, CHECK_PATH, {"secret": normal}, timeout)
130
+ if isinstance(answer, Failed):
131
+ return answer
132
+ status, body = answer
133
+ link_id = body.get("linkId") if body else None
134
+ if status == 200 and isinstance(link_id, str) and codes.normalize(link_id) == link_id:
135
+ return Linked(link_id)
136
+ code = _error_code(body)
137
+ if code == "link_refused":
138
+ return Refused()
139
+ if code == "throttled":
140
+ return Throttled()
141
+ return Failed(f"HTTP {status}")
142
+
143
+
144
+ def ping(link_id, secret, kind, *, base=BASE_URL, timeout=TIMEOUT_SECONDS):
145
+ """POST {linkId, secret, kind} to the link ping: Sent, Refused, NotDelivered(status) or Failed."""
146
+ if kind not in KINDS:
147
+ raise ValueError("the kind is not one of the two this program sends")
148
+ normal_link_id, normal_secret = codes.normalize(link_id), codes.normalize(secret)
149
+ if normal_link_id is None or normal_secret is None:
150
+ raise ValueError("the stored pair is not two values of twelve symbols")
151
+ answer = _post(base, PING_PATH, {"linkId": normal_link_id, "secret": normal_secret, "kind": kind}, timeout)
152
+ if isinstance(answer, Failed):
153
+ return answer
154
+ status, body = answer
155
+ if 200 <= status < 400 and body and body.get("sent"):
156
+ return Sent()
157
+ if _error_code(body) == "link_refused":
158
+ return Refused()
159
+ return NotDelivered(status)
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: pendify
3
+ Version: 0.1.0
4
+ Summary: Pairs this PC with a phone account by a QR on a loopback page and sends it two kinds of alerts.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/ElkinDev/PendiFy
7
+ Project-URL: Source, https://github.com/ElkinDev/PendiFy
8
+ Keywords: notifications,alerts,phone,qr,pairing,windows
9
+ Classifier: Operating System :: Microsoft :: Windows
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # PendiFy
18
+
19
+ ## Español
20
+
21
+ Un programa pequeño para Windows que avisa en tu teléfono cuando se encuentra tu partida y cuando empieza.
22
+ Se enlaza una sola vez con tu cuenta de Pendi leyendo un código QR, sin crear ninguna cuenta nueva.
23
+ Es gratis, no pide permisos de administrador y solo usa Python.
24
+
25
+ ### Instalar
26
+
27
+ Pulsa Windows + R, pega esta línea completa y pulsa Enter. También sirve en PowerShell o en el Símbolo del sistema.
28
+
29
+ powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-install.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/install.ps1 -OutFile ~\pendify-install.ps1; ~\pendify-install.ps1"
30
+
31
+ La línea guarda el instalador como `pendify-install.ps1` en tu carpeta de usuario y lo ejecuta desde ahí; puedes borrar ese archivo cuando termine.
32
+
33
+ La ventana queda abierta al terminar para que leas el resultado; ciérrala cuando acabe.
34
+
35
+ Si Windows dice que no puede acceder al archivo o el antivirus detiene la línea, no se instaló nada: actualiza las definiciones del antivirus y vuelve a intentarlo, o descarga `install.ps1` desde la página del repositorio, haz clic en él con el botón derecho y elige «Ejecutar con PowerShell».
36
+
37
+ Si ves "'irm' no se reconoce como un comando interno o externo", pegaste solo la parte corta en el Símbolo del sistema: usa la línea completa de arriba.
38
+
39
+ El instalador busca Python 3.10 o más nuevo; si no lo hay lo instala con winget solo para tu usuario, o te indica https://www.python.org/downloads/ cuando winget no existe. Después instala PendiFy con pip, deja un acceso directo `PendiFy` en el Escritorio y en el menú Inicio, y lo inicia.
40
+
41
+ En PowerShell: para ver qué haría sin cambiar nada: `$env:PENDIFY_DRYRUN = "1"` antes de pegar la línea. Para no iniciarlo al final: `$env:PENDIFY_NOSTART = "1"`.
42
+
43
+ Otra forma, sin ejecutar ningún script: si el PC ya tiene Python 3.10 o más nuevo, o si en él no se permite ejecutar scripts, instálalo con pip e inícialo:
44
+
45
+ python -m pip install --upgrade pendify
46
+ python -m pendify
47
+
48
+ Si Windows responde que no encuentra `python`, usa `py` en su lugar: `py -m pip install --upgrade pendify` y `py -m pendify`.
49
+
50
+ Así no se crean los accesos directos en el Escritorio ni en el menú Inicio, y el inicio con Windows queda en el interruptor de la página. Para actualizar, pulsa «Salir» en la página, ejecuta otra vez la misma línea de pip y vuelve a iniciarlo. Para quitarlo, apaga primero el interruptor «Iniciar con Windows» en la página, pulsa «Salir» y después ejecuta `python -m pip uninstall pendify`. La configuración en `%APPDATA%\pendify`, con el enlace con el teléfono, se queda; puedes borrar esa carpeta a mano.
51
+
52
+ ### Enlazar
53
+
54
+ Al iniciarse, el navegador abre una página local con un código QR. Escanéalo con la cámara del teléfono: se abre la app Pendi y te pide confirmar el enlace. La página muestra cuando el enlace quedó hecho.
55
+
56
+ El código QR y la clave se muestran durante un minuto al pulsar «Mostrar el código» y luego se ocultan de nuevo.
57
+
58
+ El botón de arriba a la derecha cambia entre el tema claro y el oscuro, y el programa guarda la elección para la próxima vez.
59
+
60
+ ### Iniciar, detener, desinstalar
61
+
62
+ - Iniciar: el acceso directo `PendiFy`, o `pythonw -m pendify`, que es lo que ejecuta el acceso directo; sin consola, si ya está abierto o no puede iniciarse te lo dice en una ventana. Para verlo en una consola: `python -m pendify`.
63
+ - Detener: pulsa «Salir» en la página del programa; si no la tienes abierta, iniciarlo otra vez la abre. Como último recurso, cierra el proceso `pythonw.exe` en el Administrador de tareas.
64
+ - Pausar: «Pausar avisos», en la tarjeta «Este PC» de la página, deja de leer el cliente del juego: no acepta partidas ni avisa a tu teléfono, y la página sigue abierta. «Reanudar avisos» vuelve a leerlo. La pausa no se guarda: cada vez que el programa se inicia, empieza activo.
65
+ - Iniciar con Windows: el interruptor «Iniciar con Windows», en la misma tarjeta, está apagado hasta que lo enciendas. Encendido, el programa empieza solo al iniciar sesión en Windows, solo para tu usuario y sin abrir el navegador ni la página.
66
+ - Actividad: la tarjeta «Actividad» de la página lista lo que el programa hizo desde que empezó: el cliente del juego encontrado y perdido, cada fase, la partida aceptada y el aviso enviado. Guarda las últimas 50 líneas, solo en memoria.
67
+ - Idioma: la página sigue el idioma del navegador hasta que eliges ES o EN con el selector de arriba, y la elección se guarda para este PC.
68
+ - Desinstalar: pega `powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-uninstall.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/uninstall.ps1 -OutFile ~\pendify-uninstall.ps1; ~\pendify-uninstall.ps1"` igual que la línea de instalación; deja `pendify-uninstall.ps1` en tu carpeta de usuario, que puedes borrar. Quita el paquete del mismo Python que lo instaló, comprueba que ya no está, quita los dos accesos directos y quita el inicio con Windows si estaba encendido; la configuración en `%APPDATA%\pendify` se queda y te dice dónde está.
69
+ - Actualizar: pulsa «Salir» en la página y vuelve a pegar la línea de instalación; instala la última versión y conserva la configuración y el enlace.
70
+
71
+ ### Qué envía y a quién
72
+
73
+ Solo envía, al servicio de enlace de Pendi, el tipo de aviso (partida encontrada o partida empezada) junto con el identificador del enlace y el secreto de este PC, que viven en `%APPDATA%\pendify\config.json`. El servicio lo entrega a la cuenta que enlazaste. No envía tu nombre, tus partidas ni nada más del juego. Para saber cuándo empieza la partida lee el reloj del juego en este mismo PC, y no lo guarda ni lo envía.
74
+
75
+ ### `--dry`
76
+
77
+ Con `--dry` el programa solo avisa y no acepta la partida por ti.
78
+
79
+ Licencia: MIT
80
+
81
+ ## English
82
+
83
+ A small Windows program that alerts your phone when your match is found and when it starts.
84
+ It links once to your Pendi account by scanning a QR code, with no new account of any kind.
85
+ It is free, needs no administrator rights and only uses Python.
86
+
87
+ ### Install
88
+
89
+ Press Windows + R, paste this whole line and press Enter. It also works in PowerShell or the Command Prompt.
90
+
91
+ powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-install.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/install.ps1 -OutFile ~\pendify-install.ps1; ~\pendify-install.ps1"
92
+
93
+ The line saves the installer as `pendify-install.ps1` in your user folder and runs it from there; you can delete that file when it is done.
94
+
95
+ The window stays open at the end so you can read the result; close it when it is done.
96
+
97
+ If Windows says it cannot access the file or the antivirus stops the line, nothing was installed: update the antivirus definitions and try again, or download `install.ps1` from the repository page, click it with the right button and choose "Run with PowerShell".
98
+
99
+ If you see "'irm' is not recognized as an internal or external command", you pasted only the short part into the Command Prompt: use the whole line above.
100
+
101
+ The installer looks for Python 3.10 or newer; when there is none it installs it with winget for your user only, or points you to https://www.python.org/downloads/ when winget is missing. Then it installs PendiFy with pip, leaves a `PendiFy` shortcut on the Desktop and in the Start menu, and starts it.
102
+
103
+ In PowerShell: to see what it would do without changing anything, set `$env:PENDIFY_DRYRUN = "1"` before pasting the line. To leave it stopped at the end, set `$env:PENDIFY_NOSTART = "1"`.
104
+
105
+ Another way, with no script at all: on a PC that already has Python 3.10 or newer, or where scripts are not allowed, install it with pip and start it:
106
+
107
+ python -m pip install --upgrade pendify
108
+ python -m pendify
109
+
110
+ If Windows answers that `python` was not found, use `py` in its place: `py -m pip install --upgrade pendify` and `py -m pendify`.
111
+
112
+ This way leaves no shortcut on the Desktop or in the Start menu, and the start with Windows is the switch on the page. To update, press «Quit» on the page, run the same pip line again and start it again. To remove it, first turn off the «Start with Windows» switch on the page, press «Quit», then run `python -m pip uninstall pendify`. The configuration in `%APPDATA%\pendify`, with the link to the phone, stays; you can delete that folder by hand.
113
+
114
+ ### Link
115
+
116
+ When it starts, the browser opens a local page with a QR code. Scan it with the phone's camera: the Pendi app opens and asks you to confirm the link. The page shows when the link is done.
117
+
118
+ The QR code and the key show for a minute when «Show the code» is pressed, and then hide again.
119
+
120
+ The button at the top right switches between the light and the dark theme, and the program keeps the choice for the next time.
121
+
122
+ ### Start, stop, uninstall
123
+
124
+ - Start: the `PendiFy` shortcut, or `pythonw -m pendify`, which is what the shortcut runs; with no console, it tells you in a window when it is already running or cannot start. To see it in a console: `python -m pendify`.
125
+ - Stop: press «Quit» on the program's page; if it is not open, starting the program again opens it. As a last resort, end the `pythonw.exe` process in Task Manager.
126
+ - Pause: «Pause alerts», on the page's «This PC» card, stops reading the game client: it accepts no match and sends no alert to your phone, and the page stays open. «Resume alerts» reads it again. The pause is not kept: every start of the program is active again.
127
+ - Start with Windows: the «Start with Windows» switch, on the same card, is off until you turn it on. When it is on, the program starts by itself when you sign in to Windows, for your user only, without opening the browser or the page.
128
+ - Activity: the page's «Activity» card lists what the program did since it started: the game client found and lost, each phase, the match accepted and the alert sent. It keeps the last 50 lines, in memory only.
129
+ - Language: the page follows the browser's language until you choose ES or EN with the switch at the top, and the choice is kept for this PC.
130
+ - Uninstall: paste `powershell -NoExit -NoProfile -ExecutionPolicy Bypass -Command "ri ~\pendify-uninstall.ps1 -ea 0; irm https://raw.githubusercontent.com/ElkinDev/PendiFy/main/uninstall.ps1 -OutFile ~\pendify-uninstall.ps1; ~\pendify-uninstall.ps1"` the same way as the install line; it leaves `pendify-uninstall.ps1` in your user folder, which you can delete. It removes the package from the same Python that installed it, checks that it is gone, removes both shortcuts and removes the start with Windows when it is on; the configuration in `%APPDATA%\pendify` stays, and it tells you where it is.
131
+ - Update: press "Quit" on the page and paste the install line again; it installs the latest version and keeps the configuration and the link.
132
+
133
+ ### What it sends and to whom
134
+
135
+ It only sends, to Pendi's link service, the alert kind (match found or match started) together with the link id and this PC's secret, which live in `%APPDATA%\pendify\config.json`. The service delivers it to the account you linked. It sends no name, no match history and nothing else from the game. To tell when the match starts it reads the game's clock on this same PC, and neither keeps nor sends it.
136
+
137
+ ### `--dry`
138
+
139
+ With `--dry` the program only alerts and never accepts the match for you.
140
+
141
+ License: MIT
142
+
143
+ ## Developer commands
144
+
145
+ python -m pendify
146
+ python -m pendify ping <kind>
147
+
148
+ The first loads the config, starts the page and opens it in the default browser, paired or not.
149
+ It also watches the game client on this PC: when a match is found it accepts after a short random delay, beeps, and sends the found-match alert; when the loading screen starts it beeps, and when the match itself starts it beeps and sends the started alert.
150
+ Add `--dry` to watch and alert without accepting. Add `--quiet` for the start by the system at logon, the line the start with Windows switch writes (`pythonw -m pendify --quiet`): it never opens the browser, a start that ends well shows no window, and a second quiet start prints `already running` and exits. One copy runs per config folder: a second start opens the page of the one that runs and exits. Ctrl+C stops the page and the watcher together.
151
+ The second sends one alert with the stored pair and prints one line with the answer.
152
+
153
+ Both commands take `--data-dir <dir>`, used instead of `%APPDATA%` (the config file lives in a folder under it), and `--worker <address>`, used instead of the pairing service's address and accepted only as `http://127.0.0.1:<port>` or `http://localhost:<port>`, so a test run never reaches the real service. `--client-lockfile <file>` replaces the game client's lockfile, with no read of the running processes, and the client it names is reached over plain http on 127.0.0.1, so a test run never reaches a real client.
154
+
155
+ python -m unittest discover -s tests -v