Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7af0b76ad3 | ||
|
|
614ba48531 | ||
|
|
5b5591aa72 | ||
|
|
b70623bb78 | ||
|
|
73b6827f6b | ||
|
|
a7998bcd8b | ||
|
|
07df538763 | ||
|
|
5761c5f6b9 | ||
|
|
87755b9c58 | ||
|
|
b6195d3a53 | ||
|
|
b9354283af | ||
|
|
bf1db6cbf9 | ||
|
|
ea159d4ed7 | ||
|
|
dd1e1ac458 | ||
|
|
ddd77c7ad4 | ||
|
|
7c3c932054 | ||
|
|
d6c7502810 | ||
|
|
dc77933014 | ||
|
|
6a6b320f37 | ||
|
|
cc91497923 | ||
|
|
10e7faa92b | ||
|
|
d030cd7bc6 | ||
|
|
36641e6519 | ||
|
|
9b11c16479 | ||
|
|
8f9e3e5c79 | ||
|
|
f1ae18b658 | ||
|
|
27f9399eda | ||
|
|
c131820be0 | ||
|
|
e43b44725b | ||
|
|
586a2bf134 | ||
|
|
ac40afc856 | ||
|
|
ff6d474f29 | ||
|
|
44aef71813 | ||
|
|
27381e7038 | ||
|
|
21d389cc26 | ||
|
|
eae036a9fc | ||
|
|
621b5525a8 | ||
|
|
6da567aee6 | ||
|
|
7071614434 | ||
|
|
8301342728 | ||
|
|
6a8dd662fa | ||
|
|
a48d8442b1 | ||
|
|
47735642f2 | ||
|
|
e15f7a5bc0 | ||
|
|
39f908dd4a | ||
|
|
176997a800 | ||
|
|
5b7e205ede | ||
|
|
47cc1b7f08 | ||
|
|
f4b7148cf9 | ||
|
|
5941be0f21 | ||
|
|
2038b676e6 | ||
|
|
8b84ee3b9c | ||
|
|
c86cd59b5c | ||
|
|
96012dc986 | ||
|
|
80d7f62eaa | ||
|
|
5f5234ee62 | ||
|
|
28e58089aa | ||
|
|
c1a90102f8 | ||
|
|
ba531adc74 | ||
|
|
04d29b256e | ||
|
|
e6e07f672a | ||
|
|
d32e7e59c3 | ||
|
|
4f8e65b76b | ||
|
|
a2605a2802 | ||
|
|
0588a8d9b3 | ||
|
|
be202b1fa1 | ||
|
|
1ad85fb687 | ||
|
|
558afee239 | ||
|
|
b2fd8d8953 | ||
|
|
737915e267 | ||
|
|
1140934e15 |
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 20500
|
versionCode 20505
|
||||||
versionName "0.2.5.0"
|
versionName "0.2.5.5"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,28 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.ariacockpit
|
package com.ariacockpit
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import com.facebook.react.ReactActivity
|
import com.facebook.react.ReactActivity
|
||||||
import com.facebook.react.ReactActivityDelegate
|
import com.facebook.react.ReactActivityDelegate
|
||||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||||
@@ -19,4 +20,26 @@ class MainActivity : ReactActivity() {
|
|||||||
*/
|
*/
|
||||||
override fun createReactActivityDelegate(): ReactActivityDelegate =
|
override fun createReactActivityDelegate(): ReactActivityDelegate =
|
||||||
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
|
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(OpenWakeWordPackage())
|
||||||
add(PhoneCallPackage())
|
add(PhoneCallPackage())
|
||||||
add(BackgroundAudioPackage())
|
add(BackgroundAudioPackage())
|
||||||
|
add(ShareIntentPackage())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getJSMainModuleName(): String = "index"
|
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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.5.0",
|
"version": "0.2.5.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -49,6 +49,8 @@ import updateService from '../services/updater';
|
|||||||
import VoiceButton from '../components/VoiceButton';
|
import VoiceButton from '../components/VoiceButton';
|
||||||
import FileUpload, { FileData } from '../components/FileUpload';
|
import FileUpload, { FileData } from '../components/FileUpload';
|
||||||
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
||||||
|
import ShareModal, { SharedItem } from '../components/ShareModal';
|
||||||
|
import shareIntent from '../services/shareIntent';
|
||||||
import MessageText from '../components/MessageText';
|
import MessageText from '../components/MessageText';
|
||||||
import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
|
import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
|
||||||
import Geolocation from '@react-native-community/geolocation';
|
import Geolocation from '@react-native-community/geolocation';
|
||||||
@@ -198,6 +200,35 @@ function formatDur(sec: number): string {
|
|||||||
return `${m}:${r.toString().padStart(2, '0')}`;
|
return `${m}:${r.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Grenzen fuer den Anhang-Re-Download ────────────────────────────
|
||||||
|
//
|
||||||
|
// Ein Re-Download kommt als base64 in EINER WebSocket-Nachricht. Die muss die
|
||||||
|
// App komplett im Speicher puffern und dann durch JSON.parse schicken — bei
|
||||||
|
// einem 150-MB-Video sind das ~200 MB base64, und das Handy stirbt daran mit
|
||||||
|
// einem nativen OOM (kein JS-Fehler, darum tauchte nie ein Stacktrace auf).
|
||||||
|
//
|
||||||
|
// Genau das war die Absturzschleife: App startet → Auto-Re-Download fordert
|
||||||
|
// ungefragt das 150-MB-Video an → OOM → Android startet den Vordergrund-
|
||||||
|
// Dienst neu → App startet → von vorn. Alle ~70s, stundenlang.
|
||||||
|
//
|
||||||
|
// Darum zwei Grenzen:
|
||||||
|
// AUTO — was die App OHNE Nachfrage holt. Klein halten; Bilder und
|
||||||
|
// Dokumente passen rein, Videos nicht.
|
||||||
|
// TAP — was sie auf ausdruecklichen Tipp holt. Hoeher, aber immer noch
|
||||||
|
// weit unter der Schmerzgrenze. Drueber gibt's eine ehrliche
|
||||||
|
// Meldung statt eines Absturzes.
|
||||||
|
/** Signatur von sendPendingAttachments — fuer den Ref weiter unten. */
|
||||||
|
type SendAttachmentsFn = (
|
||||||
|
messageText: string,
|
||||||
|
explicitAttachments?: { file: any; isPhoto: boolean }[],
|
||||||
|
voiceMode?: { audioRequestId: string; clientMsgId: string },
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
const AUTO_DL_MAX_BYTES = 25 * 1024 * 1024;
|
||||||
|
const TAP_DL_MAX_BYTES = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
const fmtMB = (n: number) => `${(n / 1024 / 1024).toFixed(0)} MB`;
|
||||||
|
|
||||||
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||||
|
|
||||||
@@ -348,6 +379,10 @@ const ChatScreen: React.FC = () => {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
||||||
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
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: ''});
|
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
||||||
// Multi-Threading: Activity pro Kontext (key = projectId, '' = Hauptchat).
|
// Multi-Threading: Activity pro Kontext (key = projectId, '' = Hauptchat).
|
||||||
// Der Indikator zeigt nur den fokussierten Kontext — nicht global.
|
// Der Indikator zeigt nur den fokussierten Kontext — nicht global.
|
||||||
@@ -363,6 +398,16 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Service-Status (AI-Box: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
|
// Service-Status (AI-Box: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
|
||||||
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({});
|
const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({});
|
||||||
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
|
const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
|
||||||
|
// Zwischenruf per Sprache: der 📣-Knopf ohne Text "spannt" die naechste
|
||||||
|
// Aufnahme als Zwischenruf. Mit Text schickt er sofort (wie bisher). Der Ref
|
||||||
|
// wird gelesen, wenn die Aufnahme startet — der State nur fuer die Optik.
|
||||||
|
const [interjectArmed, setInterjectArmed] = useState(false);
|
||||||
|
const interjectArmedRef = useRef(false);
|
||||||
|
// sendPendingAttachments ueber einen Ref: handleVoiceButtonStart steht weiter
|
||||||
|
// oben in der Datei und wuerde die Funktion sonst in ihrer Temporal Dead Zone
|
||||||
|
// greifen (TS2448 — siehe Kommentar an sendTextMessage). Umsortieren waere
|
||||||
|
// riskanter als dieser Ref, weil an der Reihenfolge mehrere Callbacks haengen.
|
||||||
|
const sendPendingAttachmentsRef = useRef<SendAttachmentsFn | null>(null);
|
||||||
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
|
// Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
|
||||||
const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true);
|
const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true);
|
||||||
const [ttsMuted, setTtsMuted] = useState(false);
|
const [ttsMuted, setTtsMuted] = useState(false);
|
||||||
@@ -419,6 +464,33 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
|
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
|
||||||
const autoOpenPaths = useRef<Set<string>>(new Set());
|
const autoOpenPaths = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-Download anfordern — mit Groessen-Bremse.
|
||||||
|
* Gibt false zurueck (und sagt warum), wenn die Datei zu gross fuer einen
|
||||||
|
* Transfer durch die WebSocket ist. Ohne das holt sich die App ein 150-MB-
|
||||||
|
* Video als ~200 MB base64 und stirbt am nativen OOM (siehe TAP_DL_MAX_BYTES).
|
||||||
|
*/
|
||||||
|
const requestAttachment = useCallback((att: Attachment, reqId: string): boolean => {
|
||||||
|
if (!att.serverPath) {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof att.size === 'number' && att.size > TAP_DL_MAX_BYTES) {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
ToastAndroid.show(
|
||||||
|
`${att.name}: ${fmtMB(att.size)} — zu gross fuers Handy. `
|
||||||
|
+ `Liegt auf dem Server unter ${att.serverPath}`,
|
||||||
|
ToastAndroid.LONG,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: reqId });
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Eindeutige Message-ID generieren
|
// Eindeutige Message-ID generieren
|
||||||
const nextId = (): string => {
|
const nextId = (): string => {
|
||||||
messageIdCounter.current += 1;
|
messageIdCounter.current += 1;
|
||||||
@@ -854,12 +926,25 @@ const ChatScreen: React.FC = () => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const missing: {id: string, serverPath: string}[] = [];
|
const missing: {id: string, serverPath: string}[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
for (const msg of prev) {
|
for (const msg of prev) {
|
||||||
for (const att of msg.attachments || []) {
|
for (const att of msg.attachments || []) {
|
||||||
if (att.serverPath && !att.uri) {
|
if (!att.serverPath || att.uri) continue;
|
||||||
|
// Grosse Dateien NICHT ungefragt holen (siehe AUTO_DL_MAX_BYTES).
|
||||||
|
// Sie bleiben als "tippen zum Laden" stehen — der User
|
||||||
|
// entscheidet, ob er sie wirklich aufs Handy zieht.
|
||||||
|
if (typeof att.size === 'number' && att.size > AUTO_DL_MAX_BYTES) {
|
||||||
|
skipped.push(`${att.name} (${fmtMB(att.size)})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
missing.push({ id: msg.id, serverPath: att.serverPath });
|
missing.push({ id: msg.id, serverPath: att.serverPath });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (skipped.length > 0) {
|
||||||
|
console.log('[Chat] Auto-Download uebersprungen (zu gross): %s', skipped.join(', '));
|
||||||
|
import('../services/logger')
|
||||||
|
.then(m => m.reportAppDebug('chat.autodl', `uebersprungen: ${skipped.join(', ')}`))
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
console.log(`[Chat] ${missing.length} fehlende Anhaenge — lade nach...`);
|
console.log(`[Chat] ${missing.length} fehlende Anhaenge — lade nach...`);
|
||||||
@@ -1256,7 +1341,11 @@ const ChatScreen: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const newText = `\uD83C\uDFA4 ${sttText}`;
|
// Zwischenruf per Sprache bekommt das Megafon statt des Mikros \u2014
|
||||||
|
// sonst sieht die Bubble wie eine normale Frage aus, obwohl sie in
|
||||||
|
// den laufenden Turn gegangen ist und keine eigene Antwort bekommt.
|
||||||
|
const isInterject = !!(message.payload as any).interject;
|
||||||
|
const newText = isInterject ? `\uD83D\uDCE3 Zwischenruf: ${sttText}` : `\uD83C\uDFA4 ${sttText}`;
|
||||||
// Primaer: matche per audioRequestId (eindeutig pro Aufnahme).
|
// Primaer: matche per audioRequestId (eindeutig pro Aufnahme).
|
||||||
// So gibt's keine Verwechslung wenn zwei Audios kurz hintereinander
|
// So gibt's keine Verwechslung wenn zwei Audios kurz hintereinander
|
||||||
// gesendet wurden und ihre STT-Results ueberlappen.
|
// gesendet wurden und ihre STT-Results ueberlappen.
|
||||||
@@ -1271,7 +1360,9 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Fallback: alte Bridge-Version ohne audioRequestId \u2014 match per Substring,
|
// Fallback: alte Bridge-Version ohne audioRequestId \u2014 match per Substring,
|
||||||
// nimmt die ERSTE noch unaufgeloeste Placeholder.
|
// nimmt die ERSTE noch unaufgeloeste Placeholder.
|
||||||
const idx = prev.findIndex(m =>
|
const idx = prev.findIndex(m =>
|
||||||
m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
|
m.sender === 'user'
|
||||||
|
&& (m.text.includes('Spracheingabe wird verarbeitet')
|
||||||
|
|| m.text.includes('Zwischenruf wird verarbeitet'))
|
||||||
);
|
);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
const next = prev.slice();
|
const next = prev.slice();
|
||||||
@@ -1303,6 +1394,10 @@ const ChatScreen: React.FC = () => {
|
|||||||
sender: 'user',
|
sender: 'user',
|
||||||
text: diagText,
|
text: diagText,
|
||||||
timestamp: message.timestamp,
|
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;
|
return;
|
||||||
@@ -1802,7 +1897,18 @@ const ChatScreen: React.FC = () => {
|
|||||||
// dem STT-Text gefuellte Bubble NICHT loeschen, sonst verschwindet die
|
// dem STT-Text gefuellte Bubble NICHT loeschen, sonst verschwindet die
|
||||||
// Sprachnachricht obwohl ARIA schon an der Antwort arbeitet.
|
// Sprachnachricht obwohl ARIA schon an der Antwort arbeitet.
|
||||||
if (ev.audioRequestId) {
|
if (ev.audioRequestId) {
|
||||||
setMessages(prev => prev.filter(m =>
|
// '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.audioRequestId === ev.audioRequestId
|
||||||
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
&& m.text.includes('Spracheingabe wird verarbeitet'))));
|
||||||
}
|
}
|
||||||
@@ -2330,11 +2436,18 @@ const ChatScreen: React.FC = () => {
|
|||||||
// produziert das Brain weiter TTS, die ins offene Mikro laeuft → genau der
|
// produziert das Brain weiter TTS, die ins offene Mikro laeuft → genau der
|
||||||
// "Mischmasch" (ARIA antwortet weiter waehrend ich rede). Fuer bewusstes
|
// "Mischmasch" (ARIA antwortet weiter waehrend ich rede). Fuer bewusstes
|
||||||
// Nicht-Abbrechen gibt es weiterhin den separaten Zwischenruf-Button (📣).
|
// Nicht-Abbrechen gibt es weiterhin den separaten Zwischenruf-Button (📣).
|
||||||
const interruptAriaIfBusy = useCallback(() => {
|
// forInterject: bei einem Zwischenruf darf der laufende Turn NICHT abgebrochen
|
||||||
|
// werden — das ist der ganze Sinn. Die Wiedergabe stoppen wir trotzdem, sonst
|
||||||
|
// redet ARIA in das offene Mikro hinein.
|
||||||
|
const interruptAriaIfBusy = useCallback((forInterject = false) => {
|
||||||
if (audioService.isPlayingAudio()) {
|
if (audioService.isPlayingAudio()) {
|
||||||
audioService.haltAllPlayback('user startet Aufnahme — Interrupt');
|
audioService.haltAllPlayback(forInterject
|
||||||
|
? 'user ruft dazwischen — nur Wiedergabe stoppen'
|
||||||
|
: 'user startet Aufnahme — Interrupt');
|
||||||
|
if (!forInterject) {
|
||||||
rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' });
|
rvs.send('cancel_request' as any, { hard: true, source: 'voice-interrupt' });
|
||||||
return true;
|
}
|
||||||
|
return !forInterject;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}, []);
|
}, []);
|
||||||
@@ -2347,18 +2460,35 @@ const ChatScreen: React.FC = () => {
|
|||||||
const handleVoiceButtonStart = useCallback(async (): Promise<boolean> => {
|
const handleVoiceButtonStart = useCallback(async (): Promise<boolean> => {
|
||||||
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
rememberMyRequest(audioRequestId);
|
rememberMyRequest(audioRequestId);
|
||||||
const wasInterrupted = interruptAriaIfBusy();
|
const asInterject = interjectArmedRef.current;
|
||||||
|
const wasInterrupted = interruptAriaIfBusy(asInterject);
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
|
|
||||||
|
// Warten Anhaenge? Dann sind sie das Thema dieser Aufnahme: sofort hochladen
|
||||||
|
// (laeuft parallel zum Reden) und der Bridge per clientMsgId sagen, dass der
|
||||||
|
// Satz dazu noch kommt. Die Bubble mit den Bildern macht
|
||||||
|
// sendPendingAttachments — sie traegt die audioRequestId und bekommt den
|
||||||
|
// gesprochenen Text spaeter eingesetzt. Also hier KEINE zweite Bubble.
|
||||||
|
let attachmentCmid = '';
|
||||||
|
if (pendingAttachments.length > 0 && !asInterject && sendPendingAttachmentsRef.current) {
|
||||||
|
attachmentCmid = nextClientMsgId();
|
||||||
|
const forVoice = pendingAttachments;
|
||||||
|
setPendingAttachments([]);
|
||||||
|
await sendPendingAttachmentsRef.current('', forVoice, {
|
||||||
|
audioRequestId, clientMsgId: attachmentCmid,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: nextId(),
|
id: nextId(),
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
text: '🎙 Spracheingabe wird verarbeitet...',
|
text: asInterject ? '📣 Zwischenruf wird verarbeitet...'
|
||||||
|
: '🎙 Spracheingabe wird verarbeitet...',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
|
||||||
audioRequestId,
|
audioRequestId,
|
||||||
};
|
};
|
||||||
setMessages(prev => capMessages([...prev, userMsg]));
|
setMessages(prev => capMessages([...prev, userMsg]));
|
||||||
|
}
|
||||||
|
|
||||||
const { ok } = await audioService.startStreamingRecording({
|
const { ok } = await audioService.startStreamingRecording({
|
||||||
audioRequestId,
|
audioRequestId,
|
||||||
@@ -2373,15 +2503,37 @@ const ChatScreen: React.FC = () => {
|
|||||||
endpointMs: await loadSttEndpointMs(),
|
endpointMs: await loadSttEndpointMs(),
|
||||||
hardCapMs: await loadMaxRecordingMs(),
|
hardCapMs: await loadMaxRecordingMs(),
|
||||||
projectId: focusedProjectIdRef.current,
|
projectId: focusedProjectIdRef.current,
|
||||||
|
...(attachmentCmid && { clientMsgId: attachmentCmid }),
|
||||||
|
...(asInterject && { interject: true }),
|
||||||
});
|
});
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
if (attachmentCmid) {
|
||||||
|
// Die Anhaenge sind SCHON hochgeladen — die Bubble darf nicht weg,
|
||||||
|
// sonst verschwinden die Bilder aus der Ansicht, obwohl sie auf dem
|
||||||
|
// Server liegen. Stattdessen ehrlich kennzeichnen; der Nutzer kann den
|
||||||
|
// Auftrag einfach tippen.
|
||||||
|
setMessages(prev => prev.map(m => m.audioRequestId === audioRequestId
|
||||||
|
? { ...m, text: '📎 Anhang gesendet — Mikro war belegt, bitte tippen',
|
||||||
|
audioRequestId: undefined }
|
||||||
|
: m));
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
ToastAndroid.show('Mikro nicht verfügbar — Anhang ist aber da', ToastAndroid.LONG);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
|
// Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
|
||||||
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
|
setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Zwischenruf-Scharfschaltung gilt genau fuer EINE Aufnahme.
|
||||||
|
if (asInterject) {
|
||||||
|
interjectArmedRef.current = false;
|
||||||
|
setInterjectArmed(false);
|
||||||
|
}
|
||||||
scheduleStaleAudioCleanup(audioRequestId, 60000);
|
scheduleStaleAudioCleanup(audioRequestId, 60000);
|
||||||
return true;
|
return true;
|
||||||
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);
|
}, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup,
|
||||||
|
pendingAttachments]);
|
||||||
|
|
||||||
// ARIA schliesst die Aufnahme SELBST — das programmatische Gegenstueck zum
|
// ARIA schliesst die Aufnahme SELBST — das programmatische Gegenstueck zum
|
||||||
// Stop-Button. Aufgerufen nach einem stillen Steuerbefehl (speak=false): der
|
// Stop-Button. Aufgerufen nach einem stillen Steuerbefehl (speak=false): der
|
||||||
@@ -2456,16 +2608,52 @@ const ChatScreen: React.FC = () => {
|
|||||||
setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]);
|
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
|
// Alle Pending Anhaenge + Text senden
|
||||||
const sendPendingAttachments = useCallback(async (messageText: string) => {
|
const sendPendingAttachments = useCallback(async (
|
||||||
if (pendingAttachments.length === 0) return;
|
messageText: string,
|
||||||
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
|
// 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 }[],
|
||||||
|
// Sprach-Modus: der Nutzer spricht die Beschreibung gerade erst ein. Dann
|
||||||
|
// gehen die Dateien SOFORT raus (der Upload laeuft parallel zum Reden),
|
||||||
|
// aber ohne Text — den liefert spaeter der stt_endpoint, und die Bridge
|
||||||
|
// merged beides ueber die clientMsgId zu einem Turn.
|
||||||
|
voiceMode?: { audioRequestId: string; clientMsgId: string },
|
||||||
|
) => {
|
||||||
|
const atts = explicitAttachments ?? pendingAttachments;
|
||||||
|
if (atts.length === 0) return;
|
||||||
|
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', atts.length);
|
||||||
const location = await getCurrentLocation();
|
const location = await getCurrentLocation();
|
||||||
const msgId = nextId();
|
const msgId = nextId();
|
||||||
|
|
||||||
// Alle Attachments fuer die Chat-Nachricht sammeln
|
// Alle Attachments fuer die Chat-Nachricht sammeln
|
||||||
const attachments: Attachment[] = [];
|
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 isImage = isPhoto || (file.type && file.type.startsWith('image/'));
|
||||||
const name = isPhoto ? file.fileName : file.name;
|
const name = isPhoto ? file.fileName : file.name;
|
||||||
const base64 = file.base64 || '';
|
const base64 = file.base64 || '';
|
||||||
@@ -2483,16 +2671,23 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
// Chat-Nachricht mit allen Anhaengen. clientMsgId nur wenn Text dabei
|
// Chat-Nachricht mit allen Anhaengen. clientMsgId nur wenn Text dabei
|
||||||
// ist — files selber haben (noch) kein ACK-Tracking auf der Bridge.
|
// ist — files selber haben (noch) kein ACK-Tracking auf der Bridge.
|
||||||
const cmid = messageText ? nextClientMsgId() : undefined;
|
// Im Sprach-Modus kommt sie von aussen: dieselbe ID muss am
|
||||||
|
// stt_stream_start haengen, damit die Bridge Anhang und Satz zusammenfuehrt.
|
||||||
|
const cmid = voiceMode ? voiceMode.clientMsgId : (messageText ? nextClientMsgId() : undefined);
|
||||||
const activePid = focusedProjectIdRef.current;
|
const activePid = focusedProjectIdRef.current;
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: msgId,
|
id: msgId,
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
|
// Im Sprach-Modus bekommt DIESE Bubble gleich den gesprochenen Text —
|
||||||
|
// via audioRequestId-Match, genau wie eine normale Sprachnachricht. So
|
||||||
|
// stehen Bild und Satz zusammen in einer Bubble statt in zwei.
|
||||||
|
text: voiceMode ? '🎙 Spracheingabe wird verarbeitet...'
|
||||||
|
: (messageText || `${atts.length} Anhang/Anhaenge`),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
attachments,
|
attachments,
|
||||||
projectId: activePid,
|
projectId: activePid,
|
||||||
...(cmid && {
|
...(voiceMode && { audioRequestId: voiceMode.audioRequestId }),
|
||||||
|
...(cmid && !voiceMode && {
|
||||||
clientMsgId: cmid,
|
clientMsgId: cmid,
|
||||||
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
|
||||||
sendAttempts: 1,
|
sendAttempts: 1,
|
||||||
@@ -2501,7 +2696,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
setMessages(prev => capMessages([...prev, userMsg]));
|
setMessages(prev => capMessages([...prev, userMsg]));
|
||||||
|
|
||||||
// Alle Dateien an RVS senden + auf Disk speichern
|
// 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 name = isPhoto ? file.fileName : file.name;
|
||||||
const base64 = file.base64 || '';
|
const base64 = file.base64 || '';
|
||||||
const mimeType = file.type || '';
|
const mimeType = file.type || '';
|
||||||
@@ -2530,6 +2725,9 @@ const ChatScreen: React.FC = () => {
|
|||||||
// die Datei genau DIESER Nachricht zuordnet — auch wenn Files (fire-
|
// die Datei genau DIESER Nachricht zuordnet — auch wenn Files (fire-
|
||||||
// and-forget) und Text (ACK-getrackt, bei Queue verzoegert) desyncen.
|
// and-forget) und Text (ACK-getrackt, bei Queue verzoegert) desyncen.
|
||||||
...(cmid && { clientMsgId: cmid }),
|
...(cmid && { clientMsgId: cmid }),
|
||||||
|
// awaitVoice: die Bridge soll den Anhang NICHT nach 0.8s alleine ans
|
||||||
|
// Brain schicken, sondern auf die Sprachnachricht warten.
|
||||||
|
...(voiceMode && { awaitVoice: true }),
|
||||||
...(isPhoto && file.width && { width: file.width, height: file.height }),
|
...(isPhoto && file.width && { width: file.width, height: file.height }),
|
||||||
...(location && { location }),
|
...(location && { location }),
|
||||||
});
|
});
|
||||||
@@ -2537,8 +2735,8 @@ const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
// Text als separate Nachricht (damit ARIA weiss was zu tun ist) — mit
|
// Text als separate Nachricht (damit ARIA weiss was zu tun ist) — mit
|
||||||
// dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble
|
// dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble
|
||||||
// adressieren.
|
// adressieren. Im Sprach-Modus gibt es hier noch keinen Text.
|
||||||
if (messageText && cmid) {
|
if (messageText && cmid && !voiceMode) {
|
||||||
dispatchWithAck(cmid, 'chat', {
|
dispatchWithAck(cmid, 'chat', {
|
||||||
text: messageText,
|
text: messageText,
|
||||||
voice: localXttsVoiceRef.current,
|
voice: localXttsVoiceRef.current,
|
||||||
@@ -2551,11 +2749,41 @@ const ChatScreen: React.FC = () => {
|
|||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
setInputText('');
|
setInputText('');
|
||||||
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
|
}, [pendingAttachments, getCurrentLocation, dispatchWithAck]);
|
||||||
|
// Ref aktuell halten — handleVoiceButtonStart greift die Funktion von hier
|
||||||
|
// (steht weiter oben in der Datei, siehe sendPendingAttachmentsRef).
|
||||||
|
sendPendingAttachmentsRef.current = sendPendingAttachments;
|
||||||
|
|
||||||
// Reine Text-Nachricht senden. Steht bewusst NACH interruptAriaIfBusy und
|
// Reine Text-Nachricht senden. Steht bewusst NACH interruptAriaIfBusy und
|
||||||
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
|
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
|
||||||
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
|
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
|
||||||
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
|
// (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 sendTextMessage = useCallback(async () => {
|
||||||
const text = inputText.trim();
|
const text = inputText.trim();
|
||||||
|
|
||||||
@@ -2790,16 +3018,15 @@ const ChatScreen: React.FC = () => {
|
|||||||
) : att.type === 'image' && !att.uri ? (
|
) : att.type === 'image' && !att.uri ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.attachmentFile}
|
style={styles.attachmentFile}
|
||||||
onPress={() => {
|
onPress={() => { requestAttachment(att, item.id); }}
|
||||||
if (att.serverPath) {
|
|
||||||
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.attachmentFileIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
|
<Text style={styles.attachmentFileIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
|
||||||
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
|
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
|
||||||
<Text style={styles.attachmentFileSize}>
|
<Text style={styles.attachmentFileSize}>
|
||||||
{att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
|
{!att.serverPath ? '(nicht verfuegbar)'
|
||||||
|
: typeof att.size === 'number' && att.size > AUTO_DL_MAX_BYTES
|
||||||
|
? `(${fmtMB(att.size)} — tippen zum Laden)`
|
||||||
|
: '(tippen zum Laden)'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : att.type === 'audio' ? (
|
) : att.type === 'audio' ? (
|
||||||
@@ -2836,11 +3063,9 @@ const ChatScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
// Re-Download via file_request \u2192 bei file_response wird die
|
// Re-Download via file_request \u2192 bei file_response wird die
|
||||||
// Datei gespeichert UND geoeffnet (autoOpenPaths-Tracking).
|
// Datei gespeichert UND geoeffnet (autoOpenPaths-Tracking).
|
||||||
if (att.serverPath) {
|
if (att.serverPath) autoOpenPaths.current.add(att.serverPath);
|
||||||
autoOpenPaths.current.add(att.serverPath);
|
if (!requestAttachment(att, item.id) && att.serverPath) {
|
||||||
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
|
autoOpenPaths.current.delete(att.serverPath);
|
||||||
} else if (Platform.OS === 'android') {
|
|
||||||
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -3391,7 +3616,9 @@ const ChatScreen: React.FC = () => {
|
|||||||
style={styles.textInput}
|
style={styles.textInput}
|
||||||
value={inputText}
|
value={inputText}
|
||||||
onChangeText={setInputText}
|
onChangeText={setInputText}
|
||||||
placeholder={pendingAttachments.length > 0 ? "Text zu den Anhaengen (optional)..." : "Nachricht an ARIA..."}
|
placeholder={pendingAttachments.length > 0
|
||||||
|
? "Text zu den Anhaengen — oder einsprechen 🎤"
|
||||||
|
: "Nachricht an ARIA..."}
|
||||||
placeholderTextColor="#555570"
|
placeholderTextColor="#555570"
|
||||||
multiline
|
multiline
|
||||||
maxLength={4000}
|
maxLength={4000}
|
||||||
@@ -3399,29 +3626,52 @@ const ChatScreen: React.FC = () => {
|
|||||||
returnKeyType="send"
|
returnKeyType="send"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Senden oder Sprache */}
|
{/* Rechte Knopfreihe.
|
||||||
{inputText.trim() || pendingAttachments.length > 0 ? (
|
Das Mikro ist jetzt IMMER da \u2014 auch mit Anhang oder getipptem Text.
|
||||||
<>
|
Vorher war es ein Entweder-oder: sobald etwas im Feld stand oder ein
|
||||||
{/* Zwischenruf: nur wenn ARIA im aktiven Kontext gerade arbeitet und
|
Bild dranhing, verschwand das Mikro. Damit ging weder "Bild anhaengen
|
||||||
Text da ist. Schiebt die Korrektur in den laufenden Turn statt
|
und den Auftrag dazu sprechen" noch ein Zwischenruf per Sprache.
|
||||||
sie anzustellen. */}
|
Der Senden-Pfeil und die Mund-/Ohr-Knoepfe weichen dafuer je nach
|
||||||
{inputText.trim() && (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? (
|
Lage, damit die Zeile nicht ueberlaeuft. */}
|
||||||
<TouchableOpacity style={styles.interjectButton} onPress={sendInterject} accessibilityLabel="Zwischenruf">
|
|
||||||
|
{/* Zwischenruf \u2014 sichtbar, wenn ARIA im aktiven Kontext arbeitet (oder
|
||||||
|
noch scharf geschaltet ist). Mit Text: sofort abschicken, wie bisher.
|
||||||
|
Ohne Text: die naechste Aufnahme geht als Zwischenruf in den
|
||||||
|
laufenden Turn, statt sich als neue Anfrage anzustellen. */}
|
||||||
|
{interjectArmed || (agentActivityByCtx[focusedProjectId]?.activity || 'idle') !== 'idle' ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.interjectButton, interjectArmed && styles.interjectButtonArmed]}
|
||||||
|
onPress={() => {
|
||||||
|
if (inputText.trim()) { sendInterject(); return; }
|
||||||
|
const next = !interjectArmedRef.current;
|
||||||
|
interjectArmedRef.current = next;
|
||||||
|
setInterjectArmed(next);
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
ToastAndroid.show(
|
||||||
|
next ? 'N\u00E4chste Aufnahme geht als Zwischenruf raus'
|
||||||
|
: 'Zwischenruf abgew\u00E4hlt',
|
||||||
|
ToastAndroid.SHORT);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
accessibilityLabel={inputText.trim() ? 'Zwischenruf senden' : 'Zwischenruf per Sprache'}
|
||||||
|
>
|
||||||
<Text style={styles.interjectIcon}>{'\uD83D\uDCE3'}</Text>
|
<Text style={styles.interjectIcon}>{'\uD83D\uDCE3'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null}
|
) : null}
|
||||||
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
|
||||||
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<VoiceButton
|
<VoiceButton
|
||||||
onTapStart={handleVoiceButtonStart}
|
onTapStart={handleVoiceButtonStart}
|
||||||
onTapStop={handleVoiceButtonStop}
|
onTapStop={handleVoiceButtonStop}
|
||||||
disabled={connectionState !== 'connected'}
|
disabled={connectionState !== 'connected'}
|
||||||
wakeWordActive={wakeWordActive}
|
wakeWordActive={wakeWordActive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{inputText.trim() || pendingAttachments.length > 0 ? (
|
||||||
|
<TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
|
||||||
|
<Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* Mund-Button: TTS auf diesem Geraet muten/aufheben.
|
{/* Mund-Button: TTS auf diesem Geraet muten/aufheben.
|
||||||
Nur sichtbar wenn TTS in den Settings grundsaetzlich aktiv ist. */}
|
Nur sichtbar wenn TTS in den Settings grundsaetzlich aktiv ist. */}
|
||||||
{ttsDeviceEnabled && (
|
{ttsDeviceEnabled && (
|
||||||
@@ -3693,6 +3943,13 @@ const ChatScreen: React.FC = () => {
|
|||||||
{/* Datei-Upload Modal */}
|
{/* Datei-Upload Modal */}
|
||||||
<Modal visible={showFileUpload} transparent animationType="slide">
|
<Modal visible={showFileUpload} transparent animationType="slide">
|
||||||
<View style={styles.modalOverlay}>
|
<View style={styles.modalOverlay}>
|
||||||
|
<ShareModal
|
||||||
|
visible={shareVisible}
|
||||||
|
items={shareItems}
|
||||||
|
initialText={shareText}
|
||||||
|
onCancel={() => { setShareVisible(false); setShareItems([]); setShareText(''); }}
|
||||||
|
onSend={handleShareSend}
|
||||||
|
/>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
onFileSelected={handleFileSelected}
|
onFileSelected={handleFileSelected}
|
||||||
onCancel={() => setShowFileUpload(false)}
|
onCancel={() => setShowFileUpload(false)}
|
||||||
@@ -3940,6 +4197,13 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginRight: 6,
|
marginRight: 6,
|
||||||
},
|
},
|
||||||
|
// Scharf geschaltet: die naechste Aufnahme geht als Zwischenruf raus. Heller
|
||||||
|
// plus Rand, damit man das vor dem Sprechen sieht und nicht erst danach.
|
||||||
|
interjectButtonArmed: {
|
||||||
|
backgroundColor: '#FFB340',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#FFF',
|
||||||
|
},
|
||||||
interjectIcon: {
|
interjectIcon: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1141,7 +1141,14 @@ class AudioService {
|
|||||||
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
|
* Leer = Hauptchat. Ohne Prefix / Sticky landet die STT-Nachricht damit
|
||||||
* automatisch in dem Kontext den Stefan gerade sieht. */
|
* automatisch in dem Kontext den Stefan gerade sieht. */
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
}): Promise<{ requestId: string; ok: boolean }> {
|
/** clientMsgId wartender Anhaenge: diese Aufnahme ist ihre Beschreibung.
|
||||||
|
* Die Bridge fuehrt Anhang + gesprochenen Satz darueber zu EINEM Turn
|
||||||
|
* zusammen, statt das Bild ohne Auftrag und den Satz ohne Bild zu sehen. */
|
||||||
|
clientMsgId?: string;
|
||||||
|
/** Zwischenruf: der Text geht in den LAUFENDEN Turn, nicht als neue
|
||||||
|
* Anfrage in die Warteschlange (Sprach-Gegenstueck zum 📣-Knopf). */
|
||||||
|
interject?: boolean;
|
||||||
|
}): Promise<{ requestId: string; ok: boolean; error?: string }> {
|
||||||
if (this.recordingState !== 'idle') {
|
if (this.recordingState !== 'idle') {
|
||||||
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
|
console.warn('[Audio] startStreamingRecording: bereits aktiv (state=%s)', this.recordingState);
|
||||||
return { requestId: '', ok: false };
|
return { requestId: '', ok: false };
|
||||||
@@ -1155,12 +1162,32 @@ class AudioService {
|
|||||||
console.warn('[Audio] Keine Mikrofon-Berechtigung');
|
console.warn('[Audio] Keine Mikrofon-Berechtigung');
|
||||||
return { requestId: '', ok: false };
|
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)
|
// Laufende Wiedergabe stoppen (damit ARIA sich nicht selbst hoert)
|
||||||
this.stopPlayback();
|
this.stopPlayback();
|
||||||
|
|
||||||
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
const requestId = `sttstr_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
this.streamRequestId = requestId;
|
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 || '';
|
this.streamAudioRequestId = opts.audioRequestId || '';
|
||||||
// Redundanz-Routing: freie STT-Instanz leasen BEVOR Chunks fliessen, damit
|
// Redundanz-Routing: freie STT-Instanz leasen BEVOR Chunks fliessen, damit
|
||||||
// start + alle Chunks + end dieselbe Instanz adressieren. Kurzer Timeout →
|
// start + alle Chunks + end dieselbe Instanz adressieren. Kurzer Timeout →
|
||||||
@@ -1202,20 +1229,17 @@ class AudioService {
|
|||||||
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
|
console.warn('[Audio] PcmStreamChunk-Subscription fehlgeschlagen:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const started = await PcmStreamRecorder.start();
|
// Abbruch waehrend der awaits oben? Dann hier aussteigen, statt eine
|
||||||
if (!started) {
|
// Sitzung zu starten, die niemand mehr erwartet.
|
||||||
throw new Error('PcmStreamRecorder.start returned false');
|
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.
|
// stt_stream_start MUSS vor dem Recorder raus: sonst treffen die ersten
|
||||||
// Vorher merken ob Musik lief (fuer Resume am Dialog-Ende).
|
// Chunks (200ms-Takt) auf eine Bridge, die die Sitzung noch nicht kennt,
|
||||||
await this._captureMediaActive();
|
// und werden verworfen — der Wortanfang fehlt, bei kurzen Kommandos bleibt
|
||||||
this._cancelDeferredFocusRelease();
|
// gar nichts uebrig.
|
||||||
AudioFocus?.requestExclusive().catch(() => {});
|
|
||||||
|
|
||||||
this.setState('recording');
|
|
||||||
|
|
||||||
// stt_stream_start — der Whisper-Bridge mitteilen dass jetzt Chunks kommen.
|
|
||||||
rvs.send('stt_stream_start' as any, {
|
rvs.send('stt_stream_start' as any, {
|
||||||
requestId,
|
requestId,
|
||||||
audioRequestId: opts.audioRequestId || '',
|
audioRequestId: opts.audioRequestId || '',
|
||||||
@@ -1228,20 +1252,44 @@ class AudioService {
|
|||||||
sampleRate: 16000,
|
sampleRate: 16000,
|
||||||
projectId: opts.projectId || '',
|
projectId: opts.projectId || '',
|
||||||
targetInstance: this.streamTargetInstance,
|
targetInstance: this.streamTargetInstance,
|
||||||
|
...(opts.clientMsgId ? { clientMsgId: opts.clientMsgId } : {}),
|
||||||
|
...(opts.interject ? { interject: true } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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.
|
// No-Speech-Watchdog — ersetzt den alten VAD-noSpeechTimer.
|
||||||
// Wenn nach Konversationsfenster kein einziger stt_partial gekommen ist,
|
// Wenn nach Konversationsfenster kein einziger stt_partial gekommen ist,
|
||||||
// hat der User vermutlich nix gesagt → Session beenden.
|
// 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);
|
const noSpeechMs = Number(opts.noSpeechTimeoutMs || 0);
|
||||||
if (noSpeechMs > 0) {
|
if (noSpeechMs > 0) {
|
||||||
|
const effectiveMs = noSpeechMs + NO_SPEECH_GRACE_MS;
|
||||||
this.streamNoSpeechTimer = setTimeout(() => {
|
this.streamNoSpeechTimer = setTimeout(() => {
|
||||||
if (this.streamRequestId === requestId && !this.streamGotPartial) {
|
if (this.streamRequestId === requestId && !this.streamGotPartial) {
|
||||||
console.log('[Audio] Stream %s: no-speech nach %dms → cancel',
|
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(() => {});
|
this.cancelStreamingRecording('no-speech').catch(() => {});
|
||||||
}
|
}
|
||||||
}, noSpeechMs);
|
}, effectiveMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).
|
// Hard-Cap als zweite Sicherheitsleine (App-seitig zusaetzlich zur Bridge).
|
||||||
|
|||||||
@@ -49,8 +49,15 @@ const BREADCRUMB_KEY = 'aria_last_breadcrumb';
|
|||||||
let _breadcrumb: { ts: number; scope: string; message: string } = { ts: 0, scope: '', message: '' };
|
let _breadcrumb: { ts: number; scope: string; message: string } = { ts: 0, scope: '', message: '' };
|
||||||
let _breadcrumbDirty = false;
|
let _breadcrumbDirty = false;
|
||||||
|
|
||||||
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot. */
|
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot.
|
||||||
|
*
|
||||||
|
* Die Crash-Meldung selbst darf den Brotkrumen NICHT ueberschreiben: sonst
|
||||||
|
* merkt sich der zweite Absturz nur noch "letzte Aktivitaet: Crash-Meldung",
|
||||||
|
* der dritte "letzte Aktivitaet: Crash-Meldung ueber Crash-Meldung" — und bei
|
||||||
|
* einer Absturzschleife ist nach dem ersten Durchlauf jede Spur weg. Genau
|
||||||
|
* das ist passiert; die eigentliche Ursache stand nur in der ERSTEN Meldung. */
|
||||||
export function noteBreadcrumb(scope: string, message: string): void {
|
export function noteBreadcrumb(scope: string, message: string): void {
|
||||||
|
if (scope === 'app.crash-detected') return;
|
||||||
_breadcrumb = { ts: Date.now(), scope: scope || '', message: String(message || '').slice(0, 120) };
|
_breadcrumb = { ts: Date.now(), scope: scope || '', message: String(message || '').slice(0, 120) };
|
||||||
_breadcrumbDirty = true;
|
_breadcrumbDirty = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,11 +104,7 @@ class RVSConnection {
|
|||||||
// Verbindungen + doppelte Events.
|
// Verbindungen + doppelte Events.
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.log('info', 'Bestehende WS-Verbindung wird geschlossen vor Neu-Connect');
|
this.log('info', 'Bestehende WS-Verbindung wird geschlossen vor Neu-Connect');
|
||||||
try {
|
this.detachSocket(this.ws);
|
||||||
this.ws.onclose = null; // verhindert dass scheduleReconnect doppelt feuert
|
|
||||||
this.ws.onerror = null;
|
|
||||||
this.ws.close();
|
|
||||||
} catch (_) {}
|
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +130,15 @@ class RVSConnection {
|
|||||||
this.setState('disconnected');
|
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 */
|
/** Nachricht an den RVS senden */
|
||||||
send(type: MessageType, payload: Record<string, unknown>): void {
|
send(type: MessageType, payload: Record<string, unknown>): void {
|
||||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
@@ -215,7 +220,12 @@ class RVSConnection {
|
|||||||
this.startHeartbeat();
|
this.startHeartbeat();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const socket = this.ws; // genau DIESE Verbindung (nicht 'this.ws' spaeter)
|
||||||
this.ws.onmessage = (event: WebSocketMessageEvent) => {
|
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 {
|
try {
|
||||||
const message: RVSMessage = JSON.parse(event.data as string);
|
const message: RVSMessage = JSON.parse(event.data as string);
|
||||||
this.notifyMessageListeners(message);
|
this.notifyMessageListeners(message);
|
||||||
@@ -258,8 +268,7 @@ class RVSConnection {
|
|||||||
this.log('warn', 'TLS fehlgeschlagen — Fallback auf ws:// (ohne TLS)');
|
this.log('warn', 'TLS fehlgeschlagen — Fallback auf ws:// (ohne TLS)');
|
||||||
this.clearTimers();
|
this.clearTimers();
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.ws.onclose = null; // onclose-Handler entfernen um Doppel-Reconnect zu verhindern
|
this.detachSocket(this.ws); // inkl. onmessage — sonst bleibt ein Zombie, der weiter empfaengt
|
||||||
try { this.ws.close(); } catch (_) {}
|
|
||||||
}
|
}
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.shouldReconnect = true;
|
this.shouldReconnect = true;
|
||||||
@@ -302,6 +311,25 @@ class RVSConnection {
|
|||||||
}, HEARTBEAT_INTERVAL_MS);
|
}, 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 {
|
private clearTimers(): void {
|
||||||
if (this.heartbeatTimer) {
|
if (this.heartbeatTimer) {
|
||||||
clearInterval(this.heartbeatTimer);
|
clearInterval(this.heartbeatTimer);
|
||||||
|
|||||||
@@ -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
@@ -1241,6 +1241,16 @@ META_TOOLS = [
|
|||||||
"oder Key). Fuer Geraete mit hinterlegten Zugangsdaten "
|
"oder Key). Fuer Geraete mit hinterlegten Zugangsdaten "
|
||||||
"(community/v3/Login/SSH) nutzt der Satellit diese automatisch — Du musst "
|
"(community/v3/Login/SSH) nutzt der Satellit diese automatisch — Du musst "
|
||||||
"keine Passwoerter mitgeben. "
|
"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 "
|
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks UND "
|
||||||
"um Geraete-Statusseiten zu lesen (Fallback fuer Tinte, NAS ...). Bei grossen "
|
"um Geraete-Statusseiten zu lesen (Fallback fuer Tinte, NAS ...). Bei grossen "
|
||||||
"Seiten NICHT blind paginieren: setze params['contains'] (String oder "
|
"Seiten NICHT blind paginieren: setze params['contains'] (String oder "
|
||||||
@@ -1270,7 +1280,8 @@ META_TOOLS = [
|
|||||||
"description": (
|
"description": (
|
||||||
"Zeigt die ARIA-Host-Agenten (Rechner, auf denen ein Agent DIREKT "
|
"Zeigt die ARIA-Host-Agenten (Rechner, auf denen ein Agent DIREKT "
|
||||||
"laeuft und sich per RVS meldet) die ONLINE sind + was sie koennen "
|
"laeuft und sich per RVS meldet) die ONLINE sind + was sie koennen "
|
||||||
"(exec/read/write/info/screenshot). Ein Host-Agent gibt Dir vollen "
|
"(exec/read/write/info/screenshot/ui_dump; Android-Agenten koennen "
|
||||||
|
"screenshot+ui_dump). Ein Host-Agent gibt Dir vollen "
|
||||||
"Zugriff auf GENAU DIESEN Rechner — auch wenn er hinter NAT/Firewall "
|
"Zugriff auf GENAU DIESEN Rechner — auch wenn er hinter NAT/Firewall "
|
||||||
"sitzt. Nutze das, wenn Stefan etwas 'auf meinem Laptop/PC/Server X' "
|
"sitzt. Nutze das, wenn Stefan etwas 'auf meinem Laptop/PC/Server X' "
|
||||||
"machen will, das kein Geraet im Netz ist."
|
"machen will, das kein Geraet im Netz ist."
|
||||||
@@ -1364,6 +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",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -2079,6 +2365,11 @@ class Agent:
|
|||||||
# Events vom letzten Turn weglassen
|
# Events vom letzten Turn weglassen
|
||||||
self._pending_events = []
|
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
|
# Projekt-Kontext pro Request statt aus globalem State
|
||||||
active_project_id = (project_id or "").strip()
|
active_project_id = (project_id or "").strip()
|
||||||
active_project = projects_mod.get_project(active_project_id) if active_project_id else None
|
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:
|
if not sat or not action:
|
||||||
return "FEHLER: satellite und action sind Pflicht."
|
return "FEHLER: satellite und action sind Pflicht."
|
||||||
params = arguments.get("params") if isinstance(arguments.get("params"), dict) else {}
|
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", {
|
result = _post("/internal/satellite", {
|
||||||
"op": "command", "satellite": sat,
|
"op": "command", "satellite": sat,
|
||||||
"device": arguments.get("device") or "", "action": action, "params": params,
|
"device": arguments.get("device") or "", "action": action, "params": params,
|
||||||
}, 30)
|
"timeout": sat_timeout,
|
||||||
|
}, sat_timeout + 20)
|
||||||
if result.get("ok"):
|
if result.get("ok"):
|
||||||
r = result.get("result")
|
r = result.get("result")
|
||||||
return f"OK — {r}" if r else "OK — Aktion ausgefuehrt."
|
return f"OK — {r}" if r else "OK — Aktion ausgefuehrt."
|
||||||
@@ -2536,9 +2835,276 @@ class Agent:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"FEHLER: Satellit/Bridge nicht erreichbar: {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:
|
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_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
|
import base64 as _b64
|
||||||
|
|
||||||
def _post(path: str, body: dict, timeout: float) -> dict:
|
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."
|
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}"
|
return f"FEHLER: unbekanntes Host-Tool {name}"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return f"FEHLER: Host/Bridge nicht erreichbar: {exc}"
|
return f"FEHLER: Host/Bridge nicht erreichbar: {exc}"
|
||||||
@@ -3448,8 +4049,15 @@ class Agent:
|
|||||||
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
|
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
|
||||||
if name in ("satellite_list", "satellite_devices", "satellite_command"):
|
if name in ("satellite_list", "satellite_devices", "satellite_command"):
|
||||||
return self._dispatch_satellite(name, arguments)
|
return self._dispatch_satellite(name, arguments)
|
||||||
|
if name in ("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",
|
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)
|
return self._dispatch_host(name, arguments)
|
||||||
if name == "vm_register":
|
if name == "vm_register":
|
||||||
pid = (project_id or "").strip()
|
pid = (project_id or "").strip()
|
||||||
|
|||||||
+214
-24
@@ -739,6 +739,14 @@ class ARIABridge:
|
|||||||
# weder Meta-Kommando noch Prefix ist und der Sticky abgelaufen,
|
# weder Meta-Kommando noch Prefix ist und der Sticky abgelaufen,
|
||||||
# nutzen wir das als Default (Voice folgt dem sichtbaren Kontext).
|
# nutzen wir das als Default (Voice folgt dem sichtbaren Kontext).
|
||||||
self._stt_stream_projects: dict[str, str] = {}
|
self._stt_stream_projects: dict[str, str] = {}
|
||||||
|
# Zusatz-Absicht pro Stream, auch von stt_stream_start:
|
||||||
|
# cmid — clientMsgId der Anhaenge, die zu DIESER Aufnahme gehoeren
|
||||||
|
# (Sprachnachricht als Beschreibung zu einem Bild/Video).
|
||||||
|
# interject — die Aufnahme ist ein Zwischenruf in den laufenden Turn,
|
||||||
|
# keine neue Anfrage.
|
||||||
|
# Getrennt von _stt_stream_projects gehalten: der Fokus-Default greift
|
||||||
|
# auch fuer alte App-Versionen, die diese Felder nicht mitschicken.
|
||||||
|
self._stt_stream_meta: dict[str, dict] = {}
|
||||||
# Voice-Override aus letzter Chat-Nachricht einer App.
|
# Voice-Override aus letzter Chat-Nachricht einer App.
|
||||||
# Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt.
|
# Wird fuer die direkt folgende ARIA-Antwort genutzt und dann zurueckgesetzt.
|
||||||
# So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request).
|
# So kann jedes Geraet seine bevorzugte Stimme bekommen (pro Request).
|
||||||
@@ -808,6 +816,18 @@ class ARIABridge:
|
|||||||
# Projekt landen statt im Hauptchat.
|
# Projekt landen statt im Hauptchat.
|
||||||
self._pending_files_project_id: str = ""
|
self._pending_files_project_id: str = ""
|
||||||
self._PENDING_FILES_WINDOW_SEC: float = 0.8
|
self._PENDING_FILES_WINDOW_SEC: float = 0.8
|
||||||
|
# Wartet ein Upload auf eine SPRACHNACHRICHT als Beschreibung, reichen
|
||||||
|
# 0.8s nicht: der Nutzer spricht erst, und Whisper braucht danach noch
|
||||||
|
# ein paar Sekunden fuers Final-Transkript. Mit dem kurzen Fenster waere
|
||||||
|
# der Anhang laengst alleine rausgegangen ("warte auf seine Anweisung"),
|
||||||
|
# und der gesprochene Text kaeme als zweiter, unverbundener Turn.
|
||||||
|
# Das lange Fenster ist nur eine Notbremse gegen Haengenbleiben, falls
|
||||||
|
# gar kein Endpoint mehr kommt — der Normalfall ist der Merge im
|
||||||
|
# stt_endpoint-Handler. Es MUSS ueber dem Aufnahme-Hard-Cap der App
|
||||||
|
# liegen (Default 5 Minuten) plus Whisper-Final, sonst waeren die
|
||||||
|
# Anhaenge bei einer langen Aufnahme vorher alleine rausgegangen.
|
||||||
|
self._PENDING_FILES_VOICE_WINDOW_SEC: float = 420.0
|
||||||
|
self._pending_files_await_voice: bool = False
|
||||||
|
|
||||||
def initialize(self) -> None:
|
def initialize(self) -> None:
|
||||||
"""Initialisiert alle Komponenten.
|
"""Initialisiert alle Komponenten.
|
||||||
@@ -1879,6 +1899,13 @@ class ARIABridge:
|
|||||||
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
|
return (f"Stefan hat dir {n} {anhang} geschickt:\n{files_summary}\n\n"
|
||||||
f"Warte auf seine Anweisung was du damit tun sollst.")
|
f"Warte auf seine Anweisung was du damit tun sollst.")
|
||||||
|
|
||||||
|
def _pending_files_window(self) -> float:
|
||||||
|
"""Wie lange auf den zugehoerigen Text gewartet wird, bevor die Anhaenge
|
||||||
|
alleine rausgehen. Lang, wenn eine Sprachnachricht unterwegs ist."""
|
||||||
|
return (self._PENDING_FILES_VOICE_WINDOW_SEC
|
||||||
|
if self._pending_files_await_voice
|
||||||
|
else self._PENDING_FILES_WINDOW_SEC)
|
||||||
|
|
||||||
async def _flush_pending_files_after(self, delay: float) -> None:
|
async def _flush_pending_files_after(self, delay: float) -> None:
|
||||||
"""Wenn nach `delay`s kein chat-Text gekommen ist: alle noch gepufferten
|
"""Wenn nach `delay`s kein chat-Text gekommen ist: alle noch gepufferten
|
||||||
Files alleine an aria-core senden ('warte auf Anweisung'-Variante)."""
|
Files alleine an aria-core senden ('warte auf Anweisung'-Variante)."""
|
||||||
@@ -1892,13 +1919,15 @@ class ARIABridge:
|
|||||||
text = self._build_pending_files_message("", files)
|
text = self._build_pending_files_message("", files)
|
||||||
self._pending_files = []
|
self._pending_files = []
|
||||||
self._pending_files_flush_task = None
|
self._pending_files_flush_task = None
|
||||||
|
self._pending_files_await_voice = False
|
||||||
pid = self._pending_files_project_id
|
pid = self._pending_files_project_id
|
||||||
self._pending_files_project_id = ""
|
self._pending_files_project_id = ""
|
||||||
await self.send_to_core(text, source="app-file", project_id=pid)
|
await self.send_to_core(text, source="app-file", project_id=pid)
|
||||||
|
|
||||||
async def _flush_pending_files_with_text(self, user_text: str,
|
async def _flush_pending_files_with_text(self, user_text: str,
|
||||||
project_id: str = "",
|
project_id: str = "",
|
||||||
client_msg_id: str = "") -> bool:
|
client_msg_id: str = "",
|
||||||
|
require_match: bool = False) -> bool:
|
||||||
"""Wenn ein chat-Text reinkommt waehrend Files gepuffert sind:
|
"""Wenn ein chat-Text reinkommt waehrend Files gepuffert sind:
|
||||||
Files + Text zu einer einzigen aria-core-Nachricht mergen.
|
Files + Text zu einer einzigen aria-core-Nachricht mergen.
|
||||||
Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden).
|
Returns True wenn gemerged wurde (Caller soll dann nicht nochmal senden).
|
||||||
@@ -1913,12 +1942,20 @@ class ARIABridge:
|
|||||||
|
|
||||||
project_id: Projekt-Kontext aus dem chat-Payload (der sichtbare Focus
|
project_id: Projekt-Kontext aus dem chat-Payload (der sichtbare Focus
|
||||||
beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext zurueck.
|
beim Absenden). Faellt auf den beim File-Upload gemerkten Kontext zurueck.
|
||||||
|
|
||||||
|
require_match: nur mergen, wenn die cmid wirklich passt. Der Sprach-Pfad
|
||||||
|
nutzt das — sonst wuerde eine Sprachnachricht, die zufaellig waehrend
|
||||||
|
eines fremden Uploads fertig wird, dessen Anhaenge an sich reissen. Beim
|
||||||
|
Text-Pfad bleibt der Fallback (alles mergen) wie bisher, damit nie ein
|
||||||
|
Bild verloren geht.
|
||||||
"""
|
"""
|
||||||
if not self._pending_files:
|
if not self._pending_files:
|
||||||
return False
|
return False
|
||||||
cmid = (client_msg_id or "").strip()
|
cmid = (client_msg_id or "").strip()
|
||||||
matching = [f for f in self._pending_files if cmid and f[6] == cmid]
|
matching = [f for f in self._pending_files if cmid and f[6] == cmid]
|
||||||
if not matching:
|
if not matching:
|
||||||
|
if require_match:
|
||||||
|
return False
|
||||||
# Kein cmid-Treffer → altes Verhalten: alle gepufferten Files mergen.
|
# Kein cmid-Treffer → altes Verhalten: alle gepufferten Files mergen.
|
||||||
matching = list(self._pending_files)
|
matching = list(self._pending_files)
|
||||||
remaining: list = []
|
remaining: list = []
|
||||||
@@ -1935,10 +1972,11 @@ class ARIABridge:
|
|||||||
self._pending_files_flush_task = None
|
self._pending_files_flush_task = None
|
||||||
if remaining:
|
if remaining:
|
||||||
self._pending_files_flush_task = asyncio.create_task(
|
self._pending_files_flush_task = asyncio.create_task(
|
||||||
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
|
self._flush_pending_files_after(self._pending_files_window())
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._pending_files_project_id = ""
|
self._pending_files_project_id = ""
|
||||||
|
self._pending_files_await_voice = False
|
||||||
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
|
# create_task statt await — sonst blockt der RVS-recv-Loop bis Brain
|
||||||
# fertig ist (siehe chat-handler oben).
|
# fertig ist (siehe chat-handler oben).
|
||||||
asyncio.create_task(self.send_to_core(text, source="app-file+chat", project_id=pid))
|
asyncio.create_task(self.send_to_core(text, source="app-file+chat", project_id=pid))
|
||||||
@@ -2752,10 +2790,17 @@ class ARIABridge:
|
|||||||
# genau SEINER Nachricht zu (Queue-Korrelation, s. _flush_*).
|
# genau SEINER Nachricht zu (Queue-Korrelation, s. _flush_*).
|
||||||
file_cmid = str(payload.get("clientMsgId") or "")
|
file_cmid = str(payload.get("clientMsgId") or "")
|
||||||
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0), file_cmid))
|
self._pending_files.append((file_path, file_name, file_type, size_kb, int(width or 0), int(height or 0), file_cmid))
|
||||||
|
# awaitVoice: der Nutzer spricht gerade die Beschreibung zu diesem
|
||||||
|
# Anhang ein. Dann NICHT nach 0.8s alleine rausschicken, sondern auf
|
||||||
|
# den stt_endpoint warten (siehe _PENDING_FILES_VOICE_WINDOW_SEC).
|
||||||
|
if payload.get("awaitVoice"):
|
||||||
|
self._pending_files_await_voice = True
|
||||||
|
logger.info("[rvs] Anhang wartet auf Sprachnachricht: %s (cmid=%s)",
|
||||||
|
file_name, file_cmid[:12] or "?")
|
||||||
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
if self._pending_files_flush_task and not self._pending_files_flush_task.done():
|
||||||
self._pending_files_flush_task.cancel()
|
self._pending_files_flush_task.cancel()
|
||||||
self._pending_files_flush_task = asyncio.create_task(
|
self._pending_files_flush_task = asyncio.create_task(
|
||||||
self._flush_pending_files_after(self._PENDING_FILES_WINDOW_SEC)
|
self._flush_pending_files_after(self._pending_files_window())
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -3324,8 +3369,18 @@ class ARIABridge:
|
|||||||
focused_pid = str(payload.get("projectId") or "")
|
focused_pid = str(payload.get("projectId") or "")
|
||||||
if req_id:
|
if req_id:
|
||||||
self._stt_stream_projects[req_id] = focused_pid
|
self._stt_stream_projects[req_id] = focused_pid
|
||||||
logger.info("[rvs] stt_stream_start id=%s focus=%s",
|
# Absicht dieser Aufnahme: gehoert sie zu wartenden Anhaengen,
|
||||||
req_id[:12], focused_pid or "(main)")
|
# und ist sie ein Zwischenruf statt einer neuen Anfrage?
|
||||||
|
att_cmid = str(payload.get("clientMsgId") or "")
|
||||||
|
is_interject = bool(payload.get("interject"))
|
||||||
|
if att_cmid or is_interject:
|
||||||
|
self._stt_stream_meta[req_id] = {
|
||||||
|
"cmid": att_cmid, "interject": is_interject,
|
||||||
|
}
|
||||||
|
logger.info("[rvs] stt_stream_start id=%s focus=%s%s%s",
|
||||||
|
req_id[:12], focused_pid or "(main)",
|
||||||
|
f" anhang-cmid={att_cmid[:12]}" if att_cmid else "",
|
||||||
|
" [ZWISCHENRUF]" if is_interject else "")
|
||||||
return
|
return
|
||||||
|
|
||||||
elif msg_type == "stt_stream_end":
|
elif msg_type == "stt_stream_end":
|
||||||
@@ -3340,10 +3395,10 @@ class ARIABridge:
|
|||||||
if req_id:
|
if req_id:
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
loop.call_later(
|
def _drop(rid: str = req_id) -> None:
|
||||||
20.0,
|
self._stt_stream_projects.pop(rid, None)
|
||||||
lambda rid=req_id: self._stt_stream_projects.pop(rid, None),
|
self._stt_stream_meta.pop(rid, None)
|
||||||
)
|
loop.call_later(20.0, _drop)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Kein laufender Loop (sollte im ws-Handler nie passieren) —
|
# Kein laufender Loop (sollte im ws-Handler nie passieren) —
|
||||||
# dann lieber gar nicht aufraeumen als crashen.
|
# dann lieber gar nicht aufraeumen als crashen.
|
||||||
@@ -3407,11 +3462,18 @@ class ARIABridge:
|
|||||||
# weiter auseinander (man hoert den Wechsel erst).
|
# weiter auseinander (man hoert den Wechsel erst).
|
||||||
_norm_txt = " ".join(text.lower().split())
|
_norm_txt = " ".join(text.lower().split())
|
||||||
_now_t = asyncio.get_event_loop().time()
|
_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
|
if (_norm_txt
|
||||||
and _norm_txt == getattr(self, "_last_endpoint_norm", "")
|
and _norm_txt == getattr(self, "_last_endpoint_norm", "")
|
||||||
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < 5.0):
|
and (_now_t - getattr(self, "_last_endpoint_at", 0.0)) < _DUPE_WINDOW_SEC):
|
||||||
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <5s): %r",
|
logger.info("[rvs] stt_endpoint Dupe verworfen (gleicher Text <%.1fs): %r",
|
||||||
text[:60])
|
_DUPE_WINDOW_SEC, text[:60])
|
||||||
return
|
return
|
||||||
self._last_endpoint_norm = _norm_txt
|
self._last_endpoint_norm = _norm_txt
|
||||||
self._last_endpoint_at = _now_t
|
self._last_endpoint_at = _now_t
|
||||||
@@ -3420,11 +3482,36 @@ class ARIABridge:
|
|||||||
# App-Version die projectId noch nicht mitschickt: leer = Hauptchat).
|
# App-Version die projectId noch nicht mitschickt: leer = Hauptchat).
|
||||||
stream_req_id = payload.get("requestId", "") or ""
|
stream_req_id = payload.get("requestId", "") or ""
|
||||||
focused_pid = self._stt_stream_projects.pop(stream_req_id, "")
|
focused_pid = self._stt_stream_projects.pop(stream_req_id, "")
|
||||||
|
meta = self._stt_stream_meta.pop(stream_req_id, {})
|
||||||
|
|
||||||
|
# Zwischenruf per Sprache: in den LAUFENDEN Turn schieben, nicht als
|
||||||
|
# neue Anfrage anstellen. Gleicher Weg wie der 📣-Knopf mit Text.
|
||||||
|
if meta.get("interject"):
|
||||||
|
logger.info("[rvs] stt_endpoint ist ein Zwischenruf → project=%s: %s",
|
||||||
|
focused_pid or "(main)", text[:80])
|
||||||
|
asyncio.create_task(
|
||||||
|
self._interject_proxy_for_project(focused_pid, text))
|
||||||
|
# Den erkannten Text trotzdem broadcasten, damit die
|
||||||
|
# Sprach-Bubble in der App ihren Text bekommt statt ewig auf
|
||||||
|
# "wird verarbeitet..." zu stehen.
|
||||||
|
asyncio.create_task(self._send_to_rvs({
|
||||||
|
"type": "chat",
|
||||||
|
"payload": {
|
||||||
|
"text": text,
|
||||||
|
"sender": "stt",
|
||||||
|
"audioRequestId": audio_request_id,
|
||||||
|
"projectId": focused_pid,
|
||||||
|
"interject": True,
|
||||||
|
},
|
||||||
|
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
|
||||||
asyncio.create_task(self._process_endpoint_text(
|
asyncio.create_task(self._process_endpoint_text(
|
||||||
text, interrupted, audio_request_id, location,
|
text, interrupted, audio_request_id, location,
|
||||||
client_msg_id=client_msg_id,
|
client_msg_id=client_msg_id,
|
||||||
focused_project_id=focused_pid))
|
focused_project_id=focused_pid,
|
||||||
|
attachment_client_msg_id=str(meta.get("cmid") or "")))
|
||||||
return
|
return
|
||||||
|
|
||||||
elif msg_type == "oauth_callback":
|
elif msg_type == "oauth_callback":
|
||||||
@@ -3543,6 +3630,10 @@ class ARIABridge:
|
|||||||
elif msg_type == "sat_hello":
|
elif msg_type == "sat_hello":
|
||||||
sid = (payload.get("id") or "").strip()
|
sid = (payload.get("id") or "").strip()
|
||||||
if sid:
|
if sid:
|
||||||
|
# sat_hello kommt alle ~25s erneut. Nur Neuanmeldungen und echte
|
||||||
|
# Aenderungen loggen — sonst ersaeuft das Bridge-Log in
|
||||||
|
# "Satellit online", und die interessanten Zeilen sind weg.
|
||||||
|
prev = self._satellites.get(sid)
|
||||||
self._satellites[sid] = {
|
self._satellites[sid] = {
|
||||||
"id": sid,
|
"id": sid,
|
||||||
"location": payload.get("location") or sid,
|
"location": payload.get("location") or sid,
|
||||||
@@ -3550,9 +3641,12 @@ class ARIABridge:
|
|||||||
"control": bool(payload.get("control")),
|
"control": bool(payload.get("control")),
|
||||||
"last_seen": time.time(),
|
"last_seen": time.time(),
|
||||||
}
|
}
|
||||||
logger.info("[sat] Satellit online: %s (%s) caps=%s control=%s",
|
cur = self._satellites[sid]
|
||||||
sid, self._satellites[sid]["location"],
|
if prev is None or any(prev.get(k) != cur.get(k)
|
||||||
self._satellites[sid]["caps"], self._satellites[sid]["control"])
|
for k in ("location", "caps", "control")):
|
||||||
|
logger.info("[sat] Satellit %s: %s (%s) caps=%s control=%s",
|
||||||
|
"online" if prev is None else "aktualisiert",
|
||||||
|
sid, cur["location"], cur["caps"], cur["control"])
|
||||||
return
|
return
|
||||||
|
|
||||||
elif msg_type == "host_hello":
|
elif msg_type == "host_hello":
|
||||||
@@ -3589,6 +3683,17 @@ class ARIABridge:
|
|||||||
self._hosts[hid]["last_seen"] = time.time()
|
self._hosts[hid]["last_seen"] = time.time()
|
||||||
return
|
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":
|
elif msg_type == "worker_hello":
|
||||||
iid = (payload.get("instanceId") or "").strip()
|
iid = (payload.get("instanceId") or "").strip()
|
||||||
if iid:
|
if iid:
|
||||||
@@ -3607,9 +3712,16 @@ class ARIABridge:
|
|||||||
"busy": bool(prev.get("busy", False)),
|
"busy": bool(prev.get("busy", False)),
|
||||||
"last_seen": time.time(),
|
"last_seen": time.time(),
|
||||||
}
|
}
|
||||||
logger.info("[worker] online: %s (service=%s node=%s gpus=%s model=%s)",
|
# Wie bei sat_hello: worker_hello wiederholt sich im Takt von
|
||||||
iid, self._workers[iid]["service"], self._workers[iid]["node"],
|
# ~30s. Nur neue Boxen und echte Aenderungen (anderes Modell,
|
||||||
self._workers[iid]["gpus"] or "?", self._workers[iid]["model"] or "?")
|
# andere GPUs) ins Log — der Rest ist Rauschen.
|
||||||
|
cur = self._workers[iid]
|
||||||
|
if not prev or any(prev.get(k) != cur.get(k)
|
||||||
|
for k in ("service", "node", "gpus", "model", "models")):
|
||||||
|
logger.info("[worker] %s: %s (service=%s node=%s gpus=%s model=%s)",
|
||||||
|
"online" if not prev else "aktualisiert",
|
||||||
|
iid, cur["service"], cur["node"],
|
||||||
|
cur["gpus"] or "?", cur["model"] or "?")
|
||||||
return
|
return
|
||||||
|
|
||||||
elif msg_type == "stt_lease_request":
|
elif msg_type == "stt_lease_request":
|
||||||
@@ -3766,8 +3878,12 @@ class ARIABridge:
|
|||||||
r")\s*[.!?]?\s*$",
|
r")\s*[.!?]?\s*$",
|
||||||
re.IGNORECASE,
|
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(
|
_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,
|
re.IGNORECASE | re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3822,7 +3938,10 @@ class ARIABridge:
|
|||||||
s = SequenceMatcher(None, q, pname).ratio()
|
s = SequenceMatcher(None, q, pname).ratio()
|
||||||
if s > best_score:
|
if s > best_score:
|
||||||
best, best_score = p, s
|
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"]
|
pid = best["id"]
|
||||||
self._voice_sticky_project_id = pid
|
self._voice_sticky_project_id = pid
|
||||||
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
||||||
@@ -3834,8 +3953,10 @@ class ARIABridge:
|
|||||||
# Kein Match → als normale Nachricht weiter (Sticky wenn aktiv)
|
# Kein Match → als normale Nachricht weiter (Sticky wenn aktiv)
|
||||||
# 3) Kein Meta / Prefix → Sticky oder Default
|
# 3) Kein Meta / Prefix → Sticky oder Default
|
||||||
if self._voice_sticky_project_id and now < self._voice_sticky_expires_at:
|
if self._voice_sticky_project_id and now < self._voice_sticky_expires_at:
|
||||||
# Sticky refreshen
|
# Sticky NICHT verlaengern: frueher hat jede Folgenachricht die Frist
|
||||||
self._voice_sticky_expires_at = now + self._VOICE_STICKY_TIMEOUT_SEC
|
# 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")
|
return (True, stripped, self._voice_sticky_project_id, "sticky")
|
||||||
# Sticky abgelaufen — zurücksetzen
|
# Sticky abgelaufen — zurücksetzen
|
||||||
self._voice_sticky_project_id = ""
|
self._voice_sticky_project_id = ""
|
||||||
@@ -3850,7 +3971,8 @@ class ARIABridge:
|
|||||||
audio_request_id: str = "",
|
audio_request_id: str = "",
|
||||||
location: Optional[dict] = None,
|
location: Optional[dict] = None,
|
||||||
client_msg_id: Optional[str] = None,
|
client_msg_id: Optional[str] = None,
|
||||||
focused_project_id: str = "") -> None:
|
focused_project_id: str = "",
|
||||||
|
attachment_client_msg_id: str = "") -> None:
|
||||||
"""Phase-2 Brain-Shortcut: Streaming-Whisper hat den finalen Text
|
"""Phase-2 Brain-Shortcut: Streaming-Whisper hat den finalen Text
|
||||||
schon ermittelt — wir uebernehmen den Pfad ab broadcast-STT + brain.
|
schon ermittelt — wir uebernehmen den Pfad ab broadcast-STT + brain.
|
||||||
|
|
||||||
@@ -3919,6 +4041,18 @@ class ARIABridge:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("[rvs] STT-Text (endpoint) konnte nicht broadcastet werden: %s", e)
|
logger.warning("[rvs] STT-Text (endpoint) konnte nicht broadcastet werden: %s", e)
|
||||||
|
|
||||||
|
# Sprachnachricht als Beschreibung zu Anhaengen: die liegen schon
|
||||||
|
# gepuffert (awaitVoice) und warten genau auf diesen Text — beides geht
|
||||||
|
# als EIN Turn ans Brain, sonst sieht ARIA ein Bild ohne Auftrag und
|
||||||
|
# danach einen Satz ohne Bild.
|
||||||
|
if attachment_client_msg_id and await self._flush_pending_files_with_text(
|
||||||
|
cleaned, project_id=project_id,
|
||||||
|
client_msg_id=attachment_client_msg_id,
|
||||||
|
require_match=True):
|
||||||
|
logger.info("[rvs] Sprachnachricht mit ihren Anhaengen gemerged (cmid=%s)",
|
||||||
|
attachment_client_msg_id[:12])
|
||||||
|
return
|
||||||
|
|
||||||
core_text = self._build_core_text(cleaned, interrupted, location)
|
core_text = self._build_core_text(cleaned, interrupted, location)
|
||||||
await self.send_to_core(core_text,
|
await self.send_to_core(core_text,
|
||||||
source="app-voice-stream" + (" [barge-in]" if interrupted else ""),
|
source="app-voice-stream" + (" [barge-in]" if interrupted else ""),
|
||||||
@@ -4580,6 +4714,17 @@ class ARIABridge:
|
|||||||
timeout=float(data.get("timeout") or 60.0),
|
timeout=float(data.get("timeout") or 60.0),
|
||||||
)
|
)
|
||||||
await _send_response(writer, 200, result)
|
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":
|
elif method == "POST" and path == "/internal/satellite":
|
||||||
# Brain-Tool: Discovery oder Command an einen Satelliten.
|
# Brain-Tool: Discovery oder Command an einen Satelliten.
|
||||||
# body: {op:'discover'|'command', satellite, device?, action?, params?}
|
# body: {op:'discover'|'command', satellite, device?, action?, params?}
|
||||||
@@ -4929,6 +5074,51 @@ class ARIABridge:
|
|||||||
"online": (now - h.get("last_seen", 0)) < 300,
|
"online": (now - h.get("last_seen", 0)) < 300,
|
||||||
} for h in self._hosts.values()]
|
} 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 = "",
|
async def _host_request(self, host: str = "", action: str = "",
|
||||||
params: Optional[dict] = None,
|
params: Optional[dict] = None,
|
||||||
timeout: float = 60.0) -> dict:
|
timeout: float = 60.0) -> dict:
|
||||||
|
|||||||
+501
-4
@@ -961,6 +961,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Jellyfin (Medien) -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>Jellyfin (Filme & 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 -->
|
<!-- FLUX Bildgenerierung -->
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h2>FLUX Bildgenerierung</h2>
|
<h2>FLUX Bildgenerierung</h2>
|
||||||
@@ -1594,6 +1621,71 @@
|
|||||||
</div>
|
</div>
|
||||||
</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()">×</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()">×</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) -->
|
<!-- Memory-Modal (Neu + Editieren) -->
|
||||||
<div class="modal-overlay" id="memory-modal">
|
<div class="modal-overlay" id="memory-modal">
|
||||||
<div class="modal-box" style="max-width:640px;">
|
<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 === 'host_update') { hosts = msg.hosts || []; renderHosts(); return; }
|
||||||
if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); 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') {
|
if (msg.type === 'sat_creds_list_result') {
|
||||||
const p = msg.payload || msg;
|
const p = msg.payload || msg;
|
||||||
const sat = p.satellite || '';
|
const sat = p.satellite || '';
|
||||||
@@ -4458,6 +4565,7 @@
|
|||||||
loadRuntimeConfig();
|
loadRuntimeConfig();
|
||||||
loadOnboardingQR();
|
loadOnboardingQR();
|
||||||
loadOAuthServices();
|
loadOAuthServices();
|
||||||
|
loadJellyfinConfig();
|
||||||
refreshVoiceIdStatus();
|
refreshVoiceIdStatus();
|
||||||
} else if (tab === 'brain') {
|
} else if (tab === 'brain') {
|
||||||
loadBrainStatus();
|
loadBrainStatus();
|
||||||
@@ -4514,7 +4622,9 @@
|
|||||||
'<span style="color:#8888AA;">' + escapeHtml(modelText) + '</span>' +
|
'<span style="color:#8888AA;">' + escapeHtml(modelText) + '</span>' +
|
||||||
(w.gpus ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
|
(w.gpus ? '<span style="color:#8888AA;">GPU ' + escapeHtml(w.gpus) + '</span>' : '') +
|
||||||
'<span style="margin-left:auto;color:' + dot + ';">' + stat + '</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('');
|
}).join('');
|
||||||
// Auslastungs-Button pro Node — Ziel bevorzugt die llm-Instanz (sieht alle
|
// Auslastungs-Button pro Node — Ziel bevorzugt die llm-Instanz (sieht alle
|
||||||
// GPUs + Tokens), sonst irgendein online Worker des Node.
|
// GPUs + Tokens), sonst irgendein online Worker des Node.
|
||||||
@@ -4527,6 +4637,62 @@
|
|||||||
}).join('');
|
}).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 & Co.): ' +
|
||||||
|
'<code style="color:#FFD60A;">http://' + escapeHtml(bind === '0.0.0.0' ? '<box-ip>' : '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
|
// Sprung von den Einstellungen zur Compute-Flotte (dort sitzt der
|
||||||
// Auslastung-Button pro Node → Live nvidia-smi + Graphen).
|
// Auslastung-Button pro Node → Live nvidia-smi + Graphen).
|
||||||
function gotoComputeFleet() {
|
function gotoComputeFleet() {
|
||||||
@@ -4643,13 +4809,337 @@
|
|||||||
const caps = (h.caps || []).join(', ') || '—';
|
const caps = (h.caps || []).join(', ') || '—';
|
||||||
const ctrl = h.control ? '<span style="color:#0096FF;">steuerbar</span>' : '<span style="color:#FF6E6E;">CONTROL_ENABLED=false</span>';
|
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;">' +
|
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> ' +
|
'<strong style="color:#E0E0F0;">💻 ' + escapeHtml(h.name || h.hostId) + '</strong> ' +
|
||||||
'<span style="color:#666;font-size:11px;">id=' + escapeHtml(h.hostId) + '</span></div>' +
|
'<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 style="color:#8888AA;font-size:11px;margin-top:4px;">' + escapeHtml(h.os || '') + ' · kann: ' + escapeHtml(caps) + ' · ' + ctrl + '</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).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) {
|
function scanSatellite(id) {
|
||||||
satScanning = id;
|
satScanning = id;
|
||||||
renderSatellites();
|
renderSatellites();
|
||||||
@@ -4683,18 +5173,24 @@
|
|||||||
if (d.model) tags.push(escapeHtml(d.model));
|
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.snmp && d.snmp.descr) tags.push(escapeHtml(String(d.snmp.descr).slice(0, 40)));
|
||||||
if (d.dialAppUrl) tags.push('DIAL');
|
if (d.dialAppUrl) tags.push('DIAL');
|
||||||
|
if (d.adbCapable) tags.push('ADB');
|
||||||
if (d.mac) tags.push(escapeHtml(d.mac));
|
if (d.mac) tags.push(escapeHtml(d.mac));
|
||||||
const tagStr = tags.length ? ' <span style="color:#666;">(' + tags.join(' · ') + ')</span>' : '';
|
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 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)" ' +
|
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) + '\')" ' +
|
'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>' : '';
|
'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;">' +
|
return '<div style="padding:3px 6px;background:#0D0D1A;border-radius:4px;display:flex;align-items:center;">' +
|
||||||
'<div style="flex:1;min-width:0;">' +
|
'<div style="flex:1;min-width:0;">' +
|
||||||
'<span style="color:#E0E0F0;">' + escapeHtml(d.name || d.id || '?') + '</span> ' +
|
'<span style="color:#E0E0F0;">' + escapeHtml(d.name || d.id || '?') + '</span> ' +
|
||||||
'<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' +
|
'<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' +
|
||||||
'<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr +
|
'<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr +
|
||||||
'</div>' + keyBtn +
|
'</div>' + keyBtn + apkBtn +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('') + '</div>';
|
}).join('') + '</div>';
|
||||||
}
|
}
|
||||||
@@ -4710,7 +5206,8 @@
|
|||||||
(scanning ? ' disabled' : '') + '>Geraete scannen</button>' +
|
(scanning ? ' disabled' : '') + '>Geraete scannen</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">kann: ' + escapeHtml(caps) + ' · ' + ctrl +
|
'<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>' : '') +
|
(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 +
|
devHtml +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|||||||
+195
-2
@@ -328,6 +328,87 @@ function writeLocalLlmConfig(patch) {
|
|||||||
return cur;
|
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) ────────────────
|
// ── Lokale Modell-Liste (Diagnostic-Dropdown) ────────────────
|
||||||
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
|
// /shared/config/local_models.json — kuratierte Liste; muss zu den KEYS in
|
||||||
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
|
// xtts/llama-swap/config.yaml passen. Wird bei Bedarf mit Defaults seeded.
|
||||||
@@ -557,7 +638,7 @@ function hostList() {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return Array.from(hosts.values()).map(h => ({
|
return Array.from(hosts.values()).map(h => ({
|
||||||
hostId: h.hostId, name: h.name, os: h.os, caps: h.caps, control: h.control,
|
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 => ({
|
return Array.from(workers.values()).map(w => ({
|
||||||
instanceId: w.instanceId, service: w.service, node: w.node,
|
instanceId: w.instanceId, service: w.service, node: w.node,
|
||||||
gpus: w.gpus, model: w.model, models: w.models || [], busy: !!w.busy,
|
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,
|
online: (now - (w.last_seen || 0)) < WORKER_OFFLINE_MS,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -1087,7 +1170,8 @@ function connectRVS(forcePlain) {
|
|||||||
const wasKnown = hosts.has(p.hostId);
|
const wasKnown = hosts.has(p.hostId);
|
||||||
hosts.set(p.hostId, {
|
hosts.set(p.hostId, {
|
||||||
hostId: p.hostId, name: p.name || p.hostId, os: p.os || "",
|
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();
|
if (!wasKnown) broadcastHosts();
|
||||||
else hosts.get(p.hostId).last_seen = Date.now();
|
else hosts.get(p.hostId).last_seen = Date.now();
|
||||||
@@ -1098,6 +1182,15 @@ function connectRVS(forcePlain) {
|
|||||||
} else if (msg.type === "rooms_info") {
|
} else if (msg.type === "rooms_info") {
|
||||||
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
|
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
|
||||||
broadcast({ type: "rooms_info", payload: msg.payload || {} });
|
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") {
|
} else if (msg.type === "sat_devices") {
|
||||||
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
|
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
|
||||||
const p = msg.payload || {};
|
const p = msg.payload || {};
|
||||||
@@ -1120,6 +1213,11 @@ function connectRVS(forcePlain) {
|
|||||||
instanceId: p.instanceId, service: p.service || "",
|
instanceId: p.instanceId, service: p.service || "",
|
||||||
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
|
node: p.node || "", gpus: p.gpus || "", model: p.model || "",
|
||||||
models: Array.isArray(p.models) ? p.models : (p.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(),
|
busy: !!prev.busy, last_seen: Date.now(),
|
||||||
});
|
});
|
||||||
broadcastWorkers();
|
broadcastWorkers();
|
||||||
@@ -1944,6 +2042,19 @@ const server = http.createServer((req, res) => {
|
|||||||
"Expires": "0",
|
"Expires": "0",
|
||||||
});
|
});
|
||||||
res.end(fs.readFileSync(htmlPath, "utf-8"));
|
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") {
|
} else if (req.url === "/api/state") {
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify({ state, logs: logs.slice(-100) }));
|
res.end(JSON.stringify({ state, logs: logs.slice(-100) }));
|
||||||
@@ -2005,6 +2116,39 @@ const server = http.createServer((req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
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") {
|
} else if (req.url === "/api/onboarding") {
|
||||||
// RVS-Credentials fuer QR-Code App-Onboarding
|
// RVS-Credentials fuer QR-Code App-Onboarding
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
@@ -2792,10 +2936,59 @@ wss.on("connection", (ws) => {
|
|||||||
} else if (msg.action === "host_list") {
|
} else if (msg.action === "host_list") {
|
||||||
// Browser will die aktuelle Host-Agenten-Liste.
|
// Browser will die aktuelle Host-Agenten-Liste.
|
||||||
ws.send(JSON.stringify({ type: "host_update", hosts: hostList() }));
|
ws.send(JSON.stringify({ type: "host_update", hosts: hostList() }));
|
||||||
|
} else if (msg.action === "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") {
|
} else if (msg.action === "rooms_query") {
|
||||||
// Browser will die RVS-Raum-Diagnose — via persistente rvsWs anfragen,
|
// Browser will die RVS-Raum-Diagnose — via persistente rvsWs anfragen,
|
||||||
// die Antwort (rooms_info) kommt auf derselben Verbindung zurueck.
|
// die Antwort (rooms_info) kommt auf derselben Verbindung zurueck.
|
||||||
sendToRVS_raw({ type: "rooms_query", payload: {}, timestamp: Date.now() });
|
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") {
|
} else if (msg.action === "worker_list") {
|
||||||
// Browser will die aktuelle Compute-Flotte.
|
// Browser will die aktuelle Compute-Flotte.
|
||||||
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
|
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
FROM python:3.11-slim-bullseye
|
FROM python:3.11-slim-bullseye
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
|
# PyInstaller braucht objdump aus binutils (im slim-Image nicht enthalten).
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends binutils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN pip install --no-cache-dir pyinstaller
|
RUN pip install --no-cache-dir pyinstaller
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Baut die Windows-.exe des Host-Agents AUF LINUX — via Wine + Windows-Python +
|
||||||
|
# PyInstaller. tobix/pywine bringt Wine + Windows-Python 3.11 mit (PyInstaller
|
||||||
|
# kann NICHT cross-compilen, deshalb der Wine-Umweg).
|
||||||
|
#
|
||||||
|
# Zusaetzlich baut NSIS ein setup.exe, das die Agent-.exe installiert, eine .env
|
||||||
|
# in C:\ProgramData\ARIA-Host-Agent anlegt (falls keine da ist) und den Agent als
|
||||||
|
# automatisch startenden Windows-Dienst (via nssm) einrichtet.
|
||||||
|
FROM tobix/pywine:3.11
|
||||||
|
|
||||||
|
ARG VERSION=0.0.0
|
||||||
|
|
||||||
|
# NSIS (Installer-Compiler, laeuft nativ auf Linux) + Tools fuer nssm.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends nsis curl unzip ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /work
|
||||||
|
|
||||||
|
# nssm — Non-Sucking Service Manager (public domain): macht aus der Konsolen-.exe
|
||||||
|
# einen sauberen Windows-Dienst (die .exe selbst spricht das SCM nicht).
|
||||||
|
RUN curl -fsSL https://nssm.cc/release/nssm-2.24.zip -o /tmp/nssm.zip \
|
||||||
|
&& unzip -q /tmp/nssm.zip -d /tmp \
|
||||||
|
&& cp /tmp/nssm-2.24/win64/nssm.exe ./nssm.exe \
|
||||||
|
&& rm -rf /tmp/nssm*
|
||||||
|
|
||||||
|
COPY requirements.txt host_agent.py ./
|
||||||
|
COPY windows/installer.nsi ./
|
||||||
|
|
||||||
|
# Windows-Python-Deps + PyInstaller, dann die Onefile-.exe bauen.
|
||||||
|
RUN wine pip install --no-cache-dir -r requirements.txt pyinstaller
|
||||||
|
RUN wine pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py \
|
||||||
|
&& cp dist/aria-host-agent.exe ./aria-host-agent.exe \
|
||||||
|
&& makensis -DVERSION=${VERSION} installer.nsi
|
||||||
|
|
||||||
|
# Beide Artefakte rausreichen (dist/ wird vom build-win.sh als Volume gemountet).
|
||||||
|
CMD ["bash","-lc","cp dist/aria-host-agent.exe /out/ && cp aria-host-agent-setup.exe /out/ && echo 'OK -> /out/aria-host-agent.exe + /out/aria-host-agent-setup.exe'"]
|
||||||
+88
-6
@@ -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 |
|
| `exec` | Shell-Kommando ausführen (optional `sudo`), stdout/stderr/exit |
|
||||||
| `read` | Datei lesen (Base64, mit Offset/Limit) |
|
| `read` | Datei lesen (Base64, mit Offset/Limit) |
|
||||||
| `write` | Datei schreiben/anhängen (Base64 oder Text) |
|
| `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) |
|
| `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`/
|
ARIA nutzt diese über die Brain-Tools `host_list`/`host_exec`/`host_read`/
|
||||||
`host_write` / `host_info` / `host_screenshot`.
|
`host_write`/`host_info`/`host_screenshot` 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
|
```bash
|
||||||
./build.sh # braucht Docker; erzeugt dist/aria-host-agent (~15 MB)
|
./build.sh # -> dist/aria-host-agent (~15 MB, läuft auf vielen Distros)
|
||||||
```
|
```
|
||||||
|
|
||||||
Gebaut wird in einem bullseye-Container (altes glibc), damit die Binary auf
|
**Linux/macOS ohne Docker** — PyInstaller direkt (linkt gegen lokales glibc/OS):
|
||||||
möglichst vielen Distributionen läuft.
|
```bash
|
||||||
|
./build-native.sh # -> dist/aria-host-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows — nativ** (auf einem Windows-Rechner, Python 3 im PATH nötig):
|
||||||
|
```bat
|
||||||
|
build-native.bat REM -> dist\aria-host-agent.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows — aus Docker heraus (auf Linux!), inkl. Installer** — Wine baut die
|
||||||
|
`.exe`, NSIS packt ein `setup.exe`, das den Agent als Windows-Dienst einrichtet:
|
||||||
|
```bash
|
||||||
|
./build-win.sh [version]
|
||||||
|
# -> dist/aria-host-agent.exe (Konsolen-Binary)
|
||||||
|
# -> dist/aria-host-agent-setup.exe (Installer: Dienst + .env in ProgramData)
|
||||||
|
```
|
||||||
|
Der erste Lauf zieht das `tobix/pywine`-Image (~1–2 GB) und richtet die Wine-
|
||||||
|
Python-Umgebung ein — das dauert; Folge-Builds sind schnell. PyInstaller kann
|
||||||
|
nicht cross-compilen, deshalb der Wine-Umweg. **macOS geht so NICHT** (Apple
|
||||||
|
lässt sich nicht legal aus Docker bauen) — dort `./build-native.sh` auf einem Mac.
|
||||||
|
|
||||||
### Docker scheitert? (Live-ISO / overlayfs-Root)
|
### 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
|
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).
|
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
|
## TLS / SNI — Agent im selben Netz wie der RVS
|
||||||
|
|
||||||
Steht der Rechner im **selben Netz wie der RVS** (z.B. Rechenzentrum) und soll
|
Steht der Rechner im **selben Netz wie der RVS** (z.B. Rechenzentrum) und soll
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
build/
|
||||||
|
.gradle/
|
||||||
|
dist/
|
||||||
|
*.apk
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# Signaturschluessel — NUR lokal, niemals ins oeffentliche Repo (Backup machen!)
|
||||||
|
aria-agent.keystore
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Baut die Android-Agent-APK (Debug, auto-signiert -> direkt installierbar).
|
||||||
|
# APK-Builds gehen nur unter Linux — deshalb im Container.
|
||||||
|
FROM eclipse-temurin:17-jdk-jammy
|
||||||
|
|
||||||
|
ARG GRADLE_VERSION=8.5
|
||||||
|
ARG CMDLINE_TOOLS=11076708
|
||||||
|
ENV ANDROID_SDK_ROOT=/opt/android-sdk
|
||||||
|
ENV ANDROID_HOME=/opt/android-sdk
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
unzip wget ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
RUN wget -q https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -O /tmp/g.zip \
|
||||||
|
&& unzip -q /tmp/g.zip -d /opt && rm /tmp/g.zip
|
||||||
|
|
||||||
|
# Android cmdline-tools + SDK
|
||||||
|
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
|
||||||
|
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip -O /tmp/c.zip \
|
||||||
|
&& unzip -q /tmp/c.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && rm /tmp/c.zip \
|
||||||
|
&& mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest
|
||||||
|
|
||||||
|
ENV PATH="/opt/gradle-${GRADLE_VERSION}/bin:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}"
|
||||||
|
|
||||||
|
RUN yes | sdkmanager --licenses >/dev/null 2>&1 || true
|
||||||
|
RUN sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" >/dev/null 2>&1
|
||||||
|
|
||||||
|
WORKDIR /project
|
||||||
|
COPY . /project
|
||||||
|
|
||||||
|
CMD ["bash","-lc","gradle --no-daemon assembleDebug && cp app/build/outputs/apk/debug/app-debug.apk /out/aria-android-agent.apk && echo 'OK -> /out/aria-android-agent.apk'"]
|
||||||
@@ -0,0 +1,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).
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+275
@@ -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 & 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" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Root-Build. Plugin-Versionen zentral, in den Modulen nur angewandt.
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.2.2' apply false
|
||||||
|
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
|
||||||
|
}
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut die Android-Agent-APK per Docker (Android-SDK + Gradle).
|
||||||
|
# ./build.sh
|
||||||
|
# Ergebnis: dist/aria-android-agent.apk -> aufs Handy kopieren + installieren
|
||||||
|
# ("Unbekannte Quellen erlauben").
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
mkdir -p dist
|
||||||
|
docker build -f Dockerfile.build -t aria-android-agent-build .
|
||||||
|
docker run --rm -v "$(pwd)/dist:/out" aria-android-agent-build
|
||||||
|
echo
|
||||||
|
echo "Fertig: dist/aria-android-agent.apk"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
org.gradle.caching=true
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "aria-android-agent"
|
||||||
|
include(":app")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
@echo off
|
||||||
|
REM ARIA Host-Agent — Windows-Build mit PyInstaller (kein Docker).
|
||||||
|
REM Voraussetzung: Python 3 installiert und im PATH (python.org, "Add to PATH").
|
||||||
|
REM
|
||||||
|
REM build-native.bat
|
||||||
|
REM
|
||||||
|
REM Ergebnis: dist\aria-host-agent.exe (Onefile). Danach .env danebenlegen
|
||||||
|
REM (siehe .env.example) und starten. Fuer Admin-Rechte die .exe per Rechtsklick
|
||||||
|
REM "Als Administrator ausfuehren".
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
python -m venv .buildenv || goto :err
|
||||||
|
call .buildenv\Scripts\activate.bat
|
||||||
|
python -m pip install --quiet --upgrade pip
|
||||||
|
python -m pip install --quiet pyinstaller -r requirements.txt || goto :err
|
||||||
|
pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py || goto :err
|
||||||
|
call deactivate
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Fertig: dist\aria-host-agent.exe
|
||||||
|
echo .env danebenlegen (siehe .env.example), dann starten (ggf. als Administrator).
|
||||||
|
goto :eof
|
||||||
|
|
||||||
|
:err
|
||||||
|
echo.
|
||||||
|
echo FEHLER beim Bauen. Ist Python 3 installiert und im PATH? (python --version)
|
||||||
|
exit /b 1
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut die Windows-.exe + setup.exe des Host-Agents AUF LINUX (Wine im Docker).
|
||||||
|
# ./build-win.sh [version]
|
||||||
|
# Ergebnis:
|
||||||
|
# dist/aria-host-agent.exe (Konsolen-Binary)
|
||||||
|
# dist/aria-host-agent-setup.exe (Installer: richtet Windows-Dienst ein)
|
||||||
|
#
|
||||||
|
# Hinweis: Der erste Lauf zieht das tobix/pywine-Image (~1-2 GB) + baut die
|
||||||
|
# Wine-Umgebung — das dauert. Folge-Builds sind schnell.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
VERSION="${1:-0.0.0}"
|
||||||
|
mkdir -p dist
|
||||||
|
docker build -f Dockerfile.win --build-arg VERSION="$VERSION" -t aria-host-agent-win .
|
||||||
|
docker run --rm -v "$(pwd)/dist:/out" aria-host-agent-win
|
||||||
|
echo
|
||||||
|
echo "Fertig: dist/aria-host-agent.exe + dist/aria-host-agent-setup.exe"
|
||||||
+274
-12
@@ -31,11 +31,28 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
|
# ─── Plattform-Weichen (Linux / macOS / Windows) ────────────────────
|
||||||
|
IS_WINDOWS = os.name == "nt"
|
||||||
|
IS_MAC = sys.platform == "darwin"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin() -> bool:
|
||||||
|
"""root (Unix) bzw. Administrator (Windows)."""
|
||||||
|
try:
|
||||||
|
return os.geteuid() == 0 # Unix (Linux/macOS)
|
||||||
|
except AttributeError:
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
return ctypes.windll.shell32.IsUserAnAdmin() != 0 # Windows
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s [host-agent] %(levelname)s %(message)s",
|
format="%(asctime)s [host-agent] %(levelname)s %(message)s",
|
||||||
@@ -47,7 +64,13 @@ def _load_dotenv() -> None:
|
|||||||
"""Laedt eine .env neben der Binary/dem Script (oder im CWD) in os.environ.
|
"""Laedt eine .env neben der Binary/dem Script (oder im CWD) in os.environ.
|
||||||
Bereits gesetzte Werte gewinnen. Kein python-dotenv noetig."""
|
Bereits gesetzte Werte gewinnen. Kein python-dotenv noetig."""
|
||||||
here = os.path.dirname(os.path.abspath(__file__))
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
for path in (os.path.join(here, ".env"), os.path.join(os.getcwd(), ".env")):
|
# PyInstaller-onefile: __file__ liegt im Temp-Extract-Dir, NICHT beim .exe/
|
||||||
|
# Binary — deshalb zusaetzlich sys.executable-Ordner (echter Binary-Ort) und
|
||||||
|
# das CWD (z.B. der ProgramData-Ordner, den der Windows-Dienst als AppDir nutzt).
|
||||||
|
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
|
||||||
|
seen = set()
|
||||||
|
candidates = [os.path.join(d, ".env") for d in (exe_dir, here, os.getcwd())]
|
||||||
|
for path in [p for p in candidates if not (p in seen or seen.add(p))]:
|
||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -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).
|
# Datei-Transfer-Limit (Base64 durchs RVS).
|
||||||
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
|
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
|
||||||
|
|
||||||
|
# Version (wird von release_agent.sh beim Release gesetzt).
|
||||||
|
AGENT_VERSION = "0.0.1.2"
|
||||||
|
|
||||||
HEARTBEAT_SEC = 25
|
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"]
|
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 ──────────────────────────────────────────────
|
# ─── Text-/Zahl-Helfer ──────────────────────────────────────────────
|
||||||
@@ -180,8 +236,18 @@ def _window_text(text: str, params: dict) -> dict:
|
|||||||
# ─── Aktionen ───────────────────────────────────────────────────────
|
# ─── Aktionen ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _wrap_sudo(cmd: str, use_sudo: bool):
|
def _wrap_sudo(cmd: str, use_sudo: bool):
|
||||||
"""Gibt (argv, stdin_data) oder (None, fehlertext) wenn sudo nicht moeglich."""
|
"""Baut die Argv (OS-abhaengige Shell) + optional Root/Admin-Rechte.
|
||||||
if not use_sudo or os.geteuid() == 0:
|
Gibt (argv, stdin_data) oder (None, fehlertext)."""
|
||||||
|
if IS_WINDOWS:
|
||||||
|
# PowerShell; kein sudo. Fuer Admin-Rechte muss der Agent SELBST als
|
||||||
|
# Administrator laufen (UAC) — dann hat 'sudo:true' bereits volle Rechte.
|
||||||
|
if use_sudo and not _is_admin():
|
||||||
|
return None, ("Windows kennt kein sudo. Starte den Agent als "
|
||||||
|
"Administrator ('Als Administrator ausfuehren'), dann "
|
||||||
|
"laufen Kommandos mit vollen Rechten.")
|
||||||
|
return ["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd], None
|
||||||
|
# Unix: Linux + macOS (bash vorhanden; macOS-sudo verhaelt sich wie Linux)
|
||||||
|
if not use_sudo or _is_admin():
|
||||||
return ["bash", "-lc", cmd], None
|
return ["bash", "-lc", cmd], None
|
||||||
if SUDO_PASSWORD:
|
if SUDO_PASSWORD:
|
||||||
return ["sudo", "-S", "-p", "", "bash", "-lc", cmd], SUDO_PASSWORD + "\n"
|
return ["sudo", "-S", "-p", "", "bash", "-lc", cmd], SUDO_PASSWORD + "\n"
|
||||||
@@ -264,9 +330,12 @@ def _do_write(params: dict) -> dict:
|
|||||||
def _do_info(params: dict) -> dict:
|
def _do_info(params: dict) -> dict:
|
||||||
info = {
|
info = {
|
||||||
"host": HOST_NAME, "hostname": socket.gethostname(),
|
"host": HOST_NAME, "hostname": socket.gethostname(),
|
||||||
|
"agent_version": AGENT_VERSION,
|
||||||
"os": platform.platform(), "kernel": platform.release(),
|
"os": platform.platform(), "kernel": platform.release(),
|
||||||
"arch": platform.machine(), "python": platform.python_version(),
|
"arch": platform.machine(), "python": platform.python_version(),
|
||||||
"user": os.environ.get("USER") or "", "is_root": os.geteuid() == 0,
|
"user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
|
||||||
|
"is_root": _is_admin(),
|
||||||
|
"gui": GUI_METHOD or "none", # '' => nur Terminal, keine GUI-Steuerung
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
import psutil
|
import psutil
|
||||||
@@ -306,13 +375,24 @@ def _do_info(params: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _do_screenshot(params: dict) -> dict:
|
def _do_screenshot(params: dict) -> dict:
|
||||||
"""Bildschirmfoto via System-Tool (Wayland: grim; X11: scrot/maim/import/
|
"""Bildschirmfoto — OS-abhaengig. Windows: PowerShell/System.Drawing;
|
||||||
gnome-screenshot). Braucht eine aktive grafische Session (DISPLAY/WAYLAND)."""
|
macOS: screencapture; Linux: grim (Wayland) / scrot/maim/import (X11).
|
||||||
|
Braucht eine aktive grafische Session."""
|
||||||
import tempfile
|
import tempfile
|
||||||
tmp = os.path.join(tempfile.gettempdir(), f"aria_shot_{int(time.time())}.png")
|
tmp = os.path.join(tempfile.gettempdir(), f"aria_shot_{int(time.time())}.png")
|
||||||
wayland = bool(os.environ.get("WAYLAND_DISPLAY"))
|
|
||||||
candidates = []
|
candidates = []
|
||||||
if wayland and shutil.which("grim"):
|
if IS_WINDOWS:
|
||||||
|
ps = ("Add-Type -AssemblyName System.Windows.Forms,System.Drawing;"
|
||||||
|
"$b=[System.Windows.Forms.SystemInformation]::VirtualScreen;"
|
||||||
|
"$bmp=New-Object System.Drawing.Bitmap $b.Width,$b.Height;"
|
||||||
|
"$g=[System.Drawing.Graphics]::FromImage($bmp);"
|
||||||
|
"$g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$b.Size);"
|
||||||
|
f"$bmp.Save('{tmp}');$g.Dispose();$bmp.Dispose()")
|
||||||
|
candidates.append(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps])
|
||||||
|
elif IS_MAC:
|
||||||
|
candidates.append(["screencapture", "-x", tmp]) # -x = ohne Ton
|
||||||
|
else:
|
||||||
|
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("grim"):
|
||||||
candidates.append(["grim", tmp])
|
candidates.append(["grim", tmp])
|
||||||
for tool, argv in (("scrot", ["scrot", "-o", tmp]),
|
for tool, argv in (("scrot", ["scrot", "-o", tmp]),
|
||||||
("maim", ["maim", tmp]),
|
("maim", ["maim", tmp]),
|
||||||
@@ -322,8 +402,8 @@ def _do_screenshot(params: dict) -> dict:
|
|||||||
candidates.append(argv)
|
candidates.append(argv)
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return {"ok": False, "error":
|
return {"ok": False, "error":
|
||||||
"Kein Screenshot-Tool gefunden. Installiere grim (Wayland) oder "
|
"Kein Screenshot-Tool gefunden. Linux: grim (Wayland) oder "
|
||||||
"scrot/maim (X11)."}
|
"scrot/maim (X11) installieren. (Windows/macOS nutzen Bordmittel.)"}
|
||||||
last_err = ""
|
last_err = ""
|
||||||
for argv in candidates:
|
for argv in candidates:
|
||||||
try:
|
try:
|
||||||
@@ -341,9 +421,191 @@ def _do_screenshot(params: dict) -> dict:
|
|||||||
"Laeuft der Agent in derselben grafischen Session?"}
|
"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 = {
|
ACTIONS = {
|
||||||
"exec": _do_exec, "read": _do_read, "write": _do_write,
|
"exec": _do_exec, "read": _do_read, "write": _do_write,
|
||||||
"info": _do_info, "screenshot": _do_screenshot,
|
"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)
|
HOST_ID, HOST_NAME, ",".join(CAPS), CONTROL_ENABLED)
|
||||||
await self._send({"type": "host_hello", "payload": {
|
await self._send({"type": "host_hello", "payload": {
|
||||||
"hostId": HOST_ID, "name": HOST_NAME, "os": platform.platform(),
|
"hostId": HOST_ID, "name": HOST_NAME, "os": platform.platform(),
|
||||||
"caps": CAPS, "control": CONTROL_ENABLED,
|
"version": AGENT_VERSION, "caps": CAPS, "control": CONTROL_ENABLED,
|
||||||
}, "timestamp": int(time.time() * 1000)})
|
}, "timestamp": int(time.time() * 1000)})
|
||||||
|
|
||||||
async def _heartbeat(self) -> None:
|
async def _heartbeat(self) -> None:
|
||||||
@@ -466,7 +728,7 @@ class HostAgent:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
logger.info("ARIA Host-Agent startet — id=%s name=%s control=%s root=%s",
|
logger.info("ARIA Host-Agent startet — id=%s name=%s control=%s root=%s",
|
||||||
HOST_ID, HOST_NAME, CONTROL_ENABLED, os.geteuid() == 0)
|
HOST_ID, HOST_NAME, CONTROL_ENABLED, _is_admin())
|
||||||
try:
|
try:
|
||||||
asyncio.run(HostAgent().run())
|
asyncio.run(HostAgent().run())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
Executable
+150
@@ -0,0 +1,150 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ════════════════════════════════════════════════════════════════════
|
||||||
|
# ARIA Host-Agent — Release Script
|
||||||
|
# Baut die auf Linux+Docker moeglichen Artefakte und haengt sie als
|
||||||
|
# Gitea-Release-Assets an einen Tag. NICHTS wandert in den Git-Tree —
|
||||||
|
# Binaries leben nur unter "Releases" (blaeht clone/History nicht auf).
|
||||||
|
#
|
||||||
|
# Verwendung: ./release_agent.sh <version> (z.B. ./release_agent.sh 0.2.0)
|
||||||
|
#
|
||||||
|
# Artefakte:
|
||||||
|
# - Linux-x64-Binary (Docker, PyInstaller) -> immer
|
||||||
|
# - Android-APK (Docker, Gradle) -> immer
|
||||||
|
# - macOS / Windows (falls in dist/ vorgebaut) -> optional
|
||||||
|
# (mac/win koennen auf Linux nicht cross-gebaut werden -> build-native.*
|
||||||
|
# auf dem jeweiligen OS laufen lassen, Ergebnis nach host-agent/dist/ legen)
|
||||||
|
#
|
||||||
|
# Eigener Tag-Namespace agent-v<version> (kollidiert NICHT mit den
|
||||||
|
# App-Tags v<version>).
|
||||||
|
#
|
||||||
|
# Gitea-Zugang (GITEA_URL, GITEA_REPO, GITEA_USER) wird aus der Umgebung
|
||||||
|
# oder aus host-agent/.env bzw. der Repo-Wurzel-.env gelesen; das Kennwort
|
||||||
|
# wird interaktiv abgefragt.
|
||||||
|
# ════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")" # host-agent/
|
||||||
|
SCRIPT_DIR="$(pwd)"
|
||||||
|
ROOT_DIR="$(cd .. && pwd)"
|
||||||
|
|
||||||
|
# ── Farben ───────────────────────────────────────────────────────────
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||||
|
|
||||||
|
# ── Parameter ────────────────────────────────────────────────────────
|
||||||
|
VERSION=${1:?"Usage: ./release_agent.sh <version> (z.B. 0.2.0)"}
|
||||||
|
TAG="agent-v$VERSION"
|
||||||
|
|
||||||
|
# ── Gitea-Konfiguration (env > host-agent/.env > Repo-Wurzel-.env) ────
|
||||||
|
[ -f "$SCRIPT_DIR/.env" ] && source "$SCRIPT_DIR/.env"
|
||||||
|
[ -f "$ROOT_DIR/.env" ] && source "$ROOT_DIR/.env"
|
||||||
|
|
||||||
|
GITEA_URL="${GITEA_URL:?"GITEA_URL nicht gesetzt (in .env oder als Umgebungsvariable)"}"
|
||||||
|
GITEA_REPO="${GITEA_REPO:?"GITEA_REPO nicht gesetzt (z.B. stefan/aria-agent)"}"
|
||||||
|
GITEA_USER="${GITEA_USER:-$(echo "$GITEA_REPO" | cut -d'/' -f1)}"
|
||||||
|
|
||||||
|
echo -e "${CYAN}╔═══════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${CYAN}║ ARIA Host-Agent Release — ${TAG}$(printf '%*s' $((14 - ${#TAG})) '')║${NC}"
|
||||||
|
echo -e "${CYAN}╚═══════════════════════════════════════════╝${NC}\n"
|
||||||
|
|
||||||
|
# ── Kennwort ─────────────────────────────────────────────────────────
|
||||||
|
echo -e "${YELLOW}Gitea-Login: ${GITEA_USER}${NC}"
|
||||||
|
read -s -p "Gitea-Kennwort: " GITEA_PASS; echo ""
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -u "${GITEA_USER}:${GITEA_PASS}" "$GITEA_URL/api/v1/user")
|
||||||
|
if [ "$HTTP_CODE" != "200" ]; then
|
||||||
|
echo -e "${RED}Login fehlgeschlagen (HTTP $HTTP_CODE). Kennwort korrekt?${NC}"; exit 1
|
||||||
|
fi
|
||||||
|
echo -e " ${GREEN}✓${NC} Login erfolgreich\n"
|
||||||
|
|
||||||
|
# ── Versionsnummern setzen ───────────────────────────────────────────
|
||||||
|
echo -e "${GREEN}[1/5] Version auf $VERSION setzen...${NC}"
|
||||||
|
# Desktop-Agent
|
||||||
|
sed -i "s/^AGENT_VERSION = \"[^\"]*\"/AGENT_VERSION = \"$VERSION\"/" host_agent.py
|
||||||
|
echo -e " ${GREEN}✓${NC} host_agent.py → AGENT_VERSION $VERSION"
|
||||||
|
# Android: versionName + versionCode (aus Version berechnen; 3- oder 4-stellig)
|
||||||
|
IFS='.' read -ra VP <<< "$VERSION"
|
||||||
|
V1=${VP[0]:-0}; V2=${VP[1]:-0}; V3=${VP[2]:-0}; V4=${VP[3]:-0}
|
||||||
|
VERSION_CODE=$((V1 * 1000000 + V2 * 10000 + V3 * 100 + V4)); [ "$VERSION_CODE" -lt 1 ] && VERSION_CODE=1
|
||||||
|
sed -i "s/versionName '[^']*'/versionName '$VERSION'/" android/app/build.gradle
|
||||||
|
sed -i "s/versionCode [0-9]*/versionCode $VERSION_CODE/" android/app/build.gradle
|
||||||
|
echo -e " ${GREEN}✓${NC} android/app/build.gradle → versionName $VERSION, versionCode $VERSION_CODE\n"
|
||||||
|
|
||||||
|
# ── Bauen (Docker) ───────────────────────────────────────────────────
|
||||||
|
echo -e "${GREEN}[2/5] Linux-Binary + Android-APK bauen (Docker)...${NC}"
|
||||||
|
./build.sh >/dev/null
|
||||||
|
LINUX_BIN="$SCRIPT_DIR/dist/aria-host-agent"
|
||||||
|
[ -f "$LINUX_BIN" ] || { echo -e "${RED}Linux-Binary fehlt: $LINUX_BIN${NC}"; exit 1; }
|
||||||
|
echo -e " ${GREEN}✓${NC} Linux-Binary ($(du -h "$LINUX_BIN" | cut -f1))"
|
||||||
|
|
||||||
|
( cd android && ./build.sh >/dev/null )
|
||||||
|
APK="$SCRIPT_DIR/android/dist/aria-android-agent.apk"
|
||||||
|
[ -f "$APK" ] || { echo -e "${RED}APK fehlt: $APK${NC}"; exit 1; }
|
||||||
|
echo -e " ${GREEN}✓${NC} Android-APK ($(du -h "$APK" | cut -f1))"
|
||||||
|
|
||||||
|
# Windows (.exe + setup.exe) via Wine im Docker. Dauert (pywine-Image ~1-2 GB) —
|
||||||
|
# mit SKIP_WINDOWS=1 ./release_agent.sh <v> ueberspringbar.
|
||||||
|
if [ "${SKIP_WINDOWS:-0}" = "1" ]; then
|
||||||
|
echo -e " ${YELLOW}Windows-Build uebersprungen (SKIP_WINDOWS=1)${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${CYAN}…${NC} Windows-.exe + setup.exe bauen (Wine, kann dauern)"
|
||||||
|
if ./build-win.sh "$VERSION" >/dev/null 2>&1; then
|
||||||
|
echo -e " ${GREEN}✓${NC} Windows-.exe + setup.exe"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}Windows-Build fehlgeschlagen — Release laeuft ohne Windows weiter.${NC}"
|
||||||
|
echo -e " ${YELLOW}(Einzeln testen: ./build-win.sh $VERSION)${NC}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Asset-Liste aufbauen: "lokaler_pfad::asset-name"
|
||||||
|
ASSETS=(
|
||||||
|
"$LINUX_BIN::aria-host-agent-linux-x64"
|
||||||
|
"$APK::aria-host-agent-android-$TAG.apk"
|
||||||
|
)
|
||||||
|
# Native Artefakte (nur wenn vorhanden): Windows aus build-win.sh, macOS
|
||||||
|
# vorgebaut (build-native.sh auf einem Mac -> dist/aria-host-agent-macos legen).
|
||||||
|
[ -f "$SCRIPT_DIR/dist/aria-host-agent-macos" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent-macos::aria-host-agent-macos")
|
||||||
|
[ -f "$SCRIPT_DIR/dist/aria-host-agent.exe" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent.exe::aria-host-agent-windows.exe")
|
||||||
|
[ -f "$SCRIPT_DIR/dist/aria-host-agent-setup.exe" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent-setup.exe::aria-host-agent-windows-setup.exe")
|
||||||
|
|
||||||
|
# ── Git-Tag ──────────────────────────────────────────────────────────
|
||||||
|
echo -e "${GREEN}[3/5] Git-Tag $TAG...${NC}"
|
||||||
|
git add host_agent.py android/app/build.gradle
|
||||||
|
git commit -m "release(agent): bump to $VERSION" 2>/dev/null || echo -e " ${YELLOW}Keine Aenderungen zum Committen${NC}"
|
||||||
|
if git rev-parse "$TAG" &>/dev/null; then
|
||||||
|
echo -e " ${YELLOW}Tag $TAG existiert bereits — überspringe${NC}"
|
||||||
|
else
|
||||||
|
git tag "$TAG"; echo -e " ${GREEN}✓${NC} Tag $TAG erstellt"
|
||||||
|
fi
|
||||||
|
git push origin main "$TAG"
|
||||||
|
echo -e " ${GREEN}✓${NC} Tag gepusht\n"
|
||||||
|
|
||||||
|
# ── Gitea-Release ────────────────────────────────────────────────────
|
||||||
|
echo -e "${GREEN}[4/5] Gitea-Release anlegen...${NC}"
|
||||||
|
BODY=$(printf 'ARIA Host-Agent %s\n\nDesktop (Linux) + Android-APK. Auf das Zielgeraet kopieren, siehe README.' "$TAG")
|
||||||
|
BODY_JSON=$(printf '%s' "$BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "$BODY")
|
||||||
|
RESP=$(curl -s -X POST "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" \
|
||||||
|
-u "${GITEA_USER}:${GITEA_PASS}" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"Host-Agent $TAG\",\"body\":$BODY_JSON,\"draft\":false,\"prerelease\":false}")
|
||||||
|
RELEASE_ID=$(echo "$RESP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||||
|
if [ -z "$RELEASE_ID" ]; then echo -e "${RED}Release fehlgeschlagen:${NC}\n$RESP"; exit 1; fi
|
||||||
|
echo -e " ${GREEN}✓${NC} Release #$RELEASE_ID erstellt\n"
|
||||||
|
|
||||||
|
# ── Assets hochladen ─────────────────────────────────────────────────
|
||||||
|
echo -e "${GREEN}[5/5] Assets hochladen (${#ASSETS[@]})...${NC}"
|
||||||
|
for entry in "${ASSETS[@]}"; do
|
||||||
|
path="${entry%%::*}"; name="${entry##*::}"
|
||||||
|
UP=$(curl -s -X POST \
|
||||||
|
"$GITEA_URL/api/v1/repos/$GITEA_REPO/releases/$RELEASE_ID/assets?name=$name" \
|
||||||
|
-u "${GITEA_USER}:${GITEA_PASS}" -F "attachment=@${path}")
|
||||||
|
if echo "$UP" | grep -q '"name"'; then
|
||||||
|
echo -e " ${GREEN}✓${NC} $name"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}✗ $name fehlgeschlagen:${NC} $UP"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
|
||||||
|
echo -e "${GREEN}║${NC} $GITEA_URL/$GITEA_REPO/releases/tag/$TAG"
|
||||||
|
echo -e "${GREEN}╚═══════════════════════════════════════════════════╝${NC}"
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
; ARIA Host-Agent — Windows-Installer (NSIS, auf Linux mit makensis gebaut).
|
||||||
|
;
|
||||||
|
; Installiert die Agent-.exe nach %ProgramFiles%\ARIA Host-Agent, legt eine .env
|
||||||
|
; in %ProgramData%\ARIA-Host-Agent an (nur falls noch keine da ist — User-Config
|
||||||
|
; bleibt erhalten) und richtet einen automatisch startenden Windows-Dienst via
|
||||||
|
; nssm ein. Der Dienst laeuft mit AppDirectory = ProgramData-Ordner, damit der
|
||||||
|
; Agent die .env von dort (aus dem CWD) liest.
|
||||||
|
|
||||||
|
!ifndef VERSION
|
||||||
|
!define VERSION "0.0.0"
|
||||||
|
!endif
|
||||||
|
!define SVC "ARIAHostAgent"
|
||||||
|
|
||||||
|
Name "ARIA Host-Agent ${VERSION}"
|
||||||
|
OutFile "aria-host-agent-setup.exe"
|
||||||
|
InstallDir "$PROGRAMFILES64\ARIA Host-Agent"
|
||||||
|
RequestExecutionLevel admin
|
||||||
|
Unicode true
|
||||||
|
ShowInstDetails show
|
||||||
|
ShowUninstDetails show
|
||||||
|
|
||||||
|
Var DataDir
|
||||||
|
|
||||||
|
Page directory
|
||||||
|
Page instfiles
|
||||||
|
UninstPage uninstConfirm
|
||||||
|
UninstPage instfiles
|
||||||
|
|
||||||
|
Section "Install"
|
||||||
|
SetOutPath "$INSTDIR"
|
||||||
|
File "aria-host-agent.exe"
|
||||||
|
File "nssm.exe"
|
||||||
|
|
||||||
|
; ProgramData-Ordner fuer die .env bestimmen
|
||||||
|
ReadEnvStr $0 "ProgramData"
|
||||||
|
StrCmp $0 "" 0 +2
|
||||||
|
StrCpy $0 "$PROFILE" ; Fallback, falls %ProgramData% fehlt
|
||||||
|
StrCpy $DataDir "$0\ARIA-Host-Agent"
|
||||||
|
CreateDirectory "$DataDir"
|
||||||
|
|
||||||
|
; .env nur schreiben, wenn noch keine existiert (User-Config nicht ueberschreiben)
|
||||||
|
IfFileExists "$DataDir\.env" env_done 0
|
||||||
|
FileOpen $1 "$DataDir\.env" w
|
||||||
|
FileWrite $1 "# ARIA Host-Agent — Konfiguration (dieser Windows-Dienst liest diese Datei).$\r$\n"
|
||||||
|
FileWrite $1 "# Nach dem Aendern den Dienst neu starten: services.msc -> ARIA Host-Agent.$\r$\n"
|
||||||
|
FileWrite $1 "RVS_HOST=rvs.example.de$\r$\n"
|
||||||
|
FileWrite $1 "RVS_PORT=443$\r$\n"
|
||||||
|
FileWrite $1 "RVS_TLS=true$\r$\n"
|
||||||
|
FileWrite $1 "RVS_TLS_FALLBACK=true$\r$\n"
|
||||||
|
FileWrite $1 "RVS_TOKEN=$\r$\n"
|
||||||
|
FileWrite $1 "RVS_SNI=$\r$\n"
|
||||||
|
FileWrite $1 "HOST_NAME=$\r$\n"
|
||||||
|
FileWrite $1 "CONTROL_ENABLED=true$\r$\n"
|
||||||
|
FileClose $1
|
||||||
|
env_done:
|
||||||
|
|
||||||
|
; Dienst (neu) einrichten — evtl. alten sauber entfernen, dann installieren
|
||||||
|
nsExec::ExecToLog '"$INSTDIR\nssm.exe" stop ${SVC}'
|
||||||
|
nsExec::ExecToLog '"$INSTDIR\nssm.exe" remove ${SVC} confirm'
|
||||||
|
nsExec::ExecToLog '"$INSTDIR\nssm.exe" install ${SVC} "$INSTDIR\aria-host-agent.exe"'
|
||||||
|
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} AppDirectory "$DataDir"'
|
||||||
|
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} DisplayName "ARIA Host-Agent"'
|
||||||
|
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} Description "ARIA-Fernsteuerung dieses Rechners (RVS-Agent)."'
|
||||||
|
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} Start SERVICE_AUTO_START'
|
||||||
|
nsExec::ExecToLog '"$INSTDIR\nssm.exe" start ${SVC}'
|
||||||
|
|
||||||
|
; Uninstaller + Eintrag unter "Apps & Features"
|
||||||
|
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||||
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "DisplayName" "ARIA Host-Agent"
|
||||||
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "DisplayVersion" "${VERSION}"
|
||||||
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "UninstallString" '"$INSTDIR\uninstall.exe"'
|
||||||
|
SectionEnd
|
||||||
|
|
||||||
|
Section "Uninstall"
|
||||||
|
nsExec::ExecToLog '"$INSTDIR\nssm.exe" stop ${SVC}'
|
||||||
|
nsExec::ExecToLog '"$INSTDIR\nssm.exe" remove ${SVC} confirm'
|
||||||
|
Delete "$INSTDIR\aria-host-agent.exe"
|
||||||
|
Delete "$INSTDIR\nssm.exe"
|
||||||
|
Delete "$INSTDIR\uninstall.exe"
|
||||||
|
RMDir "$INSTDIR"
|
||||||
|
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent"
|
||||||
|
; Die .env in %ProgramData%\ARIA-Host-Agent bleibt bewusst erhalten (User-Config).
|
||||||
|
SectionEnd
|
||||||
@@ -18,6 +18,9 @@
|
|||||||
*/
|
*/
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { EventEmitter } from "events";
|
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";
|
import { isAssistantMessage, isResultMessage, isContentDelta } from "../types/claude-cli.js";
|
||||||
const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.)
|
const DEFAULT_TIMEOUT = 86400000; // 24h — lange Agent-Loops (Pentests etc.)
|
||||||
export class ClaudeSubprocess extends EventEmitter {
|
export class ClaudeSubprocess extends EventEmitter {
|
||||||
@@ -26,6 +29,8 @@ export class ClaudeSubprocess extends EventEmitter {
|
|||||||
timeoutId = null;
|
timeoutId = null;
|
||||||
isKilled = false;
|
isKilled = false;
|
||||||
_stdinClosed = false;
|
_stdinClosed = false;
|
||||||
|
_systemPromptFile = null;
|
||||||
|
_systemPromptDir = null;
|
||||||
/**
|
/**
|
||||||
* Start the Claude CLI subprocess with the given prompt
|
* 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)
|
// Handle spawn errors (e.g., claude not found)
|
||||||
this.process.on("error", (err) => {
|
this.process.on("error", (err) => {
|
||||||
this.clearTimeout();
|
this.clearTimeout();
|
||||||
|
this._cleanupSystemPrompt();
|
||||||
if (err.message.includes("ENOENT")) {
|
if (err.message.includes("ENOENT")) {
|
||||||
reject(new Error("Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"));
|
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) => {
|
this.process.on("close", (code) => {
|
||||||
console.error(`[Subprocess] Process closed with code: ${code}`);
|
console.error(`[Subprocess] Process closed with code: ${code}`);
|
||||||
this.clearTimeout();
|
this.clearTimeout();
|
||||||
|
this._cleanupSystemPrompt();
|
||||||
// Process any remaining buffer
|
// Process any remaining buffer
|
||||||
if (this.buffer.trim()) {
|
if (this.buffer.trim()) {
|
||||||
this.processBuffer();
|
this.processBuffer();
|
||||||
@@ -96,6 +103,7 @@ export class ClaudeSubprocess extends EventEmitter {
|
|||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
this.clearTimeout();
|
this.clearTimeout();
|
||||||
|
this._cleanupSystemPrompt();
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -114,13 +122,47 @@ export class ClaudeSubprocess extends EventEmitter {
|
|||||||
"stream-json", // ARIA: User-Messages via stdin (Zwischenruf)
|
"stream-json", // ARIA: User-Messages via stdin (Zwischenruf)
|
||||||
"--model",
|
"--model",
|
||||||
options.model, // Model alias (opus/sonnet/haiku)
|
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) {
|
if (options.sessionId) {
|
||||||
args.push("--session-id", options.sessionId);
|
args.push("--session-id", options.sessionId);
|
||||||
}
|
}
|
||||||
return args;
|
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.
|
* Eine User-Message im stream-json-Input-Format an stdin schreiben.
|
||||||
* Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage).
|
* Genutzt fuer den initialen Prompt UND fuer Zwischenrufe (sendMessage).
|
||||||
|
|||||||
+123
@@ -88,6 +88,20 @@ const ALLOWED_TYPES = new Set([
|
|||||||
// host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/
|
// host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/
|
||||||
// screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT.
|
// screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT.
|
||||||
"host_hello", "host_ping", "host_command", "host_result",
|
"host_hello", "host_ping", "host_command", "host_result",
|
||||||
|
// 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),
|
// Raum-Diagnose: Diagnostic fragt die aktuellen RVS-Raeume ab (rooms_query),
|
||||||
// RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je
|
// RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je
|
||||||
// Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log).
|
// 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),
|
// 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.
|
// und die Box bleibt in der Flotte unsichtbar, obwohl sie sendet.
|
||||||
"worker_hello", "worker_ping", "worker_update", "worker_list",
|
"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", "node_stats_stream_start", "node_stats_stream_stop",
|
||||||
"node_stats_history_request", "node_stats_history",
|
"node_stats_history_request", "node_stats_history",
|
||||||
"node_stats_reset", "node_stats_reset_done",
|
"node_stats_reset", "node_stats_reset_done",
|
||||||
@@ -107,6 +124,86 @@ const ALLOWED_TYPES = new Set([
|
|||||||
// Token-Raum: token -> { clients: Set<ws> }
|
// Token-Raum: token -> { clients: Set<ws> }
|
||||||
const rooms = new Map();
|
const rooms = new Map();
|
||||||
|
|
||||||
|
// ── Punkt-zu-Punkt-Antworten ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Der RVS ist ein Broadcast-Relay: normalerweise bekommt JEDER Client im Raum
|
||||||
|
// jede Nachricht. Bei Datei-Antworten ist das fatal. Ein 150-MB-Video wird als
|
||||||
|
// base64 ~200 MB gross und ging bisher an ALLE Clients im Raum — auch an die
|
||||||
|
// STT/TTS-Worker, die daran mit Code 1009 (message too big) gestorben sind und
|
||||||
|
// die gerade laufende Sprachaufnahme mitgerissen haben ("erste Sprachnachricht
|
||||||
|
// kommt nicht durch"). Die Worker-max_size-Angleichung verhindert den Absturz,
|
||||||
|
// aber 200 MB an zehn Clients zu schicken bleibt Unsinn: nur EINER hat gefragt.
|
||||||
|
//
|
||||||
|
// Darum: wer eine Anfrage mit requestId stellt, wird gemerkt — die Antwort mit
|
||||||
|
// derselben requestId geht nur an ihn zurueck. Unbekannte requestId → wie
|
||||||
|
// bisher broadcasten (nichts geht verloren, falls ein Client mithoeren muss,
|
||||||
|
// dessen Anfrage der RVS nie gesehen hat).
|
||||||
|
const TARGETED = new Map([
|
||||||
|
["file_request", "file_response"],
|
||||||
|
["file_zip_request", "file_zip_response"],
|
||||||
|
["file_version_download_request", "file_version_download_response"],
|
||||||
|
["brain_request", "brain_response"],
|
||||||
|
]);
|
||||||
|
const TARGETED_REPLIES = new Set(TARGETED.values());
|
||||||
|
|
||||||
|
// `antwortTyp \0 requestId` -> { clients: Set<ws>, at }
|
||||||
|
// Mehrere Clients koennen dieselbe requestId benutzen (die App nimmt die
|
||||||
|
// Nachrichten-ID) — darum ein Set, sonst bekaeme nur der letzte die Antwort.
|
||||||
|
const pendingReplies = new Map();
|
||||||
|
const PENDING_TTL_MS = 180_000;
|
||||||
|
|
||||||
|
function pendingKey(replyType, requestId) {
|
||||||
|
return `${replyType} | ||||||