Compare commits

...
65 Commits
Author SHA1 Message Date
duffyduck 07df538763 release: bump version to 0.2.5.3 2026-10-02 01:37:49 +02:00
duffyduckandClaude Opus 4.8 5761c5f6b9 fix(xtts): Worker-Neustartschleife — max_size an den RVS angleichen (50MB -> 1500MB)
Ursache der staendigen whisper/f5tts-Neustarts, direkt aus dem Box-Log:
  sent 1009 (message too big) frame with 209221780 bytes
  exceeds limit of 52428800 bytes

Der RVS ist ein BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer,
Voice-Upload, APK-Update) geht an JEDEN Client im Raum. Die aria-bridge nutzt
deshalb max_size=1500MB — die Worker standen auf 50MB (llm-adapter sogar 16MB).
Ein 209-MB-Frame liess sie die Verbindung mit Code 1009 schliessen; danach
TLS-Fallback auf ws:// (scheitert an Caddy), Reconnect, naechster Frame, von
vorn. Ergebnis: Neustart im Minutentakt, und jede laufende Aufnahme war weg —
das ist die eigentliche Ursache fuer 'erste Sprachnachricht kommt nicht durch'.

Alle vier Worker nutzen jetzt RVS_MAX_SIZE_MB (Default 1500), passend zu
RVS/Bridge. Die GPU war NICHT das Problem (4,3 von 8 GB belegt, zweite Karte leer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:33:22 +02:00
duffyduckandClaude Opus 4.8 87755b9c58 fix(app): Aufnahme-Sperre fragt den Socket statt den gemerkten Zustand
Beweis aus app.log 02.10 01:22: beim Aufnehmen fehlte 'bg.start slot=rec' —
die Aufnahme wurde abgelehnt, BEVOR sie begann. Ursache ist die in 10e7faa
eingefuehrte Sperre 'ohne Verbindung nicht aufnehmen': sie prueft den gemerkten
Zustand, und der steht wegen des Zombie-Bugs faelschlich auf 'disconnected',
obwohl die Verbindung laeuft (TTS kam ja an). Ergebnis: Stefan konnte gar nicht
mehr sprechen — die Sperre hat das Problem verschlimmert statt es zu verhindern.

rvs.isSocketOpen() fragt jetzt ws.readyState direkt; audio.ts nutzt das. Damit
ist die Sperre immun gegen falsche Zustandsmeldungen und blockt nur, wenn der
Socket wirklich zu ist. tsc: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:24:57 +02:00
duffyduckandClaude Opus 4.8 b6195d3a53 feat(app): Teilen-Ziel — Bilder/Videos/Dateien aus anderen Apps an ARIA
'Teilen -> ARIA' aus WhatsApp, Galerie, Browser & Co. Ablauf wie gewuenscht:
sehen WAS geteilt wurde -> WOHIN (Projekt oder Hauptchat) -> optional Text dazu.
Ohne Text geht genauso.

- Manifest: intent-filter fuer ACTION_SEND (text/image/video/audio/application)
  und ACTION_SEND_MULTIPLE. launchMode=singleTask war bereits gesetzt.
- ShareIntentModule (Kotlin): liest den Intent und kopiert die Inhalte SOFORT in
  den App-Cache — die Leseerlaubnis fuer content://-URIs gilt nur fuer diesen
  Intent und waere nach der Projektauswahl womoeglich erloschen. Liefert echte
  Dateipfade, dadurch laeuft Teilen durch exakt dieselbe Upload-Strecke wie die
  Bueroklammer (RNFS -> Base64 -> Anhang im Chat).
- MainActivity.onNewIntent: setIntent + Event an JS (best effort). Zweitweg in
  JS ueber AppState 'active' -> getSharedData(), falls das Event je nach
  RN-Architektur nicht durchkommt; getSharedData markiert den Intent als
  verbraucht, also kein doppeltes Aufpoppen.
- ShareModal: Vorschau der geteilten Inhalte, Projektliste (inkl. Hauptchat),
  optionales Textfeld. Senden erst nach Zielwahl moeglich.
- sendPendingAttachments nimmt die Anhaenge jetzt optional als Parameter — beim
  Teilen waere der State zum Aufrufzeitpunkt noch leer.
tsc --noEmit: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:19:55 +02:00
duffyduck b9354283af release: bump version to 0.2.5.2 2026-10-02 01:14:25 +02:00
duffyduckandClaude Opus 4.8 bf1db6cbf9 fix(app): Diagnostic-Nachrichten verschwanden in Projekten (projectId fehlte)
Stefan: 'die antwort kam auf der app, aber meine frage ist nicht in der app'.
Die App zeigt Diagnostic-Nachrichten durchaus an (sender=diagnostic -> als
User-Bubble), legte sie aber OHNE projectId ab. Die Chat-Ansicht filtert nach
Projekt (ChatScreen.tsx:2078) — damit war die Nachricht nur im Hauptchat
sichtbar und in jedem Projekt unsichtbar. ARIAs Antwort trug die projectId und
erschien korrekt; nur die eigene Frage fehlte. Jetzt wird die mitgeschickte
projectId uebernommen. tsc: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:11:18 +02:00
duffyduckandClaude Opus 4.8 ea159d4ed7 fix(app): Zombie-Verbindung — App zeigt 'getrennt', empfaengt aber weiter
Symptom (live beobachtet): die App steht auf 'getrennt' und nichts, was Stefan
schreibt oder spricht, erreicht den Server — ARIAs Antwort und TTS kommen aber
trotzdem in der App an.

Ursache: Beim Verwerfen einer Verbindung wurden nur onclose und onerror
entfernt, NICHT onmessage. Die aufgegebene Verbindung lieferte deshalb weiter
Nachrichten an die App-Listener (daher die ankommende Antwort), waehrend this.ws
auf die neue/tote zeigte — send() lehnte ab und der Zustand blieb
'disconnected'. Betraf beide Abriss-Pfade (Neu-Connect und TLS-Fallback).

Fix: detachSocket() klemmt eine Verbindung vollstaendig ab (onopen/onmessage/
onclose/onerror + close) und wird in beiden Pfaden genutzt. Zusaetzlich ignoriert
der onmessage-Handler Nachrichten, die nicht von der AKTUELLEN Verbindung
stammen — sonst taeuscht ein Zombie weiter Betrieb vor. tsc: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 01:08:37 +02:00
duffyduckandClaude Opus 4.8 dd1e1ac458 fix(app): verlorene Aufnahme sichtbar machen statt still zu loeschen
Beweis aus den Logs: die Whisper-Bridge startete MITTEN in der ersten Aufnahme
neu (stream.start 16:35:43 -> boot 16:36:02), damit war die Sitzung weg. Die App
fiel in den 3s-Fallback (reason=...no-response) und entfernte die Bubble
kommentarlos — fuer den Nutzer sah es aus, als waere nie etwas gesendet worden.

Jetzt wird bei 'no-response' die Platzhalter-Bubble durch einen Hinweis ersetzt
('Aufnahme ging verloren — Spracherkennung war nicht erreichbar'). Der stille
no-speech-Fall (Du hast wirklich nichts gesagt) bleibt wie bisher. tsc: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-01 17:08:42 +02:00
duffyduckandClaude Opus 4.8 ddd77c7ad4 docs(xtts): openhands-Profil in .env.example und README sichtbar machen
Das Profil existierte bereits in der compose-Datei, tauchte aber nicht in der
Profil-Liste der .env.example auf — man konnte also nicht sehen, dass es waehlbar
(und per Default AUS) ist. Jetzt dort gelistet, mit dem Hinweis, dass ohne
Eintrag gar kein Container angelegt wird, plus Warnung, dass Profile compose v2
brauchen (das alte docker-compose 1.x behandelt sie unzuverlaessig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 22:40:14 +02:00
duffyduckandClaude Opus 4.8 7c3c932054 docs(xtts): OpenHands — SSH-Tunnel statt LAN-Port als Absicherung
Der Host-Socket bleibt (bewusste Entscheidung fuer den Heimbetrieb). Dafuer der
wirksamste kostenlose Schutz explizit dokumentiert: OPENHANDS_BIND bleibt auf
127.0.0.1, Zugriff von anderen Rechnern per 'ssh -L 3000:127.0.0.1:3000'. Dazu
der Hinweis, was echte Isolation waere (eigene VM / rootless Daemon).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 22:34:14 +02:00
duffyduckandClaude Opus 4.8 d6c7502810 feat(xtts): OpenAI-Endpunkt fuers lokale LLM + OpenHands-Profil
llama-swap ist bereits OpenAI-kompatibel, war aber nur im Compose-Netz erreichbar
(kein ports-Mapping). Jetzt nutzbar fuer OpenHands, Continue, Open WebUI & Co.

- compose: llama-swap veroeffentlicht 8080 als
  ${LLM_OPENAI_BIND:-127.0.0.1}:${LLM_OPENAI_PORT:-8081} — sicher per Default
  (nur localhost), fuer LAN bewusst BIND=0.0.0.0 setzen.
- llm-adapter: API-Key wird in JEDEN llama-server-Befehl der generierten
  llama-swap-Config eingehaengt (--api-key, shlex-gequotet). Quelle: .env
  (Vorrang) sonst /models/openai_key.json (0600). Neuer Handler
  llm_openai_config setzt ihn zur Laufzeit und lehnt bei .env-Vorrang ab.
  worker_hello meldet Port/Bind/keySet/envLocked — den Key NIE (Broadcast!).
- Diagnostic: pro LLM-Box Endpunkt-Zeile mit Base-URL, Key-Status und Eingabe.
  Bei .env-Vorrang ist das Feld schreibgeschuetzt (🔒). Warnung, wenn im LAN
  offen ohne Key. Der PORT wird nur ANGEZEIGT: er ist ein Docker-Port-Mapping
  und steht beim Container-Start fest — dafuer ist die .env zustaendig.
- rvs: llm_openai_config(_result) in ALLOWED_TYPES (sonst still verworfen).
- Neues Profil 'openhands' (COMPOSE_PROFILES=llm,openhands) mit Port/Bind/Modell
  aus der .env, lokales LLM vorbelegt. Ohne Profil aendert sich nichts.
- .env.example + README: alle Variablen, Sicherheitshinweise (offener Endpunkt,
  Docker-Socket bei OpenHands), curl-Test und wann der Key greift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 22:30:37 +02:00
duffyduckandClaude Opus 4.8 dc77933014 fix(jellyfin): Erfolgsmeldung sagt ausdruecklich, dass kein Neustart noetig war
Nachgeprueft: der Selbstheilungs-Fix lief korrekt — die Wiedergabe startete auf
Anhieb (Nachkontrolle positiv), es gab KEINEN App-Neustart (kein host_app_launch
in der Agent-Historie). ARIA behauptete trotzdem 'Session war eingeschlafen, ich
hab Jellyfin neu gestartet' — nacherzaehlt aus dem vorherigen Turn, wo das
tatsaechlich noetig war.

Die Erfolgsmeldung stellt jetzt klar, dass nichts repariert werden musste, damit
das Modell die alte Panne nicht weitererzaehlt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 22:01:54 +02:00
duffyduckandClaude Opus 4.8 6a6b320f37 fix(jellyfin): eingeschlafene Sitzung erkennen und selbst neu aufbauen
Beim zweiten Film ging nichts mehr ('Session abgelaufen') — ARIA musste Jellyfin
von Hand neu starten. Ursache: Jellyfin listet Clients noch, wenn die App laengst
weg ist, und quittiert den Play-Befehl auch dann mit Erfolg. Es lief nur nichts.

- /Sessions wird jetzt mit activeWithinSeconds gefiltert (Play + devices) —
  Karteileichen tauchen gar nicht erst als Ziel auf.
- _jellyfin_playing(): nach dem Play-Befehl wird nachgesehen, ob NowPlayingItem
  wirklich gesetzt ist (bis ~6s). Ohne das meldet man faelschlich Erfolg.
- Laeuft nichts und ist 'host' gesetzt: Jellyfin wird ueber den Host-Agenten neu
  gestartet (host_app_launch), frische Sitzung geholt, einmal nachgelegt — also
  genau der Handgriff, den ARIA vorher manuell machen musste. Ohne 'host' gibt es
  eine klare Handlungsanweisung statt eines stillen Fehlschlags.
- Tool-Beschreibung draengt auf 'host', damit die Selbstheilung greift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 21:50:43 +02:00
duffyduck cc91497923 release: bump version to 0.2.5.1 2026-09-30 21:18:09 +02:00
duffyduckandClaude Opus 4.8 10e7faa92b fix(app): verlorene erste Sprachaufnahme — Watchdog, Reihenfolge, Startzustand
Drei App-seitige Ursachen fuer 'erst beim zweiten Aufnehmen' (tsc --noEmit: 0 Fehler):

1) No-Speech-Watchdog: Der Aufrufer uebergibt die Sprechpausen-Toleranz
   (sttEndpointMs, Default 2400ms, per Setting bis 1000ms). Die misst ab dem
   ENDE des Sprechens, der Watchdog lief aber ab dem START der Aufnahme. Bis ein
   Zwischenergebnis zurueck sein KANN, vergehen realistisch 1,3-2,2s (600ms
   Mindestpuffer + 700ms Transkriptions-Takt + Netzweg). Schon kurzes Ueberlegen
   nach dem Gong brach die Aufnahme ab. Jetzt +2500ms Zuschlag.

2) stt_stream_start ging ERST NACH PcmStreamRecorder.start() raus — die ersten
   Chunks (200ms-Takt) trafen auf eine Bridge ohne Sitzung und wurden verworfen
   (Wortanfang weg; bei kurzen Kommandos blieb nichts uebrig). Jetzt davor.

3) recordingState wurde erst nach mehreren awaits auf 'recording' gesetzt. In
   diesem Fenster war isStreamingRecording() true, der Zustand aber 'idle': ein
   Abbruch dort liess den Start weiterlaufen, der Recorder blieb haengen und die
   NAECHSTE Aufnahme wurde abgelehnt. Zustand wird jetzt sofort gesetzt, plus
   Session-Guard nach den awaits.

Zusaetzlich: ohne RVS-Verbindung wird gar nicht erst aufgenommen (send()
verwirft still, kein Puffer) — nach App-Resume fiel sonst die erste Aufnahme
komplett aus. Nachtraeglich gepuffertes Audio waere sinnlos, die STT-Sitzung
laeuft in Echtzeit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 13:02:55 +02:00
duffyduckandClaude Opus 4.8 d030cd7bc6 fix(voice): verlorene Sprachnachrichten — Allow-List, Kaltstart, Dedup, Voice-Router
Vier unabhaengige Ursachen fuer 'erst beim zweiten Aufnehmen' bzw. 'wird gar
nicht verarbeitet' (server-/box-seitig, ohne App-Update wirksam):

1) rvs/server.js: stt_lease, stt_lease_request, stt_transcribe_blob,
   stt_transcribe_result und chat_ack fehlten in ALLOWED_TYPES und wurden STILL
   verworfen (dieselbe Falle wie bei worker_*/host_*). Folgen: das Lease lief
   immer in den 250ms-Timeout -> jede Aufnahme startete verspaetet und OHNE
   targetInstance nahmen alle STT-Worker dieselbe Session an (doppelte
   stt_endpoint); chat_ack kam nie an -> getippte Nachrichten galten in der App
   nach 3 Versuchen als fehlgeschlagen, obwohl sie verarbeitet waren.

2) xtts/whisper/bridge.py: Beim Kaltstart wurde die Session erst NACH dem
   Modell-Load angelegt — alle Chunks der ersten Aufnahme liefen ins Leere und
   der Take war weg (die zweite klappte, weil das Modell dann warm war). Session
   wird jetzt sofort angelegt, der Endpointer wartet solange (runner.model None).

3) bridge/aria_bridge.py: Text-Dedup-Fenster 5s -> 1.5s. Es soll nur den
   Zwilling zweier Worker abfangen; mit 5s fielen echte Wiederholungen weg —
   also genau das Nachsprechen nach einem verschluckten Take, und kurze
   Kommandos ('lauter', 'nächstes').

4) bridge/aria_bridge.py Voice-Router: Komma als Prefix-Trenner entfernt (nur
   noch ':' und '-'), Fuzzy-Schwelle 0.6 -> 0.82, Sticky wird nicht mehr bei
   jeder Nachricht verlaengert. Vorher matchten normale Saetze wie 'In Ordnung,
   mach mal ...' als Projekt-Prefix: die Nachricht landete still in einem
   Projekt und war im Hauptchat unsichtbar — verifiziert mit Beispielsaetzen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 11:05:06 +02:00
duffyduckandClaude Opus 4.8 36641e6519 fix(proxy): 'spawn E2BIG' — System-Prompt ueber Datei statt Kommandozeile
Gefunden ueber die Brain-Logs: mehrere Nachrichten blieben unbeantwortet, weil
POST /chat mit 502 endete. Ursache:
  Proxy HTTP 500: {"error":{"message":"spawn E2BIG"}}
execve() begrenzt argv+envp ZUSAMMEN auf ARG_MAX — im Proxy-Container nur
131072 Bytes (getconf ARG_MAX). Der System-Prompt wurde als einzelnes Argument
uebergeben und war im Fehlerfall 76881 Zeichen gross (gepinnte Erinnerungen +
Skills + Projektkontext) -> Start scheitert, Turn verloren, App bekommt 502.
11 solcher Faelle im aktuellen Log.

Fix: --system-prompt-file <tmpfile> statt --system-prompt <text> (die CLI
unterstuetzt das). Damit faellt die Groessengrenze praktisch weg. Die Temp-Datei
wird bei close/error/catch wieder entfernt; schlaegt das Schreiben fehl, wird
wie bisher das Argument genutzt (kleine Prompts laufen weiter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 10:58:45 +02:00
duffyduckandClaude Opus 4.8 9b11c16479 fix(android-agent): Sprachausgabe — ARIAs echte Stimme statt (fehlender) Geraete-TTS
Der Agent meldete 'gesprochen', es kam aber kein Ton: Fire TV Sticks haben in
der Regel keine (deutsche) TTS-Engine, und wir haben weder setLanguage noch den
Rueckgabewert von speak() geprueft -> stiller Fehlschlag.

- speak/notify fordern jetzt ARIAs ECHTE Stimme ueber den bestehenden Serverpfad
  an: tts_request -> Bridge -> F5/XTTS -> audio_pcm, das der Agent abspielt.
  Keine Server-Aenderung noetig (beide Typen stehen in der RVS-Allow-List).
  VoiceSession merkt sich die angeforderte messageId und spielt genau dieses
  Audio ab — die Turn-Ownership bleibt sonst unveraendert (Handy redet nicht mit).
- Geraete-TTS nur noch auf ausdrueckliche Anforderung (engine='device') und nun
  ehrlich: fehlende Engine, fehlende deutsche Sprachdaten und ein abgelehntes
  speak() werden als Fehler gemeldet statt als Erfolg.
Android-Version 0.0.1.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 03:47:22 +02:00
duffyduckandClaude Opus 4.8 8f9e3e5c79 feat: Jellyfin-Integration — ARIA steuert die Wiedergabe ueber die API
Statt sich durch die Fire-TV-Oberflaeche zu klicken (fehleranfaellig: ARIA ist am
Titel vorbeigescrollt und auf Home gelandet) spricht ARIA den Jellyfin-Server
direkt an: suchen -> Sitzung des Abspielgeraets finden -> abspielen.

- Diagnostic (Einstellungen > Integrationen): Jellyfin-Abschnitt mit MEHREREN
  Servern (+-Button; eigener, vom Kumpel ...), einer davon Standard. Pro Server
  Name/URL/API-Key + Test-Knopf (prueft /System/Info, meldet Name und Version).
  Gespeichert in /shared/config/jellyfin.json mit Datei-Rechten 0600 (API-Keys).
- Brain: jellyfin_search / jellyfin_devices / jellyfin_play / jellyfin_control.
  Serverwahl per Name oder Standard. 'resume' ist echt implementiert (liest
  PlaybackPositionTicks und setzt startPositionTicks) statt nur zu existieren.
  Fehlertexte sind handlungsleitend: 401 -> API-Key pruefen; kein Geraet -> die
  Jellyfin-App muss offen sein, sonst erst host_app_launch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 03:23:30 +02:00
duffyduck f1ae18b658 release(agent): bump to 0.0.1.2 2026-09-30 02:51:29 +02:00
duffyduckandClaude Opus 4.8 27f9399eda feat(diagnostic): bereits vergebener Geraetename wird beim Ausrollen vorgeschlagen
Bisher stand im Namensfeld die technische Kennung aus der Netz-Erkennung
(F8FCE10B4E6C@AS-AFTKA[AirPlay]) — auch wenn das Geraet laengst 'Fire-TV
Schlafzimmer' heisst. Jetzt in dieser Reihenfolge:
  1. Name, den der Agent auf DIESEM Geraet traegt — Zuordnung ueber die IP, die
     der Agent neu in host_hello mitmeldet (localIp()); Server reicht sie durch.
  2. zuletzt hier eingegebener Name (localStorage je Satellit+IP).
  3. Name aus der Erkennung, aber nur wenn menschenlesbar — Seriennummern, IPs
     und Kennungen mit @/[]/: werden verworfen (Feld bleibt leer).
Android-Version 0.0.1.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:49:38 +02:00
duffyduckandClaude Opus 4.8 c131820be0 feat(android-agent): Audio-Fokus — ARIA redet nicht mehr ueber den laufenden Film
Bisher wurde KEIN Audio-Fokus angefordert: Android mischt dann beide Tonspuren,
d.h. Netflix/Jellyfin/Spotify liefen in voller Lautstaerke weiter und ARIA war
kaum zu verstehen — und beim Zuhoeren nahm das Mikrofon den Filmton mit auf.

AudioFocusHelper mit drei Modi:
- speak/notify: AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK ('duck', Standard) -> Medien
  werden leiser und danach automatisch wieder lauter. Per focus-Param auch
  'pause' (Medien anhalten) oder 'none'.
- listen: AUDIOFOCUS_GAIN_TRANSIENT ('pause') -> Medien halten an, solange das
  Mikrofon offen ist; danach laufen sie weiter.
- Fokus wird in allen Pfaden zurueckgegeben (Completion, Fehler, stop, shutdown),
  sonst blieben die Medien dauerhaft leise.
Brain: focus-Parameter in host_notify/host_speak. Android-Version 0.0.1.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:46:14 +02:00
duffyduckandClaude Opus 4.8 e43b44725b feat(android-agent): Bildschirm-Aufnahme nach Neustart automatisch scharf schalten
Die BERECHTIGUNGEN bleiben dauerhaft (pm grant / appops in appops.xml), die
MediaProjection-SITZUNG aber nicht — Android laesst sie nicht speichern. Nach
Reboot/App-Neustart war der Agent also verbunden, konnte aber keinen Screenshot
machen, bis jemand am Fernseher 'Bildschirm-Zugriff erlauben' drueckt.

- CaptureActivity: unsichtbarer Helfer (Theme.Translucent, noHistory,
  excludeFromRecents), fordert die Projektion an und reicht das Ergebnis an den
  Dienst. Mit gesetztem AppOp PROJECT_MEDIA laeuft das OHNE Dialog durch.
- AgentService startet sie beim Dienststart automatisch nach (2,5s Verzoegerung),
  aber nur wenn captureEnabled gesetzt ist — wer die Aufnahme nie wollte, bekommt
  auch keinen Dialog.
- AgentConfig.captureEnabled: wird gesetzt, sobald die Aufnahme einmal lief, und
  kann beim Fern-Einrichten mitgegeben werden (nur wenn der Satellit PROJECT_MEDIA
  wirklich erteilen konnte).
Android-Version 0.0.1.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:41:46 +02:00
duffyduckandClaude Opus 4.8 586a2bf134 feat: Brain-Tools fuer Overlay/Sprache + Rechte automatisch per ADB erteilen
1) ARIA hatte KEIN Werkzeug fuer die Einblendung — der Agent kann notify/speak/
   volume laengst, aber im Brain fehlten die Tools. Sie hat deshalb improvisiert
   ('cmd notification post', gibt es auf Android 9 nicht) und ist gescheitert.
   Neu: host_notify (Overlay/Sprache, mode ueberschreibbar), host_speak,
   host_volume — mit dem ausdruecklichen Hinweis, es NICHT ueber adb zu versuchen.
2) _adb_grant_permissions: appops PROJECT_MEDIA (Screenshots ohne Dialog) +
   SYSTEM_ALERT_WINDOW (Overlay) + pm grant RECORD_AUDIO (Mikro) werden beim
   Einrichten automatisch gesetzt — kein Zusammenklicken mit der Fernbedienung
   mehr. Best effort; was scheitert, wird als 'manuell noetig' gemeldet. Neue
   Aktion adb.permissions setzt sie einzeln nach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:37:20 +02:00
duffyduck ac40afc856 release(agent): bump to 0.0.1.1 2026-09-30 02:18:08 +02:00
duffyduckandClaude Opus 4.8 ff6d474f29 fix: Umbenennen greift nicht — Android lieferte den Intent nicht aus
Android vergleicht beim Activity-Start Komponente/Action/Daten, aber NICHT die
Extras. Ein zweites Provisioning galt damit als 'derselbe Intent': die Activity
wurde nur nach vorn geholt, onCreate/onNewIntent liefen nie — der alte Name blieb
stehen (und kam nach dem Loeschen aus der Liste sofort wieder).

- Satellit: eindeutige Daten-URI (aria://provision/<ms>) je Aufruf -> der Intent
  ist nie 'gleich'. Zusaetzlich wird die am-start-Ausgabe auf 'brought to the
  front'/'not started' geprueft und als Fehler gemeldet statt als Erfolg.
- Android: MainActivity launchMode=singleTop -> erneutes Provisioning landet
  zuverlaessig in onNewIntent (statt neuer Instanz); setIntent() ergaenzt.
Android-Version 0.0.1.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:16:21 +02:00
duffyduckandClaude Opus 4.8 44aef71813 fix: Leerzeichen im Geraetenamen + alte Agenten aus der Liste entfernbar
1) 'adb shell' haengt seine Argumente zu EINER Kommandozeile zusammen, die auf
   dem Geraet nochmal von der Shell zerlegt wird — '-e name Wohnzimmer Fire TV'
   kam als 'Wohnzimmer' an, der Rest fiel weg. Alle Werte werden jetzt mit
   shlex.quote gequotet.
2) Umbenennen erzeugt eine neue hostId, der alte Eintrag blieb fuer immer in der
   Diagnostic stehen (nach 5 min nur rot, nie weg). Neuer ✕-Button je Agent
   (host_forget) mit Rueckfrage; laeuft der Agent noch, meldet er sich von selbst
   wieder — das Entfernen stoppt ihn also nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:07:27 +02:00
duffyduckandClaude Opus 4.8 27381e7038 feat(diagnostic): Geraetename beim Rollout am PC vergeben + 'Nur einrichten'
Fire TV meldet sich in der Netz-Erkennung oft nur mit der Seriennummer
(0d94400fa5b63301) — genau der Name, den man auf dem Fernseher nicht tippen will.

- Namensfeld im Rollout-Dialog (am PC getippt), wird beim Einrichten mitgegeben.
- Vorbelegung filtert technische Namen (Hex-Seriennummern, IPs) raus.
- Neuer Button 'Nur einrichten': setzt Zugang + Namen per adb.provision, OHNE die
  APK neu zu installieren — zum Umbenennen bereits ausgerollter Geraete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 02:01:55 +02:00
duffyduck 21d389cc26 release(agent): bump to 0.0.1.0 2026-09-30 01:56:49 +02:00
duffyduckandClaude Opus 4.8 eae036a9fc feat: Fern-Einrichtung des Agents per ADB (kein Abtippen am Fire TV)
Am Fire TV gibt es keine Kamera (kein QR) und Host/Token mit der Fernbedienung
einzugeben ist eine Qual. Der Satellit hat aber ohnehin schon eine ADB-Verbindung
— darueber bekommt der Agent seine Zugangsdaten direkt.

- Android: MainActivity.applyProvisioning() liest Intent-Extras (host/port/tls/
  token/name/control/reply_mode), speichert sie, startet den Dienst und zeigt
  'Von ARIA eingerichtet' (Transparenz). Auch via onNewIntent.
- Satellit: adb.provision (am start mit Extras) und adb.status (installiert?
  laeuft? welche Version?). adb.install richtet direkt mit ein, wenn rvs_host/
  rvs_token mitkommen -> ein Klick = installieren + einrichten + online.
- Diagnostic: Checkbox 'Gleich einrichten' (default an) + 'Status pruefen'-Button.
  Der RVS-Zugang wird SERVERSEITIG ergaenzt (params.provision) — das Token steht
  nie im Browser-Formular.
- Allowlists (Code + .env.example) und README ergaenzt, inkl. Sicherheitshinweis.
Android-Version 0.0.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 01:54:38 +02:00
duffyduckandClaude Opus 4.8 621b5525a8 feat(satellite): eigene MAC-Adresse melden + in der Diagnostic anzeigen
NET bekommt ein 'mac'-Feld (_own_mac): liest /sys/class/net des Interfaces, das
zur primaeren IP gehoert — wichtig, weil sonst die Docker-Bridge-MAC (02:42:...)
statt der echten Karte kaeme. Fallback uuid.getnode(). Wird ueber sat_hello.net
mitgeschickt (Server reicht net bereits durch) und in der Satelliten-Zeile hinter
der IP angezeigt; auch im Startup-Log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 01:41:48 +02:00
duffyduckandClaude Opus 4.8 6da567aee6 docs(satellite): Workaround fuer KeyError 'ContainerConfig' (docker-compose 1.29.2)
Bug der alten Python-Version beim Ersetzen eines Containers mit Volume:
get_container_data_volumes liest image_config['ContainerConfig'], das neuere
Docker-Versionen nicht mehr schreiben. Fix: 'docker compose' (v2) nutzen oder
den alten Container vorher mit 'rm -sf' entfernen (dann greift der create- statt
recreate-Pfad). Trat schon beim frueheren Satelliten-Build auf — jetzt dokumentiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 01:36:44 +02:00
duffyduck 7071614434 release(agent): bump to 0.0.0.9 2026-09-30 01:36:14 +02:00
duffyduckandClaude Opus 4.8 8301342728 feat(satellite): ADB-Rollout wartet auf die Geraete-Bestaetigung (ein Klick genuegt)
Bisher: 1. Klick -> 'unauthorized' (Fehler), am Fire TV bestaetigen, 2. Klick ->
klappt. Jetzt wartet der Satellit nach dem ersten connect bis zu ADB_AUTH_WAIT_SEC
(90s) auf die Autorisierung und installiert danach automatisch weiter — der
Dialog auf dem Fernseher erscheint ja erst DURCH das connect.

- _adb_wait_authorized(): pollt adb devices alle 2s und verbindet neu (nach dem
  Bestaetigen wechselt der Zustand oft erst mit frischem connect auf 'device').
- adb.install/adb.connect nutzen es; scheitert es doch, nennt die Meldung Wartezeit
  und Zustand statt nur 'unauthorized'.
- Brain-Timeout fuer adb.* auf 320s (Warten + Download + Installation).
- Diagnostic: Hinweis erscheint SOFORT beim Klick ('Jetzt auf den Bildschirm des
  Geraets schauen'), nicht erst als Fehler hinterher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-30 01:34:18 +02:00
duffyduck 6a8dd662fa release(agent): bump to 0.0.0.8 2026-09-30 01:25:49 +02:00
duffyduck a48d8442b1 docs(android-agent): Fire TV (Overlay/Sprache/D-Pad) + ADB-Rollout per Satellit 2026-09-29 19:18:19 +02:00
duffyduckandClaude Opus 4.8 47735642f2 feat(satellite): APK-Rollout per ADB — Fire TV & Android aus der Ferne bespielen
Der Satellit kann Android-Geraete im LAN erkennen und die Agent-APK ausrollen,
statt sie von Hand aufs Geraet zu kopieren.

- satellite.py: adb.devices/connect/install/shell. _apk_fetch laedt die APK per
  HTTP (gecached in /data/apk, sha256 optional) statt sie durchs RVS zu schicken —
  der RVS ist ein BROADCAST-Relay (jede base64-APK ginge an alle Clients, und das
  Satelliten-max_size liegt bei 8 MB). adb.install ist selbstheilend (connect ->
  install -r) und uebersetzt die typischen Fehler in klare Ansagen (unauthorized,
  Signatur-Mismatch, Speicher voll). Imports lokal in den Funktionen (Hausmuster).
- Discovery: mDNS-Typen _amzn-wplay/_adb-tls-connect/_androidtvremote2, _mdns_kind
  -> firetv/android-adb/androidtv, plus _enrich_adb (TCP-Probe 5555) -> adbCapable.
- Dockerfile: adb; compose: HOME=/data (ADB-Key persistent, sonst fragt das Geraet
  nach jedem Rebuild neu) + ANDROID_ADB_SERVER_PORT=5038 (Host-Kollision).
- .env.example: neue Aktionen + Warnung, dass die Allowlist den Code-Default
  ueberschreibt (sonst lehnt ein bestehender Satellit adb.* ab).
- Brain: adb.* in der satellite_command-Beschreibung; WICHTIG Timeout 200s fuer
  adb.*/ssh.exec — der Bridge-Default von 20s haette jede Installation abgewuergt.
- Diagnostic: sat_command (Browser->RVS) und sat_result (RVS->Browser) gab es
  bisher GAR NICHT; beide ergaenzt. 📦-Button je ADB-faehigem Geraet + Rollout-
  Modal mit Status/Log, ADB-Tag in der Geraeteliste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-29 19:17:53 +02:00
duffyduckandClaude Opus 4.8 e15f7a5bc0 feat(android-agent): Fire TV als ARIA-Endpunkt — Sprache, Overlay, Ausgabe-Modus
Der Stick wird vom gesteuerten Geraet zum Gespraechspartner:

- VoiceSession: Mikro -> stt_stream_start/stt_audio_chunk(pcm,16k mono s16le,
  6400B)/stt_stream_end (exakt das App-Protokoll, keine Server-Aenderung noetig);
  Antwort via chat(sender=aria) + audio_pcm(base64, 24k) in AudioTrack. AEC/NS an.
  TURN-OWNERSHIP: der RVS broadcastet an alle — es wird nur ausgegeben, was aus der
  EIGENEN Frage entstand (audioRequestId -> messageId), sonst reden Handy und Stick
  gleichzeitig.
- OverlayView: TYPE_APPLICATION_OVERLAY, Einblendung ueber dem laufenden Bild
  (Text + optional Bild), nicht fokussierbar (Fernbedienung bleibt frei).
- Speaker: ARIAs echte Stimme (fertiges Audio) bevorzugt, Geraete-TTS als Fallback,
  Lautstaerke-Steuerung.
- Neue Aktionen: notify (Einblendung/Sprache, mode ueberschreibbar), speak, volume,
  listen/listen_stop.
- Einstellung replyMode (overlay|speech) in der App, pro Anfrage ueberschreibbar.
- Sprech-Taste in der UI (Fire TV: OK-Taste) + Mikro-/Overlay-Berechtigungen.
Version 0.0.1.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-29 19:12:17 +02:00
duffyduckandClaude Opus 4.8 39f908dd4a feat(android-agent): Fire TV / Android TV Support (eine APK fuer Handy + Stick)
Kein zweiter Agent: dieselbe APK laeuft auf Fire TV, Fähigkeiten capability-gated
(wie beim Desktop-Agent).

- Manifest: touchscreen/faketouch/leanback als required=false (sonst gilt die App
  als Touch-Pflicht bzw. laeuft nicht mehr aufs Handy), LEANBACK_LAUNCHER (ohne
  das fehlt die App im Fire-TV-Menue), TV-Banner (Launcher zeigt Banner statt Icon).
- RvsClient: isTv-Erkennung (leanback/television/UiModeManager) -> caps bekommen
  'tv', os meldet 'Fire TV / Android TV'.
- AriaAccessibilityService.globalKey: D-Pad (up/down/left/right/ok) via Fokus-
  Navigation, Medientasten via dispatchMediaKeyEvent, Lautstaerke/Mute via
  AudioManager — deutsche Aliase (hoch/runter/lauter/stumm).
- MainActivity/Layout: auf TV QR-Scan ausgeblendet (keine Kamera), erster Fokus
  gesetzt, Buttons fokussierbar (Fernbedienung).
Version 0.0.0.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-29 18:26:09 +02:00
duffyduckandClaude Opus 4.8 176997a800 feat(host-agent): GUI-Steuerung fuer Desktop (capability-gated)
Desktop-Agenten (Win/Linux/Mac) koennen jetzt bedienen, nicht nur sehen — mit
DENSELBEN Actions/Brain-Tools wie Android (ui_tap/ui_text/ui_key/ui_swipe/
app_launch), ein Werkzeugset fuer Handy und Rechner.

- host_agent.py: _gui_input_method() erkennt X11(xdotool)/Wayland(ydotool)/
  macOS(osascript+cliclick)/Windows(PowerShell). CAPS werden dynamisch erweitert
  -> reiner Terminal-Server (kein DISPLAY) meldet KEINE ui_*-Caps. _do_ui_tap/
  text/key/swipe + _do_app_launch je Methode; info liefert 'gui'.
- Brain: Tool-Beschreibungen decken Desktop ab (Koordinaten aus dem Screenshot,
  kein ui_dump; button/double bei tap; command bei app_launch); _UI_ACTIONS
  reicht die neuen Params durch.
- README: Fähigkeiten/Plattform-Tabelle + Helfer je OS + HiDPI-Hinweis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 14:30:38 +02:00
duffyduck 5b7e205ede release(agent): bump to 0.0.0.7 2026-09-25 13:52:39 +02:00
duffyduckandClaude Opus 4.8 47cc1b7f08 feat(android-agent): Sperre-Erkennung — Steuern nur entsperrt (v0.0.0.7)
Erkenntnis aus dem Live-Test: bei gesperrtem Bildschirm greifen Gesten/Tasten
nicht (Android-Sicherheit), der Agent meldete aber still 'ok' -> Verwirrung
(home,home,home, Screenshot zeigt trotzdem alte App). Fix: KeyguardManager.
isKeyguardLocked() gate fuer Steuer-Aktionen (ui_tap/text/swipe/key/app_launch)
-> klare Meldung 'Geraet ist gesperrt - bitte entsperren'. Sehen (screenshot/
ui_dump/info) bleibt auch gesperrt erlaubt. README-Hinweis ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 13:44:49 +02:00
duffyduckandClaude Opus 4.8 f4b7148cf9 feat(diagnostic): Agent-Historie pro Agent — Akkordeon je Nachricht
Jede Host-Aktion wird als Historie-Eintrag festgehalten und in der Diagnostic
pro User-Nachricht gruppiert aufklappbar dargestellt (mit Screenshot-Thumbnails).

- Brain: trace_id+msg pro chat(); _dispatch_host als Wrapper um _dispatch_host_inner
  emittiert je Aktion einen Eintrag an POST /internal/agent-history (Datei-Pfad aus
  Screenshot-Text erkannt).
- Bridge: /internal/agent-history persistiert nach /shared/config/agent_history/
  <hostId>.jsonl (Ringpuffer 200) + broadcastet agent_history; agent_history_query
  -> agent_history_list.
- RVS: agent_history/_query/_list in ALLOWED_TYPES (sonst still verworfen).
- Diagnostic-Server: relay browser<->RVS + /uploads/<file> Bild-Endpoint (nur
  /shared/uploads, kein Traversal).
- index.html: 'Historie'-Button je Host, Modal mit Akkordeon (Gruppe=Nachricht,
  aufklappbar, Screenshot-Thumbs), Live-Refresh bei agent_history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-25 13:32:40 +02:00
duffyduck 5941be0f21 release(agent): bump to 0.0.0.6 2026-09-24 21:57:38 +02:00
duffyduckandClaude Opus 4.8 2038b676e6 feat(android-agent): Meilenstein 3 — steuern (tap/text/swipe/key/app_launch)
Bedienungshilfe kann jetzt bedienen: canPerformGestures + dispatchGesture (tap/
swipe), ACTION_SET_TEXT (text), performGlobalAction (back/home/recents/notif).
app_launch via getLaunchIntentForPackage (+ Label-Suche, QUERY_ALL_PACKAGES).
caps erweitert. Brain-Tools host_ui_tap/_text/_swipe/_key/host_app_launch ->
generischer _UI_ACTIONS-Dispatch. Tap-Koordinaten = ui_dump-x/y (echte Pixel,
nicht der skalierte Screenshot). Damit voller Ablauf sehen->steuern. Version 0.0.0.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 21:53:02 +02:00
duffyduck 8b84ee3b9c release(agent): bump to 0.0.0.5 2026-09-24 21:44:05 +02:00
duffyduckandClaude Opus 4.8 c86cd59b5c fix(android-agent): Screenshot — Vordergrund-Dienst-Typ mediaProjection
Geraet (Android 12) meldete beim Projizieren:
'Media projections require a foreground service ...
FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION'. Der Dienst lief als reiner dataSync.
Fix: foregroundServiceType=dataSync|mediaProjection + Permissions
FOREGROUND_SERVICE_MEDIA_PROJECTION/-DATA_SYNC; beim Projektions-Start
startForeground(..., MEDIA_PROJECTION|DATA_SYNC) VOR getMediaProjection().
Normale Starts nutzen explizit nur dataSync (Android-14-konform). Version 0.0.0.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 21:42:23 +02:00
duffyduck 96012dc986 release(agent): bump to 0.0.0.4 2026-09-24 21:16:56 +02:00
duffyduckandClaude Opus 4.8 80d7f62eaa fix(android-agent): Screenshot-Timeout — nie stumm scheitern + gekappte Aufnahme
Timeout hiess: kein host_result kam zurueck. Ursachen adressiert:
1) handle() umschliesst JEDE Aktion mit try/catch(Throwable) -> auch OOM/Exception
   liefert jetzt ein host_result mit Fehlertext statt Stille (kein Timeout mehr).
