Files
kimandClaude Sonnet 5 f014fec7e2 v4.0.0: 라이브 서비스 /srv/homeclaw로 이전 + Android 실기기 USB 연동 + 관리자 IP 차단 뷰어
- homeclaw 라이브 서비스를 /home/kim/homeclaw에서 /srv/homeclaw(480G 전용 파티션)로
  컷오버, 하드코딩된 절대경로 전부 치환 (config, mcp-servers, routes, scripts, web-ui)
- Android 에뮬레이터 컨테이너 리소스를 32스레드 Xeon 기준으로 상향(3g/3cpu → 6g/10cpu)
- 코드 에디터에 USB 실기기 패널 추가: 기기 감지, APK 설치, Logcat, scrcpy 화면 미러링
  (companion.py에 독립 프로세스로 scrcpy 스폰하는 로직 포함)
- 관리자 컨텍스트 뷰어에 차단된 IP 목록 탭 추가
- ollama primary 모델을 gemma4:31b-cloud로, 음성 TTS를 edge_tts로 전환

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 13:31:28 +09:00

828 lines
31 KiB
Python

#!/usr/bin/env python3
"""
companion.py — 웹 코드 에디터용 로컬 Python 실행 컴패니언
사용법:
pip install websockets
python companion.py
웹 에디터(브라우저)가 ws://localhost:9000 에 연결해서
코드 실행 요청을 보내면 이 프로세스가 로컬에서 실행합니다.
pygame 등 GUI 라이브러리도 정상 동작합니다.
"""
import asyncio
import io
import json
import locale
import os
import sys
import tempfile
import threading
# pythonw.exe에서는 sys.stdout/stderr가 None이므로 print() 크래시 방지
if sys.stdout is None:
sys.stdout = io.TextIOWrapper(io.BytesIO(), encoding='utf-8', errors='replace')
if sys.stderr is None:
sys.stderr = io.TextIOWrapper(io.BytesIO(), encoding='utf-8', errors='replace')
try:
import websockets
except ImportError:
print('오류: websockets 패키지가 없습니다.')
print('설치: pip install websockets')
sys.exit(1)
HOST = '127.0.0.1'
PORT = 9000
IS_WINDOWS = sys.platform == 'win32'
# 자동시작(.vbs)으로 실행되면 프로세스 cwd가 System32로 잡히는 경우가 있어
# (wscript.exe 기본 작업 폴더 상속), 터미널이 엉뚱한 위치에서 열리는 걸 막기
# 위해 항상 홈 디렉터리에서 시작하도록 고정한다.
try:
os.chdir(os.path.expanduser('~'))
except Exception:
pass
def _refresh_env():
"""시작 후 변경된 환경 변수(PATH 등)를 레지스트리에서 실시간으로 반영한다."""
if not IS_WINDOWS:
return
try:
import winreg
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
r'SYSTEM\CurrentControlSet\Control\Session Manager\Environment',
0, winreg.KEY_READ) as key:
sys_path = winreg.QueryValueEx(key, 'Path')[0]
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
r'Environment',
0, winreg.KEY_READ) as key:
user_path = winreg.QueryValueEx(key, 'Path')[0]
expanded = os.path.expandvars(sys_path + ';' + user_path)
existing = set(p.strip().lower() for p in os.environ.get('Path', '').split(';') if p.strip())
new_entries = [p for p in expanded.split(';') if p.strip() and p.strip().lower() not in existing]
os.environ['Path'] = os.environ.get('Path', '') + ';' + ';'.join(new_entries)
except Exception:
pass
async def _safe_send(ws, data: str):
try:
await ws.send(data)
except Exception:
pass
class ShellSession:
"""shellId별 독립 PTY 셸 세션."""
def __init__(self, shell_id: str):
self.shell_id = shell_id
self.proc = None
self.master_fd = None
self._shell_task = None
self._win_pty = None
async def kill(self):
if IS_WINDOWS and self._win_pty:
try:
if hasattr(self._win_pty, 'close'):
self._win_pty.close()
except Exception:
pass
self._win_pty = None
if self._shell_task:
self._shell_task.cancel()
try:
await self._shell_task
except (asyncio.CancelledError, Exception):
pass
self._shell_task = None
if self.master_fd is not None:
try:
asyncio.get_running_loop().remove_reader(self.master_fd)
except Exception:
pass
try:
os.close(self.master_fd)
except Exception:
pass
self.master_fd = None
if self.proc and self.proc.returncode is None:
try:
self.proc.kill()
await self.proc.wait()
except Exception:
pass
self.proc = None
def _ready_msg(self):
return json.dumps({
'type': 'shell_ready',
'shellId': self.shell_id,
'platform': 'windows' if IS_WINDOWS else 'unix',
'home': os.path.expanduser('~'),
})
async def start(self, ws, rows: int = 24, cols: int = 80):
if IS_WINDOWS:
await self._start_windows(ws, rows, cols)
else:
await self._start_unix(ws, rows, cols)
async def _start_unix(self, ws, rows, cols):
import pty, fcntl, termios, struct
master_fd, slave_fd = pty.openpty()
winsize = struct.pack('HHHH', rows, cols, 0, 0)
fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, winsize)
fl = fcntl.fcntl(master_fd, fcntl.F_GETFL)
fcntl.fcntl(master_fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
self.proc = await asyncio.create_subprocess_exec(
'/bin/bash', '--login',
stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
close_fds=True,
env={**os.environ, 'TERM': 'xterm-256color'},
)
os.close(slave_fd)
self.master_fd = master_fd
await ws.send(self._ready_msg())
self._shell_task = asyncio.create_task(self._stream_unix(ws))
async def _start_windows(self, ws, rows, cols):
# 1) pywinpty 2.x
try:
from pywinpty import Pty
self._win_pty = Pty(cols, rows)
self._win_pty.spawn('cmd.exe')
await ws.send(self._ready_msg())
self._shell_task = asyncio.create_task(self._stream_winpty(ws))
print(f'[셸:{self.shell_id}] pywinpty 2.x', flush=True)
return
except ImportError:
pass
except Exception as e:
print(f'[셸:{self.shell_id}] pywinpty 2.x 실패: {e}', flush=True)
# 2) pywinpty 0.x
try:
from winpty import PtyProcess
self._win_pty = PtyProcess.spawn(['cmd.exe'], dimensions=(rows, cols))
await ws.send(self._ready_msg())
self._shell_task = asyncio.create_task(self._stream_winpty(ws))
print(f'[셸:{self.shell_id}] pywinpty 0.x', flush=True)
return
except ImportError:
pass
# 3) pipe fallback
print(f'[셸:{self.shell_id}] pipe 모드 (pip install pywinpty 권장)', flush=True)
self.proc = await asyncio.create_subprocess_exec(
'cmd.exe', '/K', 'chcp 65001 > nul',
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env={**os.environ},
)
await ws.send(self._ready_msg())
self._shell_task = asyncio.create_task(self._stream_pipe(ws))
async def _stream_unix(self, ws):
loop = asyncio.get_running_loop()
done = asyncio.Event()
def on_readable():
fd = self.master_fd
if fd is None:
done.set()
return
try:
data = os.read(fd, 4096)
loop.create_task(_safe_send(ws, json.dumps({
'type': 'shell_output',
'shellId': self.shell_id,
'data': data.decode('utf-8', errors='replace'),
})))
except BlockingIOError:
pass
except OSError:
try:
loop.remove_reader(fd)
except Exception:
pass
done.set()
loop.add_reader(self.master_fd, on_readable)
print(f'[셸스트림:{self.shell_id}] 시작 fd={self.master_fd}', flush=True)
try:
await done.wait()
except asyncio.CancelledError:
fd = self.master_fd
if fd is not None:
try:
loop.remove_reader(fd)
except Exception:
pass
return
if self.proc:
try:
await self.proc.wait()
except Exception:
pass
try:
await ws.send(json.dumps({
'type': 'shell_exit',
'shellId': self.shell_id,
'code': self.proc.returncode if self.proc else -1,
}))
except Exception:
pass
async def _stream_winpty(self, ws):
loop = asyncio.get_running_loop()
try:
while self._win_pty:
is_alive = False
try:
if hasattr(self._win_pty, 'is_alive'):
is_alive = self._win_pty.is_alive()
elif hasattr(self._win_pty, 'isalive'):
is_alive = self._win_pty.isalive()
except Exception:
break
if not is_alive:
break
try:
data = await loop.run_in_executor(None, self._win_pty.read, 4096)
if data:
await _safe_send(ws, json.dumps({
'type': 'shell_output',
'shellId': self.shell_id,
'data': data,
}))
except EOFError:
break
except Exception as e:
print(f'[winpty 스트림:{self.shell_id}] {e}', flush=True)
break
except asyncio.CancelledError:
pass
try:
await ws.send(json.dumps({'type': 'shell_exit', 'shellId': self.shell_id, 'code': 0}))
except Exception:
pass
async def _stream_pipe(self, ws):
try:
while self.proc and self.proc.returncode is None:
try:
chunk = await asyncio.wait_for(self.proc.stdout.read(512), timeout=0.05)
if chunk:
await _safe_send(ws, json.dumps({
'type': 'shell_output',
'shellId': self.shell_id,
'data': chunk.decode('utf-8', errors='replace').replace('\x00', ''),
}))
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
pass
except Exception as e:
print(f'[pipe 오류:{self.shell_id}] {e}', flush=True)
if self.proc:
try:
await self.proc.wait()
except Exception:
pass
try:
await ws.send(json.dumps({
'type': 'shell_exit',
'shellId': self.shell_id,
'code': self.proc.returncode if self.proc else -1,
}))
except Exception:
pass
def resize(self, cols: int, rows: int):
if IS_WINDOWS:
if self._win_pty:
try:
if hasattr(self._win_pty, 'set_size'):
self._win_pty.set_size(cols, rows)
elif hasattr(self._win_pty, 'setwinsize'):
self._win_pty.setwinsize(rows, cols)
except Exception:
pass
return
if self.master_fd is None:
return
try:
import fcntl, termios, struct
winsize = struct.pack('HHHH', rows, cols, 0, 0)
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize)
except Exception:
pass
def write_input(self, data: str):
if IS_WINDOWS and self._win_pty:
try:
self._win_pty.write(data)
except Exception as e:
print(f'[입력오류:{self.shell_id}] {e}', flush=True)
elif self.master_fd is not None:
try:
os.write(self.master_fd, data.encode('utf-8'))
except Exception as e:
print(f'[입력오류:{self.shell_id}] {e}', flush=True)
elif self.proc and self.proc.stdin and self.proc.returncode is None:
try:
self.proc.stdin.write(data.encode('utf-8'))
except Exception:
pass
class Runner:
"""▶ 버튼 실행 전용 (run 메시지)."""
def __init__(self):
self.proc = None
self.tmp_path = None
async def kill(self):
if self.proc and self.proc.returncode is None:
try:
self.proc.kill()
await self.proc.wait()
except Exception:
pass
if self.tmp_path:
try:
os.unlink(self.tmp_path)
except Exception:
pass
self.tmp_path = None
self.proc = None
def write_input(self, data: str):
if self.proc and self.proc.stdin and self.proc.returncode is None:
try:
self.proc.stdin.write(data.encode('utf-8'))
except Exception:
pass
# ── 파일 작업(Read/Write/Edit/Glob/Grep)용 헬퍼 ──────────────────────────
# claude-app의 Bash 경로와 동일한 root를 사용해 파일 작업을 처리한다.
# 이렇게 하면 Bash cwd와 Read/Write 대상 폴더가 항상 같은 곳을 가리킨다.
FS_IGNORE_DIRS = {'node_modules', '.git', 'dist', 'build', '__pycache__', 'venv', '.venv', '.next', 'target', '.cache', 'out'}
def _pick_folder():
"""네이티브 OS 폴더 선택 창을 열고 절대경로를 반환한다 (동기, executor에서 실행)."""
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.attributes('-topmost', True)
try:
path = filedialog.askdirectory(title='claude-app 프로젝트 폴더 선택')
finally:
root.destroy()
return path or None
def _pick_file(title: str, filetypes):
"""네이티브 OS 파일 선택 창을 열고 절대경로를 반환한다 (동기, executor에서 실행)."""
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.attributes('-topmost', True)
try:
path = filedialog.askopenfilename(
title=title or '파일 선택',
filetypes=filetypes or [('모든 파일', '*.*')],
)
finally:
root.destroy()
return path or None
def _spawn_scrcpy(serial):
"""scrcpy를 companion.py와 완전히 독립된(disconnect/재시작에도 안 죽는) 프로세스로 띄운다.
exec/shell 세션의 모든 자식 프로세스는 WS 연결 종료 시 handle()의
finally 블록에서 kill되므로, scrcpy처럼 브라우저 탭과 무관하게 계속
떠 있어야 하는 네이티브 GUI 창은 shells/runner에 등록하지 않고
OS 세션에서 완전히 분리해서 스폰해야 한다.
"""
import shutil
import subprocess
_refresh_env()
scrcpy_path = shutil.which('scrcpy')
if not scrcpy_path:
return {'ok': False, 'error': 'scrcpy를 찾을 수 없습니다. https://github.com/Genymobile/scrcpy 에서 설치 후 PATH에 추가해주세요.'}
cmd = [scrcpy_path] + (['-s', serial] if serial else [])
log_path = tempfile.mktemp(suffix='.scrcpy.log')
try:
log_file = open(log_path, 'w', encoding='utf-8', errors='replace')
popen_kwargs = dict(stdout=log_file, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
if IS_WINDOWS:
popen_kwargs['creationflags'] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
else:
popen_kwargs['start_new_session'] = True
proc = subprocess.Popen(cmd, **popen_kwargs)
except Exception as e:
return {'ok': False, 'error': f'scrcpy 실행 실패: {e}'}
return {'ok': True, 'pid': proc.pid, '_proc': proc, '_log_path': log_path}
def _safe_join(root_path: str, rel_path: str) -> str:
root_abs = os.path.abspath(root_path)
full = os.path.abspath(os.path.join(root_abs, rel_path))
if full != root_abs and not full.startswith(root_abs + os.sep):
raise ValueError('경로가 작업 폴더를 벗어났습니다')
return full
def _fs_walk(root_path: str):
root_abs = os.path.abspath(root_path)
results = []
for dirpath, dirnames, filenames in os.walk(root_abs):
dirnames[:] = [d for d in dirnames if d not in FS_IGNORE_DIRS and not d.startswith('.')]
for fn in filenames:
rel = os.path.relpath(os.path.join(dirpath, fn), root_abs).replace(os.sep, '/')
results.append(rel)
return results
async def handle(ws):
shells = {} # shell_id -> ShellSession
runner = Runner()
addr = ws.remote_address
print(f'[연결] {addr}')
try:
async for raw in ws:
try:
msg = json.loads(raw)
except Exception:
continue
t = msg.get('type')
if t == 'run':
await runner.kill()
_refresh_env()
code = msg.get('code', '')
filename = msg.get('filename', 'script.py')
ext = os.path.splitext(filename)[1] or '.py'
with tempfile.NamedTemporaryFile(
mode='w', suffix=ext, delete=False, encoding='utf-8'
) as f:
f.write(code)
runner.tmp_path = f.name
print(f'[실행] {filename}')
await ws.send(json.dumps({'type': 'started', 'filename': filename}))
try:
runner.proc = await asyncio.create_subprocess_exec(
sys.executable, runner.tmp_path,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env={**os.environ},
)
while True:
chunk = await runner.proc.stdout.read(512)
if not chunk:
break
await ws.send(json.dumps({
'type': 'output',
'data': chunk.decode('utf-8', errors='replace'),
}))
await runner.proc.wait()
code_ret = runner.proc.returncode
print(f'[종료] {filename} → exit {code_ret}')
await ws.send(json.dumps({'type': 'exit', 'code': code_ret}))
except Exception as e:
await ws.send(json.dumps({'type': 'error', 'data': str(e)}))
finally:
if runner.tmp_path:
try:
os.unlink(runner.tmp_path)
except Exception:
pass
runner.tmp_path = None
elif t == 'shell':
shell_id = msg.get('shellId', 'default')
rows = msg.get('rows', 24)
cols = msg.get('cols', 80)
_refresh_env()
if shell_id in shells:
await shells[shell_id].kill()
session = ShellSession(shell_id)
shells[shell_id] = session
print(f'[셸:{shell_id}] 시작 ({cols}x{rows})')
await session.start(ws, rows=rows, cols=cols)
elif t == 'shell_close':
shell_id = msg.get('shellId')
if shell_id and shell_id in shells:
await shells[shell_id].kill()
del shells[shell_id]
print(f'[셸:{shell_id}] 닫힘')
elif t == 'resize':
shell_id = msg.get('shellId')
if shell_id and shell_id in shells:
shells[shell_id].resize(msg.get('cols', 80), msg.get('rows', 24))
elif t == 'input':
shell_id = msg.get('shellId')
if shell_id and shell_id in shells:
shells[shell_id].write_input(msg.get('data', ''))
elif not shell_id:
runner.write_input(msg.get('data', ''))
if runner.proc and runner.proc.stdin and runner.proc.returncode is None and IS_WINDOWS:
try:
await runner.proc.stdin.drain()
except Exception:
pass
elif t == 'kill':
shell_id = msg.get('shellId')
if shell_id and shell_id in shells:
await shells[shell_id].kill()
del shells[shell_id]
print(f'[셸:{shell_id}] 강제종료')
await ws.send(json.dumps({'type': 'killed', 'shellId': shell_id}))
else:
await runner.kill()
print('[강제 종료]')
await ws.send(json.dumps({'type': 'killed'}))
elif t == 'exec':
# 단발성 명령 실행 (에디터의 Bash 툴 / claude-app 에이전트 루프용).
# 인터랙티브 셸(ShellSession)과 무관하게 reqId로 결과를 상관시킨다.
req_id = msg.get('reqId')
command = msg.get('command', '')
cwd = msg.get('cwd') or None
# Windows(cmd.exe)는 시스템 로컬 인코딩(CP949 등)을 사용하므로
# UTF-8 디코딩 실패 시 시스템 기본 인코딩으로 폴백한다.
_refresh_env()
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=cwd if cwd and os.path.isdir(cwd) else None,
env={**os.environ},
)
try:
out, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
# Try UTF-8 first; on Windows fall back to system locale encoding
try:
text = out.decode('utf-8')
except UnicodeDecodeError:
text = out.decode(locale.getpreferredencoding(False), errors='replace')
if len(text) > 200_000:
text = text[:200_000] + '\n…(출력 잘림)'
await ws.send(json.dumps({
'type': 'exec_result', 'reqId': req_id,
'output': text, 'error': proc.returncode != 0,
}))
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
await ws.send(json.dumps({
'type': 'exec_result', 'reqId': req_id,
'output': '오류: 명령 실행 시간 초과 (30s)', 'error': True,
}))
except Exception as e:
await ws.send(json.dumps({
'type': 'exec_result', 'reqId': req_id,
'output': f'오류: {e}', 'error': True,
}))
elif t == 'browse_folder':
req_id = msg.get('reqId')
try:
path = await asyncio.get_event_loop().run_in_executor(None, _pick_folder)
await ws.send(json.dumps({'type': 'browse_result', 'reqId': req_id, 'path': path}))
except Exception as e:
await ws.send(json.dumps({
'type': 'browse_result', 'reqId': req_id, 'path': None,
'error': f'폴더 선택 창을 열 수 없습니다: {e}',
}))
elif t == 'browse_file':
req_id = msg.get('reqId')
title = msg.get('title') or '파일 선택'
filetypes = msg.get('filetypes')
filetypes = [tuple(ft) for ft in filetypes] if filetypes else None
try:
path = await asyncio.get_event_loop().run_in_executor(None, _pick_file, title, filetypes)
await ws.send(json.dumps({'type': 'browse_file_result', 'reqId': req_id, 'path': path}))
except Exception as e:
await ws.send(json.dumps({
'type': 'browse_file_result', 'reqId': req_id, 'path': None,
'error': f'파일 선택 창을 열 수 없습니다: {e}',
}))
elif t == 'scrcpy_start':
req_id = msg.get('reqId')
serial = msg.get('serial')
try:
result = await asyncio.get_event_loop().run_in_executor(None, _spawn_scrcpy, serial)
if result.get('ok'):
proc = result.pop('_proc')
log_path = result.pop('_log_path')
await asyncio.sleep(1.2)
if proc.poll() is not None:
# 스폰 직후 바로 종료됨 — 로그 tail을 에러로 반환
tail = ''
try:
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
tail = f.read()[-2000:]
except Exception:
pass
result = {'ok': False, 'error': f'scrcpy가 바로 종료되었습니다: {tail or "(로그 없음)"}'}
await ws.send(json.dumps({'type': 'scrcpy_result', 'reqId': req_id, **result}))
except Exception as e:
await ws.send(json.dumps({'type': 'scrcpy_result', 'reqId': req_id, 'ok': False, 'error': str(e)}))
elif t == 'fs_list':
req_id = msg.get('reqId')
root_path = msg.get('cwd') or ''
try:
paths = _fs_walk(root_path) if root_path and os.path.isdir(root_path) else []
await ws.send(json.dumps({'type': 'fs_list_result', 'reqId': req_id, 'paths': paths}))
except Exception as e:
await ws.send(json.dumps({'type': 'fs_list_result', 'reqId': req_id, 'paths': [], 'error': str(e)}))
elif t == 'fs_read':
req_id = msg.get('reqId')
root_path = msg.get('cwd') or ''
rel = msg.get('path') or ''
try:
full = _safe_join(root_path, rel)
with open(full, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
await ws.send(json.dumps({'type': 'fs_read_result', 'reqId': req_id, 'content': content}))
except Exception as e:
await ws.send(json.dumps({'type': 'fs_read_result', 'reqId': req_id, 'content': None, 'error': str(e)}))
elif t == 'fs_write':
req_id = msg.get('reqId')
root_path = msg.get('cwd') or ''
rel = msg.get('path') or ''
content = msg.get('content', '')
try:
full = _safe_join(root_path, rel)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, 'w', encoding='utf-8') as f:
f.write(content)
await ws.send(json.dumps({'type': 'fs_write_result', 'reqId': req_id, 'ok': True}))
except Exception as e:
await ws.send(json.dumps({'type': 'fs_write_result', 'reqId': req_id, 'ok': False, 'error': str(e)}))
elif t == 'ping':
await ws.send(json.dumps({'type': 'pong'}))
except Exception as e:
print(f'[연결오류] {type(e).__name__}: {e}', flush=True)
finally:
for session in list(shells.values()):
await session.kill()
await runner.kill()
print(f'[종료] {addr}', flush=True)
def _make_tray_icon():
from PIL import Image, ImageDraw
size = 64
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.ellipse([2, 2, size - 2, size - 2], fill=(52, 120, 200))
d.ellipse([18, 18, 46, 46], fill='white')
d.ellipse([24, 24, 40, 40], fill=(52, 120, 200))
return img
def _run_asyncio_in_thread(stop_event):
async def _serve():
async with websockets.serve(handle, HOST, PORT):
while not stop_event.is_set():
await asyncio.sleep(0.3)
try:
asyncio.run(_serve())
except Exception as e:
print(f'[서버오류] {e}', flush=True)
def _run_with_tray():
import pystray
stop_event = threading.Event()
t = threading.Thread(target=_run_asyncio_in_thread, args=(stop_event,), daemon=True)
t.start()
def on_quit(icon, item):
stop_event.set()
icon.stop()
icon = pystray.Icon(
'python_companion',
_make_tray_icon(),
f'Python Companion ws://{HOST}:{PORT}',
menu=pystray.Menu(
pystray.MenuItem('Python Companion', None, enabled=False),
pystray.MenuItem(f'포트: {PORT} | 연결 대기 중', None, enabled=False),
pystray.Menu.SEPARATOR,
pystray.MenuItem('종료', on_quit),
),
)
print('시스템 트레이에서 실행 중 (우클릭 → 종료)', flush=True)
icon.run()
stop_event.set()
async def main():
async with websockets.serve(handle, HOST, PORT):
await asyncio.Future()
def _check_dependencies():
"""선택적 의존성 상태를 확인해 시작 시 한눈에 보여준다.
websockets는 필수라 파일 최상단에서 이미 체크됨(없으면 여기까지 못 옴)."""
checks = [('websockets (필수)', True, '')]
try:
import tkinter # noqa: F401
checks.append(('tkinter — 폴더 선택창', True, ''))
except ImportError:
hint = 'python.org 설치본은 기본 포함, 커스텀/최소 설치라면 재설치 필요' if IS_WINDOWS else 'sudo apt install python3-tk (또는 배포판에 맞는 tk 패키지)'
checks.append(('tkinter — 폴더 선택창', False, hint))
if IS_WINDOWS:
has_pty = False
for mod in ('pywinpty', 'winpty'):
try:
__import__(mod)
has_pty = True
break
except ImportError:
continue
checks.append(('pywinpty — 터미널 색상/크기 지원', has_pty, '' if has_pty else 'pip install pywinpty'))
try:
import pystray # noqa: F401
from PIL import Image # noqa: F401
checks.append(('pystray+Pillow — 시스템 트레이 실행', True, ''))
except ImportError:
checks.append(('pystray+Pillow — 시스템 트레이 실행', False, 'pip install pystray Pillow'))
print('의존성 상태:', flush=True)
for name, ok, hint in checks:
line = f' {"✅" if ok else "⚠️ "} {name}'
if not ok and hint:
line += f' → {hint}'
print(line, flush=True)
print(flush=True)
if __name__ == '__main__':
try:
print('━' * 40)
print(f'Python Companion ws://{HOST}:{PORT}')
print(f'플랫폼: {"Windows" if IS_WINDOWS else "Unix/Linux"}')
print('━' * 40)
print(f'Python: {sys.executable}')
print()
except Exception:
pass
_check_dependencies()
try:
import pystray
from PIL import Image
_run_with_tray()
except ImportError:
print('웹 에디터에서 연결 대기 중...\nCtrl+C 로 종료\n', flush=True)
try:
asyncio.run(main())
except KeyboardInterrupt:
print('\n컴패니언 종료')