feat: release 1.4.0 with isolated upgrades and device discovery

This commit is contained in:
chenmingxuan committed 2026-10-09 15:46:28 +08:00
1 parent a6a3ee2eb0
commit df66897ff4
41 files changed
+1564 -126

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
# Shared preparation/upgrade mutex. ASCII for Windows PowerShell 5.1.
function Enter-ArtemisInstallationLock {
param([string]$ProjectRoot)
$normalized = [IO.Path]::GetFullPath($ProjectRoot).TrimEnd('\').ToLowerInvariant()
$hash = [Security.Cryptography.SHA256]::Create()
try { $digest = [BitConverter]::ToString($hash.ComputeHash([Text.Encoding]::UTF8.GetBytes($normalized))).Replace('-', '') }
finally { $hash.Dispose() }
$mutex = New-Object Threading.Mutex($false, "Local\ArtemisInstallation-$digest")
try {
try { $entered = $mutex.WaitOne(0) }
catch [Threading.AbandonedMutexException] { $entered = $true }
if (-not $entered) { throw 'Another startup preparation or upgrade is running. Please wait and retry.' }
return $mutex
} catch { $mutex.Dispose(); throw }
}
function Exit-ArtemisInstallationLock {
param($Mutex)
if ($null -ne $Mutex) { $Mutex.ReleaseMutex(); $Mutex.Dispose() }
}
+49 -22
View File
@@ -17,8 +17,10 @@ param(
[int]$Port = 8000,
[switch]$NoOpen = $false,
[switch]$SetupOnly = $false,
[switch]$PrepareOnly = $false,
[switch]$SkipUpdate = $false,
[switch]$SkipMcpSync = $false,
[switch]$Help = $false,
[ValidateSet('Official', 'China')][string]$Mirror
)
@@ -31,6 +33,35 @@ function Get-LaunchText {
return $English
}
# Keep UTF-8 Chinese text in this BOM-encoded script, never in CMD batch files.
# CMD can misparse multibyte batch lines when an archive/editor converts CRLF to LF.
if ($Help) {
Write-Host (Get-LaunchText 'Usage: start.bat [options]' '用法:start-cn.bat [选项]')
Write-Host ''
Write-Host (Get-LaunchText ' -SetupOnly Prepare the environment without launching the UI' ' -SetupOnly 安装或检查环境,不启动网页')
Write-Host (Get-LaunchText ' -Port 8001 Use the specified port (default: 8000)' ' -Port 8001 使用指定端口,默认 8000')
Write-Host (Get-LaunchText ' -NoOpen Do not open the browser automatically' ' -NoOpen 启动服务后不自动打开浏览器')
Write-Host (Get-LaunchText ' -SkipUpdate Skip the source update notification' ' -SkipUpdate 跳过源码更新提示(启动不会拉取源码)')
Write-Host (Get-LaunchText ' -SkipMcpSync Skip Codex MCP installation and updates' ' -SkipMcpSync 仅启动网页,跳过 Codex MCP 安装和更新')
Write-Host (Get-LaunchText ' -Mirror China Use China download mirrors' ' -Mirror China 使用国内下载镜像')
Write-Host (Get-LaunchText ' -Help Show this help' ' -Help 显示本说明')
Write-Host ''
Write-Host (Get-LaunchText 'Download sources: config\downloads.json' '国内镜像设置:config\downloads.json')
Write-Host (Get-LaunchText 'First launch requires Internet access. Upgrade with update.bat; startup only checks for updates.' '首次运行需联网;启动仅提示源码更新,升级请运行 update-cn.bat。')
exit 0
}
Write-Host '======================================================'
if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN' -and $Mirror -eq 'China') {
Write-Host ' ARTEMIS 中文启动 - 使用国内镜像'
Write-Host '======================================================'
Write-Host '正在检查已有环境、更新提示及依赖;首次启动可能需要下载组件。'
Write-Host '镜像地址和下载超时可在 config\downloads.json 中调整。'
} else {
Write-Host (Get-LaunchText ' Artemis Autonomous Mobile Agent UI' ' ARTEMIS 手机自动化测试')
Write-Host '======================================================'
}
Write-Host ''
# Ensure modern TLS protocols are enabled for downloads (GitHub releases, nodejs.org, Google repositories)
try {
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
@@ -40,28 +71,18 @@ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$RootDir = Split-Path -Parent $ScriptDir
Set-Location $RootDir
# Existing installations check source BEFORE importing bootstrap helpers.
# Startup never mutates source; upgrades run from a staged, independent process.
. (Join-Path $ScriptDir 'installation_lock.ps1')
$installationLock = $null
if ($env:ARTEMIS_UPGRADE_CHILD -ne '1') {
try { $installationLock = Enter-ArtemisInstallationLock -ProjectRoot $RootDir }
catch { Write-Host (Get-LaunchText "[ERROR] $_" '[错误] 另一个环境准备或升级正在运行,请稍后重试。') -ForegroundColor Red; exit 1 }
}
$existingPython = Join-Path $RootDir '.venv\Scripts\python.exe'
if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) {
& $existingPython (Join-Path $ScriptDir 'update_installation.py') source --port $Port
$updateExit = $LASTEXITCODE
if ($updateExit -eq 20) { exit 1 }
if ($updateExit -eq 10) {
# Reparse the freshly updated launcher; do not continue running stale functions.
$reloadArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ScriptDir 'start.ps1'), '-Port', $Port, '-SkipUpdate')
if ($NoOpen) { $reloadArgs += '-NoOpen' }
if ($SetupOnly) { $reloadArgs += '-SetupOnly' }
if ($SkipMcpSync) { $reloadArgs += '-SkipMcpSync' }
if ($Mirror) { $reloadArgs += @('-Mirror', $Mirror) }
& powershell.exe @reloadArgs
exit $LASTEXITCODE
}
if ($updateExit -ne 0) {
Write-Host (Get-LaunchText ' [ERROR] Source update check failed. Resolve the error above, or use -SkipUpdate for local startup.' ' [错误] 源码更新检查失败。请处理上方错误;要运行当前源码可传入 -SkipUpdate。') -ForegroundColor Red
exit 1
}
& $existingPython (Join-Path $ScriptDir 'update_installation.py') source-check
# Network/authentication failures are warnings, never a reason to prevent startup.
}
. (Join-Path $ScriptDir 'download_config.ps1')
try {
Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror
@@ -376,7 +397,7 @@ if (-not $SkipMcpSync) {
} else {
Write-Host (Get-LaunchText ' [SKIP] MCP synchronization was skipped.' ' [跳过] 已跳过 MCP 同步。') -ForegroundColor Yellow
}
if ($SetupOnly) {
if ($SetupOnly -and -not $PrepareOnly) {
Write-Host (Get-LaunchText ' [OK] Setup complete. Run start.bat to launch Artemis.' ' [完成] 环境已准备好。运行 start-cn.bat 启动 ARTEMIS。') -ForegroundColor Green
exit 0
}
@@ -431,7 +452,7 @@ if ($missingCore.Count -gt 0) {
}
# Ensure local ADB daemon is warm & listening so probes do not hit 'Connection refused'
Start-LocalAdbServer
if (-not $PrepareOnly) { Start-LocalAdbServer }
# 6. Check and build Showcase UI if not already compiled
$ShowcaseIndex = "$RootDir\apps\showcase_ui\dist\frontend\browser\index.html"
@@ -523,9 +544,15 @@ if ($frontendChanged -or ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Pat
# MCP clients were refreshed after Python dependency sync above.
Exit-ArtemisInstallationLock -Mutex $installationLock
$installationLock = $null
if ($PrepareOnly) {
Write-Host (Get-LaunchText '[OK] Python, frontend and Codex MCP preparation complete.' '[完成] Python、网页构建和 Codex MCP 已准备好。') -ForegroundColor Green
exit 0
}
# 8. Launch unified Showcase UI & open browser
# Ensure local ADB daemon is warm & listening
Start-LocalAdbServer
if (-not $PrepareOnly) { Start-LocalAdbServer }
# Launch via `python -m artemis` (not the `artemis.exe` console-script shim) so the
# long-running server never locks .venv\Scripts\artemis.exe against `uv sync` reinstalls.
+40 -42
View File
@@ -13,7 +13,6 @@ import subprocess
import sys
import tempfile
import tomllib
import urllib.request
from urllib.parse import urlsplit
ROOT = Path(__file__).resolve().parents[1]
@@ -42,34 +41,16 @@ def git(root: Path, *args: str, timeout: int = 15) -> str:
return result.stdout.strip()
def update_source(root: Path, port: int = 8000) -> int:
"""Return 10 after a fast-forward (caller reloads launcher); never overwrite local work."""
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(f"http://127.0.0.1:{port}/api/status", timeout=2) as response:
status = json.load(response)
if (
status.get("active_tasks")
or status.get("queue")
or (status.get("status") == "running" and status.get("pid"))
):
print(
launch_text(
"[WAIT] Tasks are active. Finish/stop them before updating dependencies or restarting.",
"[等待] 有任务正在执行或排队。请先处理任务,再更新依赖或重启。",
)
)
return 20
except (OSError, ValueError):
pass
def update_source(root: Path, *, check_only: bool = False) -> int:
"""Return 10 for an available/applied fast-forward, 1 on failure; preserve local work."""
if not shutil.which("git"):
print(
launch_text(
"[SKIP] Git is not installed. Continuing with local source; install Git for automatic source updates.",
"[跳过] 未安装 Git,继续使用当前源码;安装 Git 后可自动检查更新。",
"[ERROR] Install Git before upgrading; local source is preserved.",
"[提示] 未安装 Git,无法升级;当前源码保持不变。",
)
)
return 0
return 1
if not (root / ".git").exists():
print(
launch_text(
@@ -77,8 +58,16 @@ def update_source(root: Path, port: int = 8000) -> int:
"[跳过] 旧版源码包不含 Git 信息。请使用新版源码包或 Git 克隆;当前文件保持不变。",
)
)
return 0
return 1
try:
if not check_only and git(root, "status", "--porcelain"):
print(
launch_text(
"[ERROR] Local changes exist; commit or back up first.",
"[错误] 发现本地修改;请先提交或备份后再升级。",
)
)
return 1
branch = git(root, "symbolic-ref", "--quiet", "--short", "HEAD")
remote = git(root, "config", "--get", f"branch.{branch}.remote")
merge_ref = git(root, "config", "--get", f"branch.{branch}.merge")
@@ -98,45 +87,53 @@ def update_source(root: Path, port: int = 8000) -> int:
"[检查] 正在检查当前分支的远程更新...",
)
)
git(root, "fetch", "--no-tags", remote, merge_ref, timeout=45)
git(root, "fetch", "--no-tags", remote, merge_ref, timeout=5 if check_only else 45)
target = git(root, "rev-parse", "FETCH_HEAD")
current = git(root, "rev-parse", "HEAD")
if current == target:
print(launch_text("[OK] Source is up to date.", "[完成] 源码已是最新版。"))
return 0
if check_only:
print(
launch_text(
"[UPDATE] A different upstream revision is available. Run update.bat (update-cn.bat for China mirrors).",
"[更新] 远程版本有变化;请运行 update-cn.bat 或 update.bat 升级。",
)
)
return 10
if git(root, "status", "--porcelain"):
print(
launch_text(
"[SKIP] Upstream differs, but local changes exist. Commit/back up your work before updating; continuing locally.",
"[跳过] 发现本地修改;请先提交或备份。此次继续使用本地源码。",
"[ERROR] Local changes appeared during the check. Upgrade stopped; files preserved.",
"[错误] 检查期间出现本地修改;已停止升级,保留现有文件。",
)
)
return 0
return 1
ancestor = git(root, "merge-base", "HEAD", target)
if ancestor != current:
print(
launch_text(
"[SKIP] Local branch is ahead or diverged. No automatic merge/rebase; continuing locally.",
"[跳过] 本地分支领先或分叉,不自动合并;继续使用本地源码。",
"[ERROR] Local branch is ahead or diverged. Upgrade stopped; no merge/rebase performed.",
"[错误] 本地分支领先或分叉;已停止升级,不自动合并。",
)
)
return 0
return 1
git(root, "merge", "--ff-only", target, timeout=30)
print(
launch_text(
f"[UPDATED] Source {current[:12]} -> {target[:12]}. Reloading launcher and checking dependencies.",
f"[已更新] 源码 {current[:12]} -> {target[:12]},重新载入启动脚本并检查依赖。",
f"[UPDATED] Source {current[:12]} -> {target[:12]}. Preparing the updated installation.",
f"[已更新] 源码 {current[:12]} -> {target[:12]},准备同步新版环境。",
)
)
return 10
except (RuntimeError, subprocess.TimeoutExpired) as exc:
except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
print(
launch_text(
f"[WARN] {exc if isinstance(exc, RuntimeError) else 'Git update timed out.'} Continuing with local source.",
"[提示] Git 更新失败或超时;请检查网络、凭据和仓库状态。继续使用当前源码。",
f"[WARN] {exc if isinstance(exc, RuntimeError) else 'Git check failed or timed out.'} Local source is preserved.",
"[提示] Git 检查/更新失败或超时;请检查网络、凭据和仓库状态。当前源码保持不变。",
)
)
return 0
return 1
def _atomic_write(path: Path, text: str) -> None:
@@ -406,12 +403,13 @@ def frontend_fingerprint(root: Path) -> str:
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["source", "mcp", "frontend-check", "frontend-mark"])
parser.add_argument(
"action", choices=["source", "source-check", "mcp", "frontend-check", "frontend-mark"]
)
parser.add_argument("--root", type=Path, default=ROOT)
parser.add_argument("--port", type=int, default=8000)
args = parser.parse_args()
if args.action == "source":
sys.exit(update_source(args.root, args.port))
if args.action in {"source", "source-check"}:
sys.exit(update_source(args.root, check_only=args.action == "source-check"))
elif args.action == "mcp":
sys.exit(refresh_mcp(args.root))
else:
+84
View File
@@ -0,0 +1,84 @@
[CmdletBinding()]
param(
[string]$ProjectRoot,
[ValidateSet('Official', 'China')][string]$Mirror,
[switch]$Help,
[switch]$Staged
)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
$env:PYTHONUTF8 = '1'
$env:PYTHONIOENCODING = 'utf-8'
if ($Help) {
Write-Host '用法:update.bat 或 update-cn.bat [-Help]'
Write-Host '升级会终止本项目的 ARTEMIS 服务、MCP 和任务进程;保留配置、密钥与历史资料。'
Write-Host '随后快进更新源码,同步依赖、网页构建和 Codex MCP;完成后请重新启动并重载 MCP。'
exit 0
}
if (-not $ProjectRoot) { $ProjectRoot = Split-Path $PSScriptRoot -Parent }
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot).Path
. (Join-Path $PSScriptRoot 'installation_lock.ps1')
if (-not $Staged) {
$lock = $null
$stage = $null
$stageCreated = $false
$code = 1
try {
$lock = Enter-ArtemisInstallationLock -ProjectRoot $ProjectRoot
# Every program used before the merge is frozen outside the checkout.
$stage = Join-Path ([IO.Path]::GetTempPath()) ('artemis-upgrade-' + [Guid]::NewGuid().ToString('N'))
$fullStage = [IO.Path]::GetFullPath($stage)
$rootPrefix = [IO.Path]::GetFullPath($ProjectRoot).TrimEnd('\') + '\'
if ($fullStage.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'TEMP must be outside the project directory.' }
New-Item -ItemType Directory -Path $stage | Out-Null
$stageCreated = $true
foreach ($name in @('upgrade.ps1', 'installation_lock.ps1', 'upgrade_processes.ps1', 'update_installation.py')) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination (Join-Path $stage $name)
}
$forward = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $stage 'upgrade.ps1'), '-Staged', '-ProjectRoot', $ProjectRoot)
if ($Mirror) { $forward += @('-Mirror', $Mirror) }
& powershell.exe @forward
$code = $LASTEXITCODE
} catch {
Write-Host "[升级失败] $_" -ForegroundColor Red
} finally {
# Only remove the unique, validated staging directory created above.
if ($stageCreated -and (Test-Path -LiteralPath $stage)) { Remove-Item -LiteralPath $stage -Recurse -Force }
Exit-ArtemisInstallationLock -Mutex $lock
}
exit $code
}
try {
Set-Location -LiteralPath $PSScriptRoot
$python = Join-Path $ProjectRoot '.venv\Scripts\python.exe'
if (-not (Test-Path -LiteralPath $python)) { throw '尚未安装项目 Python 环境;请先运行 start.bat 或 start-cn.bat。' }
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw '未安装 Git,无法升级。' }
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot '.git'))) { throw '此目录不含 Git 信息;请改用 Git 克隆或新版发布源码包。' }
# Refuse dirty checkouts before terminating the user's running processes.
$localChanges = & git -C $ProjectRoot status --porcelain 2>$null
if ($LASTEXITCODE -ne 0) { throw '无法检查 Git 工作区。' }
if ($localChanges) { throw '发现本地修改;请先提交或备份,未终止任何任务。' }
Write-Host '======================================================'
Write-Host 'ARTEMIS 独立升级:将终止当前项目的服务、MCP 及任务。' -ForegroundColor Yellow
. (Join-Path $PSScriptRoot 'upgrade_processes.ps1')
Stop-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot
& $python (Join-Path $PSScriptRoot 'update_installation.py') source --root $ProjectRoot
if ($LASTEXITCODE -notin @(0, 10)) { throw '源码升级未完成;配置和历史资料已保留。' }
Stop-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot
# Load the new launcher only AFTER the frozen Git updater has exited.
$prepare = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $ProjectRoot 'scripts\start.ps1'), '-PrepareOnly', '-SkipUpdate', '-NoOpen')
if ($Mirror) { $prepare += @('-Mirror', $Mirror) }
$previousChild = $env:ARTEMIS_UPGRADE_CHILD
$env:ARTEMIS_UPGRADE_CHILD = '1'
try { & powershell.exe @prepare; $prepareCode = $LASTEXITCODE }
finally { $env:ARTEMIS_UPGRADE_CHILD = $previousChild }
if ($prepareCode -ne 0) { throw '新版源码已保留,但环境同步失败;请处理上方错误后重新运行升级脚本。' }
Write-Host '[升级完成] 源码、依赖、网页及 Codex MCP 已检查并同步。' -ForegroundColor Green
Write-Host '[下一步] 运行启动脚本,并在 Codex 中重载 MCP 或重启 Codex。'
exit 0
} catch {
Write-Host "[升级失败] $_" -ForegroundColor Red
exit 1
}
+65
View File
@@ -0,0 +1,65 @@
# Process ownership is determined by checkout paths, never by a port or name alone.
function Get-ArtemisUpgradeProcesses {
param([string]$ProjectRoot, [object[]]$Processes, [int[]]$ProtectedIds = @())
$root = [IO.Path]::GetFullPath($ProjectRoot).TrimEnd('\') + '\'
$venv = $root + '.venv\'
$selected = @{}
foreach ($process in $Processes) {
if ($ProtectedIds -contains [int]$process.ProcessId) { continue }
$executable = [string]$process.ExecutablePath
$command = [string]$process.CommandLine
$python = $process.Name -match '^(python(w)?|artemis(-admin)?)\.exe$'
$inVenv = $executable.StartsWith($venv, [StringComparison]::OrdinalIgnoreCase)
$explicitRoot = $command.Replace('/', '\').IndexOf($root, [StringComparison]::OrdinalIgnoreCase) -ge 0
$entrypoint = $command -match '(?i)(?:-m\s+(?:artemis(?:\s|\.|$)|apps\.admin_console(?:\.|\s)|mcp_server(?:\.|\s|$))|[\\/]artemis(?:\.exe|[\\/]__main__\.py)|[\\/]mcp_server[\\/])'
if (($python -and $inVenv) -or ($explicitRoot -and $entrypoint -and ($python -or $process.Name -eq 'uv.exe'))) {
$selected[[int]$process.ProcessId] = $process
}
}
do {
$added = $false
foreach ($process in $Processes) {
$id = [int]$process.ProcessId
if ($selected.ContainsKey($id) -or $ProtectedIds -contains $id) { continue }
# Shared device services and desktop applications can outlive a task.
if ($process.Name -match '^(adb|codex|chrome|msedge|firefox|emulator|MuMu.*|Nemu.*|Nox.*|MEmu.*|LdVBox.*|HD-Player)\.exe$') { continue }
if ($selected.ContainsKey([int]$process.ParentProcessId)) {
$selected[$id] = $process
$added = $true
}
}
} while ($added)
return @($selected.Values)
}
function Stop-ArtemisUpgradeProcesses {
param([string]$ProjectRoot)
$snapshot = @(Get-CimInstance Win32_Process -ErrorAction Stop)
$protected = @($PID)
$parentId = $PID
do {
$parent = $snapshot | Where-Object { [int]$_.ProcessId -eq $parentId } | Select-Object -First 1
$parentId = if ($parent) { [int]$parent.ParentProcessId } else { 0 }
if ($parentId -and $protected -notcontains $parentId) { $protected += $parentId } else { break }
} while ($true)
$deadline = [DateTime]::UtcNow.AddSeconds(10)
do {
$targets = @(Get-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot -Processes $snapshot -ProtectedIds $protected)
if (-not $targets.Count) { return }
foreach ($target in $targets) {
$id = [int]$target.ProcessId
$current = Get-CimInstance Win32_Process -Filter "ProcessId = $id" -ErrorAction Stop
if (-not $current -or $current.CreationDate -ne $target.CreationDate) { continue }
Write-Host "[STOP] ARTEMIS PID $id ($($target.Name))"
try { Stop-Process -Id $id -Force -ErrorAction Stop }
catch {
if (Get-CimInstance Win32_Process -Filter "ProcessId = $id" -ErrorAction Stop) { throw }
}
}
Start-Sleep -Milliseconds 200
$snapshot = @(Get-CimInstance Win32_Process -ErrorAction Stop)
} while ([DateTime]::UtcNow -lt $deadline)
if (@(Get-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot -Processes $snapshot -ProtectedIds $protected).Count) {
throw 'ARTEMIS processes are still running. Source/dependency updates were not started.'
}
}