2) ScreenCapturer nimmt direkt in gekappter Aufloesung auf (max 1280 lange Seite,
   MediaProjection skaliert) statt Vollbild-Bitmap + Nachskalieren -> viel weniger
   Speicher/Zeit (kein 10-MB-ARGB-Bitmap -> kein OOM), Frame-Warten bis ~3s.
lastError fliesst in die Screenshot-Fehlermeldung. Version 0.0.0.4/code 4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 21:14:15 +02:00
duffyduck 5f5234ee62 release(agent): bump to 0.0.0.3 2026-09-24 21:01:05 +02:00
duffyduckandClaude Opus 4.8 28e58089aa fix(android-agent): fester Signaturschluessel -> Updates statt Paket-Konflikt
Jeder Build signierte bisher mit einem zufaelligen Auto-Debug-Key -> neue APK =
andere Signatur -> Android verweigert Update ('Konflikt mit bestehendem Paket').
Fester signingConfig (rootProject/aria-agent.keystore) fuer debug+release, per
Datei-Existenz-Guard. Keystore liegt nur lokal (gitignored, NICHT im public Repo
— Backup!). Verifiziert: Signer-DN CN=ARIA Host-Agent. Version 0.0.0.3/code 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:59:00 +02:00
duffyduck c1a90102f8 release(agent): bump to 0.0.0.2 2026-09-24 20:52:21 +02:00
duffyduckandClaude Opus 4.8 ba531adc74 fix(android-agent): Crash bei Bildschirm-Zugriff (startForeground-Pflicht)
Der Projection-Consent kommt per startForegroundService, obwohl der Dienst schon
laeuft. Android verlangt danach binnen ~5s ein startForeground() -> fehlte im
Projection-Zweig -> ForegroundServiceDidNotStartInTimeException -> Prozess-Crash
(Diagnostic zeigt den Host bis zum Ping-Timeout noch gruen). Fix: onStartCommand
ruft IMMER zuerst startForeground(). Zusaetzlich ScreenCapturer.start in try/catch
mit lastError, das in der Screenshot-Fehlermeldung und der Notification erscheint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:50:33 +02:00
duffyduck 04d29b256e fix(host-agent): Release-Notes mit echten Zeilenumbruechen (literal \n weg) 2026-09-24 20:43:08 +02:00
duffyduck e6e07f672a release(agent): bump to 0.0.0.1 2026-09-24 20:41:50 +02:00
duffyduckandClaude Opus 4.8 d32e7e59c3 feat(host-agent): Windows-Build aus Docker (Wine) + setup.exe (Dienst)
Dockerfile.win (tobix/pywine): baut die Windows-.exe via Wine+PyInstaller und
per NSIS ein setup.exe, das den Agent via nssm als Autostart-Windows-Dienst
einrichtet und die .env aus %ProgramData%\ARIA-Host-Agent liest (AppDirectory).
build-win.sh als Einstieg; release_agent.sh baut Windows jetzt mit (SKIP_WINDOWS=1
ueberspringt). _load_dotenv haertet: sucht .env auch neben sys.executable (onefile-
.exe-Ort), nicht nur __file__/CWD. README: Windows-Build + Dienst + Release.
macOS bleibt self-build (nicht aus Docker moeglich).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:39:08 +02:00
duffyduckandClaude Opus 4.8 4f8e65b76b feat(host-agent): release_agent.sh — Binaries als Gitea-Assets, nicht im Tree
release_agent.sh <version> (wie die App-release.sh): setzt Version
(host_agent.py AGENT_VERSION + Android versionName/Code), baut Linux-Binary +
Android-APK per Docker, taggt agent-v<version> (eigener Namespace, keine
Kollision mit App-Tags v<version>), legt Gitea-Release an und laedt Assets hoch.
mac/win optional falls in dist/ vorgebaut. Agent meldet AGENT_VERSION jetzt in
host_hello + info. README-Release-Sektion aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:32:11 +02:00
duffyduckandClaude Opus 4.8 a2605a2802 fix(host-agent): binutils im Build-Image (PyInstaller braucht objdump)
Das slim-bullseye-Image bringt kein objdump mit -> PyInstaller bricht mit
'On Linux, objdump is required' ab. binutils per apt nachinstalliert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:28:08 +02:00
duffyduckandClaude Opus 4.8 0588a8d9b3 docs(android-agent): README-Build-Abschnitt praezisiert; Version 0.2.0
Ehrlicher Bau-Weg (nur Docker noetig, dist/aria-android-agent.apk, Debug-Key,
Installieren, Erst-Build-Kosten/Stolperer). release.sh klar als M4 geplant
markiert statt als vorhandener Befehl. versionCode 2 / versionName 0.2.0 (M1+M2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:26:50 +02:00
duffyduckandClaude Opus 4.8 be202b1fa1 feat(android-agent): Meilenstein 2 — sehen (Screenshot + ui_dump)
Screenshot via MediaProjection (ScreenCapturer, gleicher {format,bytes,base64}-
Vertrag wie der Desktop-Agent -> host_screenshot, inkl. Vision). UI-Baum via
AriaAccessibilityService (nur lesend) -> neues Brain-Tool host_ui_dump. Freigabe
einmalig in der App: 'Bildschirm-Zugriff erlauben' + 'Bedienungshilfe oeffnen'.
caps = [info, screenshot, ui_dump]. targetSdk 33 -> keine mediaProjection-FGS-
Typpflicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:22:52 +02:00
duffyduckandClaude Opus 4.8 1ad85fb687 feat(android-agent): Meilenstein 1 — verbinden + in Diagnostic sichtbar
Nativer Kotlin-Agent (host-agent/android/): Gradle-Projekt, AndroidManifest,
RVS-WebSocket-Client (OkHttp) mit host_hello/host_ping/host_command->host_result,
Foreground-Service (Weg A, Auto-Reconnect, BootReceiver), Connect-UI (QR-Scan via
ZXing ODER manuell: host/port/token/name/TLS/Steuerung-Schalter). QR-Format =
{host,port,token,tls} wie die ARIA-App -> derselbe QR nutzbar.

M1-Aktion: info (Modell/Android/Akku). screenshot/ui_* liefern 'kommt in M2/M3'.
Gate: CONTROL_ENABLED-Schalter in der App. Build: Docker (Android-SDK+Gradle) ->
dist/aria-android-agent.apk (Debug, auto-signiert). Kein Google.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:08:54 +02:00
duffyduckandClaude Opus 4.8 558afee239 docs(android-agent): Push OHNE Google — self-hosted ntfy/UnifiedPush
Klargestellt: FCM (Google) ist NUR eine Option, keine Pflicht. Akkuschonender
Push geht self-hosted via UnifiedPush + ntfy auf dem ARIA-Server (laeuft auch auf
Custom-ROMs ohne Play Services). Fuer ein dediziertes Ziel-Handy ist der eigene
RVS-Socket (Weg A) oft die einfachste Dauerloesung. Empfehlung entsprechend
angepasst: A -> Push-Hybrid mit ntfy, FCM nur optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:50:14 +02:00
duffyduckandClaude Opus 4.8 b2fd8d8953 docs(android-agent): Push-Hybrid-Modell (Leerlauf=Push, Session=Socket) ergaenzt
Akku-optimal wie WhatsApp: idle nur FCM-Push, bei Befehl kurz Socket + Wakelock
fuer die Interaktion, danach wieder schlafen. Requirements (Firebase/Play Services/
Server-Push-Trigger) + Latenz dokumentiert. Empfehlung: erst Foreground-Service
(M1-3, sofort lauffaehig), dann Push-Hybrid als Akku-Ausbau.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:47:33 +02:00
duffyduckandClaude Opus 4.8 737915e267 docs(android-agent): Design fuer nativen Android-Agenten (Pfad B)
Nativer Kotlin-Agent unter host-agent/android/ zur Handy-Fernsteuerung inkl.
Bedienen fremder App-UIs (E-Mail-Setup) via AccessibilityService. Verbindung wie
die ARIA-App (QR-Scan/manuell), erscheint in der Diagnostic (host_hello).

Festgehalten: Android-Action-Set (screenshot/ui_dump/ui_tap/ui_text/ui_swipe/
ui_key/app_launch/info/notify statt Shell), Sicherheit (CONTROL_ENABLED + explizite
Accessibility/MediaProjection-Freigabe), Dauerbetrieb (A: Foreground-Service —
einfach, reutzt host_command 1:1; B: FCM-Push wie WhatsApp — akkuschonend, spaeter),
4 Meilensteine, Build via Docker/Gradle + release.sh <version> -> Gitea-Asset.

Status: Design. Naechster Schritt: Meilenstein 1 (Verbinden + sichtbar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:46:19 +02:00
duffyduckandClaude Opus 4.8 1140934e15 feat(host-agent): cross-platform — Linux, macOS UND Windows
Eine Codebasis fuer alle drei Desktop-OS. Der Agent waehlt je OS automatisch:
- exec: bash -lc (Linux/macOS) bzw. PowerShell (Windows)
- Root/Admin: sudo (Unix) bzw. 'als Administrator starten' (Windows, kein sudo)
- Screenshot: grim/scrot (Linux) · screencapture (macOS) · PowerShell/System.
  Drawing (Windows)
- Root-Check: _is_admin() (os.geteuid Unix / IsUserAnAdmin Windows) statt hartem
  os.geteuid (crashte auf Windows)
- info: user aus USER|USERNAME; getloadavg bleibt guarded

Build: build.sh (Linux/Docker), build-native.sh (Linux/macOS), build-native.bat
(Windows). PyInstaller cross-kompiliert nicht -> je OS bauen. README-Plattform-
Tabelle ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:40:17 +02:00
64 changed files with 6515 additions and 96 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20500
versionName "0.2.5.0"
versionCode 20503
versionName "0.2.5.3"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
@@ -49,6 +49,28 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Teilen-Ziel: ARIA erscheint im Teilen-Menue anderer Apps (WhatsApp,
Galerie, Browser ...). EINE Datei bzw. reiner Text. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="application/*" />
</intent-filter>
<!-- Mehrere Dateien auf einmal. -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="application/*" />
</intent-filter>
</activity>
<provider
@@ -1,5 +1,6 @@
package com.ariacockpit
import android.content.Intent
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
@@ -19,4 +20,26 @@ class MainActivity : ReactActivity() {
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
/**
* Teilen in die LAUFENDE App (launchMode=singleTask liefert das hier ab).
* setIntent() ist wichtig, damit getSharedData() denselben Inhalt sieht, und
* das Event weckt die JS-Seite sofort — ohne es wuerde der geteilte Inhalt
* erst beim naechsten Kaltstart auftauchen.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
// Best effort: je nach RN-Architektur ist der Kontext anders erreichbar.
// Scheitert es, greift der zuverlaessige Zweitweg — die JS-Seite fragt beim
// Zurueckkehren in den Vordergrund ohnehin nochmal via getSharedData() nach
// (setIntent() oben sorgt dafuer, dass sie denselben Inhalt sieht).
try {
val ctx = (application as? com.facebook.react.ReactApplication)
?.reactNativeHost?.reactInstanceManager?.currentReactContext
ShareIntentModule.notifyNewIntent(
ctx as? com.facebook.react.bridge.ReactApplicationContext, intent)
} catch (_: Throwable) {
}
}
}
@@ -25,6 +25,7 @@ class MainApplication : Application(), ReactApplication {
add(OpenWakeWordPackage())
add(PhoneCallPackage())
add(BackgroundAudioPackage())
add(ShareIntentPackage())
}
override fun getJSMainModuleName(): String = "index"
@@ -0,0 +1,154 @@
package com.ariacockpit
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.io.File
/**
* Teilen-Ziel: nimmt Inhalte entgegen, die aus anderen Apps (WhatsApp, Galerie,
* Browser ...) per "Teilen → ARIA" geschickt werden.
*
* Die geteilten Dateien kommen als content://-URI, auf die nur DIESE App und nur
* JETZT zugreifen darf. Deshalb kopieren wir sie sofort in den App-Cache und
* geben einen echten Dateipfad zurueck — den kann die JS-Seite wie eine normal
* ausgewaehlte Datei weiterverarbeiten (RNFS.readFile → Base64 → Upload).
*
* Ablauf:
* App geschlossen → Intent liegt beim Start an → getSharedData()
* App laeuft → onNewIntent (launchMode=singleTask) → Event 'ShareReceived'
*/
class ShareIntentModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "ShareIntent"
companion object {
private const val EVENT = "ShareReceived"
/** Vom MainActivity.onNewIntent gerufen, wenn die App schon laeuft. */
@JvmStatic
fun notifyNewIntent(ctx: ReactApplicationContext?, intent: Intent?) {
if (ctx == null || intent == null || !isShare(intent)) return
try {
val data = extract(ctx, intent)
ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(EVENT, data)
} catch (_: Throwable) {
}
}
private fun isShare(i: Intent): Boolean =
i.action == Intent.ACTION_SEND || i.action == Intent.ACTION_SEND_MULTIPLE
/** Baut {text, items:[{path,name,type,size}]} aus dem Intent. */
private fun extract(ctx: ReactApplicationContext, intent: Intent): WritableMap {
val out = Arguments.createMap()
out.putString("text", intent.getStringExtra(Intent.EXTRA_TEXT) ?: "")
val uris = mutableListOf<Uri>()
if (intent.action == Intent.ACTION_SEND) {
@Suppress("DEPRECATION")
(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))?.let { uris.add(it) }
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
?.forEach { uris.add(it) }
}
val items: WritableArray = Arguments.createArray()
for (uri in uris) {
copyToCache(ctx, uri)?.let { items.pushMap(it) }
}
out.putArray("items", items)
return out
}
/**
* Kopiert den geteilten Inhalt in den Cache. Noetig, weil die Leseerlaubnis
* fuer die content://-URI nur fuer diesen Intent gilt — spaeter (nach der
* Projektauswahl) waere sie womoeglich schon erloschen.
*/
private fun copyToCache(ctx: ReactApplicationContext, uri: Uri): WritableMap? {
return try {
val cr = ctx.contentResolver
var name = "geteilt"
var size = 0L
cr.query(uri, null, null, null, null)?.use { c ->
val ni = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val si = c.getColumnIndex(OpenableColumns.SIZE)
if (c.moveToFirst()) {
if (ni >= 0) c.getString(ni)?.let { name = it }
if (si >= 0) size = c.getLong(si)
}
}
val type = cr.getType(uri) ?: "application/octet-stream"
val dir = File(ctx.cacheDir, "shared").apply { mkdirs() }
// Eindeutiger Name, damit zwei gleichnamige Bilder sich nicht ueberschreiben.
val safe = name.replace(Regex("[^A-Za-z0-9._-]"), "_").take(80)
val dst = File(dir, "${System.currentTimeMillis()}_$safe")
cr.openInputStream(uri)?.use { input ->
dst.outputStream().use { output -> input.copyTo(output) }
} ?: return null
Arguments.createMap().apply {
putString("path", dst.absolutePath)
putString("name", name)
putString("type", type)
putDouble("size", (if (size > 0) size else dst.length()).toDouble())
}
} catch (_: Throwable) {
null
}
}
}
/**
* Holt den Inhalt, mit dem die App GESTARTET wurde (App war zu).
* Gibt {text, items:[]} zurueck — items leer, wenn nichts geteilt wurde.
*/
@ReactMethod
fun getSharedData(promise: Promise) {
try {
val activity = currentActivity
val intent = activity?.intent
if (intent == null || !isShare(intent)) {
promise.resolve(Arguments.createMap().apply {
putString("text", "")
putArray("items", Arguments.createArray())
})
return
}
val data = extract(reactContext, intent)
// Verbraucht markieren, damit ein Rotieren/Zurueckkehren den Dialog
// nicht erneut oeffnet.
activity.intent = Intent(Intent.ACTION_MAIN)
promise.resolve(data)
} catch (e: Throwable) {
promise.reject("SHARE_READ_FAILED", e.message, e)
}
}
/** Aufgeraeumte Kopien loeschen (nach erfolgreichem Senden). */
@ReactMethod
fun clearCache(promise: Promise) {
try {
File(reactContext.cacheDir, "shared").listFiles()?.forEach { it.delete() }
promise.resolve(true)
} catch (e: Throwable) {
promise.resolve(false)
}
}
// Fuer NativeEventEmitter (RN verlangt diese beiden Methoden).
@ReactMethod fun addListener(eventName: String) {}
@ReactMethod fun removeListeners(count: Int) {}
}
@@ -0,0 +1,16 @@
package com.ariacockpit
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class ShareIntentPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ShareIntentModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.5.0",
"version": "0.2.5.3",
"private": true,
"scripts": {
"android": "react-native run-android",
+183
View File
@@ -0,0 +1,183 @@
/**
* Teilen-Dialog — was aus anderen Apps per "Teilen → ARIA" hereinkommt.
*
* Ablauf wie gewuenscht: erst sehen WAS geteilt wurde, dann WOHIN (Projekt
* oder Hauptchat), dann optional ein Text dazu. Ohne Text geht genauso.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
ActivityIndicator, FlatList, Image, Modal, StyleSheet, Text,
TextInput, TouchableOpacity, View,
} from 'react-native';
import brainApi, { Project } from '../services/brainApi';
export interface SharedItem {
path: string;
name: string;
type: string;
size: number;
}
interface Props {
visible: boolean;
items: SharedItem[];
/** Text, den die sendende App mitgegeben hat (z.B. geteilter Link). */
initialText?: string;
onCancel: () => void;
/** projectId: '' = Hauptchat. text: optional, darf leer sein. */
onSend: (projectId: string, text: string) => void;
}
const fmtSize = (n: number) =>
n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB`
: n > 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`;
const ShareModal: React.FC<Props> = ({ visible, items, initialText, onCancel, onSend }) => {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false);
const [pid, setPid] = useState<string | null>(null); // null = noch nichts gewaehlt
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
useEffect(() => {
if (!visible) return;
setPid(null);
setText(initialText || '');
setSending(false);
setLoading(true);
brainApi.listProjects(false)
.then(ps => setProjects(ps || []))
.catch(() => setProjects([]))
.finally(() => setLoading(false));
}, [visible, initialText]);
const send = useCallback(() => {
if (pid === null || sending) return;
setSending(true);
onSend(pid, text.trim());
}, [pid, text, sending, onSend]);
const preview = items.find(i => i.type?.startsWith('image/'));
return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onCancel}>
<View style={s.backdrop}>
<View style={s.box}>
<Text style={s.title}>Mit ARIA teilen</Text>
{/* Was wird geteilt */}
<View style={s.itemsBox}>
{preview ? (
<Image source={{ uri: 'file://' + preview.path }} style={s.thumb} resizeMode="cover" />
) : null}
<View style={{ flex: 1 }}>
{items.length === 0 ? (
<Text style={s.dim}>Nur Text</Text>
) : items.slice(0, 3).map((it, i) => (
<Text key={i} style={s.itemLine} numberOfLines={1}>
{it.name} <Text style={s.dim}>({fmtSize(it.size)})</Text>
</Text>
))}
{items.length > 3 ? (
<Text style={s.dim}>+ {items.length - 3} weitere</Text>
) : null}
</View>
</View>
{/* Wohin */}
<Text style={s.label}>Wohin soll das?</Text>
{loading ? (
<ActivityIndicator color="#FFD60A" style={{ marginVertical: 12 }} />
) : (
<FlatList
style={s.list}
data={[{ id: '', name: 'Hauptchat', description: '' } as Project, ...projects]}
keyExtractor={p => p.id || '__main__'}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => {
const sel = pid === item.id;
return (
<TouchableOpacity
style={[s.row, sel && s.rowSel]}
onPress={() => setPid(item.id)}>
<Text style={[s.rowText, sel && s.rowTextSel]} numberOfLines={1}>
{item.id ? '📁 ' : '💬 '}{item.name}
</Text>
{sel ? <Text style={s.check}>✓</Text> : null}
</TouchableOpacity>
);
}}
/>
)}
{/* Optionaler Text */}
<Text style={s.label}>Text dazu <Text style={s.dim}>(optional)</Text></Text>
<TextInput
style={s.input}
value={text}
onChangeText={setText}
placeholder="z.B. „Schau dir das mal an“ — kann auch leer bleiben"
placeholderTextColor="#666680"
multiline
/>
<View style={s.actions}>
<TouchableOpacity style={s.btnGhost} onPress={onCancel} disabled={sending}>
<Text style={s.btnGhostText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.btn, (pid === null || sending) && s.btnDisabled]}
onPress={send}
disabled={pid === null || sending}>
<Text style={s.btnText}>
{sending ? 'Senden …' : pid === null ? 'Ziel wählen' : 'Senden'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
const s = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.75)', justifyContent: 'flex-end' },
box: {
backgroundColor: '#0D0D1A', borderTopLeftRadius: 16, borderTopRightRadius: 16,
padding: 18, maxHeight: '88%',
},
title: { color: '#E0E0F0', fontSize: 19, fontWeight: '700', marginBottom: 12 },
itemsBox: {
flexDirection: 'row', gap: 10, alignItems: 'center',
backgroundColor: '#12121E', borderRadius: 10, padding: 10, marginBottom: 14,
},
thumb: { width: 56, height: 56, borderRadius: 8, backgroundColor: '#1E1E2E' },
itemLine: { color: '#E0E0F0', fontSize: 13 },
dim: { color: '#8888AA', fontSize: 12 },
label: { color: '#8888AA', fontSize: 12, marginBottom: 6 },
list: { maxHeight: 190, marginBottom: 14 },
row: {
flexDirection: 'row', alignItems: 'center', paddingVertical: 10,
paddingHorizontal: 12, borderRadius: 8, marginBottom: 4, backgroundColor: '#12121E',
},
rowSel: { backgroundColor: '#1E2A1E', borderWidth: 1, borderColor: '#3FFF9F' },
rowText: { color: '#E0E0F0', fontSize: 14, flex: 1 },
rowTextSel: { color: '#3FFF9F', fontWeight: '600' },
check: { color: '#3FFF9F', fontSize: 16 },
input: {
backgroundColor: '#12121E', borderRadius: 8, color: '#E0E0F0',
paddingHorizontal: 12, paddingVertical: 10, minHeight: 56,
textAlignVertical: 'top', marginBottom: 16, fontSize: 14,
},
actions: { flexDirection: 'row', gap: 10, justifyContent: 'flex-end' },
btnGhost: { paddingVertical: 11, paddingHorizontal: 18 },
btnGhostText: { color: '#8888AA', fontSize: 15 },
btn: {
backgroundColor: '#FFD60A', borderRadius: 8,
paddingVertical: 11, paddingHorizontal: 24,
},
btnDisabled: { backgroundColor: '#3A3A2A' },
btnText: { color: '#0D0D1A', fontSize: 15, fontWeight: '700' },
});
export default ShareModal;
+95 -9
View File
@@ -49,6 +49,8 @@ import updateService from '../services/updater';
import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload';
import ShareModal, { SharedItem } from '../components/ShareModal';
import shareIntent from '../services/shareIntent';
import MessageText from '../components/MessageText';
import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
import Geolocation from '@react-native-community/geolocation';
@@ -348,6 +350,10 @@ const ChatScreen: React.FC = () => {
} | null>(null);
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
// Teilen-Ziel: Inhalte, die aus anderen Apps hereingereicht wurden.
const [shareItems, setShareItems] = useState<SharedItem[]>([]);
const [shareText, setShareText] = useState('');
const [shareVisible, setShareVisible] = useState(false);
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
// Multi-Threading: Activity pro Kontext (key = projectId, '' = Hauptchat).
// Der Indikator zeigt nur den fokussierten Kontext — nicht global.
@@ -1303,6 +1309,10 @@ const ChatScreen: React.FC = () => {
sender: 'user',
text: diagText,
timestamp: message.timestamp,
// projectId MUSS mit: die Chat-Ansicht filtert danach. Ohne das
// landete die Nachricht im Hauptchat-Topf und war in jedem Projekt
// unsichtbar — man sah nur ARIAs Antwort, nicht die eigene Frage.
projectId: (message.payload.projectId as string) || '',
}]));
}
return;
@@ -1802,9 +1812,20 @@ const ChatScreen: React.FC = () => {
// dem STT-Text gefuellte Bubble NICHT loeschen, sonst verschwindet die
// Sprachnachricht obwohl ARIA schon an der Antwort arbeitet.
if (ev.audioRequestId) {
setMessages(prev => prev.filter(m =>
!(m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet'))));
// 'no-response' heisst: die Spracherkennung hat GAR NICHT geantwortet
// (Worker neu gestartet / nicht erreichbar) — die Aufnahme ist also
// wirklich verloren gegangen, Du hast nicht etwa nichts gesagt. Das
// zeigen wir an, statt die Bubble stillschweigend zu entfernen; sonst
// wirkt es, als waere die Nachricht nie gesendet worden.
const lost = String(ev.reason || '').includes('no-response');
setMessages(prev => lost
? prev.map(m => (m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet'))
? { ...m, text: '⚠️ Aufnahme ging verloren — die Spracherkennung war gerade nicht erreichbar. Bitte nochmal aufnehmen.' }
: m)
: prev.filter(m =>
!(m.audioRequestId === ev.audioRequestId
&& m.text.includes('Spracheingabe wird verarbeitet'))));
}
// Passiv-Lauschen: leeres Endpoint (no-speech / Silero-Musik / speaker_
// mismatch). NICHT sofort beenden — solange die Stille-Toleranz ab
@@ -2456,16 +2477,47 @@ const ChatScreen: React.FC = () => {
setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]);
}, []);
// ── Teilen-Ziel ────────────────────────────────────────────────
// Beim Start pruefen (App war zu) und auf spaetere Shares lauschen
// (App laeuft schon → onNewIntent im MainActivity).
useEffect(() => {
let alive = true;
const open = (p: { text: string; items: SharedItem[] }) => {
if (!alive) return;
if (!p.items.length && !p.text) return; // nichts dabei
setShareItems(p.items);
setShareText(p.text);
setShareVisible(true);
};
shareIntent.getShared().then(open).catch(() => {});
const unsub = shareIntent.onShare(open);
// Zweitweg: Teilen holt die App in den Vordergrund. Beim Wechsel nach
// 'active' nochmal nachfragen — das greift auch, wenn das native Event
// (je nach RN-Architektur) nicht durchkommt. getSharedData() markiert den
// Intent als verbraucht, es kann also nicht doppelt aufpoppen.
const appSub = AppState.addEventListener('change', st => {
if (st === 'active') shareIntent.getShared().then(open).catch(() => {});
});
return () => { alive = false; unsub(); try { appSub.remove(); } catch (_) {} };
}, []);
// Alle Pending Anhaenge + Text senden
const sendPendingAttachments = useCallback(async (messageText: string) => {
if (pendingAttachments.length === 0) return;
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
const sendPendingAttachments = useCallback(async (
messageText: string,
// Explizite Liste (Teilen-Weg): der State waere beim direkten Aufruf noch
// nicht aktualisiert. Ohne Angabe wie bisher die Pending-Liste.
explicitAttachments?: { file: any; isPhoto: boolean }[],
) => {
const atts = explicitAttachments ?? pendingAttachments;
if (atts.length === 0) return;
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', atts.length);
const location = await getCurrentLocation();
const msgId = nextId();
// Alle Attachments fuer die Chat-Nachricht sammeln
const attachments: Attachment[] = [];
for (const { file, isPhoto } of pendingAttachments) {
for (const { file, isPhoto } of atts) {
const isImage = isPhoto || (file.type && file.type.startsWith('image/'));
const name = isPhoto ? file.fileName : file.name;
const base64 = file.base64 || '';
@@ -2488,7 +2540,7 @@ const ChatScreen: React.FC = () => {
const userMsg: ChatMessage = {
id: msgId,
sender: 'user',
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
text: messageText || `${atts.length} Anhang/Anhaenge`,
timestamp: Date.now(),
attachments,
projectId: activePid,
@@ -2501,7 +2553,7 @@ const ChatScreen: React.FC = () => {
setMessages(prev => capMessages([...prev, userMsg]));
// Alle Dateien an RVS senden + auf Disk speichern
for (const { file, isPhoto } of pendingAttachments) {
for (const { file, isPhoto } of atts) {
const name = isPhoto ? file.fileName : file.name;
const base64 = file.base64 || '';
const mimeType = file.type || '';
@@ -2556,6 +2608,33 @@ const ChatScreen: React.FC = () => {
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
/** Geteiltes senden: Ziel setzen, Dateien anhaengen, abschicken. */
const handleShareSend = useCallback(async (projectId: string, text: string) => {
try {
// Chat-Focus aufs gewaehlte Ziel — die Nachricht landet dort, und Stefan
// steht nach dem Senden gleich im richtigen Kontext.
setFocusedProjectId(projectId);
focusedProjectIdRef.current = projectId;
const files = await shareIntent.toFileData(shareItems);
if (files.length) {
// Liste explizit uebergeben: der State waere hier noch leer.
await sendPendingAttachments(text, files.map(f => ({ file: f, isPhoto: false })));
} else if (text) {
// Reiner Text (z.B. geteilter Link): ins Eingabefeld, Stefan schickt ab.
setInputText(text);
}
shareIntent.clearShareCache();
} catch (e) {
console.warn('[Share] Senden fehlgeschlagen:', e);
Alert.alert('Teilen fehlgeschlagen', String((e as any)?.message || e));
} finally {
setShareVisible(false);
setShareItems([]);
setShareText('');
}
}, [shareItems, sendPendingAttachments]);
const sendTextMessage = useCallback(async () => {
const text = inputText.trim();
@@ -3693,6 +3772,13 @@ const ChatScreen: React.FC = () => {
{/* Datei-Upload Modal */}
<Modal visible={showFileUpload} transparent animationType="slide">
<View style={styles.modalOverlay}>
<ShareModal
visible={shareVisible}
items={shareItems}
initialText={shareText}
onCancel={() => { setShareVisible(false); setShareItems([]); setShareText(''); }}
onSend={handleShareSend}
/>
<FileUpload
onFileSelected={handleFileSelected}
onCancel={() => setShowFileUpload(false)}
+54 -15
View File
@@ -1141,7 +1141,7 @@ class AudioService {
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
* automatisch in dem Kontext den Stefan gerade sieht. */
projectId?: string;
}): Promise<{ requestId: string; ok: boolean }> {
}): Promise<{ requestId: string; ok: boolean; error?: string }> {
if (this.recordingState !== 'idle') {
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
return { requestId: '', ok: false };
@@ -1155,12 +1155,32 @@ class AudioService {
console.warn('[Audio] Keine Mikrofon-Berechtigung');
return { requestId: '', ok: false };
}
// Ohne Verbindung gar nicht erst aufnehmen: rvs.send() verwirft still, wenn
// der Socket nicht offen ist (kein Puffer, kein Retry). Genau deshalb fiel
// nach App-Resume/Reconnect die ERSTE Aufnahme komplett unter den Tisch —
// start, Chunks und end waren weg, die Bubble verschwand, und erst der
// zweite Versuch klappte (da war der Reconnect durch). Nachtraeglich
// gepuffertes Audio waere sinnlos: die STT-Sitzung laeuft in Echtzeit.
// WICHTIG: den SOCKET fragen, nicht den gemerkten Zustand. Der kann
// faelschlich 'disconnected' sein, obwohl die Verbindung laeuft (Zombie) —
// dann wuerde hier jede Aufnahme abgelehnt, bevor sie beginnt, und der
// Nutzer kann gar nicht mehr sprechen.
if (!rvs.isSocketOpen()) {
console.warn('[Audio] startStreamingRecording: Socket nicht offen (state=%s)',
rvs.getState());
return { requestId: '', ok: false, error: 'not-connected' };
}
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
this.stopPlayback();
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
this.streamRequestId = requestId;
// Zustand SOFORT setzen (nicht erst nach den awaits weiter unten): sonst ist
// isStreamingRecording() bereits true, recordingState aber noch 'idle' —
// ein Abbruch in diesem Fenster liess den Start danach weiterlaufen und der
// Recorder blieb haengen, sodass die NAECHSTE Aufnahme abgelehnt wurde.
this.setState('recording');
this.streamAudioRequestId = opts.audioRequestId || '';
// Redundanz-Routing: freie STT-Instanz leasen BEVOR Chunks fliessen, damit
// start + alle Chunks + end dieselbe Instanz adressieren. Kurzer Timeout →
@@ -1202,20 +1222,17 @@ class AudioService {
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
}
const started = await PcmStreamRecorder.start();
if (!started) {
throw new Error('PcmStreamRecorder.start returned false');
// Abbruch waehrend der awaits oben? Dann hier aussteigen, statt eine
// Sitzung zu starten, die niemand mehr erwartet.
if (this.streamRequestId !== requestId) {
console.warn('[Audio] Start abgebrochen (Session gewechselt) — breche ab');
return { requestId: '', ok: false, error: 'superseded' };
}
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
await this._captureMediaActive();
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
this.setState('recording');
// stt_stream_start — der Whisper-Bridge mitteilen dass jetzt Chunks kommen.
// stt_stream_start MUSS vor dem Recorder raus: sonst treffen die ersten
// Chunks (200ms-Takt) auf eine Bridge, die die Sitzung noch nicht kennt,
// und werden verworfen — der Wortanfang fehlt, bei kurzen Kommandos bleibt
// gar nichts uebrig.
rvs.send('stt_stream_start' as any, {
requestId,
audioRequestId: opts.audioRequestId || '',
@@ -1230,18 +1247,40 @@ class AudioService {
targetInstance: this.streamTargetInstance,
});
const started = await PcmStreamRecorder.start();
if (!started) {
throw new Error('PcmStreamRecorder.start returned false');
}
// AudioFocus exklusiv — gleiche Semantik wie beim Legacy-Pfad.
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
await this._captureMediaActive();
this._cancelDeferredFocusRelease();
AudioFocus?.requestExclusive().catch(() => {});
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
// Wenn nach Konversationsfenster kein einziger stt_partial gekommen ist,
// hat der User vermutlich nix gesagt → Session beenden.
// WICHTIG: Der Aufrufer uebergibt hier die Sprechpausen-Toleranz
// (sttEndpointMs, Default 2400ms). Die misst aber ab dem ENDE des
// Sprechens — dieser Watchdog laeuft ab dem START der Aufnahme. Bis das
// erste Zwischenergebnis zurueck sein KANN, vergehen realistisch 1,3-2,2s
// (Mindest-Puffer 600ms + Transkriptions-Takt 700ms + Netzweg hin/zurueck).
// Ohne Zuschlag reisst schon ein kurzes Ueberlegen nach dem Gong die
// Grenze: die Aufnahme wurde abgebrochen und die Bubble verschwand — erst
// der zweite Versuch klappte, weil man da sofort losredet.
const NO_SPEECH_GRACE_MS = 2500;
const noSpeechMs = Number(opts.noSpeechTimeoutMs || 0);
if (noSpeechMs > 0) {
const effectiveMs = noSpeechMs + NO_SPEECH_GRACE_MS;
this.streamNoSpeechTimer = setTimeout(() => {
if (this.streamRequestId === requestId && !this.streamGotPartial) {
console.log('[Audio] Stream %s: no-speech nach %dms → cancel',
requestId.slice(0, 12), noSpeechMs);
requestId.slice(0, 12), effectiveMs);
this.cancelStreamingRecording('no-speech').catch(() => {});
}
}, noSpeechMs);
}, effectiveMs);
}
// Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).
+35 -7
View File
@@ -104,11 +104,7 @@ class RVSConnection {
// Verbindungen + doppelte Events.
if (this.ws) {
this.log('info', 'Bestehende WS-Verbindung wird geschlossen vor Neu-Connect');
try {
this.ws.onclose = null; // verhindert dass scheduleReconnect doppelt feuert
this.ws.onerror = null;
this.ws.close();
} catch (_) {}
this.detachSocket(this.ws);
this.ws = null;
}
@@ -134,6 +130,15 @@ class RVSConnection {
this.setState('disconnected');
}
/**
* Ist die Verbindung WIRKLICH offen? Fragt den Socket selbst, nicht den
* gemerkten Zustand — der kann (z.B. nach einem Zombie) falsch sein. Wer
* davon abhaengig macht, ob ueberhaupt gesendet wird, muss das Echte wissen.
*/
isSocketOpen(): boolean {
return !!this.ws && this.ws.readyState === WebSocket.OPEN;
}
/** Nachricht an den RVS senden */
send(type: MessageType, payload: Record<string, unknown>): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
@@ -215,7 +220,12 @@ class RVSConnection {
this.startHeartbeat();
};
const socket = this.ws; // genau DIESE Verbindung (nicht 'this.ws' spaeter)
this.ws.onmessage = (event: WebSocketMessageEvent) => {
// Nur die AKTUELLE Verbindung darf Nachrichten einspeisen. Sonst
// koennte eine alte, laengst aufgegebene Verbindung weiter Daten
// liefern und vortaeuschen, es sei alles in Ordnung.
if (socket !== this.ws) return;
try {
const message: RVSMessage = JSON.parse(event.data as string);
this.notifyMessageListeners(message);
@@ -258,8 +268,7 @@ class RVSConnection {
this.log('warn', 'TLS fehlgeschlagen — Fallback auf ws:// (ohne TLS)');
this.clearTimers();
if (this.ws) {
this.ws.onclose = null; // onclose-Handler entfernen um Doppel-Reconnect zu verhindern
try { this.ws.close(); } catch (_) {}
this.detachSocket(this.ws); // inkl. onmessage — sonst bleibt ein Zombie, der weiter empfaengt
}
this.ws = null;
this.shouldReconnect = true;
@@ -302,6 +311,25 @@ class RVSConnection {
}, HEARTBEAT_INTERVAL_MS);
}
/**
* Klemmt eine Verbindung VOLLSTAENDIG ab, bevor sie verworfen wird.
*
* Wichtig: frueher wurden nur onclose/onerror entfernt — onmessage blieb
* haengen. Eine aufgegebene (Zombie-)Verbindung lieferte dadurch weiter
* Nachrichten an die App, waehrend gesendet wurde ueber die neue/tote.
* Symptom: die App zeigt "getrennt", ARIAs Sprachausgabe kommt aber trotzdem
* an — und nichts, was man schreibt oder spricht, erreicht den Server.
*/
private detachSocket(ws: WebSocket): void {
try {
ws.onopen = null;
ws.onmessage = null; // <- das fehlte
ws.onclose = null; // verhindert doppelten Reconnect
ws.onerror = null;
ws.close();
} catch (_) {}
}
private clearTimers(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
+77
View File
@@ -0,0 +1,77 @@
/**
* Teilen-Ziel — Bruecke zum nativen ShareIntent-Modul.
*
* Zwei Faelle:
* App war ZU → der Inhalt liegt beim Start am Intent → getShared()
* App LAEUFT → onNewIntent feuert das Event 'ShareReceived' → onShare()
*
* Die Dateien hat die native Seite schon in den App-Cache kopiert und liefert
* echte Pfade; content://-URIs waeren nach dem Dialog womoeglich nicht mehr
* lesbar (die Leseerlaubnis gilt nur fuer den Intent).
*/
import { NativeEventEmitter, NativeModules } from 'react-native';
import RNFS from 'react-native-fs';
import type { FileData } from '../components/FileUpload';
import type { SharedItem } from '../components/ShareModal';
const { ShareIntent } = NativeModules;
export interface SharedPayload {
text: string;
items: SharedItem[];
}
const EMPTY: SharedPayload = { text: '', items: [] };
/** Inhalt, mit dem die App gestartet wurde (leer, wenn normal geoeffnet). */
export async function getShared(): Promise<SharedPayload> {
if (!ShareIntent?.getSharedData) return EMPTY;
try {
const r = await ShareIntent.getSharedData();
return { text: r?.text || '', items: Array.isArray(r?.items) ? r.items : [] };
} catch (e) {
console.warn('[Share] getSharedData fehlgeschlagen:', e);
return EMPTY;
}
}
/** Abonniert Shares in die bereits laufende App. Gibt Unsubscribe zurueck. */
export function onShare(cb: (p: SharedPayload) => void): () => void {
if (!ShareIntent) return () => {};
const emitter = new NativeEventEmitter(ShareIntent);
const sub = emitter.addListener('ShareReceived', (r: any) => {
cb({ text: r?.text || '', items: Array.isArray(r?.items) ? r.items : [] });
});
return () => { try { sub.remove(); } catch (_) {} };
}
/** Zwischenkopien loeschen (nach erfolgreichem Senden). */
export async function clearShareCache(): Promise<void> {
try { await ShareIntent?.clearCache?.(); } catch (_) {}
}
/**
* Macht aus geteilten Dateien das Format, das der bestehende Upload-Weg
* erwartet (Base64) — so laeuft Teilen durch exakt dieselbe Strecke wie die
* Bueroklammer, inklusive Anhang-Darstellung im Chat.
*/
export async function toFileData(items: SharedItem[]): Promise<FileData[]> {
const out: FileData[] = [];
for (const it of items) {
try {
const base64 = await RNFS.readFile(it.path, 'base64');
out.push({
name: it.name || 'geteilt',
type: it.type || 'application/octet-stream',
size: it.size || 0,
base64,
uri: 'file://' + it.path,
});
} catch (e) {
console.warn('[Share] Datei nicht lesbar (%s): %s', it.name, e);
}
}
return out;
}
export default { getShared, onShare, clearShareCache, toFileData };
+612 -4
View File
@@ -1241,6 +1241,16 @@ META_TOOLS = [
"oder Key). Fuer Geraete mit hinterlegten Zugangsdaten "
"(community/v3/Login/SSH) nutzt der Satellit diese automatisch — Du musst "
"keine Passwoerter mitgeben. "
"ANDROID/FIRE TV ausrollen: 'adb.install' (params={'ip':'<geraet>', "
"'url':'<apk-url>'}) installiert/aktualisiert eine App auf einem Android-"
"Geraet im Netz — so kommt der ARIA-Agent auf einen Fire TV Stick, ohne "
"eine Datei von Hand zu kopieren. Voraussetzung: auf dem Geraet ist ADB-"
"Debugging aktiv (Fire TV: Einstellungen > Mein Fire TV > Entwickleroptionen) "
"und der Dialog 'USB-Debugging zulassen?' wurde bestaetigt. Weiter: "
"'adb.devices' (welche Geraete sind verbunden), 'adb.connect' "
"(params={'ip':'...'}) und 'adb.shell' (params={'ip':'...','cmd':'...'}) "
"fuer Diagnose. In der Geraeteliste (satellite_devices) haben ausrollbare "
"Geraete adbCapable=true; Fire TV Sticks type='firetv'. "
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks UND "
"um Geraete-Statusseiten zu lesen (Fallback fuer Tinte, NAS ...). Bei grossen "
"Seiten NICHT blind paginieren: setze params['contains'] (String oder "
@@ -1270,7 +1280,8 @@ META_TOOLS = [
"description": (
"Zeigt die ARIA-Host-Agenten (Rechner, auf denen ein Agent DIREKT "
"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 "
"sitzt. Nutze das, wenn Stefan etwas 'auf meinem Laptop/PC/Server X' "
"machen will, das kein Geraet im Netz ist."
@@ -1364,6 +1375,281 @@ 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": (
"Klickt/tippt auf einem Host-Agenten auf eine Bildschirm-Position (x,y). "
"ANDROID: nimm die Koordinaten aus host_ui_dump (echte Pixel) — NICHT aus "
"dem Screenshot (der ist skaliert). DESKTOP (Windows/Linux/Mac): es gibt "
"kein ui_dump; nimm die Koordinaten DIREKT aus dem host_screenshot (voller "
"Auflösung = echte Pixel). button/double optional (Rechtsklick/Doppelklick)."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"x": {"type": "integer", "description": "X (Pixel)."},
"y": {"type": "integer", "description": "Y (Pixel)."},
"button": {"type": "string", "description": "left (Default) | right | middle."},
"double": {"type": "boolean", "description": "Doppelklick."},
},
"required": ["host", "x", "y"],
},
},
},
{
"type": "function",
"function": {
"name": "host_ui_text",
"description": (
"Schreibt Text auf einem Host-Agenten. ANDROID: in das Feld an x/y (aus "
"host_ui_dump, editable=true). DESKTOP: tippt in das AKTUELL fokussierte "
"Feld (x/y optional) — ggf. vorher mit host_ui_tap ins Feld klicken."
),
"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 Taste auf einem Host-Agenten. ANDROID: back | home | recents "
"| notifications. DESKTOP (Win/Linux/Mac): Tastennamen/Kombis wie Return, "
"Escape, Tab, Delete, Up/Down, ctrl+c, alt+F4 (Linux/xdotool-Syntax)."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"key": {"type": "string",
"description": "Android: back/home/recents/notifications. Desktop: Return, Escape, ctrl+c, …"},
},
"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). DESKTOP "
"(Win/Linux/Mac): per App-Name (query, z.B. 'firefox'/'Safari') ODER "
"beliebigem Startbefehl (command). Danach mit host_screenshot weiterarbeiten."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"package": {"type": "string", "description": "Android-Paketname (optional)."},
"query": {"type": "string", "description": "App-Name/Teilstring (optional)."},
"command": {"type": "string", "description": "Desktop: Startbefehl (optional)."},
},
"required": ["host"],
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_search",
"description": (
"Sucht Filme/Serien/Folgen auf einem Jellyfin-Server (Medien-Bibliothek). "
"Liefert Titel, Jahr, Typ und die itemId, die Du zum Abspielen brauchst. "
"NIMM DAS statt Dich durch die Fire-TV-Oberflaeche zu klicken — das ist "
"zuverlaessig und schnell. Server werden im Diagnostic unter "
"Integrationen gepflegt; ohne Angabe wird der Standard-Server genutzt."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Suchbegriff, z.B. 'Asterix Kleopatra'."},
"server": {"type": "string", "description": "Server-Name (optional, sonst Standard)."},
"limit": {"type": "integer", "description": "Max. Treffer (Default 10)."},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_devices",
"description": (
"Zeigt, auf welchen Geraeten gerade eine Jellyfin-App laeuft und damit "
"fernsteuerbar ist (Sitzungen) — z.B. der Fire TV im Schlafzimmer. "
"WICHTIG: Ein Geraet erscheint nur, wenn die Jellyfin-App dort GEOEFFNET "
"ist. Ist sie zu, starte sie zuerst mit host_app_launch (query='Jellyfin') "
"und frage dann erneut."
),
"parameters": {
"type": "object",
"properties": {
"server": {"type": "string", "description": "Server-Name (optional)."},
},
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_play",
"description": (
"Startet die Wiedergabe eines Titels auf einem Jellyfin-Geraet. Ablauf: "
"1. jellyfin_search -> itemId, 2. jellyfin_devices -> Geraet/Sitzung, "
"3. jellyfin_play. Prueft selbst nach, ob die Wiedergabe wirklich "
"angelaufen ist. GIB IMMER 'host' MIT (Name des Host-Agenten am "
"Fernseher, z.B. 'Fire-TV Schlafzimmer'): dann starte ich Jellyfin bei "
"einer eingeschlafenen Sitzung automatisch neu und lege nochmal nach, "
"statt faelschlich Erfolg zu melden."
),
"parameters": {
"type": "object",
"properties": {
"item_id": {"type": "string", "description": "itemId aus jellyfin_search."},
"device": {"type": "string", "description": "Geraete-/Sitzungsname aus jellyfin_devices (z.B. 'Fire TV')."},
"host": {"type": "string", "description": "Host-Agent am Geraet (fuer automatischen Neustart bei toter Sitzung)."},
"server": {"type": "string", "description": "Server-Name (optional)."},
"resume": {"type": "boolean", "description": "Wenn moeglich fortsetzen statt von vorn (Default true)."},
},
"required": ["item_id", "device"],
},
},
},
{
"type": "function",
"function": {
"name": "jellyfin_control",
"description": (
"Steuert die laufende Wiedergabe auf einem Jellyfin-Geraet: pause, "
"unpause, stop, nextTrack, previousTrack."
),
"parameters": {
"type": "object",
"properties": {
"device": {"type": "string", "description": "Geraete-/Sitzungsname."},
"command": {"type": "string", "description": "pause | unpause | stop | nextTrack | previousTrack"},
"server": {"type": "string", "description": "Server-Name (optional)."},
},
"required": ["device", "command"],
},
},
},
{
"type": "function",
"function": {
"name": "host_notify",
"description": (
"Zeigt eine Nachricht AUF einem Host-Agenten an — bei einem Fire TV / "
"Android TV als EINBLENDUNG ueber dem laufenden Bild (Netflix & Co. "
"laufen weiter), wahlweise als Sprachausgabe. NIMM DIESES TOOL, wenn "
"Stefan etwas 'auf dem Fernseher einblenden/anzeigen' will — versuche "
"das NICHT ueber host_exec/adb (dort gibt es kein passendes Kommando). "
"mode: 'overlay' (einblenden), 'speech' (vorlesen) oder 'both'; ohne "
"Angabe gilt die Einstellung am Geraet. Braucht dort einmalig die "
"Berechtigung 'Ueber anderen Apps anzeigen'."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name (z.B. 'Fire-TV Schlafzimmer')."},
"text": {"type": "string", "description": "Der anzuzeigende Text."},
"title": {"type": "string", "description": "Ueberschrift (Default 'ARIA')."},
"seconds": {"type": "integer", "description": "Anzeigedauer (Default 12)."},
"mode": {"type": "string", "description": "overlay | speech | both"},
"focus": {"type": "string", "description": "Bei Sprachausgabe: 'duck' (laufende Medien werden leiser, Standard) | 'pause' (Film/Musik anhalten) | 'none'."},
},
"required": ["host", "text"],
},
},
},
{
"type": "function",
"function": {
"name": "host_speak",
"description": (
"Laesst einen Host-Agenten Text laut vorlesen (Fire TV: ueber die "
"Fernseher-Lautsprecher). Fuer reine Sprachansagen; soll es auch/nur "
"sichtbar sein, nimm host_notify."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"text": {"type": "string", "description": "Was gesprochen werden soll."},
"focus": {"type": "string", "description": "'duck' (Medien leiser, Standard) | 'pause' (Film/Musik anhalten) | 'none'."},
},
"required": ["host", "text"],
},
},
},
{
"type": "function",
"function": {
"name": "host_volume",
"description": "Setzt die Medien-Lautstaerke eines Host-Agenten (Fire TV / Android).",
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"percent": {"type": "integer", "description": "0-100."},
},
"required": ["host", "percent"],
},
},
},
{
"type": "function",
"function": {
@@ -2079,6 +2365,11 @@ class Agent:
# Events vom letzten Turn weglassen
self._pending_events = []
# Trace fuer die Agent-Historie (Diagnostic gruppiert Host-Aktionen pro
# dieser einen User-Nachricht). Best-effort, nur Anzeige.
self._trace_id = os.urandom(6).hex()
self._trace_msg = user_message[:160]
# Projekt-Kontext pro Request statt aus globalem State
active_project_id = (project_id or "").strip()
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
@@ -2525,10 +2816,18 @@ class Agent:
if not sat or not action:
return "FEHLER: satellite und action sind Pflicht."
params = arguments.get("params") if isinstance(arguments.get("params"), dict) else {}
# Langlaeufer brauchen mehr als den Bridge-Default (20s): eine APK ueber
# WLAN zu installieren dauert 30-60s. Ohne das meldet ARIA "Timeout",
# obwohl die Installation noch laeuft.
# adb.*: Warten auf die Geraete-Autorisierung (bis 90s) + Download +
# Installation koennen zusammen mehrere Minuten dauern.
sat_timeout = (320.0 if action.startswith("adb.")
else 200.0 if action == "ssh.exec" else 20.0)
result = _post("/internal/satellite", {
"op": "command", "satellite": sat,
"device": arguments.get("device") or "", "action": action, "params": params,
}, 30)
"timeout": sat_timeout,
}, sat_timeout + 20)
if result.get("ok"):
r = result.get("result")
return f"OK — {r}" if r else "OK — Aktion ausgefuehrt."
@@ -2536,9 +2835,276 @@ class Agent:
except Exception as exc:
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
# ── Jellyfin (Medien) ─────────────────────────────────────
_JELLYFIN_CONFIG = "/shared/config/jellyfin.json"
def _jellyfin_server(self, wanted: str = "") -> Optional[dict]:
"""Server aus der Diagnostic-Konfiguration waehlen (Name oder Standard)."""
try:
with open(self._JELLYFIN_CONFIG, "r", encoding="utf-8") as f:
servers = (json.load(f) or {}).get("servers") or []
except Exception:
return None
servers = [s for s in servers if s.get("url")]
if not servers:
return None
w = (wanted or "").strip().lower()
if w:
for s in servers:
if w in (s.get("name") or "").lower() or w in (s.get("url") or "").lower():
return s
for s in servers:
if s.get("isDefault"):
return s
return servers[0]
def _jellyfin_call(self, server: dict, path: str, method: str = "GET",
params: Optional[dict] = None, timeout: float = 15.0):
"""Ruft die Jellyfin-HTTP-API. Gibt (daten, fehlertext)."""
url = server["url"].rstrip("/") + path
if params:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
req = urllib.request.Request(url, method=method, headers={
"Authorization": f'MediaBrowser Token="{server.get("apiKey", "")}"',
"Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", "ignore")
return (json.loads(raw) if raw.strip() else {}), ""
except urllib.error.HTTPError as exc:
if exc.code in (401, 403):
return None, ("Jellyfin lehnt den API-Key ab (401/403) — im Diagnostic "
"unter Integrationen pruefen.")
return None, f"Jellyfin-Fehler HTTP {exc.code}"
except Exception as exc:
return None, f"Jellyfin nicht erreichbar: {exc}"
def _jellyfin_find_session(self, server: dict, device: str, fresh_sec: int = 600):
"""Sucht die Sitzung eines Geraets. Gibt (session, fehlertext).
`activeWithinSeconds` filtert eingeschlafene Sitzungen aus: Jellyfin
listet Clients noch eine Weile, nachdem die App laengst weg ist — ein
Play-Befehl an so eine Karteileiche verpufft wortlos.
"""
sessions, err = self._jellyfin_call(
server, "/Sessions", params={"activeWithinSeconds": fresh_sec})
if err:
return None, err
d = (device or "").strip().lower()
cands = [s for s in (sessions or []) if s.get("SupportsRemoteControl")]
for s in cands:
blob = f"{s.get('DeviceName', '')} {s.get('Client', '')}".lower()
if d and d in blob:
return s, ""
if not d and len(cands) == 1:
return cands[0], ""
names = ", ".join(f"{s.get('DeviceName')} ({s.get('Client')})" for s in cands) or "keine"
return None, (f"Kein steuerbares Jellyfin-Geraet fuer '{device}' gefunden. "
f"Aktuell offen: {names}. Tipp: Die Jellyfin-App muss auf dem "
f"Geraet GEOEFFNET sein — sonst vorher host_app_launch "
f"(query='Jellyfin') aufrufen.")
def _jellyfin_playing(self, server: dict, sess: dict, item_id: str,
tries: int = 4, wait: float = 1.5) -> bool:
"""Prueft, ob auf der Sitzung wirklich etwas abgespielt wird.
Jellyfin quittiert einen Play-Befehl auch dann mit 204, wenn die Sitzung
laengst tot ist — ohne diese Kontrolle meldet man faelschlich Erfolg.
"""
want = (item_id or "").strip()
for _ in range(max(1, tries)):
time.sleep(wait)
sessions, err = self._jellyfin_call(server, "/Sessions", timeout=10.0)
if err:
continue
for s in (sessions or []):
if s.get("Id") != sess.get("Id"):
continue
now = s.get("NowPlayingItem") or {}
if now and (not want or now.get("Id") == want):
return True
return False
def _dispatch_jellyfin(self, name: str, arguments: dict) -> str:
server = self._jellyfin_server(arguments.get("server") or "")
if not server:
return ("FEHLER: Kein Jellyfin-Server eingerichtet. Im Diagnostic unter "
"Einstellungen → Integrationen → Jellyfin einen Server mit URL und "
"API-Key anlegen.")
label = server.get("name") or server.get("url")
try:
if name == "jellyfin_search":
q = (arguments.get("query") or "").strip()
if not q:
return "FEHLER: query ist Pflicht."
data, err = self._jellyfin_call(server, "/Items", params={
"searchTerm": q, "Recursive": "true",
"IncludeItemTypes": "Movie,Series,Episode",
"Limit": int(arguments.get("limit") or 10),
"Fields": "ProductionYear,Overview",
})
if err:
return f"FEHLER: {err}"
items = (data or {}).get("Items") or []
if not items:
return f"Keine Treffer fuer '{q}' auf {label}."
lines = []
for it in items:
lines.append(f"- {it.get('Name')} ({it.get('ProductionYear') or '?'}) "
f"[{it.get('Type')}] itemId={it.get('Id')}")
return f"Treffer auf {label}:\n" + "\n".join(lines)
if name == "jellyfin_devices":
# Nur Sitzungen, die zuletzt aktiv waren — sonst listet Jellyfin
# auch laengst geschlossene Apps, an die kein Befehl mehr geht.
sessions, err = self._jellyfin_call(
server, "/Sessions", params={"activeWithinSeconds": 600})
if err:
return f"FEHLER: {err}"
out = []
for s in (sessions or []):
if not s.get("SupportsRemoteControl"):
continue
now = (s.get("NowPlayingItem") or {}).get("Name")
out.append(f"- {s.get('DeviceName')} ({s.get('Client')})"
+ (f" — laeuft gerade: {now}" if now else ""))
if not out:
return (f"Auf {label} ist gerade kein steuerbares Geraet offen. Die "
f"Jellyfin-App muss auf dem Zielgeraet laufen — starte sie "
f"mit host_app_launch (query='Jellyfin') und frag dann erneut.")
return f"Steuerbare Jellyfin-Geraete auf {label}:\n" + "\n".join(out)
if name == "jellyfin_play":
item_id = (arguments.get("item_id") or "").strip()
device = (arguments.get("device") or "").strip()
if not item_id or not device:
return "FEHLER: item_id und device sind Pflicht."
sess, err = self._jellyfin_find_session(server, device)
if err:
return f"FEHLER: {err}"
play_params = {"itemIds": item_id, "playCommand": "PlayNow"}
note = ""
if arguments.get("resume") is not False:
# Fortsetzen: gespeicherte Position des Titels mitgeben.
info, _e = self._jellyfin_call(server, "/Items",
params={"ids": item_id, "Fields": "UserData"})
items = (info or {}).get("Items") or []
ticks = 0
if items:
ticks = ((items[0].get("UserData") or {}).get("PlaybackPositionTicks") or 0)
if ticks > 0:
play_params["startPositionTicks"] = ticks
note = f" — fortgesetzt bei {int(ticks / 10_000_000 // 60)} min"
_, err = self._jellyfin_call(
server, f"/Sessions/{sess['Id']}/Playing", method="POST",
params=play_params)
if err:
return f"FEHLER: {err}"
# Nachsehen, ob wirklich etwas laeuft. Eine eingeschlafene Sitzung
# nimmt den Befehl wortlos an, ohne dass der Film startet — genau
# der Fall "beim zweiten Mal ging es nicht".
if self._jellyfin_playing(server, sess, item_id):
# Ausdruecklich vermerken, dass NICHTS repariert werden musste —
# sonst erzaehlt das Modell gern die Panne aus einem frueheren
# Turn nach ("Session war eingeschlafen"), obwohl alles glatt lief.
return (f"OK — Wiedergabe auf '{sess.get('DeviceName')}' laeuft"
f"{note} (Server {label}). Sitzung war aktiv, kein Neustart "
f"noetig — erwaehne also KEINEN Neustart und KEINE "
f"eingeschlafene Sitzung.")
# Sitzung war tot. Wenn wir den zugehoerigen Agenten kennen:
# Jellyfin dort neu starten, frische Sitzung holen, einmal
# nachlegen. Sonst ehrlich sagen, was zu tun ist.
host = (arguments.get("host") or "").strip()
if not host:
return ("FEHLER: Der Befehl wurde angenommen, aber es laeuft nichts — "
"die Jellyfin-Sitzung auf dem Geraet war eingeschlafen. "
"Starte die Jellyfin-App neu (host_app_launch mit "
"query='Jellyfin') und versuche es erneut. Tipp: Gib "
"jellyfin_play den Parameter 'host' mit (Name des "
"Host-Agenten), dann mache ich das automatisch.")
self._dispatch_host("host_app_launch", {"host": host, "query": "Jellyfin"})
time.sleep(6) # App braucht einen Moment, bis sie sich anmeldet
sess2, err2 = self._jellyfin_find_session(server, device, fresh_sec=120)
if err2:
return (f"FEHLER: Jellyfin auf '{host}' neu gestartet, aber es meldet "
f"sich keine Sitzung: {err2}")
self._jellyfin_call(server, f"/Sessions/{sess2['Id']}/Playing",
method="POST", params=play_params)
if self._jellyfin_playing(server, sess2, item_id):
return (f"OK — Wiedergabe laeuft auf '{sess2.get('DeviceName')}'"
f"{note}. (Die Sitzung war eingeschlafen, ich habe Jellyfin "
f"auf '{host}' neu gestartet.)")
return (f"FEHLER: Auch nach dem Neustart von Jellyfin auf '{host}' startet "
f"die Wiedergabe nicht. Laeuft die App auf dem Bildschirm? "
f"Ggf. dort einmal anmelden.")
if name == "jellyfin_control":
device = (arguments.get("device") or "").strip()
cmd = (arguments.get("command") or "").strip()
allowed = {"pause", "unpause", "stop", "nexttrack", "previoustrack",
"playpause", "seek"}
if cmd.lower() not in allowed:
return f"FEHLER: command '{cmd}' unbekannt ({', '.join(sorted(allowed))})."
sess, err = self._jellyfin_find_session(server, device)
if err:
return f"FEHLER: {err}"
_, err = self._jellyfin_call(
server, f"/Sessions/{sess['Id']}/Playing/{cmd}", method="POST")
if err:
return f"FEHLER: {err}"
return f"OK — '{cmd}' an '{sess.get('DeviceName')}' gesendet."
return f"FEHLER: unbekanntes Jellyfin-Tool {name}"
except Exception as exc:
return f"FEHLER: Jellyfin-Aufruf fehlgeschlagen: {exc}"
def _dispatch_host(self, name: str, arguments: dict) -> str:
"""Wrapper um _dispatch_host_inner: fuehrt die Host-Aktion aus und schreibt
danach einen Historie-Eintrag an die Bridge (fuer die Diagnostic-Ansicht
'was wurde pro Nachricht auf diesem Agent gemacht')."""
text = self._dispatch_host_inner(name, arguments)
try:
self._emit_agent_history(name, arguments, text)
except Exception:
pass
return text
def _emit_agent_history(self, name: str, arguments: dict, text: str) -> None:
"""Best-effort: ein Historie-Eintrag pro Host-Aktion an die Bridge."""
if name == "host_list":
return
host = (arguments.get("host") or "").strip()
if not host:
return
ok = not text.lstrip().upper().startswith("FEHLER")
m = re.search(r"/shared/uploads/\S+?\.(?:png|jpg|jpeg|webp)", text)
body = {
"host": host,
"trace_id": getattr(self, "_trace_id", ""),
"msg": getattr(self, "_trace_msg", ""),
"tool": name,
"action": name[len("host_"):] if name.startswith("host_") else name,
"args": {k: v for k, v in arguments.items() if k != "host"},
"ok": ok,
"summary": (text.strip().splitlines()[0] if text.strip() else "")[:200],
"file": m.group(0) if m else None,
"ts": int(time.time() * 1000),
}
try:
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(f"{BRIDGE_URL}/internal/agent-history",
data=data, method="POST",
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=5).close()
except Exception:
pass
def _dispatch_host_inner(self, name: str, arguments: dict) -> str:
"""host_list / host_exec / host_read / host_write / host_info /
host_screenshot — via Bridge (/internal/host*) → RVS → Host-Agent."""
host_screenshot / host_ui_* — via Bridge (/internal/host*) → RVS → Host-Agent."""
import base64 as _b64
def _post(path: str, body: dict, timeout: float) -> dict:
@@ -2652,6 +3218,41 @@ class Agent:
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", "button", "double"]),
"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", "command"]),
"host_notify": ("notify", ["text", "title", "seconds", "mode", "focus"]),
"host_speak": ("speak", ["text", "focus"]),
"host_volume": ("volume", ["percent"]),
}
if name in _UI_ACTIONS:
action, keys = _UI_ACTIONS[name]
params = {k: arguments[k] for k in keys if arguments.get(k) is not None}
result = _post("/internal/host",
{"host": host, "action": action, "params": params}, 20)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
r = result.get("result") or {}
return f"OK ({host}): {r.get('message', 'ausgefuehrt')}"
return f"FEHLER: unbekanntes Host-Tool {name}"
except Exception as exc:
return f"FEHLER: Host/Bridge nicht erreichbar: {exc}"
@@ -3448,8 +4049,15 @@ class Agent:
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
if name in ("satellite_list", "satellite_devices", "satellite_command"):
return self._dispatch_satellite(name, arguments)
if name in ("jellyfin_search", "jellyfin_devices", "jellyfin_play",
"jellyfin_control"):
return self._dispatch_jellyfin(name, arguments)
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",
"host_notify", "host_speak", "host_volume"):
return self._dispatch_host(name, arguments)
if name == "vm_register":
pid = (project_id or "").strip()
+90 -7
View File
@@ -3407,11 +3407,18 @@ class ARIABridge:
# weiter auseinander (man hoert den Wechsel erst).
_norm_txt = " ".join(text.lower().split())
_now_t = asyncio.get_event_loop().time()
# Fenster bewusst KURZ (1.5s): es soll nur den Zwilling abfangen, den
# zwei STT-Worker fuer DIESELBE Aufnahme liefern. Mit den frueheren 5s
# fielen echte Wiederholungen weg — also genau der Fall "ARIA hat mich
# nicht gehoert, ich sag's nochmal" und kurze Kommandos wie "lauter",
# "nächstes", "stopp". Die eigentliche Absicherung ist ohnehin die
# Idempotenz ueber audioRequestId weiter oben.
_DUPE_WINDOW_SEC = 1.5
if (_norm_txt
and _norm_txt == getattr(self, "_last_endpoint_norm", "")
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < 5.0):
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <5s): %r",
text[:60])
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < _DUPE_WINDOW_SEC):
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <%.1fs): %r",
_DUPE_WINDOW_SEC, text[:60])
return
self._last_endpoint_norm = _norm_txt
self._last_endpoint_at = _now_t
@@ -3589,6 +3596,17 @@ class ARIABridge:
self._hosts[hid]["last_seen"] = time.time()
return
elif msg_type == "agent_history_query":
# Diagnostic fragt die gespeicherte Historie eines Agenten ab.
hid = (payload.get("host") or "").strip()
events = self._agent_history_read(hid) if hid else []
await self._send_to_rvs({
"type": "agent_history_list",
"payload": {"host": hid, "events": events},
"timestamp": int(time.time() * 1000),
})
return
elif msg_type == "worker_hello":
iid = (payload.get("instanceId") or "").strip()
if iid:
@@ -3766,8 +3784,12 @@ class ARIABridge:
r")\s*[.!?]?\s*$",
re.IGNORECASE,
)
# Trenner bewusst nur ':' und '-' — NICHT das Komma. Whisper setzt gern
# Kommata, und damit matchten normale Saetze wie "In Ordnung, mach mal ..."
# als Projekt-Prefix: die Nachricht wanderte still in ein Projekt und war im
# Hauptchat unsichtbar ("wird gar nicht verarbeitet").
_VOICE_META_PROJECT_PREFIX = re.compile(
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})[:\-,]\s*(.+?)\s*$",
r"^\s*(?:aria[,.]?\s+)?(?:f(?:ü|ue)r|ins?)\s+([\w\-äöüßÄÖÜ]{2,40})\s*[:\-]\s*(.+?)\s*$",
re.IGNORECASE | re.DOTALL,
)
@@ -3822,7 +3844,10 @@ class ARIABridge:
s = SequenceMatcher(None, q, pname).ratio()
if s > best_score:
best, best_score = p, s
if best and best_score >= 0.6:
# Schwelle bewusst hoch (0.82): bei 0.6 reichte schon eine vage
# Aehnlichkeit, und die Nachricht verschwand still in einem
# fremden Projekt. Lieber im Hauptchat lassen als falsch routen.
if best and best_score >= 0.82:
pid = best["id"]
self._voice_sticky_project_id = pid
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
@@ -3834,8 +3859,10 @@ class ARIABridge:
# Kein Match → als normale Nachricht weiter (Sticky wenn aktiv)
# 3) Kein Meta / Prefix → Sticky oder Default
if self._voice_sticky_project_id and now < self._voice_sticky_expires_at:
# Sticky refreshen
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
# Sticky NICHT verlaengern: frueher hat jede Folgenachricht die Frist
# erneuert — ein einziger Fehl-Match und danach landete dauerhaft
# alles in diesem Projekt. Jetzt laeuft er nach der urspruenglichen
# Zeit aus und der Hauptchat uebernimmt wieder.
return (True, stripped, self._voice_sticky_project_id, "sticky")
# Sticky abgelaufen — zurücksetzen
self._voice_sticky_project_id = ""
@@ -4580,6 +4607,17 @@ class ARIABridge:
timeout=float(data.get("timeout") or 60.0),
)
await _send_response(writer, 200, result)
elif method == "POST" and path == "/internal/agent-history":
# Brain: ein Historie-Eintrag pro Host-Aktion (fuer die Diagnostic).
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
self._agent_history_append(data)
await self._send_to_rvs({"type": "agent_history", "payload": data,
"timestamp": int(time.time() * 1000)})
await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/satellite":
# Brain-Tool: Discovery oder Command an einen Satelliten.
# body: {op:'discover'|'command', satellite, device?, action?, params?}
@@ -4929,6 +4967,51 @@ class ARIABridge:
"online": (now - h.get("last_seen", 0)) < 300,
} for h in self._hosts.values()]
_AGENT_HISTORY_DIR = "/shared/config/agent_history"
_AGENT_HISTORY_MAX = 200
def _agent_history_path(self, host: str) -> str:
safe = re.sub(r"[^a-zA-Z0-9_-]+", "-", host).strip("-") or "host"
return os.path.join(self._AGENT_HISTORY_DIR, f"{safe}.jsonl")
def _agent_history_append(self, entry: dict) -> None:
"""Haengt einen Historie-Eintrag an <hostId>.jsonl (Ringpuffer)."""
host = (entry.get("host") or "").strip()
if not host:
return
try:
os.makedirs(self._AGENT_HISTORY_DIR, exist_ok=True)
path = self._agent_history_path(host)
lines = []
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
lines.append(json.dumps(entry, ensure_ascii=False))
lines = lines[-self._AGENT_HISTORY_MAX:]
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
except Exception as exc:
logger.warning("[history] append fehlgeschlagen: %s", exc)
def _agent_history_read(self, host: str, limit: int = 200) -> list:
try:
path = self._agent_history_path(host)
if not os.path.exists(path):
return []
out = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except Exception:
pass
return out[-limit:]
except Exception:
return []
async def _host_request(self, host: str = "", action: str = "",
params: Optional[dict] = None,
timeout: float = 60.0) -> dict:
+501 -4
View File
@@ -961,6 +961,33 @@
</div>
</div>
<!-- Jellyfin (Medien) -->
<div class="settings-section">
<h2>Jellyfin (Filme &amp; Serien)</h2>
<div style="font-size:11px;color:#8888AA;margin-bottom:8px;">
Damit steuert ARIA die Wiedergabe direkt über die Jellyfin-Schnittstelle statt
sich durch die Fire-TV-Oberfläche zu klicken: Film suchen → Abspielgerät finden →
starten. Mehrere Server möglich (eigener, vom Kumpel …) — einer davon ist der
Standard, wenn ARIA keinen nennt.
<br>API-Key: Jellyfin → <em>Dashboard → API-Schlüssel → +</em>.
<strong>ARIAs Brain muss den Server erreichen können</strong> (URL aus Sicht des Servers,
nicht des Browsers).
</div>
<div class="card" style="max-width:780px;">
<div id="jellyfin-list" style="display:flex;flex-direction:column;gap:10px;">
<div style="color:#555570;font-style:italic;">Lade …</div>
</div>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center;">
<button class="btn secondary" onclick="addJellyfinServer()" style="padding:6px 14px;font-size:12px;">+ Server</button>
<button class="btn" onclick="saveJellyfinConfig()" style="padding:6px 14px;font-size:12px;">Speichern</button>
<span id="jellyfin-status" style="color:#8888AA;font-size:11px;"></span>
</div>
<div style="color:#666680;font-size:10px;margin-top:8px;">
API-Keys werden mit Datei-Rechten 0600 gespeichert (/shared/config/jellyfin.json).
</div>
</div>
</div>
<!-- FLUX Bildgenerierung -->
<div class="settings-section">
<h2>FLUX Bildgenerierung</h2>
@@ -1594,6 +1621,71 @@
</div>
</div>
<!-- APK-Rollout-Modal (Agent auf Fire TV / Android-Gerät installieren) -->
<div class="modal-overlay" id="apk-modal">
<div class="modal-box" style="max-width:620px;">
<div class="modal-header">
<h3 id="apk-title">ARIA-Agent ausrollen</h3>
<button class="modal-close" onclick="closeApkDeploy()">&times;</button>
</div>
<div class="modal-body" style="padding:16px;font-size:13px;color:#E0E0F0;">
<div style="color:#8888AA;margin-bottom:10px;">
Installiert die Agent-App per ADB über das Netzwerk. Voraussetzung am Gerät:
<strong>ADB-Debugging aktiv</strong> (Fire TV: Einstellungen › Mein Fire TV ›
Entwickleroptionen). Beim <strong>ersten Mal</strong> erscheint dort
„USB-Debugging zulassen?" — einfach bestätigen, während der Rollout läuft:
er wartet darauf und macht dann automatisch weiter.
</div>
<label style="display:block;margin-bottom:4px;color:#8888AA;">APK-URL</label>
<input id="apk-url" type="text" style="width:100%;padding:6px;background:#0D0D1A;border:1px solid #1E1E2E;border-radius:4px;color:#E0E0F0;"
placeholder="https://…/aria-host-agent-android-agent-v0.0.1.0.apk">
<div style="color:#666;font-size:11px;margin-top:4px;">
Aus dem Gitea-Release. Der Satellit lädt die Datei selbst (einmal) und
installiert sie auf dem Gerät.
</div>
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer;">
<input type="checkbox" id="apk-provision" checked>
<span>Gleich einrichten (RVS-Zugang übertragen)</span>
</label>
<div style="color:#666;font-size:11px;margin-left:26px;">
Spart das Abtippen auf dem Fernseher: Host und Token gehen direkt vom
Server an den Agenten — QR-Scan geht am Fire TV ja nicht.
</div>
<div style="margin-top:10px;margin-left:26px;">
<label style="display:block;margin-bottom:4px;color:#8888AA;">Name des Geräts</label>
<input id="apk-name" type="text" style="width:100%;padding:6px;background:#0D0D1A;border:1px solid #1E1E2E;border-radius:4px;color:#E0E0F0;"
placeholder="z.B. Wohnzimmer Fire TV">
<div style="color:#666;font-size:11px;margin-top:4px;">
So spricht ARIA das Gerät an („mach einen Screenshot vom Wohnzimmer Fire TV").
Hier am PC eingeben — auf dem Fernseher wäre das Tippen mühsam.
</div>
</div>
<div style="margin-top:14px;">
<button class="btn" onclick="runApkDeploy()">📦 Jetzt ausrollen</button>
<button class="btn secondary" onclick="runApkProvision()" style="margin-left:6px;"
title="Nur Zugang/Namen setzen — App bleibt wie sie ist">Nur einrichten</button>
<button class="btn secondary" onclick="runApkStatus()" style="margin-left:6px;">Status prüfen</button>
<span id="apk-status" style="margin-left:10px;color:#8888AA;"></span>
</div>
<pre id="apk-log" style="margin-top:12px;background:#0D0D1A;border-radius:6px;padding:10px;max-height:240px;overflow:auto;white-space:pre-wrap;color:#8888AA;font-size:12px;display:none;"></pre>
</div>
</div>
</div>
<!-- Agent-Historie-Modal (Befehle + Screenshots pro Nachricht) -->
<div class="modal-overlay" id="agent-history-modal">
<div class="modal-box" style="max-width:760px;">
<div class="modal-header">
<h3 id="agent-history-title">Historie</h3>
<button class="modal-close" onclick="closeAgentHistory()">&times;</button>
</div>
<div style="padding:6px 16px;color:#8888AA;font-size:11px;">
Jede Zeile = eine Anfrage an ARIA. Aufklappen zeigt, was auf diesem Agenten dafür gemacht wurde.
</div>
<div class="modal-body" id="agent-history-body" style="padding:10px 16px 16px;max-height:70vh;overflow-y:auto;"></div>
</div>
</div>
<!-- Memory-Modal (Neu + Editieren) -->
<div class="modal-overlay" id="memory-modal">
<div class="modal-box" style="max-width:640px;">
@@ -2185,6 +2277,21 @@
}
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_result') { onSatResult(msg.payload || {}); return; }
if (msg.type === 'llm_openai_config_result') { onOpenAiConfigResult(msg.payload || {}); return; }
if (msg.type === 'agent_history_list') {
if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) {
renderAgentHistory(msg.payload.events || []);
}
return;
}
if (msg.type === 'agent_history') {
// Live: wenn das Modal fuer diesen Host offen ist, neu abfragen (einfach + robust).
if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) {
send({ action: 'agent_history_query', host: agentHistoryHost });
}
return;
}
if (msg.type === 'sat_creds_list_result') {
const p = msg.payload || msg;
const sat = p.satellite || '';
@@ -4458,6 +4565,7 @@
loadRuntimeConfig();
loadOnboardingQR();
loadOAuthServices();
loadJellyfinConfig();
refreshVoiceIdStatus();
} else if (tab === 'brain') {
loadBrainStatus();
@@ -4514,7 +4622,9 @@
'<span style="color:#8888AA;">' + escapeHtml(modelText) + '</span>' +
(w.gpus ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
'<span style="margin-left:auto;color:' + dot + ';">' + stat + '</span>' +
'</div>';
'</div>' +
// Nur LLM-Boxen: OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.)
(w.service === 'llm' ? renderOpenAiEndpoint(w) : '');
}).join('');
// Auslastungs-Button pro Node — Ziel bevorzugt die llm-Instanz (sieht alle
// GPUs + Tokens), sonst irgendein online Worker des Node.
@@ -4527,6 +4637,62 @@
}).join('');
}
// ── OpenAI-Endpunkt einer LLM-Box (fuer OpenHands, Continue, ...) ──
// Der PORT kommt immer aus der .env der Box (Docker-Port-Mapping, steht beim
// Container-Start fest) und wird hier nur angezeigt. Der API-Key ist hier
// setzbar — ausser er steht in der .env, dann hat die Vorrang und das Feld
// ist schreibgeschuetzt.
function renderOpenAiEndpoint(w) {
const id = 'oa-' + (w.instanceId || '').replace(/[^a-zA-Z0-9]/g, '-');
const port = w.openaiPort || '8081';
const bind = w.openaiBind || '127.0.0.1';
const lanOpen = bind === '0.0.0.0';
const locked = !!w.openaiEnvLocked;
const keyState = w.openaiKeySet
? '<span style="color:#34C759;">Key gesetzt</span>'
: '<span style="color:#FF9500;">kein Key — Endpunkt offen</span>';
const warn = (lanOpen && !w.openaiKeySet)
? '<div style="color:#FF6E6E;margin-top:4px;">⚠ Im LAN erreichbar OHNE Key — jeder im Netz kann das Modell nutzen.</div>'
: '';
const field = locked
? '<input type="text" value="aus .env übernommen" disabled ' +
'style="flex:1;background:#15151F;color:#666;border:1px solid #1E1E2E;border-radius:4px;padding:4px 8px;font-size:11px;">' +
'<span style="color:#8888AA;font-size:10px;">🔒 .env hat Vorrang</span>'
: '<input type="password" id="' + id + '" placeholder="API-Key setzen (leer = entfernen)" ' +
'style="flex:1;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;border-radius:4px;padding:4px 8px;font-size:11px;">' +
'<button class="btn secondary" onclick="saveOpenAiKey(\'' + escapeHtml(w.instanceId) + '\',\'' + id + '\')" ' +
'style="padding:2px 8px;font-size:10px;">Speichern</button>';
return '<div style="margin:2px 0 8px 18px;padding:6px 8px;background:#0D0D1A;border-radius:6px;font-size:11px;">' +
'<div style="color:#8888AA;">OpenAI-Endpunkt (OpenHands &amp; Co.): ' +
'<code style="color:#FFD60A;">http://' + escapeHtml(bind === '0.0.0.0' ? '&lt;box-ip&gt;' : '127.0.0.1') +
':' + escapeHtml(port) + '/v1</code> · ' + keyState + '</div>' +
'<div style="display:flex;gap:6px;align-items:center;margin-top:5px;">' + field + '</div>' +
'<div id="' + id + '-msg" style="color:#8888AA;margin-top:4px;"></div>' +
'<div style="color:#666;margin-top:3px;">Port und Erreichbarkeit kommen aus der <code>.env</code> der Box ' +
'(<code>LLM_OPENAI_PORT</code>, <code>LLM_OPENAI_BIND</code>) — sie stehen beim Container-Start fest.</div>' +
warn + '</div>';
}
function saveOpenAiKey(instanceId, fieldId) {
const el = document.getElementById(fieldId);
const msg = document.getElementById(fieldId + '-msg');
if (!el) return;
openAiReqId = 'oa_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
if (msg) msg.textContent = 'speichere …';
send({ action: 'llm_openai_config', instanceId, apiKey: el.value, requestId: openAiReqId });
}
let openAiReqId = null;
function onOpenAiConfigResult(p) {
if (!p || (openAiReqId && p.requestId && p.requestId !== openAiReqId)) return;
const id = 'oa-' + String(p.instanceId || '').replace(/[^a-zA-Z0-9]/g, '-');
const msg = document.getElementById(id + '-msg');
if (!msg) return;
msg.innerHTML = p.ok
? '<span style="color:#34C759;">✓ ' + escapeHtml(p.message || 'gespeichert') + '</span>'
: '<span style="color:#FF6E6E;">✗ ' + escapeHtml(p.error || 'fehlgeschlagen') + '</span>';
}
// Sprung von den Einstellungen zur Compute-Flotte (dort sitzt der
// Auslastung-Button pro Node → Live nvidia-smi + Graphen).
function gotoComputeFleet() {
@@ -4643,13 +4809,337 @@
const caps = (h.caps || []).join(', ') || '—';
const ctrl = h.control ? '<span style="color:#0096FF;">steuerbar</span>' : '<span style="color:#FF6E6E;">CONTROL_ENABLED=false</span>';
return '<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;margin-bottom:8px;">' +
'<div><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-right:6px;"></span>' +
'<div style="display:flex;align-items:center;gap:8px;">' +
'<div style="flex:1;"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-right:6px;"></span>' +
'<strong style="color:#E0E0F0;">💻 ' + escapeHtml(h.name || h.hostId) + '</strong> ' +
'<span style="color:#666;font-size:11px;">id=' + escapeHtml(h.hostId) + '</span></div>' +
'<button onclick="openAgentHistory(\'' + escapeHtml(h.hostId) + '\')" style="background:#12121E;color:#FFD60A;border:1px solid #FFD60A44;border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;">🕘 Historie</button>' +
'<button onclick="forgetHost(\'' + escapeHtml(h.hostId) + '\',\'' + escapeHtml(h.name || h.hostId) + '\')" title="Aus der Liste entfernen (z.B. alter Name nach dem Umbenennen). Meldet sich der Agent wieder, erscheint er von selbst."' +
' style="background:#12121E;color:#8888AA;border:1px solid #1E1E2E;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer;margin-left:4px;">✕</button>' +
'</div>' +
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">' + escapeHtml(h.os || '') + ' · kann: ' + escapeHtml(caps) + ' · ' + ctrl + '</div>' +
'</div>';
}).join('');
}
/** Entfernt einen Agenten aus der Liste (z.B. den alten Namen nach dem
* Umbenennen). Läuft er noch, meldet er sich von selbst wieder. */
function forgetHost(hostId, name) {
if (!confirm('"' + name + '" aus der Liste entfernen?\n\n' +
'Das stoppt den Agenten NICHT — läuft er noch, erscheint er ' +
'beim nächsten Lebenszeichen wieder.')) return;
send({ action: 'host_forget', hostId });
}
// ── Jellyfin-Server (Medien-Integration) ─────────────────────────
let jellyfinServers = [];
async function loadJellyfinConfig() {
try {
const r = await fetch('/api/jellyfin-config');
const d = await r.json();
jellyfinServers = d.servers || [];
} catch (e) {
jellyfinServers = [];
}
renderJellyfin();
}
function addJellyfinServer() {
jellyfinServers.push({
id: 'jf_' + Date.now().toString(36),
name: '', url: '', apiKey: '',
isDefault: jellyfinServers.length === 0,
});
renderJellyfin();
}
function removeJellyfinServer(i) {
const s = jellyfinServers[i];
if (!confirm('Server "' + (s.name || s.url || 'neu') + '" entfernen?')) return;
jellyfinServers.splice(i, 1);
if (jellyfinServers.length && !jellyfinServers.some(x => x.isDefault)) {
jellyfinServers[0].isDefault = true;
}
renderJellyfin();
}
function setJellyfinDefault(i) {
jellyfinServers.forEach((s, k) => { s.isDefault = (k === i); });
renderJellyfin();
}
function updateJellyfinField(i, field, value) {
if (jellyfinServers[i]) jellyfinServers[i][field] = value;
}
function renderJellyfin() {
const box = document.getElementById('jellyfin-list');
if (!box) return;
if (!jellyfinServers.length) {
box.innerHTML = '<div style="color:#555570;font-style:italic;">' +
'Noch kein Server. Mit „+ Server" einen anlegen.</div>';
return;
}
box.innerHTML = jellyfinServers.map((s, i) =>
'<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;">' +
'<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">' +
'<input type="radio" name="jf-default" ' + (s.isDefault ? 'checked' : '') +
' onchange="setJellyfinDefault(' + i + ')" title="Standard-Server">' +
'<span style="color:#8888AA;font-size:11px;">Standard</span>' +
'<input type="text" value="' + escapeHtml(s.name || '') + '" placeholder="Name (z.B. Zuhause, Kumpel Tim)"' +
' oninput="updateJellyfinField(' + i + ',\'name\',this.value)"' +
' style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'<button class="btn secondary" onclick="testJellyfinServer(' + i + ')" style="padding:4px 10px;font-size:11px;">Test</button>' +
'<button class="btn secondary" onclick="removeJellyfinServer(' + i + ')" style="padding:4px 9px;font-size:11px;">✕</button>' +
'</div>' +
'<div style="display:flex;gap:8px;">' +
'<input type="text" value="' + escapeHtml(s.url || '') + '" placeholder="http://192.168.0.10:8096"' +
' oninput="updateJellyfinField(' + i + ',\'url\',this.value)"' +
' style="flex:2;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'<input type="password" value="' + escapeHtml(s.apiKey || '') + '" placeholder="API-Key"' +
' oninput="updateJellyfinField(' + i + ',\'apiKey\',this.value)"' +
' style="flex:1;background:#1E1E2E;color:#fff;border:1px solid #2A2A3E;border-radius:6px;padding:5px 9px;font-size:12px;">' +
'</div>' +
'<div id="jf-test-' + i + '" style="font-size:11px;color:#8888AA;margin-top:6px;"></div>' +
'</div>'
).join('');
}
async function testJellyfinServer(i) {
const s = jellyfinServers[i];
const out = document.getElementById('jf-test-' + i);
if (!s || !out) return;
out.textContent = 'prüfe …';
try {
const r = await fetch('/api/jellyfin-test', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: s.url, apiKey: s.apiKey }),
});
const d = await r.json();
out.innerHTML = d.ok
? '<span style="color:#34C759;">✓ erreichbar — ' + escapeHtml(d.name || '') +
' (Version ' + escapeHtml(d.version || '?') + ')</span>'
: '<span style="color:#FF6E6E;">✗ ' + escapeHtml(d.error || 'nicht erreichbar') + '</span>';
} catch (e) {
out.innerHTML = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(e.message) + '</span>';
}
}
async function saveJellyfinConfig() {
const el = document.getElementById('jellyfin-status');
el.textContent = 'speichere …';
try {
const r = await fetch('/api/jellyfin-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers: jellyfinServers }),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || 'Fehler');
jellyfinServers = d.config.servers || [];
renderJellyfin();
el.innerHTML = '<span style="color:#34C759;">✓ gespeichert (' + jellyfinServers.length + ')</span>';
setTimeout(() => { el.textContent = ''; }, 4000);
} catch (e) {
el.innerHTML = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(e.message) + '</span>';
}
}
// ── APK-Rollout auf Android/Fire TV (per Satellit + ADB) ─────────
let apkTarget = null; // {satId, ip, name}
let apkRequestId = null;
function openApkDeploy(satId, ip, name) {
apkTarget = { satId, ip, name };
document.getElementById('apk-title').textContent = 'ARIA-Agent ausrollen → ' + name;
document.getElementById('apk-status').textContent = '';
const log = document.getElementById('apk-log');
log.style.display = 'none'; log.textContent = '';
// Namensfeld vorbelegen. Reihenfolge:
// 1. Name, den der Agent auf DIESEM Gerät schon trägt (per IP zugeordnet)
// 2. zuletzt hier eingegebener Name (im Browser gemerkt)
// 3. der Name aus der Netz-Erkennung — aber nur, wenn er menschenlesbar ist
// (Fire TV meldet oft Seriennummern wie F8FCE10B4E6C@AS-AFTKA[AirPlay])
const known = (hosts || []).find(h => h.ip && h.ip === ip);
const remembered = (function () {
try { return localStorage.getItem('apkName:' + satId + ':' + ip) || ''; }
catch (e) { return ''; }
})();
const looksTechnical = !name || /^[0-9a-f]{8,}$/i.test(name) ||
/^\d{1,3}(\.\d{1,3}){3}$/.test(name) || /[@\[\]:]/.test(name);
document.getElementById('apk-name').value =
(known && known.name) || remembered || (looksTechnical ? '' : name);
document.getElementById('apk-modal').classList.add('open');
}
function closeApkDeploy() {
document.getElementById('apk-modal').classList.remove('open');
apkTarget = null; apkRequestId = null;
}
function runApkDeploy() {
if (!apkTarget) return;
const url = document.getElementById('apk-url').value.trim();
if (!url) { document.getElementById('apk-status').textContent = 'APK-URL fehlt'; return; }
apkRequestId = 'apk_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
document.getElementById('apk-status').innerHTML =
'<span style="color:#FFD60A;">läuft …</span>';
const log = document.getElementById('apk-log');
log.style.display = 'block';
log.innerHTML = '👀 <strong style="color:#FFD60A;">Jetzt auf den Bildschirm des Geräts schauen.</strong>\n' +
'Beim ersten Mal erscheint dort „USB-Debugging zulassen?" — bestätigen\n' +
'(Haken bei „Immer erlauben"). Ich warte bis zu 90 Sekunden darauf und\n' +
'installiere dann automatisch weiter. Kein zweiter Klick nötig.\n\n' +
'Danach: APK laden + installieren, zusammen 1–3 Minuten.';
const params = { ip: apkTarget.ip, url };
// Server ergaenzt Host/Port/Token — steht bewusst NICHT im Browser-Formular.
if (document.getElementById('apk-provision').checked) {
params.provision = true;
// Am PC getippter Name gewinnt; sonst der aus der Netz-Erkennung (der
// ist bei Fire TV oft nur die Seriennummer).
params.name = document.getElementById('apk-name').value.trim() || apkTarget.name;
rememberApkName(params.name);
}
send({ action: 'sat_command', satellite: apkTarget.satId, cmd: 'adb.install',
requestId: apkRequestId, params });
}
/** Nur einrichten: RVS-Zugang + Namen setzen, ohne die APK neu zu installieren
* (z.B. um ein bereits ausgerolltes Gerät umzubenennen). */
function runApkProvision() {
if (!apkTarget) return;
apkRequestId = 'apk_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
document.getElementById('apk-status').innerHTML = '<span style="color:#FFD60A;">richte ein …</span>';
const log = document.getElementById('apk-log');
log.style.display = 'block';
log.textContent = 'Übertrage Zugang und Namen an den Agenten …\n' +
'Die App startet dabei kurz auf dem Bildschirm — das ist normal.';
send({ action: 'sat_command', satellite: apkTarget.satId, cmd: 'adb.provision',
requestId: apkRequestId,
params: { ip: apkTarget.ip, provision: true,
name: rememberApkName(document.getElementById('apk-name').value.trim()
|| apkTarget.name) } });
}
/** Merkt sich den vergebenen Namen pro Gerät (Vorschlag beim naechsten Mal). */
function rememberApkName(n) {
try {
if (apkTarget && n) localStorage.setItem('apkName:' + apkTarget.satId + ':' + apkTarget.ip, n);
} catch (e) { /* privater Modus o.ae. */ }
return n;
}
/** Prüft, ob der Agent auf dem Gerät installiert ist und läuft. */
function runApkStatus() {
if (!apkTarget) return;
apkRequestId = 'apk_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
document.getElementById('apk-status').innerHTML = '<span style="color:#FFD60A;">prüfe …</span>';
const log = document.getElementById('apk-log');
log.style.display = 'block';
log.textContent = 'Frage das Gerät über ADB ab …';
send({ action: 'sat_command', satellite: apkTarget.satId, cmd: 'adb.status',
requestId: apkRequestId, params: { ip: apkTarget.ip } });
}
function onSatResult(p) {
if (!apkRequestId || !p || p.requestId !== apkRequestId) return;
const status = document.getElementById('apk-status');
const log = document.getElementById('apk-log');
log.style.display = 'block';
if (p.ok) {
const r = p.result || {};
status.innerHTML = '<span style="color:#34C759;">✓ erfolgreich</span>';
let txt = r.message || JSON.stringify(r, null, 2);
if (typeof r.installed !== 'undefined') { // adb.status
txt = (r.running ? '🟢 Agent läuft' : r.installed ? '🟡 installiert, läuft nicht' : '🔴 nicht installiert') +
(r.version ? '\nVersion: ' + r.version : '') +
'\nPaket: ' + (r.package || '') + '\nGerät: ' + (r.target || '');
if (!r.running && r.installed) {
txt += '\n\nTipp: Auf dem Gerät die App öffnen und „Speichern & Verbinden" —' +
'\noder hier „Gleich einrichten" nutzen, dann startet sie von selbst.';
}
} else if (r.provisioned === false) {
status.innerHTML = '<span style="color:#FF9500;">⚠ installiert, Einrichtung offen</span>';
}
log.textContent = txt;
} else {
status.innerHTML = '<span style="color:#FF6E6E;">✗ fehlgeschlagen</span>';
log.textContent = p.error || 'Unbekannter Fehler';
}
apkRequestId = null;
}
// ── Agent-Historie (Befehle + Screenshots pro Nachricht) ──────────
let agentHistoryHost = null;
function openAgentHistory(hostId) {
const h = (hosts || []).find(x => x.hostId === hostId);
agentHistoryHost = hostId;
document.getElementById('agent-history-title').textContent =
'Historie: ' + ((h && (h.name || h.hostId)) || hostId);
document.getElementById('agent-history-body').innerHTML =
'<span style="color:#8888AA;">lade …</span>';
document.getElementById('agent-history-modal').classList.add('open');
send({ action: 'agent_history_query', host: hostId });
}
function closeAgentHistory() {
document.getElementById('agent-history-modal').classList.remove('open');
agentHistoryHost = null;
}
function toggleHistGroup(gi) {
const el = document.getElementById('hist-group-' + gi);
const caret = document.getElementById('hist-caret-' + gi);
if (!el) return;
const open = el.style.display !== 'none';
el.style.display = open ? 'none' : 'block';
if (caret) caret.textContent = open ? '▶' : '▼';
}
function renderAgentHistory(events) {
const body = document.getElementById('agent-history-body');
if (!body) return;
if (!events || !events.length) {
body.innerHTML = '<span style="color:#8888AA;">Noch keine Aktionen für diesen Agenten aufgezeichnet. ' +
'Sobald ARIA etwas auf ihm tut (Screenshot, tippen, …), erscheint es hier.</span>';
return;
}
// Nach trace_id gruppieren (eine Gruppe = eine User-Nachricht), Reihenfolge erhalten.
const groups = [], idx = {};
events.forEach(ev => {
const key = ev.trace_id || ('_' + ev.ts);
if (!(key in idx)) { idx[key] = groups.length; groups.push({ msg: ev.msg || '', ts: ev.ts, items: [] }); }
groups[idx[key]].items.push(ev);
});
groups.reverse(); // neueste Nachricht oben
body.innerHTML = groups.map((g, gi) => {
const t = new Date(g.ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const bad = g.items.some(e => e.ok === false);
const dot = bad ? '#FF9500' : '#34C759';
const head = '<div onclick="toggleHistGroup(' + gi + ')" style="cursor:pointer;padding:8px 10px;background:#12121E;border-radius:6px;display:flex;align-items:center;gap:8px;">' +
'<span id="hist-caret-' + gi + '" style="color:#8888AA;">' + (gi === 0 ? '▼' : '▶') + '</span>' +
'<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';"></span>' +
'<span style="color:#666;font-size:11px;">' + t + '</span>' +
'<span style="color:#E0E0F0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + escapeHtml(g.msg || '(ohne Text)') + '</span>' +
'<span style="color:#8888AA;font-size:11px;white-space:nowrap;">' + g.items.length + ' Aktion(en)</span></div>';
const rows = g.items.map(e => {
const good = e.ok !== false;
const ic = good ? '✓' : '✗';
const icColor = good ? '#34C759' : '#FF6E6E';
const args = (e.args && Object.keys(e.args).length)
? ' <span style="color:#666;font-size:11px;">' + escapeHtml(JSON.stringify(e.args)) + '</span>' : '';
let thumb = '';
if (e.file) {
const base = String(e.file).split('/').pop();
thumb = '<div style="margin-top:5px;"><img src="/uploads/' + encodeURIComponent(base) +
'" loading="lazy" style="max-width:180px;max-height:320px;border:1px solid #1E1E2E;border-radius:6px;cursor:pointer;" ' +
'onclick="window.open(this.src)" onerror="this.style.display=\'none\'"></div>';
}
return '<div style="padding:6px 10px 6px 26px;border-bottom:1px solid #15151F;">' +
'<span style="color:' + icColor + ';">' + ic + '</span> ' +
'<code style="color:#FFD60A;">' + escapeHtml(e.action || e.tool || '?') + '</code>' + args +
(e.summary ? '<div style="color:#8888AA;font-size:11px;margin-top:2px;">' + escapeHtml(e.summary) + '</div>' : '') +
thumb + '</div>';
}).join('');
return '<div style="margin-bottom:6px;">' + head +
'<div id="hist-group-' + gi + '" style="display:' + (gi === 0 ? 'block' : 'none') + ';">' + rows + '</div></div>';
}).join('');
}
function scanSatellite(id) {
satScanning = id;
renderSatellites();
@@ -4683,18 +5173,24 @@
if (d.model) tags.push(escapeHtml(d.model));
if (d.snmp && d.snmp.descr) tags.push(escapeHtml(String(d.snmp.descr).slice(0, 40)));
if (d.dialAppUrl) tags.push('DIAL');
if (d.adbCapable) tags.push('ADB');
if (d.mac) tags.push(escapeHtml(d.mac));
const tagStr = tags.length ? ' <span style="color:#666;">(' + tags.join(' · ') + ')</span>' : '';
const hasCreds = d.ip && (satCreds[s.id] || {})[d.ip] && (satCreds[s.id][d.ip] || []).length;
const keyBtn = d.ip ? '<button class="btn secondary" title="Zugangsdaten (SNMP / HTTP / FritzBox)" ' +
'onclick="openDeviceCreds(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.ip) + '\',\'' + escapeHtml(d.name || d.ip) + '\')" ' +
'style="padding:1px 6px;font-size:11px;margin-left:8px;">' + (hasCreds ? '🔑 ✓' : '🔑') + '</button>' : '';
// APK ausrollen: bei ADB-faehigen Geraeten (Fire TV & Co.).
const canAdb = d.ip && (d.adbCapable || ['firetv', 'android', 'android-adb', 'androidtv', 'dial'].includes(d.type));
const apkBtn = canAdb ? '<button class="btn secondary" title="ARIA-Agent (APK) auf dieses Gerät ausrollen" ' +
'onclick="openApkDeploy(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.ip) + '\',\'' + escapeHtml(d.name || d.ip) + '\')" ' +
'style="padding:1px 6px;font-size:11px;margin-left:6px;">📦</button>' : '';
return '<div style="padding:3px 6px;background:#0D0D1A;border-radius:4px;display:flex;align-items:center;">' +
'<div style="flex:1;min-width:0;">' +
'<span style="color:#E0E0F0;">' + escapeHtml(d.name || d.id || '?') + '</span> ' +
'<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' +
'<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr +
'</div>' + keyBtn +
'</div>' + keyBtn + apkBtn +
'</div>';
}).join('') + '</div>';
}
@@ -4710,7 +5206,8 @@
(scanning ? ' disabled' : '') + '>Geraete scannen</button>' +
'</div>' +
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">kann: ' + escapeHtml(caps) + ' · ' + ctrl +
(s.net && s.net.primary_ip ? ' · Netz: ' + escapeHtml(s.net.primary_ip) : '') + '</div>' +
(s.net && s.net.primary_ip ? ' · Netz: ' + escapeHtml(s.net.primary_ip) : '') +
(s.net && s.net.mac ? ' · MAC: <span style="font-family:monospace;">' + escapeHtml(s.net.mac) + '</span>' : '') + '</div>' +
(s.net && s.net.warning ? '<div style="color:#FF6E6E;font-size:11px;margin-top:6px;padding:6px;background:#3A1F1F;border-radius:4px;">⚠ ' + escapeHtml(s.net.warning) + '</div>' : '') +
devHtml +
'</div>';
+195 -2
View File
@@ -328,6 +328,87 @@ function writeLocalLlmConfig(patch) {
return cur;
}
// ── Jellyfin-Server (Medien-Integration) ─────────────────────
// /shared/config/jellyfin.json — mehrere Server moeglich (eigener + z.B. vom
// Kumpel). ARIAs Brain liest dieselbe Datei und spricht die HTTP-API direkt an:
// Film suchen -> Sitzung des Abspielgeraets finden -> abspielen. Das ist
// deutlich zuverlaessiger, als sich durch die Fire-TV-Oberflaeche zu klicken.
const JELLYFIN_CONFIG_FILE = "/shared/config/jellyfin.json";
function readJellyfinConfig() {
try {
const p = JSON.parse(fs.readFileSync(JELLYFIN_CONFIG_FILE, "utf-8"));
const servers = Array.isArray(p.servers) ? p.servers : [];
return {
servers: servers.map(s => ({
id: String(s.id || ""),
name: String(s.name || ""),
url: String(s.url || ""),
apiKey: String(s.apiKey || ""),
isDefault: !!s.isDefault,
})).filter(s => s.id && s.url),
};
} catch {
return { servers: [] };
}
}
function writeJellyfinConfig(cfg) {
const servers = (Array.isArray(cfg.servers) ? cfg.servers : [])
.map(s => ({
id: String(s.id || "").trim() || Math.random().toString(36).slice(2, 10),
name: String(s.name || "").trim(),
url: String(s.url || "").trim().replace(/\/+$/, ""), // ohne Slash am Ende
apiKey: String(s.apiKey || "").trim(),
isDefault: !!s.isDefault,
}))
.filter(s => s.url);
// Genau einen Standard-Server markieren (der erste, falls keiner gesetzt ist).
if (servers.length && !servers.some(s => s.isDefault)) servers[0].isDefault = true;
let seen = false;
for (const s of servers) {
if (s.isDefault && seen) s.isDefault = false;
if (s.isDefault) seen = true;
}
fs.mkdirSync("/shared/config", { recursive: true });
const tmp = JELLYFIN_CONFIG_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify({ servers }, null, 2), { mode: 0o600 });
fs.renameSync(tmp, JELLYFIN_CONFIG_FILE);
try { fs.chmodSync(JELLYFIN_CONFIG_FILE, 0o600); } catch (_) {} // API-Keys!
return { servers };
}
/** Erreichbarkeit + Version eines Jellyfin-Servers pruefen. */
function testJellyfin(url, apiKey) {
return new Promise((resolve) => {
let u;
try { u = new URL(url.replace(/\/+$/, "") + "/System/Info"); }
catch (e) { return resolve({ ok: false, error: "Ungültige URL" }); }
const mod = u.protocol === "https:" ? require("https") : require("http");
const req = mod.request(u, {
method: "GET",
timeout: 8000,
headers: { "Authorization": `MediaBrowser Token="${apiKey}"` },
}, (res) => {
let body = "";
res.on("data", c => { body += c; if (body.length > 65536) req.destroy(); });
res.on("end", () => {
if (res.statusCode === 401 || res.statusCode === 403) {
return resolve({ ok: false, error: "API-Key abgelehnt (401/403)" });
}
if (res.statusCode !== 200) {
return resolve({ ok: false, error: `HTTP ${res.statusCode}` });
}
try {
const j = JSON.parse(body);
resolve({ ok: true, name: j.ServerName || "", version: j.Version || "" });
} catch (e) { resolve({ ok: false, error: "Antwort nicht lesbar" }); }
});
});
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "Zeitüberschreitung" }); });
req.on("error", (e) => resolve({ ok: false, error: e.message }));
req.end();
});
}
// ── Lokale Modell-Liste (Diagnostic-Dropdown) ────────────────
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
@@ -557,7 +638,7 @@ function hostList() {
const now = Date.now();
return Array.from(hosts.values()).map(h => ({
hostId: h.hostId, name: h.name, os: h.os, caps: h.caps, control: h.control,
online: (now - (h.last_seen || 0)) < 300000,
ip: h.ip || "", online: (now - (h.last_seen || 0)) < 300000,
}));
}
@@ -575,6 +656,8 @@ function workerList() {
return Array.from(workers.values()).map(w => ({
instanceId: w.instanceId, service: w.service, node: w.node,
gpus: w.gpus, model: w.model, models: w.models || [], busy: !!w.busy,
openaiPort: w.openaiPort || "", openaiBind: w.openaiBind || "",
openaiKeySet: !!w.openaiKeySet, openaiEnvLocked: !!w.openaiEnvLocked,
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
}));
}
@@ -1087,7 +1170,8 @@ function connectRVS(forcePlain) {
const wasKnown = hosts.has(p.hostId);
hosts.set(p.hostId, {
hostId: p.hostId, name: p.name || p.hostId, os: p.os || "",
caps: p.caps || [], control: !!p.control, last_seen: Date.now(),
caps: p.caps || [], control: !!p.control, ip: p.ip || "",
last_seen: Date.now(),
});
if (!wasKnown) broadcastHosts();
else hosts.get(p.hostId).last_seen = Date.now();
@@ -1098,6 +1182,15 @@ function connectRVS(forcePlain) {
} 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 === "llm_openai_config_result") {
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
} else if (msg.type === "sat_result") {
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
broadcast({ type: "sat_result", payload: msg.payload || {} });
} else if (msg.type === "agent_history" || msg.type === "agent_history_list") {
// Live-Event (agent_history) bzw. Abfrage-Antwort (agent_history_list)
// der Bridge → an den Browser (Agent-Historie).
broadcast({ type: msg.type, payload: msg.payload || {} });
} else if (msg.type === "sat_devices") {
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
const p = msg.payload || {};
@@ -1120,6 +1213,11 @@ function connectRVS(forcePlain) {
instanceId: p.instanceId, service: p.service || "",
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
models: Array.isArray(p.models) ? p.models : (p.model ? [p.model] : []),
// OpenAI-Endpunkt (nur llm-Worker): Port/Bind kommen aus der .env der
// Box, der Key wird NIE uebertragen — nur ob einer gesetzt ist und ob
// die .env ihn vorgibt (dann ist das Feld hier schreibgeschuetzt).
openaiPort: p.openaiPort || "", openaiBind: p.openaiBind || "",
openaiKeySet: !!p.openaiKeySet, openaiEnvLocked: !!p.openaiEnvLocked,
busy: !!prev.busy, last_seen: Date.now(),
});
broadcastWorkers();
@@ -1944,6 +2042,19 @@ const server = http.createServer((req, res) => {
"Expires": "0",
});
res.end(fs.readFileSync(htmlPath, "utf-8"));
} else if (req.url.startsWith("/uploads/")) {
// Screenshots aus der Agent-Historie (nur /shared/uploads, kein Traversal).
const base = path.basename(decodeURIComponent(req.url.slice("/uploads/".length)));
const file = path.join("/shared/uploads", base);
if (!file.startsWith("/shared/uploads/") || !fs.existsSync(file)) {
res.writeHead(404); res.end("not found"); return;
}
const ext = path.extname(file).toLowerCase();
const ct = ext === ".png" ? "image/png"
: (ext === ".jpg" || ext === ".jpeg") ? "image/jpeg"
: ext === ".webp" ? "image/webp" : "application/octet-stream";
res.writeHead(200, { "Content-Type": ct, "Cache-Control": "max-age=86400" });
res.end(fs.readFileSync(file));
} else if (req.url === "/api/state") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ state, logs: logs.slice(-100) }));
@@ -2005,6 +2116,39 @@ const server = http.createServer((req, res) => {
}
});
return;
} else if (req.url === "/api/jellyfin-config" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(readJellyfinConfig()));
} else if (req.url === "/api/jellyfin-config" && req.method === "POST") {
let body = "";
req.on("data", chunk => { body += chunk; if (body.length > 65536) req.destroy(); });
req.on("end", () => {
try {
const cfg = writeJellyfinConfig(JSON.parse(body));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, config: cfg }));
log("info", "server", `Jellyfin-Config: ${cfg.servers.length} Server gespeichert`);
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if (req.url === "/api/jellyfin-test" && req.method === "POST") {
let body = "";
req.on("data", chunk => { body += chunk; if (body.length > 8192) req.destroy(); });
req.on("end", async () => {
try {
const { url, apiKey } = JSON.parse(body);
const r = await testJellyfin(String(url || ""), String(apiKey || ""));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(r));
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
}
});
return;
} else if (req.url === "/api/onboarding") {
// RVS-Credentials fuer QR-Code App-Onboarding
res.writeHead(200, { "Content-Type": "application/json" });
@@ -2792,10 +2936,59 @@ wss.on("connection", (ws) => {
} else if (msg.action === "host_list") {
// Browser will die aktuelle Host-Agenten-Liste.
ws.send(JSON.stringify({ type: "host_update", hosts: hostList() }));
} else if (msg.action === "host_forget") {
// Alten/umbenannten Agenten aus der Liste werfen. Meldet er sich wieder
// (host_hello), taucht er automatisch neu auf — das ist also gefahrlos.
const id = String(msg.hostId || "");
if (id && hosts.delete(id)) {
log("info", "host", `Agent '${id}' aus der Liste entfernt`);
broadcastHosts();
}
} 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 === "sat_command") {
// Browser loest eine Satelliten-Aktion aus (z.B. APK auf Fire TV ausrollen).
// Die Antwort (sat_result) kommt ueber die rvsWs zurueck.
const params = msg.params && typeof msg.params === "object" ? msg.params : {};
// Fern-Einrichtung: der RVS-Zugang wird SERVERSEITIG ergaenzt — so muss
// niemand Host/Token im Browser eintippen (und das Token steht nicht im
// Browser-Formular). Gilt fuer adb.provision und adb.install mit Setup.
if (params.provision === true) {
delete params.provision;
params.rvs_host = RVS_HOST;
params.rvs_port = Number(RVS_PORT) || 443;
params.rvs_tls = RVS_TLS === "true";
params.rvs_token = RVS_TOKEN;
}
sendToRVS_raw({
type: "sat_command",
payload: {
satellite: String(msg.satellite || ""),
device: String(msg.device || ""),
action: String(msg.cmd || ""),
params,
requestId: String(msg.requestId || ""),
},
timestamp: Date.now(),
});
} else if (msg.action === "llm_openai_config") {
// Browser setzt den API-Key des OpenAI-Endpunkts einer Box.
sendToRVS_raw({
type: "llm_openai_config",
payload: {
instanceId: String(msg.instanceId || ""),
apiKey: String(msg.apiKey || ""),
requestId: String(msg.requestId || ""),
},
timestamp: Date.now(),
});
} else if (msg.action === "agent_history_query") {
// Browser will die gespeicherte Historie eines Agenten — die Bridge
// antwortet mit agent_history_list (auf derselben rvsWs).
sendToRVS_raw({ type: "agent_history_query",
payload: { host: String(msg.host || "") }, timestamp: Date.now() });
} else if (msg.action === "worker_list") {
// Browser will die aktuelle Compute-Flotte.
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
+6
View File
@@ -4,6 +4,12 @@
FROM python:3.11-slim-bullseye
WORKDIR /build
# PyInstaller braucht objdump aus binutils (im slim-Image nicht enthalten).
RUN apt-get update \
&& apt-get install -y --no-install-recommends binutils \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir pyinstaller
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+36
View File
@@ -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'"]
+89 -7
View File
@@ -15,20 +15,72 @@ Geräte in einem LAN; der Host-Agent steuert *den Rechner, auf dem er läuft*.
| `exec` | Shell-Kommando ausführen (optional `sudo`), stdout/stderr/exit |
| `read` | Datei lesen (Base64, mit Offset/Limit) |
| `write` | Datei schreiben/anhängen (Base64 oder Text) |
| `info` | OS, CPU/RAM/Disk-Auslastung, Uptime, IP |
| `info` | OS, CPU/RAM/Disk-Auslastung, Uptime, IP, `gui`-Methode |
| `screenshot` | Bildschirmfoto (X11: scrot/maim · Wayland: grim) |
| `ui_tap` | Mausklick auf x,y (button/double optional) *(nur mit GUI)* |
| `ui_text` | Text ins fokussierte Feld tippen *(nur mit GUI)* |
| `ui_key` | Taste/Kombi (Return, Escape, ctrl+c …) *(nur mit GUI)* |
| `ui_swipe` | Maus ziehen x1,y1 → x2,y2 *(nur mit GUI)* |
| `app_launch` | App/Programm starten (`app`/`query` oder `command`) *(nur mit GUI)* |
ARIA nutzt diese über die Brain-Tools `host_list` / `host_exec` / `host_read` /
`host_write` / `host_info` / `host_screenshot`.
ARIA nutzt diese über die Brain-Tools `host_list`/`host_exec`/`host_read`/
`host_write`/`host_info`/`host_screenshot` sowie `host_ui_tap`/`host_ui_text`/
`host_ui_key`/`host_ui_swipe`/`host_app_launch` (dieselben Tools wie beim
Android-Agenten — ein Werkzeugset für Handy **und** Rechner).
## Bauen (portable Binary)
> **GUI-Steuerung ist capability-gated.** Der Agent meldet die `ui_*`/`app_launch`-
> Fähigkeiten in seinen `caps` nur, wenn eine grafische Session da ist — ein reiner
> Terminal-/Server-Linux (kein `DISPLAY`/`WAYLAND_DISPLAY`) bekommt sie **nicht**
> (dort bleibt es bei `exec` & Co.). ARIA arbeitet mit dem **Screenshot** als Augen
> (voller Auflösung = echte Pixel; auf dem Desktop gibt es kein `ui_dump`).
>
> Nötige Helfer je System: **Linux/X11** → `xdotool`; **Linux/Wayland** → `ydotool`
> (Daemon, meist root); **macOS** → Text/Tasten via Bordmittel (`osascript`), Maus
> via `cliclick` (`brew install cliclick`); **Windows** → Bordmittel (PowerShell).
> HiDPI/Retina-Hinweis: Screenshot-Pixel können 2× der Klick-Koordinaten sein — bei
> Fehlklicks Koordinaten halbieren.
## 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 | GUI-Steuerung (`ui_*`) |
|---|---|---|---|---|
| **Linux** | `bash -lc` | sudo (`SUDO_PASSWORD`/`SUDO_NOPASSWD`) / root | grim (Wayland) · scrot/maim (X11) | `xdotool` (X11) · `ydotool` (Wayland) |
| **macOS** | `bash -lc` | sudo (wie Linux) | `screencapture` (Bordmittel) | `osascript` (Text/Tasten) · `cliclick` (Maus) |
| **Windows** | PowerShell | Agent **als Administrator** starten (kein sudo) | PowerShell/System.Drawing (Bordmittel) | PowerShell (Bordmittel) |
PyInstaller kann **nicht cross-kompilieren** — jede Binary wird auf ihrem OS gebaut.
## Bauen
**Linux (portabel, empfohlen)** — Docker-Container mit altem glibc:
```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
möglichst vielen Distributionen läuft.
**Linux/macOS ohne Docker** — PyInstaller direkt (linkt gegen lokales glibc/OS):
```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)
@@ -75,6 +127,36 @@ Danach: `systemctl status aria-host-agent` · `journalctl -u aria-host-agent -f`
Die Binary sucht er unter `dist/aria-host-agent` bzw. `./aria-host-agent` (oder
2. Argument). Braucht `dialog` für den Dateibrowser (bietet die Installation an).
### Windows-Dienst (setup.exe)
`aria-host-agent-setup.exe` (aus `build-win.sh` oder dem Gitea-Release) als
Administrator ausführen. Der Installer:
- kopiert die `.exe` nach `%ProgramFiles%\ARIA Host-Agent`,
- legt `%ProgramData%\ARIA-Host-Agent\.env` an (nur falls noch keine da ist),
- richtet über **nssm** den Dienst **ARIA Host-Agent** ein (Autostart) und startet ihn.
Danach die `.env` unter `%ProgramData%\ARIA-Host-Agent\` mit RVS-Zugang +
`CONTROL_ENABLED=true` füllen und den Dienst neu starten (`services.msc` →
*ARIA Host-Agent*, oder `nssm restart ARIAHostAgent`). Deinstallation über
*Apps & Features* → *ARIA Host-Agent* (die `.env` in ProgramData bleibt erhalten).
## Release (Binaries als Gitea-Assets)
`release_agent.sh <version>` baut alles Docker-Baubare und hängt es als
**Release-Asset** an den Tag `agent-v<version>` — nichts landet im Git-Tree:
```bash
./release_agent.sh 0.2.0 # Linux + Android + Windows (Wine)
SKIP_WINDOWS=1 ./release_agent.sh 0.2.0 # ohne Windows (schneller)
```
Assets: `aria-host-agent-linux-x64`, `aria-host-agent-android-agent-v<v>.apk`,
`aria-host-agent-windows.exe`, `aria-host-agent-windows-setup.exe`. **macOS** ist
nicht Docker-baubar — auf einem Mac `./build-native.sh` laufen lassen und das
Ergebnis vor dem Release nach `dist/aria-host-agent-macos` legen, dann nimmt das
Skript es automatisch mit. Gitea-Zugang via `.env`/Umgebung (`GITEA_URL`,
`GITEA_REPO`, `GITEA_USER`), Kennwort wird abgefragt.
## TLS / SNI — Agent im selben Netz wie der RVS
Steht der Rechner im **selben Netz wie der RVS** (z.B. Rechenzentrum) und soll
+11
View File
@@ -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
+32
View File
@@ -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'"]
+270
View File
@@ -0,0 +1,270 @@
# 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.
### Fire TV / Android TV
**Dieselbe APK** läuft auf Handy und Stick — die TV-Fähigkeiten schalten sich
automatisch frei (`caps` enthalten dann `tv`). Zusätzlich auf dem TV:
| Action | Was |
|---|---|
| `notify` | **Einblendung über dem laufenden Bild** (Text/Bild) — ohne Netflix & Co. zu stoppen; oder als Sprache, je nach Einstellung (`mode` überschreibt) |
| `speak` | Sprachausgabe über die TV-Lautsprecher (ARIAs echte Stimme, sonst Geräte-TTS) |
| `volume` | Lautstärke setzen (`percent`) |
| `listen` / `listen_stop` | Mikrofon öffnen/schließen — ARIA-Sprachdialog auf dem Gerät |
| `ui_key` | zusätzlich D-Pad: `up/down/left/right/ok`, `play_pause/next/prev`, `lauter/leiser/stumm` |
**Sprachdialog:** Die App spricht das gleiche Protokoll wie die ARIA-App
(`stt_stream_*` rein, `chat` + `audio_pcm` raus) — **serverseitig ist nichts
anzupassen**. In den App-Einstellungen wählst Du, wie ARIA hier antwortet:
**Einblendung** (Standard) oder **Sprache**; sie kann es pro Anfrage überschreiben.
> **Turn-Ownership:** Der RVS verteilt jede Antwort an *alle* Clients im Raum.
> Damit nicht Handy **und** Stick gleichzeitig losreden, gibt dieses Gerät nur
> aus, was aus **seiner eigenen** Frage entstanden ist.
> **Mikrofon der Fire-TV-Fernbedienung:** Die Mikro-Taste ist systemseitig mit
> Alexa belegt; ob Fremd-Apps an das Remote-Mikro kommen, ist je nach Stick-
> Generation unterschiedlich und **muss am Gerät ausprobiert werden**. Sicher
> funktioniert der **Sprech-Knopf in der App** (OK-Taste) — und falls das Remote-
> Mikro nicht hergibt, tut es ein USB-Mikro am Stick.
Berechtigungen einmalig in der App: **„Einblendung erlauben"** (Über anderen Apps
anzeigen) und beim ersten Sprechen das **Mikrofon**.
### Ausrollen per Satellit (ohne Datei-Kopieren)
Statt die APK von Hand aufs Gerät zu schaufeln, kann ein **Satellit** sie per ADB
über das Netz installieren:
1. Am Fire TV: **Einstellungen › Mein Fire TV › Entwickleroptionen › ADB-Debugging** an.
2. Diagnostic → Satelliten → scannen. ADB-fähige Geräte zeigen ein **ADB**-Tag
und einen **📦**-Button.
3. 📦 → APK-URL (aus dem Gitea-Release) → **Jetzt ausrollen**. Beim ersten Mal
erscheint auf dem TV „USB-Debugging zulassen?" → bestätigen (Haken „Immer erlauben").
Der Rollout **wartet darauf** und macht dann automatisch weiter.
**Einrichtung ohne Tippen:** Am Fire TV gibt es keine Kamera (kein QR-Scan) und
Host/Token mit der Fernbedienung einzugeben ist eine Qual. Deshalb ist im Rollout-
Dialog **„Gleich einrichten"** vorausgewählt: die Diagnostic kennt den RVS-Zugang
selbst und gibt ihn dem Agenten über dieselbe ADB-Verbindung mit
(`adb.provision` → `am start` mit Intent-Extras). Der Agent speichert die Daten,
startet den Dienst und ist **sofort online** — null Eingaben am Fernseher.
Das Token geht dabei **serverseitig** raus, es steht nie im Browser-Formular.
Mit **„Status prüfen"** (`adb.status`) siehst Du jederzeit, ob der Agent auf dem
Gerät installiert ist, welche Version läuft und ob der Dienst aktiv ist.
> **Sicherheitshinweis:** Die Einrichtung per Intent geht an die (exportierte)
> Haupt-Activity. Wer ADB-Zugriff auf das Gerät hat, kann den Agenten damit
> umkonfigurieren — wer ADB hat, kontrolliert das Gerät ohnehin. Die App zeigt
> beim Empfang einen Hinweis („Von ARIA eingerichtet"), damit es nicht unbemerkt
> passiert. Auf einem dedizierten Stick ist das unkritisch; schalte ADB-Debugging
> nach dem Ausrollen ab, wenn Du ganz sicher gehen willst.
ARIA kann dasselbe: `satellite_command` mit `action='adb.install'`,
`params={'ip':'…','url':'…'}`. Der Satellit lädt die APK **einmal** und kann sie
auf beliebig viele Sticks installieren.
> **⚠️ Gerät muss ENTSPERRT sein, damit ARIA es steuern kann.** Gesten und
> Systemtasten (`ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch`) greifen bei
> gesperrtem Bildschirm nicht — Android-Sicherheit. Der Agent erkennt das und
> meldet „Gerät ist gesperrt — bitte erst entsperren", statt still „ok" zu sagen.
> **Sehen** (`screenshot`, `ui_dump`, `info`) funktioniert dagegen auch bei
> gesperrtem Gerät.
## 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).
+57
View File
@@ -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 102
versionName '0.0.1.2'
}
// 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,89 @@
<?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" />
<!-- Overlay: ARIA blendet Text/Bild UEBER das laufende Programm ein (Fire TV).
Muss vom Nutzer einmalig erlaubt werden (Spezialberechtigung). -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- Sprache: Mikrofon (Fire-TV-Fernbedienung / USB-Mikro) fuer ARIA. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Fire TV / Android TV: dieselbe APK laeuft auf Stick UND Handy.
touchscreen/faketouch NICHT erforderlich (Fire TV hat keinen), und
leanback ebenfalls optional (sonst laeuft sie nicht mehr aufs Handy). -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.hardware.faketouch" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:banner="@drawable/banner_tv"
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:launchMode="singleTop"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- Ohne LEANBACK_LAUNCHER taucht die App im Fire-TV-Menue NICHT
auf (sie waere installiert, aber nicht startbar). -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- Unsichtbarer Helfer: schaltet die Bildschirm-Aufnahme nach einem
Neustart automatisch wieder scharf (die Sitzung ist nicht speicherbar,
nur die Berechtigung). Durchsichtig, ohne Animation -> man sieht nichts. -->
<activity
android:name=".CaptureActivity"
android:exported="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<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,64 @@
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,
/** Wie antwortet ARIA auf diesem Geraet standardmaessig: "overlay" oder "speech".
* Pro Anfrage ueberschreibbar ("...bitte als Sprache"). */
var replyMode: String = "overlay",
/** Soll die Bildschirm-Aufnahme nach einem Neustart automatisch wieder scharf
* geschaltet werden? Wird gesetzt, sobald sie einmal erlaubt wurde — die
* SITZUNG ist naemlich nicht speicherbar, nur die Berechtigung. */
var captureEnabled: 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),
replyMode = p.getString("replyMode", "overlay") ?: "overlay",
captureEnabled = p.getBoolean("capture", 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)
putString("replyMode", replyMode)
putBoolean("capture", captureEnabled)
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,186 @@
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 ACTION_TALK = "de.hackersoft.ariaagent.TALK"
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_TALK) {
// Sprech-Taste: laeuft gerade eine Aufnahme -> beenden, sonst starten.
val v = rvs?.voice
if (v == null) startRvs()
else if (v.isRecording) v.stopListening("user") else v.startListening()
return START_STICKY
}
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) {
// Merken: nach einem Neustart automatisch wieder scharf
// schalten (die Sitzung selbst ist nicht speicherbar).
AgentConfig.load(this).takeIf { !it.captureEnabled }?.let {
it.captureEnabled = true; it.save(this)
}
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()
// Bildschirm-Aufnahme nach Geraete-/App-Neustart automatisch wieder scharf
// schalten. Die Berechtigung (AppOp PROJECT_MEDIA) bleibt erhalten, die
// SITZUNG nicht — ohne das muesste man am Fernseher jedes Mal von Hand
// "Bildschirm-Zugriff erlauben" druecken. Ist der AppOp gesetzt, laeuft
// die Anfrage ohne Dialog durch; sonst fragt Android einmal nach.
if (cfg.captureEnabled && !ScreenCapturer.active) {
android.os.Handler(mainLooper).postDelayed({
if (!ScreenCapturer.active) {
try { CaptureActivity.request(applicationContext) } catch (_: Throwable) {}
}
}, 2500)
}
return START_STICKY
}
override fun onDestroy() {
try { rvs?.voice?.shutdown() } catch (_: Exception) {}
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))
}
}
@@ -0,0 +1,275 @@
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.View
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 — plus D-Pad (Fire TV). */
fun globalKey(name: String): JSONObject {
val key = name.trim().lowercase()
// D-Pad/Medien: auf dem Fernseher wird damit navigiert (kein Touch).
DPAD_KEYS[key]?.let { return dpad(it, key) }
val action = when (key) {
"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. Verfuegbar: back, home, " +
"recents, notifications, up, down, left, right, ok/center, " +
"play_pause, next, prev, volume_up, volume_down, mute")
}
return if (performGlobalAction(action)) okMsg("Taste '$name' ausgefuehrt")
else errMsg("Taste '$name' fehlgeschlagen")
}
/** D-Pad/Medien-Taste — via Fokus-Navigation bzw. Medien-Session. */
private fun dpad(keyCode: Int, label: String): JSONObject {
// Lautstaerke/Stumm gehen ueber den AudioManager (zuverlaessiger als KeyEvent).
when (keyCode) {
android.view.KeyEvent.KEYCODE_VOLUME_UP,
android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
android.view.KeyEvent.KEYCODE_VOLUME_MUTE -> {
val am = getSystemService(android.content.Context.AUDIO_SERVICE)
as android.media.AudioManager
val dir = when (keyCode) {
android.view.KeyEvent.KEYCODE_VOLUME_UP -> android.media.AudioManager.ADJUST_RAISE
android.view.KeyEvent.KEYCODE_VOLUME_DOWN -> android.media.AudioManager.ADJUST_LOWER
else -> android.media.AudioManager.ADJUST_TOGGLE_MUTE
}
am.adjustStreamVolume(android.media.AudioManager.STREAM_MUSIC, dir,
android.media.AudioManager.FLAG_SHOW_UI)
return okMsg("Taste '$label' ausgefuehrt")
}
}
// Medientasten an die aktive Wiedergabe-Session.
if (keyCode in MEDIA_KEYS) {
val am = getSystemService(android.content.Context.AUDIO_SERVICE)
as android.media.AudioManager
val now = android.os.SystemClock.uptimeMillis()
am.dispatchMediaKeyEvent(android.view.KeyEvent(now, now,
android.view.KeyEvent.ACTION_DOWN, keyCode, 0))
am.dispatchMediaKeyEvent(android.view.KeyEvent(now, now,
android.view.KeyEvent.ACTION_UP, keyCode, 0))
return okMsg("Taste '$label' ausgefuehrt")
}
// Richtungen/OK: ueber die Bedienungshilfe-Fokus-Navigation.
val root = rootInActiveWindow ?: return errMsg("kein aktives Fenster")
val focus = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
if (keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER) {
val target = focus ?: return errMsg("nichts fokussiert — vorher navigieren")
return if (target.performAction(AccessibilityNodeInfo.ACTION_CLICK))
okMsg("OK/Auswahl ausgefuehrt") else errMsg("Auswahl fehlgeschlagen")
}
val dir = when (keyCode) {
android.view.KeyEvent.KEYCODE_DPAD_UP -> View.FOCUS_UP
android.view.KeyEvent.KEYCODE_DPAD_DOWN -> View.FOCUS_DOWN
android.view.KeyEvent.KEYCODE_DPAD_LEFT -> View.FOCUS_LEFT
else -> View.FOCUS_RIGHT
}
val from = focus ?: root
val next = from.focusSearch(dir)
?: return errMsg("kein Element in Richtung '$label' gefunden")
return if (next.performAction(AccessibilityNodeInfo.ACTION_FOCUS))
okMsg("Taste '$label' ausgefuehrt") else errMsg("Taste '$label' 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
/** Fernseher-Navigation (Fire TV): D-Pad, Medien- und Lautstaerke-Tasten. */
private val DPAD_KEYS: Map<String, Int> = mapOf(
"up" to android.view.KeyEvent.KEYCODE_DPAD_UP,
"hoch" to android.view.KeyEvent.KEYCODE_DPAD_UP,
"down" to android.view.KeyEvent.KEYCODE_DPAD_DOWN,
"runter" to android.view.KeyEvent.KEYCODE_DPAD_DOWN,
"left" to android.view.KeyEvent.KEYCODE_DPAD_LEFT,
"links" to android.view.KeyEvent.KEYCODE_DPAD_LEFT,
"right" to android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
"rechts" to android.view.KeyEvent.KEYCODE_DPAD_RIGHT,
"ok" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"center" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"select" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"enter" to android.view.KeyEvent.KEYCODE_DPAD_CENTER,
"play_pause" to android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
"play" to android.view.KeyEvent.KEYCODE_MEDIA_PLAY,
"pause" to android.view.KeyEvent.KEYCODE_MEDIA_PAUSE,
"stop" to android.view.KeyEvent.KEYCODE_MEDIA_STOP,
"next" to android.view.KeyEvent.KEYCODE_MEDIA_NEXT,
"prev" to android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS,
"forward" to android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD,
"rewind" to android.view.KeyEvent.KEYCODE_MEDIA_REWIND,
"volume_up" to android.view.KeyEvent.KEYCODE_VOLUME_UP,
"lauter" to android.view.KeyEvent.KEYCODE_VOLUME_UP,
"volume_down" to android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
"leiser" to android.view.KeyEvent.KEYCODE_VOLUME_DOWN,
"mute" to android.view.KeyEvent.KEYCODE_VOLUME_MUTE,
"stumm" to android.view.KeyEvent.KEYCODE_VOLUME_MUTE,
)
private val MEDIA_KEYS: Set<Int> = setOf(
android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
android.view.KeyEvent.KEYCODE_MEDIA_PLAY,
android.view.KeyEvent.KEYCODE_MEDIA_PAUSE,
android.view.KeyEvent.KEYCODE_MEDIA_STOP,
android.view.KeyEvent.KEYCODE_MEDIA_NEXT,
android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS,
android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD,
android.view.KeyEvent.KEYCODE_MEDIA_REWIND,
)
}
}
@@ -0,0 +1,69 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
/**
* Audio-Fokus — damit ARIA nicht einfach ueber den laufenden Film redet.
*
* Ohne Fokus mischt Android beide Tonspuren: Netflix/Jellyfin/Spotify laufen in
* voller Lautstaerke weiter und ARIA ist kaum zu verstehen. Mit Fokus verhaelt
* sie sich wie ein normaler Sprachassistent:
*
* "duck" (Standard) laufende Medien werden LEISER, ARIA spricht darueber,
* danach geht die Lautstaerke von selbst wieder hoch.
* "pause" laufende Medien werden PAUSIERT (Player reagieren auf
* den Fokusverlust) — sinnvoll beim Zuhoeren, damit das
* Mikrofon nicht den Filmton aufnimmt.
* "none" kein Fokus (alte Mischung).
*/
object AudioFocusHelper {
private var request: AudioFocusRequest? = null
private var holder: String = ""
/** Fordert den Fokus an. [mode] = duck | pause | none. */
@Synchronized
fun request(ctx: Context, mode: String, who: String): Boolean {
if (mode == "none") return true
abandon(ctx)
val gain = if (mode == "pause") AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
else AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
val usage = if (who == "listen") AudioAttributes.USAGE_ASSISTANCE_SONIFICATION
else AudioAttributes.USAGE_ASSISTANT
return try {
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val req = AudioFocusRequest.Builder(gain)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(usage)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
// Kurze Unterbrechungen nicht als "verloren" behandeln.
.setWillPauseWhenDucked(false)
.setOnAudioFocusChangeListener { }
.build()
request = req
holder = who
am.requestAudioFocus(req) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
} catch (_: Throwable) {
false
}
}
/** Gibt den Fokus zurueck — die Medien werden wieder lauter / laufen weiter. */
@Synchronized
fun abandon(ctx: Context) {
val req = request ?: return
request = null
holder = ""
try {
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.abandonAudioFocusRequest(req)
} catch (_: Throwable) {
}
}
}
@@ -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,65 @@
package de.hackersoft.ariaagent
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Bundle
/**
* Unsichtbarer Helfer, der die Bildschirm-Aufnahme (neu) scharf schaltet.
*
* Hintergrund: Die Berechtigung (AppOp PROJECT_MEDIA) bleibt dauerhaft erhalten,
* die MediaProjection-SITZUNG aber nicht — Android laesst sie grundsaetzlich
* nicht speichern, sie stirbt mit dem Prozess. Nach einem Neustart des Geraets
* oder der App muesste man sonst jedes Mal von Hand "Bildschirm-Zugriff erlauben"
* druecken (auf einem Fire TV mit der Fernbedienung besonders laestig).
*
* Diese Activity loest die Anfrage automatisch aus und reicht das Ergebnis an den
* Dienst weiter. Ist PROJECT_MEDIA per ADB erlaubt, laeuft das OHNE Dialog durch —
* der Nutzer sieht nichts (durchsichtiges Theme, kein Fenster-Inhalt).
*/
class CaptureActivity : Activity() {
companion object {
private const val REQ = 4711
/** Startet die (Neu-)Scharfschaltung aus dem Dienst heraus. */
fun request(ctx: Context) {
val i = Intent(ctx, CaptureActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS or
Intent.FLAG_ACTIVITY_NO_ANIMATION)
ctx.startActivity(i)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
startActivityForResult(mpm.createScreenCaptureIntent(), REQ)
} catch (e: Throwable) {
ScreenCapturer.lastError = "Anfrage fehlgeschlagen: ${e.message}"
finish()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQ && resultCode == RESULT_OK && data != null) {
val i = Intent(this, AgentService::class.java)
.setAction(AgentService.ACTION_PROJECTION)
.putExtra(AgentService.EXTRA_RESULT_CODE, resultCode)
.putExtra(AgentService.EXTRA_RESULT_DATA, data)
androidx.core.content.ContextCompat.startForegroundService(this, i)
} else {
// Abgelehnt oder (ohne AppOp) Dialog weggeklickt — kein Drama, der
// Nutzer kann es in der App jederzeit nachholen.
ScreenCapturer.lastError =
"Bildschirm-Zugriff nicht erteilt (in der Agent-App nachholen)"
}
finish()
overridePendingTransition(0, 0)
}
}
@@ -0,0 +1,277 @@
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.RadioButton
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 micPerm = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) talkToAria() else toast("Ohne Mikrofon-Berechtigung kein Sprachdialog")
}
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.btnTalk).setOnClickListener {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED) talkToAria()
else micPerm.launch(Manifest.permission.RECORD_AUDIO)
}
findViewById<Button>(R.id.btnOverlayPerm).setOnClickListener {
if (OverlayView.canDraw(this)) { toast("Einblendung ist bereits erlaubt"); return@setOnClickListener }
try {
startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
android.net.Uri.parse("package:$packageName")))
toast("'ARIA Host-Agent' erlauben")
} catch (_: Exception) {
try { startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)) }
catch (_: Exception) { toast("Einstellung nicht gefunden") }
}
}
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")
}
}
// Fern-Einrichtung (z.B. Fire TV, wo QR-Scan und Tippen wegfallen): der
// Satellit startet die App per ADB mit den Zugangsdaten als Extras.
// Hat das geklappt, ist die UI schon befuellt — sonst normal laden.
if (!applyProvisioning(intent)) loadIntoUi(AgentConfig.load(this))
// Fire TV: kein Touchscreen — der QR-Scan ist dort sinnlos (keine Kamera),
// und der erste Fokus muss gesetzt sein, damit die Fernbedienung greift.
if (isTvDevice()) {
findViewById<Button>(R.id.btnScan).visibility = android.view.View.GONE
host.requestFocus()
}
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
findViewById<RadioButton>(
if (c.replyMode == "speech") R.id.modeSpeech else R.id.modeOverlay
).isChecked = true
}
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,
replyMode = if (findViewById<RadioButton>(R.id.modeSpeech).isChecked) "speech" else "overlay",
)
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"
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent == null) return
setIntent(intent)
applyProvisioning(intent)
}
/**
* Fern-Einrichtung per Intent-Extras (vom Satelliten via ADB gestartet).
*
* Auf einem Fire TV gibt es keine Kamera (kein QR) und Tippen mit der
* Fernbedienung ist muehsam — deshalb kann der Satellit die Zugangsdaten
* direkt mitgeben:
* adb shell am start -n de.hackersoft.ariaagent/.MainActivity \
* -a de.hackersoft.ariaagent.PROVISION \
* -e host rvs.example.com --ei port 443 --ez tls true -e token XYZ
*
* Gibt true zurueck, wenn eingerichtet wurde (dann startet der Dienst gleich).
*/
private fun applyProvisioning(intent: Intent): Boolean {
val host = intent.getStringExtra("host")?.trim().orEmpty()
val token = intent.getStringExtra("token")?.trim().orEmpty()
if (host.isEmpty() || token.isEmpty()) return false
val old = AgentConfig.load(this)
val cfg = AgentConfig(
host = host,
port = intent.getIntExtra("port", old.port.takeIf { it > 0 } ?: 443),
tls = intent.getBooleanExtra("tls", old.tls),
token = token,
name = intent.getStringExtra("name")?.trim().takeUnless { it.isNullOrEmpty() } ?: old.name,
controlEnabled = intent.getBooleanExtra("control", true),
replyMode = intent.getStringExtra("reply_mode")?.trim()
.takeUnless { it.isNullOrEmpty() } ?: old.replyMode,
// Der Satellit erteilt beim Einrichten PROJECT_MEDIA — dann darf sich
// die Bildschirm-Aufnahme nach jedem Neustart selbst scharf schalten.
captureEnabled = intent.getBooleanExtra("capture", old.captureEnabled),
)
cfg.save(this)
AgentService.start(this)
// Sichtbar machen, dass die Konfiguration von aussen kam (Transparenz).
toast("Von ARIA eingerichtet: ${cfg.host} — Agent gestartet")
loadIntoUi(cfg)
refreshStatus()
return true
}
/** Sprech-Taste: startet/stoppt die Aufnahme im laufenden Dienst. */
private fun talkToAria() {
if (!AgentConfig.load(this).isValid()) { toast("Erst verbinden"); return }
ContextCompat.startForegroundService(this,
Intent(this, AgentService::class.java).setAction(AgentService.ACTION_TALK))
toast("Sprich jetzt — nochmal drücken beendet die Aufnahme")
}
/** Fire TV / Android TV (kein Touchscreen, Bedienung per Fernbedienung). */
private fun isTvDevice(): Boolean = try {
packageManager.hasSystemFeature("android.software.leanback") ||
packageManager.hasSystemFeature("android.hardware.type.television")
} catch (_: Exception) { false }
private fun toast(m: String) = Toast.makeText(this, m, Toast.LENGTH_SHORT).show()
}
@@ -0,0 +1,139 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.util.Base64
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
/**
* Einblendung ueber dem laufenden Bild (Fire TV / Android TV).
*
* ARIA kann Text (und optional ein Bild) UEBER Netflix & Co. anzeigen, ohne die
* Wiedergabe zu stoppen — fuer Antworten, Hinweise, Tuerklingel-Schnappschuesse.
* Technisch ein TYPE_APPLICATION_OVERLAY-Fenster; der Nutzer muss die
* Spezialberechtigung "Ueber anderen Apps anzeigen" einmalig erteilen.
*
* Nicht fokussierbar (FLAG_NOT_FOCUSABLE) — die Fernbedienung steuert weiter die
* App darunter, die Einblendung schluckt keine Tastendruecke.
*/
object OverlayView {
private val main = Handler(Looper.getMainLooper())
private var root: LinearLayout? = null
private var hideRunnable: Runnable? = null
/** Hat der Nutzer "Ueber anderen Apps anzeigen" erlaubt? */
fun canDraw(ctx: Context): Boolean =
if (Build.VERSION.SDK_INT >= 23) Settings.canDrawOverlays(ctx) else true
/**
* Zeigt eine Einblendung. [seconds] = Anzeigedauer (0 = bis zum naechsten
* hide()). [imageB64] optional ein PNG/JPEG als Base64.
* Muss NICHT vom Main-Thread gerufen werden.
*/
fun show(ctx: Context, title: String, text: String, seconds: Int, imageB64: String?) {
main.post {
try {
hideNow(ctx)
val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val box = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
setPadding(36, 28, 36, 28)
background = GradientDrawable().apply {
cornerRadius = 22f
setColor(Color.parseColor("#E60D0D1A")) // dunkel, leicht transparent
setStroke(3, Color.parseColor("#3FFF9F"))
}
}
if (title.isNotBlank()) {
box.addView(TextView(ctx).apply {
setText(title)
setTextColor(Color.parseColor("#3FFF9F"))
textSize = 22f
setPadding(0, 0, 0, 10)
})
}
if (text.isNotBlank()) {
box.addView(TextView(ctx).apply {
setText(text)
setTextColor(Color.parseColor("#E0E0F0"))
textSize = 19f
maxLines = 8
})
}
imageB64?.let { b64 ->
try {
val bytes = Base64.decode(b64, Base64.DEFAULT)
val bmp = android.graphics.BitmapFactory
.decodeByteArray(bytes, 0, bytes.size)
if (bmp != null) {
box.addView(ImageView(ctx).apply {
setImageBitmap(bmp)
adjustViewBounds = true
maxHeight = 520
setPadding(0, 14, 0, 0)
})
}
} catch (_: Exception) { /* Bild optional */ }
}
val type = if (Build.VERSION.SDK_INT >= 26)
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else
@Suppress("DEPRECATION") WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
val lp = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
type,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
android.graphics.PixelFormat.TRANSLUCENT,
).apply {
gravity = Gravity.TOP or Gravity.END
x = 48
y = 48
width = (ctx.resources.displayMetrics.widthPixels * 0.42f).toInt()
}
wm.addView(box, lp)
root = box
if (seconds > 0) {
hideRunnable = Runnable { hideNow(ctx) }
main.postDelayed(hideRunnable!!, seconds * 1000L)
}
} catch (_: Exception) {
root = null
}
}
}
/** Blendet eine laufende Einblendung aus. */
fun hide(ctx: Context) {
main.post { hideNow(ctx) }
}
private fun hideNow(ctx: Context) {
hideRunnable?.let { main.removeCallbacks(it) }
hideRunnable = null
val v: View? = root
root = null
if (v != null) {
try {
(ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager).removeView(v)
} catch (_: Exception) { /* schon weg */ }
}
}
}
@@ -0,0 +1,407 @@
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
/** Sprachdialog mit ARIA (Mikro rein, Sprache/Einblendung raus). */
val voice: VoiceSession by lazy {
VoiceSession(appCtx, config,
send = { t, p -> ws?.let { send(it, t, p) } },
onEvent = { msg -> onStatus(true, msg) })
}
/** Fire TV / Android TV? Dann gibt es keinen Touchscreen, dafuer D-Pad. */
private val isTv: Boolean by lazy {
try {
val pm = appCtx.packageManager
pm.hasSystemFeature("android.software.leanback") ||
pm.hasSystemFeature("android.hardware.type.television") ||
(appCtx.getSystemService(Context.UI_MODE_SERVICE) as android.app.UiModeManager)
.currentModeType == android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
} catch (_: Exception) { false }
}
private val caps: List<String> by lazy {
val base = mutableListOf("info", "screenshot", "ui_dump",
"ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch",
// Ausgabe auf dem Geraet: Einblendung ueber dem Bild + Sprachausgabe.
"notify", "speak", "volume",
// Sprachdialog: Mikro oeffnen/schliessen (ARIA kann es auch anstossen).
"listen", "listen_stop")
if (isTv) base += "tv" // Kennzeichen: dieses Geraet ist ein Fernseher/Stick
base
}
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())
val kind = if (isTv) "Fire TV / Android TV" else "Android"
p.put("os", "$kind ${Build.VERSION.RELEASE} (${Build.MODEL})")
// Eigene LAN-IP mitmelden: so kann die Diagnostic diesen Agenten dem im
// Netz entdeckten Geraet zuordnen (z.B. um beim Ausrollen den bereits
// vergebenen Namen vorzuschlagen statt der technischen Kennung).
localIp()?.let { p.put("ip", it) }
p.put("caps", JSONArray(caps))
p.put("control", config.controlEnabled)
send(webSocket, "host_hello", p)
}
/** Erste echte IPv4 dieses Geraets im LAN (ohne Loopback). */
private fun localIp(): String? = try {
java.net.NetworkInterface.getNetworkInterfaces().toList()
.filter { it.isUp && !it.isLoopback }
.flatMap { it.inetAddresses.toList() }
.firstOrNull { !it.isLoopbackAddress && it is java.net.Inet4Address }
?.hostAddress
} catch (_: Throwable) { null }
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 }
val type = msg.optString("type")
// Sprachdialog: chat/audio_pcm/stt_* laufen ueber dieselbe Verbindung.
// (Der RVS broadcastet alles im Raum — VoiceSession filtert auf den
// eigenen Turn, damit nicht auch Handy und Stick gleichzeitig antworten.)
if (type != "host_command") {
if (type in VOICE_TYPES) {
try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) }
catch (_: Throwable) {}
}
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()
// Steuern geht nur bei ENTSPERRTEM Gerät (Gesten/Tasten greifen sonst
// nicht — Android-Sicherheit). Sehen (screenshot/ui_dump) geht gesperrt.
action in CONTROL_ACTIONS && isLocked() ->
err("Gerät ist gesperrt — zum Steuern bitte erst entsperren. " +
"(Screenshot/Bildschirm lesen geht auch gesperrt.)")
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 == "notify" -> doNotify(params)
action == "speak" -> doSpeak(params)
action == "volume" -> doVolume(params)
action == "listen" -> voice.startListening()
action == "listen_stop" -> voice.stopListening("remote")
action == "app_list" ->
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)
}
/** Steuer-Aktionen, die ein entsperrtes Gerät brauchen. */
private val CONTROL_ACTIONS = setOf("ui_tap", "ui_text", "ui_swipe", "ui_key", "app_launch")
/** Message-Typen des ARIA-Sprachdialogs (kein host_command). */
private val VOICE_TYPES = setOf(
"chat", "audio_pcm", "stt_endpoint", "stt_stream_done", "stt_partial")
private fun isLocked(): Boolean = try {
val km = appCtx.getSystemService(Context.KEYGUARD_SERVICE) as android.app.KeyguardManager
km.isKeyguardLocked
} catch (_: Exception) { false }
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"))
}
/**
* ARIAs Antwort auf dem Geraet ausgeben: Einblendung (Overlay) und/oder Sprache.
* `mode` optional ("overlay" | "speech" | "both") — ohne Angabe gilt die
* Einstellung aus der App (config.replyMode). So kann ARIA pro Anfrage
* ueberschreiben ("...bitte als Sprache"), ohne die Voreinstellung zu aendern.
*/
private fun doNotify(p: JSONObject): JSONObject {
val text = p.optString("text").ifBlank { p.optString("message") }
val title = p.optString("title", "ARIA")
if (text.isBlank() && !p.has("image_base64")) return err("notify braucht 'text'.")
val mode = p.optString("mode").ifBlank { config.replyMode }
val done = mutableListOf<String>()
val problems = mutableListOf<String>()
if (mode == "overlay" || mode == "both") {
if (!OverlayView.canDraw(appCtx)) {
problems += "Einblendung nicht erlaubt — in der Agent-App auf dem Gerät " +
"'Einblendung erlauben' antippen (Berechtigung 'Über anderen Apps anzeigen')"
} else {
OverlayView.show(appCtx, title, text,
p.optInt("seconds", 12),
if (p.has("image_base64")) p.optString("image_base64") else null)
done += "eingeblendet"
}
}
if (mode == "speech" || mode == "both") {
val r = speakOut(p, text)
r.exceptionOrNull()?.let { problems += "Sprachausgabe: ${it.message}" }
?: run { done += "gesprochen (ARIAs Stimme)" }
}
if (done.isEmpty()) return err(problems.joinToString("; ").ifBlank { "nichts ausgegeben" })
val msg = done.joinToString(" + ") + (if (problems.isEmpty()) "" else " (${problems.joinToString("; ")})")
return JSONObject().put("ok", true).put("result", JSONObject().put("message", msg))
}
/** Nur Sprachausgabe (ARIAs Audio, falls mitgeliefert — sonst Geraete-TTS). */
private fun doSpeak(p: JSONObject): JSONObject {
val text = p.optString("text")
if (text.isBlank() && !p.has("audio_base64")) return err("speak braucht 'text' oder 'audio_base64'.")
val how = if (p.optString("audio_base64").isNotBlank()) "Audio abgespielt"
else if (p.optString("engine") == "device") "über die Geräte-Stimme gesprochen"
else "wird mit ARIAs Stimme gesprochen (Audio kommt vom Server)"
val r = speakOut(p, text)
return r.fold(
{ JSONObject().put("ok", true).put("result", JSONObject().put("message", how)) },
{ err("Sprachausgabe fehlgeschlagen: ${it.message}") },
)
}
/**
* Sprachausgabe. Reihenfolge:
* 1. mitgeliefertes Audio abspielen,
* 2. ARIAs ECHTE Stimme ueber den Server anfordern (Standard) — Fire TV
* Sticks haben oft gar keine (deutsche) TTS-Engine,
* 3. geraeteeigene Sprachausgabe nur auf ausdrueckliche Anforderung
* (engine='device') oder wenn kein Server-Audio moeglich ist.
* focus: duck (Medien leiser, Standard) | pause (Medien anhalten) | none.
*/
private fun speakOut(p: JSONObject, text: String): Result<Unit> {
val audio = p.optString("audio_base64")
val focus = p.optString("focus").ifBlank { "duck" }
if (audio.isNotBlank())
return Speaker.play(appCtx, audio, p.optString("audio_format", "wav"), focus)
if (p.optString("engine") == "device")
return Speaker.speak(appCtx, text, focus)
return runCatching { voice.requestTts(text); Unit }
}
private fun doVolume(p: JSONObject): JSONObject {
if (!p.has("percent")) return err("volume braucht 'percent' (0-100).")
val v = p.optInt("percent")
Speaker.setVolume(appCtx, v)
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Lautstärke auf $v% gesetzt"))
}
/** 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,105 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.speech.tts.TextToSpeech
import android.util.Base64
import java.io.File
import java.util.Locale
/**
* Sprachausgabe auf dem Geraet (v.a. Fire TV: ARIA spricht ueber die TV-Lautsprecher).
*
* Zwei Wege:
* 1) `play(audioBase64)` — ARIAs ECHTE Stimme: fertiges Audio (WAV/MP3) aus dem
* bestehenden TTS-Pfad (F5/XTTS) wird einfach abgespielt. Bevorzugt.
* 2) `speak(text)` — geraeteeigene TextToSpeech als Rueckfallebene, falls kein
* Audio mitgeliefert wurde (klingt nach Standard-Stimme, nicht nach ARIA).
*/
object Speaker {
private var tts: TextToSpeech? = null
private var ttsReady = false
private var player: MediaPlayer? = null
/** Spielt fertiges Audio (Base64, z.B. WAV aus dem ARIA-TTS) ueber die Lautsprecher.
* [focus] = duck (Medien leiser, Standard) | pause (Medien anhalten) | none. */
fun play(ctx: Context, audioB64: String, format: String = "wav",
focus: String = "duck"): Result<Unit> = runCatching {
stop()
val bytes = Base64.decode(audioB64, Base64.DEFAULT)
val f = File(ctx.cacheDir, "aria_speak.${format.ifBlank { "wav" }}")
f.writeBytes(bytes)
// Erst Fokus holen, damit Netflix & Co. leiser werden (sonst redet ARIA
// einfach mittendrin mit und man versteht beides nicht).
AudioFocusHelper.request(ctx, focus, "speak")
player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
setDataSource(f.absolutePath)
setOnCompletionListener { mp ->
try { mp.release() } catch (_: Exception) {}
player = null
AudioFocusHelper.abandon(ctx) // Medien wieder laut / weiterlaufen
}
setOnErrorListener { _, _, _ -> AudioFocusHelper.abandon(ctx); false }
prepare()
start()
}
}
/** Rueckfallebene: geraeteeigene Sprachausgabe. */
fun speak(ctx: Context, text: String, focus: String = "duck"): Result<Unit> = runCatching {
if (tts == null) {
tts = TextToSpeech(ctx.applicationContext) { status ->
ttsReady = status == TextToSpeech.SUCCESS
if (ttsReady) tts?.language = Locale.GERMAN
}
// Kurz auf die Initialisierung warten (TTS meldet sich asynchron).
var waited = 0
while (!ttsReady && waited < 3000) { Thread.sleep(100); waited += 100 }
}
if (!ttsReady) throw IllegalStateException(
"Geraet hat keine Sprachausgabe (TTS-Engine fehlt — auf Fire TV ueblich)")
// Sprache pruefen: eine Engine ohne deutsche Daten wuerde sonst STILL
// nichts sagen und wir wuerden faelschlich 'gesprochen' melden.
val lang = tts?.setLanguage(Locale.GERMAN) ?: TextToSpeech.LANG_NOT_SUPPORTED
if (lang == TextToSpeech.LANG_MISSING_DATA || lang == TextToSpeech.LANG_NOT_SUPPORTED)
throw IllegalStateException(
"Geraete-Sprachausgabe kann kein Deutsch (Sprachdaten fehlen)")
AudioFocusHelper.request(ctx, focus, "speak")
tts?.setOnUtteranceProgressListener(object : android.speech.tts.UtteranceProgressListener() {
override fun onStart(id: String?) {}
override fun onDone(id: String?) { AudioFocusHelper.abandon(ctx) }
@Deprecated("Deprecated in Java")
override fun onError(id: String?) { AudioFocusHelper.abandon(ctx) }
})
val rc = tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "aria")
if (rc != TextToSpeech.SUCCESS) {
AudioFocusHelper.abandon(ctx)
throw IllegalStateException("Sprachausgabe abgelehnt (Code $rc)")
}
Unit
}
/** Lautstaerke 0..100 (Medien-Kanal). */
fun setVolume(ctx: Context, percent: Int) {
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
val v = (max * percent.coerceIn(0, 100) / 100.0).toInt()
am.setStreamVolume(AudioManager.STREAM_MUSIC, v, AudioManager.FLAG_SHOW_UI)
}
fun stop(ctx: Context? = null) {
try { player?.stop(); player?.release() } catch (_: Exception) {}
player = null
try { tts?.stop() } catch (_: Exception) {}
ctx?.let { AudioFocusHelper.abandon(it) }
}
}
@@ -0,0 +1,326 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.NoiseSuppressor
import android.util.Base64
import okhttp3.WebSocket
import org.json.JSONObject
import kotlin.random.Random
/**
* Sprachdialog mit ARIA direkt auf dem Geraet (v.a. Fire TV: reden ueber die
* Fernbedienung, Antwort ueber die TV-Lautsprecher bzw. als Einblendung).
*
* Nutzt GENAU das Protokoll der ARIA-App, damit serverseitig nichts angepasst
* werden muss:
* rein : stt_stream_start -> stt_audio_chunk (Feld 'pcm', 16 kHz mono s16le,
* 6400-Byte-Bloecke) -> stt_stream_end
* raus : chat (sender=aria, messageId/speak/converse) + audio_pcm (Feld
* 'base64', 24 kHz mono s16le, chunkweise bis final=true)
*
* WICHTIG — Turn-Ownership: Der RVS ist ein Broadcast-Relay ohne Adressierung.
* Jeder Client im Raum bekommt JEDE Antwort. Ohne Filter wuerden Handy-App und
* Fire TV dieselbe Antwort gleichzeitig vorlesen. Deshalb gibt dieses Geraet nur
* aus, was aus SEINER eigenen Frage entstanden ist (audioRequestId -> messageId).
*/
class VoiceSession(
private val appCtx: Context,
private val config: AgentConfig,
private val send: (type: String, payload: JSONObject) -> Unit,
private val onEvent: (String) -> Unit = {},
) {
companion object {
// Aufnahme — muss exakt zur STT-Bridge passen.
private const val IN_RATE = 16000
private const val CHUNK_BYTES = 6400 // 3200 Samples = 200 ms
// Wiedergabe — F5-TTS liefert 24 kHz (sampleRate steht aber im Payload).
private const val OUT_RATE_DEFAULT = 24000
private const val MAX_RECORD_MS = 60000
}
@Volatile private var recording = false
@Volatile private var recordThread: Thread? = null
private var rec: AudioRecord? = null
private var aec: AcousticEchoCanceler? = null
private var ns: NoiseSuppressor? = null
private var track: AudioTrack? = null
@Volatile private var playingMessageId: String? = null
/** Eigener Turn: gesetzt beim Aufnehmen, wandert ueber stt_endpoint zur messageId. */
@Volatile private var myAudioRequestId: String? = null
@Volatile private var myMessageId: String? = null
@Volatile private var sessionId: String? = null
/** Text der letzten ARIA-Antwort auf UNSERE Frage (fuer die Einblendung). */
@Volatile private var lastReplyText: String = ""
/** Angeforderte TTS-Ausgaben (messageId) — deren Audio spielen wir ab, auch
* wenn es nicht aus einem eigenen Sprach-Turn stammt. */
private val expectedAudio = java.util.Collections.synchronizedSet(mutableSetOf<String>())
val isRecording: Boolean get() = recording
// ── Aufnehmen + streamen ────────────────────────────────────────
/** Startet die Aufnahme und streamt live an die STT. */
fun startListening(): JSONObject {
if (recording) return JSONObject().put("ok", false).put("error", "Nimmt bereits auf.")
val stamp = System.currentTimeMillis()
val rnd = Random.nextInt(100000, 999999)
val sid = "sttstr_${stamp}_$rnd"
val aid = "audio_${stamp}_$rnd"
sessionId = sid
myAudioRequestId = aid
myMessageId = null
val minBuf = AudioRecord.getMinBufferSize(
IN_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
if (minBuf <= 0) return JSONObject().put("ok", false)
.put("error", "Kein Mikrofon verfuegbar (Fire-TV-Fernbedienung aktiv? USB-Mikro?)")
val r = try {
AudioRecord(MediaRecorder.AudioSource.VOICE_COMMUNICATION, IN_RATE,
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
maxOf(minBuf, CHUNK_BYTES * 4))
} catch (e: Throwable) {
return JSONObject().put("ok", false)
.put("error", "Mikrofon-Zugriff fehlgeschlagen: ${e.message}")
}
if (r.state != AudioRecord.STATE_INITIALIZED) {
try { r.release() } catch (_: Exception) {}
return JSONObject().put("ok", false)
.put("error", "Mikrofon nicht initialisiert (Berechtigung erteilt? Mikro vorhanden?)")
}
// Echo/Rauschen daempfen, damit der Stick nicht seine eigene Ausgabe hoert.
try { if (AcousticEchoCanceler.isAvailable()) aec = AcousticEchoCanceler.create(r.audioSessionId)?.apply { enabled = true } } catch (_: Exception) {}
try { if (NoiseSuppressor.isAvailable()) ns = NoiseSuppressor.create(r.audioSessionId)?.apply { enabled = true } } catch (_: Exception) {}
rec = r
recording = true
// Beim Zuhoeren laufende Medien ANHALTEN — sonst nimmt das Mikrofon den
// Filmton mit auf und die Spracherkennung versteht nur Kauderwelsch.
AudioFocusHelper.request(appCtx, "pause", "listen")
send("stt_stream_start", JSONObject().apply {
put("requestId", sid)
put("audioRequestId", aid)
put("voice", "")
put("speed", 1.0)
put("interrupted", false)
put("endpointMs", 2400)
put("hardCapMs", MAX_RECORD_MS)
put("sampleRate", IN_RATE)
put("projectId", "")
put("targetInstance", "")
put("language", "de")
})
onEvent("hoert zu …")
recordThread = Thread {
val buf = ByteArray(CHUNK_BYTES)
var seq = 0
try {
r.startRecording()
val until = System.currentTimeMillis() + MAX_RECORD_MS
while (recording && System.currentTimeMillis() < until) {
val n = r.read(buf, 0, buf.size)
if (n <= 0) continue
val slice = if (n == buf.size) buf else buf.copyOf(n)
send("stt_audio_chunk", JSONObject().apply {
put("requestId", sid)
put("pcm", Base64.encodeToString(slice, Base64.NO_WRAP))
put("seq", seq++)
put("targetInstance", "")
})
}
} catch (_: Throwable) {
// Aufnahme abgebrochen — stopListening raeumt auf.
} finally {
stopListening("auto")
}
}.also { it.isDaemon = true; it.start() }
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Hoere zu …"))
}
/** Beendet die Aufnahme (die STT antwortet dann per stt_endpoint). */
fun stopListening(reason: String = "user"): JSONObject {
if (!recording) return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Nahm nicht auf"))
recording = false
try { rec?.stop() } catch (_: Exception) {}
try { rec?.release() } catch (_: Exception) {}
try { aec?.release() } catch (_: Exception) {}
try { ns?.release() } catch (_: Exception) {}
rec = null; aec = null; ns = null
// Aufnahme vorbei -> Medien laufen weiter (die Antwort holt sich gleich
// selbst wieder Fokus, sobald ARIAs Audio kommt).
AudioFocusHelper.abandon(appCtx)
sessionId?.let {
send("stt_stream_end", JSONObject().apply {
put("requestId", it); put("reason", reason); put("targetInstance", "")
})
}
onEvent("verarbeite …")
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Aufnahme beendet"))
}
/**
* Laesst ARIAs ECHTE Stimme etwas sagen: fordert das TTS-Audio ueber den
* bestehenden Server-Pfad an (tts_request -> Bridge -> F5/XTTS -> audio_pcm)
* und spielt es ab, sobald es hereinkommt.
*
* Besser als die geraeteeigene Sprachausgabe: Fire TV Sticks haben oft gar
* keine (deutsche) TTS-Engine — und es klingt nach ARIA, nicht nach Roboter.
*/
fun requestTts(text: String): String {
val mid = "spk_${System.currentTimeMillis()}_${Random.nextInt(100000, 999999)}"
expectedAudio.add(mid)
// Nicht unbegrenzt wachsen lassen, falls mal keine Antwort kommt.
if (expectedAudio.size > 20) {
synchronized(expectedAudio) {
expectedAudio.iterator().let { if (it.hasNext()) { it.next(); it.remove() } }
}
}
send("tts_request", JSONObject().apply {
put("text", text)
put("messageId", mid)
put("voice", "")
put("speed", 1.0)
})
return mid
}
/** Text direkt an ARIA schicken (ohne Mikrofon) — Antwort landet hier genauso. */
fun ask(text: String): JSONObject {
if (text.isBlank()) return JSONObject().put("ok", false).put("error", "leerer Text")
val stamp = System.currentTimeMillis()
myAudioRequestId = "audio_${stamp}_${Random.nextInt(100000, 999999)}"
myMessageId = null
send("chat", JSONObject().apply {
put("text", text)
put("voice", ""); put("speed", 1.0); put("projectId", "")
put("clientMsgId", "cmsg_${stamp}_${Random.nextInt(100000, 999999)}")
})
return JSONObject().put("ok", true)
.put("result", JSONObject().put("message", "Frage gesendet"))
}
// ── Eingehende Nachrichten ──────────────────────────────────────
/** Verarbeitet Sprach-/Chat-Nachrichten. true = war fuer uns relevant. */
fun handle(type: String, p: JSONObject): Boolean = when (type) {
"stt_endpoint", "stt_stream_done" -> {
if (p.optString("requestId") == sessionId) {
val text = p.optString("text")
if (type == "stt_endpoint" && text.isNotBlank()) onEvent("verstanden: $text")
if (type == "stt_stream_done") { recording = false; sessionId = null }
}
true
}
"chat" -> {
if (p.optString("sender") == "aria" && isMyTurn(p)) {
myMessageId = p.optString("messageId")
lastReplyText = p.optString("text")
deliverReply(p)
}
true
}
"audio_pcm" -> {
// Abspielen, wenn (a) wir dieses Audio selbst angefordert haben
// (host_speak/notify) oder (b) es die Antwort auf UNSERE Frage ist und
// Sprachausgabe gewuenscht war. Sonst wuerde auch das Handy mitreden.
val mid = p.optString("messageId")
if (mid.isNotBlank() &&
(expectedAudio.contains(mid) || (mid == myMessageId && wantSpeech()))) {
feedAudio(p)
if (p.optBoolean("final", false)) expectedAudio.remove(mid)
}
true
}
else -> false
}
/** Gehoert diese Antwort zu unserer Frage? */
private fun isMyTurn(p: JSONObject): Boolean {
val aid = myAudioRequestId ?: return false
val pa = p.optString("audioRequestId")
// Sprach-Turn: audioRequestId wird durchgereicht. Text-Turn (ask): die
// naechste ARIA-Antwort nach unserer Frage gehoert uns.
return if (pa.isNotBlank()) pa == aid else myMessageId == null
}
private fun wantSpeech(): Boolean = config.replyMode == "speech"
/** Antwort ausgeben: Einblendung und/oder Sprache (je nach Einstellung). */
private fun deliverReply(p: JSONObject) {
val text = p.optString("text")
if (text.isBlank()) return
if (config.replyMode == "overlay" && OverlayView.canDraw(appCtx)) {
OverlayView.show(appCtx, "ARIA", text, 15, null)
} else if (config.replyMode == "overlay") {
onEvent("Antwort (Einblendung nicht erlaubt): $text")
}
// Bei "speech" kommt das Audio ueber audio_pcm; falls ARIA nicht spricht
// (speak=false), trotzdem einblenden, damit nichts verloren geht.
if (wantSpeech() && !p.optBoolean("speak", true) && OverlayView.canDraw(appCtx)) {
OverlayView.show(appCtx, "ARIA", text, 15, null)
}
}
// ── TTS-Wiedergabe (chunkweises PCM) ────────────────────────────
private fun feedAudio(p: JSONObject) {
val mid = p.optString("messageId")
val rate = p.optInt("sampleRate", OUT_RATE_DEFAULT)
if (p.optBoolean("final", false)) {
try { track?.stop() } catch (_: Exception) {}
try { track?.release() } catch (_: Exception) {}
track = null; playingMessageId = null
// Fertig gesprochen -> Medien wieder hochfahren/weiterlaufen lassen.
AudioFocusHelper.abandon(appCtx)
onEvent("bereit")
return
}
val b64 = p.optString("base64")
if (b64.isBlank()) return
val pcm = try { Base64.decode(b64, Base64.DEFAULT) } catch (_: Exception) { return }
if (track == null || playingMessageId != mid) {
try { track?.release() } catch (_: Exception) {}
// ARIA antwortet -> laufende Medien leiser (duck), bis sie fertig ist.
AudioFocusHelper.request(appCtx, "duck", "speak")
val min = AudioTrack.getMinBufferSize(rate, AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT)
track = AudioTrack.Builder()
.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build())
.setAudioFormat(AudioFormat.Builder()
.setSampleRate(rate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT).build())
.setBufferSizeInBytes(maxOf(min, 16384))
.setTransferMode(AudioTrack.MODE_STREAM)
.build().also { it.play() }
playingMessageId = mid
onEvent("spricht …")
}
try { track?.write(pcm, 0, pcm.size) } catch (_: Exception) {}
}
fun shutdown() {
stopListening("shutdown")
try { track?.stop(); track?.release() } catch (_: Exception) {}
track = null
AudioFocusHelper.abandon(appCtx)
}
}
@@ -0,0 +1,32 @@
<!-- Banner-Kachel fuer den Fire-TV-/Android-TV-Launcher (320x180 dp).
TV-Launcher zeigen Banner statt Icons — ohne das fehlt die Kachel. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<path
android:fillColor="#0D0D1A"
android:pathData="M0,0h320v180h-320z" />
<!-- Ring (ARIA-Auge) -->
<path
android:fillColor="#3FFF9F"
android:pathData="M74,90 m-34,0 a34,34 0 1,0 68,0 a34,34 0 1,0 -68,0 Z M74,90 m-17,0 a17,17 0 1,1 34,0 a17,17 0 1,1 -34,0 Z" />
<!-- Schriftzug "ARIA" als Pfade (keine Font-Abhaengigkeit im Vector) -->
<path
android:fillColor="#E0E0F0"
android:pathData="M140,110 L152,64 L162,64 L174,110 L164,110 L161,99 L153,99 L150,110 Z M155,91 L159,91 L157,82 Z" />
<path
android:fillColor="#E0E0F0"
android:pathData="M180,110 L180,64 L196,64 A13,13 0 0,1 196,90 L200,110 L190,110 L187,92 L189,92 L189,110 Z M189,72 L189,84 L195,84 A6,6 0 0,0 195,72 Z" />
<path
android:fillColor="#E0E0F0"
android:pathData="M208,64 L217,64 L217,110 L208,110 Z" />
<path
android:fillColor="#E0E0F0"
android:pathData="M224,110 L236,64 L246,64 L258,110 L248,110 L245,99 L237,99 L234,110 Z M239,91 L243,91 L241,82 Z" />
<!-- Untertitel-Balken -->
<path
android:fillColor="#3FFF9F"
android:pathData="M140,122 h118 v4 h-118 z" />
</vector>
@@ -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,181 @@
<?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:focusable="true"
android:focusableInTouchMode="false"
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 &amp; Verbinden" />
<Button
android:id="@+id/btnStop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Agent stoppen"
android:paddingTop="8dp" />
<Button
android:id="@+id/btnTalk"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:text="🎤 Mit ARIA sprechen"
android:paddingTop="16dp" />
<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" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Antworten (Fernseher)"
android:textStyle="bold"
android:paddingTop="24dp"
android:paddingBottom="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Wie ARIA hier standardmäßig antwortet. Sie kann das pro Anfrage überschreiben (z.B. mit dem Zusatz: bitte als Sprache)."
android:textSize="13sp"
android:paddingBottom="8dp" />
<RadioGroup
android:id="@+id/replyMode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<RadioButton
android:id="@+id/modeOverlay"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:text="Einblendung über dem Bild (Standard)" />
<RadioButton
android:id="@+id/modeSpeech"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:text="Sprachausgabe über die Lautsprecher" />
</RadioGroup>
<Button
android:id="@+id/btnOverlayPerm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Einblendung erlauben"
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" />
+5
View File
@@ -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
}
+12
View File
@@ -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"
+5
View File
@@ -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
+16
View File
@@ -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")
+28
View File
@@ -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
+17
View File
@@ -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"
+281 -19
View File
@@ -31,11 +31,28 @@ import re
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path
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(
level=logging.INFO,
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.
Bereits gesetzte Werte gewinnen. Kein python-dotenv noetig."""
here = os.path.dirname(os.path.abspath(__file__))
for path in (os.path.join(here, ".env"), os.path.join(os.getcwd(), ".env")):
# PyInstaller-onefile: __file__ liegt im Temp-Extract-Dir, NICHT beim .exe/
# Binary — deshalb zusaetzlich sys.executable-Ordner (echter Binary-Ort) und
# das CWD (z.B. der ProgramData-Ordner, den der Windows-Dienst als AppDir nutzt).
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
seen = set()
candidates = [os.path.join(d, ".env") for d in (exe_dir, here, os.getcwd())]
for path in [p for p in candidates if not (p in seen or seen.add(p))]:
if not os.path.isfile(path):
continue
try:
@@ -138,8 +161,41 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
# Datei-Transfer-Limit (Base64 durchs RVS).
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
# Version (wird von release_agent.sh beim Release gesetzt).
AGENT_VERSION = "0.0.1.2"
HEARTBEAT_SEC = 25
def _gui_input_method() -> str:
"""Welche Methode zur GUI-Steuerung (Maus/Tastatur) ist verfuegbar?
'' = keine (z.B. Linux-Server ohne X/Wayland -> nur Terminal/exec).
windows -> PowerShell (Bordmittel)
macos -> osascript (Text/Tasten) + cliclick (Maus, falls installiert)
xdotool -> Linux/X11
ydotool -> Linux/Wayland (Daemon noetig)"""
if IS_WINDOWS:
return "windows"
if IS_MAC:
return "macos"
if os.environ.get("DISPLAY") and shutil.which("xdotool"):
return "xdotool"
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("ydotool"):
return "ydotool"
return ""
GUI_METHOD = _gui_input_method()
# macOS: Maus braucht cliclick; Text/Tasten/App gehen per osascript (Bordmittel).
_MAC_MOUSE = IS_MAC and shutil.which("cliclick") is not None
CAPS = ["exec", "read", "write", "info", "screenshot"]
if GUI_METHOD:
# Tastatur/Text/App gehen bei jeder GUI-Methode; Maus (tap/swipe) auf dem Mac
# nur mit cliclick.
CAPS += ["ui_text", "ui_key", "app_launch"]
if GUI_METHOD != "macos" or _MAC_MOUSE:
CAPS += ["ui_tap", "ui_swipe"]
# ─── Text-/Zahl-Helfer ──────────────────────────────────────────────
@@ -180,8 +236,18 @@ def _window_text(text: str, params: dict) -> dict:
# ─── Aktionen ───────────────────────────────────────────────────────
def _wrap_sudo(cmd: str, use_sudo: bool):
"""Gibt (argv, stdin_data) oder (None, fehlertext) wenn sudo nicht moeglich."""
if not use_sudo or os.geteuid() == 0:
"""Baut die Argv (OS-abhaengige Shell) + optional Root/Admin-Rechte.
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
if SUDO_PASSWORD:
return ["sudo", "-S", "-p", "", "bash", "-lc", cmd], SUDO_PASSWORD + "\n"
@@ -264,9 +330,12 @@ def _do_write(params: dict) -> dict:
def _do_info(params: dict) -> dict:
info = {
"host": HOST_NAME, "hostname": socket.gethostname(),
"agent_version": AGENT_VERSION,
"os": platform.platform(), "kernel": platform.release(),
"arch": platform.machine(), "python": platform.python_version(),
"user": os.environ.get("USER") or "", "is_root": os.geteuid() == 0,
"user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
"is_root": _is_admin(),
"gui": GUI_METHOD or "none", # '' => nur Terminal, keine GUI-Steuerung
}
try:
import psutil
@@ -306,24 +375,35 @@ def _do_info(params: dict) -> dict:
def _do_screenshot(params: dict) -> dict:
"""Bildschirmfoto via System-Tool (Wayland: grim; X11: scrot/maim/import/
gnome-screenshot). Braucht eine aktive grafische Session (DISPLAY/WAYLAND)."""
"""Bildschirmfoto — OS-abhaengig. Windows: PowerShell/System.Drawing;
macOS: screencapture; Linux: grim (Wayland) / scrot/maim/import (X11).
Braucht eine aktive grafische Session."""
import tempfile
tmp = os.path.join(tempfile.gettempdir(), f"aria_shot_{int(time.time())}.png")
wayland = bool(os.environ.get("WAYLAND_DISPLAY"))
candidates = []
if wayland and shutil.which("grim"):
candidates.append(["grim", tmp])
for tool, argv in (("scrot", ["scrot", "-o", tmp]),
("maim", ["maim", tmp]),
("gnome-screenshot", ["gnome-screenshot", "-f", tmp]),
("import", ["import", "-window", "root", tmp])):
if shutil.which(tool):
candidates.append(argv)
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])
for tool, argv in (("scrot", ["scrot", "-o", tmp]),
("maim", ["maim", tmp]),
("gnome-screenshot", ["gnome-screenshot", "-f", tmp]),
("import", ["import", "-window", "root", tmp])):
if shutil.which(tool):
candidates.append(argv)
if not candidates:
return {"ok": False, "error":
"Kein Screenshot-Tool gefunden. Installiere grim (Wayland) oder "
"scrot/maim (X11)."}
"Kein Screenshot-Tool gefunden. Linux: grim (Wayland) oder "
"scrot/maim (X11) installieren. (Windows/macOS nutzen Bordmittel.)"}
last_err = ""
for argv in candidates:
try:
@@ -341,9 +421,191 @@ def _do_screenshot(params: dict) -> dict:
"Laeuft der Agent in derselben grafischen Session?"}
# ─── GUI-Steuerung (Maus/Tastatur/App) — nur mit grafischer Session ──
def _gui_unavailable() -> dict:
return {"ok": False, "error":
"Keine grafische Session zum Steuern. Linux: X11 (DISPLAY + xdotool) "
"oder Wayland (WAYLAND_DISPLAY + ydotool-Daemon). macOS: cliclick fuer "
"Maus. Auf einem reinen Terminal-/Server-System gibt es keine GUI."}
def _run_gui(argv: list, desc: str) -> dict:
try:
r = subprocess.run(argv, capture_output=True, text=True, timeout=15)
if r.returncode == 0:
return {"ok": True, "result": {"message": f"{desc} ausgefuehrt"}}
return {"ok": False, "error": f"{desc} fehlgeschlagen: "
+ (r.stderr or r.stdout or f"exit {r.returncode}").strip()[:200]}
except Exception as exc:
return {"ok": False, "error": f"{desc}: {exc}"}
def _ps(script: str, desc: str) -> dict:
return _run_gui(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], desc)
def _osa(script: str, desc: str) -> dict:
return _run_gui(["osascript", "-e", script], desc)
_WIN_MOUSE_DECL = (
"Add-Type -Name U -Namespace W -MemberDefinition '"
"[DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int x,int y);"
"[DllImport(\"user32.dll\")] public static extern void mouse_event("
"uint f,uint x,uint y,uint d,int e);';"
)
def _do_ui_tap(params: dict) -> dict:
if not GUI_METHOD:
return _gui_unavailable()
x = _to_int(params.get("x")); y = _to_int(params.get("y"))
if x is None or y is None:
return {"ok": False, "error": "ui_tap braucht x,y (Bildschirm-Pixel aus dem Screenshot)."}
button = str(params.get("button", "left")).lower()
double = bool(params.get("double"))
desc = f"Klick ({x},{y})"
if GUI_METHOD == "xdotool":
btn = {"left": "1", "middle": "2", "right": "3"}.get(button, "1")
argv = ["xdotool", "mousemove", "--sync", str(x), str(y), "click"]
if double:
argv += ["--repeat", "2", "--delay", "120"]
return _run_gui(argv + [btn], desc)
if GUI_METHOD == "ydotool":
code = {"left": "0xC0", "right": "0xC1", "middle": "0xC2"}.get(button, "0xC0")
_run_gui(["ydotool", "mousemove", "-a", str(x), str(y)], "move")
r = _run_gui(["ydotool", "click", code], desc)
if double and r.get("ok"):
_run_gui(["ydotool", "click", code], desc)
return r
if GUI_METHOD == "macos":
if not _MAC_MOUSE:
return {"ok": False, "error": "Maus-Steuerung braucht cliclick (brew install cliclick)."}
cmd = ("dc:" if double else ("rc:" if button == "right" else "c:")) + f"{x},{y}"
return _run_gui(["cliclick", cmd], desc)
if GUI_METHOD == "windows":
down, up = ("0x0002", "0x0004") if button != "right" else ("0x0008", "0x0010")
click = f"[W.U]::mouse_event({down},0,0,0,0);[W.U]::mouse_event({up},0,0,0,0);"
script = _WIN_MOUSE_DECL + f"[W.U]::SetCursorPos({x},{y});" + click + (click if double else "")
return _ps(script, desc)
return _gui_unavailable()
def _do_ui_text(params: dict) -> dict:
if not GUI_METHOD:
return _gui_unavailable()
text = str(params.get("text", ""))
if not text:
return {"ok": False, "error": "ui_text braucht 'text'."}
desc = f"Text ({len(text)} Zeichen)"
if GUI_METHOD == "xdotool":
return _run_gui(["xdotool", "type", "--clearmodifiers", "--", text], desc)
if GUI_METHOD == "ydotool":
return _run_gui(["ydotool", "type", "--", text], desc)
if GUI_METHOD == "macos":
esc = text.replace("\\", "\\\\").replace('"', '\\"')
return _osa(f'tell application "System Events" to keystroke "{esc}"', desc)
if GUI_METHOD == "windows":
safe = re.sub(r"([+^%~(){}\[\]])", r"{\1}", text).replace('"', '`"')
return _ps("Add-Type -AssemblyName System.Windows.Forms;"
f"[System.Windows.Forms.SendKeys]::SendWait(\"{safe}\")", desc)
return _gui_unavailable()
def _do_ui_key(params: dict) -> dict:
if not GUI_METHOD:
return _gui_unavailable()
key = str(params.get("key", "")).strip()
if not key:
return {"ok": False, "error": "ui_key braucht 'key' (z.B. Return, Escape, ctrl+c)."}
desc = f"Taste '{key}'"
if GUI_METHOD == "xdotool":
return _run_gui(["xdotool", "key", "--clearmodifiers", key], desc)
if GUI_METHOD == "ydotool":
return _run_gui(["ydotool", "key", key], desc) # best effort (Keycodes)
if GUI_METHOD == "macos":
codes = {"return": 36, "enter": 36, "tab": 48, "space": 49, "delete": 51,
"escape": 53, "esc": 53, "left": 123, "right": 124, "down": 125, "up": 126,
"home": 115, "end": 119, "pageup": 116, "pagedown": 121}
k = key.lower()
if k in codes:
return _osa(f'tell application "System Events" to key code {codes[k]}', desc)
return _osa(f'tell application "System Events" to keystroke "{key}"', desc)
if GUI_METHOD == "windows":
m = {"return": "{ENTER}", "enter": "{ENTER}", "escape": "{ESC}", "esc": "{ESC}",
"tab": "{TAB}", "backspace": "{BACKSPACE}", "delete": "{DEL}",
"up": "{UP}", "down": "{DOWN}", "left": "{LEFT}", "right": "{RIGHT}",
"home": "{HOME}", "end": "{END}", "pageup": "{PGUP}", "pagedown": "{PGDN}"}
send = m.get(key.lower(), key)
return _ps("Add-Type -AssemblyName System.Windows.Forms;"
f"[System.Windows.Forms.SendKeys]::SendWait('{send}')", desc)
return _gui_unavailable()
def _do_ui_swipe(params: dict) -> dict:
"""Auf dem Desktop = Maus ziehen von (x1,y1) nach (x2,y2)."""
if not GUI_METHOD:
return _gui_unavailable()
x1 = _to_int(params.get("x1")); y1 = _to_int(params.get("y1"))
x2 = _to_int(params.get("x2")); y2 = _to_int(params.get("y2"))
if None in (x1, y1, x2, y2):
return {"ok": False, "error": "ui_swipe braucht x1,y1,x2,y2."}
desc = f"Ziehen ({x1},{y1} -> {x2},{y2})"
if GUI_METHOD == "xdotool":
return _run_gui(["xdotool", "mousemove", "--sync", str(x1), str(y1),
"mousedown", "1", "mousemove", "--sync", str(x2), str(y2),
"mouseup", "1"], desc)
if GUI_METHOD == "ydotool":
_run_gui(["ydotool", "mousemove", "-a", str(x1), str(y1)], "move")
_run_gui(["ydotool", "click", "0x40"], "down")
_run_gui(["ydotool", "mousemove", "-a", str(x2), str(y2)], "move")
return _run_gui(["ydotool", "click", "0x80"], desc)
if GUI_METHOD == "macos":
if not _MAC_MOUSE:
return {"ok": False, "error": "Ziehen braucht cliclick."}
return _run_gui(["cliclick", f"dd:{x1},{y1}", f"du:{x2},{y2}"], desc)
if GUI_METHOD == "windows":
script = (_WIN_MOUSE_DECL +
f"[W.U]::SetCursorPos({x1},{y1});[W.U]::mouse_event(0x0002,0,0,0,0);"
f"Start-Sleep -Milliseconds 80;[W.U]::SetCursorPos({x2},{y2});"
"[W.U]::mouse_event(0x0004,0,0,0,0);")
return _ps(script, desc)
return _gui_unavailable()
def _do_app_launch(params: dict) -> dict:
"""Startet eine App/ein Programm (nicht-blockierend). 'app' = Name/Pfad,
'command' = beliebiger Startbefehl."""
app = str(params.get("app") or params.get("query") or "").strip()
command = str(params.get("command") or "").strip()
if not app and not command:
return {"ok": False, "error": "app_launch braucht 'app' (Name) oder 'command'."}
try:
if command:
argv = command if IS_WINDOWS else ["/bin/sh", "-c", command]
subprocess.Popen(argv, shell=bool(IS_WINDOWS),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return {"ok": True, "result": {"message": f"Befehl gestartet: {command}"}}
if IS_MAC:
subprocess.Popen(["open", "-a", app])
elif IS_WINDOWS:
subprocess.Popen(["cmd", "/c", "start", "", app])
else:
launcher = shutil.which(app) or app
head = ["setsid"] if shutil.which("setsid") else []
subprocess.Popen(head + [launcher],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return {"ok": True, "result": {"message": f"App gestartet: {app}"}}
except Exception as exc:
return {"ok": False, "error": f"Start fehlgeschlagen: {exc}"}
ACTIONS = {
"exec": _do_exec, "read": _do_read, "write": _do_write,
"info": _do_info, "screenshot": _do_screenshot,
"ui_tap": _do_ui_tap, "ui_text": _do_ui_text, "ui_key": _do_ui_key,
"ui_swipe": _do_ui_swipe, "app_launch": _do_app_launch,
}
@@ -375,7 +637,7 @@ class HostAgent:
HOST_ID, HOST_NAME, ",".join(CAPS), CONTROL_ENABLED)
await self._send({"type": "host_hello", "payload": {
"hostId": HOST_ID, "name": HOST_NAME, "os": platform.platform(),
"caps": CAPS, "control": CONTROL_ENABLED,
"version": AGENT_VERSION, "caps": CAPS, "control": CONTROL_ENABLED,
}, "timestamp": int(time.time() * 1000)})
async def _heartbeat(self) -> None:
@@ -466,7 +728,7 @@ class HostAgent:
def main() -> None:
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:
asyncio.run(HostAgent().run())
except KeyboardInterrupt:
+150
View File
@@ -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}"
+83
View File
@@ -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
+43 -1
View File
@@ -18,6 +18,9 @@
*/
import { spawn } from "child_process";
import { EventEmitter } from "events";
import fs from "fs";
import os from "os";
import path from "path";
import { isAssistantMessage, isResultMessage, isContentDelta } from "../types/claude-cli.js";
const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.)
export class ClaudeSubprocess extends EventEmitter {
@@ -26,6 +29,8 @@ export class ClaudeSubprocess extends EventEmitter {
timeoutId = null;
isKilled = false;
_stdinClosed = false;
_systemPromptFile = null;
_systemPromptDir = null;
/**
* Start the Claude CLI subprocess with the given prompt
*/
@@ -51,6 +56,7 @@ export class ClaudeSubprocess extends EventEmitter {
// Handle spawn errors (e.g., claude not found)
this.process.on("error", (err) => {
this.clearTimeout();
this._cleanupSystemPrompt();
if (err.message.includes("ENOENT")) {
reject(new Error("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"));
}
@@ -85,6 +91,7 @@ export class ClaudeSubprocess extends EventEmitter {
this.process.on("close", (code) => {
console.error(`[Subprocess] Process closed with code: ${code}`);
this.clearTimeout();
this._cleanupSystemPrompt();
// Process any remaining buffer
if (this.buffer.trim()) {
this.processBuffer();
@@ -96,6 +103,7 @@ export class ClaudeSubprocess extends EventEmitter {
}
catch (err) {
this.clearTimeout();
this._cleanupSystemPrompt();
reject(err);
}
});
@@ -114,13 +122,47 @@ export class ClaudeSubprocess extends EventEmitter {
"stream-json", // ARIA: User-Messages via stdin (Zwischenruf)
"--model",
options.model, // Model alias (opus/sonnet/haiku)
"--no-session-persistence", "--dangerously-skip-permissions", "--system-prompt", options.systemPrompt, "--safe-mode",
"--no-session-persistence", "--dangerously-skip-permissions", "--safe-mode",
];
// ARIA: System-Prompt ueber eine DATEI statt als Kommandozeilen-Argument.
// Grund: execve() begrenzt argv+envp zusammen auf ARG_MAX — im Container
// nur 128 KiB. Ein gewachsener System-Prompt (gepinnte Erinnerungen,
// Skills, Projektkontext; real gesehen ~77k Zeichen) sprengte das und der
// Start brach mit "spawn E2BIG" ab: der Turn ging verloren, die App bekam
// 502 und die Nachricht blieb unbeantwortet. Ueber die Datei gibt es
// praktisch keine Groessengrenze mehr.
this._systemPromptFile = null;
const sysPrompt = options.systemPrompt || "";
if (sysPrompt) {
try {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aria-sysprompt-"));
const file = path.join(dir, "system-prompt.txt");
fs.writeFileSync(file, sysPrompt, "utf-8");
this._systemPromptFile = file;
this._systemPromptDir = dir;
args.push("--system-prompt-file", file);
}
catch (err) {
// Notfalls wie bisher (kleine Prompts funktionieren so weiterhin).
console.error(`[Subprocess] System-Prompt-Datei fehlgeschlagen (${err.message}) — nutze Argument`);
args.push("--system-prompt", sysPrompt);
}
}
if (options.sessionId) {
args.push("--session-id", options.sessionId);
}
return args;
}
/** Temporaere System-Prompt-Datei wieder entfernen. */
_cleanupSystemPrompt() {
try {
if (this._systemPromptFile)
fs.rmSync(this._systemPromptDir || this._systemPromptFile, { recursive: true, force: true });
}
catch (_) { }
this._systemPromptFile = null;
this._systemPromptDir = null;
}
/**
* Eine User-Message im stream-json-Input-Format an stdin schreiben.
* Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage).
+17
View File
@@ -88,6 +88,20 @@ const ALLOWED_TYPES = new Set([
// host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/
// screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT.
"host_hello", "host_ping", "host_command", "host_result",
// Agent-Historie: Brain schickt pro Host-Aktion ein agent_history-Event (via
// Bridge), Diagnostic fragt die gespeicherte Historie per agent_history_query
// ab, die Bridge antwortet mit agent_history_list. OHNE diese Typen verwirft
// der RVS sie still an der Allow-List und die Historie bleibt leer.
"agent_history", "agent_history_query", "agent_history_list",
// STT-Lease: die App fragt vor einer Aufnahme, WELCHE STT-Instanz sie nehmen
// soll (stt_lease_request -> stt_lease). Fehlten diese Typen hier, lief die
// Anfrage IMMER in den 250ms-Timeout: jede Aufnahme startete verspaetet (erste
// Silben weg) und ohne targetInstance nahmen ALLE STT-Worker dieselbe Session
// an -> doppelte stt_endpoint. chat_ack quittiert getippte Nachrichten; ohne
// ihn liefen sie in der App nach 3 Versuchen auf "fehlgeschlagen", obwohl sie
// laengst verarbeitet waren. stt_transcribe_* ist die Wake-Wort-Bestaetigung.
"stt_lease", "stt_lease_request", "stt_transcribe_blob", "stt_transcribe_result",
"chat_ack",
// 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).
@@ -99,6 +113,9 @@ const ALLOWED_TYPES = new Set([
// OHNE diese Typen verwirft der RVS die Meldungen an der Allow-List (Z. 312),
// und die Box bleibt in der Flotte unsichtbar, obwohl sie sendet.
"worker_hello", "worker_ping", "worker_update", "worker_list",
// OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
// immer aus der .env der Box — er ist ein Docker-Port-Mapping).
"llm_openai_config", "llm_openai_config_result",
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
"node_stats_history_request", "node_stats_history",
"node_stats_reset", "node_stats_reset_done",
+21 -1
View File
@@ -41,7 +41,27 @@ CONTROL_ENABLED=true
# fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login)
# fritzbox.hosts FritzBox: verbundene Geraete (TR-064, braucht Login)
# ssh.exec Kommando per SSH ausfuehren (params: ip, cmd; Auth aus Creds 'ssh')
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec
# adb.install APK auf ein Android-Geraet/Fire TV ausrollen (params: ip, url)
# adb.connect ADB-Verbindung aufbauen (params: ip)
# adb.devices verbundene ADB-Geraete auflisten
# adb.shell Shell-Kommando auf dem Android-Geraet (params: ip, cmd)
# adb.provision Agent FERN einrichten: RVS-Zugang uebergeben, statt ihn mit der
# Fernbedienung abzutippen (params: ip, rvs_host, rvs_token, ...)
# adb.status laeuft/installiert der Agent auf dem Geraet? (params: ip)
# adb.permissions Sonderrechte per ADB erteilen (Bildschirmaufnahme/Overlay/
# Mikro) — spart das Zusammenklicken mit der Fernbedienung
# ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion
# ergaenzt, MUSS sie hier nachgetragen werden — sonst lehnt der Satellit sie ab.
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
# ─── ADB / Android-Rollout (optional) ──────────────────────────────
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren.
# Voraussetzung am Geraet: ADB-Debugging AN (Fire TV: Einstellungen > Mein Fire TV
# > Entwickleroptionen > ADB-Debugging) und den Dialog "USB-Debugging zulassen?"
# einmalig bestaetigen (Haken "Immer erlauben").
ADB_PORT=5555
ADB_TIMEOUT_SEC=180 # install dauert 30-60s, grosszuegig lassen
APK_CACHE_DIR=/data/apk # heruntergeladene APKs (1x laden, N Geraete)
# ─── Credential-Store (optional) ───────────────────────────────────
# Pro Geraet koennen im Diagnostic Zugangsdaten hinterlegt werden (SNMP-Community/
+4 -1
View File
@@ -7,8 +7,11 @@ WORKDIR /app
# net-snmp-CLI (snmpget/snmpwalk) fuer die snmp.*-Aktionen — z.B. Drucker-
# Tintenstaende zuverlaessig aus der Printer-MIB statt HTML zu scrapen.
# adb: Android-Geraete im LAN (v.a. Fire TV Sticks) per Netzwerk-Debugging
# erreichen und APKs ausrollen (adb.*-Aktionen). In Debian bookworm heisst das
# Paket schlicht "adb" (nicht mehr android-tools-adb).
RUN apt-get update \
&& apt-get install -y --no-install-recommends snmp \
&& apt-get install -y --no-install-recommends snmp adb \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
+12
View File
@@ -32,6 +32,18 @@ docker compose logs -f # "Netz: primary_ip=192.168.0.x" + "[scan] N Ger
```
`network_mode: host` (schon gesetzt) gibt hier echtes LAN + Multicast.
> **Fehler `KeyError: 'ContainerConfig'` beim Neubauen?**
> Das ist ein **Bug im alten `docker-compose` 1.29.2** (Python-Version), nicht im
> Satelliten: Beim *Ersetzen* eines bestehenden Containers mit Volume liest es ein
> Feld aus dem Image, das neuere Docker-Versionen nicht mehr schreiben. Zwei Fixes:
> ```bash
> docker compose up -d --build # v2 (mit LEERZEICHEN) — falls vorhanden
> # oder mit der alten Version: alten Container erst weg, dann neu anlegen
> docker-compose rm -sf satellite && docker-compose up -d --build
> ```
> Der `./data`-Ordner (Credentials, ADB-Schlüssel, APK-Cache) bleibt dabei erhalten —
> er ist ein Bind-Mount, kein Container-Volume.
**B) Nativ als Python-Prozess** (für Mac/Windows-Test oder ohne Docker) — läuft direkt
auf einer Maschine im Ziel-LAN. Das Script lädt die `.env` **selbst** (muss im
`satellite/`-Ordner liegen):
+6
View File
@@ -21,6 +21,12 @@ services:
network_mode: host
env_file: .env
restart: unless-stopped
environment:
# ADB-Schluessel in /data persistieren: sonst erzeugt adb nach jedem
# Rebuild einen NEUEN Schluessel und das Fire TV fragt erneut
# "USB-Debugging zulassen?" (Autorisierung geht verloren).
- HOME=/data
- ANDROID_ADB_SERVER_PORT=5038 # 5037 kann auf dem Host belegt sein
volumes:
# Persistenter Credential-Store (verschluesselt) + Schluesseldatei.
- ./data:/data
+540 -5
View File
@@ -134,10 +134,24 @@ CONTROL_ALLOWLIST = [
a.strip() for a in
os.environ.get("CONTROL_ALLOWLIST",
"dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,"
"snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec").split(",")
"snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,"
"adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status").split(",")
if a.strip()
]
# adb.*: Android-Geraete (v.a. Fire TV) im LAN per Netzwerk-Debugging erreichen.
# install dauert lange (APK laden + uebertragen + installieren) -> grosszuegiger
# Timeout. APK-Cache in /data (persistentes Volume), damit N Sticks nur EINEN
# Download brauchen.
ADB_PORT = int(os.environ.get("ADB_PORT", "5555") or "5555")
ADB_TIMEOUT_SEC = float(os.environ.get("ADB_TIMEOUT_SEC", "180") or "180")
# Beim ERSTEN Verbinden zeigt das Geraet "USB-Debugging zulassen?" auf dem
# Bildschirm. Statt mit Fehler abzubrechen (und den Nutzer nochmal klicken zu
# lassen) wartet der Satellit so lange auf die Bestaetigung.
ADB_AUTH_WAIT_SEC = float(os.environ.get("ADB_AUTH_WAIT_SEC", "90") or "90")
APK_CACHE_DIR = os.environ.get("APK_CACHE_DIR", "/data/apk")
APK_MAX_BYTES = int(os.environ.get("APK_MAX_BYTES", str(64 * 1024 * 1024)) or str(64 * 1024 * 1024))
SCAN_INTERVAL_SEC = int(os.environ.get("SCAN_INTERVAL_SEC", "300") or "300")
DISCOVER_TIMEOUT_SEC = float(os.environ.get("DISCOVER_TIMEOUT_SEC", "6") or "6")
DEVICE_CACHE_TTL_SEC = int(os.environ.get("DEVICE_CACHE_TTL_SEC", "120") or "120")
@@ -269,6 +283,9 @@ MDNS_TYPES = [
"_smb._tcp.local.", # NAS / Fileshares
"_workstation._tcp.local.", # generische Hosts
"_http._tcp.local.", # Web-UIs (Router, NAS, IoT)
"_amzn-wplay._tcp.local.", # Amazon Whisperplay — zuverlaessigster Fire-TV-Marker
"_adb-tls-connect._tcp.local.", # Android 11+ Wireless Debugging
"_androidtvremote2._tcp.local.", # Android TV Remote
]
CAPABILITIES = ["discover"]
@@ -316,7 +333,48 @@ def _net_context() -> dict:
"mDNS/SSDP erreichen die realen Geraete so nicht. Auf Docker Desktop "
"(Mac/Windows) geht LAN-Discovery nicht — den Satelliten NATIV (python "
"satellite.py) oder auf einem Linux-Host im Ziel-LAN betreiben.")
return {"primary_ip": p, "ips": ips, "warning": warning}
return {"primary_ip": p, "ips": ips, "mac": _own_mac(p), "warning": warning}
def _own_mac(ip: str) -> str:
"""MAC der eigenen (primaeren) Netzwerkkarte — identifiziert die Kiste
eindeutig (DHCP-Reservierung, WOL). Liest /sys/class/net (Linux); faellt auf
uuid.getnode() zurueck, wenn das Interface nicht zuzuordnen ist."""
import subprocess
try:
base = "/sys/class/net"
if os.path.isdir(base):
# Interface zur primaeren IP finden (ueber die Adressen im /proc-Netz).
want = ""
if ip:
try:
out = subprocess.run(["ip", "-o", "-4", "addr", "show"],
capture_output=True, text=True, timeout=3).stdout
for line in out.splitlines():
parts = line.split()
if len(parts) >= 4 and parts[3].split("/")[0] == ip:
want = parts[1]
break
except Exception:
pass
names = [want] if want else []
names += [n for n in sorted(os.listdir(base)) if n != "lo" and n not in names]
for n in names:
try:
with open(os.path.join(base, n, "address"), "r") as f:
mac = f.read().strip()
if mac and mac != "00:00:00:00:00:00":
return mac.lower()
except Exception:
continue
except Exception:
pass
try:
import uuid
node = uuid.getnode()
return ":".join(f"{(node >> e) & 0xFF:02x}" for e in range(40, -8, -8))
except Exception:
return ""
NET = _net_context()
@@ -404,6 +462,8 @@ def _mdns_kind(service_type: str) -> str:
"_spotify-connect": "spotify", "_sonos": "sonos", "_hap": "homekit",
"_printer": "printer", "_ipp": "printer", "_smb": "fileshare",
"_workstation": "host", "_http": "web",
"_amzn-wplay": "firetv", "_adb-tls-connect": "android-adb",
"_androidtvremote2": "androidtv",
}
for k, v in m.items():
if service_type.startswith(k):
@@ -565,6 +625,20 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
return await loop.run_in_executor(None, _do_fritzbox, action, params)
if action == "ssh.exec":
return await loop.run_in_executor(None, _do_ssh, params)
if action == "adb.devices":
return await loop.run_in_executor(None, _do_adb_devices, params)
if action == "adb.connect":
return await loop.run_in_executor(None, _do_adb_connect, params)
if action == "adb.install":
return await loop.run_in_executor(None, _do_adb_install, params)
if action == "adb.shell":
return await loop.run_in_executor(None, _do_adb_shell, params)
if action == "adb.provision":
return await loop.run_in_executor(None, _do_adb_provision, params)
if action == "adb.status":
return await loop.run_in_executor(None, _do_adb_status, params)
if action == "adb.permissions":
return await loop.run_in_executor(None, _do_adb_permissions, params)
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
except Exception as exc:
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
@@ -1046,6 +1120,429 @@ def _do_ssh(params: dict) -> dict:
"stderr": err[:4000], **win}}
# ─── ADB: Android-Geraete im LAN (Fire TV Sticks) ───────────────────
def _adb_run(args: list, timeout: float = 0) -> tuple:
"""Fuehrt adb aus. Gibt (rc, stdout, stderr). Muster wie _snmp_run."""
import subprocess
try:
r = subprocess.run(["adb"] + args, capture_output=True, text=True,
timeout=timeout or ADB_TIMEOUT_SEC)
return r.returncode, (r.stdout or "").strip(), (r.stderr or "").strip()
except FileNotFoundError:
return 127, "", ("adb fehlt im Container — 'adb' ins satellite/Dockerfile "
"aufnehmen und neu bauen.")
except subprocess.TimeoutExpired:
return 124, "", f"adb-Timeout nach {timeout or ADB_TIMEOUT_SEC:.0f}s."
def _adb_target(params: dict) -> str:
"""Ziel als ip:port (Port default 5555)."""
host = _snmp_target(params)
if not host:
return ""
return host if ":" in host else f"{host}:{int(params.get('port') or ADB_PORT)}"
def _adb_connect(target: str) -> tuple:
"""Verbindet zum Geraet. Gibt (ok, meldung)."""
rc, out, err = _adb_run(["connect", target], timeout=25)
txt = f"{out} {err}".strip()
low = txt.lower()
if "connected to" in low: # connected / already connected
return True, txt
if "unauthorized" in low:
return False, ("Geraet hat die Verbindung nicht autorisiert. Auf dem "
"Bildschirm des Geraets den Dialog 'USB-Debugging zulassen?' "
"bestaetigen (Haken bei 'Immer erlauben').")
if rc == 127 or "adb fehlt" in low:
return False, txt
return False, (txt or "Verbindung fehlgeschlagen") + \
" — ADB-Debugging aktiv? (Fire TV: Einstellungen > Mein Fire TV > " \
"Entwickleroptionen > ADB-Debugging)"
def _adb_state(target: str) -> str:
"""'device' | 'unauthorized' | 'offline' | '' (unbekannt)."""
rc, out, _ = _adb_run(["devices"], timeout=20)
for line in out.splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0] == target:
return parts[1]
return ""
def _adb_wait_authorized(target: str, seconds: float) -> tuple:
"""Wartet, bis das Geraet autorisiert ist ('device').
Beim ersten Kontakt erscheint auf dem Fernseher/Handy der Dialog
"USB-Debugging zulassen?". Frueher brach die Installation hier ab und man
musste nach dem Bestaetigen ein zweites Mal starten — jetzt wartet der
Satellit einfach, bis der Nutzer bestaetigt hat. Gibt (ok, zustand).
"""
import time as _time
deadline = _time.time() + max(0.0, seconds)
last = _adb_state(target)
while _time.time() < deadline:
if last == "device":
return True, last
_time.sleep(2.0)
# Erneut verbinden: nach dem Bestaetigen wechselt der Zustand oft erst
# mit einem frischen connect von 'unauthorized'/'offline' auf 'device'.
_adb_run(["connect", target], timeout=15)
last = _adb_state(target)
return last == "device", last
def _do_adb_devices(params: dict) -> dict:
"""Listet die dem Satelliten bekannten ADB-Geraete."""
rc, out, err = _adb_run(["devices", "-l"], timeout=20)
if rc not in (0,):
return {"ok": False, "error": err or "adb devices fehlgeschlagen"}
devices = []
for line in out.splitlines()[1:]:
parts = line.split()
if len(parts) >= 2:
devices.append({"target": parts[0], "state": parts[1],
"info": " ".join(parts[2:])})
return {"ok": True, "result": {"devices": devices, "count": len(devices)}}
def _do_adb_connect(params: dict) -> dict:
target = _adb_target(params)
if not target:
return {"ok": False, "error": "ip/host erforderlich."}
ok, msg = _adb_connect(target)
state = _adb_state(target)
wait = float(params.get("auth_wait") or ADB_AUTH_WAIT_SEC)
if (not ok or state != "device") and wait > 0:
authed, state = _adb_wait_authorized(target, wait)
if not authed:
return {"ok": False, "error":
"Nicht autorisiert — auf dem Bildschirm des Geraets "
"'USB-Debugging zulassen?' bestaetigen. "
f"(gewartet: {wait:.0f}s, Zustand: '{state or '?'}')"}
msg = f"verbunden (nach Bestaetigung am Geraet)"
elif not ok:
return {"ok": False, "error": msg}
return {"ok": True, "result": {"target": target, "state": state, "message": msg}}
AGENT_PACKAGE = os.environ.get("AGENT_PACKAGE", "de.hackersoft.ariaagent")
def _adb_app_installed(target: str, package: str) -> bool:
rc, out, _ = _adb_run(["-s", target, "shell", "pm", "list", "packages", package],
timeout=30)
return rc == 0 and f"package:{package}" in out
def _adb_app_running(target: str, package: str) -> bool:
"""Laeuft der Vordergrund-Dienst des Agents? (Fire TV / Android)."""
rc, out, _ = _adb_run(["-s", target, "shell", "dumpsys", "activity", "services",
package], timeout=30)
return rc == 0 and "ServiceRecord" in out
def _do_adb_status(params: dict) -> dict:
"""Ist der ARIA-Agent auf dem Geraet installiert und laeuft er?"""
target = _adb_target(params)
if not target:
return {"ok": False, "error": "ip/host erforderlich."}
package = (params.get("package") or AGENT_PACKAGE).strip()
ok, msg = _adb_connect(target)
if not ok:
return {"ok": False, "error": msg}
installed = _adb_app_installed(target, package)
running = _adb_app_running(target, package) if installed else False
ver = ""
if installed:
rc, out, _ = _adb_run(["-s", target, "shell", "dumpsys", "package", package],
timeout=30)
m = re.search(r"versionName=(\S+)", out or "")
ver = m.group(1) if m else ""
return {"ok": True, "result": {
"target": target, "package": package, "installed": installed,
"running": running, "version": ver,
"message": ("Agent laeuft" if running else
"installiert, laeuft aber nicht" if installed else
"nicht installiert")}}
def _adb_grant_permissions(target: str, package: str) -> dict:
"""Erteilt dem Agenten die Sonderrechte per ADB — das spart das Antippen mit
der Fernbedienung (auf einem Fire TV besonders muehsam):
PROJECT_MEDIA Bildschirmaufnahme ohne Nachfrage-Dialog (Screenshots)
SYSTEM_ALERT_WINDOW Einblendung ueber dem laufenden Bild (Overlay)
RECORD_AUDIO Mikrofon (Sprachdialog)
Best effort: was nicht geht, wird gemeldet, bricht aber nichts ab."""
granted, failed = [], []
ops = [("appops", ["appops", "set", package, "PROJECT_MEDIA", "allow"], "Bildschirmaufnahme"),
("appops", ["appops", "set", package, "SYSTEM_ALERT_WINDOW", "allow"], "Einblendung")]
perms = [("android.permission.RECORD_AUDIO", "Mikrofon")]
for _, cmd, label in ops:
rc, out, err = _adb_run(["-s", target, "shell"] + cmd, timeout=30)
(granted if rc == 0 and "error" not in f"{out}{err}".lower() else failed).append(label)
for perm, label in perms:
rc, out, err = _adb_run(["-s", target, "shell", "pm", "grant", package, perm], timeout=30)
(granted if rc == 0 and "error" not in f"{out}{err}".lower() else failed).append(label)
return {"granted": granted, "failed": failed}
def _do_adb_permissions(params: dict) -> dict:
"""Nur die Berechtigungen setzen (ohne Neuinstallation/Einrichtung)."""
target = _adb_target(params)
if not target:
return {"ok": False, "error": "ip/host erforderlich."}
package = (params.get("package") or AGENT_PACKAGE).strip()
ok, msg = _adb_connect(target)
if not ok:
return {"ok": False, "error": msg}
r = _adb_grant_permissions(target, package)
msg = "erteilt: " + (", ".join(r["granted"]) or "—")
if r["failed"]:
msg += " · nicht moeglich: " + ", ".join(r["failed"])
return {"ok": True, "result": {"target": target, "message": msg, **r}}
def _do_adb_provision(params: dict) -> dict:
"""Richtet den ARIA-Agent auf dem Geraet FERN ein (RVS-Zugang uebergeben).
Auf einem Fire TV gibt es keine Kamera (kein QR-Scan) und Tippen mit der
Fernbedienung ist muehsam. Der Satellit startet die App deshalb per ADB
direkt mit den Zugangsdaten als Intent-Extras.
params: {ip, rvs_host, rvs_token, rvs_port?, rvs_tls?, name?, control?,
reply_mode?, package?}
"""
target = _adb_target(params)
if not target:
return {"ok": False, "error": "ip/host erforderlich."}
rvs_host = (params.get("rvs_host") or "").strip()
rvs_token = (params.get("rvs_token") or "").strip()
if not rvs_host or not rvs_token:
return {"ok": False, "error": "rvs_host und rvs_token erforderlich."}
package = (params.get("package") or AGENT_PACKAGE).strip()
ok, msg = _adb_connect(target)
state = _adb_state(target)
wait = float(params.get("auth_wait") or ADB_AUTH_WAIT_SEC)
if (not ok or state != "device") and wait > 0:
authed, state = _adb_wait_authorized(target, wait)
if not authed:
return {"ok": False, "error":
"Nicht autorisiert — 'USB-Debugging zulassen?' auf dem "
f"Bildschirm bestaetigen. (Zustand: '{state or '?'}')"}
elif not ok:
return {"ok": False, "error": msg}
if not _adb_app_installed(target, package):
return {"ok": False, "error": f"'{package}' ist nicht installiert — "
"erst adb.install ausfuehren."}
# Sonderrechte gleich mit erteilen (Bildschirm/Overlay/Mikro) — sonst muesste
# man sie am Fernseher mit der Fernbedienung zusammenklicken.
perms = ({"granted": [], "failed": []} if params.get("skip_permissions")
else _adb_grant_permissions(target, package))
# WICHTIG: 'adb shell' haengt die Argumente zu EINER Kommandozeile zusammen,
# die auf dem Geraet nochmal von der Shell zerlegt wird. Werte mit Leerzeichen
# ("Wohnzimmer Fire TV") wuerden dabei abgeschnitten -> jeden Wert quoten.
import shlex
q = shlex.quote
# Der Intent MUSS eindeutig sein: Android vergleicht beim Start Komponente,
# Action und Daten — NICHT die Extras. Ohne eigene Daten-URI gilt ein zweites
# Provisioning als "gleicher Intent", die Activity wird nur nach vorn geholt
# und onCreate/onNewIntent laufen nie -> der alte Name bliebe stehen.
stamp = str(int(time.time() * 1000))
args = ["-s", target, "shell", "am", "start",
"-n", f"{package}/.MainActivity",
"-a", f"{package}.PROVISION",
"-d", q(f"aria://provision/{stamp}"),
"-e", "host", q(rvs_host),
"-e", "token", q(rvs_token),
"--ei", "port", str(int(params.get("rvs_port") or 443)),
"--ez", "tls", "true" if _as_bool(params.get("rvs_tls"), True) else "false",
"--ez", "control", "true" if _as_bool(params.get("control"), True) else "false",
# Nur wenn die Bildschirm-Berechtigung wirklich sitzt: dann darf sich
# die Aufnahme nach jedem Neustart selbst scharf schalten (dialogfrei).
"--ez", "capture", "true" if "Bildschirmaufnahme" in perms.get("granted", []) else "false"]
if params.get("name"):
args += ["-e", "name", q(str(params["name"]))]
if params.get("reply_mode"):
args += ["-e", "reply_mode", q(str(params["reply_mode"]))]
rc, out, err = _adb_run(args, timeout=60)
txt = f"{out} {err}".strip()
if rc != 0 or "Error" in txt:
return {"ok": False, "error": (txt or f"exit {rc}")[:400]}
# Android meldet das, wenn es den Intent als "schon offen" abtut und die
# Activity nur nach vorn holt — dann kaeme die neue Konfiguration NICHT an.
if "brought to the front" in txt.lower() or "not started" in txt.lower():
return {"ok": False, "error":
"Android hat den Einrichtungs-Intent nicht ausgeliefert (Activity "
"war schon offen). Auf dem Geraet die Agent-App einmal schliessen "
"und erneut 'Nur einrichten' druecken. Details: " + txt[:200]}
msg = f"Agent eingerichtet (RVS {rvs_host}) und gestartet"
if perms.get("granted"):
msg += " · Rechte erteilt: " + ", ".join(perms["granted"])
if perms.get("failed"):
msg += " · manuell noetig: " + ", ".join(perms["failed"])
return {"ok": True, "result": {
"target": target, "package": package, "permissions": perms, "message": msg}}
def _as_bool(v, default: bool) -> bool:
if v is None:
return default
if isinstance(v, bool):
return v
return str(v).strip().lower() in ("1", "true", "yes", "on", "ja")
def _do_adb_shell(params: dict) -> dict:
"""Shell-Kommando auf dem Android-Geraet (Diagnose, z.B. 'pm list packages')."""
target = _adb_target(params)
cmd = (params.get("cmd") or params.get("command") or "").strip()
if not target:
return {"ok": False, "error": "ip/host erforderlich."}
if not cmd:
return {"ok": False, "error": "cmd erforderlich."}
ok, msg = _adb_connect(target)
if not ok:
return {"ok": False, "error": msg}
rc, out, err = _adb_run(["-s", target, "shell", cmd], timeout=60)
win = _window_text(out, params)
return {"ok": rc == 0, "result": {"target": target, "exit_code": rc,
"stderr": err[:2000], **win},
**({} if rc == 0 else {"error": err[:500] or f"exit {rc}"})}
def _apk_fetch(params: dict) -> tuple:
"""Besorgt die APK lokal. Gibt (pfad, fehler).
Quelle: 'url' (HTTP-Pull, gecached in APK_CACHE_DIR) ODER 'apk_base64'.
HTTP-Pull ist der Normalweg — der RVS ist ein Broadcast-Relay, eine APK
als base64 ginge an JEDEN Client im Raum."""
import base64
import hashlib
import requests
os.makedirs(APK_CACHE_DIR, exist_ok=True)
url = (params.get("url") or "").strip()
b64 = params.get("apk_base64")
want_sha = (params.get("sha256") or "").strip().lower()
if b64:
try:
data = base64.b64decode(b64)
except Exception as exc:
return "", f"apk_base64 ungueltig: {exc}"
elif url:
digest = hashlib.sha256(url.encode()).hexdigest()[:16]
cached = os.path.join(APK_CACHE_DIR, f"{digest}.apk")
if os.path.isfile(cached) and os.path.getsize(cached) > 0:
return cached, ""
try:
headers = {}
if params.get("token"):
headers["Authorization"] = f"token {params['token']}"
with requests.get(url, stream=True, timeout=60, headers=headers,
verify=not params.get("insecure")) as r:
r.raise_for_status()
total = 0
tmp = cached + ".part"
with open(tmp, "wb") as f:
for chunk in r.iter_content(65536):
total += len(chunk)
if total > APK_MAX_BYTES:
f.close(); os.remove(tmp)
return "", f"APK groesser als {APK_MAX_BYTES // 1024 // 1024} MB."
f.write(chunk)
os.replace(tmp, cached)
except Exception as exc:
return "", f"APK-Download fehlgeschlagen: {exc}"
data = None
else:
return "", "url ODER apk_base64 erforderlich."
if data is not None:
if len(data) > APK_MAX_BYTES:
return "", f"APK groesser als {APK_MAX_BYTES // 1024 // 1024} MB."
path = os.path.join(APK_CACHE_DIR,
hashlib.sha256(data).hexdigest()[:16] + ".apk")
with open(path, "wb") as f:
f.write(data)
cached = path
if want_sha:
h = hashlib.sha256()
with open(cached, "rb") as f:
for blk in iter(lambda: f.read(65536), b""):
h.update(blk)
if h.hexdigest().lower() != want_sha:
return "", "sha256 stimmt nicht — Download verworfen."
return cached, ""
def _do_adb_install(params: dict) -> dict:
"""Rollt eine APK auf ein Android-Geraet aus (Fire TV & Co.).
params: {ip|host|device, url|apk_base64, sha256?, port?, downgrade?, timeout?}
Selbstheilend: verbindet, installiert, gibt den Status zurueck."""
target = _adb_target(params)
if not target:
return {"ok": False, "error": "ip/host erforderlich."}
apk, err = _apk_fetch(params)
if err:
return {"ok": False, "error": err}
ok, msg = _adb_connect(target)
state = _adb_state(target)
# Erstkontakt: Das Geraet zeigt jetzt "USB-Debugging zulassen?" auf dem
# Bildschirm. Statt abzubrechen warten wir auf die Bestaetigung — ein Klick
# im Diagnostic genuegt, kein zweiter Anlauf noetig.
wait = float(params.get("auth_wait") or ADB_AUTH_WAIT_SEC)
if (not ok or state != "device") and wait > 0:
logger.info("[adb] %s: warte bis zu %.0fs auf Autorisierung (Zustand '%s')",
target, wait, state or "?")
authed, state = _adb_wait_authorized(target, wait)
if not authed:
hint = ("Das Geraet hat die Verbindung nicht autorisiert. Auf dem "
"Bildschirm erscheint 'USB-Debugging zulassen?' — bitte "
"bestaetigen (Haken bei 'Immer erlauben'). "
f"Ich habe {wait:.0f}s gewartet, Zustand: '{state or 'nicht erreichbar'}'.")
return {"ok": False, "error": hint if (state or ok) else msg}
elif not ok:
return {"ok": False, "error": msg}
args = ["-s", target, "install", "-r"] # -r = Update statt Neuinstallation
if params.get("downgrade"):
args.append("-d")
args.append(apk)
rc, out, err2 = _adb_run(args, timeout=float(params.get("timeout") or ADB_TIMEOUT_SEC))
txt = f"{out} {err2}".strip()
size_mb = round(os.path.getsize(apk) / 1024 / 1024, 1)
if rc == 0 and "success" in txt.lower():
res = {"target": target, "apk_mb": size_mb,
"message": f"APK installiert ({size_mb} MB)"}
# Direkt einrichten, wenn die RVS-Daten mitkamen — auf einem Fire TV
# spart das der Fernbedienung das Abtippen von Host und Token.
if params.get("rvs_host") and params.get("rvs_token"):
prov = _do_adb_provision(params)
res["provisioned"] = bool(prov.get("ok"))
if prov.get("ok"):
res["message"] += " und eingerichtet — der Agent sollte gleich online sein"
else:
res["message"] += (f". Einrichtung fehlgeschlagen: "
f"{prov.get('error', '?')}")
return {"ok": True, "result": res}
low = txt.lower()
if "signatures do not match" in low or "update_incompatible" in low:
return {"ok": False, "error": "Signatur passt nicht zur installierten Version — "
"die alte App muss zuerst deinstalliert werden (adb.shell "
"'pm uninstall <paket>')."}
if "insufficient" in low or "no space" in low:
return {"ok": False, "error": "Nicht genug Speicher auf dem Geraet."}
return {"ok": False, "error": (txt or f"Installation fehlgeschlagen (exit {rc})")[:500]}
def _to_int(s: str):
try:
return int(str(s).strip())
@@ -1113,6 +1610,13 @@ def _parse_http_headers(text: str) -> dict:
return headers
def _looks_amazon(dev: dict) -> bool:
"""Fire TV? Amazon-Hersteller oder Modell-Praefix AFT (AFTMM, AFTKA, ...)."""
blob = f"{dev.get('manufacturer', '')} {dev.get('name', '')}".lower()
model = str(dev.get("model", "")).upper()
return "amazon" in blob or "fire tv" in blob or model.startswith("AFT")
def _xml_tag(xml: str, tag: str) -> str:
m = re.search(rf"<{tag}>(.*?)</{tag}>", xml, re.IGNORECASE | re.DOTALL)
return m.group(1).strip() if m else ""
@@ -1147,10 +1651,12 @@ class Satellite:
self._devices = _merge_devices(mdns, ssdp, arp)
if SNMP_DISCOVERY:
await self._enrich_snmp(self._devices)
await self._enrich_adb(self._devices)
self._devices_ts = time.time()
n_snmp = sum(1 for d in self._devices if d.get("snmpCapable"))
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d, snmp=%d)",
len(self._devices), len(mdns), len(ssdp), len(arp), n_snmp)
n_adb = sum(1 for d in self._devices if d.get("adbCapable"))
logger.info("[scan] %d Geraete (mdns=%d ssdp=%d arp=%d, snmp=%d, adb=%d)",
len(self._devices), len(mdns), len(ssdp), len(arp), n_snmp, n_adb)
finally:
self._scanning = False
return self._devices
@@ -1181,6 +1687,34 @@ class Satellite:
await asyncio.gather(*(_one(d) for d in devices))
async def _enrich_adb(self, devices: list) -> None:
"""Prueft parallel, welche Geraete ADB-Netzwerk-Debugging offen haben
(TCP 5555). Damit sieht ARIA/die Diagnostic sofort, auf welche Geraete
(v.a. Fire TV Sticks) sich eine APK ausrollen laesst."""
sem = asyncio.Semaphore(16)
async def _one(dev: dict) -> None:
ip = dev.get("ip") or ""
if not ip:
return
async with sem:
try:
fut = asyncio.open_connection(ip, ADB_PORT)
reader, writer = await asyncio.wait_for(fut, timeout=1.5)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
except Exception:
return
dev["adbCapable"] = True
dev["adbPort"] = ADB_PORT
if dev.get("type", "") in ("", "host", "unknown", "dial"):
dev["type"] = "firetv" if _looks_amazon(dev) else "android"
await asyncio.gather(*(_one(d) for d in devices))
async def _send(self, message: dict) -> None:
if self.ws is None:
return
@@ -1381,7 +1915,8 @@ class Satellite:
def main() -> None:
logger.info("ARIA Satellit startet — id=%s location=%s control=%s",
SATELLITE_ID, SATELLITE_LOCATION, CONTROL_ENABLED)
logger.info("Netz: primary_ip=%s alle=%s", NET.get("primary_ip"), NET.get("ips"))
logger.info("Netz: primary_ip=%s mac=%s alle=%s",
NET.get("primary_ip"), NET.get("mac") or "?", NET.get("ips"))
if NET.get("warning"):
logger.warning("⚠ %s", NET["warning"])
try:
+38 -1
View File
@@ -4,7 +4,7 @@
# ════════════════════════════════════════════════
# ─── Welche Dienste startet DIESER Node? ──────────
# Komma-getrennt aus: voxtral, whisper, f5tts, llm
# Komma-getrennt aus: voxtral, whisper, f5tts, llm, openhands
# Nur die aufgefuehrten Dienste starten bei `docker compose up -d`.
# nur STT-Box (Voxtral) → COMPOSE_PROFILES=voxtral
# nur STT-Box (Whisper) → COMPOSE_PROFILES=whisper
@@ -13,6 +13,12 @@
# Kleine Karte (wenig VRAM): Whisper + F5-TTS auf EINER GPU
# → COMPOSE_PROFILES=whisper,f5tts
# All-in-One, grosse Karte → COMPOSE_PROFILES=voxtral,f5tts,llm
# LLM + OpenHands-Oberflaeche → COMPOSE_PROFILES=llm,openhands
# 'openhands' ist REIN OPTIONAL (Standalone-Entwickler-Agent, s.u.). Wer es nicht
# auffuehrt, bei dem wird der Container gar nicht erst angelegt — das Image wird
# nicht einmal geladen. Fuer den normalen ARIA-Betrieb also einfach weglassen.
# ACHTUNG: Profile brauchen compose v2 (`docker compose`, mit Leerzeichen). Das
# alte `docker-compose` 1.x behandelt sie unzuverlaessig.
# Hinweis: voxtral UND whisper zusammen NICHT sinnvoll — beide sind STT und
# wuerden dieselbe Anfrage doppelt beantworten. Pro Node genau EINEN STT waehlen:
# Voxtral-3B (~9 GB, beste Qualitaet) ODER Whisper (klein, passt neben F5-TTS).
@@ -56,3 +62,34 @@ RVS_SNI=
# WHISPER_MODEL=small # tiny|base|small|medium|large-v3 (Hot-Swap via Diagnostic)
# WHISPER_LANGUAGE=de
# LLM_MODEL=qwen3-8b # Key aus llama-swap/config.yaml
# ─── OpenAI-Endpunkt des lokalen LLM (optional) ───
# llama-swap ist OpenAI-kompatibel (/v1/chat/completions, /v1/models). Damit
# koennen Fremd-Tools direkt auf das lokale Modell: OpenHands, Continue,
# Open WebUI, Aider ... Base-URL dann: http://<box>:<LLM_OPENAI_PORT>/v1
#
# LLM_OPENAI_BIND steuert, WER drankommt:
# 127.0.0.1 nur diese Maschine (Default, sicher)
# 0.0.0.0 das ganze LAN → dann UNBEDINGT LLM_OPENAI_KEY setzen!
LLM_OPENAI_BIND=127.0.0.1
LLM_OPENAI_PORT=8081
# API-Key. Leer = Endpunkt ohne Authentifizierung (nur mit BIND=127.0.0.1 ok).
# WICHTIG: Steht hier ein Wert, hat er VORRANG — das Feld ist dann in der ARIA-
# Diagnostic schreibgeschuetzt. Leer lassen, wenn Du den Key lieber dort pro Box
# setzen willst (er wird dann auf der Box unter /models/openai_key.json abgelegt).
# Der Key wirkt, sobald ein Modell das naechste Mal geladen wird.
LLM_OPENAI_KEY=
# ─── OpenHands (optional, eigenes Profil) ─────────
# Standalone-Entwickler-Agent, der das lokale LLM nutzt. Start:
# COMPOSE_PROFILES=llm,openhands docker compose up -d
# Dann http://<box>:<OPENHANDS_PORT> aufrufen.
# ACHTUNG: OpenHands startet eigene Arbeits-Container und bekommt dafuer den
# Docker-Socket — das ist faktisch Root-Zugriff auf diese Maschine. Deshalb
# BIND auf 127.0.0.1 lassen und von aussen per SSH-Tunnel zugreifen:
# ssh -L 3000:127.0.0.1:3000 user@box -> lokal http://localhost:3000
OPENHANDS_BIND=127.0.0.1
OPENHANDS_PORT=3000
# Wie OpenHands das lokale Modell anspricht (Container-intern, daher :8080):
OPENHANDS_LLM_BASE_URL=http://llama-swap:8080/v1
OPENHANDS_LLM_MODEL=openai/qwen3-8b
+80
View File
@@ -70,3 +70,83 @@ nvidia-smi # Host sieht die GPU
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi # Container auch
docker logs -f aria-whisper-bridge # "RVS verbunden" + service_status ready
```
## Lokales LLM von außen nutzen (OpenAI-Endpunkt)
`llama-swap` spricht **OpenAI-kompatibel** (`/v1/chat/completions`, `/v1/models`).
Damit können Fremd-Tools direkt auf das lokale Modell: **OpenHands**, Continue,
Open WebUI, Aider …
```
Base-URL: http://<box>:<LLM_OPENAI_PORT>/v1
Modell: qwen3-8b (oder ein anderer Key aus llama-swap/config.yaml)
API-Key: LLM_OPENAI_KEY (falls gesetzt)
```
In der `.env`:
| Variable | Bedeutung |
|---|---|
| `LLM_OPENAI_BIND` | `127.0.0.1` = nur diese Maschine (**Default, sicher**) · `0.0.0.0` = ganzes LAN |
| `LLM_OPENAI_PORT` | Host-Port (Default `8081`) |
| `LLM_OPENAI_KEY` | API-Key; leer = **ohne Authentifizierung** |
> ⚠️ `LLM_OPENAI_BIND=0.0.0.0` **ohne** `LLM_OPENAI_KEY` heißt: jeder im Netz kann
> das Modell benutzen. Entweder Key setzen oder auf localhost gebunden lassen.
**Wo wird was konfiguriert?**
- **Port und Erreichbarkeit** immer in der `.env`. Beides ist ein Docker-Port-Mapping
und steht beim Container-Start fest — es lässt sich nicht aus der Ferne ändern.
- **API-Key**: entweder in der `.env` **oder** — wenn dort leer — in der ARIA-Diagnostic
unter *Compute-Flotte* bei der jeweiligen Box. Dort abgelegt landet er auf der Box
unter `/models/openai_key.json` (Rechte 0600).
- **Die `.env` hat immer Vorrang.** Steht `LLM_OPENAI_KEY` dort, ist das Feld in der
Diagnostic **schreibgeschützt** (🔒-Hinweis).
- Ein geänderter Key greift, sobald ein Modell das **nächste Mal geladen** wird
(llama-server bekommt ihn per `--api-key`). Laufendes Modell also einmal entladen
lassen (TTL) oder ein anderes Modell anfragen.
Prüfen:
```bash
curl -s http://localhost:8081/v1/models # ohne Key
curl -s -H "Authorization: Bearer $LLM_OPENAI_KEY" \
http://localhost:8081/v1/models # mit Key
```
## OpenHands (optionales Profil)
OpenHands ist **rein optional** und liegt in einem eigenen Profil. Wer es nicht in
`COMPOSE_PROFILES` aufführt, bei dem wird der Container **gar nicht erst angelegt** —
das Image wird nicht einmal geladen. Für den normalen ARIA-Betrieb also einfach weglassen:
```bash
COMPOSE_PROFILES=voxtral,f5tts,llm docker compose up -d # ohne OpenHands
COMPOSE_PROFILES=llm,openhands docker compose up -d # mit OpenHands
```
Dann `http://<box>:<OPENHANDS_PORT>` (Default `3000`) öffnen. Das lokale Modell ist
schon vorbelegt (`OPENHANDS_LLM_BASE_URL`, `OPENHANDS_LLM_MODEL`) und in der
Oberfläche änderbar.
| Variable | Bedeutung |
|---|---|
| `OPENHANDS_BIND` | `127.0.0.1` (Default) oder `0.0.0.0` fürs LAN |
| `OPENHANDS_PORT` | Host-Port (Default `3000`) |
| `OPENHANDS_LLM_BASE_URL` | Default `http://llama-swap:8080/v1` (Container-intern — **nicht** der Host-Port) |
| `OPENHANDS_LLM_MODEL` | Default `openai/qwen3-8b` |
> ⚠️ OpenHands startet eigene Arbeits-Container und bekommt dafür den **Docker-Socket**.
> Das ist faktisch Root-Zugriff auf diese Maschine — wer die Oberfläche erreicht,
> kann im Zweifel alles auf der Box.
>
> **Deshalb bleibt `OPENHANDS_BIND` auf `127.0.0.1`.** Von einem anderen Rechner
> greifst Du per SSH-Tunnel zu, statt den Port ins LAN zu öffnen:
> ```bash
> ssh -L 3000:127.0.0.1:3000 user@box # dann lokal http://localhost:3000
> ```
> Das kostet nichts und ist die wirksamste Absicherung, solange der Socket
> durchgereicht wird. Wer echte Isolation braucht: OpenHands in eine eigene VM,
> oder einen rootless Docker-Daemon als Beiwagen statt des Host-Sockets.
Ohne das Profil ändert sich nichts: Wer nur ARIAs Box fahren will, startet OpenHands gar nicht.
+37
View File
@@ -139,6 +139,13 @@ services:
# Liest die vom llm-adapter generierte Config. Beim allerersten Boot faengt
# restart: unless-stopped die Reihenfolge ab, bis der Adapter sie geschrieben hat.
command: ["--config", "/models/llama-swap.config.yaml", "--listen", "0.0.0.0:8080"]
# OpenAI-kompatibler Endpunkt (/v1/chat/completions, /v1/models) — damit
# koennen Fremd-Tools wie OpenHands, Continue oder Open WebUI direkt auf das
# lokale LLM. Standardmaessig NUR auf localhost gebunden (sicher); fuer
# Zugriff aus dem LAN LLM_OPENAI_BIND=0.0.0.0 setzen — dann aber unbedingt
# LLM_OPENAI_KEY vergeben, sonst kann jeder im Netz das Modell nutzen.
ports:
- "${LLM_OPENAI_BIND:-127.0.0.1}:${LLM_OPENAI_PORT:-8081}:8080"
restart: unless-stopped
# ─── Local-LLM-Adapter — RVS <-> llama.cpp ────
@@ -172,3 +179,33 @@ services:
# Erster Load eines Modells kann ein GGUF ziehen (mehrere GB) — grosszuegig.
- LLM_TIMEOUT_SEC=${LLM_TIMEOUT_SEC:-600}
restart: unless-stopped
# ─── OpenHands (optional, Standalone) ────────────────
# Eigenstaendige Entwickler-Agenten-Oberflaeche, die das LOKALE LLM ueber den
# OpenAI-Endpunkt von llama-swap nutzt. Bewusst ein eigenes Profil: wer nur
# ARIAs Box fahren will, startet es gar nicht erst.
# COMPOSE_PROFILES=llm,openhands docker compose up -d
# Danach http://<box>:${OPENHANDS_PORT:-3000} und dort als Provider
# "OpenAI-kompatibel" mit Base-URL http://llama-swap:8080/v1 eintragen
# (aus dem Container heraus erreichbar), Modell z.B. qwen3-8b.
openhands:
image: docker.all-hands.dev/all-hands-ai/openhands:latest
container_name: aria-openhands
profiles: ["openhands"]
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:latest
- LOG_ALL_EVENTS=true
# Vorbelegung fuer das lokale Modell — in der Oberflaeche aenderbar.
- LLM_BASE_URL=${OPENHANDS_LLM_BASE_URL:-http://llama-swap:8080/v1}
- LLM_API_KEY=${LLM_OPENAI_KEY:-local}
- LLM_MODEL=${OPENHANDS_LLM_MODEL:-openai/qwen3-8b}
volumes:
# OpenHands startet seine Arbeits-Container selbst -> braucht den Socket.
# ACHTUNG: das gibt dem Container Kontrolle ueber den Docker-Host.
- /var/run/docker.sock:/var/run/docker.sock
- ./openhands-state:/.openhands
ports:
- "${OPENHANDS_BIND:-127.0.0.1}:${OPENHANDS_PORT:-3000}:3000"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
+8 -1
View File
@@ -896,7 +896,14 @@ async def run_loop(runner: F5Runner) -> None:
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
# whisper und f5tts im Minutentakt neu starten liess.
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
try:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
+110 -1
View File
@@ -27,6 +27,8 @@ import asyncio
import json
import logging
import os
import re
import shlex
import time
import httpx
@@ -94,6 +96,44 @@ BASE_CONFIG_PATH = os.getenv("LLAMA_BASE_CONFIG", "/llamaswap/config.yaml")
GEN_CONFIG_PATH = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml")
REGISTRY_PATH = os.getenv("LLM_REGISTRY", "/models/aria_models.json")
# ── OpenAI-kompatibler Endpunkt (fuer OpenHands & Co.) ─────
# Der Port ist ein Docker-Port-Mapping und steht beim Container-Start fest —
# er kommt IMMER aus der .env. Der API-Key kann zusaetzlich zur Laufzeit aus der
# Diagnostic gesetzt werden, ABER: steht er in der .env, hat die .env Vorrang
# und das Feld ist in der Diagnostic schreibgeschuetzt.
OPENAI_PORT = os.getenv("LLM_OPENAI_PORT", "8081").strip() or "8081"
OPENAI_BIND = os.getenv("LLM_OPENAI_BIND", "127.0.0.1").strip() or "127.0.0.1"
_OPENAI_KEY_ENV = os.getenv("LLM_OPENAI_KEY", "").strip()
OPENAI_KEY_FILE = os.getenv("LLM_OPENAI_KEY_FILE", "/models/openai_key.json")
def _openai_key() -> str:
"""Gueltiger API-Key: .env gewinnt, sonst der in der Diagnostic gesetzte."""
if _OPENAI_KEY_ENV:
return _OPENAI_KEY_ENV
try:
with open(OPENAI_KEY_FILE) as f:
return str((json.load(f) or {}).get("key") or "").strip()
except Exception:
return ""
def _openai_key_save(key: str) -> bool:
"""Speichert den in der Diagnostic gesetzten Key. Bei .env-Vorrang: nein."""
if _OPENAI_KEY_ENV:
return False
try:
os.makedirs(os.path.dirname(OPENAI_KEY_FILE), exist_ok=True)
tmp = OPENAI_KEY_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump({"key": key.strip()}, f)
os.replace(tmp, OPENAI_KEY_FILE)
os.chmod(OPENAI_KEY_FILE, 0o600)
return True
except Exception as exc:
logger.error("OpenAI-Key speichern fehlgeschlagen: %s", exc)
return False
# ── Auslastungs-Monitor (Stage E) ──────────────────────────
import node_stats
STATS_PATH = os.getenv("STATS_PATH", "/models/aria_stats.json")
@@ -144,6 +184,19 @@ def _generate_config() -> int:
f"-hf {repo}:{quant}\n-ngl {ngl} -c {ctx} --jinja"),
"ttl": 3600,
}
# API-Key auf JEDES Modell anwenden (auch die aus dem Basis-Template) —
# llama-server prueft dann den Authorization-Header. Ohne Key bleibt der
# Endpunkt offen; deshalb ist er per Default nur an localhost gebunden.
key = _openai_key()
for k, spec in list(models.items()):
cmd = str((spec or {}).get("cmd") or "")
if not cmd:
continue
cmd = re.sub(r"\s*--api-key\s+\S+", "", cmd) # alten Key entfernen
if key:
cmd = cmd.rstrip() + f" --api-key {shlex.quote(key)}"
models[k] = dict(spec, cmd=cmd)
out = dict(base)
out["models"] = models
try:
@@ -270,6 +323,13 @@ async def _announce(ws) -> None:
"instanceId": INSTANCE_ID, "service": WORKER_SERVICE,
"node": NODE_NAME, "gpus": GPU_IDS, "model": LLM_MODEL,
"models": models, # welche Modelle diese Box fahren kann (llama-swap-Keys)
# OpenAI-Endpunkt fuer Fremd-Tools (OpenHands & Co.). Der Key selbst wird
# NICHT gesendet (Broadcast!) — nur ob einer gesetzt ist und ob die .env
# ihn vorgibt (dann ist das Feld in der Diagnostic schreibgeschuetzt).
"openaiPort": OPENAI_PORT,
"openaiBind": OPENAI_BIND,
"openaiKeySet": bool(_openai_key()),
"openaiEnvLocked": bool(_OPENAI_KEY_ENV),
})
logger.info("worker_hello: models=%s", models)
@@ -355,6 +415,45 @@ async def _do_llm_request(ws, payload: dict) -> None:
})
async def _handle_openai_config(ws, payload: dict) -> None:
"""Setzt den API-Key des OpenAI-Endpunkts (aus der Diagnostic).
Der PORT wird hier bewusst NICHT geaendert: er ist ein Docker-Port-Mapping
und steht beim Container-Start fest — dafuer ist die .env zustaendig.
Steht der Key in der .env, gewinnt sie und wir lehnen ab.
"""
if (payload.get("instanceId") or INSTANCE_ID) != INSTANCE_ID:
return
req_id = payload.get("requestId", "")
key = str(payload.get("apiKey") or "").strip()
if _OPENAI_KEY_ENV:
await _send(ws, "llm_openai_config_result", {
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": False,
"error": "Der API-Key steht in der .env dieser Box — die hat Vorrang. "
"Zum Aendern dort anpassen und die Box neu starten.",
})
return
if not _openai_key_save(key):
await _send(ws, "llm_openai_config_result", {
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": False,
"error": "Key konnte nicht gespeichert werden (Schreibrechte auf /models?)",
})
return
# Config neu schreiben, damit llama-server den Key beim naechsten Laden nutzt.
_generate_config()
logger.info("OpenAI-API-Key %s (wirkt beim naechsten Modell-Laden)",
"gesetzt" if key else "entfernt")
await _send(ws, "llm_openai_config_result", {
"requestId": req_id, "instanceId": INSTANCE_ID, "ok": True,
"keySet": bool(key),
"message": ("API-Key gesetzt. Er greift, sobald das Modell das naechste Mal "
"geladen wird — bereits laufende Modelle einmal entladen lassen "
"(TTL) oder ein anderes Modell anfragen."
if key else "API-Key entfernt — der Endpunkt ist jetzt offen."),
})
await _announce(ws)
async def _handle_provision(ws, payload: dict) -> None:
"""Fuegt ein Modell hinzu: Registry+Config schreiben, reload, dann Warmup
(zieht das GGUF via -hf beim ersten Load). Meldet die neue Modell-Liste."""
@@ -435,7 +534,14 @@ async def _run() -> None:
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
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 16 * 1024 * 1024}
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
# whisper und f5tts im Minutentakt neu starten liess.
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
try:
logger.info("Verbinde zu RVS: %s%s (llama=%s)", masked,
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "", LLAMA_URL)
@@ -464,6 +570,9 @@ async def _run() -> None:
# Auslastungs-Monitor (node_stats_*) abfangen.
if await _stats.handle(ws, mtype, payload, _send):
continue
if mtype == "llm_openai_config":
asyncio.create_task(_handle_openai_config(ws, payload))
continue
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model"):
continue
if mtype == "llm_provision_model":
+8 -1
View File
@@ -760,7 +760,14 @@ async def run_loop(sessions: SessionManager) -> None:
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
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
# whisper und f5tts im Minutentakt neu starten liess.
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
try:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
+23 -7
View File
@@ -584,6 +584,12 @@ class SessionManager:
if ws is None:
return # disconnected — Endpointer pausiert bis Reconnect
# Modell laedt noch (Kaltstart): Audio weiter sammeln, aber NICHT
# transkribieren/finalisieren — sonst kaeme leerer Text heraus und die
# Aufnahme waere verloren. Sobald es geladen ist, laeuft es normal weiter.
if self.runner.model is None:
return
audio_ms = self._buffer_duration_ms(sess)
# Hard-Cap erreicht → wie Endpoint behandeln (egal ob neuer Text)
@@ -892,7 +898,14 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
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
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
# max_size MUSS zum RVS/zur Bridge passen (1500 MB). Der RVS ist ein
# BROADCAST-Relay: eine grosse Nachricht (Datei-Transfer, Voice-Upload,
# APK-Update) geht an JEDEN Client im Raum. Wer ein kleineres Limit hat,
# schliesst die Verbindung mit Code 1009 ("message too big") und landet in
# einer Reconnect-Schleife — real gesehen mit einem 209-MB-Frame, der
# whisper und f5tts im Minutentakt neu starten liess.
_MAX_WS_BYTES = int(os.getenv("RVS_MAX_SIZE_MB", "1500")) * 1024 * 1024
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": _MAX_WS_BYTES}
try:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
@@ -962,19 +975,22 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
# transcribe-Call mit Leerstring weil Model None.
target_model = payload.get("model") or runner.model_size or WHISPER_MODEL
needs_load = (runner.model is None) or (target_model != runner.model_size)
# WICHTIG: Session SOFORT anlegen — auch wenn das Modell
# noch laedt. Sonst laufen alle stt_audio_chunk der ersten
# Aufnahme ins Leere (feed_chunk findet keine Session) und
# der Take ist verloren; erst die zweite Aufnahme klappt,
# weil das Modell dann warm ist. Der Buffer fuellt sich
# jetzt waehrend des Ladens, das Transkribieren wartet.
sessions.start_session(payload)
if needs_load:
async def _load_then_start(p, target):
async def _load_only(target):
await _broadcast_status(ws, "loading", model=target)
try:
await runner.ensure_loaded(target)
await _broadcast_status(ws, "ready", model=runner.model_size)
except Exception as e:
await _broadcast_status(ws, "error", error=str(e)[:200])
return
sessions.start_session(p)
asyncio.create_task(_load_then_start(payload, target_model))
else:
sessions.start_session(payload)
asyncio.create_task(_load_only(target_model))
elif mtype == "stt_audio_chunk":
ok = sessions.feed_chunk(payload)