#!/usr/bin/env python3
"""PhoneDrop — 手機 ⇄ 電腦 同一個 Wi-Fi 底下互傳照片 / 檔案 / 文字。

單一檔案、只用 Python 標準函式庫、手機不用裝 App：
  1. 電腦執行  python phonedrop.py
  2. 手機相機掃終端機（或電腦瀏覽器頁面）上的 QR code
  3. 手機瀏覽器就能上傳照片到電腦、下載電腦放進共享資料夾的檔案、互傳文字/連結

選項：
  --dir PATH    共享資料夾（預設 ~/Downloads/PhoneDrop）
  --port N      連接埠（預設 8790）
  --host IP     多網卡時指定要公布的 IP
  --code XXXX   固定配對碼（預設隨機產生並存在 ~/.phonedrop.key 重複使用）
  --no-auth     不要配對碼（同網段任何人都能用，不建議）
  --no-open     啟動時不要自動開電腦瀏覽器
"""
from __future__ import annotations

import argparse
import hashlib
import html
import json
import mimetypes
import os
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import threading
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, quote, unquote, urlsplit

__version__ = "1.0.0"

# ============================================================================
# QR code 編碼器（byte mode，版本 1–10，四種糾錯等級）
# 純標準函式庫實作；已用 python-qrcode 逐格比對、遮罩評分用 segno 比對驗證（見 tests/test_qr.py）。
# ============================================================================

# version -> level -> (每個區塊的 EC codeword 數, [(區塊數, 每區塊資料 codeword 數), ...])
_QR_EC = {
    1: {"L": (7, [(1, 19)]), "M": (10, [(1, 16)]), "Q": (13, [(1, 13)]), "H": (17, [(1, 9)])},
    2: {"L": (10, [(1, 34)]), "M": (16, [(1, 28)]), "Q": (22, [(1, 22)]), "H": (28, [(1, 16)])},
    3: {"L": (15, [(1, 55)]), "M": (26, [(1, 44)]), "Q": (18, [(2, 17)]), "H": (22, [(2, 13)])},
    4: {"L": (20, [(1, 80)]), "M": (18, [(2, 32)]), "Q": (26, [(2, 24)]), "H": (16, [(4, 9)])},
    5: {"L": (26, [(1, 108)]), "M": (24, [(2, 43)]), "Q": (18, [(2, 15), (2, 16)]), "H": (22, [(2, 11), (2, 12)])},
    6: {"L": (18, [(2, 68)]), "M": (16, [(4, 27)]), "Q": (24, [(4, 19)]), "H": (28, [(4, 15)])},
    7: {"L": (20, [(2, 78)]), "M": (18, [(4, 31)]), "Q": (18, [(2, 14), (4, 15)]), "H": (26, [(4, 13), (1, 14)])},
    8: {"L": (24, [(2, 97)]), "M": (22, [(2, 38), (2, 39)]), "Q": (22, [(4, 18), (2, 19)]), "H": (26, [(4, 14), (2, 15)])},
    9: {"L": (30, [(2, 116)]), "M": (22, [(3, 36), (2, 37)]), "Q": (20, [(4, 16), (4, 17)]), "H": (24, [(4, 12), (4, 13)])},
    10: {"L": (18, [(2, 68), (2, 69)]), "M": (26, [(4, 43), (1, 44)]), "Q": (24, [(6, 19), (2, 20)]), "H": (28, [(6, 15), (2, 16)])},
}
_QR_ALIGN = {1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34],
             7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50]}

# GF(256) 查表，本原多項式 0x11D
_GF_EXP = [0] * 512
_GF_LOG = [0] * 256
_x = 1
for _i in range(255):
    _GF_EXP[_i] = _x
    _GF_LOG[_x] = _i
    _x <<= 1
    if _x & 0x100:
        _x ^= 0x11D
for _i in range(255, 512):
    _GF_EXP[_i] = _GF_EXP[_i - 255]


def _gf_mul(a: int, b: int) -> int:
    return 0 if a == 0 or b == 0 else _GF_EXP[_GF_LOG[a] + _GF_LOG[b]]


def _rs_remainder(data: list[int], n: int) -> list[int]:
    """Reed-Solomon：回傳 data 對應的 n 個糾錯 codeword。"""
    gen = [1]
    for i in range(n):
        nxt = [0] * (len(gen) + 1)
        for j, c in enumerate(gen):
            nxt[j] ^= c
            nxt[j + 1] ^= _gf_mul(c, _GF_EXP[i])
        gen = nxt
    buf = list(data) + [0] * n
    for i in range(len(data)):
        coef = buf[i]
        if coef:
            for j in range(1, len(gen)):
                buf[i + j] ^= _gf_mul(gen[j], coef)
    return buf[len(data):]


def _qr_capacity(version: int, level: str) -> int:
    ec_n, groups = _QR_EC[version][level]
    total = sum(n * k for n, k in groups)
    return (total * 8 - 4 - (8 if version < 10 else 16)) // 8


def _qr_codewords(data: bytes, version: int, level: str) -> list[int]:
    ec_n, groups = _QR_EC[version][level]
    total = sum(n * k for n, k in groups)
    bits: list[int] = []

    def put(val: int, width: int) -> None:
        for i in range(width - 1, -1, -1):
            bits.append((val >> i) & 1)

    put(0b0100, 4)                                   # byte mode
    put(len(data), 8 if version < 10 else 16)        # 字元數
    for b in data:
        put(b, 8)
    put(0, min(4, total * 8 - len(bits)))            # terminator
    while len(bits) % 8:
        bits.append(0)
    cw = [int("".join(map(str, bits[i:i + 8])), 2) for i in range(0, len(bits), 8)]
    for i in range(total - len(cw)):                 # pad codewords
        cw.append(0xEC if i % 2 == 0 else 0x11)

    blocks, ecs = [], []
    pos = 0
    for n, k in groups:
        for _ in range(n):
            blk = cw[pos:pos + k]
            pos += k
            blocks.append(blk)
            ecs.append(_rs_remainder(blk, ec_n))
    out: list[int] = []
    for i in range(max(len(b) for b in blocks)):     # 交錯排列
        out.extend(b[i] for b in blocks if i < len(b))
    for i in range(ec_n):
        out.extend(e[i] for e in ecs)
    return out


