commit 1a2185a92692916c8985fb09e95f848c1383d2ca Author: cfn Date: Wed Nov 4 18:25:43 2026 +0800 feat: initialize iTuoke with real-time phone control diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e280ccb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto +*.sh text eol=lf +gradlew text eol=lf +*.bat text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..412bfa7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Build outputs and task caches +/bin/ +/.task/ +/frontend/dist/ +/frontend/bindings/ +/frontend/node_modules/ +/frontend/*.tsbuildinfo + +# Local Python environments and caches +.venv/ +venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ + +# Local settings and credentials +.env +.env.* +!.env.example +!.env.*.example +*.local +.idea/ +.vscode/ +.DS_Store +._* + +# Logs, temporary files, backups, and diagnostic recordings +*.log +*.tmp +*.bak +*.oldwails +/- +/recordings/ +/captures/ +/build/icon-preview.png + +# Bundled SDK binaries are synchronized before packaging +/third_party/platform-tools/ +/third_party/scrcpy/ +/build/linux/appimage/build/ +/build/windows/nsis/MicrosoftEdgeWebview2Setup.exe + +# Mobile build output and machine-specific configuration +/build/android/.gradle/ +/build/android/local.properties +/build/android/app/build/ +/build/android/build/ +/build/android/.cxx/ +/build/android/overlay.json +/build/android/gen/ +/build/ios/xcode/overlay.json +/build/ios/xcode/gen/ +/build/ios/DerivedData/ +*.xcuserstate +xcuserdata/ diff --git a/CODEMAP.md b/CODEMAP.md new file mode 100644 index 0000000..1392e4b --- /dev/null +++ b/CODEMAP.md @@ -0,0 +1,29 @@ +# 代码导航(改哪里) + +按「要改什么」找文件;源码里也有同风格备注。 + +| 你想改… | 打开 | +|---------|------| +| 窗口标题 / 尺寸 / 注册服务 | `main.go` | +| 设备列表、配对、连接、截帧、点击 | `internal/device/service.go` | +| ADB 命令封装(找 adb / pair / screencap) | `internal/device/adb.go` | +| 内置 adb 同步到安装包 | `scripts/sync-platform-tools.sh` → `third_party/platform-tools/` | +| 设备相关数据结构 | `internal/device/types.go` | +| 无线配对/连接面板 | `frontend/src/components/device/ConnectPanel.vue` | +| 对话 / 启动 Agent / 协议版本 | `internal/agent/service.go` | +| Go → Python HTTP 调用 | `internal/agent/client.go` | +| JSON-RPC 版本与消息字段 | `internal/protocol/*.go` | +| Python 模型与 method 实现 | `agent/main.py` | +| 左右分栏总布局 | `frontend/src/App.vue` | +| 左侧画面 | `frontend/src/components/screen/CapturePane.vue` | +| 右侧对话 | `frontend/src/components/chat/ChatPane.vue` | +| 顶部设备栏 | `frontend/src/components/device/DeviceBar.vue` | +| 设置页 | `frontend/src/components/settings/SettingsPane.vue` | +| 日志页 | `frontend/src/components/logs/LogPane.vue` | +| 全局配色 / 字体 | `frontend/src/styles/app.css` | + +改了 Go 的导出方法后执行: + +```bash +wails3 generate bindings -ts -i ./... +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..53ef842 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# iTuoke + +手机操控桌面应用:**Wails + Vue3 + TypeScript**(UI) / **Go**(ADB 设备核心) / **Python**(Agent 独立进程)。 + +## 架构 + +```text +frontend/ UI:左画面 | 右对话 | 设置 | 日志 +main.go 桌面壳入口(窗口 + 注册服务) +internal/device/ ADB 连接 / 视频预览 / 实时触摸控制(Go) +internal/agent/ 对接 Python Agent 的桥 +internal/protocol/ 版本化 JSON-RPC 契约 +agent/ Python Agent 进程(可后接云端模型) +``` + +关键改代码位置已在源文件顶部用 `[关键]` / `[入口]` / `[UI · …]` 备注标出。 + +## 开发 + +需要 Go 1.26、Node.js 22.18+(测试直接运行 TypeScript)、npm 和 Wails v3。macOS 实时预览还需安装 `scrcpy 5.0.1` 与 `ffmpeg`(当前控制协议对应 scrcpy 5.0.1)。首次运行先安装前端依赖,并同步内置 ADB: + +```bash +npm --prefix frontend install +./scripts/sync-platform-tools.sh +``` + + +```bash +# 1) 生成前端绑定(改 Go 导出方法后重跑) +wails3 generate bindings -ts -i ./... + +# 2) 跑桌面应用(热重载) +wails3 dev + +# 3) 可选:单独起 Python Agent +cd agent && python3 main.py +``` + +macOS 开发运行会把应用包同步到 `~/Applications/iTuoke.app` 后启动,避免从外置磁盘运行时反复触发「可移除宗卷」文件授权。ADB 使用应用专用的本地后台服务,不复用其他工具的默认服务;配对记录继续保留。 + +## 内置 ADB(客户无需安装) + +```bash +./scripts/sync-platform-tools.sh # 同步 platform-tools 到 third_party/ +wails3 build # macOS 打包时自动拷进 .app/Contents/Resources/ +``` + +运行时优先使用应用内置 `adb`,不依赖客户电脑是否安装过 Android SDK。 + +## 当前能力 + +- USB / 无线 ADB 设备发现、配对、连接与离线重连。 +- scrcpy 视频预览,支持适应窗口及 50%–200% 缩放。 +- 预览中点按、长按及连续拖动:按下、移动、松开实时发送到 scrcpy 控制通道。 +- 深色、浅色和跟随系统主题,连接配置与缩放偏好持久化。 +- 设置与日志页面;Go/Python Agent 对话目前仍为 stub,尚未接入模型。 + +`frontend/bindings/`、依赖目录、构建产物、录屏和本机配置不提交;前端绑定由上述生成命令或 Wails 构建任务重建。图标、原始图标素材及各平台打包模板保留在仓库。 + +## 验证 + +```bash +node --test frontend/tests/*.test.mjs +npm --prefix frontend run build +go test -race ./internal/... +``` + +可选实机预览检查(设备需已连接到 iTuoke 的专用 ADB 服务): + +```bash +ITUOKE_TEST_DEVICE='手机IP:连接端口' go test -race ./internal/device -run TestLocalPhonePreview -v -count=1 +``` diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..55e18d3 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,70 @@ +version: '3' + +vars: + APP_NAME: "iTuoke" + BIN_DIR: "bin" + PACKAGE_MANAGER: '{{.PACKAGE_MANAGER | default "npm"}}' + VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}' + # Target OS for build/package/run. Defaults to the host OS, and is overridden + # by `wails3 build GOOS=...` (or the GOOS env var) for cross-compilation. The + # tasks below dispatch to the matching platform Taskfile via this variable. + GOOS: '{{.GOOS | default OS}}' + +includes: + common: ./build/Taskfile.yml + windows: ./build/windows/Taskfile.yml + darwin: ./build/darwin/Taskfile.yml + linux: ./build/linux/Taskfile.yml + ios: ./build/ios/Taskfile.yml + android: ./build/android/Taskfile.yml + +tasks: + build: + summary: Builds the application + cmds: + - task: "{{.GOOS}}:build" + + package: + summary: Packages a production build of the application + cmds: + - task: "{{.GOOS}}:package" + + run: + summary: Runs the application + cmds: + - task: "{{.GOOS}}:run" + + dev: + summary: Runs the application in development mode + cmds: + - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}} + + install:msix:tools: + summary: Installs tools required for Windows MSIX packaging + cmds: + - task: windows:install:msix:tools + + setup:docker: + summary: Builds Docker image for cross-compilation (~800MB download) + cmds: + - task: common:setup:docker + + build:server: + summary: Builds the application in server mode (no GUI, HTTP server only) + cmds: + - task: common:build:server + + run:server: + summary: Runs the application in server mode + cmds: + - task: common:run:server + + build:docker: + summary: Builds a Docker image for server mode deployment + cmds: + - task: common:build:docker + + run:docker: + summary: Builds and runs the Docker image + cmds: + - task: common:run:docker diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..b617ad7 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,28 @@ +# Python Agent(独立进程) + +> **[关键]** 模型理解、下一步规划、工具调用、审计日志都放这里。 +> Go 通过 `internal/agent/client.go` 以 JSON-RPC(`POST /rpc`)调用本进程。 + +## 协议 + +- 版本常量:与 Go 侧 `internal/protocol.Version` 保持一致(当前 `1.0`) +- 默认地址:`127.0.0.1:17890` +- 消息结构:见 `internal/protocol/messages.go` + +## 本地运行(开发) + +```bash +cd agent +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +## 方法 + +| method | 说明 | +|--------|------| +| `agent.health` | 健康检查 | +| `agent.chat` | 对话(可先接云端 API) | +| `agent.plan_next` | 看图 + 上下文 → 设备动作列表 | diff --git a/agent/main.py b/agent/main.py new file mode 100644 index 0000000..a57cc02 --- /dev/null +++ b/agent/main.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +[Agent 入口] Python 独立进程 — JSON-RPC over HTTP +改监听地址:HOST / PORT +改业务方法:handle_rpc 内的 method 分支 +协议字段必须与 Go internal/protocol 对齐(protocolVersion=1.0) +""" + +from __future__ import annotations + +import json +import uuid +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any + +HOST = "127.0.0.1" +PORT = 17890 +PROTOCOL_VERSION = "1.0" + + +def handle_rpc(body: dict[str, Any]) -> dict[str, Any]: + # ---------- [关键] 所有 Agent method 分发在这里 ---------- + req_id = body.get("id") or str(uuid.uuid4()) + version = body.get("protocolVersion", "") + method = body.get("method", "") + params = body.get("params") or {} + + if version != PROTOCOL_VERSION: + return { + "protocolVersion": PROTOCOL_VERSION, + "id": req_id, + "error": { + "code": -32000, + "message": f"protocol mismatch: got {version}, want {PROTOCOL_VERSION}", + }, + } + + if method == "agent.health": + return { + "protocolVersion": PROTOCOL_VERSION, + "id": req_id, + "result": {"ok": True, "engine": "python-stub"}, + } + + if method == "agent.chat": + # TODO[云端 API]: 在此接入 OpenAI / Claude 等 + message = "" + if isinstance(params, dict): + message = str(params.get("message") or params.get("userMessage") or "") + return { + "protocolVersion": PROTOCOL_VERSION, + "id": req_id, + "result": { + "role": "assistant", + "content": f"(Python stub)收到:{message}", + }, + } + + if method == "agent.plan_next": + # TODO[多模态]: 解析 frameBase64 + userMessage → actions + return { + "protocolVersion": PROTOCOL_VERSION, + "id": req_id, + "result": { + "thought": "骨架:尚未接入视觉模型", + "actions": [], + }, + } + + return { + "protocolVersion": PROTOCOL_VERSION, + "id": req_id, + "error": {"code": -32601, "message": f"method not found: {method}"}, + } + + +class RPCHandler(BaseHTTPRequestHandler): + def log_message(self, fmt: str, *args: Any) -> None: + print(f"[agent] {self.address_string()} - {fmt % args}") + + def do_POST(self) -> None: + # [关键] 唯一 RPC 入口路径 + if self.path != "/rpc": + self.send_error(404) + return + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) + try: + body = json.loads(raw.decode("utf-8")) + except json.JSONDecodeError: + self._write(400, {"error": {"code": -32700, "message": "parse error"}}) + return + self._write(200, handle_rpc(body)) + + def _write(self, status: int, payload: dict[str, Any]) -> None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + +def main() -> None: + server = HTTPServer((HOST, PORT), RPCHandler) + print(f"[agent] listening on http://{HOST}:{PORT}/rpc protocol={PROTOCOL_VERSION}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/agent/requirements.txt b/agent/requirements.txt new file mode 100644 index 0000000..6e7a2e9 --- /dev/null +++ b/agent/requirements.txt @@ -0,0 +1,4 @@ +# 第一期仅标准库即可跑 stub;接云端 API 后再解开下面依赖 +# openai>=1.0.0 +# anthropic>=0.40.0 +# pillow>=10.0.0 diff --git a/build/Taskfile.yml b/build/Taskfile.yml new file mode 100644 index 0000000..9714b9e --- /dev/null +++ b/build/Taskfile.yml @@ -0,0 +1,398 @@ +version: '3' + +tasks: + go:mod:tidy: + summary: Runs `go mod tidy` + internal: true + # Universal/multi-arch builds invoke this concurrently from parallel deps; + # two `go mod tidy` processes racing on go.mod can corrupt it (#4637). + run: once + cmds: + - go mod tidy + + install:frontend:deps: + summary: Install frontend dependencies + run: once + cmds: + - task: install:frontend:deps:{{.PACKAGE_MANAGER}} + + install:frontend:deps:npm: + dir: frontend + sources: + - package.json + - package-lock.json + generates: + - node_modules + preconditions: + - sh: npm version + msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/" + cmds: + - npm install + + install:frontend:deps:bun: + dir: frontend + sources: + - package.json + - bun.lock + - bun.lockb + generates: + - node_modules + preconditions: + - sh: bun --version + msg: "bun not found" + cmds: + - bun install + + install:frontend:deps:pnpm: + dir: frontend + sources: + - package.json + - pnpm-lock.yaml + generates: + - node_modules + preconditions: + - sh: pnpm --version + msg: "pnpm not found" + cmds: + - pnpm install + + install:frontend:deps:yarn: + dir: frontend + sources: + - package.json + - yarn.lock + status: + - test -d node_modules || test -f .pnp.cjs + preconditions: + - sh: yarn --version + msg: "yarn not found" + cmds: + - yarn install + + build:frontend: + label: build:frontend (DEV={{.DEV}} RUNNER={{.PACKAGE_MANAGER}}) + summary: Build the frontend project + # darwin:build:universal runs its per-arch builds as parallel deps, each of + # which depends on this task. Without run:once the two executions race: + # one regenerates frontend/bindings (-clean deletes it first) while the + # other's bundler is reading it, failing intermittently with + # 'Could not resolve "./bindings/"' (#4637). + run: once + dir: frontend + sources: + - "**/*" + - exclude: node_modules/**/* + generates: + - dist/**/* + deps: + - task: install:frontend:deps + - task: generate:bindings + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + OBFUSCATED: + ref: .OBFUSCATED + cmds: + - task: frontend:run + vars: + SCRIPT: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}' + env: + PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}' + + frontend:run: + summary: Run a frontend script with selected runner + cmds: + - task: frontend:run:{{.PACKAGE_MANAGER}} + vars: + SCRIPT: "{{.SCRIPT}}" + vars: + SCRIPT: "{{.SCRIPT}}" + + frontend:run:npm: + dir: frontend + cmds: + - npm run {{.SCRIPT}} -q + vars: + SCRIPT: "{{.SCRIPT}}" + + frontend:run:yarn: + dir: frontend + cmds: + - yarn {{.SCRIPT}} + vars: + SCRIPT: "{{.SCRIPT}}" + + frontend:run:pnpm: + dir: frontend + cmds: + - pnpm run {{.SCRIPT}} + vars: + SCRIPT: "{{.SCRIPT}}" + + frontend:run:bun: + dir: frontend + cmds: + - bun run {{.SCRIPT}} + vars: + SCRIPT: "{{.SCRIPT}}" + + frontend:vendor:puppertino: + summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling + sources: + - frontend/public/puppertino/puppertino.css + generates: + - frontend/public/puppertino/puppertino.css + cmds: + - | + set -euo pipefail + mkdir -p frontend/public/puppertino + # If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error. + if [ ! -f frontend/public/puppertino/puppertino.css ]; then + echo "No bundled Puppertino found. Attempting to fetch from GitHub..." + if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then + curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true + echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css" + else + echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it." + fi + else + echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css" + fi + # Ensure index.html includes Puppertino CSS and button classes + INDEX_HTML=frontend/index.html + if [ -f "$INDEX_HTML" ]; then + if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then + # Insert Puppertino link tag after style.css link + awk ' + /href="\/style.css"\/?/ && !x { print; print " "; x=1; next }1 + ' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML" + fi + # Replace default .btn with Puppertino primary button classes if present + sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true + fi + + + + generate:bindings: + summary: Generates bindings for the frontend + run: once + deps: + - task: go:mod:tidy + sources: + - "**/*.[jt]s" + - exclude: frontend/**/* + - frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output + - "**/*.go" + - go.mod + - go.sum + generates: + - frontend/bindings/**/* + cmds: + - wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true{{if eq .OBFUSCATED "true"}} -obfuscated{{end}} -ts -i + + generate:icons: + summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms). + run: once + dir: build + sources: + - "appicon.png" + - "appicon.icon" + generates: + - "darwin/icons.icns" + - "windows/icon.ico" + cmds: + - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin + + dev:frontend: + summary: Runs the frontend in development mode + deps: + - task: install:frontend:deps + cmds: + - task: frontend:dev:{{.PACKAGE_MANAGER}} + + frontend:dev:npm: + dir: frontend + cmds: + - npm run dev -- --port {{.VITE_PORT}} --strictPort + + frontend:dev:yarn: + dir: frontend + cmds: + - yarn dev --port {{.VITE_PORT}} --strictPort + + frontend:dev:pnpm: + dir: frontend + cmds: + - pnpm dev --port {{.VITE_PORT}} --strictPort + + frontend:dev:bun: + dir: frontend + cmds: + - bun run dev --port {{.VITE_PORT}} --strictPort + + update:build-assets: + summary: Updates the build assets + dir: build + cmds: + - wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir . + + build:server: + summary: Builds the application in server mode (no GUI, HTTP server only) + desc: | + Builds a production server binary by default: -tags server,production, + -trimpath and a stripped binary, mirroring the desktop `build` task. + Server mode runs as a pure HTTP server without native GUI dependencies. + + Usage: task build:server [DEV=true] [OBFUSCATED=true] [EXTRA_TAGS=tag1,tag2] + DEV=true development server (-tags server, no strip, inlining kept) + OBFUSCATED=true obfuscated build via garble (requires garble installed) + EXTRA_TAGS additional comma-separated build tags + deps: + - task: build:frontend + vars: + DEV: + ref: .DEV + BUILD_FLAGS: + ref: .BUILD_FLAGS + OBFUSCATED: + ref: .OBFUSCATED + preconditions: + - sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}' + msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility." + cmds: + - '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}"' + vars: + BUILD_FLAGS: '-tags server{{if eq .DEV "true"}}{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -buildvcs=false -gcflags=all="-l"{{else}},production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}' + + run:server: + summary: Builds and runs a development server (DEV=true) + deps: + - task: build:server + vars: + DEV: "true" + cmds: + - '"./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}"' + + build:docker: + summary: Builds a Docker image for server mode deployment + desc: | + Creates a minimal Docker image containing the production server binary. + Defaults to a pure-Go static binary on a distroless/static base. The + production frontend is built first so the embedded assets are current. + + Usage: task build:docker [TAG=myapp:latest] [CGO_ENABLED=1] [GO_IMAGE=...] [RUNTIME_IMAGE=...] + For CGO apps, set CGO_ENABLED=1 with libc-compatible builder/runtime images, e.g.: + task build:docker CGO_ENABLED=1 GO_IMAGE=golang:bookworm RUNTIME_IMAGE=gcr.io/distroless/base-debian12 + deps: + # Build the production frontend so frontend/dist (embedded by the Go build + # inside the image) is present and current in the Docker build context. + # Pass the server,production tags so binding generation analyses the same + # build the Docker image compiles, not the default-tag build. + - task: build:frontend + vars: + BUILD_FLAGS: "-tags server,production" + cmds: + - >- + docker build + --build-arg CGO_ENABLED={{.CGO_ENABLED | default "0"}} + --build-arg GO_IMAGE={{.GO_IMAGE | default "golang:alpine"}} + --build-arg RUNTIME_IMAGE={{.RUNTIME_IMAGE | default "gcr.io/distroless/static-debian12"}} + -t {{.TAG | default (printf "%s:latest" .APP_NAME)}} + -f build/docker/Dockerfile.server . + vars: + TAG: "{{.TAG}}" + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required. Please install Docker first." + - sh: test -f build/docker/Dockerfile.server + msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it." + + run:docker: + summary: Builds and runs the Docker image + desc: | + Builds the Docker image and runs it, exposing port 8080. + Usage: task run:docker [TAG=myapp:latest] [PORT=8080] + Note: The internal container port is always 8080. The PORT variable + only changes the host port mapping. Ensure your app uses port 8080 + or modify the Dockerfile to match your ServerOptions.Port setting. + deps: + - task: build:docker + vars: + TAG: + ref: .TAG + cmds: + - docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}} + vars: + TAG: "{{.TAG}}" + PORT: "{{.PORT}}" + + setup:docker: + summary: Builds Docker image for cross-compilation (~800MB download) + desc: | + Builds the Docker image needed for cross-compiling to any platform. + Run this once to enable cross-platform builds from any OS. + cmds: + - docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/ + preconditions: + - sh: docker info > /dev/null 2>&1 + msg: "Docker is required. Please install Docker first." + + ios:device:list: + summary: Lists connected iOS devices (UDIDs) + cmds: + - xcrun xcdevice list + + ios:run:device: + summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl) + vars: + PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/.xcodeproj + SCHEME: '{{.SCHEME}}' # e.g., ios.dev + CONFIG: '{{.CONFIG | default "Debug"}}' + DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}' + UDID: '{{.UDID}}' # from `task ios:device:list` + BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev + TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing + preconditions: + - sh: xcrun -f xcodebuild + msg: "xcodebuild not found. Please install Xcode." + - sh: xcrun -f devicectl + msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)." + - sh: test -n '{{.PROJECT}}' + msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)." + - sh: test -n '{{.SCHEME}}' + msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)." + - sh: test -n '{{.UDID}}' + msg: "Set UDID to your device UDID (see: task ios:device:list)." + - sh: test -n '{{.BUNDLE_ID}}' + msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)." + cmds: + - | + set -euo pipefail + echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}" + XCB_ARGS=( + -project "{{.PROJECT}}" + -scheme "{{.SCHEME}}" + -configuration "{{.CONFIG}}" + -destination "id={{.UDID}}" + -derivedDataPath "{{.DERIVED}}" + -allowProvisioningUpdates + -allowProvisioningDeviceRegistration + ) + # Optionally inject signing identifiers if provided + if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi + if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi + xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true + # If xcpretty isn't installed, run without it + if [ "${PIPESTATUS[0]}" -ne 0 ]; then + xcodebuild "${XCB_ARGS[@]}" build + fi + # Find built .app + APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1) + if [ -z "$APP_PATH" ]; then + echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2 + exit 1 + fi + echo "Installing: $APP_PATH" + xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH" + echo "Launching: {{.BUNDLE_ID}}" + xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}" diff --git a/build/android/Taskfile.yml b/build/android/Taskfile.yml new file mode 100644 index 0000000..980b700 --- /dev/null +++ b/build/android/Taskfile.yml @@ -0,0 +1,464 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +vars: + APP_ID: '{{.APP_ID | default "com.wails.app"}}' + MIN_SDK: '21' + TARGET_SDK: '35' + # The emulator runs the host architecture; physical devices are arm64 + HOST_ARCH: + sh: '[ "$(uname -m)" = "x86_64" ] && echo "amd64" || echo "arm64"' + # System-image ABI for the host, used in the "create an AVD" hint below. + ANDROID_ABI: + sh: '[ "$(uname -m)" = "arm64" ] && echo "arm64-v8a" || echo "x86_64"' + # SDK location: $ANDROID_HOME / $ANDROID_SDK_ROOT, else the per-OS default + # (macOS: ~/Library/Android/sdk, Linux/other: ~/Android/Sdk) + SDK_ROOT: + sh: 'echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}"' + ADB: + sh: 'command -v adb || echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}/platform-tools/adb"' + EMULATOR: + sh: 'command -v emulator || echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}/emulator/emulator"' + # avdmanager lives under cmdline-tools//bin; used to auto-create an + # AVD when none exists (mirrors the iOS `ensure-simulator` auto-create flow). + AVDMANAGER: + sh: 'command -v avdmanager || ls "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}"/cmdline-tools/*/bin/avdmanager 2>/dev/null | sort -V | tail -1 || true' + +tasks: + install:deps: + summary: Check and install Android development dependencies + cmds: + - go run build/android/scripts/deps/install_deps.go + env: + TASK_FORCE_YES: '{{if .YES}}true{{else}}false{{end}}' + prompt: This will check and install Android development dependencies. Continue? + + build: + summary: Creates a debug build of the application for Android + deps: + - task: common:go:mod:tidy + - task: generate:android:overlay + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + PRODUCTION: + ref: .PRODUCTION + cmds: + - echo "Building Android app {{.APP_NAME}}..." + - task: compile:go:shared + vars: + ARCH: '{{.ARCH | default .HOST_ARCH}}' + vars: + BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,android -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags android,debug -buildvcs=false -gcflags=all="-l"{{end}}' + env: + PRODUCTION: '{{.PRODUCTION | default "false"}}' + + compile:go:shared: + summary: Compile Go code to shared library (.so) + cmds: + - | + # Locate the NDK: $ANDROID_NDK_HOME, or the newest installed NDK + NDK_ROOT="$ANDROID_NDK_HOME" + if [ -z "$NDK_ROOT" ]; then + SDK_ROOT="{{.SDK_ROOT}}" + NDK_ROOT=$(ls -d "$SDK_ROOT"/ndk/* 2>/dev/null | sort -V | tail -1) + fi + if [ -z "$NDK_ROOT" ] || [ ! -d "$NDK_ROOT" ]; then + echo "Error: Android NDK not found" + echo "Install one with: sdkmanager 'ndk;26.3.11579264' (or set ANDROID_NDK_HOME)" + exit 1 + fi + + # Determine toolchain based on host OS + case "$(uname -s)" in + Darwin) HOST_TAG="darwin-x86_64" ;; + Linux) HOST_TAG="linux-x86_64" ;; + *) echo "Unsupported host OS"; exit 1 ;; + esac + + TOOLCHAIN="$NDK_ROOT/toolchains/llvm/prebuilt/$HOST_TAG" + + # Set compiler based on architecture + case "{{.ARCH}}" in + arm64) + export CC="$TOOLCHAIN/bin/aarch64-linux-android{{.MIN_SDK}}-clang" + export CXX="$TOOLCHAIN/bin/aarch64-linux-android{{.MIN_SDK}}-clang++" + export GOARCH=arm64 + JNI_DIR="arm64-v8a" + ;; + amd64|x86_64) + export CC="$TOOLCHAIN/bin/x86_64-linux-android{{.MIN_SDK}}-clang" + export CXX="$TOOLCHAIN/bin/x86_64-linux-android{{.MIN_SDK}}-clang++" + export GOARCH=amd64 + JNI_DIR="x86_64" + ;; + *) + echo "Unsupported architecture: {{.ARCH}}" + exit 1 + ;; + esac + + export CGO_ENABLED=1 + export GOOS=android + + mkdir -p {{.BIN_DIR}} + mkdir -p build/android/app/src/main/jniLibs/$JNI_DIR + + go build -buildmode=c-shared -overlay build/android/overlay.json {{.BUILD_FLAGS}} \ + -o build/android/app/src/main/jniLibs/$JNI_DIR/libwails.so + vars: + BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,android -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags android,debug -buildvcs=false -gcflags=all="-l"{{end}}' + + compile:go:all-archs: + summary: Compile Go code for all Android architectures (fat APK) + cmds: + - task: compile:go:shared + vars: + ARCH: arm64 + - task: compile:go:shared + vars: + ARCH: amd64 + + package: + summary: Packages a production build of the application into a signed release APK + desc: | + Builds for arm64 by default (covers 99%+ of real devices). Set ARCH=amd64 + for emulator-only APKs, or use package:fat for a universal APK. + deps: + - task: build + vars: + PRODUCTION: "true" + ARCH: '{{.ARCH | default "arm64"}}' + cmds: + - task: assemble:apk:release + + package:fat: + summary: Packages a production build for all architectures (fat APK) + deps: + - task: build + vars: + PRODUCTION: "true" + ARCH: arm64 + cmds: + - task: compile:go:shared + vars: + ARCH: amd64 + PRODUCTION: "true" + - task: assemble:apk:release + + bundle: + summary: Packages a production AAB (Android App Bundle) for Play Store submission + desc: | + Builds for arm64 by default. Set ARCH=amd64 for emulator builds, or use + bundle:fat for a universal AAB. + deps: + - task: build + vars: + PRODUCTION: "true" + ARCH: '{{.ARCH | default "arm64"}}' + cmds: + - task: assemble:aab:release + + bundle:fat: + summary: Packages a production AAB for all architectures + deps: + - task: build + vars: + PRODUCTION: "true" + ARCH: arm64 + cmds: + - task: compile:go:shared + vars: + ARCH: amd64 + PRODUCTION: "true" + - task: assemble:aab:release + + assemble:apk: + summary: Assembles a debug APK using Gradle + preconditions: + - sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]' + msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME" + cmds: + - | + cd build/android + # The exec bit is lost when gradlew is extracted from the embedded + # build assets, so restore it before invoking the wrapper. + chmod +x ./gradlew + ./gradlew assembleDebug + cp app/build/outputs/apk/debug/app-debug.apk "../../{{.BIN_DIR}}/{{.APP_NAME}}.apk" + echo "APK created: {{.BIN_DIR}}/{{.APP_NAME}}.apk" + + assemble:apk:release: + summary: Assembles a release APK using Gradle (signed with the debug keystore unless ANDROID_KEYSTORE_FILE is set) + preconditions: + - sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]' + msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME" + cmds: + - | + cd build/android + # The exec bit is lost when gradlew is extracted from the embedded + # build assets, so restore it before invoking the wrapper. + chmod +x ./gradlew + ./gradlew assembleRelease + cp app/build/outputs/apk/release/app-release.apk "../../{{.BIN_DIR}}/{{.APP_NAME}}.apk" + echo "Release APK created: {{.BIN_DIR}}/{{.APP_NAME}}.apk" + + assemble:aab: + summary: Assembles a debug AAB (Android App Bundle) using Gradle + preconditions: + - sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]' + msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME" + cmds: + - | + cd build/android + # The exec bit is lost when gradlew is extracted from the embedded + # build assets, so restore it before invoking the wrapper. + chmod +x ./gradlew + ./gradlew bundleDebug + cp app/build/outputs/bundle/debug/app-debug.aab "../../{{.BIN_DIR}}/{{.APP_NAME}}.aab" + echo "AAB created: {{.BIN_DIR}}/{{.APP_NAME}}.aab" + + assemble:aab:release: + summary: Assembles a release AAB for Play Store upload (signed with the debug keystore unless ANDROID_KEYSTORE_FILE is set) + preconditions: + - sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]' + msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME" + cmds: + - | + # With Play App Signing, the keystore configured here is your UPLOAD + # key: Google verifies the upload with it, then re-signs the app with + # the app signing key it manages for distribution. + if [ -z "$ANDROID_KEYSTORE_FILE" ]; then + echo "WARNING: ANDROID_KEYSTORE_FILE is not set, so this AAB will be" + echo "signed with the debug keystore. Google Play rejects debug-signed" + echo "bundles. Set ANDROID_KEYSTORE_FILE, ANDROID_KEYSTORE_PASSWORD," + echo "ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD before uploading." + fi + cd build/android + # The exec bit is lost when gradlew is extracted from the embedded + # build assets, so restore it before invoking the wrapper. + chmod +x ./gradlew + ./gradlew bundleRelease + cp app/build/outputs/bundle/release/app-release.aab "../../{{.BIN_DIR}}/{{.APP_NAME}}.aab" + echo "Release AAB created: {{.BIN_DIR}}/{{.APP_NAME}}.aab" + + generate:android:overlay: + internal: true + summary: Generate Go build overlay that registers the Android main + sources: + - build/config.yml + generates: + - build/android/overlay.json + - build/android/gen/main_android.gen.go + cmds: + - wails3 android overlay:gen -out build/android/overlay.json -config build/config.yml + + generate:android:bindings: + internal: true + summary: Generates bindings for Android + sources: + - "**/*.go" + - go.mod + - go.sum + generates: + - frontend/bindings/**/* + cmds: + # Bindings are generated from the Go AST; CGO is disabled so the NDK + # is not required for this step + - wails3 generate bindings -f '-tags android' -clean=true + env: + GOOS: android + CGO_ENABLED: 0 + + ensure-emulator: + internal: true + summary: Ensure Android Emulator is running + silent: true + cmds: + - | + # Check if an emulator is already running + if "{{.ADB}}" devices | grep -q "emulator"; then + echo "Emulator already running" + exit 0 + fi + + # Get first available AVD + AVD_NAME=$("{{.EMULATOR}}" -list-avds | tail -1) + if [ -z "$AVD_NAME" ]; then + # No AVD yet. Mirror the iOS `ensure-simulator` flow and create one + # automatically — but ONLY from a system image that is already + # installed. We never trigger an sdkmanager download from a `run` + # task (that would be a surprise multi-GB download + license prompt). + # Pick the highest-API installed image matching the host ABI. + SDK_ROOT="{{.SDK_ROOT}}" + ABI="{{.ANDROID_ABI}}" + IMG=$(ls -d "$SDK_ROOT"/system-images/android-*/*/"$ABI" 2>/dev/null | sort -V | tail -1) + AVDMANAGER="{{.AVDMANAGER}}" + if [ -n "$IMG" ] && [ -x "$AVDMANAGER" ]; then + PKG="system-images;$(echo "$IMG" | sed "s|$SDK_ROOT/system-images/||" | tr '/' ';')" + echo "No Android Virtual Devices found. Creating 'wails' from $PKG..." + echo "no" | "$AVDMANAGER" create avd --name wails --package "$PKG" --device pixel_7 --force + AVD_NAME=wails + else + echo "No Android Virtual Devices found, and no system image is installed to create one from." + echo "Install a system image and create an AVD, e.g.:" + echo " sdkmanager 'system-images;android-35;google_apis;$ABI'" + echo " avdmanager create avd --name wails --package 'system-images;android-35;google_apis;$ABI' --device pixel_7" + exit 1 + fi + fi + + echo "Starting emulator: $AVD_NAME" + # Start the emulator daemonized so it outlives this task step. go-task's + # shell tracks background jobs by PID and reaps them when the command's + # interpreter finishes (which nohup/setsid alone don't prevent — the kill + # is direct), so a bare `emulator &` is gone before the later + # install/launch steps run. Launch it from a short-lived child shell that + # backgrounds the emulator and exits immediately: the emulator is then + # reparented to init/launchd and go-task's shell has no handle to reap it. + nohup sh -c "'{{.EMULATOR}}' -avd '$AVD_NAME' -no-snapshot-load /dev/null 2>&1 &" >/dev/null 2>&1 + + # Wait for emulator to boot (max 120 seconds) + echo "Waiting for emulator to boot..." + "{{.ADB}}" wait-for-device + + for i in $(seq 1 120); do + BOOT_COMPLETED=$("{{.ADB}}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') + if [ "$BOOT_COMPLETED" = "1" ]; then + echo "Emulator booted successfully" + exit 0 + fi + sleep 1 + done + + echo "Emulator boot timeout" + exit 1 + preconditions: + - sh: '[ -x "{{.ADB}}" ] || command -v adb' + msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)" + - sh: '[ -x "{{.EMULATOR}}" ] || command -v emulator' + msg: "emulator not found. Install the Android SDK emulator package (or set ANDROID_HOME)" + + deploy-emulator: + summary: Deploy the packaged release APK to the Android Emulator + deps: + - task: package + vars: + ARCH: '{{.ARCH | default .HOST_ARCH}}' + cmds: + - task: ensure-emulator + - '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true' + - '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"' + - '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity' + + run: + summary: Build, install and launch a debug build in the Android Emulator + deps: + - task: ensure-emulator + - task: build + cmds: + - task: assemble:apk + - '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true' + - '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"' + - '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity' + + device:list: + summary: Lists connected Android devices and emulators (serials) + cmds: + - '"{{.ADB}}" devices -l' + + run:device: + summary: Build, install and launch a debug build on a connected physical Android device + deps: + - task: build + vars: + ARCH: arm64 + cmds: + - task: assemble:apk + - | + DEVICE='{{.DEVICE_ID | default ""}}' + if [ -z "$DEVICE" ]; then + DEVICE="${DEVICE_ID:-}" + fi + if [ -z "$DEVICE" ]; then + DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }') + fi + if [ -z "$DEVICE" ]; then + echo "Error: no connected physical Android device found." + echo "Pass DEVICE_ID= to target a device explicitly." + echo "Find connected device serials with: {{.ADB}} devices" + exit 1 + fi + + echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..." + "{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true + "{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk" + "{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity + preconditions: + - sh: '[ -x "{{.ADB}}" ] || command -v adb' + msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)" + + deploy-device: + summary: Deploy the packaged release APK to a connected physical Android device + deps: + - task: package + vars: + ARCH: arm64 + cmds: + - | + DEVICE='{{.DEVICE_ID | default ""}}' + if [ -z "$DEVICE" ]; then + DEVICE="${DEVICE_ID:-}" + fi + if [ -z "$DEVICE" ]; then + DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }') + fi + if [ -z "$DEVICE" ]; then + echo "Error: no connected physical Android device found." + echo "Pass DEVICE_ID= to target a device explicitly." + echo "Find connected device serials with: {{.ADB}} devices" + exit 1 + fi + + echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..." + "{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true + "{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk" + "{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity + preconditions: + - sh: '[ -x "{{.ADB}}" ] || command -v adb' + msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)" + + studio: + summary: Open the generated Android project in Android Studio + cmds: + - | + if command -v studio >/dev/null 2>&1; then + studio build/android + elif [ -d "/Applications/Android Studio.app" ]; then + open -a "Android Studio" build/android + else + echo "Android Studio not found. Install it from https://developer.android.com/studio," + echo "then open the build/android directory." + exit 1 + fi + + logs: + summary: Stream Android logcat filtered to this app + cmds: + - '"{{.ADB}}" logcat -v time | grep -E "(Wails|{{.APP_NAME}})" || true' + + logs:all: + summary: Stream all Android logcat (verbose) + cmds: + - '"{{.ADB}}" logcat -v time' + + clean: + summary: Clean build artifacts + cmds: + - rm -rf {{.BIN_DIR}} + - rm -rf build/android/app/build + - rm -rf build/android/app/src/main/jniLibs/*/libwails.so + - rm -rf build/android/.gradle diff --git a/build/android/app/build.gradle b/build/android/app/build.gradle new file mode 100644 index 0000000..9c69943 --- /dev/null +++ b/build/android/app/build.gradle @@ -0,0 +1,83 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'com.wails.app' + compileSdk 35 + + buildFeatures { + buildConfig = true + } + + defaultConfig { + applicationId "com.wails.app" + minSdk 21 + targetSdk 35 + versionCode 1 + versionName "1.0" + + // Configure supported ABIs + ndk { + abiFilters 'arm64-v8a', 'x86_64' + } + } + + def keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE") + def hasKeystore = keystoreFile != null && !keystoreFile.trim().isEmpty() + + signingConfigs { + // A real keystore can be provided via environment variables; without + // one, release builds are signed with the debug keystore so they can + // be installed for testing (not suitable for Play Store uploads). + release { + if (hasKeystore) { + storeFile file(keystoreFile) + storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD") + keyAlias System.getenv("ANDROID_KEY_ALIAS") + keyPassword System.getenv("ANDROID_KEY_PASSWORD") + } + } + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + signingConfig hasKeystore ? signingConfigs.release : signingConfigs.debug + } + debug { + debuggable true + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + // Source sets configuration + sourceSets { + main { + // JNI libraries are in jniLibs folder + jniLibs.srcDirs = ['src/main/jniLibs'] + // Assets for the WebView + assets.srcDirs = ['src/main/assets'] + } + } + + // Packaging options + packagingOptions { + // Don't strip Go symbols in debug builds + doNotStrip '*/arm64-v8a/libwails.so' + doNotStrip '*/x86_64/libwails.so' + } +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.webkit:webkit:1.9.0' + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.biometric:biometric:1.1.0' + implementation 'androidx.security:security-crypto:1.1.0-alpha06' +} diff --git a/build/android/app/proguard-rules.pro b/build/android/app/proguard-rules.pro new file mode 100644 index 0000000..8b88c3d --- /dev/null +++ b/build/android/app/proguard-rules.pro @@ -0,0 +1,12 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. + +# Keep native methods +-keepclasseswithmembernames class * { + native ; +} + +# Keep Wails bridge classes +-keep class com.wails.app.WailsBridge { *; } +-keep class com.wails.app.WailsJSBridge { *; } diff --git a/build/android/app/src/main/AndroidManifest.xml b/build/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e4f6cca --- /dev/null +++ b/build/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/android/app/src/main/java/com/wails/app/MainActivity.java b/build/android/app/src/main/java/com/wails/app/MainActivity.java new file mode 100644 index 0000000..13df575 --- /dev/null +++ b/build/android/app/src/main/java/com/wails/app/MainActivity.java @@ -0,0 +1,935 @@ +package com.wails.app; + +import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.res.Configuration; +import android.database.Cursor; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.Uri; +import android.os.BatteryManager; +import android.os.Build; +import android.os.Bundle; +import android.os.PowerManager; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.provider.MediaStore; +import android.provider.OpenableColumns; +import android.util.Base64; +import android.util.Log; +import android.webkit.ConsoleMessage; +import android.webkit.ValueCallback; +import android.webkit.WebChromeClient; +import android.webkit.WebChromeClient.FileChooserParams; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.FileProvider; +import androidx.webkit.WebViewAssetLoader; + +import org.json.JSONObject; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * MainActivity hosts the WebView and manages the Wails application lifecycle. + * It uses WebViewAssetLoader to serve assets from the Go library without + * requiring a network server. + */ +public class MainActivity extends AppCompatActivity { + private static final String TAG = "WailsActivity"; + private static final boolean DEBUG = BuildConfig.DEBUG; + private static final String WAILS_SCHEME = "https"; + private static final String WAILS_HOST = "wails.localhost"; + private static final int FILE_PICKER_REQUEST = 7001; + + private WebView webView; + private WailsBridge bridge; + // Battery: system-event receivers are registered only while the activity is + // in the foreground (onStart) and torn down in onStop, so background battery/ + // network/screen broadcasts don't wake the app. + private boolean systemReceiversRegistered = false; + private WebViewAssetLoader assetLoader; + + // The Go-side dialog ID of the in-flight file picker (-1 when idle) + private int pendingFilePickerCallbackID = -1; + private static final int PHOTO_CAPTURE_REQUEST = 7002; + private static final int VIDEO_CAPTURE_REQUEST = 7003; + private static final int CAMERA_PERMISSION_REQUEST = 7010; + + // In-flight callback for an HTML chooser opened by the + // WebView's WebChromeClient (null when idle). + private static final int WEBVIEW_FILE_CHOOSER_REQUEST = 7004; + private ValueCallback webViewFileChooserCallback; + private File pendingCaptureFile; + private boolean pendingCaptureIsVideo; + + // System-event sources (battery/power, screen lock, network). Registered in + // onCreate, torn down in onDestroy. Each forwards a "system:*" event to JS + // via the bridge. + private BroadcastReceiver batteryReceiver; + private BroadcastReceiver screenReceiver; + private BroadcastReceiver powerSaveReceiver; + private ConnectivityManager connectivityManager; + private ConnectivityManager.NetworkCallback networkCallback; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + // Initialize the native Go library + bridge = new WailsBridge(this); + bridge.initialize(); + + // Set up WebView + setupWebView(); + + // Load the application + loadApplication(); + } + + @SuppressLint("SetJavaScriptEnabled") + private void setupWebView() { + webView = findViewById(R.id.webview); + bridge.setWebView(webView); + + // Configure WebView settings + WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setDatabaseEnabled(true); + settings.setAllowFileAccess(false); + settings.setAllowContentAccess(false); + settings.setMediaPlaybackRequiresUserGesture(false); + settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); + + // Enable debugging in debug builds + if (DEBUG) { + WebView.setWebContentsDebuggingEnabled(true); + } + + // Set up asset loader for serving local assets + assetLoader = new WebViewAssetLoader.Builder() + .setDomain(WAILS_HOST) + .addPathHandler("/", new WailsPathHandler(bridge)) + .build(); + + // Set up WebView client to intercept requests + webView.setWebViewClient(new WebViewClient() { + @Nullable + @Override + public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { + // Handle wails.localhost requests + if (request.getUrl().getHost() != null && + request.getUrl().getHost().equals(WAILS_HOST)) { + + // For wails API calls (runtime, capabilities, etc.) pass the + // full URL including the query string, because + // WebViewAssetLoader.PathHandler strips query params + String path = request.getUrl().getPath(); + if (path != null && path.startsWith("/wails/")) { + String fullPath = path; + String query = request.getUrl().getQuery(); + if (query != null && !query.isEmpty()) { + fullPath = path + "?" + query; + } + if (DEBUG) Log.d(TAG, "Wails API call: " + fullPath); + + byte[] data = bridge.serveAsset(fullPath, request.getMethod(), "{}"); + if (data != null && data.length > 0) { + java.io.InputStream inputStream = new java.io.ByteArrayInputStream(data); + java.util.Map headers = new java.util.HashMap<>(); + headers.put("Access-Control-Allow-Origin", "*"); + headers.put("Cache-Control", "no-cache"); + headers.put("Content-Type", "application/json"); + + // Detect error envelopes: if the body starts with + // {"ok":false, return HTTP 500 so the fallback + // transport's fetch() rejects the promise. + int status = 200; + String reason = "OK"; + if (data.length > 12) { + String prefix = new String(data, 0, Math.min(data.length, 20), + java.nio.charset.StandardCharsets.UTF_8); + if (prefix.contains("\"ok\":false")) { + status = 500; + reason = "Internal Error"; + } + } + + return new WebResourceResponse( + "application/json", + "UTF-8", + status, + reason, + headers, + inputStream + ); + } + // Return error response if data is null + return new WebResourceResponse( + "application/json", + "UTF-8", + 500, + "Internal Error", + new java.util.HashMap<>(), + new java.io.ByteArrayInputStream("{}".getBytes()) + ); + } + + // Stream captured photos/videos from the cache with HTTP Range + // support so