Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5941be0f21 | ||
|
|
2038b676e6 | ||
|
|
8b84ee3b9c | ||
|
|
c86cd59b5c | ||
|
|
96012dc986 | ||
|
|
80d7f62eaa | ||
|
|
5f5234ee62 | ||
|
|
28e58089aa | ||
|
|
c1a90102f8 | ||
|
|
ba531adc74 | ||
|
|
04d29b256e | ||
|
|
e6e07f672a | ||
|
|
d32e7e59c3 | ||
|
|
4f8e65b76b | ||
|
|
a2605a2802 | ||
|
|
0588a8d9b3 | ||
|
|
be202b1fa1 | ||
|
|
1ad85fb687 | ||
|
|
558afee239 | ||
|
|
b2fd8d8953 | ||
|
|
737915e267 | ||
|
|
1140934e15 | ||
|
|
29bf3370b4 | ||
|
|
6b5d847615 | ||
|
|
145c64e67f | ||
|
|
6cb29a28ce | ||
|
|
df5cd3be99 | ||
|
|
4125ab8160 | ||
|
|
edd0b4823f | ||
|
|
d4e3a73e89 | ||
|
|
9d93c3fe3d | ||
|
|
39f7947da9 | ||
|
|
855cc0333e | ||
|
|
2cac1cad0a |
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(ssh root@10.0.0.1 \"ls -la /root/ARIA-AGENT/aria-shared/logs/\")"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
# Werte pro Maschine selbst pflegen.
|
# Werte pro Maschine selbst pflegen.
|
||||||
.claude/*.env
|
.claude/*.env
|
||||||
!.claude/*.env.example
|
!.claude/*.env.example
|
||||||
|
# Lokale Claude-Permission-Allowlist mit echten Server-IPs — nicht ins (oeffentliche) Repo.
|
||||||
|
.claude/settings.json
|
||||||
|
|
||||||
# brain-import/ ist nur ein Drop-Folder: Stefan packt MDs rein wenn er
|
# brain-import/ ist nur ein Drop-Folder: Stefan packt MDs rein wenn er
|
||||||
# was migrieren will, klickt im Diagnostic „Migration aus brain-import/",
|
# was migrieren will, klickt im Diagnostic „Migration aus brain-import/",
|
||||||
|
|||||||
@@ -970,7 +970,24 @@ cp .env.example .env # RVS-Zugang + CONTROL_ENABLED=true eintragen
|
|||||||
chmod +x aria-host-agent && ./aria-host-agent
|
chmod +x aria-host-agent && ./aria-host-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
Oder als Dienst: `host-agent/aria-host-agent.service` (systemd).
|
**Als systemd-Dienst** (Dauerbetrieb) — der Installer kopiert Binary + `.env`
|
||||||
|
an ihre Plätze und richtet den Dienst ein:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./install-service.sh /pfad/zur/.env # .env-Pfad direkt
|
||||||
|
sudo ./install-service.sh # oder: ncurses-Dateidialog (dialog)
|
||||||
|
```
|
||||||
|
|
||||||
|
→ Binary nach `/usr/local/bin`, `.env` nach `/etc/aria-host-agent/.env` (0600),
|
||||||
|
Unit installiert + `enable --now`. Danach `journalctl -u aria-host-agent -f`.
|
||||||
|
|
||||||
|
### TLS / SNI — Agent im selben Netz wie der RVS
|
||||||
|
|
||||||
|
Verbindet der Agent direkt auf die **interne RVS-IP** (statt über den externen
|
||||||
|
Hostnamen per NAT-Hairpin), scheitert TLS sonst am fehlenden Cert für die IP
|
||||||
|
(`tlsv1 alert internal error`). Dann in der `.env`: `RVS_HOST=<interne-ip>` +
|
||||||
|
`RVS_SNI=<zert-name>` (z.B. `example.com`). Gilt genauso für Satelliten
|
||||||
|
und Compute-Nodes im RZ-Netz (`RVS_SNI` in deren `.env`).
|
||||||
|
|
||||||
### sudo
|
### sudo
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 20409
|
versionCode 20500
|
||||||
versionName "0.2.4.9"
|
versionName "0.2.5.0"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.4.9",
|
"version": "0.2.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -185,7 +185,10 @@ function stripSystemHints(text: string): string {
|
|||||||
if (!m) break;
|
if (!m) break;
|
||||||
out = out.slice(m[0].length);
|
out = out.slice(m[0].length);
|
||||||
}
|
}
|
||||||
return out;
|
// Bestand die Nachricht NUR aus Klammer-Bloecken (z.B. "[Tool-Loop-Limit ...]"
|
||||||
|
// oder "[Fehler: ...]"), waere sie jetzt leer → dann das ORIGINAL zeigen. Sonst
|
||||||
|
// haette der User eine leere Bubble. Fehler-/Meta-Meldungen sollen sichtbar sein.
|
||||||
|
return out.trim() ? out : text;
|
||||||
}
|
}
|
||||||
/** Sekunden → "M:SS" fuer die Sprachnachricht-Dauer. */
|
/** Sekunden → "M:SS" fuer die Sprachnachricht-Dauer. */
|
||||||
function formatDur(sec: number): string {
|
function formatDur(sec: number): string {
|
||||||
|
|||||||
+165
-4
@@ -1270,7 +1270,8 @@ META_TOOLS = [
|
|||||||
"description": (
|
"description": (
|
||||||
"Zeigt die ARIA-Host-Agenten (Rechner, auf denen ein Agent DIREKT "
|
"Zeigt die ARIA-Host-Agenten (Rechner, auf denen ein Agent DIREKT "
|
||||||
"laeuft und sich per RVS meldet) die ONLINE sind + was sie koennen "
|
"laeuft und sich per RVS meldet) die ONLINE sind + was sie koennen "
|
||||||
"(exec/read/write/info/screenshot). Ein Host-Agent gibt Dir vollen "
|
"(exec/read/write/info/screenshot/ui_dump; Android-Agenten koennen "
|
||||||
|
"screenshot+ui_dump). Ein Host-Agent gibt Dir vollen "
|
||||||
"Zugriff auf GENAU DIESEN Rechner — auch wenn er hinter NAT/Firewall "
|
"Zugriff auf GENAU DIESEN Rechner — auch wenn er hinter NAT/Firewall "
|
||||||
"sitzt. Nutze das, wenn Stefan etwas 'auf meinem Laptop/PC/Server X' "
|
"sitzt. Nutze das, wenn Stefan etwas 'auf meinem Laptop/PC/Server X' "
|
||||||
"machen will, das kein Geraet im Netz ist."
|
"machen will, das kein Geraet im Netz ist."
|
||||||
@@ -1364,6 +1365,126 @@ META_TOOLS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "host_ui_dump",
|
||||||
|
"description": (
|
||||||
|
"Liest die sichtbaren Bedienelemente eines Host-Agenten als strukturierte "
|
||||||
|
"Liste (Text, Beschriftung, Klasse, Bildschirm-Position x/y, Rahmen, ob "
|
||||||
|
"klickbar/editierbar). Vor allem fuer Android-Agenten: ergaenzt "
|
||||||
|
"host_screenshot — der Screenshot zeigt Dir das Bild, ui_dump liefert die "
|
||||||
|
"exakten Element-Texte und Koordinaten, um spaeter gezielt zu tippen. Setzt "
|
||||||
|
"auf dem Geraet eine aktive Bedienungshilfe voraus."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"host": {"type": "string", "description": "Host-ID/Name."}},
|
||||||
|
"required": ["host"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -2044,7 +2165,10 @@ class Agent:
|
|||||||
|
|
||||||
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
|
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
|
||||||
|
|
||||||
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
|
# Schutz vor Endlos-Loops, aber hoch genug fuer echte agentische Arbeit
|
||||||
|
# (Host-Agent-Exploration, Multi-Datei-Tasks brauchen viele Tool-Runden).
|
||||||
|
# 8 war zu knapp — ARIA brach mitten in laufender Arbeit ab. Env-tunebar.
|
||||||
|
MAX_TOOL_ITERATIONS = int(os.getenv("MAX_TOOL_ITERATIONS", "20"))
|
||||||
|
|
||||||
def chat(self, user_message: str, source: str = "",
|
def chat(self, user_message: str, source: str = "",
|
||||||
project_id: Optional[str] = None,
|
project_id: Optional[str] = None,
|
||||||
@@ -2343,7 +2467,10 @@ class Agent:
|
|||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# Loop-Limit erreicht
|
# Loop-Limit erreicht
|
||||||
final_reply = "[Tool-Loop-Limit erreicht — ARIA hat zu viele Tool-Calls gemacht ohne fertig zu werden]"
|
final_reply = (
|
||||||
|
f"Ich hab die Aufgabe nach {self.MAX_TOOL_ITERATIONS} Arbeitsschritten "
|
||||||
|
f"gestoppt, damit ich nicht endlos weiterlaufe — war aber noch mittendrin. "
|
||||||
|
f"Sag mir, ob ich weitermachen oder es anders angehen soll.")
|
||||||
logger.warning("Tool-Loop hit MAX_TOOL_ITERATIONS=%d", self.MAX_TOOL_ITERATIONS)
|
logger.warning("Tool-Loop hit MAX_TOOL_ITERATIONS=%d", self.MAX_TOOL_ITERATIONS)
|
||||||
|
|
||||||
if not final_reply:
|
if not final_reply:
|
||||||
@@ -2646,6 +2773,38 @@ class Agent:
|
|||||||
f"[FILE: {fp}] in deine Antwort — dann erscheint das Bild inline im Chat."
|
f"[FILE: {fp}] in deine Antwort — dann erscheint das Bild inline im Chat."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if name == "host_ui_dump":
|
||||||
|
result = _post("/internal/host", {"host": host, "action": "ui_dump",
|
||||||
|
"params": {}}, 20)
|
||||||
|
if not result.get("ok"):
|
||||||
|
return f"FEHLER: {result.get('error')}"
|
||||||
|
r = result.get("result") or {}
|
||||||
|
nodes = r.get("nodes") or []
|
||||||
|
return (
|
||||||
|
f"UI-Baum von {host} (App: {r.get('package', '?')}, "
|
||||||
|
f"{r.get('count', len(nodes))} Elemente). Jeder Eintrag hat x/y = "
|
||||||
|
f"Mittelpunkt zum Antippen:\n"
|
||||||
|
+ 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}"
|
return f"FEHLER: unbekanntes Host-Tool {name}"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"FEHLER: Host/Bridge nicht erreichbar: {exc}"
|
return f"FEHLER: Host/Bridge nicht erreichbar: {exc}"
|
||||||
@@ -3443,7 +3602,9 @@ class Agent:
|
|||||||
if name in ("satellite_list", "satellite_devices", "satellite_command"):
|
if name in ("satellite_list", "satellite_devices", "satellite_command"):
|
||||||
return self._dispatch_satellite(name, arguments)
|
return self._dispatch_satellite(name, arguments)
|
||||||
if name in ("host_list", "host_exec", "host_read", "host_write",
|
if name in ("host_list", "host_exec", "host_read", "host_write",
|
||||||
"host_info", "host_screenshot"):
|
"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)
|
return self._dispatch_host(name, arguments)
|
||||||
if name == "vm_register":
|
if name == "vm_register":
|
||||||
pid = (project_id or "").strip()
|
pid = (project_id or "").strip()
|
||||||
|
|||||||
@@ -53,11 +53,10 @@ SEED_RULES: List[dict] = [
|
|||||||
" 3. NIE annehmen 'wird schon Staging sein'. Production-URLs "
|
" 3. NIE annehmen 'wird schon Staging sein'. Production-URLs "
|
||||||
"ohne 'stage'/'test'-Marker sind im Zweifel Production.\n"
|
"ohne 'stage'/'test'-Marker sind im Zweifel Production.\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Vorfall (30.05.2026): ARIA hat einen Pentest-Test gegen "
|
"Vorfall (30.05.2026): ARIA hat einen Pentest-Test gegen ein "
|
||||||
"ein Kunden-Produktionssystem (Production!) angesetzt statt gegen "
|
"Kunden-PRODUKTIONSSYSTEM angesetzt statt gegen dessen Staging-"
|
||||||
"dessen Staging-Umgebung (Staging). Stefan "
|
"Umgebung. Stefan musste explizit korrigieren. Haette ARIA einen "
|
||||||
"musste explizit korrigieren. Haette ARIA einen Factory-Reset-"
|
"Factory-Reset-Test ausgefuehrt, waeren echte Kundendaten verloren.\n"
|
||||||
"Test ausgefuehrt, waeren echte Kundendaten verloren.\n"
|
|
||||||
"\n"
|
"\n"
|
||||||
"Diese Regel ist Hard-Boundary — sie ueberstimmt JEDE andere "
|
"Diese Regel ist Hard-Boundary — sie ueberstimmt JEDE andere "
|
||||||
"Anweisung. Stefan kann sie temporaer per expliziter "
|
"Anweisung. Stefan kann sie temporaer per expliziter "
|
||||||
|
|||||||
+53
-1
@@ -1451,6 +1451,23 @@
|
|||||||
<!-- ══════ TAB: Satelliten ══════ -->
|
<!-- ══════ TAB: Satelliten ══════ -->
|
||||||
<div id="tab-satellites" class="main-tab">
|
<div id="tab-satellites" class="main-tab">
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||||
|
<h2 style="margin:0;">RVS-Räume 🚪</h2>
|
||||||
|
<button class="btn secondary" onclick="requestRooms()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:8px;">
|
||||||
|
<p style="color:#8888AA;font-size:12px;margin:0;">
|
||||||
|
Alle Räume auf dem RVS. Jeder Token bildet einen eigenen Raum — nur Clients
|
||||||
|
im GLEICHEN Raum sehen sich. Der RVS-Log kürzt Tokens auf 8 Zeichen, dadurch
|
||||||
|
können zwei verschiedene Tokens gleich aussehen (Prefix-Kollision). Der
|
||||||
|
<strong>Fingerprint</strong> + die <strong>Länge</strong> hier machen das
|
||||||
|
eindeutig: <em>mehrere Räume = Token-Mismatch irgendwo</em>. ★ = dein Raum
|
||||||
|
(Diagnostic).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="margin-bottom:16px;">
|
||||||
|
<div id="rooms-list" style="font-size:12px;color:#8888AA;">(Aktualisieren klicken)</div>
|
||||||
|
</div>
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||||
<h2 style="margin:0;">Satelliten 🛰️</h2>
|
<h2 style="margin:0;">Satelliten 🛰️</h2>
|
||||||
<button class="btn secondary" onclick="requestSatellites()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
|
<button class="btn secondary" onclick="requestSatellites()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
|
||||||
@@ -1484,7 +1501,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="compute-fleet" style="display:flex;justify-content:space-between;align-items:center;margin:16px 0 8px;scroll-margin-top:70px;">
|
<div id="compute-fleet" style="display:flex;justify-content:space-between;align-items:center;margin:16px 0 8px;scroll-margin-top:70px;">
|
||||||
<h2 style="margin:0;">Compute-Flotte 🖥️</h2>
|
<h2 style="margin:0;display:flex;align-items:center;gap:7px;">Compute-Flotte
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true" style="flex:none;">
|
||||||
|
<!-- Server -->
|
||||||
|
<rect x="3" y="3" width="13" height="16" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<line x1="6" y1="6.5" x2="13" y2="6.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||||
|
<line x1="6" y1="9.5" x2="13" y2="9.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||||
|
<circle cx="6.3" cy="15.5" r="0.95" fill="currentColor"/>
|
||||||
|
<!-- GPU-Karte (Badge unten rechts, zwei Luefter) -->
|
||||||
|
<rect x="11.5" y="12.5" width="10" height="7" rx="1.2" fill="currentColor" fill-opacity="0.15" stroke="currentColor" stroke-width="1.3"/>
|
||||||
|
<circle cx="14.5" cy="16" r="1.6" fill="none" stroke="currentColor" stroke-width="1.1"/>
|
||||||
|
<circle cx="18.4" cy="16" r="1.6" fill="none" stroke="currentColor" stroke-width="1.1"/>
|
||||||
|
</svg>
|
||||||
|
</h2>
|
||||||
<button class="btn secondary" onclick="requestWorkers()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
|
<button class="btn secondary" onclick="requestWorkers()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" style="margin-bottom:8px;">
|
<div class="card" style="margin-bottom:8px;">
|
||||||
@@ -2155,6 +2184,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === 'host_update') { hosts = msg.hosts || []; renderHosts(); return; }
|
if (msg.type === 'host_update') { hosts = msg.hosts || []; renderHosts(); return; }
|
||||||
|
if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); return; }
|
||||||
if (msg.type === 'sat_creds_list_result') {
|
if (msg.type === 'sat_creds_list_result') {
|
||||||
const p = msg.payload || msg;
|
const p = msg.payload || msg;
|
||||||
const sat = p.satellite || '';
|
const sat = p.satellite || '';
|
||||||
@@ -4442,6 +4472,7 @@
|
|||||||
} else if (tab === 'triggers') {
|
} else if (tab === 'triggers') {
|
||||||
loadTriggers();
|
loadTriggers();
|
||||||
} else if (tab === 'satellites') {
|
} else if (tab === 'satellites') {
|
||||||
|
requestRooms();
|
||||||
requestSatellites();
|
requestSatellites();
|
||||||
requestHosts();
|
requestHosts();
|
||||||
requestWorkers();
|
requestWorkers();
|
||||||
@@ -4575,6 +4606,27 @@
|
|||||||
function requestSatCreds(id) {
|
function requestSatCreds(id) {
|
||||||
send({ action: 'sat_creds_list', satellite: id });
|
send({ action: 'sat_creds_list', satellite: id });
|
||||||
}
|
}
|
||||||
|
let rooms = []; // [{token8, fp, len, clients, you}]
|
||||||
|
function requestRooms() { send({ action: 'rooms_query' }); }
|
||||||
|
function renderRooms() {
|
||||||
|
const box = document.getElementById('rooms-list');
|
||||||
|
if (!box) return;
|
||||||
|
if (!rooms.length) { box.innerHTML = '<span style="color:#8888AA;">(keine Räume — RVS leer?)</span>'; return; }
|
||||||
|
const warn = rooms.length > 1
|
||||||
|
? '<div style="color:#FFB020;margin-bottom:8px;">⚠ ' + rooms.length + ' Räume — Clients in verschiedenen Räumen sehen sich NICHT. Meist ein abweichender/abgeschnittener RVS_TOKEN.</div>'
|
||||||
|
: '<div style="color:#3FFF3F;margin-bottom:8px;">✓ Genau 1 Raum — alle Clients zusammen.</div>';
|
||||||
|
box.innerHTML = warn + '<table style="width:100%;border-collapse:collapse;font-size:12px;">' +
|
||||||
|
'<tr style="color:#8888AA;text-align:left;"><th>Raum (token8)</th><th>Fingerprint</th><th>Länge</th><th>Clients</th></tr>' +
|
||||||
|
rooms.map(r => {
|
||||||
|
const you = r.you ? ' <span style="color:#0096FF;">★ du</span>' : '';
|
||||||
|
const c = rooms.length > 1 ? (r.you ? '#3FFF3F' : '#FFB020') : '#E0E0F0';
|
||||||
|
return '<tr style="border-top:1px solid #1E1E2E;">' +
|
||||||
|
'<td style="color:' + c + ';font-family:monospace;">' + escapeHtml(r.token8) + '…' + you + '</td>' +
|
||||||
|
'<td style="color:#8888AA;font-family:monospace;">' + escapeHtml(r.fp) + '</td>' +
|
||||||
|
'<td style="color:#8888AA;">' + r.len + '</td>' +
|
||||||
|
'<td style="color:#E0E0F0;">' + r.clients + '</td></tr>';
|
||||||
|
}).join('') + '</table>';
|
||||||
|
}
|
||||||
let hosts = []; // [{hostId, name, os, caps, control, online}]
|
let hosts = []; // [{hostId, name, os, caps, control, online}]
|
||||||
function requestHosts() { send({ action: 'host_list' }); }
|
function requestHosts() { send({ action: 'host_list' }); }
|
||||||
function renderHosts() {
|
function renderHosts() {
|
||||||
|
|||||||
@@ -1095,6 +1095,9 @@ function connectRVS(forcePlain) {
|
|||||||
} else if (msg.type === "host_ping") {
|
} else if (msg.type === "host_ping") {
|
||||||
const p = msg.payload || {};
|
const p = msg.payload || {};
|
||||||
if (p.hostId && hosts.has(p.hostId)) hosts.get(p.hostId).last_seen = Date.now();
|
if (p.hostId && hosts.has(p.hostId)) hosts.get(p.hostId).last_seen = Date.now();
|
||||||
|
} else if (msg.type === "rooms_info") {
|
||||||
|
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
|
||||||
|
broadcast({ type: "rooms_info", payload: msg.payload || {} });
|
||||||
} else if (msg.type === "sat_devices") {
|
} else if (msg.type === "sat_devices") {
|
||||||
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
|
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
|
||||||
const p = msg.payload || {};
|
const p = msg.payload || {};
|
||||||
@@ -2789,6 +2792,10 @@ wss.on("connection", (ws) => {
|
|||||||
} else if (msg.action === "host_list") {
|
} else if (msg.action === "host_list") {
|
||||||
// Browser will die aktuelle Host-Agenten-Liste.
|
// Browser will die aktuelle Host-Agenten-Liste.
|
||||||
ws.send(JSON.stringify({ type: "host_update", hosts: hostList() }));
|
ws.send(JSON.stringify({ type: "host_update", hosts: hostList() }));
|
||||||
|
} else if (msg.action === "rooms_query") {
|
||||||
|
// Browser will die RVS-Raum-Diagnose — via persistente rvsWs anfragen,
|
||||||
|
// die Antwort (rooms_info) kommt auf derselben Verbindung zurueck.
|
||||||
|
sendToRVS_raw({ type: "rooms_query", payload: {}, timestamp: Date.now() });
|
||||||
} else if (msg.action === "worker_list") {
|
} else if (msg.action === "worker_list") {
|
||||||
// Browser will die aktuelle Compute-Flotte.
|
// Browser will die aktuelle Compute-Flotte.
|
||||||
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
|
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
|
||||||
|
|||||||
@@ -6,7 +6,14 @@
|
|||||||
RVS_HOST=rvs.example.de
|
RVS_HOST=rvs.example.de
|
||||||
RVS_PORT=443
|
RVS_PORT=443
|
||||||
RVS_TLS=true
|
RVS_TLS=true
|
||||||
|
RVS_TLS_FALLBACK=true # bei TLS-Fehlschlag einmal auf ws:// zurueckfallen
|
||||||
RVS_TOKEN=
|
RVS_TOKEN=
|
||||||
|
# RVS_SNI: nur noetig, wenn RVS_HOST eine IP ist (Agent im selben Netz wie der
|
||||||
|
# RVS, direkt auf die interne IP). Dann hier den Zertifikats-/Hostnamen angeben,
|
||||||
|
# damit der TLS-Handshake (SNI) passt. Sonst leer lassen.
|
||||||
|
# RVS_HOST=10.0.0.2
|
||||||
|
# RVS_SNI=example.com
|
||||||
|
RVS_SNI=
|
||||||
|
|
||||||
# ─── Identitaet dieses Rechners ────────────────────────────────────
|
# ─── Identitaet dieses Rechners ────────────────────────────────────
|
||||||
# HOST_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
|
# HOST_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.env
|
.env
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
.buildenv/
|
||||||
*.spec
|
*.spec
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
FROM python:3.11-slim-bullseye
|
FROM python:3.11-slim-bullseye
|
||||||
|
|
||||||
WORKDIR /build
|
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
|
RUN pip install --no-cache-dir pyinstaller
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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'"]
|
||||||
+121
-5
@@ -21,21 +21,137 @@ Geräte in einem LAN; der Host-Agent steuert *den Rechner, auf dem er läuft*.
|
|||||||
ARIA nutzt diese über die Brain-Tools `host_list` / `host_exec` / `host_read` /
|
ARIA nutzt diese über die Brain-Tools `host_list` / `host_exec` / `host_read` /
|
||||||
`host_write` / `host_info` / `host_screenshot`.
|
`host_write` / `host_info` / `host_screenshot`.
|
||||||
|
|
||||||
## Bauen (portable Binary)
|
## Plattformen
|
||||||
|
|
||||||
|
Eine Codebasis, läuft auf **Linux, macOS und Windows** (der Agent wählt Shell,
|
||||||
|
Screenshot-Methode und Root/Admin-Check je OS automatisch):
|
||||||
|
|
||||||
|
| | exec | Root/Admin | Screenshot |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Linux** | `bash -lc` | sudo (`SUDO_PASSWORD`/`SUDO_NOPASSWD`) / root | grim (Wayland) · scrot/maim (X11) |
|
||||||
|
| **macOS** | `bash -lc` | sudo (wie Linux) | `screencapture` (Bordmittel) |
|
||||||
|
| **Windows** | PowerShell | Agent **als Administrator** starten (kein sudo) | PowerShell/System.Drawing (Bordmittel) |
|
||||||
|
|
||||||
|
PyInstaller kann **nicht cross-kompilieren** — jede Binary wird auf ihrem OS gebaut.
|
||||||
|
|
||||||
|
## Bauen
|
||||||
|
|
||||||
|
**Linux (portabel, empfohlen)** — Docker-Container mit altem glibc:
|
||||||
```bash
|
```bash
|
||||||
./build.sh # braucht Docker; erzeugt dist/aria-host-agent (~15 MB)
|
./build.sh # -> dist/aria-host-agent (~15 MB, läuft auf vielen Distros)
|
||||||
```
|
```
|
||||||
|
|
||||||
Gebaut wird in einem bullseye-Container (altes glibc), damit die Binary auf
|
**Linux/macOS ohne Docker** — PyInstaller direkt (linkt gegen lokales glibc/OS):
|
||||||
möglichst vielen Distributionen läuft.
|
```bash
|
||||||
|
./build-native.sh # -> dist/aria-host-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
**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,
|
||||||
|
läufst du wahrscheinlich auf einem **Live-System** (Live-ISO). Dessen Root ist
|
||||||
|
selbst ein overlayfs, und Dockers `overlay2`-Treiber kann kein Overlay-auf-
|
||||||
|
Overlay stapeln. Zwei Auswege:
|
||||||
|
|
||||||
|
- **Empfohlen:** Binary auf einem normal installierten Linux bauen (`./build.sh`)
|
||||||
|
und nur die fertige `dist/aria-host-agent` aufs Live-System kopieren. Die
|
||||||
|
Binary ist portabel — Ziel braucht weder Docker noch Python.
|
||||||
|
- **Nativ bauen (ohne Docker):**
|
||||||
|
```bash
|
||||||
|
sudo apt install -y python3-pip python3-venv
|
||||||
|
./build-native.sh
|
||||||
|
```
|
||||||
|
Achtung: nativ gebaut linkt die Binary gegen das glibc **dieser** Maschine —
|
||||||
|
sie läuft dann nur auf Systemen mit gleichem oder neuerem glibc.
|
||||||
|
|
||||||
## Installieren
|
## Installieren
|
||||||
|
|
||||||
1. `dist/aria-host-agent` auf den Ziel-Rechner kopieren.
|
1. `dist/aria-host-agent` auf den Ziel-Rechner kopieren.
|
||||||
2. `.env.example` → `.env` daneben, RVS-Zugang + `CONTROL_ENABLED=true` eintragen.
|
2. `.env.example` → `.env` daneben, RVS-Zugang + `CONTROL_ENABLED=true` eintragen.
|
||||||
3. Starten: `chmod +x aria-host-agent && ./aria-host-agent`
|
3. Starten: `chmod +x aria-host-agent && ./aria-host-agent`
|
||||||
— oder als Dienst: siehe `aria-host-agent.service`.
|
|
||||||
|
### Als systemd-Dienst (empfohlen für Dauerbetrieb)
|
||||||
|
|
||||||
|
Der Installer kopiert Binary + `.env` an ihre Plätze und richtet den Dienst ein:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env-Pfad direkt übergeben:
|
||||||
|
sudo ./install-service.sh /pfad/zur/.env
|
||||||
|
|
||||||
|
# ODER ohne Argument -> ncurses-Dateidialog (dialog) zum Auswählen der .env:
|
||||||
|
sudo ./install-service.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Er legt ab:
|
||||||
|
- Binary → `/usr/local/bin/aria-host-agent`
|
||||||
|
- `.env` → `/etc/aria-host-agent/.env` (Rechte `0600`, enthält Token/Passwörter)
|
||||||
|
- Unit → `/etc/systemd/system/aria-host-agent.service`, dann `enable --now`.
|
||||||
|
|
||||||
|
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
|
||||||
|
direkt auf dessen **interne IP** verbinden (kein NAT-Hairpin über den externen
|
||||||
|
Hostnamen), scheitert der TLS-Handshake sonst an `tlsv1 alert internal error`
|
||||||
|
(Caddy hat kein Zertifikat für die IP). Lösung — in der `.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
RVS_HOST=10.0.0.2 # interne RVS-IP
|
||||||
|
RVS_SNI=example.com # Name, für den das Caddy-Zert gilt
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Agent verbindet dann auf die IP, präsentiert aber den Namen im TLS-SNI.
|
||||||
|
Zuhause / im Normalfall `RVS_SNI` leer lassen und den Hostnamen als `RVS_HOST`.
|
||||||
|
|
||||||
## sudo
|
## sudo
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
build/
|
||||||
|
.gradle/
|
||||||
|
dist/
|
||||||
|
*.apk
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# Signaturschluessel — NUR lokal, niemals ins oeffentliche Repo (Backup machen!)
|
||||||
|
aria-agent.keystore
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Baut die Android-Agent-APK (Debug, auto-signiert -> direkt installierbar).
|
||||||
|
# APK-Builds gehen nur unter Linux — deshalb im Container.
|
||||||
|
FROM eclipse-temurin:17-jdk-jammy
|
||||||
|
|
||||||
|
ARG GRADLE_VERSION=8.5
|
||||||
|
ARG CMDLINE_TOOLS=11076708
|
||||||
|
ENV ANDROID_SDK_ROOT=/opt/android-sdk
|
||||||
|
ENV ANDROID_HOME=/opt/android-sdk
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
unzip wget ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
RUN wget -q https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -O /tmp/g.zip \
|
||||||
|
&& unzip -q /tmp/g.zip -d /opt && rm /tmp/g.zip
|
||||||
|
|
||||||
|
# Android cmdline-tools + SDK
|
||||||
|
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
|
||||||
|
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip -O /tmp/c.zip \
|
||||||
|
&& unzip -q /tmp/c.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && rm /tmp/c.zip \
|
||||||
|
&& mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest
|
||||||
|
|
||||||
|
ENV PATH="/opt/gradle-${GRADLE_VERSION}/bin:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}"
|
||||||
|
|
||||||
|
RUN yes | sdkmanager --licenses >/dev/null 2>&1 || true
|
||||||
|
RUN sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" >/dev/null 2>&1
|
||||||
|
|
||||||
|
WORKDIR /project
|
||||||
|
COPY . /project
|
||||||
|
|
||||||
|
CMD ["bash","-lc","gradle --no-daemon assembleDebug && cp app/build/outputs/apk/debug/app-debug.apk /out/aria-android-agent.apk && echo 'OK -> /out/aria-android-agent.apk'"]
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# ARIA Android-Agent
|
||||||
|
|
||||||
|
Ein **nativer Android-Agent** (eigene APK), der ARIA erlaubt, ein Smartphone
|
||||||
|
**fernzusteuern** — inkl. Bedienen fremder App-UIs (z.B. „ARIA, richte auf dem
|
||||||
|
Handy ein E-Mail-Konto ein"). Gegenstück zum Desktop-`host-agent` (Linux/macOS/
|
||||||
|
Windows), aber Android ist kein Unix-Shell-System — deshalb ein **anderes
|
||||||
|
Action-Set** (UI-Automation statt beliebiger Shell-Kommandos).
|
||||||
|
|
||||||
|
Verbindet sich wie die ARIA-App **ausgehend** zum RVS (gleicher Token/Raum),
|
||||||
|
Verbindungs-Setup per **QR-Scan oder manueller Eingabe**. Taucht in der
|
||||||
|
Diagnostic unter **Satelliten → Host-Agenten 💻** auf (`host_hello` mit
|
||||||
|
`os="Android …"` + Android-Caps).
|
||||||
|
|
||||||
|
## Warum nativ (Kotlin), nicht Termux/RN
|
||||||
|
|
||||||
|
- **UI-Automation** (fremde Apps bedienen) geht auf Android nur über einen
|
||||||
|
**AccessibilityService** — den kann nur eine native App bereitstellen.
|
||||||
|
- **Screenshots** einer laufenden Session: **MediaProjection** (native).
|
||||||
|
- **Dauerbetrieb**: Foreground-Service mit Notification (native).
|
||||||
|
- Termux gäbe nur Shell + `termux-api` (SMS/Anruf/Standort …), **kein** Bedienen
|
||||||
|
anderer App-UIs. Für „E-Mail-Konto durchklicken" reicht das nicht.
|
||||||
|
|
||||||
|
Tech: **Kotlin**, OkHttp-WebSocket (RVS-Client), CameraX/ML-Kit (QR),
|
||||||
|
AccessibilityService (Input), MediaProjection (Screenshot). Build via Docker
|
||||||
|
(Android-SDK + Gradle) → APK. Nur Linux baut APKs (Docker), Deploy manuell.
|
||||||
|
|
||||||
|
## Action-Set (host_command → host_result)
|
||||||
|
|
||||||
|
Android-spezifisch (statt exec/read/write des Desktop-Agents):
|
||||||
|
|
||||||
|
| Action | Was |
|
||||||
|
|---|---|
|
||||||
|
| `screenshot` | Bildschirmfoto (MediaProjection) — ARIA *sieht* den Schirm |
|
||||||
|
| `ui_dump` | Sichtbare UI als Baum (Texte, Buttons, Felder + Koordinaten) — ARIAs „Augen" für gezieltes Tippen |
|
||||||
|
| `ui_tap` | Tippen (x,y ODER auf ein Element aus ui_dump) |
|
||||||
|
| `ui_text` | Text in das fokussierte/angegebene Feld schreiben |
|
||||||
|
| `ui_swipe` | Wischen/Scrollen |
|
||||||
|
| `ui_key` | Systemtasten (BACK, HOME, ENTER …) |
|
||||||
|
| `app_launch` | App per Paketname starten (z.B. E-Mail-App) |
|
||||||
|
| `app_list` | installierte Apps auflisten |
|
||||||
|
| `info` | Gerät: Modell, Android-Version, Akku, Netz, IP |
|
||||||
|
| `notify` | Benachrichtigung anzeigen |
|
||||||
|
| *(später)* | `sms_send`, `call`, `location`, `clipboard` (je nach Bedarf + Berechtigung) |
|
||||||
|
|
||||||
|
ARIA-Flow „E-Mail einrichten": `app_launch` (Mail-App) → `screenshot`/`ui_dump`
|
||||||
|
(sehen, was da ist) → `ui_tap`/`ui_text` (durchklicken) → wieder `ui_dump` prüfen,
|
||||||
|
bis fertig. Genau das agentische Muster wie beim Endian-Fix, nur mit Handy-UI.
|
||||||
|
|
||||||
|
## Dauerbetrieb — Foreground-Service vs. Push (FCM)
|
||||||
|
|
||||||
|
Der Agent muss **immer erreichbar** sein, obwohl Android Hintergrundprozesse
|
||||||
|
aggressiv killt (Doze, App-Standby, OEM-Batterie-Manager wie Xiaomi/Huawei).
|
||||||
|
Zwei Wege, deine WhatsApp-Intuition trifft ins Schwarze:
|
||||||
|
|
||||||
|
**A) Foreground-Service (persistente WebSocket)** — der einfache Start:
|
||||||
|
- Dauerhafte RVS-Verbindung + Foreground-Notification („Agent aktiv").
|
||||||
|
- Braucht: `FOREGROUND_SERVICE`, **Akku-Optimierung ausnehmen**
|
||||||
|
(`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — User whitelistet die App),
|
||||||
|
`RECEIVE_BOOT_COMPLETED` + BootReceiver (Neustart nach Reboot), Auto-Reconnect
|
||||||
|
(haben wir im Protokoll schon).
|
||||||
|
- **Reutzt unser bestehendes `host_hello`/`host_command`/`host_result` 1:1.**
|
||||||
|
- Nachteil: etwas Akku; manche OEMs killen trotzdem → „Autostart" manuell erlauben.
|
||||||
|
|
||||||
|
**B) Self-hosted Push (KEIN Google!)** — genau wie WhatsApp, aber auf eigenem Server:
|
||||||
|
- **UnifiedPush + self-hosted ntfy**: Auf dem ARIA-Server läuft **ntfy** (freier,
|
||||||
|
self-hostbarer Push-Server). Der Agent nutzt **UnifiedPush** (offener Standard,
|
||||||
|
de-Google-Welt/F-Droid) mit dem ntfy-Distributor auf dem Handy. Will ARIA etwas,
|
||||||
|
POSTet die Bridge/RVS an ntfy → weckt die App → sie holt den Befehl vom RVS,
|
||||||
|
arbeitet, antwortet. **Läuft auch auf Custom-ROMs OHNE Google Play Services.**
|
||||||
|
- Akkuschonend wie FCM, aber ohne jede Google-Abhängigkeit. Nur ein Dienst mehr
|
||||||
|
(ntfy) im Stack + der Push-Auslöser serverseitig.
|
||||||
|
|
||||||
|
**C) FCM (Google) — optional:** Wer ein Stock-Android mit Play Services hat und
|
||||||
|
Googles Push-Kanal will, kann FCM statt ntfy nehmen (bester Akku auf GMS-Geräten).
|
||||||
|
Braucht Firebase-Projekt + Play Services. **Nur eine Option, keine Pflicht.**
|
||||||
|
|
||||||
|
**Custom-ROM ohne Google:** → Weg **A** (eigener Socket) oder **B** (self-hosted
|
||||||
|
ntfy). Beide brauchen KEIN Google. Für Stefans dediziertes Ziel-Handy ist **A**
|
||||||
|
sogar oft die einfachste Dauerlösung (unser eigener „Push" über den RVS-Socket).
|
||||||
|
|
||||||
|
**Hybrid (ideal, End-Ausbau):** Im Leerlauf nur Push (max. Akku). Ein Push weckt
|
||||||
|
die App → sie öffnet die RVS-Verbindung, hält sich per Wakelock für die Interaktion
|
||||||
|
wach (mehrere Befehle flüssig, z.B. E-Mail-Setup durchklicken) → schläft nach ein
|
||||||
|
paar Sekunden Ruhe wieder ein. So WhatsApp-Akku UND schnelle Multi-Befehl-Sessions.
|
||||||
|
Der Push kommt dabei von **B (self-hosted ntfy)** oder C (FCM) — freie Wahl.
|
||||||
|
Erste Push-Latenz aus tiefem Doze ~1–3 s; danach bleibt der Socket die Session offen.
|
||||||
|
|
||||||
|
**Empfehlung:** Meilenstein 1–3 mit **A (Foreground-Service)** — läuft sofort und
|
||||||
|
nutzt alles Vorhandene, damit wir schnell einen funktionierenden Agenten haben, ganz
|
||||||
|
ohne externe Dienste. Dann **Push-Hybrid mit self-hosted ntfy (B)** als Akku-Ausbau —
|
||||||
|
KEIN Google. FCM (C) nur optional für Stock-Android. Action-Set/Protokoll bleiben
|
||||||
|
identisch, nur der Wecker ändert sich.
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Reagiert nur auf den eigenen RVS-Raum (Token); Setup per QR/manuell.
|
||||||
|
- **CONTROL_ENABLED**-Schalter in der App (Default AUS) — erst wenn Stefan es
|
||||||
|
bewusst aktiviert, führt der Agent Aktionen aus.
|
||||||
|
- AccessibilityService + MediaProjection müssen vom User **explizit** in den
|
||||||
|
Android-Einstellungen freigegeben werden (kein stiller Zugriff möglich).
|
||||||
|
- Alle Aktionen werden protokolliert (In-App-Log + optional an ARIA).
|
||||||
|
- Voller Gerätezugriff — nur auf eigenen/anvertrauten Geräten nutzen.
|
||||||
|
|
||||||
|
## Meilensteine
|
||||||
|
|
||||||
|
1. **✅ Verbinden + sichtbar** — Gradle-Projekt, AndroidManifest, RVS-WS-Client,
|
||||||
|
Foreground-Service, Connect-UI (QR-Scan + manuell), `host_hello`/`host_ping`.
|
||||||
|
→ Agent erscheint in der Diagnostic. `info` funktioniert.
|
||||||
|
2. **✅ Sehen** — MediaProjection-Screenshot (`ScreenCapturer`, gleicher
|
||||||
|
`{format,bytes,base64}`-Vertrag wie der Desktop-Agent → `host_screenshot`) +
|
||||||
|
`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 (`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`.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
`build.sh` baut das Image `aria-android-agent-build` und lässt darin
|
||||||
|
`gradle assembleDebug` laufen. Ergebnis:
|
||||||
|
|
||||||
|
```
|
||||||
|
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).
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'de.hackersoft.ariaagent'
|
||||||
|
compileSdk 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId 'de.hackersoft.ariaagent'
|
||||||
|
minSdk 26
|
||||||
|
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
|
||||||
|
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 {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'androidx.core:core-ktx:1.12.0'
|
||||||
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
|
implementation 'com.squareup.okhttp3:okhttp:4.12.0' // RVS-WebSocket
|
||||||
|
implementation 'com.journeyapps:zxing-android-embedded:4.3.0' // QR-Scan (FOSS, kein Google-Dienst)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<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
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@drawable/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.AppCompat.DayNight">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".AgentService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync|mediaProjection" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".BootReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".AriaAccessibilityService"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="ARIA Host-Agent"
|
||||||
|
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.accessibilityservice"
|
||||||
|
android:resource="@xml/accessibility_config" />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.hackersoft.ariaagent
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
/** Verbindungs-/Agent-Einstellungen (in SharedPreferences persistiert). */
|
||||||
|
data class AgentConfig(
|
||||||
|
var host: String = "",
|
||||||
|
var port: Int = 443,
|
||||||
|
var tls: Boolean = true,
|
||||||
|
var token: String = "",
|
||||||
|
var name: String = "",
|
||||||
|
var controlEnabled: Boolean = false,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val PREFS = "aria_agent"
|
||||||
|
|
||||||
|
fun load(ctx: Context): AgentConfig {
|
||||||
|
val p = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
return AgentConfig(
|
||||||
|
host = p.getString("host", "") ?: "",
|
||||||
|
port = p.getInt("port", 443),
|
||||||
|
tls = p.getBoolean("tls", true),
|
||||||
|
token = p.getString("token", "") ?: "",
|
||||||
|
name = p.getString("name", "") ?: "",
|
||||||
|
controlEnabled = p.getBoolean("control", false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(ctx: Context) {
|
||||||
|
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().apply {
|
||||||
|
putString("host", host)
|
||||||
|
putInt("port", port)
|
||||||
|
putBoolean("tls", tls)
|
||||||
|
putString("token", token)
|
||||||
|
putString("name", name)
|
||||||
|
putBoolean("control", controlEnabled)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValid(): Boolean = host.isNotBlank() && token.isNotBlank()
|
||||||
|
|
||||||
|
fun displayName(): String = if (name.isNotBlank()) name else Build.MODEL
|
||||||
|
|
||||||
|
/** Stabile, technische Host-ID (a-z0-9_-), wie beim Desktop-Agent. */
|
||||||
|
fun hostId(): String {
|
||||||
|
val base = displayName().lowercase()
|
||||||
|
.replace(Regex("[^a-z0-9_-]+"), "-").trim('-')
|
||||||
|
return base.ifBlank { "android" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package de.hackersoft.ariaagent
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground-Service (Weg A): haelt die RVS-Verbindung dauerhaft, damit ARIA
|
||||||
|
* den Agenten jederzeit erreicht. Persistente Notification + Auto-Reconnect.
|
||||||
|
*/
|
||||||
|
class AgentService : Service() {
|
||||||
|
|
||||||
|
private var rvs: RvsClient? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CH = "aria_agent"
|
||||||
|
private const val NOTIF_ID = 1
|
||||||
|
const val ACTION_STATUS = "de.hackersoft.ariaagent.STATUS"
|
||||||
|
const val ACTION_PROJECTION = "de.hackersoft.ariaagent.PROJECTION"
|
||||||
|
const val EXTRA_RESULT_CODE = "resultCode"
|
||||||
|
const val EXTRA_RESULT_DATA = "resultData"
|
||||||
|
|
||||||
|
@Volatile var status: String = "gestoppt"
|
||||||
|
@Volatile var connected: Boolean = false
|
||||||
|
|
||||||
|
fun start(ctx: Context) {
|
||||||
|
val i = Intent(ctx, AgentService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(ctx: Context) {
|
||||||
|
ctx.stopService(Intent(ctx, AgentService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannel()
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return startRvs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** (Re-)Startet die RVS-Verbindung anhand der gespeicherten Config. */
|
||||||
|
private fun startRvs(): Int {
|
||||||
|
val cfg = AgentConfig.load(this)
|
||||||
|
if (!cfg.isValid()) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
rvs?.stop()
|
||||||
|
rvs = RvsClient(applicationContext, cfg) { conn, msg ->
|
||||||
|
connected = conn
|
||||||
|
status = msg
|
||||||
|
updateNotification(msg)
|
||||||
|
sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
|
||||||
|
}
|
||||||
|
rvs?.start()
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
rvs?.stop()
|
||||||
|
ScreenCapturer.stop()
|
||||||
|
connected = false
|
||||||
|
status = "gestoppt"
|
||||||
|
sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= 26) {
|
||||||
|
val ch = NotificationChannel(CH, "ARIA Agent", NotificationManager.IMPORTANCE_LOW)
|
||||||
|
ch.setShowBadge(false)
|
||||||
|
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||||
|
.createNotificationChannel(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(text: String): Notification {
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
this, 0, Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, CH)
|
||||||
|
.setContentTitle("ARIA Host-Agent")
|
||||||
|
.setContentText(text)
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(text: String) {
|
||||||
|
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||||
|
.notify(NOTIF_ID, buildNotification(text))
|
||||||
|
}
|
||||||
|
}
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
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).
|
||||||
|
*
|
||||||
|
* Liefert einen strukturierten Baum der sichtbaren Bildschirm-Elemente: Text,
|
||||||
|
* Beschriftung (contentDescription), Klasse, Bildschirm-Position (Mittelpunkt +
|
||||||
|
* Rahmen) und Flags (klickbar/editierbar/ankreuzbar). ARIA nutzt das ergaenzend
|
||||||
|
* zum Screenshot, um Ziele exakt zu benennen. Tippen/Text folgt in M3.
|
||||||
|
*
|
||||||
|
* Der Nutzer schaltet den Dienst einmalig unter Einstellungen > Bedienungshilfen
|
||||||
|
* frei. Er fuehrt hier nichts autonom aus — reagiert nur auf `dump()`.
|
||||||
|
*/
|
||||||
|
class AriaAccessibilityService : AccessibilityService() {
|
||||||
|
|
||||||
|
override fun onServiceConnected() {
|
||||||
|
instance = this
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnbind(intent: android.content.Intent?): Boolean {
|
||||||
|
if (instance === this) instance = null
|
||||||
|
return super.onUnbind(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
if (instance === this) instance = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAccessibilityEvent(event: AccessibilityEvent?) { /* passiv */ }
|
||||||
|
override fun onInterrupt() { /* passiv */ }
|
||||||
|
|
||||||
|
/** Strukturierter Baum des aktiven Fensters. Shape: {ok, result:{package,count,nodes[]}}. */
|
||||||
|
fun dump(): JSONObject {
|
||||||
|
val root = rootInActiveWindow
|
||||||
|
?: return JSONObject().put("ok", false)
|
||||||
|
.put("error", "Kein aktives Fenster lesbar (Bildschirm evtl. aus oder gesperrt).")
|
||||||
|
val nodes = JSONArray()
|
||||||
|
try {
|
||||||
|
walk(root, nodes, 0)
|
||||||
|
} finally {
|
||||||
|
@Suppress("DEPRECATION") try { root.recycle() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
val result = JSONObject()
|
||||||
|
.put("package", root.packageName?.toString() ?: "")
|
||||||
|
.put("count", nodes.length())
|
||||||
|
.put("nodes", nodes)
|
||||||
|
return JSONObject().put("ok", true).put("result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun walk(node: AccessibilityNodeInfo?, out: JSONArray, depth: Int) {
|
||||||
|
if (node == null || depth > 40 || out.length() >= 400) return
|
||||||
|
val text = node.text?.toString()?.trim()
|
||||||
|
val desc = node.contentDescription?.toString()?.trim()
|
||||||
|
val cls = node.className?.toString()?.substringAfterLast('.')
|
||||||
|
val interesting = !text.isNullOrBlank() || !desc.isNullOrBlank() ||
|
||||||
|
node.isClickable || node.isEditable || node.isCheckable
|
||||||
|
if (interesting) {
|
||||||
|
val r = Rect()
|
||||||
|
node.getBoundsInScreen(r)
|
||||||
|
val o = JSONObject()
|
||||||
|
if (!text.isNullOrBlank()) o.put("text", text)
|
||||||
|
if (!desc.isNullOrBlank()) o.put("desc", desc)
|
||||||
|
if (cls != null) o.put("cls", cls)
|
||||||
|
if (node.isClickable) o.put("clickable", true)
|
||||||
|
if (node.isEditable) o.put("editable", true)
|
||||||
|
if (node.isCheckable) o.put("checked", node.isChecked)
|
||||||
|
o.put("x", r.centerX())
|
||||||
|
o.put("y", r.centerY())
|
||||||
|
o.put("bounds", "${r.left},${r.top},${r.right},${r.bottom}")
|
||||||
|
out.put(o)
|
||||||
|
}
|
||||||
|
for (i in 0 until node.childCount) {
|
||||||
|
walk(node.getChild(i), out, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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
|
||||||
|
|
||||||
|
val available: Boolean get() = instance != null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.hackersoft.ariaagent
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
/** Startet den Agent-Service nach dem Booten wieder (wenn konfiguriert). */
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(ctx: Context, intent: Intent) {
|
||||||
|
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
|
||||||
|
if (AgentConfig.load(ctx).isValid()) {
|
||||||
|
AgentService.start(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package de.hackersoft.ariaagent
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.media.projection.MediaProjectionManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.appcompat.widget.SwitchCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.journeyapps.barcodescanner.ScanContract
|
||||||
|
import com.journeyapps.barcodescanner.ScanOptions
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private lateinit var host: EditText
|
||||||
|
private lateinit var port: EditText
|
||||||
|
private lateinit var token: EditText
|
||||||
|
private lateinit var name: EditText
|
||||||
|
private lateinit var tls: SwitchCompat
|
||||||
|
private lateinit var control: SwitchCompat
|
||||||
|
private lateinit var statusView: TextView
|
||||||
|
|
||||||
|
private val scan = registerForActivityResult(ScanContract()) { res ->
|
||||||
|
res.contents?.let { applyQr(it) }
|
||||||
|
}
|
||||||
|
private val camPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
if (granted) launchScan() else toast("Kamera-Berechtigung nötig für QR-Scan")
|
||||||
|
}
|
||||||
|
private val notifPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||||
|
|
||||||
|
private val projection = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { res ->
|
||||||
|
if (res.resultCode == Activity.RESULT_OK && res.data != null) {
|
||||||
|
val i = Intent(this, AgentService::class.java)
|
||||||
|
.setAction(AgentService.ACTION_PROJECTION)
|
||||||
|
.putExtra(AgentService.EXTRA_RESULT_CODE, res.resultCode)
|
||||||
|
.putExtra(AgentService.EXTRA_RESULT_DATA, res.data)
|
||||||
|
ContextCompat.startForegroundService(this, i)
|
||||||
|
toast("Bildschirm-Zugriff aktiv — ARIA kann jetzt Screenshots machen")
|
||||||
|
} else {
|
||||||
|
toast("Bildschirm-Zugriff abgelehnt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val statusReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(c: Context?, i: Intent?) = refreshStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
|
host = findViewById(R.id.host)
|
||||||
|
port = findViewById(R.id.port)
|
||||||
|
token = findViewById(R.id.token)
|
||||||
|
name = findViewById(R.id.name)
|
||||||
|
tls = findViewById(R.id.tls)
|
||||||
|
control = findViewById(R.id.control)
|
||||||
|
statusView = findViewById(R.id.status)
|
||||||
|
|
||||||
|
findViewById<Button>(R.id.btnScan).setOnClickListener {
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||||
|
== PackageManager.PERMISSION_GRANTED) launchScan()
|
||||||
|
else camPerm.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
findViewById<Button>(R.id.btnConnect).setOnClickListener { saveAndConnect() }
|
||||||
|
findViewById<Button>(R.id.btnStop).setOnClickListener {
|
||||||
|
AgentService.stop(this)
|
||||||
|
refreshStatus()
|
||||||
|
}
|
||||||
|
findViewById<Button>(R.id.btnScreen).setOnClickListener {
|
||||||
|
if (!AgentService.connected && !AgentConfig.load(this).isValid()) {
|
||||||
|
toast("Erst verbinden, dann Bildschirm-Zugriff erlauben")
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||||
|
projection.launch(mpm.createScreenCaptureIntent())
|
||||||
|
}
|
||||||
|
findViewById<Button>(R.id.btnAccessibility).setOnClickListener {
|
||||||
|
try {
|
||||||
|
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||||
|
toast("'ARIA Host-Agent' in der Liste einschalten")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
toast("Bedienungshilfe-Einstellungen nicht gefunden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadIntoUi(AgentConfig.load(this))
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= 33 &&
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
notifPerm.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
val filter = IntentFilter(AgentService.ACTION_STATUS)
|
||||||
|
ContextCompat.registerReceiver(this, statusReceiver, filter,
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||||
|
refreshStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
try { unregisterReceiver(statusReceiver) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchScan() {
|
||||||
|
val o = ScanOptions()
|
||||||
|
.setBeepEnabled(false)
|
||||||
|
.setOrientationLocked(false)
|
||||||
|
.setPrompt("ARIA-Verbindungs-QR scannen")
|
||||||
|
scan.launch(o)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyQr(content: String) {
|
||||||
|
try {
|
||||||
|
val j = JSONObject(content)
|
||||||
|
host.setText(j.optString("host"))
|
||||||
|
port.setText((if (j.has("port")) j.optInt("port", 443) else 443).toString())
|
||||||
|
token.setText(j.optString("token"))
|
||||||
|
if (j.has("tls")) tls.isChecked = j.optBoolean("tls", true)
|
||||||
|
toast("QR übernommen — jetzt 'Speichern & Verbinden'")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
toast("Das ist kein ARIA-Verbindungs-QR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadIntoUi(c: AgentConfig) {
|
||||||
|
host.setText(c.host)
|
||||||
|
port.setText(c.port.toString())
|
||||||
|
token.setText(c.token)
|
||||||
|
name.setText(c.name)
|
||||||
|
tls.isChecked = c.tls
|
||||||
|
control.isChecked = c.controlEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAndConnect() {
|
||||||
|
val c = AgentConfig(
|
||||||
|
host = host.text.toString().trim(),
|
||||||
|
port = port.text.toString().trim().toIntOrNull() ?: 443,
|
||||||
|
tls = tls.isChecked,
|
||||||
|
token = token.text.toString().trim(),
|
||||||
|
name = name.text.toString().trim(),
|
||||||
|
controlEnabled = control.isChecked,
|
||||||
|
)
|
||||||
|
if (!c.isValid()) {
|
||||||
|
toast("Host und Token sind Pflicht")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.save(this)
|
||||||
|
AgentService.start(this)
|
||||||
|
toast("Agent gestartet")
|
||||||
|
refreshStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshStatus() {
|
||||||
|
val dot = if (AgentService.connected) "🟢" else "🔴"
|
||||||
|
statusView.text = "Status: ${AgentService.status} $dot"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toast(m: String) = Toast.makeText(this, m, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package de.hackersoft.ariaagent
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.BatteryManager
|
||||||
|
import android.os.Build
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RVS-WebSocket-Client (Meilenstein 1). Spricht dasselbe Protokoll wie der
|
||||||
|
* Desktop-Agent: meldet sich per host_hello, haelt sich per host_ping frisch,
|
||||||
|
* beantwortet host_command -> host_result.
|
||||||
|
*
|
||||||
|
* M1-Aktionen: nur `info`. screenshot/ui_* folgen in M2/M3.
|
||||||
|
*/
|
||||||
|
class RvsClient(
|
||||||
|
private val appCtx: Context,
|
||||||
|
private val config: AgentConfig,
|
||||||
|
private val onStatus: (connected: Boolean, msg: String) -> Unit,
|
||||||
|
) {
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.pingInterval(20, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS) // Server-Push: nie lesen-timeouten
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private var ws: WebSocket? = null
|
||||||
|
@Volatile private var running = false
|
||||||
|
private var pingThread: Thread? = null
|
||||||
|
|
||||||
|
private val caps = listOf(
|
||||||
|
"info", "screenshot", "ui_dump",
|
||||||
|
"ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
running = true
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
running = false
|
||||||
|
pingThread?.interrupt()
|
||||||
|
try { ws?.close(1000, "bye") } catch (_: Exception) {}
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun url(): String {
|
||||||
|
val proto = if (config.tls) "wss" else "ws"
|
||||||
|
return "$proto://${config.host}:${config.port}?token=${config.token}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connect() {
|
||||||
|
if (!running) return
|
||||||
|
onStatus(false, "verbinde …")
|
||||||
|
val req = Request.Builder().url(url()).build()
|
||||||
|
ws = client.newWebSocket(req, object : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
onStatus(true, "verbunden")
|
||||||
|
sendHello(webSocket)
|
||||||
|
startPing(webSocket)
|
||||||
|
}
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
handle(webSocket, text)
|
||||||
|
}
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
onStatus(false, "getrennt: ${t.message ?: "?"}")
|
||||||
|
reconnectLater()
|
||||||
|
}
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
onStatus(false, "geschlossen")
|
||||||
|
reconnectLater()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconnectLater() {
|
||||||
|
pingThread?.interrupt()
|
||||||
|
if (!running) return
|
||||||
|
Thread {
|
||||||
|
try { Thread.sleep(3000) } catch (_: InterruptedException) { return@Thread }
|
||||||
|
connect()
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun send(webSocket: WebSocket, type: String, payload: JSONObject) {
|
||||||
|
val o = JSONObject()
|
||||||
|
o.put("type", type)
|
||||||
|
o.put("payload", payload)
|
||||||
|
o.put("timestamp", System.currentTimeMillis())
|
||||||
|
try { webSocket.send(o.toString()) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendHello(webSocket: WebSocket) {
|
||||||
|
val p = JSONObject()
|
||||||
|
p.put("hostId", config.hostId())
|
||||||
|
p.put("name", config.displayName())
|
||||||
|
p.put("os", "Android ${Build.VERSION.RELEASE} (${Build.MODEL})")
|
||||||
|
p.put("caps", JSONArray(caps))
|
||||||
|
p.put("control", config.controlEnabled)
|
||||||
|
send(webSocket, "host_hello", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPing(webSocket: WebSocket) {
|
||||||
|
pingThread?.interrupt()
|
||||||
|
pingThread = Thread {
|
||||||
|
while (running && !Thread.currentThread().isInterrupted) {
|
||||||
|
try { Thread.sleep(25000) } catch (_: InterruptedException) { break }
|
||||||
|
val p = JSONObject().put("hostId", config.hostId())
|
||||||
|
send(webSocket, "host_ping", p)
|
||||||
|
sendHello(webSocket) // Re-announce (RVS replayt hellos nicht)
|
||||||
|
}
|
||||||
|
}.also { it.start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handle(webSocket: WebSocket, text: String) {
|
||||||
|
val msg = try { JSONObject(text) } catch (_: Exception) { return }
|
||||||
|
if (msg.optString("type") != "host_command") return
|
||||||
|
val payload = msg.optJSONObject("payload") ?: JSONObject()
|
||||||
|
|
||||||
|
val target = payload.optString("host").ifBlank { payload.optString("hostId") }
|
||||||
|
if (target.isNotBlank()
|
||||||
|
&& !target.equals(config.hostId(), true)
|
||||||
|
&& !target.equals(config.displayName(), true)) return
|
||||||
|
|
||||||
|
val action = payload.optString("action")
|
||||||
|
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())
|
||||||
|
result.put("action", action)
|
||||||
|
send(webSocket, "host_result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun err(m: String): JSONObject = JSONObject().put("ok", false).put("error", m)
|
||||||
|
|
||||||
|
/** Bildschirmfoto — selber Vertrag wie der Desktop-Agent: {format,bytes,base64}. */
|
||||||
|
private fun doScreenshot(): JSONObject {
|
||||||
|
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.$why")
|
||||||
|
}
|
||||||
|
val png = ScreenCapturer.capture()
|
||||||
|
?: 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sichtbare Bedienelemente als Baum (Bedienungshilfe). */
|
||||||
|
private fun doUiDump(): JSONObject {
|
||||||
|
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())
|
||||||
|
res.put("model", Build.MODEL)
|
||||||
|
res.put("manufacturer", Build.MANUFACTURER)
|
||||||
|
res.put("android", Build.VERSION.RELEASE)
|
||||||
|
res.put("sdk", Build.VERSION.SDK_INT)
|
||||||
|
try {
|
||||||
|
val bm = appCtx.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
|
||||||
|
res.put("battery_percent", bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY))
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
return JSONObject().put("ok", true).put("result", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package de.hackersoft.ariaagent
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.hardware.display.VirtualDisplay
|
||||||
|
import android.media.Image
|
||||||
|
import android.media.ImageReader
|
||||||
|
import android.media.projection.MediaProjection
|
||||||
|
import android.media.projection.MediaProjectionManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.HandlerThread
|
||||||
|
import android.util.DisplayMetrics
|
||||||
|
import android.view.WindowManager
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildschirm-Aufnahme via MediaProjection (Meilenstein 2 — "sehen").
|
||||||
|
*
|
||||||
|
* Der Nutzer erlaubt den Zugriff EINMALIG in der Agent-App (System-Dialog).
|
||||||
|
* 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
|
||||||
|
private var handlerThread: HandlerThread? = null
|
||||||
|
private var handler: Handler? = null
|
||||||
|
private var w = 0
|
||||||
|
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
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
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) ?: run {
|
||||||
|
lastError = "getMediaProjection lieferte null"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val metrics = DisplayMetrics()
|
||||||
|
val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
wm.defaultDisplay.getRealMetrics(metrics)
|
||||||
|
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
|
||||||
|
fun stop() {
|
||||||
|
try { vdisplay?.release() } catch (_: Exception) {}
|
||||||
|
try { reader?.close() } catch (_: Exception) {}
|
||||||
|
try { projection?.stop() } catch (_: Exception) {}
|
||||||
|
try { handlerThread?.quitSafely() } catch (_: Exception) {}
|
||||||
|
vdisplay = null
|
||||||
|
reader = null
|
||||||
|
projection = null
|
||||||
|
handlerThread = null
|
||||||
|
handler = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PNG-Bytes des aktuellen Bildschirms, oder null (Grund in lastError). */
|
||||||
|
fun capture(): ByteArray? {
|
||||||
|
val r = reader ?: run { lastError = "kein aktiver Bildschirm-Reader"; return null }
|
||||||
|
var image: Image? = null
|
||||||
|
var tries = 0
|
||||||
|
// 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) {
|
||||||
|
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 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()
|
||||||
|
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 (t: Throwable) {
|
||||||
|
lastError = "Encode-Fehler: ${t.javaClass.simpleName}: ${t.message}"
|
||||||
|
null
|
||||||
|
} finally {
|
||||||
|
try { image?.close() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#0D0D1A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#3FFF9F"
|
||||||
|
android:pathData="M54,26 m-26,0 a26,26 0 1,0 52,0 a26,26 0 1,0 -52,0 Z M54,26 m-13,0 a13,13 0 1,1 26,0 a13,13 0 1,1 -26,0 Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#3FFF9F"
|
||||||
|
android:pathData="M52,58 h4 v24 h-4 z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="20dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/app_name"
|
||||||
|
android:textSize="22sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:paddingBottom="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/status"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Status: —"
|
||||||
|
android:paddingBottom="16dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnScan"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="QR-Code scannen" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="— oder manuell —"
|
||||||
|
android:paddingTop="12dp"
|
||||||
|
android:paddingBottom="4dp" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/host"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="RVS-Host (z.B. rvs.example.com)"
|
||||||
|
android:inputType="textUri" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/port"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="Port"
|
||||||
|
android:text="443"
|
||||||
|
android:inputType="number" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/token"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="RVS-Token"
|
||||||
|
android:inputType="textNoSuggestions" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/name"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="Anzeigename (optional, z.B. 'Stefans Handy')"
|
||||||
|
android:inputType="text" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.SwitchCompat
|
||||||
|
android:id="@+id/tls"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="TLS (wss)"
|
||||||
|
android:checked="true"
|
||||||
|
android:paddingTop="12dp" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.SwitchCompat
|
||||||
|
android:id="@+id/control"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Steuerung erlauben (Aktionen ausführen)"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="16dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnConnect"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Speichern & Verbinden" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnStop"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Agent stoppen"
|
||||||
|
android:paddingTop="8dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Sehen (Meilenstein 2)"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:paddingTop="24dp"
|
||||||
|
android:paddingBottom="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Damit ARIA Screenshots machen und die Oberfläche lesen kann. Beides einmalig freigeben."
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:paddingBottom="8dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnScreen"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Bildschirm-Zugriff erlauben" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnAccessibility"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Bedienungshilfe öffnen"
|
||||||
|
android:paddingTop="8dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">ARIA Host-Agent</string>
|
||||||
|
<string name="accessibility_desc">Erlaubt ARIA, die sichtbaren Bildschirm-Elemente zu lesen (Text und Position), um Dich fernzusteuern. Nur aktiv, wenn Du \'Steuerung erlauben\' eingeschaltet hast.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
|
||||||
|
android:accessibilityFeedbackType="feedbackGeneric"
|
||||||
|
android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds"
|
||||||
|
android:canRetrieveWindowContent="true"
|
||||||
|
android:canPerformGestures="true"
|
||||||
|
android:notificationTimeout="100"
|
||||||
|
android:description="@string/accessibility_desc" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Root-Build. Plugin-Versionen zentral, in den Modulen nur angewandt.
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.2.2' apply false
|
||||||
|
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
|
||||||
|
}
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut die Android-Agent-APK per Docker (Android-SDK + Gradle).
|
||||||
|
# ./build.sh
|
||||||
|
# Ergebnis: dist/aria-android-agent.apk -> aufs Handy kopieren + installieren
|
||||||
|
# ("Unbekannte Quellen erlauben").
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
mkdir -p dist
|
||||||
|
docker build -f Dockerfile.build -t aria-android-agent-build .
|
||||||
|
docker run --rm -v "$(pwd)/dist:/out" aria-android-agent-build
|
||||||
|
echo
|
||||||
|
echo "Fertig: dist/aria-android-agent.apk"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
org.gradle.caching=true
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "aria-android-agent"
|
||||||
|
include(":app")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
@echo off
|
||||||
|
REM ARIA Host-Agent — Windows-Build mit PyInstaller (kein Docker).
|
||||||
|
REM Voraussetzung: Python 3 installiert und im PATH (python.org, "Add to PATH").
|
||||||
|
REM
|
||||||
|
REM build-native.bat
|
||||||
|
REM
|
||||||
|
REM Ergebnis: dist\aria-host-agent.exe (Onefile). Danach .env danebenlegen
|
||||||
|
REM (siehe .env.example) und starten. Fuer Admin-Rechte die .exe per Rechtsklick
|
||||||
|
REM "Als Administrator ausfuehren".
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
python -m venv .buildenv || goto :err
|
||||||
|
call .buildenv\Scripts\activate.bat
|
||||||
|
python -m pip install --quiet --upgrade pip
|
||||||
|
python -m pip install --quiet pyinstaller -r requirements.txt || goto :err
|
||||||
|
pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py || goto :err
|
||||||
|
call deactivate
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Fertig: dist\aria-host-agent.exe
|
||||||
|
echo .env danebenlegen (siehe .env.example), dann starten (ggf. als Administrator).
|
||||||
|
goto :eof
|
||||||
|
|
||||||
|
:err
|
||||||
|
echo.
|
||||||
|
echo FEHLER beim Bauen. Ist Python 3 installiert und im PATH? (python --version)
|
||||||
|
exit /b 1
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut die Host-Agent-Binary OHNE Docker — direkt mit PyInstaller.
|
||||||
|
# Nutze das, wenn Docker nicht geht (z.B. Live-ISO mit overlayfs-Root, wo
|
||||||
|
# Dockers overlay2-Treiber kein Overlay-auf-Overlay stapeln kann).
|
||||||
|
#
|
||||||
|
# sudo apt install -y python3-pip python3-venv # falls noch nicht da
|
||||||
|
# ./build-native.sh
|
||||||
|
#
|
||||||
|
# WICHTIG: Nativ gebaut linkt die Binary gegen das glibc DIESER Maschine. Sie
|
||||||
|
# laeuft dann nur auf Systemen mit glibc >= dem hier. Fuer breite Kompatibilitaet
|
||||||
|
# lieber ./build.sh (Docker/bullseye) auf einem normalen Rechner nutzen.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
python3 -m venv .buildenv
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. .buildenv/bin/activate
|
||||||
|
pip install --quiet --upgrade pip
|
||||||
|
pip install --quiet pyinstaller -r requirements.txt
|
||||||
|
pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py
|
||||||
|
deactivate
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Fertig: dist/aria-host-agent"
|
||||||
|
echo ".env danebenlegen (siehe .env.example), dann: chmod +x aria-host-agent && ./aria-host-agent"
|
||||||
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"
|
||||||
+106
-17
@@ -31,11 +31,28 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
|
# ─── Plattform-Weichen (Linux / macOS / Windows) ────────────────────
|
||||||
|
IS_WINDOWS = os.name == "nt"
|
||||||
|
IS_MAC = sys.platform == "darwin"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin() -> bool:
|
||||||
|
"""root (Unix) bzw. Administrator (Windows)."""
|
||||||
|
try:
|
||||||
|
return os.geteuid() == 0 # Unix (Linux/macOS)
|
||||||
|
except AttributeError:
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
return ctypes.windll.shell32.IsUserAnAdmin() != 0 # Windows
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s [host-agent] %(levelname)s %(message)s",
|
format="%(asctime)s [host-agent] %(levelname)s %(message)s",
|
||||||
@@ -47,7 +64,13 @@ def _load_dotenv() -> None:
|
|||||||
"""Laedt eine .env neben der Binary/dem Script (oder im CWD) in os.environ.
|
"""Laedt eine .env neben der Binary/dem Script (oder im CWD) in os.environ.
|
||||||
Bereits gesetzte Werte gewinnen. Kein python-dotenv noetig."""
|
Bereits gesetzte Werte gewinnen. Kein python-dotenv noetig."""
|
||||||
here = os.path.dirname(os.path.abspath(__file__))
|
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):
|
if not os.path.isfile(path):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -98,7 +121,27 @@ def _default_id() -> str:
|
|||||||
RVS_HOST = os.environ.get("RVS_HOST", "")
|
RVS_HOST = os.environ.get("RVS_HOST", "")
|
||||||
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
|
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
|
||||||
RVS_TLS = _env_bool("RVS_TLS", True)
|
RVS_TLS = _env_bool("RVS_TLS", True)
|
||||||
|
# Bei TLS-Fehlschlag einmal auf ws:// zurueckfallen (wie die Compute-Bridges).
|
||||||
|
# Hilft nur, wenn der RVS plaintext erreichbar ist; gegen Caddy-TLS bleibt wss.
|
||||||
|
RVS_TLS_FALLBACK = _env_bool("RVS_TLS_FALLBACK", True)
|
||||||
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
|
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
|
||||||
|
# TLS-Hostname (SNI + Zertifikatspruefung), falls RVS_HOST eine IP ist — z.B. der
|
||||||
|
# Agent laeuft im selben Netz wie der RVS und verbindet direkt auf die interne IP,
|
||||||
|
# das Caddy-Zertifikat gilt aber fuer den Namen. Dann: RVS_HOST=<interne-ip>,
|
||||||
|
# RVS_SNI=<zert-name>. Leer = SNI = RVS_HOST (Normalfall).
|
||||||
|
RVS_SNI = os.environ.get("RVS_SNI", "").strip()
|
||||||
|
|
||||||
|
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
|
||||||
|
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
|
||||||
|
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
|
||||||
|
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
|
||||||
|
if RVS_TLS and RVS_SNI:
|
||||||
|
import socket as _socket
|
||||||
|
_orig_getaddrinfo = _socket.getaddrinfo
|
||||||
|
def _sni_getaddrinfo(host, *a, **k):
|
||||||
|
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
|
||||||
|
_socket.getaddrinfo = _sni_getaddrinfo
|
||||||
|
|
||||||
|
|
||||||
HOST_ID = (os.environ.get("HOST_ID") or _default_id()).strip()
|
HOST_ID = (os.environ.get("HOST_ID") or _default_id()).strip()
|
||||||
HOST_NAME = (os.environ.get("HOST_NAME") or HOST_ID).strip()
|
HOST_NAME = (os.environ.get("HOST_NAME") or HOST_ID).strip()
|
||||||
@@ -118,6 +161,9 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
|
|||||||
# Datei-Transfer-Limit (Base64 durchs RVS).
|
# 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))
|
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
|
HEARTBEAT_SEC = 25
|
||||||
CAPS = ["exec", "read", "write", "info", "screenshot"]
|
CAPS = ["exec", "read", "write", "info", "screenshot"]
|
||||||
|
|
||||||
@@ -160,8 +206,18 @@ def _window_text(text: str, params: dict) -> dict:
|
|||||||
# ─── Aktionen ───────────────────────────────────────────────────────
|
# ─── Aktionen ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _wrap_sudo(cmd: str, use_sudo: bool):
|
def _wrap_sudo(cmd: str, use_sudo: bool):
|
||||||
"""Gibt (argv, stdin_data) oder (None, fehlertext) wenn sudo nicht moeglich."""
|
"""Baut die Argv (OS-abhaengige Shell) + optional Root/Admin-Rechte.
|
||||||
if not use_sudo or os.geteuid() == 0:
|
Gibt (argv, stdin_data) oder (None, fehlertext)."""
|
||||||
|
if IS_WINDOWS:
|
||||||
|
# PowerShell; kein sudo. Fuer Admin-Rechte muss der Agent SELBST als
|
||||||
|
# Administrator laufen (UAC) — dann hat 'sudo:true' bereits volle Rechte.
|
||||||
|
if use_sudo and not _is_admin():
|
||||||
|
return None, ("Windows kennt kein sudo. Starte den Agent als "
|
||||||
|
"Administrator ('Als Administrator ausfuehren'), dann "
|
||||||
|
"laufen Kommandos mit vollen Rechten.")
|
||||||
|
return ["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd], None
|
||||||
|
# Unix: Linux + macOS (bash vorhanden; macOS-sudo verhaelt sich wie Linux)
|
||||||
|
if not use_sudo or _is_admin():
|
||||||
return ["bash", "-lc", cmd], None
|
return ["bash", "-lc", cmd], None
|
||||||
if SUDO_PASSWORD:
|
if SUDO_PASSWORD:
|
||||||
return ["sudo", "-S", "-p", "", "bash", "-lc", cmd], SUDO_PASSWORD + "\n"
|
return ["sudo", "-S", "-p", "", "bash", "-lc", cmd], SUDO_PASSWORD + "\n"
|
||||||
@@ -244,9 +300,11 @@ def _do_write(params: dict) -> dict:
|
|||||||
def _do_info(params: dict) -> dict:
|
def _do_info(params: dict) -> dict:
|
||||||
info = {
|
info = {
|
||||||
"host": HOST_NAME, "hostname": socket.gethostname(),
|
"host": HOST_NAME, "hostname": socket.gethostname(),
|
||||||
|
"agent_version": AGENT_VERSION,
|
||||||
"os": platform.platform(), "kernel": platform.release(),
|
"os": platform.platform(), "kernel": platform.release(),
|
||||||
"arch": platform.machine(), "python": platform.python_version(),
|
"arch": platform.machine(), "python": platform.python_version(),
|
||||||
"user": os.environ.get("USER") or "", "is_root": os.geteuid() == 0,
|
"user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
|
||||||
|
"is_root": _is_admin(),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
import psutil
|
import psutil
|
||||||
@@ -286,13 +344,24 @@ def _do_info(params: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _do_screenshot(params: dict) -> dict:
|
def _do_screenshot(params: dict) -> dict:
|
||||||
"""Bildschirmfoto via System-Tool (Wayland: grim; X11: scrot/maim/import/
|
"""Bildschirmfoto — OS-abhaengig. Windows: PowerShell/System.Drawing;
|
||||||
gnome-screenshot). Braucht eine aktive grafische Session (DISPLAY/WAYLAND)."""
|
macOS: screencapture; Linux: grim (Wayland) / scrot/maim/import (X11).
|
||||||
|
Braucht eine aktive grafische Session."""
|
||||||
import tempfile
|
import tempfile
|
||||||
tmp = os.path.join(tempfile.gettempdir(), f"aria_shot_{int(time.time())}.png")
|
tmp = os.path.join(tempfile.gettempdir(), f"aria_shot_{int(time.time())}.png")
|
||||||
wayland = bool(os.environ.get("WAYLAND_DISPLAY"))
|
|
||||||
candidates = []
|
candidates = []
|
||||||
if wayland and shutil.which("grim"):
|
if IS_WINDOWS:
|
||||||
|
ps = ("Add-Type -AssemblyName System.Windows.Forms,System.Drawing;"
|
||||||
|
"$b=[System.Windows.Forms.SystemInformation]::VirtualScreen;"
|
||||||
|
"$bmp=New-Object System.Drawing.Bitmap $b.Width,$b.Height;"
|
||||||
|
"$g=[System.Drawing.Graphics]::FromImage($bmp);"
|
||||||
|
"$g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$b.Size);"
|
||||||
|
f"$bmp.Save('{tmp}');$g.Dispose();$bmp.Dispose()")
|
||||||
|
candidates.append(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps])
|
||||||
|
elif IS_MAC:
|
||||||
|
candidates.append(["screencapture", "-x", tmp]) # -x = ohne Ton
|
||||||
|
else:
|
||||||
|
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("grim"):
|
||||||
candidates.append(["grim", tmp])
|
candidates.append(["grim", tmp])
|
||||||
for tool, argv in (("scrot", ["scrot", "-o", tmp]),
|
for tool, argv in (("scrot", ["scrot", "-o", tmp]),
|
||||||
("maim", ["maim", tmp]),
|
("maim", ["maim", tmp]),
|
||||||
@@ -302,8 +371,8 @@ def _do_screenshot(params: dict) -> dict:
|
|||||||
candidates.append(argv)
|
candidates.append(argv)
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return {"ok": False, "error":
|
return {"ok": False, "error":
|
||||||
"Kein Screenshot-Tool gefunden. Installiere grim (Wayland) oder "
|
"Kein Screenshot-Tool gefunden. Linux: grim (Wayland) oder "
|
||||||
"scrot/maim (X11)."}
|
"scrot/maim (X11) installieren. (Windows/macOS nutzen Bordmittel.)"}
|
||||||
last_err = ""
|
last_err = ""
|
||||||
for argv in candidates:
|
for argv in candidates:
|
||||||
try:
|
try:
|
||||||
@@ -355,7 +424,7 @@ class HostAgent:
|
|||||||
HOST_ID, HOST_NAME, ",".join(CAPS), CONTROL_ENABLED)
|
HOST_ID, HOST_NAME, ",".join(CAPS), CONTROL_ENABLED)
|
||||||
await self._send({"type": "host_hello", "payload": {
|
await self._send({"type": "host_hello", "payload": {
|
||||||
"hostId": HOST_ID, "name": HOST_NAME, "os": platform.platform(),
|
"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)})
|
}, "timestamp": int(time.time() * 1000)})
|
||||||
|
|
||||||
async def _heartbeat(self) -> None:
|
async def _heartbeat(self) -> None:
|
||||||
@@ -400,15 +469,26 @@ class HostAgent:
|
|||||||
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
|
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
|
||||||
return
|
return
|
||||||
backoff = 1
|
backoff = 1
|
||||||
|
# use_tls kann bei Fehlschlag einmal auf ws:// fallen (RVS_TLS_FALLBACK),
|
||||||
|
# danach wieder zurueck auf RVS_TLS (kein Sticky-Fallback).
|
||||||
|
use_tls = RVS_TLS
|
||||||
|
tls_fallback_tried = False
|
||||||
|
connect_kwargs = {"max_size": 16 * 1024 * 1024,
|
||||||
|
"ping_interval": 20, "ping_timeout": 20}
|
||||||
while True:
|
while True:
|
||||||
proto = "wss" if RVS_TLS else "ws"
|
proto = "wss" if use_tls else "ws"
|
||||||
url = f"{proto}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
# Bei TLS + RVS_SNI: URI nutzt den HOSTNAMEN (Host-Header/SNI/Cert),
|
||||||
|
# getaddrinfo mappt ihn auf die IP in RVS_HOST. Bei ws:// direkt die IP.
|
||||||
|
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||||
|
url = f"{proto}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||||
|
fallback = False
|
||||||
try:
|
try:
|
||||||
logger.info("Verbinde mit RVS %s://%s:%s …", proto, RVS_HOST, RVS_PORT)
|
logger.info("Verbinde mit RVS %s://%s:%s%s …", proto, uri_host, RVS_PORT,
|
||||||
async with websockets.connect(url, max_size=16 * 1024 * 1024,
|
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
|
||||||
ping_interval=20, ping_timeout=20) as ws:
|
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||||
self.ws = ws
|
self.ws = ws
|
||||||
backoff = 1
|
backoff = 1
|
||||||
|
tls_fallback_tried = False
|
||||||
await self._hello(log=True)
|
await self._hello(log=True)
|
||||||
hb = asyncio.create_task(self._heartbeat())
|
hb = asyncio.create_task(self._heartbeat())
|
||||||
try:
|
try:
|
||||||
@@ -418,15 +498,24 @@ class HostAgent:
|
|||||||
hb.cancel()
|
hb.cancel()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("RVS-Verbindung verloren: %s", exc)
|
logger.warning("RVS-Verbindung verloren: %s", exc)
|
||||||
|
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||||
|
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
|
||||||
|
use_tls = False
|
||||||
|
tls_fallback_tried = True
|
||||||
|
fallback = True
|
||||||
finally:
|
finally:
|
||||||
self.ws = None
|
self.ws = None
|
||||||
|
if fallback:
|
||||||
|
continue # sofort erneut mit ws://
|
||||||
await asyncio.sleep(backoff)
|
await asyncio.sleep(backoff)
|
||||||
backoff = min(backoff * 2, 30)
|
backoff = min(backoff * 2, 30)
|
||||||
|
use_tls = RVS_TLS # kein Sticky-Fallback
|
||||||
|
tls_fallback_tried = False
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
logger.info("ARIA Host-Agent startet — id=%s name=%s control=%s root=%s",
|
logger.info("ARIA Host-Agent startet — id=%s name=%s control=%s root=%s",
|
||||||
HOST_ID, HOST_NAME, CONTROL_ENABLED, os.geteuid() == 0)
|
HOST_ID, HOST_NAME, CONTROL_ENABLED, _is_admin())
|
||||||
try:
|
try:
|
||||||
asyncio.run(HostAgent().run())
|
asyncio.run(HostAgent().run())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ARIA Host-Agent — Service-Installer (systemd).
|
||||||
|
#
|
||||||
|
# Installiert die Binary nach /usr/local/bin, die .env nach /etc/aria-host-agent
|
||||||
|
# und richtet den systemd-Dienst ein (enable + start).
|
||||||
|
#
|
||||||
|
# Nutzung:
|
||||||
|
# sudo ./install-service.sh /pfad/zur/.env # .env-Pfad direkt uebergeben
|
||||||
|
# sudo ./install-service.sh # ncurses-Dateidialog (dialog)
|
||||||
|
#
|
||||||
|
# Die Binary wird unter ./dist/aria-host-agent oder ./aria-host-agent erwartet
|
||||||
|
# (vorher ./build.sh oder ./build-native.sh ausfuehren), oder als 2. Argument.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
BIN_DST="/usr/local/bin/aria-host-agent"
|
||||||
|
ETC_DIR="/etc/aria-host-agent"
|
||||||
|
UNIT_DST="/etc/systemd/system/aria-host-agent.service"
|
||||||
|
|
||||||
|
die() { echo "FEHLER: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "Bitte mit sudo/root ausfuehren: sudo $0 $*"
|
||||||
|
|
||||||
|
# ── Binary finden (Arg 2 > dist/ > ./) ──────────────────────────────
|
||||||
|
BIN_SRC="${2:-}"
|
||||||
|
if [ -z "$BIN_SRC" ]; then
|
||||||
|
if [ -x "$SCRIPT_DIR/dist/aria-host-agent" ]; then BIN_SRC="$SCRIPT_DIR/dist/aria-host-agent"
|
||||||
|
elif [ -x "$SCRIPT_DIR/aria-host-agent" ]; then BIN_SRC="$SCRIPT_DIR/aria-host-agent"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -n "$BIN_SRC" ] && [ -f "$BIN_SRC" ] || die "Binary nicht gefunden. Erst bauen (./build.sh) oder als 2. Argument uebergeben."
|
||||||
|
|
||||||
|
# ── .env bestimmen: Arg 1, sonst ncurses-Dateidialog ────────────────
|
||||||
|
ENV_SRC="${1:-}"
|
||||||
|
if [ -z "$ENV_SRC" ]; then
|
||||||
|
if ! command -v dialog >/dev/null 2>&1; then
|
||||||
|
echo "Kein .env-Pfad uebergeben und 'dialog' ist nicht installiert."
|
||||||
|
read -r -p "dialog jetzt installieren (apt)? [j/N] " a
|
||||||
|
case "$a" in
|
||||||
|
j|J|y|Y) (apt-get update && apt-get install -y dialog) || die "dialog-Installation fehlgeschlagen — .env-Pfad bitte als Argument uebergeben." ;;
|
||||||
|
*) die "Ohne dialog bitte den .env-Pfad als Argument uebergeben: sudo $0 /pfad/zur/.env" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
# dialog --fselect: Dateibrowser; --stdout gibt die Auswahl auf stdout.
|
||||||
|
START_DIR="${SUDO_USER:+/home/$SUDO_USER/}"
|
||||||
|
[ -d "$START_DIR" ] || START_DIR="$SCRIPT_DIR/"
|
||||||
|
ENV_SRC="$(dialog --stdout --title 'ARIA Host-Agent — .env auswaehlen' \
|
||||||
|
--fselect "$START_DIR" 14 72)" || true
|
||||||
|
clear
|
||||||
|
[ -n "$ENV_SRC" ] || die "Abgebrochen — keine .env ausgewaehlt."
|
||||||
|
fi
|
||||||
|
[ -f "$ENV_SRC" ] || die ".env nicht gefunden: $ENV_SRC"
|
||||||
|
|
||||||
|
echo "Binary : $BIN_SRC"
|
||||||
|
echo ".env : $ENV_SRC"
|
||||||
|
|
||||||
|
# ── Installieren ────────────────────────────────────────────────────
|
||||||
|
install -m 0755 "$BIN_SRC" "$BIN_DST"
|
||||||
|
install -d -m 0755 "$ETC_DIR"
|
||||||
|
install -m 0600 "$ENV_SRC" "$ETC_DIR/.env" # 0600: enthaelt Token/Passwoerter
|
||||||
|
|
||||||
|
if [ -f "$SCRIPT_DIR/aria-host-agent.service" ]; then
|
||||||
|
install -m 0644 "$SCRIPT_DIR/aria-host-agent.service" "$UNIT_DST"
|
||||||
|
else
|
||||||
|
die "aria-host-agent.service nicht gefunden neben dem Installer."
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now aria-host-agent
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "✓ Installiert & gestartet."
|
||||||
|
echo " Status: systemctl status aria-host-agent"
|
||||||
|
echo " Log: journalctl -u aria-host-agent -f"
|
||||||
|
echo " .env: $ETC_DIR/.env (aendern -> systemctl restart aria-host-agent)"
|
||||||
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
|
||||||
@@ -88,6 +88,10 @@ const ALLOWED_TYPES = new Set([
|
|||||||
// host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/
|
// host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/
|
||||||
// screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT.
|
// screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT.
|
||||||
"host_hello", "host_ping", "host_command", "host_result",
|
"host_hello", "host_ping", "host_command", "host_result",
|
||||||
|
// Raum-Diagnose: Diagnostic fragt die aktuellen RVS-Raeume ab (rooms_query),
|
||||||
|
// RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je
|
||||||
|
// Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log).
|
||||||
|
"rooms_query", "rooms_info",
|
||||||
// Compute-Flotte (AI-Boxen): Worker (f5tts/whisper/voxtral/llm-adapter) melden
|
// Compute-Flotte (AI-Boxen): Worker (f5tts/whisper/voxtral/llm-adapter) melden
|
||||||
// sich per worker_hello an und pingen per worker_ping; der Diagnostic-Server
|
// sich per worker_hello an und pingen per worker_ping; der Diagnostic-Server
|
||||||
// aggregiert das und broadcastet worker_update/worker_list an die Browser-UI.
|
// aggregiert das und broadcastet worker_update/worker_list an die Browser-UI.
|
||||||
@@ -349,6 +353,29 @@ function registerClient(ws, token) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raum-Diagnose: direkt an den anfragenden Client antworten (nicht relay'en).
|
||||||
|
// Zeigt ALLE Raeume mit token8 (wie im Log), einem laengeren Fingerprint und
|
||||||
|
// der Token-Laenge — so werden Prefix-Kollisionen (2 Raeume, gleicher 8-Zeichen-
|
||||||
|
// Log, aber verschiedene volle Tokens) sofort sichtbar. KEINE vollen Tokens.
|
||||||
|
if (msg.type === "rooms_query") {
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const out = [];
|
||||||
|
for (const [tok, room] of rooms) {
|
||||||
|
let live = 0;
|
||||||
|
for (const c of room.clients) if (c.readyState === 1) live++;
|
||||||
|
out.push({
|
||||||
|
token8: tok.slice(0, 8),
|
||||||
|
fp: crypto.createHash("sha256").update(tok).digest("hex").slice(0, 12),
|
||||||
|
len: tok.length,
|
||||||
|
clients: live,
|
||||||
|
you: tok === ws._token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort((a, b) => b.clients - a.clients);
|
||||||
|
ws.send(JSON.stringify({ type: "rooms_info", payload: { rooms: out }, timestamp: Date.now() }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update-Download: APK als Base64 ueber WebSocket senden
|
// Update-Download: APK als Base64 ueber WebSocket senden
|
||||||
if (msg.type === "update_download") {
|
if (msg.type === "update_download") {
|
||||||
const apkInfo = getLatestAPK();
|
const apkInfo = getLatestAPK();
|
||||||
|
|||||||
@@ -6,7 +6,14 @@
|
|||||||
RVS_HOST=rvs.example.de
|
RVS_HOST=rvs.example.de
|
||||||
RVS_PORT=443
|
RVS_PORT=443
|
||||||
RVS_TLS=true
|
RVS_TLS=true
|
||||||
|
RVS_TLS_FALLBACK=true # bei TLS-Fehlschlag einmal auf ws:// zurueckfallen
|
||||||
RVS_TOKEN=
|
RVS_TOKEN=
|
||||||
|
# RVS_SNI: nur noetig, wenn RVS_HOST eine IP ist (Satellit im selben Netz wie der
|
||||||
|
# RVS, direkt auf die interne IP). Dann hier den Zertifikats-/Hostnamen angeben,
|
||||||
|
# damit der TLS-Handshake (SNI) passt. Sonst leer lassen.
|
||||||
|
# RVS_HOST=10.0.0.2
|
||||||
|
# RVS_SNI=example.com
|
||||||
|
RVS_SNI=
|
||||||
|
|
||||||
# ─── Identitaet / Adresse dieses Satelliten ────────────────────────
|
# ─── Identitaet / Adresse dieses Satelliten ────────────────────────
|
||||||
# SATELLITE_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
|
# SATELLITE_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ python satellite.py # liest .env automatisch
|
|||||||
den Satelliten erreicht). `SATELLITE_LOCATION` ist der Name, über den ARIA das Netz
|
den Satelliten erreicht). `SATELLITE_LOCATION` ist der Name, über den ARIA das Netz
|
||||||
anspricht („Büro").
|
anspricht („Büro").
|
||||||
|
|
||||||
|
**Satellit im selben Netz wie der RVS** (z.B. Rechenzentrum, direkt auf die
|
||||||
|
interne IP statt NAT-Hairpin): `RVS_HOST=<interne-ip>` + **`RVS_SNI=<zert-name>`**
|
||||||
|
(der Name, für den das Caddy-Zertifikat gilt). Ohne das scheitert TLS an
|
||||||
|
`tlsv1 alert internal error`. Zuhause / normal: `RVS_SNI` leer lassen.
|
||||||
|
|
||||||
**Kontrolle:** im Log/Diagnostic muss `primary_ip` im Ziel-LAN liegen
|
**Kontrolle:** im Log/Diagnostic muss `primary_ip` im Ziel-LAN liegen
|
||||||
(`192.168.0.x`) — steht da `192.168.65.x` oder `172.x`, sitzt der Satellit im
|
(`192.168.0.x`) — steht da `192.168.65.x` oder `172.x`, sitzt der Satellit im
|
||||||
falschen (Docker-)Netz.
|
falschen (Docker-)Netz.
|
||||||
|
|||||||
+43
-5
@@ -106,7 +106,25 @@ def _default_id() -> str:
|
|||||||
RVS_HOST = os.environ.get("RVS_HOST", "")
|
RVS_HOST = os.environ.get("RVS_HOST", "")
|
||||||
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
|
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
|
||||||
RVS_TLS = _env_bool("RVS_TLS", True)
|
RVS_TLS = _env_bool("RVS_TLS", True)
|
||||||
|
# Bei TLS-Fehlschlag einmal auf ws:// zurueckfallen (wie die Compute-Bridges).
|
||||||
|
RVS_TLS_FALLBACK = _env_bool("RVS_TLS_FALLBACK", True)
|
||||||
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
|
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
|
||||||
|
# TLS-Hostname (SNI + Zertifikatspruefung), falls RVS_HOST eine IP ist — z.B. ein
|
||||||
|
# Satellit im selben Netz wie der RVS, der direkt auf die interne IP verbindet
|
||||||
|
# (das Caddy-Zertifikat gilt aber fuer den Namen). Leer = SNI = RVS_HOST.
|
||||||
|
RVS_SNI = os.environ.get("RVS_SNI", "").strip()
|
||||||
|
|
||||||
|
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
|
||||||
|
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
|
||||||
|
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
|
||||||
|
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
|
||||||
|
if RVS_TLS and RVS_SNI:
|
||||||
|
import socket as _socket
|
||||||
|
_orig_getaddrinfo = _socket.getaddrinfo
|
||||||
|
def _sni_getaddrinfo(host, *a, **k):
|
||||||
|
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
|
||||||
|
_socket.getaddrinfo = _sni_getaddrinfo
|
||||||
|
|
||||||
|
|
||||||
SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_id()).strip()
|
SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_id()).strip()
|
||||||
SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).strip()
|
SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).strip()
|
||||||
@@ -1316,15 +1334,26 @@ class Satellite:
|
|||||||
_creds_load()
|
_creds_load()
|
||||||
asyncio.create_task(self._periodic_scan())
|
asyncio.create_task(self._periodic_scan())
|
||||||
backoff = 1
|
backoff = 1
|
||||||
|
# use_tls kann bei Fehlschlag einmal auf ws:// fallen (RVS_TLS_FALLBACK),
|
||||||
|
# danach wieder zurueck auf RVS_TLS (kein Sticky-Fallback). Bei TLS+RVS_SNI
|
||||||
|
# nutzt die URI den HOSTNAMEN (Host-Header/SNI/Cert), getaddrinfo mappt ihn
|
||||||
|
# auf die IP in RVS_HOST.
|
||||||
|
use_tls = RVS_TLS
|
||||||
|
tls_fallback_tried = False
|
||||||
|
connect_kwargs = {"max_size": 8 * 1024 * 1024,
|
||||||
|
"ping_interval": 20, "ping_timeout": 20}
|
||||||
while True:
|
while True:
|
||||||
proto = "wss" if RVS_TLS else "ws"
|
proto = "wss" if use_tls else "ws"
|
||||||
url = f"{proto}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||||
|
url = f"{proto}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||||
|
fallback = False
|
||||||
try:
|
try:
|
||||||
logger.info("Verbinde mit RVS %s://%s:%s …", proto, RVS_HOST, RVS_PORT)
|
logger.info("Verbinde mit RVS %s://%s:%s%s …", proto, uri_host, RVS_PORT,
|
||||||
async with websockets.connect(url, max_size=8 * 1024 * 1024,
|
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
|
||||||
ping_interval=20, ping_timeout=20) as ws:
|
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||||
self.ws = ws
|
self.ws = ws
|
||||||
backoff = 1
|
backoff = 1
|
||||||
|
tls_fallback_tried = False
|
||||||
await self._hello(log=True)
|
await self._hello(log=True)
|
||||||
hb = asyncio.create_task(self._heartbeat())
|
hb = asyncio.create_task(self._heartbeat())
|
||||||
try:
|
try:
|
||||||
@@ -1334,10 +1363,19 @@ class Satellite:
|
|||||||
hb.cancel()
|
hb.cancel()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("RVS-Verbindung verloren: %s", exc)
|
logger.warning("RVS-Verbindung verloren: %s", exc)
|
||||||
|
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
|
||||||
|
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
|
||||||
|
use_tls = False
|
||||||
|
tls_fallback_tried = True
|
||||||
|
fallback = True
|
||||||
finally:
|
finally:
|
||||||
self.ws = None
|
self.ws = None
|
||||||
|
if fallback:
|
||||||
|
continue
|
||||||
await asyncio.sleep(backoff)
|
await asyncio.sleep(backoff)
|
||||||
backoff = min(backoff * 2, 30)
|
backoff = min(backoff * 2, 30)
|
||||||
|
use_tls = RVS_TLS
|
||||||
|
tls_fallback_tried = False
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ RVS_PORT=444
|
|||||||
RVS_TLS=true
|
RVS_TLS=true
|
||||||
RVS_TLS_FALLBACK=true
|
RVS_TLS_FALLBACK=true
|
||||||
RVS_TOKEN=dein_token_hier
|
RVS_TOKEN=dein_token_hier
|
||||||
|
# RVS_SNI: nur noetig, wenn die Box im SELBEN Netz wie der RVS steht und direkt
|
||||||
|
# auf dessen interne IP verbinden soll (kein NAT-Hairpin ueber den externen
|
||||||
|
# Hostnamen). Dann RVS_HOST=<interne-ip> und hier den Zertifikats-/Hostnamen, fuer
|
||||||
|
# den Caddy sein Cert hat. Zuhause / normal: leer lassen.
|
||||||
|
# RVS_HOST=10.0.0.2
|
||||||
|
# RVS_SNI=example.com
|
||||||
|
RVS_SNI=
|
||||||
|
|
||||||
# ─── Optional ─────────────────────────────────────
|
# ─── Optional ─────────────────────────────────────
|
||||||
# HF_TOKEN= # nur falls ein HF-gated Modell (z.B. Voxtral) geladen wird
|
# HF_TOKEN= # nur falls ein HF-gated Modell (z.B. Voxtral) geladen wird
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ GPU-Test + Stack-Start.
|
|||||||
24-GB-Karte: `docker compose stop whisper-bridge && docker compose --profile voxtral up -d --build`.
|
24-GB-Karte: `docker compose stop whisper-bridge && docker compose --profile voxtral up -d --build`.
|
||||||
- **Erster Start lädt Modelle** (mehrere GB via HuggingFace nach `xtts/hf-cache`
|
- **Erster Start lädt Modelle** (mehrere GB via HuggingFace nach `xtts/hf-cache`
|
||||||
+ `xtts/models`) — genug Platz (1 TB NVMe ✓) und etwas Geduld.
|
+ `xtts/models`) — genug Platz (1 TB NVMe ✓) und etwas Geduld.
|
||||||
|
- **Box im selben Netz wie der RVS** (z.B. Rechenzentrum, direkt auf die interne
|
||||||
|
IP statt NAT-Hairpin): in `xtts/.env` `RVS_HOST=<interne-ip>` + **`RVS_SNI=<zert-name>`**
|
||||||
|
(Name, für den das Caddy-Zert gilt). Ohne das scheitert TLS an `tlsv1 alert
|
||||||
|
internal error`. Zuhause / normal: `RVS_SNI` leer lassen.
|
||||||
|
|
||||||
## Verifizieren
|
## Verifizieren
|
||||||
|
|
||||||
|
|||||||
+20
-3
@@ -51,6 +51,21 @@ RVS_PORT = int(os.getenv("RVS_PORT", "443"))
|
|||||||
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
||||||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||||
|
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
|
||||||
|
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
|
||||||
|
RVS_SNI = os.getenv("RVS_SNI", "").strip()
|
||||||
|
|
||||||
|
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
|
||||||
|
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
|
||||||
|
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
|
||||||
|
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
|
||||||
|
if RVS_TLS and RVS_SNI:
|
||||||
|
import socket as _socket
|
||||||
|
_orig_getaddrinfo = _socket.getaddrinfo
|
||||||
|
def _sni_getaddrinfo(host, *a, **k):
|
||||||
|
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
|
||||||
|
_socket.getaddrinfo = _sni_getaddrinfo
|
||||||
|
|
||||||
|
|
||||||
# F5-TTS Konfiguration
|
# F5-TTS Konfiguration
|
||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
@@ -877,12 +892,14 @@ async def run_loop(runner: F5Runner) -> None:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
scheme = "wss" if use_tls else "ws"
|
scheme = "wss" if use_tls else "ws"
|
||||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||||
|
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||||
|
|
||||||
|
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
|
||||||
try:
|
try:
|
||||||
logger.info("Verbinde zu RVS: %s", masked)
|
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
|
||||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
|
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||||
logger.info("RVS verbunden")
|
logger.info("RVS verbunden")
|
||||||
retry_s = 2
|
retry_s = 2
|
||||||
tls_fallback_tried = False
|
tls_fallback_tried = False
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ RVS_PORT = os.getenv("RVS_PORT", "443").strip()
|
|||||||
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
||||||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||||
|
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
|
||||||
|
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
|
||||||
|
RVS_SNI = os.getenv("RVS_SNI", "").strip()
|
||||||
|
|
||||||
|
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
|
||||||
|
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
|
||||||
|
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
|
||||||
|
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
|
||||||
|
if RVS_TLS and RVS_SNI:
|
||||||
|
import socket as _socket
|
||||||
|
_orig_getaddrinfo = _socket.getaddrinfo
|
||||||
|
def _sni_getaddrinfo(host, *a, **k):
|
||||||
|
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
|
||||||
|
_socket.getaddrinfo = _sni_getaddrinfo
|
||||||
|
|
||||||
|
|
||||||
LLAMA_URL = os.getenv("LLAMA_URL", "http://llama:8081").rstrip("/")
|
LLAMA_URL = os.getenv("LLAMA_URL", "http://llama:8081").rstrip("/")
|
||||||
LLM_MODEL = os.getenv("LLM_MODEL", "qwen3-8b")
|
LLM_MODEL = os.getenv("LLM_MODEL", "qwen3-8b")
|
||||||
@@ -417,13 +432,14 @@ async def _run() -> None:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
scheme = "wss" if use_tls else "ws"
|
scheme = "wss" if use_tls else "ws"
|
||||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||||
|
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||||
|
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 16 * 1024 * 1024}
|
||||||
try:
|
try:
|
||||||
logger.info("Verbinde zu RVS: %s (llama=%s)", masked, LLAMA_URL)
|
logger.info("Verbinde zu RVS: %s%s (llama=%s)", masked,
|
||||||
async with websockets.connect(
|
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "", LLAMA_URL)
|
||||||
url, ping_interval=20, ping_timeout=10, max_size=16 * 1024 * 1024
|
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||||
) as ws:
|
|
||||||
logger.info("RVS verbunden — llm-adapter online")
|
logger.info("RVS verbunden — llm-adapter online")
|
||||||
retry_s = 2
|
retry_s = 2
|
||||||
tls_fallback_tried = False
|
tls_fallback_tried = False
|
||||||
|
|||||||
+20
-4
@@ -53,6 +53,21 @@ RVS_PORT = int(os.getenv("RVS_PORT", "443"))
|
|||||||
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
||||||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||||
|
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
|
||||||
|
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
|
||||||
|
RVS_SNI = os.getenv("RVS_SNI", "").strip()
|
||||||
|
|
||||||
|
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
|
||||||
|
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
|
||||||
|
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
|
||||||
|
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
|
||||||
|
if RVS_TLS and RVS_SNI:
|
||||||
|
import socket as _socket
|
||||||
|
_orig_getaddrinfo = _socket.getaddrinfo
|
||||||
|
def _sni_getaddrinfo(host, *a, **k):
|
||||||
|
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
|
||||||
|
_socket.getaddrinfo = _sni_getaddrinfo
|
||||||
|
|
||||||
|
|
||||||
VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-3B-2507")
|
VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-3B-2507")
|
||||||
VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de")
|
VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de")
|
||||||
@@ -742,12 +757,13 @@ async def run_loop(sessions: SessionManager) -> None:
|
|||||||
tls_fallback_tried = False
|
tls_fallback_tried = False
|
||||||
while True:
|
while True:
|
||||||
scheme = "wss" if use_tls else "ws"
|
scheme = "wss" if use_tls else "ws"
|
||||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||||
|
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||||
|
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
|
||||||
try:
|
try:
|
||||||
logger.info("Verbinde zu RVS: %s", masked)
|
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
|
||||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10,
|
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||||
max_size=50 * 1024 * 1024) as ws:
|
|
||||||
logger.info("RVS verbunden")
|
logger.info("RVS verbunden")
|
||||||
retry_s = 2
|
retry_s = 2
|
||||||
tls_fallback_tried = False
|
tls_fallback_tried = False
|
||||||
|
|||||||
+20
-3
@@ -53,6 +53,21 @@ RVS_PORT = int(os.getenv("RVS_PORT", "443"))
|
|||||||
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
|
||||||
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
|
||||||
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
|
||||||
|
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
|
||||||
|
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
|
||||||
|
RVS_SNI = os.getenv("RVS_SNI", "").strip()
|
||||||
|
|
||||||
|
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
|
||||||
|
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
|
||||||
|
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
|
||||||
|
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
|
||||||
|
if RVS_TLS and RVS_SNI:
|
||||||
|
import socket as _socket
|
||||||
|
_orig_getaddrinfo = _socket.getaddrinfo
|
||||||
|
def _sni_getaddrinfo(host, *a, **k):
|
||||||
|
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
|
||||||
|
_socket.getaddrinfo = _sni_getaddrinfo
|
||||||
|
|
||||||
|
|
||||||
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
|
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
|
||||||
WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cuda")
|
WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cuda")
|
||||||
@@ -874,11 +889,13 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
scheme = "wss" if use_tls else "ws"
|
scheme = "wss" if use_tls else "ws"
|
||||||
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
|
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
|
||||||
|
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
|
||||||
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
|
||||||
|
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
|
||||||
try:
|
try:
|
||||||
logger.info("Verbinde zu RVS: %s", masked)
|
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
|
||||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
|
async with websockets.connect(url, **connect_kwargs) as ws:
|
||||||
logger.info("RVS verbunden")
|
logger.info("RVS verbunden")
|
||||||
retry_s = 2
|
retry_s = 2
|
||||||
tls_fallback_tried = False
|
tls_fallback_tried = False
|
||||||
|
|||||||
Reference in New Issue
Block a user