_QR_MASKS = [
    lambda r, c: (r + c) % 2 == 0,
    lambda r, c: r % 2 == 0,
    lambda r, c: c % 3 == 0,
    lambda r, c: (r + c) % 3 == 0,
    lambda r, c: (r // 2 + c // 3) % 2 == 0,
    lambda r, c: (r * c) % 2 + (r * c) % 3 == 0,
    lambda r, c: ((r * c) % 2 + (r * c) % 3) % 2 == 0,
    lambda r, c: ((r + c) % 2 + (r * c) % 3) % 2 == 0,
]


def _qr_bch_format(level: str, mask: int) -> int:
    data = ({"L": 1, "M": 0, "Q": 3, "H": 2}[level] << 3) | mask
    rem = data << 10
    for i in range(14, 9, -1):
        if rem & (1 << i):
            rem ^= 0b10100110111 << (i - 10)
    return ((data << 10) | rem) ^ 0b101010000010010


def _qr_bch_version(version: int) -> int:
    rem = version << 12
    for i in range(17, 11, -1):
        if rem & (1 << i):
            rem ^= 0b1111100100101 << (i - 12)
    return (version << 12) | rem


def _qr_place_format(m: list[list[int]], level: str, mask: int, version: int) -> None:
    size = len(m)
    fb = _qr_bch_format(level, mask)
    for i in range(15):
        bit = (fb >> i) & 1
        if i < 6:
            m[i][8] = bit
        elif i < 8:
            m[i + 1][8] = bit
        else:
            m[size - 15 + i][8] = bit
        if i < 8:
            m[8][size - 1 - i] = bit
        elif i < 9:
            m[8][7] = bit
        else:
            m[8][14 - i] = bit
    if version >= 7:
        vb = _qr_bch_version(version)
        for i in range(18):
            bit = (vb >> i) & 1
            m[i // 3][size - 11 + i % 3] = bit
            m[size - 11 + i % 3][i // 3] = bit


def _qr_penalty(m: list[list[int]]) -> int:
    size = len(m)
    score = 0
    lines = m + [list(col) for col in zip(*m)]
    for line in lines:                                # 規則 1：同色連續 >= 5
        run = 1
        for i in range(1, size):
            if line[i] == line[i - 1]:
                run += 1
            else:
                if run >= 5:
                    score += run - 2
                run = 1
        if run >= 5:
            score += run - 2
    for r in range(size - 1):                         # 規則 2：2x2 同色
        for c in range(size - 1):
            if m[r][c] == m[r][c + 1] == m[r + 1][c] == m[r + 1][c + 1]:
                score += 3
    pat = bytes((1, 0, 1, 1, 1, 0, 1))                # 規則 3：1:1:3:1:1 且一側有 4 格白（邊界外視為白）
    for line in lines:
        seq = bytes(line)
        idx = seq.find(pat)
        while idx != -1:
            nxt = idx + 7
            if not any(seq[max(idx - 4, 0):idx]) or not any(seq[nxt:nxt + 4]):
                score += 40
            else:
                nxt = idx + 4
            idx = seq.find(pat, nxt)
    dark = sum(map(sum, m))                           # 規則 4：黑格比例偏離 50%
    score += 10 * (abs(20 * dark - 10 * size * size) // (size * size))
    return score


def qr_matrix(text: str, level: str = "M", mask: int | None = None) -> list[list[int]]:
    """回傳 QR 模組矩陣（list of rows，1 = 黑）。自動選最小版本與最佳遮罩。"""
    data = text.encode("utf-8")
    version = next((v for v in range(1, 11) if _qr_capacity(v, level) >= len(data)), None)
    if version is None:
        raise ValueError(f"內容太長，QR 最多 {_qr_capacity(10, level)} bytes")
    codewords = _qr_codewords(data, version, level)
    size = 17 + 4 * version
    m: list[list[int | None]] = [[None] * size for _ in range(size)]

    for r0, c0 in ((0, 0), (0, size - 7), (size - 7, 0)):          # 定位圖案 + 分隔
        for dr in range(-1, 8):
            for dc in range(-1, 8):
                r, c = r0 + dr, c0 + dc
                if 0 <= r < size and 0 <= c < size:
                    inside = 0 <= dr <= 6 and 0 <= dc <= 6
                    m[r][c] = int(inside and (dr in (0, 6) or dc in (0, 6) or (2 <= dr <= 4 and 2 <= dc <= 4)))
    for i in range(8, size - 8):                                    # 時序圖案
        m[6][i] = m[i][6] = int(i % 2 == 0)
    pts = _QR_ALIGN[version]                                        # 校正圖案
    for r in pts:
        for c in pts:
            if (r, c) in ((6, 6), (6, size - 7), (size - 7, 6)):
                continue
            for dr in range(-2, 3):
                for dc in range(-2, 3):
                    m[r + dr][c + dc] = int(max(abs(dr), abs(dc)) != 1)
    m[size - 8][8] = 1                                              # 固定黑格
    for i in range(9):                                              # 保留格式資訊區
        for r, c in ((8, i), (i, 8)):
            if m[r][c] is None:
                m[r][c] = 0
    for i in range(8):
        for r, c in ((8, size - 1 - i), (size - 1 - i, 8)):
            if m[r][c] is None:
                m[r][c] = 0
    if version >= 7:                                                # 保留版本資訊區
        for i in range(6):
            for j in range(3):
                m[i][size - 11 + j] = 0
                m[size - 11 + j][i] = 0

    bits = [(cw >> i) & 1 for cw in codewords for i in range(7, -1, -1)]
    is_data = [[False] * size for _ in range(size)]
    idx, col, upward = 0, size - 1, True
    while col > 0:                                                  # 之字形填資料
        if col == 6:
            col -= 1
        rows = range(size - 1, -1, -1) if upward else range(size)
        for r in rows:
            for c in (col, col - 1):
                if m[r][c] is None:
                    m[r][c] = bits[idx] if idx < len(bits) else 0
                    is_data[r][c] = True
                    idx += 1
        col -= 2
        upward = not upward

    best = None
    for mi in ([mask] if mask is not None else range(8)):
        fn = _QR_MASKS[mi]
        cand = [[(m[r][c] ^ 1) if (is_data[r][c] and fn(r, c)) else m[r][c] for c in range(size)]
                for r in range(size)]
        _qr_place_format(cand, level, mi, version)
        p = _qr_penalty(cand)
        if best is None or p < best[0]:
            best = (p, cand)
    return best[1]


def qr_terminal(m: list[list[int]], quiet: int = 4) -> str:
    """用半格方塊字元 + 明確黑白底色畫 QR，不受終端機主題影響。"""
    size = len(m)
    n = size + 2 * quiet

    def dark(r: int, c: int) -> bool:
        r -= quiet
        c -= quiet
        return 0 <= r < size and 0 <= c < size and m[r][c] == 1

    rows = []
    for r in range(0, n, 2):
        line = []
        for c in range(n):
            top, bot = dark(r, c), dark(r + 1, c)
            line.append("█" if top and bot else "▀" if top else "▄" if bot else " ")
        rows.append("\x1b[30;107m" + "".join(line) + "\x1b[0m")
    return "\n".join(rows)


# ============================================================================
# 網頁（手機與電腦共用同一頁）
# ============================================================================

PAGE = r"""<!doctype html>
<html lang="{{html_lang}}">
<head>
<meta charset="utf-8">
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="format-detection" content="telephone=no">
<meta name="theme-color" content="#2563eb">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="PhoneDrop">
<link rel="icon" type="image/png" sizes="192x192" href="/icon.png?s=192">
<link rel="apple-touch-icon" sizes="180x180" href="/icon.png?s=180">
<link rel="manifest" href="__MANIFEST__">
<title>PhoneDrop</title>
<style>
:root{color-scheme:light dark;--bg:#f3f4f6;--card:#fff;--fg:#111827;--muted:#6b7280;--line:#e5e7eb;--accent:#2563eb;--accent-fg:#fff;--ok:#16a34a;--err:#dc2626;--soft:#eff6ff;--danger-bg:#fee2e2;--danger-fg:#b91c1c}
@media(prefers-color-scheme:dark){:root{--bg:#0b0e14;--card:#161a23;--fg:#e5e7eb;--muted:#9aa3b2;--line:#262c38;--accent:#3b82f6;--soft:#1b2537;--danger-bg:#3b1d1d;--danger-fg:#fca5a5}}
*{box-sizing:border-box;min-width:0}
[hidden]{display:none!important}
html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans TC","Microsoft JhengHei",sans-serif;-webkit-text-size-adjust:100%;overflow-x:hidden;max-width:100vw}
header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:14px 16px;max-width:760px;margin:0 auto;flex-wrap:wrap}
h1{font-size:20px;margin:0}
.hdr{display:flex;align-items:center;gap:8px;min-width:0;flex:1;justify-content:flex-end}
.pill{font-size:12px;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:2px 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40vw}
img,video,canvas{max-width:100%}
.pill.off{color:var(--err);border-color:var(--err)}
.lang{font-size:12px;color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:999px;padding:2px 8px;white-space:nowrap}
main{max-width:760px;margin:0 auto;padding:0 12px calc(24px + env(safe-area-inset-bottom));display:grid;gap:12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:14px}
h2{font-size:13px;margin:0 0 10px;color:var(--muted);font-weight:600;letter-spacing:.04em}
.cardhead{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.cardhead h2{flex:1;margin:0}
.card.collapsed>:not(.cardhead){display:none}
.card.collapsed .cardhead{margin-bottom:0;cursor:pointer}
.card.collapsed .cardhead .btn{display:none}
.cardhead.clickable h2{cursor:pointer}
.qsum .row{margin-top:6px}
.toolbar{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 10px}
.drop{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;border:2px dashed var(--line);border-radius:14px;padding:26px 12px;text-align:center;cursor:pointer;background:var(--soft);transition:border-color .15s}
.drop.over{border-color:var(--accent)}
.drop strong{font-size:17px}
.drop small{color:var(--muted)}
.camrow{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;align-items:center}
.turbo{font-size:13px;color:var(--muted);display:inline-flex;align-items:center;gap:4px;margin-left:auto;cursor:pointer;white-space:nowrap}
.turbo input{accent-color:var(--accent);margin:0}
ul{list-style:none;margin:0;padding:0}
.q li{padding:8px 0;border-top:1px solid var(--line)}
.q li:first-child{border-top:0}
.row{display:flex;align-items:center;gap:10px;min-width:0}
.name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.meta{color:var(--muted);font-size:13px;white-space:nowrap}
.bar{height:6px;border-radius:3px;background:var(--line);margin-top:6px;overflow:hidden}
.bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .15s}
.done .bar i{background:var(--ok)}
.fail .bar i{background:var(--err);width:100%}
.bar.wait i{width:35%;opacity:.7;animation:slide 1.3s ease-in-out infinite}
@keyframes slide{0%{margin-left:0}50%{margin-left:65%}100%{margin-left:0}}
.spinner{width:18px;height:18px;border:3px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin 1s linear infinite;flex:none}
@keyframes spin{to{transform:rotate(360deg)}}
.q .x{appearance:none;border:0;background:none;color:var(--muted);font-size:20px;line-height:1;padding:0 2px 0 6px;cursor:pointer}
.pending{margin-top:10px;padding:10px 12px;border-radius:12px;background:var(--soft);font-size:14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.pairreq{margin-top:10px;padding:10px 12px;border-radius:12px;background:var(--danger-bg);color:var(--danger-fg);font-size:15px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.pairreq span{flex:1;min-width:10em}
.devs{margin-top:12px;font-size:13px;color:var(--muted)}
.devs li{display:flex;align-items:center;gap:8px;padding:5px 0;border-top:1px solid var(--line)}
.devs li span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pending span{flex:1;min-width:12em}
.awake{display:flex;align-items:center;gap:12px;margin-top:12px;flex-wrap:wrap}
#qtext{word-break:break-word}
.awake video{width:120px;height:120px;border-radius:60px;background:var(--soft);flex:none}
.awake small{color:var(--muted);font-size:13px}
.qsum{color:var(--muted);font-size:13px;margin:10px 0 2px;display:none}
.qsum.on{display:block}
.qsum .bar.big{height:10px;border-radius:5px;margin-top:6px}
.qsum.finished .bar i{background:var(--ok)}
textarea{width:100%;min-height:64px;resize:vertical;border:1px solid var(--line);border-radius:12px;padding:10px;font:inherit;background:var(--bg);color:var(--fg)}
.btn{appearance:none;border:0;border-radius:12px;padding:11px 16px;font:inherit;font-weight:600;background:var(--accent);color:var(--accent-fg);cursor:pointer;text-decoration:none;display:inline-block;white-space:nowrap}
.btn:disabled,.btn.disabled{opacity:.5;pointer-events:none}
.btn.sm{padding:6px 10px;font-size:13px;font-weight:500;background:var(--soft);color:var(--accent)}
.btn.danger{background:var(--danger-bg);color:var(--danger-fg)}
.btn.ghost{background:transparent;color:var(--muted);border:1px solid var(--line)}
.msg{padding:10px 0;border-top:1px solid var(--line)}
.msg:first-child{border-top:0}
.msg p{margin:0 0 6px;white-space:pre-wrap;word-break:break-word}
.msg a{color:var(--accent)}
.files li{display:flex;align-items:center;gap:10px;padding:8px 0;border-top:1px solid var(--line)}
.files li:first-child{border-top:0}
.files a.name{color:var(--fg);text-decoration:none}
.files .ck{display:none;width:20px;height:20px;flex:none;accent-color:var(--accent);margin:0}
.files.sel .ck{display:block}
.files.sel .acts{display:none}
.ico{width:40px;height:40px;flex:none;display:flex;align-items:center;justify-content:center;font-size:22px;border-radius:8px;overflow:hidden;background:var(--soft)}
.ico img{width:100%;height:100%;object-fit:cover;display:block}
.acts{display:flex;gap:4px;flex:none}
.empty{color:var(--muted);font-size:14px;padding:6px 0}
#qr{display:block;margin:0 auto;image-rendering:pixelated;max-width:100%;height:auto;border-radius:8px}
.url{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:14px;word-break:break-all;text-align:center;margin-top:10px}
.center{text-align:center}
.hint{color:var(--muted);font-size:13px;margin:8px 0 0}
</style>
</head>
<body>
<header><h1>📱⇄💻 PhoneDrop</h1><div class="hdr"><a class="lang" id="lang" href="/">{{other_lang}}</a><span class="pill" id="pill">{{connecting}}</span></div></header>
<main>
<section class="card" id="qrcard" hidden>
  <h2>{{qr_title}}</h2>
  <canvas id="qr"></canvas>
  <div class="url" id="url"></div>
  <p class="center hint" id="codeline"></p>
  <p class="center" id="rotrow" hidden><button class="btn sm ghost" id="rotate">{{qr_rotate}}</button></p>
  <div id="pairreq"></div>
  <div id="devices"></div>
</section>
<section class="card">
  <h2 id="sendTitle"></h2>
  <label class="drop" id="drop">
    <input type="file" id="file" multiple hidden>
    <strong>{{drop_main}}</strong>
    <small id="dropHint">{{drop_hint}}</small>
  </label>
  <div class="camrow"><button class="btn sm" id="dirBtn" title="{{folder_note}}">{{folder_pick}}</button><input type="file" id="dir" webkitdirectory multiple hidden><span id="camrow" hidden><button class="btn sm" id="camBtn">{{camera}}</button><input type="file" id="cam" accept="image/*,video/*" capture="environment" hidden></span><label class="turbo" title="{{turbo_hint}}"><input type="checkbox" id="turbo"> {{turbo}}</label></div>
  <div class="pending" id="folderwait" hidden><i class="spinner"></i><span>{{folder_reading}}</span><button class="btn sm ghost" id="folderwaitClose">×</button></div>
  <div class="pending" id="pending" hidden></div>
  <div class="awake" id="awake" hidden></div>
  <div class="qsum" id="qsum"><div id="qtext"></div><div class="bar big"><i id="qbar"></i></div><div class="row"><span style="flex:1"></span><button class="btn sm ghost" id="qclear" hidden>{{clear_done}}</button></div></div>
  <ul class="q" id="queue"></ul>
</section>
<section class="card">
  <h2>{{text_title}}</h2>
  <textarea id="txt" placeholder="{{text_ph}}"></textarea>
  <div class="row" style="margin-top:8px;justify-content:flex-end"><button class="btn" id="send">{{send}}</button></div>
  <ul id="msgs"></ul>
</section>
<section class="card" id="filescard">
  <div class="cardhead" id="filesHead"><h2 id="filesH2">{{files_title}}</h2><button class="btn sm" id="openBtn" hidden>{{open_folder}}</button><button class="btn sm" id="selBtn">{{select}}</button></div>
  <div class="toolbar" id="selbar" hidden><button class="btn sm" id="selAll">{{select_all}}</button><a class="btn sm" id="zipBtn" href="#" download></a><button class="btn sm danger" id="delSel"></button><button class="btn sm ghost" id="selCancel">{{cancel}}</button></div>
  <div class="meta" id="dir" style="white-space:normal;word-break:break-all"></div>
  <ul class="files" id="files"></ul>
  <p class="hint" id="notifrow" hidden><button class="btn sm" id="notifBtn"></button></p>
</section>
<p class="hint center" id="a2hs" hidden></p>
<p class="hint center" id="quitrow" hidden><button class="btn sm ghost" id="quitBtn">{{quit}}</button></p>
</main>
<script>
const PD = __CONFIG__;
const T = PD.t;
const tf = (s, o) => String(s).replace(/\{(\w+)\}/g, (_, k) => (o && k in o) ? o[k] : '{' + k + '}');
const $ = s => document.querySelector(s);
if (location.search) history.replaceState(null, '', '/');   // 網址裡不留配對票；裝置憑證在 cookie
const STANDALONE = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || navigator.standalone === true;
const withKey = p => p;
const fmt = n => n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(0) + ' KB' : n < 1073741824 ? (n / 1048576).toFixed(1) + ' MB' : (n / 1073741824).toFixed(2) + ' GB';
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const linkify = s => esc(s).replace(/https?:\/\/[^\s<]+/g, u => '<a href="' + u + '" target="_blank" rel="noopener noreferrer">' + u + '</a>');
const tm = ts => { const d = new Date(ts * 1000); return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0'); };
const stamp = () => { const d = new Date(), p = n => String(n).padStart(2, '0'); return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()); };
const icon = n => { const e = (n.split('.').pop() || '').toLowerCase();
  if (/^(jpe?g|png|gif|webp|heic|heif|bmp|svg|avif)$/.test(e)) return '🖼️';
  if (/^(mp4|mov|m4v|webm|mkv|avi)$/.test(e)) return '🎬';
  if (/^(mp3|m4a|aac|wav|flac|ogg)$/.test(e)) return '🎵';
  if (/^(pdf)$/.test(e)) return '📄';
  if (/^(zip|7z|rar|gz|tar)$/.test(e)) return '🗜️';
  if (/^(docx?|xlsx?|pptx?|txt|md|csv)$/.test(e)) return '📝';
  return '📎'; };
$('#lang').href = '/?lang=' + PD.otherLang;

// ---- QR（只有電腦端顯示；IP 變了會重畫）
function drawQR() {
  const m = PD.qr, n = m.length, q = 4, s = 8, cv = $('#qr');
  cv.width = cv.height = (n + 2 * q) * s;
  cv.style.width = Math.min(300, cv.width) + 'px';
  const ctx = cv.getContext('2d');
  ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, cv.width, cv.height); ctx.fillStyle = '#000';
  m.forEach((row, r) => row.forEach((v, c) => { if (v) ctx.fillRect((c + q) * s, (r + q) * s, s, s); }));
  $('#url').textContent = PD.url;
  $('#codeline').textContent = PD.auth ? tf(T.qr_hint, { code: PD.code }) : T.qr_noauth;
  $('#rotrow').hidden = !PD.auth;
}
if (PD.qr) { $('#qrcard').hidden = false; drawQR(); }
$('#rotate').onclick = async () => { try { await api('POST', '/api/ticket/rotate'); } catch (e) {} refresh(); };
let knownPairReq = new Set();
function renderPairing(s) {
  if (!PD.isPc) return;
  const reqs = s.pending_devices || [], devs = s.devices || [];
  $('#pairreq').innerHTML = reqs.map(d => '<div class="pairreq"><span>' + esc(tf(T.pair_req, { name: d.name })) + '</span><button class="btn sm" data-act="approve" data-id="' + esc(d.id) + '">' + esc(T.allow) + '</button><button class="btn sm danger" data-act="deny" data-id="' + esc(d.id) + '">' + esc(T.deny) + '</button></div>').join('');
  for (const d of reqs) if (!knownPairReq.has(d.id)) { knownPairReq.add(d.id); if (notifyOn()) notify(tf(T.notify_pair, { name: d.name })); }
  $('#devices').innerHTML = devs.length ? '<div class="devs"><div>' + esc(tf(T.devices_title, { n: devs.length })) + '</div><ul>' + devs.map(d => '<li><span>' + esc(d.name) + ' · ' + esc(tf(T.last_seen, { t: new Date(d.last * 1000).toLocaleString() })) + '</span><button class="btn sm ghost" data-act="revoke" data-id="' + esc(d.id) + '" data-name="' + esc(d.name) + '">' + esc(T.revoke) + '</button></li>').join('') + '</ul></div>' : '';
}
$('#qrcard').addEventListener('click', async e => {
  const b = e.target.closest('[data-act]'); if (!b) return;
  if (b.dataset.act === 'revoke' && !confirm(tf(T.revoke_confirm, { name: b.dataset.name }))) return;
  try { await api('POST', '/api/devices/' + b.dataset.id + '/' + b.dataset.act); } catch (e2) {}
  refresh();
});
if (!PD.isPc && !STANDALONE) {
  const ua = navigator.userAgent;
  const tip = /iPhone|iPad/.test(ua) ? T.a2hs_ios : /Android/.test(ua) ? T.a2hs_android : '';
  if (tip) { $('#a2hs').textContent = '💡 ' + tip; $('#a2hs').hidden = false; }
}
$('#sendTitle').textContent = PD.isPc ? T.send_to_phone : tf(T.send_to_pc, { pc: PD.pc });
if (PD.isPc) {
  $('#dropHint').textContent = T.drop_hint_pc; $('#openBtn').hidden = false; $('#quitrow').hidden = false;
  $('#quitBtn').onclick = async () => {
    if (!confirm(T.quit_confirm)) return;
    try { await api('POST', '/api/quit'); } catch (e) {}
    document.body.innerHTML = '<p style="padding:3em 1em;text-align:center;font:16px system-ui">' + esc(T.quit_done) + '</p>';
  };
}
else if (/iPhone|iPad|Android/.test(navigator.userAgent)) $('#camrow').hidden = false;
// 手機：共享資料夾清單預設收起來（手機多半是「傳完就好」），要拿電腦的檔案再點開；記住上次的狀態
let filesCount = 0;
function filesHeader() {
  if (PD.isPc) return;
  const open = !$('#filescard').classList.contains('collapsed');
  $('#filesH2').textContent = tf(T.files_title_phone, { n: filesCount }) + (open ? ' ▾' : ' ▸');
}
if (!PD.isPc) {
  let open = false; try { open = localStorage.getItem('pd_files_open') === '1'; } catch (e) {}
  $('#filescard').classList.toggle('collapsed', !open);
  $('#filesHead').classList.add('clickable');
  $('#filesH2').onclick = () => {
    const now = $('#filescard').classList.toggle('collapsed');
    try { localStorage.setItem('pd_files_open', now ? '0' : '1'); } catch (e) {}
    if (now) setSelecting(false);
    filesHeader();
  };
  filesHeader();
}

// ---- 上傳中防鎖屏：播一段瀏覽器自己產生的「影片」（畫布串流＋靜音音軌），不嵌任何外部檔案。
// 畫布上畫的是上傳進度圈，影片就擺在頁面上給人看：Chrome 要影片「看得見」、有聲軌且不 muted 才會保持螢幕，
// iOS 也只在真的有媒體播放時不自動鎖屏。上傳結束就停。手機用；電腦不需要。
const keepAwake = (() => {
  let video = null, actx = null, on = false, cv = null, g = null, pct = 0, spin = 0, indet = false;
  function draw() {
    if (!g) return;
    const S = cv.width, c = S / 2, r = S * 0.4;
    spin++;
    g.clearRect(0, 0, S, S);
    g.fillStyle = getComputedStyle(document.body).getPropertyValue('--card').trim() || '#fff'; g.fillRect(0, 0, S, S);
    g.lineWidth = S * 0.09; g.lineCap = 'round';
    g.strokeStyle = getComputedStyle(document.body).getPropertyValue('--line').trim() || '#e5e7eb';
    g.beginPath(); g.arc(c, c, r, 0, Math.PI * 2); g.stroke();
    g.strokeStyle = '#2563eb';
    if (indet) {   // 還不知道進度（讀取資料夾中）：轉圈
      const a = (spin * 0.5) % (Math.PI * 2);
      g.beginPath(); g.arc(c, c, r, a, a + Math.PI * 0.6); g.stroke();
      g.fillStyle = getComputedStyle(document.body).getPropertyValue('--fg').trim() || '#111';
      g.font = Math.round(S * 0.3) + 'px system-ui,sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
      g.fillText('📂', c, c + S * 0.02);
      return;
    }
    const start = -Math.PI / 2;
    g.beginPath(); g.arc(c, c, r, start, start + Math.PI * 2 * Math.max(0.02, pct / 100)); g.stroke();
    g.fillStyle = getComputedStyle(document.body).getPropertyValue('--fg').trim() || '#111';
    g.font = 'bold ' + Math.round(S * 0.22) + 'px system-ui,sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
    g.fillText(Math.round(pct) + '%', c, c + S * 0.02);
    g.fillStyle = (spin & 1) ? '#2563eb' : '#60a5fa';
    g.beginPath(); g.arc(c + r * Math.cos(-Math.PI / 2 + Math.PI * 2 * pct / 100), c + r * Math.sin(-Math.PI / 2 + Math.PI * 2 * pct / 100), S * 0.05, 0, Math.PI * 2); g.fill();
  }
  function build() {
    cv = document.createElement('canvas'); cv.width = cv.height = 240; g = cv.getContext('2d'); draw();
    setInterval(draw, 250);
    const tracks = [...cv.captureStream(2).getVideoTracks()];
    try {
      actx = new (window.AudioContext || window.webkitAudioContext)();
      const dest = actx.createMediaStreamDestination(), osc = actx.createOscillator(), gain = actx.createGain();
      gain.gain.value = 0; osc.connect(gain).connect(dest); osc.start();
      tracks.push(...dest.stream.getAudioTracks());
    } catch (e) {}
    video = document.createElement('video');
    video.setAttribute('playsinline', ''); video.setAttribute('title', T.uploading_title); video.disablePictureInPicture = true;
    video.srcObject = new MediaStream(tracks);
    const box = $('#awake'); box.innerHTML = ''; box.appendChild(video);
    const hint = document.createElement('small'); hint.textContent = T.awake_hint; box.appendChild(hint);
    try { if ('mediaSession' in navigator) navigator.mediaSession.metadata = new MediaMetadata({ title: T.uploading_title, artist: 'PhoneDrop' }); } catch (e) {}
  }
  return {
    get active() { return on; },
    progress(p) { pct = p; indet = false; },
    indeterminate(v) { indet = !!v; },
    prime() {   // 要在使用者點擊的當下呼叫一次，iOS 之後才允許程式自己 play()
      if (PD.isPc) return;
      try { if (!video) build(); if (actx && actx.state === 'suspended') actx.resume(); } catch (e) { return; }
      if (!on) video.play().then(() => { if (!on) video.pause(); }).catch(() => {});
    },
    on() { if (PD.isPc) return; try { if (!video) build(); on = true; $('#awake').hidden = false; video.play().catch(() => {}); } catch (e) {} },
    off() { on = false; if (video) video.pause(); $('#awake').hidden = true; },
  };
})();

// ---- 上傳佇列：分塊、可續傳（最多 2 個檔案同時）
const CHUNK = PD.chunk || 8 * 1024 * 1024, SINGLE = 16 * 1024 * 1024;   // 16 MB 以下的檔一個請求到位
let SLOTS = 4;   // 名額：小檔佔 1、大檔佔 2；急速模式用滿瀏覽器每台主機 6 條的上限（手機讀檔是瓶頸時有用）
const slotCost = f => f.size > SINGLE ? 2 : 1;
try { if (localStorage.getItem('pd_turbo') === '1') { SLOTS = 6; $('#turbo').checked = true; } } catch (e) {}
$('#turbo').addEventListener('change', e => { SLOTS = e.target.checked ? 6 : 4; try { localStorage.setItem('pd_turbo', e.target.checked ? '1' : '0'); } catch (e2) {} pump(); });
const queue = $('#queue'), qsum = $('#qsum'), pending = [], jobs = new Set(), mine = new Set();
let active = 0;
const sleep = ms => new Promise(r => setTimeout(r, ms));
const fmtT = s => { s = Math.max(0, Math.round(s)); const m = Math.floor(s / 60) % 60, h = Math.floor(s / 3600); return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(s % 60).padStart(2, '0'); };
const rate = {   // 最近 5 秒的平均速度
  log: [], total: 0,
  add(b) { this.total += b; const t = Date.now(); this.log.push([t, this.total]); while (this.log.length > 2 && t - this.log[0][0] > 5000) this.log.shift(); },
  bps() { if (this.log.length < 2) return 0; const a = this.log[0], z = this.log[this.log.length - 1], dt = (z[0] - a[0]) / 1000; return dt > 0.3 ? (z[1] - a[1]) / dt : 0; },
};
const API_HDR = { 'X-PhoneDrop': '1' };   // 寫入類 API 都要帶：跨來源網頁加不了自訂標頭，瀏覽器會先 preflight 然後擋下（CSRF 防護）
async function api(method, path, body) {
  const r = await fetch(withKey(path), { method, cache: 'no-store', body: body === undefined ? undefined : JSON.stringify(body), headers: body === undefined ? API_HDR : { ...API_HDR, 'Content-Type': 'application/json' } });
  let data = null; try { data = await r.json(); } catch (e) {}
  if (!r.ok) { const e = new Error('http ' + r.status); e.status = r.status; e.data = data; throw e; }
  return data;
}
function enqueue(files) {
  for (const f of files) {
    if ([...jobs].some(j => !j.done && j.f.name === f.name && j.f.size === f.size && j.f.lastModified === f.lastModified)) continue;  // 同一個檔已在佇列
    const li = document.createElement('li');
    li.innerHTML = '<div class="row"><span class="name">' + esc(f.name) + '</span><span class="meta">' + fmt(f.size) + '</span><span class="meta st">' + esc(T.waiting) + '</span><button class="x" title="' + esc(T.cancel) + '" aria-label="' + esc(T.cancel) + '">×</button></div><div class="bar wait"><i></i></div>';
    queue.prepend(li);
    const job = { f, li, uid: null, offset: 0, sent: 0, xhr: null, cancelled: false, done: false };
    li.querySelector('.x').onclick = () => cancelJob(job);
    pending.push(job); jobs.add(job);
  }
  pump();
}
function pump() {
  while (pending.length) {
    const cost = slotCost(pending[0].f);
    if (active > 0 && active + cost > SLOTS) break;
    const job = pending.shift(); active += cost;
    keepAwake.indeterminate(false); keepAwake.on();
    upload(job).finally(() => { active -= cost; if (!active && !pending.length) keepAwake.off(); updateSummary(); pump(); });
  }
  updateSummary();
}
function cancelJob(job) {
  job.cancelled = true; job.done = true;
  const i = pending.indexOf(job); if (i >= 0) pending.splice(i, 1);
  if (job.xhr) { try { job.xhr.abort(); } catch (e) {} }
  if (job.uid) fetch(withKey('/api/upload/' + job.uid), { method: 'DELETE', headers: API_HDR }).catch(() => {});
  job.li.remove(); jobs.delete(job); updateSummary();
}
function setProg(job) {
  const pct = job.f.size ? job.sent / job.f.size * 100 : 100, bps = rate.bps();
  job.li.querySelector('.bar').classList.remove('wait');
  job.li.querySelector('.bar i').style.width = pct.toFixed(1) + '%';
  job.li.querySelector('.st').textContent = Math.round(pct) + '%' + (bps ? ' · ' + fmt(bps) + '/s · ' + tf(T.left, { t: fmtT((job.f.size - job.sent) / bps) }) : '');
}
function updateSummary() {   // 整批的進度：已完成/總數、總百分比、整批進度條；傳完留著變綠
  const all = [...jobs];
  if (!all.length) { qsum.classList.remove('on'); return; }
  const total = all.reduce((a, j) => a + j.f.size, 0), sent = all.reduce((a, j) => a + (j.done ? j.f.size : j.sent), 0), bps = rate.bps();
  const done = all.filter(j => j.done && !j.li.classList.contains('fail')).length, remaining = all.some(j => !j.done);
  const pct = total ? sent / total * 100 : 100;
  keepAwake.progress(pct);
  $('#qtext').textContent = tf(T.summary, { done, n: all.length, pct: Math.round(pct) })
    + (remaining && bps ? ' · ' + fmt(bps) + '/s · ' + tf(T.left, { t: fmtT((total - sent) / bps) }) : '') + (remaining ? '' : ' · ' + T.all_done);
  $('#qbar').style.width = pct.toFixed(1) + '%';
  qsum.classList.toggle('finished', !remaining);
  $('#qclear').hidden = remaining;
  qsum.classList.add('on');
}
$('#qclear').onclick = () => { for (const j of [...jobs]) if (j.done) { j.li.remove(); jobs.delete(j); } updateSummary(); };
function finishJob(job, name) {
  job.done = true; job.sent = job.f.size; job.li.classList.add('done'); job.li.querySelector('.bar').classList.remove('wait'); job.li.querySelector('.bar i').style.width = '100%'; mine.add(name);
  job.li.querySelector('.st').textContent = T.done + (name !== job.f.name ? tf(T.saved_as, { name }) : '');
  const x = job.li.querySelector('.x'); if (x) x.remove();
  updateSummary(); scheduleRefresh();
}
let refreshTimer = null;
function scheduleRefresh() { clearTimeout(refreshTimer); refreshTimer = setTimeout(refresh, 700); }
function xhrPut(job, url, body, baseOffset) {   // 共用：帶進度的 PUT
  return new Promise((res, rej) => {
    const xhr = new XMLHttpRequest(); job.xhr = xhr;
    xhr.open('PUT', withKey(url));
    xhr.setRequestHeader('X-PhoneDrop', '1');
    xhr.timeout = 180000;
    let last = 0;
    xhr.upload.onprogress = e => { if (!e.lengthComputable) return; rate.add(e.loaded - last); last = e.loaded; job.sent = baseOffset + e.loaded; setProg(job); updateSummary(); };
    xhr.onload = () => {
      job.xhr = null; let d = null; try { d = JSON.parse(xhr.responseText); } catch (e) {}
      if (xhr.status === 200) res(d); else { const err = new Error('http ' + xhr.status); err.status = xhr.status; err.data = d; rej(err); }
    };
    xhr.onerror = xhr.ontimeout = xhr.onabort = () => { job.xhr = null; rej(new Error('network')); };
    xhr.send(body);
  });
}
function putChunk(job, blob, offset) {
  return xhrPut(job, '/api/upload/' + job.uid + '?offset=' + offset, blob, offset);
}
async function upload(job) {
  const f = job.f, st = job.li.querySelector('.st');
  let fails = 0;
  while (!job.cancelled) {
    try {
      if (!job.uid && !job.chunked && f.size <= SINGLE) {   // 小檔：一個請求整個送完
        try {
          const r = await xhrPut(job, '/api/upload/new?name=' + encodeURIComponent(f.name) + '&size=' + f.size + '&mtime=' + (f.lastModified || 0), f, 0);
          finishJob(job, r.name); return;
        } catch (e) {
          if (e.status === 409 && e.data && e.data.uid) { job.uid = e.data.uid; job.offset = job.sent = e.data.offset || 0; job.chunked = true; updateSummary(); continue; }   // 伺服器有一部分：改走分塊
          throw e;
        }
      }
      if (!job.uid) {
        const r = await api('POST', '/api/upload/init', { name: f.name, size: f.size, mtime: f.lastModified || 0 });
        job.uid = r.uid; job.offset = job.sent = r.offset; updateSummary();
        if (r.done) { finishJob(job, r.name); return; }   // 伺服器早就收齊了（上次沒收到 finish）
        if (r.offset > 0 && r.offset < f.size) st.textContent = tf(T.resume_from, { pct: Math.round(r.offset / f.size * 100) });
      }
      while (job.offset < f.size && !job.cancelled) {
        const r = await putChunk(job, f.slice(job.offset, Math.min(job.offset + CHUNK, f.size)), job.offset);
        job.offset = job.sent = r.offset; fails = 0; setProg(job); updateSummary();
        if (r.done) { finishJob(job, r.name); return; }   // 最後一塊伺服器直接完成
      }
      if (job.cancelled) return;
      const r = await api('POST', '/api/upload/' + job.uid + '/finish');
      finishJob(job, r.name);
      return;
    } catch (e) {
      if (job.cancelled) return;
      if (e.status === 401) { location.href = '/'; return; }
      if (e.status === 409 && e.data && typeof e.data.offset === 'number') { job.offset = job.sent = e.data.offset; continue; }   // 進度對不上，照伺服器的
      if (e.status === 404) { job.uid = null; await sleep(500); continue; }   // 伺服器重啟過不認得這筆；重新 init 會從磁碟上的 .part 接著傳
      if (e.status === 400 || e.status === 413 || e.status === 507) { job.done = true; job.li.classList.add('fail'); st.textContent = (e.data && e.data.error) || tf(T.failed, { status: e.status }); return; }
      fails++;
      if (fails >= 2) {   // 連續失敗：先確認檔案本身還讀得到（Android 從資料夾選的檔案、iCloud 沒下載的檔案會在這裡掛掉）
        try { await f.slice(0, 1).arrayBuffer(); } catch (e3) { job.done = true; job.li.classList.add('fail'); st.textContent = T.unreadable; return; }
      }
      const wait = Math.min(15000, 1000 * Math.pow(2, Math.min(fails, 4)));
      st.textContent = tf(T.retrying, { s: Math.round(wait / 1000), n: fails });
      job.li.querySelector('.bar').classList.add('wait');
      await sleep(wait);
      if (job.uid) { try { const sv = await api('GET', '/api/upload/' + job.uid); job.offset = job.sent = sv.offset; } catch (e2) { if (e2.status === 404) job.uid = null; } }
    }
  }
}
window.addEventListener('beforeunload', e => { if (active || pending.length) { e.preventDefault(); e.returnValue = ''; } });
$('#file').addEventListener('change', e => { enqueue([...e.target.files]); e.target.value = ''; });
$('#cam').addEventListener('change', e => { enqueue([...e.target.files]); e.target.value = ''; });
$('#camBtn').onclick = () => { keepAwake.prime(); $('#cam').click(); };
$('#dir').addEventListener('change', e => { const fs = [...e.target.files]; e.target.value = ''; if (fs.length) enqueue(fs); folderWaitOff(); });
// 選整個資料夾：Android 的 Chrome 要逐一讀每個檔案的資訊（幾百張照片會花一兩分鐘），讀完才會觸發 change，
// 這段時間先顯示提示，免得看起來像當掉
let folderWaitTimer = null;
function folderWaitOff() {
  $('#folderwait').hidden = true; clearTimeout(folderWaitTimer);
  keepAwake.indeterminate(false);
  if (!active && !pending.length) keepAwake.off();
}
$('#dirBtn').onclick = () => {
  keepAwake.prime();
  $('#folderwait').hidden = false;
  if (!PD.isPc) { keepAwake.indeterminate(true); keepAwake.on(); }   // 讀取資料夾那一兩分鐘也不能讓螢幕鎖
  clearTimeout(folderWaitTimer); folderWaitTimer = setTimeout(folderWaitOff, 10 * 60 * 1000);
  $('#dir').click();
};
$('#dir').addEventListener('cancel', folderWaitOff);
$('#folderwaitClose').onclick = folderWaitOff;
// 電腦拖整個資料夾進來：用 webkitGetAsEntry 遞迴讀
async function filesFromDrop(dt) {
  const items = [...(dt.items || [])];
  if (!items.length || !items[0].webkitGetAsEntry) return [...dt.files];
  const out = [];
  const readDir = reader => new Promise(res => reader.readEntries(res, () => res([])));
  const walk = async entry => {
    if (entry.isFile) { await new Promise(res => entry.file(f => { out.push(f); res(); }, res)); return; }
    if (entry.isDirectory) { const reader = entry.createReader(); let batch; do { batch = await readDir(reader); for (const e of batch) await walk(e); } while (batch.length); }
  };
  const entries = items.map(i => i.webkitGetAsEntry()).filter(Boolean);
  if (!entries.length) return [...dt.files];
  for (const e of entries) await walk(e);
  return out;
}
const drop = $('#drop');
drop.addEventListener('click', () => keepAwake.prime());
['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('over'); }));
['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('over'); }));
drop.addEventListener('drop', e => { filesFromDrop(e.dataTransfer).then(enqueue); });
document.addEventListener('paste', e => {
  const items = [...((e.clipboardData && e.clipboardData.items) || [])].filter(i => i.kind === 'file');
  const files = items.map(i => i.getAsFile()).filter(Boolean);
  if (!files.length) return;
  e.preventDefault();
  enqueue(files.map(f => /^image\.(png|jpe?g)$/i.test(f.name) ? new File([f], 'paste-' + stamp() + '.' + f.name.split('.').pop(), { type: f.type, lastModified: Date.now() }) : f));
});

// ---- 文字
$('#send').onclick = async () => {
  const t = $('#txt').value.trim(); if (!t) return;
  $('#send').disabled = true;
  try {
    const r = await fetch(withKey('/api/text'), { method: 'POST', body: t, headers: { ...API_HDR, 'Content-Type': 'text/plain;charset=UTF-8' } });
    if (r.ok) { $('#txt').value = ''; refresh(); } else alert(tf(T.send_failed, { status: r.status }));
  } catch (e) { alert(T.net_failed); } finally { $('#send').disabled = false; }
};
$('#txt').addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) $('#send').click(); });
let msgTexts = [];
$('#msgs').addEventListener('click', e => {
  const b = e.target.closest('[data-copy]'); if (!b) return;
  copyText(msgTexts[+b.dataset.copy], b);
});
function copyText(t, btn) {
  const flash = msg => { btn.textContent = msg; setTimeout(() => btn.textContent = T.copy, 1800); };
  const selectMessage = () => {   // 最後手段：把訊息文字選起來，手機會跳出系統的「拷貝」
    const p = btn.closest('.msg').querySelector('p');
    const range = document.createRange(); range.selectNodeContents(p);
    const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
    flash(T.selected_copy);
  };
  const fallback = () => {        // http 頁面沒有 navigator.clipboard，用 execCommand
    const ta = document.createElement('textarea');
    ta.value = t; ta.contentEditable = 'true'; ta.readOnly = false;
    ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;font-size:16px';
    document.body.appendChild(ta);
    const range = document.createRange(); range.selectNodeContents(ta);
    const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
    ta.setSelectionRange(0, t.length);
    let ok = false; try { ok = document.execCommand('copy'); } catch (e) {}
    ta.remove();
    ok ? flash(T.copied) : selectMessage();
  };
  if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(t).then(() => flash(T.copied), fallback);
  else fallback();
}

// ---- 共享資料夾：選取 / zip / 刪除 / 開啟資料夾 / 通知
let selecting = false; const selected = new Set();
function setSelecting(on) {
  selecting = on; selected.clear();
  $('#files').classList.toggle('sel', on); $('#selbar').hidden = !on; $('#selBtn').hidden = on;
  $('#files').querySelectorAll('.ck').forEach(c => c.checked = false);
  updateSelbar();
}
function updateSelbar() {
  const n = selected.size;
  $('#zipBtn').textContent = tf(T.zip_selected, { n }); $('#delSel').textContent = tf(T.delete_selected, { n });
  $('#zipBtn').classList.toggle('disabled', !n); $('#delSel').disabled = !n;
  $('#zipBtn').href = n ? withKey('/api/zip?' + [...selected].map(x => 'f=' + encodeURIComponent(x)).join('&')) : '#';
}
$('#selBtn').onclick = () => setSelecting(true);
$('#selCancel').onclick = () => setSelecting(false);
$('#selAll').onclick = () => { $('#files').querySelectorAll('li[data-name]').forEach(li => { selected.add(li.dataset.name); li.querySelector('.ck').checked = true; }); updateSelbar(); };
$('#delSel').onclick = () => { if (selected.size && confirm(tf(T.delete_n_confirm, { n: selected.size }))) deleteFiles([...selected]).then(() => setSelecting(false)); };
$('#openBtn').onclick = () => api('POST', '/api/open-folder').catch(() => {});
async function deleteFiles(names) {
  for (const n of names) { try { await api('DELETE', '/files/' + encodeURIComponent(n)); } catch (e) {} }
  refresh();
}
$('#files').addEventListener('click', e => {
  const li = e.target.closest('li[data-name]'); if (!li) return;
  const name = li.dataset.name;
  if (e.target.closest('.del')) { e.preventDefault(); if (confirm(tf(T.delete_confirm, { name }))) deleteFiles([name]); return; }
  if (!selecting) return;
  const ck = li.querySelector('.ck');
  if (e.target !== ck) { e.preventDefault(); ck.checked = !ck.checked; }
  ck.checked ? selected.add(name) : selected.delete(name);
  updateSelbar();
});
const canNotify = PD.isPc && 'Notification' in window && window.isSecureContext;
const notifyOn = () => canNotify && Notification.permission === 'granted' && localStorage.getItem('pd_notify') === '1';
function notifLabel() {
  const b = $('#notifBtn');
  if (Notification.permission === 'denied') { b.textContent = T.notify_denied; b.disabled = true; }
  else b.textContent = notifyOn() ? T.notify_on : T.notify;
}
if (canNotify) {
  $('#notifrow').hidden = false; notifLabel();
  $('#notifBtn').onclick = async () => {
    if (notifyOn()) localStorage.setItem('pd_notify', '0');
    else { const p = await Notification.requestPermission(); if (p === 'granted') localStorage.setItem('pd_notify', '1'); }
    notifLabel();
  };
}
function notify(body) { try { new Notification('PhoneDrop', { body, icon: '/icon.png?s=192' }); } catch (e) {} }
let knownFiles = null, lastMsgId = -1;

// ---- 狀態輪詢
let last = '';
async function refresh() {
  try {
    const r = await fetch(withKey('/api/state'), { cache: 'no-store' });
    if (r.status === 401) { location.href = '/'; return; }
    const s = await r.json();
    if (PD.isPc && s.url && s.qr && (s.url !== PD.url || (s.ticket && s.ticket !== PD.code))) { PD.url = s.url; PD.qr = s.qr; if (s.ticket) PD.code = s.ticket; drawQR(); }
    if (PD.isPc) renderPairing(s);
    const sig = JSON.stringify([s.files, s.messages, s.dir, s.pc, s.pending]);
    if (sig !== last) { last = sig; render(s); }
    $('#pill').textContent = tf(PD.isPc ? T.this_pc : T.connected_to, { pc: s.pc });
    $('#pill').classList.remove('off');
  } catch (e) { $('#pill').textContent = T.conn_lost; $('#pill').classList.add('off'); }
}
function render(s) {
  $('#dir').textContent = s.dir;
  filesCount = s.total != null ? s.total : s.files.length; filesHeader();
  $('#dir').textContent = s.dir + (s.total > s.files.length ? ' · ' + tf(T.list_capped, { shown: s.files.length, total: s.total }) : '');
  const pend = (s.pending || []).filter(u => ![...jobs].some(j => !j.done && j.uid === u.uid));
  const pb = $('#pending');
  pb.hidden = !pend.length;
  if (pend.length) {
    pb.innerHTML = '<span>' + esc(tf(T.pending_banner, { n: pend.length })) + '<br><small class="meta">' + pend.map(u => esc(u.name) + ' (' + Math.round(u.size ? u.received / u.size * 100 : 0) + '%)').join(' · ') + '</small></span><button class="btn sm ghost" id="discardAll">' + esc(T.discard) + '</button>';
    pb.querySelector('#discardAll').onclick = async () => { for (const u of pend) { try { await api('DELETE', '/api/upload/' + u.uid); } catch (e) {} } refresh(); };
  }
  const msgs = s.messages.slice().reverse();
  msgTexts = msgs.map(m => m.text);
  $('#msgs').innerHTML = msgs.length
    ? msgs.map((m, i) => '<li class="msg"><p>' + linkify(m.text) + '</p><div class="row"><span class="meta">' + esc(m.from === 'PC' ? T.pc_label : m.from) + ' · ' + tm(m.ts) + '</span><span style="flex:1"></span><button class="btn sm" data-copy="' + i + '">' + esc(T.copy) + '</button></div></li>').join('')
    : '<li class="empty">' + esc(T.no_msgs) + '</li>';
  let thumbs = 0;
  $('#files').innerHTML = s.files.length
    ? s.files.map(f => {
        const u = '/files/' + encodeURIComponent(f.name);
        let ico = icon(f.name);
        if (/\.jpe?g$/i.test(f.name) && thumbs < 30) { thumbs++; ico = '<img src="' + withKey('/thumb/' + encodeURIComponent(f.name) + '?v=' + f.mtime) + '" loading="lazy" alt="" onerror="this.parentNode.textContent=\'🖼️\'">'; }
        return '<li data-name="' + esc(f.name) + '"><input type="checkbox" class="ck"' + (selected.has(f.name) ? ' checked' : '') + '><span class="ico">' + ico + '</span><a class="name" href="' + withKey(u) + '" target="_blank" rel="noopener noreferrer">' + esc(f.name) + '</a><span class="meta">' + fmt(f.size) + '</span><span class="acts"><a class="btn sm" href="' + withKey(u + '?dl=1') + '" download title="' + esc(T.download) + '">⬇</a><button class="btn sm danger del" title="' + esc(T.delete) + '">🗑</button></span></li>';
      }).join('')
    : '<li class="empty">' + esc(T.folder_empty) + '</li>';
  if (notifyOn()) {
    if (knownFiles) for (const f of s.files) if (!knownFiles.has(f.name) && !mine.has(f.name)) notify(tf(T.received, { name: f.name }));
    if (lastMsgId >= 0) for (const m of s.messages) if (m.id > lastMsgId && m.from !== 'PC') notify(tf(T.new_message, { who: m.from }) + ': ' + m.text.slice(0, 80));
  }
  knownFiles = new Set(s.files.map(f => f.name));
  lastMsgId = Math.max(lastMsgId, 0, ...s.messages.map(m => m.id || 0));
}
setInterval(() => { if (!document.hidden) refresh(); }, 2500);
document.addEventListener('visibilitychange', () => { if (!document.hidden) refresh(); });
refresh();
</script>
</body>
</html>
"""

LOGIN_PAGE = """<!doctype html>
<html lang="{{html_lang}}"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" type="image/png" href="/icon.png?s=192"><link rel="apple-touch-icon" href="/icon.png?s=180"><title>{{login_title}}</title>
<style>
:root{color-scheme:light dark}body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px/1.5 -apple-system,"Segoe UI",Roboto,"Noto Sans TC","Microsoft JhengHei",sans-serif;background:#f3f4f6;color:#111827}
@media(prefers-color-scheme:dark){body{background:#0b0e14;color:#e5e7eb}}
form{display:grid;gap:12px;width:min(92vw,340px);text-align:center}
input{font:inherit;font-size:22px;letter-spacing:.2em;text-align:center;padding:12px;border-radius:12px;border:1px solid #9ca3af;background:transparent;color:inherit}
button{font:inherit;font-weight:600;padding:12px;border:0;border-radius:12px;background:#2563eb;color:#fff}
p{color:#6b7280;margin:0;font-size:14px}
</style></head><body>
<form method="get" action="/">
<h1 style="margin:0;font-size:22px">📱⇄💻 PhoneDrop</h1>
<p>{{login_hint}}</p>
<input name="p" autocomplete="off" autocapitalize="none" autocorrect="off" spellcheck="false" placeholder="{{login_ph}}" autofocus>
<button>{{login_btn}}</button>
</form></body></html>
"""


WAIT_PAGE = """<!doctype html>
<html lang="{{html_lang}}"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" type="image/png" href="/icon.png?s=192"><link rel="apple-touch-icon" href="/icon.png?s=180"><title>{{pair_title}}</title>
<style>
:root{color-scheme:light dark}body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px/1.5 -apple-system,"Segoe UI",Roboto,"Noto Sans TC","Microsoft JhengHei",sans-serif;background:#f3f4f6;color:#111827}
@media(prefers-color-scheme:dark){body{background:#0b0e14;color:#e5e7eb}}
.box{display:grid;gap:14px;width:min(92vw,360px);text-align:center}
.spin{width:44px;height:44px;border:4px solid #d1d5db;border-top-color:#2563eb;border-radius:50%;margin:0 auto;animation:r 1s linear infinite}
@keyframes r{to{transform:rotate(360deg)}}
p{color:#6b7280;margin:0;font-size:15px}
</style></head><body>
<div class="box">
<h1 style="margin:0;font-size:22px">📱⇄💻 PhoneDrop</h1>
<div class="spin" id="spin"></div>
<h2 style="margin:0;font-size:18px" id="title">{{pair_title}}</h2>
<p id="msg">__WAIT_TEXT__</p>
</div>
<script>
(async () => {
  while (true) {
    await new Promise(r => setTimeout(r, 1500));
    try {
      const r = await fetch('/api/pair/status', { cache: 'no-store' }); const s = await r.json();
      if (s.state === 'trusted') { location.replace('/'); return; }
      if (s.state !== 'pending') { document.getElementById('spin').hidden = true; document.getElementById('msg').textContent = __DENIED_TEXT__; return; }
    } catch (e) {}
  }
})();
</script></body></html>
"""

# ============================================================================
# 字串表（網頁、終端機共用）。頁面語言：?lang= → cookie → Accept-Language；終端機：--lang → 系統語言。
# ============================================================================

STRINGS: dict[str, dict[str, str]] = {
    "en": {
        "html_lang": "en", "other_lang": "中文",
        "connecting": "Connecting…", "this_pc": "This computer: {pc}", "connected_to": "Connected to {pc}",
        "conn_lost": "Connection lost, retrying…",
        "qr_title": "Connect your phone: same Wi-Fi, scan this QR with the camera",
        "qr_hint": "Can't scan? Type the address above into the phone's browser, or enter the pairing code {code}. The code and the QR change every 10 minutes; a phone that used them still has to be allowed here.",
        "qr_rotate": "↻ New code now", "pair_req": "📱 {name} wants to connect", "allow": "Allow", "deny": "Deny",
        "devices_title": "Paired devices ({n})", "revoke": "Revoke", "revoke_confirm": "Revoke {name}? It will have to pair again.",
        "last_seen": "last seen {t}", "notify_pair": "{name} wants to connect",
        "pair_title": "Waiting for the computer", "pair_wait": "This device ({name}) is waiting for approval. On the computer, open http://localhost:{port}/ and press Allow.",
        "pair_denied": "The computer denied this device. Scan the QR again to retry.",
        "qr_noauth": "(pairing code disabled)",
        "send_to_phone": "Send to phone: drop files here, tap them on the phone to save", "send_to_pc": "Send to {pc}",
        "drop_main": "Tap to choose photos, videos or files", "drop_hint": "Several at once, originals, no compression",
        "drop_hint_pc": "Drag files or a whole folder here, or paste a screenshot with Ctrl+V", "camera": "📷 Take a photo",
        "folder_pick": "📁 Whole folder", "folder_note": "Files inside sub-folders are flattened into the shared folder",
        "folder_reading": "📂 Reading the folder… the phone has to go through every file first; with hundreds of photos this takes a minute or two, then uploads start by themselves. Android won't let any app pick the Download folder itself or the storage root: pick a sub-folder, or use the file button, long-press a file and choose “Select all”.",
        "turbo": "⚡ Turbo", "turbo_hint": "6 parallel uploads instead of 4. Helps when the phone's file reading is the bottleneck, not the Wi-Fi; the phone may get warm.",
        "text_title": "Text & links", "text_ph": "Paste text or a link; the other device sees it right away",
        "send": "Send", "send_failed": "Send failed ({status})", "net_failed": "Connection failed", "no_msgs": "No messages yet",
        "copy": "Copy", "copied": "Copied ✓", "selected_copy": "Selected, long-press → Copy", "pc_label": "Computer",
        "files_title": "Shared folder (both sides see it; tap a name to open, ⬇ to download)", "folder_empty": "Folder is empty",
        "files_title_phone": "📂 Files on the computer ({n})", "clear_done": "Clear finished", "all_done": "all done ✓",
        "list_capped": "showing the latest {shown} of {total}",
        "download": "Download", "delete": "Delete",
        "delete_confirm": "Delete “{name}”? It will be removed from the computer too.",
        "delete_n_confirm": "Delete {n} files? They will be removed from the computer too.",
        "select": "Select", "select_all": "All", "cancel": "Cancel", "zip_selected": "⬇ Download {n} as zip", "delete_selected": "🗑 Delete {n}",
        "open_folder": "Open folder", "notify": "🔔 Notify me when files arrive", "notify_on": "🔔 Notifications on",
        "notify_denied": "Notifications are blocked in this browser", "received": "Received {name}", "new_message": "New message from {who}",
        "waiting": "Waiting", "done": "Done ✓", "saved_as": " (saved as {name})", "resume_from": "Resuming from {pct}%",
        "retrying": "Connection lost, retrying in {s} s (attempt {n})", "failed": "Failed ({status})", "left": "{t} left",
        "summary": "{done}/{n} files · {pct}% overall", "awake_note": "screen stays on until done",
        "a2hs_ios": "Safari: “Share” → “Add to Home Screen”, then it opens like an app",
        "a2hs_android": "Chrome: “⋮” → “Add to Home screen”, then it opens like an app",
        "login_title": "PhoneDrop pairing", "login_hint": "Enter the 6-character pairing code shown on the computer (it changes every 10 minutes; scanning the QR skips this)",
        "login_ph": "pairing code", "login_btn": "Connect",
        "t_started": "PhoneDrop is running", "t_folder": "Shared folder: {folder}",
        "t_scan": "On the phone (same Wi-Fi) scan the QR with the camera, or open:", "t_code": "pairing code {code}",
        "t_ticket_note": "The QR and the code change every 10 minutes (the computer page always shows the current one). A phone that scans it must then be allowed on the computer page.",
        "t_devices": "Paired devices: {n}", "log_pair_request": "📱 {name} wants to connect → allow it on http://localhost:{port}/",
        "log_pair_auto": "✓ {name} connected (auto-approve)", "log_pair_ok": "✓ allowed {name}", "log_pair_deny": "✗ denied {name}", "log_pair_revoke": "✗ revoked {name}",
        "t_pc_page": "Computer page: http://localhost:{port}/   (also shows the QR; easier to scan from the screen)",
        "t_pending": "↻ {n} unfinished upload(s): re-select the same file on the phone to resume",
        "t_cleaned": "🧹 removed {n} unfinished upload(s) older than 7 days",
        "t_multi_ip": "⚠ several IPs detected {ips}; if the phone can't connect, try --host with another one",
        "t_firewall": "⚠ The first start on Windows shows a firewall prompt: tick “Private networks” and click “Allow access”, or the phone can't connect",
        "t_noauth": "⛔ No pairing code (--no-auth): anyone on this network can see the shared folder and put files in it",
        "t_baddir": "⛔ The shared folder is {dir}: every file in it will be listed; use a dedicated folder",
        "t_quit": "Ctrl+C to quit", "t_bye": "Bye 👋",
        "t_port": "Cannot listen on port {port}: {err} (use --port, or close the program using it)",
        "t_ip_changed": "ℹ This computer's address changed, new link: {url}",
        "log_msg": "💬 {who}: {text}", "log_received": "⬇ received {name}  {size}  {speed}/s  ← {who}",
        "log_sent": "⬆ sent {name}  {size}  → {who}", "log_resume": "↻ resuming {name}  from {offset} / {size}  ← {who}",
        "log_cancel": "✗ upload cancelled {name}  ← {who}", "log_write_fail": "✗ write failed {name}: {err}",
        "log_bad_code": "⛔ wrong pairing code ← {ip} ({n} in the last minute)",
        "log_bad_host": "⛔ rejected Host “{host}” ← {ip} (DNS-rebinding guard; add --allow-host to allow it)",
        "log_deleted": "🗑 deleted {name}  ← {who}", "log_zip": "⬆ zip of {n} files  → {who}",
        "err_disk": "Not enough disk space on the computer ({free} left)", "err_write": "Write failed on the computer: {err}",
        "err_toolarge": "exceeds the declared file size", "who_pc": "computer",
        "quit": "Quit PhoneDrop on this computer", "quit_confirm": "Stop PhoneDrop on this computer? Phones will no longer be able to connect.",
        "quit_done": "PhoneDrop stopped. Start it again from the computer.",
        "t_autostart_on": "✓ Autostart installed: {where}\n  PhoneDrop will start hidden at login; the computer page is http://localhost:{port}/ (it has a Quit button). Undo with --uninstall-autostart",
        "t_autostart_off": "✓ Autostart removed: {where}", "t_autostart_none": "No autostart entry found",
        "t_log": "No console: messages go to {log}",
        "t_quit_page": "Running in the background: quit from the computer page http://localhost:{port}/",
        "pending_banner": "↻ {n} unfinished upload(s). Pick the same files (or the same folder) again and they continue where they stopped.",
        "discard": "Discard", "unreadable": "The file can't be read any more (moved, deleted or not downloaded?). Pick it again.",
        "uploading_title": "PhoneDrop · uploading", "awake_hint": "Keep this page open; the screen stays on while uploading",
        "log_finalized": "⬇ completed earlier upload {name}  {size}",
    },
    "zh-TW": {
        "html_lang": "zh-Hant", "other_lang": "EN",
        "connecting": "連線中…", "this_pc": "這台電腦：{pc}", "connected_to": "已連上 {pc}", "conn_lost": "連線中斷，重試中…",
        "qr_title": "手機連線：同一個 Wi-Fi，用相機掃這個 QR",
        "qr_hint": "掃不到？手機瀏覽器輸入上面網址，或輸入配對碼 {code}。配對碼和 QR 每 10 分鐘換新；手機掃了之後還要在這裡按「允許」。", "qr_noauth": "（未啟用配對碼）",
        "qr_rotate": "↻ 立刻換新", "pair_req": "📱 {name} 想要連線", "allow": "允許", "deny": "拒絕",
        "devices_title": "已配對的裝置（{n}）", "revoke": "撤銷", "revoke_confirm": "撤銷 {name}？它要重新配對。",
        "last_seen": "上次 {t}", "notify_pair": "{name} 想要連線",
        "pair_title": "等待電腦允許", "pair_wait": "這台裝置（{name}）正在等電腦允許。請到電腦頁面 http://localhost:{port}/ 按「允許」。",
        "pair_denied": "電腦拒絕了這台裝置。要重試請再掃一次 QR。",
        "send_to_phone": "傳到手機：放進共享資料夾，手機那邊點一下就能存", "send_to_pc": "傳到電腦 {pc}",
        "drop_main": "點這裡選照片 / 影片 / 檔案", "drop_hint": "可一次選多個，原檔傳送不壓縮",
        "drop_hint_pc": "可拖曳檔案或整個資料夾到這裡，或直接 Ctrl+V 貼上截圖", "camera": "📷 拍照直接傳",
        "folder_pick": "📁 整個資料夾", "folder_note": "子資料夾裡的檔案會平鋪進共享資料夾",
        "folder_reading": "📂 正在讀取資料夾… 手機要先把每個檔案看過一遍，幾百張照片大約要一兩分鐘，讀完會自己開始傳。Android 不准任何 App 選「Download」本身和儲存空間根目錄（按鈕會是灰的）：請選它底下的子資料夾，或改用上面的選檔案鈕，長按一個檔案 → 右上「全選」。",
        "turbo": "⚡ 急速", "turbo_hint": "同時 6 條（平常 4 條）。手機讀檔是瓶頸、Wi-Fi 很快時有用；手機可能會比較熱。",
        "text_title": "文字 / 連結", "text_ph": "貼上文字或網址，另一台裝置馬上看得到",
        "send": "送出", "send_failed": "送出失敗（{status}）", "net_failed": "連線失敗", "no_msgs": "還沒有訊息",
        "copy": "複製", "copied": "已複製 ✓", "selected_copy": "已選取，長按 → 拷貝", "pc_label": "電腦",
        "files_title": "共享資料夾（兩邊都看得到，點檔名開啟、⬇ 下載）", "folder_empty": "資料夾是空的",
        "files_title_phone": "📂 電腦上的檔案（{n}）", "clear_done": "清除已完成", "all_done": "全部完成 ✓",
        "list_capped": "只列最近 {shown} 個，共 {total} 個",
        "download": "下載", "delete": "刪除",
        "delete_confirm": "刪除「{name}」？電腦上的檔案會一起刪掉。", "delete_n_confirm": "刪除 {n} 個檔案？電腦上的檔案會一起刪掉。",
        "select": "選取", "select_all": "全選", "cancel": "取消", "zip_selected": "⬇ 打包下載 {n} 個", "delete_selected": "🗑 刪除 {n} 個",
        "open_folder": "開啟資料夾", "notify": "🔔 收到檔案時通知我", "notify_on": "🔔 通知已開啟",
        "notify_denied": "瀏覽器已封鎖通知", "received": "收到 {name}", "new_message": "{who} 傳來訊息",
        "waiting": "等待", "done": "完成 ✓", "saved_as": "（存為 {name}）", "resume_from": "從 {pct}% 續傳",
        "retrying": "連線中斷，{s} 秒後重試（第 {n} 次）", "failed": "失敗（{status}）", "left": "剩 {t}",
        "summary": "{done}/{n} 個檔案 · 總進度 {pct}%", "awake_note": "傳完前螢幕不會鎖",
        "a2hs_ios": "Safari 下方「分享」→「加入主畫面」，之後像 App 一樣一點就開",
        "a2hs_android": "Chrome 右上「⋮」→「加到主畫面」，之後像 App 一樣一點就開",
        "login_title": "PhoneDrop 配對", "login_hint": "輸入電腦畫面上的 6 碼配對碼（每 10 分鐘換新；掃 QR 可略過這步）", "login_ph": "配對碼", "login_btn": "連線",
        "t_started": "PhoneDrop 已啟動", "t_folder": "共享資料夾：{folder}",
        "t_scan": "手機（同一個 Wi-Fi）用相機掃 QR，或在瀏覽器輸入：", "t_code": "配對碼 {code}",
        "t_ticket_note": "QR 和配對碼每 10 分鐘換新（電腦頁面永遠是最新的）。手機掃了之後要在電腦頁面按「允許」才能用。",
        "t_devices": "已配對裝置：{n} 台", "log_pair_request": "📱 {name} 想要連線 → 到電腦頁面 http://localhost:{port}/ 按允許",
        "log_pair_auto": "✓ {name} 已連線（自動允許）", "log_pair_ok": "✓ 已允許 {name}", "log_pair_deny": "✗ 已拒絕 {name}", "log_pair_revoke": "✗ 已撤銷 {name}",
        "t_pc_page": "電腦端頁面：http://localhost:{port}/   （這頁也有 QR，掃螢幕比較清楚）",
        "t_pending": "↻ 有 {n} 個沒傳完的檔案，手機重新選同一個檔就會從斷點續傳",
        "t_cleaned": "🧹 清掉 {n} 個超過 7 天的未完成上傳",
        "t_multi_ip": "⚠ 偵測到多個 IP {ips}，手機連不上時試試 --host 指定另一個",
        "t_firewall": "⚠ 第一次啟動 Windows 會跳「防火牆已封鎖」視窗 → 勾「私人網路」按「允許存取」，手機才連得到",
        "t_noauth": "⛔ 沒有配對碼（--no-auth）：同一個網路裡任何人都看得到共享資料夾、也能放檔案進來",
        "t_baddir": "⛔ 共享資料夾是 {dir}：裡面所有檔案都會被列出，建議改用專用資料夾",
        "t_quit": "Ctrl+C 結束", "t_bye": "再見 👋",
        "t_port": "無法監聽 port {port}：{err}（換 --port，或關掉佔用的程式）",
        "t_ip_changed": "ℹ 這台電腦的位址變了，新網址：{url}",
        "log_msg": "💬 {who}: {text}", "log_received": "⬇ 收到 {name}  {size}  {speed}/s  ← {who}",
        "log_sent": "⬆ 送出 {name}  {size}  → {who}", "log_resume": "↻ 續傳 {name}  從 {offset} / {size}  ← {who}",
        "log_cancel": "✗ 取消上傳 {name}  ← {who}", "log_write_fail": "✗ 寫入失敗 {name}: {err}",
        "log_bad_code": "⛔ 配對碼錯誤 ← {ip}（一分鐘內第 {n} 次）",
        "log_bad_host": "⛔ 拒絕 Host「{host}」← {ip}（防 DNS rebinding；要允許請加 --allow-host）",
        "log_deleted": "🗑 刪除 {name}  ← {who}", "log_zip": "⬆ 打包 {n} 個檔案  → {who}",
        "err_disk": "電腦磁碟空間不足（剩 {free}）", "err_write": "電腦寫入失敗：{err}",
        "err_toolarge": "超出宣告的檔案大小", "who_pc": "電腦",
        "quit": "結束這台電腦上的 PhoneDrop", "quit_confirm": "要結束這台電腦上的 PhoneDrop 嗎？手機就連不上了。",
        "quit_done": "PhoneDrop 已結束。要再用請在電腦上重新啟動。",
        "t_autostart_on": "✓ 已設定登入自動啟動：{where}\n  之後登入就會在背景跑，電腦頁面 http://localhost:{port}/（上面有結束按鈕）；取消用 --uninstall-autostart",
        "t_autostart_off": "✓ 已取消自動啟動：{where}", "t_autostart_none": "沒有設定過自動啟動",
        "t_log": "沒有終端機：訊息寫到 {log}",
        "t_quit_page": "在背景執行中：要結束請開電腦頁面 http://localhost:{port}/ 按結束",
        "pending_banner": "↻ 有 {n} 個沒傳完的檔案。重新選同一個檔案（或同一個資料夾）就會從斷點接著傳。",
        "discard": "清除", "unreadable": "這個檔案已經讀不到（被移走、刪除或還沒下載？），請重新選擇。",
        "uploading_title": "PhoneDrop · 上傳中", "awake_hint": "請讓這頁留在前景，上傳中螢幕不會鎖",
        "log_finalized": "⬇ 補完成先前的上傳 {name}  {size}",
    },
}
LANG = "en"            # 終端機語言，main() 依 --lang / 系統語言設定
_PLACEHOLDER = re.compile(r"\{\{([a-z0-9_]+)\}\}")


def norm_lang(v: str) -> str:
    return "zh-TW" if v.strip().lower().startswith("zh") else "en"


def T(key: str, **kw) -> str:
    """終端機訊息（用 LANG）。"""
    s = STRINGS.get(LANG, STRINGS["en"]).get(key) or STRINGS["en"][key]
    return s.format(**kw) if kw else s


def who_label(who: str) -> str:
    return T("who_pc") if who == "PC" else who


def detect_lang(arg: str | None) -> str:
    if arg:
        return norm_lang(arg)
    cands = [os.environ.get(k, "") for k in ("PHONEDROP_LANG", "LC_ALL", "LC_MESSAGES", "LANG")]
    try:
        import locale
        cands.append(locale.getlocale()[0] or "")
        if os.name == "nt":
            import ctypes
            lid = ctypes.windll.kernel32.GetUserDefaultUILanguage()
            cands.append("zh" if lid & 0xFF == 0x04 else "")
    except Exception:
        pass
    for c in cands:
        c = c.lower()
        if c.startswith("zh") or "chinese" in c:
            return "zh-TW"
    return "en"


def render_tpl(template: str, lang: str) -> str:
    t = STRINGS.get(lang, STRINGS["en"])
    return _PLACEHOLDER.sub(lambda m: html.escape(t.get(m.group(1)) or STRINGS["en"].get(m.group(1), m.group(1)), quote=True), template)

# ============================================================================
# 伺服器
# ============================================================================

CONFIG: dict = {}
LOCK = threading.Lock()
MESSAGES: list[dict] = []
MSG_SEQ = [0]
AUTH_FAILS: dict[str, list[float]] = {}   # ip -> 最近一分鐘猜錯配對碼的時間
API_HEADER = "X-PhoneDrop"                 # 寫入類 API 必帶（CSRF 防護，見 SECURITY.md）
ACTIVE_TYPES = {"text/html", "application/xhtml+xml", "text/xml", "application/xml"}   # 會執行腳本的類型：一律當附件
PAGE_HEADERS = {"Referrer-Policy": "no-referrer", "X-Frame-Options": "DENY",
                "Content-Security-Policy": "frame-ancestors 'none'"}
BAD_HOST_PAGE = """<!doctype html><meta charset="utf-8"><title>PhoneDrop</title>
<body style="font-family:system-ui;padding:2em;max-width:40em"><h2>This address is not one PhoneDrop announced</h2>
<p>To block DNS-rebinding attacks, only the computer's own name, its IP addresses and <code>.local</code> names are accepted.
Use the link printed at startup, or start with <code>--allow-host NAME</code> to allow another name.</p>
<h2>這個網址不是 PhoneDrop 公布的位址</h2>
<p>為了防止 DNS rebinding 攻擊，只接受本機名稱、本機 IP 與 <code>.local</code> 名稱。
請改用啟動時印出的網址；真的要用別的名稱，啟動時加 <code>--allow-host 名稱</code>。</p></body>"""
_RESERVED = {"CON", "PRN", "AUX", "NUL"} | {f"COM{i}" for i in range(1, 10)} | {f"LPT{i}" for i in range(1, 10)}

for _ext, _type in ((".heic", "image/heic"), (".heif", "image/heif"), (".webp", "image/webp"), (".avif", "image/avif"),
                    (".mov", "video/quicktime"), (".mp4", "video/mp4"), (".m4v", "video/mp4"), (".webm", "video/webm"),
                    (".m4a", "audio/mp4"), (".json", "application/json"), (".js", "text/javascript")):
    mimetypes.add_type(_type, _ext)


def now_str() -> str:
    return time.strftime("%H:%M:%S")


def human(n: float) -> str:
    for unit in ("B", "KB", "MB", "GB"):
        if n < 1024 or unit == "GB":
            return f"{n:.0f} {unit}" if unit in ("B", "KB") else f"{n:.1f} {unit}"
        n /= 1024
    return f"{n:.1f} GB"


def safe_name(name: str) -> str:
    name = name.replace("\\", "/").split("/")[-1]
    name = "".join(ch for ch in name if ch >= " " and ch not in '<>:"|?*').strip(" .")
    stem, ext = os.path.splitext(name)
    ext = ext[:16]
    if not stem or stem.upper() in _RESERVED:
        stem = "file-" + time.strftime("%Y%m%d-%H%M%S")
    return stem[:150] + ext


# ---- 分塊上傳：name+size+mtime 算出 uid；磁碟上 <檔名>.part 存已收到的連續資料、<檔名>.part.json 存身分，
#      重啟後靠 .part.json 重建，所以手機重選同一個檔就能從斷點續傳。
CHUNK_SIZE = 8 * 1024 * 1024          # 建議前端每塊大小
MAX_CHUNK_BODY = 32 * 1024 * 1024     # 單次 PUT 上限
MAX_FILE_SIZE = 1 << 40               # 1 TB，防亂填
UPLOADS: dict[str, dict] = {}         # uid -> 進行中的上傳
RECENT_DONE: dict[str, tuple[str, int]] = {}   # 最近完成的 uid -> (檔名, 大小)：最後一塊自動完成後，再收到 finish 也回 200
_UPLOAD_RE = re.compile(r"^/api/upload/([0-9a-f]{16})(/finish)?$")


def upload_uid(name: str, size: int, mtime: int) -> str:
    return hashlib.sha1(f"{name}\x00{size}\x00{mtime}".encode("utf-8")).hexdigest()[:16]


def _meta_path(part: Path) -> Path:
    return part.with_name(part.name + ".json")


def _unique_dest(folder: Path, name: str) -> tuple[Path, Path]:
    stem, ext = os.path.splitext(name)
    for i in range(1, 10000):
        cand = name if i == 1 else f"{stem} ({i}){ext}"
        dest, part = folder / cand, folder / (cand + ".part")
        if not (dest.exists() or part.exists() or _meta_path(part).exists()):
            return dest, part
    raise RuntimeError("找不到可用檔名")


def load_pending_uploads(folder: Path) -> None:
    """從磁碟上的 .part.json 重建進行中的上傳。呼叫者需持有 LOCK。"""
    for meta in folder.glob("*.part.json"):
        part = meta.with_name(meta.name[:-5])
        try:
            info = json.loads(meta.read_text(encoding="utf-8"))
            uid, name, size = str(info["uid"]), str(info["name"]), int(info["size"])
            if not part.exists() or not re.fullmatch(r"[0-9a-f]{16}", uid):
                raise ValueError
        except (OSError, ValueError, KeyError, TypeError):
            try:
                meta.unlink()
            except OSError:
                pass
            continue
        if uid not in UPLOADS:
            UPLOADS[uid] = {"uid": uid, "name": name, "size": size, "mtime": int(info.get("mtime") or 0),
                            "dest": folder / str(info.get("dest") or name), "part": part, "meta": meta,
                            "lock": threading.Lock(), "t0": time.time()}


def get_upload(uid: str) -> dict | None:
    up = UPLOADS.get(uid)
    if up is None:
        with LOCK:
            load_pending_uploads(CONFIG["folder"])
            up = UPLOADS.get(uid)
    return up


def begin_upload(name: str, size: int, mtime: int) -> dict:
    """同名＋同大小＋同修改時間視為同一個檔：有未完成的 .part 就接著傳，否則開新的。"""
    folder: Path = CONFIG["folder"]
    folder.mkdir(parents=True, exist_ok=True)
    uid = upload_uid(name, size, mtime)
    with LOCK:
        up = UPLOADS.get(uid)
        if up is None or not up["part"].exists():
            UPLOADS.pop(uid, None)
            load_pending_uploads(folder)
            up = UPLOADS.get(uid)
        if up is not None and up["part"].exists():
            return up
        dest, part = _unique_dest(folder, name)
        with open(part, "xb"):
            pass
        meta = _meta_path(part)
        meta.write_text(json.dumps({"uid": uid, "name": name, "size": size, "mtime": mtime, "dest": dest.name}, ensure_ascii=False), encoding="utf-8")
        up = {"uid": uid, "name": name, "size": size, "mtime": mtime, "dest": dest, "part": part, "meta": meta,
              "lock": threading.Lock(), "t0": time.time()}
        UPLOADS[uid] = up
        return up


def part_size(up: dict) -> int:
    try:
        return up["part"].stat().st_size
    except OSError:
        return -1


def finalize_upload(up: dict) -> Path:
    """.part 已收齊：設 mtime、改成正式檔名、刪 .json、從登錄拿掉。呼叫者需持有 up["lock"]（或確定沒人在寫）。"""
    dest: Path = up["dest"]
    if dest.exists():                                 # 等待期間有人放了同名檔
        dest, _ = _unique_dest(dest.parent, dest.name)
    if up["mtime"] > 0:
        ts = up["mtime"] / 1000
        try:
            os.utime(up["part"], (ts, ts))
        except OSError:
            pass
    os.replace(up["part"], dest)
    try:
        up["meta"].unlink()
    except OSError:
        pass
    UPLOADS.pop(up["uid"], None)
    RECENT_DONE[up["uid"]] = (dest.name, up["size"])
    while len(RECENT_DONE) > 500:
        del RECENT_DONE[next(iter(RECENT_DONE))]
    return dest


def finalize_complete_uploads() -> int:
    """啟動時：上次收齊但沒收到 finish 的檔案直接補完成。回傳補完成的數量。"""
    n = 0
    for up in list(UPLOADS.values()):
        size = part_size(up)
        if size >= 0 and size == up["size"]:
            with up["lock"]:
                if part_size(up) == up["size"]:
                    dest = finalize_upload(up)
                    print(f"{now_str()}  " + T("log_finalized", name=dest.name, size=human(size)), flush=True)
                    n += 1
    return n


def pending_uploads() -> list[dict]:
    """沒傳完而且已經閒置一陣子的上傳（正在傳的不算，免得電腦頁面在上傳途中就喊「沒傳完」）。"""
    out = []
    now = time.time()
    for up in list(UPLOADS.values()):
        got = part_size(up)
        if got >= 0 and now - up.get("last", 0) > 60:
            out.append({"uid": up["uid"], "name": up["dest"].name, "size": up["size"], "received": got})
    out.sort(key=lambda u: u["name"])
    return out[:50]


def drop_upload(up: dict) -> None:
    for p in (up["part"], up["meta"]):
        try:
            p.unlink()
        except OSError:
            pass
    with LOCK:
        UPLOADS.pop(up["uid"], None)


def cleanup_parts(folder: Path, max_age_days: int = 7) -> tuple[int, int]:
    """清掉太舊（或舊版沒有 .json 的）未完成上傳；回傳 (可續傳的數量, 清掉的數量)。"""
    kept = removed = 0
    now = time.time()
    for part in folder.glob("*.part"):
        meta = _meta_path(part)
        try:
            age = now - part.stat().st_mtime
        except OSError:
            continue
        if age > max_age_days * 86400 or not meta.exists():
            for p in (part, meta):
                try:
                    p.unlink()
                except OSError:
                    pass
            removed += 1
        else:
            kept += 1
    return kept, removed


def snapshot(is_local: bool = False) -> dict:
    folder: Path = CONFIG["folder"]
    folder.mkdir(parents=True, exist_ok=True)
    files = []
    for p in folder.iterdir():
        if p.is_file() and not p.name.startswith(".") and not p.name.endswith((".part", ".part.json")):
            st = p.stat()
            files.append({"name": p.name, "size": st.st_size, "mtime": int(st.st_mtime)})
    files.sort(key=lambda f: -f["mtime"])
    with LOCK:
        msgs = MESSAGES[-30:]
    out = {"files": files[:500], "total": len(files), "messages": msgs, "pc": CONFIG["pc_name"], "dir": str(folder),
           "url": current_url(), "pending": pending_uploads()}   # 清單只列最近 500 個（手機每 2.5 秒輪詢），總數另外給
    if is_local:
        out["qr"] = CONFIG["qr"]
        if CONFIG["auth"]:
            out["ticket"] = current_ticket()
            out["ticket_exp_in"] = int(max(0, TICKET["exp"] - time.time())) if not TICKET["fixed"] else -1
            out["pending_devices"] = pending_devices()
            out["devices"] = trusted_devices()
    return out


_IP_CACHE = {"t": 0.0}


def current_url() -> str:
    """每 5 秒重新偵測本機 IP，配對票到期也換；網址變了就重畫 QR（常駐時 DHCP 換 IP 用）。"""
    if "port" not in CONFIG:
        return CONFIG.get("url", "")
    now = time.time()
    if now - _IP_CACHE["t"] > 5:
        _IP_CACHE["t"] = now
        ip, _ = detect_ip(CONFIG.get("host_override"))
        if ip.lower() not in CONFIG["hosts"]:
            CONFIG["hosts"].add(ip.lower())
            print(f"{now_str()}  " + T("t_ip_changed", url=f"http://{ip}:{CONFIG['port']}/"), flush=True)
        _IP_CACHE["ip"] = ip
    ip = _IP_CACHE.get("ip") or "127.0.0.1"
    url = f"http://{ip}:{CONFIG['port']}/" + (f"?p={current_ticket()}" if CONFIG["auth"] else "")
    if url != CONFIG.get("url"):
        CONFIG["url"], CONFIG["qr"] = url, qr_matrix(url, "M")
    return CONFIG["url"]


def resolve_shared(name: str) -> Path | None:
    """共享資料夾裡的檔案：檔名不能有路徑、不能是隱藏檔或暫存檔，resolve 後必須還在資料夾內。"""
    if not name or "/" in name or "\\" in name or name.startswith(".") or name in (".", "..") or name.endswith((".part", ".part.json")):
        return None
    try:
        real = (CONFIG["folder"] / name).resolve(strict=True)
    except OSError:
        return None
    if real.parent != CONFIG["folder_real"] or not real.is_file():
        return None
    return real


# ---- JPEG 內建的 EXIF 縮圖（手機照片幾乎都有，約 160×120），不用解碼大圖、不用圖像庫
_THUMB_CACHE: dict[tuple, bytes | None] = {}


def exif_thumbnail(path: Path) -> bytes | None:
    import struct
    try:
        with open(path, "rb") as f:
            if f.read(2) != b"\xff\xd8":
                return None
            app1 = None
            for _ in range(64):
                marker = f.read(2)
                if len(marker) < 2 or marker[0] != 0xFF:
                    return None
                if marker[1] in (0xD8, 0x01) or 0xD0 <= marker[1] <= 0xD7:
                    continue
                if marker[1] in (0xDA, 0xD9):
                    return None
                ln = int.from_bytes(f.read(2), "big")
                if marker[1] == 0xE1:
                    data = f.read(ln - 2)
                    if data[:6] == b"Exif\x00\x00":
                        app1 = data[6:]
                        break
                    continue
                f.seek(ln - 2, 1)
    except OSError:
        return None
    if app1 is None or len(app1) < 8:
        return None
    endian = {b"II": "<", b"MM": ">"}.get(app1[:2])
    if not endian:
        return None
    try:
        u16 = lambda o: struct.unpack_from(endian + "H", app1, o)[0]   # noqa: E731
        u32 = lambda o: struct.unpack_from(endian + "I", app1, o)[0]   # noqa: E731
        if u16(2) != 42:
            return None
        ifd0 = u32(4)
        n = u16(ifd0)
        orientation = 1
        for i in range(n):
            e = ifd0 + 2 + 12 * i
            if u16(e) == 0x0112 and u16(e + 2) == 3:
                orientation = u16(e + 8)
        ifd1 = u32(ifd0 + 2 + 12 * n)
        if not ifd1:
            return None
        n1 = u16(ifd1)
        off = ln = None
        for i in range(n1):
            e = ifd1 + 2 + 12 * i
            tag, typ = u16(e), u16(e + 2)
            val = u32(e + 8) if typ == 4 else u16(e + 8)
            if tag == 0x0201:
                off = val
            elif tag == 0x0202:
                ln = val
        if off is None or ln is None or ln < 4:
            return None
        thumb = app1[off:off + ln]
        if thumb[:2] != b"\xff\xd8":
            return None
        if orientation in (0, 1):
            return thumb
        tiff = b"MM\x00\x2a\x00\x00\x00\x08\x00\x01" + struct.pack(">HHIHH", 0x0112, 3, 1, orientation, 0) + b"\x00\x00\x00\x00"
        seg = b"Exif\x00\x00" + tiff
        return thumb[:2] + b"\xff\xe1" + struct.pack(">H", len(seg) + 2) + seg + thumb[2:]   # 把方向帶給瀏覽器
    except (struct.error, IndexError):
        return None


def thumbnail_for(real: Path) -> bytes | None:
    st = real.stat()
    key = (str(real), st.st_mtime_ns, st.st_size)
    if key in _THUMB_CACHE:
        return _THUMB_CACHE[key]
    data = exif_thumbnail(real) if real.suffix.lower() in (".jpg", ".jpeg") else None
    with LOCK:
        if len(_THUMB_CACHE) > 300:
            _THUMB_CACHE.clear()
        _THUMB_CACHE[key] = data
    return data


class _Chunked:
    """把寫入包成 HTTP chunked 編碼（zip 邊打包邊送，不用先算大小）。沒有 tell/seek，zipfile 會自動改用 data descriptor。"""

    def __init__(self, raw):
        self.raw = raw
        self.buf = bytearray()

    def write(self, b):
        self.buf += b
        if len(self.buf) >= 256 * 1024:
            self.flush()
        return len(b)

    def flush(self):
        if self.buf:
            self.raw.write(f"{len(self.buf):x}\r\n".encode() + bytes(self.buf) + b"\r\n")
            self.buf.clear()

    def close(self):
        self.flush()
        self.raw.write(b"0\r\n\r\n")


_ICON_SIZES = (180, 192, 512)
_ICON_CACHE: dict[int, bytes] = {}
_ICON_LOCK = threading.Lock()


def icon_png(size: int) -> bytes:
    """App 圖示（藍底、白色手機⇄螢幕），純 Python 畫、快取；給 iOS 主畫面 / Android 捷徑 / 分頁 favicon。"""
    with _ICON_LOCK:
        if size not in _ICON_CACHE:
            _ICON_CACHE[size] = _render_icon(size)
        return _ICON_CACHE[size]


def _render_icon(S: int) -> bytes:
    import struct
    import zlib
    top, bot = (63, 131, 248), (29, 78, 216)
    white = (255, 255, 255)
    # ("rrect", x0, y0, x1, y1, radius, color) / ("tri", apex_x, apex_y, length, half_height, direction, color)
    # color None = 用藍底挖空（螢幕）
    shapes = [
        ("rrect", 0.12, 0.22, 0.40, 0.78, 0.055, white),      # 手機
        ("rrect", 0.15, 0.27, 0.37, 0.69, 0.02, None),
        ("rrect", 0.22, 0.722, 0.30, 0.746, 0.012, None),
        ("rrect", 0.54, 0.25, 0.90, 0.57, 0.04, white),       # 螢幕
        ("rrect", 0.57, 0.28, 0.87, 0.53, 0.015, None),
        ("rrect", 0.69, 0.57, 0.75, 0.64, 0.0, white),
        ("rrect", 0.61, 0.64, 0.83, 0.68, 0.015, white),
        ("rrect", 0.43, 0.385, 0.49, 0.415, 0.01, white),     # 箭頭 →
        ("tri", 0.53, 0.40, 0.055, 0.045, 1, white),
        ("rrect", 0.47, 0.585, 0.53, 0.615, 0.01, white),     # 箭頭 ←
        ("tri", 0.43, 0.60, 0.055, 0.045, -1, white),
    ]
    pre = []
    for sh in shapes:
        if sh[0] == "rrect":
            _, x0, y0, x1, y1, r, col = sh
            geo = (x0 * S, y0 * S, x1 * S, y1 * S, r * S)
            pre.append(("rrect", geo, (geo[0] - 1, geo[1] - 1, geo[2] + 1, geo[3] + 1), col))
        else:
            _, ax, ay, ln, hh, d, col = sh
            geo = (ax * S, ay * S, ln * S, hh * S, d)
            bx = geo[0] - d * geo[2]
            pre.append(("tri", geo, (min(geo[0], bx) - 1, geo[1] - geo[3] - 1, max(geo[0], bx) + 1, geo[1] + geo[3] + 1), col))
    rows = []
    for y in range(S):
        py = y + 0.5
        t = py / S
        base = tuple(a + (b - a) * t for a, b in zip(top, bot))
        row = bytearray()
        for x in range(S):
            px = x + 0.5
            r_, g_, b_ = base
            for kind, geo, (bx0, by0, bx1, by1), col in pre:
                if px < bx0 or px > bx1 or py < by0 or py > by1:
                    continue
                if kind == "rrect":
                    x0, y0, x1, y1, rad = geo
                    qx = abs(px - (x0 + x1) / 2) - (x1 - x0) / 2 + rad
                    qy = abs(py - (y0 + y1) / 2) - (y1 - y0) / 2 + rad
                    d = (max(qx, 0.0) ** 2 + max(qy, 0.0) ** 2) ** 0.5 + min(max(qx, qy), 0.0) - rad
                else:
                    ax, ay, ln, hh, dr = geo
                    ux, uy = (px - ax) * dr, py - ay
                    d = max(-ln - ux, (abs(uy) * ln + hh * ux) / (ln * ln + hh * hh) ** 0.5)
                cov = min(1.0, max(0.0, 0.5 - d))
                if cov > 0:
                    cr, cg, cb = col or base
                    r_ += (cr - r_) * cov
                    g_ += (cg - g_) * cov
                    b_ += (cb - b_) * cov
            row += bytes((int(r_ + 0.5), int(g_ + 0.5), int(b_ + 0.5), 255))
        rows.append(bytes(row))

    def chunk(tag: bytes, data: bytes) -> bytes:
        return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)

    raw = b"".join(b"\x00" + r for r in rows)
    return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", S, S, 8, 6, 0, 0, 0))
            + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))


def icon_ico() -> bytes:
    """Windows 捷徑／exe 用的 .ico（裡面直接放 256px PNG）。"""
    import struct
    png = icon_png(256)
    return struct.pack("<HHH", 0, 1, 1) + struct.pack("<BBBBHHII", 0, 0, 0, 0, 1, 32, len(png), 22) + png


# ---- 登入自動啟動：Windows「啟動」資料夾捷徑（pythonw，無視窗）、macOS LaunchAgent、Linux XDG autostart
def launch_command(args) -> list[str]:
    """常駐用的啟動指令：凍結成 exe 就是 exe 本身，否則用 pythonw（Windows）或目前的 python 跑這個檔。"""
    extra = ["--no-open", "--dir", str(Path(args.dir).expanduser().resolve())]
    if args.port != 8790:
        extra += ["--port", str(args.port)]
    if args.host:
        extra += ["--host", args.host]
    for h in args.allow_host:
        extra += ["--allow-host", h]
    if args.code:
        extra += ["--code", args.code]
    if args.no_auth:
        extra.append("--no-auth")
    if args.lang:
        extra += ["--lang", args.lang]
    if getattr(sys, "frozen", False):
        return [sys.executable] + extra
    exe = Path(sys.executable)
    if os.name == "nt" and exe.with_name("pythonw.exe").exists():
        exe = exe.with_name("pythonw.exe")
    return [str(exe), str(Path(__file__).resolve())] + extra


def _startup_dir() -> Path:
    override = os.environ.get("PHONEDROP_STARTUP_DIR")      # 測試用，指到別的資料夾
    if override:
        return Path(override)
    if os.name == "nt":
        return Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming"))) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
    if sys.platform == "darwin":
        return Path.home() / "Library" / "LaunchAgents"
    return Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / "autostart"


def _ps_quote(v) -> str:
    return "'" + str(v).replace("'", "''") + "'"


def autostart_install(args) -> str:
    cmd = launch_command(args)
    d = _startup_dir()
    d.mkdir(parents=True, exist_ok=True)
    if os.name == "nt":
        lnk = d / "PhoneDrop.lnk"
        ico = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "PhoneDrop" / "phonedrop.ico"
        try:
            ico.parent.mkdir(parents=True, exist_ok=True)
            ico.write_bytes(icon_ico())
        except OSError:
            ico = None
        argv = " ".join(f'"{a}"' if (" " in a or not a) else a for a in cmd[1:])
        ps = (f"$s=(New-Object -ComObject WScript.Shell).CreateShortcut({_ps_quote(lnk)});"
              f"$s.TargetPath={_ps_quote(cmd[0])};$s.Arguments={_ps_quote(argv)};$s.WorkingDirectory={_ps_quote(Path(cmd[0]).parent)};"
              + (f"$s.IconLocation={_ps_quote(ico)};" if ico else "") + "$s.Description='PhoneDrop';$s.Save()")
        try:
            subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], check=True, capture_output=True, timeout=60)
            return str(lnk)
        except (OSError, subprocess.SubprocessError):      # 沒有 PowerShell／COM：退而用 .vbs 隱藏視窗執行
            vbs = d / "PhoneDrop.vbs"
            line = " ".join('""' + a + '""' for a in cmd)
            vbs.write_text(f'CreateObject("WScript.Shell").Run "{line}", 0, False\n', encoding="utf-8")
            return str(vbs)
    if sys.platform == "darwin":
        import plistlib
        plist = d / "com.phonedrop.app.plist"
        plist.write_bytes(plistlib.dumps({"Label": "com.phonedrop.app", "ProgramArguments": cmd, "RunAtLoad": True, "KeepAlive": False}))
        subprocess.run(["launchctl", "unload", str(plist)], capture_output=True)
        subprocess.run(["launchctl", "load", "-w", str(plist)], capture_output=True)
        return str(plist)
    desk = d / "phonedrop.desktop"
    desk.write_text("[Desktop Entry]\nType=Application\nName=PhoneDrop\nComment=Phone to computer file transfer\n"
                    "Exec=" + " ".join(shlex.quote(a) for a in cmd) + "\nTerminal=false\nX-GNOME-Autostart-enabled=true\n", encoding="utf-8")
    return str(desk)


