pywebview Desktop Apps
Build native-feeling desktop apps with a Python backend and a web-technology frontend, packaged as a single executable. Stack: pywebview (native window + JS↔Python bridge) + domain libs (ebooklib/pandas/pillow/…) + PyInstaller (single-file packaging).
When to use
Trigger phrases:
- "데스크톱 앱 만들어줘", "exe 파일로", "세련된 앱", "GUI 프로그램"
- "make me a desktop app / a little tool with a UI / package this as an exe"
- User wants a polished standalone utility (reader, viewer, editor, dashboard, converter)
NOT for:
- One-off HTML mockups →
claude-design, sketch, popular-web-designs
- Browser games →
browser-game-development
- Cross-platform with native widgets (Qt-style) → recommend PySide6 instead
Architecture
┌─────────────────────────────────────────┐
│ Native window (pywebview / WebView2) │
│ ┌───────────────────────────────────┐ │
│ │ HTML/CSS/JS UI (single string │ │
│ │ or bundled file) │ │
│ │ │ │
│ │ window.pywebview.api.method() │──┼──► Python Api class
│ └───────────────────────────────────┘ │ (your domain logic)
└─────────────────────────────────────────┘
Why pywebview over Electron: ~30MB exe vs ~150MB, uses the OS's webview (WebView2 on Windows, WKWebView on macOS, WebKitGTK on Linux), pure Python backend, no Node toolchain.
Why over Tkinter/Qt: modern CSS styling, flexbox/grid layouts, animations, the entire web design ecosystem.
Workflow
1. Scaffold the app (single file is fine to start)
import webview, json
class Api:
def do_thing(self, arg):
# Python domain logic — return JSON strings to JS
return json.dumps({"result": ...}, ensure_ascii=False)
HTML_UI = r"""<!DOCTYPE html>
<html>...your UI...
<script>
async function callPython() {
const result = await window.pywebview.api.do_thing("hello");
const data = JSON.parse(result);
}
</script>
</html>"""
if __name__ == '__main__':
api = Api()
window = webview.create_window(
'My App', html=HTML_UI, js_api=api,
width=1100, height=750, min_size=(800, 600),
background_color='#1a1a2e', text_select=True,
)
webview.start(debug=False) # debug=True opens DevTools
See templates/app_skeleton.py for a fuller starter (file dialog, theming, settings persistence).
2. Install deps
pip install pywebview pyinstaller
pip install <domain libs> # ebooklib, pandas, pillow, etc.
pip install <transitive deps> # beautifulsoup4, lxml — see pitfall #1
3. Smoke-test BEFORE packaging
Run python app.py in a background terminal and verify the window renders:
terminal(background=true): cd ~/myapp && python app.py
computer_use(action='capture', app='python', mode='vision') # confirm UI rendered
process(action='kill')
This catches missing modules and broken JS↔Python calls before you waste a 60-second PyInstaller build.
4. Package with PyInstaller
pyinstaller --noconfirm --onefile --windowed \
--name "MyApp" \
--hidden-import=<lib1> --hidden-import=<lib2> \
--collect-all <lib_with_data_files> \
app.py
Flag cheat sheet:
--windowed — no console window (REQUIRED for GUI apps; omit for debugging to see tracebacks)
--onefile — single exe (slower startup, ~5s extract; use --onedir if startup time matters)
--collect-all <pkg> — bundle a package's data files + submodules (needed for libs like ebooklib, babel, anything with templates/locales)
--hidden-import=<mod> — force-include modules PyInstaller's static analysis misses (libs that import dynamically)
--icon=app.ico — custom icon (Windows wants .ico, not .png)
Output: dist/MyApp.exe (or dist/MyApp.app on macOS).
5. Verify the exe
terminal(background=true): ./dist/MyApp.exe
computer_use(action='capture', app='MyApp', mode='vision')
Confirm the window title, UI rendered, no crash dialog. Then process(action='kill').
Pitfalls
Transitive deps not auto-detected. A lib like ebooklib imports bs4 lazily — pip install ebooklib does NOT install bs4, and the app crashes with ModuleNotFoundError on first use. Fix: run the app once, read the traceback, pip install the missing module, repeat. Common offenders: beautifulsoup4, lxml, html5lib, Pillow (for image libs).
PyInstaller misses dynamic imports. If a module is imported via importlib or string-based lookup, PyInstaller won't see it. Symptom: exe runs but feature X silently fails. Fix: --hidden-import=<module>. For whole packages with data files, --collect-all <pkg> is the bigger hammer.
--windowed hides tracebacks. If the exe crashes on startup you see nothing. Debug build: drop --windowed temporarily, run the exe from a terminal, read the console. Or webview.start(debug=True) for in-app DevTools.
File paths break under --onefile. PyInstaller extracts to a temp dir at runtime; __file__-relative paths point into the bundle. Use this helper for any bundled asset:
def resource_path(rel):
base = getattr(sys, '_MEIPASS', os.path.abspath('.'))
return os.path.join(base, rel)
User-facing paths (open/save dialogs, config files) should use Path.home() or app-data dirs, NOT the bundle dir.
create_file_dialog API drift. Older pywebview: window.create_file_dialog(webview.OPEN_DIALOG, ...). Newer (6.x): deprecation warning, prefers FileDialog.OPEN. Both still work as of 6.2; if you see the deprecation warning, the old form is fine for now but plan to migrate.
JS↔Python returns strings only. js_api methods must return JSON-serializable values; complex objects need json.dumps(..., ensure_ascii=False) (Korean/CJK will mojibake without ensure_ascii=False). On the JS side, JSON.parse(await window.pywebview.api.method()).
Don't use shell & backgrounding in terminal(). Hermes rejects cmd &. Use terminal(background=true) and process(action='poll'/'kill').
Content links navigate the webview → black screen (CRITICAL for content viewers). If your app renders external HTML (EPUB chapters, markdown, saved pages) into the webview, any <a href> the user clicks makes the webview try to navigate to a URL that doesn't exist → blank/black screen, app appears frozen and untouchable. This was a real user-reported bug. Fix: after injecting content, attach a click handler to every <a> that calls e.preventDefault(); e.stopPropagation(), then resolve the href yourself (exact match → normalized relative path → basename fallback) and route to the right content via your Python API. Same-chapter #anchors should smooth-scroll; http(s):// links should open in the real browser (webbrowser.open via an API method). Never let the webview itself navigate.
Tap-zone UIs must not fire on scroll-drags. For e-reader-style zones (left 30% = prev / center = menu toggle / right 30% = next), use pointerdown + pointerup and treat it as a click ONLY if: pointer moved < ~10px, elapsed < ~600ms, window.getSelection().isCollapsed (no text selection), and the target isn't a/button/input/select. Also ignore clicks within ~20px of the right edge (scrollbar). Otherwise a normal scroll gesture turns a page. Show a subtle ‹/› hover indicator so zones are discoverable.
pywebview has NO native file-drop event. To support drag-and-drop file opening, use JS dragenter/dragover/dragleave/drop listeners on document, read the dropped file via file.arrayBuffer(), base64-encode in 8KB chunks (String.fromCharCode.apply(null, subarray)), send to a Python API method that writes to a tempfile.NamedTemporaryFile(delete=False), loads it, then os.unlinks the temp. Show a full-screen overlay (position:fixed; inset:0; z-index:9999; pointer-events:none) with a dashed border box during drag. Use a dragCounter (increment on dragenter, decrement on dragleave) to handle nested element enter/leave correctly. Validate extension before processing.
PyInstaller rebuild fails if the exe is still running. Windows locks the file → PermissionError: [WinError 5] on os.remove. Fix: taskkill //F //IM MyApp.exe first (note: in MSYS/git-bash, use //F not /F to avoid path mangling), then rebuild. If taskkill doesn't release the lock immediately, mv dist/MyApp.exe dist/MyApp_old.exe works as a fallback since rename doesn't need the same lock as delete.
ebooklib Section is a subclass of Link. When flattening book.toc, check isinstance(item, epub.Section) BEFORE isinstance(item, epub.Link) in the elif chain — otherwise Sections are silently treated as Links and lose their heading semantics. Sections without an href should resolve to their first child chapter's index for a better UX (clicking a volume heading jumps to its first chapter).
Python-side and JS-side DOM selectors MUST match exactly for index-based features. When Python extracts elements (e.g. BeautifulSoup.find_all(['p','h1',...])) and JS tags/highlights the same elements (e.g. querySelectorAll('p, h1, ...')), the selector lists must be identical — same tags, same order, same filtering rules (e.g. len >= 2). If Python includes div but JS doesn't (or vice versa), highlight indices will be misaligned and the wrong paragraph gets highlighted. This is a silent bug — no error, just wrong UX. Test by counting elements on both sides for a known document.
TTS: prefer paragraph-level over sentence-level. Users find sentence-by-sentence playback choppy. Extract block elements (p, h1–h6, li, blockquote) as units and pass each whole paragraph to synthesize() — modern TTS engines (Supertonic-3) handle internal chunking with natural pauses. This also makes highlight tracking simpler (one highlight per paragraph, not per sentence).
HTML defined AFTER the <script> that references it → silent total JS failure (CRITICAL). Because a pywebview UI is one big HTML string, it's easy to append a new widget's markup below the <script> block. Any top-level $('someBtn').addEventListener(...) then runs while the element doesn't exist yet → $('someBtn') is null → null.addEventListener throws a TypeError → the entire script halts at that line, so every handler registered after it (including ones for elements that DO exist) never binds. Symptom: a feature "just doesn't work" with no error visible anywhere (especially under --windowed). This was the real cause of a user-reported "tts실행이 안돼". Fix options: (a) put ALL markup above the <script> that touches it; (b) wrap handler registration in window.addEventListener('DOMContentLoaded', () => {...}); (c) at minimum, null-guard: const el = $('x'); if (el) el.addEventListener(...). When a whole feature goes dark with no traceback, suspect script/DOM ordering FIRST.
window must be a MODULE-LEVEL global if background threads call evaluate_js. The standard pywebview scaffold puts window = webview.create_window(...) inside if __name__ == '__main__': — that makes it a local variable of the main block. A background engine thread (TTS, inference) that does window.evaluate_js(...) at module scope then hits NameError: name 'window' is not defined, or references a stale/None global. Symptom: Python→JS callbacks silently never fire; the frontend never learns that work finished. Fix: declare window = None at module scope, then global window; window = webview.create_window(...) in main (or assign to the module global). Guard the callback: if window is not None: window.evaluate_js(...). This pairs with pitfall #15 — when "the engine runs but the UI never updates," check both the JS handler binding AND that the Python callback can actually reach window.
Patch tool converts \r\n escape sequences to literal newlines. When inserting Python code that contains string literals like '\r\n' or '\r' via the patch tool, the tool's fuzzy matching interprets the backslash-r/backslash-n as actual carriage-return/line-feed characters, splitting one line into multiple broken lines and causing SyntaxError: unterminated string literal. Workarounds: (a) write the code using chr(13)+chr(10) instead of '\r\n' — functionally identical, no escape sequences to mangle; (b) if the damage is already done, fix via terminal with a Python script that reads the file's lines, deletes the broken fragments, and inserts the correct line; (c) use write_file for the whole file if the edit is large. Always run python -c "import ast; ast.parse(...)" after any patch that touches string literals with escape characters.
Debugging a feature that "doesn't work" in a --windowed exe
A windowed exe has no console, so a JS TypeError or a swallowed Python exception is invisible. Use this sequence — it resolved a TTS failure in one pass:
- Test the risky backend standalone FIRST, outside the app, with a
python -c one-liner that exercises the exact call chain (import → model load → compute → output). If it prints success, the backend is innocent and the bug is in the JS↔Python wiring or the frontend. This isolates the half of the stack to investigate before you go instrumenting.python -c "from supertonic import TTS; t=TTS(model='supertonic-3'); import sounddevice as sd; w,d=t.synthesize('테스트',voice_style=t.get_voice_style('F1'),lang='ko'); sd.play(w.squeeze(),t.sample_rate); sd.wait(); print('OK')"
- Add file-based debug logging (not prints — there's no console). A tiny append-to-file helper at module scope, called at every stage of the suspect path (API entry, thread start, model load, each unit, each
evaluate_js), writes a timestamped trail you can read after the fact:_LOG = os.path.join(os.environ.get('APPDATA','.'), 'MyApp', 'debug.log')
def _log(msg):
try:
os.makedirs(os.path.dirname(_LOG), exist_ok=True)
with open(_LOG, 'a', encoding='utf-8') as f:
f.write(f"[{__import__('datetime').datetime.now():%H:%M:%S}] {msg}\n")
except Exception: pass
Log exception tracebacks too (traceback.format_exc()), not just str(e).
- For pure-frontend suspicion, you don't even need the log — check script/DOM ordering (pitfall #15) and whether any top-level
$(...).addEventListener could hit a not-yet-rendered element. A single null there kills everything below it.
Background threads + JS callbacks (async long-running work)
For any feature that runs longer than a UI frame — TTS synthesis, model inference, batch conversion, audio playback — use this pattern:
import threading, json
class AsyncEngine:
def __init__(self):
self._stop = threading.Event()
self._thread = None
def start(self, ...):
self.stop()
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=3)
def _run(self):
for i, unit in enumerate(work_units):
if self._stop.is_set(): return
result = heavy_compute(unit) # synthesize / infer / convert
self._notify(f'onProgress({i}, {len(work_units)})')
self._notify(f'onDone()')
def _notify(self, js_code):
try:
window.evaluate_js(js_code) # push to frontend
except Exception:
pass
Key rules:
window.evaluate_js() is thread-safe from background threads (pywebview marshals to the UI thread).
- Define the JS callback functions (
onProgress, onDone, onError) as global functions in your HTML <script> so evaluate_js can call them by name.
- The JS side handles UI state (progress bar, auto-advance to next chapter/page) — Python only reports events.
- For auto-advance flows (e.g. TTS finishes chapter → JS navigates → JS calls
api().start_next(idx)), let JS own the navigation and call back into Python to start the next unit. This avoids race conditions between Python pushing navigation and the webview's render cycle.
- Always check
self._stop.is_set() between units AND after any blocking call (e.g. sd.wait() for audio).
PyInstaller flags for ML/inference packages:
--hidden-import=supertonic --hidden-import=sounddevice \
--hidden-import=soundfile --hidden-import=onnxruntime \
--collect-all supertonic
ONNX-based packages bundle model files + shared libs that static analysis misses. Expect exe size to grow 50-100MB. First run downloads the model to a cache dir (HuggingFace Hub); subsequent runs use the cache.
PyInstaller flags for PDF support (pymupdf):
--hidden-import=fitz --hidden-import=pymupdf --collect-all pymupdf
Excluding unwanted heavy deps from the bundle:
If your venv has large packages installed for experimentation (e.g. torch, transformers, qwen-tts) that the app does NOT use, PyInstaller will try to collect them → build times out or exe balloons to 2GB+. Add explicit excludes:
--exclude-module=torch --exclude-module=qwen_tts --exclude-module=transformers \
--exclude-module=accelerate --exclude-module=gradio --exclude-module=librosa \
--exclude-module=torchaudio --exclude-module=flash_attn
This keeps the build fast (3min) and the exe at expected size (120MB).
See references/supertonic-tts-integration.md for the full Supertonic-3 API: paragraph-level extraction, voice selection (F1F5/M1M5), volume gain, highlight+auto-scroll architecture, and the selector-matching requirement.
Multi-format content loading (viewers/readers)
When the app must open more than one file type, use a dispatcher + per-format loader pattern:
def load_file_auto(filepath):
ext = os.path.splitext(filepath)[1].lower()
loaders = {'.epub': load_epub, '.txt': load_txt, '.pdf': load_pdf,
'.html': load_html_file, '.htm': load_html_file}
loader = loaders.get(ext, load_txt) # fallback: treat as text
result = loader(filepath)
# Auto-register in library on success
try:
data = json.loads(result)
if not data.get('error'):
_add_to_library(filepath, data.get('title',''), data.get('author',''))
except Exception: pass
return result
Key patterns per format:
- TXT: try encodings in order
['utf-8', 'utf-8-sig', 'euc-kr', 'cp949', 'latin-1'] (Korean files are often EUC-KR/CP949). Do NOT split on \n{3,} or chunk at 5000 chars — that produces dozens of tiny choppy "chapters" that feel fragmented. Instead:
- Merge lines into paragraphs: consecutive non-blank lines join into one
<p> (space-joined); a blank line ends the paragraph. This gives natural prose flow instead of one-line-per-<p> choppiness.
- Detect chapter headings with a regex:
제\s*\d+\s*[장화절편권부과], Chapter\s*\d+, PART\s*\d+, Prologue, Epilogue, 서장, 에필로그, 프롤로그, numbered items (1. 제목), Roman numerals, separator lines (───). Split chapters at headings.
- Fallback chunking: if no headings found, split at 15000 chars (not 5000) to keep long stretches of prose together.
- Normalize line endings with
text.replace(chr(13)+chr(10), chr(10)).replace(chr(13), chr(10)) — see pitfall #17 for why NOT to write '\r\n' literally in patch-tool edits.
- PDF:
pymupdf (import fitz) — one chapter per page via page.get_text("text"). Metadata from doc.metadata. PyInstaller: --hidden-import=fitz --hidden-import=pymupdf --collect-all pymupdf.
- HTML: BeautifulSoup parse, strip
<script>/<style>, extract <body> or full soup. Single-chapter.
- EPUB: ebooklib spine-ordered extraction (see references/epub-reader-case-study.md).
All loaders return the same JSON shape: {title, author, bookKey, totalChapters, toc, linkMap, textLengths, chapter} — the frontend doesn't care about format.
File dialog: offer a combined filter first: '모든 지원 파일 (*.epub;*.txt;*.pdf;*.html;*.htm)' then per-format filters.
Drag-and-drop: validate extension against the supported list before processing (pitfall #10 pattern).
Library / bookshelf persistence
Store a library.json in the app-data dir (%APPDATA%/<AppName>/):
def _add_to_library(filepath, title='', author=''):
lib_file = os.path.join(STATE_DIR, 'library.json')
library = []
try:
with open(lib_file, 'r', encoding='utf-8') as f:
library = json.load(f)
except Exception: pass
abspath = os.path.abspath(filepath)
library = [b for b in library if b.get('path') != abspath] # dedup
library.insert(0, { # most recent first
'path': abspath, 'title': title, 'author': author,
'format': os.path.splitext(filepath)[1].lstrip('.').upper(),
'added': datetime.now().isoformat(),
})
with open(lib_file, 'w', encoding='utf-8') as f:
json.dump(library, f, ensure_ascii=False, indent=2)
Frontend: card grid (grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))), each card shows format badge + icon + title + author. Check os.path.isfile() server-side and flag missing files (dim the card, block open). Auto-register on every successful file open — user never manually "adds to library."
Full-text search (across chapters)
Python-side search is simpler and more reliable than JS DOM search for multi-chapter content:
def search_text(self, query):
query_lower = query.lower()
results = []
for i, (title, html, name, tlen) in enumerate(current_chapters):
soup = BeautifulSoup(html, 'html.parser')
text = soup.get_text()
for line in text.split('\n'):
if query_lower in line.lower():
results.append({"chapter": i, "title": title,
"snippet": line.strip()[:120]})
break # one hit per chapter is enough for navigation
return json.dumps(results, ensure_ascii=False)
Frontend: fixed search bar (Ctrl+F to open), input + Enter triggers search, results render as clickable items that call goToChapter(result.chapter). Escape closes.
UI design tips for "세련된" (polished) look
The user asked for stylish, not just functional. Defaults that read as polished:
- Dark theme first (
#1a1a2e / #16213e navy palette) with a violet accent (#6c63ff) — offer light + sepia as alternates via CSS variables and data-theme attribute
- CSS variables for theming:
--bg, --text, --accent, --border, --radius — swap via [data-theme="light"] selector
- System font stack:
'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif (no web font download needed)
- Grid layout for app shell:
grid-template-rows: auto 1fr auto (header / content / footer)
- Subtle motion: 0.2-0.3s transitions on hover,
fadeIn keyframe on content swap, backdrop-filter: blur() on header
- Persist settings in
localStorage (theme, font size) — restores on next launch
- Keyboard shortcuts: arrows for nav, Ctrl+O for open — wire via
document.addEventListener('keydown')
See templates/app_skeleton.py for a working example with all of the above.
Verification
The deliverable is a working exe, not source code. Before reporting done:
- exe exists at
dist/<Name>.exe with reasonable size (>10MB for a pywebview app)
- Launched it via
terminal(background=true)
computer_use capture shows the actual window with your UI rendered (not a crash dialog, not a blank window)
- Killed the test process
Report the absolute exe path so the user can find it. Offer a desktop shortcut as a follow-up.
Desktop shortcut (Windows):
powershell.exe -NoProfile -Command "
$ws = New-Object -ComObject WScript.Shell
$desktop = [Environment]::GetFolderPath('Desktop')
$sc = $ws.CreateShortcut(\"$desktop\MyApp.lnk\")
$sc.TargetPath = 'C:\Users\<user>\myapp\dist\MyApp.exe'
$sc.WorkingDirectory = 'C:\Users\<user>\myapp\dist'
$sc.Description = 'MyApp'
$sc.Save()
"
Note: PowerShell $ variables need \$ escaping when called from git-bash/MSYS terminal.
If computer_use fails with "session ... has ended; call start_session" — the cua-driver session died. Do NOT retry the same capture in a loop. Fallback:
- Screenshot directly:
python -c "from PIL import ImageGrab; import time; time.sleep(1.5); ImageGrab.grab().save('shot.png')" then vision_analyze the PNG.
- If another app (e.g. a fullscreen game) owns the screen, bring your window to front first via PowerShell
Add-Type P/Invoke of SetForegroundWindow/ShowWindow(…,9) on the process whose MainWindowTitle matches your app name.
- If the screen is genuinely un-capturable, verify the backend logic headlessly instead:
import the app module (guard window creation under if __name__=='__main__'), call its parse/resolve functions, and assert on the returned JSON. This proves the risky logic (link resolution, chapter extraction, image inlining) even without pixels. See references/epub-reader-case-study.md for the assertion script.
Support files
templates/app_skeleton.py — full starter: Api class, themed HTML/CSS/JS shell, file dialog, settings persistence, PyInstaller-friendly resource_path. Copy and modify.
references/epub-reader-case-study.md — concrete worked example (Lumina Reader): ebooklib integration, image inlining via data URIs, spine-ordered chapter extraction, the exact PyInstaller invocation that worked.
references/supertonic-tts-integration.md — Supertonic-3 local TTS: API reference, paragraph extraction, background-thread + evaluate_js callback architecture, auto-advance without duplicates, prefetch pipeline (pre-synthesize next 4 paragraphs to kill inter-paragraph delay), start-from-visible-paragraph (viewport detection), 0.5~2.0x speed control, TTS settings persistence (voice/speed/volume/autoVoice → state.json), auto next-chapter with render polling, gender-based automatic voice switching (GenderDetector for Korean novels), TTS model selection guide (RTF benchmarks, GPU compatibility), PyInstaller flags for ONNX packages.
1---2name: pywebview-desktop-apps3description: Use for Python desktop GUI apps shipped as a single .exe.4---56# pywebview Desktop Apps78Build native-feeling desktop apps with a Python backend and a web-technology frontend, packaged as a single executable. Stack: **pywebview** (native window + JS↔Python bridge) + domain libs (ebooklib/pandas/pillow/…) + **PyInstaller** (single-file packaging).910## When to use1112Trigger phrases:13- "데스크톱 앱 만들어줘", "exe 파일로", "세련된 앱", "GUI 프로그램"14- "make me a desktop app / a little tool with a UI / package this as an exe"15- User wants a polished standalone utility (reader, viewer, editor, dashboard, converter)1617NOT for:18- One-off HTML mockups → `claude-design`, `sketch`, `popular-web-designs`19- Browser games → `browser-game-development`20- Cross-platform with native widgets (Qt-style) → recommend PySide6 instead2122## Architecture2324```25┌─────────────────────────────────────────┐26│ Native window (pywebview / WebView2) │27│ ┌───────────────────────────────────┐ │28│ │ HTML/CSS/JS UI (single string │ │29│ │ or bundled file) │ │30│ │ │ │31│ │ window.pywebview.api.method() │──┼──► Python Api class32│ └───────────────────────────────────┘ │ (your domain logic)33└─────────────────────────────────────────┘34```3536**Why pywebview over Electron**: ~30MB exe vs ~150MB, uses the OS's webview (WebView2 on Windows, WKWebView on macOS, WebKitGTK on Linux), pure Python backend, no Node toolchain.3738**Why over Tkinter/Qt**: modern CSS styling, flexbox/grid layouts, animations, the entire web design ecosystem.3940## Workflow4142### 1. Scaffold the app (single file is fine to start)4344```python45import webview, json4647class Api:48 def do_thing(self, arg):49 # Python domain logic — return JSON strings to JS50 return json.dumps({"result": ...}, ensure_ascii=False)5152HTML_UI = r"""<!DOCTYPE html>53<html>...your UI...54<script>55async function callPython() {56 const result = await window.pywebview.api.do_thing("hello");57 const data = JSON.parse(result);58}59</script>60</html>"""6162if __name__ == '__main__':63 api = Api()64 window = webview.create_window(65 'My App', html=HTML_UI, js_api=api,66 width=1100, height=750, min_size=(800, 600),67 background_color='#1a1a2e', text_select=True,68 )69 webview.start(debug=False) # debug=True opens DevTools70```7172See `templates/app_skeleton.py` for a fuller starter (file dialog, theming, settings persistence).7374### 2. Install deps7576```bash77pip install pywebview pyinstaller78pip install <domain libs> # ebooklib, pandas, pillow, etc.79pip install <transitive deps> # beautifulsoup4, lxml — see pitfall #180```8182### 3. Smoke-test BEFORE packaging8384Run `python app.py` in a background terminal and verify the window renders:8586```87terminal(background=true): cd ~/myapp && python app.py88computer_use(action='capture', app='python', mode='vision') # confirm UI rendered89process(action='kill')90```9192This catches missing modules and broken JS↔Python calls before you waste a 60-second PyInstaller build.9394### 4. Package with PyInstaller9596```bash97pyinstaller --noconfirm --onefile --windowed \98 --name "MyApp" \99 --hidden-import=<lib1> --hidden-import=<lib2> \100 --collect-all <lib_with_data_files> \101 app.py102```103104Flag cheat sheet:105- `--windowed` — no console window (REQUIRED for GUI apps; omit for debugging to see tracebacks)106- `--onefile` — single exe (slower startup, ~5s extract; use `--onedir` if startup time matters)107- `--collect-all <pkg>` — bundle a package's data files + submodules (needed for libs like `ebooklib`, `babel`, anything with templates/locales)108- `--hidden-import=<mod>` — force-include modules PyInstaller's static analysis misses (libs that import dynamically)109- `--icon=app.ico` — custom icon (Windows wants .ico, not .png)110111Output: `dist/MyApp.exe` (or `dist/MyApp.app` on macOS).112113### 5. Verify the exe114115```116terminal(background=true): ./dist/MyApp.exe117computer_use(action='capture', app='MyApp', mode='vision')118```119120Confirm the window title, UI rendered, no crash dialog. Then `process(action='kill')`.121122## Pitfalls1231241. **Transitive deps not auto-detected.** A lib like `ebooklib` imports `bs4` lazily — `pip install ebooklib` does NOT install bs4, and the app crashes with `ModuleNotFoundError` on first use. Fix: run the app once, read the traceback, `pip install` the missing module, repeat. Common offenders: `beautifulsoup4`, `lxml`, `html5lib`, `Pillow` (for image libs).1251262. **PyInstaller misses dynamic imports.** If a module is imported via `importlib` or string-based lookup, PyInstaller won't see it. Symptom: exe runs but feature X silently fails. Fix: `--hidden-import=<module>`. For whole packages with data files, `--collect-all <pkg>` is the bigger hammer.1271283. **`--windowed` hides tracebacks.** If the exe crashes on startup you see nothing. Debug build: drop `--windowed` temporarily, run the exe from a terminal, read the console. Or `webview.start(debug=True)` for in-app DevTools.1291304. **File paths break under `--onefile`.** PyInstaller extracts to a temp dir at runtime; `__file__`-relative paths point into the bundle. Use this helper for any bundled asset:131 ```python132 def resource_path(rel):133 base = getattr(sys, '_MEIPASS', os.path.abspath('.'))134 return os.path.join(base, rel)135 ```136 User-facing paths (open/save dialogs, config files) should use `Path.home()` or app-data dirs, NOT the bundle dir.1371385. **`create_file_dialog` API drift.** Older pywebview: `window.create_file_dialog(webview.OPEN_DIALOG, ...)`. Newer (6.x): deprecation warning, prefers `FileDialog.OPEN`. Both still work as of 6.2; if you see the deprecation warning, the old form is fine for now but plan to migrate.1391406. **JS↔Python returns strings only.** `js_api` methods must return JSON-serializable values; complex objects need `json.dumps(..., ensure_ascii=False)` (Korean/CJK will mojibake without `ensure_ascii=False`). On the JS side, `JSON.parse(await window.pywebview.api.method())`.1411427. **Don't use shell `&` backgrounding in `terminal()`.** Hermes rejects `cmd &`. Use `terminal(background=true)` and `process(action='poll'/'kill')`.1431448. **Content links navigate the webview → black screen (CRITICAL for content viewers).** If your app renders *external* HTML (EPUB chapters, markdown, saved pages) into the webview, any `<a href>` the user clicks makes the webview try to **navigate** to a URL that doesn't exist → blank/black screen, app appears frozen and untouchable. This was a real user-reported bug. Fix: after injecting content, attach a click handler to every `<a>` that calls `e.preventDefault(); e.stopPropagation()`, then resolve the href yourself (exact match → normalized relative path → basename fallback) and route to the right content via your Python API. Same-chapter `#anchors` should smooth-scroll; `http(s)://` links should open in the real browser (`webbrowser.open` via an API method). **Never let the webview itself navigate.**1451469. **Tap-zone UIs must not fire on scroll-drags.** For e-reader-style zones (left 30% = prev / center = menu toggle / right 30% = next), use `pointerdown` + `pointerup` and treat it as a click ONLY if: pointer moved < ~10px, elapsed < ~600ms, `window.getSelection().isCollapsed` (no text selection), and the target isn't `a/button/input/select`. Also ignore clicks within ~20px of the right edge (scrollbar). Otherwise a normal scroll gesture turns a page. Show a subtle `‹`/`›` hover indicator so zones are discoverable.14714810. **pywebview has NO native file-drop event.** To support drag-and-drop file opening, use JS `dragenter/dragover/dragleave/drop` listeners on `document`, read the dropped file via `file.arrayBuffer()`, base64-encode in 8KB chunks (`String.fromCharCode.apply(null, subarray)`), send to a Python API method that writes to a `tempfile.NamedTemporaryFile(delete=False)`, loads it, then `os.unlink`s the temp. Show a full-screen overlay (`position:fixed; inset:0; z-index:9999; pointer-events:none`) with a dashed border box during drag. Use a `dragCounter` (increment on dragenter, decrement on dragleave) to handle nested element enter/leave correctly. Validate extension before processing.14915011. **PyInstaller rebuild fails if the exe is still running.** Windows locks the file → `PermissionError: [WinError 5]` on `os.remove`. Fix: `taskkill //F //IM MyApp.exe` first (note: in MSYS/git-bash, use `//F` not `/F` to avoid path mangling), then rebuild. If taskkill doesn't release the lock immediately, `mv dist/MyApp.exe dist/MyApp_old.exe` works as a fallback since rename doesn't need the same lock as delete.15115212. **ebooklib `Section` is a subclass of `Link`.** When flattening `book.toc`, check `isinstance(item, epub.Section)` BEFORE `isinstance(item, epub.Link)` in the elif chain — otherwise Sections are silently treated as Links and lose their heading semantics. Sections without an href should resolve to their first child chapter's index for a better UX (clicking a volume heading jumps to its first chapter).15315413. **Python-side and JS-side DOM selectors MUST match exactly for index-based features.** When Python extracts elements (e.g. `BeautifulSoup.find_all(['p','h1',...])`) and JS tags/highlights the same elements (e.g. `querySelectorAll('p, h1, ...')`), the selector lists must be identical — same tags, same order, same filtering rules (e.g. `len >= 2`). If Python includes `div` but JS doesn't (or vice versa), highlight indices will be misaligned and the wrong paragraph gets highlighted. This is a silent bug — no error, just wrong UX. Test by counting elements on both sides for a known document.15515614. **TTS: prefer paragraph-level over sentence-level.** Users find sentence-by-sentence playback choppy. Extract block elements (`p`, `h1`–`h6`, `li`, `blockquote`) as units and pass each whole paragraph to `synthesize()` — modern TTS engines (Supertonic-3) handle internal chunking with natural pauses. This also makes highlight tracking simpler (one highlight per paragraph, not per sentence).15715815. **HTML defined AFTER the `<script>` that references it → silent total JS failure (CRITICAL).** Because a pywebview UI is one big HTML string, it's easy to append a new widget's markup *below* the `<script>` block. Any top-level `$('someBtn').addEventListener(...)` then runs while the element doesn't exist yet → `$('someBtn')` is `null` → `null.addEventListener` throws a `TypeError` → **the entire script halts at that line**, so every handler registered after it (including ones for elements that DO exist) never binds. Symptom: a feature "just doesn't work" with no error visible anywhere (especially under `--windowed`). This was the real cause of a user-reported "tts실행이 안돼". **Fix options:** (a) put ALL markup above the `<script>` that touches it; (b) wrap handler registration in `window.addEventListener('DOMContentLoaded', () => {...})`; (c) at minimum, null-guard: `const el = $('x'); if (el) el.addEventListener(...)`. When a whole feature goes dark with no traceback, suspect script/DOM ordering FIRST.15916016. **`window` must be a MODULE-LEVEL global if background threads call `evaluate_js`.** The standard pywebview scaffold puts `window = webview.create_window(...)` inside `if __name__ == '__main__':` — that makes it a *local* variable of the main block. A background engine thread (TTS, inference) that does `window.evaluate_js(...)` at module scope then hits `NameError: name 'window' is not defined`, or references a stale/None global. Symptom: Python→JS callbacks silently never fire; the frontend never learns that work finished. **Fix:** declare `window = None` at module scope, then `global window; window = webview.create_window(...)` in main (or assign to the module global). Guard the callback: `if window is not None: window.evaluate_js(...)`. This pairs with pitfall #15 — when "the engine runs but the UI never updates," check both the JS handler binding AND that the Python callback can actually reach `window`.16116217. **Patch tool converts `\r\n` escape sequences to literal newlines.** When inserting Python code that contains string literals like `'\r\n'` or `'\r'` via the `patch` tool, the tool's fuzzy matching interprets the backslash-r/backslash-n as actual carriage-return/line-feed characters, splitting one line into multiple broken lines and causing `SyntaxError: unterminated string literal`. **Workarounds:** (a) write the code using `chr(13)+chr(10)` instead of `'\r\n'` — functionally identical, no escape sequences to mangle; (b) if the damage is already done, fix via `terminal` with a Python script that reads the file's lines, deletes the broken fragments, and inserts the correct line; (c) use `write_file` for the whole file if the edit is large. Always run `python -c "import ast; ast.parse(...)"` after any patch that touches string literals with escape characters.163164## Debugging a feature that "doesn't work" in a `--windowed` exe165166A windowed exe has no console, so a JS TypeError or a swallowed Python exception is invisible. Use this sequence — it resolved a TTS failure in one pass:1671681. **Test the risky backend standalone FIRST**, outside the app, with a `python -c` one-liner that exercises the exact call chain (import → model load → compute → output). If it prints success, the backend is innocent and the bug is in the JS↔Python wiring or the frontend. This isolates the half of the stack to investigate before you go instrumenting.169 ```bash170 python -c "from supertonic import TTS; t=TTS(model='supertonic-3'); import sounddevice as sd; w,d=t.synthesize('테스트',voice_style=t.get_voice_style('F1'),lang='ko'); sd.play(w.squeeze(),t.sample_rate); sd.wait(); print('OK')"171 ```1722. **Add file-based debug logging** (not prints — there's no console). A tiny append-to-file helper at module scope, called at every stage of the suspect path (API entry, thread start, model load, each unit, each `evaluate_js`), writes a timestamped trail you can read after the fact:173 ```python174 _LOG = os.path.join(os.environ.get('APPDATA','.'), 'MyApp', 'debug.log')175 def _log(msg):176 try:177 os.makedirs(os.path.dirname(_LOG), exist_ok=True)178 with open(_LOG, 'a', encoding='utf-8') as f:179 f.write(f"[{__import__('datetime').datetime.now():%H:%M:%S}] {msg}\n")180 except Exception: pass181 ```182 Log exception tracebacks too (`traceback.format_exc()`), not just `str(e)`.1833. **For pure-frontend suspicion, you don't even need the log** — check script/DOM ordering (pitfall #15) and whether any top-level `$(...).addEventListener` could hit a not-yet-rendered element. A single null there kills everything below it.184185## Background threads + JS callbacks (async long-running work)186187For any feature that runs longer than a UI frame — TTS synthesis, model inference, batch conversion, audio playback — use this pattern:188189```python190import threading, json191192class AsyncEngine:193 def __init__(self):194 self._stop = threading.Event()195 self._thread = None196197 def start(self, ...):198 self.stop()199 self._stop.clear()200 self._thread = threading.Thread(target=self._run, daemon=True)201 self._thread.start()202203 def stop(self):204 self._stop.set()205 if self._thread and self._thread.is_alive():206 self._thread.join(timeout=3)207208 def _run(self):209 for i, unit in enumerate(work_units):210 if self._stop.is_set(): return211 result = heavy_compute(unit) # synthesize / infer / convert212 self._notify(f'onProgress({i}, {len(work_units)})')213 self._notify(f'onDone()')214215 def _notify(self, js_code):216 try:217 window.evaluate_js(js_code) # push to frontend218 except Exception:219 pass220```221222**Key rules:**223- `window.evaluate_js()` is thread-safe from background threads (pywebview marshals to the UI thread).224- Define the JS callback functions (`onProgress`, `onDone`, `onError`) as **global functions** in your HTML `<script>` so `evaluate_js` can call them by name.225- The JS side handles UI state (progress bar, auto-advance to next chapter/page) — Python only reports events.226- For auto-advance flows (e.g. TTS finishes chapter → JS navigates → JS calls `api().start_next(idx)`), let **JS own the navigation** and call back into Python to start the next unit. This avoids race conditions between Python pushing navigation and the webview's render cycle.227- Always check `self._stop.is_set()` between units AND after any blocking call (e.g. `sd.wait()` for audio).228229**PyInstaller flags for ML/inference packages:**230```bash231--hidden-import=supertonic --hidden-import=sounddevice \232--hidden-import=soundfile --hidden-import=onnxruntime \233--collect-all supertonic234```235ONNX-based packages bundle model files + shared libs that static analysis misses. Expect exe size to grow 50-100MB. First run downloads the model to a cache dir (HuggingFace Hub); subsequent runs use the cache.236237**PyInstaller flags for PDF support (pymupdf):**238```bash239--hidden-import=fitz --hidden-import=pymupdf --collect-all pymupdf240```241242**Excluding unwanted heavy deps from the bundle:**243If your venv has large packages installed for experimentation (e.g. `torch`, `transformers`, `qwen-tts`) that the app does NOT use, PyInstaller will try to collect them → build times out or exe balloons to 2GB+. Add explicit excludes:244```bash245--exclude-module=torch --exclude-module=qwen_tts --exclude-module=transformers \246--exclude-module=accelerate --exclude-module=gradio --exclude-module=librosa \247--exclude-module=torchaudio --exclude-module=flash_attn248```249This keeps the build fast (~3min) and the exe at expected size (~120MB).250251See `references/supertonic-tts-integration.md` for the full Supertonic-3 API: paragraph-level extraction, voice selection (F1~F5/M1~M5), volume gain, highlight+auto-scroll architecture, and the selector-matching requirement.252253## Multi-format content loading (viewers/readers)254255When the app must open more than one file type, use a **dispatcher + per-format loader** pattern:256257```python258def load_file_auto(filepath):259 ext = os.path.splitext(filepath)[1].lower()260 loaders = {'.epub': load_epub, '.txt': load_txt, '.pdf': load_pdf,261 '.html': load_html_file, '.htm': load_html_file}262 loader = loaders.get(ext, load_txt) # fallback: treat as text263 result = loader(filepath)264 # Auto-register in library on success265 try:266 data = json.loads(result)267 if not data.get('error'):268 _add_to_library(filepath, data.get('title',''), data.get('author',''))269 except Exception: pass270 return result271```272273**Key patterns per format:**274- **TXT**: try encodings in order `['utf-8', 'utf-8-sig', 'euc-kr', 'cp949', 'latin-1']` (Korean files are often EUC-KR/CP949). **Do NOT split on `\n{3,}` or chunk at 5000 chars** — that produces dozens of tiny choppy "chapters" that feel fragmented. Instead:275 1. **Merge lines into paragraphs**: consecutive non-blank lines join into one `<p>` (space-joined); a blank line ends the paragraph. This gives natural prose flow instead of one-line-per-`<p>` choppiness.276 2. **Detect chapter headings** with a regex: `제\s*\d+\s*[장화절편권부과]`, `Chapter\s*\d+`, `PART\s*\d+`, `Prologue`, `Epilogue`, `서장`, `에필로그`, `프롤로그`, numbered items (`1. 제목`), Roman numerals, separator lines (`───`). Split chapters at headings.277 3. **Fallback chunking**: if no headings found, split at **15000 chars** (not 5000) to keep long stretches of prose together.278 4. Normalize line endings with `text.replace(chr(13)+chr(10), chr(10)).replace(chr(13), chr(10))` — see pitfall #17 for why NOT to write `'\r\n'` literally in patch-tool edits.279- **PDF**: `pymupdf` (`import fitz`) — one chapter per page via `page.get_text("text")`. Metadata from `doc.metadata`. PyInstaller: `--hidden-import=fitz --hidden-import=pymupdf --collect-all pymupdf`.280- **HTML**: BeautifulSoup parse, strip `<script>`/`<style>`, extract `<body>` or full soup. Single-chapter.281- **EPUB**: ebooklib spine-ordered extraction (see references/epub-reader-case-study.md).282283All loaders return the same JSON shape: `{title, author, bookKey, totalChapters, toc, linkMap, textLengths, chapter}` — the frontend doesn't care about format.284285**File dialog**: offer a combined filter first: `'모든 지원 파일 (*.epub;*.txt;*.pdf;*.html;*.htm)'` then per-format filters.286287**Drag-and-drop**: validate extension against the supported list before processing (pitfall #10 pattern).288289## Library / bookshelf persistence290291Store a `library.json` in the app-data dir (`%APPDATA%/<AppName>/`):292293```python294def _add_to_library(filepath, title='', author=''):295 lib_file = os.path.join(STATE_DIR, 'library.json')296 library = []297 try:298 with open(lib_file, 'r', encoding='utf-8') as f:299 library = json.load(f)300 except Exception: pass301 abspath = os.path.abspath(filepath)302 library = [b for b in library if b.get('path') != abspath] # dedup303 library.insert(0, { # most recent first304 'path': abspath, 'title': title, 'author': author,305 'format': os.path.splitext(filepath)[1].lstrip('.').upper(),306 'added': datetime.now().isoformat(),307 })308 with open(lib_file, 'w', encoding='utf-8') as f:309 json.dump(library, f, ensure_ascii=False, indent=2)310```311312**Frontend**: card grid (`grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))`), each card shows format badge + icon + title + author. Check `os.path.isfile()` server-side and flag missing files (dim the card, block open). Auto-register on every successful file open — user never manually "adds to library."313314## Full-text search (across chapters)315316Python-side search is simpler and more reliable than JS DOM search for multi-chapter content:317318```python319def search_text(self, query):320 query_lower = query.lower()321 results = []322 for i, (title, html, name, tlen) in enumerate(current_chapters):323 soup = BeautifulSoup(html, 'html.parser')324 text = soup.get_text()325 for line in text.split('\n'):326 if query_lower in line.lower():327 results.append({"chapter": i, "title": title,328 "snippet": line.strip()[:120]})329 break # one hit per chapter is enough for navigation330 return json.dumps(results, ensure_ascii=False)331```332333**Frontend**: fixed search bar (Ctrl+F to open), input + Enter triggers search, results render as clickable items that call `goToChapter(result.chapter)`. Escape closes.334335## UI design tips for "세련된" (polished) look336337The user asked for *stylish*, not just functional. Defaults that read as polished:338- **Dark theme first** (`#1a1a2e` / `#16213e` navy palette) with a violet accent (`#6c63ff`) — offer light + sepia as alternates via CSS variables and `data-theme` attribute339- **CSS variables for theming**: `--bg`, `--text`, `--accent`, `--border`, `--radius` — swap via `[data-theme="light"]` selector340- **System font stack**: `'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif` (no web font download needed)341- **Grid layout** for app shell: `grid-template-rows: auto 1fr auto` (header / content / footer)342- **Subtle motion**: 0.2-0.3s transitions on hover, `fadeIn` keyframe on content swap, `backdrop-filter: blur()` on header343- **Persist settings** in `localStorage` (theme, font size) — restores on next launch344- **Keyboard shortcuts**: arrows for nav, Ctrl+O for open — wire via `document.addEventListener('keydown')`345346See `templates/app_skeleton.py` for a working example with all of the above.347348## Verification349350The deliverable is a **working exe**, not source code. Before reporting done:3511. exe exists at `dist/<Name>.exe` with reasonable size (>10MB for a pywebview app)3522. Launched it via `terminal(background=true)`3533. `computer_use` capture shows the actual window with your UI rendered (not a crash dialog, not a blank window)3544. Killed the test process355356Report the absolute exe path so the user can find it. Offer a desktop shortcut as a follow-up.357358**Desktop shortcut (Windows):**359```powershell360powershell.exe -NoProfile -Command "361$ws = New-Object -ComObject WScript.Shell362$desktop = [Environment]::GetFolderPath('Desktop')363$sc = $ws.CreateShortcut(\"$desktop\MyApp.lnk\")364$sc.TargetPath = 'C:\Users\<user>\myapp\dist\MyApp.exe'365$sc.WorkingDirectory = 'C:\Users\<user>\myapp\dist'366$sc.Description = 'MyApp'367$sc.Save()368"369```370Note: PowerShell `$` variables need `\$` escaping when called from git-bash/MSYS terminal.371372**If `computer_use` fails with "session ... has ended; call start_session"** — the cua-driver session died. Do NOT retry the same capture in a loop. Fallback:373- Screenshot directly: `python -c "from PIL import ImageGrab; import time; time.sleep(1.5); ImageGrab.grab().save('shot.png')"` then `vision_analyze` the PNG.374- If another app (e.g. a fullscreen game) owns the screen, bring your window to front first via PowerShell `Add-Type` P/Invoke of `SetForegroundWindow`/`ShowWindow(…,9)` on the process whose `MainWindowTitle` matches your app name.375- If the screen is genuinely un-capturable, verify the **backend logic headlessly** instead: `import` the app module (guard window creation under `if __name__=='__main__'`), call its parse/resolve functions, and assert on the returned JSON. This proves the risky logic (link resolution, chapter extraction, image inlining) even without pixels. See `references/epub-reader-case-study.md` for the assertion script.376377## Support files378379- `templates/app_skeleton.py` — full starter: Api class, themed HTML/CSS/JS shell, file dialog, settings persistence, PyInstaller-friendly `resource_path`. Copy and modify.380- `references/epub-reader-case-study.md` — concrete worked example (Lumina Reader): ebooklib integration, image inlining via data URIs, spine-ordered chapter extraction, the exact PyInstaller invocation that worked.381- `references/supertonic-tts-integration.md` — Supertonic-3 local TTS: API reference, paragraph extraction, background-thread + evaluate_js callback architecture, auto-advance without duplicates, **prefetch pipeline (pre-synthesize next 4 paragraphs to kill inter-paragraph delay)**, **start-from-visible-paragraph (viewport detection)**, **0.5~2.0x speed control**, **TTS settings persistence (voice/speed/volume/autoVoice → state.json)**, **auto next-chapter with render polling**, **gender-based automatic voice switching (GenderDetector for Korean novels)**, TTS model selection guide (RTF benchmarks, GPU compatibility), PyInstaller flags for ONNX packages.