Add user workspaces, dental DB scripts, server memory + code editor improvements
- cherry: orofacial/TMD PPTX presentations + 음악가 구강건강 가이드 - papa: sinus/infraoccluded PPTX, garden-monitor Arduino, MIDI files - databases: PCSP scraper, translation scripts, vision benchmark, batch JSONs - web-ui: code.js/companion.py/styles.css major updates, autostart installer - src: ollama-client, session, LLMProvider, ollama-adapter patches - .gitignore: add claude-key, SQLite WAL/SHM, __pycache__, nohup.out Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+199
-73
@@ -12,12 +12,19 @@ pygame 등 GUI 라이브러리도 정상 동작합니다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
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:
|
||||
@@ -30,6 +37,28 @@ PORT = 9000
|
||||
IS_WINDOWS = sys.platform == 'win32'
|
||||
|
||||
|
||||
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)
|
||||
@@ -37,17 +66,21 @@ async def _safe_send(ws, data: str):
|
||||
pass
|
||||
|
||||
|
||||
class Runner:
|
||||
def __init__(self):
|
||||
class ShellSession:
|
||||
"""shellId별 독립 PTY 셸 세션."""
|
||||
|
||||
def __init__(self, shell_id: str):
|
||||
self.shell_id = shell_id
|
||||
self.proc = None
|
||||
self.tmp_path = None
|
||||
self.master_fd = None # Unix PTY only
|
||||
self.master_fd = None
|
||||
self._shell_task = None
|
||||
self._win_pty = None
|
||||
|
||||
async def kill(self):
|
||||
if IS_WINDOWS and hasattr(self, '_win_pty') and self._win_pty:
|
||||
if IS_WINDOWS and self._win_pty:
|
||||
try:
|
||||
self._win_pty.close()
|
||||
if hasattr(self._win_pty, 'close'):
|
||||
self._win_pty.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._win_pty = None
|
||||
@@ -74,29 +107,29 @@ class Runner:
|
||||
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
|
||||
|
||||
async def start_shell(self, ws, rows: int = 24, cols: int = 80):
|
||||
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_shell_windows(ws, rows, cols)
|
||||
await self._start_windows(ws, rows, cols)
|
||||
else:
|
||||
await self._start_shell_unix(ws, rows, cols)
|
||||
await self._start_unix(ws, rows, cols)
|
||||
|
||||
async def _start_shell_unix(self, ws, rows: int, cols: int):
|
||||
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,
|
||||
@@ -105,33 +138,48 @@ class Runner:
|
||||
)
|
||||
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))
|
||||
|
||||
await ws.send(json.dumps({'type': 'shell_ready', 'platform': 'windows' if IS_WINDOWS else 'unix', 'home': os.path.expanduser('~')}))
|
||||
self._shell_task = asyncio.create_task(self._stream_shell_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)
|
||||
|
||||
async def _start_shell_windows(self, ws, rows: int = 24, cols: int = 80):
|
||||
# 2) pywinpty 0.x
|
||||
try:
|
||||
from winpty import PtyProcess
|
||||
self._win_pty = PtyProcess.spawn(
|
||||
['cmd.exe'],
|
||||
dimensions=(rows, cols),
|
||||
)
|
||||
await ws.send(json.dumps({'type': 'shell_ready', 'platform': 'windows' if IS_WINDOWS else 'unix', 'home': os.path.expanduser('~')}))
|
||||
self._shell_task = asyncio.create_task(self._stream_shell_winpty(ws))
|
||||
print('[셸] winpty 사용', flush=True)
|
||||
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:
|
||||
print('[셸] pywinpty 없음 → 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(json.dumps({'type': 'shell_ready', 'platform': 'windows' if IS_WINDOWS else 'unix', 'home': os.path.expanduser('~')}))
|
||||
self._shell_task = asyncio.create_task(self._stream_shell_pipe(ws))
|
||||
pass
|
||||
|
||||
async def _stream_shell_unix(self, ws):
|
||||
# 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()
|
||||
|
||||
@@ -144,6 +192,7 @@ class Runner:
|
||||
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:
|
||||
@@ -156,7 +205,7 @@ class Runner:
|
||||
done.set()
|
||||
|
||||
loop.add_reader(self.master_fd, on_readable)
|
||||
print(f'[셸스트림] 시작 fd={self.master_fd}', flush=True)
|
||||
print(f'[셸스트림:{self.shell_id}] 시작 fd={self.master_fd}', flush=True)
|
||||
try:
|
||||
await done.wait()
|
||||
except asyncio.CancelledError:
|
||||
@@ -176,43 +225,55 @@ class Runner:
|
||||
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_shell_winpty(self, ws):
|
||||
async def _stream_winpty(self, ws):
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
while self._win_pty and self._win_pty.isalive():
|
||||
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:
|
||||
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', 'code': 0}))
|
||||
await ws.send(json.dumps({'type': 'shell_exit', 'shellId': self.shell_id, 'code': 0}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _stream_shell_pipe(self, ws):
|
||||
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
|
||||
)
|
||||
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:
|
||||
@@ -220,8 +281,7 @@ class Runner:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f'[pipe 오류] {e}', flush=True)
|
||||
|
||||
print(f'[pipe 오류:{self.shell_id}] {e}', flush=True)
|
||||
if self.proc:
|
||||
try:
|
||||
await self.proc.wait()
|
||||
@@ -230,6 +290,7 @@ class Runner:
|
||||
try:
|
||||
await ws.send(json.dumps({
|
||||
'type': 'shell_exit',
|
||||
'shellId': self.shell_id,
|
||||
'code': self.proc.returncode if self.proc else -1,
|
||||
}))
|
||||
except Exception:
|
||||
@@ -237,9 +298,12 @@ class Runner:
|
||||
|
||||
def resize(self, cols: int, rows: int):
|
||||
if IS_WINDOWS:
|
||||
if hasattr(self, '_win_pty') and self._win_pty:
|
||||
if self._win_pty:
|
||||
try:
|
||||
self._win_pty.setwinsize(rows, cols)
|
||||
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
|
||||
@@ -253,16 +317,16 @@ class Runner:
|
||||
pass
|
||||
|
||||
def write_input(self, data: str):
|
||||
if IS_WINDOWS and hasattr(self, '_win_pty') and self._win_pty:
|
||||
if IS_WINDOWS and self._win_pty:
|
||||
try:
|
||||
self._win_pty.write(data)
|
||||
except Exception as e:
|
||||
print(f'[입력오류] {e}', flush=True)
|
||||
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'[입력오류] {e}', flush=True)
|
||||
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'))
|
||||
@@ -270,7 +334,38 @@ class Runner:
|
||||
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
|
||||
|
||||
|
||||
async def handle(ws):
|
||||
shells = {} # shell_id -> ShellSession
|
||||
runner = Runner()
|
||||
addr = ws.remote_address
|
||||
print(f'[연결] {addr}')
|
||||
@@ -285,6 +380,7 @@ async def handle(ws):
|
||||
|
||||
if t == 'run':
|
||||
await runner.kill()
|
||||
_refresh_env()
|
||||
|
||||
code = msg.get('code', '')
|
||||
filename = msg.get('filename', 'script.py')
|
||||
@@ -334,27 +430,52 @@ async def handle(ws):
|
||||
runner.tmp_path = None
|
||||
|
||||
elif t == 'shell':
|
||||
await runner.kill()
|
||||
shell_id = msg.get('shellId', 'default')
|
||||
rows = msg.get('rows', 24)
|
||||
cols = msg.get('cols', 80)
|
||||
print(f'[셸] 시작 ({cols}x{rows})')
|
||||
await runner.start_shell(ws, rows=rows, cols=cols)
|
||||
_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':
|
||||
runner.resize(msg.get('cols', 80), msg.get('rows', 24))
|
||||
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':
|
||||
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
|
||||
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':
|
||||
await runner.kill()
|
||||
print('[강제 종료]')
|
||||
await ws.send(json.dumps({'type': 'killed'}))
|
||||
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 == 'ping':
|
||||
await ws.send(json.dumps({'type': 'pong'}))
|
||||
@@ -362,6 +483,8 @@ async def handle(ws):
|
||||
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)
|
||||
|
||||
@@ -420,11 +543,14 @@ async def main():
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
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}')
|
||||
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}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
import pystray
|
||||
|
||||
Reference in New Issue
Block a user