def autostart_uninstall() -> str | None:
    d = _startup_dir()
    removed = []
    names = ["PhoneDrop.lnk", "PhoneDrop.vbs"] if os.name == "nt" else ["com.phonedrop.app.plist"] if sys.platform == "darwin" else ["phonedrop.desktop"]
    for n in names:
        f = d / n
        if f.exists():
            if sys.platform == "darwin":
                subprocess.run(["launchctl", "unload", "-w", str(f)], capture_output=True)
            f.unlink()
            removed.append(str(f))
    return ", ".join(removed) or None


def manifest_json() -> bytes:
    return json.dumps({
        "name": "PhoneDrop", "short_name": "PhoneDrop", "start_url": "/", "scope": "/", "display": "standalone",
        "background_color": "#1d4ed8", "theme_color": "#2563eb",
        "icons": [{"src": "/icon.png?s=192", "sizes": "192x192", "type": "image/png"},
                  {"src": "/icon.png?s=512", "sizes": "512x512", "type": "image/png"}],
    }).encode("utf-8")


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    server_version = "PhoneDrop/1.0"
    timeout = 120

    # ---- 基礎
    def log_message(self, fmt, *args):  # 靜音預設 log，改用自己的
        pass

    def log_request(self, code="-", size="-"):
        if CONFIG.get("debug"):
            print(f"{now_str()}  [{self.client_address[0]}] {self.command} {self.path[:100]} → {code}", flush=True)

    def handle(self):
        try:
            super().handle()
        except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, TimeoutError):
            pass

    def parse_request(self) -> bool:
        """多做一件事：Host 標頭不是我們公布的位址就拒絕（防 DNS rebinding 借用「本機自動信任」）。"""
        if not super().parse_request():
            return False
        if self._host_ok():
            return True
        host = self.headers.get("Host", "")
        print(f"{now_str()}  " + T("log_bad_host", host=host[:60], ip=self.client_address[0]), flush=True)
        self.close_connection = True
        if self.path.startswith("/api/") or self.command != "GET":
            self._json({"error": "bad host"}, 403, {"Connection": "close"})
        else:
            self._send(403, BAD_HOST_PAGE.encode("utf-8"), extra={"Connection": "close"})
        return False

    def _host_ok(self) -> bool:
        host = self.headers.get("Host", "")
        try:
            name = (urlsplit("//" + host).hostname or "").lower()
        except ValueError:
            return False
        if not name:
            return False
        if name in CONFIG.get("hosts", ()):
            return True
        try:                                     # 這條連線在本機這端的 IP（多網卡也不用列舉）
            local = self.connection.getsockname()[0].lower()
        except OSError:
            return False
        return name == local or name == local.removeprefix("::ffff:")

    def _is_local(self) -> bool:
        return self.client_address[0] in ("127.0.0.1", "::1", "::ffff:127.0.0.1")

    def _csrf_ok(self) -> bool:
        """寫入類 API 要帶自訂標頭；跨來源網頁加了自訂標頭就會觸發 preflight，而我們不回 CORS 標頭。"""
        return self.headers.get(API_HEADER) == "1"

    def _device(self) -> dict | None:
        """cookie 裡的裝置憑證對應的裝置（任何狀態）。"""
        for part in self.headers.get("Cookie", "").split(";"):
            name, _, val = part.strip().partition("=")
            if name == "pd":
                return DEVICES.get(val.strip())
        return None

    def _authed(self, query: dict) -> bool:
        if not CONFIG["auth"] or self._is_local():
            return True
        dev = self._device()
        if dev is None or dev["state"] != "trusted":
            return False
        now = time.time()
        if now - dev["last"] > DEVICE_TTL:
            with LOCK:
                DEVICES.pop(dev["token"], None)
            save_devices()
            return False
        if now - dev["last"] > 600:                # 每次使用都續期（寫檔最多十分鐘一次）
            dev["last"] = now
            save_devices()
        else:
            dev["last"] = now
        return True

    def _note_auth_fail(self) -> None:
        ip = self.client_address[0]
        now = time.time()
        with LOCK:
            lst = AUTH_FAILS.setdefault(ip, [])
            lst[:] = [t for t in lst if now - t < 60]
            lst.append(now)
            n = len(lst)
        if n <= 5 or n % 50 == 0:
            print(f"{now_str()}  " + T("log_bad_code", ip=ip, n=n), flush=True)
        if n > 5:
            time.sleep(min(5.0, 0.5 * (n - 5)))

    def _who(self) -> str:
        if self._is_local():
            return "PC"
        ua = self.headers.get("User-Agent", "")
        for key, label in (("iPhone", "iPhone"), ("iPad", "iPad"), ("Android", "Android"), ("Macintosh", "Mac"),
                           ("Windows", "Windows"), ("Linux", "Linux")):
            if key in ua:
                return label
        return self.client_address[0]

    def _send(self, status: int, body: bytes, ctype: str = "text/html; charset=utf-8", extra=None):
        pairs = list(extra.items()) if isinstance(extra, dict) else list(extra or [])
        self.send_response(status)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("X-Content-Type-Options", "nosniff")
        if not any(k == "Cache-Control" for k, _ in pairs):
            self.send_header("Cache-Control", "no-store")
        for k, v in pairs:
            self.send_header(k, v)
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(body)

    def _json(self, obj, status: int = 200, extra: dict | None = None):
        self._send(status, json.dumps(obj, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8", extra)

    def _deny(self):
        self.close_connection = True   # 不讀未授權的 body
        self._json({"error": "unauthorized"}, 401, {"Connection": "close"})

    def _lang(self, q: dict) -> str:
        v = q.get("lang", [""])[0]
        if v:
            return norm_lang(v)
        for part in self.headers.get("Cookie", "").split(";"):
            name, _, val = part.strip().partition("=")
            if name == "lang" and val.strip():
                return norm_lang(val)
        al = self.headers.get("Accept-Language", "")
        if not al:
            return LANG
        for item in al.split(","):
            code = item.split(";")[0].strip().lower()
            if code.startswith("zh"):
                return "zh-TW"
            if code.startswith("en"):
                return "en"
        return "en"

    # ---- 路由
    def do_GET(self):
        u = urlsplit(self.path)
        q = parse_qs(u.query)
        path = unquote(u.path)
        if path == "/":
            lang = self._lang(q)
            extra = list(PAGE_HEADERS.items())
            if q.get("lang"):
                extra.append(("Set-Cookie", f"lang={lang}; Path=/; Max-Age=31536000; SameSite=Lax"))
            if CONFIG["auth"] and not self._is_local():
                dev = self._device()
                ticket = (q.get("p") or q.get("k") or [""])[0].strip()
                if dev is None or dev["state"] == "denied":
                    if not ticket_ok(ticket):              # 沒敲門或票不對：登入頁（猜錯會被拖慢）
                        if ticket:
                            self._note_auth_fail()
                        return self._send(401, render_tpl(LOGIN_PAGE, lang).encode("utf-8"), extra=extra)
                    ip = self.client_address[0]
                    state = "trusted" if CONFIG.get("auto_approve") else "pending"
                    dev = new_device(device_name(self.headers.get("User-Agent", ""), ip), ip, state)
                    extra.append(("Set-Cookie", f"pd={dev['token']}; Path=/; Max-Age={DEVICE_TTL}; SameSite=Lax; HttpOnly"))
                    print(f"{now_str()}  " + (T("log_pair_auto", name=dev["name"]) if state == "trusted" else T("log_pair_request", name=dev["name"], port=CONFIG["port"])), flush=True)
                if dev["state"] == "pending":
                    t = STRINGS.get(lang, STRINGS["en"])
                    page = render_tpl(WAIT_PAGE, lang).replace("__WAIT_TEXT__", html.escape(t["pair_wait"].format(name=dev["name"], port=CONFIG["port"]))) \
                                                      .replace("__DENIED_TEXT__", json.dumps(t["pair_denied"], ensure_ascii=False).replace("<", "\\u003c"))
                    return self._send(200, page.encode("utf-8"), extra=extra)
            is_pc = self._is_local()
            current_url()
            cfg = {"pc": CONFIG["pc_name"], "url": CONFIG["url"], "code": current_ticket() if CONFIG["auth"] else "",
                   "auth": CONFIG["auth"], "isPc": is_pc, "qr": CONFIG["qr"] if is_pc else None, "chunk": CHUNK_SIZE,
                   "lang": lang, "otherLang": "en" if lang == "zh-TW" else "zh-TW", "t": STRINGS[lang]}
            page = render_tpl(PAGE, lang)
            page = page.replace("__CONFIG__", json.dumps(cfg, ensure_ascii=False).replace("<", "\\u003c"))
            page = page.replace("__MANIFEST__", "/manifest.json")
            return self._send(200, page.encode("utf-8"), extra=extra)
        if path == "/api/pair/status":              # 待允許頁輪詢用：只回自己的狀態
            dev = self._device()
            return self._json({"state": "trusted" if (not CONFIG["auth"]) else (dev["state"] if dev else "none")})
        if path in ("/icon.png", "/favicon.ico", "/apple-touch-icon.png", "/apple-touch-icon-precomposed.png"):
            size = q.get("s", [""])[0]
            size = int(size) if size.isdigit() and int(size) in _ICON_SIZES else (180 if "apple" in path else 192)
            return self._send(200, icon_png(size), "image/png", {"Cache-Control": "public, max-age=86400"})
        if path == "/manifest.json":
            if not self._authed(q):
                return self._deny()
            return self._send(200, manifest_json(), "application/manifest+json; charset=utf-8")
        if path == "/api/state":
            if not self._authed(q):
                return self._deny()
            return self._json(snapshot(self._is_local()))
        if path.startswith("/thumb/"):
            if not self._authed(q):
                return self._deny()
            real = resolve_shared(path[len("/thumb/"):])
            data = thumbnail_for(real) if real else None
            if not data:
                return self._json({"error": "no thumbnail"}, 404)
            return self._send(200, data, "image/jpeg", {"Cache-Control": "private, max-age=86400"})
        if path == "/api/zip":
            if not self._authed(q):
                return self._deny()
            return self._serve_zip(q.get("f", []))
        m = _UPLOAD_RE.match(path)
        if m and not m.group(2):
            if not self._authed(q):
                return self._deny()
            up = get_upload(m.group(1))
            if up is None:
                return self._json({"error": "unknown upload"}, 404)
            return self._json({"uid": up["uid"], "offset": max(part_size(up), 0), "size": up["size"], "name": up["dest"].name})
        if path.startswith("/files/"):
            if not self._authed(q):
                return self._deny()
            return self._serve_file(path[len("/files/"):], dl="dl" in q)
        self._json({"error": "not found"}, 404)

    do_HEAD = do_GET

    def _read_body(self, limit: int) -> bytes | None:
        """讀整個 body；超過 limit 回 413 並回傳 None。"""
        try:
            length = int(self.headers.get("Content-Length") or 0)
        except ValueError:
            length = -1
        if length < 0 or length > limit:
            self.close_connection = True
            self._json({"error": "too large"}, 413, {"Connection": "close"})
            return None
        buf = bytearray()
        while len(buf) < length:
            chunk = self.rfile.read(min(1 << 20, length - len(buf)))
            if not chunk:
                raise ConnectionError("client disconnected")
            buf += chunk
        return bytes(buf)

    def _reject_csrf(self):
        self.close_connection = True
        self._json({"error": f"missing {API_HEADER} header"}, 403, {"Connection": "close"})

    def do_POST(self):
        u = urlsplit(self.path)
        q = parse_qs(u.query)
        m = _UPLOAD_RE.match(u.path)
        if not self._csrf_ok():
            return self._reject_csrf()
        if u.path == "/api/ticket/rotate" or u.path.startswith("/api/devices/"):
            if not self._is_local():
                return self._json({"error": "local only"}, 403)
            if u.path == "/api/ticket/rotate":
                new_ticket()
                current_url()
                return self._json({"ticket": current_ticket()})
            m2 = re.match(r"^/api/devices/([0-9a-f]{10})/(approve|deny|revoke)$", u.path)
            dev = device_by_id(m2.group(1)) if m2 else None
            if dev is None:
                return self._json({"error": "not found"}, 404)
            act = m2.group(2)
            if act == "approve":
                dev["state"] = "trusted"
                dev["last"] = time.time()
                save_devices()
                print(f"{now_str()}  " + T("log_pair_ok", name=dev["name"]), flush=True)
            elif act == "deny":
                dev["state"] = "denied"
                dev["last"] = time.time()
                print(f"{now_str()}  " + T("log_pair_deny", name=dev["name"]), flush=True)
            else:
                with LOCK:
                    DEVICES.pop(dev["token"], None)
                save_devices()
                print(f"{now_str()}  " + T("log_pair_revoke", name=dev["name"]), flush=True)
            return self._json({"ok": True})
        if u.path == "/api/quit":
            if not self._is_local():
                return self._json({"error": "local only"}, 403)
            self._json({"ok": True})
            srv = CONFIG.get("server")
            if srv is not None:
                threading.Thread(target=lambda: (time.sleep(0.3), srv.shutdown()), daemon=True).start()
            return
        if u.path == "/api/open-folder":
            if not self._is_local():
                return self._json({"error": "local only"}, 403)
            folder = str(CONFIG["folder"])
            try:
                if os.name == "nt":
                    os.startfile(folder)  # type: ignore[attr-defined]
                else:
                    import subprocess
                    subprocess.Popen(["open" if sys.platform == "darwin" else "xdg-open", folder])
            except OSError as exc:
                return self._json({"error": str(exc)}, 500)
            return self._json({"ok": True})
        if u.path == "/api/upload/init":
            return self._upload_init(q)
        if m and m.group(2):
            return self._upload_finish(q, m.group(1))
        if u.path != "/api/text":
            self.close_connection = True
            return self._json({"error": "not found"}, 404, {"Connection": "close"})
        if not self._authed(q):
            return self._deny()
        raw = self._read_body(1_000_000)
        if raw is None:
            return
        text = raw.decode("utf-8", "replace").strip()
        if text:
            who = self._who()
            with LOCK:
                MSG_SEQ[0] += 1
                MESSAGES.append({"id": MSG_SEQ[0], "text": text[:20000], "from": who, "ts": int(time.time())})
                del MESSAGES[:-200]
            preview = text.replace("\n", " ")
            print(f"{now_str()}  " + T("log_msg", who=who_label(who), text=preview[:80] + ("…" if len(preview) > 80 else "")), flush=True)
        self._json({"ok": True})

    # ---- 分塊上傳：init → 逐塊 PUT（帶 offset）→ finish；斷線後 GET 查 offset 再接著傳
    def _upload_init(self, q: dict):
        if not self._authed(q):
            return self._deny()
        raw = self._read_body(4096)
        if raw is None:
            return
        try:
            body = json.loads(raw.decode("utf-8"))
            name = safe_name(str(body.get("name", "")))
            size, mtime = int(body.get("size", -1)), int(body.get("mtime", 0) or 0)
            if not (0 <= size <= MAX_FILE_SIZE):
                raise ValueError
        except (ValueError, TypeError, AttributeError):
            return self._json({"error": "bad request"}, 400)
        folder: Path = CONFIG["folder"]
        folder.mkdir(parents=True, exist_ok=True)
        try:
            free = shutil.disk_usage(folder).free
        except OSError:
            free = None
        if free is not None and size + (64 << 20) > free:
            return self._json({"error": T("err_disk", free=human(free))}, 507)
        up = begin_upload(name, size, mtime)
        offset = part_size(up)
        if offset < 0 or offset > size:          # 暫存檔不見或比宣告的大：重來
            drop_upload(up)
            up = begin_upload(name, size, mtime)
            offset = 0
        if offset == size:                       # 上次已收齊只差 finish：直接完成
            with up["lock"]:
                dest = finalize_upload(up)
            print(f"{now_str()}  " + T("log_received", name=dest.name, size=human(size), speed="-", who=who_label(self._who())), flush=True)
            return self._json({"uid": up["uid"], "offset": size, "chunk": CHUNK_SIZE, "name": dest.name, "done": True})
        up["t0"] = up["last"] = time.time()
        if offset:
            print(f"{now_str()}  " + T("log_resume", name=up["dest"].name, offset=human(offset), size=human(size), who=who_label(self._who())), flush=True)
        self._json({"uid": up["uid"], "offset": offset, "chunk": CHUNK_SIZE, "name": up["dest"].name})

    def _upload_single(self, q: dict):
        """小檔一趟到位：PUT /api/upload/new?name=&size=&mtime= 帶整個檔案，寫完直接完成（一個請求搞定）。
        伺服器已經有一部分的話回 409 帶 uid/offset，前端改走分塊續傳；不讀 body、關連線。"""
        t0 = time.time()
        if not self._csrf_ok():
            return self._reject_csrf()
        if not self._authed(q):
            return self._deny()
        try:
            name = safe_name(q.get("name", [""])[0])
            size, mtime = int(q.get("size", ["-1"])[0]), int(q.get("mtime", ["0"])[0] or 0)
            length = int(self.headers.get("Content-Length", ""))
            if not (0 <= size <= MAX_CHUNK_BODY) or length != size:
                raise ValueError
        except ValueError:
            self.close_connection = True
            return self._json({"error": "bad request"}, 400, {"Connection": "close"})
        folder: Path = CONFIG["folder"]
        folder.mkdir(parents=True, exist_ok=True)
        up = begin_upload(name, size, mtime)
        cur = part_size(up)
        if cur < 0 or cur > size:
            drop_upload(up)
            up = begin_upload(name, size, mtime)
            cur = 0
        up["last"] = time.time()
        if cur == size:                          # 之前就收齊了：直接完成，body 不用讀
            with up["lock"]:
                dest = finalize_upload(up)
            self.close_connection = True
            print(f"{now_str()}  " + T("log_received", name=dest.name, size=human(size), speed="-", who=who_label(self._who())), flush=True)
            return self._json({"uid": up["uid"], "offset": size, "done": True, "name": dest.name}, 200, {"Connection": "close"})
        if cur > 0:                              # 有一部分：改走分塊續傳
            self.close_connection = True
            return self._json({"uid": up["uid"], "offset": cur}, 409, {"Connection": "close"})
        try:
            data = self._read_body(MAX_CHUNK_BODY)
        except ConnectionError:
            self.close_connection = True
            return
        if data is None:
            return
        with up["lock"]:
            if part_size(up) != 0:
                return self._json({"uid": up["uid"], "offset": part_size(up)}, 409)
            try:
                with open(up["part"], "ab") as f:
                    f.write(data)
            except OSError as exc:
                print(f"{now_str()}  " + T("log_write_fail", name=up["dest"].name, err=exc), flush=True)
                return self._json({"error": T("err_write", err=exc.strerror or exc)}, 507)
            dest = finalize_upload(up)
        dt = max(time.time() - t0, 1e-3)
        print(f"{now_str()}  " + T("log_received", name=dest.name, size=human(size), speed=human(size / dt), who=who_label(self._who())), flush=True)
        self._json({"uid": up["uid"], "offset": size, "done": True, "name": dest.name})

    def do_PUT(self):
        u = urlsplit(self.path)
        q = parse_qs(u.query)
        if u.path == "/api/upload/new":
            return self._upload_single(q)
        m = _UPLOAD_RE.match(u.path)
        if not m or m.group(2):
            self.close_connection = True
            return self._json({"error": "not found"}, 404, {"Connection": "close"})
        if not self._csrf_ok():
            return self._reject_csrf()
        if not self._authed(q):
            return self._deny()
        up = get_upload(m.group(1))
        if up is None:
            self.close_connection = True
            return self._json({"error": "unknown upload"}, 404, {"Connection": "close"})
        up["last"] = time.time()
        try:
            offset = int(q.get("offset", [""])[0])
        except ValueError:
            self.close_connection = True
            return self._json({"error": "offset required"}, 400, {"Connection": "close"})
        try:
            data = self._read_body(MAX_CHUNK_BODY)
        except ConnectionError:
            self.close_connection = True
            return
        if data is None:
            return
        with up["lock"]:
            cur = part_size(up)
            if cur < 0:
                return self._json({"error": "unknown upload"}, 404)
            if cur != offset:
                return self._json({"offset": cur}, 409)
            if cur + len(data) > up["size"]:
                return self._json({"error": T("err_toolarge")}, 400)
            try:
                with open(up["part"], "ab") as f:
                    f.write(data)
            except OSError as exc:                 # 多半是磁碟滿了
                print(f"{now_str()}  " + T("log_write_fail", name=up["dest"].name, err=exc), flush=True)
                return self._json({"error": T("err_write", err=exc.strerror or exc)}, 507)
            cur += len(data)
            if cur == up["size"]:                  # 最後一塊：直接完成，省一趟 finish
                dest = finalize_upload(up)
                dt = max(time.time() - up["t0"], 1e-3)
                print(f"{now_str()}  " + T("log_received", name=dest.name, size=human(cur), speed=human(cur / dt), who=who_label(self._who())), flush=True)
                return self._json({"offset": cur, "done": True, "name": dest.name})
        self._json({"offset": cur})

    def _upload_finish(self, q: dict, uid: str):
        if not self._authed(q):
            return self._deny()
        up = get_upload(uid)
        if up is None:
            if uid in RECENT_DONE:                 # 最後一塊時已經自動完成了
                name, size = RECENT_DONE[uid]
                return self._json({"name": name, "size": size})
            return self._json({"error": "unknown upload"}, 404)
        with up["lock"]:
            cur = part_size(up)
            if cur != up["size"]:
                return self._json({"offset": max(cur, 0), "error": "incomplete"}, 409)
            dest = finalize_upload(up)
        dt = max(time.time() - up["t0"], 1e-3)
        print(f"{now_str()}  " + T("log_received", name=dest.name, size=human(cur), speed=human(cur / dt), who=who_label(self._who())), flush=True)
        self._json({"name": dest.name, "size": cur})

    def do_DELETE(self):
        u = urlsplit(self.path)
        q = parse_qs(u.query)
        path = unquote(u.path)
        if path.startswith("/files/"):
            if not self._csrf_ok():
                return self._reject_csrf()
            if not self._authed(q):
                return self._deny()
            real = resolve_shared(path[len("/files/"):])
            if real is None:
                return self._json({"error": "not found"}, 404)
            try:
                real.unlink()
            except OSError as exc:
                return self._json({"error": str(exc)}, 500)
            print(f"{now_str()}  " + T("log_deleted", name=real.name, who=who_label(self._who())), flush=True)
            return self._json({"ok": True})
        m = _UPLOAD_RE.match(u.path)
        if not m or m.group(2):
            return self._json({"error": "not found"}, 404)
        if not self._csrf_ok():
            return self._reject_csrf()
        if not self._authed(q):
            return self._deny()
        up = get_upload(m.group(1))
        if up is None:
            return self._json({"error": "unknown upload"}, 404)
        with up["lock"]:
            drop_upload(up)
        print(f"{now_str()}  " + T("log_cancel", name=up["dest"].name, who=who_label(self._who())), flush=True)
        self._json({"ok": True})

    # ---- 檔案下載（支援 Range，iPhone 播影片需要）
    def _serve_zip(self, names: list[str]):
        files = [p for p in (resolve_shared(n) for n in names[:500]) if p is not None]
        if not files:
            return self._json({"error": "not found"}, 404)
        import zipfile
        zipname = "PhoneDrop-" + time.strftime("%Y%m%d-%H%M") + ".zip"
        self.send_response(200)
        self.send_header("Content-Type", "application/zip")
        self.send_header("Transfer-Encoding", "chunked")
        self.send_header("Cache-Control", "no-store")
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("Content-Disposition", f"attachment; filename*=UTF-8''{quote(zipname, safe='')}")
        self.end_headers()
        if self.command == "HEAD":
            return
        w = _Chunked(self.wfile)
        with zipfile.ZipFile(w, "w", zipfile.ZIP_STORED) as z:
            for real in files:
                z.write(real, arcname=real.name)
        w.close()
        print(f"{now_str()}  " + T("log_zip", n=len(files), who=who_label(self._who())), flush=True)

    def _serve_file(self, name: str, dl: bool):
        real = resolve_shared(name)
        if real is None:
            return self._json({"error": "not found"}, 404)
        st = real.stat()
        size = st.st_size
        start, end, status = 0, size - 1, 200
        rng = self.headers.get("Range", "")
        if rng.startswith("bytes=") and "," not in rng:
            a, _, b = rng[6:].partition("-")
            try:
                if a:
                    start, end = int(a), (int(b) if b else size - 1)
                elif b:
                    start, end = max(0, size - int(b)), size - 1
                else:
                    raise ValueError
            except ValueError:
                start, end = 0, size - 1
            else:
                if start >= size or start > end:
                    self.send_response(416)
                    self.send_header("Content-Range", f"bytes */{size}")
                    self.send_header("Content-Length", "0")
                    self.end_headers()
                    return
                end = min(end, size - 1)
                status = 206
        length = end - start + 1
        ctype = mimetypes.guess_type(name)[0] or "application/octet-stream"
        base_type = ctype.split(";")[0].strip().lower()
        if base_type in ACTIVE_TYPES:              # 上傳來的 HTML 不能在這個來源底下執行
            dl = True
        self.send_response(status)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(length))
        self.send_header("Accept-Ranges", "bytes")
        self.send_header("Last-Modified", self.date_time_string(st.st_mtime))
        self.send_header("Cache-Control", "no-cache")
        self.send_header("X-Content-Type-Options", "nosniff")
        if base_type in ACTIVE_TYPES or base_type == "image/svg+xml":
            self.send_header("Content-Security-Policy", "sandbox")
        self.send_header("Content-Disposition", f"{'attachment' if dl else 'inline'}; filename*=UTF-8''{quote(name, safe='')}")
        if status == 206:
            self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
        self.end_headers()
        if self.command == "HEAD":
            return
        with open(real, "rb") as f:
            f.seek(start)
            remaining = length
            while remaining > 0:
                chunk = f.read(min(1 << 20, remaining))
                if not chunk:
                    break
                self.wfile.write(chunk)
                remaining -= len(chunk)
        if status == 200 and not self._is_local():
            print(f"{now_str()}  " + T("log_sent", name=name, size=human(size), who=who_label(self._who())), flush=True)


class Server(ThreadingHTTPServer):
    daemon_threads = True
    allow_reuse_address = True


# ============================================================================
# 啟動
# ============================================================================

_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"   # 去掉 0/o/1/l/i 這些易混淆字


# ---- 握手：QR 裡的配對票（10 分鐘換新）只用來「敲門」；敲門後裝置是「待允許」，電腦按允許才拿到自己的裝置憑證。
#      憑證放在 cookie，90 天沒用才失效（每次請求都續期，所以傳到一半不會因為 QR 換新而停）；電腦頁面可隨時撤銷。
DEVICES: dict[str, dict] = {}                      # token -> {"token","id","name","ip","created","last","state"}
DEVICES_FILE = Path.home() / ".phonedrop.devices.json"
DEVICE_TTL = 90 * 86400
TICKET_TTL = 600
TICKET = {"value": "", "exp": 0.0, "fixed": False}


def new_ticket() -> str:
    if TICKET["fixed"]:
        return TICKET["value"]
    TICKET["value"] = "".join(secrets.choice(_ALPHABET) for _ in range(6))
    TICKET["exp"] = time.time() + TICKET_TTL
    return TICKET["value"]


def current_ticket() -> str:
    if not TICKET["value"] or (not TICKET["fixed"] and time.time() > TICKET["exp"]):
        new_ticket()
    return TICKET["value"]


def ticket_ok(value: str) -> bool:
    cur = current_ticket()
    return bool(value) and len(value) == len(cur) and secrets.compare_digest(value, cur)


def device_name(ua: str, ip: str) -> str:
    label = ip
    for key, lab in (("iPhone", "iPhone"), ("iPad", "iPad"), ("Android", "Android"), ("Macintosh", "Mac"), ("Windows", "Windows"), ("Linux", "Linux")):
        if key in ua:
            label = lab
            break
    m = re.search(r"Android [\d.]+; ([^;)]+)", ua)      # Android 的 UA 通常有機型
    if m:
        label += " " + m.group(1).strip()[:24]
    return f"{label} ({ip})"


def new_device(name: str, ip: str, state: str) -> dict:
    token = secrets.token_urlsafe(24)
    now = time.time()
    dev = {"token": token, "id": hashlib.sha1(token.encode()).hexdigest()[:10], "name": name, "ip": ip,
           "created": now, "last": now, "state": state}
    with LOCK:
        DEVICES[token] = dev
        for old in [d for d in DEVICES.values() if d["state"] == "denied" and now - d["last"] > 3600]:   # 被拒絕的放一小時就忘
            DEVICES.pop(old["token"], None)
    if state == "trusted":
        save_devices()
    return dev


def device_by_id(dev_id: str) -> dict | None:
    return next((d for d in DEVICES.values() if d["id"] == dev_id), None)


def save_devices() -> None:
    data = [{k: d[k] for k in ("token", "id", "name", "ip", "created", "last")} for d in DEVICES.values() if d["state"] == "trusted"]
    try:
        DEVICES_FILE.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
    except OSError:
        pass


def load_devices() -> None:
    try:
        data = json.loads(DEVICES_FILE.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return
    now = time.time()
    for d in data:
        try:
            if now - float(d["last"]) < DEVICE_TTL and isinstance(d["token"], str) and len(d["token"]) >= 16:
                DEVICES[d["token"]] = {**d, "state": "trusted"}
        except (KeyError, TypeError, ValueError):
            continue


def trusted_devices() -> list[dict]:
    return sorted(({"id": d["id"], "name": d["name"], "ip": d["ip"], "last": int(d["last"]), "created": int(d["created"])}
                   for d in DEVICES.values() if d["state"] == "trusted"), key=lambda d: -d["last"])


def pending_devices() -> list[dict]:
    return [{"id": d["id"], "name": d["name"], "ip": d["ip"], "since": int(d["created"])} for d in DEVICES.values() if d["state"] == "pending"]


def detect_ip(override: str | None) -> tuple[str, list[str]]:
    candidates: list[str] = []
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))            # 不會真的送封包，只是拿預設路由的網卡 IP
        candidates.append(s.getsockname()[0])
        s.close()
    except OSError:
        pass
    try:
        for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
            ip = info[4][0]
            if ip not in candidates and not ip.startswith("127."):
                candidates.append(ip)
    except OSError:
        pass
    primary = override or (candidates[0] if candidates else "127.0.0.1")
    return primary, candidates


