Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e04d8f360b | ||
|
|
4685632294 | ||
|
|
769025c41b | ||
|
|
2005e9b85e | ||
|
|
25abd220ad |
@@ -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 20204
|
versionCode 20206
|
||||||
versionName "0.2.2.4"
|
versionName "0.2.2.6"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.2.4",
|
"version": "0.2.2.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -77,11 +77,17 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
|||||||
|
|
||||||
var msg=document.getElementById('msg');
|
var msg=document.getElementById('msg');
|
||||||
|
|
||||||
// Verstecktes Eingabefeld → Software-Tastatur des Handys tippt in die VM.
|
// (Fast) unsichtbares Eingabefeld → Software-Tastatur des Handys tippt in die
|
||||||
|
// VM. WICHTIG: on-screen (nicht off-screen), sonst oeffnet Android die Tastatur
|
||||||
|
// oft nicht bzw. liefert keine Events. opacity:0 + pointer-events:none = sichtbar
|
||||||
|
// fuer den Fokus, aber unsichtbar und klaut keine VM-Touches. font-size:16px
|
||||||
|
// verhindert Auto-Zoom.
|
||||||
var kbd=document.createElement('input');
|
var kbd=document.createElement('input');
|
||||||
kbd.setAttribute('autocomplete','off'); kbd.setAttribute('autocorrect','off');
|
kbd.setAttribute('autocomplete','off'); kbd.setAttribute('autocorrect','off');
|
||||||
kbd.setAttribute('autocapitalize','off'); kbd.spellcheck=false;
|
kbd.setAttribute('autocapitalize','off'); kbd.spellcheck=false;
|
||||||
kbd.style.cssText='position:absolute;left:-1000px;top:0;width:1px;height:1px;opacity:0;';
|
kbd.style.cssText='position:fixed;bottom:0;left:0;width:100%;height:1px;opacity:0;'
|
||||||
|
+'border:0;padding:0;margin:0;background:transparent;color:transparent;'
|
||||||
|
+'caret-color:transparent;font-size:16px;pointer-events:none;';
|
||||||
document.body.appendChild(kbd);
|
document.body.appendChild(kbd);
|
||||||
var SPECIAL={Enter:0xff0d,Backspace:0xff08,Tab:0xff09,Escape:0xff1b,Delete:0xffff,
|
var SPECIAL={Enter:0xff0d,Backspace:0xff08,Tab:0xff09,Escape:0xff1b,Delete:0xffff,
|
||||||
ArrowLeft:0xff51,ArrowUp:0xff52,ArrowRight:0xff53,ArrowDown:0xff54,Home:0xff50,End:0xff57};
|
ArrowLeft:0xff51,ArrowUp:0xff52,ArrowRight:0xff53,ArrowDown:0xff54,Home:0xff50,End:0xff57};
|
||||||
@@ -101,16 +107,27 @@ export const NOVNC_HTML = `<!doctype html><html><head><meta charset="utf-8">
|
|||||||
|
|
||||||
// Tasten aus dem versteckten Feld an die VM schicken.
|
// Tasten aus dem versteckten Feld an die VM schicken.
|
||||||
function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} }
|
function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} }
|
||||||
|
// Sondertasten (feuern zuverlaessig als keydown).
|
||||||
kbd.addEventListener('keydown', function(e){
|
kbd.addEventListener('keydown', function(e){
|
||||||
if(SPECIAL[e.key]!==undefined){ tap(SPECIAL[e.key], e.code); e.preventDefault(); }
|
if(SPECIAL[e.key]!==undefined){ tap(SPECIAL[e.key], e.code); e.preventDefault(); }
|
||||||
});
|
});
|
||||||
|
// Druckbare Zeichen + Editieren: beforeinput ist auf Android robuster als
|
||||||
|
// ein input-Diff. preventDefault haelt das Feld leer → kein Doppel-Senden.
|
||||||
|
kbd.addEventListener('beforeinput', function(e){
|
||||||
|
var t=e.inputType||'';
|
||||||
|
if(t==='insertText' && e.data){ for(var i=0;i<e.data.length;i++) tap(e.data.charCodeAt(i)); e.preventDefault(); }
|
||||||
|
else if(t.indexOf('insertLineBreak')>=0 || t.indexOf('insertParagraph')>=0){ tap(0xff0d); e.preventDefault(); }
|
||||||
|
else if(t.indexOf('deleteContentBackward')>=0){ tap(0xff08); e.preventDefault(); }
|
||||||
|
kbd.value='';
|
||||||
|
});
|
||||||
|
// Fallback, falls beforeinput nicht unterstuetzt wird.
|
||||||
kbd.addEventListener('input', function(){
|
kbd.addEventListener('input', function(){
|
||||||
var v=kbd.value; for(var i=0;i<v.length;i++){ tap(v.charCodeAt(i)); } kbd.value='';
|
if(kbd.value){ for(var i=0;i<kbd.value.length;i++){ tap(kbd.value.charCodeAt(i)); } kbd.value=''; }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Steuerungs-API fuer die App (per injectJavaScript).
|
// Steuerungs-API fuer die App (per injectJavaScript).
|
||||||
window.ariaVncCtl = {
|
window.ariaVncCtl = {
|
||||||
focusKeyboard: function(){ try{ kbd.focus(); }catch(_){} },
|
focusKeyboard: function(){ try{ kbd.value=''; kbd.focus(); }catch(_){} },
|
||||||
blurKeyboard: function(){ try{ kbd.blur(); }catch(_){} },
|
blurKeyboard: function(){ try{ kbd.blur(); }catch(_){} },
|
||||||
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
|
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
|
||||||
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
|
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
|
||||||
|
|||||||
@@ -71,21 +71,6 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verbunden → noVNC-Ansicht der VM + Zurück-Leiste.
|
|
||||||
if (connected) {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.bar}>
|
|
||||||
<TouchableOpacity onPress={() => setConnected(null)} style={styles.barBtn}>
|
|
||||||
<Text style={styles.barBtnText}>‹ VMs</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={styles.barTitle} numberOfLines={1}>{connected.name} · :{connected.vnc_display}</Text>
|
|
||||||
</View>
|
|
||||||
<VncTile projectId={projectId} focused port={connected.vnc_port || (5900 + (connected.vnc_display || 1))} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.bar}>
|
<View style={styles.bar}>
|
||||||
@@ -157,6 +142,18 @@ const DesktopTile: React.FC<Props> = ({ projectId, focused }) => {
|
|||||||
<Text style={styles.shotHint}>Tippen zum Schließen</Text>
|
<Text style={styles.shotHint}>Tippen zum Schließen</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Vollbild-VNC — randlos ueber das ganze Display (Header + Dock weg). */}
|
||||||
|
{connected && (
|
||||||
|
<Modal visible animationType="slide" onRequestClose={() => setConnected(null)} supportedOrientations={['portrait', 'landscape']}>
|
||||||
|
<View style={styles.fs}>
|
||||||
|
<VncTile projectId={projectId} focused port={connected.vnc_port || (5900 + (connected.vnc_display || 1))} />
|
||||||
|
<TouchableOpacity style={styles.fsBack} onPress={() => setConnected(null)} activeOpacity={0.8}>
|
||||||
|
<Text style={styles.fsBackText}>‹ VMs</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -180,6 +177,9 @@ const styles = StyleSheet.create({
|
|||||||
vmBtns: { flexDirection: 'row', gap: 6, alignItems: 'center' },
|
vmBtns: { flexDirection: 'row', gap: 6, alignItems: 'center' },
|
||||||
vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, minWidth: 34, alignItems: 'center' },
|
vmBtn: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 10, paddingVertical: 6, minWidth: 34, alignItems: 'center' },
|
||||||
vmBtnText: { fontSize: 12, fontWeight: '700' },
|
vmBtnText: { fontSize: 12, fontWeight: '700' },
|
||||||
|
fs: { flex: 1, backgroundColor: '#000000' },
|
||||||
|
fsBack: { position: 'absolute', top: 34, left: 10, backgroundColor: 'rgba(18,18,42,0.9)', borderColor: '#2A2A3E', borderWidth: 1, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 7 },
|
||||||
|
fsBackText: { color: '#0096FF', fontSize: 14, fontWeight: '700' },
|
||||||
shotOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center', padding: 12 },
|
shotOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center', padding: 12 },
|
||||||
shotTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', marginBottom: 10 },
|
shotTitle: { color: '#E0E0F0', fontSize: 14, fontWeight: '700', marginBottom: 10 },
|
||||||
shotImg: { width: '100%', height: '78%', backgroundColor: '#000' },
|
shotImg: { width: '100%', height: '78%', backgroundColor: '#000' },
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ const styles = StyleSheet.create({
|
|||||||
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
sub: { color: '#9090B0', fontSize: 14, marginTop: 8 },
|
||||||
ctlBar: {
|
ctlBar: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 8,
|
top: 34,
|
||||||
right: 8,
|
right: 8,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
|
|||||||
+29
-23
@@ -963,17 +963,21 @@ def _docker_gateway() -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _ssh_aria_vm(*args: str, timeout: int = 25):
|
def _ssh_host(*cmd: str, timeout: int = 25):
|
||||||
import subprocess
|
import subprocess
|
||||||
cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
|
full = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
|
||||||
_ARIA_VM_HOST, "aria-vm", *[str(a) for a in args]]
|
_ARIA_VM_HOST, *[str(c) for c in cmd]]
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
r = subprocess.run(full, capture_output=True, text=True, timeout=timeout)
|
||||||
return r.returncode, r.stdout or "", r.stderr or ""
|
return r.returncode, r.stdout or "", r.stderr or ""
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return 1, "", str(exc)
|
return 1, "", str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_aria_vm(*args: str, timeout: int = 25):
|
||||||
|
return _ssh_host("aria-vm", *args, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
def _vm_running_names() -> set:
|
def _vm_running_names() -> set:
|
||||||
rc, out, _err = _ssh_aria_vm("list", timeout=15)
|
rc, out, _err = _ssh_aria_vm("list", timeout=15)
|
||||||
names = set()
|
names = set()
|
||||||
@@ -1074,11 +1078,13 @@ def project_vm_stop(project_id: str, name: str):
|
|||||||
|
|
||||||
@app.post("/projects/{project_id}/vms/{name}/screenshot")
|
@app.post("/projects/{project_id}/vms/{name}/screenshot")
|
||||||
def project_vm_screenshot(project_id: str, name: str):
|
def project_vm_screenshot(project_id: str, name: str):
|
||||||
"""Macht einen Screenshot der laufenden VM (aria-vm screenshot → PNG in
|
"""Macht einen Screenshot der laufenden VM und liefert ihn als Base64.
|
||||||
/shared/uploads) und liefert ihn als Base64 zurueck. So sieht Stefan den
|
|
||||||
VM-Bildschirm auch ohne Live-VNC (genau wie ARIA es beim Testen macht)."""
|
aria-vm schreibt das PNG ins VM-Verzeichnis (dem aria-User gehoerend — nicht
|
||||||
|
ins /root-Shared-Volume, wo der aria-User keinen Zugriff hat). Der Brain holt
|
||||||
|
die Datei danach per SSH (base64) — funktioniert unabhaengig von Volume-
|
||||||
|
Rechten. Zusaetzlich wird das PNG ins Projekt kopiert (Dateien-Panel)."""
|
||||||
import base64
|
import base64
|
||||||
import time as _t
|
|
||||||
rc, out, err = _ssh_aria_vm("screenshot", name, timeout=30)
|
rc, out, err = _ssh_aria_vm("screenshot", name, timeout=30)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
raise HTTPException(status_code=400, detail=f"Screenshot fehlgeschlagen: {(err or out).strip()[:200]}")
|
raise HTTPException(status_code=400, detail=f"Screenshot fehlgeschlagen: {(err or out).strip()[:200]}")
|
||||||
@@ -1088,28 +1094,28 @@ def project_vm_screenshot(project_id: str, name: str):
|
|||||||
path = line.split("=", 1)[1].strip()
|
path = line.split("=", 1)[1].strip()
|
||||||
if not path:
|
if not path:
|
||||||
raise HTTPException(status_code=500, detail=f"Kein Screenshot-Pfad: {out.strip()[:200]}")
|
raise HTTPException(status_code=500, detail=f"Kein Screenshot-Pfad: {out.strip()[:200]}")
|
||||||
local = os.path.join("/shared/uploads", os.path.basename(path))
|
# PNG per SSH als Base64 holen (kein Shared-Volume noetig).
|
||||||
for _ in range(10): # kurze Bind-Mount-Latenz abfangen
|
rc2, b64, err2 = _ssh_host("base64", "-w0", path, timeout=20)
|
||||||
if os.path.isfile(local):
|
if rc2 != 0 or not b64.strip():
|
||||||
break
|
raise HTTPException(status_code=500, detail=f"Screenshot konnte nicht gelesen werden: {(err2 or 'leer').strip()[:200]}")
|
||||||
_t.sleep(0.2)
|
b64 = b64.strip()
|
||||||
if not os.path.isfile(local):
|
try:
|
||||||
raise HTTPException(status_code=500, detail=f"Screenshot-Datei nicht gefunden: {local}")
|
data = base64.b64decode(b64)
|
||||||
with open(local, "rb") as f:
|
except Exception as exc:
|
||||||
data = f.read()
|
raise HTTPException(status_code=500, detail=f"Base64 ungueltig: {exc}")
|
||||||
# Auch ins Projekt kopieren → taucht im Dateien-Panel auf.
|
fname = os.path.basename(path)
|
||||||
|
# Ins Projekt kopieren → taucht im Dateien-Panel auf.
|
||||||
proj_rel = ""
|
proj_rel = ""
|
||||||
try:
|
try:
|
||||||
shots_dir = os.path.join(_project_dir(project_id), "screenshots")
|
shots_dir = os.path.join(_project_dir(project_id), "screenshots")
|
||||||
os.makedirs(shots_dir, exist_ok=True)
|
os.makedirs(shots_dir, exist_ok=True)
|
||||||
with open(os.path.join(shots_dir, os.path.basename(local)), "wb") as f:
|
with open(os.path.join(shots_dir, fname), "wb") as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
proj_rel = "screenshots/" + os.path.basename(local)
|
proj_rel = "screenshots/" + fname
|
||||||
except Exception:
|
except Exception:
|
||||||
proj_rel = ""
|
proj_rel = ""
|
||||||
return {"ok": True, "name": name, "filename": os.path.basename(local),
|
return {"ok": True, "name": name, "filename": fname,
|
||||||
"projectPath": proj_rel,
|
"projectPath": proj_rel, "base64": b64}
|
||||||
"base64": base64.b64encode(data).decode("ascii")}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/conversation/stats")
|
@app.get("/conversation/stats")
|
||||||
|
|||||||
+23
-1
@@ -43,6 +43,25 @@ from openwakeword.model import Model as WakeWordModel
|
|||||||
|
|
||||||
from modes import Mode, canonical_id, detect_mode_switch, mode_from_id, should_speak
|
from modes import Mode, canonical_id, detect_mode_switch, mode_from_id, should_speak
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_gateway() -> str:
|
||||||
|
"""Docker-Gateway-IP (= Host-IP auf DIESEM Container-Netz, aria-net) aus
|
||||||
|
/proc/net/route. Genau die IP, an die der Brain QEMUs VNC bindet — im
|
||||||
|
Gegensatz zu host.docker.internal, das auf die Default-Bridge (docker0)
|
||||||
|
zeigt und daher die VM nicht trifft."""
|
||||||
|
try:
|
||||||
|
import socket as _sock
|
||||||
|
import struct as _struct
|
||||||
|
with open("/proc/net/route") as f:
|
||||||
|
for line in f.readlines()[1:]:
|
||||||
|
fields = line.strip().split()
|
||||||
|
if len(fields) >= 4 and fields[1] == "00000000" and int(fields[3], 16) & 2:
|
||||||
|
return _sock.inet_ntoa(_struct.pack("<L", int(fields[2], 16)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
# ── Logging ──────────────────────────────────────────────────
|
# ── Logging ──────────────────────────────────────────────────
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -738,7 +757,10 @@ class ARIABridge:
|
|||||||
# "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem
|
# "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem
|
||||||
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
|
# Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON).
|
||||||
self._vnc_sessions: dict[str, dict] = {}
|
self._vnc_sessions: dict[str, dict] = {}
|
||||||
self._vnc_host: str = os.environ.get("ARIA_VNC_HOST", "host.docker.internal")
|
# VNC-Host: das aria-net-Gateway (dort bindet der Brain QEMUs VNC).
|
||||||
|
# host.docker.internal zeigt faelschlich auf docker0 (172.17.0.1) → refused.
|
||||||
|
self._vnc_host: str = (os.environ.get("ARIA_VNC_HOST")
|
||||||
|
or _docker_gateway() or "host.docker.internal")
|
||||||
# Satelliten (Aussenposten in fremden Netzen). id → {location, caps,
|
# Satelliten (Aussenposten in fremden Netzen). id → {location, caps,
|
||||||
# control, last_seen}. Registrierung via sat_hello. _pending_sat:
|
# control, last_seen}. Registrierung via sat_hello. _pending_sat:
|
||||||
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
|
# requestId → Future (sat_devices / sat_result), analog _pending_flux.
|
||||||
|
|||||||
@@ -147,16 +147,20 @@ cmd_screenshot() {
|
|||||||
local d; d="$(vm_dir "${name}")"
|
local d; d="$(vm_dir "${name}")"
|
||||||
vm_running "${name}" || die "VM '${name}' laeuft nicht"
|
vm_running "${name}" || die "VM '${name}' laeuft nicht"
|
||||||
command -v socat >/dev/null || die "socat fehlt (qemu-setup.sh?)"
|
command -v socat >/dev/null || die "socat fehlt (qemu-setup.sh?)"
|
||||||
mkdir -p "${SHOT_DIR}"
|
# Standard: ins VM-Verzeichnis schreiben (dem aria-User gehoerend) — NICHT
|
||||||
|
# nach /root/... (da kommt der aria-User nicht hin). Der Brain holt das PNG
|
||||||
|
# danach per SSH (base64). Ueberschreibbar via ARIA_VM_SHOT_DIR.
|
||||||
|
local out_dir="${ARIA_VM_SHOT_DIR:-${d}}"
|
||||||
|
mkdir -p "${out_dir}"
|
||||||
local ts; ts="$(date +%s)"
|
local ts; ts="$(date +%s)"
|
||||||
local ppm="${d}/shot-${ts}.ppm"
|
local ppm="${d}/shot-${ts}.ppm"
|
||||||
printf 'screendump %s\n' "${ppm}" | socat - "unix-connect:${d}/monitor.sock" >/dev/null
|
printf 'screendump %s\n' "${ppm}" | socat - "unix-connect:${d}/monitor.sock" >/dev/null
|
||||||
sleep 0.3
|
sleep 0.3
|
||||||
local out="${SHOT_DIR}/${name}-${ts}.png"
|
local out="${out_dir}/${name}-${ts}.png"
|
||||||
if command -v convert >/dev/null; then
|
if command -v convert >/dev/null; then
|
||||||
convert "${ppm}" "${out}" && rm -f "${ppm}"
|
convert "${ppm}" "${out}" && rm -f "${ppm}"
|
||||||
else
|
else
|
||||||
out="${SHOT_DIR}/${name}-${ts}.ppm"; mv "${ppm}" "${out}"
|
out="${out_dir}/${name}-${ts}.ppm"; mv "${ppm}" "${out}"
|
||||||
fi
|
fi
|
||||||
echo "screenshot=${out}"
|
echo "screenshot=${out}"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user