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.
- local_control/__init__.py +11 -0
- local_control/app.py +291 -0
- local_control/auth.py +240 -0
- local_control/cli.py +143 -0
- local_control/clipboard.py +342 -0
- local_control/config.py +47 -0
- local_control/control.py +1043 -0
- local_control/startup.py +140 -0
- local_control/static/css/styles.css +393 -0
- local_control/static/index.html +140 -0
- local_control/static/js/app.js +1658 -0
- local_control/utils/__init__.py +9 -0
- local_control/utils/qrcodegen.py +907 -0
- local_control/utils/terminal_qr.py +34 -0
- local_control-0.1.2.dist-info/METADATA +49 -0
- local_control-0.1.2.dist-info/RECORD +20 -0
- local_control-0.1.2.dist-info/WHEEL +5 -0
- local_control-0.1.2.dist-info/entry_points.txt +2 -0
- local_control-0.1.2.dist-info/licenses/LICENSE +21 -0
- local_control-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Clipboard synchronization helpers for text and image payloads.
|
|
3
|
+
|
|
4
|
+
This module attempts to use native OS facilities first and falls back to an
|
|
5
|
+
in-memory clipboard replica when platform commands are unavailable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import platform
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
import textwrap
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterable, List, Optional
|
|
18
|
+
|
|
19
|
+
OS_NAME = platform.system()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ClipboardData:
|
|
24
|
+
kind: str # "text" or "image"
|
|
25
|
+
data: str # text content or base64 payload
|
|
26
|
+
mime: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_FALLBACK_CLIPBOARD: Optional[ClipboardData] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_clipboard() -> Optional[ClipboardData]:
|
|
33
|
+
"""
|
|
34
|
+
Return the clipboard contents as ClipboardData.
|
|
35
|
+
Attempts to retrieve images first (PNG), then text. Falls back to the
|
|
36
|
+
in-memory replica if OS access fails.
|
|
37
|
+
"""
|
|
38
|
+
global _FALLBACK_CLIPBOARD
|
|
39
|
+
for getter in (_get_image_clipboard, _get_text_clipboard):
|
|
40
|
+
try:
|
|
41
|
+
data = getter()
|
|
42
|
+
except Exception:
|
|
43
|
+
data = None
|
|
44
|
+
if data:
|
|
45
|
+
_FALLBACK_CLIPBOARD = data
|
|
46
|
+
return data
|
|
47
|
+
return _FALLBACK_CLIPBOARD
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def set_clipboard(data: ClipboardData) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Update the host clipboard with the provided payload.
|
|
53
|
+
"""
|
|
54
|
+
global _FALLBACK_CLIPBOARD
|
|
55
|
+
handlers = {
|
|
56
|
+
"text": _set_text_clipboard,
|
|
57
|
+
"image": _set_image_clipboard,
|
|
58
|
+
}
|
|
59
|
+
handler = handlers.get(data.kind)
|
|
60
|
+
if not handler:
|
|
61
|
+
raise ValueError(f"Unsupported clipboard type: {data.kind}")
|
|
62
|
+
try:
|
|
63
|
+
handler(data)
|
|
64
|
+
_FALLBACK_CLIPBOARD = data
|
|
65
|
+
except Exception:
|
|
66
|
+
# Fallback to in-memory replica when OS update fails.
|
|
67
|
+
_FALLBACK_CLIPBOARD = data
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --------------------------------------------------------------------------- #
|
|
71
|
+
# Text clipboard helpers
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_text_clipboard() -> Optional[ClipboardData]:
|
|
75
|
+
text = None
|
|
76
|
+
if OS_NAME == "Darwin":
|
|
77
|
+
text = _run_command_capture(["pbpaste"])
|
|
78
|
+
if text is not None:
|
|
79
|
+
text = text.decode("utf-8", errors="replace")
|
|
80
|
+
elif OS_NAME == "Windows":
|
|
81
|
+
script = (
|
|
82
|
+
"Add-Type -AssemblyName System.Windows.Forms;"
|
|
83
|
+
'[System.Windows.Forms.Clipboard]::GetText()'
|
|
84
|
+
)
|
|
85
|
+
text = _run_command_capture(
|
|
86
|
+
["powershell", "-NoProfile", "-Command", script], text_mode=True
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
text = _first_success(
|
|
90
|
+
[
|
|
91
|
+
["wl-paste", "--no-newline"],
|
|
92
|
+
["xclip", "-selection", "clipboard", "-out"],
|
|
93
|
+
["xsel", "--clipboard", "--output"],
|
|
94
|
+
],
|
|
95
|
+
decode=True,
|
|
96
|
+
)
|
|
97
|
+
if text:
|
|
98
|
+
return ClipboardData(kind="text", data=text)
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _set_text_clipboard(data: ClipboardData) -> None:
|
|
103
|
+
payload = data.data
|
|
104
|
+
if OS_NAME == "Darwin":
|
|
105
|
+
_run_command_capture(
|
|
106
|
+
["pbcopy"],
|
|
107
|
+
input_data=payload.encode("utf-8"),
|
|
108
|
+
)
|
|
109
|
+
elif OS_NAME == "Windows":
|
|
110
|
+
script = textwrap.dedent(
|
|
111
|
+
"""
|
|
112
|
+
Add-Type -AssemblyName System.Windows.Forms;
|
|
113
|
+
$inputText = [Console]::In.ReadToEnd();
|
|
114
|
+
[System.Windows.Forms.Clipboard]::SetText($inputText);
|
|
115
|
+
"""
|
|
116
|
+
)
|
|
117
|
+
_run_command_capture(
|
|
118
|
+
["powershell", "-NoProfile", "-Command", script],
|
|
119
|
+
input_data=payload,
|
|
120
|
+
text_mode=True,
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
commands = [
|
|
124
|
+
["wl-copy"],
|
|
125
|
+
["xclip", "-selection", "clipboard"],
|
|
126
|
+
["xsel", "--clipboard", "--input"],
|
|
127
|
+
]
|
|
128
|
+
for cmd in commands:
|
|
129
|
+
result = _run_command_capture(cmd, input_data=payload.encode("utf-8"))
|
|
130
|
+
if result is not None:
|
|
131
|
+
return
|
|
132
|
+
raise RuntimeError("Failed to set clipboard text via wl-copy/xclip/xsel.")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --------------------------------------------------------------------------- #
|
|
136
|
+
# Image clipboard helpers (PNG base64)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _get_image_clipboard() -> Optional[ClipboardData]:
|
|
140
|
+
if OS_NAME == "Darwin":
|
|
141
|
+
return _mac_get_image()
|
|
142
|
+
if OS_NAME == "Windows":
|
|
143
|
+
return _windows_get_image()
|
|
144
|
+
if OS_NAME == "Linux":
|
|
145
|
+
return _linux_get_image()
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _set_image_clipboard(data: ClipboardData) -> None:
|
|
150
|
+
if OS_NAME == "Darwin":
|
|
151
|
+
_mac_set_image(data)
|
|
152
|
+
elif OS_NAME == "Windows":
|
|
153
|
+
_windows_set_image(data)
|
|
154
|
+
elif OS_NAME == "Linux":
|
|
155
|
+
_linux_set_image(data)
|
|
156
|
+
else:
|
|
157
|
+
raise RuntimeError("Image clipboard unsupported on this platform.")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _mac_get_image() -> Optional[ClipboardData]:
|
|
161
|
+
script = textwrap.dedent(
|
|
162
|
+
"""
|
|
163
|
+
on run argv
|
|
164
|
+
set outPath to POSIX file (item 1 of argv)
|
|
165
|
+
try
|
|
166
|
+
set theData to the clipboard as «class PNGf»
|
|
167
|
+
on error
|
|
168
|
+
return ""
|
|
169
|
+
end try
|
|
170
|
+
set fileRef to open for access outPath with write permission
|
|
171
|
+
set eof of fileRef to 0
|
|
172
|
+
write theData to fileRef
|
|
173
|
+
close access fileRef
|
|
174
|
+
return POSIX path of outPath
|
|
175
|
+
end run
|
|
176
|
+
"""
|
|
177
|
+
)
|
|
178
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
179
|
+
script_path = Path(tmp) / "extract_clipboard.scpt"
|
|
180
|
+
script_path.write_text(script, encoding="utf-8")
|
|
181
|
+
output_path = Path(tmp) / "clipboard.png"
|
|
182
|
+
result = _run_command_capture(
|
|
183
|
+
["osascript", str(script_path), str(output_path)],
|
|
184
|
+
text_mode=True,
|
|
185
|
+
)
|
|
186
|
+
if not result:
|
|
187
|
+
return None
|
|
188
|
+
if not output_path.exists():
|
|
189
|
+
return None
|
|
190
|
+
data = output_path.read_bytes()
|
|
191
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
192
|
+
return ClipboardData(kind="image", data=encoded, mime="image/png")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _mac_set_image(data: ClipboardData) -> None:
|
|
196
|
+
if data.mime and data.mime != "image/png":
|
|
197
|
+
raise ValueError("macOS clipboard only accepts PNG payloads in this implementation.")
|
|
198
|
+
raw = base64.b64decode(data.data)
|
|
199
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
|
200
|
+
tmp.write(raw)
|
|
201
|
+
tmp_path = Path(tmp.name)
|
|
202
|
+
script = textwrap.dedent(
|
|
203
|
+
"""
|
|
204
|
+
on run argv
|
|
205
|
+
set inPath to POSIX file (item 1 of argv)
|
|
206
|
+
set the clipboard to (read inPath as «class PNGf»)
|
|
207
|
+
end run
|
|
208
|
+
"""
|
|
209
|
+
)
|
|
210
|
+
script_path = tmp_path.with_suffix(".scpt")
|
|
211
|
+
try:
|
|
212
|
+
script_path.write_text(script, encoding="utf-8")
|
|
213
|
+
_run_command_capture(["osascript", str(script_path), str(tmp_path)], text_mode=True)
|
|
214
|
+
finally:
|
|
215
|
+
if tmp_path.exists():
|
|
216
|
+
tmp_path.unlink()
|
|
217
|
+
if script_path.exists():
|
|
218
|
+
script_path.unlink()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _windows_get_image() -> Optional[ClipboardData]:
|
|
222
|
+
script = textwrap.dedent(
|
|
223
|
+
"""
|
|
224
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
225
|
+
Add-Type -AssemblyName System.Drawing
|
|
226
|
+
$image = [Windows.Forms.Clipboard]::GetImage()
|
|
227
|
+
if ($image -eq $null) { return "" }
|
|
228
|
+
$path = [System.IO.Path]::GetTempFileName() + ".png"
|
|
229
|
+
$image.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
230
|
+
Write-Output $path
|
|
231
|
+
"""
|
|
232
|
+
)
|
|
233
|
+
result = _run_command_capture(
|
|
234
|
+
["powershell", "-NoProfile", "-Command", script],
|
|
235
|
+
text_mode=True,
|
|
236
|
+
)
|
|
237
|
+
if not result:
|
|
238
|
+
return None
|
|
239
|
+
path = Path(result.strip())
|
|
240
|
+
if not path.exists():
|
|
241
|
+
return None
|
|
242
|
+
data = path.read_bytes()
|
|
243
|
+
try:
|
|
244
|
+
path.unlink()
|
|
245
|
+
except OSError:
|
|
246
|
+
pass
|
|
247
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
248
|
+
return ClipboardData(kind="image", data=encoded, mime="image/png")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _windows_set_image(data: ClipboardData) -> None:
|
|
252
|
+
if data.mime and data.mime != "image/png":
|
|
253
|
+
raise ValueError("Windows clipboard helper expects PNG payloads.")
|
|
254
|
+
raw = base64.b64decode(data.data)
|
|
255
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
|
256
|
+
tmp.write(raw)
|
|
257
|
+
tmp_path = Path(tmp.name)
|
|
258
|
+
script = textwrap.dedent(
|
|
259
|
+
"""
|
|
260
|
+
Param([string]$imagePath)
|
|
261
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
262
|
+
Add-Type -AssemblyName System.Drawing
|
|
263
|
+
$img = [System.Drawing.Image]::FromFile($imagePath)
|
|
264
|
+
[Windows.Forms.Clipboard]::SetImage($img)
|
|
265
|
+
$img.Dispose()
|
|
266
|
+
"""
|
|
267
|
+
)
|
|
268
|
+
try:
|
|
269
|
+
_run_command_capture(
|
|
270
|
+
["powershell", "-NoProfile", "-Command", script, str(tmp_path)],
|
|
271
|
+
text_mode=True,
|
|
272
|
+
)
|
|
273
|
+
finally:
|
|
274
|
+
if tmp_path.exists():
|
|
275
|
+
tmp_path.unlink()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _linux_get_image() -> Optional[ClipboardData]:
|
|
279
|
+
commands = [
|
|
280
|
+
["wl-paste", "--type", "image/png"],
|
|
281
|
+
["xclip", "-selection", "clipboard", "-t", "image/png", "-out"],
|
|
282
|
+
]
|
|
283
|
+
for cmd in commands:
|
|
284
|
+
blob = _run_command_capture(cmd)
|
|
285
|
+
if blob:
|
|
286
|
+
encoded = base64.b64encode(blob).decode("ascii")
|
|
287
|
+
return ClipboardData(kind="image", data=encoded, mime="image/png")
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _linux_set_image(data: ClipboardData) -> None:
|
|
292
|
+
payload = base64.b64decode(data.data)
|
|
293
|
+
commands = [
|
|
294
|
+
["wl-copy", "--type", "image/png"],
|
|
295
|
+
["xclip", "-selection", "clipboard", "-t", "image/png", "-in"],
|
|
296
|
+
]
|
|
297
|
+
for cmd in commands:
|
|
298
|
+
result = _run_command_capture(cmd, input_data=payload)
|
|
299
|
+
if result is not None:
|
|
300
|
+
return
|
|
301
|
+
raise RuntimeError("No clipboard image utility (wl-copy/xclip) available.")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# --------------------------------------------------------------------------- #
|
|
305
|
+
# Utility helpers
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _run_command_capture(
|
|
309
|
+
command: Iterable[str],
|
|
310
|
+
input_data: Optional[bytes | str] = None,
|
|
311
|
+
text_mode: bool = False,
|
|
312
|
+
timeout: int = 5,
|
|
313
|
+
) -> Optional[str | bytes]:
|
|
314
|
+
if text_mode and isinstance(input_data, bytes):
|
|
315
|
+
input_value: Optional[bytes | str] = input_data.decode("utf-8", errors="ignore")
|
|
316
|
+
else:
|
|
317
|
+
input_value = input_data
|
|
318
|
+
try:
|
|
319
|
+
result = subprocess.run(
|
|
320
|
+
list(command),
|
|
321
|
+
input=input_value,
|
|
322
|
+
stdout=subprocess.PIPE,
|
|
323
|
+
stderr=subprocess.DEVNULL,
|
|
324
|
+
timeout=timeout,
|
|
325
|
+
check=False,
|
|
326
|
+
text=text_mode,
|
|
327
|
+
)
|
|
328
|
+
except (FileNotFoundError, subprocess.SubprocessError, TimeoutError):
|
|
329
|
+
return None
|
|
330
|
+
if result.returncode != 0:
|
|
331
|
+
return None
|
|
332
|
+
return result.stdout
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _first_success(commands: Iterable[List[str]], decode: bool = False) -> Optional[str]:
|
|
336
|
+
for cmd in commands:
|
|
337
|
+
output = _run_command_capture(cmd)
|
|
338
|
+
if output:
|
|
339
|
+
if decode:
|
|
340
|
+
return output.decode("utf-8", errors="replace")
|
|
341
|
+
return output
|
|
342
|
+
return None
|
local_control/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration helpers for locating persistent storage and secrets.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
APP_NAME = "local_control"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def data_dir() -> Path:
|
|
17
|
+
"""
|
|
18
|
+
Return the directory used to persist trusted device metadata.
|
|
19
|
+
The directory is created lazily when first accessed.
|
|
20
|
+
"""
|
|
21
|
+
system = platform.system()
|
|
22
|
+
if system == "Windows":
|
|
23
|
+
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
24
|
+
elif system == "Darwin":
|
|
25
|
+
base = Path.home() / "Library" / "Application Support"
|
|
26
|
+
else:
|
|
27
|
+
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
28
|
+
|
|
29
|
+
target = base / APP_NAME
|
|
30
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
return target
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_json(path: Path, default: Any) -> Any:
|
|
35
|
+
if not path.exists():
|
|
36
|
+
return default
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
39
|
+
except json.JSONDecodeError:
|
|
40
|
+
return default
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_json(path: Path, payload: Any) -> None:
|
|
44
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
tmp_path = path.with_suffix(".tmp")
|
|
46
|
+
tmp_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
47
|
+
tmp_path.replace(path)
|