local-control 0.1.2__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 DIYer22
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: local_control
3
+ Version: 0.1.2
4
+ Summary: LAN-accessible remote control server for mouse, keyboard, and power management
5
+ Author: DIYer22
6
+ License: MIT License
7
+ Project-URL: Homepage, https://github.com/DIYer22/local_control
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: flask>=2.3
12
+ Dynamic: license-file
13
+
14
+ # 🖱️ Local Control
15
+
16
+ Let you steer the computer's mouse, keyboard from any device's browser.
17
+
18
+ <a href="https://yl-data.github.io/2511.local_control/images/local-send-screenshot.jpeg">
19
+ <img style="height:384px" src=https://yl-data.github.io/2511.local_control/images/local-send-screenshot.jpeg>
20
+ </a>
21
+
22
+ The server is written in pure Python with minimal dependencies and ships with a mobile-friendly frontend.
23
+
24
+ ## Features
25
+ - Mouse cursor movement and click controls from touch or mouse devices.
26
+ - Realtime input field streams keystrokes (including Backspace/Delete) as you type.
27
+ - OS-level lock, and shutdown shortcuts (best-effort across Windows, macOS, Linux).
28
+ - Authentication that reuses the current OS account credentials, remembers trusted devices, and rate-limits brute-force attempts.
29
+ - Build with GPT-5-Codex, easy to customize and modify with vibe coding.
30
+
31
+ ## Requirements
32
+ - Python 3.9 or newer.
33
+ - Desktop environments capable of receiving simulated input (X11/Wayland, Windows, or macOS).
34
+ - Linux/X11 hosts require the `libX11` and `libXtst` system libraries (commonly present on desktop distributions; Wayland sessions need XWayland support).
35
+ - macOS hosts must grant the Python process accessibility permissions (System Settings → Privacy & Security → Accessibility).
36
+
37
+ ## Installation
38
+ ```bash
39
+ pip install local_control
40
+ ```
41
+
42
+ ## Usage
43
+ ```bash
44
+ local-control --help
45
+ local-control --port 4001
46
+ ```
47
+
48
+ Open `http://<host-ip>:4001` from your phone, tablet, or another computer on the same LAN. Sign in with the current desktop user's username and password. Devices marked as trusted skip future logins under the same secret.
49
+
@@ -0,0 +1,36 @@
1
+ # 🖱️ Local Control
2
+
3
+ Let you steer the computer's mouse, keyboard from any device's browser.
4
+
5
+ <a href="https://yl-data.github.io/2511.local_control/images/local-send-screenshot.jpeg">
6
+ <img style="height:384px" src=https://yl-data.github.io/2511.local_control/images/local-send-screenshot.jpeg>
7
+ </a>
8
+
9
+ The server is written in pure Python with minimal dependencies and ships with a mobile-friendly frontend.
10
+
11
+ ## Features
12
+ - Mouse cursor movement and click controls from touch or mouse devices.
13
+ - Realtime input field streams keystrokes (including Backspace/Delete) as you type.
14
+ - OS-level lock, and shutdown shortcuts (best-effort across Windows, macOS, Linux).
15
+ - Authentication that reuses the current OS account credentials, remembers trusted devices, and rate-limits brute-force attempts.
16
+ - Build with GPT-5-Codex, easy to customize and modify with vibe coding.
17
+
18
+ ## Requirements
19
+ - Python 3.9 or newer.
20
+ - Desktop environments capable of receiving simulated input (X11/Wayland, Windows, or macOS).
21
+ - Linux/X11 hosts require the `libX11` and `libXtst` system libraries (commonly present on desktop distributions; Wayland sessions need XWayland support).
22
+ - macOS hosts must grant the Python process accessibility permissions (System Settings → Privacy & Security → Accessibility).
23
+
24
+ ## Installation
25
+ ```bash
26
+ pip install local_control
27
+ ```
28
+
29
+ ## Usage
30
+ ```bash
31
+ local-control --help
32
+ local-control --port 4001
33
+ ```
34
+
35
+ Open `http://<host-ip>:4001` from your phone, tablet, or another computer on the same LAN. Sign in with the current desktop user's username and password. Devices marked as trusted skip future logins under the same secret.
36
+
@@ -0,0 +1,11 @@
1
+ """
2
+ Local Control package exposing the web server and CLI helpers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ __all__ = ["create_app", "__version__"]
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ from .app import create_app # noqa: E402 (lazy import to avoid side effects)
@@ -0,0 +1,291 @@
1
+ """
2
+ Flask application wiring the authentication manager, control handlers,
3
+ and static frontend.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Dict, Optional, Tuple
12
+
13
+ from flask import (
14
+ Flask,
15
+ Response,
16
+ jsonify,
17
+ make_response,
18
+ request,
19
+ send_from_directory,
20
+ )
21
+
22
+ from .auth import AuthManager, CredentialError, RateLimitError
23
+ from . import control, clipboard
24
+ from .clipboard import ClipboardData
25
+
26
+ SESSION_COOKIE = "session_token"
27
+ TRUSTED_COOKIE = "trusted_token"
28
+ SESSION_MAX_AGE = 60 * 60 * 4 # 4 hours
29
+ TRUSTED_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
30
+
31
+ LOG = logging.getLogger(__name__)
32
+
33
+
34
+ def create_app(auth_manager: Optional[AuthManager] = None) -> Flask:
35
+ static_dir = Path(__file__).parent / "static"
36
+ app = Flask(
37
+ __name__,
38
+ static_folder=str(static_dir),
39
+ static_url_path="/static",
40
+ )
41
+ app.config["JSONIFY_PRETTYPRINT_REGULAR"] = False
42
+ auth = auth_manager or AuthManager()
43
+
44
+ def client_key() -> str:
45
+ return request.remote_addr or "unknown"
46
+
47
+ def ensure_session() -> Tuple[Optional[str], Optional[str]]:
48
+ token = request.cookies.get(SESSION_COOKIE)
49
+ user = auth.session_user(token)
50
+ if user:
51
+ return user, None
52
+
53
+ trusted = request.cookies.get(TRUSTED_COOKIE)
54
+ user = auth.auto_login(trusted)
55
+ if user:
56
+ fresh = auth.create_session(user)
57
+ return user, fresh
58
+ return None, None
59
+
60
+ def require_auth() -> Tuple[Optional[str], Optional[str]]:
61
+ user, new_token = ensure_session()
62
+ if not user:
63
+ return None, None
64
+ return user, new_token
65
+
66
+ def build_response(
67
+ payload: Dict[str, Any],
68
+ session_token: Optional[str] = None,
69
+ clear_session: bool = False,
70
+ trusted_token: Optional[str] = None,
71
+ clear_trusted: bool = False,
72
+ status: int = 200,
73
+ ) -> Response:
74
+ response = make_response(jsonify(payload), status)
75
+ if session_token:
76
+ response.set_cookie(
77
+ SESSION_COOKIE,
78
+ session_token,
79
+ httponly=True,
80
+ secure=False,
81
+ samesite="Strict",
82
+ max_age=SESSION_MAX_AGE,
83
+ )
84
+ if clear_session:
85
+ response.delete_cookie(SESSION_COOKIE)
86
+ if trusted_token:
87
+ response.set_cookie(
88
+ TRUSTED_COOKIE,
89
+ trusted_token,
90
+ httponly=True,
91
+ secure=False,
92
+ samesite="Strict",
93
+ max_age=TRUSTED_MAX_AGE,
94
+ )
95
+ if clear_trusted:
96
+ response.delete_cookie(TRUSTED_COOKIE)
97
+ return response
98
+
99
+ # Routes -----------------------------------------------------------------
100
+ @app.get("/")
101
+ def index() -> Response:
102
+ return send_from_directory(app.static_folder, "index.html")
103
+
104
+ @app.get("/api/session")
105
+ def session_info() -> Response:
106
+ user, fresh_token = ensure_session()
107
+ if user:
108
+ return build_response(
109
+ {"authenticated": True, "username": user},
110
+ session_token=fresh_token,
111
+ )
112
+ return build_response({"authenticated": False, "username": None})
113
+
114
+ @app.post("/api/login")
115
+ def login() -> Response:
116
+ data = request.get_json(silent=True) or {}
117
+ username = str(data.get("username", "")).strip()
118
+ password = str(data.get("password", ""))
119
+ remember = bool(data.get("remember", False))
120
+ remote = client_key()
121
+
122
+ try:
123
+ auth.check_rate_limit(remote)
124
+ except RateLimitError as exc:
125
+ return build_response({"error": str(exc)}, status=429)
126
+
127
+ try:
128
+ verified = auth.verify_credentials(username, password)
129
+ except CredentialError as exc:
130
+ LOG.warning("Credential verification error: %s", exc)
131
+ return build_response({"error": str(exc)}, status=400)
132
+
133
+ if not verified:
134
+ auth.register_failure(remote)
135
+ return build_response({"error": "Invalid username or password."}, status=401)
136
+
137
+ auth.register_success(remote)
138
+ session_token = auth.create_session(username)
139
+ trusted_token = auth.create_trusted_token(username) if remember else None
140
+
141
+ payload = {"authenticated": True, "username": username}
142
+ return build_response(
143
+ payload,
144
+ session_token=session_token,
145
+ trusted_token=trusted_token,
146
+ )
147
+
148
+ @app.post("/api/logout")
149
+ def logout() -> Response:
150
+ token = request.cookies.get(SESSION_COOKIE)
151
+ if token:
152
+ auth.destroy_session(token)
153
+ return build_response({"authenticated": False}, clear_session=True)
154
+
155
+ def auth_endpoint(handler: Callable[[Dict[str, Any]], Dict[str, Any]]) -> Response:
156
+ user, fresh_token = require_auth()
157
+ if not user:
158
+ return build_response({"error": "Authentication required."}, status=401)
159
+ payload = request.get_json(silent=True) or {}
160
+ try:
161
+ response_payload = handler(payload)
162
+ except ValueError as exc:
163
+ return build_response({"error": str(exc)}, status=400)
164
+ except Exception as exc: # pragma: no cover - defensive
165
+ LOG.exception("Handler error: %s", exc)
166
+ return build_response({"error": str(exc)}, status=500)
167
+ return build_response(response_payload, session_token=fresh_token)
168
+
169
+ @app.post("/api/mouse/move")
170
+ def mouse_move() -> Response:
171
+ def action(data: Dict[str, Any]) -> Dict[str, Any]:
172
+ dx = float(data.get("dx", 0.0))
173
+ dy = float(data.get("dy", 0.0))
174
+ state = control.move_cursor(dx, dy)
175
+ return {"status": "ok", "state": state}
176
+
177
+ return auth_endpoint(action)
178
+
179
+ @app.post("/api/mouse/click")
180
+ def mouse_click() -> Response:
181
+ def action(data: Dict[str, Any]) -> Dict[str, Any]:
182
+ button = str(data.get("button", "left"))
183
+ double = bool(data.get("double", False))
184
+ control.click(button=button, double=double)
185
+ return {"status": "ok"}
186
+
187
+ return auth_endpoint(action)
188
+
189
+ @app.post("/api/mouse/button")
190
+ def mouse_button() -> Response:
191
+ def action(data: Dict[str, Any]) -> Dict[str, Any]:
192
+ button = str(data.get("button", "left"))
193
+ button_action = str(data.get("action", "down"))
194
+ control.button_action(button=button, action=button_action)
195
+ return {"status": "ok"}
196
+
197
+ return auth_endpoint(action)
198
+
199
+ @app.post("/api/mouse/scroll")
200
+ def mouse_scroll() -> Response:
201
+ def action(data: Dict[str, Any]) -> Dict[str, Any]:
202
+ vertical = float(data.get("vertical", 0.0))
203
+ horizontal = float(data.get("horizontal", 0.0))
204
+ control.scroll(vertical=vertical, horizontal=horizontal)
205
+ return {"status": "ok"}
206
+
207
+ return auth_endpoint(action)
208
+
209
+ @app.get("/api/mouse/state")
210
+ def mouse_state() -> Response:
211
+ user, fresh_token = require_auth()
212
+ if not user:
213
+ return build_response({"error": "Authentication required."}, status=401)
214
+ state = control.cursor_state()
215
+ return build_response({"status": "ok", "state": state}, session_token=fresh_token)
216
+
217
+ @app.post("/api/keyboard/type")
218
+ def keyboard_type() -> Response:
219
+ def action(data: Dict[str, Any]) -> Dict[str, Any]:
220
+ text = str(data.get("text", ""))
221
+ control.type_text(text)
222
+ return {"status": "ok"}
223
+
224
+ return auth_endpoint(action)
225
+
226
+ @app.post("/api/keyboard/key")
227
+ def keyboard_key() -> Response:
228
+ def action(data: Dict[str, Any]) -> Dict[str, Any]:
229
+ key = str(data.get("key", "")).lower()
230
+ action_name = str(data.get("action", "press")).lower()
231
+ control.key_action(key, action_name)
232
+ return {"status": "ok"}
233
+
234
+ return auth_endpoint(action)
235
+
236
+ @app.post("/api/system/lock")
237
+ def system_lock() -> Response:
238
+ def action(_: Dict[str, Any]) -> Dict[str, Any]:
239
+ control.lock_screen()
240
+ return {"status": "ok"}
241
+
242
+ return auth_endpoint(action)
243
+
244
+ @app.post("/api/system/unlock")
245
+ def system_unlock() -> Response:
246
+ def action(_: Dict[str, Any]) -> Dict[str, Any]:
247
+ control.unlock_screen()
248
+ return {"status": "ok"}
249
+
250
+ return auth_endpoint(action)
251
+
252
+ @app.post("/api/system/shutdown")
253
+ def system_shutdown() -> Response:
254
+ def action(_: Dict[str, Any]) -> Dict[str, Any]:
255
+ control.shutdown_system()
256
+ return {"status": "ok"}
257
+
258
+ return auth_endpoint(action)
259
+
260
+ @app.get("/api/clipboard")
261
+ def clipboard_read() -> Response:
262
+ user, fresh_token = require_auth()
263
+ if not user:
264
+ return build_response({"error": "Authentication required."}, status=401)
265
+ clip = clipboard.get_clipboard()
266
+ if clip:
267
+ content = {"type": clip.kind, "data": clip.data, "mime": clip.mime}
268
+ else:
269
+ content = None
270
+ return build_response(
271
+ {"status": "ok", "content": content},
272
+ session_token=fresh_token,
273
+ )
274
+
275
+ @app.post("/api/clipboard")
276
+ def clipboard_write() -> Response:
277
+ def action(data: Dict[str, Any]) -> Dict[str, Any]:
278
+ clip_type = str(data.get("type", "")).lower()
279
+ if clip_type not in {"text", "image"}:
280
+ raise ValueError("Clipboard type must be 'text' or 'image'.")
281
+ payload = data.get("data")
282
+ if payload is None:
283
+ raise ValueError("Clipboard payload missing.")
284
+ mime = data.get("mime")
285
+ clip = ClipboardData(kind=clip_type, data=str(payload), mime=str(mime) if mime else None)
286
+ clipboard.set_clipboard(clip)
287
+ return {"status": "ok"}
288
+
289
+ return auth_endpoint(action)
290
+
291
+ return app
@@ -0,0 +1,240 @@
1
+ """
2
+ Authentication manager handling OS credential checks, sessions, device trust,
3
+ and brute-force protections.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import getpass
9
+ import hashlib
10
+ import secrets
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Dict, List, Optional
17
+
18
+ from .config import data_dir, load_json, save_json
19
+
20
+
21
+ class RateLimitError(Exception):
22
+ """Raised when a client exceeds the allowed login attempts."""
23
+
24
+
25
+ class CredentialError(Exception):
26
+ """Raised when provided credentials cannot be validated."""
27
+
28
+
29
+ @dataclass
30
+ class Session:
31
+ username: str
32
+ created_at: float
33
+
34
+
35
+ @dataclass
36
+ class TrustedDevice:
37
+ username: str
38
+ token_hash: str
39
+ created_at: float
40
+
41
+
42
+ class AuthManager:
43
+ MAX_ATTEMPTS = 5
44
+ WINDOW_SECONDS = 600
45
+ LOCKOUT_SECONDS = 600
46
+
47
+ def __init__(self) -> None:
48
+ base = data_dir()
49
+ self._secret_path = base / "secret.key"
50
+ self._trusted_path = base / "trusted_devices.json"
51
+ self._secret = self._load_secret()
52
+ self._sessions: Dict[str, Session] = {}
53
+ self._trusted_devices: List[TrustedDevice] = self._load_trusted_devices()
54
+ self._attempts: Dict[str, Dict[str, float]] = {}
55
+ self._current_user = getpass.getuser()
56
+
57
+ @property
58
+ def current_user(self) -> str:
59
+ return self._current_user
60
+
61
+ # Secret helpers ---------------------------------------------------------
62
+ def _load_secret(self) -> bytes:
63
+ if self._secret_path.exists():
64
+ return self._secret_path.read_bytes()
65
+
66
+ secret = secrets.token_bytes(32)
67
+ self._secret_path.write_bytes(secret)
68
+ return secret
69
+
70
+ # Trusted device persistence ---------------------------------------------
71
+ def _load_trusted_devices(self) -> List[TrustedDevice]:
72
+ data = load_json(
73
+ self._trusted_path,
74
+ default={"devices": []},
75
+ )
76
+ devices: List[TrustedDevice] = []
77
+ for entry in data.get("devices", []):
78
+ try:
79
+ devices.append(
80
+ TrustedDevice(
81
+ username=entry["username"],
82
+ token_hash=entry["token_hash"],
83
+ created_at=float(entry.get("created_at", 0.0)),
84
+ )
85
+ )
86
+ except KeyError:
87
+ continue
88
+ return devices
89
+
90
+ def _persist_trusted(self) -> None:
91
+ payload = {
92
+ "devices": [
93
+ {
94
+ "username": device.username,
95
+ "token_hash": device.token_hash,
96
+ "created_at": device.created_at,
97
+ }
98
+ for device in self._trusted_devices
99
+ ]
100
+ }
101
+ save_json(self._trusted_path, payload)
102
+
103
+ # Rate limiting ----------------------------------------------------------
104
+ def check_rate_limit(self, key: str) -> None:
105
+ entry = self._attempts.get(key)
106
+ now = time.time()
107
+ if not entry:
108
+ return
109
+
110
+ locked_until = entry.get("locked_until", 0.0)
111
+ if locked_until and locked_until > now:
112
+ raise RateLimitError("Too many attempts. Retry later.")
113
+
114
+ timestamps = entry.get("timestamps", [])
115
+ # Drop attempts outside of the time window.
116
+ timestamps = [ts for ts in timestamps if now - ts < self.WINDOW_SECONDS]
117
+ entry["timestamps"] = timestamps
118
+ if len(timestamps) >= self.MAX_ATTEMPTS:
119
+ entry["locked_until"] = now + self.LOCKOUT_SECONDS
120
+ raise RateLimitError("Too many attempts. Retry later.")
121
+
122
+ def register_failure(self, key: str) -> None:
123
+ now = time.time()
124
+ entry = self._attempts.setdefault(
125
+ key, {"timestamps": [], "locked_until": 0.0}
126
+ )
127
+ entry.setdefault("timestamps", []).append(now)
128
+ # Enforce the window retention.
129
+ entry["timestamps"] = [
130
+ ts for ts in entry["timestamps"] if now - ts < self.WINDOW_SECONDS
131
+ ]
132
+ if len(entry["timestamps"]) >= self.MAX_ATTEMPTS:
133
+ entry["locked_until"] = now + self.LOCKOUT_SECONDS
134
+
135
+ def register_success(self, key: str) -> None:
136
+ if key in self._attempts:
137
+ del self._attempts[key]
138
+
139
+ # Token helpers ----------------------------------------------------------
140
+ def _hash_token(self, token: str) -> str:
141
+ return hashlib.sha256(self._secret + token.encode("utf-8")).hexdigest()
142
+
143
+ def create_session(self, username: str) -> str:
144
+ token = secrets.token_urlsafe(32)
145
+ token_hash = self._hash_token(token)
146
+ self._sessions[token_hash] = Session(username=username, created_at=time.time())
147
+ return token
148
+
149
+ def destroy_session(self, token: str) -> None:
150
+ token_hash = self._hash_token(token)
151
+ self._sessions.pop(token_hash, None)
152
+
153
+ def session_user(self, token: Optional[str]) -> Optional[str]:
154
+ if not token:
155
+ return None
156
+ token_hash = self._hash_token(token)
157
+ session = self._sessions.get(token_hash)
158
+ if session:
159
+ return session.username
160
+ return None
161
+
162
+ # Trusted devices --------------------------------------------------------
163
+ def create_trusted_token(self, username: str) -> str:
164
+ token = secrets.token_urlsafe(32)
165
+ token_hash = self._hash_token(token)
166
+ self._trusted_devices.append(
167
+ TrustedDevice(username=username, token_hash=token_hash, created_at=time.time())
168
+ )
169
+ self._persist_trusted()
170
+ return token
171
+
172
+ def auto_login(self, token: Optional[str]) -> Optional[str]:
173
+ if not token:
174
+ return None
175
+ token_hash = self._hash_token(token)
176
+ for device in self._trusted_devices:
177
+ if device.token_hash == token_hash and device.username == self._current_user:
178
+ return device.username
179
+ return None
180
+
181
+ # Credential verification ------------------------------------------------
182
+ def verify_credentials(self, username: str, password: str) -> bool:
183
+ if username != self._current_user:
184
+ return False
185
+
186
+ system = sys.platform
187
+ if system.startswith("win"):
188
+ return self._verify_windows(username, password)
189
+ return self._verify_unix(username, password)
190
+
191
+ def _verify_unix(self, username: str, password: str) -> bool:
192
+ # Use sudo in non-interactive mode. Requires the user to be part of sudoers.
193
+ if not password:
194
+ return False
195
+ try:
196
+ subprocess.run(
197
+ ["sudo", "-k"],
198
+ stdout=subprocess.DEVNULL,
199
+ stderr=subprocess.DEVNULL,
200
+ check=False,
201
+ )
202
+ proc = subprocess.run(
203
+ ["sudo", "-S", "-p", "", "true"],
204
+ input=f"{password}\n".encode("utf-8"),
205
+ stdout=subprocess.DEVNULL,
206
+ stderr=subprocess.PIPE,
207
+ check=False,
208
+ timeout=10,
209
+ )
210
+ return proc.returncode == 0
211
+ except (OSError, subprocess.SubprocessError):
212
+ raise CredentialError(
213
+ "Could not verify credentials on this platform. Ensure sudo is available."
214
+ )
215
+
216
+ def _verify_windows(self, username: str, password: str) -> bool:
217
+ if not password:
218
+ return False
219
+ try:
220
+ import ctypes
221
+ from ctypes import wintypes
222
+ except ImportError as exc: # pragma: no cover
223
+ raise CredentialError("Windows credential APIs are unavailable.") from exc
224
+
225
+ LOGON32_LOGON_INTERACTIVE = 2
226
+ LOGON32_PROVIDER_DEFAULT = 0
227
+
228
+ handle = wintypes.HANDLE()
229
+ result = ctypes.windll.advapi32.LogonUserW(
230
+ ctypes.c_wchar_p(username),
231
+ ctypes.c_wchar_p(None),
232
+ ctypes.c_wchar_p(password),
233
+ LOGON32_LOGON_INTERACTIVE,
234
+ LOGON32_PROVIDER_DEFAULT,
235
+ ctypes.byref(handle),
236
+ )
237
+ if result:
238
+ ctypes.windll.kernel32.CloseHandle(handle)
239
+ return True
240
+ return False