{
  "title": "DSH 云服务器部署 + 手机 APK 联动",
  "doc_url": "https://www.lukezystudio.cn/dsh-mobile-doc/",
  "updated": "2026-10-21",
  "stack": {
    "os": "Ubuntu 24.04.4 LTS",
    "node": "22.23.0",
    "dsh": "0.2.1-rc.2",
    "nginx": "1.24.0 (Ubuntu)",
    "service_manager": "systemd --user (dbus user session, linger enabled)"
  },
  "service": {
    "name": "dsh web",
    "bind": "127.0.0.1:13080",
    "home": "/home/ubuntu/.dsh",
    "unit": "~/.config/systemd/user/dsh.service",
    "start_flags": [
      "--host 127.0.0.1",
      "--port 13080",
      "--trusted-host <domain>",
      "--trusted-host <domain>:<gateway-port>"
    ],
    "notes": [
      "官方 Web 只有 launch token，没有账号体系，禁止直接绑 0.0.0.0",
      "DSH_HOME 一旦确定不要迁移，插件 node_modules 里是绝对路径软链"
    ]
  },
  "gateway": {
    "plugin": "dsh-cloud-gateway",
    "repo": "https://github.com/MrLukezy/dsh-cloud-gateway",
    "listen": "0.0.0.0:3080",
    "base_path": "/dsh",
    "upstream": "http://127.0.0.1:13080",
    "auth": "form login -> session cookie",
    "config_file": "~/.dsh/profiles/web/cordis.patch.yml",
    "must_set": {
      "upstreamHost": "127.0.0.1",
      "upstreamPort": 13080,
      "basePath": "/dsh",
      "trustProxy": true
    },
    "pitfall": "缺少 upstreamHost/upstreamPort 时回落到 3080，网关转发给自己，登录永远失败"
  },
  "edge": {
    "server": "nginx",
    "tls": "Let's Encrypt (certbot --nginx)",
    "proxied_prefixes": ["/dsh/", "/assets/", "/plugins/", "/api"],
    "websocket": "Upgrade + Connection upgrade, read/send timeout 86400s",
    "buffering": "off (streaming SSE)",
    "cache": {
      "hashed_assets": "public, max-age=31536000, immutable",
      "entry_html": "no-cache",
      "apk_dir": "no-store"
    },
    "client_max_body_size": "50m"
  },
  "client": {
    "name": "dsh-mobile",
    "repo": "https://github.com/MrLukezy/dsh-mobile",
    "visibility": "private",
    "apk_url": "https://www.lukezystudio.cn/dsh-apk/mobile/dsh-mobile.apk",
    "landing_url": "https://www.lukezystudio.cn/dsh-apk/mobile/",
    "manifest_url": "https://www.lukezystudio.cn/dsh-apk/mobile/version.json",
    "version_code": 2,
    "version_name": "1.0.1",
    "size_bytes": 19344,
    "sha256": "402d59dabc702c1b1df43cb41354e8c6458e0a2d7086e3c16bf0ae47bc664d6c",
    "runtime": "Android WebView shell, no bundled web assets",
    "build": {
      "jdk": 17,
      "android_sdk": "platforms;android-34, build-tools;34.0.0",
      "command": "./gradlew assembleRelease",
      "output": "app/build/outputs/apk/release/app-release.apk"
    },
    "constants": {
      "DEFAULT_URL": "entry url",
      "VERSION_CHECK_URL": "version.json url",
      "VERSION_CODE": "must match version.json versionCode"
    },
    "features": [
      "basic-auth credential memory",
      "system file chooser (upload)",
      "DownloadManager (download with cookie)",
      "self-update via version.json",
      "back-key menu: refresh / change url / clear credentials / about"
    ]
  },
  "hot_update": {
    "layer_1": "page and plugin bundles served from server; refresh picks up new rev",
    "layer_2": "APK shell only when shell capabilities change (version.json prompt)",
    "mechanism": "entry html embeds bundle rev; hashed asset urls change when content changes"
  },
  "repos": [
    {"name": "MrLukezy/dsh-mobile", "desc": "Android WebView shell", "visibility": "private"},
    {"name": "MrLukezy/dsh-cloud-gateway", "desc": "login wall + reverse proxy plugin", "visibility": "public"},
    {"name": "MrLukezy/dsh-mobile-files", "desc": "phone file upload plugin", "visibility": "public"},
    {"name": "MrLukezy/dsh-balance", "desc": "balance / quota panel", "visibility": "public"},
    {"name": "MrLukezy/dsh-win-tunnel", "desc": "Windows DSH over SSH reverse tunnel", "visibility": "public"},
    {"name": "MrLukezy/dsh-mobile-cloud-deploy", "desc": "AI skill for the same deployment", "visibility": "public"},
    {"name": "mexiaosqwq/dsh-web-mobile", "desc": "mobile layout plugin", "visibility": "public"}
  ],
  "checks": [
    "systemctl --user status dsh --no-pager",
    "ss -lntp | grep -E '13080|3080'",
    "curl -sI https://<host>/dsh/  # expect 302 to /dsh/login",
    "journalctl --user -u dsh -f"
  ],
  "known_issues": [
    {"symptom": "login loops back to login page", "cause": "gateway upstream not configured", "fix": "set upstreamHost/upstreamPort"},
    {"symptom": "transport failure when picking a directory", "cause": "host/origin mismatch with --trusted-host", "fix": "add domain and domain:port"},
    {"symptom": "blank page with 404s", "cause": "only /dsh/ proxied", "fix": "also proxy /assets/, /plugins/, /api"},
    {"symptom": "old UI after upgrade", "cause": "entry html cached", "fix": "no-cache on /dsh/"},
    {"symptom": "response arrives in one burst", "cause": "nginx buffering SSE", "fix": "proxy_buffering off"},
    {"symptom": "upload fails", "cause": "body size limit", "fix": "client_max_body_size 50m"},
    {"symptom": "a UI row disappears", "cause": "plugin references missing icon -> slot crash", "fix": "icon fallback chain"},
    {"symptom": "ERR_TOO_MANY_RETRIES in app", "cause": "auth handler cancelled before proceed", "fix": "one-shot handler, proceed directly"},
    {"symptom": "update prompt loops", "cause": "versionCode changed in only one place", "fix": "build.gradle + MainActivity"},
    {"symptom": "http url fails on android", "cause": "cleartext blocked on Android 9+", "fix": "use https"}
  ]
}
