"""실행 중인 밀리 PDF 연결 GUI. 앱 실행/종료 및 PDF 파일 변경 없음.""" import json from pathlib import Path import queue import re import subprocess import threading import time import tkinter as tk from tkinter import ttk from urllib.parse import urlparse, parse_qs, unquote import urllib.request HOOK = Path(__file__).with_name('millie_gui_hook.js').read_text(encoding='utf-8') def find_readers(): command = ( "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " "Get-CimInstance Win32_Process | Where-Object { " "$_.Name -eq 'msedgewebview2.exe' -and " "$_.CommandLine -like '*millie-desktop*' } | " "Select-Object CommandLine | ConvertTo-Json -Compress" ) result = subprocess.run( ['powershell.exe', '-NoProfile', '-Command', command], capture_output=True, encoding='utf-8-sig', errors='replace', creationflags=subprocess.CREATE_NO_WINDOW, timeout=20, ) if result.returncode: raise RuntimeError('밀리 프로세스를 확인하지 못했습니다: ' + result.stderr.strip()) rows = json.loads(result.stdout) if result.stdout.strip() else [] if isinstance(rows, dict): rows = [rows] ports = set() for row in rows: match = re.search(r'--remote-debugging-port=(\d+)', row.get('CommandLine') or '') if match and int(match[1]): ports.add(int(match[1])) if not ports: raise RuntimeError( '밀리의 디버깅 연결을 찾지 못했습니다.\n' '디버깅이 활성화된 밀리에서 PDF 책을 연 뒤 다시 확인하세요.\n' '이 프로그램은 밀리를 재실행하지 않습니다.' ) opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) readers, errors = [], [] for port in ports: try: with opener.open(f'http://127.0.0.1:{port}/json/list', timeout=3) as response: targets = json.load(response) for target in targets: url = urlparse(target.get('url', '')) if (target.get('type') == 'page' and url.hostname in ('localhost', '127.0.0.1') and parse_qs(url.query).get('bookSeq') and target.get('webSocketDebuggerUrl')): readers.append(target) except (OSError, ValueError) as error: errors.append(str(error)) if not readers: raise RuntimeError('열린 PDF 뷰어를 찾지 못했습니다. 밀리에서 PDF 책을 열어 주세요.' + ('\n연결 오류: ' + '; '.join(errors) if errors else '')) return readers class CDP: def __init__(self, url): try: import websocket except ImportError as error: raise RuntimeError('필요한 모듈을 설치하세요: python -m pip install websocket-client') from error self.ws = websocket.create_connection( url, timeout=5, suppress_origin=True, http_no_proxy=['localhost', '127.0.0.1']) self.seq = 0 def call(self, method, params=None): self.seq += 1 self.ws.send(json.dumps({'id': self.seq, 'method': method, 'params': params or {}})) deadline = time.monotonic() + 8 while time.monotonic() < deadline: reply = json.loads(self.ws.recv()) if reply.get('id') == self.seq: if 'error' in reply: raise RuntimeError(reply['error'].get('message', str(reply['error']))) return reply.get('result', {}) raise TimeoutError('뷰어 응답 시간이 초과됐습니다.') def evaluate(self, expression): reply = self.call('Runtime.evaluate', {'expression': expression, 'returnByValue': True}) if reply.get('exceptionDetails'): raise RuntimeError('뷰어 상태를 읽지 못했습니다.') return reply.get('result', {}).get('value') def close(self): self.ws.close() def capture(target, status=lambda message: None, cancelled=None): cancelled = cancelled or threading.Event() client = CDP(target['webSocketDebuggerUrl']) script_id = None try: state = client.evaluate('({url:location.href, page:window.pdf?.currentPage, ' 'supported:typeof window.PSPDF === "function" || ' 'typeof window.PDF === "function"})') if state['url'] != target['url']: raise RuntimeError('책이 변경됐습니다. 열린 책 목록을 다시 확인하세요.') if not state.get('supported'): raise RuntimeError('지원되는 PDF 뷰어가 아닙니다. PDF 책의 로딩이 끝난 뒤 다시 시도하세요.') cached = client.evaluate('window.__millieGuiResult || null') if cached and cached.get('hasPassword'): return cached client.call('Page.enable') script_id = client.call('Page.addScriptToEvaluateOnNewDocument', {'source': HOOK})['identifier'] status('현재 PDF 화면을 새로고침하고 있습니다…') client.call('Page.reload') deadline = time.monotonic() + 35 payload = None restored = False while time.monotonic() < deadline: if cancelled.is_set(): raise RuntimeError('캡처를 중단했습니다.') data = client.evaluate('({capture:window.__millieGuiResult || null, ' 'ready:!!window.pdf?.instance, url:location.href})') if not data: time.sleep(0.2) continue if data['url'] != state['url']: raise RuntimeError('캡처 중 다른 책으로 이동했습니다. 다시 시도하세요.') payload = data.get('capture') or payload if data.get('ready'): page = state.get('page') if isinstance(page, int) and not restored: client.evaluate(f'window.pdf.setPage({page})') restored = True if payload: if not payload.get('hasPassword'): raise RuntimeError('이 PDF의 초기화 과정에서는 암호가 전달되지 않았습니다.') return payload time.sleep(0.25) if payload and payload.get('hasPassword'): payload['warning'] = '암호는 캡처했지만 읽던 위치의 복원은 확인하지 못했습니다.' return payload raise RuntimeError('암호 전달을 감지하지 못했습니다. PDF가 정상적으로 열리는지 확인하세요.') finally: if script_id: try: client.call('Page.removeScriptToEvaluateOnNewDocument', {'identifier': script_id}) except Exception: pass client.close() class App: def __init__(self, root): self.root = root self.events = queue.Queue() self.cancelled = threading.Event() self.targets = [] self.busy = False root.title('밀리 PDF 암호 확인') root.geometry('760x420') root.minsize(650, 390) root.option_add('*Font', ('맑은 고딕', 10)) frame = ttk.Frame(root, padding=22) frame.pack(fill='both', expand=True) ttk.Label(frame, text='현재 열린 PDF 암호', font=('맑은 고딕', 17, 'bold')).pack(anchor='w') ttk.Label(frame, text='밀리에서 PDF 책을 연 뒤 선택하세요. 앱은 재실행하지 않습니다.').pack(anchor='w', pady=(8, 4)) ttk.Label(frame, text='첫 캡처 시 PDF 화면만 새로고침하며 읽던 위치를 복원합니다.').pack(anchor='w') row = ttk.Frame(frame) row.pack(fill='x', pady=(18, 12)) self.books = ttk.Combobox(row, state='readonly') self.books.pack(side='left', fill='x', expand=True) self.books.bind('<>', lambda event: self.clear_result()) self.refresh_button = ttk.Button(row, text='열린 책 확인', command=self.refresh) self.refresh_button.pack(side='left', padx=(8, 0)) self.capture_button = ttk.Button(frame, text='선택한 PDF 암호 확인', command=self.start_capture) self.capture_button.pack(anchor='w') self.filename = tk.StringVar(value='파일: —') ttk.Label(frame, textvariable=self.filename).pack(anchor='w', pady=(16, 5)) self.password = tk.StringVar() ttk.Entry(frame, textvariable=self.password, state='readonly', font=('Consolas', 10)).pack(fill='x') self.copy_button = ttk.Button(frame, text='암호 복사', command=self.copy, state='disabled') self.copy_button.pack(anchor='e', pady=8) self.status = tk.StringVar(value='열린 책을 확인하고 있습니다…') ttk.Label(frame, textvariable=self.status, wraplength=690, justify='left').pack(anchor='w') root.protocol('WM_DELETE_WINDOW', self.close) root.after(100, self.poll) root.after(200, self.refresh) def clear_result(self): self.password.set('') self.filename.set('파일: —') self.copy_button.configure(state='disabled') def work(self, operation, kind): if self.busy: return self.busy = True self.refresh_button.configure(state='disabled') self.capture_button.configure(state='disabled') self.books.configure(state='disabled') def run(): try: self.events.put((kind, operation())) except Exception as error: self.events.put(('error', str(error))) finally: self.events.put(('done', None)) threading.Thread(target=run, daemon=True).start() def refresh(self): if self.busy: return self.clear_result() self.targets = [] self.books.set('') self.books.configure(values=[]) self.status.set('열린 책을 확인하고 있습니다…') self.work(find_readers, 'readers') def start_capture(self): index = self.books.current() if self.busy or not 0 <= index < len(self.targets): self.status.set('먼저 열린 PDF 책을 선택하세요.') return self.clear_result() self.status.set('뷰어에 연결하고 있습니다…') self.work(lambda: capture(self.targets[index], lambda message: self.events.put(('status', message)), self.cancelled), 'capture') def poll(self): while not self.events.empty(): kind, value = self.events.get_nowait() if kind == 'done': self.busy = False self.refresh_button.configure(state='normal') self.capture_button.configure(state='normal' if self.targets else 'disabled') self.books.configure(state='readonly') elif kind in ('error', 'status'): self.status.set(value) elif kind == 'readers': self.targets = value self.books.configure(values=[t.get('title') or t['url'] for t in value]) self.books.current(0) self.status.set('암호를 확인할 PDF를 선택하고 버튼을 누르세요.') elif kind == 'capture': self.password.set(value['password']) self.filename.set('파일: ' + unquote(urlparse(value.get('url', '')).path.rsplit('/', 1)[-1])) self.copy_button.configure(state='normal') self.status.set(value.get('warning') or '암호를 캡처했습니다. 읽던 PDF 화면을 유지합니다.') self.root.after(100, self.poll) def copy(self): if self.password.get(): self.root.clipboard_clear() self.root.clipboard_append(self.password.get()) self.status.set('암호를 복사했습니다.') def close(self): self.cancelled.set() self.root.destroy() if __name__ == '__main__': root = tk.Tk() App(root) root.mainloop()