feat: initialize iTuoke with real-time phone control

This commit is contained in:
cfn 2026-11-04 18:25:43 +08:00
commit 1a2185a926
131 changed files with 13990 additions and 0 deletions

4
.gitattributes vendored Normal file
View File

@ -0,0 +1,4 @@
* text=auto
*.sh text eol=lf
gradlew text eol=lf
*.bat text eol=crlf

55
.gitignore vendored Normal file
View File

@ -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/

29
CODEMAP.md Normal file
View File

@ -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 ./...
```

72
README.md Normal file
View File

@ -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
```

70
Taskfile.yml Normal file
View File

@ -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

28
agent/README.md Normal file
View File

@ -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` | 看图 + 上下文 → 设备动作列表 |

111
agent/main.py Normal file
View File

@ -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()

4
agent/requirements.txt Normal file
View File

@ -0,0 +1,4 @@
# 第一期仅标准库即可跑 stub;接云端 API 后再解开下面依赖
# openai>=1.0.0
# anthropic>=0.40.0
# pillow>=10.0.0

398
build/Taskfile.yml Normal file
View File

@ -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/<pkg>"' (#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 " <link rel=\"stylesheet\" href=\"/puppertino/puppertino.css\"/>"; 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/<YourProject>.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}}"

464
build/android/Taskfile.yml Normal file
View File

@ -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/<version>/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 >/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=<serial> 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=<serial> 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

View File

@ -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'
}

12
build/android/app/proguard-rules.pro vendored Normal file
View File

@ -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 <methods>;
}
# Keep Wails bridge classes
-keep class com.wails.app.WailsBridge { *; }
-keep class com.wails.app.WailsJSBridge { *; }

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Internet permission for WebView -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Observe network connectivity / type for android:NetworkChanged events -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
<intent>
<action android:name="android.media.action.VIDEO_CAPTURE" />
</intent>
</queries>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.WailsApp"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|keyboardHidden|uiMode"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".WailsForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View File

@ -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 <input type="file"> chooser opened by the
// WebView's WebChromeClient (null when idle).
private static final int WEBVIEW_FILE_CHOOSER_REQUEST = 7004;
private ValueCallback<Uri[]> 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<String, String> 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 <video> can seek/stream a clip of any length.
if (path != null && path.startsWith("/__capture__/")) {
return serveCaptureFile(path.substring("/__capture__/".length()), request);
}
// For regular assets, use the asset loader
return assetLoader.shouldInterceptRequest(request.getUrl());
}
return super.shouldInterceptRequest(view, request);
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
if (DEBUG) Log.d(TAG, "Page loaded: " + url);
bridge.onPageFinished(url);
// Now that JS listeners are mounted, push a snapshot of the
// current battery / network / theme so the UI starts populated.
emitSystemSnapshot();
}
});
// Add JavaScript interface for Go communication
webView.addJavascriptInterface(new WailsJSBridge(bridge, webView), "wails");
// A WebChromeClient is required for HTML <input type="file"> elements to
// work; without onShowFileChooser the WebView silently ignores clicks on
// file inputs. It also forwards JS console messages to logcat for
// debugging. Note: in-page permission prompts (geolocation, getUserMedia)
// require an onPermissionRequest override which is not provided here;
// those requests will be denied by the default implementation until a
// scoped permissions handler is added.
webView.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onConsoleMessage(ConsoleMessage msg) {
if (DEBUG) {
Log.d(TAG, "[WebView] " + msg.messageLevel() + ": " + msg.message()
+ " (" + msg.sourceId() + ":" + msg.lineNumber() + ")");
}
return true;
}
@Override
public boolean onShowFileChooser(WebView view,
ValueCallback<Uri[]> filePathCallback,
FileChooserParams fileChooserParams) {
// Cancel any previous, still-pending chooser before starting a new one.
if (webViewFileChooserCallback != null) {
webViewFileChooserCallback.onReceiveValue(null);
}
webViewFileChooserCallback = filePathCallback;
Intent intent = fileChooserParams.createIntent();
try {
startActivityForResult(intent, WEBVIEW_FILE_CHOOSER_REQUEST);
} catch (android.content.ActivityNotFoundException e) {
webViewFileChooserCallback = null;
filePathCallback.onReceiveValue(null);
return false;
}
return true;
}
});
}
private void loadApplication() {
String url = WAILS_SCHEME + "://" + WAILS_HOST + "/";
if (DEBUG) Log.d(TAG, "Loading URL: " + url);
webView.loadUrl(url);
}
/**
* Launch the system camera to capture a photo (video=false) or a video
* (video=true). The capture is written to a FileProvider URI in the cache and
* the result is delivered to JS as a "common:capture" event.
*/
public void launchCameraCapture(boolean video) {
if (checkSelfPermission("android.permission.CAMERA") != PackageManager.PERMISSION_GRANTED) {
pendingCaptureIsVideo = video;
requestPermissions(new String[]{"android.permission.CAMERA"}, CAMERA_PERMISSION_REQUEST);
return;
}
try {
File dir = new File(getCacheDir(), "captures");
if (!dir.exists()) dir.mkdirs();
pendingCaptureFile = new File(dir, "capture_" + System.currentTimeMillis() + (video ? ".mp4" : ".jpg"));
pendingCaptureIsVideo = video;
Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", pendingCaptureFile);
Intent intent = new Intent(video ? MediaStore.ACTION_VIDEO_CAPTURE : MediaStore.ACTION_IMAGE_CAPTURE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, uri);
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
// Don't pre-check with resolveActivity(): Android 11+ package visibility
// hides other apps' intents unless declared in <queries>, so it can
// return null even when a camera app exists. Just launch and handle a miss.
startActivityForResult(intent, video ? VIDEO_CAPTURE_REQUEST : PHOTO_CAPTURE_REQUEST);
} catch (android.content.ActivityNotFoundException e) {
bridge.emitEvent("common:capture", "{\"error\":\"no camera app available\"}");
} catch (Exception e) {
Log.e(TAG, "launchCameraCapture failed", e);
bridge.emitEvent("common:capture", "{\"error\":\"capture failed\"}");
}
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == CAMERA_PERMISSION_REQUEST) {
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
launchCameraCapture(pendingCaptureIsVideo);
} else {
bridge.emitEvent("common:capture", "{\"error\":\"camera permission denied\"}");
}
return;
}
if (bridge != null) {
bridge.onRequestPermissionsResult(requestCode, grantResults);
}
}
private void handleCaptureResult(int resultCode, @Nullable Intent data) {
File file = pendingCaptureFile;
final boolean video = pendingCaptureIsVideo;
pendingCaptureFile = null;
if (resultCode != RESULT_OK) {
bridge.emitEvent("common:capture", "{\"cancelled\":true}");
return;
}
// Some camera apps (commonly for video) ignore EXTRA_OUTPUT and instead
// return a content URI in the result data; copy that into our cache.
if ((file == null || !file.exists() || file.length() == 0)
&& data != null && data.getData() != null) {
String copied = copyUriToCache(data.getData());
if (copied != null) file = new File(copied);
}
final File f = file;
if (f == null || !f.exists() || f.length() == 0) {
bridge.emitEvent("common:capture", "{\"cancelled\":true}");
return;
}
new Thread(() -> {
try {
JSONObject o = new JSONObject();
o.put("type", video ? "video" : "photo");
o.put("path", f.getAbsolutePath());
o.put("size", f.length());
if (!video) {
String thumb = makePhotoThumbnail(f);
if (thumb != null) o.put("thumb", thumb);
}
// Stream URL works for both: <video>/<img> load it from the cache
// via shouldInterceptRequest (Range-enabled), no size limit.
o.put("streamUrl", captureStreamUrl(f));
bridge.emitEvent("common:capture", o.toString());
} catch (Exception e) {
Log.e(TAG, "handleCaptureResult failed", e);
bridge.emitEvent("common:capture", "{\"error\":\"result processing failed\"}");
}
}).start();
}
/** Downscale a captured photo into a base64 JPEG data URL for display in the webview. */
@Nullable
private String makePhotoThumbnail(File file) {
try {
BitmapFactory.Options bounds = new BitmapFactory.Options();
bounds.inJustDecodeBounds = true;
BitmapFactory.decodeFile(file.getAbsolutePath(), bounds);
int sample = 1;
while (Math.max(bounds.outWidth, bounds.outHeight) / sample > 640) sample *= 2;
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inSampleSize = sample;
Bitmap bmp = BitmapFactory.decodeFile(file.getAbsolutePath(), opts);
if (bmp == null) return null;
ByteArrayOutputStream baos = new ByteArrayOutputStream();
bmp.compress(Bitmap.CompressFormat.JPEG, 70, baos);
bmp.recycle();
return "data:image/jpeg;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP);
} catch (Exception e) {
return null;
}
}
/**
* Build a same-origin URL the webview can stream a capture from. Served by
* serveCaptureFile (via shouldInterceptRequest); the path is relative to the
* cache dir so both camera files (captures/) and copied content URIs
* (wails-picker/) resolve.
*/
private String captureStreamUrl(File file) {
String base = getCacheDir().getAbsolutePath() + File.separator;
String abs = file.getAbsolutePath();
String rel = abs.startsWith(base) ? abs.substring(base.length()) : file.getName();
return "/__capture__/" + Uri.encode(rel, "/");
}
/**
* Serve a captured file (under the app cache) to the webview with HTTP Range
* support, so &lt;video&gt; can stream and seek a clip of any length without
* inlining it as a data URL.
*/
private WebResourceResponse serveCaptureFile(String relPath, WebResourceRequest request) {
try {
File cache = getCacheDir();
File file = new File(cache, Uri.decode(relPath));
// Path-traversal guard: only ever serve files under the cache dir.
if (!file.getCanonicalPath().startsWith(cache.getCanonicalPath() + File.separator)
|| !file.exists() || !file.isFile()) {
return new WebResourceResponse("text/plain", "UTF-8", 404, "Not Found",
new java.util.HashMap<>(), new java.io.ByteArrayInputStream(new byte[0]));
}
String name = file.getName().toLowerCase();
String mime = name.endsWith(".mp4") ? "video/mp4"
: name.endsWith(".mov") ? "video/quicktime"
: name.endsWith(".jpg") || name.endsWith(".jpeg") ? "image/jpeg"
: name.endsWith(".png") ? "image/png" : "application/octet-stream";
long length = file.length();
java.util.Map<String, String> reqHeaders = request.getRequestHeaders();
String range = reqHeaders != null ? reqHeaders.get("Range") : null;
if (range == null && reqHeaders != null) range = reqHeaders.get("range");
java.util.Map<String, String> headers = new java.util.HashMap<>();
headers.put("Accept-Ranges", "bytes");
headers.put("Cache-Control", "no-store");
if (range != null && range.startsWith("bytes=")) {
long start = 0, end = length - 1;
String spec = range.substring(6).trim();
int dash = spec.indexOf('-');
if (dash >= 0) {
try {
if (dash > 0) start = Long.parseLong(spec.substring(0, dash).trim());
String e = spec.substring(dash + 1).trim();
if (!e.isEmpty()) end = Long.parseLong(e);
} catch (NumberFormatException ignored) { }
}
if (start < 0) start = 0;
if (end >= length) end = length - 1;
if (start > end) { start = 0; end = length - 1; }
long count = end - start + 1;
java.io.InputStream in = new java.io.FileInputStream(file);
long toSkip = start;
while (toSkip > 0) {
long s = in.skip(toSkip);
if (s <= 0) break;
toSkip -= s;
}
headers.put("Content-Range", "bytes " + start + "-" + end + "/" + length);
headers.put("Content-Length", String.valueOf(count));
return new WebResourceResponse(mime, null, 206, "Partial Content",
headers, new LimitedInputStream(in, count));
}
headers.put("Content-Length", String.valueOf(length));
return new WebResourceResponse(mime, null, 200, "OK", headers,
new java.io.FileInputStream(file));
} catch (Exception e) {
Log.e(TAG, "serveCaptureFile failed", e);
return new WebResourceResponse("text/plain", "UTF-8", 500, "Error",
new java.util.HashMap<>(), new java.io.ByteArrayInputStream(new byte[0]));
}
}
/** Wraps a stream to yield at most a fixed number of bytes (for Range responses). */
private static final class LimitedInputStream extends java.io.FilterInputStream {
private long remaining;
LimitedInputStream(java.io.InputStream in, long limit) {
super(in);
this.remaining = limit;
}
@Override public int read() throws java.io.IOException {
if (remaining <= 0) return -1;
int b = super.read();
if (b >= 0) remaining--;
return b;
}
@Override public int read(byte[] b, int off, int len) throws java.io.IOException {
if (remaining <= 0) return -1;
int n = super.read(b, off, (int) Math.min(len, remaining));
if (n > 0) remaining -= n;
return n;
}
}
/**
* Launch the system document picker. Results are copied into the app's
* cache directory so Go receives real filesystem paths. Called by
* WailsBridge on the main thread.
*/
public void launchFilePicker(int callbackID, boolean multiple) {
synchronized (this) {
if (pendingFilePickerCallbackID != -1) {
// Only one picker can be in flight
bridge.filePickerDone(callbackID);
return;
}
pendingFilePickerCallbackID = callbackID;
}
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple);
try {
startActivityForResult(intent, FILE_PICKER_REQUEST);
} catch (Exception e) {
Log.e(TAG, "Failed to launch file picker", e);
pendingFilePickerCallbackID = -1;
bridge.filePickerDone(callbackID);
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == PHOTO_CAPTURE_REQUEST || requestCode == VIDEO_CAPTURE_REQUEST) {
handleCaptureResult(resultCode, data);
return;
}
if (requestCode == WEBVIEW_FILE_CHOOSER_REQUEST) {
if (webViewFileChooserCallback == null) {
return;
}
Uri[] results = null;
if (resultCode == RESULT_OK && data != null) {
if (data.getClipData() != null) {
// Multiple selection (<input type="file" multiple>) is
// returned as ClipData. FileChooserParams.parseResult only
// reads getData(), so it would drop all but one (and return
// null when getData() is empty) — read every item explicitly.
int count = data.getClipData().getItemCount();
java.util.List<Uri> validated = new java.util.ArrayList<>(count);
for (int i = 0; i < count; i++) {
Uri uri = data.getClipData().getItemAt(i).getUri();
if (isFileChooserUriSafe(uri)) validated.add(uri);
}
if (!validated.isEmpty()) {
results = validated.toArray(new Uri[0]);
}
} else if (data.getData() != null) {
Uri uri = data.getData();
if (isFileChooserUriSafe(uri)) {
results = new Uri[]{ uri };
}
}
}
if (results == null) {
// Fallback for pickers that report the result differently.
results = FileChooserParams.parseResult(resultCode, data);
}
webViewFileChooserCallback.onReceiveValue(results);
webViewFileChooserCallback = null;
return;
}
if (requestCode != FILE_PICKER_REQUEST) {
return;
}
final int callbackID = pendingFilePickerCallbackID;
pendingFilePickerCallbackID = -1;
if (callbackID == -1) {
return;
}
final List<Uri> uris = new ArrayList<>();
if (resultCode == RESULT_OK && data != null) {
if (data.getClipData() != null) {
for (int i = 0; i < data.getClipData().getItemCount(); i++) {
uris.add(data.getClipData().getItemAt(i).getUri());
}
} else if (data.getData() != null) {
uris.add(data.getData());
}
}
// Copy the documents off the main thread, then notify Go
new Thread(() -> {
for (Uri uri : uris) {
String path = copyUriToCache(uri);
if (path != null) {
bridge.filePickerResult(callbackID, path);
}
}
bridge.filePickerDone(callbackID);
}).start();
}
/**
* Validate that a file-chooser URI is safe to pass to the WebView.
* Rejects file:// URIs pointing at the app's own private data (a malicious
* third-party picker could return such URIs to exfiltrate sensitive files).
* Only content:// URIs (the standard document-picker output) are accepted.
*/
private boolean isFileChooserUriSafe(Uri uri) {
if (uri == null) return false;
String scheme = uri.getScheme();
// content:// URIs are the standard, safe output of document pickers.
if ("content".equals(scheme)) return true;
// Reject file:// URIs — they can point at app-private data.
// Also reject anything else (null scheme, unknown schemes).
if (DEBUG) Log.w(TAG, "Rejected unsafe file-chooser URI: " + uri);
return false;
}
/**
* Copy a content URI into the app cache and return its filesystem path.
*/
@Nullable
private String copyUriToCache(Uri uri) {
String name = "document";
try (Cursor cursor = getContentResolver().query(uri, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
int idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (idx >= 0 && cursor.getString(idx) != null) {
name = new File(cursor.getString(idx)).getName();
}
}
} catch (Exception ignored) {
}
try {
File dir = new File(getCacheDir(), "wails-picker/" + System.nanoTime());
if (!dir.mkdirs()) {
return null;
}
File out = new File(dir, name);
try (InputStream in = getContentResolver().openInputStream(uri);
OutputStream os = new FileOutputStream(out)) {
if (in == null) {
return null;
}
byte[] buf = new byte[64 * 1024];
int n;
while ((n = in.read(buf)) > 0) {
os.write(buf, 0, n);
}
}
return out.getAbsolutePath();
} catch (Exception e) {
Log.e(TAG, "Failed to copy picked document", e);
return null;
}
}
/**
* Execute JavaScript in the WebView from the Go side
*/
public void executeJavaScript(final String js) {
runOnUiThread(() -> {
if (webView != null) {
webView.evaluateJavascript(js, null);
}
});
}
// ---- System events ---------------------------------------------------
// Battery/power, screen lock and network connectivity are surfaced to JS as
// "system:*" events. The OS broadcasts used here (ACTION_BATTERY_CHANGED,
// SCREEN_OFF, USER_PRESENT, POWER_SAVE_MODE_CHANGED) are protected system
// broadcasts, so dynamic registration needs no RECEIVER_* export flag.
private void registerSystemEventReceivers() {
// Battery + charging state (sticky broadcast: the current value is
// delivered to the receiver immediately on registration).
batteryReceiver = new BroadcastReceiver() {
@Override public void onReceive(Context context, Intent intent) {
emitBattery(intent);
}
};
registerReceiver(batteryReceiver, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
// Low-power (battery saver) mode toggles → re-emit battery with the flag.
powerSaveReceiver = new BroadcastReceiver() {
@Override public void onReceive(Context context, Intent intent) {
emitBattery(registerSticky(Intent.ACTION_BATTERY_CHANGED));
}
};
registerReceiver(powerSaveReceiver,
new IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED));
// Screen lock / unlock. SCREEN_OFF ≈ locked; USER_PRESENT = unlocked.
screenReceiver = new BroadcastReceiver() {
@Override public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (Intent.ACTION_SCREEN_OFF.equals(action)) {
emitLock(true);
} else if (Intent.ACTION_USER_PRESENT.equals(action)) {
emitLock(false);
}
}
};
IntentFilter screenFilter = new IntentFilter();
screenFilter.addAction(Intent.ACTION_SCREEN_OFF);
screenFilter.addAction(Intent.ACTION_USER_PRESENT);
registerReceiver(screenReceiver, screenFilter);
// Network connectivity / transport type / cellular signal strength.
connectivityManager = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
if (connectivityManager != null) {
networkCallback = new ConnectivityManager.NetworkCallback() {
@Override public void onAvailable(Network network) { emitNetwork(network); }
@Override public void onLost(Network network) { emitNetworkDisconnected(); }
@Override public void onCapabilitiesChanged(Network network, NetworkCapabilities caps) {
emitNetwork(network);
}
};
try {
connectivityManager.registerDefaultNetworkCallback(networkCallback);
} catch (Exception e) {
Log.e(TAG, "registerDefaultNetworkCallback failed", e);
}
}
}
private void unregisterSystemEventReceivers() {
safeUnregister(batteryReceiver);
batteryReceiver = null;
safeUnregister(powerSaveReceiver);
powerSaveReceiver = null;
safeUnregister(screenReceiver);
screenReceiver = null;
if (connectivityManager != null && networkCallback != null) {
try {
connectivityManager.unregisterNetworkCallback(networkCallback);
} catch (Exception ignored) {
}
networkCallback = null;
}
}
private void safeUnregister(BroadcastReceiver r) {
if (r != null) {
try {
unregisterReceiver(r);
} catch (Exception ignored) {
}
}
}
/** Read the current sticky value for an action without a standing receiver. */
@Nullable
private Intent registerSticky(String action) {
return registerReceiver(null, new IntentFilter(action));
}
/** Push current battery / network / theme so a freshly-loaded UI is populated. */
private void emitSystemSnapshot() {
emitBattery(registerSticky(Intent.ACTION_BATTERY_CHANGED));
if (connectivityManager != null) {
Network active = connectivityManager.getActiveNetwork();
if (active != null) {
emitNetwork(active);
} else {
emitNetworkDisconnected();
}
}
emitTheme();
}
private void emitBattery(@Nullable Intent batteryStatus) {
try {
float level = -1f;
String state = "unknown";
if (batteryStatus != null) {
int lvl = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
if (lvl >= 0 && scale > 0) {
level = lvl / (float) scale;
}
switch (batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1)) {
case BatteryManager.BATTERY_STATUS_CHARGING: state = "charging"; break;
case BatteryManager.BATTERY_STATUS_FULL: state = "full"; break;
case BatteryManager.BATTERY_STATUS_DISCHARGING:
case BatteryManager.BATTERY_STATUS_NOT_CHARGING: state = "unplugged"; break;
default: state = "unknown"; break;
}
}
boolean lowPower = false;
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
if (pm != null) {
lowPower = pm.isPowerSaveMode();
}
JSONObject o = new JSONObject();
o.put("level", (double) level);
o.put("state", state);
o.put("lowPowerMode", lowPower);
if (bridge != null) bridge.emitSystemEvent("android:BatteryChanged", o.toString());
} catch (Exception e) {
Log.e(TAG, "emitBattery failed", e);
}
}
private void emitNetwork(@Nullable Network network) {
try {
boolean connected = false;
String type = "none";
boolean metered = false;
Integer signal = null;
if (connectivityManager != null && network != null) {
NetworkCapabilities caps = connectivityManager.getNetworkCapabilities(network);
if (caps != null) {
connected = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
type = "wifi";
} else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
type = "cellular";
} else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
type = "wired";
} else {
type = "other";
}
metered = !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
int s = caps.getSignalStrength();
if (s != Integer.MIN_VALUE) {
signal = s; // dBm; closer to 0 is a stronger signal
}
}
}
}
JSONObject o = new JSONObject();
o.put("connected", connected);
o.put("type", type);
o.put("metered", metered);
if (signal != null) {
o.put("signal", (int) signal);
}
if (bridge != null) bridge.emitSystemEvent("android:NetworkChanged", o.toString());
} catch (Exception e) {
Log.e(TAG, "emitNetwork failed", e);
}
}
private void emitNetworkDisconnected() {
try {
JSONObject o = new JSONObject();
o.put("connected", false);
o.put("type", "none");
o.put("metered", false);
if (bridge != null) bridge.emitSystemEvent("android:NetworkChanged", o.toString());
} catch (Exception ignored) {
}
}
private void emitLock(boolean locked) {
// Lock/unlock are signals (no payload); name carries the state.
if (bridge != null) {
bridge.emitSystemEvent(locked ? "android:ScreenLocked" : "android:ScreenUnlocked", "{}");
}
}
private void emitTheme() {
try {
int mode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
JSONObject o = new JSONObject();
// "isDarkMode" matches the context key the desktop platforms use.
o.put("isDarkMode", mode == Configuration.UI_MODE_NIGHT_YES);
if (bridge != null) bridge.emitSystemEvent("android:ThemeChanged", o.toString());
} catch (Exception ignored) {
}
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
// Fires for light/dark switches because the manifest lists uiMode in
// android:configChanges (otherwise the activity would be recreated).
emitTheme();
}
@Override
protected void onStart() {
super.onStart();
// Battery: only monitor system events while the app is visible.
if (!systemReceiversRegistered) {
registerSystemEventReceivers();
systemReceiversRegistered = true;
}
if (bridge != null) {
bridge.onStart();
}
}
@Override
protected void onResume() {
super.onResume();
if (bridge != null) {
bridge.onResume();
}
}
@Override
protected void onPause() {
super.onPause();
if (bridge != null) {
bridge.onPause();
}
}
@Override
protected void onStop() {
super.onStop();
if (systemReceiversRegistered) {
unregisterSystemEventReceivers();
systemReceiversRegistered = false;
}
if (bridge != null) {
bridge.onStop();
}
}
@Override
public void onLowMemory() {
super.onLowMemory();
if (bridge != null) {
bridge.onLowMemory();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
unregisterSystemEventReceivers();
if (bridge != null) {
bridge.shutdown();
}
if (webView != null) {
webView.destroy();
}
}
@Override
public void onBackPressed() {
if (webView != null && webView.canGoBack()) {
webView.goBack();
} else {
super.onBackPressed();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,74 @@
package com.wails.app;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
/**
* A minimal started foreground service. It does no work of its own — its purpose
* is to keep the app's process alive (with the required ongoing notification) so
* the developer's Go goroutines keep running while the app is backgrounded,
* which Android would otherwise be free to kill. Start it from
* {@link WailsBridge#startForegroundService(String)} and stop it with
* {@link WailsBridge#stopForegroundService()}.
*/
public class WailsForegroundService extends android.app.Service {
public static final String ACTION_START = "com.wails.app.FGS_START";
private static final String CHANNEL_ID = "wails_foreground";
private static final int NOTIFICATION_ID = 0x57A1; // "WAI"
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String title = "Wails";
String text = "Running in the background";
if (intent != null) {
if (intent.getStringExtra("title") != null) title = intent.getStringExtra("title");
if (intent.getStringExtra("text") != null) text = intent.getStringExtra("text");
}
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel ch = new NotificationChannel(
CHANNEL_ID, "Background work", NotificationManager.IMPORTANCE_LOW);
nm.createNotificationChannel(ch);
}
PendingIntent contentIntent = null;
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
if (launch != null) {
int piFlags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
? PendingIntent.FLAG_IMMUTABLE : 0;
contentIntent = PendingIntent.getActivity(this, 0, launch, piFlags);
}
Notification n = new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_popup_sync)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)
.setContentIntent(contentIntent)
.build();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
} else {
startForeground(NOTIFICATION_ID, n);
}
// Restart if the OS kills us while still wanted.
return START_STICKY;
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
}

View File

@ -0,0 +1,151 @@
package com.wails.app;
import android.util.Log;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import com.wails.app.BuildConfig;
/**
* WailsJSBridge provides the JavaScript interface that allows the web frontend
* to communicate with the Go backend. This is exposed to JavaScript as the
* `window.wails` object.
*
* Similar to iOS's WKScriptMessageHandler but using Android's addJavascriptInterface.
*/
public class WailsJSBridge {
private static final String TAG = "WailsJSBridge";
private static final boolean DEBUG = BuildConfig.DEBUG;
// Pooled threads avoid unbounded thread creation under high call volume.
private static final ExecutorService executor = Executors.newCachedThreadPool();
private final WailsBridge bridge;
private final WebView webView;
public WailsJSBridge(WailsBridge bridge, WebView webView) {
this.bridge = bridge;
this.webView = webView;
}
/**
* Send a message to Go and return the response synchronously.
* Called from JavaScript: wails.invoke(message)
*
* @param message The message to send (JSON string)
* @return The response from Go (JSON string)
*/
@JavascriptInterface
public String invoke(String message) {
if (DEBUG) Log.d(TAG, "Invoke called: " + message);
return bridge.handleMessage(message);
}
/**
* Send a message to Go asynchronously.
* The response will be sent back via a callback.
* Called from JavaScript: wails.invokeAsync(callbackId, message)
*
* @param callbackId The callback ID to use for the response
* @param message The message to send (JSON string)
*/
@JavascriptInterface
public void invokeAsync(final String callbackId, final String payload) {
if (DEBUG) Log.d(TAG, "InvokeAsync called: " + payload);
// Handle off the JS thread so we don't block the WebView.
executor.execute(() -> {
try {
String response = bridge.handleRuntimeCall(payload);
sendCallback(callbackId, response, null);
} catch (Exception e) {
Log.e(TAG, "Error in async invoke", e);
sendCallback(callbackId, null, e.getMessage());
}
});
}
/**
* Log a message from JavaScript to Android's logcat
* Called from JavaScript: wails.log(level, message)
*
* @param level The log level (debug, info, warn, error)
* @param message The message to log
*/
@JavascriptInterface
public void log(String level, String message) {
switch (level.toLowerCase()) {
case "debug":
Log.d(TAG + "/JS", message);
break;
case "info":
Log.i(TAG + "/JS", message);
break;
case "warn":
Log.w(TAG + "/JS", message);
break;
case "error":
Log.e(TAG + "/JS", message);
break;
default:
Log.v(TAG + "/JS", message);
break;
}
}
/**
* Get the platform name
* Called from JavaScript: wails.platform()
*
* @return "android"
*/
@JavascriptInterface
public String platform() {
return "android";
}
/**
* Check if we're running in debug mode
* Called from JavaScript: wails.isDebug()
*
* @return true if debug build, false otherwise
*/
@JavascriptInterface
public boolean isDebug() {
return BuildConfig.DEBUG;
}
/**
* Send a callback response to JavaScript
*/
private void sendCallback(String callbackId, String result, String error) {
final String js;
if (error != null) {
js = String.format(
"window._wailsAndroidCallback && window._wailsAndroidCallback('%s', null, '%s');",
escapeJsString(callbackId),
escapeJsString(error)
);
} else {
js = String.format(
"window._wailsAndroidCallback && window._wailsAndroidCallback('%s', '%s', null);",
escapeJsString(callbackId),
escapeJsString(result != null ? result : "")
);
}
webView.post(() -> webView.evaluateJavascript(js, null));
}
private String escapeJsString(String str) {
if (str == null) return "";
return str.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r")
// JS line terminators (U+2028/U+2029) must be escaped too; built via
// (char) casts so the Java lexer does not reinterpret them as newlines.
.replace(String.valueOf((char) 0x2028), "\\u2028")
.replace(String.valueOf((char) 0x2029), "\\u2029");
}
}

View File

@ -0,0 +1,68 @@
package com.wails.app;
import android.net.Uri;
import android.util.Log;
import android.webkit.WebResourceResponse;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.webkit.WebViewAssetLoader;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
/**
* WailsPathHandler implements WebViewAssetLoader.PathHandler to serve assets
* from the Go asset server. This allows the WebView to load assets without
* using a network server, similar to iOS's WKURLSchemeHandler.
*/
public class WailsPathHandler implements WebViewAssetLoader.PathHandler {
private static final String TAG = "WailsPathHandler";
private static final boolean DEBUG = BuildConfig.DEBUG;
private final WailsBridge bridge;
public WailsPathHandler(WailsBridge bridge) {
this.bridge = bridge;
}
@Nullable
@Override
public WebResourceResponse handle(@NonNull String path) {
if (DEBUG) Log.d(TAG, "Handling path: " + path);
// Normalize path
if (path.isEmpty() || path.equals("/")) {
path = "/index.html";
}
// Get asset from Go
byte[] data = bridge.serveAsset(path, "GET", "{}");
if (data == null || data.length == 0) {
Log.w(TAG, "Asset not found: " + path);
return null; // Return null to let WebView handle 404
}
// Determine MIME type
String mimeType = bridge.getAssetMimeType(path);
if (DEBUG) Log.d(TAG, "Serving " + path + " with type " + mimeType + " (" + data.length + " bytes)");
// Create response
InputStream inputStream = new ByteArrayInputStream(data);
Map<String, String> headers = new HashMap<>();
headers.put("Access-Control-Allow-Origin", "*");
headers.put("Cache-Control", "no-cache");
return new WebResourceResponse(
mimeType,
"UTF-8",
200,
"OK",
headers,
inputStream
);
}
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/main_container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="wails_blue">#3574D4</color>
<color name="wails_blue_dark">#2C5FB8</color>
<color name="wails_background">#1B2636</color>
<color name="white">#FFFFFFFF</color>
<color name="black">#FF000000</color>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Wails App</string>
</resources>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.WailsApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/wails_blue</item>
<item name="colorPrimaryVariant">@color/wails_blue_dark</item>
<item name="colorOnPrimary">@android:color/white</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">@color/wails_background</item>
<item name="android:navigationBarColor">@color/wails_background</item>
<!-- Window background -->
<item name="android:windowBackground">@color/wails_background</item>
</style>
</resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Camera captures are written here and shared with the camera app via the
FileProvider. -->
<cache-path name="captures" path="captures/" />
</paths>

View File

@ -0,0 +1,4 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.7.3' apply false
}

View File

@ -0,0 +1,26 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/build/optimize-your-build#parallel
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
build/android/gradlew vendored Normal file
View File

@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
build/android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,11 @@
//go:build android
package main
import "github.com/wailsapp/wails/v3/pkg/application"
func init() {
// Register main function to be called when the Android app initializes
// This is necessary because in c-shared build mode, main() is not automatically called
application.RegisterAndroidMain(main)
}

View File

@ -0,0 +1,266 @@
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
)
func main() {
fmt.Println("Checking Android development dependencies...")
fmt.Println()
errors := []string{}
// Check Go
if !checkCommand("go", "version") {
errors = append(errors, "Go is not installed. Install from https://go.dev/dl/")
} else {
fmt.Println("✓ Go is installed")
}
// Check ANDROID_HOME
androidHome := os.Getenv("ANDROID_HOME")
if androidHome == "" {
androidHome = os.Getenv("ANDROID_SDK_ROOT")
}
if androidHome == "" {
// Try common default locations
home, _ := os.UserHomeDir()
possiblePaths := []string{
filepath.Join(home, "Android", "Sdk"),
filepath.Join(home, "Library", "Android", "sdk"),
"/usr/local/share/android-sdk",
}
for _, p := range possiblePaths {
if _, err := os.Stat(p); err == nil {
androidHome = p
break
}
}
}
if androidHome == "" {
errors = append(errors, "ANDROID_HOME not set. Install Android Studio and set ANDROID_HOME environment variable")
} else {
fmt.Printf("✓ ANDROID_HOME: %s\n", androidHome)
}
// Check adb
if !checkCommand("adb", "version") {
if androidHome != "" {
platformTools := filepath.Join(androidHome, "platform-tools")
errors = append(errors, fmt.Sprintf("adb not found. Add %s to PATH", platformTools))
} else {
errors = append(errors, "adb not found. Install Android SDK Platform-Tools")
}
} else {
fmt.Println("✓ adb is installed")
}
// Check emulator
if !checkCommand("emulator", "-list-avds") {
if androidHome != "" {
emulatorPath := filepath.Join(androidHome, "emulator")
errors = append(errors, fmt.Sprintf("emulator not found. Add %s to PATH", emulatorPath))
} else {
errors = append(errors, "emulator not found. Install Android Emulator via SDK Manager")
}
} else {
fmt.Println("✓ Android Emulator is installed")
}
// Check NDK
ndkHome := os.Getenv("ANDROID_NDK_HOME")
if ndkHome == "" && androidHome != "" {
// Look for NDK in default location
ndkDir := filepath.Join(androidHome, "ndk")
if entries, err := os.ReadDir(ndkDir); err == nil {
for _, entry := range entries {
if entry.IsDir() {
ndkHome = filepath.Join(ndkDir, entry.Name())
break
}
}
}
}
if ndkHome == "" {
errors = append(errors, "Android NDK not found. Install NDK via Android Studio > SDK Manager > SDK Tools > NDK (Side by side)")
} else {
fmt.Printf("✓ Android NDK: %s\n", ndkHome)
}
// Check Java
if !checkCommand("java", "-version") {
errors = append(errors, "Java not found. Install JDK 11+ (OpenJDK recommended)")
} else {
fmt.Println("✓ Java is installed")
}
// Check for AVD (Android Virtual Device)
if checkCommand("emulator", "-list-avds") {
cmd := exec.Command("emulator", "-list-avds")
output, err := cmd.Output()
if err == nil && len(strings.TrimSpace(string(output))) > 0 {
avds := strings.Split(strings.TrimSpace(string(output)), "\n")
fmt.Printf("✓ Found %d Android Virtual Device(s)\n", len(avds))
} else {
// Mirror the iOS installer, which offers to create a simulator when
// none exist. Only create from an already-installed system image.
offerCreateAVD(androidHome)
}
}
fmt.Println()
if len(errors) > 0 {
fmt.Println("❌ Missing dependencies:")
for _, err := range errors {
fmt.Printf(" - %s\n", err)
}
fmt.Println()
fmt.Println("Setup instructions:")
fmt.Println("1. Install Android Studio: https://developer.android.com/studio")
fmt.Println("2. Open SDK Manager and install:")
fmt.Println(" - Android SDK Platform (API 35)")
fmt.Println(" - Android SDK Build-Tools")
fmt.Println(" - Android SDK Platform-Tools")
fmt.Println(" - Android Emulator")
fmt.Println(" - NDK (Side by side)")
fmt.Println("3. Set environment variables:")
if runtime.GOOS == "darwin" {
fmt.Println(" export ANDROID_HOME=$HOME/Library/Android/sdk")
} else {
fmt.Println(" export ANDROID_HOME=$HOME/Android/Sdk")
}
fmt.Println(" export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator")
fmt.Println("4. Create an AVD via Android Studio > Tools > Device Manager")
os.Exit(1)
}
fmt.Println("✓ All Android development dependencies are installed!")
}
func checkCommand(name string, args ...string) bool {
cmd := exec.Command(name, args...)
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Run() == nil
}
// offerCreateAVD mirrors the iOS installer's simulator offer: when no AVD
// exists, offer to create one — but ONLY from a system image that is already
// installed. We never run sdkmanager here (a multi-GB download with a license
// prompt is a surprise the user should trigger themselves).
func offerCreateAVD(androidHome string) {
abi := "x86_64"
if runtime.GOARCH == "arm64" {
abi = "arm64-v8a"
}
// Find the highest-API installed system image matching the host ABI.
// The API level must be compared numerically: lexicographic sorting
// would rank android-9 above android-35.
var img string
if androidHome != "" {
matches, _ := filepath.Glob(filepath.Join(androidHome, "system-images", "android-*", "*", abi))
bestAPI := -1
for _, m := range matches {
apiDir := filepath.Base(filepath.Dir(filepath.Dir(m)))
api, err := strconv.Atoi(strings.TrimPrefix(apiDir, "android-"))
if err != nil {
continue // preview/extension images (e.g. android-35-ext14)
}
if api > bestAPI {
bestAPI = api
img = m
}
}
}
avdmanager := findAVDManager(androidHome)
if img == "" || avdmanager == "" {
fmt.Println("⚠ No Android Virtual Devices found.")
fmt.Println(" Install a system image and create an AVD, e.g.:")
fmt.Printf(" sdkmanager 'system-images;android-35;google_apis;%s'\n", abi)
fmt.Printf(" avdmanager create avd --name wails --package 'system-images;android-35;google_apis;%s' --device pixel_7\n", abi)
return
}
// Derive the package path from the installed image directory, e.g.
// <sdk>/system-images/android-35/google_apis/arm64-v8a
// -> system-images;android-35;google_apis;arm64-v8a
rel := strings.TrimPrefix(img, filepath.Join(androidHome, "system-images")+string(os.PathSeparator))
pkg := "system-images;" + strings.ReplaceAll(rel, string(os.PathSeparator), ";")
fmt.Println("⚠ No Android Virtual Devices found.")
fmt.Printf(" Would you like to create a 'wails' AVD from %s?\n", pkg)
if !promptUser("Create AVD?") {
fmt.Println(" Skipping AVD creation.")
fmt.Printf(" Create manually: avdmanager create avd --name wails --package '%s' --device pixel_7\n", pkg)
return
}
cmd := exec.Command(avdmanager, "create", "avd", "--name", "wails", "--package", pkg, "--device", "pixel_7", "--force")
cmd.Stdin = strings.NewReader("no\n") // decline the custom hardware-profile prompt
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf(" Failed to create AVD: %v\n", err)
} else {
fmt.Println(" ✅ 'wails' AVD created")
}
}
// findAVDManager returns the avdmanager path from PATH, or from the SDK's
// cmdline-tools (preferring the newest version), or "" if not found.
func findAVDManager(androidHome string) string {
if p, err := exec.LookPath("avdmanager"); err == nil {
return p
}
if androidHome != "" {
matches, _ := filepath.Glob(filepath.Join(androidHome, "cmdline-tools", "*", "bin", "avdmanager"))
// Prefer the "latest" alias; otherwise compare versions numerically
// ("9.0" would lexicographically outrank "11.0").
best := ""
bestVersion := -1.0
for _, m := range matches {
version := filepath.Base(filepath.Dir(filepath.Dir(m)))
if version == "latest" {
return m
}
v, err := strconv.ParseFloat(version, 64)
if err != nil {
v = 0
}
if v > bestVersion {
bestVersion = v
best = m
}
}
return best
}
return ""
}
func promptUser(question string) bool {
if os.Getenv("CI") != "" || os.Getenv("TASK_FORCE_YES") == "true" {
fmt.Printf("%s [y/N]: y (auto-accepted)\n", question)
return true
}
reader := bufio.NewReader(os.Stdin)
fmt.Printf("%s [y/N]: ", question)
response, err := reader.ReadString('\n')
if err != nil {
return false
}
response = strings.ToLower(strings.TrimSpace(response))
return response == "y" || response == "yes"
}

View File

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "WailsApp"
include ':app'

BIN
build/appicon-source.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 583 533" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-246,-251)">
<g id="Ebene1">
<path d="M246,251L265,784L401,784L506,450L507,450L505,784L641,784L829,251L682,251L596,567L595,567L596,251L478,251L378,568L391,251L246,251Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 698 B

View File

@ -0,0 +1,51 @@
{
"fill": {
"automatic-gradient": "extended-gray:1.00000,1.00000"
},
"groups": [
{
"layers": [
{
"fill-specializations": [
{
"appearance": "dark",
"value": {
"solid": "srgb:0.92143,0.92145,0.92144,1.00000"
}
},
{
"appearance": "tinted",
"value": {
"solid": "srgb:0.83742,0.83744,0.83743,1.00000"
}
}
],
"image-name": "appicon_layer.png",
"name": "appicon_layer",
"position": {
"scale": 0.85,
"translation-in-points": [
0.0,
0.0
]
}
}
],
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"specular": true,
"translucency": {
"enabled": true,
"value": 0.5
}
}
],
"supported-platforms": {
"circles": [
"watchOS"
],
"squares": "shared"
}
}

BIN
build/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

87
build/config.yml Normal file
View File

@ -0,0 +1,87 @@
# This file contains the configuration for this project.
# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
# Note that this will overwrite any changes you have made to the assets.
version: '3'
# This information is used to generate the build assets.
info:
companyName: "iTuoke" # The name of the company
productName: "iTuoke" # The name of the application
productIdentifier: "com.ituoke.app" # The unique product identifier
description: "Enterprise phone control — ADB capture + AI agent" # The application description
copyright: "(c) 2026, iTuoke" # Copyright text
comments: "ADB + Agent desktop shell" # Comments
version: "0.1.0" # The application version
# cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional)
# # Should match the name of your .icon file without the extension
# # If not set and Assets.car exists, defaults to "appicon"
# iOS build configuration (uncomment to customise iOS project generation)
# Note: Keys under `ios` OVERRIDE values under `info` when set.
# ios:
# # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier)
# bundleID: "com.mycompany.myproduct"
# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName)
# displayName: "My Product"
# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion)
# version: "0.0.1"
# # The company/organisation name for templates and project settings
# company: "My Company"
# # Additional comments to embed in Info.plist metadata
# comments: "Some Product Comments"
# Dev mode configuration
dev_mode:
root_path: .
log_level: warn
debounce: 1000
ignore:
dir:
- .git
- node_modules
- frontend
- bin
file:
- .DS_Store
- .gitignore
- .gitkeep
- "*_test.go"
watched_extension:
- "*.go"
- "*.js" # Watch for changes to JS/TS files included using the //wails:include directive.
- "*.ts" # The frontend directory will be excluded entirely by the setting above.
git_ignore: true
executes:
- cmd: wails3 build DEV=true
type: blocking
- cmd: wails3 task common:dev:frontend
type: background
# The standard frontend task waits up to 60s for HTTP 200 from
# FRONTEND_DEVSERVER_URL before starting the app. Override if needed;
# custom background commands can opt in with the same readiness block.
# readiness:
# http: "http://localhost:5173/"
# timeout: "60s"
# interval: "200ms"
- cmd: wails3 task run
type: primary
exit_policy: shutdown
# File Associations
# More information at: https://v3.wails.io/noit/done/yet
fileAssociations:
# - ext: wails
# name: Wails
# description: Wails Application File
# iconName: wailsFileIcon
# role: Editor
# - ext: jpg
# name: JPEG
# description: Image File
# iconName: jpegFileIcon
# role: Editor
# mimeType: image/jpeg # (optional)
# Other data
other:
- name: My Other Data

View File

@ -0,0 +1,42 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>iTuoke</string>
<key>CFBundleDisplayName</key>
<string>iTuoke</string>
<key>CFBundleExecutable</key>
<string>iTuoke</string>
<key>CFBundleIdentifier</key>
<string>com.ituoke.app</string>
<key>CFBundleVersion</key>
<string>0.1.4</string>
<key>CFBundleGetInfoString</key>
<string>ADB + Agent desktop shell</string>
<key>CFBundleShortVersionString</key>
<string>0.1.4</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>© 2026 iTuoke</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>iTuoke 需要访问局域网,以便通过无线调试配对并连接你的手机、获取屏幕画面。</string>
<key>NSBonjourServices</key>
<array>
<string>_adb-tls-pairing._tcp</string>
<string>_adb-tls-connect._tcp</string>
<string>_adb._tcp</string>
</array>
</dict>
</plist>

42
build/darwin/Info.plist Normal file
View File

@ -0,0 +1,42 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>iTuoke</string>
<key>CFBundleDisplayName</key>
<string>iTuoke</string>
<key>CFBundleExecutable</key>
<string>iTuoke</string>
<key>CFBundleIdentifier</key>
<string>com.ituoke.app</string>
<key>CFBundleVersion</key>
<string>0.1.4</string>
<key>CFBundleGetInfoString</key>
<string>ADB + Agent desktop shell</string>
<key>CFBundleShortVersionString</key>
<string>0.1.4</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>© 2026 iTuoke</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>iTuoke 需要访问局域网,以便通过无线调试配对并连接你的手机、获取屏幕画面。</string>
<key>NSBonjourServices</key>
<array>
<string>_adb-tls-pairing._tcp</string>
<string>_adb-tls-connect._tcp</string>
<string>_adb._tcp</string>
</array>
</dict>
</plist>

243
build/darwin/Taskfile.yml Normal file
View File

@ -0,0 +1,243 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
# Docker image for cross-compilation (used when building on non-macOS)
CROSS_IMAGE: wails-cross
tasks:
build:
summary: Builds the application
cmds:
- task: '{{if eq OS "darwin"}}build:native{{else}}build:docker{{end}}'
vars:
ARCH: '{{.ARCH}}'
DEV: '{{.DEV}}'
OUTPUT: '{{.OUTPUT}}'
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
OBFUSCATED: '{{.OBFUSCATED}}'
GARBLE_ARGS: '{{.GARBLE_ARGS}}'
vars:
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
build:native:
summary: Builds the application natively on macOS
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
OBFUSCATED:
ref: .OBFUSCATED
DEV:
ref: .DEV
- task: common:generate:icons
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:
- wails3 tool prepare-macos-output --dir "{{.BIN_DIR}}"
- '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"'
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: darwin
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
CGO_CFLAGS: "-mmacosx-version-min=12.0"
CGO_LDFLAGS: "-mmacosx-version-min=12.0"
MACOSX_DEPLOYMENT_TARGET: "12.0"
build:docker:
summary: Cross-compiles for macOS using Docker (for Linux/Windows hosts)
internal: true
deps:
- task: common:build:frontend
vars:
OBFUSCATED:
ref: .OBFUSCATED
- task: common:generate:icons
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required for cross-compilation. Please install Docker."
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
msg: |
Docker image '{{.CROSS_IMAGE}}' not found.
Build it first: wails3 task setup:docker
cmds:
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} {{.CROSS_IMAGE}} darwin {{.DOCKER_ARCH}}
- cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
platforms: [linux, darwin]
- mkdir -p {{.BIN_DIR}}
- mv "bin/{{.APP_NAME}}-darwin-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
vars:
DOCKER_ARCH: '{{if eq .ARCH "arm64"}}arm64{{else if eq .ARCH "amd64"}}amd64{{else}}arm64{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
# Generate Docker volume mounts: Go module cache + go.mod replace directives
# Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
DOCKER_MOUNTS:
sh: 'wails3 tool docker-mounts'
build:universal:
summary: Builds darwin universal binary (arm64 + amd64)
deps:
- task: build
vars:
ARCH: amd64
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
- task: build
vars:
ARCH: arm64
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
cmds:
- task: '{{if eq OS "darwin"}}build:universal:lipo:native{{else}}build:universal:lipo:go{{end}}'
build:universal:lipo:native:
summary: Creates universal binary using native lipo (macOS)
internal: true
cmds:
- lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
- rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
build:universal:lipo:go:
summary: Creates universal binary using wails3 tool lipo (Linux/Windows)
internal: true
cmds:
- wails3 tool lipo -output "{{.BIN_DIR}}/{{.APP_NAME}}" -input "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" -input "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
package:
summary: Packages the application into a `.app` bundle
deps:
- task: build
cmds:
- task: create:app:bundle
package:universal:
summary: Packages darwin universal binary (arm64 + amd64)
deps:
- task: build:universal
cmds:
- task: create:app:bundle
package:dmg:
summary: Packages the application into a styled `.dmg`
deps:
- task: package
cmds:
- task: create:dmg
create:dmg:
summary: Creates a configurable `.dmg` from the `.app` bundle
platforms: [darwin]
cmds:
- >-
wails3 tool package --format dmg --name "{{.APP_NAME}}" --out "{{.BIN_DIR}}"
--background "{{.DMG_BACKGROUND}}"
--volume-icon "{{.DMG_VOLUME_ICON}}"
--file-icon "{{.DMG_FILE_ICON}}"
--window-width "{{.DMG_WINDOW_WIDTH}}"
--window-height "{{.DMG_WINDOW_HEIGHT}}"
{{if .DMG_FILES}}--files "{{.DMG_FILES}}"{{end}}
vars:
DMG_BACKGROUND: '{{.DMG_BACKGROUND | default "build/darwin/dmg-background.png"}}'
DMG_VOLUME_ICON: '{{.DMG_VOLUME_ICON | default "build/darwin/icons.icns"}}'
DMG_FILE_ICON: '{{.DMG_FILE_ICON | default "build/darwin/dmg-file-icon.icns"}}'
DMG_WINDOW_WIDTH: '{{.DMG_WINDOW_WIDTH | default "540"}}'
DMG_WINDOW_HEIGHT: '{{.DMG_WINDOW_HEIGHT | default "380"}}'
DMG_FILES: '{{.DMG_FILES | default ""}}'
create:app:bundle:
summary: Creates an `.app` bundle
cmds:
# A reused bundle can acquire Finder metadata in synced folders. Build a
# fresh bundle so codesign never has to replace a contaminated signature.
- test -n "{{.BIN_DIR}}" && test -n "{{.APP_NAME}}" && rm -rf -- "{{.BIN_DIR}}/{{.APP_NAME}}.app"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
# 故意不拷贝旧 Assets.car,避免覆盖 icons.icns 图标
- cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
# [内置 adb] 打进 Resources,客户无需安装 platform-tools
- |
if [ -x third_party/platform-tools/adb ]; then
mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources/platform-tools"
rsync -a third_party/platform-tools/ "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources/platform-tools/"
echo "bundled platform-tools into app Resources"
else
echo "WARNING: third_party/platform-tools/adb missing — run ./scripts/sync-platform-tools.sh"
fi
- cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents"
- task: '{{if eq OS "darwin"}}codesign:adhoc{{else}}codesign:skip{{end}}'
codesign:adhoc:
summary: Ad-hoc signs the app bundle (macOS only)
internal: true
cmds:
- xattr -cr "{{.BIN_DIR}}/{{.APP_NAME}}.app"
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app"
- codesign --verify --deep --strict "{{.BIN_DIR}}/{{.APP_NAME}}.app"
codesign:skip:
summary: Skips codesigning when cross-compiling
internal: true
cmds:
- 'echo "Skipping codesign (not available on {{OS}}). Sign the .app on macOS before distribution."'
run:
cmds:
- wails3 tool prepare-macos-output --dir "{{.BIN_DIR}}"
# 开发包也用 ituoke.app(不要 .dev,系统设置里会显示成 ituoke.dev)
- test -n "{{.BIN_DIR}}" && test -n "{{.APP_NAME}}" && rm -rf -- "{{.BIN_DIR}}/{{.APP_NAME}}.app" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources/icons.icns"
- cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
- |
if [ -x third_party/platform-tools/adb ]; then
mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources/platform-tools"
rsync -a third_party/platform-tools/ "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources/platform-tools/"
fi
- cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Info.plist"
- xattr -cr "{{.BIN_DIR}}/{{.APP_NAME}}.app"
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app"
- codesign --verify --deep --strict "{{.BIN_DIR}}/{{.APP_NAME}}.app"
# 必须用 LaunchServices 打开 .app,才会出现在「本地网络」并带上图标/名称
# Launch from the local disk: running on /Volumes may require a new
# removable-volume grant after each development rebuild.
- mkdir -p "$HOME/Applications"
- ditto "{{.BIN_DIR}}/{{.APP_NAME}}.app" "$HOME/Applications/{{.APP_NAME}}.app"
- open -W "$HOME/Applications/{{.APP_NAME}}.app"
sign:
summary: Signs the application bundle with Developer ID
desc: |
Signs the .app bundle for distribution.
Uses signing identity from `wails3 setup` (stored in ~/.config/wails/defaults.yaml).
Override with: task darwin:sign -- --identity "Developer ID Application: ..."
deps:
- task: package
cmds:
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" {{.CLI_ARGS}}
sign:notarize:
summary: Signs and notarizes the application bundle
desc: |
Signs the .app bundle and submits it for notarization.
Uses signing identity and keychain profile from `wails3 setup`.
First-time setup:
wails3 setup
deps:
- task: package
cmds:
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --notarize {{.CLI_ARGS}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
build/darwin/icons.icns Normal file

Binary file not shown.

View File

@ -0,0 +1,225 @@
# Cross-compile Wails v3 apps to any platform
#
# Darwin: Zig + macOS SDK
# Linux: Native GCC when host matches target, Zig for cross-arch
# Windows: Zig + bundled mingw
#
# Usage:
# docker build -t wails-cross -f Dockerfile.cross .
# docker run --rm -v $(pwd):/app wails-cross darwin arm64
# docker run --rm -v $(pwd):/app wails-cross darwin amd64
# docker run --rm -v $(pwd):/app wails-cross linux amd64
# docker run --rm -v $(pwd):/app wails-cross linux arm64
# docker run --rm -v $(pwd):/app wails-cross windows amd64
# docker run --rm -v $(pwd):/app wails-cross windows arm64
# Debian 13 provides the GTK >= 4.14 API baseline used by the default Linux backend.
FROM golang:1.26-trixie
ARG TARGETARCH
ARG GARBLE_VERSION=v0.16.0
# Install base tools, GCC, and GTK/WebKit dev packages
RUN apt-get update && apt-get install -y --no-install-recommends \
curl xz-utils python3 nodejs npm pkg-config gcc libc6-dev \
libgtk-3-dev libwebkit2gtk-4.1-dev \
libgtk-4-dev libwebkitgtk-6.0-dev \
&& pkg-config --atleast-version=4.14 gtk4 \
&& pkg-config --exists webkitgtk-6.0 \
&& pkg-config --exists gtk+-3.0 \
&& pkg-config --exists webkit2gtk-4.1 \
&& rm -rf /var/lib/apt/lists/*
RUN go install mvdan.cc/garble@${GARBLE_VERSION}
# Install Zig - automatically selects correct binary for host architecture.
# The SHA-256 is fetched from Zig's official download manifest and verified
# before extraction to protect against MITM or corrupted downloads.
ARG ZIG_VERSION=0.14.0
RUN ZIG_ARCH=$(case "${TARGETARCH}" in arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
ZIG_FILE="zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" && \
curl -fSL "https://ziglang.org/download/${ZIG_VERSION}/${ZIG_FILE}" -o "/tmp/${ZIG_FILE}" && \
EXPECTED=$(curl -fsSL "https://ziglang.org/download/index.json" | \
python3 -c "import json,sys; d=json.load(sys.stdin); print(d[sys.argv[1]][sys.argv[2]+'-linux']['shasum'])" \
"${ZIG_VERSION}" "${ZIG_ARCH}") && \
echo "${EXPECTED} /tmp/${ZIG_FILE}" | sha256sum -c - && \
tar -xJ -f "/tmp/${ZIG_FILE}" -C /opt && \
rm "/tmp/${ZIG_FILE}" && \
ln -s /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig
# Download macOS SDK (required for darwin targets)
ARG MACOS_SDK_VERSION=14.5
RUN curl -L "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_SDK_VERSION}/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz" \
| tar -xJ -C /opt \
&& mv /opt/MacOSX${MACOS_SDK_VERSION}.sdk /opt/macos-sdk
ENV MACOS_SDK_PATH=/opt/macos-sdk
# Create Zig CC wrappers for cross-compilation targets
# Darwin and Windows use Zig; Linux uses native GCC (run with --platform for cross-arch)
# Darwin arm64
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-arm64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-mmacosx-version-min=*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -fno-sanitize=all -target aarch64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-darwin-arm64
# Darwin amd64
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-amd64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-mmacosx-version-min=*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -fno-sanitize=all -target x86_64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-darwin-amd64
# Windows amd64 - uses Zig's bundled mingw
COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-amd64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-Wl,*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -target x86_64-windows-gnu $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-windows-amd64
# Windows arm64 - uses Zig's bundled mingw
COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-arm64
#!/bin/sh
ARGS=""
SKIP_NEXT=0
for arg in "$@"; do
if [ $SKIP_NEXT -eq 1 ]; then
SKIP_NEXT=0
continue
fi
case "$arg" in
-target) SKIP_NEXT=1 ;;
-Wl,*) ;;
*) ARGS="$ARGS $arg" ;;
esac
done
exec zig cc -target aarch64-windows-gnu $ARGS
ZIGWRAP
RUN chmod +x /usr/local/bin/zcc-windows-arm64
# Build script
COPY <<'SCRIPT' /usr/local/bin/build.sh
#!/bin/sh
set -e
OS=${1:-darwin}
ARCH=${2:-arm64}
case "${OS}-${ARCH}" in
darwin-arm64|darwin-aarch64)
export CC=zcc-darwin-arm64
export GOARCH=arm64
export GOOS=darwin
;;
darwin-amd64|darwin-x86_64)
export CC=zcc-darwin-amd64
export GOARCH=amd64
export GOOS=darwin
;;
linux-arm64|linux-aarch64)
export CC=gcc
export GOARCH=arm64
export GOOS=linux
;;
linux-amd64|linux-x86_64)
export CC=gcc
export GOARCH=amd64
export GOOS=linux
;;
windows-arm64|windows-aarch64)
export CC=zcc-windows-arm64
export GOARCH=arm64
export GOOS=windows
;;
windows-amd64|windows-x86_64)
export CC=zcc-windows-amd64
export GOARCH=amd64
export GOOS=windows
;;
*)
echo "Usage: <os> <arch>"
echo " os: darwin, linux, windows"
echo " arch: amd64, arm64"
exit 1
;;
esac
export CGO_ENABLED=1
export CGO_CFLAGS="-w"
# Build frontend if exists and not already built (host may have built it)
if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
(cd frontend && npm install --silent && npm run build --silent)
fi
# Build
APP=${APP_NAME:-$(basename $(pwd))}
mkdir -p bin
EXT=""
LDFLAGS="-s -w"
if [ "$GOOS" = "windows" ]; then
EXT=".exe"
LDFLAGS="-s -w -H windowsgui"
fi
TAGS="production"
if [ -n "$EXTRA_TAGS" ]; then
TAGS="${TAGS},${EXTRA_TAGS}"
fi
COMPILER="go build"
if [ "$OBFUSCATED" = "true" ]; then
COMPILER="garble ${GARBLE_ARGS} build"
TAGS="${TAGS},wails_obfuscated"
fi
${COMPILER} -tags "$TAGS" -trimpath -buildvcs=false -ldflags="$LDFLAGS" -o bin/${APP}-${GOOS}-${GOARCH}${EXT} .
echo "Built: bin/${APP}-${GOOS}-${GOARCH}${EXT}"
SCRIPT
RUN chmod +x /usr/local/bin/build.sh
WORKDIR /app
ENTRYPOINT ["/usr/local/bin/build.sh"]
CMD ["darwin", "arm64"]

View File

@ -0,0 +1,67 @@
# Wails Server Mode Dockerfile
# Multi-stage build for a minimal image.
#
# Defaults produce a pure-Go, fully static binary on a distroless/static base:
# the smallest, most secure image, and correct for the default (CGO-free) app.
#
# If your app needs CGO, override the build args, e.g.:
# docker build --build-arg CGO_ENABLED=1 \
# --build-arg GO_IMAGE=golang:bookworm \
# --build-arg RUNTIME_IMAGE=gcr.io/distroless/base-debian12 \
# -f build/docker/Dockerfile.server .
# CGO needs a C toolchain in the builder and a libc in the runtime image; keep
# the builder and runtime libc compatible (a glibc builder -> a glibc runtime).
# The Taskfile passes these through: task build:docker CGO_ENABLED=1 GO_IMAGE=... RUNTIME_IMAGE=...
ARG GO_IMAGE=golang:alpine
ARG RUNTIME_IMAGE=gcr.io/distroless/static-debian12
ARG CGO_ENABLED=0
# Build stage
FROM ${GO_IMAGE} AS builder
ARG CGO_ENABLED
WORKDIR /app
# Install build dependencies: git always; a C compiler only when CGO is enabled.
# Supports both apk (alpine) and apt (debian/bookworm) base images.
RUN if command -v apk >/dev/null 2>&1; then \
apk add --no-cache git $([ "$CGO_ENABLED" = "1" ] && echo gcc musl-dev); \
else \
apt-get update && apt-get install -y --no-install-recommends git $([ "$CGO_ENABLED" = "1" ] && echo gcc libc6-dev) && rm -rf /var/lib/apt/lists/*; \
fi
# Copy source code
COPY . .
# Remove local replace directive if present (for production builds)
RUN sed -i '/^replace/d' go.mod || true
# Download dependencies
RUN go mod tidy
# Build the production server binary.
# - server,production tags: HTTP server mode with production code paths (no dev
# logger/asset middleware), matching the desktop production build.
# - CGO_ENABLED defaults to 0 for a fully static binary (distroless/static);
# override with --build-arg CGO_ENABLED=1 (see header) for CGO apps.
RUN CGO_ENABLED=${CGO_ENABLED} go build -tags server,production -trimpath -buildvcs=false -ldflags="-s -w" -o server .
# Runtime stage - minimal image
FROM ${RUNTIME_IMAGE}
# Copy the binary
COPY --from=builder /app/server /server
# Copy frontend assets
COPY --from=builder /app/frontend/dist /frontend/dist
# Expose the default port
EXPOSE 8080
# Bind to all interfaces (required for Docker)
# Can be overridden at runtime with -e WAILS_SERVER_HOST=...
ENV WAILS_SERVER_HOST=0.0.0.0
# Run the server
ENTRYPOINT ["/server"]

116
build/ios/Assets.xcassets Normal file
View File

@ -0,0 +1,116 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"images" : [
{
"filename" : "icon-20@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon-20@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "icon-29@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon-29@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "icon-40@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon-40@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "icon-60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "icon-60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "icon-20.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "icon-20@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "icon-29.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "icon-29@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "icon-40.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "icon-40@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "icon-76.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "icon-76@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "icon-83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "icon-1024.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
]
}

61
build/ios/Info.dev.plist Normal file
View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>ituoke</string>
<key>CFBundleIdentifier</key>
<string>com.ituoke.app.dev</string>
<key>CFBundleName</key>
<string>iTuoke (Dev)</string>
<key>CFBundleDisplayName</key>
<string>iTuoke (Dev)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0-dev</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MinimumOSVersion</key>
<string>15.0</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<!-- Development mode enabled -->
<key>WailsDevelopmentMode</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>© 2026 iTuoke</string>
<key>CFBundleGetInfoString</key>
<string>ADB + Agent desktop shell</string>
</dict>
</plist>

80
build/ios/Info.plist Normal file
View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>ituoke</string>
<key>CFBundleIdentifier</key>
<string>com.ituoke.app</string>
<key>CFBundleName</key>
<string>iTuoke</string>
<key>CFBundleDisplayName</key>
<string>iTuoke</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MinimumOSVersion</key>
<string>15.0</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSHumanReadableCopyright</key>
<string>© 2026 iTuoke</string>
<key>CFBundleGetInfoString</key>
<string>ADB + Agent desktop shell</string>
<!--
Privacy usage descriptions. iOS hard-crashes (and the App Store rejects the
build) the moment a permission-gated API is called without the matching
key. The Wails runtime exposes camera, microphone, photo library, location,
motion and biometric APIs; uncomment the key for each feature your app
actually uses and replace the string with a real, user-facing reason. Only
ship keys for features you use — unused keys draw App Store review scrutiny.
<key>NSCameraUsageDescription</key>
<string>This app uses the camera to capture photos and videos.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app uses the microphone when recording video.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app accesses your photo library to select images.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app uses your location while you are using it.</string>
<key>NSMotionUsageDescription</key>
<string>This app uses motion data from device sensors.</string>
<key>NSFaceIDUsageDescription</key>
<string>This app uses Face ID to authenticate you.</string>
-->
</dict>
</plist>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="iTuoke" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb">
<rect key="frame" x="0.0" y="397" width="393" height="43"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="36"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Enterprise phone control desktop - ADB capture and AI agent" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="MN2-I3-ftu">
<rect key="frame" x="0.0" y="448" width="393" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="GJd-Yh-RWb" secondAttribute="centerX" id="Q3B-4B-g5h"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="bottom" multiplier="1/2" constant="-20" id="moa-c2-u7t"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="x7j-FC-K8j"/>
<constraint firstItem="MN2-I3-ftu" firstAttribute="top" secondItem="GJd-Yh-RWb" secondAttribute="bottom" constant="8" symbolic="YES" id="cPy-rs-vsC"/>
<constraint firstItem="MN2-I3-ftu" firstAttribute="centerX" secondItem="Bcu-3y-fUS" secondAttribute="centerX" id="OQL-iM-xY6"/>
<constraint firstItem="MN2-I3-ftu" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="Dti-5h-tvW"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
</document>

365
build/ios/Taskfile.yml Normal file
View File

@ -0,0 +1,365 @@
version: '3'
includes:
common: ../Taskfile.yml
# Platform selection:
# task ios:package -> simulator bundle (ad-hoc signed)
# task ios:package IOS_PLATFORM=device CODESIGN_IDENTITY="Apple Development: ..." \
# [PROVISIONING_PROFILE=path.mobileprovision]
# -> device bundle (real signing)
# task ios:package:ipa IOS_PLATFORM=device ... -> .ipa for distribution/install
# For automatically managed signing & provisioning, use `task ios:xcode` and
# build from the generated Xcode project instead.
vars:
BUNDLE_ID: '{{.BUNDLE_ID | default "com.wails.app"}}'
IOS_PLATFORM: '{{.IOS_PLATFORM | default "simulator"}}'
MIN_IOS_VERSION: '{{.MIN_IOS_VERSION | default "15.0"}}'
IOS_SDK: '{{if eq .IOS_PLATFORM "device"}}iphoneos{{else}}iphonesimulator{{end}}'
IOS_TARGET: 'arm64-apple-ios{{.MIN_IOS_VERSION}}{{if ne .IOS_PLATFORM "device"}}-simulator{{end}}'
IOS_VERSION_MIN_FLAG: '{{if eq .IOS_PLATFORM "device"}}-miphoneos-version-min={{.MIN_IOS_VERSION}}{{else}}-mios-simulator-version-min={{.MIN_IOS_VERSION}}{{end}}'
# "-" is ad-hoc signing: fine for the simulator, rejected by real devices.
CODESIGN_IDENTITY: '{{.CODESIGN_IDENTITY | default "-"}}'
# Optional path to a .mobileprovision embedded into device bundles
PROVISIONING_PROFILE: '{{.PROVISIONING_PROFILE | default ""}}'
# Optional entitlements applied at codesign time (generated default below)
ENTITLEMENTS_FILE: '{{.ENTITLEMENTS_FILE | default "build/ios/entitlements.plist"}}'
# SDK_PATH is computed lazily at task-level to avoid errors on non-macOS systems
tasks:
install:deps:
summary: Check and install iOS development dependencies
cmds:
- go run build/ios/scripts/deps/install_deps.go
env:
TASK_FORCE_YES: '{{if .YES}}true{{else}}false{{end}}'
prompt: This will check and install iOS development dependencies. Continue?
# Note: Bindings generation may show CGO warnings for iOS C imports.
# These warnings are harmless and don't affect the generated bindings,
# as the generator only needs to parse Go types, not C implementations.
build:
summary: Creates a build of the application for iOS
deps:
- task: generate:ios:overlay
- task: generate:ios:xcode
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
PRODUCTION:
ref: .PRODUCTION
cmds:
- echo "Building iOS app {{.APP_NAME}} ({{.IOS_PLATFORM}})..."
- go build -buildmode=c-archive -overlay build/ios/xcode/overlay.json {{.BUILD_FLAGS}} -o {{.OUTPUT}}.a
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,ios -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags ios,debug -buildvcs=false -gcflags=all="-l"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
SDK_PATH:
sh: xcrun --sdk {{.IOS_SDK}} --show-sdk-path
env:
GOOS: ios
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default "arm64"}}'
PRODUCTION: '{{.PRODUCTION | default "false"}}'
CGO_CFLAGS: '-isysroot {{.SDK_PATH}} -target {{.IOS_TARGET}} {{.IOS_VERSION_MIN_FLAG}}'
CGO_LDFLAGS: '-isysroot {{.SDK_PATH}} -target {{.IOS_TARGET}}'
package:
summary: Packages a production build of the application into a `.app` bundle
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: compile:ios:link
- task: create:app:bundle
package:ipa:
summary: Packages a production build into an .ipa (requires IOS_PLATFORM=device and a real CODESIGN_IDENTITY)
deps:
- task: package
cmds:
- rm -rf "{{.BIN_DIR}}/Payload" "{{.BIN_DIR}}/{{.APP_NAME}}.ipa"
- mkdir -p "{{.BIN_DIR}}/Payload"
- cp -R "{{.BIN_DIR}}/{{.APP_NAME}}.app" "{{.BIN_DIR}}/Payload/"
- cd "{{.BIN_DIR}}" && zip -qry "{{.APP_NAME}}.ipa" Payload
- rm -rf "{{.BIN_DIR}}/Payload"
- echo "Created {{.BIN_DIR}}/{{.APP_NAME}}.ipa"
# Link the Go c-archive with main.m into the final executable.
# Used by both the dev `run` flow and `package`.
compile:ios:link:
internal: true
summary: Link the iOS executable from the Go archive and main.m
vars:
SDK_PATH:
sh: xcrun --sdk {{.IOS_SDK}} --show-sdk-path
cmds:
- |
MAIN_M=build/ios/xcode/main/main.m
if [ ! -f "$MAIN_M" ]; then
MAIN_M=build/ios/main.m
fi
xcrun -sdk {{.IOS_SDK}} clang \
-target {{.IOS_TARGET}} \
-isysroot {{.SDK_PATH}} \
-framework Foundation -framework UIKit -framework WebKit \
-framework Security -framework CoreFoundation \
-framework UniformTypeIdentifiers \
-framework LocalAuthentication -framework UserNotifications \
-framework AVFoundation \
-framework CoreLocation -framework CoreMotion -framework SystemConfiguration \
-lresolv \
-o "{{.BIN_DIR}}/{{.APP_NAME | lower}}" \
"$MAIN_M" -Wl,-force_load,"{{.BIN_DIR}}/{{.APP_NAME}}.a"
create:app:bundle:
summary: Creates an iOS `.app` bundle
cmds:
- rm -rf "{{.BIN_DIR}}/{{.APP_NAME}}.app"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app"
- cp "{{.BIN_DIR}}/{{.APP_NAME | lower}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/"
- cp build/ios/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/"
- |
# Embed the provisioning profile for device builds when provided
if [ "{{.IOS_PLATFORM}}" = "device" ] && [ -n "{{.PROVISIONING_PROFILE}}" ]; then
cp "{{.PROVISIONING_PROFILE}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/embedded.mobileprovision"
fi
- |
# Compile asset catalog and embed icons in the app bundle
APP_BUNDLE="{{.BIN_DIR}}/{{.APP_NAME}}.app"
AC_IN="build/ios/xcode/main/Assets.xcassets"
if [ -d "$AC_IN" ]; then
TMP_AC=$(mktemp -d)
xcrun actool \
--compile "$TMP_AC" \
--app-icon AppIcon \
--platform {{.IOS_SDK}} \
--minimum-deployment-target {{.MIN_IOS_VERSION}} \
--product-type com.apple.product-type.application \
--target-device iphone \
--target-device ipad \
--output-partial-info-plist "$APP_BUNDLE/assetcatalog_generated_info.plist" \
"$AC_IN"
if [ -f "$TMP_AC/Assets.car" ]; then
cp -f "$TMP_AC/Assets.car" "$APP_BUNDLE/Assets.car"
fi
rm -rf "$TMP_AC"
if [ -f "$APP_BUNDLE/assetcatalog_generated_info.plist" ]; then
/usr/libexec/PlistBuddy -c "Merge $APP_BUNDLE/assetcatalog_generated_info.plist" "$APP_BUNDLE/Info.plist" || true
fi
fi
- |
# Sign the bundle. Ad-hoc ("-") works on the simulator only; device
# builds need a real identity (and usually entitlements + profile).
# Entitlements are only applied to device builds - simulator bundles
# must not carry them (SpringBoard refuses to launch the app).
if [ "{{.IOS_PLATFORM}}" = "device" ] && [ -f "{{.ENTITLEMENTS_FILE}}" ]; then
codesign --force --sign "{{.CODESIGN_IDENTITY}}" --entitlements "{{.ENTITLEMENTS_FILE}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app"
else
codesign --force --sign "{{.CODESIGN_IDENTITY}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app"
fi
deploy-simulator:
summary: Deploy to iOS Simulator
deps: [package]
cmds:
- |
# Read the real bundle id from the built bundle (it comes from
# build/config.yml, not the BUNDLE_ID var, so it varies per project).
APP="{{.BIN_DIR}}/{{.APP_NAME}}.app"
BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Info.plist")
xcrun simctl terminate booted "$BUNDLE_ID" 2>/dev/null || true
xcrun simctl uninstall booted "$BUNDLE_ID" 2>/dev/null || true
xcrun simctl install booted "$APP"
xcrun simctl launch booted "$BUNDLE_ID"
deploy-device:
summary: Install and launch on a connected device (requires IOS_PLATFORM=device build and real signing)
cmds:
- |
DEVICE="{{.DEVICE_ID}}"
if [ -z "$DEVICE" ]; then
DEVICE=$(xcrun devicectl list devices --hide-headers 2>/dev/null | awk 'NR==1 {print $3}')
fi
if [ -z "$DEVICE" ]; then
echo "No connected device found. Pass DEVICE_ID=<identifier> (see: xcrun devicectl list devices)"
exit 1
fi
xcrun devicectl device install app --device "$DEVICE" "{{.BIN_DIR}}/{{.APP_NAME}}.app"
BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "{{.BIN_DIR}}/{{.APP_NAME}}.app/Info.plist")
xcrun devicectl device process launch --device "$DEVICE" "$BUNDLE_ID"
compile:ios:
summary: Compile the iOS executable from Go archive and main.m
deps:
- task: build
cmds:
- task: compile:ios:link
generate:ios:bindings:
internal: true
summary: Generates bindings for iOS with proper CGO flags
sources:
- "**/*.go"
- go.mod
- go.sum
generates:
- frontend/bindings/**/*
vars:
SDK_PATH:
sh: xcrun --sdk {{.IOS_SDK}} --show-sdk-path
cmds:
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
env:
GOOS: ios
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default "arm64"}}'
CGO_CFLAGS: '-isysroot {{.SDK_PATH}} -target {{.IOS_TARGET}} {{.IOS_VERSION_MIN_FLAG}}'
CGO_LDFLAGS: '-isysroot {{.SDK_PATH}} -target {{.IOS_TARGET}}'
ensure-simulator:
internal: true
summary: Ensure iOS Simulator is running and booted
silent: true
cmds:
- |
if ! xcrun simctl list devices booted | grep -q "Booted"; then
echo "Starting iOS Simulator..."
# Get first available iPhone device
DEVICE_ID=$(xcrun simctl list devices available | grep "iPhone" | head -1 | grep -o "[A-F0-9-]\{36\}" || true)
if [ -z "$DEVICE_ID" ]; then
echo "No iPhone simulator found. Creating one..."
RUNTIME=$(xcrun simctl list runtimes | grep iOS | tail -1 | awk '{print $NF}')
# Use the newest iPhone device type this Xcode knows about
DEVICE_TYPE=$(xcrun simctl list devicetypes | grep -oE "iPhone [0-9][^(]*" | tail -1 | xargs)
DEVICE_ID=$(xcrun simctl create "Wails iPhone" "${DEVICE_TYPE:-iPhone 15}" "$RUNTIME")
fi
# Boot the device
echo "Booting device $DEVICE_ID..."
xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true
# Open Simulator app
open -a Simulator
# Wait for boot (max 30 seconds)
for i in {1..30}; do
if xcrun simctl list devices booted | grep -q "Booted"; then
echo "Simulator booted successfully"
break
fi
sleep 1
done
# Final check
if ! xcrun simctl list devices booted | grep -q "Booted"; then
echo "Failed to boot simulator after 30 seconds"
exit 1
fi
fi
preconditions:
- sh: command -v xcrun
msg: "xcrun not found. Please run 'wails3 task ios:install:deps' to install iOS development dependencies"
generate:ios:overlay:
internal: true
summary: Generate Go build overlay and iOS shim
sources:
- build/config.yml
generates:
- build/ios/xcode/overlay.json
- build/ios/xcode/gen/main_ios.gen.go
cmds:
- wails3 ios overlay:gen -out build/ios/xcode/overlay.json -config build/config.yml
generate:ios:xcode:
internal: true
summary: Generate iOS Xcode project structure and assets
sources:
- build/config.yml
- build/appicon.png
generates:
- build/ios/xcode/main/main.m
- build/ios/xcode/main/Assets.xcassets/**/*
- build/ios/xcode/project.pbxproj
cmds:
- wails3 ios xcode:gen -outdir build/ios/xcode -config build/config.yml
run:
summary: Run the application in iOS Simulator
deps:
- task: ensure-simulator
- task: compile:ios
cmds:
- rm -rf "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
- cp "{{.BIN_DIR}}/{{.APP_NAME | lower}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/{{.APP_NAME | lower}}"
- cp build/ios/Info.dev.plist "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Info.plist"
- |
# Compile asset catalog and embed icons for dev bundle
APP_BUNDLE="{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
AC_IN="build/ios/xcode/main/Assets.xcassets"
if [ -d "$AC_IN" ]; then
TMP_AC=$(mktemp -d)
xcrun actool \
--compile "$TMP_AC" \
--app-icon AppIcon \
--platform iphonesimulator \
--minimum-deployment-target {{.MIN_IOS_VERSION}} \
--product-type com.apple.product-type.application \
--target-device iphone \
--target-device ipad \
--output-partial-info-plist "$APP_BUNDLE/assetcatalog_generated_info.plist" \
"$AC_IN"
if [ -f "$TMP_AC/Assets.car" ]; then
cp -f "$TMP_AC/Assets.car" "$APP_BUNDLE/Assets.car"
fi
rm -rf "$TMP_AC"
if [ -f "$APP_BUNDLE/assetcatalog_generated_info.plist" ]; then
/usr/libexec/PlistBuddy -c "Merge $APP_BUNDLE/assetcatalog_generated_info.plist" "$APP_BUNDLE/Info.plist" || true
fi
fi
- codesign --force --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
- |
# The dev bundle id comes from build/ios/Info.dev.plist (derived from
# build/config.yml), so read it from the built bundle rather than
# assuming a com.wails.* prefix.
DEV_APP="{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$DEV_APP/Info.plist")
xcrun simctl terminate booted "$BUNDLE_ID" 2>/dev/null || true
xcrun simctl uninstall booted "$BUNDLE_ID" 2>/dev/null || true
xcrun simctl install booted "$DEV_APP"
xcrun simctl launch booted "$BUNDLE_ID"
xcode:
summary: Open the generated Xcode project for this app
cmds:
- task: generate:ios:xcode
- open build/ios/xcode/main.xcodeproj
logs:
summary: Stream iOS Simulator logs filtered to this app
cmds:
- |
xcrun simctl spawn booted log stream \
--level debug \
--style compact \
--predicate 'senderImagePath CONTAINS[c] "{{.APP_NAME | lower}}.app/" OR composedMessage CONTAINS[c] "{{.APP_NAME | lower}}" OR eventMessage CONTAINS[c] "{{.APP_NAME | lower}}" OR process == "{{.APP_NAME | lower}}" OR category CONTAINS[c] "{{.APP_NAME | lower}}"'
logs:dev:
summary: Stream logs for the dev bundle (used by `task ios:run`)
cmds:
- |
xcrun simctl spawn booted log stream \
--level debug \
--style compact \
--predicate 'senderImagePath CONTAINS[c] ".dev.app/" OR subsystem == "com.wails.{{.APP_NAME | lower}}.dev" OR process == "{{.APP_NAME | lower}}"'
logs:wide:
summary: Wide log stream to help discover the exact process/bundle identifiers
cmds:
- |
xcrun simctl spawn booted log stream \
--level debug \
--style compact \
--predicate 'senderImagePath CONTAINS[c] ".app/"'

View File

@ -0,0 +1,10 @@
//go:build !ios
package main
import "github.com/wailsapp/wails/v3/pkg/application"
// modifyOptionsForIOS is a no-op on non-iOS platforms
func modifyOptionsForIOS(opts *application.Options) {
// No modifications needed for non-iOS platforms
}

View File

@ -0,0 +1,11 @@
//go:build ios
package main
import "github.com/wailsapp/wails/v3/pkg/application"
// modifyOptionsForIOS adjusts the application options for iOS
func modifyOptionsForIOS(opts *application.Options) {
// Disable signal handlers on iOS to prevent crashes
opts.DisableDefaultSignalHandler = true
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- iOS development entitlements. Applied to device builds only
(task ios:package IOS_PLATFORM=device). Add capability keys here
(e.g. aps-environment) as your app needs them. -->
<key>get-task-allow</key>
<true/>
</dict>
</plist>

3
build/ios/icon.png Normal file
View File

@ -0,0 +1,3 @@
# iOS Icon Placeholder
# This file should be replaced with the actual app icon (1024x1024 PNG)
# The build process will generate all required icon sizes from this base icon

22
build/ios/main.m Normal file
View File

@ -0,0 +1,22 @@
//go:build ios
// Minimal bootstrap: delegate comes from Go archive (WailsAppDelegate)
#import <UIKit/UIKit.h>
#include <stdio.h>
int main(int argc, char * argv[]) {
@autoreleasepool {
// Disable buffering so stdout/stderr from Go log.Printf flush immediately
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
// Call UIApplicationMain IMMEDIATELY and start NOTHING else here. Do not
// start the Go runtime yet: starting it concurrently with UIApplicationMain
// intermittently corrupts the FrontBoard launch handshake on a physical
// device, so the app delegate's didFinishLaunchingWithOptions never fires
// (blank cold launch / 0x8BADF00D). Instead, the WailsAppDelegate (provided
// by the Go archive) starts the Go runtime itself from
// didFinishLaunchingWithOptions — i.e. only AFTER UIKit has delivered the
// launch — so the runtime never races the launch handshake.
return UIApplicationMain(argc, argv, nil, @"WailsAppDelegate");
}
}

23
build/ios/main_ios.go Normal file
View File

@ -0,0 +1,23 @@
//go:build ios
package main
import (
"C"
)
// For iOS builds we export a function the WailsAppDelegate can call.
// This wrapper keeps the user's main.go unmodified.
// WailsIOSMain runs the user's main() (application.New / app.Run). It is invoked
// by the WailsAppDelegate from didFinishLaunchingWithOptions — i.e. only AFTER
// UIKit has launched — on a BACKGROUND thread, so the Go runtime never starts
// concurrently with UIApplicationMain (that race intermittently corrupts the
// FrontBoard launch handshake on a physical device → blank cold launch /
// scene-create watchdog 0x8BADF00D). Keeping all app setup off the OS main
// thread also leaves UIApplicationMain unobstructed on the main thread.
//
//export WailsIOSMain
func WailsIOSMain() {
main()
}

230
build/ios/project.pbxproj Normal file
View File

@ -0,0 +1,230 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
C0DEBEEF0000000000000001 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000002 /* main.m */; };
C0DEBEEF00000000000000F1 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000101 /* UIKit.framework */; };
C0DEBEEF00000000000000F2 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000102 /* Foundation.framework */; };
C0DEBEEF00000000000000F3 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000103 /* WebKit.framework */; };
C0DEBEEF00000000000000F4 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000104 /* Security.framework */; };
C0DEBEEF00000000000000F5 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000105 /* CoreFoundation.framework */; };
C0DEBEEF00000000000000F6 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000106 /* libresolv.tbd */; };
C0DEBEEF00000000000000F7 /* iTuoke.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C0DEBEEF0000000000000107 /* iTuoke.a */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
C0DEBEEF0000000000000002 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; };
C0DEBEEF0000000000000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C0DEBEEF0000000000000004 /* iTuoke.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "iTuoke.app"; sourceTree = BUILT_PRODUCTS_DIR; };
C0DEBEEF0000000000000101 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
C0DEBEEF0000000000000102 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; };
C0DEBEEF0000000000000103 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
C0DEBEEF0000000000000104 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
C0DEBEEF0000000000000105 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
C0DEBEEF0000000000000106 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.text-based-dylib-definition; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; };
C0DEBEEF0000000000000107 /* iTuoke.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "iTuoke.a"; path = "../../../bin/iTuoke.a"; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
C0DEBEEF0000000000000010 = {
isa = PBXGroup;
children = (
C0DEBEEF0000000000000020 /* Products */,
C0DEBEEF0000000000000045 /* Frameworks */,
C0DEBEEF0000000000000030 /* main */,
);
sourceTree = "<group>";
};
C0DEBEEF0000000000000020 /* Products */ = {
isa = PBXGroup;
children = (
C0DEBEEF0000000000000004 /* iTuoke.app */,
);
name = Products;
sourceTree = "<group>";
};
C0DEBEEF0000000000000030 /* main */ = {
isa = PBXGroup;
children = (
C0DEBEEF0000000000000002 /* main.m */,
C0DEBEEF0000000000000003 /* Info.plist */,
);
path = main;
sourceTree = SOURCE_ROOT;
};
C0DEBEEF0000000000000045 /* Frameworks */ = {
isa = PBXGroup;
children = (
C0DEBEEF0000000000000101 /* UIKit.framework */,
C0DEBEEF0000000000000102 /* Foundation.framework */,
C0DEBEEF0000000000000103 /* WebKit.framework */,
C0DEBEEF0000000000000104 /* Security.framework */,
C0DEBEEF0000000000000105 /* CoreFoundation.framework */,
C0DEBEEF0000000000000106 /* libresolv.tbd */,
C0DEBEEF0000000000000107 /* iTuoke.a */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
C0DEBEEF0000000000000040 /* iTuoke */ = {
isa = PBXNativeTarget;
buildConfigurationList = C0DEBEEF0000000000000070 /* Build configuration list for PBXNativeTarget "iTuoke" */;
buildPhases = (
C0DEBEEF0000000000000055 /* Prebuild: Wails Go Archive */,
C0DEBEEF0000000000000050 /* Sources */,
C0DEBEEF0000000000000056 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = "iTuoke";
productName = "iTuoke";
productReference = C0DEBEEF0000000000000004 /* iTuoke.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
C0DEBEEF0000000000000060 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1500;
ORGANIZATIONNAME = "iTuoke";
TargetAttributes = {
C0DEBEEF0000000000000040 = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = C0DEBEEF0000000000000080 /* Build configuration list for PBXProject "main" */;
compatibilityVersion = "Xcode 15.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
);
mainGroup = C0DEBEEF0000000000000010;
productRefGroup = C0DEBEEF0000000000000020 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
C0DEBEEF0000000000000040 /* iTuoke */,
);
};
/* End PBXProject section */
/* Begin PBXFrameworksBuildPhase section */
C0DEBEEF0000000000000056 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C0DEBEEF00000000000000F7 /* iTuoke.a in Frameworks */,
C0DEBEEF00000000000000F1 /* UIKit.framework in Frameworks */,
C0DEBEEF00000000000000F2 /* Foundation.framework in Frameworks */,
C0DEBEEF00000000000000F3 /* WebKit.framework in Frameworks */,
C0DEBEEF00000000000000F4 /* Security.framework in Frameworks */,
C0DEBEEF00000000000000F5 /* CoreFoundation.framework in Frameworks */,
C0DEBEEF00000000000000F6 /* libresolv.tbd in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
C0DEBEEF0000000000000055 /* Prebuild: Wails Go Archive */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Prebuild: Wails Go Archive";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\nAPP_ROOT=\"${PROJECT_DIR}/../../..\"\nPLATFORM=${PLATFORM_NAME:-iphonesimulator}\nSDK_PATH=${SDKROOT:-$(xcrun --sdk ${PLATFORM} --show-sdk-path)}\nif [ ${PLATFORM} = iphonesimulator ]; then GO_TARGET=arm64-apple-ios15.0-simulator; MIN_FLAG=-mios-simulator-version-min=15.0; else GO_TARGET=arm64-apple-ios15.0; MIN_FLAG=-miphoneos-version-min=15.0; fi\nexport GOOS=ios\nexport GOARCH=arm64\nexport CGO_ENABLED=1\nexport CGO_CFLAGS=\"-isysroot ${SDK_PATH} -target ${GO_TARGET} ${MIN_FLAG}\"\nexport CGO_LDFLAGS=\"-isysroot ${SDK_PATH} -target ${GO_TARGET}\"\ncd \"${APP_ROOT}\"\n# Ensure overlay exists\nif [ ! -f build/ios/xcode/overlay.json ]; then\n wails3 ios overlay:gen -out build/ios/xcode/overlay.json -config build/config.yml || true\nfi\n# Always rebuild the Go c-archive; Go's build cache keeps this fast\n# and avoids stale binaries when .go sources change.\necho \"Building Go c-archive...\"\ngo build -buildmode=c-archive -overlay build/ios/xcode/overlay.json -o \"bin/iTuoke.a\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
C0DEBEEF0000000000000050 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C0DEBEEF0000000000000001 /* main.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
C0DEBEEF0000000000000090 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
INFOPLIST_FILE = main/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
);
PRODUCT_BUNDLE_IDENTIFIER = "com.ituoke.app";
PRODUCT_NAME = "iTuoke";
CODE_SIGNING_ALLOWED = NO;
SDKROOT = iphoneos;
};
name = Debug;
};
C0DEBEEF00000000000000A0 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
INFOPLIST_FILE = main/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
);
PRODUCT_BUNDLE_IDENTIFIER = "com.ituoke.app";
PRODUCT_NAME = "iTuoke";
CODE_SIGNING_ALLOWED = NO;
SDKROOT = iphoneos;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
C0DEBEEF0000000000000070 /* Build configuration list for PBXNativeTarget "iTuoke" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C0DEBEEF0000000000000090 /* Debug */,
C0DEBEEF00000000000000A0 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
C0DEBEEF0000000000000080 /* Build configuration list for PBXProject "main" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C0DEBEEF0000000000000090 /* Debug */,
C0DEBEEF00000000000000A0 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = C0DEBEEF0000000000000060 /* Project object */;
}

View File

@ -0,0 +1,319 @@
// install_deps.go - iOS development dependency checker
// This script checks for required iOS development tools.
// It's designed to be portable across different shells by using Go instead of shell scripts.
//
// Usage:
// go run install_deps.go # Interactive mode
// TASK_FORCE_YES=true go run install_deps.go # Auto-accept prompts
// CI=true go run install_deps.go # CI mode (auto-accept)
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"strings"
)
type Dependency struct {
Name string
CheckFunc func() (bool, string) // Returns (success, details)
Required bool
InstallCmd []string
InstallMsg string
SuccessMsg string
FailureMsg string
}
func main() {
fmt.Println("Checking iOS development dependencies...")
fmt.Println("=" + strings.Repeat("=", 50))
fmt.Println()
hasErrors := false
dependencies := []Dependency{
{
Name: "Xcode",
CheckFunc: func() (bool, string) {
// Check if xcodebuild exists
if !checkCommand([]string{"xcodebuild", "-version"}) {
return false, ""
}
// Get version info
out, err := exec.Command("xcodebuild", "-version").Output()
if err != nil {
return false, ""
}
lines := strings.Split(string(out), "\n")
if len(lines) > 0 {
return true, strings.TrimSpace(lines[0])
}
return true, ""
},
Required: true,
InstallMsg: "Please install Xcode from the Mac App Store:\n https://apps.apple.com/app/xcode/id497799835\n Xcode is REQUIRED for iOS development (includes iOS SDKs, simulators, and frameworks)",
SuccessMsg: "✅ Xcode found",
FailureMsg: "❌ Xcode not found (REQUIRED)",
},
{
Name: "Xcode Developer Path",
CheckFunc: func() (bool, string) {
// Check if xcode-select points to a valid Xcode path
out, err := exec.Command("xcode-select", "-p").Output()
if err != nil {
return false, "xcode-select not configured"
}
path := strings.TrimSpace(string(out))
// Check if path exists and is in Xcode.app
if _, err := os.Stat(path); err != nil {
return false, "Invalid Xcode path"
}
// Verify it's pointing to Xcode.app (not just Command Line Tools)
if !strings.Contains(path, "Xcode.app") {
return false, fmt.Sprintf("Points to %s (should be Xcode.app)", path)
}
return true, path
},
Required: true,
InstallCmd: []string{"sudo", "xcode-select", "-s", "/Applications/Xcode.app/Contents/Developer"},
InstallMsg: "Xcode developer path needs to be configured",
SuccessMsg: "✅ Xcode developer path configured",
FailureMsg: "❌ Xcode developer path not configured correctly",
},
{
Name: "iOS SDK",
CheckFunc: func() (bool, string) {
// Get the iOS Simulator SDK path
cmd := exec.Command("xcrun", "--sdk", "iphonesimulator", "--show-sdk-path")
output, err := cmd.Output()
if err != nil {
return false, "Cannot find iOS SDK"
}
sdkPath := strings.TrimSpace(string(output))
// Check if the SDK path exists
if _, err := os.Stat(sdkPath); err != nil {
return false, "iOS SDK path not found"
}
// Check for UIKit framework (essential for iOS development)
uikitPath := fmt.Sprintf("%s/System/Library/Frameworks/UIKit.framework", sdkPath)
if _, err := os.Stat(uikitPath); err != nil {
return false, "UIKit.framework not found"
}
// Get SDK version
versionCmd := exec.Command("xcrun", "--sdk", "iphonesimulator", "--show-sdk-version")
versionOut, _ := versionCmd.Output()
version := strings.TrimSpace(string(versionOut))
return true, fmt.Sprintf("iOS %s SDK", version)
},
Required: true,
InstallMsg: "iOS SDK comes with Xcode. Please ensure Xcode is properly installed.",
SuccessMsg: "✅ iOS SDK found with UIKit framework",
FailureMsg: "❌ iOS SDK not found or incomplete",
},
{
Name: "iOS Simulator Runtime",
CheckFunc: func() (bool, string) {
if !checkCommand([]string{"xcrun", "simctl", "help"}) {
return false, ""
}
// Check if we can list runtimes
out, err := exec.Command("xcrun", "simctl", "list", "runtimes").Output()
if err != nil {
return false, "Cannot access simulator"
}
// Count iOS runtimes
lines := strings.Split(string(out), "\n")
count := 0
var versions []string
for _, line := range lines {
if strings.Contains(line, "iOS") && !strings.Contains(line, "unavailable") {
count++
// Extract version number
if parts := strings.Fields(line); len(parts) > 2 {
for _, part := range parts {
if strings.HasPrefix(part, "(") && strings.HasSuffix(part, ")") {
versions = append(versions, strings.Trim(part, "()"))
break
}
}
}
}
}
if count > 0 {
return true, fmt.Sprintf("%d runtime(s): %s", count, strings.Join(versions, ", "))
}
return false, "No iOS runtimes installed"
},
Required: true,
InstallMsg: "iOS Simulator runtimes come with Xcode. You may need to download them:\n Xcode → Settings → Platforms → iOS",
SuccessMsg: "✅ iOS Simulator runtime available",
FailureMsg: "❌ iOS Simulator runtime not available",
},
}
// Check each dependency
for _, dep := range dependencies {
success, details := dep.CheckFunc()
if success {
msg := dep.SuccessMsg
if details != "" {
msg = fmt.Sprintf("%s (%s)", dep.SuccessMsg, details)
}
fmt.Println(msg)
} else {
fmt.Println(dep.FailureMsg)
if details != "" {
fmt.Printf(" Details: %s\n", details)
}
if dep.Required {
hasErrors = true
if len(dep.InstallCmd) > 0 {
fmt.Println()
fmt.Println(" " + dep.InstallMsg)
fmt.Printf(" Fix command: %s\n", strings.Join(dep.InstallCmd, " "))
if promptUser("Do you want to run this command?") {
fmt.Println("Running command...")
cmd := exec.Command(dep.InstallCmd[0], dep.InstallCmd[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
fmt.Printf("Command failed: %v\n", err)
os.Exit(1)
}
fmt.Println("✅ Command completed. Please run this check again.")
} else {
fmt.Printf(" Please run manually: %s\n", strings.Join(dep.InstallCmd, " "))
}
} else {
fmt.Println(" " + dep.InstallMsg)
}
}
}
}
// Check for iPhone simulators
fmt.Println()
fmt.Println("Checking for iPhone simulator devices...")
if !checkCommand([]string{"xcrun", "simctl", "list", "devices"}) {
fmt.Println("❌ Cannot check for iPhone simulators")
hasErrors = true
} else {
out, err := exec.Command("xcrun", "simctl", "list", "devices").Output()
if err != nil {
fmt.Println("❌ Failed to list simulator devices")
hasErrors = true
} else if !strings.Contains(string(out), "iPhone") {
fmt.Println("⚠️ No iPhone simulator devices found")
fmt.Println()
// Get the latest iOS runtime
runtimeOut, err := exec.Command("xcrun", "simctl", "list", "runtimes").Output()
if err != nil {
fmt.Println(" Failed to get iOS runtimes:", err)
} else {
lines := strings.Split(string(runtimeOut), "\n")
var latestRuntime string
for _, line := range lines {
if strings.Contains(line, "iOS") && !strings.Contains(line, "unavailable") {
// Extract runtime identifier
parts := strings.Fields(line)
if len(parts) > 0 {
latestRuntime = parts[len(parts)-1]
}
}
}
if latestRuntime == "" {
fmt.Println(" No iOS runtime found. Please install iOS simulators in Xcode:")
fmt.Println(" Xcode → Settings → Platforms → iOS")
} else {
fmt.Println(" Would you like to create an iPhone 15 Pro simulator?")
createCmd := []string{"xcrun", "simctl", "create", "iPhone 15 Pro", "iPhone 15 Pro", latestRuntime}
fmt.Printf(" Command: %s\n", strings.Join(createCmd, " "))
if promptUser("Create simulator?") {
cmd := exec.Command(createCmd[0], createCmd[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf(" Failed to create simulator: %v\n", err)
} else {
fmt.Println(" ✅ iPhone 15 Pro simulator created")
}
} else {
fmt.Println(" Skipping simulator creation")
fmt.Printf(" Create manually: %s\n", strings.Join(createCmd, " "))
}
}
}
} else {
// Count iPhone devices
count := 0
lines := strings.Split(string(out), "\n")
for _, line := range lines {
if strings.Contains(line, "iPhone") && !strings.Contains(line, "unavailable") {
count++
}
}
fmt.Printf("✅ %d iPhone simulator device(s) available\n", count)
}
}
// Final summary
fmt.Println()
fmt.Println("=" + strings.Repeat("=", 50))
if hasErrors {
fmt.Println("❌ Some required dependencies are missing or misconfigured.")
fmt.Println()
fmt.Println("Quick setup guide:")
fmt.Println("1. Install Xcode from Mac App Store (if not installed)")
fmt.Println("2. Open Xcode once and agree to the license")
fmt.Println("3. Install additional components when prompted")
fmt.Println("4. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer")
fmt.Println("5. Download iOS simulators: Xcode → Settings → Platforms → iOS")
fmt.Println("6. Run this check again")
os.Exit(1)
} else {
fmt.Println("✅ All required dependencies are installed!")
fmt.Println(" You're ready for iOS development with Wails!")
}
}
func checkCommand(args []string) bool {
if len(args) == 0 {
return false
}
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdout = nil
cmd.Stderr = nil
err := cmd.Run()
return err == nil
}
func promptUser(question string) bool {
// Check if we're in a non-interactive environment
if os.Getenv("CI") != "" || os.Getenv("TASK_FORCE_YES") == "true" {
fmt.Printf("%s [y/N]: y (auto-accepted)\n", question)
return true
}
reader := bufio.NewReader(os.Stdin)
fmt.Printf("%s [y/N]: ", question)
response, err := reader.ReadString('\n')
if err != nil {
return false
}
response = strings.ToLower(strings.TrimSpace(response))
return response == "y" || response == "yes"
}

220
build/linux/Taskfile.yml Normal file
View File

@ -0,0 +1,220 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
# Signing configuration - edit these values for your project
# PGP_KEY: "path/to/signing-key.asc"
# SIGN_ROLE: "builder" # Options: origin, maint, archive, builder
#
# Password is stored securely in system keychain. Run: wails3 setup signing
# Docker image for cross-compilation (used when building on non-Linux or no CC available)
CROSS_IMAGE: wails-cross
tasks:
build:
summary: Builds the application for Linux
cmds:
# Linux requires CGO - use Docker when:
# 1. Cross-compiling from non-Linux, OR
# 2. No C compiler is available, OR
# 3. Target architecture differs from host architecture (cross-arch compilation)
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
vars:
ARCH: '{{.ARCH}}'
DEV: '{{.DEV}}'
OUTPUT: '{{.OUTPUT}}'
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
OBFUSCATED: '{{.OBFUSCATED}}'
GARBLE_ARGS: '{{.GARBLE_ARGS}}'
vars:
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
# Determine target architecture (defaults to host ARCH if not specified)
TARGET_ARCH: '{{.ARCH | default ARCH}}'
# Check if a C compiler is available (gcc or clang) — cross-platform via wails3 tool
HAS_CC:
sh: 'wails3 tool has "gcc|clang"'
build:native:
summary: Builds the application natively on Linux
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
OBFUSCATED:
ref: .OBFUSCATED
DEV:
ref: .DEV
- task: common:generate:icons
- task: generate:dotdesktop
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 {{.OUTPUT}}'
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: linux
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
build:docker:
summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
internal: true
deps:
- task: common:build:frontend
vars:
OBFUSCATED:
ref: .OBFUSCATED
- task: common:generate:icons
- task: generate:dotdesktop
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required for cross-compilation to Linux. Please install Docker."
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
msg: |
Docker image '{{.CROSS_IMAGE}}' not found.
Build it first: wails3 task setup:docker
cmds:
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}}
- cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
platforms: [linux, darwin]
- mkdir -p {{.BIN_DIR}}
- mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
vars:
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
# Generate Docker volume mounts: Go module cache + go.mod replace directives
# Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
DOCKER_MOUNTS:
sh: 'wails3 tool docker-mounts'
package:
summary: Packages the application for Linux
deps:
- task: build
cmds:
- task: create:appimage
- task: create:deb
- task: create:rpm
- task: create:aur
create:appimage:
summary: Creates an AppImage
dir: build/linux/appimage
deps:
- task: build
- task: generate:dotdesktop
cmds:
- cp "{{.APP_BINARY}}" "{{.APP_NAME}}"
- cp ../../appicon.png "{{.APP_NAME}}.png"
- wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build
vars:
APP_NAME: '{{.APP_NAME}}'
APP_BINARY: '../../../bin/{{.APP_NAME}}'
ICON: '{{.APP_NAME}}.png'
DESKTOP_FILE: '../{{.APP_NAME}}.desktop'
OUTPUT_DIR: '../../../bin'
create:deb:
summary: Creates a deb package
deps:
- task: build
cmds:
- task: generate:dotdesktop
- task: generate:deb
create:rpm:
summary: Creates a rpm package
deps:
- task: build
cmds:
- task: generate:dotdesktop
- task: generate:rpm
create:aur:
summary: Creates a arch linux packager package
deps:
- task: build
cmds:
- task: generate:dotdesktop
- task: generate:aur
generate:deb:
summary: Creates a deb package
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:rpm:
summary: Creates a rpm package
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:aur:
summary: Creates a arch linux packager package
cmds:
- wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:dotdesktop:
summary: Generates a `.desktop` file
dir: build
cmds:
- mkdir -p {{.ROOT_DIR}}/build/linux/appimage
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}"
vars:
APP_NAME: '{{.APP_NAME}}'
EXEC: '{{.APP_NAME}}'
ICON: '{{.APP_NAME}}'
CATEGORIES: 'Development;'
OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop'
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'
sign:deb:
summary: Signs the DEB package
desc: |
Signs the .deb package with a PGP key.
Set PGP_KEY in the vars section to override the key configured globally via
`wails3 setup` (~/.config/wails/defaults.yaml).
Password is retrieved from system keychain (run: wails3 setup signing)
deps:
- task: create:deb
cmds:
# PGP_KEY is optional: if unset, `wails3 tool sign` falls back to the key
# configured globally via `wails3 setup`.
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" {{if .PGP_KEY}}--pgp-key "{{.PGP_KEY}}"{{end}} {{if .SIGN_ROLE}}--role "{{.SIGN_ROLE}}"{{end}}
sign:rpm:
summary: Signs the RPM package
desc: |
Signs the .rpm package with a PGP key.
Set PGP_KEY in the vars section to override the key configured globally via
`wails3 setup` (~/.config/wails/defaults.yaml).
Password is retrieved from system keychain (run: wails3 setup signing)
deps:
- task: create:rpm
cmds:
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" {{if .PGP_KEY}}--pgp-key "{{.PGP_KEY}}"{{end}}
sign:packages:
summary: Signs all Linux packages (DEB and RPM)
desc: |
Signs both .deb and .rpm packages with a PGP key.
Set PGP_KEY in the vars section to override the key configured globally via
`wails3 setup` (~/.config/wails/defaults.yaml).
Password is retrieved from system keychain (run: wails3 setup signing)
cmds:
- task: sign:deb
- task: sign:rpm

View File

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Copyright (c) 2018-Present Lea Anthony
# SPDX-License-Identifier: MIT
# Fail script on any error
set -euxo pipefail
# Define variables
APP_DIR="${APP_NAME}.AppDir"
# Create AppDir structure
mkdir -p "${APP_DIR}/usr/bin"
cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/"
cp "${ICON_PATH}" "${APP_DIR}/"
cp "${DESKTOP_FILE}" "${APP_DIR}/"
if [[ $(uname -m) == *x86_64* ]]; then
# Download linuxdeploy and make it executable
wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
chmod +x linuxdeploy-x86_64.AppImage
# Run linuxdeploy to bundle the application
./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage
else
# Download linuxdeploy and make it executable (arm64)
wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage
chmod +x linuxdeploy-aarch64.AppImage
# Run linuxdeploy to bundle the application (arm64)
./linuxdeploy-aarch64.AppImage --appdir "${APP_DIR}" --output appimage
fi
# Rename the generated AppImage
mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage"

11
build/linux/desktop Normal file
View File

@ -0,0 +1,11 @@
[Desktop Entry]
Version=1.0
Name=iTuoke
Comment=Enterprise phone control desktop - ADB capture and AI agent
# The Exec line includes %u to pass the URL to the application
Exec=/usr/local/bin/ituoke %u
Terminal=false
Type=Application
Icon=ituoke
Categories=Utility;
StartupWMClass=ituoke

View File

@ -0,0 +1,80 @@
# Feel free to remove those if you don't want/need to use them.
# Make sure to check the documentation at https://nfpm.goreleaser.com
#
# The lines below are called `modelines`. See `:help modeline`
name: "ituoke"
arch: ${GOARCH}
platform: "linux"
version: "0.1.0"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
description: "Enterprise phone control desktop - ADB capture and AI agent"
vendor: "iTuoke"
homepage: "https://wails.io"
license: "MIT"
release: "1"
contents:
- src: "./bin/ituoke"
dst: "/usr/local/bin/ituoke"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/ituoke.png"
- src: "./build/linux/ituoke.desktop"
dst: "/usr/share/applications/ituoke.desktop"
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
depends:
- libgtk-4-1
- libwebkitgtk-6.0-4
# Distribution-specific overrides for different package formats
overrides:
# RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux
rpm:
depends:
- gtk4
- webkitgtk6.0
# Arch Linux packages
archlinux:
depends:
- gtk4
- webkitgtk-6.0
# scripts section to ensure desktop database is updated after install
scripts:
postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
# You can also add preremove, postremove if needed
# preremove: "./build/linux/nfpm/scripts/preremove.sh"
# postremove: "./build/linux/nfpm/scripts/postremove.sh"
# If you build your app with -tags gtk3 (legacy WebKit2GTK 4.1 stack — supported through v3.0.x, removed in v3.1),
# replace the depends/overrides above with these:
#
# depends:
# - libgtk-3-0
# - libwebkit2gtk-4.1-0
# overrides:
# rpm:
# depends:
# - gtk3
# - webkit2gtk4.1
# archlinux:
# depends:
# - gtk3
# - webkit2gtk-4.1
#
# replaces:
# - foobar
# provides:
# - bar
# recommends:
# - whatever
# suggests:
# - something-else
# conflicts:
# - not-foo
# - not-bar
# changelog: "changelog.yaml"

View File

@ -0,0 +1,21 @@
#!/bin/sh
# Update desktop database for .desktop file changes
# This makes the application appear in application menus and registers its capabilities.
if command -v update-desktop-database >/dev/null 2>&1; then
echo "Updating desktop database..."
update-desktop-database -q /usr/share/applications
else
echo "Warning: update-desktop-database command not found. Desktop file may not be immediately recognized." >&2
fi
# Update MIME database for custom URL schemes (x-scheme-handler)
# This ensures the system knows how to handle your custom protocols.
if command -v update-mime-database >/dev/null 2>&1; then
echo "Updating MIME database..."
update-mime-database -n /usr/share/mime
else
echo "Warning: update-mime-database command not found. Custom URL schemes may not be immediately recognized." >&2
fi
exit 0

View File

@ -0,0 +1 @@
#!/bin/bash

View File

@ -0,0 +1 @@
#!/bin/bash

View File

@ -0,0 +1 @@
#!/bin/bash

195
build/windows/Taskfile.yml Normal file
View File

@ -0,0 +1,195 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
# Signing configuration - edit these values for your project
# SIGN_CERTIFICATE: "path/to/certificate.pfx"
# SIGN_THUMBPRINT: "certificate-thumbprint" # Alternative to SIGN_CERTIFICATE
# TIMESTAMP_SERVER: "http://timestamp.digicert.com"
#
# Password is stored securely in system keychain. Run: wails3 setup signing
# Docker image for cross-compilation with CGO (used when CGO_ENABLED=1 on non-Windows)
CROSS_IMAGE: wails-cross
tasks:
build:
summary: Builds the application for Windows
cmds:
# Auto-detect CGO: if CGO_ENABLED=1, use Docker; otherwise use native Go cross-compile
- task: '{{if and (ne OS "windows") (eq .CGO_ENABLED "1")}}build:docker{{else}}build:native{{end}}'
vars:
ARCH: '{{.ARCH}}'
DEV: '{{.DEV}}'
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
OBFUSCATED: '{{.OBFUSCATED}}'
GARBLE_ARGS: '{{.GARBLE_ARGS}}'
vars:
# Default to CGO_ENABLED=0 if not explicitly set
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
build:native:
summary: Builds the application using native Go cross-compilation
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
OBFUSCATED:
ref: .OBFUSCATED
DEV:
ref: .DEV
- task: common:generate:icons
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:
- task: generate:syso
vars:
ARCH: '{{.ARCH}}'
- '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}.exe"'
- cmd: powershell Remove-item *.syso
platforms: [windows]
- cmd: rm -f *.syso
platforms: [linux, darwin]
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{end}}'
env:
GOOS: windows
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
GOARCH: '{{.ARCH | default ARCH}}'
build:docker:
summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows)
internal: true
deps:
- task: common:build:frontend
vars:
OBFUSCATED:
ref: .OBFUSCATED
- task: common:generate:icons
preconditions:
- sh: docker info > /dev/null 2>&1
msg: "Docker is required for CGO cross-compilation. Please install Docker."
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
msg: |
Docker image '{{.CROSS_IMAGE}}' not found.
Build it first: wails3 task setup:docker
cmds:
- task: generate:syso
vars:
ARCH: '{{.ARCH}}'
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.DOCKER_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{if eq .OBFUSCATED "true"}}-e OBFUSCATED=true{{end}} {{if .GARBLE_ARGS}}-e GARBLE_ARGS="{{.GARBLE_ARGS}}"{{end}} {{.CROSS_IMAGE}} windows {{.DOCKER_ARCH}}
- cmd: docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
platforms: [linux, darwin]
- rm -f *.syso
vars:
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
# Generate Docker volume mounts: Go module cache + go.mod replace directives
# Uses wails3 tool docker-mounts for cross-platform compatibility (Windows/Linux/macOS)
DOCKER_MOUNTS:
sh: 'wails3 tool docker-mounts'
package:
summary: Packages the application
cmds:
- task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}'
vars:
INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
vars:
FORMAT: '{{.FORMAT | default "nsis"}}'
INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
generate:syso:
summary: Generates Windows `.syso` file
dir: build
cmds:
- wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
vars:
ARCH: '{{.ARCH | default ARCH}}'
create:nsis:installer:
summary: Creates an NSIS installer
dir: build/windows/nsis
deps:
- task: build
preconditions:
- sh: '[ "{{.INSTALL_SCOPE}}" = "user" ] || [ "{{.INSTALL_SCOPE}}" = "machine" ]'
msg: "INSTALL_SCOPE must be 'user' or 'machine', got '{{.INSTALL_SCOPE}}'"
cmds:
# Create the Microsoft WebView2 bootstrapper if it doesn't exist
- wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis"
- |
{{if eq OS "windows"}}
makensis {{if eq .INSTALL_SCOPE "user"}}-DWAILS_INSTALL_SCOPE=user -DREQUEST_EXECUTION_LEVEL=user {{end}}-DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
{{else}}
makensis {{if eq .INSTALL_SCOPE "user"}}-DWAILS_INSTALL_SCOPE=user -DREQUEST_EXECUTION_LEVEL=user {{end}}-DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi
{{end}}
vars:
ARCH: '{{.ARCH | default ARCH}}'
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
create:msix:package:
summary: Creates an MSIX package
deps:
- task: build
cmds:
- |-
wails3 tool msix \
--config "{{.ROOT_DIR}}/build/config.yml" \
--name "{{.APP_NAME}}.exe" \
--executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \
--arch "{{.ARCH}}" \
--out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \
{{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \
{{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \
{{if eq .USE_MSIX_TOOL "true"}}--use-msix-tool{{else}}--use-makeappx{{end}}
vars:
ARCH: '{{.ARCH | default ARCH}}'
CERT_PATH: '{{.CERT_PATH | default ""}}'
PUBLISHER: '{{.PUBLISHER | default ""}}'
USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
install:msix:tools:
summary: Installs tools required for MSIX packaging
cmds:
- wails3 tool msix-install-tools
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}.exe'
sign:
summary: Signs the Windows executable
desc: |
Signs the .exe with an Authenticode certificate.
Set SIGN_CERTIFICATE or SIGN_THUMBPRINT to override the certificate
configured globally via `wails3 setup` (~/.config/wails/defaults.yaml).
Password is retrieved from system keychain (run: wails3 setup signing)
deps:
- task: build
cmds:
# Certificate vars are optional: if unset, `wails3 tool sign` falls back to
# the certificate configured globally via `wails3 setup`.
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.exe" {{if .SIGN_CERTIFICATE}}--certificate "{{.SIGN_CERTIFICATE}}"{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint "{{.SIGN_THUMBPRINT}}"{{end}} {{if .TIMESTAMP_SERVER}}--timestamp "{{.TIMESTAMP_SERVER}}"{{end}}
sign:installer:
summary: Signs the NSIS installer
desc: |
Creates and signs the NSIS installer.
Set SIGN_CERTIFICATE or SIGN_THUMBPRINT to override the certificate
configured globally via `wails3 setup` (~/.config/wails/defaults.yaml).
Password is retrieved from system keychain (run: wails3 setup signing)
deps:
- task: create:nsis:installer
vars:
INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
vars:
INSTALL_SCOPE: '{{.INSTALL_SCOPE | default "machine"}}'
cmds:
- wails3 tool sign --input "build/windows/nsis/{{.APP_NAME}}-installer.exe" {{if .SIGN_CERTIFICATE}}--certificate "{{.SIGN_CERTIFICATE}}"{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint "{{.SIGN_THUMBPRINT}}"{{end}} {{if .TIMESTAMP_SERVER}}--timestamp "{{.TIMESTAMP_SERVER}}"{{end}}

BIN
build/windows/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

15
build/windows/info.json Normal file
View File

@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "0.1.0"
},
"info": {
"0409": {
"ProductVersion": "0.1.0",
"CompanyName": "iTuoke",
"FileDescription": "Enterprise phone control desktop - ADB capture and AI agent",
"LegalCopyright": "© 2026 iTuoke",
"ProductName": "iTuoke",
"Comments": "ADB + Agent desktop shell"
}
}
}

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
IgnorableNamespaces="uap3">
<Identity
Name="com.ituoke.app"
Publisher="CN=iTuoke"
Version="0.1.0.0"
ProcessorArchitecture="arm64" />
<Properties>
<DisplayName>iTuoke</DisplayName>
<PublisherDisplayName>iTuoke</PublisherDisplayName>
<Description>Enterprise phone control desktop - ADB capture and AI agent</Description>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="en-us" />
</Resources>
<Applications>
<Application Id="com.ituoke.app" Executable="ituoke" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="iTuoke"
Description="Enterprise phone control desktop - ADB capture and AI agent"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
<Extensions>
<desktop:Extension Category="windows.fullTrustProcess" Executable="ituoke" />
</Extensions>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<MsixPackagingToolTemplate
xmlns="http://schemas.microsoft.com/msix/packaging/msixpackagingtool/template/2022">
<Settings
AllowTelemetry="false"
ApplyACLsToPackageFiles="true"
GenerateCommandLineFile="true"
AllowPromptForPassword="false">
</Settings>
<Installer
Path="ituoke"
Arguments=""
InstallLocation="C:\Program Files\iTuoke\iTuoke">
</Installer>
<PackageInformation
PackageName="iTuoke"
PackageDisplayName="iTuoke"
PublisherName="CN=iTuoke"
PublisherDisplayName="iTuoke"
Version="0.1.0.0"
PackageDescription="Enterprise phone control desktop - ADB capture and AI agent">
<Capabilities>
<Capability Name="runFullTrust" />
</Capabilities>
<Applications>
<Application
Id="com.ituoke.app"
Description="Enterprise phone control desktop - ADB capture and AI agent"
DisplayName="iTuoke"
ExecutableName="ituoke"
EntryPoint="Windows.FullTrustApplication">
</Application>
</Applications>
<Resources>
<Resource Language="en-us" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Properties>
<Framework>false</Framework>
<DisplayName>iTuoke</DisplayName>
<PublisherDisplayName>iTuoke</PublisherDisplayName>
<Description>Enterprise phone control desktop - ADB capture and AI agent</Description>
<Logo>Assets\AppIcon.png</Logo>
</Properties>
</PackageInformation>
<SaveLocation PackagePath="ituoke.msix" />
<PackageIntegrity>
<CertificatePath></CertificatePath>
</PackageIntegrity>
</MsixPackagingToolTemplate>

View File

@ -0,0 +1,119 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "my-project" # Default "ituoke"
## !define INFO_COMPANYNAME "My Company" # Default "iTuoke"
## !define INFO_PRODUCTNAME "My Product Name" # Default "iTuoke"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0"
## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© 2026 iTuoke"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
## !define WAILS_INSTALL_SCOPE "user" # Default "machine" - set to "user" for per-user install ($LOCALAPPDATA) without UAC prompt
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
!if "${WAILS_INSTALL_SCOPE}" == "user"
InstallDir "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
!else
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}"
!endif
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd

View File

@ -0,0 +1,261 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "ituoke"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "iTuoke"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "iTuoke"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.1.0"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2026 iTuoke"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef WAILS_INSTALL_SCOPE
!define WAILS_INSTALL_SCOPE "machine"
!endif
!ifndef REQUEST_EXECUTION_LEVEL
!if "${WAILS_INSTALL_SCOPE}" == "user"
!define REQUEST_EXECUTION_LEVEL "user"
!else
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
!if "${WAILS_INSTALL_SCOPE}" == "user"
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
!else
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!endif
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
!if "${WAILS_INSTALL_SCOPE}" == "user"
DeleteRegKey HKCU "${UNINST_KEY}"
!else
DeleteRegKey HKLM "${UNINST_KEY}"
!endif
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
!macroend
!macro wails.unassociateFiles
; Delete app associations
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
!macroend

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.ituoke.app" version="0.1.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>

5
frontend/.npmrc Normal file
View File

@ -0,0 +1,5 @@
# Refuse to install package versions published less than 7 days (10080 minutes)
# ago, reducing exposure to freshly compromised or malicious releases.
# Honoured by pnpm and bun; npm currently has no native support and ignores it,
# so this is harmless today and forward-compatible.
minimum-release-age=10080

14
frontend/index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/appicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>iTuoke</title>
</head>
<body>
<!-- [挂载点] Vue 根节点;勿删 -->
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1227
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build:dev": "vue-tsc && vite build --minify false --mode development",
"build": "vue-tsc && vite build --mode production",
"preview": "vite preview"
},
"dependencies": {
"@wailsio/runtime": "3.0.0",
"vue": "^3.2.45"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "~5.9.3",
"vite": "^8.2.4",
"vue-tsc": "^3.3.12"
}
}

BIN
frontend/public/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
frontend/public/wails.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

490
frontend/src/App.vue Normal file
View File

@ -0,0 +1,490 @@
<script setup lang="ts">
/**
* [UI · 根组件]
* 顶栏菜单 + 可拖拽左右分栏;连接配置在「设置」并持久化
*/
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { TouchQueue, type TouchEvent } from './lib/touch'
import DeviceBar from './components/device/DeviceBar.vue'
import CapturePane from './components/screen/CapturePane.vue'
import ChatPane from './components/chat/ChatPane.vue'
import LogPane from './components/logs/LogPane.vue'
import SettingsPane from './components/settings/SettingsPane.vue'
import type {
ActionResult,
AdbInfo,
ChatMessage,
DeviceInfo,
MainTab,
} from './types'
import { loadPrefs, savePrefs, type AppPrefs } from './lib/prefs'
import { Service as DeviceService } from '../bindings/github.com/ituoke/ituoke/internal/device'
import { Service as AgentService } from '../bindings/github.com/ituoke/ituoke/internal/agent'
const prefs = ref<AppPrefs>(loadPrefs())
const systemAppearance = window.matchMedia('(prefers-color-scheme: dark)')
function applyTheme() {
document.documentElement.dataset.theme = prefs.value.theme === 'system'
? (systemAppearance.matches ? 'dark' : 'light')
: prefs.value.theme
}
watch(() => prefs.value.theme, applyTheme, { immediate: true })
systemAppearance.addEventListener('change', applyTheme)
const tab = ref<MainTab>(prefs.value.lastTab || 'workspace')
const splitRatio = ref(prefs.value.splitRatio)
const devices = ref<DeviceInfo[]>([])
const activeId = ref('')
const previewStarting = ref(false)
const connectBusy = ref(false)
const connectionNotice = ref('')
const connectionRequest = ref(0)
const live = ref(false)
const streamUrl = ref('')
const controlError = ref('')
const frameError = ref('')
const adbInfo = ref<AdbInfo | null>(null)
const messages = ref<ChatMessage[]>([
{
id: 'welcome',
role: 'system',
content: '在「设置」里完成配对与连接。连接参数会自动记住。工作台可拖拽中间分隔条调整左右宽度。',
createdAt: Date.now(),
},
])
const sending = ref(false)
const logs = ref<string[]>([`[${ts()}] app boot`])
const protocolVersion = ref('')
const agentStatus = ref<Record<string, unknown> | null>(null)
const workspaceRef = ref<HTMLElement | null>(null)
let dragging = false
let liveTimer: ReturnType<typeof setTimeout> | null = null
function ts() {
return new Date().toLocaleTimeString()
}
function log(line: string) {
logs.value = [...logs.value.slice(-400), `[${ts()}] ${line}`]
}
const activeDevice = computed(
() => devices.value.find((d) => d.id === activeId.value) ?? null,
)
const adbHint = computed(() => {
const a = adbInfo.value
if (!a) return '检测 adb…'
if (!a.ok) return a.error || 'adb 不可用'
return `${a.version || 'adb ok'}`
})
const splitStyle = computed(() => ({
gridTemplateColumns: `${splitRatio.value}fr 6px ${1 - splitRatio.value}fr`,
}))
function setTab(t: MainTab) {
tab.value = t
prefs.value = savePrefs({ lastTab: t })
}
function onPrefsChange(p: AppPrefs) {
prefs.value = p
}
function onSplitPointerDown(e: PointerEvent) {
dragging = true
;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
window.addEventListener('pointermove', onSplitPointerMove)
window.addEventListener('pointerup', onSplitPointerUp)
}
function onSplitPointerMove(e: PointerEvent) {
if (!dragging || !workspaceRef.value) return
const rect = workspaceRef.value.getBoundingClientRect()
const x = e.clientX - rect.left
const ratio = Math.min(0.75, Math.max(0.25, x / rect.width))
splitRatio.value = ratio
}
function onSplitPointerUp() {
if (!dragging) return
dragging = false
window.removeEventListener('pointermove', onSplitPointerMove)
window.removeEventListener('pointerup', onSplitPointerUp)
prefs.value = savePrefs({ splitRatio: splitRatio.value })
}
async function refreshAdb() {
try {
adbInfo.value = (await DeviceService.GetAdbInfo()) as AdbInfo
} catch (e) {
adbInfo.value = { path: '', version: '', ok: false, error: String(e) }
}
}
async function refreshDevices() {
try {
const list = (await DeviceService.ListDevices()) as DeviceInfo[]
devices.value = list ?? []
if (activeId.value && !devices.value.some((d) => d.id === activeId.value)) {
activeId.value = ''
}
if (!activeId.value) {
const initial = devices.value.find((d) => d.online) ?? devices.value[0]
if (initial) activeId.value = initial.id
}
if (live.value && !activeDevice.value?.online) await stopStream()
log(`devices: ${devices.value.length}`)
} catch (e) {
log(`ListDevices failed: ${String(e)}`)
}
}
async function selectDevice(id: string) {
if (!id) return
try {
await DeviceService.SelectDevice(id)
activeId.value = id
frameError.value = ''
log(`select device ${id}`)
if (live.value) {
if (activeDevice.value?.online) void startStream()
else await stopStream()
}
} catch (e) {
log(`SelectDevice failed: ${String(e)}`)
}
}
const touchQueue = new TouchQueue((event) => DeviceService.Touch(event), (error) => {
controlError.value = `触摸控制失败:${String(error)}`
log(`input touch: ${String(error)}`)
})
function onPhoneTouch(event: TouchEvent) {
if (event.action !== 3 && (!live.value || activeId.value !== event.deviceId)) return
if (event.action === 0) controlError.value = ''
touchQueue.push(event)
}
watch([activeId, live], () => { controlError.value = '' })
async function startStream() {
if (!activeDevice.value?.online) {
frameError.value = '请先连接在线设备'
return
}
previewStarting.value = true
frameError.value = ''
try {
const res = (await DeviceService.StartPreviewStream(activeId.value)) as ActionResult
if (!res.ok) {
live.value = false
streamUrl.value = ''
frameError.value = res.message
log(`stream failed: ${res.message}`)
return
}
streamUrl.value = `${res.message}?t=${Date.now()}`
live.value = true
log(`stream on: ${res.message}`)
} catch (e) {
live.value = false
streamUrl.value = ''
frameError.value = String(e)
log(`stream error: ${String(e)}`)
} finally {
previewStarting.value = false
}
}
async function stopStream() {
if (liveTimer) {
clearTimeout(liveTimer)
liveTimer = null
}
live.value = false
streamUrl.value = ''
try {
await DeviceService.StopPreviewStream()
log('stream off')
} catch (e) {
log(`stream stop failed: ${String(e)}`)
}
}
async function toggleLive() {
if (live.value) await stopStream()
else await startStream()
}
async function onRequestLocalNetwork() {
try {
const res = (await DeviceService.RequestLocalNetworkAccess()) as ActionResult
log(`local-network: ${res.message}`)
} catch (e) {
log(`local-network failed: ${String(e)}`)
}
}
async function onOpenLocalNetworkSettings() {
try {
await DeviceService.OpenLocalNetworkSettings()
} catch (e) {
log(`open settings failed: ${String(e)}`)
}
}
async function onPair(hostPort: string, code: string) {
connectBusy.value = true
try {
const res = (await DeviceService.PairWireless({ hostPort, code })) as ActionResult
log(`pair: ${res.message}`)
messages.value.push({
id: `sys-${Date.now()}`,
role: 'system',
content: res.ok ? `配对成功:${res.message}` : `配对失败:${res.message}`,
createdAt: Date.now(),
})
} catch (e) {
log(`pair failed: ${String(e)}`)
} finally {
connectBusy.value = false
}
}
function openConnectionSettings(message: string) {
connectionNotice.value = message
connectionRequest.value++
setTab('settings')
}
async function onConnect(hostPort: string) {
if (connectBusy.value) return
hostPort = hostPort.trim()
if (!hostPort) {
openConnectionSettings('请先填写手机无线调试主界面中的连接 IP 和端口。首次连接请先完成配对。')
return
}
connectionNotice.value = ''
connectBusy.value = true
try {
const res = (await DeviceService.ConnectWireless({ hostPort })) as ActionResult
log(`connect: ${res.message}`)
messages.value.push({
id: `sys-${Date.now()}`,
role: 'system',
content: res.ok ? `连接结果:${res.message}` : `连接失败:${res.message}`,
createdAt: Date.now(),
})
await refreshDevices()
if (!res.ok) {
openConnectionSettings(`连接未成功,请检查手机无线调试是否开启,并更新下方连接 IP 和端口(端口可能已变化)。${res.message}`)
}
if (res.ok) {
const connected = await DeviceService.GetActiveDevice() as DeviceInfo
if (connected.online) {
await selectDevice(connected.id)
setTab('workspace')
await startStream()
}
}
} catch (e) {
log(`connect failed: ${String(e)}`)
openConnectionSettings(`连接失败,请检查配对和连接地址后重试。${String(e)}`)
} finally {
connectBusy.value = false
}
}
async function onDeviceConnect(id: string) {
const device = devices.value.find((d) => d.id === id)
if (!device) {
openConnectionSettings('尚未选择可连接的设备。请先在手机上开启无线调试,首次连接需完成配对;已配对的手机请核对下方连接 IP 和端口后,点击「连接并看画面」。')
return
}
if (device.mode !== 'lan') {
openConnectionSettings('请检查 USB 连接,并在手机上允许 USB 调试后点击右上角「刷新」。也可以在下方配置无线连接。')
return
}
await onConnect(device.id)
}
async function onDisconnect(id: string) {
if (connectBusy.value) return
connectBusy.value = true
try {
await stopStream()
const res = (await DeviceService.DisconnectDevice(id)) as ActionResult
log(`disconnect: ${res.message}`)
await refreshDevices()
} catch (e) {
log(`disconnect failed: ${String(e)}`)
} finally {
connectBusy.value = false
}
}
async function sendChat(text: string) {
messages.value.push({
id: `u-${Date.now()}`,
role: 'user',
content: text,
createdAt: Date.now(),
})
sending.value = true
try {
const res = (await AgentService.Chat(activeId.value || 'default', text)) as {
content?: string
}
messages.value.push({
id: `a-${Date.now()}`,
role: 'assistant',
content: res?.content ?? JSON.stringify(res),
createdAt: Date.now(),
})
} catch (e) {
messages.value.push({
id: `e-${Date.now()}`,
role: 'system',
content: `发送失败:${String(e)}`,
createdAt: Date.now(),
})
} finally {
sending.value = false
}
}
async function refreshAgentMeta() {
try {
protocolVersion.value = await AgentService.ProtocolVersion()
agentStatus.value = (await AgentService.Status()) as Record<string, unknown>
} catch (e) {
log(`agent meta failed: ${String(e)}`)
}
}
onMounted(async () => {
await refreshAdb()
await refreshDevices()
await refreshAgentMeta()
// 若已保存连接地址且当前无设备,可提示去设置;不自动连以免打扰
if (prefs.value.connectHost && !devices.value.length) {
messages.value.push({
id: `tip-${Date.now()}`,
role: 'system',
content: `已记住连接地址 ${prefs.value.connectHost},可在「设置」一键连接。`,
createdAt: Date.now(),
})
}
})
onUnmounted(() => {
systemAppearance.removeEventListener('change', applyTheme)
void stopStream()
window.removeEventListener('pointermove', onSplitPointerMove)
window.removeEventListener('pointerup', onSplitPointerUp)
})
</script>
<template>
<div class="app">
<DeviceBar
:devices="devices"
:active-id="activeId"
:tab="tab"
:busy="connectBusy"
@select="selectDevice"
@refresh="refreshDevices"
@disconnect="onDisconnect"
@connect="onDeviceConnect"
@update:tab="setTab"
/>
<main v-if="tab === 'workspace'" ref="workspaceRef" class="workspace" :style="splitStyle">
<CapturePane
:zoom="prefs.previewZoom"
@update:zoom="prefs = savePrefs({ previewZoom: $event })"
:device="activeDevice"
:stream-url="streamUrl"
:loading="previewStarting"
:live="live"
:error="frameError"
:control-error="controlError"
@touch="onPhoneTouch"
@toggle-live="toggleLive"
/>
<div
class="splitter"
title="拖动调整左右宽度"
@pointerdown.prevent="onSplitPointerDown"
/>
<ChatPane :messages="messages" :sending="sending" @send="sendChat" />
</main>
<main v-else-if="tab === 'settings'" class="page">
<SettingsPane
:protocol-version="protocolVersion"
:agent-status="agentStatus"
:adb-ok="adbInfo?.ok"
:adb-hint="adbHint"
:busy="connectBusy"
:connection-notice="connectionNotice"
:connection-request="connectionRequest"
:prefs="prefs"
@prefs-change="onPrefsChange"
@start-agent="AgentService.Start().then(refreshAgentMeta)"
@stop-agent="AgentService.Stop().then(refreshAgentMeta)"
@pair="onPair"
@connect="onConnect"
@request-local-network="onRequestLocalNetwork"
@open-local-network-settings="onOpenLocalNetworkSettings"
/>
</main>
<main v-else class="page">
<LogPane :lines="logs" @clear="logs = []" />
</main>
</div>
</template>
<style scoped>
.app {
display: flex;
flex-direction: column;
height: 100%;
}
.workspace {
flex: 1;
min-height: 0;
display: grid;
/* Keep the row inside the window even when the chat history is long. */
grid-template-rows: minmax(0, 1fr);
overflow: hidden;
}
.splitter {
cursor: col-resize;
background: var(--border);
position: relative;
z-index: 2;
}
.splitter::after {
content: '';
position: absolute;
inset: 0 -3px;
}
.splitter:hover,
.splitter:active {
background: var(--accent);
}
.page {
flex: 1;
min-height: 0;
}
</style>

View File

@ -0,0 +1,203 @@
<script setup lang="ts">
/**
* [UI · 右栏] 对话窗口
* 发送消息走 AgentService.Chat(Go stub → 后续 Python/云端)
*/
import { nextTick, ref, watch } from 'vue'
import type { ChatMessage } from '../../types'
const props = defineProps<{
messages: ChatMessage[]
sending?: boolean
}>()
const emit = defineEmits<{
send: [text: string]
}>()
const input = ref('')
const listEl = ref<HTMLElement | null>(null)
watch(
() => props.messages.length,
async () => {
await nextTick()
if (listEl.value) {
listEl.value.scrollTop = listEl.value.scrollHeight
}
},
)
function onSubmit() {
const text = input.value.trim()
if (!text || props.sending) return
emit('send', text)
input.value = ''
}
</script>
<template>
<section class="chat">
<header class="head">
<h2>对话</h2>
<span class="hint">指挥 Agent 分析画面并操作手机</span>
</header>
<div ref="listEl" class="list">
<div
v-for="m in messages"
:key="m.id"
class="bubble"
:class="m.role"
>
<div class="role">{{ m.role === 'user' ? '你' : m.role === 'assistant' ? 'Agent' : '系统' }}</div>
<div class="content">{{ m.content }}</div>
</div>
</div>
<form class="composer" @submit.prevent="onSubmit">
<textarea
v-model="input"
rows="3"
placeholder="例如:打开微信,进入第一个未读消息…"
@keydown.meta.enter.prevent="onSubmit"
@keydown.ctrl.enter.prevent="onSubmit"
/>
<div class="actions">
<span class="tip">⌘/Ctrl + Enter 发送</span>
<button type="submit" class="send" :disabled="sending || !input.trim()">
{{ sending ? '发送中…' : '发送' }}
</button>
</div>
</form>
</section>
</template>
<style scoped>
.chat {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
background: var(--bg);
}
.head {
display: flex;
align-items: center;
gap: 10px;
height: 44px;
box-sizing: border-box;
padding: 0 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
h2 {
margin: 0;
font-size: 13px;
font-weight: 600;
line-height: 1;
}
.hint {
font-size: 12px;
line-height: 1;
color: var(--text-muted);
}
.list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.bubble {
flex-shrink: 0;
max-width: 92%;
padding: 10px 12px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-elevated);
}
.bubble.user {
align-self: flex-end;
background: var(--accent-soft);
border-color: rgba(61, 139, 253, 0.35);
}
.bubble.assistant,
.bubble.system {
align-self: flex-start;
}
.role {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.content {
font-size: 14px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.composer {
flex-shrink: 0;
border-top: 1px solid var(--border);
padding: 12px;
background: var(--bg-elevated);
}
textarea {
width: 100%;
resize: none;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg);
padding: 10px 12px;
outline: none;
}
textarea:focus {
border-color: var(--accent);
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.tip {
font-size: 12px;
color: var(--text-muted);
}
.send {
height: 34px;
padding: 0 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: #fff;
font-weight: 600;
}
.send:disabled {
opacity: 0.5;
cursor: default;
}
</style>

View File

@ -0,0 +1,222 @@
<script setup lang="ts">
/**
* [UI · 连接] 无线调试:配对码配对 + IP:端口连接
* 手机路径:开发者选项 → 无线调试
* 1)「使用配对码配对设备」→ 填配对 IP:端口 + 六位码
* 2) 无线调试主界面的「IP 地址与端口」→ 填连接 IP:端口
*/
import { ref } from 'vue'
defineProps<{
busy?: boolean
adbOk?: boolean
adbHint?: string
}>()
const emit = defineEmits<{
pair: [hostPort: string, code: string]
connect: [hostPort: string]
requestLocalNetwork: []
openLocalNetworkSettings: []
}>()
const open = ref(true)
const pairHost = ref('')
const pairCode = ref('')
const connectHost = ref('')
function doPair() {
emit('pair', pairHost.value.trim(), pairCode.value.trim())
}
function doConnect() {
emit('connect', connectHost.value.trim())
}
</script>
<template>
<section class="connect" :class="{ collapsed: !open }">
<header class="head" @click="open = !open">
<h2>连接手机</h2>
<span class="adb" :class="{ ok: adbOk, bad: adbOk === false }">
{{ adbHint || (adbOk ? 'adb 就绪' : '检测 adb…') }}
</span>
<span class="chev">{{ open ? '收起' : '展开' }}</span>
</header>
<div v-show="open" class="body">
<p class="guide">
手机与电脑同一 Wi‑Fi,关掉 VPN。打开
<b>开发者选项 → 无线调试</b>。
<b>配对端口</b>和<b>连接端口</b>不是同一个。
</p>
<div class="perm">
<span>Mac「本地网络」列表没有 iTuoke 时:</span>
<button type="button" class="btn" :disabled="busy" @click.stop="emit('requestLocalNetwork')">
申请本地网络权限
</button>
<button type="button" class="btn" :disabled="busy" @click.stop="emit('openLocalNetworkSettings')">
打开设置
</button>
</div>
<div class="block">
<div class="title">① 配对码配对(弹窗里的端口,配对时弹窗保持打开)</div>
<div class="row">
<input
v-model="pairHost"
placeholder="配对 IP:端口(弹窗内,端口通常较大)"
@keydown.enter.prevent="doPair"
/>
<input
v-model="pairCode"
class="code"
placeholder="六位配对码"
maxlength="8"
@keydown.enter.prevent="doPair"
/>
<button type="button" class="btn" :disabled="busy" @click="doPair">配对</button>
</div>
</div>
<div class="block">
<div class="title">② 连接(无线调试主界面「IP 地址与端口」)</div>
<div class="row">
<input
v-model="connectHost"
placeholder="连接 IP:端口(主界面那一行)"
@keydown.enter.prevent="doConnect"
/>
<button type="button" class="btn primary" :disabled="busy" @click="doConnect">
连接并看画面
</button>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.connect {
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
}
.head {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 14px;
cursor: pointer;
user-select: none;
}
h2 {
margin: 0;
font-size: 13px;
font-weight: 600;
}
.adb {
flex: 1;
font-size: 12px;
color: var(--text-muted);
font-family: var(--mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.adb.ok {
color: var(--ok);
}
.adb.bad {
color: var(--danger);
}
.chev {
font-size: 12px;
color: var(--text-muted);
}
.body {
padding: 0 14px 14px;
}
.guide {
margin: 0 0 12px;
font-size: 12px;
line-height: 1.5;
color: var(--text-muted);
}
.perm {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 0 0 12px;
font-size: 12px;
color: var(--text-muted);
}
.block + .block {
margin-top: 10px;
}
.title {
font-size: 12px;
margin-bottom: 6px;
color: var(--text);
}
.row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
input {
flex: 1;
min-width: 160px;
height: 34px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
outline: none;
}
input:focus {
border-color: var(--accent);
}
input.code {
flex: 0 0 120px;
min-width: 100px;
letter-spacing: 0.12em;
}
.btn {
height: 34px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
white-space: nowrap;
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
font-weight: 600;
}
.btn:disabled {
opacity: 0.55;
cursor: default;
}
</style>

View File

@ -0,0 +1,236 @@
<script setup lang="ts">
/**
* [UI · 顶栏/菜单栏]
* 左:品牌 + 工作台/设置/日志
* 右:设备选择 / 刷新 / 断开
*/
import { computed } from 'vue'
import type { DeviceInfo, MainTab } from '../../types'
const props = defineProps<{
devices: DeviceInfo[]
activeId: string
tab: MainTab
busy?: boolean
}>()
const selectedDevice = computed(() => props.devices.find((d) => d.id === props.activeId))
const emit = defineEmits<{
select: [id: string]
refresh: []
disconnect: [id: string]
connect: [id: string]
'update:tab': [tab: MainTab]
}>()
</script>
<template>
<div class="device-bar">
<div class="left" data-wails-drag>
<div class="brand">
<span class="logo">iTuoke</span>
<span class="tag">ADB · Agent</span>
</div>
<nav class="menu" @pointerdown.stop>
<button
type="button"
:class="{ active: tab === 'workspace' }"
@click="emit('update:tab', 'workspace')"
>
工作台
</button>
<button
type="button"
:class="{ active: tab === 'settings' }"
@click="emit('update:tab', 'settings')"
>
设置
</button>
<button
type="button"
:class="{ active: tab === 'logs' }"
@click="emit('update:tab', 'logs')"
>
日志
</button>
</nav>
</div>
<div class="devices">
<label class="label" for="device-select">设备</label>
<select
id="device-select"
class="select"
:value="activeId"
:disabled="busy || !devices.length"
:title="devices.length ? '选择设备' : '暂无设备,请点击「配置」设置连接'"
@change="emit('select', ($event.target as HTMLSelectElement).value)"
>
<option v-if="!activeId" value="" disabled>{{ devices.length ? '请选择设备' : '未连接设备' }}</option>
<option v-for="d in devices" :key="d.id" :value="d.id">
{{ d.model }} · {{ d.id }} · {{ d.online ? '在线' : d.state === 'unauthorized' ? '未授权' : '离线' }}
</option>
</select>
<button type="button" class="btn" :disabled="busy" @click="emit('refresh')">刷新</button>
<button
type="button"
class="btn"
:class="{ danger: selectedDevice?.online }"
:disabled="busy"
@click="selectedDevice?.online ? emit('disconnect', activeId) : emit('connect', activeId)"
>
{{ busy ? '处理中…' : selectedDevice?.online ? '断开' : selectedDevice ? '连接' : '配置' }}
</button>
</div>
</div>
</template>
<style scoped>
/* 与 main.go InvisibleTitleBarHeight:52 / MacTitleBarHiddenInset 对齐:
macOS 红绿灯贴顶偏上,顶栏内容不能按整高垂直居中,否则会一上一下。 */
.device-bar {
flex-shrink: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
height: 52px;
box-sizing: border-box;
padding: 11px 16px 0 96px;
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
-webkit-app-region: drag;
}
.left {
display: flex;
align-items: center;
gap: 18px;
min-width: 0;
height: 30px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
height: 30px;
}
.logo {
font-size: 14px;
font-weight: 650;
letter-spacing: 0.02em;
line-height: 1;
}
.tag {
font-size: 12px;
line-height: 1;
color: var(--text-muted);
}
.menu {
display: flex;
align-items: center;
gap: 4px;
height: 30px;
-webkit-app-region: no-drag;
}
.menu button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 12px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: var(--text-muted);
font-size: 13px;
line-height: 1;
}
.menu button.active {
color: var(--text);
background: var(--accent-soft);
border-color: rgba(61, 139, 253, 0.35);
}
.devices {
display: flex;
align-items: center;
gap: 8px;
height: 30px;
-webkit-app-region: no-drag;
}
.label {
font-size: 12px;
line-height: 1;
color: var(--text-muted);
}
.select {
color: var(--text);
cursor: pointer;
min-width: 220px;
max-width: 380px;
height: 28px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
font-size: 13px;
line-height: 1;
}
.select option {
color: var(--text);
background: var(--bg-elevated);
}
.select option:disabled {
color: var(--text-muted);
}
.select:hover {
border-color: var(--text-muted);
}
.select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
font-size: 13px;
line-height: 1;
}
.btn:hover:not(:disabled) {
background: var(--accent-soft);
border-color: var(--accent);
}
.btn.danger:hover:not(:disabled) {
border-color: var(--danger);
color: var(--danger);
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
</style>

View File

@ -0,0 +1,65 @@
<script setup lang="ts">
/**
* [UI · 日志] 运行日志视图(审计 / 调试)
* 后续由 Go 推事件填充;现为本地环形缓冲展示
*/
defineProps<{
lines: string[]
}>()
const emit = defineEmits<{
clear: []
}>()
</script>
<template>
<section class="logs">
<header class="head">
<h2>日志</h2>
<button type="button" class="btn" @click="emit('clear')">清空</button>
</header>
<pre class="body">{{ lines.length ? lines.join('\n') : '暂无日志' }}</pre>
</section>
</template>
<style scoped>
.logs {
display: flex;
flex-direction: column;
height: 100%;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 14px;
border-bottom: 1px solid var(--border);
}
h2 {
margin: 0;
font-size: 14px;
}
.btn {
height: 28px;
padding: 0 10px;
border-radius: 7px;
border: 1px solid var(--border);
background: transparent;
}
.body {
flex: 1;
margin: 0;
padding: 14px;
overflow: auto;
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
color: var(--text-muted);
white-space: pre-wrap;
}
</style>

View File

@ -0,0 +1,439 @@
<script setup lang="ts">
/**
* [UI · 左栏] 手机画面
* live:轮询 /snap.jpg(WKWebView 不支持 multipart MJPEG 的 <img>)
* 缩放支持适应窗口与固定百分比
*/
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { previewZoomOptions, type PreviewZoom } from '../../lib/prefs'
import { mapPoint, type Point, type Size } from '../../lib/gestures'
import type { TouchEvent } from '../../lib/touch'
import type { DeviceInfo } from '../../types'
const props = defineProps<{
zoom: PreviewZoom
device: DeviceInfo | null
streamUrl?: string
loading?: boolean
live?: boolean
error?: string
controlError?: string
}>()
const emit = defineEmits<{
'update:zoom': [zoom: PreviewZoom]
toggleLive: []
touch: [event: TouchEvent]
}>()
const stage = ref<HTMLElement | null>(null)
const viewport = ref({ width: 0, height: 0 })
const naturalSize = ref({ width: 0, height: 0 })
let resizeObserver: ResizeObserver | null = null
const imageSize = computed(() => {
const width = naturalSize.value.width || props.device?.width || 1
const height = naturalSize.value.height || props.device?.height || 1
const scale = props.zoom === 'fit'
? Math.max(0, Math.min((viewport.value.width - 32) / width, (viewport.value.height - 32) / height))
: props.zoom / 100
return { width: width * scale, height: height * scale }
})
const imageStyle = computed(() => ({
width: `${imageSize.value.width}px`,
height: `${imageSize.value.height}px`,
}))
const canvasStyle = computed(() => {
const hasImage = props.live && snapSrc.value
return {
width: `${Math.max(viewport.value.width, hasImage ? imageSize.value.width + 32 : 0)}px`,
height: `${Math.max(viewport.value.height, hasImage ? imageSize.value.height + 32 : 0)}px`,
}
})
function onImageLoad(event: Event) {
const image = event.target as HTMLImageElement
if (image.naturalWidth !== naturalSize.value.width || image.naturalHeight !== naturalSize.value.height) {
cancelGesture()
naturalSize.value = { width: image.naturalWidth, height: image.naturalHeight }
}
}
function changeZoom(event: Event) {
cancelGesture()
const value = (event.target as HTMLSelectElement).value
emit('update:zoom', value === 'fit' ? 'fit' : Number(value) as PreviewZoom)
stage.value?.scrollTo(0, 0)
}
onMounted(() => {
window.addEventListener('blur', cancelGesture)
if (!stage.value) return
resizeObserver = new ResizeObserver(([entry]) => {
if (entry) viewport.value = { width: entry.contentRect.width, height: entry.contentRect.height }
})
resizeObserver.observe(stage.value)
})
watch(() => [props.device?.id, props.live, props.streamUrl], () => {
cancelGesture()
naturalSize.value = { width: 0, height: 0 }
})
const canControl = computed(() => !!(props.live && props.device?.online && naturalSize.value.width > 0 && !props.loading))
let pointer: { id: number; image: HTMLImageElement; rect: DOMRect; screen: Size; deviceId: string; point: Point } | null = null
let moveFrame: number | null = null
function sendTouch(action: TouchEvent['action'], gesture: NonNullable<typeof pointer>) {
emit('touch', { deviceId: gesture.deviceId, action, ...gesture.point, ...gesture.screen })
}
function clearPointer() {
if (moveFrame !== null) cancelAnimationFrame(moveFrame)
moveFrame = null
const previous = pointer
pointer = null
if (previous?.image.hasPointerCapture(previous.id)) previous.image.releasePointerCapture(previous.id)
}
function cancelGesture() {
if (pointer) sendTouch(3, pointer)
clearPointer()
}
function onPointerDown(event: PointerEvent) {
if (!canControl.value || pointer || !event.isPrimary || event.button !== 0 || !props.device) return
const image = event.currentTarget as HTMLImageElement
const rect = image.getBoundingClientRect()
if (!rect.width || !rect.height) return
const screen = { ...naturalSize.value }
event.preventDefault()
pointer = { id: event.pointerId, image, rect, screen, deviceId: props.device.id,
point: mapPoint(event.clientX, event.clientY, rect, screen) }
image.setPointerCapture(event.pointerId)
sendTouch(0, pointer)
}
function onPointerMove(event: PointerEvent) {
if (!pointer || event.pointerId !== pointer.id) return
pointer.point = mapPoint(event.clientX, event.clientY, pointer.rect, pointer.screen)
if (moveFrame === null) moveFrame = requestAnimationFrame(() => {
moveFrame = null
if (pointer) sendTouch(2, pointer)
})
}
function onPointerUp(event: PointerEvent) {
if (!pointer || event.pointerId !== pointer.id) return
pointer.point = mapPoint(event.clientX, event.clientY, pointer.rect, pointer.screen)
if (moveFrame !== null) sendTouch(2, pointer)
sendTouch(1, pointer)
clearPointer()
}
watch(() => props.zoom, cancelGesture)
const snapSrc = ref('')
const snapReady = ref(false)
const snapError = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
let pollGeneration = 0
let snapRequest: AbortController | null = null
let lastObjectUrl = ''
const streamBase = computed(() => {
if (!props.streamUrl) return ''
try {
const u = new URL(props.streamUrl)
return `${u.protocol}//${u.host}`
} catch {
return props.streamUrl.replace(/\/mjpeg.*$/i, '')
}
})
function clearPoll() {
pollGeneration++
snapRequest?.abort()
snapRequest = null
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
if (lastObjectUrl) {
URL.revokeObjectURL(lastObjectUrl)
lastObjectUrl = ''
}
snapSrc.value = ''
snapReady.value = false
snapError.value = ''
}
async function pullSnap(generation: number) {
const base = streamBase.value
if (!base) return
try {
snapRequest = new AbortController()
const res = await fetch(`${base}/snap.jpg?t=${Date.now()}`, { cache: 'no-store', signal: snapRequest.signal })
if (generation !== pollGeneration) return
if (!res.ok) {
if (!snapReady.value) snapError.value = '等待首帧…'
return
}
const blob = await res.blob()
if (!blob.size || generation !== pollGeneration) return
const url = URL.createObjectURL(blob)
const prev = lastObjectUrl
lastObjectUrl = url
snapSrc.value = url
snapReady.value = true
snapError.value = ''
if (prev) URL.revokeObjectURL(prev)
} catch (e) {
if (generation === pollGeneration && !snapRequest?.signal.aborted && !snapReady.value) snapError.value = `预览拉流失败:${String(e)}`
}
}
function startPoll() {
clearPoll()
const generation = pollGeneration
const tick = async () => {
await pullSnap(generation)
if (generation === pollGeneration) pollTimer = setTimeout(tick, 33)
}
void tick()
}
watch(
() => [props.live, props.streamUrl] as const,
([live, url]) => {
if (live && url) startPoll()
else clearPoll()
},
{ immediate: true },
)
onUnmounted(() => {
window.removeEventListener('blur', cancelGesture)
cancelGesture()
clearPoll()
resizeObserver?.disconnect()
})
</script>
<template>
<section class="capture">
<header class="head">
<div class="info">
<h2>画面</h2>
<div class="meta" v-if="device">
{{ device.width || '?' }}×{{ device.height || '?' }}
· {{ (device.mode || '').toUpperCase() }}
· {{ device.online ? '在线' : '离线' }}
</div>
<div class="meta" v-else>未连接</div>
</div>
<div class="actions">
<select class="zoom" aria-label="画面缩放" title="画面缩放(百分比按图像原始尺寸)" :value="zoom" @change="changeZoom">
<option v-for="option in previewZoomOptions" :key="option" :value="option">
{{ option === 'fit' ? '适应窗口' : `${option}%` }}
</option>
</select>
<button type="button" class="btn" :class="{ on: live }" :disabled="loading || !device?.online" @click="emit('toggleLive')">
{{ loading ? '启动中…' : live ? '停止预览' : '开始预览' }}
</button>
</div>
</header>
<div ref="stage" class="stage" @scroll="cancelGesture">
<div class="canvas" :style="canvasStyle">
<img
v-if="live && snapSrc"
class="frame"
:class="{ controllable: canControl }"
:draggable="false"
title="点击操作手机,按住拖动滑动"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="cancelGesture"
@lostpointercapture="cancelGesture"
@dragstart.prevent
@contextmenu.prevent
:style="imageStyle"
@load="onImageLoad"
:src="snapSrc"
alt="可操作的手机实时画面"
/>
<div v-else-if="live" class="placeholder">
<p class="title">{{ snapError || '正在拉取画面…' }}</p>
<p class="hint">预览已启动,等待设备视频帧。</p>
</div>
<div v-else class="placeholder">
<p class="title">{{ error ? '画面获取失败' : '等待设备画面' }}</p>
<p class="hint" v-if="error">{{ error }}</p>
<p class="hint" v-else>连接手机后点「开始预览」。</p>
</div>
</div>
</div>
<p v-if="controlError" class="control-error" role="alert">{{ controlError }}</p>
</section>
</template>
<style scoped>
.capture {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
background: var(--bg-panel);
border-right: 1px solid var(--border);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px 12px;
flex-wrap: wrap;
min-height: 44px;
box-sizing: border-box;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.info {
flex: 1 1 100px;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
h2 {
margin: 0;
font-size: 13px;
font-weight: 600;
line-height: 1;
flex-shrink: 0;
}
.meta {
font-size: 12px;
line-height: 1;
color: var(--text-muted);
font-family: var(--mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.zoom {
height: 28px;
max-width: 116px;
padding: 0 6px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.zoom:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 12px;
border-radius: 7px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 13px;
line-height: 1;
white-space: nowrap;
}
.btn.on {
border-color: var(--ok);
color: var(--ok);
background: rgba(62, 207, 142, 0.12);
}
.btn:hover:not(:disabled) {
border-color: var(--accent);
background: var(--accent-soft);
}
.btn:disabled {
opacity: 0.55;
cursor: default;
}
.stage {
flex: 1;
min-height: 0;
min-width: 0;
overflow: auto;
background: var(--preview-bg);
}
.canvas {
display: grid;
place-items: center;
}
.frame {
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
display: block;
max-width: none;
max-height: none;
object-fit: contain;
border-radius: 12px;
box-shadow: var(--preview-shadow);
}
.frame { cursor: default; }
.control-error {
position: absolute;
bottom: 12px;
left: 12px;
right: 12px;
z-index: 1;
margin: 0;
padding: 10px 12px;
border: 1px solid var(--danger);
border-radius: 8px;
background: var(--bg-elevated);
color: var(--text);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.placeholder {
max-width: 380px;
text-align: center;
color: var(--text-muted);
}
.title {
margin: 0 0 8px;
color: var(--text);
font-size: 15px;
}
.hint {
margin: 0;
font-size: 13px;
line-height: 1.55;
}
</style>

View File

@ -0,0 +1,356 @@
<script setup lang="ts">
/**
* [UI · 设置]
* - 设备连接(配对/连接,配置自动记住)
* - Agent / 协议
*/
import { nextTick, ref, watch } from 'vue'
import type { AppPrefs, ThemePreference } from '../../lib/prefs'
import { savePrefs } from '../../lib/prefs'
const props = defineProps<{
protocolVersion: string
agentStatus: Record<string, unknown> | null
adbOk?: boolean
adbHint?: string
busy?: boolean
connectionNotice?: string
connectionRequest?: number
prefs: AppPrefs
}>()
const emit = defineEmits<{
startAgent: []
stopAgent: []
pair: [hostPort: string, code: string]
connect: [hostPort: string]
requestLocalNetwork: []
openLocalNetworkSettings: []
prefsChange: [prefs: AppPrefs]
}>()
const connectionCard = ref<HTMLElement | null>(null)
const connectInput = ref<HTMLInputElement | null>(null)
watch(() => props.connectionRequest, async () => {
if (!props.connectionNotice) return
await nextTick()
connectionCard.value?.scrollIntoView({ block: 'nearest' })
connectInput.value?.focus({ preventScroll: true })
}, { immediate: true, flush: 'post' })
const pairHost = ref(props.prefs.pairHost)
const pairCode = ref(props.prefs.pairCode)
const connectHost = ref(props.prefs.connectHost)
watch(
() => props.prefs,
(p) => {
pairHost.value = p.pairHost
pairCode.value = p.pairCode
connectHost.value = p.connectHost
},
)
function persist(patch: Partial<AppPrefs>) {
const next = savePrefs(patch)
emit('prefsChange', next)
}
function onPairHostBlur() {
persist({ pairHost: pairHost.value.trim() })
}
function onPairCodeBlur() {
persist({ pairCode: pairCode.value.trim() })
}
function onConnectHostBlur() {
persist({ connectHost: connectHost.value.trim() })
}
function doPair() {
persist({
pairHost: pairHost.value.trim(),
pairCode: pairCode.value.trim(),
})
emit('pair', pairHost.value.trim(), pairCode.value.trim())
}
function doConnect() {
persist({ connectHost: connectHost.value.trim() })
emit('connect', connectHost.value.trim())
}
</script>
<template>
<section class="settings">
<header class="head">
<h2>设置</h2>
<span class="adb" :class="{ ok: adbOk, bad: adbOk === false }">
{{ adbHint || '检测 adb…' }}
</span>
</header>
<div class="body">
<div class="card">
<h3>外观</h3>
<div class="appearance">
<label for="theme-preference">主题</label>
<select id="theme-preference" :value="prefs.theme"
@change="persist({ theme: ($event.target as HTMLSelectElement).value as ThemePreference })">
<option value="system">跟随系统(默认)</option>
<option value="dark">深色</option>
<option value="light">浅色</option>
</select>
</div>
<p class="muted theme-hint">更改后立即生效。跟随系统会自动响应系统外观变化。</p>
</div>
<div ref="connectionCard" class="card">
<h3>设备连接</h3>
<p v-if="connectionNotice" class="connection-notice" role="status">{{ connectionNotice }}</p>
<p class="muted">
手机与电脑同一 Wi‑Fi。开发者选项 → 无线调试。配对端口与连接端口不同。配置会自动保存,下次打开无需重填。
</p>
<div class="perm">
<button type="button" class="btn" :disabled="busy" @click="emit('requestLocalNetwork')">
申请本地网络权限
</button>
<button type="button" class="btn" :disabled="busy" @click="emit('openLocalNetworkSettings')">
打开系统设置
</button>
</div>
<div class="block">
<div class="title">① 配对码配对</div>
<div class="row">
<input
v-model="pairHost"
placeholder="配对 IP:端口"
@blur="onPairHostBlur"
@keydown.enter.prevent="doPair"
/>
<input
v-model="pairCode"
class="code"
placeholder="六位配对码"
maxlength="8"
@blur="onPairCodeBlur"
@keydown.enter.prevent="doPair"
/>
<button type="button" class="btn" :disabled="busy" @click="doPair">配对</button>
</div>
</div>
<div class="block">
<div class="title">② IP + 端口连接</div>
<div class="row">
<input
ref="connectInput"
v-model="connectHost"
aria-label="连接 IP:端口"
placeholder="连接 IP:端口"
@blur="onConnectHostBlur"
@keydown.enter.prevent="doConnect"
/>
<button type="button" class="btn primary" :disabled="busy" @click="doConnect">
连接并看画面
</button>
</div>
</div>
</div>
<div class="card">
<h3>协议</h3>
<p>当前契约版本:<code>{{ protocolVersion || '—' }}</code></p>
</div>
<div class="card">
<h3>Agent</h3>
<pre class="json">{{ agentStatus ? JSON.stringify(agentStatus, null, 2) : '未读取' }}</pre>
<div class="row">
<button type="button" class="btn primary" @click="emit('startAgent')">启动 Agent</button>
<button type="button" class="btn" @click="emit('stopAgent')">停止</button>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.settings {
display: flex;
flex-direction: column;
height: 100%;
}
.head {
height: 44px;
padding: 0 14px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border);
}
h2 {
margin: 0;
font-size: 14px;
}
.adb {
font-size: 12px;
color: var(--text-muted);
font-family: var(--mono);
}
.adb.ok {
color: var(--ok);
}
.adb.bad {
color: var(--danger);
}
.body {
flex: 1;
overflow: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.card {
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elevated);
}
h3 {
margin: 0 0 8px;
font-size: 13px;
}
p {
margin: 0 0 10px;
font-size: 13px;
line-height: 1.5;
}
.connection-notice {
padding: 10px 12px;
border: 1px solid var(--accent);
border-radius: 8px;
background: var(--accent-soft);
color: var(--text);
overflow-wrap: anywhere;
}
.muted {
color: var(--text-muted);
}
.perm {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.block + .block {
margin-top: 12px;
}
.title {
font-size: 12px;
margin-bottom: 6px;
}
.row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.appearance {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
}
.appearance select {
min-width: 180px;
height: 34px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
cursor: pointer;
}
.appearance select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.theme-hint {
margin: 10px 0 0;
}
input {
flex: 1;
min-width: 160px;
height: 34px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
outline: none;
}
input:focus {
border-color: var(--accent);
}
input.code {
flex: 0 0 120px;
letter-spacing: 0.12em;
}
code {
font-family: var(--mono);
font-size: 12px;
color: var(--accent);
}
.json {
margin: 0 0 10px;
padding: 10px;
border-radius: 8px;
background: var(--bg);
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
overflow: auto;
}
.btn {
height: 34px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
white-space: nowrap;
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
font-weight: 600;
}
.btn:disabled {
opacity: 0.55;
cursor: default;
}
</style>

View File

@ -0,0 +1,12 @@
export interface Point { x: number; y: number }
export interface Size { width: number; height: number }
export interface PreviewRect extends Size { left: number; top: number }
/** The image rectangle includes zoom, pane resizing, and scroll offsets. */
export function mapPoint(clientX: number, clientY: number, rect: PreviewRect, screen: Size): Point {
const x = (clientX - rect.left) / rect.width
const y = (clientY - rect.top) / rect.height
return {
x: Math.max(0, Math.min(screen.width - 1, Math.floor(x * screen.width))),
y: Math.max(0, Math.min(screen.height - 1, Math.floor(y * screen.height))),
}
}

Some files were not shown because too many files have changed in this diff Show More