「Taildrop Web」のデバイス検出や表示部分を更新

監視フォルダにファイルが追加されたら自動送信するスクリプトを作成したので、この機会にTaildrop Webのインストールスクリプトを更新してみました。Tailnet内のデバイス検出関連や表示関連を修正。またインストールから常時起動までを一発でインストール出来るようにしています。

LXDコンテナにインストール

#!/usr/bin/env bash
# =============================================================================
#  Taildrop Web UI — インストールスクリプト
#  - ファイルのドラッグ&ドロップ / クリック選択で送信(複数可)
#  - サーバ上の /opt/lxd-data/taildrop/ からチェックボックスで選択送信
#  - Tailscale IP のみにバインド(LAN/外部には非公開)
# =============================================================================
set -euo pipefail

INSTALL_DIR="/opt/taildrop-web"
SERVICE_NAME="taildrop-web"
PORT=5000
SERVER_FILE_DIR="/opt/lxd-data/taildrop"

info()  { echo -e "\033[1;34m[INFO]\033[0m  $*"; }
ok()    { echo -e "\033[1;32m[ OK ]\033[0m  $*"; }
warn()  { echo -e "\033[1;33m[WARN]\033[0m  $*"; }
die()   { echo -e "\033[1;31m[ERR ]\033[0m  $*" >&2; exit 1; }

# ── 前提チェック ───────────────────────────────────────────────────────────────
info "前提確認..."
command -v python3 >/dev/null 2>&1 || die "python3 が見つかりません"
command -v tailscale >/dev/null 2>&1 || die "tailscale が見つかりません"
tailscale status >/dev/null 2>&1    || die "tailscale が接続されていません"

TS_IP=$(tailscale ip -4 2>/dev/null | head -1) || die "Tailscale IP が取得できません"
TS_HOSTNAME=$(tailscale status --json \
  | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['Self']['DNSName'].rstrip('.'))" \
  2>/dev/null) || TS_HOSTNAME="${TS_IP}"
ok "Tailscale IP: ${TS_IP} / hostname: ${TS_HOSTNAME}"

# ── ディレクトリ作成 ──────────────────────────────────────────────────────────
info "ディレクトリ作成..."
mkdir -p "${INSTALL_DIR}"
mkdir -p "${SERVER_FILE_DIR}"   # サーバファイル置き場(なければ作成)

# ── venv 作成 & 依存パッケージインストール ────────────────────────────────────
info "Python venv を確認..."

if [[ -d "${INSTALL_DIR}/venv" ]]; then
  if ! "${INSTALL_DIR}/venv/bin/python" -c "import sys" >/dev/null 2>&1; then
    warn "既存のvenvが壊れています。削除して再作成します..."
    rm -rf "${INSTALL_DIR}/venv"
  fi
fi

if [[ ! -d "${INSTALL_DIR}/venv" ]]; then
  # venv が使えるか確認
  TMPV=$(mktemp -d)
  if ! python3 -m venv "${TMPV}/test" 2>/dev/null; then
    rm -rf "${TMPV}"
    warn "python3-venv をインストールします..."
    PYVER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
    SUDO_CMD=""; [[ $EUID -ne 0 ]] && SUDO_CMD="sudo"
    ${SUDO_CMD} apt-get update -qq
    ${SUDO_CMD} apt-get install -y -qq "python3.${PYVER}-venv" 2>/dev/null || \
    ${SUDO_CMD} apt-get install -y -qq python3-venv || \
    die "python3-venv のインストールに失敗しました"
  else
    rm -rf "${TMPV}"
  fi
  python3 -m venv "${INSTALL_DIR}/venv" || die "venv の作成に失敗しました"
  ok "venv 作成完了"
else
  ok "venv は既に存在します(スキップ)"
fi

PIP="${INSTALL_DIR}/venv/bin/pip"
PYTHON="${INSTALL_DIR}/venv/bin/python"

info "依存パッケージを確認..."
for pkg in flask werkzeug; do
  if "${PYTHON}" -c "import importlib.metadata; importlib.metadata.version('${pkg}')" >/dev/null 2>&1; then
    ok "${pkg} は既にインストール済みです(スキップ)"
  else
    info "${pkg} をインストール..."
    "${PIP}" install --quiet "${pkg}" || die "${pkg} のインストールに失敗しました"
    ok "${pkg} インストール完了"
  fi
done

