Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5941be0f21 | ||
|
|
2038b676e6 | ||
|
|
8b84ee3b9c | ||
|
|
c86cd59b5c | ||
|
|
96012dc986 | ||
|
|
80d7f62eaa | ||
|
|
5f5234ee62 | ||
|
|
28e58089aa | ||
|
|
c1a90102f8 | ||
|
|
ba531adc74 | ||
|
|
04d29b256e | ||
|
|
e6e07f672a | ||
|
|
d32e7e59c3 | ||
|
|
4f8e65b76b | ||
|
|
a2605a2802 | ||
|
|
0588a8d9b3 |
+122
-1
@@ -1384,6 +1384,107 @@ META_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_ui_tap",
|
||||
"description": (
|
||||
"Tippt auf einem Host-Agenten (v.a. Android) auf eine Bildschirm-Position. "
|
||||
"Nutze die x/y-Koordinaten AUS host_ui_dump (Element-Mittelpunkt, echte "
|
||||
"Bildschirm-Pixel) — NICHT aus dem Screenshot (der ist skaliert). Ablauf: "
|
||||
"erst host_ui_dump/host_screenshot (sehen), dann host_ui_tap (steuern)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "Host-ID/Name."},
|
||||
"x": {"type": "integer", "description": "X (Pixel, aus ui_dump)."},
|
||||
"y": {"type": "integer", "description": "Y (Pixel, aus ui_dump)."},
|
||||
},
|
||||
"required": ["host", "x", "y"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_ui_text",
|
||||
"description": (
|
||||
"Schreibt Text in ein Eingabefeld an Position x/y (aus host_ui_dump, "
|
||||
"editable=true). Tippe ggf. vorher mit host_ui_tap ins Feld, damit es "
|
||||
"fokussiert ist."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "Host-ID/Name."},
|
||||
"x": {"type": "integer", "description": "X des Feldes (aus ui_dump)."},
|
||||
"y": {"type": "integer", "description": "Y des Feldes (aus ui_dump)."},
|
||||
"text": {"type": "string", "description": "Einzugebender Text."},
|
||||
},
|
||||
"required": ["host", "x", "y", "text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_ui_swipe",
|
||||
"description": (
|
||||
"Wischt/scrollt auf einem Host-Agenten von (x1,y1) nach (x2,y2). Zum "
|
||||
"Scrollen nach unten z.B. von weiter unten nach weiter oben wischen."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "Host-ID/Name."},
|
||||
"x1": {"type": "integer"}, "y1": {"type": "integer"},
|
||||
"x2": {"type": "integer"}, "y2": {"type": "integer"},
|
||||
"duration_ms": {"type": "integer", "description": "Dauer in ms (Default 300)."},
|
||||
},
|
||||
"required": ["host", "x1", "y1", "x2", "y2"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_ui_key",
|
||||
"description": (
|
||||
"Drueckt eine globale Taste auf einem Host-Agenten (Android): "
|
||||
"back, home, recents, notifications."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "Host-ID/Name."},
|
||||
"key": {"type": "string",
|
||||
"description": "back | home | recents | notifications"},
|
||||
},
|
||||
"required": ["host", "key"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "host_app_launch",
|
||||
"description": (
|
||||
"Startet eine App auf einem Host-Agenten (Android) — per Paketname "
|
||||
"(package, z.B. 'com.google.android.gm') ODER Namens-Suche (query, z.B. "
|
||||
"'Einstellungen'). Danach mit host_screenshot/host_ui_dump weiterarbeiten."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {"type": "string", "description": "Host-ID/Name."},
|
||||
"package": {"type": "string", "description": "Paketname (optional)."},
|
||||
"query": {"type": "string", "description": "App-Name/Teilstring (optional)."},
|
||||
},
|
||||
"required": ["host"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -2686,6 +2787,24 @@ class Agent:
|
||||
+ json.dumps(nodes, ensure_ascii=False, indent=2)
|
||||
)
|
||||
|
||||
# ── Steuern (Meilenstein 3) ──────────────────────────────
|
||||
_UI_ACTIONS = {
|
||||
"host_ui_tap": ("ui_tap", ["x", "y"]),
|
||||
"host_ui_text": ("ui_text", ["x", "y", "text"]),
|
||||
"host_ui_swipe": ("ui_swipe", ["x1", "y1", "x2", "y2", "duration_ms"]),
|
||||
"host_ui_key": ("ui_key", ["key"]),
|
||||
"host_app_launch": ("app_launch", ["package", "query"]),
|
||||
}
|
||||
if name in _UI_ACTIONS:
|
||||
action, keys = _UI_ACTIONS[name]
|
||||
params = {k: arguments[k] for k in keys if arguments.get(k) is not None}
|
||||
result = _post("/internal/host",
|
||||
{"host": host, "action": action, "params": params}, 20)
|
||||
if not result.get("ok"):
|
||||
return f"FEHLER: {result.get('error')}"
|
||||
r = result.get("result") or {}
|
||||
return f"OK ({host}): {r.get('message', 'ausgefuehrt')}"
|
||||
|
||||
return f"FEHLER: unbekanntes Host-Tool {name}"
|
||||
except Exception as exc:
|
||||
return f"FEHLER: Host/Bridge nicht erreichbar: {exc}"
|
||||
@@ -3483,7 +3602,9 @@ class Agent:
|
||||
if name in ("satellite_list", "satellite_devices", "satellite_command"):
|
||||
return self._dispatch_satellite(name, arguments)
|
||||
if name in ("host_list", "host_exec", "host_read", "host_write",
|
||||
"host_info", "host_screenshot", "host_ui_dump"):
|
||||
"host_info", "host_screenshot", "host_ui_dump",
|
||||
"host_ui_tap", "host_ui_text", "host_ui_swipe",
|
||||
"host_ui_key", "host_app_launch"):
|
||||
return self._dispatch_host(name, arguments)
|
||||
if name == "vm_register":
|
||||
pid = (project_id or "").strip()
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
FROM python:3.11-slim-bullseye
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# PyInstaller braucht objdump aus binutils (im slim-Image nicht enthalten).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends binutils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir pyinstaller
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Baut die Windows-.exe des Host-Agents AUF LINUX — via Wine + Windows-Python +
|
||||
# PyInstaller. tobix/pywine bringt Wine + Windows-Python 3.11 mit (PyInstaller
|
||||
# kann NICHT cross-compilen, deshalb der Wine-Umweg).
|
||||
#
|
||||
# Zusaetzlich baut NSIS ein setup.exe, das die Agent-.exe installiert, eine .env
|
||||
# in C:\ProgramData\ARIA-Host-Agent anlegt (falls keine da ist) und den Agent als
|
||||
# automatisch startenden Windows-Dienst (via nssm) einrichtet.
|
||||
FROM tobix/pywine:3.11
|
||||
|
||||
ARG VERSION=0.0.0
|
||||
|
||||
# NSIS (Installer-Compiler, laeuft nativ auf Linux) + Tools fuer nssm.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nsis curl unzip ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
# nssm — Non-Sucking Service Manager (public domain): macht aus der Konsolen-.exe
|
||||
# einen sauberen Windows-Dienst (die .exe selbst spricht das SCM nicht).
|
||||
RUN curl -fsSL https://nssm.cc/release/nssm-2.24.zip -o /tmp/nssm.zip \
|
||||
&& unzip -q /tmp/nssm.zip -d /tmp \
|
||||
&& cp /tmp/nssm-2.24/win64/nssm.exe ./nssm.exe \
|
||||
&& rm -rf /tmp/nssm*
|
||||
|
||||
COPY requirements.txt host_agent.py ./
|
||||
COPY windows/installer.nsi ./
|
||||
|
||||
# Windows-Python-Deps + PyInstaller, dann die Onefile-.exe bauen.
|
||||
RUN wine pip install --no-cache-dir -r requirements.txt pyinstaller
|
||||
RUN wine pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py \
|
||||
&& cp dist/aria-host-agent.exe ./aria-host-agent.exe \
|
||||
&& makensis -DVERSION=${VERSION} installer.nsi
|
||||
|
||||
# Beide Artefakte rausreichen (dist/ wird vom build-win.sh als Volume gemountet).
|
||||
CMD ["bash","-lc","cp dist/aria-host-agent.exe /out/ && cp aria-host-agent-setup.exe /out/ && echo 'OK -> /out/aria-host-agent.exe + /out/aria-host-agent-setup.exe'"]
|
||||
+43
-1
@@ -46,11 +46,23 @@ PyInstaller kann **nicht cross-kompilieren** — jede Binary wird auf ihrem OS g
|
||||
./build-native.sh # -> dist/aria-host-agent
|
||||
```
|
||||
|
||||
**Windows** — PyInstaller (Python 3 im PATH nötig):
|
||||
**Windows — nativ** (auf einem Windows-Rechner, Python 3 im PATH nötig):
|
||||
```bat
|
||||
build-native.bat REM -> dist\aria-host-agent.exe
|
||||
```
|
||||
|
||||
**Windows — aus Docker heraus (auf Linux!), inkl. Installer** — Wine baut die
|
||||
`.exe`, NSIS packt ein `setup.exe`, das den Agent als Windows-Dienst einrichtet:
|
||||
```bash
|
||||
./build-win.sh [version]
|
||||
# -> dist/aria-host-agent.exe (Konsolen-Binary)
|
||||
# -> dist/aria-host-agent-setup.exe (Installer: Dienst + .env in ProgramData)
|
||||
```
|
||||
Der erste Lauf zieht das `tobix/pywine`-Image (~1–2 GB) und richtet die Wine-
|
||||
Python-Umgebung ein — das dauert; Folge-Builds sind schnell. PyInstaller kann
|
||||
nicht cross-compilen, deshalb der Wine-Umweg. **macOS geht so NICHT** (Apple
|
||||
lässt sich nicht legal aus Docker bauen) — dort `./build-native.sh` auf einem Mac.
|
||||
|
||||
### Docker scheitert? (Live-ISO / overlayfs-Root)
|
||||
|
||||
Wenn `build.sh` mit `failed to mount … overlayfs … invalid argument` abbricht,
|
||||
@@ -96,6 +108,36 @@ Danach: `systemctl status aria-host-agent` · `journalctl -u aria-host-agent -f`
|
||||
Die Binary sucht er unter `dist/aria-host-agent` bzw. `./aria-host-agent` (oder
|
||||
2. Argument). Braucht `dialog` für den Dateibrowser (bietet die Installation an).
|
||||
|
||||
### Windows-Dienst (setup.exe)
|
||||
|
||||
`aria-host-agent-setup.exe` (aus `build-win.sh` oder dem Gitea-Release) als
|
||||
Administrator ausführen. Der Installer:
|
||||
- kopiert die `.exe` nach `%ProgramFiles%\ARIA Host-Agent`,
|
||||
- legt `%ProgramData%\ARIA-Host-Agent\.env` an (nur falls noch keine da ist),
|
||||
- richtet über **nssm** den Dienst **ARIA Host-Agent** ein (Autostart) und startet ihn.
|
||||
|
||||
Danach die `.env` unter `%ProgramData%\ARIA-Host-Agent\` mit RVS-Zugang +
|
||||
`CONTROL_ENABLED=true` füllen und den Dienst neu starten (`services.msc` →
|
||||
*ARIA Host-Agent*, oder `nssm restart ARIAHostAgent`). Deinstallation über
|
||||
*Apps & Features* → *ARIA Host-Agent* (die `.env` in ProgramData bleibt erhalten).
|
||||
|
||||
## Release (Binaries als Gitea-Assets)
|
||||
|
||||
`release_agent.sh <version>` baut alles Docker-Baubare und hängt es als
|
||||
**Release-Asset** an den Tag `agent-v<version>` — nichts landet im Git-Tree:
|
||||
|
||||
```bash
|
||||
./release_agent.sh 0.2.0 # Linux + Android + Windows (Wine)
|
||||
SKIP_WINDOWS=1 ./release_agent.sh 0.2.0 # ohne Windows (schneller)
|
||||
```
|
||||
|
||||
Assets: `aria-host-agent-linux-x64`, `aria-host-agent-android-agent-v<v>.apk`,
|
||||
`aria-host-agent-windows.exe`, `aria-host-agent-windows-setup.exe`. **macOS** ist
|
||||
nicht Docker-baubar — auf einem Mac `./build-native.sh` laufen lassen und das
|
||||
Ergebnis vor dem Release nach `dist/aria-host-agent-macos` legen, dann nimmt das
|
||||
Skript es automatisch mit. Gitea-Zugang via `.env`/Umgebung (`GITEA_URL`,
|
||||
`GITEA_REPO`, `GITEA_USER`), Kennwort wird abgefragt.
|
||||
|
||||
## TLS / SNI — Agent im selben Netz wie der RVS
|
||||
|
||||
Steht der Rechner im **selben Netz wie der RVS** (z.B. Rechenzentrum) und soll
|
||||
|
||||
@@ -6,3 +6,6 @@ local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
captures/
|
||||
|
||||
# Signaturschluessel — NUR lokal, niemals ins oeffentliche Repo (Backup machen!)
|
||||
aria-agent.keystore
|
||||
|
||||
@@ -111,21 +111,88 @@ identisch, nur der Wecker ändert sich.
|
||||
`ui_dump` (`AriaAccessibilityService`, nur lesend → Brain-Tool `host_ui_dump`).
|
||||
Freigabe einmalig in der App: „Bildschirm-Zugriff erlauben" + „Bedienungshilfe
|
||||
öffnen". → ARIA sieht den Schirm und liest die UI-Elemente mit Koordinaten.
|
||||
3. **Steuern** — `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` über den
|
||||
AccessibilityService. → ARIA bedient Apps (E-Mail-Setup).
|
||||
4. **Feinschliff** — `info`/`app_list`/`notify`, Build-Härtung, `release.sh`
|
||||
(Version-Param → Gitea-Release-Asset, wie die App).
|
||||
3. **✅ Steuern** — `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` über den
|
||||
AccessibilityService (`canPerformGestures`, `dispatchGesture`, `ACTION_SET_TEXT`,
|
||||
`performGlobalAction`) → Brain-Tools `host_ui_tap`/`_text`/`_swipe`/`_key`/
|
||||
`host_app_launch`. Tap-Koordinaten = die x/y aus `ui_dump` (echte Pixel), NICHT
|
||||
aus dem (skalierten) Screenshot. → ARIA bedient Apps (E-Mail-Setup).
|
||||
4. **Feinschliff** — `app_list`/`notify`, Build-Härtung, `release_agent.sh`.
|
||||
|
||||
## Build & Release (geplant)
|
||||
## Bauen
|
||||
|
||||
APK-Builds laufen **nur unter Linux** — deshalb im Docker-Container. Du brauchst
|
||||
nichts Android-spezifisches installiert, **nur Docker**. Android-SDK, Gradle und
|
||||
Build-Tools zieht der Container selbst (`Dockerfile.build`).
|
||||
|
||||
```bash
|
||||
cd host-agent/android
|
||||
./build.sh # Docker (Android-SDK+Gradle) -> aria-android-agent.apk
|
||||
./release.sh 0.1.0 # baut, taggt, laedt APK als Gitea-Release-Asset hoch
|
||||
./build.sh
|
||||
```
|
||||
|
||||
APK wird manuell aufs Zielgerät kopiert + installiert (unbekannte Quellen
|
||||
erlauben). Gitea-Release macht den Download einfach (wie die Haupt-App).
|
||||
`build.sh` baut das Image `aria-android-agent-build` und lässt darin
|
||||
`gradle assembleDebug` laufen. Ergebnis:
|
||||
|
||||
> Status: **M1 + M2 fertig** (verbinden, `info`, `screenshot`, `ui_dump`).
|
||||
> Als Nächstes Meilenstein 3 (Steuern).
|
||||
```
|
||||
host-agent/android/dist/aria-android-agent.apk
|
||||
```
|
||||
|
||||
Das ist ein **Debug-APK**: auto-signiert mit dem Android-Debug-Key, also direkt
|
||||
installierbar — ohne eigenen Keystore, ohne Play Store.
|
||||
|
||||
### Auf dem Handy installieren
|
||||
|
||||
1. `dist/aria-android-agent.apk` aufs Zielgerät kopieren (USB, Cloud, `adb install
|
||||
dist/aria-android-agent.apk`, …).
|
||||
2. Antippen → Android fragt nach **„Unbekannte Quellen / Aus dieser Quelle
|
||||
installieren erlauben"** → erlauben.
|
||||
3. App öffnen → verbinden (QR/manuell), „Steuerung erlauben" an, für M2 zusätzlich
|
||||
„Bildschirm-Zugriff erlauben" + „Bedienungshilfe öffnen".
|
||||
|
||||
### Was der erste Build kostet
|
||||
|
||||
Der **erste** Lauf lädt viel (JDK-Image, Gradle, Android-SDK, Dependencies) und
|
||||
dauert entsprechend — mehrere Minuten. Folge-Builds sind schnell (Docker-Layer +
|
||||
Gradle-Cache im Image). Häufige Stolperer:
|
||||
|
||||
- **Docker fehlt / kein Zugriff** → `docker`-Rechte prüfen (`docker ps`).
|
||||
- **Overlay-on-Overlay auf einem Live-ISO** („invalid argument" beim Image-Bau) —
|
||||
gleiches Problem wie beim Desktop-Agent auf dem Mint-Live-System; von einer
|
||||
installierten Linux-Kiste bauen.
|
||||
- **Kotlin-/Manifest-Fehler** beim allerersten Bau eines neuen Meilensteins: den
|
||||
Gradle-Fehler posten, das glätten wir schnell.
|
||||
|
||||
### Version setzen
|
||||
|
||||
Bis es `release.sh` gibt, wird die Version in `app/build.gradle` gepflegt
|
||||
(`versionCode` / `versionName`). Aktuell `1` / `0.2.0` (M1+M2).
|
||||
|
||||
## Release
|
||||
|
||||
Das APK ist **kein** Teil des Git-Trees (blaeht sonst die History dauerhaft auf) —
|
||||
es wird als **Release-Asset** an einen Tag gehaengt. Das macht `release_agent.sh`
|
||||
(liegt eine Ebene hoeher, in `host-agent/`):
|
||||
|
||||
```bash
|
||||
cd host-agent
|
||||
./release_agent.sh 0.2.0
|
||||
```
|
||||
|
||||
Das Skript (wie die `release.sh` der Haupt-App, Version als Parameter):
|
||||
|
||||
- setzt die Version (`host_agent.py` → `AGENT_VERSION`, `app/build.gradle` →
|
||||
`versionName`/`versionCode`),
|
||||
- baut **Linux-Binary + Android-APK** per Docker,
|
||||
- committet den Version-Bump, taggt **`agent-v<version>`** (eigener Namespace,
|
||||
kollidiert nicht mit den App-Tags `v<version>`) und pusht,
|
||||
- legt ein Gitea-Release an und laedt die Assets hoch:
|
||||
`aria-host-agent-linux-x64`, `aria-host-agent-android-agent-v<version>.apk`,
|
||||
optional `-macos` / `-windows.exe` (falls in `host-agent/dist/` vorgebaut).
|
||||
|
||||
Gitea-Zugang (`GITEA_URL`, `GITEA_REPO`, `GITEA_USER`) kommt aus der Umgebung oder
|
||||
einer `.env`; das Kennwort wird interaktiv abgefragt. **Binaries landen unter
|
||||
„Releases", nie im Tree.**
|
||||
|
||||
> Status: **M1–M3 fertig** — verbinden, `info`, `screenshot`, `ui_dump` (sehen)
|
||||
> und `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` (steuern);
|
||||
> `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern
|
||||
> (z.B. E-Mail-Konto einrichten). Nächstes: Feinschliff (M4).
|
||||
|
||||
@@ -11,13 +11,33 @@ android {
|
||||
applicationId 'de.hackersoft.ariaagent'
|
||||
minSdk 26
|
||||
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
|
||||
versionCode 1
|
||||
versionName '0.1.0'
|
||||
versionCode 6
|
||||
versionName '0.0.0.6'
|
||||
}
|
||||
|
||||
// Fester Signaturschlüssel: jeder Build signiert mit DEMSELBEN Key, damit
|
||||
// Android neue APKs als Update derselben App akzeptiert (sonst "Konflikt mit
|
||||
// bestehendem Paket"). Die Keystore-Datei liegt NUR lokal (gitignored, nicht
|
||||
// im oeffentlichen Repo) — UNBEDINGT sichern, sonst brechen kuenftige Updates.
|
||||
def ariaKeystore = rootProject.file('aria-agent.keystore')
|
||||
signingConfigs {
|
||||
aria {
|
||||
if (ariaKeystore.exists()) {
|
||||
storeFile ariaKeystore
|
||||
storePassword 'ariaagent'
|
||||
keyAlias 'aria'
|
||||
keyPassword 'ariaagent'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
if (ariaKeystore.exists()) signingConfig signingConfigs.aria
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
if (ariaKeystore.exists()) signingConfig signingConfigs.aria
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<application
|
||||
@@ -30,7 +34,7 @@
|
||||
<service
|
||||
android:name=".AgentService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
android:foregroundServiceType="dataSync|mediaProjection" />
|
||||
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
@@ -43,15 +44,56 @@ class AgentService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
startForeground(NOTIF_ID, buildNotification("startet …"))
|
||||
startForegroundDataSync("startet …")
|
||||
}
|
||||
|
||||
/** Normaler Vordergrund-Start (Typ dataSync). mediaProjection wird NUR beim
|
||||
* Projizieren gesetzt — auf Android 14 darf man diesen Typ sonst nicht ohne
|
||||
* Projection-Token verwenden. */
|
||||
private fun startForegroundDataSync(text: String) {
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
startForeground(NOTIF_ID, buildNotification(text),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, buildNotification(text))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// WICHTIG: Jeder startForegroundService()-Aufruf MUSS binnen ~5s mit
|
||||
// startForeground() beantwortet werden — sonst crasht Android den Prozess
|
||||
// (ForegroundServiceDidNotStartInTimeException). Der Projection-Intent kommt
|
||||
// per startForegroundService, obwohl der Dienst schon laeuft -> hier IMMER
|
||||
// zuerst startForeground aufrufen (idempotent).
|
||||
startForegroundDataSync(status)
|
||||
|
||||
if (intent?.action == ACTION_PROJECTION) {
|
||||
val code = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
|
||||
@Suppress("DEPRECATION")
|
||||
val data = intent.getParcelableExtra<Intent>(EXTRA_RESULT_DATA)
|
||||
if (code != 0 && data != null) ScreenCapturer.start(applicationContext, code, data)
|
||||
if (code != 0 && data != null) {
|
||||
try {
|
||||
// Android verlangt beim Projizieren einen Vordergrund-Dienst vom
|
||||
// Typ mediaProjection — VOR getMediaProjection() setzen, sonst
|
||||
// SecurityException (FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION).
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
startForeground(
|
||||
NOTIF_ID, buildNotification("Bildschirm-Zugriff aktiv"),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION or
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
}
|
||||
ScreenCapturer.start(applicationContext, code, data)
|
||||
if (ScreenCapturer.active) {
|
||||
updateNotification("$status · Bildschirm-Zugriff aktiv")
|
||||
} else {
|
||||
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
ScreenCapturer.lastError = "${e.javaClass.simpleName}: ${e.message}"
|
||||
updateNotification("Bildschirm-Fehler: ${ScreenCapturer.lastError}")
|
||||
}
|
||||
}
|
||||
if (rvs == null) startRvs() // Dienst war frisch -> Verbindung nachziehen
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
+85
@@ -1,11 +1,17 @@
|
||||
package de.hackersoft.ariaagent
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.accessibilityservice.GestureDescription
|
||||
import android.graphics.Path
|
||||
import android.graphics.Rect
|
||||
import android.os.Bundle
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Bedienungshilfe-Dienst (Meilenstein 2 — nur LESEND).
|
||||
@@ -82,6 +88,85 @@ class AriaAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Steuern (Meilenstein 3) ────────────────────────────────────
|
||||
|
||||
/** Tippt auf Bildschirm-Koordinaten (Pixel wie in ui_dump x/y). */
|
||||
fun tap(x: Int, y: Int): JSONObject {
|
||||
val path = Path().apply { moveTo(x.toFloat(), y.toFloat()) }
|
||||
return gesture(path, 0, 60, "Tippen ($x,$y)")
|
||||
}
|
||||
|
||||
/** Wischt von (x1,y1) nach (x2,y2) ueber dauerMs (Scrollen/Swipen). */
|
||||
fun swipe(x1: Int, y1: Int, x2: Int, y2: Int, durationMs: Int): JSONObject {
|
||||
val path = Path().apply {
|
||||
moveTo(x1.toFloat(), y1.toFloat())
|
||||
lineTo(x2.toFloat(), y2.toFloat())
|
||||
}
|
||||
return gesture(path, 0, durationMs.coerceIn(50, 5000).toLong(),
|
||||
"Wischen ($x1,$y1 -> $x2,$y2)")
|
||||
}
|
||||
|
||||
private fun gesture(path: Path, startMs: Long, durationMs: Long, desc: String): JSONObject {
|
||||
val g = GestureDescription.Builder()
|
||||
.addStroke(GestureDescription.StrokeDescription(path, startMs, durationMs))
|
||||
.build()
|
||||
val latch = CountDownLatch(1)
|
||||
val ok = AtomicBoolean(false)
|
||||
val dispatched = dispatchGesture(g, object : GestureResultCallback() {
|
||||
override fun onCompleted(d: GestureDescription?) { ok.set(true); latch.countDown() }
|
||||
override fun onCancelled(d: GestureDescription?) { latch.countDown() }
|
||||
}, null)
|
||||
if (!dispatched) return errMsg("Geste konnte nicht ausgeloest werden ($desc)")
|
||||
try { latch.await(6, TimeUnit.SECONDS) } catch (_: InterruptedException) {}
|
||||
return if (ok.get()) okMsg("$desc ausgefuehrt") else errMsg("$desc abgebrochen/timeout")
|
||||
}
|
||||
|
||||
/** Schreibt Text in ein Eingabefeld an (x,y) — oder in das fokussierte Feld. */
|
||||
fun setText(x: Int, y: Int, text: String): JSONObject {
|
||||
val root = rootInActiveWindow ?: return errMsg("kein aktives Fenster")
|
||||
val node = editableAt(root, x, y)
|
||||
?: root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)?.takeIf { it.isEditable }
|
||||
?: return errMsg("kein Textfeld an ($x,$y) gefunden — vorher ui_tap aufs Feld?")
|
||||
node.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
|
||||
val args = Bundle().apply {
|
||||
putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
|
||||
}
|
||||
val done = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
|
||||
return if (done) okMsg("Text gesetzt (${text.length} Zeichen)")
|
||||
else errMsg("Text setzen fehlgeschlagen (Feld nicht editierbar?)")
|
||||
}
|
||||
|
||||
/** Tiefste editierbare Node, deren Rahmen (x,y) enthaelt. */
|
||||
private fun editableAt(node: AccessibilityNodeInfo?, x: Int, y: Int): AccessibilityNodeInfo? {
|
||||
if (node == null) return null
|
||||
var found: AccessibilityNodeInfo? = null
|
||||
for (i in 0 until node.childCount) {
|
||||
editableAt(node.getChild(i), x, y)?.let { found = it }
|
||||
}
|
||||
if (found != null) return found
|
||||
val r = Rect(); node.getBoundsInScreen(r)
|
||||
return if (node.isEditable && r.contains(x, y)) node else null
|
||||
}
|
||||
|
||||
/** Globale Taste: back/home/recents/notifications. */
|
||||
fun globalKey(name: String): JSONObject {
|
||||
val action = when (name.trim().lowercase()) {
|
||||
"back", "zurueck", "zurück" -> GLOBAL_ACTION_BACK
|
||||
"home", "start", "startseite" -> GLOBAL_ACTION_HOME
|
||||
"recents", "letzte", "uebersicht", "übersicht" -> GLOBAL_ACTION_RECENTS
|
||||
"notifications", "benachrichtigungen" -> GLOBAL_ACTION_NOTIFICATIONS
|
||||
else -> return errMsg("Taste '$name' unbekannt (back/home/recents/notifications)")
|
||||
}
|
||||
return if (performGlobalAction(action)) okMsg("Taste '$name' ausgefuehrt")
|
||||
else errMsg("Taste '$name' fehlgeschlagen")
|
||||
}
|
||||
|
||||
private fun okMsg(m: String): JSONObject =
|
||||
JSONObject().put("ok", true).put("result", JSONObject().put("message", m))
|
||||
|
||||
private fun errMsg(m: String): JSONObject =
|
||||
JSONObject().put("ok", false).put("error", m)
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
var instance: AriaAccessibilityService? = null
|
||||
|
||||
@@ -33,7 +33,10 @@ class RvsClient(
|
||||
@Volatile private var running = false
|
||||
private var pingThread: Thread? = null
|
||||
|
||||
private val caps = listOf("info", "screenshot", "ui_dump") // M3: ui_tap/ui_text/…
|
||||
private val caps = listOf(
|
||||
"info", "screenshot", "ui_dump",
|
||||
"ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch",
|
||||
)
|
||||
|
||||
fun start() {
|
||||
running = true
|
||||
@@ -126,16 +129,28 @@ class RvsClient(
|
||||
&& !target.equals(config.displayName(), true)) return
|
||||
|
||||
val action = payload.optString("action")
|
||||
val result: JSONObject = when {
|
||||
!config.controlEnabled ->
|
||||
err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")
|
||||
action == "info" -> doInfo()
|
||||
action == "screenshot" -> doScreenshot()
|
||||
action == "ui_dump" -> doUiDump()
|
||||
action in listOf("ui_tap", "ui_text", "ui_swipe", "ui_key",
|
||||
"app_launch", "app_list", "notify") ->
|
||||
err("Aktion '$action' kommt in Meilenstein 3 (noch nicht implementiert).")
|
||||
else -> err("Aktion '$action' unbekannt.")
|
||||
val params = payload.optJSONObject("params") ?: JSONObject()
|
||||
// WICHTIG: JEDE Aktion muss ein host_result liefern — auch bei Exception
|
||||
// ODER OutOfMemoryError (Throwable!). Sonst bekommt ARIA statt einer
|
||||
// Fehlermeldung nur einen Timeout (kein Result kommt zurueck).
|
||||
val result: JSONObject = try {
|
||||
when {
|
||||
!config.controlEnabled ->
|
||||
err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")
|
||||
action == "info" -> doInfo()
|
||||
action == "screenshot" -> doScreenshot()
|
||||
action == "ui_dump" -> doUiDump()
|
||||
action == "ui_tap" -> doUiTap(params)
|
||||
action == "ui_text" -> doUiText(params)
|
||||
action == "ui_swipe" -> doUiSwipe(params)
|
||||
action == "ui_key" -> doUiKey(params)
|
||||
action == "app_launch" -> doAppLaunch(params)
|
||||
action in listOf("app_list", "notify") ->
|
||||
err("Aktion '$action' kommt spaeter (noch nicht implementiert).")
|
||||
else -> err("Aktion '$action' unbekannt.")
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
err("Fehler bei '$action': ${t.javaClass.simpleName}: ${t.message}")
|
||||
}
|
||||
result.put("requestId", payload.optString("requestId"))
|
||||
result.put("hostId", config.hostId())
|
||||
@@ -147,11 +162,13 @@ class RvsClient(
|
||||
|
||||
/** Bildschirmfoto — selber Vertrag wie der Desktop-Agent: {format,bytes,base64}. */
|
||||
private fun doScreenshot(): JSONObject {
|
||||
if (!ScreenCapturer.active)
|
||||
if (!ScreenCapturer.active) {
|
||||
val why = ScreenCapturer.lastError?.let { " (letzter Fehler: $it)" } ?: ""
|
||||
return err("Bildschirm-Zugriff nicht erlaubt. In der Agent-App auf dem Handy " +
|
||||
"einmalig 'Bildschirm-Zugriff erlauben' antippen.")
|
||||
"einmalig 'Bildschirm-Zugriff erlauben' antippen.$why")
|
||||
}
|
||||
val png = ScreenCapturer.capture()
|
||||
?: return err("Screenshot fehlgeschlagen (kein Frame). Ist der Bildschirm an?")
|
||||
?: return err("Screenshot fehlgeschlagen: ${ScreenCapturer.lastError ?: "unbekannt"}")
|
||||
val b64 = android.util.Base64.encodeToString(png, android.util.Base64.NO_WRAP)
|
||||
val res = JSONObject().put("format", "png").put("bytes", png.size).put("base64", b64)
|
||||
return JSONObject().put("ok", true).put("result", res)
|
||||
@@ -159,12 +176,75 @@ class RvsClient(
|
||||
|
||||
/** Sichtbare Bedienelemente als Baum (Bedienungshilfe). */
|
||||
private fun doUiDump(): JSONObject {
|
||||
val svc = AriaAccessibilityService.instance
|
||||
?: return err("Bedienungshilfe nicht aktiv. In der Agent-App 'Bedienungshilfe " +
|
||||
"öffnen' antippen und 'ARIA Host-Agent' einschalten.")
|
||||
val svc = a11y() ?: return a11yMissing()
|
||||
return svc.dump()
|
||||
}
|
||||
|
||||
private fun a11y(): AriaAccessibilityService? = AriaAccessibilityService.instance
|
||||
|
||||
private fun a11yMissing(): JSONObject =
|
||||
err("Bedienungshilfe nicht aktiv. In der Agent-App 'Bedienungshilfe öffnen' " +
|
||||
"antippen und 'ARIA Host-Agent' einschalten.")
|
||||
|
||||
/** Tippen auf Koordinaten (Pixel wie in ui_dump x/y). */
|
||||
private fun doUiTap(p: JSONObject): JSONObject {
|
||||
val svc = a11y() ?: return a11yMissing()
|
||||
if (!p.has("x") || !p.has("y")) return err("ui_tap braucht x und y (aus ui_dump).")
|
||||
return svc.tap(p.optInt("x"), p.optInt("y"))
|
||||
}
|
||||
|
||||
/** Text in Feld an (x,y) schreiben. */
|
||||
private fun doUiText(p: JSONObject): JSONObject {
|
||||
val svc = a11y() ?: return a11yMissing()
|
||||
val text = p.optString("text")
|
||||
if (!p.has("x") || !p.has("y")) return err("ui_text braucht x, y und text.")
|
||||
return svc.setText(p.optInt("x"), p.optInt("y"), text)
|
||||
}
|
||||
|
||||
/** Wischen/Scrollen von (x1,y1) nach (x2,y2). */
|
||||
private fun doUiSwipe(p: JSONObject): JSONObject {
|
||||
val svc = a11y() ?: return a11yMissing()
|
||||
if (!p.has("x1") || !p.has("y1") || !p.has("x2") || !p.has("y2"))
|
||||
return err("ui_swipe braucht x1,y1,x2,y2 (optional duration_ms).")
|
||||
return svc.swipe(p.optInt("x1"), p.optInt("y1"), p.optInt("x2"), p.optInt("y2"),
|
||||
p.optInt("duration_ms", 300))
|
||||
}
|
||||
|
||||
/** Globale Taste: back/home/recents/notifications. */
|
||||
private fun doUiKey(p: JSONObject): JSONObject {
|
||||
val svc = a11y() ?: return a11yMissing()
|
||||
return svc.globalKey(p.optString("key"))
|
||||
}
|
||||
|
||||
/** App starten (per Paketname oder Namens-Suche). */
|
||||
private fun doAppLaunch(p: JSONObject): JSONObject {
|
||||
val pm = appCtx.packageManager
|
||||
var pkg = p.optString("package").trim()
|
||||
val query = p.optString("query").trim()
|
||||
if (pkg.isBlank() && query.isNotBlank()) {
|
||||
pkg = resolvePackage(query) ?: return err("Keine App zu '$query' gefunden.")
|
||||
}
|
||||
if (pkg.isBlank()) return err("app_launch braucht 'package' ODER 'query' (App-Name).")
|
||||
val intent = pm.getLaunchIntentForPackage(pkg)
|
||||
?: return err("App '$pkg' nicht installiert oder ohne Start-Symbol.")
|
||||
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
appCtx.startActivity(intent)
|
||||
return JSONObject().put("ok", true)
|
||||
.put("result", JSONObject().put("message", "App '$pkg' gestartet"))
|
||||
}
|
||||
|
||||
/** Paketname per Label-Teilstring finden (case-insensitive). */
|
||||
private fun resolvePackage(query: String): String? {
|
||||
val pm = appCtx.packageManager
|
||||
val q = query.lowercase()
|
||||
val launch = android.content.Intent(android.content.Intent.ACTION_MAIN)
|
||||
.addCategory(android.content.Intent.CATEGORY_LAUNCHER)
|
||||
return pm.queryIntentActivities(launch, 0)
|
||||
.mapNotNull { it.activityInfo }
|
||||
.firstOrNull { pm.getApplicationLabel(it.applicationInfo).toString().lowercase().contains(q) }
|
||||
?.packageName
|
||||
}
|
||||
|
||||
private fun doInfo(): JSONObject {
|
||||
val res = JSONObject()
|
||||
res.put("host", config.displayName())
|
||||
|
||||
@@ -23,10 +23,16 @@ import java.io.ByteArrayOutputStream
|
||||
* Danach laeuft ein stiller VirtualDisplay -> ImageReader, aus dem `capture()`
|
||||
* bei Bedarf das aktuelle Bild als PNG zieht. Kein Google-Dienst.
|
||||
*
|
||||
* Aufgenommen wird direkt in GEKAPPTER Aufloesung (max. 1280 lange Seite): die
|
||||
* MediaProjection skaliert den Bildschirminhalt auf die VirtualDisplay-Groesse.
|
||||
* Das haelt Speicher/Zeit klein (kein 10-MB-Vollbild-Bitmap -> kein OOM/Timeout).
|
||||
*
|
||||
* Der Zugriff geht bei App-Kill / Neustart verloren und muss neu erlaubt werden
|
||||
* (Android-Sicherheit — Projection-Token ist nicht persistierbar).
|
||||
*/
|
||||
object ScreenCapturer {
|
||||
private const val MAX_SIDE = 1280
|
||||
|
||||
private var projection: MediaProjection? = null
|
||||
private var reader: ImageReader? = null
|
||||
private var vdisplay: VirtualDisplay? = null
|
||||
@@ -36,6 +42,10 @@ object ScreenCapturer {
|
||||
private var h = 0
|
||||
private var dpi = 0
|
||||
|
||||
/** Letzter Init-/Capture-Fehler (fuer die Fehlermeldung an ARIA). */
|
||||
@Volatile
|
||||
var lastError: String? = null
|
||||
|
||||
val active: Boolean
|
||||
@Synchronized get() = projection != null
|
||||
|
||||
@@ -43,31 +53,41 @@ object ScreenCapturer {
|
||||
fun start(ctx: Context, resultCode: Int, data: Intent) {
|
||||
stop()
|
||||
val mpm = ctx.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
val mp = mpm.getMediaProjection(resultCode, data) ?: return
|
||||
val mp = mpm.getMediaProjection(resultCode, data) ?: run {
|
||||
lastError = "getMediaProjection lieferte null"
|
||||
return
|
||||
}
|
||||
|
||||
val metrics = DisplayMetrics()
|
||||
val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@Suppress("DEPRECATION")
|
||||
wm.defaultDisplay.getRealMetrics(metrics)
|
||||
w = metrics.widthPixels
|
||||
h = metrics.heightPixels
|
||||
dpi = metrics.densityDpi
|
||||
// Direkt gekappt aufnehmen (lange Seite <= MAX_SIDE), Seitenverhaeltnis wahren.
|
||||
val longSide = maxOf(metrics.widthPixels, metrics.heightPixels)
|
||||
val scale = if (longSide > MAX_SIDE) MAX_SIDE.toFloat() / longSide else 1f
|
||||
w = (metrics.widthPixels * scale).toInt().coerceAtLeast(1)
|
||||
h = (metrics.heightPixels * scale).toInt().coerceAtLeast(1)
|
||||
|
||||
handlerThread = HandlerThread("aria-capture").also { it.start() }
|
||||
handler = Handler(handlerThread!!.looper)
|
||||
|
||||
// Ab Android 14 Pflicht VOR createVirtualDisplay; frueher unschaedlich.
|
||||
mp.registerCallback(object : MediaProjection.Callback() {
|
||||
override fun onStop() { stop() }
|
||||
}, handler)
|
||||
|
||||
val ir = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 2)
|
||||
reader = ir
|
||||
// AUTO_MIRROR = Standard-Flag fuer MediaProjection-Capture (die Projection
|
||||
// selbst autorisiert die Aufnahme, kein Sonderrecht noetig).
|
||||
vdisplay = mp.createVirtualDisplay(
|
||||
"aria-screen", w, h, dpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
ir.surface, null, handler,
|
||||
)
|
||||
projection = mp
|
||||
lastError = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -83,50 +103,47 @@ object ScreenCapturer {
|
||||
handler = null
|
||||
}
|
||||
|
||||
/** PNG-Bytes des aktuellen Bildschirms, oder null. Wartet kurz auf einen Frame. */
|
||||
/** PNG-Bytes des aktuellen Bildschirms, oder null (Grund in lastError). */
|
||||
fun capture(): ByteArray? {
|
||||
val r = reader ?: return null
|
||||
val r = reader ?: run { lastError = "kein aktiver Bildschirm-Reader"; return null }
|
||||
var image: Image? = null
|
||||
var tries = 0
|
||||
while (tries < 20) {
|
||||
// Bis ~3s auf den ersten Frame warten (frischer VirtualDisplay braucht evtl. kurz).
|
||||
while (tries < 37) {
|
||||
image = r.acquireLatestImage()
|
||||
if (image != null) break
|
||||
try { Thread.sleep(80) } catch (_: InterruptedException) {}
|
||||
tries++
|
||||
}
|
||||
if (image == null) return null
|
||||
if (image == null) {
|
||||
lastError = "kein Frame erhalten (Bildschirm an? evtl. DRM-geschuetzter Inhalt)"
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
val pixelStride = plane.pixelStride
|
||||
val rowStride = plane.rowStride
|
||||
val rowPadding = rowStride - pixelStride * w
|
||||
val padded = Bitmap.createBitmap(
|
||||
w + (if (pixelStride > 0) rowPadding / pixelStride else 0),
|
||||
h, Bitmap.Config.ARGB_8888,
|
||||
)
|
||||
padded.copyPixelsFromBuffer(buffer)
|
||||
val cropped = if (rowPadding == 0) padded else Bitmap.createBitmap(padded, 0, 0, w, h)
|
||||
val scaled = downscale(cropped, 1280)
|
||||
val bmpW = w + (if (pixelStride > 0) rowPadding / pixelStride else 0)
|
||||
val bmp = Bitmap.createBitmap(bmpW, h, Bitmap.Config.ARGB_8888)
|
||||
bmp.copyPixelsFromBuffer(buffer)
|
||||
val out = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
if (scaled !== cropped) scaled.recycle()
|
||||
if (cropped !== padded) cropped.recycle()
|
||||
padded.recycle()
|
||||
if (rowPadding == 0) {
|
||||
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
} else {
|
||||
val cropped = Bitmap.createBitmap(bmp, 0, 0, w, h)
|
||||
cropped.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
cropped.recycle()
|
||||
}
|
||||
bmp.recycle()
|
||||
lastError = null
|
||||
out.toByteArray()
|
||||
} catch (_: Exception) {
|
||||
} catch (t: Throwable) {
|
||||
lastError = "Encode-Fehler: ${t.javaClass.simpleName}: ${t.message}"
|
||||
null
|
||||
} finally {
|
||||
try { image?.close() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun downscale(src: Bitmap, maxSide: Int): Bitmap {
|
||||
val longSide = maxOf(src.width, src.height)
|
||||
if (longSide <= maxSide) return src
|
||||
val scale = maxSide.toFloat() / longSide
|
||||
return Bitmap.createScaledBitmap(
|
||||
src, (src.width * scale).toInt(), (src.height * scale).toInt(), true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds"
|
||||
android:canRetrieveWindowContent="true"
|
||||
android:canPerformGestures="true"
|
||||
android:notificationTimeout="100"
|
||||
android:description="@string/accessibility_desc" />
|
||||
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baut die Windows-.exe + setup.exe des Host-Agents AUF LINUX (Wine im Docker).
|
||||
# ./build-win.sh [version]
|
||||
# Ergebnis:
|
||||
# dist/aria-host-agent.exe (Konsolen-Binary)
|
||||
# dist/aria-host-agent-setup.exe (Installer: richtet Windows-Dienst ein)
|
||||
#
|
||||
# Hinweis: Der erste Lauf zieht das tobix/pywine-Image (~1-2 GB) + baut die
|
||||
# Wine-Umgebung — das dauert. Folge-Builds sind schnell.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
VERSION="${1:-0.0.0}"
|
||||
mkdir -p dist
|
||||
docker build -f Dockerfile.win --build-arg VERSION="$VERSION" -t aria-host-agent-win .
|
||||
docker run --rm -v "$(pwd)/dist:/out" aria-host-agent-win
|
||||
echo
|
||||
echo "Fertig: dist/aria-host-agent.exe + dist/aria-host-agent-setup.exe"
|
||||
@@ -64,7 +64,13 @@ def _load_dotenv() -> None:
|
||||
"""Laedt eine .env neben der Binary/dem Script (oder im CWD) in os.environ.
|
||||
Bereits gesetzte Werte gewinnen. Kein python-dotenv noetig."""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
for path in (os.path.join(here, ".env"), os.path.join(os.getcwd(), ".env")):
|
||||
# PyInstaller-onefile: __file__ liegt im Temp-Extract-Dir, NICHT beim .exe/
|
||||
# Binary — deshalb zusaetzlich sys.executable-Ordner (echter Binary-Ort) und
|
||||
# das CWD (z.B. der ProgramData-Ordner, den der Windows-Dienst als AppDir nutzt).
|
||||
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
|
||||
seen = set()
|
||||
candidates = [os.path.join(d, ".env") for d in (exe_dir, here, os.getcwd())]
|
||||
for path in [p for p in candidates if not (p in seen or seen.add(p))]:
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
try:
|
||||
@@ -155,6 +161,9 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
|
||||
# Datei-Transfer-Limit (Base64 durchs RVS).
|
||||
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
|
||||
|
||||
# Version (wird von release_agent.sh beim Release gesetzt).
|
||||
AGENT_VERSION = "0.0.0.6"
|
||||
|
||||
HEARTBEAT_SEC = 25
|
||||
CAPS = ["exec", "read", "write", "info", "screenshot"]
|
||||
|
||||
@@ -291,6 +300,7 @@ def _do_write(params: dict) -> dict:
|
||||
def _do_info(params: dict) -> dict:
|
||||
info = {
|
||||
"host": HOST_NAME, "hostname": socket.gethostname(),
|
||||
"agent_version": AGENT_VERSION,
|
||||
"os": platform.platform(), "kernel": platform.release(),
|
||||
"arch": platform.machine(), "python": platform.python_version(),
|
||||
"user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
|
||||
@@ -414,7 +424,7 @@ class HostAgent:
|
||||
HOST_ID, HOST_NAME, ",".join(CAPS), CONTROL_ENABLED)
|
||||
await self._send({"type": "host_hello", "payload": {
|
||||
"hostId": HOST_ID, "name": HOST_NAME, "os": platform.platform(),
|
||||
"caps": CAPS, "control": CONTROL_ENABLED,
|
||||
"version": AGENT_VERSION, "caps": CAPS, "control": CONTROL_ENABLED,
|
||||
}, "timestamp": int(time.time() * 1000)})
|
||||
|
||||
async def _heartbeat(self) -> None:
|
||||
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/bin/bash
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# ARIA Host-Agent — Release Script
|
||||
# Baut die auf Linux+Docker moeglichen Artefakte und haengt sie als
|
||||
# Gitea-Release-Assets an einen Tag. NICHTS wandert in den Git-Tree —
|
||||
# Binaries leben nur unter "Releases" (blaeht clone/History nicht auf).
|
||||
#
|
||||
# Verwendung: ./release_agent.sh <version> (z.B. ./release_agent.sh 0.2.0)
|
||||
#
|
||||
# Artefakte:
|
||||
# - Linux-x64-Binary (Docker, PyInstaller) -> immer
|
||||
# - Android-APK (Docker, Gradle) -> immer
|
||||
# - macOS / Windows (falls in dist/ vorgebaut) -> optional
|
||||
# (mac/win koennen auf Linux nicht cross-gebaut werden -> build-native.*
|
||||
# auf dem jeweiligen OS laufen lassen, Ergebnis nach host-agent/dist/ legen)
|
||||
#
|
||||
# Eigener Tag-Namespace agent-v<version> (kollidiert NICHT mit den
|
||||
# App-Tags v<version>).
|
||||
#
|
||||
# Gitea-Zugang (GITEA_URL, GITEA_REPO, GITEA_USER) wird aus der Umgebung
|
||||
# oder aus host-agent/.env bzw. der Repo-Wurzel-.env gelesen; das Kennwort
|
||||
# wird interaktiv abgefragt.
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")" # host-agent/
|
||||
SCRIPT_DIR="$(pwd)"
|
||||
ROOT_DIR="$(cd .. && pwd)"
|
||||
|
||||
# ── Farben ───────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
|
||||
# ── Parameter ────────────────────────────────────────────────────────
|
||||
VERSION=${1:?"Usage: ./release_agent.sh <version> (z.B. 0.2.0)"}
|
||||
TAG="agent-v$VERSION"
|
||||
|
||||
# ── Gitea-Konfiguration (env > host-agent/.env > Repo-Wurzel-.env) ────
|
||||
[ -f "$SCRIPT_DIR/.env" ] && source "$SCRIPT_DIR/.env"
|
||||
[ -f "$ROOT_DIR/.env" ] && source "$ROOT_DIR/.env"
|
||||
|
||||
GITEA_URL="${GITEA_URL:?"GITEA_URL nicht gesetzt (in .env oder als Umgebungsvariable)"}"
|
||||
GITEA_REPO="${GITEA_REPO:?"GITEA_REPO nicht gesetzt (z.B. stefan/aria-agent)"}"
|
||||
GITEA_USER="${GITEA_USER:-$(echo "$GITEA_REPO" | cut -d'/' -f1)}"
|
||||
|
||||
echo -e "${CYAN}╔═══════════════════════════════════════════╗${NC}"
|
||||
echo -e "${CYAN}║ ARIA Host-Agent Release — ${TAG}$(printf '%*s' $((14 - ${#TAG})) '')║${NC}"
|
||||
echo -e "${CYAN}╚═══════════════════════════════════════════╝${NC}\n"
|
||||
|
||||
# ── Kennwort ─────────────────────────────────────────────────────────
|
||||
echo -e "${YELLOW}Gitea-Login: ${GITEA_USER}${NC}"
|
||||
read -s -p "Gitea-Kennwort: " GITEA_PASS; echo ""
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -u "${GITEA_USER}:${GITEA_PASS}" "$GITEA_URL/api/v1/user")
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo -e "${RED}Login fehlgeschlagen (HTTP $HTTP_CODE). Kennwort korrekt?${NC}"; exit 1
|
||||
fi
|
||||
echo -e " ${GREEN}✓${NC} Login erfolgreich\n"
|
||||
|
||||
# ── Versionsnummern setzen ───────────────────────────────────────────
|
||||
echo -e "${GREEN}[1/5] Version auf $VERSION setzen...${NC}"
|
||||
# Desktop-Agent
|
||||
sed -i "s/^AGENT_VERSION = \"[^\"]*\"/AGENT_VERSION = \"$VERSION\"/" host_agent.py
|
||||
echo -e " ${GREEN}✓${NC} host_agent.py → AGENT_VERSION $VERSION"
|
||||
# Android: versionName + versionCode (aus Version berechnen; 3- oder 4-stellig)
|
||||
IFS='.' read -ra VP <<< "$VERSION"
|
||||
V1=${VP[0]:-0}; V2=${VP[1]:-0}; V3=${VP[2]:-0}; V4=${VP[3]:-0}
|
||||
VERSION_CODE=$((V1 * 1000000 + V2 * 10000 + V3 * 100 + V4)); [ "$VERSION_CODE" -lt 1 ] && VERSION_CODE=1
|
||||
sed -i "s/versionName '[^']*'/versionName '$VERSION'/" android/app/build.gradle
|
||||
sed -i "s/versionCode [0-9]*/versionCode $VERSION_CODE/" android/app/build.gradle
|
||||
echo -e " ${GREEN}✓${NC} android/app/build.gradle → versionName $VERSION, versionCode $VERSION_CODE\n"
|
||||
|
||||
# ── Bauen (Docker) ───────────────────────────────────────────────────
|
||||
echo -e "${GREEN}[2/5] Linux-Binary + Android-APK bauen (Docker)...${NC}"
|
||||
./build.sh >/dev/null
|
||||
LINUX_BIN="$SCRIPT_DIR/dist/aria-host-agent"
|
||||
[ -f "$LINUX_BIN" ] || { echo -e "${RED}Linux-Binary fehlt: $LINUX_BIN${NC}"; exit 1; }
|
||||
echo -e " ${GREEN}✓${NC} Linux-Binary ($(du -h "$LINUX_BIN" | cut -f1))"
|
||||
|
||||
( cd android && ./build.sh >/dev/null )
|
||||
APK="$SCRIPT_DIR/android/dist/aria-android-agent.apk"
|
||||
[ -f "$APK" ] || { echo -e "${RED}APK fehlt: $APK${NC}"; exit 1; }
|
||||
echo -e " ${GREEN}✓${NC} Android-APK ($(du -h "$APK" | cut -f1))"
|
||||
|
||||
# Windows (.exe + setup.exe) via Wine im Docker. Dauert (pywine-Image ~1-2 GB) —
|
||||
# mit SKIP_WINDOWS=1 ./release_agent.sh <v> ueberspringbar.
|
||||
if [ "${SKIP_WINDOWS:-0}" = "1" ]; then
|
||||
echo -e " ${YELLOW}Windows-Build uebersprungen (SKIP_WINDOWS=1)${NC}"
|
||||
else
|
||||
echo -e " ${CYAN}…${NC} Windows-.exe + setup.exe bauen (Wine, kann dauern)"
|
||||
if ./build-win.sh "$VERSION" >/dev/null 2>&1; then
|
||||
echo -e " ${GREEN}✓${NC} Windows-.exe + setup.exe"
|
||||
else
|
||||
echo -e " ${YELLOW}Windows-Build fehlgeschlagen — Release laeuft ohne Windows weiter.${NC}"
|
||||
echo -e " ${YELLOW}(Einzeln testen: ./build-win.sh $VERSION)${NC}"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Asset-Liste aufbauen: "lokaler_pfad::asset-name"
|
||||
ASSETS=(
|
||||
"$LINUX_BIN::aria-host-agent-linux-x64"
|
||||
"$APK::aria-host-agent-android-$TAG.apk"
|
||||
)
|
||||
# Native Artefakte (nur wenn vorhanden): Windows aus build-win.sh, macOS
|
||||
# vorgebaut (build-native.sh auf einem Mac -> dist/aria-host-agent-macos legen).
|
||||
[ -f "$SCRIPT_DIR/dist/aria-host-agent-macos" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent-macos::aria-host-agent-macos")
|
||||
[ -f "$SCRIPT_DIR/dist/aria-host-agent.exe" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent.exe::aria-host-agent-windows.exe")
|
||||
[ -f "$SCRIPT_DIR/dist/aria-host-agent-setup.exe" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent-setup.exe::aria-host-agent-windows-setup.exe")
|
||||
|
||||
# ── Git-Tag ──────────────────────────────────────────────────────────
|
||||
echo -e "${GREEN}[3/5] Git-Tag $TAG...${NC}"
|
||||
git add host_agent.py android/app/build.gradle
|
||||
git commit -m "release(agent): bump to $VERSION" 2>/dev/null || echo -e " ${YELLOW}Keine Aenderungen zum Committen${NC}"
|
||||
if git rev-parse "$TAG" &>/dev/null; then
|
||||
echo -e " ${YELLOW}Tag $TAG existiert bereits — überspringe${NC}"
|
||||
else
|
||||
git tag "$TAG"; echo -e " ${GREEN}✓${NC} Tag $TAG erstellt"
|
||||
fi
|
||||
git push origin main "$TAG"
|
||||
echo -e " ${GREEN}✓${NC} Tag gepusht\n"
|
||||
|
||||
# ── Gitea-Release ────────────────────────────────────────────────────
|
||||
echo -e "${GREEN}[4/5] Gitea-Release anlegen...${NC}"
|
||||
BODY=$(printf 'ARIA Host-Agent %s\n\nDesktop (Linux) + Android-APK. Auf das Zielgeraet kopieren, siehe README.' "$TAG")
|
||||
BODY_JSON=$(printf '%s' "$BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "$BODY")
|
||||
RESP=$(curl -s -X POST "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" \
|
||||
-u "${GITEA_USER}:${GITEA_PASS}" -H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"Host-Agent $TAG\",\"body\":$BODY_JSON,\"draft\":false,\"prerelease\":false}")
|
||||
RELEASE_ID=$(echo "$RESP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
if [ -z "$RELEASE_ID" ]; then echo -e "${RED}Release fehlgeschlagen:${NC}\n$RESP"; exit 1; fi
|
||||
echo -e " ${GREEN}✓${NC} Release #$RELEASE_ID erstellt\n"
|
||||
|
||||
# ── Assets hochladen ─────────────────────────────────────────────────
|
||||
echo -e "${GREEN}[5/5] Assets hochladen (${#ASSETS[@]})...${NC}"
|
||||
for entry in "${ASSETS[@]}"; do
|
||||
path="${entry%%::*}"; name="${entry##*::}"
|
||||
UP=$(curl -s -X POST \
|
||||
"$GITEA_URL/api/v1/repos/$GITEA_REPO/releases/$RELEASE_ID/assets?name=$name" \
|
||||
-u "${GITEA_USER}:${GITEA_PASS}" -F "attachment=@${path}")
|
||||
if echo "$UP" | grep -q '"name"'; then
|
||||
echo -e " ${GREEN}✓${NC} $name"
|
||||
else
|
||||
echo -e " ${RED}✗ $name fehlgeschlagen:${NC} $UP"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
|
||||
echo -e "${GREEN}║${NC} $GITEA_URL/$GITEA_REPO/releases/tag/$TAG"
|
||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════╝${NC}"
|
||||
@@ -0,0 +1,83 @@
|
||||
; ARIA Host-Agent — Windows-Installer (NSIS, auf Linux mit makensis gebaut).
|
||||
;
|
||||
; Installiert die Agent-.exe nach %ProgramFiles%\ARIA Host-Agent, legt eine .env
|
||||
; in %ProgramData%\ARIA-Host-Agent an (nur falls noch keine da ist — User-Config
|
||||
; bleibt erhalten) und richtet einen automatisch startenden Windows-Dienst via
|
||||
; nssm ein. Der Dienst laeuft mit AppDirectory = ProgramData-Ordner, damit der
|
||||
; Agent die .env von dort (aus dem CWD) liest.
|
||||
|
||||
!ifndef VERSION
|
||||
!define VERSION "0.0.0"
|
||||
!endif
|
||||
!define SVC "ARIAHostAgent"
|
||||
|
||||
Name "ARIA Host-Agent ${VERSION}"
|
||||
OutFile "aria-host-agent-setup.exe"
|
||||
InstallDir "$PROGRAMFILES64\ARIA Host-Agent"
|
||||
RequestExecutionLevel admin
|
||||
Unicode true
|
||||
ShowInstDetails show
|
||||
ShowUninstDetails show
|
||||
|
||||
Var DataDir
|
||||
|
||||
Page directory
|
||||
Page instfiles
|
||||
UninstPage uninstConfirm
|
||||
UninstPage instfiles
|
||||
|
||||
Section "Install"
|
||||
SetOutPath "$INSTDIR"
|
||||
File "aria-host-agent.exe"
|
||||
File "nssm.exe"
|
||||
|
||||
; ProgramData-Ordner fuer die .env bestimmen
|
||||
ReadEnvStr $0 "ProgramData"
|
||||
StrCmp $0 "" 0 +2
|
||||
StrCpy $0 "$PROFILE" ; Fallback, falls %ProgramData% fehlt
|
||||
StrCpy $DataDir "$0\ARIA-Host-Agent"
|
||||
CreateDirectory "$DataDir"
|
||||
|
||||
; .env nur schreiben, wenn noch keine existiert (User-Config nicht ueberschreiben)
|
||||
IfFileExists "$DataDir\.env" env_done 0
|
||||
FileOpen $1 "$DataDir\.env" w
|
||||
FileWrite $1 "# ARIA Host-Agent — Konfiguration (dieser Windows-Dienst liest diese Datei).$\r$\n"
|
||||
FileWrite $1 "# Nach dem Aendern den Dienst neu starten: services.msc -> ARIA Host-Agent.$\r$\n"
|
||||
FileWrite $1 "RVS_HOST=rvs.example.de$\r$\n"
|
||||
FileWrite $1 "RVS_PORT=443$\r$\n"
|
||||
FileWrite $1 "RVS_TLS=true$\r$\n"
|
||||
FileWrite $1 "RVS_TLS_FALLBACK=true$\r$\n"
|
||||
FileWrite $1 "RVS_TOKEN=$\r$\n"
|
||||
FileWrite $1 "RVS_SNI=$\r$\n"
|
||||
FileWrite $1 "HOST_NAME=$\r$\n"
|
||||
FileWrite $1 "CONTROL_ENABLED=true$\r$\n"
|
||||
FileClose $1
|
||||
env_done:
|
||||
|
||||
; Dienst (neu) einrichten — evtl. alten sauber entfernen, dann installieren
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" stop ${SVC}'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" remove ${SVC} confirm'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" install ${SVC} "$INSTDIR\aria-host-agent.exe"'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} AppDirectory "$DataDir"'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} DisplayName "ARIA Host-Agent"'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} Description "ARIA-Fernsteuerung dieses Rechners (RVS-Agent)."'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} Start SERVICE_AUTO_START'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" start ${SVC}'
|
||||
|
||||
; Uninstaller + Eintrag unter "Apps & Features"
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "DisplayName" "ARIA Host-Agent"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "DisplayVersion" "${VERSION}"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "UninstallString" '"$INSTDIR\uninstall.exe"'
|
||||
SectionEnd
|
||||
|
||||
Section "Uninstall"
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" stop ${SVC}'
|
||||
nsExec::ExecToLog '"$INSTDIR\nssm.exe" remove ${SVC} confirm'
|
||||
Delete "$INSTDIR\aria-host-agent.exe"
|
||||
Delete "$INSTDIR\nssm.exe"
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
RMDir "$INSTDIR"
|
||||
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent"
|
||||
; Die .env in %ProgramData%\ARIA-Host-Agent bleibt bewusst erhalten (User-Config).
|
||||
SectionEnd
|
||||
Reference in New Issue
Block a user