local-control 0.1.2__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.
@@ -0,0 +1,140 @@
1
+ """
2
+ Cross-platform helpers for registering/unregistering the LAN control server at user login.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import plistlib
9
+ import shlex
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import List
14
+
15
+
16
+ class StartupError(RuntimeError):
17
+ """Raised when registering or unregistering auto-start fails."""
18
+
19
+
20
+ class StartupManager:
21
+ def __init__(self, host: str, port: int, debug: bool = False) -> None:
22
+ self.host = host
23
+ self.port = port
24
+ self.debug = debug
25
+ self.python = Path(sys.executable).resolve()
26
+ self.command = self._build_command()
27
+
28
+ def enable(self) -> None:
29
+ system = sys.platform
30
+ try:
31
+ if system.startswith("win"):
32
+ self._enable_windows()
33
+ elif system == "darwin":
34
+ self._enable_darwin()
35
+ else:
36
+ self._enable_linux()
37
+ except OSError as exc: # pragma: no cover - platform specific
38
+ raise StartupError(str(exc)) from exc
39
+
40
+ def disable(self) -> None:
41
+ system = sys.platform
42
+ try:
43
+ if system.startswith("win"):
44
+ self._disable_windows()
45
+ elif system == "darwin":
46
+ self._disable_darwin()
47
+ else:
48
+ self._disable_linux()
49
+ except OSError as exc: # pragma: no cover - platform specific
50
+ raise StartupError(str(exc)) from exc
51
+
52
+ # ------------------------------------------------------------------ #
53
+ def _build_command(self) -> List[str]:
54
+ cmd = [
55
+ str(self.python),
56
+ "-m",
57
+ "local_control.cli",
58
+ "--host",
59
+ self.host,
60
+ "--port",
61
+ str(self.port),
62
+ ]
63
+ if self.debug:
64
+ cmd.append("--debug")
65
+ return cmd
66
+
67
+ # Linux ------------------------------------------------------------- #
68
+ def _autostart_path(self) -> Path:
69
+ return Path.home() / ".config" / "autostart" / "local_control.desktop"
70
+
71
+ def _enable_linux(self) -> None:
72
+ path = self._autostart_path()
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+ exec_cmd = " ".join(shlex.quote(part) for part in self.command)
75
+ desktop_entry = "\n".join(
76
+ [
77
+ "[Desktop Entry]",
78
+ "Type=Application",
79
+ "Version=1.0",
80
+ "Name=Local Control",
81
+ "Comment=Start the Local Control server on login",
82
+ f"Exec={exec_cmd}",
83
+ "X-GNOME-Autostart-enabled=true",
84
+ ]
85
+ )
86
+ path.write_text(desktop_entry, encoding="utf-8")
87
+
88
+ def _disable_linux(self) -> None:
89
+ path = self._autostart_path()
90
+ if path.exists():
91
+ path.unlink()
92
+
93
+ # macOS ------------------------------------------------------------- #
94
+ def _launch_agent_path(self) -> Path:
95
+ return Path.home() / "Library" / "LaunchAgents" / "com.local_control.server.plist"
96
+
97
+ def _enable_darwin(self) -> None:
98
+ agent_path = self._launch_agent_path()
99
+ agent_path.parent.mkdir(parents=True, exist_ok=True)
100
+ plist = {
101
+ "Label": "com.local_control.server",
102
+ "ProgramArguments": self.command,
103
+ "RunAtLoad": True,
104
+ "KeepAlive": False,
105
+ "WorkingDirectory": str(Path.home()),
106
+ "StandardOutPath": str(agent_path.with_suffix(".log")),
107
+ "StandardErrorPath": str(agent_path.with_suffix(".err.log")),
108
+ }
109
+ agent_path.write_bytes(plistlib.dumps(plist))
110
+ subprocess.run(["launchctl", "load", str(agent_path)], check=False)
111
+
112
+ def _disable_darwin(self) -> None:
113
+ agent_path = self._launch_agent_path()
114
+ subprocess.run(["launchctl", "unload", str(agent_path)], check=False)
115
+ if agent_path.exists():
116
+ agent_path.unlink()
117
+
118
+ # Windows ----------------------------------------------------------- #
119
+ def _startup_script_path(self) -> Path:
120
+ appdata = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
121
+ startup_dir = appdata / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
122
+ startup_dir.mkdir(parents=True, exist_ok=True)
123
+ return startup_dir / "local_control_startup.bat"
124
+
125
+ def _enable_windows(self) -> None:
126
+ path = self._startup_script_path()
127
+ cmd = " ".join(f'"{part}"' if " " in part else part for part in self.command)
128
+ script = "\r\n".join(
129
+ [
130
+ "@echo off",
131
+ "REM Auto-generated by local_control StartupManager",
132
+ f'start "" {cmd}',
133
+ ]
134
+ )
135
+ path.write_text(script, encoding="utf-8")
136
+
137
+ def _disable_windows(self) -> None:
138
+ path = self._startup_script_path()
139
+ if path.exists():
140
+ path.unlink()
@@ -0,0 +1,393 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
4
+ font-size: 16px;
5
+ line-height: 1.4;
6
+ }
7
+
8
+ body {
9
+ margin: 0;
10
+ display: flex;
11
+ min-height: 100vh;
12
+ background: #111;
13
+ color: #f5f5f5;
14
+ }
15
+
16
+ main {
17
+ margin: auto;
18
+ width: clamp(320px, 90vw, 1400px);
19
+ padding: 2rem;
20
+ background: rgba(20, 20, 20, 0.85);
21
+ border-radius: 18px;
22
+ box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35);
23
+ }
24
+
25
+ h1,
26
+ h2 {
27
+ margin: 0 0 1rem;
28
+ font-weight: 700;
29
+ }
30
+
31
+ form,
32
+ .controls,
33
+ .system-actions {
34
+ display: flex;
35
+ flex-direction: column;
36
+ gap: 0.75rem;
37
+ }
38
+
39
+ label {
40
+ display: flex;
41
+ flex-direction: column;
42
+ gap: 0.25rem;
43
+ font-size: 0.95rem;
44
+ }
45
+
46
+ textarea {
47
+ box-sizing: border-box;
48
+ }
49
+
50
+ input[type="text"],
51
+ input[type="password"],
52
+ textarea {
53
+ padding: 0.65rem;
54
+ border-radius: 0.6rem;
55
+ border: 1px solid rgba(255, 255, 255, 0.15);
56
+ background: rgba(255, 255, 255, 0.08);
57
+ color: inherit;
58
+ font: inherit;
59
+ }
60
+
61
+ button {
62
+ padding: 0.7rem 1.1rem;
63
+ border-radius: 0.7rem;
64
+ border: none;
65
+ font-size: 1rem;
66
+ font-weight: 600;
67
+ cursor: pointer;
68
+ color: #0f141d;
69
+ background: #4ad66d;
70
+ transition: transform 0.1s, box-shadow 0.1s;
71
+ }
72
+
73
+ button:hover,
74
+ button:focus-visible {
75
+ transform: translateY(-1px);
76
+ box-shadow: 0 4px 16px rgba(74, 214, 109, 0.4);
77
+ }
78
+
79
+ button.secondary {
80
+ background: rgba(255, 255, 255, 0.2);
81
+ color: #f5f5f5;
82
+ }
83
+
84
+ button.warn {
85
+ background: #f6c945;
86
+ }
87
+
88
+ button.danger {
89
+ background: #f66a45;
90
+ }
91
+
92
+ .error {
93
+ color: #f87171;
94
+ min-height: 1.25rem;
95
+ }
96
+
97
+ .remember {
98
+ flex-direction: row;
99
+ align-items: center;
100
+ gap: 0.5rem;
101
+ }
102
+
103
+ #control-view header {
104
+ display: flex;
105
+ justify-content: space-between;
106
+ align-items: center;
107
+ margin-bottom: 1rem;
108
+ }
109
+
110
+ .header-actions {
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 0.75rem;
114
+ }
115
+
116
+ .icon-button {
117
+ background: rgba(255, 255, 255, 0.18);
118
+ color: inherit;
119
+ border-radius: 50%;
120
+ width: 2.6rem;
121
+ height: 2.6rem;
122
+ display: inline-flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ font-size: 1.2rem;
126
+ padding: 0;
127
+ }
128
+
129
+ .control-layout {
130
+ display: flex;
131
+ flex-direction: column;
132
+ gap: 1.5rem;
133
+ }
134
+
135
+ .control-panel {
136
+ display: flex;
137
+ flex-direction: column;
138
+ gap: 1.5rem;
139
+ }
140
+
141
+ .trackpad-wrapper {
142
+ width: 100%;
143
+ display: flex;
144
+ flex-direction: column;
145
+ gap: 0.9rem;
146
+ }
147
+
148
+ #trackpad {
149
+ position: relative;
150
+ border-radius: 16px;
151
+ margin: 0;
152
+ width: 100%;
153
+ aspect-ratio: 4 / 3;
154
+ min-height: 280px;
155
+ max-height: 85vh;
156
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02));
157
+ display: flex;
158
+ justify-content: center;
159
+ align-items: center;
160
+ text-align: center;
161
+ color: rgba(255, 255, 255, 0.6);
162
+ user-select: none;
163
+ -webkit-user-select: none;
164
+ -moz-user-select: none;
165
+ -ms-user-select: none;
166
+ touch-action: none;
167
+ overflow: hidden;
168
+ }
169
+
170
+ #trackpad .trackpad-hint {
171
+ transition: opacity 0.25s ease;
172
+ pointer-events: none;
173
+ }
174
+
175
+ #trackpad.pointer-sync .trackpad-hint {
176
+ opacity: 0;
177
+ }
178
+
179
+ #pointer-sync-indicator {
180
+ position: absolute;
181
+ inset: 0;
182
+ display: flex;
183
+ flex-direction: column;
184
+ justify-content: center;
185
+ align-items: center;
186
+ gap: 1.2rem;
187
+ text-align: center;
188
+ color: rgba(245, 245, 245, 0.85);
189
+ padding: 1.5rem;
190
+ opacity: 0;
191
+ transform: scale(0.95);
192
+ transition: opacity 0.25s ease, transform 0.25s ease;
193
+ pointer-events: none;
194
+ }
195
+
196
+ #trackpad.pointer-sync #pointer-sync-indicator {
197
+ opacity: 1;
198
+ transform: scale(1);
199
+ }
200
+
201
+ #pointer-sync-indicator p {
202
+ margin: 0;
203
+ font-size: 0.95rem;
204
+ max-width: 22rem;
205
+ color: rgba(245, 245, 245, 0.75);
206
+ }
207
+
208
+ #help-overlay {
209
+ position: fixed;
210
+ inset: 0;
211
+ background: rgba(0, 0, 0, 0.6);
212
+ display: flex;
213
+ justify-content: center;
214
+ align-items: center;
215
+ padding: 1.5rem;
216
+ z-index: 50;
217
+ }
218
+
219
+ #help-overlay[hidden] {
220
+ display: none;
221
+ }
222
+
223
+ .help-panel {
224
+ position: relative;
225
+ max-width: 520px;
226
+ width: min(90vw, 520px);
227
+ background: rgba(18, 18, 18, 0.92);
228
+ border-radius: 16px;
229
+ padding: 1.75rem;
230
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
231
+ border: 1px solid rgba(255, 255, 255, 0.1);
232
+ }
233
+
234
+ .help-panel h3 {
235
+ margin: 0 0 1rem;
236
+ font-size: 1.35rem;
237
+ }
238
+
239
+ .help-panel ul {
240
+ margin: 0;
241
+ padding-left: 1.2rem;
242
+ display: flex;
243
+ flex-direction: column;
244
+ gap: 0.75rem;
245
+ font-size: 0.98rem;
246
+ }
247
+
248
+ #help-close {
249
+ position: absolute;
250
+ top: 0.75rem;
251
+ right: 0.75rem;
252
+ font-size: 1rem;
253
+ }
254
+
255
+ .controls {
256
+ display: grid;
257
+ grid-template-columns: repeat(4, minmax(0, 1fr));
258
+ gap: 0.5rem;
259
+ }
260
+
261
+ .controls button {
262
+ width: 100%;
263
+ }
264
+
265
+ .control-panel > .controls {
266
+ margin-top: 0;
267
+ }
268
+
269
+ .realtime,
270
+ .keyboard {
271
+ display: flex;
272
+ flex-direction: column;
273
+ gap: 0.75rem;
274
+ }
275
+
276
+ .realtime input {
277
+ width: 100%;
278
+ }
279
+
280
+ .type-input-wrapper {
281
+ display: flex;
282
+ align-items: stretch;
283
+ gap: 0.5rem;
284
+ }
285
+
286
+ .type-input-wrapper textarea {
287
+ width: 100%;
288
+ font-size: 1.05rem;
289
+ resize: vertical;
290
+ }
291
+
292
+ .type-input-wrapper button {
293
+ align-self: stretch;
294
+ min-width: 4.5rem;
295
+ }
296
+
297
+ .clipboard {
298
+ display: flex;
299
+ flex-direction: column;
300
+ gap: 0.75rem;
301
+ padding: 1rem;
302
+ background: rgba(255, 255, 255, 0.05);
303
+ border-radius: 12px;
304
+ border: 1px solid rgba(255, 255, 255, 0.08);
305
+ }
306
+
307
+ .clipboard-header {
308
+ display: flex;
309
+ justify-content: space-between;
310
+ align-items: center;
311
+ gap: 1rem;
312
+ }
313
+
314
+ .clipboard-actions {
315
+ display: flex;
316
+ flex-wrap: wrap;
317
+ gap: 0.5rem;
318
+ }
319
+
320
+ .clipboard-preview {
321
+ display: flex;
322
+ flex-direction: column;
323
+ gap: 0.75rem;
324
+ }
325
+
326
+ #clipboard-text {
327
+ width: 100%;
328
+ max-width: 100%;
329
+ resize: vertical;
330
+ min-height: 3.5rem;
331
+ background: rgba(0, 0, 0, 0.35);
332
+ border: 1px solid rgba(255, 255, 255, 0.12);
333
+ color: inherit;
334
+ padding: 0.6rem;
335
+ border-radius: 0.6rem;
336
+ }
337
+
338
+ #clipboard-image {
339
+ max-width: 100%;
340
+ border-radius: 0.75rem;
341
+ border: 1px solid rgba(255, 255, 255, 0.12);
342
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
343
+ }
344
+
345
+ .hint {
346
+ font-size: 0.85rem;
347
+ color: rgba(245, 245, 245, 0.65);
348
+ margin: 0;
349
+ }
350
+
351
+ .hint[data-tone="warn"] {
352
+ color: #f6c945;
353
+ }
354
+
355
+ .hint[data-tone="error"] {
356
+ color: #f87171;
357
+ }
358
+
359
+ .system-actions {
360
+ margin-top: 1.5rem;
361
+ gap: 0.5rem;
362
+ }
363
+
364
+ @media (min-width: 640px) {
365
+ .controls {
366
+ justify-content: center;
367
+ }
368
+
369
+ .system-actions {
370
+ flex-direction: row;
371
+ }
372
+
373
+ .system-actions button {
374
+ flex: 1;
375
+ }
376
+ }
377
+
378
+ @media (min-width: 900px) {
379
+ .control-layout {
380
+ display: grid;
381
+ grid-template-columns: minmax(520px, 2.2fr) minmax(320px, 1fr);
382
+ gap: 2rem;
383
+ align-items: start;
384
+ }
385
+
386
+ .trackpad-wrapper {
387
+ margin-right: auto;
388
+ }
389
+
390
+ #trackpad {
391
+ max-height: 90vh;
392
+ }
393
+ }
@@ -0,0 +1,140 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Local Control</title>
7
+ <link
8
+ rel="icon"
9
+ href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 120 120'%3E%3Ctext y='1em' font-size='96'%3E%F0%9F%96%B1%EF%B8%8F%3C/text%3E%3C/svg%3E"
10
+ />
11
+ <link rel="stylesheet" href="/static/css/styles.css" />
12
+ </head>
13
+ <body>
14
+ <main>
15
+ <section id="login-view">
16
+ <h1>Local Control</h1>
17
+ <form id="login-form">
18
+ <label>
19
+ Username
20
+ <input type="text" id="username" autocomplete="username" required />
21
+ </label>
22
+ <label>
23
+ Password
24
+ <input type="password" id="password" autocomplete="current-password" required />
25
+ </label>
26
+ <label class="remember">
27
+ <input type="checkbox" id="remember" />
28
+ Remember this device
29
+ </label>
30
+ <button type="submit">Sign In</button>
31
+ <p id="login-error" class="error" aria-live="assertive"></p>
32
+ </form>
33
+ </section>
34
+
35
+ <section id="control-view" hidden>
36
+ <header>
37
+ <div>
38
+ <h2>Remote Pad</h2>
39
+ <p class="status">
40
+ Signed in as <span id="status-user"></span>
41
+ </p>
42
+ </div>
43
+ <div class="header-actions">
44
+ <button id="help-button" class="icon-button" aria-label="Show help">❔</button>
45
+ <button id="logout-button" class="secondary">Log out</button>
46
+ </div>
47
+ </header>
48
+
49
+ <div class="control-layout">
50
+ <div class="trackpad-wrapper">
51
+ <div id="trackpad" role="application" aria-label="Trackpad surface">
52
+ <span class="trackpad-hint">Drag to move, tap for click.</span>
53
+ <div id="pointer-sync-indicator" aria-hidden="true">
54
+ <p>
55
+ Pointer synced. Push against any screen edge or press Esc to escape the lock.
56
+ </p>
57
+ </div>
58
+ </div>
59
+ <div class="controls">
60
+ <button data-click="left">Left Click</button>
61
+ <button data-click="right">Right Click</button>
62
+ <button data-click="middle">Middle Click</button>
63
+ <button data-click="double">Double Click</button>
64
+ </div>
65
+ </div>
66
+
67
+ <div class="control-panel">
68
+ <div class="realtime">
69
+ <label for="realtime-input">Realtime input</label>
70
+ <input
71
+ id="realtime-input"
72
+ type="text"
73
+ autocomplete="off"
74
+ autocapitalize="none"
75
+ spellcheck="false"
76
+ placeholder="Focus here to stream keystrokes instantly"
77
+ />
78
+ <small>Keys send as you type; Backspace/Delete and Enter are forwarded automatically.</small>
79
+ </div>
80
+
81
+ <form id="type-form" class="keyboard">
82
+ <label for="type-input">Type or paste text</label>
83
+ <div class="type-input-wrapper">
84
+ <textarea
85
+ id="type-input"
86
+ rows="3"
87
+ autocomplete="off"
88
+ placeholder="Paste text, then press Send or ⌘/Ctrl + Enter"
89
+ ></textarea>
90
+ <button type="submit" id="type-send">Send</button>
91
+ </div>
92
+ <small>Special keys (Enter, Backspace, arrows, etc.) are forwarded while this area is focused.</small>
93
+ </form>
94
+
95
+ <div class="clipboard">
96
+ <div class="clipboard-header">
97
+ <h3>Clipboard Bridge</h3>
98
+ <div class="clipboard-actions">
99
+ <button id="clipboard-pull" class="secondary">Pull from host</button>
100
+ <button id="clipboard-push">Push to host</button>
101
+ </div>
102
+ </div>
103
+ <div class="clipboard-preview">
104
+ <textarea
105
+ id="clipboard-text"
106
+ rows="3"
107
+ readonly
108
+ placeholder="Clipboard text preview"
109
+ ></textarea>
110
+ <img id="clipboard-image" alt="Clipboard image preview" hidden />
111
+ </div>
112
+ <p id="clipboard-status" class="hint"></p>
113
+ </div>
114
+
115
+ <div class="system-actions">
116
+ <button id="lock-button" class="warn">Lock Screen</button>
117
+ <button id="shutdown-button" class="danger">Shutdown</button>
118
+ <button id="unlock-button" class="secondary">Wake / Unlock</button>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </section>
123
+ <div id="help-overlay" hidden>
124
+ <div class="help-panel" role="dialog" aria-modal="true" aria-labelledby="help-title">
125
+ <button id="help-close" class="icon-button" aria-label="Close help">✕</button>
126
+ <h3 id="help-title">Quick Help</h3>
127
+ <ul>
128
+ <li>Single finger swipe moves the remote cursor. Tap once to left click.</li>
129
+ <li>Two fingers drag to scroll. Tap with two fingers for right click.</li>
130
+ <li>Three-finger tap triggers a middle click. Push to screen edges to release pointer lock.</li>
131
+ <li>Realtime input streams keystrokes instantly; the text pad sends blocks of text.</li>
132
+ <li>Use the power buttons for lock, shutdown, or wake actions (wake is best-effort on macOS).</li>
133
+ </ul>
134
+ </div>
135
+ </div>
136
+ </main>
137
+
138
+ <script src="/static/js/app.js" defer></script>
139
+ </body>
140
+ </html>