# ── テンプレート(HTML)を別ファイルとして生成 ─────────────────────────────────
info "テンプレートファイルを生成..."
cat > "${INSTALL_DIR}/template.html" << 'HTMLEOF'
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Taildrop Web</title>
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: system-ui, sans-serif; background: #f0f2f5; color: #222; }
  header { background: #0f7acc; color: #fff; padding: 14px 24px; display: flex; align-items: center; gap: 10px; }
  header h1 { font-size: 20px; font-weight: 600; }
  .container { max-width: 900px; margin: 24px auto; padding: 0 16px; display: flex; flex-direction: column; gap: 20px; }
  .card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.1); padding: 20px 24px; }
  .card h2 { font-size: 15px; font-weight: 600; color: #444; margin-bottom: 14px; border-bottom: 1px solid #eee; padding-bottom: 8px; }

  .device-grid { display: flex; flex-wrap: wrap; gap: 10px; }
  .device-item label {
    display: flex; align-items: center; gap: 8px; padding: 8px 14px;
    border: 1.5px solid #ddd; border-radius: 8px; cursor: pointer;
    font-size: 14px; transition: border-color .15s, background .15s; user-select: none;
  }
  .device-item label:has(input:checked) { border-color: #0f7acc; background: #e8f3fd; }
  .device-item label:has(input:disabled) { opacity: .45; cursor: not-allowed; }

  .drop-zone {
    border: 2.5px dashed #0f7acc; border-radius: 12px; padding: 48px 20px;
    text-align: center; background: #f8fbff; cursor: pointer;
    transition: background .15s; font-size: 15px; color: #555;
  }
  .drop-zone.dragover { background: #dceffe; border-color: #0056a0; }
  .file-list { margin-top: 10px; font-size: 13px; color: #555; line-height: 1.8;
    max-height: 140px; overflow-y: auto; padding: 0 4px; }

  .server-files { display: flex; flex-direction: column; gap: 4px; max-height: 320px; overflow-y: auto; }
  .sf-item label {
    display: flex; align-items: center; gap: 10px; padding: 7px 10px;
    border-radius: 6px; cursor: pointer; font-size: 14px; transition: background .1s; user-select: none;
  }
  .sf-item label:hover { background: #f0f4f8; }
  .sf-meta { margin-left: auto; font-size: 12px; color: #999; white-space: nowrap; }
  .empty-msg { color: #999; font-size: 14px; text-align: center; padding: 20px; }

  .toolbar { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; }
  .tbtn { font-size: 12px; color: #0f7acc; background: none; border: 1px solid #c5dcf0;
    border-radius: 5px; cursor: pointer; padding: 3px 9px; }
  .tbtn:hover { background: #e8f3fd; }
  .tbtn.gray { color: #666; border-color: #ddd; }
  .tbtn.gray:hover { background: #f5f5f5; }
  .ml-auto { margin-left: auto; }

  .send-btn {
    width: 100%; padding: 13px; font-size: 16px; font-weight: 600;
    background: #0f7acc; color: #fff; border: none; border-radius: 10px;
    cursor: pointer; transition: background .15s;
  }
  .send-btn:hover:not(:disabled) { background: #0960a5; }
  .send-btn:disabled { opacity: .5; cursor: not-allowed; }

  .progress-wrap { margin-top: 10px; display: none; }
  .progress-wrap.show { display: block; }
  .progress-bar-bg { background: #e0e8f0; border-radius: 6px; height: 10px; overflow: hidden; }
  .progress-bar { background: #0f7acc; height: 100%; width: 0%; transition: width .2s; border-radius: 6px; }
  .progress-label { font-size: 12px; color: #666; margin-top: 4px; }

  #status { font-size: 14px; line-height: 1.9; }
  .ok   { color: #1a7f3c; }
  .err  { color: #c0392b; }
  .warn { color: #b07800; }

  .error-toast {
    position: fixed; bottom: 20px; right: 20px; background: #c0392b; color: #fff;
    padding: 12px 18px; border-radius: 8px; font-size: 13px; max-width: 360px;
    box-shadow: 0 4px 12px rgba(0,0,0,.2); display: none; z-index: 999;
  }
</style>
</head>
<body>
<header>
  <span style="font-size:22px">🚀</span>
  <h1>Taildrop Web</h1>
</header>

<div class="container">

  <div class="card">
    <h2>📡 送信先デバイス</h2>
    <div class="device-grid" id="device-list">読み込み中...</div>
  </div>

  <div class="card">
    <h2>📂 ファイルをアップロードして送信</h2>
    <div id="drop-zone" class="drop-zone">
      <div>ここにファイルをドロップ</div>
      <div style="font-size:13px;margin-top:6px;color:#999">またはクリックして選択(複数可)</div>
    </div>
    <input type="file" id="file-input" multiple style="display:none">
    <div id="upload-file-list" class="file-list"></div>
  </div>

  <div class="card">
    <h2>🗂 サーバ上のファイルから送信</h2>
    <div class="toolbar">
      <button class="tbtn" onclick="selectAll(true)">全て選択</button>
      <button class="tbtn" onclick="selectAll(false)">全て解除</button>
      <button class="tbtn gray ml-auto" onclick="loadServerFiles()">🔄 更新</button>
    </div>
    <div class="server-files" id="server-files">読み込み中...</div>
  </div>

  <div class="card">
    <button class="send-btn" id="send-btn" onclick="sendAll()" disabled>📤 送信</button>
    <div class="progress-wrap" id="progress-wrap">
      <div class="progress-bar-bg"><div class="progress-bar" id="progress-bar"></div></div>
      <div class="progress-label" id="progress-label"></div>
    </div>
    <div id="status" style="margin-top:14px"></div>
  </div>

</div>

<div class="error-toast" id="errorToast"></div>

<script>
let selectedDevices = [];
let uploadFiles     = [];

function showError(msg) {
  const toast = document.getElementById('errorToast');
  toast.textContent = '⚠️ ' + msg;
  toast.style.display = 'block';
  clearTimeout(toast._hideTimer);
  toast._hideTimer = setTimeout(() => { toast.style.display = 'none'; }, 6000);
}

// ---- デバイス ----
function loadDevices() {
  const div = document.getElementById('device-list');
  fetch('/devices')
    .then(r => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return r.json();
    })
    .then(data => {
      div.innerHTML = '';
      if (!data.length) { div.textContent = 'デバイスが見つかりません'; return; }
      data.forEach(d => {
        const wrap = document.createElement('div');
        wrap.className = 'device-item';
        wrap.innerHTML = `<label>
          <input type="checkbox" value="${d.dns_name}" ${d.online ? '' : 'disabled'}>
          <span>${d.online ? '🟢' : '⚪'}</span>
          <span>${d.name}<br><small style="color:#999;font-weight:400">${d.lastseen}</small></span>
        </label>`;
        wrap.querySelector('input').onchange = () => {
          selectedDevices = [...document.querySelectorAll('#device-list input:checked')].map(i => i.value);
          updateBtn();
        };
        div.appendChild(wrap);
      });
    })
    .catch((err) => {
      console.error('loadDevices failed:', err);
      div.textContent = 'デバイス取得エラー';
      showError('デバイス一覧の取得に失敗しました: ' + err.message);
    });
}
loadDevices();

// ---- ドロップゾーン ----
const zone      = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');

zone.addEventListener('click',     () => fileInput.click());
zone.addEventListener('dragover',  e  => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', ()  => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
  e.preventDefault(); zone.classList.remove('dragover');
  setUploadFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', e => setUploadFiles(e.target.files));

function setUploadFiles(files) {
  if (!files || !files.length) return;
  uploadFiles = [...files];
  document.getElementById('upload-file-list').innerHTML =
    uploadFiles.map(f => `📄 ${f.name} <span style="color:#aaa">(${humanSize(f.size)})</span>`).join('<br>');
  updateBtn();
}

function humanSize(b) {
  if (b < 1024)       return b + ' B';
  if (b < 1024**2)    return (b/1024).toFixed(1) + ' KB';
  if (b < 1024**3)    return (b/1024**2).toFixed(1) + ' MB';
  return (b/1024**3).toFixed(2) + ' GB';
}

// ---- サーバファイル ----
function loadServerFiles() {
  const div = document.getElementById('server-files');
  div.innerHTML = '<span style="color:#999;font-size:14px;padding:10px;display:block">読み込み中...</span>';
  fetch('/server-files')
    .then(r => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return r.json();
    })
    .then(data => {
      div.innerHTML = '';
      if (!data.length) { div.innerHTML = '<div class="empty-msg">ファイルがありません</div>'; return; }
      data.forEach(f => {
        const item = document.createElement('div');
        item.className = 'sf-item';
        item.innerHTML = `<label>
          <input type="checkbox" name="sf" value="${f.name}" onchange="updateBtn()">
          <span>${f.is_dir ? '📁' : '📄'}</span>
          <span>${f.name}</span>
          <span class="sf-meta">${f.size_str}</span>
        </label>`;
        div.appendChild(item);
      });
    })
    .catch((err) => {
      console.error('loadServerFiles failed:', err);
      div.innerHTML = '<div class="empty-msg">取得エラー</div>';
      showError('サーバファイル一覧の取得に失敗しました: ' + err.message);
    });
}
loadServerFiles();

function selectAll(state) {
  document.querySelectorAll('#server-files input[name="sf"]').forEach(c => c.checked = state);
  updateBtn();
}

// ---- ボタン制御 ----
function updateBtn() {
  const hasTarget = selectedDevices.length > 0;
  const hasUpload = uploadFiles.length > 0;
  const hasSF     = [...document.querySelectorAll('#server-files input[name="sf"]:checked')].length > 0;
  document.getElementById('send-btn').disabled = !(hasTarget && (hasUpload || hasSF));
}

// ---- プログレス ----
function setProgress(pct, label) {
  document.getElementById('progress-wrap').classList.add('show');
  document.getElementById('progress-bar').style.width   = pct + '%';
  document.getElementById('progress-label').textContent = label;
}
function hideProgress() {
  document.getElementById('progress-wrap').classList.remove('show');
  document.getElementById('progress-bar').style.width = '0%';
}

// ---- XHR(プログレス付き) ----
function xhrUpload(url, formData, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', url);
    xhr.upload.onprogress = e => { if (e.lengthComputable) onProgress(e.loaded, e.total); };
    xhr.onload  = () => {
      if (xhr.status < 200 || xhr.status >= 300) {
        reject(new Error(`サーバーエラー (HTTP ${xhr.status})`));
        return;
      }
      try { resolve(JSON.parse(xhr.responseText)); }
      catch(e) { reject(new Error('レスポンスのパース失敗')); }
    };
    xhr.onerror = () => reject(new Error('ネットワークエラー'));
    xhr.send(formData);
  });
}

// ---- 送信 ----
async function sendAll() {
  if (!selectedDevices.length) { alert('送信先を選択してください'); return; }
  const btn    = document.getElementById('send-btn');
  const status = document.getElementById('status');
  btn.disabled = true; btn.textContent = '送信中...';
  status.innerHTML = '';
  hideProgress();
  const results = [];

  // 1) アップロードファイル(1ファイルずつ)
  for (let i = 0; i < uploadFiles.length; i++) {
    const f = uploadFiles[i];
    setProgress(
      Math.round(i / uploadFiles.length * 100),
      `送信中 ${i + 1} / ${uploadFiles.length}: ${f.name}`
    );
    const form = new FormData();
    form.append('devices', JSON.stringify(selectedDevices));
    form.append('file', f, f.name);
    try {
      const res = await xhrUpload('/send-upload', form, (loaded, total) => {
        const base = Math.round(i / uploadFiles.length * 100);
        const step = Math.round(loaded / total * (100 / uploadFiles.length));
        setProgress(base + step,
          `送信中 ${i + 1}/${uploadFiles.length}: ${f.name}  ${humanSize(loaded)} / ${humanSize(total)}`
        );
      });
      results.push(...res.results);
    } catch(e) {
      console.error('upload send failed:', e);
      results.push({ ok: false, msg: `❌ ${f.name} 送信エラー: ${e.message}` });
    }
  }
  if (uploadFiles.length) hideProgress();

  // 2) サーバファイル
  const sfChecked = [...document.querySelectorAll('#server-files input[name="sf"]:checked')].map(c => c.value);
  if (sfChecked.length > 0) {
    status.innerHTML = '<span style="color:#555">⏳ サーバファイル送信中...</span>';
    try {
      const r = await fetch('/send-server', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ files: sfChecked, devices: selectedDevices })
      });
      if (!r.ok) throw new Error(`サーバーエラー (HTTP ${r.status})`);
      const res = await r.json();
      results.push(...res.results);
    } catch(e) {
      console.error('server file send failed:', e);
      results.push({ ok: false, msg: `❌ サーバファイル送信エラー: ${e.message}` });
    }
  }

  status.innerHTML = results.map(r =>
    `<div class="${r.ok ? 'ok' : 'err'}">${r.msg}</div>`
  ).join('') || '<span class="ok">完了</span>';

  btn.textContent = '📤 送信';
  btn.disabled    = false;
  hideProgress();
}
</script>
</body>
</html>
HTMLEOF
ok "テンプレートファイル生成完了"

# ── アプリケーションスクリプト生成 ────────────────────────────────────────────
info "アプリケーションを生成..."
cat > "${INSTALL_DIR}/app.py" << 'PYEOF'
#!/usr/bin/env python3
"""
Taildrop Web UI
- Tailscale IP のみにバインド(LAN/外部には非公開)
- ファイルのドラッグ&ドロップ / クリック選択で送信(複数可)
- サーバ上の /opt/lxd-data/taildrop/ からチェックボックスで選択送信
"""

from flask import Flask, request, render_template, jsonify
from werkzeug.utils import secure_filename
import subprocess
import json
import os
import tempfile
import zipfile

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app = Flask(__name__, template_folder=BASE_DIR)
SERVER_FILE_DIR = "/opt/lxd-data/taildrop"


def get_tailscale_ip() -> str:
    try:
        ip = subprocess.check_output(['tailscale', 'ip', '-4'], timeout=5).decode().strip()
        if ip:
            return ip.split('\n')[0]
    except Exception:
        pass
    return '0.0.0.0'


def run_tailscale_cp(src: str, device: str):
    try:
        subprocess.check_call(
            ['tailscale', 'file', 'cp', src, f'{device}:'],
            timeout=300,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.PIPE,
        )
        return True, None
    except subprocess.TimeoutExpired:
        return False, 'タイムアウト'
    except subprocess.CalledProcessError as e:
        stderr = e.stderr.decode(errors='replace').strip() if e.stderr else ''
        return False, stderr or f'終了コード {e.returncode}'
    except Exception as e:
        return False, str(e)


def human_size(size: int) -> str:
    for unit in ('B', 'KB', 'MB', 'GB'):
        if size < 1024:
            return f'{size} {unit}' if unit == 'B' else f'{size:.1f} {unit}'
        size /= 1024
    return f'{size:.1f} TB'


@app.route('/')
def index():
    return render_template('template.html')


@app.route('/devices')
def get_devices():
    try:
        result = subprocess.check_output(['tailscale', 'status', '--json'], timeout=10)
        data   = json.loads(result)
        peers  = []
        for peer in data.get('Peer', {}).values():
            # dns_name: tailscale file cp の宛先解決に使う実際の名前(DNSNameの先頭ラベル)
            # name:     UI表示用のわかりやすい名前(HostNameを優先、スペース等含んでもOK)
            dns_name = peer.get('DNSName', '').rstrip('.').split('.')[0]
            if not dns_name:
                continue
            host_name = peer.get('HostName') or ''
            # iOS/iPadOS等で HostName が "localhost" になるケースがあるため、その場合は dns_name を表示に使う
            name = host_name if host_name and host_name.lower() != 'localhost' else dns_name
            online   = peer.get('Online', False)
            lastseen = '接続中' if online else (peer.get('LastSeen') or '不明')[:16]
            peers.append({'name': name, 'dns_name': dns_name, 'online': online, 'lastseen': lastseen})
        peers.sort(key=lambda x: (not x['online'], x['name']))
        return jsonify(peers)
    except Exception as e:
        return jsonify([{'name': f'エラー: {e}', 'dns_name': '', 'online': False, 'lastseen': ''}])


@app.route('/server-files')
def server_files():
    items = []
    try:
        for entry in sorted(os.scandir(SERVER_FILE_DIR), key=lambda e: (not e.is_dir(), e.name.lower())):
            try:
                if entry.is_dir():
                    total    = sum(f.stat().st_size for f in os.scandir(entry.path) if f.is_file())
                    size_str = f'{human_size(total)} (フォルダ)'
                    is_dir   = True
                else:
                    size_str = human_size(entry.stat().st_size)
                    is_dir   = False
                items.append({'name': entry.name, 'size_str': size_str, 'is_dir': is_dir})
            except PermissionError:
                pass
    except FileNotFoundError:
        pass
    return jsonify(items)


@app.route('/send-upload', methods=['POST'])
def send_upload():
    f = request.files.get('file')
    if not f:
        return jsonify({'results': [{'ok': False, 'msg': '❌ ファイルが見つかりません'}]})
    try:
        devices = json.loads(request.form.get('devices', '[]'))
    except Exception:
        return jsonify({'results': [{'ok': False, 'msg': '❌ 送信先パラメータが不正'}]})

    safe_name = secure_filename(f.filename) or 'upload'
    results   = []
    with tempfile.TemporaryDirectory() as tmp:
        save_path = os.path.join(tmp, safe_name)
        f.save(save_path)
        for dev in devices:
            ok, err = run_tailscale_cp(save_path, dev)
            msg = f'✅ {safe_name} → {dev}' if ok else f'❌ {safe_name} → {dev} : {err}'
            results.append({'ok': ok, 'msg': msg})
    return jsonify({'results': results})


@app.route('/send-server', methods=['POST'])
def send_server():
    body       = request.get_json(force=True, silent=True) or {}
    file_names = body.get('files', [])
    devices    = body.get('devices', [])
    results    = []

    with tempfile.TemporaryDirectory() as tmp:
        for name in file_names:
            safe_name = os.path.basename(name)
            if not safe_name or safe_name != name:
                results.append({'ok': False, 'msg': f'⚠️ 不正なファイル名: {name}'})
                continue
            src = os.path.join(SERVER_FILE_DIR, safe_name)
            if not os.path.exists(src):
                results.append({'ok': False, 'msg': f'⚠️ 見つかりません: {safe_name}'})
                continue

            if os.path.isdir(src):
                # サーバ上のフォルダはzip化して送信
                zip_name = safe_name + '.zip'
                zip_path = os.path.join(tmp, zip_name)
                with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
                    for root, dirs, ffiles in os.walk(src):
                        for ff in ffiles:
                            full = os.path.join(root, ff)
                            zf.write(full, os.path.relpath(full, os.path.dirname(src)))
                send_path, display_name = zip_path, zip_name
            else:
                send_path, display_name = src, safe_name

            for dev in devices:
                ok, err = run_tailscale_cp(send_path, dev)
                msg = f'✅ {display_name} → {dev}' if ok else f'❌ {display_name} → {dev} : {err}'
                results.append({'ok': ok, 'msg': msg})

    return jsonify({'results': results})


def main():
    template_path = os.path.join(BASE_DIR, 'template.html')
    if not os.path.exists(template_path):
        print(f'[ERR] テンプレートファイルが見つかりません: {template_path}')
        raise SystemExit(1)

    os.makedirs(SERVER_FILE_DIR, exist_ok=True)

    port  = int(os.environ.get('PORT', 5000))
    ts_ip = get_tailscale_ip()
    print('🚀 Taildrop Web 起動')
    print(f'   バインド  : {ts_ip}:{port}  ← Tailscaleネットワーク内のみ')
    print(f'   アクセスURL: http://{ts_ip}:{port}')
    print(f'   サーバファイル: {SERVER_FILE_DIR}')
    app.run(host=ts_ip, port=port, debug=False)


if __name__ == '__main__':
    main()
PYEOF
ok "アプリケーションスクリプト生成完了"

# ── systemd ユニットファイル生成 ───────────────────────────────────────────────
info "systemd ユニットファイルを生成..."
cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF
[Unit]
Description=Taildrop Web UI
After=network.target tailscaled.service
Wants=tailscaled.service

[Service]
Type=simple
ExecStart=${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/app.py
WorkingDirectory=${INSTALL_DIR}
Environment=PORT=${PORT}
Restart=on-failure
RestartSec=5
# Tailscale IP の取得が間に合わない場合のリトライ猶予
StartLimitIntervalSec=60
StartLimitBurst=5

[Install]
WantedBy=multi-user.target
EOF
ok "systemd ユニットファイル生成完了"

# ── サービス起動 ──────────────────────────────────────────────────────────────
info "サービスを起動..."
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"

# 起動確認(Tailscale IP でアクセス。このアプリは ts_ip にのみバインドするため 127.0.0.1 では確認できない)
for i in $(seq 1 15); do
  if curl -s --max-time 1 "http://${TS_IP}:${PORT}/" >/dev/null 2>&1; then
    break
  fi
  sleep 1
done

if curl -s --max-time 1 "http://${TS_IP}:${PORT}/" >/dev/null 2>&1; then
  ok "サービス起動完了"
else
  die "サービスの起動に失敗しました。journalctl -u ${SERVICE_NAME} -n 30 で確認してください"
fi

# ── 完了サマリー ──────────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
ok "セットアップ完了!"
echo ""
echo "  Web UI : http://${TS_IP}:${PORT}"
echo "  Web UI : http://${TS_HOSTNAME%%.*}:${PORT}  (MagicDNS)"
echo "  サーバファイル置き場: ${SERVER_FILE_DIR}"
echo ""
echo "  ▶ Web UI にアクセスして送信先デバイスを選択し、ファイルを送信してください。"
echo ""
echo "  管理コマンド:"
echo "    systemctl status ${SERVICE_NAME}"
echo "    systemctl restart ${SERVICE_NAME}"
echo "    journalctl -u ${SERVICE_NAME} -f"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
タイトルとURLをコピーしました