def enable_vt() -> bool:
    if os.name != "nt":
        return True
    try:
        import ctypes
        k32 = ctypes.windll.kernel32
        h = k32.GetStdHandle(-11)
        mode = ctypes.c_uint32()
        if k32.GetConsoleMode(h, ctypes.byref(mode)):
            return bool(k32.SetConsoleMode(h, mode.value | 0x0004))
    except Exception:
        pass
    return False


def main() -> None:
    ap = argparse.ArgumentParser(description="PhoneDrop — phone ⇄ computer file transfer over the local network (手機⇄電腦同網段互傳)",
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--dir", default=str(Path.home() / "Downloads" / "PhoneDrop"), help="shared folder (default ~/Downloads/PhoneDrop) 共享資料夾")
    ap.add_argument("--port", type=int, default=8790, help="port (default 8790)")
    ap.add_argument("--host", help="IP to announce to phones when the computer has several 多網卡時指定要公布的 IP")
    ap.add_argument("--allow-host", action="append", default=[], metavar="NAME", help="extra Host names to accept (custom DNS names), repeatable 額外允許的 Host 名稱")
    ap.add_argument("--code", help="fixed pairing code instead of one that changes every 10 minutes (less safe) 固定配對碼")
    ap.add_argument("--auto-approve", action="store_true", help="trust any phone that has the current code, without pressing Allow 掃到 QR 就直接允許")
    ap.add_argument("--no-auth", action="store_true", help="no pairing at all (not recommended) 不要配對")
    ap.add_argument("--no-open", action="store_true", help="do not open the browser on the computer 不自動開瀏覽器")
    ap.add_argument("--lang", choices=["en", "zh-TW"], help="terminal language (default: system language) 終端機語言")
    ap.add_argument("--install-autostart", action="store_true", help="start hidden at login, with the options given here 登入時在背景自動啟動")
    ap.add_argument("--uninstall-autostart", action="store_true", help="remove the autostart entry 取消自動啟動")
    ap.add_argument("--debug", action="store_true", help="log every request 印出每個請求")
    ap.add_argument("--version", action="version", version=f"PhoneDrop {__version__}")
    args = ap.parse_args()
    global LANG
    LANG = detect_lang(args.lang)

    if sys.stdout is None or sys.stderr is None:     # pythonw / 無視窗 exe：訊息改寫到檔案
        log = Path.home() / ".phonedrop.log"
        try:
            if log.exists() and log.stat().st_size > 5_000_000:
                log.unlink()
            sys.stdout = sys.stderr = open(log, "a", encoding="utf-8", buffering=1)
            print(f"\n{time.strftime('%Y-%m-%d %H:%M:%S')}  " + T("t_log", log=log))
        except OSError:
            pass
    if args.install_autostart:
        print(T("t_autostart_on", where=autostart_install(args), port=args.port))
        return
    if args.uninstall_autostart:
        removed = autostart_uninstall()
        print(T("t_autostart_off", where=removed) if removed else T("t_autostart_none"))
        return

    try:
        sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    except Exception:
        pass
    vt = enable_vt()

    folder = Path(args.dir).expanduser()
    folder.mkdir(parents=True, exist_ok=True)
    kept, removed = cleanup_parts(folder)
    with LOCK:
        load_pending_uploads(folder)
    CONFIG["debug"] = args.debug
    kept -= finalize_complete_uploads()
    ip, candidates = detect_ip(args.host)
    auth = not args.no_auth
    if args.code:
        TICKET.update(value=args.code.strip(), exp=float("inf"), fixed=True)
    load_devices()

    hostname = socket.gethostname()
    hosts = {"localhost", "127.0.0.1", "::1", ip.lower(), hostname.lower(), hostname.lower() + ".local"}
    hosts |= {c.lower() for c in candidates} | {h.strip().lower() for h in args.allow_host if h.strip()}
    if args.host:
        hosts.add(args.host.lower())
    CONFIG.update({
        "folder": folder, "folder_real": folder.resolve(), "auth": auth, "auto_approve": args.auto_approve,
        "pc_name": hostname, "hosts": hosts, "port": args.port, "host_override": args.host,
    })
    _IP_CACHE.update(t=time.time(), ip=ip)
    url = current_url()

    try:
        server = Server(("0.0.0.0", args.port), Handler)
    except OSError as exc:
        sys.exit(T("t_port", port=args.port, err=exc))
    CONFIG["server"] = server

    print()
    print("  📱⇄💻 " + T("t_started"))
    print("  " + T("t_folder", folder=folder))
    print("  " + T("t_scan") + "\n")
    print(f"     {url}" + ("      " + T("t_code", code=current_ticket()) if auth else ""))
    print()
    if vt and getattr(sys.stdout, "isatty", lambda: False)():
        for line in qr_terminal(CONFIG["qr"]).splitlines():
            print("     " + line)
        print()
    print("  " + T("t_pc_page", port=args.port))
    if auth:
        print("  " + T("t_ticket_note"))
        print("  " + T("t_devices", n=len(trusted_devices())))
    if kept:
        print("  " + T("t_pending", n=kept))
    if removed:
        print("  " + T("t_cleaned", n=removed))
    if len(candidates) > 1:
        print("  " + T("t_multi_ip", ips=candidates))
    if os.name == "nt":
        print("  " + T("t_firewall"))
    if not auth:
        print("  " + T("t_noauth"))
    real = folder.resolve()
    if real == Path.home().resolve() or real.parent == real or len(real.parts) <= 1:
        print("  " + T("t_baddir", dir=real))
    print("  " + (T("t_quit") if getattr(sys.stdout, "isatty", lambda: False)() else T("t_quit_page", port=args.port)) + "\n", flush=True)

    threading.Thread(target=lambda: [icon_png(sz) for sz in _ICON_SIZES], daemon=True).start()
    if not args.no_open:
        threading.Timer(0.6, lambda: webbrowser.open(f"http://localhost:{args.port}/")).start()
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n  " + T("t_bye"))
    finally:
        server.server_close()


if __name__ == "__main__":
    main()
