onsense 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.
- onsense/__init__.py +14 -0
- onsense/__main__.py +7 -0
- onsense/auth.py +82 -0
- onsense/cli.py +87 -0
- onsense/clip.py +506 -0
- onsense/crypto.py +66 -0
- onsense/doctor.py +188 -0
- onsense/pair.py +153 -0
- onsense/server.py +165 -0
- onsense-0.1.0.dist-info/METADATA +222 -0
- onsense-0.1.0.dist-info/RECORD +14 -0
- onsense-0.1.0.dist-info/WHEEL +4 -0
- onsense-0.1.0.dist-info/entry_points.txt +2 -0
- onsense-0.1.0.dist-info/licenses/LICENSE +21 -0
onsense/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""onSense — 폰을 AI의 눈·센서로 만드는 PC측 MCP 브로커.
|
|
2
|
+
|
|
3
|
+
서브커맨드: serve(MCP 서버) / pair(페어링+등록) / doctor(진단).
|
|
4
|
+
폰(Android 앱)은 HTTP 제공자, 이 패키지는 stdio MCP 브로커로 AI 클라이언트에 연결한다.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
# 폰 앱과 합의된 상수 (android: CameraService.PORT / Auth / NSD)
|
|
10
|
+
PHONE_PORT = 8080
|
|
11
|
+
MDNS_TYPE = "_onsense._tcp.local."
|
|
12
|
+
TOKEN_HEADER = "X-Token"
|
|
13
|
+
PAIR_PORT = 8765
|
|
14
|
+
CLIP_PORT = 8770 # 폰→PC 클립보드/파일 데몬 (clip.py). 양방향 GET 도 동일 포트.
|
onsense/__main__.py
ADDED
onsense/auth.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""onSense 요청 서명 — 토큰을 평문으로 전송하지 않고 HMAC-SHA256으로 인증·재전송 방지.
|
|
2
|
+
|
|
3
|
+
서명: HMAC-SHA256(token, f"{METHOD}\\n{path}\\n{ts}\\n{nonce}") → hex
|
|
4
|
+
헤더: X-Ts(유닉스초), X-Nonce(hex), X-Sig(hex). (구 X-Token 평문 전송 폐지)
|
|
5
|
+
검증: ts 윈도(±WINDOW) + nonce 중복 거부(메모리 캐시) + 상수시간 sig 비교.
|
|
6
|
+
|
|
7
|
+
표준 라이브러리만 사용. 데이터 본문은 여전히 평문(전체 기밀은 추후 TLS) —
|
|
8
|
+
서명은 토큰 도용·요청 위조·replay 를 차단한다. Android Auth.kt 와 동일 규약.
|
|
9
|
+
"""
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from . import crypto
|
|
16
|
+
|
|
17
|
+
WINDOW = 300 # 허용 시계 오차 / replay 윈도(초)
|
|
18
|
+
TS_HEADER = "X-Ts"
|
|
19
|
+
NONCE_HEADER = "X-Nonce"
|
|
20
|
+
SIG_HEADER = "X-Sig"
|
|
21
|
+
ENC_HEADER = "X-Enc" # 본문 암호화 방식(canonical에 포함 → 스트립/다운그레이드 방지)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _canon(method: str, path: str, ts: str, nonce: str, enc: str) -> bytes:
|
|
25
|
+
return f"{method.upper()}\n{path}\n{ts}\n{nonce}\n{enc}".encode("utf-8")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sign(token: str, method: str, path: str) -> dict:
|
|
29
|
+
"""요청 1건의 서명 헤더 dict(키=auth_key, X-Enc 포함)."""
|
|
30
|
+
ts = str(int(time.time()))
|
|
31
|
+
nonce = os.urandom(16).hex()
|
|
32
|
+
enc = crypto.ENC
|
|
33
|
+
sig = hmac.new(crypto.auth_key(token), _canon(method, path, ts, nonce, enc),
|
|
34
|
+
hashlib.sha256).hexdigest()
|
|
35
|
+
return {TS_HEADER: ts, NONCE_HEADER: nonce, SIG_HEADER: sig, ENC_HEADER: enc}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NonceCache:
|
|
39
|
+
"""replay 방지용 nonce 캐시(메모리, 윈도 만료)."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, cap: int = 1024):
|
|
42
|
+
self.cap = cap
|
|
43
|
+
self._d = {} # nonce -> 만료시각
|
|
44
|
+
|
|
45
|
+
def check_and_add(self, nonce: str, now: int) -> bool:
|
|
46
|
+
"""이미 본 nonce면 True(거부). 처음이면 등록 후 False."""
|
|
47
|
+
if len(self._d) > self.cap:
|
|
48
|
+
for k in [k for k, e in self._d.items() if e < now]:
|
|
49
|
+
self._d.pop(k, None)
|
|
50
|
+
exp = self._d.get(nonce)
|
|
51
|
+
if exp is not None and exp >= now:
|
|
52
|
+
return True
|
|
53
|
+
self._d[nonce] = now + WINDOW
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def verify(token: str, method: str, path: str, get_header, nonce_cache=None, now=None) -> bool:
|
|
58
|
+
"""서명 검증. get_header(name)->값|None. nonce_cache 주면 replay 거부."""
|
|
59
|
+
if not token:
|
|
60
|
+
return False
|
|
61
|
+
ts = get_header(TS_HEADER)
|
|
62
|
+
nonce = get_header(NONCE_HEADER)
|
|
63
|
+
sig = get_header(SIG_HEADER)
|
|
64
|
+
enc = get_header(ENC_HEADER)
|
|
65
|
+
if not ts or not nonce or not sig:
|
|
66
|
+
return False
|
|
67
|
+
if enc != crypto.ENC: # 미지정/다운그레이드 거부(하드 컷오버)
|
|
68
|
+
return False
|
|
69
|
+
try:
|
|
70
|
+
tsi = int(ts)
|
|
71
|
+
except (TypeError, ValueError):
|
|
72
|
+
return False
|
|
73
|
+
now = int(time.time()) if now is None else now
|
|
74
|
+
if abs(now - tsi) > WINDOW:
|
|
75
|
+
return False
|
|
76
|
+
expect = hmac.new(crypto.auth_key(token), _canon(method, path, ts, nonce, enc),
|
|
77
|
+
hashlib.sha256).hexdigest()
|
|
78
|
+
if not hmac.compare_digest(sig, expect):
|
|
79
|
+
return False
|
|
80
|
+
if nonce_cache is not None and nonce_cache.check_and_add(nonce, now):
|
|
81
|
+
return False
|
|
82
|
+
return True
|
onsense/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""onsense CLI — serve / pair / doctor 서브커맨드 디스패치."""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from . import __version__
|
|
6
|
+
|
|
7
|
+
try: # Windows 콘솔 한글/이모지 출력 안전화
|
|
8
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
9
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
10
|
+
except Exception:
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
p = argparse.ArgumentParser(
|
|
16
|
+
prog="onsense",
|
|
17
|
+
description="폰을 AI의 눈·센서로 — PC측 MCP 브로커 (onSense)",
|
|
18
|
+
)
|
|
19
|
+
p.add_argument("-V", "--version", action="version",
|
|
20
|
+
version=f"onsense {__version__}")
|
|
21
|
+
sub = p.add_subparsers(dest="cmd", metavar="{pair,doctor,serve,clip}")
|
|
22
|
+
|
|
23
|
+
sp = sub.add_parser("serve", help="MCP 서버 실행(stdio). AI 클라이언트가 호출 — pair가 자동 등록")
|
|
24
|
+
sp.add_argument("--no-clip", action="store_true",
|
|
25
|
+
help="폰→PC 클립보드/파일 데몬 자동 기동 안 함")
|
|
26
|
+
|
|
27
|
+
cp = sub.add_parser("clip", help="폰↔PC 클립보드/파일 데몬 실행(:8770). serve가 자동 기동하나 단독 실행도 가능")
|
|
28
|
+
cp.add_argument("--port", type=int, default=None, help="리스닝 포트(기본 8770)")
|
|
29
|
+
cp.add_argument("--allow-pull", action="store_true",
|
|
30
|
+
help="PC 클립보드/복사 파일을 폰이 가져가는 GET /clip 허용(영속 설정 ON)")
|
|
31
|
+
cp.add_argument("--no-allow-pull", action="store_true",
|
|
32
|
+
help="GET /clip 허용을 영속 OFF (실행 중 데몬에도 즉시 반영)")
|
|
33
|
+
cp.add_argument("--set-clipboard", action="store_true",
|
|
34
|
+
help="폰→PC POST 수신 시 OS 클립보드 자동 주입 허용(영속 설정 ON, 파일 저장은 항상 수행)")
|
|
35
|
+
cp.add_argument("--no-set-clipboard", action="store_true",
|
|
36
|
+
help="OS 클립보드 자동 주입을 영속 OFF (실행 중 데몬에도 즉시 반영)")
|
|
37
|
+
|
|
38
|
+
pp = sub.add_parser("pair", help="폰과 페어링 + Claude에 MCP 자동 등록")
|
|
39
|
+
pp.add_argument("uri", nargs="?",
|
|
40
|
+
help="onsense://pair?base=...&token=... (생략 시 QR 리스너 모드)")
|
|
41
|
+
pp.add_argument("--img", metavar="PNG", help="QR 이미지 파일에서 디코드(opencv 필요)")
|
|
42
|
+
pp.add_argument("--local", action="store_true",
|
|
43
|
+
help="개발용: uvx 대신 현재 인터프리터(python -m onsense)로 등록")
|
|
44
|
+
pp.add_argument("--client", default="claude",
|
|
45
|
+
help="MCP 등록 대상 CLI (기본: claude)")
|
|
46
|
+
|
|
47
|
+
dp = sub.add_parser("doctor", help="설치/연결 문제 진단 (Python/uv/mcp/Claude/네트워크/폰)")
|
|
48
|
+
dp.add_argument("--base", help="폰 주소 http://IP:8080 (생략 시 mDNS 자동 탐색)")
|
|
49
|
+
dp.add_argument("--token", help="폰이 표시하는 페어링 토큰")
|
|
50
|
+
return p
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv=None) -> int:
|
|
54
|
+
args = build_parser().parse_args(argv)
|
|
55
|
+
if args.cmd == "serve":
|
|
56
|
+
if not getattr(args, "no_clip", False):
|
|
57
|
+
from . import clip
|
|
58
|
+
clip.spawn_detached() # 폰→PC 클립보드/파일 데몬 싱글톤 보장(실패해도 serve엔 무영향)
|
|
59
|
+
from . import server
|
|
60
|
+
server.main()
|
|
61
|
+
return 0
|
|
62
|
+
if args.cmd == "clip":
|
|
63
|
+
from . import clip
|
|
64
|
+
# 플래그가 하나라도 지정되면 영속 상태(settings.json)를 먼저 갱신하고 결과를 출력.
|
|
65
|
+
# 그 뒤 clip.main() 을 호출 — 이미 데몬이 떠 있으면 "already running"으로 종료되지만
|
|
66
|
+
# 살아있는 데몬이 요청 시점에 영속 설정을 LIVE로 읽으므로 의도대로 반영된다(option ①).
|
|
67
|
+
allow_pull = True if args.allow_pull else (False if args.no_allow_pull else None)
|
|
68
|
+
set_clipboard = True if args.set_clipboard else (False if args.no_set_clipboard else None)
|
|
69
|
+
if allow_pull is not None or set_clipboard is not None:
|
|
70
|
+
state = clip.save_flags(allow_pull=allow_pull, set_clipboard=set_clipboard)
|
|
71
|
+
print(f"[clip] 영속 설정 갱신 → pull={'ON' if state['allow_pull'] else 'OFF'}, "
|
|
72
|
+
f"set_clipboard={'ON' if state['set_clipboard'] else 'OFF'}")
|
|
73
|
+
# main() 인자는 영속 승격용(True만 의미); 그 외는 None 처리되어 기존 영속값 유지.
|
|
74
|
+
return clip.main(args.port or clip.CLIP_PORT,
|
|
75
|
+
bool(args.allow_pull), bool(args.set_clipboard))
|
|
76
|
+
if args.cmd == "pair":
|
|
77
|
+
from . import pair
|
|
78
|
+
return pair.main(args)
|
|
79
|
+
if args.cmd == "doctor":
|
|
80
|
+
from . import doctor
|
|
81
|
+
return doctor.main(args)
|
|
82
|
+
build_parser().print_help()
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
sys.exit(main())
|