feat: initialize iTuoke with real-time phone control
|
|
@ -0,0 +1,4 @@
|
||||||
|
* text=auto
|
||||||
|
*.sh text eol=lf
|
||||||
|
gradlew text eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
|
|
@ -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/
|
||||||
|
|
@ -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 ./...
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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` | 看图 + 上下文 → 设备动作列表 |
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
# 第一期仅标准库即可跑 stub;接云端 API 后再解开下面依赖
|
||||||
|
# openai>=1.0.0
|
||||||
|
# anthropic>=0.40.0
|
||||||
|
# pillow>=10.0.0
|
||||||
|
|
@ -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}}"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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'
|
||||||
|
}
|
||||||
|
|
@ -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 { *; }
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 <video> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Wails App</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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" "$@"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
|
@ -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 |
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 107 KiB |
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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}}
|
||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
|
@ -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"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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/"'
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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 */;
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
@ -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}}
|
||||||
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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))),
|
||||||
|
}
|
||||||
|
}
|
||||||