Spaces:
Running
Running
Upload folder using huggingface_hub (part 5)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- plugins/_context_window/extensions/python/message_loop_prompts_after/_99_capture_context_usage.py +8 -0
- plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js +42 -0
- plugins/_context_window/extensions/webui/interface-controls-end/context-window.html +40 -0
- plugins/_context_window/extensions/webui/model-context-strip-end/context-window.html +241 -0
- plugins/_context_window/helpers/__init__.py +1 -0
- plugins/_context_window/helpers/usage.py +278 -0
- plugins/_context_window/plugin.yaml +8 -0
- plugins/_context_window/tests/__init__.py +1 -0
- plugins/_context_window/tests/test_context_window.py +434 -0
- plugins/_context_window/webui/context-window-store.js +160 -0
- plugins/_context_window/webui/thumbnail.webp +0 -0
- plugins/_desktop/AGENTS.md +33 -0
- plugins/_desktop/api/desktop_session.py +160 -0
- plugins/_desktop/assets/desktop/README.md +142 -0
- plugins/_desktop/extensions/python/message_loop_prompts_after/_55_include_desktop_state.py +19 -0
- plugins/_desktop/extensions/python/startup_migration/_20_desktop_routes.py +49 -0
- plugins/_desktop/extensions/webui/right-canvas-panels/desktop-panel.html +15 -0
- plugins/_desktop/extensions/webui/right-canvas-toolbar-start/desktop-new-menu.html +38 -0
- plugins/_desktop/extensions/webui/right_canvas_register_surfaces/register-desktop.js +49 -0
- plugins/_desktop/extensions/webui/surfaces_register/register-desktop.js +49 -0
- plugins/_desktop/helpers/desktop_routes.py +6 -0
- plugins/_desktop/helpers/desktop_session.py +2601 -0
- plugins/_desktop/helpers/desktop_state.py +893 -0
- plugins/_desktop/helpers/prompt_context.py +18 -0
- plugins/_desktop/hooks.py +456 -0
- plugins/_desktop/plugin.yaml +5 -0
- plugins/_desktop/prompts/agent.extras.desktop_state.md +1 -0
- plugins/_desktop/skills/linux-desktop/SKILL.md +202 -0
- plugins/_desktop/skills/linux-desktop/scripts/calc_set_cell.py +315 -0
- plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh +459 -0
- plugins/_desktop/webui/desktop-panel.html +734 -0
- plugins/_desktop/webui/desktop-store.js +0 -0
- plugins/_desktop/webui/main.html +21 -0
- plugins/_desktop/webui/thumbnail.jpg +0 -0
- plugins/_discovery/AGENTS.md +31 -0
- plugins/_discovery/extensions/python/banners/10_discovery_cards.py +154 -0
- plugins/_discovery/extensions/webui/initFw_end/auto-modal.js +136 -0
- plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html +597 -0
- plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html +557 -0
- plugins/_discovery/plugin.yaml +8 -0
- plugins/_discovery/webui/assets/cli-connector-preview.png +0 -0
- plugins/_discovery/webui/assets/hero-openai-oauth.png +0 -0
- plugins/_discovery/webui/assets/hero-plugin-hub.png +0 -0
- plugins/_discovery/webui/assets/thumb-email.png +0 -0
- plugins/_discovery/webui/assets/thumb-telegram.png +0 -0
- plugins/_discovery/webui/assets/thumb-whatsapp.png +0 -0
- plugins/_discovery/webui/discovery-store.js +190 -0
- plugins/_discovery/webui/thumbnail.jpg +0 -0
- plugins/_document_query/AGENTS.md +31 -0
- plugins/_document_query/README.md +62 -0
plugins/_context_window/extensions/python/message_loop_prompts_after/_99_capture_context_usage.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from agent import LoopData
|
| 2 |
+
from helpers.extension import Extension
|
| 3 |
+
from plugins._context_window.helpers.usage import capture_context
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class CaptureContextUsage(Extension):
|
| 7 |
+
def execute(self, loop_data: LoopData | None = None, **kwargs):
|
| 8 |
+
capture_context(self.agent, loop_data)
|
plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { store as contextWindowStore } from "/plugins/_context_window/webui/context-window-store.js";
|
| 2 |
+
|
| 3 |
+
const OVERRIDE_REVISION_KEY = "_model_config_override_revision";
|
| 4 |
+
let lastContextId = "";
|
| 5 |
+
let lastRevision = null;
|
| 6 |
+
let lastGenerationKey = "";
|
| 7 |
+
|
| 8 |
+
function latestGenerationKey(logs) {
|
| 9 |
+
if (!Array.isArray(logs)) return "";
|
| 10 |
+
for (let index = logs.length - 1; index >= 0; index--) {
|
| 11 |
+
const item = logs[index];
|
| 12 |
+
if (item?.type !== "agent" || Number(item.agentno || 0) !== 0) continue;
|
| 13 |
+
return `${item.no ?? ""}:${item.id ?? ""}`;
|
| 14 |
+
}
|
| 15 |
+
return "";
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export default async function refreshContextWindow(ctx) {
|
| 19 |
+
const snapshot = ctx?.snapshot;
|
| 20 |
+
const contextId = String(snapshot?.context || "");
|
| 21 |
+
if (!contextId) {
|
| 22 |
+
lastContextId = "";
|
| 23 |
+
lastRevision = null;
|
| 24 |
+
lastGenerationKey = "";
|
| 25 |
+
return;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const contexts = Array.isArray(snapshot?.contexts) ? snapshot.contexts : [];
|
| 29 |
+
const active = contexts.find(item => item?.id === contextId) || null;
|
| 30 |
+
const revision = active?.[OVERRIDE_REVISION_KEY] || null;
|
| 31 |
+
const generationKey = latestGenerationKey(snapshot?.logs);
|
| 32 |
+
if (
|
| 33 |
+
contextId === lastContextId
|
| 34 |
+
&& revision === lastRevision
|
| 35 |
+
&& (!generationKey || generationKey === lastGenerationKey)
|
| 36 |
+
) return;
|
| 37 |
+
|
| 38 |
+
lastContextId = contextId;
|
| 39 |
+
lastRevision = revision;
|
| 40 |
+
if (generationKey) lastGenerationKey = generationKey;
|
| 41 |
+
await contextWindowStore.refresh(contextId);
|
| 42 |
+
}
|
plugins/_context_window/extensions/webui/interface-controls-end/context-window.html
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script type="module">
|
| 2 |
+
import { store } from "/plugins/_context_window/webui/context-window-store.js";
|
| 3 |
+
</script>
|
| 4 |
+
|
| 5 |
+
<div x-data>
|
| 6 |
+
<template x-if="$store.settings.uiVisibility">
|
| 7 |
+
<div class="ui-visibility-row">
|
| 8 |
+
<div class="ui-visibility-control">
|
| 9 |
+
<x-icon aria-hidden="true" name="donut_large"></x-icon>
|
| 10 |
+
<div>
|
| 11 |
+
<div class="field-title">Context window</div>
|
| 12 |
+
<div class="ui-visibility-state" x-text="$store.settings.uiControlVisibilityLabel('contextWindowUsage')"></div>
|
| 13 |
+
</div>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<div class="ui-device-selector" aria-label="Context window visibility">
|
| 17 |
+
<button type="button"
|
| 18 |
+
class="ui-device-button"
|
| 19 |
+
:class="{ 'is-visible': $store.settings.isUiControlVisible('contextWindowUsage', 'mobile') }"
|
| 20 |
+
aria-label="Show context window on mobile"
|
| 21 |
+
:aria-pressed="$store.settings.isUiControlVisible('contextWindowUsage', 'mobile').toString()"
|
| 22 |
+
:title="$store.settings.isUiControlVisible('contextWindowUsage', 'mobile') ? 'Shown on mobile' : 'Hidden on mobile'"
|
| 23 |
+
@click="$store.settings.toggleUiControl('contextWindowUsage', 'mobile')">
|
| 24 |
+
<x-icon aria-hidden="true" name="smartphone"></x-icon>
|
| 25 |
+
<span>Mobile</span>
|
| 26 |
+
</button>
|
| 27 |
+
<button type="button"
|
| 28 |
+
class="ui-device-button"
|
| 29 |
+
:class="{ 'is-visible': $store.settings.isUiControlVisible('contextWindowUsage', 'desktop') }"
|
| 30 |
+
aria-label="Show context window on desktop"
|
| 31 |
+
:aria-pressed="$store.settings.isUiControlVisible('contextWindowUsage', 'desktop').toString()"
|
| 32 |
+
:title="$store.settings.isUiControlVisible('contextWindowUsage', 'desktop') ? 'Shown on desktop' : 'Hidden on desktop'"
|
| 33 |
+
@click="$store.settings.toggleUiControl('contextWindowUsage', 'desktop')">
|
| 34 |
+
<x-icon aria-hidden="true" name="desktop_windows"></x-icon>
|
| 35 |
+
<span>Desktop</span>
|
| 36 |
+
</button>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
</template>
|
| 40 |
+
</div>
|
plugins/_context_window/extensions/webui/model-context-strip-end/context-window.html
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script type="module">
|
| 2 |
+
import { store } from "/plugins/_context_window/webui/context-window-store.js";
|
| 3 |
+
</script>
|
| 4 |
+
|
| 5 |
+
<div x-data>
|
| 6 |
+
<template x-if="$store.contextWindow && $store.preferences.isUiControlVisible('contextWindowUsage')">
|
| 7 |
+
<div class="context-window-anchor"
|
| 8 |
+
x-create="$store.contextWindow.onMount($watch)"
|
| 9 |
+
x-destroy="$store.contextWindow.cleanup()"
|
| 10 |
+
@click.outside="$store.contextWindow.open = false"
|
| 11 |
+
@keydown.escape.window="$store.contextWindow.open = false">
|
| 12 |
+
<button type="button"
|
| 13 |
+
class="context-window-button"
|
| 14 |
+
:aria-expanded="$store.contextWindow.open"
|
| 15 |
+
:aria-label="$store.contextWindow.usage.ariaLabel"
|
| 16 |
+
@click="$store.contextWindow.toggle()">
|
| 17 |
+
<svg viewBox="0 0 36 36" aria-hidden="true">
|
| 18 |
+
<circle class="context-window-ring-track" cx="18" cy="18" r="15.5"></circle>
|
| 19 |
+
<circle class="context-window-ring-value" cx="18" cy="18" r="15.5" pathLength="100"
|
| 20 |
+
:stroke-dasharray="$store.contextWindow.usage.ringDasharray"></circle>
|
| 21 |
+
</svg>
|
| 22 |
+
<span x-text="$store.contextWindow.usage.ringLabel"></span>
|
| 23 |
+
</button>
|
| 24 |
+
|
| 25 |
+
<div class="context-window-popover"
|
| 26 |
+
x-show="$store.contextWindow.open"
|
| 27 |
+
x-transition.opacity
|
| 28 |
+
style="display: none;"
|
| 29 |
+
role="dialog"
|
| 30 |
+
aria-label="Context window usage">
|
| 31 |
+
<div class="context-window-header">
|
| 32 |
+
<strong>Context window</strong>
|
| 33 |
+
<div class="context-window-summary">
|
| 34 |
+
<span class="context-window-summary-tokens" x-text="$store.contextWindow.usage.summaryTokens"></span>
|
| 35 |
+
<span class="context-window-summary-percent" x-text="$store.contextWindow.usage.summaryPercent"></span>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
<div class="context-window-meter" aria-hidden="true">
|
| 39 |
+
<span :style="$store.contextWindow.usage.meterStyle"></span>
|
| 40 |
+
</div>
|
| 41 |
+
<template x-if="$store.contextWindow.usage.hasBreakdown">
|
| 42 |
+
<div class="context-window-rows">
|
| 43 |
+
<template x-for="row in $store.contextWindow.usage.rows" :key="row.key">
|
| 44 |
+
<div class="context-window-row">
|
| 45 |
+
<span x-text="row.label"></span>
|
| 46 |
+
<span class="context-window-value context-window-tokens" x-text="row.tokensLabel"></span>
|
| 47 |
+
<span class="context-window-value" x-text="row.percentLabel"></span>
|
| 48 |
+
</div>
|
| 49 |
+
</template>
|
| 50 |
+
</div>
|
| 51 |
+
</template>
|
| 52 |
+
<template x-if="$store.contextWindow.usage.missingBreakdown">
|
| 53 |
+
<div class="context-window-empty">Breakdown available after the next message.</div>
|
| 54 |
+
</template>
|
| 55 |
+
<template x-if="$store.contextWindow.usage.provider.hasData">
|
| 56 |
+
<div class="context-window-provider">
|
| 57 |
+
<template x-if="$store.contextWindow.usage.provider.price.hasData">
|
| 58 |
+
<div class="context-window-provider-row context-window-provider-price">
|
| 59 |
+
<span>Price</span>
|
| 60 |
+
<span class="context-window-value" x-text="$store.contextWindow.usage.provider.price.label"></span>
|
| 61 |
+
</div>
|
| 62 |
+
</template>
|
| 63 |
+
<template x-if="$store.contextWindow.usage.provider.cache.hasData">
|
| 64 |
+
<div class="context-window-provider-row">
|
| 65 |
+
<span>Cache hit</span>
|
| 66 |
+
<span class="context-window-value" x-text="$store.contextWindow.usage.provider.cache.label"></span>
|
| 67 |
+
</div>
|
| 68 |
+
</template>
|
| 69 |
+
<template x-if="$store.contextWindow.usage.provider.tokens">
|
| 70 |
+
<div class="context-window-provider-row">
|
| 71 |
+
<span>Tokens In/Out</span>
|
| 72 |
+
<span class="context-window-value" x-text="$store.contextWindow.usage.provider.tokens"></span>
|
| 73 |
+
</div>
|
| 74 |
+
</template>
|
| 75 |
+
</div>
|
| 76 |
+
</template>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
</template>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<style>
|
| 83 |
+
.context-window-anchor {
|
| 84 |
+
position: static;
|
| 85 |
+
display: flex;
|
| 86 |
+
align-items: center;
|
| 87 |
+
}
|
| 88 |
+
.context-window-button {
|
| 89 |
+
position: relative;
|
| 90 |
+
display: grid;
|
| 91 |
+
place-items: center;
|
| 92 |
+
width: 1.8rem;
|
| 93 |
+
height: 1.8rem;
|
| 94 |
+
padding: 0;
|
| 95 |
+
border: 0;
|
| 96 |
+
border-radius: 50%;
|
| 97 |
+
background: transparent;
|
| 98 |
+
color: var(--color-text);
|
| 99 |
+
cursor: pointer;
|
| 100 |
+
opacity: .82;
|
| 101 |
+
}
|
| 102 |
+
.context-window-button:hover {
|
| 103 |
+
background: var(--color-background-hover);
|
| 104 |
+
opacity: 1;
|
| 105 |
+
}
|
| 106 |
+
.context-window-button svg {
|
| 107 |
+
position: absolute;
|
| 108 |
+
inset: 2px;
|
| 109 |
+
width: calc(100% - 4px);
|
| 110 |
+
height: calc(100% - 4px);
|
| 111 |
+
transform: rotate(-90deg);
|
| 112 |
+
fill: none;
|
| 113 |
+
stroke-width: 3;
|
| 114 |
+
}
|
| 115 |
+
.context-window-ring-track {
|
| 116 |
+
stroke: color-mix(in srgb, var(--color-text) 18%, transparent);
|
| 117 |
+
}
|
| 118 |
+
.context-window-ring-value {
|
| 119 |
+
stroke: var(--color-highlight);
|
| 120 |
+
stroke-linecap: round;
|
| 121 |
+
transition: stroke-dasharray .18s ease;
|
| 122 |
+
}
|
| 123 |
+
.context-window-button > span {
|
| 124 |
+
position: relative;
|
| 125 |
+
z-index: 1;
|
| 126 |
+
font-family: var(--font-family-main);
|
| 127 |
+
font-size: .42rem;
|
| 128 |
+
font-weight: 600;
|
| 129 |
+
letter-spacing: -.03em;
|
| 130 |
+
}
|
| 131 |
+
.context-window-popover {
|
| 132 |
+
position: absolute;
|
| 133 |
+
right: 0;
|
| 134 |
+
bottom: calc(100% + 6px);
|
| 135 |
+
z-index: 1050;
|
| 136 |
+
width: min(19rem, calc(100vw - 2rem));
|
| 137 |
+
padding: .9rem;
|
| 138 |
+
border: 1px solid var(--color-border);
|
| 139 |
+
border-radius: 12px;
|
| 140 |
+
background-color: color-mix(in srgb, var(--color-panel) 90%, black 10%);
|
| 141 |
+
box-shadow: 0 16px 38px rgba(0, 0, 0, .3);
|
| 142 |
+
}
|
| 143 |
+
.context-window-header,
|
| 144 |
+
.context-window-row,
|
| 145 |
+
.context-window-provider-row {
|
| 146 |
+
display: grid;
|
| 147 |
+
align-items: center;
|
| 148 |
+
}
|
| 149 |
+
.context-window-header {
|
| 150 |
+
gap: .4rem;
|
| 151 |
+
font-size: .86rem;
|
| 152 |
+
}
|
| 153 |
+
.context-window-summary,
|
| 154 |
+
.context-window-value {
|
| 155 |
+
font-family: var(--font-family-main);
|
| 156 |
+
font-variant-numeric: tabular-nums;
|
| 157 |
+
}
|
| 158 |
+
.context-window-summary {
|
| 159 |
+
display: flex;
|
| 160 |
+
align-items: baseline;
|
| 161 |
+
justify-content: space-between;
|
| 162 |
+
gap: .75rem;
|
| 163 |
+
color: color-mix(in srgb, var(--color-text) 72%, transparent);
|
| 164 |
+
font-size: .76rem;
|
| 165 |
+
}
|
| 166 |
+
.context-window-summary-percent {
|
| 167 |
+
color: var(--color-text);
|
| 168 |
+
}
|
| 169 |
+
.context-window-summary-tokens,
|
| 170 |
+
.context-window-tokens {
|
| 171 |
+
font-family: var(--font-family-code);
|
| 172 |
+
}
|
| 173 |
+
.context-window-provider {
|
| 174 |
+
margin-top: .7rem;
|
| 175 |
+
padding-top: .7rem;
|
| 176 |
+
border-top: 1px solid var(--color-border);
|
| 177 |
+
}
|
| 178 |
+
.context-window-provider-row {
|
| 179 |
+
grid-template-columns: minmax(0, 1fr) auto;
|
| 180 |
+
gap: .75rem;
|
| 181 |
+
min-height: 1.35rem;
|
| 182 |
+
color: color-mix(in srgb, var(--color-text) 72%, transparent);
|
| 183 |
+
font-size: .76rem;
|
| 184 |
+
}
|
| 185 |
+
.context-window-provider-price {
|
| 186 |
+
margin-bottom: .25rem;
|
| 187 |
+
color: var(--color-text);
|
| 188 |
+
font-size: .82rem;
|
| 189 |
+
}
|
| 190 |
+
.context-window-provider-price .context-window-value {
|
| 191 |
+
font-size: 1rem;
|
| 192 |
+
font-weight: 600;
|
| 193 |
+
}
|
| 194 |
+
.context-window-meter {
|
| 195 |
+
height: .45rem;
|
| 196 |
+
margin: .4rem 0 .75rem;
|
| 197 |
+
overflow: hidden;
|
| 198 |
+
border-radius: 999px;
|
| 199 |
+
background: color-mix(in srgb, var(--color-text) 9%, transparent);
|
| 200 |
+
}
|
| 201 |
+
.context-window-meter > span {
|
| 202 |
+
display: block;
|
| 203 |
+
height: 100%;
|
| 204 |
+
border-radius: inherit;
|
| 205 |
+
background: var(--color-highlight);
|
| 206 |
+
transition: width .18s ease;
|
| 207 |
+
}
|
| 208 |
+
.context-window-rows {
|
| 209 |
+
display: grid;
|
| 210 |
+
gap: .3rem;
|
| 211 |
+
}
|
| 212 |
+
.context-window-empty {
|
| 213 |
+
padding: .35rem 0 .15rem;
|
| 214 |
+
color: color-mix(in srgb, var(--color-text) 62%, transparent);
|
| 215 |
+
font-size: .8rem;
|
| 216 |
+
}
|
| 217 |
+
.context-window-row {
|
| 218 |
+
grid-template-columns: minmax(0, 1fr) auto 3rem;
|
| 219 |
+
gap: .5rem;
|
| 220 |
+
min-height: 1.5rem;
|
| 221 |
+
color: color-mix(in srgb, var(--color-text) 78%, transparent);
|
| 222 |
+
font-size: .8rem;
|
| 223 |
+
}
|
| 224 |
+
.context-window-value {
|
| 225 |
+
color: var(--color-text);
|
| 226 |
+
text-align: right;
|
| 227 |
+
}
|
| 228 |
+
.context-window-tokens {
|
| 229 |
+
color: color-mix(in srgb, var(--color-text) 62%, transparent);
|
| 230 |
+
}
|
| 231 |
+
@media (max-width: 25rem) {
|
| 232 |
+
.context-window-popover {
|
| 233 |
+
right: 1.25rem;
|
| 234 |
+
width: min(17rem, calc(100vw - 3rem));
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
.light-mode .context-window-popover {
|
| 238 |
+
background: var(--color-panel);
|
| 239 |
+
box-shadow: 0 4px 14px rgba(0, 0, 0, .12);
|
| 240 |
+
}
|
| 241 |
+
</style>
|
plugins/_context_window/helpers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
plugins/_context_window/helpers/usage.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import math
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from helpers import files, history, skills, tokens
|
| 6 |
+
from helpers.llm_result import result_from_metadata
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
PARTS_KEY = "context_window_usage"
|
| 10 |
+
CACHE_KEY = "_context_window_usage_cache"
|
| 11 |
+
PROVIDER_USAGE_KEY = "context_window_provider_usage"
|
| 12 |
+
USAGE_KEYS = (
|
| 13 |
+
"messages",
|
| 14 |
+
"system_tools",
|
| 15 |
+
"skills",
|
| 16 |
+
"mcp_tools",
|
| 17 |
+
"system_prompt",
|
| 18 |
+
"extras",
|
| 19 |
+
)
|
| 20 |
+
MEASURED_KEYS = tuple(key for key in USAGE_KEYS if key != "system_prompt")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def reset(agent: Any) -> None:
|
| 24 |
+
params = _temporary_params(agent)
|
| 25 |
+
if params is not None:
|
| 26 |
+
params[PARTS_KEY] = {}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def discard(agent: Any) -> None:
|
| 30 |
+
params = _temporary_params(agent)
|
| 31 |
+
if params is not None:
|
| 32 |
+
params.pop(PARTS_KEY, None)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def record_prompt(agent: Any, key: str, prompt: Any) -> None:
|
| 36 |
+
parts = _parts(agent)
|
| 37 |
+
if parts is None or key not in MEASURED_KEYS:
|
| 38 |
+
return
|
| 39 |
+
text = files.remove_code_fences(str(prompt or ""), language="json")
|
| 40 |
+
parts[key] = _cached_tokens(agent, f"prompt:{key}", text)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def capture_context(agent: Any, loop_data: Any) -> None:
|
| 44 |
+
parts = _parts(agent)
|
| 45 |
+
if parts is None or loop_data is None:
|
| 46 |
+
return
|
| 47 |
+
|
| 48 |
+
output = list(getattr(loop_data, "history_output", None) or [])
|
| 49 |
+
parts["_history_output"] = output
|
| 50 |
+
skill_output = [message for message in output if skills.skill_instruction_name(message)]
|
| 51 |
+
skill_tokens = _output_tokens(agent, "history_skills", skill_output)
|
| 52 |
+
parts["messages"] = max(_history_tokens(agent, output) - skill_tokens, 0)
|
| 53 |
+
parts["skills"] = parts.get("skills", 0) + skill_tokens
|
| 54 |
+
|
| 55 |
+
protocol_values = {
|
| 56 |
+
**getattr(loop_data, "protocol_persistent", {}),
|
| 57 |
+
**getattr(loop_data, "protocol_temporary", {}),
|
| 58 |
+
}
|
| 59 |
+
extras_values = {
|
| 60 |
+
**getattr(loop_data, "extras_persistent", {}),
|
| 61 |
+
**getattr(loop_data, "extras_temporary", {}),
|
| 62 |
+
}
|
| 63 |
+
protocol = agent._build_context_message(
|
| 64 |
+
"agent.context.protocol.md",
|
| 65 |
+
"protocol",
|
| 66 |
+
protocol_values,
|
| 67 |
+
include_empty=False,
|
| 68 |
+
)
|
| 69 |
+
extras = agent._build_context_message(
|
| 70 |
+
"agent.context.extras.md",
|
| 71 |
+
"extras",
|
| 72 |
+
extras_values,
|
| 73 |
+
include_empty=True,
|
| 74 |
+
)
|
| 75 |
+
parts["extras"] = _output_tokens(agent, "extras", protocol + extras)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def finalize(agent: Any) -> None:
|
| 79 |
+
params = _temporary_params(agent)
|
| 80 |
+
parts = params.pop(PARTS_KEY, None) if params is not None else None
|
| 81 |
+
window = agent.get_data(agent.DATA_NAME_CTX_WINDOW) if agent else None
|
| 82 |
+
if not isinstance(parts, dict) or not isinstance(window, dict):
|
| 83 |
+
return
|
| 84 |
+
|
| 85 |
+
history_output = parts.pop("_history_output", None)
|
| 86 |
+
total = _non_negative_int(window.get("tokens"))
|
| 87 |
+
usage = {key: _non_negative_int(parts.get(key)) for key in MEASURED_KEYS}
|
| 88 |
+
measured_total = sum(usage.values())
|
| 89 |
+
if total and measured_total >= total and isinstance(history_output, list):
|
| 90 |
+
message_output = [
|
| 91 |
+
message
|
| 92 |
+
for message in history_output
|
| 93 |
+
if not skills.skill_instruction_name(message)
|
| 94 |
+
]
|
| 95 |
+
usage["messages"] = _output_tokens(
|
| 96 |
+
agent, "history_messages", message_output
|
| 97 |
+
)
|
| 98 |
+
measured_total = sum(usage.values())
|
| 99 |
+
if measured_total > total and measured_total:
|
| 100 |
+
usage = _scale_to_total(usage, total, measured_total)
|
| 101 |
+
measured_total = total
|
| 102 |
+
usage["system_prompt"] = total - measured_total
|
| 103 |
+
usage = {key: usage.get(key, 0) for key in USAGE_KEYS}
|
| 104 |
+
|
| 105 |
+
updated = dict(window)
|
| 106 |
+
updated["usage"] = usage
|
| 107 |
+
agent.set_data(agent.DATA_NAME_CTX_WINDOW, updated)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def usage_snapshot(value: Any) -> dict[str, int]:
|
| 111 |
+
if not isinstance(value, dict):
|
| 112 |
+
return {}
|
| 113 |
+
return {key: _non_negative_int(value.get(key)) for key in USAGE_KEYS}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def capture_provider_usage(agent: Any, result: Any) -> None:
|
| 117 |
+
if agent is None or result is None or not hasattr(result, "usage"):
|
| 118 |
+
return
|
| 119 |
+
|
| 120 |
+
snapshot = provider_usage_snapshot(getattr(result, "usage", None))
|
| 121 |
+
agent.set_data(
|
| 122 |
+
PROVIDER_USAGE_KEY,
|
| 123 |
+
snapshot if snapshot else {"available": False},
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def latest_provider_usage(agent: Any) -> dict[str, int | float]:
|
| 128 |
+
data = getattr(agent, "data", None)
|
| 129 |
+
if isinstance(data, dict) and PROVIDER_USAGE_KEY in data:
|
| 130 |
+
stored = data.get(PROVIDER_USAGE_KEY)
|
| 131 |
+
if isinstance(stored, dict) and stored.get("available") is False:
|
| 132 |
+
return {}
|
| 133 |
+
return provider_usage_snapshot(stored)
|
| 134 |
+
|
| 135 |
+
all_messages = getattr(getattr(agent, "history", None), "all_messages", None)
|
| 136 |
+
if not callable(all_messages):
|
| 137 |
+
return {}
|
| 138 |
+
for message in reversed(all_messages()):
|
| 139 |
+
if not getattr(message, "ai", False):
|
| 140 |
+
continue
|
| 141 |
+
result = result_from_metadata(getattr(message, "metadata", None))
|
| 142 |
+
if result:
|
| 143 |
+
return provider_usage_snapshot(result.usage)
|
| 144 |
+
return {}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def provider_usage_snapshot(value: Any) -> dict[str, int | float]:
|
| 148 |
+
if not isinstance(value, dict):
|
| 149 |
+
return {}
|
| 150 |
+
|
| 151 |
+
input_details = {
|
| 152 |
+
**_mapping(value.get("prompt_tokens_details")),
|
| 153 |
+
**_mapping(value.get("input_tokens_details")),
|
| 154 |
+
}
|
| 155 |
+
result: dict[str, int | float] = {}
|
| 156 |
+
fields = {
|
| 157 |
+
"input_tokens": (value.get("input_tokens"), value.get("prompt_tokens")),
|
| 158 |
+
"cached_tokens": (
|
| 159 |
+
input_details.get("cached_tokens"),
|
| 160 |
+
input_details.get("cache_read_tokens"),
|
| 161 |
+
value.get("cache_read_input_tokens"),
|
| 162 |
+
value.get("cached_tokens"),
|
| 163 |
+
),
|
| 164 |
+
"output_tokens": (
|
| 165 |
+
value.get("output_tokens"),
|
| 166 |
+
value.get("completion_tokens"),
|
| 167 |
+
),
|
| 168 |
+
}
|
| 169 |
+
for key, values in fields.items():
|
| 170 |
+
number = _optional_non_negative_int(*values)
|
| 171 |
+
if number is not None:
|
| 172 |
+
result[key] = number
|
| 173 |
+
|
| 174 |
+
cost = _optional_non_negative_float(
|
| 175 |
+
value.get("cost"), value.get("response_cost")
|
| 176 |
+
)
|
| 177 |
+
if cost is not None:
|
| 178 |
+
result["cost"] = cost
|
| 179 |
+
return result
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _parts(agent: Any) -> dict[str, Any] | None:
|
| 183 |
+
params = _temporary_params(agent)
|
| 184 |
+
value = params.get(PARTS_KEY) if params is not None else None
|
| 185 |
+
return value if isinstance(value, dict) else None
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _temporary_params(agent: Any) -> dict[str, Any] | None:
|
| 189 |
+
loop_data = getattr(agent, "loop_data", None)
|
| 190 |
+
params = getattr(loop_data, "params_temporary", None)
|
| 191 |
+
return params if isinstance(params, dict) else None
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _history_tokens(agent: Any, output: list[history.OutputMessage]) -> int:
|
| 195 |
+
get_tokens = getattr(getattr(agent, "history", None), "get_tokens", None)
|
| 196 |
+
if callable(get_tokens):
|
| 197 |
+
return _non_negative_int(get_tokens())
|
| 198 |
+
return _count_output_tokens(output)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _output_tokens(
|
| 202 |
+
agent: Any, cache_key: str, output: list[history.OutputMessage]
|
| 203 |
+
) -> int:
|
| 204 |
+
text = history.output_text(output, ai_label="assistant", human_label="user")
|
| 205 |
+
return _cached_tokens(agent, cache_key, text)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _count_output_tokens(output: list[history.OutputMessage]) -> int:
|
| 209 |
+
text = history.output_text(output, ai_label="assistant", human_label="user")
|
| 210 |
+
return tokens.approximate_prompt_tokens(text)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _cached_tokens(agent: Any, key: str, text: str) -> int:
|
| 214 |
+
cache = _cache(agent)
|
| 215 |
+
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
| 216 |
+
cached = cache.get(key) if cache is not None else None
|
| 217 |
+
if isinstance(cached, tuple) and len(cached) == 2 and cached[0] == digest:
|
| 218 |
+
return _non_negative_int(cached[1])
|
| 219 |
+
|
| 220 |
+
count = tokens.approximate_prompt_tokens(text)
|
| 221 |
+
if cache is not None:
|
| 222 |
+
cache[key] = (digest, count)
|
| 223 |
+
return count
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _cache(agent: Any) -> dict[str, tuple[str, int]] | None:
|
| 227 |
+
data = getattr(agent, "data", None)
|
| 228 |
+
if not isinstance(data, dict):
|
| 229 |
+
return None
|
| 230 |
+
cache = data.get(CACHE_KEY)
|
| 231 |
+
if not isinstance(cache, dict):
|
| 232 |
+
cache = {}
|
| 233 |
+
data[CACHE_KEY] = cache
|
| 234 |
+
return cache
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _non_negative_int(value: Any) -> int:
|
| 238 |
+
try:
|
| 239 |
+
return max(int(value or 0), 0)
|
| 240 |
+
except (TypeError, ValueError):
|
| 241 |
+
return 0
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _mapping(value: Any) -> dict[str, Any]:
|
| 245 |
+
return value if isinstance(value, dict) else {}
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _optional_non_negative_int(*values: Any) -> int | None:
|
| 249 |
+
for value in values:
|
| 250 |
+
if value is None:
|
| 251 |
+
continue
|
| 252 |
+
try:
|
| 253 |
+
return max(int(value), 0)
|
| 254 |
+
except (TypeError, ValueError):
|
| 255 |
+
continue
|
| 256 |
+
return None
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _optional_non_negative_float(*values: Any) -> float | None:
|
| 260 |
+
for value in values:
|
| 261 |
+
if value is None:
|
| 262 |
+
continue
|
| 263 |
+
try:
|
| 264 |
+
number = float(value)
|
| 265 |
+
except (TypeError, ValueError):
|
| 266 |
+
continue
|
| 267 |
+
if math.isfinite(number):
|
| 268 |
+
return max(number, 0)
|
| 269 |
+
return None
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def _scale_to_total(values: dict[str, int], total: int, current: int) -> dict[str, int]:
|
| 273 |
+
scaled = {key: value * total // current for key, value in values.items()}
|
| 274 |
+
remainder = total - sum(scaled.values())
|
| 275 |
+
order = sorted(values, key=lambda key: values[key] * total % current, reverse=True)
|
| 276 |
+
for key in order[:remainder]:
|
| 277 |
+
scaled[key] += 1
|
| 278 |
+
return scaled
|
plugins/_context_window/plugin.yaml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: _context_window
|
| 2 |
+
title: Context Window
|
| 3 |
+
description: Shows token usage for the active chat context window.
|
| 4 |
+
version: 0.1.0
|
| 5 |
+
settings_sections: []
|
| 6 |
+
per_project_config: false
|
| 7 |
+
per_agent_config: false
|
| 8 |
+
always_enabled: true
|
plugins/_context_window/tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
plugins/_context_window/tests/test_context_window.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from types import SimpleNamespace
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from langchain_core.messages import HumanMessage, SystemMessage
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
ROOT = Path(__file__).resolve().parents[3]
|
| 10 |
+
if str(ROOT) not in sys.path:
|
| 11 |
+
sys.path.insert(0, str(ROOT))
|
| 12 |
+
|
| 13 |
+
import models
|
| 14 |
+
from agent import Agent, LoopData
|
| 15 |
+
from helpers import extension, extract_tools, history, litellm_transport
|
| 16 |
+
from helpers.llm_result import LLMResult
|
| 17 |
+
from plugins._context_window.api.context_window import ContextWindow
|
| 18 |
+
from plugins._context_window.helpers import usage
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class _Log:
|
| 22 |
+
def set_progress(self, _message: str) -> None:
|
| 23 |
+
pass
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.mark.asyncio
|
| 27 |
+
async def test_usage_follows_prompt_sources_and_reconciles_to_total(monkeypatch):
|
| 28 |
+
agent = object.__new__(Agent)
|
| 29 |
+
loop_data = LoopData()
|
| 30 |
+
agent.loop_data = loop_data
|
| 31 |
+
agent.context = SimpleNamespace(log=_Log())
|
| 32 |
+
agent.history = history.History(agent)
|
| 33 |
+
agent.data = {}
|
| 34 |
+
agent.history.add_message(False, "User asks a question.")
|
| 35 |
+
agent.history.add_message(True, "Assistant answers.")
|
| 36 |
+
agent.history.add_message(
|
| 37 |
+
False,
|
| 38 |
+
{
|
| 39 |
+
"tool_name": "skills_tool",
|
| 40 |
+
"tool_result": "Skill instructions without a special heading.",
|
| 41 |
+
"skill_instructions": {
|
| 42 |
+
"name": "test-skill",
|
| 43 |
+
"content_included": True,
|
| 44 |
+
},
|
| 45 |
+
},
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
system_parts = {
|
| 49 |
+
"system_prompt": "Main instructions without a special heading.",
|
| 50 |
+
"system_tools": "Tool definitions without a special heading.",
|
| 51 |
+
"mcp_tools": "Remote definitions without a special heading.",
|
| 52 |
+
"skills": "Available skill names without a special heading.",
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
async def get_system_prompt(_loop_data):
|
| 56 |
+
for key in ("system_tools", "mcp_tools", "skills"):
|
| 57 |
+
usage.record_prompt(agent, key, system_parts[key])
|
| 58 |
+
return list(system_parts.values())
|
| 59 |
+
|
| 60 |
+
def read_prompt(prompt_file: str, **kwargs) -> str:
|
| 61 |
+
if prompt_file == "agent.context.protocol.md":
|
| 62 |
+
return "[PROTOCOL]\n" + kwargs["protocol"]
|
| 63 |
+
if prompt_file == "agent.context.extras.md":
|
| 64 |
+
return "[EXTRAS]\n" + kwargs["extras"]
|
| 65 |
+
raise AssertionError(f"Unexpected prompt: {prompt_file}")
|
| 66 |
+
|
| 67 |
+
async def call_extensions(extension_point: str, agent=None, **kwargs):
|
| 68 |
+
if extension_point == "message_loop_prompts_after":
|
| 69 |
+
current = kwargs["loop_data"]
|
| 70 |
+
current.protocol_persistent["project"] = "Project instructions."
|
| 71 |
+
current.extras_temporary["time"] = "Current time."
|
| 72 |
+
usage.capture_context(agent, current)
|
| 73 |
+
|
| 74 |
+
agent.get_system_prompt = get_system_prompt
|
| 75 |
+
agent.read_prompt = read_prompt
|
| 76 |
+
monkeypatch.setattr(extension, "call_extensions_async", call_extensions)
|
| 77 |
+
monkeypatch.setattr(history.History, "_get_max_embeds", lambda self: 0)
|
| 78 |
+
|
| 79 |
+
usage.reset(agent)
|
| 80 |
+
await Agent.prepare_prompt.__wrapped__(agent, loop_data)
|
| 81 |
+
usage.finalize(agent)
|
| 82 |
+
|
| 83 |
+
window = agent.get_data(Agent.DATA_NAME_CTX_WINDOW)
|
| 84 |
+
breakdown = window["usage"]
|
| 85 |
+
assert tuple(breakdown) == usage.USAGE_KEYS
|
| 86 |
+
assert sum(breakdown.values()) == window["tokens"]
|
| 87 |
+
assert all(breakdown[key] > 0 for key in usage.USAGE_KEYS)
|
| 88 |
+
assert usage.PARTS_KEY not in loop_data.params_temporary
|
| 89 |
+
assert "history_messages" not in agent.data[usage.CACHE_KEY]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@pytest.mark.asyncio
|
| 93 |
+
async def test_api_returns_only_counts_and_effective_limit(monkeypatch):
|
| 94 |
+
agent = SimpleNamespace(
|
| 95 |
+
DATA_NAME_CTX_WINDOW="ctx_window",
|
| 96 |
+
get_data=lambda _key: {
|
| 97 |
+
"text": "private prompt",
|
| 98 |
+
"tokens": 120,
|
| 99 |
+
"usage": {"messages": 42},
|
| 100 |
+
},
|
| 101 |
+
)
|
| 102 |
+
handler = object.__new__(ContextWindow)
|
| 103 |
+
handler.use_context = lambda _context_id: SimpleNamespace(
|
| 104 |
+
streaming_agent=None,
|
| 105 |
+
agent0=agent,
|
| 106 |
+
)
|
| 107 |
+
monkeypatch.setattr(
|
| 108 |
+
"plugins._context_window.api.context_window.get_chat_model_config",
|
| 109 |
+
lambda _agent: {"ctx_length": 128_000},
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
result = await handler.process({"context": "ctx-1"}, SimpleNamespace())
|
| 113 |
+
|
| 114 |
+
assert result == {
|
| 115 |
+
"tokens": 120,
|
| 116 |
+
"context_window": 128_000,
|
| 117 |
+
"usage": {
|
| 118 |
+
"messages": 42,
|
| 119 |
+
"system_tools": 0,
|
| 120 |
+
"skills": 0,
|
| 121 |
+
"mcp_tools": 0,
|
| 122 |
+
"system_prompt": 0,
|
| 123 |
+
"extras": 0,
|
| 124 |
+
},
|
| 125 |
+
"provider_usage": {},
|
| 126 |
+
}
|
| 127 |
+
assert "text" not in result
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_webui_and_accounting_are_plugin_owned():
|
| 131 |
+
model_switcher = (
|
| 132 |
+
ROOT
|
| 133 |
+
/ "plugins/_model_config/extensions/webui/chat-input-progress-start/model-switcher.html"
|
| 134 |
+
).read_text(encoding="utf-8")
|
| 135 |
+
model_store = (ROOT / "plugins/_model_config/webui/switcher-mixin.js").read_text(
|
| 136 |
+
encoding="utf-8"
|
| 137 |
+
)
|
| 138 |
+
component = (
|
| 139 |
+
ROOT
|
| 140 |
+
/ "plugins/_context_window/extensions/webui/model-context-strip-end/context-window.html"
|
| 141 |
+
).read_text(encoding="utf-8")
|
| 142 |
+
context_store = (
|
| 143 |
+
ROOT / "plugins/_context_window/webui/context-window-store.js"
|
| 144 |
+
).read_text(encoding="utf-8")
|
| 145 |
+
helper = (ROOT / "plugins/_context_window/helpers/usage.py").read_text(
|
| 146 |
+
encoding="utf-8"
|
| 147 |
+
)
|
| 148 |
+
refresh_hook = (
|
| 149 |
+
ROOT
|
| 150 |
+
/ "plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js"
|
| 151 |
+
).read_text(encoding="utf-8")
|
| 152 |
+
|
| 153 |
+
assert 'id="model-context-strip-end"' in model_switcher
|
| 154 |
+
assert "contextWindowUsage" not in model_switcher
|
| 155 |
+
assert "contextUsage" not in model_store
|
| 156 |
+
assert "Context window" in component
|
| 157 |
+
assert "position: static" in component
|
| 158 |
+
assert "width: min(19rem, calc(100vw - 2rem))" in component
|
| 159 |
+
assert "right: 1.25rem" in component
|
| 160 |
+
assert "width: min(17rem, calc(100vw - 3rem))" in component
|
| 161 |
+
assert 'label: "Free space"' in context_store
|
| 162 |
+
assert "Last model call" not in component
|
| 163 |
+
assert ">Price<" in component
|
| 164 |
+
assert ">Cache hit<" in component
|
| 165 |
+
assert ">Tokens In/Out<" in component
|
| 166 |
+
assert "context-window-cache-meter" not in component
|
| 167 |
+
assert "price: {" in context_store
|
| 168 |
+
assert "hasData: cost !== null" in context_store
|
| 169 |
+
assert 'label: cost === null ? "" : formatCost(cost)' in context_store
|
| 170 |
+
assert "usage.provider.price.hasData" in component
|
| 171 |
+
assert "usage.provider.price.label" in component
|
| 172 |
+
assert 'value < 0.001 ? "<$0.001"' in context_store
|
| 173 |
+
assert "maximumSignificantDigits: 3" in context_store
|
| 174 |
+
assert "border-top: 1px solid var(--color-border)" in component
|
| 175 |
+
assert " → " in context_store
|
| 176 |
+
assert "summaryTokens" in context_store
|
| 177 |
+
assert 'summaryPercent: `${percentLabel} used`' in context_store
|
| 178 |
+
assert "formatTokens(output)} tok" not in context_store
|
| 179 |
+
assert "context-window-summary-tokens" in component
|
| 180 |
+
assert "context-window-summary-percent" in component
|
| 181 |
+
assert "font-family: var(--font-family-main)" in component
|
| 182 |
+
assert "<details" not in component
|
| 183 |
+
assert "Reasoning" not in component
|
| 184 |
+
assert "Images sent" not in component
|
| 185 |
+
assert "provider did not split out their token cost" not in context_store
|
| 186 |
+
assert "cached / input" in context_store
|
| 187 |
+
assert "Math.round(cachePercent)" in context_store
|
| 188 |
+
assert "context-window-dot" not in component
|
| 189 |
+
assert "dotStyle" not in context_store
|
| 190 |
+
assert "Breakdown available after the next message." in component
|
| 191 |
+
assert "startswith(" not in helper
|
| 192 |
+
assert "rpartition(" not in helper
|
| 193 |
+
assert 'item?.type !== "agent"' in refresh_hook
|
| 194 |
+
assert "Number(item.agentno || 0) !== 0" in refresh_hook
|
| 195 |
+
assert "generationKey === lastGenerationKey" in refresh_hook
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def test_source_prompt_extensions_are_registered():
|
| 199 |
+
expected = {
|
| 200 |
+
"_functions/agent/Agent/prepare_prompt/start": "ResetContextUsage",
|
| 201 |
+
"_functions/agent/Agent/prepare_prompt/end": "StoreContextUsage",
|
| 202 |
+
"_functions/agent/Agent/call_chat_model_turn/end": "RecordProviderUsage",
|
| 203 |
+
"_functions/models/LiteLLMChatWrapper/unified_turn/start": "DrainProviderUsage",
|
| 204 |
+
"_functions/models/LiteLLMChatWrapper/unified_turn/end": "RestoreProviderResponse",
|
| 205 |
+
"message_loop_prompts_after": "CaptureContextUsage",
|
| 206 |
+
}
|
| 207 |
+
for point, class_name in expected.items():
|
| 208 |
+
classes = extension._get_extension_classes(point) # type: ignore[attr-defined]
|
| 209 |
+
assert any(cls.__name__ == class_name for cls in classes)
|
| 210 |
+
|
| 211 |
+
system_prompt_classes = {
|
| 212 |
+
cls.__name__: cls
|
| 213 |
+
for cls in extension._get_extension_classes("system_prompt") # type: ignore[attr-defined]
|
| 214 |
+
}
|
| 215 |
+
for owner, recorder in {
|
| 216 |
+
"ToolsPrompt": "RecordSystemToolsUsage",
|
| 217 |
+
"MCPToolsPrompt": "RecordMcpToolsUsage",
|
| 218 |
+
"SkillsPrompt": "RecordSkillsUsage",
|
| 219 |
+
}.items():
|
| 220 |
+
builder = system_prompt_classes[owner].execute.__globals__["build_prompt"]
|
| 221 |
+
module = builder.__wrapped__.__module__.replace(".", "/")
|
| 222 |
+
point = f"_functions/{module}/build_prompt/end"
|
| 223 |
+
classes = extension._get_extension_classes(point) # type: ignore[attr-defined]
|
| 224 |
+
assert any(cls.__name__ == recorder for cls in classes)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
@pytest.mark.asyncio
|
| 228 |
+
async def test_chat_stream_drains_terminal_provider_usage(monkeypatch):
|
| 229 |
+
response = '{"tool_name":"response","tool_args":{"text":"done"}}'
|
| 230 |
+
chunks = [
|
| 231 |
+
{"choices": [{"delta": {"content": response}, "message": {}}]},
|
| 232 |
+
{"choices": [{"delta": {"content": " ignored"}, "message": {}}]},
|
| 233 |
+
{
|
| 234 |
+
"choices": [],
|
| 235 |
+
"usage": {
|
| 236 |
+
"prompt_tokens": 12_000,
|
| 237 |
+
"prompt_tokens_details": {"cached_tokens": 9_000},
|
| 238 |
+
"completion_tokens": 80,
|
| 239 |
+
},
|
| 240 |
+
"_hidden_params": {"response_cost": 0.0042},
|
| 241 |
+
},
|
| 242 |
+
]
|
| 243 |
+
consumed = []
|
| 244 |
+
|
| 245 |
+
async def stream():
|
| 246 |
+
for chunk in chunks:
|
| 247 |
+
consumed.append(chunk)
|
| 248 |
+
yield chunk
|
| 249 |
+
|
| 250 |
+
async def fake_acompletion(*args, **kwargs):
|
| 251 |
+
assert kwargs["stream_options"] == {"include_usage": True}
|
| 252 |
+
return stream()
|
| 253 |
+
|
| 254 |
+
async def fake_rate_limiter(*args, **kwargs):
|
| 255 |
+
return None
|
| 256 |
+
|
| 257 |
+
callback_calls = []
|
| 258 |
+
|
| 259 |
+
async def response_callback(chunk: str, full: str):
|
| 260 |
+
callback_calls.append((chunk, full))
|
| 261 |
+
return full if extract_tools.extract_tool_request(full) else None
|
| 262 |
+
|
| 263 |
+
monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
|
| 264 |
+
monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
|
| 265 |
+
wrapper = models.LiteLLMChatWrapper(
|
| 266 |
+
model="test-model",
|
| 267 |
+
provider="openrouter",
|
| 268 |
+
model_config=None,
|
| 269 |
+
api_base="https://openrouter.ai/api/v1",
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
result = await wrapper.unified_turn(
|
| 273 |
+
messages=[
|
| 274 |
+
SystemMessage(content="stable instructions"),
|
| 275 |
+
HumanMessage(content="question"),
|
| 276 |
+
],
|
| 277 |
+
response_callback=response_callback,
|
| 278 |
+
explicit_caching=True,
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
assert consumed == chunks
|
| 282 |
+
assert callback_calls == [
|
| 283 |
+
(response, response),
|
| 284 |
+
(" ignored", response + " ignored"),
|
| 285 |
+
]
|
| 286 |
+
assert result.response == response
|
| 287 |
+
assert result.output_items[0].type == "message"
|
| 288 |
+
assert result.usage == {
|
| 289 |
+
"prompt_tokens": 12_000,
|
| 290 |
+
"prompt_tokens_details": {"cached_tokens": 9_000},
|
| 291 |
+
"completion_tokens": 80,
|
| 292 |
+
"cost": 0.0042,
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def test_prompt_fragment_cache_is_bounded_and_content_addressed(monkeypatch):
|
| 297 |
+
calls = []
|
| 298 |
+
agent = SimpleNamespace(data={}, loop_data=LoopData())
|
| 299 |
+
monkeypatch.setattr(
|
| 300 |
+
usage.tokens,
|
| 301 |
+
"approximate_prompt_tokens",
|
| 302 |
+
lambda text: calls.append(text) or len(text),
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
usage.reset(agent)
|
| 306 |
+
usage.record_prompt(agent, "system_tools", "same prompt")
|
| 307 |
+
usage.record_prompt(agent, "system_tools", "same prompt")
|
| 308 |
+
usage.record_prompt(agent, "system_tools", "changed prompt")
|
| 309 |
+
|
| 310 |
+
assert calls == ["same prompt", "changed prompt"]
|
| 311 |
+
cache = agent.data[usage.CACHE_KEY]
|
| 312 |
+
assert len(cache) == 1
|
| 313 |
+
assert cache["prompt:system_tools"][1] == len("changed prompt")
|
| 314 |
+
assert all(len(value[0]) == 64 for value in cache.values())
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def test_history_ledger_changes_without_invalidating_fragment_cache(monkeypatch):
|
| 318 |
+
calls = []
|
| 319 |
+
history_tokens = 1_000
|
| 320 |
+
agent = SimpleNamespace(
|
| 321 |
+
data={},
|
| 322 |
+
loop_data=LoopData(),
|
| 323 |
+
history=SimpleNamespace(get_tokens=lambda: history_tokens),
|
| 324 |
+
_build_context_message=lambda *args, **kwargs: [],
|
| 325 |
+
)
|
| 326 |
+
skill_message = {
|
| 327 |
+
"ai": False,
|
| 328 |
+
"content": {
|
| 329 |
+
"tool_name": "skills_tool",
|
| 330 |
+
"tool_result": "Loaded skill body.",
|
| 331 |
+
"skill_instructions": {
|
| 332 |
+
"name": "test-skill",
|
| 333 |
+
"content_included": True,
|
| 334 |
+
},
|
| 335 |
+
},
|
| 336 |
+
}
|
| 337 |
+
loop_data = SimpleNamespace(
|
| 338 |
+
history_output=[skill_message],
|
| 339 |
+
protocol_persistent={},
|
| 340 |
+
protocol_temporary={},
|
| 341 |
+
extras_persistent={},
|
| 342 |
+
extras_temporary={},
|
| 343 |
+
)
|
| 344 |
+
monkeypatch.setattr(
|
| 345 |
+
usage.tokens,
|
| 346 |
+
"approximate_prompt_tokens",
|
| 347 |
+
lambda text: calls.append(text) or len(text),
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
usage.reset(agent)
|
| 351 |
+
usage.record_prompt(agent, "system_tools", "stable tools")
|
| 352 |
+
usage.capture_context(agent, loop_data)
|
| 353 |
+
first = dict(agent.loop_data.params_temporary[usage.PARTS_KEY])
|
| 354 |
+
|
| 355 |
+
history_tokens = 400
|
| 356 |
+
agent.loop_data.params_temporary = {}
|
| 357 |
+
usage.reset(agent)
|
| 358 |
+
usage.record_prompt(agent, "system_tools", "stable tools")
|
| 359 |
+
usage.capture_context(agent, loop_data)
|
| 360 |
+
second = agent.loop_data.params_temporary[usage.PARTS_KEY]
|
| 361 |
+
|
| 362 |
+
assert first["messages"] == 1_000 - first["skills"]
|
| 363 |
+
assert second["messages"] == 400 - second["skills"]
|
| 364 |
+
assert calls.count("stable tools") == 1
|
| 365 |
+
assert len(agent.data[usage.CACHE_KEY]) == 3
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def test_rendered_history_fallback_preserves_system_prompt_bucket(monkeypatch):
|
| 369 |
+
data = {}
|
| 370 |
+
output = [{"ai": False, "content": "short message"}]
|
| 371 |
+
agent = SimpleNamespace(
|
| 372 |
+
DATA_NAME_CTX_WINDOW="ctx_window",
|
| 373 |
+
data=data,
|
| 374 |
+
loop_data=LoopData(),
|
| 375 |
+
history=SimpleNamespace(get_tokens=lambda: 10_000),
|
| 376 |
+
_build_context_message=lambda *args, **kwargs: [],
|
| 377 |
+
get_data=lambda key: data.get(key),
|
| 378 |
+
set_data=lambda key, value: data.__setitem__(key, value),
|
| 379 |
+
)
|
| 380 |
+
loop_data = SimpleNamespace(
|
| 381 |
+
history_output=output,
|
| 382 |
+
protocol_persistent={},
|
| 383 |
+
protocol_temporary={},
|
| 384 |
+
extras_persistent={},
|
| 385 |
+
extras_temporary={},
|
| 386 |
+
)
|
| 387 |
+
monkeypatch.setattr(
|
| 388 |
+
usage.tokens,
|
| 389 |
+
"approximate_prompt_tokens",
|
| 390 |
+
lambda text: len(text),
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
usage.reset(agent)
|
| 394 |
+
usage.capture_context(agent, loop_data)
|
| 395 |
+
data[agent.DATA_NAME_CTX_WINDOW] = {"tokens": 100}
|
| 396 |
+
usage.finalize(agent)
|
| 397 |
+
|
| 398 |
+
breakdown = data[agent.DATA_NAME_CTX_WINDOW]["usage"]
|
| 399 |
+
assert breakdown["messages"] == len("user: short message")
|
| 400 |
+
assert breakdown["system_prompt"] > 0
|
| 401 |
+
assert sum(breakdown.values()) == 100
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def test_provider_usage_is_optional():
|
| 405 |
+
data = {}
|
| 406 |
+
agent = SimpleNamespace(
|
| 407 |
+
data=data,
|
| 408 |
+
history=SimpleNamespace(all_messages=lambda: []),
|
| 409 |
+
set_data=lambda key, value: data.__setitem__(key, value),
|
| 410 |
+
)
|
| 411 |
+
result = LLMResult.from_chat(
|
| 412 |
+
response="done",
|
| 413 |
+
usage={
|
| 414 |
+
"prompt_tokens": 12_000,
|
| 415 |
+
"prompt_tokens_details": {"cached_tokens": 9_000},
|
| 416 |
+
"completion_tokens": 80,
|
| 417 |
+
"cost": 0.0123,
|
| 418 |
+
},
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
usage.capture_provider_usage(agent, result)
|
| 422 |
+
|
| 423 |
+
assert usage.latest_provider_usage(agent) == {
|
| 424 |
+
"input_tokens": 12_000,
|
| 425 |
+
"cached_tokens": 9_000,
|
| 426 |
+
"output_tokens": 80,
|
| 427 |
+
"cost": 0.0123,
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
usage.capture_provider_usage(agent, LLMResult.from_chat(response="no usage"))
|
| 431 |
+
assert usage.latest_provider_usage(agent) == {}
|
| 432 |
+
assert usage.provider_usage_snapshot(
|
| 433 |
+
{"input_tokens": 100, "cached_tokens": None, "cost": None}
|
| 434 |
+
) == {"input_tokens": 100}
|
plugins/_context_window/webui/context-window-store.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createStore } from "/js/AlpineStore.js";
|
| 2 |
+
import { callJsonApi } from "/js/api.js";
|
| 3 |
+
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
|
| 4 |
+
import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
|
| 5 |
+
|
| 6 |
+
const API_PATH = "/plugins/_context_window/context_window";
|
| 7 |
+
const ROWS = [
|
| 8 |
+
{ key: "messages", label: "Messages" },
|
| 9 |
+
{ key: "system_tools", label: "System tools" },
|
| 10 |
+
{ key: "skills", label: "Skills" },
|
| 11 |
+
{ key: "mcp_tools", label: "MCP tools" },
|
| 12 |
+
{ key: "system_prompt", label: "System prompt" },
|
| 13 |
+
{ key: "extras", label: "Extras" },
|
| 14 |
+
];
|
| 15 |
+
const COST_FORMATTER = new Intl.NumberFormat("en-US", {
|
| 16 |
+
style: "currency",
|
| 17 |
+
currency: "USD",
|
| 18 |
+
maximumSignificantDigits: 3,
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
preferencesStore.registerUiControlVisibility("contextWindowUsage", {
|
| 22 |
+
mobile: true,
|
| 23 |
+
desktop: true,
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
function formatTokens(value) {
|
| 27 |
+
const amount = Math.max(Number(value) || 0, 0);
|
| 28 |
+
for (const [size, suffix] of [[1_000_000, "M"], [1_000, "K"]]) {
|
| 29 |
+
if (amount >= size) return `${(amount / size).toFixed(1).replace(/\.0$/, "")}${suffix}`;
|
| 30 |
+
}
|
| 31 |
+
return String(Math.round(amount));
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function formatPercent(value) {
|
| 35 |
+
const rounded = Math.round(Math.max(Number(value) || 0, 0) * 10) / 10;
|
| 36 |
+
return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}%`;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function optionalNumber(value, key) {
|
| 40 |
+
if (!value || !Object.prototype.hasOwnProperty.call(value, key)) return null;
|
| 41 |
+
const number = Number(value[key]);
|
| 42 |
+
return Number.isFinite(number) && number >= 0 ? number : null;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function formatCost(value) {
|
| 46 |
+
if (value === 0) return "$0";
|
| 47 |
+
return value < 0.001 ? "<$0.001" : COST_FORMATTER.format(value);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function buildProviderUsage(value = {}) {
|
| 51 |
+
const input = optionalNumber(value, "input_tokens");
|
| 52 |
+
const cached = optionalNumber(value, "cached_tokens");
|
| 53 |
+
const output = optionalNumber(value, "output_tokens");
|
| 54 |
+
const cost = optionalNumber(value, "cost");
|
| 55 |
+
|
| 56 |
+
const tokenSummary = input === null && output === null
|
| 57 |
+
? ""
|
| 58 |
+
: `${input === null ? "–" : formatTokens(input)} → ${output === null ? "–" : formatTokens(output)}`;
|
| 59 |
+
|
| 60 |
+
const cachePercent = input > 0 && cached !== null
|
| 61 |
+
? Math.min((cached / input) * 100, 100)
|
| 62 |
+
: null;
|
| 63 |
+
return {
|
| 64 |
+
hasData: cost !== null || cachePercent !== null || Boolean(tokenSummary),
|
| 65 |
+
price: {
|
| 66 |
+
hasData: cost !== null,
|
| 67 |
+
label: cost === null ? "" : formatCost(cost),
|
| 68 |
+
},
|
| 69 |
+
cache: {
|
| 70 |
+
hasData: cachePercent !== null,
|
| 71 |
+
label: cachePercent === null ? "" : `${Math.round(cachePercent)}%`,
|
| 72 |
+
},
|
| 73 |
+
tokens: tokenSummary,
|
| 74 |
+
};
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function buildUsage(data = {}) {
|
| 78 |
+
const tokens = Math.max(Number(data.tokens) || 0, 0);
|
| 79 |
+
const contextWindow = Math.max(Number(data.context_window) || 0, 0);
|
| 80 |
+
const breakdown = data.usage && typeof data.usage === "object" ? data.usage : {};
|
| 81 |
+
const percent = contextWindow > 0 ? (tokens / contextWindow) * 100 : 0;
|
| 82 |
+
const rows = ROWS.map(row => {
|
| 83 |
+
const rowTokens = Math.max(Number(breakdown[row.key]) || 0, 0);
|
| 84 |
+
const rowPercent = contextWindow > 0 ? (rowTokens / contextWindow) * 100 : 0;
|
| 85 |
+
return {
|
| 86 |
+
...row,
|
| 87 |
+
tokensLabel: formatTokens(rowTokens),
|
| 88 |
+
percentLabel: formatPercent(rowPercent),
|
| 89 |
+
};
|
| 90 |
+
});
|
| 91 |
+
const hasBreakdown = rows.some(row => Number(breakdown[row.key]) > 0);
|
| 92 |
+
if (hasBreakdown) {
|
| 93 |
+
const freeTokens = Math.max(contextWindow - tokens, 0);
|
| 94 |
+
const freePercent = contextWindow > 0 ? (freeTokens / contextWindow) * 100 : 0;
|
| 95 |
+
rows.push({
|
| 96 |
+
key: "free_space",
|
| 97 |
+
label: "Free space",
|
| 98 |
+
tokensLabel: formatTokens(freeTokens),
|
| 99 |
+
percentLabel: formatPercent(freePercent),
|
| 100 |
+
});
|
| 101 |
+
}
|
| 102 |
+
const percentLabel = formatPercent(percent);
|
| 103 |
+
return {
|
| 104 |
+
rows: hasBreakdown ? rows : [],
|
| 105 |
+
hasBreakdown,
|
| 106 |
+
missingBreakdown: !hasBreakdown,
|
| 107 |
+
ariaLabel: `Context window ${percentLabel} used`,
|
| 108 |
+
ringLabel: contextWindow ? `${Math.round(percent)}%` : "–",
|
| 109 |
+
ringDasharray: `${Math.min(percent, 100)} 100`,
|
| 110 |
+
summaryTokens: `${formatTokens(tokens)}/${contextWindow ? formatTokens(contextWindow) : "–"} tokens`,
|
| 111 |
+
summaryPercent: `${percentLabel} used`,
|
| 112 |
+
meterStyle: `width:${Math.min(percent, 100)}%`,
|
| 113 |
+
provider: buildProviderUsage(data.provider_usage),
|
| 114 |
+
};
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
const model = {
|
| 118 |
+
usage: buildUsage(),
|
| 119 |
+
loadSeq: 0,
|
| 120 |
+
open: false,
|
| 121 |
+
|
| 122 |
+
get contextId() {
|
| 123 |
+
return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
|
| 124 |
+
},
|
| 125 |
+
|
| 126 |
+
async onMount(watch) {
|
| 127 |
+
await this.refresh();
|
| 128 |
+
watch("$store.chats.selected", value => this.refresh(value || ""));
|
| 129 |
+
watch("$store.chats.selectedContext?.running", (running, previous) => {
|
| 130 |
+
if (previous && !running) void this.refresh();
|
| 131 |
+
});
|
| 132 |
+
},
|
| 133 |
+
|
| 134 |
+
cleanup() {
|
| 135 |
+
this.open = false;
|
| 136 |
+
this.loadSeq += 1;
|
| 137 |
+
},
|
| 138 |
+
|
| 139 |
+
toggle() {
|
| 140 |
+
this.open = !this.open;
|
| 141 |
+
if (this.open) void this.refresh();
|
| 142 |
+
},
|
| 143 |
+
|
| 144 |
+
async refresh(contextId = this.contextId) {
|
| 145 |
+
const requestSeq = ++this.loadSeq;
|
| 146 |
+
if (!contextId) {
|
| 147 |
+
this.usage = buildUsage();
|
| 148 |
+
return this.usage;
|
| 149 |
+
}
|
| 150 |
+
try {
|
| 151 |
+
const data = await callJsonApi(API_PATH, { context: contextId });
|
| 152 |
+
if (requestSeq === this.loadSeq) this.usage = buildUsage(data);
|
| 153 |
+
} catch (error) {
|
| 154 |
+
if (requestSeq === this.loadSeq) console.error("Context window load failed:", error);
|
| 155 |
+
}
|
| 156 |
+
return this.usage;
|
| 157 |
+
},
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
export const store = createStore("contextWindow", model);
|
plugins/_context_window/webui/thumbnail.webp
ADDED
|
|
plugins/_desktop/AGENTS.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Desktop Plugin DOX
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
|
| 5 |
+
- Own the Agent Zero Linux desktop runtime, Xpra/Xfce session integration, and live desktop surface.
|
| 6 |
+
|
| 7 |
+
## Ownership
|
| 8 |
+
|
| 9 |
+
- `helpers/` owns desktop routes, session lifecycle, state, and prompt context.
|
| 10 |
+
- `api/desktop_session.py` owns desktop session API behavior.
|
| 11 |
+
- `hooks.py` owns route registration and plugin lifecycle hooks.
|
| 12 |
+
- `prompts/`, `assets/`, `skills/`, `extensions/`, and `webui/` own desktop context, assets, skill guidance, hooks, and panel UI.
|
| 13 |
+
|
| 14 |
+
## Local Contracts
|
| 15 |
+
|
| 16 |
+
- Preserve session startup, cleanup, and route protection for desktop access.
|
| 17 |
+
- Keep the Xpra server, client modules, and GTK introspection runtime present as one compatible stack; Xpra shadow sessions import all three even when users connect only through HTML5.
|
| 18 |
+
- Keep desktop state injected into prompts accurate and bounded.
|
| 19 |
+
- Do not expose desktop routes without the expected auth protections.
|
| 20 |
+
- Keep Desktop host visibility tied to an attached modal or canvas host; modal cleanup may preserve the iframe in keepalive, but must not leave stale modal mode behind.
|
| 21 |
+
- Keep LibreOffice Writer as the default handler for Markdown and plain text files; keep Agent Zero Editor available as a secondary Open With target through the desktop intent bridge.
|
| 22 |
+
|
| 23 |
+
## Work Guidance
|
| 24 |
+
|
| 25 |
+
- Coordinate runtime and panel changes so live UI reflects actual desktop session state.
|
| 26 |
+
|
| 27 |
+
## Verification
|
| 28 |
+
|
| 29 |
+
- Smoke-test desktop startup, panel connection, route access, and session cleanup after changes.
|
| 30 |
+
|
| 31 |
+
## Child DOX Index
|
| 32 |
+
|
| 33 |
+
No child DOX files.
|
plugins/_desktop/api/desktop_session.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from helpers.api import ApiHandler, Request
|
| 4 |
+
from plugins._desktop.helpers import desktop_session
|
| 5 |
+
from plugins._office.helpers import document_store
|
| 6 |
+
from plugins._office.helpers import libreoffice
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class DesktopSession(ApiHandler):
|
| 10 |
+
async def process(self, input: dict, request: Request) -> dict:
|
| 11 |
+
action = str(input.get("action") or "desktop").lower().strip()
|
| 12 |
+
|
| 13 |
+
if action == "status":
|
| 14 |
+
return desktop_session.collect_desktop_status()
|
| 15 |
+
if action in {"desktop", "open", "session"}:
|
| 16 |
+
return self._desktop()
|
| 17 |
+
if action in {"open_document", "document"}:
|
| 18 |
+
return self._open_document(input, request)
|
| 19 |
+
if action in {"save", "desktop_save"}:
|
| 20 |
+
return self._save(input)
|
| 21 |
+
if action in {"sync", "desktop_sync", "heartbeat"}:
|
| 22 |
+
return self._sync(input)
|
| 23 |
+
if action in {"state", "desktop_state"}:
|
| 24 |
+
return self._state(input)
|
| 25 |
+
if action in {"shutdown", "desktop_shutdown"}:
|
| 26 |
+
return self._shutdown(input)
|
| 27 |
+
return {"ok": False, "error": f"Unsupported desktop session action: {action}"}
|
| 28 |
+
|
| 29 |
+
def _desktop(self) -> dict:
|
| 30 |
+
desktop = desktop_session.get_manager().ensure_system_desktop()
|
| 31 |
+
if not desktop.get("available"):
|
| 32 |
+
return {
|
| 33 |
+
"ok": False,
|
| 34 |
+
"error": desktop.get("error") or "Desktop session is unavailable.",
|
| 35 |
+
"status": desktop.get("status") or {},
|
| 36 |
+
"desktop": desktop,
|
| 37 |
+
"libreoffice": libreoffice.collect_status(),
|
| 38 |
+
}
|
| 39 |
+
document = {
|
| 40 |
+
"file_id": desktop_session.SYSTEM_FILE_ID,
|
| 41 |
+
"path": desktop["path"],
|
| 42 |
+
"basename": desktop["title"],
|
| 43 |
+
"title": desktop["title"],
|
| 44 |
+
"extension": "desktop",
|
| 45 |
+
"size": 0,
|
| 46 |
+
"version": 0,
|
| 47 |
+
}
|
| 48 |
+
return {
|
| 49 |
+
"ok": True,
|
| 50 |
+
"session_id": desktop["session_id"],
|
| 51 |
+
"desktop_session_id": desktop["session_id"],
|
| 52 |
+
"file_id": desktop_session.SYSTEM_FILE_ID,
|
| 53 |
+
"title": desktop["title"],
|
| 54 |
+
"extension": "desktop",
|
| 55 |
+
"path": desktop["path"],
|
| 56 |
+
"text": "",
|
| 57 |
+
"document": document,
|
| 58 |
+
"version": 0,
|
| 59 |
+
"desktop": desktop,
|
| 60 |
+
"store_session_id": "",
|
| 61 |
+
"mode": "desktop",
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
def _open_document(self, input: dict, request: Request) -> dict:
|
| 65 |
+
context_id = str(input.get("ctxid") or input.get("context_id") or "").strip()
|
| 66 |
+
file_id = str(input.get("file_id") or "").strip()
|
| 67 |
+
try:
|
| 68 |
+
doc = (
|
| 69 |
+
document_store.get_document(file_id)
|
| 70 |
+
if file_id
|
| 71 |
+
else document_store.register_document(str(input.get("path") or ""), context_id=context_id)
|
| 72 |
+
)
|
| 73 |
+
except Exception as exc:
|
| 74 |
+
return {"ok": False, "error": str(exc)}
|
| 75 |
+
|
| 76 |
+
ext = str(doc.get("extension") or "").lower()
|
| 77 |
+
if ext in document_store.EDITOR_TEXT_EXTENSIONS:
|
| 78 |
+
return {
|
| 79 |
+
"ok": False,
|
| 80 |
+
"error": "Text documents use the Editor surface.",
|
| 81 |
+
"requires_editor": True,
|
| 82 |
+
"document": _public_doc(doc),
|
| 83 |
+
}
|
| 84 |
+
if ext not in desktop_session.OFFICIAL_EXTENSIONS:
|
| 85 |
+
return {"ok": False, "error": f".{ext} documents do not use the Desktop surface."}
|
| 86 |
+
|
| 87 |
+
store_session = document_store.create_session(
|
| 88 |
+
doc["file_id"],
|
| 89 |
+
user_id=str(input.get("user_id") or "agent-zero-user"),
|
| 90 |
+
permission="write",
|
| 91 |
+
origin=self._origin(request),
|
| 92 |
+
)
|
| 93 |
+
desktop = desktop_session.get_manager().open(doc, refresh=input.get("refresh") is True)
|
| 94 |
+
if not desktop.get("available"):
|
| 95 |
+
document_store.close_session(session_id=store_session["session_id"])
|
| 96 |
+
return {
|
| 97 |
+
"ok": False,
|
| 98 |
+
"error": desktop.get("error") or desktop.get("reason") or "Desktop session is unavailable.",
|
| 99 |
+
"desktop": desktop,
|
| 100 |
+
"libreoffice": libreoffice.collect_status(),
|
| 101 |
+
}
|
| 102 |
+
return {
|
| 103 |
+
"ok": True,
|
| 104 |
+
"session_id": desktop["session_id"],
|
| 105 |
+
"desktop_session_id": desktop["session_id"],
|
| 106 |
+
"file_id": doc["file_id"],
|
| 107 |
+
"title": doc["basename"],
|
| 108 |
+
"extension": doc["extension"],
|
| 109 |
+
"path": doc["path"],
|
| 110 |
+
"text": "",
|
| 111 |
+
"document": _public_doc(doc),
|
| 112 |
+
"version": document_store.item_version(doc),
|
| 113 |
+
"desktop": desktop,
|
| 114 |
+
"store_session_id": store_session["session_id"],
|
| 115 |
+
"mode": "edit",
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
def _save(self, input: dict) -> dict:
|
| 119 |
+
session_id = str(input.get("desktop_session_id") or input.get("session_id") or "").strip()
|
| 120 |
+
if not session_id:
|
| 121 |
+
return {"ok": False, "error": "desktop_session_id is required."}
|
| 122 |
+
return desktop_session.get_manager().save(
|
| 123 |
+
session_id,
|
| 124 |
+
file_id=str(input.get("file_id") or ""),
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
def _sync(self, input: dict) -> dict:
|
| 128 |
+
return desktop_session.get_manager().sync(
|
| 129 |
+
session_id=str(input.get("desktop_session_id") or input.get("session_id") or ""),
|
| 130 |
+
file_id=str(input.get("file_id") or ""),
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
def _state(self, input: dict) -> dict:
|
| 134 |
+
return desktop_session.get_manager().state(
|
| 135 |
+
include_screenshot=bool(input.get("include_screenshot") is True),
|
| 136 |
+
context_id=str(input.get("ctxid") or input.get("context_id") or ""),
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
def _shutdown(self, input: dict) -> dict:
|
| 140 |
+
return desktop_session.get_manager().shutdown_system_desktop(
|
| 141 |
+
save_first=input.get("save_first") is not False,
|
| 142 |
+
source=str(input.get("source") or "api"),
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
def _origin(self, request: Request) -> str:
|
| 146 |
+
origin = request.headers.get("Origin") or request.host_url.rstrip("/")
|
| 147 |
+
return origin.rstrip("/")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _public_doc(doc: dict) -> dict:
|
| 151 |
+
return {
|
| 152 |
+
"file_id": doc["file_id"],
|
| 153 |
+
"path": document_store.display_path(doc["path"]),
|
| 154 |
+
"basename": doc["basename"],
|
| 155 |
+
"title": doc["basename"],
|
| 156 |
+
"extension": doc["extension"],
|
| 157 |
+
"size": doc["size"],
|
| 158 |
+
"version": document_store.item_version(doc),
|
| 159 |
+
"last_modified": doc["last_modified"],
|
| 160 |
+
}
|
plugins/_desktop/assets/desktop/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Agent Zero Desktop
|
| 2 |
+
|
| 3 |
+
This Desktop is Agent Zero's habitat: a visible, stateful Linux workspace where humans and agents can share files, launch tools, inspect results, and turn reasoning into observable work.
|
| 4 |
+
|
| 5 |
+
You can choose your harness, but now you and A0 have a habitat.
|
| 6 |
+
|
| 7 |
+
Here, the model reasons, the environment remembers, the user grants permission, and the work leaves artifacts that can be opened, tested, revised, and trusted. It is a small workshop for the larger idea: agents become more useful when they have a safe world to act in, not only more instructions to carry.
|
| 8 |
+
|
| 9 |
+
With gratitude to the open-source foundations that make this affordance possible:
|
| 10 |
+
|
| 11 |
+
- Xpra, for carrying a live Linux desktop through the browser, making this desktop environment usable by AI agents.
|
| 12 |
+
- Kali Linux, for the practical Linux craft and tool culture that make capable workspaces feel natural.
|
| 13 |
+
- LibreOffice, for giving this habitat a serious office bench: documents, spreadsheets, presentations, and decades of open document stewardship.
|
| 14 |
+
- Xfce, for a fast, humane desktop that stays out of the way and lets the work breathe.
|
| 15 |
+
- Jan Tomášek, for creating Agent Zero and making this whole workshop possible.
|
| 16 |
+
|
| 17 |
+
Agent Zero stands on this craft with respect. Open source is not just code we consume; it is shared affordance, accumulated care, and an invitation to build systems that are powerful without becoming opaque.
|
| 18 |
+
|
| 19 |
+
## Open the Terminal
|
| 20 |
+
|
| 21 |
+
Double-click **Terminal** on this Desktop. You will land in the Agent Zero workdir, where agent CLIs can inspect files, edit projects, and run commands inside the Linux environment.
|
| 22 |
+
|
| 23 |
+
Before installing an agent, make a small safety habit:
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
pwd
|
| 27 |
+
node -v || true
|
| 28 |
+
npm -v || true
|
| 29 |
+
python3 --version || true
|
| 30 |
+
curl --version | head -1 || true
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
Agent CLIs are powerful. Install from official sources, avoid `sudo npm install -g` unless the project explicitly requires it, and keep API keys in your shell or provider login flow rather than pasting secrets into documents.
|
| 34 |
+
|
| 35 |
+
## Install Your Favorite Agent
|
| 36 |
+
|
| 37 |
+
These commands were checked against official docs or project READMEs on 2026-05-02. Package names matter.
|
| 38 |
+
|
| 39 |
+
### OpenAI Codex
|
| 40 |
+
|
| 41 |
+
```bash
|
| 42 |
+
npm i -g @openai/codex
|
| 43 |
+
codex
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### Claude Code
|
| 47 |
+
|
| 48 |
+
Recommended native installer:
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
curl -fsSL https://claude.ai/install.sh | bash
|
| 52 |
+
source ~/.bashrc
|
| 53 |
+
claude
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
NPM alternative:
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
npm install -g @anthropic-ai/claude-code
|
| 60 |
+
claude
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
### Gemini CLI
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
npm install -g @google/gemini-cli
|
| 67 |
+
gemini
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### Aider
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
curl -LsSf https://aider.chat/install.sh | sh
|
| 74 |
+
source ~/.bashrc
|
| 75 |
+
aider
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
### OpenCode
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
curl -sL https://opencode.ai/install | bash
|
| 82 |
+
opencode
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
NPM alternative:
|
| 86 |
+
|
| 87 |
+
```bash
|
| 88 |
+
npm i -g opencode
|
| 89 |
+
opencode
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
### Goose
|
| 93 |
+
|
| 94 |
+
```bash
|
| 95 |
+
curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | bash
|
| 96 |
+
source ~/.bashrc
|
| 97 |
+
goose
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
### OpenHands CLI
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
python3 -m pip install uv
|
| 104 |
+
uv tool install openhands --python 3.12
|
| 105 |
+
openhands
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
### Qwen Code
|
| 109 |
+
|
| 110 |
+
```bash
|
| 111 |
+
npm install -g @qwen-code/qwen-code@latest
|
| 112 |
+
qwen
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
### Cursor Agent
|
| 116 |
+
|
| 117 |
+
```bash
|
| 118 |
+
curl https://cursor.com/install -fsS | bash
|
| 119 |
+
source ~/.bashrc
|
| 120 |
+
cursor-agent
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
### Hermes Agent
|
| 124 |
+
|
| 125 |
+
```bash
|
| 126 |
+
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
| 127 |
+
source ~/.bashrc
|
| 128 |
+
hermes
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
### OpenClaw
|
| 132 |
+
|
| 133 |
+
OpenClaw recommends Node 24, or Node 22.14+.
|
| 134 |
+
|
| 135 |
+
```bash
|
| 136 |
+
npm install -g openclaw@latest
|
| 137 |
+
openclaw onboard --install-daemon
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
If in doubt, start with Codex or Claude Code for coding, Aider for a lighter patch-focused loop, Goose or OpenHands for a broader local agent, and Hermes or OpenClaw when you want the agent to grow into more of a persistent runtime working together with Agent Zero.
|
| 141 |
+
|
| 142 |
+
Official references: [Codex](https://developers.openai.com/codex/cli), [Claude Code](https://code.claude.com/docs/en/getting-started), [Gemini CLI](https://google-gemini.github.io/gemini-cli/docs/get-started/), [Aider](https://aider.chat/docs/install.html), [OpenCode](https://www.opencode.live/), [Goose](https://goose-docs.ai/docs/getting-started/installation/), [OpenHands](https://docs.openhands.dev/openhands/usage/cli/installation), [Qwen Code](https://github.com/QwenLM/qwen-code), [Cursor Agent](https://docs.cursor.com/en/cli/installation), [Hermes Agent](https://github.com/NousResearch/hermes-agent), [OpenClaw](https://github.com/openclaw/openclaw).
|
plugins/_desktop/extensions/python/message_loop_prompts_after/_55_include_desktop_state.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from agent import LoopData
|
| 4 |
+
from helpers.extension import Extension
|
| 5 |
+
from plugins._desktop.helpers import prompt_context
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class IncludeDesktopState(Extension):
|
| 9 |
+
async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
|
| 10 |
+
context_id = str(getattr(getattr(self.agent, "context", None), "id", "") or "")
|
| 11 |
+
context = prompt_context.build_context(context_id=context_id)
|
| 12 |
+
if not context:
|
| 13 |
+
loop_data.extras_temporary.pop("desktop_state", None)
|
| 14 |
+
return
|
| 15 |
+
|
| 16 |
+
loop_data.extras_temporary["desktop_state"] = self.agent.read_prompt(
|
| 17 |
+
"agent.extras.desktop_state.md",
|
| 18 |
+
desktop_state=context,
|
| 19 |
+
) if self.agent else context
|
plugins/_desktop/extensions/python/startup_migration/_20_desktop_routes.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import threading
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from helpers.extension import Extension
|
| 7 |
+
from helpers.print_style import PrintStyle
|
| 8 |
+
from helpers import virtual_desktop_routes
|
| 9 |
+
from plugins._desktop import hooks
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
_startup_preparation_thread: threading.Thread | None = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class DesktopStartup(Extension):
|
| 16 |
+
def execute(self, **kwargs):
|
| 17 |
+
virtual_desktop_routes.install_route_hooks()
|
| 18 |
+
_start_background_runtime_preparation()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _start_background_runtime_preparation() -> threading.Thread:
|
| 22 |
+
global _startup_preparation_thread
|
| 23 |
+
|
| 24 |
+
if _startup_preparation_thread and _startup_preparation_thread.is_alive():
|
| 25 |
+
return _startup_preparation_thread
|
| 26 |
+
|
| 27 |
+
_startup_preparation_thread = threading.Thread(
|
| 28 |
+
target=_prepare_runtime_safely,
|
| 29 |
+
name="a0-desktop-runtime-preparation",
|
| 30 |
+
daemon=True,
|
| 31 |
+
)
|
| 32 |
+
_startup_preparation_thread.start()
|
| 33 |
+
return _startup_preparation_thread
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _prepare_runtime_safely() -> None:
|
| 37 |
+
try:
|
| 38 |
+
_log_runtime_preparation_result(hooks.cleanup_stale_runtime_state())
|
| 39 |
+
except Exception as exc:
|
| 40 |
+
PrintStyle.warning("Desktop runtime preparation failed:", exc)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _log_runtime_preparation_result(result: dict[str, Any]) -> None:
|
| 44 |
+
if result.get("errors"):
|
| 45 |
+
PrintStyle.warning("Desktop runtime preparation reported errors:", result["errors"])
|
| 46 |
+
elif result.get("warnings"):
|
| 47 |
+
PrintStyle.warning("Desktop runtime preparation reported warnings:", result["warnings"])
|
| 48 |
+
elif result.get("installed") or result.get("removed") or result.get("migrated"):
|
| 49 |
+
PrintStyle.info("Desktop runtime prepared:", result)
|
plugins/_desktop/extensions/webui/right-canvas-panels/desktop-panel.html
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div
|
| 2 |
+
class="right-canvas-surface-panel desktop-canvas-surface"
|
| 3 |
+
data-surface-id="desktop"
|
| 4 |
+
:class="{
|
| 5 |
+
'is-active': $store.rightCanvas?.isSurfaceVisible('desktop'),
|
| 6 |
+
'is-mounted': $store.rightCanvas?.isSurfaceRendered('desktop')
|
| 7 |
+
}"
|
| 8 |
+
:aria-hidden="(!$store.rightCanvas?.isSurfaceVisible('desktop')).toString()"
|
| 9 |
+
x-effect="(() => {
|
| 10 |
+
const visible = Boolean($store.rightCanvas?.isSurfaceRendered('desktop'));
|
| 11 |
+
(globalThis.queueMicrotask || ((callback) => globalThis.setTimeout(callback, 0)))(() => $store.desktop?.setDesktopHostVisible?.(visible));
|
| 12 |
+
})()"
|
| 13 |
+
>
|
| 14 |
+
<x-component path="/plugins/_desktop/webui/desktop-panel.html"></x-component>
|
| 15 |
+
</div>
|
plugins/_desktop/extensions/webui/right-canvas-toolbar-start/desktop-new-menu.html
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div
|
| 2 |
+
class="office-header-actions right-canvas-desktop-actions"
|
| 3 |
+
x-data="{ open: false }"
|
| 4 |
+
x-show="$store.rightCanvas?.isSurfaceActive('desktop')"
|
| 5 |
+
@click.outside="open = false"
|
| 6 |
+
@keydown.escape.window="open = false"
|
| 7 |
+
style="display: none;"
|
| 8 |
+
>
|
| 9 |
+
<button
|
| 10 |
+
type="button"
|
| 11 |
+
class="office-header-new-button"
|
| 12 |
+
aria-haspopup="menu"
|
| 13 |
+
:aria-expanded="open.toString()"
|
| 14 |
+
@click.stop="open = !open"
|
| 15 |
+
>
|
| 16 |
+
<x-icon aria-hidden="true" name="add"></x-icon>
|
| 17 |
+
<span>New</span>
|
| 18 |
+
<x-icon class="office-new-chevron" aria-hidden="true" name="expand_more"></x-icon>
|
| 19 |
+
</button>
|
| 20 |
+
<div class="office-new-menu" role="menu" x-show="open" @click.stop style="display: none;">
|
| 21 |
+
<button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('open')">
|
| 22 |
+
<x-icon aria-hidden="true" name="folder_open"></x-icon>
|
| 23 |
+
<span>Open</span>
|
| 24 |
+
</button>
|
| 25 |
+
<button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('writer')">
|
| 26 |
+
<x-icon aria-hidden="true" name="description"></x-icon>
|
| 27 |
+
<span>Writer</span>
|
| 28 |
+
</button>
|
| 29 |
+
<button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('spreadsheet')">
|
| 30 |
+
<x-icon aria-hidden="true" name="table_chart"></x-icon>
|
| 31 |
+
<span>Spreadsheet</span>
|
| 32 |
+
</button>
|
| 33 |
+
<button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('presentation')">
|
| 34 |
+
<x-icon aria-hidden="true" name="co_present"></x-icon>
|
| 35 |
+
<span>Presentation</span>
|
| 36 |
+
</button>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
plugins/_desktop/extensions/webui/right_canvas_register_surfaces/register-desktop.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { store as desktopStore } from "/plugins/_desktop/webui/desktop-store.js";
|
| 2 |
+
|
| 3 |
+
function waitForElement(selector, timeoutMs = 10000) {
|
| 4 |
+
const found = document.querySelector(selector);
|
| 5 |
+
if (found) return Promise.resolve(found);
|
| 6 |
+
return new Promise((resolve) => {
|
| 7 |
+
const timeout = globalThis.setTimeout(() => {
|
| 8 |
+
observer.disconnect();
|
| 9 |
+
resolve(document.querySelector(selector));
|
| 10 |
+
}, timeoutMs);
|
| 11 |
+
const observer = new MutationObserver(() => {
|
| 12 |
+
const element = document.querySelector(selector);
|
| 13 |
+
if (!element) return;
|
| 14 |
+
globalThis.clearTimeout(timeout);
|
| 15 |
+
observer.disconnect();
|
| 16 |
+
resolve(element);
|
| 17 |
+
});
|
| 18 |
+
observer.observe(document.body, { childList: true, subtree: true });
|
| 19 |
+
});
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export default async function registerDesktopSurface(surfaces) {
|
| 23 |
+
surfaces.registerSurface({
|
| 24 |
+
id: "desktop",
|
| 25 |
+
title: "Desktop",
|
| 26 |
+
icon: "desktop_windows",
|
| 27 |
+
order: 20,
|
| 28 |
+
modalPath: "/plugins/_desktop/webui/main.html",
|
| 29 |
+
async beginDockHandoff() {
|
| 30 |
+
desktopStore.beforeDesktopHostHandoff?.();
|
| 31 |
+
},
|
| 32 |
+
async finishDockHandoff(payload = {}) {
|
| 33 |
+
if (payload.opened !== false) desktopStore.afterDesktopHostShown?.({ source: "dock" });
|
| 34 |
+
},
|
| 35 |
+
async cancelDockHandoff() {
|
| 36 |
+
desktopStore.cancelDesktopHostHandoff?.();
|
| 37 |
+
},
|
| 38 |
+
async open(payload = {}) {
|
| 39 |
+
const panel = await waitForElement('[data-surface-id="desktop"] .office-panel');
|
| 40 |
+
if (!panel) throw new Error("Desktop surface panel did not mount.");
|
| 41 |
+
await desktopStore.onMount?.(panel, { mode: "canvas" });
|
| 42 |
+
await desktopStore.onOpen?.(payload);
|
| 43 |
+
desktopStore.afterDesktopHostShown?.({ source: payload?.source || "canvas" });
|
| 44 |
+
},
|
| 45 |
+
async close(payload = {}) {
|
| 46 |
+
desktopStore.beforeHostHidden?.({ unloadDesktop: payload?.reason === "mobile" });
|
| 47 |
+
},
|
| 48 |
+
});
|
| 49 |
+
}
|
plugins/_desktop/extensions/webui/surfaces_register/register-desktop.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { store as desktopStore } from "/plugins/_desktop/webui/desktop-store.js";
|
| 2 |
+
|
| 3 |
+
function waitForElement(selector, timeoutMs = 10000) {
|
| 4 |
+
const found = document.querySelector(selector);
|
| 5 |
+
if (found) return Promise.resolve(found);
|
| 6 |
+
return new Promise((resolve) => {
|
| 7 |
+
const timeout = globalThis.setTimeout(() => {
|
| 8 |
+
observer.disconnect();
|
| 9 |
+
resolve(document.querySelector(selector));
|
| 10 |
+
}, timeoutMs);
|
| 11 |
+
const observer = new MutationObserver(() => {
|
| 12 |
+
const element = document.querySelector(selector);
|
| 13 |
+
if (!element) return;
|
| 14 |
+
globalThis.clearTimeout(timeout);
|
| 15 |
+
observer.disconnect();
|
| 16 |
+
resolve(element);
|
| 17 |
+
});
|
| 18 |
+
observer.observe(document.body, { childList: true, subtree: true });
|
| 19 |
+
});
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export default async function registerDesktopSurface(surfaces) {
|
| 23 |
+
surfaces.registerSurface({
|
| 24 |
+
id: "desktop",
|
| 25 |
+
title: "Desktop",
|
| 26 |
+
icon: "desktop_windows",
|
| 27 |
+
order: 20,
|
| 28 |
+
modalPath: "/plugins/_desktop/webui/main.html",
|
| 29 |
+
async beginDockHandoff() {
|
| 30 |
+
desktopStore.beforeDesktopHostHandoff?.();
|
| 31 |
+
},
|
| 32 |
+
async finishDockHandoff(payload = {}) {
|
| 33 |
+
if (payload.opened !== false) desktopStore.afterDesktopHostShown?.({ source: "dock" });
|
| 34 |
+
},
|
| 35 |
+
async cancelDockHandoff() {
|
| 36 |
+
desktopStore.cancelDesktopHostHandoff?.();
|
| 37 |
+
},
|
| 38 |
+
async open(payload = {}) {
|
| 39 |
+
const panel = await waitForElement('[data-surface-id="desktop"] .office-panel');
|
| 40 |
+
if (!panel) throw new Error("Desktop surface panel did not mount.");
|
| 41 |
+
await desktopStore.onMount?.(panel, { mode: "canvas" });
|
| 42 |
+
await desktopStore.onOpen?.(payload);
|
| 43 |
+
desktopStore.afterDesktopHostShown?.({ source: payload?.source || "canvas" });
|
| 44 |
+
},
|
| 45 |
+
async close(payload = {}) {
|
| 46 |
+
desktopStore.beforeHostHidden?.({ unloadDesktop: payload?.reason === "mobile" });
|
| 47 |
+
},
|
| 48 |
+
});
|
| 49 |
+
}
|
plugins/_desktop/helpers/desktop_routes.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from helpers.virtual_desktop_routes import VirtualDesktopGateway, install_route_hooks, is_installed
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
__all__ = ["VirtualDesktopGateway", "install_route_hooks", "is_installed"]
|
plugins/_desktop/helpers/desktop_session.py
ADDED
|
@@ -0,0 +1,2601 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import atexit
|
| 4 |
+
import fcntl
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import shutil
|
| 9 |
+
import socket
|
| 10 |
+
import subprocess
|
| 11 |
+
import threading
|
| 12 |
+
import time
|
| 13 |
+
import uuid
|
| 14 |
+
import xml.etree.ElementTree as ET
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
from helpers import files, virtual_desktop
|
| 20 |
+
from helpers.localization import Localization
|
| 21 |
+
from plugins._desktop.helpers import desktop_state
|
| 22 |
+
from plugins._office.helpers import document_store, libreoffice
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"}
|
| 26 |
+
PLUGIN_NAME = "_desktop"
|
| 27 |
+
SYSTEM_SESSION_ID = "agent-zero-desktop"
|
| 28 |
+
SYSTEM_FILE_ID = "system-desktop"
|
| 29 |
+
SYSTEM_TITLE = "Desktop"
|
| 30 |
+
STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME))
|
| 31 |
+
RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME))
|
| 32 |
+
SESSION_DIR = STATE_DIR / "sessions"
|
| 33 |
+
PROFILE_DIR = STATE_DIR / "profiles"
|
| 34 |
+
LEGACY_SESSION_DIRS = (
|
| 35 |
+
RETIRED_STATE_DIR / "sessions",
|
| 36 |
+
Path(files.get_abs_path("tmp", "_office", "desktop", "sessions")),
|
| 37 |
+
)
|
| 38 |
+
DISPLAY_BASE = 120
|
| 39 |
+
XPRA_PORT_BASE = 14500
|
| 40 |
+
MAX_SESSIONS = 12
|
| 41 |
+
DEFAULT_SCREEN_WIDTH = virtual_desktop.DEFAULT_WIDTH
|
| 42 |
+
DEFAULT_SCREEN_HEIGHT = virtual_desktop.DEFAULT_HEIGHT
|
| 43 |
+
MAX_SCREEN_WIDTH = virtual_desktop.MAX_WIDTH
|
| 44 |
+
MAX_SCREEN_HEIGHT = virtual_desktop.MAX_HEIGHT
|
| 45 |
+
BLOCKING_DIALOG_TITLES = ("Remote Files", "File Services")
|
| 46 |
+
DISPLAY_START_TIMEOUT_SECONDS = 30.0
|
| 47 |
+
PORT_START_TIMEOUT_SECONDS = 30.0
|
| 48 |
+
STARTUP_GRACE_SECONDS = 45
|
| 49 |
+
RUNTIME_INSTALL_MESSAGE = (
|
| 50 |
+
"Installing Agent Zero Desktop runtime dependencies. "
|
| 51 |
+
"This can take a few minutes after an update."
|
| 52 |
+
)
|
| 53 |
+
HIDDEN_XPRA_DESKTOP_ENTRIES = (
|
| 54 |
+
"xpra.desktop",
|
| 55 |
+
"xpra-gui.desktop",
|
| 56 |
+
"xpra-launcher.desktop",
|
| 57 |
+
"xpra-shadow.desktop",
|
| 58 |
+
"xpra-start.desktop",
|
| 59 |
+
)
|
| 60 |
+
HIDDEN_XFCE_MENU_ENTRIES = (
|
| 61 |
+
("exo-mail-reader.desktop", "Mail Reader"),
|
| 62 |
+
("exo-web-browser.desktop", "Web Browser"),
|
| 63 |
+
("xfce4-mail-reader.desktop", "Mail Reader"),
|
| 64 |
+
("xfce4-web-browser.desktop", "Web Browser"),
|
| 65 |
+
("xfce4-session-logout.desktop", "Log Out"),
|
| 66 |
+
("xfce4-lock-screen.desktop", "Lock Screen"),
|
| 67 |
+
("xflock4.desktop", "Lock Screen"),
|
| 68 |
+
("xfce4-switch-user.desktop", "Switch User"),
|
| 69 |
+
)
|
| 70 |
+
DESKTOP_README_SOURCE = Path(__file__).resolve().parents[1] / "assets" / "desktop" / "README.md"
|
| 71 |
+
DESKTOP_FOLDER_LINKS = (
|
| 72 |
+
("Projects", ("usr", "projects")),
|
| 73 |
+
("Skills", ("usr", "skills")),
|
| 74 |
+
("Agents", ("usr", "agents")),
|
| 75 |
+
("Downloads", ("usr", "downloads")),
|
| 76 |
+
)
|
| 77 |
+
URL_INTENT_MAX_ITEMS = 50
|
| 78 |
+
URL_INTENT_MAX_LENGTH = 8192
|
| 79 |
+
URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop"
|
| 80 |
+
EDITOR_HANDLER_DESKTOP_ID = "agent-zero-editor.desktop"
|
| 81 |
+
WRITER_HANDLER_DESKTOP_ID = "libreoffice-writer.desktop"
|
| 82 |
+
SHUTDOWN_HANDLER_DESKTOP_ID = "agent-zero-shutdown.desktop"
|
| 83 |
+
SHUTDOWN_PANEL_LAUNCHER_ID = SHUTDOWN_HANDLER_DESKTOP_ID
|
| 84 |
+
SHUTDOWN_CONFIRM_SECONDS = 8
|
| 85 |
+
OOR_NS = "http://openoffice.org/2001/registry"
|
| 86 |
+
XS_NS = "http://www.w3.org/2001/XMLSchema"
|
| 87 |
+
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@dataclass
|
| 91 |
+
class DesktopSession:
|
| 92 |
+
session_id: str
|
| 93 |
+
file_id: str
|
| 94 |
+
extension: str
|
| 95 |
+
path: str
|
| 96 |
+
title: str
|
| 97 |
+
display: int
|
| 98 |
+
xpra_port: int
|
| 99 |
+
token: str
|
| 100 |
+
url: str
|
| 101 |
+
profile_dir: Path
|
| 102 |
+
width: int = DEFAULT_SCREEN_WIDTH
|
| 103 |
+
height: int = DEFAULT_SCREEN_HEIGHT
|
| 104 |
+
processes: dict[str, subprocess.Popen[Any]] = field(default_factory=dict)
|
| 105 |
+
process_ids: dict[str, int] = field(default_factory=dict)
|
| 106 |
+
owns_processes: bool = True
|
| 107 |
+
started_at: float = field(default_factory=time.time)
|
| 108 |
+
timezone: str = field(default_factory=lambda: Localization.get().get_timezone())
|
| 109 |
+
|
| 110 |
+
def alive(self) -> bool:
|
| 111 |
+
return _running(self.processes.get("xpra")) or _pid_is_running(self.process_ids.get("xpra", 0))
|
| 112 |
+
|
| 113 |
+
def public(self, doc: dict[str, Any] | None = None) -> dict[str, Any]:
|
| 114 |
+
title = str(doc.get("basename") or "") if doc else self.title
|
| 115 |
+
path = str(doc.get("path") or "") if doc else self.path
|
| 116 |
+
extension = str(doc.get("extension") or "") if doc else self.extension
|
| 117 |
+
file_id = str(doc.get("file_id") or "") if doc else self.file_id
|
| 118 |
+
return {
|
| 119 |
+
"available": True,
|
| 120 |
+
"session_id": self.session_id,
|
| 121 |
+
"file_id": file_id,
|
| 122 |
+
"extension": extension,
|
| 123 |
+
"title": title,
|
| 124 |
+
"path": document_store.display_path(path),
|
| 125 |
+
"url": self.url,
|
| 126 |
+
"token": self.token,
|
| 127 |
+
"display": f":{self.display}",
|
| 128 |
+
"desktop_path": virtual_desktop.SESSION_PATH,
|
| 129 |
+
"width": self.width,
|
| 130 |
+
"height": self.height,
|
| 131 |
+
"started_at": self.started_at,
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class DesktopSessionManager:
|
| 136 |
+
def __init__(self) -> None:
|
| 137 |
+
self._lock = threading.RLock()
|
| 138 |
+
self._sessions: dict[str, DesktopSession] = {}
|
| 139 |
+
|
| 140 |
+
def ensure_system_desktop(self) -> dict[str, Any]:
|
| 141 |
+
try:
|
| 142 |
+
with self._lock:
|
| 143 |
+
self._reap_dead_locked()
|
| 144 |
+
session = self._ensure_system_desktop_locked()
|
| 145 |
+
return session.public()
|
| 146 |
+
except Exception as exc:
|
| 147 |
+
status = collect_desktop_status()
|
| 148 |
+
return {
|
| 149 |
+
"available": False,
|
| 150 |
+
"error": str(exc),
|
| 151 |
+
"status": status,
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
def open(self, doc: dict[str, Any], *, refresh: bool = False) -> dict[str, Any]:
|
| 155 |
+
ext = str(doc.get("extension") or "").lower()
|
| 156 |
+
if ext not in OFFICIAL_EXTENSIONS:
|
| 157 |
+
return {"available": False, "reason": f".{ext} does not use the LibreOffice desktop surface."}
|
| 158 |
+
|
| 159 |
+
with self._lock:
|
| 160 |
+
self._reap_dead_locked()
|
| 161 |
+
try:
|
| 162 |
+
session = self._ensure_system_desktop_locked()
|
| 163 |
+
except Exception as exc:
|
| 164 |
+
status = collect_desktop_status()
|
| 165 |
+
return {
|
| 166 |
+
"available": False,
|
| 167 |
+
"error": str(exc),
|
| 168 |
+
"status": status,
|
| 169 |
+
}
|
| 170 |
+
refreshed = False
|
| 171 |
+
try:
|
| 172 |
+
if refresh and session.file_id == str(doc.get("file_id") or ""):
|
| 173 |
+
refreshed = self._reload_document_locked(session, doc)
|
| 174 |
+
else:
|
| 175 |
+
self._open_document_locked(session, doc)
|
| 176 |
+
except Exception as exc:
|
| 177 |
+
return {
|
| 178 |
+
"available": False,
|
| 179 |
+
"error": str(exc),
|
| 180 |
+
"status": collect_desktop_status(),
|
| 181 |
+
}
|
| 182 |
+
session.file_id = str(doc["file_id"])
|
| 183 |
+
session.extension = ext
|
| 184 |
+
session.path = str(doc["path"])
|
| 185 |
+
session.title = str(doc["basename"])
|
| 186 |
+
self._write_manifest(session)
|
| 187 |
+
public = session.public(doc)
|
| 188 |
+
public["refreshed"] = refreshed
|
| 189 |
+
return public
|
| 190 |
+
|
| 191 |
+
def refresh_document(self, file_id: str) -> dict[str, Any]:
|
| 192 |
+
normalized = str(file_id or "").strip()
|
| 193 |
+
if not normalized:
|
| 194 |
+
return {"ok": True, "refreshed": False}
|
| 195 |
+
try:
|
| 196 |
+
doc = document_store.get_document(normalized)
|
| 197 |
+
except Exception:
|
| 198 |
+
return {"ok": False, "refreshed": False, "error": "Document not found."}
|
| 199 |
+
|
| 200 |
+
ext = str(doc.get("extension") or "").lower()
|
| 201 |
+
if ext not in OFFICIAL_EXTENSIONS:
|
| 202 |
+
return {"ok": True, "refreshed": False}
|
| 203 |
+
|
| 204 |
+
with self._lock:
|
| 205 |
+
self._reap_dead_locked()
|
| 206 |
+
session = self._find_by_file_id_locked(normalized)
|
| 207 |
+
if not session:
|
| 208 |
+
existing = self._load_system_desktop_from_manifest_locked()
|
| 209 |
+
if existing:
|
| 210 |
+
self._sessions[existing.session_id] = existing
|
| 211 |
+
self._register_virtual_desktop(existing)
|
| 212 |
+
if existing.file_id == normalized:
|
| 213 |
+
session = existing
|
| 214 |
+
if not session:
|
| 215 |
+
return {"ok": True, "refreshed": False}
|
| 216 |
+
refreshed = self._reload_document_locked(session, doc)
|
| 217 |
+
session.file_id = str(doc["file_id"])
|
| 218 |
+
session.extension = ext
|
| 219 |
+
session.path = str(doc["path"])
|
| 220 |
+
session.title = str(doc["basename"])
|
| 221 |
+
self._write_manifest(session)
|
| 222 |
+
return {"ok": True, "refreshed": refreshed, "desktop": session.public(doc)}
|
| 223 |
+
|
| 224 |
+
def save(self, session_id: str, file_id: str = "") -> dict[str, Any]:
|
| 225 |
+
with self._lock:
|
| 226 |
+
session = self.require(session_id)
|
| 227 |
+
doc = self._document_for_save(session, file_id)
|
| 228 |
+
if not doc:
|
| 229 |
+
return {
|
| 230 |
+
"ok": True,
|
| 231 |
+
"session_id": session.session_id,
|
| 232 |
+
"document": None,
|
| 233 |
+
"changed": False,
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
xdotool = shutil.which("xdotool")
|
| 237 |
+
if not xdotool:
|
| 238 |
+
updated = document_store.register_document(doc["path"])
|
| 239 |
+
return {
|
| 240 |
+
"ok": False,
|
| 241 |
+
"error": "xdotool is not installed; use LibreOffice's Save control inside the canvas.",
|
| 242 |
+
"document": _public_doc(updated),
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
window_id = self._office_window_id_locked(
|
| 246 |
+
session,
|
| 247 |
+
title=str(doc.get("basename") or Path(doc["path"]).name),
|
| 248 |
+
fallback=False,
|
| 249 |
+
)
|
| 250 |
+
if not window_id:
|
| 251 |
+
return {
|
| 252 |
+
"ok": False,
|
| 253 |
+
"error": f"LibreOffice window not found for {doc['basename']}.",
|
| 254 |
+
"document": _public_doc(doc),
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
result = subprocess.run(
|
| 258 |
+
[
|
| 259 |
+
xdotool,
|
| 260 |
+
"windowactivate",
|
| 261 |
+
"--sync",
|
| 262 |
+
window_id,
|
| 263 |
+
"key",
|
| 264 |
+
"--clearmodifiers",
|
| 265 |
+
"ctrl+s",
|
| 266 |
+
],
|
| 267 |
+
check=False,
|
| 268 |
+
capture_output=True,
|
| 269 |
+
text=True,
|
| 270 |
+
timeout=8,
|
| 271 |
+
env=self._display_env(session),
|
| 272 |
+
)
|
| 273 |
+
time.sleep(0.8)
|
| 274 |
+
updated = document_store.register_document(doc["path"])
|
| 275 |
+
if result.returncode != 0:
|
| 276 |
+
detail = (result.stderr or result.stdout or "").strip()
|
| 277 |
+
return {
|
| 278 |
+
"ok": False,
|
| 279 |
+
"error": detail or "LibreOffice desktop save shortcut failed.",
|
| 280 |
+
"document": _public_doc(updated),
|
| 281 |
+
}
|
| 282 |
+
return {
|
| 283 |
+
"ok": True,
|
| 284 |
+
"session_id": session.session_id,
|
| 285 |
+
"document": _public_doc(updated),
|
| 286 |
+
"changed": updated.get("sha256") != doc.get("sha256"),
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
def sync(self, session_id: str = "", file_id: str = "") -> dict[str, Any]:
|
| 290 |
+
session = self.get(session_id) if session_id else self._find_by_file_id(file_id)
|
| 291 |
+
if not session:
|
| 292 |
+
return {"ok": False, "error": "LibreOffice desktop session not found."}
|
| 293 |
+
if not _url_bridge_script_path(session).exists():
|
| 294 |
+
try:
|
| 295 |
+
self._prepare_desktop_url_bridge(session)
|
| 296 |
+
self._refresh_xfce_desktop(session)
|
| 297 |
+
except Exception:
|
| 298 |
+
pass
|
| 299 |
+
url_intents = self.claim_url_intents(session.session_id)
|
| 300 |
+
shutdown_request = self.claim_shutdown_request(session.session_id)
|
| 301 |
+
if shutdown_request:
|
| 302 |
+
return self.shutdown_system_desktop(
|
| 303 |
+
save_first=True,
|
| 304 |
+
source=str(shutdown_request.get("source") or "tray"),
|
| 305 |
+
)
|
| 306 |
+
doc = self._document_for_save(session, file_id)
|
| 307 |
+
if not doc:
|
| 308 |
+
return {
|
| 309 |
+
"ok": True,
|
| 310 |
+
"session_id": session.session_id,
|
| 311 |
+
"desktop": session.public(),
|
| 312 |
+
"url_intents": url_intents,
|
| 313 |
+
}
|
| 314 |
+
updated = document_store.register_document(doc["path"])
|
| 315 |
+
return {
|
| 316 |
+
"ok": True,
|
| 317 |
+
"session_id": session.session_id,
|
| 318 |
+
"document": _public_doc(updated),
|
| 319 |
+
"url_intents": url_intents,
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
def state(self, *, include_screenshot: bool = False, context_id: str = "") -> dict[str, Any]:
|
| 323 |
+
with self._lock:
|
| 324 |
+
self._reap_dead_locked()
|
| 325 |
+
return desktop_state.collect_state(
|
| 326 |
+
include_screenshot=include_screenshot,
|
| 327 |
+
context_id=context_id,
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
def claim_url_intents(self, session_id: str = SYSTEM_SESSION_ID) -> list[dict[str, Any]]:
|
| 331 |
+
session = self.get(session_id) or self.get(SYSTEM_SESSION_ID)
|
| 332 |
+
if not session:
|
| 333 |
+
return []
|
| 334 |
+
return _claim_url_intents(session)
|
| 335 |
+
|
| 336 |
+
def claim_shutdown_request(self, session_id: str = SYSTEM_SESSION_ID) -> dict[str, Any] | None:
|
| 337 |
+
session = self.get(session_id) or self.get(SYSTEM_SESSION_ID)
|
| 338 |
+
if not session:
|
| 339 |
+
return None
|
| 340 |
+
return _claim_shutdown_request(session)
|
| 341 |
+
|
| 342 |
+
def shutdown_system_desktop(self, *, save_first: bool = True, source: str = "api") -> dict[str, Any]:
|
| 343 |
+
with self._lock:
|
| 344 |
+
session = self._sessions.get(SYSTEM_SESSION_ID)
|
| 345 |
+
if not session:
|
| 346 |
+
_remove_system_manifest()
|
| 347 |
+
return {
|
| 348 |
+
"ok": True,
|
| 349 |
+
"closed": 0,
|
| 350 |
+
"session_id": SYSTEM_SESSION_ID,
|
| 351 |
+
"shutdown": True,
|
| 352 |
+
"intentional_shutdown": True,
|
| 353 |
+
"source": source,
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
save_result = None
|
| 357 |
+
if save_first:
|
| 358 |
+
try:
|
| 359 |
+
save_result = self.save(session.session_id)
|
| 360 |
+
except Exception as exc:
|
| 361 |
+
save_result = {"ok": False, "error": str(exc)}
|
| 362 |
+
|
| 363 |
+
with self._lock:
|
| 364 |
+
if self._sessions.get(SYSTEM_SESSION_ID) is session:
|
| 365 |
+
self._sessions.pop(SYSTEM_SESSION_ID, None)
|
| 366 |
+
virtual_desktop.unregister_session(session.token)
|
| 367 |
+
self._terminate_session(session, include_rehydrated=True)
|
| 368 |
+
self._remove_manifest(session.session_id)
|
| 369 |
+
_clear_shutdown_request(session)
|
| 370 |
+
return {
|
| 371 |
+
"ok": True,
|
| 372 |
+
"closed": 1,
|
| 373 |
+
"session_id": session.session_id,
|
| 374 |
+
"shutdown": True,
|
| 375 |
+
"intentional_shutdown": True,
|
| 376 |
+
"source": source,
|
| 377 |
+
"save": save_result,
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
def retarget_document(self, file_id: str, doc: dict[str, Any]) -> dict[str, Any]:
|
| 381 |
+
session = self._find_by_file_id(file_id)
|
| 382 |
+
if not session:
|
| 383 |
+
return {"ok": True, "updated": False}
|
| 384 |
+
with self._lock:
|
| 385 |
+
session.path = str(doc["path"])
|
| 386 |
+
session.title = str(doc["basename"])
|
| 387 |
+
session.extension = str(doc["extension"])
|
| 388 |
+
self._write_manifest(session)
|
| 389 |
+
return {"ok": True, "updated": True, "desktop": session.public(doc)}
|
| 390 |
+
|
| 391 |
+
def close(self, session_id: str, save_first: bool = True) -> dict[str, Any]:
|
| 392 |
+
with self._lock:
|
| 393 |
+
normalized = str(session_id or "").strip()
|
| 394 |
+
session = self._sessions.get(normalized)
|
| 395 |
+
if not session:
|
| 396 |
+
return {"ok": True, "closed": 0}
|
| 397 |
+
if session.session_id == SYSTEM_SESSION_ID:
|
| 398 |
+
save_result = None
|
| 399 |
+
if save_first:
|
| 400 |
+
try:
|
| 401 |
+
save_result = self.save(session.session_id)
|
| 402 |
+
except Exception as exc:
|
| 403 |
+
save_result = {"ok": False, "error": str(exc)}
|
| 404 |
+
return {
|
| 405 |
+
"ok": True,
|
| 406 |
+
"closed": 0,
|
| 407 |
+
"session_id": session.session_id,
|
| 408 |
+
"persistent": True,
|
| 409 |
+
"save": save_result,
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
save_result = None
|
| 413 |
+
if save_first:
|
| 414 |
+
try:
|
| 415 |
+
save_result = self.save(session.session_id)
|
| 416 |
+
except Exception as exc:
|
| 417 |
+
save_result = {"ok": False, "error": str(exc)}
|
| 418 |
+
with self._lock:
|
| 419 |
+
self._sessions.pop(session.session_id, None)
|
| 420 |
+
virtual_desktop.unregister_session(session.token)
|
| 421 |
+
self._terminate_session(session, include_rehydrated=True)
|
| 422 |
+
self._remove_manifest(session.session_id)
|
| 423 |
+
return {"ok": True, "closed": 1, "session_id": session.session_id, "save": save_result}
|
| 424 |
+
|
| 425 |
+
def close_file(self, file_id: str) -> int:
|
| 426 |
+
return 0
|
| 427 |
+
|
| 428 |
+
def resize(self, session_id: str, width: int, height: int) -> dict[str, Any]:
|
| 429 |
+
session = self.get(session_id)
|
| 430 |
+
if not session:
|
| 431 |
+
return {"ok": False, "error": "LibreOffice desktop session not found."}
|
| 432 |
+
is_system_desktop = session.session_id == SYSTEM_SESSION_ID and session.extension == "desktop"
|
| 433 |
+
if is_system_desktop:
|
| 434 |
+
width, height = virtual_desktop.normalize_desktop_display_size(width, height)
|
| 435 |
+
result = virtual_desktop.resize_display(
|
| 436 |
+
display=session.display,
|
| 437 |
+
width=width,
|
| 438 |
+
height=height,
|
| 439 |
+
max_width=MAX_SCREEN_WIDTH,
|
| 440 |
+
max_height=MAX_SCREEN_HEIGHT,
|
| 441 |
+
window_class="" if is_system_desktop else "libreoffice",
|
| 442 |
+
keys=() if is_system_desktop else ("Escape",),
|
| 443 |
+
xauthority=self._xauthority(session),
|
| 444 |
+
home=str(session.profile_dir),
|
| 445 |
+
)
|
| 446 |
+
if result.get("ok"):
|
| 447 |
+
session.width = int(result["width"])
|
| 448 |
+
session.height = int(result["height"])
|
| 449 |
+
if not is_system_desktop:
|
| 450 |
+
self._dismiss_blocking_dialogs(session)
|
| 451 |
+
return result
|
| 452 |
+
|
| 453 |
+
def proxy_for_token(self, token: str) -> tuple[str, int] | None:
|
| 454 |
+
normalized = str(token or "").strip()
|
| 455 |
+
with self._lock:
|
| 456 |
+
session = self._sessions.get(normalized)
|
| 457 |
+
if not session:
|
| 458 |
+
session = next((item for item in self._sessions.values() if item.token == normalized), None)
|
| 459 |
+
if not session or not session.alive():
|
| 460 |
+
return None
|
| 461 |
+
return ("127.0.0.1", session.xpra_port)
|
| 462 |
+
|
| 463 |
+
def resize_for_token(self, token: str, width: int, height: int) -> dict[str, Any]:
|
| 464 |
+
normalized = str(token or "").strip()
|
| 465 |
+
with self._lock:
|
| 466 |
+
session = self._sessions.get(normalized)
|
| 467 |
+
if not session:
|
| 468 |
+
session = next((item for item in self._sessions.values() if item.token == normalized), None)
|
| 469 |
+
if not session:
|
| 470 |
+
return {"ok": False, "error": "LibreOffice desktop session not found."}
|
| 471 |
+
return self.resize(session.session_id, width, height)
|
| 472 |
+
|
| 473 |
+
def get(self, session_id: str) -> DesktopSession | None:
|
| 474 |
+
with self._lock:
|
| 475 |
+
session = self._sessions.get(str(session_id or "").strip())
|
| 476 |
+
return session if session and session.alive() else None
|
| 477 |
+
|
| 478 |
+
def require(self, session_id: str) -> DesktopSession:
|
| 479 |
+
session = self.get(session_id)
|
| 480 |
+
if not session:
|
| 481 |
+
raise FileNotFoundError(f"LibreOffice desktop session not found: {session_id}")
|
| 482 |
+
return session
|
| 483 |
+
|
| 484 |
+
def shutdown(self) -> None:
|
| 485 |
+
with self._lock:
|
| 486 |
+
sessions = list(self._sessions.values())
|
| 487 |
+
self._sessions.clear()
|
| 488 |
+
for session in sessions:
|
| 489 |
+
virtual_desktop.unregister_session(session.token)
|
| 490 |
+
if session.owns_processes:
|
| 491 |
+
self._terminate_session(session)
|
| 492 |
+
self._remove_manifest(session.session_id)
|
| 493 |
+
|
| 494 |
+
def sync_timezone(self, timezone: str | None = None) -> dict[str, Any]:
|
| 495 |
+
target_timezone = str(timezone or Localization.get().get_timezone()).strip()
|
| 496 |
+
if not target_timezone:
|
| 497 |
+
target_timezone = Localization.get().get_timezone()
|
| 498 |
+
|
| 499 |
+
with self._lock:
|
| 500 |
+
self._reap_dead_locked()
|
| 501 |
+
session = self._sessions.get(SYSTEM_SESSION_ID)
|
| 502 |
+
if not session or not session.alive():
|
| 503 |
+
return {"ok": True, "restarted": False, "reason": "no_active_desktop"}
|
| 504 |
+
if session.timezone == target_timezone:
|
| 505 |
+
return {"ok": True, "restarted": False, "timezone": target_timezone}
|
| 506 |
+
|
| 507 |
+
replacement = self._restart_system_desktop_for_timezone_locked(session)
|
| 508 |
+
return {
|
| 509 |
+
"ok": True,
|
| 510 |
+
"restarted": True,
|
| 511 |
+
"session_id": replacement.session_id,
|
| 512 |
+
"timezone": replacement.timezone,
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
def _document_for_save(self, session: DesktopSession, file_id: str = "") -> dict[str, Any] | None:
|
| 516 |
+
normalized = str(file_id or "").strip()
|
| 517 |
+
if normalized == SYSTEM_FILE_ID:
|
| 518 |
+
return None
|
| 519 |
+
if normalized and normalized != SYSTEM_FILE_ID:
|
| 520 |
+
return document_store.get_document(normalized)
|
| 521 |
+
if session.file_id and session.file_id != SYSTEM_FILE_ID:
|
| 522 |
+
try:
|
| 523 |
+
return document_store.get_document(session.file_id)
|
| 524 |
+
except Exception:
|
| 525 |
+
path = Path(session.path)
|
| 526 |
+
if path.is_file():
|
| 527 |
+
return document_store.register_document(path)
|
| 528 |
+
return None
|
| 529 |
+
|
| 530 |
+
def _restart_system_desktop_for_timezone_locked(self, session: DesktopSession) -> DesktopSession:
|
| 531 |
+
try:
|
| 532 |
+
doc = self._document_for_save(session, session.file_id)
|
| 533 |
+
except Exception:
|
| 534 |
+
doc = None
|
| 535 |
+
if doc:
|
| 536 |
+
try:
|
| 537 |
+
self.save(session.session_id, str(doc.get("file_id") or ""))
|
| 538 |
+
except Exception:
|
| 539 |
+
pass
|
| 540 |
+
|
| 541 |
+
virtual_desktop.unregister_session(session.token)
|
| 542 |
+
self._sessions.pop(session.session_id, None)
|
| 543 |
+
self._terminate_session(session, include_rehydrated=True)
|
| 544 |
+
self._remove_manifest(session.session_id)
|
| 545 |
+
|
| 546 |
+
replacement = self._ensure_system_desktop_locked()
|
| 547 |
+
if doc:
|
| 548 |
+
self._open_document_locked(replacement, doc)
|
| 549 |
+
replacement.file_id = str(doc["file_id"])
|
| 550 |
+
replacement.extension = str(doc["extension"])
|
| 551 |
+
replacement.path = str(doc["path"])
|
| 552 |
+
replacement.title = str(doc["basename"])
|
| 553 |
+
self._write_manifest(replacement)
|
| 554 |
+
return replacement
|
| 555 |
+
|
| 556 |
+
def _register_virtual_desktop(self, session: DesktopSession) -> None:
|
| 557 |
+
virtual_desktop.register_session(
|
| 558 |
+
token=session.token,
|
| 559 |
+
host="127.0.0.1",
|
| 560 |
+
port=session.xpra_port,
|
| 561 |
+
owner="desktop",
|
| 562 |
+
title=session.title,
|
| 563 |
+
resize=lambda width, height, session_id=session.session_id: self.resize(session_id, width, height),
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
def _ensure_system_desktop_locked(self) -> DesktopSession:
|
| 567 |
+
target_timezone = Localization.get().get_timezone()
|
| 568 |
+
existing = self._sessions.get(SYSTEM_SESSION_ID)
|
| 569 |
+
if existing and existing.alive():
|
| 570 |
+
if existing.timezone != target_timezone:
|
| 571 |
+
return self._restart_system_desktop_for_timezone_locked(existing)
|
| 572 |
+
self._prepare_desktop_url_bridge(existing)
|
| 573 |
+
self._refresh_xfce_desktop(existing)
|
| 574 |
+
return existing
|
| 575 |
+
|
| 576 |
+
existing = self._load_system_desktop_from_manifest_locked()
|
| 577 |
+
if existing:
|
| 578 |
+
self._sessions[existing.session_id] = existing
|
| 579 |
+
self._register_virtual_desktop(existing)
|
| 580 |
+
if existing.timezone != target_timezone:
|
| 581 |
+
return self._restart_system_desktop_for_timezone_locked(existing)
|
| 582 |
+
self._prepare_desktop_url_bridge(existing)
|
| 583 |
+
self._refresh_xfce_desktop(existing)
|
| 584 |
+
return existing
|
| 585 |
+
|
| 586 |
+
status = collect_desktop_status()
|
| 587 |
+
if not status["healthy"]:
|
| 588 |
+
raise RuntimeError(status["message"])
|
| 589 |
+
|
| 590 |
+
display, xpra_port = self._allocate_endpoint_locked()
|
| 591 |
+
profile_dir = PROFILE_DIR / SYSTEM_SESSION_ID
|
| 592 |
+
session = DesktopSession(
|
| 593 |
+
session_id=SYSTEM_SESSION_ID,
|
| 594 |
+
file_id=SYSTEM_FILE_ID,
|
| 595 |
+
extension="desktop",
|
| 596 |
+
path=str(document_store.document_binary_home()),
|
| 597 |
+
title=SYSTEM_TITLE,
|
| 598 |
+
display=display,
|
| 599 |
+
xpra_port=xpra_port,
|
| 600 |
+
token=SYSTEM_SESSION_ID,
|
| 601 |
+
url=_xpra_url(SYSTEM_SESSION_ID),
|
| 602 |
+
profile_dir=profile_dir,
|
| 603 |
+
timezone=target_timezone,
|
| 604 |
+
)
|
| 605 |
+
try:
|
| 606 |
+
self._prepare_profile(session)
|
| 607 |
+
self._prepare_desktop_launchers(session)
|
| 608 |
+
self._spawn_desktop_locked(session)
|
| 609 |
+
except Exception:
|
| 610 |
+
self._terminate_session(session)
|
| 611 |
+
raise
|
| 612 |
+
self._sessions[session.session_id] = session
|
| 613 |
+
self._register_virtual_desktop(session)
|
| 614 |
+
self._write_manifest(session)
|
| 615 |
+
return session
|
| 616 |
+
|
| 617 |
+
def _load_system_desktop_from_manifest_locked(self) -> DesktopSession | None:
|
| 618 |
+
manifest = SESSION_DIR / f"{SYSTEM_SESSION_ID}.json"
|
| 619 |
+
if not manifest.exists():
|
| 620 |
+
return None
|
| 621 |
+
try:
|
| 622 |
+
payload = json.loads(manifest.read_text(encoding="utf-8"))
|
| 623 |
+
display = int(payload.get("display") or 0)
|
| 624 |
+
xpra_port = int(payload.get("xpra_port") or 0)
|
| 625 |
+
process_ids = {
|
| 626 |
+
str(name): pid
|
| 627 |
+
for name, value in dict(payload.get("pids") or {}).items()
|
| 628 |
+
if (pid := _coerce_pid(value))
|
| 629 |
+
}
|
| 630 |
+
if not display or not xpra_port:
|
| 631 |
+
return None
|
| 632 |
+
if not _pid_is_running(process_ids.get("xpra", 0)):
|
| 633 |
+
return None
|
| 634 |
+
if not _port_is_accepting("127.0.0.1", xpra_port):
|
| 635 |
+
return None
|
| 636 |
+
|
| 637 |
+
path = str(payload.get("path") or document_store.document_binary_home())
|
| 638 |
+
file_id = str(payload.get("file_id") or SYSTEM_FILE_ID)
|
| 639 |
+
extension = str(payload.get("extension") or "").lower()
|
| 640 |
+
if not extension:
|
| 641 |
+
extension = "desktop" if file_id == SYSTEM_FILE_ID else Path(path).suffix.lower().lstrip(".")
|
| 642 |
+
title = str(payload.get("title") or "")
|
| 643 |
+
if not title:
|
| 644 |
+
title = SYSTEM_TITLE if file_id == SYSTEM_FILE_ID else Path(path).name or SYSTEM_TITLE
|
| 645 |
+
return DesktopSession(
|
| 646 |
+
session_id=SYSTEM_SESSION_ID,
|
| 647 |
+
file_id=file_id,
|
| 648 |
+
extension=extension,
|
| 649 |
+
path=path,
|
| 650 |
+
title=title,
|
| 651 |
+
display=display,
|
| 652 |
+
xpra_port=xpra_port,
|
| 653 |
+
token=SYSTEM_SESSION_ID,
|
| 654 |
+
url=_xpra_url(SYSTEM_SESSION_ID),
|
| 655 |
+
profile_dir=_state_path_from_retired_root(
|
| 656 |
+
Path(payload.get("profile_dir") or PROFILE_DIR / SYSTEM_SESSION_ID)
|
| 657 |
+
),
|
| 658 |
+
width=int(payload.get("width") or DEFAULT_SCREEN_WIDTH),
|
| 659 |
+
height=int(payload.get("height") or DEFAULT_SCREEN_HEIGHT),
|
| 660 |
+
process_ids=process_ids,
|
| 661 |
+
owns_processes=False,
|
| 662 |
+
started_at=float(payload.get("started_at") or time.time()),
|
| 663 |
+
timezone=str(payload.get("timezone") or Localization.get().get_timezone()),
|
| 664 |
+
)
|
| 665 |
+
except Exception:
|
| 666 |
+
return None
|
| 667 |
+
|
| 668 |
+
def _spawn_desktop_locked(self, session: DesktopSession) -> None:
|
| 669 |
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
| 670 |
+
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
| 671 |
+
session.profile_dir.mkdir(parents=True, exist_ok=True)
|
| 672 |
+
|
| 673 |
+
xpra = _require_binary("xpra")
|
| 674 |
+
xvfb = _require_binary("Xvfb")
|
| 675 |
+
_require_binary("xfce4-session")
|
| 676 |
+
_require_binary("dbus-launch")
|
| 677 |
+
xfce_launcher = self._prepare_xfce_launcher(session)
|
| 678 |
+
|
| 679 |
+
session.processes["xvfb"] = subprocess.Popen(
|
| 680 |
+
_xvfb_command(xvfb, session),
|
| 681 |
+
stdin=subprocess.DEVNULL,
|
| 682 |
+
stdout=subprocess.DEVNULL,
|
| 683 |
+
stderr=subprocess.DEVNULL,
|
| 684 |
+
env=self._session_env(session),
|
| 685 |
+
)
|
| 686 |
+
self._wait_for_display(session)
|
| 687 |
+
self._set_display_size(session, session.width, session.height)
|
| 688 |
+
self._prepare_root_window(session)
|
| 689 |
+
session.processes["xfce"] = subprocess.Popen(
|
| 690 |
+
[str(xfce_launcher)],
|
| 691 |
+
stdin=subprocess.DEVNULL,
|
| 692 |
+
stdout=subprocess.DEVNULL,
|
| 693 |
+
stderr=subprocess.DEVNULL,
|
| 694 |
+
env=self._display_env(session),
|
| 695 |
+
)
|
| 696 |
+
self._wait_for_xfce(session)
|
| 697 |
+
session.processes["xpra"] = subprocess.Popen(
|
| 698 |
+
_xpra_shadow_command(xpra, session),
|
| 699 |
+
stdin=subprocess.DEVNULL,
|
| 700 |
+
stdout=subprocess.DEVNULL,
|
| 701 |
+
stderr=subprocess.DEVNULL,
|
| 702 |
+
env=self._display_env(session),
|
| 703 |
+
)
|
| 704 |
+
_wait_for_port(
|
| 705 |
+
"127.0.0.1",
|
| 706 |
+
session.xpra_port,
|
| 707 |
+
timeout=PORT_START_TIMEOUT_SECONDS,
|
| 708 |
+
process=session.processes.get("xpra"),
|
| 709 |
+
)
|
| 710 |
+
self._refresh_xfce_desktop(session)
|
| 711 |
+
|
| 712 |
+
def _restart_xpra_shadow(self, session: DesktopSession) -> None:
|
| 713 |
+
xpra = _require_binary("xpra")
|
| 714 |
+
process = session.processes.get("xpra")
|
| 715 |
+
if process:
|
| 716 |
+
_terminate_process(process)
|
| 717 |
+
session.processes["xpra"] = subprocess.Popen(
|
| 718 |
+
_xpra_shadow_command(xpra, session),
|
| 719 |
+
stdin=subprocess.DEVNULL,
|
| 720 |
+
stdout=subprocess.DEVNULL,
|
| 721 |
+
stderr=subprocess.DEVNULL,
|
| 722 |
+
env=self._display_env(session),
|
| 723 |
+
)
|
| 724 |
+
_wait_for_port(
|
| 725 |
+
"127.0.0.1",
|
| 726 |
+
session.xpra_port,
|
| 727 |
+
timeout=PORT_START_TIMEOUT_SECONDS,
|
| 728 |
+
process=session.processes.get("xpra"),
|
| 729 |
+
)
|
| 730 |
+
|
| 731 |
+
def _open_document_locked(self, session: DesktopSession, doc: dict[str, Any]) -> None:
|
| 732 |
+
soffice = libreoffice.find_soffice()
|
| 733 |
+
if not soffice:
|
| 734 |
+
raise RuntimeError("LibreOffice is not installed in this runtime.")
|
| 735 |
+
path = str(doc["path"])
|
| 736 |
+
self._remove_stale_lock_file(session, path=path)
|
| 737 |
+
process_key = f"soffice-{doc['file_id']}"
|
| 738 |
+
session.processes[process_key] = subprocess.Popen(
|
| 739 |
+
[
|
| 740 |
+
soffice,
|
| 741 |
+
"--norestore",
|
| 742 |
+
"--nofirststartwizard",
|
| 743 |
+
"--nolockcheck",
|
| 744 |
+
f"-env:UserInstallation=file://{session.profile_dir}",
|
| 745 |
+
path,
|
| 746 |
+
],
|
| 747 |
+
cwd=str(Path(path).parent),
|
| 748 |
+
stdin=subprocess.DEVNULL,
|
| 749 |
+
stdout=subprocess.DEVNULL,
|
| 750 |
+
stderr=subprocess.DEVNULL,
|
| 751 |
+
env=self._display_env(session),
|
| 752 |
+
)
|
| 753 |
+
self._fit_office_window(session, process=session.processes[process_key])
|
| 754 |
+
window_id = self._wait_for_office_window_locked(
|
| 755 |
+
session,
|
| 756 |
+
title=str(doc.get("basename") or ""),
|
| 757 |
+
process=session.processes[process_key],
|
| 758 |
+
)
|
| 759 |
+
if not window_id:
|
| 760 |
+
raise RuntimeError(
|
| 761 |
+
f"LibreOffice did not show {doc.get('basename') or 'the document'} "
|
| 762 |
+
f"on desktop :{session.display}.",
|
| 763 |
+
)
|
| 764 |
+
self._fit_office_window_id_locked(
|
| 765 |
+
session,
|
| 766 |
+
window_id,
|
| 767 |
+
env=self._display_env(session),
|
| 768 |
+
keys=("Escape",),
|
| 769 |
+
)
|
| 770 |
+
|
| 771 |
+
def _reload_document_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
|
| 772 |
+
if self._close_document_window_locked(session, doc):
|
| 773 |
+
self._open_document_locked(session, doc)
|
| 774 |
+
return True
|
| 775 |
+
if self._send_reload_shortcut_locked(session, doc):
|
| 776 |
+
return True
|
| 777 |
+
self._open_document_locked(session, doc)
|
| 778 |
+
return False
|
| 779 |
+
|
| 780 |
+
def _close_document_window_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
|
| 781 |
+
xdotool = shutil.which("xdotool")
|
| 782 |
+
if not xdotool:
|
| 783 |
+
return False
|
| 784 |
+
title = str(doc.get("basename") or Path(str(doc.get("path") or "")).name or "").strip()
|
| 785 |
+
if not title:
|
| 786 |
+
return False
|
| 787 |
+
window_id = self._office_window_id_locked(session, title=title, fallback=False)
|
| 788 |
+
if not window_id:
|
| 789 |
+
return False
|
| 790 |
+
env = self._display_env(session)
|
| 791 |
+
self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
|
| 792 |
+
for command in (
|
| 793 |
+
[xdotool, "key", "--clearmodifiers", "alt+F4"],
|
| 794 |
+
[xdotool, "windowclose", window_id],
|
| 795 |
+
):
|
| 796 |
+
try:
|
| 797 |
+
subprocess.run(
|
| 798 |
+
command,
|
| 799 |
+
check=False,
|
| 800 |
+
stdout=subprocess.DEVNULL,
|
| 801 |
+
stderr=subprocess.DEVNULL,
|
| 802 |
+
timeout=3,
|
| 803 |
+
env=env,
|
| 804 |
+
)
|
| 805 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 806 |
+
continue
|
| 807 |
+
if self._wait_for_window_closed_locked(session, window_id):
|
| 808 |
+
return True
|
| 809 |
+
self._dismiss_blocking_dialogs(session)
|
| 810 |
+
return self._wait_for_window_closed_locked(session, window_id, timeout_seconds=1.5)
|
| 811 |
+
|
| 812 |
+
def _send_reload_shortcut_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
|
| 813 |
+
xdotool = shutil.which("xdotool")
|
| 814 |
+
if not xdotool:
|
| 815 |
+
return False
|
| 816 |
+
window_id = self._office_window_id_locked(
|
| 817 |
+
session,
|
| 818 |
+
title=str(doc.get("basename") or ""),
|
| 819 |
+
)
|
| 820 |
+
if not window_id:
|
| 821 |
+
return False
|
| 822 |
+
env = self._display_env(session)
|
| 823 |
+
self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
|
| 824 |
+
try:
|
| 825 |
+
result = subprocess.run(
|
| 826 |
+
[xdotool, "key", "--clearmodifiers", "ctrl+shift+r"],
|
| 827 |
+
check=False,
|
| 828 |
+
capture_output=True,
|
| 829 |
+
text=True,
|
| 830 |
+
timeout=4,
|
| 831 |
+
env=env,
|
| 832 |
+
)
|
| 833 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 834 |
+
return False
|
| 835 |
+
time.sleep(0.8)
|
| 836 |
+
self._dismiss_blocking_dialogs(session)
|
| 837 |
+
self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
|
| 838 |
+
return result.returncode == 0
|
| 839 |
+
|
| 840 |
+
def _office_window_id_locked(
|
| 841 |
+
self,
|
| 842 |
+
session: DesktopSession,
|
| 843 |
+
*,
|
| 844 |
+
title: str = "",
|
| 845 |
+
fallback: bool = True,
|
| 846 |
+
) -> str:
|
| 847 |
+
xdotool = shutil.which("xdotool")
|
| 848 |
+
if not xdotool:
|
| 849 |
+
return ""
|
| 850 |
+
env = self._display_env(session)
|
| 851 |
+
title = str(title or "").strip()
|
| 852 |
+
try:
|
| 853 |
+
result = subprocess.run(
|
| 854 |
+
[xdotool, "search", "--onlyvisible", "--class", "libreoffice"],
|
| 855 |
+
check=False,
|
| 856 |
+
capture_output=True,
|
| 857 |
+
text=True,
|
| 858 |
+
timeout=2,
|
| 859 |
+
env=env,
|
| 860 |
+
)
|
| 861 |
+
window_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
| 862 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 863 |
+
window_ids = []
|
| 864 |
+
if title:
|
| 865 |
+
for window_id in reversed(window_ids):
|
| 866 |
+
try:
|
| 867 |
+
result = subprocess.run(
|
| 868 |
+
[xdotool, "getwindowname", window_id],
|
| 869 |
+
check=False,
|
| 870 |
+
capture_output=True,
|
| 871 |
+
text=True,
|
| 872 |
+
timeout=2,
|
| 873 |
+
env=env,
|
| 874 |
+
)
|
| 875 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 876 |
+
continue
|
| 877 |
+
if title.casefold() in result.stdout.strip().casefold():
|
| 878 |
+
return window_id
|
| 879 |
+
if fallback and window_ids:
|
| 880 |
+
return window_ids[-1]
|
| 881 |
+
if fallback:
|
| 882 |
+
try:
|
| 883 |
+
result = subprocess.run(
|
| 884 |
+
[xdotool, "search", "--onlyvisible", "--name", "LibreOffice"],
|
| 885 |
+
check=False,
|
| 886 |
+
capture_output=True,
|
| 887 |
+
text=True,
|
| 888 |
+
timeout=2,
|
| 889 |
+
env=env,
|
| 890 |
+
)
|
| 891 |
+
window_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
| 892 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 893 |
+
window_ids = []
|
| 894 |
+
if window_ids:
|
| 895 |
+
return window_ids[-1]
|
| 896 |
+
return ""
|
| 897 |
+
|
| 898 |
+
def _wait_for_office_window_locked(
|
| 899 |
+
self,
|
| 900 |
+
session: DesktopSession,
|
| 901 |
+
*,
|
| 902 |
+
title: str = "",
|
| 903 |
+
process: subprocess.Popen[Any] | None = None,
|
| 904 |
+
timeout_seconds: float = 20.0,
|
| 905 |
+
) -> str:
|
| 906 |
+
deadline = time.time() + timeout_seconds
|
| 907 |
+
last_fallback = ""
|
| 908 |
+
title = str(title or "").strip()
|
| 909 |
+
while time.time() < deadline:
|
| 910 |
+
window_id = self._office_window_id_locked(session, title=title, fallback=False)
|
| 911 |
+
if window_id:
|
| 912 |
+
return window_id
|
| 913 |
+
last_fallback = self._office_window_id_locked(session, fallback=True) or last_fallback
|
| 914 |
+
if last_fallback and not title:
|
| 915 |
+
return last_fallback
|
| 916 |
+
if process and process.poll() is not None and last_fallback:
|
| 917 |
+
return last_fallback
|
| 918 |
+
time.sleep(0.25)
|
| 919 |
+
return self._office_window_id_locked(session, title=title, fallback=True) or last_fallback
|
| 920 |
+
|
| 921 |
+
def _wait_for_window_closed_locked(
|
| 922 |
+
self,
|
| 923 |
+
session: DesktopSession,
|
| 924 |
+
window_id: str,
|
| 925 |
+
*,
|
| 926 |
+
timeout_seconds: float = 6.0,
|
| 927 |
+
) -> bool:
|
| 928 |
+
deadline = time.time() + timeout_seconds
|
| 929 |
+
while time.time() < deadline:
|
| 930 |
+
if not self._window_exists_locked(session, window_id):
|
| 931 |
+
return True
|
| 932 |
+
time.sleep(0.2)
|
| 933 |
+
return not self._window_exists_locked(session, window_id)
|
| 934 |
+
|
| 935 |
+
def _window_exists_locked(self, session: DesktopSession, window_id: str) -> bool:
|
| 936 |
+
xdotool = shutil.which("xdotool")
|
| 937 |
+
if not xdotool or not window_id:
|
| 938 |
+
return False
|
| 939 |
+
try:
|
| 940 |
+
result = subprocess.run(
|
| 941 |
+
[xdotool, "getwindowname", str(window_id)],
|
| 942 |
+
check=False,
|
| 943 |
+
stdout=subprocess.DEVNULL,
|
| 944 |
+
stderr=subprocess.DEVNULL,
|
| 945 |
+
timeout=2,
|
| 946 |
+
env=self._display_env(session),
|
| 947 |
+
)
|
| 948 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 949 |
+
return False
|
| 950 |
+
return result.returncode == 0
|
| 951 |
+
|
| 952 |
+
def _fit_office_window_id_locked(
|
| 953 |
+
self,
|
| 954 |
+
session: DesktopSession,
|
| 955 |
+
window_id: str,
|
| 956 |
+
*,
|
| 957 |
+
env: dict[str, str],
|
| 958 |
+
keys: tuple[str, ...] = (),
|
| 959 |
+
) -> None:
|
| 960 |
+
xdotool = shutil.which("xdotool")
|
| 961 |
+
if not xdotool or not window_id:
|
| 962 |
+
return
|
| 963 |
+
for command in (
|
| 964 |
+
[xdotool, "windowactivate", window_id],
|
| 965 |
+
[
|
| 966 |
+
xdotool,
|
| 967 |
+
"windowmove",
|
| 968 |
+
window_id,
|
| 969 |
+
"0",
|
| 970 |
+
"0",
|
| 971 |
+
"windowsize",
|
| 972 |
+
window_id,
|
| 973 |
+
str(session.width),
|
| 974 |
+
str(session.height),
|
| 975 |
+
],
|
| 976 |
+
):
|
| 977 |
+
try:
|
| 978 |
+
subprocess.run(
|
| 979 |
+
command,
|
| 980 |
+
check=False,
|
| 981 |
+
stdout=subprocess.DEVNULL,
|
| 982 |
+
stderr=subprocess.DEVNULL,
|
| 983 |
+
timeout=2,
|
| 984 |
+
env=env,
|
| 985 |
+
)
|
| 986 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 987 |
+
continue
|
| 988 |
+
for key in keys:
|
| 989 |
+
try:
|
| 990 |
+
subprocess.run(
|
| 991 |
+
[xdotool, "key", "--clearmodifiers", key],
|
| 992 |
+
check=False,
|
| 993 |
+
stdout=subprocess.DEVNULL,
|
| 994 |
+
stderr=subprocess.DEVNULL,
|
| 995 |
+
timeout=2,
|
| 996 |
+
env=env,
|
| 997 |
+
)
|
| 998 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 999 |
+
continue
|
| 1000 |
+
|
| 1001 |
+
def _prepare_profile(self, session: DesktopSession) -> None:
|
| 1002 |
+
user_dir = session.profile_dir / "user"
|
| 1003 |
+
user_dir.mkdir(parents=True, exist_ok=True)
|
| 1004 |
+
registry = user_dir / "registrymodifications.xcu"
|
| 1005 |
+
_write_libreoffice_registry_defaults(registry, document_store.document_home())
|
| 1006 |
+
|
| 1007 |
+
def _prepare_desktop_launchers(self, session: DesktopSession) -> None:
|
| 1008 |
+
soffice = libreoffice.find_soffice()
|
| 1009 |
+
if not soffice:
|
| 1010 |
+
raise RuntimeError("LibreOffice is not installed in this runtime.")
|
| 1011 |
+
workdir_home = document_store.document_home()
|
| 1012 |
+
workdir_home.mkdir(parents=True, exist_ok=True)
|
| 1013 |
+
documents_home = document_store.document_binary_home()
|
| 1014 |
+
documents_home.mkdir(parents=True, exist_ok=True)
|
| 1015 |
+
downloads_home = Path(files.get_abs_path("usr", "downloads"))
|
| 1016 |
+
downloads_home.mkdir(parents=True, exist_ok=True)
|
| 1017 |
+
|
| 1018 |
+
desktop_dir = session.profile_dir / "Desktop"
|
| 1019 |
+
desktop_dir.mkdir(parents=True, exist_ok=True)
|
| 1020 |
+
_install_desktop_readme(desktop_dir)
|
| 1021 |
+
_remove_path_if_owned(desktop_dir / "Browser.desktop")
|
| 1022 |
+
_remove_path_if_owned(desktop_dir / "Files.desktop")
|
| 1023 |
+
config_dir = session.profile_dir / ".config"
|
| 1024 |
+
config_dir.mkdir(parents=True, exist_ok=True)
|
| 1025 |
+
_remove_path_if_owned(config_dir / "xfce4" / "panel")
|
| 1026 |
+
data_dir = session.profile_dir / ".local" / "share"
|
| 1027 |
+
data_dir.mkdir(parents=True, exist_ok=True)
|
| 1028 |
+
applications_dir = data_dir / "applications"
|
| 1029 |
+
applications_dir.mkdir(parents=True, exist_ok=True)
|
| 1030 |
+
cache_dir = session.profile_dir / ".cache"
|
| 1031 |
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
| 1032 |
+
(config_dir / "user-dirs.dirs").write_text(
|
| 1033 |
+
"\n".join(
|
| 1034 |
+
[
|
| 1035 |
+
'XDG_DESKTOP_DIR="$HOME/Desktop"',
|
| 1036 |
+
f'XDG_DOCUMENTS_DIR="{workdir_home}"',
|
| 1037 |
+
f'XDG_DOWNLOAD_DIR="{downloads_home}"',
|
| 1038 |
+
f'XDG_TEMPLATES_DIR="{workdir_home}"',
|
| 1039 |
+
f'XDG_PUBLICSHARE_DIR="{workdir_home}"',
|
| 1040 |
+
f'XDG_MUSIC_DIR="{workdir_home}"',
|
| 1041 |
+
f'XDG_PICTURES_DIR="{downloads_home}"',
|
| 1042 |
+
f'XDG_VIDEOS_DIR="{workdir_home}"',
|
| 1043 |
+
"",
|
| 1044 |
+
],
|
| 1045 |
+
),
|
| 1046 |
+
encoding="utf-8",
|
| 1047 |
+
)
|
| 1048 |
+
xfce_conf_dir = config_dir / "xfce4" / "xfconf" / "xfce-perchannel-xml"
|
| 1049 |
+
xfce_conf_dir.mkdir(parents=True, exist_ok=True)
|
| 1050 |
+
(xfce_conf_dir / "xfce4-desktop.xml").write_text(
|
| 1051 |
+
f"""<?xml version="1.1" encoding="UTF-8"?>
|
| 1052 |
+
|
| 1053 |
+
<channel name="xfce4-desktop" version="1.0">
|
| 1054 |
+
<property name="last-settings-migration-version" type="uint" value="1"/>
|
| 1055 |
+
<property name="backdrop" type="empty">
|
| 1056 |
+
<property name="screen0" type="empty">
|
| 1057 |
+
<property name="monitor0" type="empty">
|
| 1058 |
+
<property name="image-path" type="string" value="{_xml_attr(str(downloads_home))}"/>
|
| 1059 |
+
</property>
|
| 1060 |
+
</property>
|
| 1061 |
+
</property>
|
| 1062 |
+
<property name="desktop-icons" type="empty">
|
| 1063 |
+
<property name="style" type="int" value="2"/>
|
| 1064 |
+
<property name="file-icons" type="empty">
|
| 1065 |
+
<property name="show-home" type="bool" value="false"/>
|
| 1066 |
+
<property name="show-filesystem" type="bool" value="false"/>
|
| 1067 |
+
<property name="show-removable" type="bool" value="false"/>
|
| 1068 |
+
<property name="show-trash" type="bool" value="false"/>
|
| 1069 |
+
</property>
|
| 1070 |
+
</property>
|
| 1071 |
+
</channel>
|
| 1072 |
+
""",
|
| 1073 |
+
encoding="utf-8",
|
| 1074 |
+
)
|
| 1075 |
+
_write_thunar_defaults(xfce_conf_dir / "thunar.xml")
|
| 1076 |
+
self._hide_xpra_desktop_entries(applications_dir)
|
| 1077 |
+
self._hide_xfce_menu_entries(applications_dir)
|
| 1078 |
+
self._prepare_desktop_url_bridge(session)
|
| 1079 |
+
|
| 1080 |
+
base_args = (
|
| 1081 |
+
soffice,
|
| 1082 |
+
"--norestore",
|
| 1083 |
+
"--nofirststartwizard",
|
| 1084 |
+
"--nolockcheck",
|
| 1085 |
+
f"-env:UserInstallation=file://{session.profile_dir}",
|
| 1086 |
+
)
|
| 1087 |
+
office_launchers = (
|
| 1088 |
+
("LibreOffice Writer", "libreoffice-writer", "--writer", "Office;WordProcessor;"),
|
| 1089 |
+
("LibreOffice Calc", "libreoffice-calc", "--calc", "Office;Spreadsheet;"),
|
| 1090 |
+
("LibreOffice Impress", "libreoffice-impress", "--impress", "Office;Presentation;"),
|
| 1091 |
+
)
|
| 1092 |
+
for name, icon, mode, categories in office_launchers:
|
| 1093 |
+
_write_desktop_launcher(
|
| 1094 |
+
desktop_dir / f"{name}.desktop",
|
| 1095 |
+
name=name,
|
| 1096 |
+
exec_line=_desktop_exec(*base_args, mode),
|
| 1097 |
+
icon=icon,
|
| 1098 |
+
categories=categories,
|
| 1099 |
+
try_exec=soffice,
|
| 1100 |
+
working_dir=workdir_home,
|
| 1101 |
+
)
|
| 1102 |
+
|
| 1103 |
+
terminal = shutil.which("xfce4-terminal") or "xfce4-terminal"
|
| 1104 |
+
settings = shutil.which("xfce4-settings-manager") or "xfce4-settings-manager"
|
| 1105 |
+
desktop_apps = (
|
| 1106 |
+
{
|
| 1107 |
+
"filename": "Terminal.desktop",
|
| 1108 |
+
"name": "Terminal",
|
| 1109 |
+
"exec": _desktop_exec(terminal, f"--working-directory={workdir_home}"),
|
| 1110 |
+
"try_exec": terminal,
|
| 1111 |
+
"icon": _desktop_icon(
|
| 1112 |
+
"/usr/share/icons/hicolor/128x128/apps/org.xfce.terminal.png",
|
| 1113 |
+
"/usr/share/icons/hicolor/scalable/apps/org.xfce.terminal.svg",
|
| 1114 |
+
"org.xfce.terminal",
|
| 1115 |
+
"utilities-terminal",
|
| 1116 |
+
),
|
| 1117 |
+
"categories": "System;TerminalEmulator;",
|
| 1118 |
+
},
|
| 1119 |
+
{
|
| 1120 |
+
"filename": "Settings.desktop",
|
| 1121 |
+
"name": "Settings",
|
| 1122 |
+
"exec": _desktop_exec(settings),
|
| 1123 |
+
"try_exec": settings,
|
| 1124 |
+
"icon": _desktop_icon(
|
| 1125 |
+
"/usr/share/icons/hicolor/128x128/apps/org.xfce.settings.manager.png",
|
| 1126 |
+
"/usr/share/icons/hicolor/scalable/apps/org.xfce.settings.manager.svg",
|
| 1127 |
+
"org.xfce.settings.manager",
|
| 1128 |
+
"preferences-system",
|
| 1129 |
+
),
|
| 1130 |
+
"categories": "Settings;DesktopSettings;",
|
| 1131 |
+
},
|
| 1132 |
+
)
|
| 1133 |
+
for app in desktop_apps:
|
| 1134 |
+
_write_desktop_launcher(
|
| 1135 |
+
desktop_dir / str(app["filename"]),
|
| 1136 |
+
name=str(app["name"]),
|
| 1137 |
+
exec_line=str(app["exec"]),
|
| 1138 |
+
icon=str(app["icon"]),
|
| 1139 |
+
categories=str(app["categories"]),
|
| 1140 |
+
try_exec=str(app["try_exec"]),
|
| 1141 |
+
)
|
| 1142 |
+
_ensure_desktop_folder_link(desktop_dir, "Workdir", workdir_home)
|
| 1143 |
+
for label, target_parts in DESKTOP_FOLDER_LINKS:
|
| 1144 |
+
_ensure_desktop_folder_link(desktop_dir, label, Path(files.get_abs_path(*target_parts)))
|
| 1145 |
+
|
| 1146 |
+
self._trust_desktop_launchers(session, desktop_dir)
|
| 1147 |
+
self._prepare_xfce_panel_config(session)
|
| 1148 |
+
self._prepare_xfce_profile_autostart(session)
|
| 1149 |
+
|
| 1150 |
+
def _prepare_desktop_url_bridge(self, session: DesktopSession) -> None:
|
| 1151 |
+
desktop_dir = session.profile_dir / "Desktop"
|
| 1152 |
+
config_dir = session.profile_dir / ".config"
|
| 1153 |
+
data_dir = session.profile_dir / ".local" / "share"
|
| 1154 |
+
applications_dir = data_dir / "applications"
|
| 1155 |
+
desktop_dir.mkdir(parents=True, exist_ok=True)
|
| 1156 |
+
config_dir.mkdir(parents=True, exist_ok=True)
|
| 1157 |
+
applications_dir.mkdir(parents=True, exist_ok=True)
|
| 1158 |
+
|
| 1159 |
+
browser_bridge = _write_url_bridge_script(session)
|
| 1160 |
+
editor_bridge = _write_editor_bridge_script(session)
|
| 1161 |
+
shutdown_bridge = _write_shutdown_bridge_script(session)
|
| 1162 |
+
helpers_rc = config_dir / "xfce4" / "helpers.rc"
|
| 1163 |
+
helpers_rc.parent.mkdir(parents=True, exist_ok=True)
|
| 1164 |
+
helpers_rc.write_text(
|
| 1165 |
+
"\n".join(
|
| 1166 |
+
[
|
| 1167 |
+
"TerminalEmulator=xfce4-terminal",
|
| 1168 |
+
"FileManager=thunar",
|
| 1169 |
+
"WebBrowser=agent-zero-browser",
|
| 1170 |
+
"",
|
| 1171 |
+
],
|
| 1172 |
+
),
|
| 1173 |
+
encoding="utf-8",
|
| 1174 |
+
)
|
| 1175 |
+
_write_xfce_browser_helper(
|
| 1176 |
+
config_dir / "xfce4" / "helpers" / "agent-zero-browser.desktop",
|
| 1177 |
+
browser_bridge,
|
| 1178 |
+
)
|
| 1179 |
+
_write_mimeapps_defaults(config_dir / "mimeapps.list", URL_HANDLER_DESKTOP_ID, EDITOR_HANDLER_DESKTOP_ID)
|
| 1180 |
+
_write_mimeapps_defaults(
|
| 1181 |
+
data_dir / "applications" / "mimeapps.list",
|
| 1182 |
+
URL_HANDLER_DESKTOP_ID,
|
| 1183 |
+
EDITOR_HANDLER_DESKTOP_ID,
|
| 1184 |
+
)
|
| 1185 |
+
_write_desktop_launcher(
|
| 1186 |
+
applications_dir / URL_HANDLER_DESKTOP_ID,
|
| 1187 |
+
name="Agent Zero Browser",
|
| 1188 |
+
exec_line=_desktop_exec(browser_bridge, "%U"),
|
| 1189 |
+
icon="web-browser",
|
| 1190 |
+
categories="Network;WebBrowser;",
|
| 1191 |
+
try_exec=str(browser_bridge),
|
| 1192 |
+
mime_types=_url_handler_mime_types(),
|
| 1193 |
+
no_display=True,
|
| 1194 |
+
)
|
| 1195 |
+
_write_desktop_launcher(
|
| 1196 |
+
applications_dir / EDITOR_HANDLER_DESKTOP_ID,
|
| 1197 |
+
name="Agent Zero Editor",
|
| 1198 |
+
exec_line=_desktop_exec(editor_bridge, "%F"),
|
| 1199 |
+
icon="accessories-text-editor",
|
| 1200 |
+
categories="Utility;TextEditor;",
|
| 1201 |
+
try_exec=str(editor_bridge),
|
| 1202 |
+
mime_types=_editor_text_handler_mime_types(),
|
| 1203 |
+
)
|
| 1204 |
+
_write_desktop_launcher(
|
| 1205 |
+
applications_dir / SHUTDOWN_HANDLER_DESKTOP_ID,
|
| 1206 |
+
name="Shutdown Desktop",
|
| 1207 |
+
exec_line=_desktop_exec(shutdown_bridge),
|
| 1208 |
+
icon="system-shutdown",
|
| 1209 |
+
categories="System;",
|
| 1210 |
+
try_exec=str(shutdown_bridge),
|
| 1211 |
+
no_display=True,
|
| 1212 |
+
)
|
| 1213 |
+
_write_desktop_launcher(
|
| 1214 |
+
config_dir / "xfce4" / "panel" / "launcher-9" / SHUTDOWN_HANDLER_DESKTOP_ID,
|
| 1215 |
+
name="Shutdown Desktop",
|
| 1216 |
+
exec_line=_desktop_exec(shutdown_bridge),
|
| 1217 |
+
icon="system-shutdown",
|
| 1218 |
+
categories="System;",
|
| 1219 |
+
try_exec=str(shutdown_bridge),
|
| 1220 |
+
)
|
| 1221 |
+
_write_desktop_launcher(
|
| 1222 |
+
desktop_dir / "Browser.desktop",
|
| 1223 |
+
name="Browser",
|
| 1224 |
+
exec_line=_desktop_exec(browser_bridge),
|
| 1225 |
+
icon="web-browser",
|
| 1226 |
+
categories="Network;WebBrowser;",
|
| 1227 |
+
try_exec=str(browser_bridge),
|
| 1228 |
+
)
|
| 1229 |
+
_write_desktop_launcher(
|
| 1230 |
+
desktop_dir / "Editor.desktop",
|
| 1231 |
+
name="Editor",
|
| 1232 |
+
exec_line=_desktop_exec(editor_bridge),
|
| 1233 |
+
icon="accessories-text-editor",
|
| 1234 |
+
categories="Utility;TextEditor;",
|
| 1235 |
+
try_exec=str(editor_bridge),
|
| 1236 |
+
)
|
| 1237 |
+
self._trust_desktop_launchers(session, desktop_dir)
|
| 1238 |
+
|
| 1239 |
+
def _hide_xpra_desktop_entries(self, applications_dir: Path) -> None:
|
| 1240 |
+
for filename in HIDDEN_XPRA_DESKTOP_ENTRIES:
|
| 1241 |
+
_write_hidden_application_entry(applications_dir / filename, "Xpra")
|
| 1242 |
+
|
| 1243 |
+
def _hide_xfce_menu_entries(self, applications_dir: Path) -> None:
|
| 1244 |
+
for filename, name in HIDDEN_XFCE_MENU_ENTRIES:
|
| 1245 |
+
_write_hidden_application_entry(applications_dir / filename, name)
|
| 1246 |
+
|
| 1247 |
+
def _prepare_xfce_panel_config(self, session: DesktopSession) -> None:
|
| 1248 |
+
panel_xml = (
|
| 1249 |
+
session.profile_dir
|
| 1250 |
+
/ ".config"
|
| 1251 |
+
/ "xfce4"
|
| 1252 |
+
/ "xfconf"
|
| 1253 |
+
/ "xfce-perchannel-xml"
|
| 1254 |
+
/ "xfce4-panel.xml"
|
| 1255 |
+
)
|
| 1256 |
+
panel_xml.parent.mkdir(parents=True, exist_ok=True)
|
| 1257 |
+
|
| 1258 |
+
root = ET.Element("channel", {"name": "xfce4-panel", "version": "1.0"})
|
| 1259 |
+
ET.SubElement(root, "property", {"name": "configver", "type": "int", "value": "2"})
|
| 1260 |
+
|
| 1261 |
+
panels = ET.SubElement(root, "property", {"name": "panels", "type": "array"})
|
| 1262 |
+
ET.SubElement(panels, "value", {"type": "int", "value": "1"})
|
| 1263 |
+
panel = ET.SubElement(panels, "property", {"name": "panel-1", "type": "empty"})
|
| 1264 |
+
for name, prop_type, value in (
|
| 1265 |
+
("position", "string", "p=6;x=0;y=0"),
|
| 1266 |
+
("length", "uint", "100"),
|
| 1267 |
+
("position-locked", "bool", "true"),
|
| 1268 |
+
("size", "uint", "24"),
|
| 1269 |
+
("mode", "uint", "0"),
|
| 1270 |
+
("autohide-behavior", "uint", "0"),
|
| 1271 |
+
("disable-struts", "bool", "false"),
|
| 1272 |
+
("nrows", "uint", "1"),
|
| 1273 |
+
):
|
| 1274 |
+
ET.SubElement(panel, "property", {"name": name, "type": prop_type, "value": value})
|
| 1275 |
+
plugin_ids = ET.SubElement(panel, "property", {"name": "plugin-ids", "type": "array"})
|
| 1276 |
+
for plugin_id in ("1", "2", "3", "4", "5", "6", "7", "8", "9"):
|
| 1277 |
+
ET.SubElement(plugin_ids, "value", {"type": "int", "value": plugin_id})
|
| 1278 |
+
|
| 1279 |
+
plugins = ET.SubElement(root, "property", {"name": "plugins", "type": "empty"})
|
| 1280 |
+
ET.SubElement(plugins, "property", {"name": "plugin-1", "type": "string", "value": "applicationsmenu"})
|
| 1281 |
+
ET.SubElement(plugins, "property", {"name": "plugin-2", "type": "string", "value": "tasklist"})
|
| 1282 |
+
tasklist = _xfce_property(plugins, "plugin-2", "string", "tasklist")
|
| 1283 |
+
ET.SubElement(tasklist, "property", {"name": "flat-buttons", "type": "bool", "value": "true"})
|
| 1284 |
+
ET.SubElement(tasklist, "property", {"name": "show-handle", "type": "bool", "value": "false"})
|
| 1285 |
+
ET.SubElement(tasklist, "property", {"name": "show-labels", "type": "bool", "value": "true"})
|
| 1286 |
+
separator = ET.SubElement(plugins, "property", {"name": "plugin-3", "type": "string", "value": "separator"})
|
| 1287 |
+
ET.SubElement(separator, "property", {"name": "expand", "type": "bool", "value": "true"})
|
| 1288 |
+
ET.SubElement(separator, "property", {"name": "style", "type": "uint", "value": "0"})
|
| 1289 |
+
ET.SubElement(plugins, "property", {"name": "plugin-4", "type": "string", "value": "pager"})
|
| 1290 |
+
ET.SubElement(plugins, "property", {"name": "plugin-5", "type": "string", "value": "systray"})
|
| 1291 |
+
ET.SubElement(plugins, "property", {"name": "plugin-6", "type": "string", "value": "separator"})
|
| 1292 |
+
ET.SubElement(plugins, "property", {"name": "plugin-7", "type": "string", "value": "clock"})
|
| 1293 |
+
ET.SubElement(plugins, "property", {"name": "plugin-8", "type": "string", "value": "separator"})
|
| 1294 |
+
shutdown = ET.SubElement(plugins, "property", {"name": "plugin-9", "type": "string", "value": "launcher"})
|
| 1295 |
+
shutdown_items = ET.SubElement(shutdown, "property", {"name": "items", "type": "array"})
|
| 1296 |
+
ET.SubElement(shutdown_items, "value", {"type": "string", "value": SHUTDOWN_PANEL_LAUNCHER_ID})
|
| 1297 |
+
|
| 1298 |
+
tree = ET.ElementTree(root)
|
| 1299 |
+
try:
|
| 1300 |
+
ET.indent(tree, space=" ")
|
| 1301 |
+
except AttributeError:
|
| 1302 |
+
pass
|
| 1303 |
+
tree.write(panel_xml, encoding="utf-8", xml_declaration=True)
|
| 1304 |
+
|
| 1305 |
+
def _prepare_xfce_profile_autostart(self, session: DesktopSession) -> None:
|
| 1306 |
+
script = session.profile_dir / "prepare-xfce-profile.sh"
|
| 1307 |
+
script.write_text(
|
| 1308 |
+
"""#!/bin/sh
|
| 1309 |
+
set -eu
|
| 1310 |
+
export HOME="${HOME:-%s}"
|
| 1311 |
+
export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
|
| 1312 |
+
export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
| 1313 |
+
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
|
| 1314 |
+
export XDG_CONFIG_DIRS="/etc/xdg${XDG_CONFIG_DIRS:+:$XDG_CONFIG_DIRS}"
|
| 1315 |
+
export XDG_DATA_DIRS="/usr/local/share:/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
|
| 1316 |
+
export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"
|
| 1317 |
+
mkdir -p "$HOME/Desktop" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
|
| 1318 |
+
if command -v xfconf-query >/dev/null 2>&1; then
|
| 1319 |
+
xfconf-query -c thunar -p /last-show-hidden -n -t bool -s true >/dev/null 2>&1 || true
|
| 1320 |
+
xfconf-query -c xfce4-desktop -p /desktop-icons/style -n -t int -s 2 >/dev/null 2>&1 || true
|
| 1321 |
+
xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-home -n -t bool -s false >/dev/null 2>&1 || true
|
| 1322 |
+
xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-filesystem -n -t bool -s false >/dev/null 2>&1 || true
|
| 1323 |
+
xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-removable -n -t bool -s false >/dev/null 2>&1 || true
|
| 1324 |
+
xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-trash -n -t bool -s false >/dev/null 2>&1 || true
|
| 1325 |
+
fi
|
| 1326 |
+
for launcher in "$HOME"/Desktop/*.desktop; do
|
| 1327 |
+
[ -f "$launcher" ] || continue
|
| 1328 |
+
chmod +x "$launcher" 2>/dev/null || true
|
| 1329 |
+
if command -v gio >/dev/null 2>&1; then
|
| 1330 |
+
checksum="$(sha256sum "$launcher" 2>/dev/null | cut -d " " -f 1)"
|
| 1331 |
+
gio set "$launcher" metadata::trusted true >/dev/null 2>&1 || true
|
| 1332 |
+
if [ -n "$checksum" ]; then
|
| 1333 |
+
gio set -t string "$launcher" metadata::xfce-exe-checksum "$checksum" >/dev/null 2>&1 || true
|
| 1334 |
+
fi
|
| 1335 |
+
fi
|
| 1336 |
+
done
|
| 1337 |
+
if command -v xfdesktop >/dev/null 2>&1; then
|
| 1338 |
+
timeout 4 xfdesktop --reload >/dev/null 2>&1 || true
|
| 1339 |
+
fi
|
| 1340 |
+
""" % str(session.profile_dir),
|
| 1341 |
+
encoding="utf-8",
|
| 1342 |
+
)
|
| 1343 |
+
try:
|
| 1344 |
+
script.chmod(0o700)
|
| 1345 |
+
except OSError:
|
| 1346 |
+
pass
|
| 1347 |
+
|
| 1348 |
+
autostart_dir = session.profile_dir / ".config" / "autostart"
|
| 1349 |
+
autostart_dir.mkdir(parents=True, exist_ok=True)
|
| 1350 |
+
autostart = autostart_dir / "agent-zero-desktop.desktop"
|
| 1351 |
+
autostart.write_text(
|
| 1352 |
+
"\n".join(
|
| 1353 |
+
[
|
| 1354 |
+
"[Desktop Entry]",
|
| 1355 |
+
"Type=Application",
|
| 1356 |
+
"Name=Agent Zero desktop profile",
|
| 1357 |
+
f"Exec={script}",
|
| 1358 |
+
"Terminal=false",
|
| 1359 |
+
"OnlyShowIn=XFCE;",
|
| 1360 |
+
"X-GNOME-Autostart-enabled=true",
|
| 1361 |
+
"",
|
| 1362 |
+
],
|
| 1363 |
+
),
|
| 1364 |
+
encoding="utf-8",
|
| 1365 |
+
)
|
| 1366 |
+
|
| 1367 |
+
def _prepare_xfce_launcher(self, session: DesktopSession) -> Path:
|
| 1368 |
+
launcher = session.profile_dir / "start-xfce.sh"
|
| 1369 |
+
launcher.write_text(
|
| 1370 |
+
"\n".join(
|
| 1371 |
+
[
|
| 1372 |
+
"#!/bin/sh",
|
| 1373 |
+
'export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"',
|
| 1374 |
+
'export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"',
|
| 1375 |
+
'export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"',
|
| 1376 |
+
'export XDG_CONFIG_DIRS="/etc/xdg${XDG_CONFIG_DIRS:+:$XDG_CONFIG_DIRS}"',
|
| 1377 |
+
'export XDG_DATA_DIRS="/usr/local/share:/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"',
|
| 1378 |
+
'export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"',
|
| 1379 |
+
(
|
| 1380 |
+
"exec dbus-launch --exit-with-session sh -c "
|
| 1381 |
+
f"'\"{session.profile_dir / 'prepare-xfce-profile.sh'}\" >/dev/null 2>&1 || true; exec xfce4-session'"
|
| 1382 |
+
),
|
| 1383 |
+
"",
|
| 1384 |
+
],
|
| 1385 |
+
),
|
| 1386 |
+
encoding="utf-8",
|
| 1387 |
+
)
|
| 1388 |
+
try:
|
| 1389 |
+
launcher.chmod(0o700)
|
| 1390 |
+
except OSError:
|
| 1391 |
+
pass
|
| 1392 |
+
return launcher
|
| 1393 |
+
|
| 1394 |
+
def _prepare_root_window(self, session: DesktopSession) -> None:
|
| 1395 |
+
xsetroot = shutil.which("xsetroot")
|
| 1396 |
+
if not xsetroot:
|
| 1397 |
+
return
|
| 1398 |
+
subprocess.run(
|
| 1399 |
+
[xsetroot, "-solid", "#20242a"],
|
| 1400 |
+
check=False,
|
| 1401 |
+
stdout=subprocess.DEVNULL,
|
| 1402 |
+
stderr=subprocess.DEVNULL,
|
| 1403 |
+
timeout=2,
|
| 1404 |
+
env=self._display_env(session),
|
| 1405 |
+
)
|
| 1406 |
+
|
| 1407 |
+
def _fit_office_window(
|
| 1408 |
+
self,
|
| 1409 |
+
session: DesktopSession,
|
| 1410 |
+
*,
|
| 1411 |
+
process: subprocess.Popen[Any] | None = None,
|
| 1412 |
+
) -> None:
|
| 1413 |
+
virtual_desktop.fit_window_until(
|
| 1414 |
+
display=session.display,
|
| 1415 |
+
width=session.width,
|
| 1416 |
+
height=session.height,
|
| 1417 |
+
window_class="libreoffice",
|
| 1418 |
+
keys=("Escape",),
|
| 1419 |
+
settle_seconds=4,
|
| 1420 |
+
timeout_seconds=10,
|
| 1421 |
+
process=process,
|
| 1422 |
+
xauthority=self._xauthority(session),
|
| 1423 |
+
home=str(session.profile_dir),
|
| 1424 |
+
)
|
| 1425 |
+
self._dismiss_blocking_dialogs(session)
|
| 1426 |
+
|
| 1427 |
+
def _set_display_size(self, session: DesktopSession, width: int, height: int) -> dict[str, Any]:
|
| 1428 |
+
result = virtual_desktop.resize_display(
|
| 1429 |
+
display=session.display,
|
| 1430 |
+
width=width,
|
| 1431 |
+
height=height,
|
| 1432 |
+
max_width=MAX_SCREEN_WIDTH,
|
| 1433 |
+
max_height=MAX_SCREEN_HEIGHT,
|
| 1434 |
+
window_class="",
|
| 1435 |
+
keys=(),
|
| 1436 |
+
xauthority=self._xauthority(session),
|
| 1437 |
+
home=str(session.profile_dir),
|
| 1438 |
+
)
|
| 1439 |
+
if result.get("ok"):
|
| 1440 |
+
session.width = int(result["width"])
|
| 1441 |
+
session.height = int(result["height"])
|
| 1442 |
+
return result
|
| 1443 |
+
|
| 1444 |
+
def _dismiss_blocking_dialogs(self, session: DesktopSession) -> None:
|
| 1445 |
+
virtual_desktop.close_windows(
|
| 1446 |
+
display=session.display,
|
| 1447 |
+
names=BLOCKING_DIALOG_TITLES,
|
| 1448 |
+
xauthority=self._xauthority(session),
|
| 1449 |
+
home=str(session.profile_dir),
|
| 1450 |
+
)
|
| 1451 |
+
|
| 1452 |
+
def _refresh_xfce_desktop(self, session: DesktopSession) -> None:
|
| 1453 |
+
xfdesktop = shutil.which("xfdesktop")
|
| 1454 |
+
if not xfdesktop:
|
| 1455 |
+
return
|
| 1456 |
+
env = self._xfce_process_env(session, "xfdesktop")
|
| 1457 |
+
try:
|
| 1458 |
+
subprocess.run(
|
| 1459 |
+
[xfdesktop, "--reload"],
|
| 1460 |
+
check=False,
|
| 1461 |
+
stdout=subprocess.DEVNULL,
|
| 1462 |
+
stderr=subprocess.DEVNULL,
|
| 1463 |
+
timeout=4,
|
| 1464 |
+
env=env,
|
| 1465 |
+
)
|
| 1466 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 1467 |
+
return
|
| 1468 |
+
|
| 1469 |
+
def _trust_desktop_launchers(self, session: DesktopSession, desktop_dir: Path) -> None:
|
| 1470 |
+
gio = shutil.which("gio")
|
| 1471 |
+
if not gio:
|
| 1472 |
+
return
|
| 1473 |
+
env = self._xfce_process_env(session, "xfdesktop")
|
| 1474 |
+
for launcher in desktop_dir.glob("*.desktop"):
|
| 1475 |
+
try:
|
| 1476 |
+
launcher.chmod(0o755)
|
| 1477 |
+
checksum = hashlib.sha256(launcher.read_bytes()).hexdigest()
|
| 1478 |
+
except OSError:
|
| 1479 |
+
continue
|
| 1480 |
+
for command in (
|
| 1481 |
+
[gio, "set", str(launcher), "metadata::trusted", "true"],
|
| 1482 |
+
[gio, "set", "-t", "string", str(launcher), "metadata::xfce-exe-checksum", checksum],
|
| 1483 |
+
):
|
| 1484 |
+
try:
|
| 1485 |
+
subprocess.run(
|
| 1486 |
+
command,
|
| 1487 |
+
check=False,
|
| 1488 |
+
stdout=subprocess.DEVNULL,
|
| 1489 |
+
stderr=subprocess.DEVNULL,
|
| 1490 |
+
timeout=4,
|
| 1491 |
+
env=env,
|
| 1492 |
+
)
|
| 1493 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 1494 |
+
continue
|
| 1495 |
+
|
| 1496 |
+
def _xfce_process_env(self, session: DesktopSession, command_name: str) -> dict[str, str]:
|
| 1497 |
+
env = self._display_env(session)
|
| 1498 |
+
proc = Path("/proc")
|
| 1499 |
+
for candidate in proc.iterdir():
|
| 1500 |
+
if not candidate.name.isdigit():
|
| 1501 |
+
continue
|
| 1502 |
+
try:
|
| 1503 |
+
if (candidate / "comm").read_text(encoding="utf-8").strip() != command_name:
|
| 1504 |
+
continue
|
| 1505 |
+
process_env = self._read_process_env(candidate)
|
| 1506 |
+
except OSError:
|
| 1507 |
+
continue
|
| 1508 |
+
if process_env.get("HOME") != str(session.profile_dir):
|
| 1509 |
+
continue
|
| 1510 |
+
if process_env.get("DISPLAY") != f":{session.display}":
|
| 1511 |
+
continue
|
| 1512 |
+
for key, value in process_env.items():
|
| 1513 |
+
if (
|
| 1514 |
+
key in {"DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "HOME", "XAUTHORITY"}
|
| 1515 |
+
or key.startswith("XDG_")
|
| 1516 |
+
):
|
| 1517 |
+
env[key] = value
|
| 1518 |
+
break
|
| 1519 |
+
return env
|
| 1520 |
+
|
| 1521 |
+
def _read_process_env(self, proc_dir: Path) -> dict[str, str]:
|
| 1522 |
+
raw = (proc_dir / "environ").read_bytes()
|
| 1523 |
+
env: dict[str, str] = {}
|
| 1524 |
+
for item in raw.split(b"\0"):
|
| 1525 |
+
if not item or b"=" not in item:
|
| 1526 |
+
continue
|
| 1527 |
+
key, value = item.split(b"=", 1)
|
| 1528 |
+
env[key.decode("utf-8", errors="ignore")] = value.decode("utf-8", errors="ignore")
|
| 1529 |
+
return env
|
| 1530 |
+
|
| 1531 |
+
def _session_env(self, session: DesktopSession) -> dict[str, str]:
|
| 1532 |
+
env = {
|
| 1533 |
+
**os.environ,
|
| 1534 |
+
"HOME": str(session.profile_dir),
|
| 1535 |
+
"LANG": os.environ.get("LANG") or "C.UTF-8",
|
| 1536 |
+
"TZ": session.timezone or Localization.get().get_timezone(),
|
| 1537 |
+
}
|
| 1538 |
+
browser_bridge = _url_bridge_script_path(session)
|
| 1539 |
+
if browser_bridge.exists():
|
| 1540 |
+
env["BROWSER"] = str(browser_bridge)
|
| 1541 |
+
env.setdefault("XDG_RUNTIME_DIR", str(STATE_DIR / "xdg-runtime"))
|
| 1542 |
+
runtime_dir = Path(env["XDG_RUNTIME_DIR"])
|
| 1543 |
+
runtime_dir.mkdir(parents=True, exist_ok=True)
|
| 1544 |
+
try:
|
| 1545 |
+
runtime_dir.chmod(0o700)
|
| 1546 |
+
except OSError:
|
| 1547 |
+
pass
|
| 1548 |
+
return env
|
| 1549 |
+
|
| 1550 |
+
def _display_env(self, session: DesktopSession) -> dict[str, str]:
|
| 1551 |
+
env = {
|
| 1552 |
+
**self._session_env(session),
|
| 1553 |
+
"DISPLAY": f":{session.display}",
|
| 1554 |
+
"SAL_USE_VCLPLUGIN": os.environ.get("SAL_USE_VCLPLUGIN") or "gtk3",
|
| 1555 |
+
}
|
| 1556 |
+
xauthority = self._xauthority(session)
|
| 1557 |
+
if xauthority:
|
| 1558 |
+
env["XAUTHORITY"] = xauthority
|
| 1559 |
+
return env
|
| 1560 |
+
|
| 1561 |
+
def _xauthority(self, session: DesktopSession) -> str:
|
| 1562 |
+
path = session.profile_dir / ".Xauthority"
|
| 1563 |
+
return str(path) if path.exists() else ""
|
| 1564 |
+
|
| 1565 |
+
def _allocate_endpoint_locked(self) -> tuple[int, int]:
|
| 1566 |
+
used_displays = {session.display for session in self._sessions.values()}
|
| 1567 |
+
used_ports = {session.xpra_port for session in self._sessions.values()}
|
| 1568 |
+
for offset in range(MAX_SESSIONS):
|
| 1569 |
+
display = DISPLAY_BASE + offset
|
| 1570 |
+
port = XPRA_PORT_BASE + offset
|
| 1571 |
+
if display in used_displays or port in used_ports:
|
| 1572 |
+
continue
|
| 1573 |
+
if _port_is_free(port):
|
| 1574 |
+
return display, port
|
| 1575 |
+
raise RuntimeError("No LibreOffice desktop slots are available.")
|
| 1576 |
+
|
| 1577 |
+
def _find_by_file_id_locked(self, file_id: str) -> DesktopSession | None:
|
| 1578 |
+
for session in self._sessions.values():
|
| 1579 |
+
if session.file_id == file_id and session.alive():
|
| 1580 |
+
return session
|
| 1581 |
+
return None
|
| 1582 |
+
|
| 1583 |
+
def _find_by_file_id(self, file_id: str) -> DesktopSession | None:
|
| 1584 |
+
with self._lock:
|
| 1585 |
+
return self._find_by_file_id_locked(str(file_id or "").strip())
|
| 1586 |
+
|
| 1587 |
+
def _reap_dead_locked(self) -> None:
|
| 1588 |
+
for session_id, session in list(self._sessions.items()):
|
| 1589 |
+
if not session.alive():
|
| 1590 |
+
self._terminate_session(session)
|
| 1591 |
+
virtual_desktop.unregister_session(session.token)
|
| 1592 |
+
self._sessions.pop(session_id, None)
|
| 1593 |
+
self._remove_manifest(session_id)
|
| 1594 |
+
|
| 1595 |
+
def _wait_for_display(self, session: DesktopSession) -> None:
|
| 1596 |
+
deadline = time.time() + DISPLAY_START_TIMEOUT_SECONDS
|
| 1597 |
+
while time.time() < deadline:
|
| 1598 |
+
process = session.processes.get("xvfb") or session.processes.get("xpra")
|
| 1599 |
+
if process and process.poll() is not None:
|
| 1600 |
+
raise RuntimeError("The LibreOffice X display exited before it was ready.")
|
| 1601 |
+
try:
|
| 1602 |
+
if virtual_desktop.current_display_size(
|
| 1603 |
+
session.display,
|
| 1604 |
+
xauthority=self._xauthority(session),
|
| 1605 |
+
home=str(session.profile_dir),
|
| 1606 |
+
):
|
| 1607 |
+
return
|
| 1608 |
+
except (OSError, subprocess.TimeoutExpired):
|
| 1609 |
+
pass
|
| 1610 |
+
time.sleep(0.1)
|
| 1611 |
+
raise TimeoutError("Timed out waiting for the LibreOffice X display.")
|
| 1612 |
+
|
| 1613 |
+
def _wait_for_xfce(self, session: DesktopSession) -> None:
|
| 1614 |
+
deadline = time.time() + STARTUP_GRACE_SECONDS
|
| 1615 |
+
while time.time() < deadline:
|
| 1616 |
+
process = session.processes.get("xfce")
|
| 1617 |
+
if process and process.poll() is not None:
|
| 1618 |
+
raise RuntimeError("The XFCE desktop session exited before it was ready.")
|
| 1619 |
+
if virtual_desktop.has_window(
|
| 1620 |
+
display=session.display,
|
| 1621 |
+
name="xfce4-panel",
|
| 1622 |
+
xauthority=self._xauthority(session),
|
| 1623 |
+
home=str(session.profile_dir),
|
| 1624 |
+
):
|
| 1625 |
+
return
|
| 1626 |
+
time.sleep(0.25)
|
| 1627 |
+
|
| 1628 |
+
def _write_manifest(self, session: DesktopSession) -> None:
|
| 1629 |
+
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
| 1630 |
+
pids = dict(session.process_ids)
|
| 1631 |
+
pids.update({name: process.pid for name, process in session.processes.items()})
|
| 1632 |
+
payload = {
|
| 1633 |
+
"session_id": session.session_id,
|
| 1634 |
+
"file_id": session.file_id,
|
| 1635 |
+
"extension": session.extension,
|
| 1636 |
+
"path": session.path,
|
| 1637 |
+
"title": session.title,
|
| 1638 |
+
"display": session.display,
|
| 1639 |
+
"xpra_port": session.xpra_port,
|
| 1640 |
+
"profile_dir": str(session.profile_dir),
|
| 1641 |
+
"width": session.width,
|
| 1642 |
+
"height": session.height,
|
| 1643 |
+
"started_at": session.started_at,
|
| 1644 |
+
"timezone": session.timezone,
|
| 1645 |
+
"owner_pid": os.getpid(),
|
| 1646 |
+
"pids": pids,
|
| 1647 |
+
}
|
| 1648 |
+
manifest = SESSION_DIR / f"{session.session_id}.json"
|
| 1649 |
+
tmp = manifest.with_name(f".{manifest.name}.{uuid.uuid4().hex}.tmp")
|
| 1650 |
+
try:
|
| 1651 |
+
tmp.write_text(json.dumps(payload), encoding="utf-8")
|
| 1652 |
+
os.replace(tmp, manifest)
|
| 1653 |
+
finally:
|
| 1654 |
+
tmp.unlink(missing_ok=True)
|
| 1655 |
+
|
| 1656 |
+
def _remove_manifest(self, session_id: str) -> None:
|
| 1657 |
+
(SESSION_DIR / f"{session_id}.json").unlink(missing_ok=True)
|
| 1658 |
+
|
| 1659 |
+
def _terminate_session(self, session: DesktopSession, *, include_rehydrated: bool = False) -> None:
|
| 1660 |
+
process_names = [name for name in session.processes if name.startswith("soffice")]
|
| 1661 |
+
process_names.extend(["xfce", "xpra", "xvfb"])
|
| 1662 |
+
terminated_pids: set[int] = set()
|
| 1663 |
+
for name in process_names:
|
| 1664 |
+
process = session.processes.get(name)
|
| 1665 |
+
if not process:
|
| 1666 |
+
continue
|
| 1667 |
+
if process.pid:
|
| 1668 |
+
terminated_pids.add(process.pid)
|
| 1669 |
+
_terminate_process(process)
|
| 1670 |
+
if session.owns_processes or include_rehydrated:
|
| 1671 |
+
for name, pid in session.process_ids.items():
|
| 1672 |
+
if pid in terminated_pids:
|
| 1673 |
+
continue
|
| 1674 |
+
if name.startswith("soffice") or name in {"xfce", "xpra", "xvfb"}:
|
| 1675 |
+
_kill_pid(pid)
|
| 1676 |
+
self._remove_stale_lock_file(session)
|
| 1677 |
+
|
| 1678 |
+
def _remove_stale_lock_file(self, session: DesktopSession, *, path: str | Path | None = None) -> None:
|
| 1679 |
+
path = Path(path or session.path)
|
| 1680 |
+
if not path.name:
|
| 1681 |
+
return
|
| 1682 |
+
lock_file = path.with_name(f".~lock.{path.name}#")
|
| 1683 |
+
try:
|
| 1684 |
+
lock_file.unlink(missing_ok=True)
|
| 1685 |
+
except OSError:
|
| 1686 |
+
pass
|
| 1687 |
+
|
| 1688 |
+
|
| 1689 |
+
def collect_desktop_status() -> dict[str, Any]:
|
| 1690 |
+
desktop = virtual_desktop.collect_status()
|
| 1691 |
+
binaries = {
|
| 1692 |
+
**desktop["binaries"],
|
| 1693 |
+
"soffice": libreoffice.find_soffice(),
|
| 1694 |
+
"thunar": shutil.which("thunar") or "",
|
| 1695 |
+
"xfce4-terminal": shutil.which("xfce4-terminal") or "",
|
| 1696 |
+
"xfce4-settings-manager": shutil.which("xfce4-settings-manager") or "",
|
| 1697 |
+
"gio": shutil.which("gio") or "",
|
| 1698 |
+
}
|
| 1699 |
+
missing = [
|
| 1700 |
+
name
|
| 1701 |
+
for name in (
|
| 1702 |
+
"soffice",
|
| 1703 |
+
"thunar",
|
| 1704 |
+
"xfce4-terminal",
|
| 1705 |
+
"xfce4-settings-manager",
|
| 1706 |
+
"gio",
|
| 1707 |
+
)
|
| 1708 |
+
if not binaries[name]
|
| 1709 |
+
]
|
| 1710 |
+
missing.extend(
|
| 1711 |
+
name
|
| 1712 |
+
for name in ("xpra", "Xvfb", "xfce4-session", "dbus-launch", "xrandr", "xdotool")
|
| 1713 |
+
if not binaries.get(name)
|
| 1714 |
+
)
|
| 1715 |
+
if not desktop.get("xpra_html_root"):
|
| 1716 |
+
missing.append("xpra-html5")
|
| 1717 |
+
if desktop.get("binaries", {}).get("xpra") and desktop.get("packages", {}).get("xpra-x11") is False:
|
| 1718 |
+
missing.append("xpra-x11")
|
| 1719 |
+
healthy = not missing
|
| 1720 |
+
preparation = _runtime_preparation_status()
|
| 1721 |
+
installing = bool(preparation.get("preparing")) and not healthy
|
| 1722 |
+
return {
|
| 1723 |
+
"ok": True,
|
| 1724 |
+
"healthy": healthy,
|
| 1725 |
+
"state": "healthy" if healthy else "installing" if installing else "missing",
|
| 1726 |
+
"installing": installing,
|
| 1727 |
+
"missing": missing,
|
| 1728 |
+
"preparation": preparation,
|
| 1729 |
+
"binaries": binaries,
|
| 1730 |
+
"xpra_html_root": str(desktop.get("xpra_html_root") or ""),
|
| 1731 |
+
"message": (
|
| 1732 |
+
"Agent Zero Desktop sessions are available."
|
| 1733 |
+
if healthy
|
| 1734 |
+
else RUNTIME_INSTALL_MESSAGE
|
| 1735 |
+
if installing
|
| 1736 |
+
else f"Agent Zero Desktop sessions need: {', '.join(missing)}."
|
| 1737 |
+
),
|
| 1738 |
+
}
|
| 1739 |
+
|
| 1740 |
+
|
| 1741 |
+
def _state_path_from_retired_root(path: Path) -> Path:
|
| 1742 |
+
try:
|
| 1743 |
+
relative = path.resolve(strict=False).relative_to(
|
| 1744 |
+
RETIRED_STATE_DIR.resolve(strict=False)
|
| 1745 |
+
)
|
| 1746 |
+
except ValueError:
|
| 1747 |
+
return path
|
| 1748 |
+
return STATE_DIR / relative
|
| 1749 |
+
|
| 1750 |
+
|
| 1751 |
+
def _runtime_preparation_status() -> dict[str, Any]:
|
| 1752 |
+
try:
|
| 1753 |
+
from plugins._desktop import hooks
|
| 1754 |
+
|
| 1755 |
+
return hooks.runtime_preparation_status()
|
| 1756 |
+
except Exception:
|
| 1757 |
+
return {"preparing": False, "active_count": 0, "started_at": 0.0, "completed_at": 0.0}
|
| 1758 |
+
|
| 1759 |
+
|
| 1760 |
+
def cleanup_stale_runtime_state() -> dict[str, Any]:
|
| 1761 |
+
killed: list[int] = []
|
| 1762 |
+
errors: list[str] = []
|
| 1763 |
+
seen_dirs: set[Path] = set()
|
| 1764 |
+
for session_dir in (SESSION_DIR, *LEGACY_SESSION_DIRS):
|
| 1765 |
+
if session_dir in seen_dirs:
|
| 1766 |
+
continue
|
| 1767 |
+
seen_dirs.add(session_dir)
|
| 1768 |
+
if session_dir.exists():
|
| 1769 |
+
for manifest in session_dir.glob("*.json"):
|
| 1770 |
+
try:
|
| 1771 |
+
payload = json.loads(manifest.read_text(encoding="utf-8"))
|
| 1772 |
+
owner_pid = _coerce_pid(payload.get("owner_pid"))
|
| 1773 |
+
if owner_pid and _pid_is_running(owner_pid):
|
| 1774 |
+
continue
|
| 1775 |
+
for pid in dict(payload.get("pids") or {}).values():
|
| 1776 |
+
pid_int = _coerce_pid(pid)
|
| 1777 |
+
if not pid_int:
|
| 1778 |
+
continue
|
| 1779 |
+
if _kill_pid(pid_int):
|
| 1780 |
+
killed.append(pid_int)
|
| 1781 |
+
manifest.unlink(missing_ok=True)
|
| 1782 |
+
except Exception as exc:
|
| 1783 |
+
errors.append(str(exc))
|
| 1784 |
+
return {"ok": not errors, "killed": killed, "errors": errors}
|
| 1785 |
+
|
| 1786 |
+
|
| 1787 |
+
def get_manager() -> DesktopSessionManager:
|
| 1788 |
+
global _manager
|
| 1789 |
+
try:
|
| 1790 |
+
return _manager
|
| 1791 |
+
except NameError:
|
| 1792 |
+
_manager = DesktopSessionManager()
|
| 1793 |
+
atexit.register(_manager.shutdown)
|
| 1794 |
+
return _manager
|
| 1795 |
+
|
| 1796 |
+
|
| 1797 |
+
def _xpra_url(token: str) -> str:
|
| 1798 |
+
return virtual_desktop.session_url(token, title="Desktop")
|
| 1799 |
+
|
| 1800 |
+
|
| 1801 |
+
def _xvfb_command(xvfb: str, session: DesktopSession) -> list[str]:
|
| 1802 |
+
return [
|
| 1803 |
+
xvfb,
|
| 1804 |
+
f":{session.display}",
|
| 1805 |
+
"-screen",
|
| 1806 |
+
"0",
|
| 1807 |
+
f"{MAX_SCREEN_WIDTH}x{MAX_SCREEN_HEIGHT}x24",
|
| 1808 |
+
"+extension",
|
| 1809 |
+
"GLX",
|
| 1810 |
+
"+extension",
|
| 1811 |
+
"RANDR",
|
| 1812 |
+
"+extension",
|
| 1813 |
+
"RENDER",
|
| 1814 |
+
"+extension",
|
| 1815 |
+
"Composite",
|
| 1816 |
+
"-extension",
|
| 1817 |
+
"DOUBLE-BUFFER",
|
| 1818 |
+
"-nolisten",
|
| 1819 |
+
"tcp",
|
| 1820 |
+
"-noreset",
|
| 1821 |
+
"-ac",
|
| 1822 |
+
]
|
| 1823 |
+
|
| 1824 |
+
|
| 1825 |
+
def _xpra_shadow_command(xpra: str, session: DesktopSession) -> list[str]:
|
| 1826 |
+
return [
|
| 1827 |
+
xpra,
|
| 1828 |
+
"shadow",
|
| 1829 |
+
f":{session.display}",
|
| 1830 |
+
"--daemon=no",
|
| 1831 |
+
"--mdns=no",
|
| 1832 |
+
"--html=on",
|
| 1833 |
+
"--tray=no",
|
| 1834 |
+
"--system-tray=no",
|
| 1835 |
+
"--notifications=no",
|
| 1836 |
+
"--clipboard=yes",
|
| 1837 |
+
"--clipboard-direction=both",
|
| 1838 |
+
"--file-transfer=yes",
|
| 1839 |
+
"--open-files=no",
|
| 1840 |
+
"--open-url=no",
|
| 1841 |
+
"--printing=yes",
|
| 1842 |
+
"--audio=no",
|
| 1843 |
+
"--speaker=off",
|
| 1844 |
+
"--microphone=off",
|
| 1845 |
+
"--encoding=jpeg",
|
| 1846 |
+
"--quality=85",
|
| 1847 |
+
"--speed=80",
|
| 1848 |
+
f"--bind-tcp=127.0.0.1:{session.xpra_port}",
|
| 1849 |
+
"--resize-display=yes",
|
| 1850 |
+
f"--log-dir={session.profile_dir}",
|
| 1851 |
+
"--log-file=xpra.log",
|
| 1852 |
+
]
|
| 1853 |
+
|
| 1854 |
+
|
| 1855 |
+
def _desktop_exec(*args: str | Path) -> str:
|
| 1856 |
+
return " ".join(_desktop_exec_arg(str(arg)) for arg in args if str(arg))
|
| 1857 |
+
|
| 1858 |
+
|
| 1859 |
+
def _desktop_icon(*candidates: str) -> str:
|
| 1860 |
+
for candidate in candidates:
|
| 1861 |
+
if candidate.startswith("/") and Path(candidate).exists():
|
| 1862 |
+
return candidate
|
| 1863 |
+
return next(
|
| 1864 |
+
(candidate for candidate in candidates if not candidate.startswith("/")),
|
| 1865 |
+
candidates[-1],
|
| 1866 |
+
)
|
| 1867 |
+
|
| 1868 |
+
|
| 1869 |
+
def _ensure_desktop_folder_link(desktop_dir: Path, label: str, target: Path) -> None:
|
| 1870 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 1871 |
+
link = desktop_dir / label
|
| 1872 |
+
try:
|
| 1873 |
+
if link.is_symlink() or link.is_file():
|
| 1874 |
+
link.unlink()
|
| 1875 |
+
if not link.exists():
|
| 1876 |
+
link.symlink_to(target, target_is_directory=True)
|
| 1877 |
+
except OSError:
|
| 1878 |
+
return
|
| 1879 |
+
|
| 1880 |
+
|
| 1881 |
+
def _url_bridge_dir(session: DesktopSession) -> Path:
|
| 1882 |
+
return session.profile_dir / ".agent-zero"
|
| 1883 |
+
|
| 1884 |
+
|
| 1885 |
+
def _url_bridge_script_path(session: DesktopSession) -> Path:
|
| 1886 |
+
return _url_bridge_dir(session) / "open-url"
|
| 1887 |
+
|
| 1888 |
+
|
| 1889 |
+
def _editor_bridge_script_path(session: DesktopSession) -> Path:
|
| 1890 |
+
return _url_bridge_dir(session) / "open-editor"
|
| 1891 |
+
|
| 1892 |
+
|
| 1893 |
+
def _url_bridge_queue_path(session: DesktopSession) -> Path:
|
| 1894 |
+
return _url_bridge_dir(session) / "browser-url-intents.jsonl"
|
| 1895 |
+
|
| 1896 |
+
|
| 1897 |
+
def _url_bridge_lock_path(session: DesktopSession) -> Path:
|
| 1898 |
+
return _url_bridge_dir(session) / "browser-url-intents.lock"
|
| 1899 |
+
|
| 1900 |
+
|
| 1901 |
+
def _shutdown_request_path(session: DesktopSession) -> Path:
|
| 1902 |
+
return _url_bridge_dir(session) / "shutdown-request.json"
|
| 1903 |
+
|
| 1904 |
+
|
| 1905 |
+
def _shutdown_arm_path(session: DesktopSession) -> Path:
|
| 1906 |
+
return _url_bridge_dir(session) / "shutdown-request.arm.json"
|
| 1907 |
+
|
| 1908 |
+
|
| 1909 |
+
def _shutdown_lock_path(session: DesktopSession) -> Path:
|
| 1910 |
+
return _url_bridge_dir(session) / "shutdown-request.lock"
|
| 1911 |
+
|
| 1912 |
+
|
| 1913 |
+
def _write_url_bridge_script(session: DesktopSession) -> Path:
|
| 1914 |
+
bridge_dir = _url_bridge_dir(session)
|
| 1915 |
+
bridge_dir.mkdir(parents=True, exist_ok=True)
|
| 1916 |
+
script = _url_bridge_script_path(session)
|
| 1917 |
+
queue = _url_bridge_queue_path(session)
|
| 1918 |
+
lock = _url_bridge_lock_path(session)
|
| 1919 |
+
script.write_text(
|
| 1920 |
+
f"""#!/usr/bin/env python3
|
| 1921 |
+
import fcntl
|
| 1922 |
+
import json
|
| 1923 |
+
import os
|
| 1924 |
+
import sys
|
| 1925 |
+
import time
|
| 1926 |
+
|
| 1927 |
+
QUEUE_PATH = {str(queue)!r}
|
| 1928 |
+
LOCK_PATH = {str(lock)!r}
|
| 1929 |
+
MAX_URL_LENGTH = {URL_INTENT_MAX_LENGTH}
|
| 1930 |
+
|
| 1931 |
+
|
| 1932 |
+
def main():
|
| 1933 |
+
urls = [str(arg or "").strip()[:MAX_URL_LENGTH] for arg in sys.argv[1:] if str(arg or "").strip()]
|
| 1934 |
+
if not urls:
|
| 1935 |
+
urls = [""]
|
| 1936 |
+
os.makedirs(os.path.dirname(QUEUE_PATH), exist_ok=True)
|
| 1937 |
+
with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
|
| 1938 |
+
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
| 1939 |
+
with open(QUEUE_PATH, "a", encoding="utf-8") as queue_file:
|
| 1940 |
+
for url in urls:
|
| 1941 |
+
queue_file.write(json.dumps({{
|
| 1942 |
+
"url": url,
|
| 1943 |
+
"created_at": time.time(),
|
| 1944 |
+
"source": "desktop",
|
| 1945 |
+
}}, ensure_ascii=True) + "\\n")
|
| 1946 |
+
queue_file.flush()
|
| 1947 |
+
os.fsync(queue_file.fileno())
|
| 1948 |
+
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
| 1949 |
+
|
| 1950 |
+
|
| 1951 |
+
if __name__ == "__main__":
|
| 1952 |
+
main()
|
| 1953 |
+
""",
|
| 1954 |
+
encoding="utf-8",
|
| 1955 |
+
)
|
| 1956 |
+
try:
|
| 1957 |
+
script.chmod(0o755)
|
| 1958 |
+
except OSError:
|
| 1959 |
+
pass
|
| 1960 |
+
return script
|
| 1961 |
+
|
| 1962 |
+
|
| 1963 |
+
def _write_editor_bridge_script(session: DesktopSession) -> Path:
|
| 1964 |
+
bridge_dir = _url_bridge_dir(session)
|
| 1965 |
+
bridge_dir.mkdir(parents=True, exist_ok=True)
|
| 1966 |
+
script = _editor_bridge_script_path(session)
|
| 1967 |
+
queue = _url_bridge_queue_path(session)
|
| 1968 |
+
lock = _url_bridge_lock_path(session)
|
| 1969 |
+
script.write_text(
|
| 1970 |
+
f"""#!/usr/bin/env python3
|
| 1971 |
+
import fcntl
|
| 1972 |
+
import json
|
| 1973 |
+
import os
|
| 1974 |
+
import sys
|
| 1975 |
+
import time
|
| 1976 |
+
from urllib.parse import quote
|
| 1977 |
+
|
| 1978 |
+
QUEUE_PATH = {str(queue)!r}
|
| 1979 |
+
LOCK_PATH = {str(lock)!r}
|
| 1980 |
+
MAX_URL_LENGTH = {URL_INTENT_MAX_LENGTH}
|
| 1981 |
+
|
| 1982 |
+
|
| 1983 |
+
def editor_url(path):
|
| 1984 |
+
path = str(path or "").strip()
|
| 1985 |
+
if not path:
|
| 1986 |
+
return "a0-editor://open"
|
| 1987 |
+
return "a0-editor://open?path=" + quote(path[:MAX_URL_LENGTH], safe="/:")
|
| 1988 |
+
|
| 1989 |
+
|
| 1990 |
+
def main():
|
| 1991 |
+
urls = [editor_url(arg) for arg in sys.argv[1:] if str(arg or "").strip()]
|
| 1992 |
+
if not urls:
|
| 1993 |
+
urls = [editor_url("")]
|
| 1994 |
+
os.makedirs(os.path.dirname(QUEUE_PATH), exist_ok=True)
|
| 1995 |
+
with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
|
| 1996 |
+
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
| 1997 |
+
with open(QUEUE_PATH, "a", encoding="utf-8") as queue_file:
|
| 1998 |
+
for url in urls:
|
| 1999 |
+
queue_file.write(json.dumps({{
|
| 2000 |
+
"url": url,
|
| 2001 |
+
"created_at": time.time(),
|
| 2002 |
+
"source": "desktop-editor",
|
| 2003 |
+
}}, ensure_ascii=True) + "\\n")
|
| 2004 |
+
queue_file.flush()
|
| 2005 |
+
os.fsync(queue_file.fileno())
|
| 2006 |
+
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
| 2007 |
+
|
| 2008 |
+
|
| 2009 |
+
if __name__ == "__main__":
|
| 2010 |
+
main()
|
| 2011 |
+
""",
|
| 2012 |
+
encoding="utf-8",
|
| 2013 |
+
)
|
| 2014 |
+
try:
|
| 2015 |
+
script.chmod(0o755)
|
| 2016 |
+
except OSError:
|
| 2017 |
+
pass
|
| 2018 |
+
return script
|
| 2019 |
+
|
| 2020 |
+
|
| 2021 |
+
def _write_shutdown_bridge_script(session: DesktopSession) -> Path:
|
| 2022 |
+
bridge_dir = _url_bridge_dir(session)
|
| 2023 |
+
bridge_dir.mkdir(parents=True, exist_ok=True)
|
| 2024 |
+
script = bridge_dir / "shutdown-desktop"
|
| 2025 |
+
request = _shutdown_request_path(session)
|
| 2026 |
+
arm = _shutdown_arm_path(session)
|
| 2027 |
+
lock = _shutdown_lock_path(session)
|
| 2028 |
+
script.write_text(
|
| 2029 |
+
f"""#!/usr/bin/env python3
|
| 2030 |
+
import fcntl
|
| 2031 |
+
import json
|
| 2032 |
+
import os
|
| 2033 |
+
import shutil
|
| 2034 |
+
import subprocess
|
| 2035 |
+
import time
|
| 2036 |
+
|
| 2037 |
+
REQUEST_PATH = {str(request)!r}
|
| 2038 |
+
ARM_PATH = {str(arm)!r}
|
| 2039 |
+
LOCK_PATH = {str(lock)!r}
|
| 2040 |
+
CONFIRM_SECONDS = {SHUTDOWN_CONFIRM_SECONDS}
|
| 2041 |
+
|
| 2042 |
+
|
| 2043 |
+
def notify(message, timeout=None):
|
| 2044 |
+
if not os.environ.get("DISPLAY"):
|
| 2045 |
+
return
|
| 2046 |
+
xmessage = shutil.which("xmessage")
|
| 2047 |
+
if not xmessage:
|
| 2048 |
+
return
|
| 2049 |
+
try:
|
| 2050 |
+
subprocess.Popen(
|
| 2051 |
+
[
|
| 2052 |
+
xmessage,
|
| 2053 |
+
"-buttons",
|
| 2054 |
+
"",
|
| 2055 |
+
"-timeout",
|
| 2056 |
+
str(timeout or CONFIRM_SECONDS),
|
| 2057 |
+
"-center",
|
| 2058 |
+
message,
|
| 2059 |
+
],
|
| 2060 |
+
stdin=subprocess.DEVNULL,
|
| 2061 |
+
stdout=subprocess.DEVNULL,
|
| 2062 |
+
stderr=subprocess.DEVNULL,
|
| 2063 |
+
start_new_session=True,
|
| 2064 |
+
)
|
| 2065 |
+
except OSError:
|
| 2066 |
+
pass
|
| 2067 |
+
|
| 2068 |
+
|
| 2069 |
+
def read_arm(now):
|
| 2070 |
+
try:
|
| 2071 |
+
with open(ARM_PATH, "r", encoding="utf-8") as handle:
|
| 2072 |
+
payload = json.load(handle)
|
| 2073 |
+
except (OSError, json.JSONDecodeError):
|
| 2074 |
+
return None
|
| 2075 |
+
try:
|
| 2076 |
+
created_at = float(payload.get("created_at"))
|
| 2077 |
+
except (TypeError, ValueError):
|
| 2078 |
+
return None
|
| 2079 |
+
if now - created_at > CONFIRM_SECONDS:
|
| 2080 |
+
return None
|
| 2081 |
+
return created_at
|
| 2082 |
+
|
| 2083 |
+
|
| 2084 |
+
def write_json_atomic(path, payload):
|
| 2085 |
+
tmp_path = path + ".tmp"
|
| 2086 |
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
| 2087 |
+
json.dump(payload, handle, ensure_ascii=True)
|
| 2088 |
+
handle.write("\\n")
|
| 2089 |
+
handle.flush()
|
| 2090 |
+
os.fsync(handle.fileno())
|
| 2091 |
+
os.replace(tmp_path, path)
|
| 2092 |
+
|
| 2093 |
+
|
| 2094 |
+
def main():
|
| 2095 |
+
os.makedirs(os.path.dirname(REQUEST_PATH), exist_ok=True)
|
| 2096 |
+
now = time.time()
|
| 2097 |
+
with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
|
| 2098 |
+
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
| 2099 |
+
armed_at = read_arm(now)
|
| 2100 |
+
if armed_at is None:
|
| 2101 |
+
write_json_atomic(ARM_PATH, {{"created_at": now, "source": "tray"}})
|
| 2102 |
+
notify(
|
| 2103 |
+
f"Shutdown Desktop armed. Click Shutdown Desktop again within {{CONFIRM_SECONDS}} seconds to close it.",
|
| 2104 |
+
CONFIRM_SECONDS,
|
| 2105 |
+
)
|
| 2106 |
+
return
|
| 2107 |
+
try:
|
| 2108 |
+
os.unlink(ARM_PATH)
|
| 2109 |
+
except OSError:
|
| 2110 |
+
pass
|
| 2111 |
+
payload = {{
|
| 2112 |
+
"created_at": now,
|
| 2113 |
+
"armed_at": armed_at,
|
| 2114 |
+
"source": "tray",
|
| 2115 |
+
}}
|
| 2116 |
+
write_json_atomic(REQUEST_PATH, payload)
|
| 2117 |
+
notify("Shutting down Agent Zero Desktop.", 2)
|
| 2118 |
+
|
| 2119 |
+
|
| 2120 |
+
if __name__ == "__main__":
|
| 2121 |
+
main()
|
| 2122 |
+
""",
|
| 2123 |
+
encoding="utf-8",
|
| 2124 |
+
)
|
| 2125 |
+
try:
|
| 2126 |
+
script.chmod(0o755)
|
| 2127 |
+
except OSError:
|
| 2128 |
+
pass
|
| 2129 |
+
return script
|
| 2130 |
+
|
| 2131 |
+
|
| 2132 |
+
def _claim_url_intents(session: DesktopSession) -> list[dict[str, Any]]:
|
| 2133 |
+
queue = _url_bridge_queue_path(session)
|
| 2134 |
+
lock = _url_bridge_lock_path(session)
|
| 2135 |
+
if not queue.exists():
|
| 2136 |
+
return []
|
| 2137 |
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
| 2138 |
+
try:
|
| 2139 |
+
with open(lock, "a+", encoding="utf-8") as lock_file:
|
| 2140 |
+
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
| 2141 |
+
try:
|
| 2142 |
+
raw = queue.read_text(encoding="utf-8")
|
| 2143 |
+
queue.write_text("", encoding="utf-8")
|
| 2144 |
+
finally:
|
| 2145 |
+
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
| 2146 |
+
except OSError:
|
| 2147 |
+
return []
|
| 2148 |
+
|
| 2149 |
+
intents: list[dict[str, Any]] = []
|
| 2150 |
+
for line in raw.splitlines():
|
| 2151 |
+
try:
|
| 2152 |
+
payload = json.loads(line)
|
| 2153 |
+
except json.JSONDecodeError:
|
| 2154 |
+
continue
|
| 2155 |
+
url = str(payload.get("url") or "").strip()
|
| 2156 |
+
if len(url) > URL_INTENT_MAX_LENGTH:
|
| 2157 |
+
url = url[:URL_INTENT_MAX_LENGTH]
|
| 2158 |
+
created_at = payload.get("created_at")
|
| 2159 |
+
try:
|
| 2160 |
+
created_at = float(created_at)
|
| 2161 |
+
except (TypeError, ValueError):
|
| 2162 |
+
created_at = time.time()
|
| 2163 |
+
intents.append(
|
| 2164 |
+
{
|
| 2165 |
+
"url": url,
|
| 2166 |
+
"created_at": created_at,
|
| 2167 |
+
"source": str(payload.get("source") or "desktop"),
|
| 2168 |
+
},
|
| 2169 |
+
)
|
| 2170 |
+
if len(intents) >= URL_INTENT_MAX_ITEMS:
|
| 2171 |
+
break
|
| 2172 |
+
return intents
|
| 2173 |
+
|
| 2174 |
+
|
| 2175 |
+
def _claim_shutdown_request(session: DesktopSession) -> dict[str, Any] | None:
|
| 2176 |
+
request = _shutdown_request_path(session)
|
| 2177 |
+
if not request.exists():
|
| 2178 |
+
return None
|
| 2179 |
+
try:
|
| 2180 |
+
raw = request.read_text(encoding="utf-8")
|
| 2181 |
+
request.unlink(missing_ok=True)
|
| 2182 |
+
except OSError:
|
| 2183 |
+
return None
|
| 2184 |
+
try:
|
| 2185 |
+
payload = json.loads(raw)
|
| 2186 |
+
except json.JSONDecodeError:
|
| 2187 |
+
payload = {}
|
| 2188 |
+
created_at = payload.get("created_at")
|
| 2189 |
+
try:
|
| 2190 |
+
created_at = float(created_at)
|
| 2191 |
+
except (TypeError, ValueError):
|
| 2192 |
+
created_at = time.time()
|
| 2193 |
+
return {
|
| 2194 |
+
"created_at": created_at,
|
| 2195 |
+
"source": str(payload.get("source") or "tray"),
|
| 2196 |
+
}
|
| 2197 |
+
|
| 2198 |
+
|
| 2199 |
+
def _clear_shutdown_request(session: DesktopSession) -> None:
|
| 2200 |
+
request = _shutdown_request_path(session)
|
| 2201 |
+
arm = _shutdown_arm_path(session)
|
| 2202 |
+
lock = _shutdown_lock_path(session)
|
| 2203 |
+
request.unlink(missing_ok=True)
|
| 2204 |
+
request.with_suffix(request.suffix + ".tmp").unlink(missing_ok=True)
|
| 2205 |
+
arm.unlink(missing_ok=True)
|
| 2206 |
+
arm.with_suffix(arm.suffix + ".tmp").unlink(missing_ok=True)
|
| 2207 |
+
lock.unlink(missing_ok=True)
|
| 2208 |
+
|
| 2209 |
+
|
| 2210 |
+
def _remove_system_manifest() -> None:
|
| 2211 |
+
(SESSION_DIR / f"{SYSTEM_SESSION_ID}.json").unlink(missing_ok=True)
|
| 2212 |
+
|
| 2213 |
+
|
| 2214 |
+
def _url_handler_mime_types() -> tuple[str, ...]:
|
| 2215 |
+
return (
|
| 2216 |
+
"x-scheme-handler/http",
|
| 2217 |
+
"x-scheme-handler/https",
|
| 2218 |
+
"text/html",
|
| 2219 |
+
"application/xhtml+xml",
|
| 2220 |
+
)
|
| 2221 |
+
|
| 2222 |
+
|
| 2223 |
+
def _editor_text_handler_mime_types() -> tuple[str, ...]:
|
| 2224 |
+
return (
|
| 2225 |
+
"text/markdown",
|
| 2226 |
+
"text/x-markdown",
|
| 2227 |
+
"text/plain",
|
| 2228 |
+
)
|
| 2229 |
+
|
| 2230 |
+
|
| 2231 |
+
def _write_mimeapps_defaults(path: Path, url_desktop_id: str, editor_desktop_id: str) -> None:
|
| 2232 |
+
url_associations = ";".join([url_desktop_id, ""])
|
| 2233 |
+
# LibreOffice Writer stays the default so double-clicks keep the user (and
|
| 2234 |
+
# agents) inside the Desktop; the Agent Zero Editor remains an "Open With"
|
| 2235 |
+
# option. When Writer's desktop entry is missing, xdg falls back through
|
| 2236 |
+
# the added associations to the editor.
|
| 2237 |
+
text_associations = ";".join([WRITER_HANDLER_DESKTOP_ID, editor_desktop_id, ""])
|
| 2238 |
+
lines = [
|
| 2239 |
+
"[Default Applications]",
|
| 2240 |
+
*(f"{mime_type}={url_desktop_id}" for mime_type in _url_handler_mime_types()),
|
| 2241 |
+
*(f"{mime_type}={WRITER_HANDLER_DESKTOP_ID}" for mime_type in _editor_text_handler_mime_types()),
|
| 2242 |
+
"",
|
| 2243 |
+
"[Added Associations]",
|
| 2244 |
+
*(f"{mime_type}={url_associations}" for mime_type in _url_handler_mime_types()),
|
| 2245 |
+
*(f"{mime_type}={text_associations}" for mime_type in _editor_text_handler_mime_types()),
|
| 2246 |
+
"",
|
| 2247 |
+
]
|
| 2248 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 2249 |
+
path.write_text("\n".join(lines), encoding="utf-8")
|
| 2250 |
+
|
| 2251 |
+
|
| 2252 |
+
def _write_xfce_browser_helper(path: Path, bridge_script: Path) -> None:
|
| 2253 |
+
command = _desktop_exec(bridge_script)
|
| 2254 |
+
command_with_parameter = _desktop_exec(bridge_script, "%s")
|
| 2255 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 2256 |
+
path.write_text(
|
| 2257 |
+
"\n".join(
|
| 2258 |
+
[
|
| 2259 |
+
"[Desktop Entry]",
|
| 2260 |
+
"NoDisplay=true",
|
| 2261 |
+
"Version=1.0",
|
| 2262 |
+
"Type=X-XFCE-Helper",
|
| 2263 |
+
"X-XFCE-Category=WebBrowser",
|
| 2264 |
+
f"X-XFCE-Commands={command}",
|
| 2265 |
+
f"X-XFCE-CommandsWithParameter={command_with_parameter}",
|
| 2266 |
+
"Icon=web-browser",
|
| 2267 |
+
"Name=Agent Zero Browser",
|
| 2268 |
+
"",
|
| 2269 |
+
],
|
| 2270 |
+
),
|
| 2271 |
+
encoding="utf-8",
|
| 2272 |
+
)
|
| 2273 |
+
|
| 2274 |
+
|
| 2275 |
+
def _remove_path_if_owned(path: Path) -> None:
|
| 2276 |
+
try:
|
| 2277 |
+
if path.is_symlink() or path.is_file():
|
| 2278 |
+
path.unlink()
|
| 2279 |
+
elif path.is_dir():
|
| 2280 |
+
shutil.rmtree(path)
|
| 2281 |
+
except OSError:
|
| 2282 |
+
return
|
| 2283 |
+
|
| 2284 |
+
|
| 2285 |
+
def _desktop_exec_arg(value: str) -> str:
|
| 2286 |
+
if not any(char.isspace() or char in '"\\' for char in value):
|
| 2287 |
+
return value
|
| 2288 |
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
| 2289 |
+
return f'"{escaped}"'
|
| 2290 |
+
|
| 2291 |
+
|
| 2292 |
+
def _xml_attr(value: str) -> str:
|
| 2293 |
+
return (
|
| 2294 |
+
str(value)
|
| 2295 |
+
.replace("&", "&")
|
| 2296 |
+
.replace('"', """)
|
| 2297 |
+
.replace("<", "<")
|
| 2298 |
+
.replace(">", ">")
|
| 2299 |
+
)
|
| 2300 |
+
|
| 2301 |
+
|
| 2302 |
+
def _oor(name: str) -> str:
|
| 2303 |
+
return f"{{{OOR_NS}}}{name}"
|
| 2304 |
+
|
| 2305 |
+
|
| 2306 |
+
def _file_uri(path: str | Path) -> str:
|
| 2307 |
+
return Path(path).resolve(strict=False).as_uri()
|
| 2308 |
+
|
| 2309 |
+
|
| 2310 |
+
def _write_libreoffice_registry_defaults(registry: Path, workdir: str | Path) -> None:
|
| 2311 |
+
Path(workdir).mkdir(parents=True, exist_ok=True)
|
| 2312 |
+
ET.register_namespace("oor", OOR_NS)
|
| 2313 |
+
ET.register_namespace("xs", XS_NS)
|
| 2314 |
+
ET.register_namespace("xsi", XSI_NS)
|
| 2315 |
+
root = _read_libreoffice_registry(registry)
|
| 2316 |
+
workdir_uri = _file_uri(workdir)
|
| 2317 |
+
for path, prop, value in (
|
| 2318 |
+
("/org.openoffice.Office.Common/Misc", "FirstRun", "false"),
|
| 2319 |
+
("/org.openoffice.Setup/Office", "ooSetupInstCompleted", "true"),
|
| 2320 |
+
("/org.openoffice.Setup/Office", "MigrationCompleted", "true"),
|
| 2321 |
+
("/org.openoffice.Setup/Office", "OfficeRestartInProgress", "false"),
|
| 2322 |
+
("/org.openoffice.Setup/L10N", "ooLocale", "en-US"),
|
| 2323 |
+
("/org.openoffice.Office.Paths/Variables", "Work", workdir_uri),
|
| 2324 |
+
(
|
| 2325 |
+
"/org.openoffice.Office.Paths/Paths/org.openoffice.Office.Paths:NamedPath['Work']",
|
| 2326 |
+
"WritePath",
|
| 2327 |
+
workdir_uri,
|
| 2328 |
+
),
|
| 2329 |
+
):
|
| 2330 |
+
_set_registry_prop(root, path, prop, value)
|
| 2331 |
+
registry.parent.mkdir(parents=True, exist_ok=True)
|
| 2332 |
+
ET.ElementTree(root).write(registry, encoding="utf-8", xml_declaration=True)
|
| 2333 |
+
|
| 2334 |
+
|
| 2335 |
+
def _read_libreoffice_registry(registry: Path) -> ET.Element:
|
| 2336 |
+
if registry.exists():
|
| 2337 |
+
try:
|
| 2338 |
+
return ET.parse(registry).getroot()
|
| 2339 |
+
except ET.ParseError:
|
| 2340 |
+
pass
|
| 2341 |
+
return ET.Element(
|
| 2342 |
+
_oor("items"),
|
| 2343 |
+
{
|
| 2344 |
+
"xmlns:xs": XS_NS,
|
| 2345 |
+
"xmlns:xsi": XSI_NS,
|
| 2346 |
+
},
|
| 2347 |
+
)
|
| 2348 |
+
|
| 2349 |
+
|
| 2350 |
+
def _set_registry_prop(root: ET.Element, item_path: str, prop_name: str, value: str) -> None:
|
| 2351 |
+
item = _find_registry_item(root, item_path)
|
| 2352 |
+
if item is None:
|
| 2353 |
+
item = ET.SubElement(root, "item", {_oor("path"): item_path})
|
| 2354 |
+
prop = next((child for child in item.findall("prop") if child.get(_oor("name")) == prop_name), None)
|
| 2355 |
+
if prop is None:
|
| 2356 |
+
prop = ET.SubElement(item, "prop", {_oor("name"): prop_name, _oor("op"): "fuse"})
|
| 2357 |
+
else:
|
| 2358 |
+
prop.set(_oor("op"), "fuse")
|
| 2359 |
+
value_node = prop.find("value")
|
| 2360 |
+
if value_node is None:
|
| 2361 |
+
value_node = ET.SubElement(prop, "value")
|
| 2362 |
+
value_node.text = str(value)
|
| 2363 |
+
|
| 2364 |
+
|
| 2365 |
+
def _find_registry_item(root: ET.Element, item_path: str) -> ET.Element | None:
|
| 2366 |
+
for item in root.findall("item"):
|
| 2367 |
+
if item.get(_oor("path")) == item_path:
|
| 2368 |
+
return item
|
| 2369 |
+
return None
|
| 2370 |
+
|
| 2371 |
+
|
| 2372 |
+
def _write_desktop_launcher(
|
| 2373 |
+
path: Path,
|
| 2374 |
+
*,
|
| 2375 |
+
name: str,
|
| 2376 |
+
exec_line: str,
|
| 2377 |
+
icon: str,
|
| 2378 |
+
categories: str,
|
| 2379 |
+
try_exec: str = "",
|
| 2380 |
+
working_dir: str | Path | None = None,
|
| 2381 |
+
mime_types: tuple[str, ...] = (),
|
| 2382 |
+
no_display: bool = False,
|
| 2383 |
+
) -> None:
|
| 2384 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 2385 |
+
lines = [
|
| 2386 |
+
"[Desktop Entry]",
|
| 2387 |
+
"Version=1.0",
|
| 2388 |
+
"Type=Application",
|
| 2389 |
+
f"Name={name}",
|
| 2390 |
+
f"Exec={exec_line}",
|
| 2391 |
+
]
|
| 2392 |
+
if try_exec:
|
| 2393 |
+
lines.append(f"TryExec={try_exec}")
|
| 2394 |
+
if working_dir:
|
| 2395 |
+
lines.append(f"Path={working_dir}")
|
| 2396 |
+
if mime_types:
|
| 2397 |
+
lines.append(f"MimeType={';'.join(mime_types)};")
|
| 2398 |
+
if no_display:
|
| 2399 |
+
lines.append("NoDisplay=true")
|
| 2400 |
+
lines.extend(
|
| 2401 |
+
[
|
| 2402 |
+
f"Icon={icon}",
|
| 2403 |
+
"Terminal=false",
|
| 2404 |
+
f"Categories={categories}",
|
| 2405 |
+
"StartupNotify=true",
|
| 2406 |
+
"X-XFCE-Trusted=true",
|
| 2407 |
+
"",
|
| 2408 |
+
],
|
| 2409 |
+
)
|
| 2410 |
+
path.write_text("\n".join(lines), encoding="utf-8")
|
| 2411 |
+
try:
|
| 2412 |
+
path.chmod(0o755)
|
| 2413 |
+
except OSError:
|
| 2414 |
+
pass
|
| 2415 |
+
|
| 2416 |
+
|
| 2417 |
+
def _write_hidden_application_entry(path: Path, name: str) -> None:
|
| 2418 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 2419 |
+
path.write_text(
|
| 2420 |
+
"\n".join(
|
| 2421 |
+
[
|
| 2422 |
+
"[Desktop Entry]",
|
| 2423 |
+
"Type=Application",
|
| 2424 |
+
f"Name={name}",
|
| 2425 |
+
"NoDisplay=true",
|
| 2426 |
+
"Hidden=true",
|
| 2427 |
+
"",
|
| 2428 |
+
],
|
| 2429 |
+
),
|
| 2430 |
+
encoding="utf-8",
|
| 2431 |
+
)
|
| 2432 |
+
|
| 2433 |
+
|
| 2434 |
+
def _write_thunar_defaults(path: Path) -> None:
|
| 2435 |
+
root = _read_xfce_channel(path, "thunar")
|
| 2436 |
+
if _find_xfce_property(root, "last-view") is None:
|
| 2437 |
+
_xfce_property(root, "last-view", "string", "ThunarIconView")
|
| 2438 |
+
_xfce_property(root, "last-show-hidden", "bool", "true")
|
| 2439 |
+
_write_xfce_channel(path, root)
|
| 2440 |
+
|
| 2441 |
+
|
| 2442 |
+
def _read_xfce_channel(path: Path, channel_name: str) -> ET.Element:
|
| 2443 |
+
if path.exists():
|
| 2444 |
+
try:
|
| 2445 |
+
root = ET.parse(path).getroot()
|
| 2446 |
+
if root.tag == "channel" and root.get("name") == channel_name:
|
| 2447 |
+
root.set("version", root.get("version") or "1.0")
|
| 2448 |
+
return root
|
| 2449 |
+
except (ET.ParseError, OSError):
|
| 2450 |
+
pass
|
| 2451 |
+
return ET.Element("channel", {"name": channel_name, "version": "1.0"})
|
| 2452 |
+
|
| 2453 |
+
|
| 2454 |
+
def _write_xfce_channel(path: Path, root: ET.Element) -> None:
|
| 2455 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 2456 |
+
tree = ET.ElementTree(root)
|
| 2457 |
+
try:
|
| 2458 |
+
ET.indent(tree, space=" ")
|
| 2459 |
+
except AttributeError:
|
| 2460 |
+
pass
|
| 2461 |
+
tree.write(path, encoding="utf-8", xml_declaration=True)
|
| 2462 |
+
|
| 2463 |
+
|
| 2464 |
+
def _find_xfce_property(parent: ET.Element, name: str) -> ET.Element | None:
|
| 2465 |
+
return next((child for child in parent.findall("property") if child.get("name") == name), None)
|
| 2466 |
+
|
| 2467 |
+
|
| 2468 |
+
def _install_desktop_readme(desktop_dir: Path) -> None:
|
| 2469 |
+
if not DESKTOP_README_SOURCE.exists():
|
| 2470 |
+
return
|
| 2471 |
+
target = desktop_dir / "README.md"
|
| 2472 |
+
try:
|
| 2473 |
+
content = DESKTOP_README_SOURCE.read_text(encoding="utf-8")
|
| 2474 |
+
if target.exists() and target.read_text(encoding="utf-8") == content:
|
| 2475 |
+
return
|
| 2476 |
+
target.write_text(content, encoding="utf-8")
|
| 2477 |
+
target.chmod(0o644)
|
| 2478 |
+
except OSError:
|
| 2479 |
+
return
|
| 2480 |
+
|
| 2481 |
+
|
| 2482 |
+
def _xfce_property(parent: ET.Element, name: str, property_type: str, value: str | None = None) -> ET.Element:
|
| 2483 |
+
for child in parent.findall("property"):
|
| 2484 |
+
if child.get("name") == name:
|
| 2485 |
+
child.set("type", property_type)
|
| 2486 |
+
if value is None:
|
| 2487 |
+
child.attrib.pop("value", None)
|
| 2488 |
+
else:
|
| 2489 |
+
child.set("value", value)
|
| 2490 |
+
return child
|
| 2491 |
+
attributes = {"name": name, "type": property_type}
|
| 2492 |
+
if value is not None:
|
| 2493 |
+
attributes["value"] = value
|
| 2494 |
+
return ET.SubElement(parent, "property", attributes)
|
| 2495 |
+
|
| 2496 |
+
|
| 2497 |
+
def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
|
| 2498 |
+
return {
|
| 2499 |
+
"file_id": doc["file_id"],
|
| 2500 |
+
"path": document_store.display_path(doc["path"]),
|
| 2501 |
+
"basename": doc["basename"],
|
| 2502 |
+
"extension": doc["extension"],
|
| 2503 |
+
"size": doc["size"],
|
| 2504 |
+
"version": document_store.item_version(doc),
|
| 2505 |
+
"last_modified": doc["last_modified"],
|
| 2506 |
+
}
|
| 2507 |
+
|
| 2508 |
+
|
| 2509 |
+
def _require_binary(name: str) -> str:
|
| 2510 |
+
found = shutil.which(name)
|
| 2511 |
+
if not found:
|
| 2512 |
+
raise RuntimeError(f"{name} is required for official LibreOffice desktop sessions.")
|
| 2513 |
+
return found
|
| 2514 |
+
|
| 2515 |
+
|
| 2516 |
+
def _running(process: subprocess.Popen[Any] | None) -> bool:
|
| 2517 |
+
return bool(process and process.poll() is None)
|
| 2518 |
+
|
| 2519 |
+
|
| 2520 |
+
def _wait_for_port(
|
| 2521 |
+
host: str,
|
| 2522 |
+
port: int,
|
| 2523 |
+
timeout: float = 15.0,
|
| 2524 |
+
process: subprocess.Popen[Any] | None = None,
|
| 2525 |
+
) -> None:
|
| 2526 |
+
deadline = time.time() + timeout
|
| 2527 |
+
while time.time() < deadline:
|
| 2528 |
+
if process and process.poll() is not None:
|
| 2529 |
+
raise RuntimeError(f"Xpra exited before port {port} was ready.")
|
| 2530 |
+
try:
|
| 2531 |
+
with socket.create_connection((host, port), timeout=0.2):
|
| 2532 |
+
return
|
| 2533 |
+
except OSError:
|
| 2534 |
+
time.sleep(0.1)
|
| 2535 |
+
raise TimeoutError(f"Timed out waiting for Xpra port {port}.")
|
| 2536 |
+
|
| 2537 |
+
|
| 2538 |
+
def _port_is_free(port: int) -> bool:
|
| 2539 |
+
try:
|
| 2540 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
| 2541 |
+
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
| 2542 |
+
probe.bind(("127.0.0.1", port))
|
| 2543 |
+
return True
|
| 2544 |
+
except OSError:
|
| 2545 |
+
return False
|
| 2546 |
+
|
| 2547 |
+
|
| 2548 |
+
def _port_is_accepting(host: str, port: int) -> bool:
|
| 2549 |
+
try:
|
| 2550 |
+
with socket.create_connection((host, port), timeout=0.2):
|
| 2551 |
+
return True
|
| 2552 |
+
except OSError:
|
| 2553 |
+
return False
|
| 2554 |
+
|
| 2555 |
+
|
| 2556 |
+
def _terminate_process(process: subprocess.Popen[Any]) -> None:
|
| 2557 |
+
if process.poll() is not None:
|
| 2558 |
+
return
|
| 2559 |
+
try:
|
| 2560 |
+
process.terminate()
|
| 2561 |
+
process.wait(timeout=2)
|
| 2562 |
+
return
|
| 2563 |
+
except Exception:
|
| 2564 |
+
pass
|
| 2565 |
+
try:
|
| 2566 |
+
process.kill()
|
| 2567 |
+
process.wait(timeout=2)
|
| 2568 |
+
except Exception:
|
| 2569 |
+
pass
|
| 2570 |
+
|
| 2571 |
+
|
| 2572 |
+
def _kill_pid(pid: int) -> bool:
|
| 2573 |
+
if pid <= 0:
|
| 2574 |
+
return False
|
| 2575 |
+
try:
|
| 2576 |
+
os.kill(pid, 15)
|
| 2577 |
+
return True
|
| 2578 |
+
except ProcessLookupError:
|
| 2579 |
+
return False
|
| 2580 |
+
except PermissionError:
|
| 2581 |
+
return False
|
| 2582 |
+
|
| 2583 |
+
|
| 2584 |
+
def _coerce_pid(value: Any) -> int:
|
| 2585 |
+
try:
|
| 2586 |
+
pid = int(value)
|
| 2587 |
+
except (TypeError, ValueError):
|
| 2588 |
+
return 0
|
| 2589 |
+
return pid if pid > 0 else 0
|
| 2590 |
+
|
| 2591 |
+
|
| 2592 |
+
def _pid_is_running(pid: int) -> bool:
|
| 2593 |
+
if pid <= 0:
|
| 2594 |
+
return False
|
| 2595 |
+
try:
|
| 2596 |
+
os.kill(pid, 0)
|
| 2597 |
+
return True
|
| 2598 |
+
except ProcessLookupError:
|
| 2599 |
+
return False
|
| 2600 |
+
except PermissionError:
|
| 2601 |
+
return True
|
plugins/_desktop/helpers/desktop_state.py
ADDED
|
@@ -0,0 +1,893 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import shutil
|
| 8 |
+
import subprocess
|
| 9 |
+
import sys
|
| 10 |
+
import time
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
| 15 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 16 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
SESSION_ID = "agent-zero-desktop"
|
| 20 |
+
PLUGIN_NAME = "_desktop"
|
| 21 |
+
BASE_DIR = Path(os.environ.get("A0_BASE_DIR") or ("/a0" if Path("/a0").exists() else PROJECT_ROOT))
|
| 22 |
+
STATE_DIR = BASE_DIR / "usr" / "plugins" / PLUGIN_NAME
|
| 23 |
+
RETIRED_STATE_DIR = BASE_DIR / "usr" / PLUGIN_NAME
|
| 24 |
+
SESSION_DIR = STATE_DIR / "sessions"
|
| 25 |
+
PROFILE_DIR = STATE_DIR / "profiles"
|
| 26 |
+
SCREENSHOT_DIR = Path(os.environ.get("A0_DESKTOP_SCREENSHOT_DIR") or BASE_DIR / "tmp" / "desktop" / "screenshots")
|
| 27 |
+
RECENT_SCREENSHOT_SECONDS = 600
|
| 28 |
+
_SAFE_CONTEXT_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
|
| 29 |
+
_SCREENSHOT_SUFFIXES = {".png", ".jpg", ".jpeg", ".xwd"}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def session_manifest_path(session_id: str = SESSION_ID) -> Path:
|
| 33 |
+
return Path(os.environ.get("A0_DESKTOP_MANIFEST") or SESSION_DIR / f"{session_id}.json")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def context_screenshot_dir(context_id: str = "") -> Path:
|
| 37 |
+
return SCREENSHOT_DIR / _safe_context_id(context_id)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def chat_screenshot_dir(context_id: str = "") -> Path:
|
| 41 |
+
return BASE_DIR / "usr" / "chats" / _safe_context_id(context_id) / "screenshots" / "desktop"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def normalize_a0_path(path: str | Path) -> str:
|
| 45 |
+
candidate = Path(path)
|
| 46 |
+
try:
|
| 47 |
+
relative = candidate.resolve(strict=False).relative_to(BASE_DIR.resolve(strict=False))
|
| 48 |
+
except ValueError:
|
| 49 |
+
return str(candidate)
|
| 50 |
+
return "/a0/" + str(relative).replace(os.sep, "/")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _safe_context_id(context_id: str = "") -> str:
|
| 54 |
+
raw = str(context_id or os.environ.get("A0_DESKTOP_CONTEXT_ID") or "default")
|
| 55 |
+
return _SAFE_CONTEXT_RE.sub("_", raw).strip("._") or "default"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def session_manifest_exists(session_id: str = SESSION_ID) -> bool:
|
| 59 |
+
return session_manifest_path(session_id).exists()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def collect_state(
|
| 63 |
+
*,
|
| 64 |
+
include_screenshot: bool = False,
|
| 65 |
+
screenshot_path: str | Path | None = None,
|
| 66 |
+
context_id: str = "",
|
| 67 |
+
screenshot_transport: str = "ephemeral",
|
| 68 |
+
) -> dict[str, Any]:
|
| 69 |
+
errors: list[str] = []
|
| 70 |
+
env_info = resolve_environment(errors=errors)
|
| 71 |
+
display = env_info["display"]
|
| 72 |
+
profile_dir = env_info["profile_dir"]
|
| 73 |
+
env = display_env(display=display, profile_dir=profile_dir)
|
| 74 |
+
|
| 75 |
+
capabilities = collect_capabilities()
|
| 76 |
+
for name in ("xdotool", "xrandr", "xwininfo", "xprop"):
|
| 77 |
+
if not capabilities.get(name):
|
| 78 |
+
errors.append(f"{name} is not installed; install Desktop runtime dependencies through the _desktop plugin hook.")
|
| 79 |
+
|
| 80 |
+
size = collect_display_size(env, capabilities, errors)
|
| 81 |
+
pointer = collect_pointer(env, capabilities, errors)
|
| 82 |
+
active_window = collect_active_window(env, capabilities, errors)
|
| 83 |
+
windows = collect_windows(env, capabilities, errors)
|
| 84 |
+
screenshot = latest_screenshot(context_id=context_id)
|
| 85 |
+
|
| 86 |
+
if include_screenshot:
|
| 87 |
+
screenshot = capture_screenshot(
|
| 88 |
+
env,
|
| 89 |
+
capabilities,
|
| 90 |
+
path=screenshot_path,
|
| 91 |
+
errors=errors,
|
| 92 |
+
context_id=context_id,
|
| 93 |
+
transport=screenshot_transport,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
return stable_state(
|
| 97 |
+
context_id=context_id,
|
| 98 |
+
display=display,
|
| 99 |
+
profile_dir=profile_dir,
|
| 100 |
+
size=size,
|
| 101 |
+
pointer=pointer,
|
| 102 |
+
active_window=active_window,
|
| 103 |
+
windows=windows,
|
| 104 |
+
screenshot=screenshot,
|
| 105 |
+
capabilities=capabilities,
|
| 106 |
+
errors=errors,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def capture_screenshot(
|
| 111 |
+
env: dict[str, str] | None = None,
|
| 112 |
+
capabilities: dict[str, str] | None = None,
|
| 113 |
+
*,
|
| 114 |
+
path: str | Path | None = None,
|
| 115 |
+
errors: list[str] | None = None,
|
| 116 |
+
context_id: str = "",
|
| 117 |
+
transport: str = "ephemeral",
|
| 118 |
+
) -> dict[str, Any]:
|
| 119 |
+
local_errors = errors if errors is not None else []
|
| 120 |
+
capabilities = capabilities or collect_capabilities()
|
| 121 |
+
if not env:
|
| 122 |
+
env_errors: list[str] = []
|
| 123 |
+
env_info = resolve_environment(errors=env_errors)
|
| 124 |
+
local_errors.extend(env_errors)
|
| 125 |
+
env = display_env(display=env_info["display"], profile_dir=env_info["profile_dir"])
|
| 126 |
+
|
| 127 |
+
xwd = capabilities.get("xwd") or shutil.which("xwd") or ""
|
| 128 |
+
if not xwd:
|
| 129 |
+
message = "xwd is not installed; install x11-apps through the _desktop plugin hook."
|
| 130 |
+
local_errors.append(message)
|
| 131 |
+
return {"ok": False, "path": "", "format": "", "captured_at": "", "error": message}
|
| 132 |
+
|
| 133 |
+
explicit_path = path is not None and str(path).strip() != ""
|
| 134 |
+
transport_mode = str(transport or "").strip().lower()
|
| 135 |
+
chat_scoped = bool(not explicit_path and transport_mode == "path" and str(context_id or "").strip())
|
| 136 |
+
ephemeral_ref = not explicit_path and transport_mode != "path"
|
| 137 |
+
screenshot_dir = chat_screenshot_dir(context_id) if chat_scoped else context_screenshot_dir(context_id)
|
| 138 |
+
if not explicit_path and not chat_scoped:
|
| 139 |
+
prune_context_screenshots(context_id=context_id)
|
| 140 |
+
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
| 141 |
+
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
| 142 |
+
millis = int((time.time() % 1) * 1000)
|
| 143 |
+
target = Path(path) if explicit_path else screenshot_dir / f"desktop-{timestamp}-{millis:03d}.png"
|
| 144 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 145 |
+
raw_path = target.with_suffix(".xwd")
|
| 146 |
+
safe_context = _safe_context_id(context_id)
|
| 147 |
+
|
| 148 |
+
result = run([xwd, "-root", "-silent", "-out", str(raw_path)], env=env, timeout=8)
|
| 149 |
+
if result.returncode != 0:
|
| 150 |
+
detail = command_output(result) or "xwd screenshot capture failed."
|
| 151 |
+
local_errors.append(detail)
|
| 152 |
+
raw_path.unlink(missing_ok=True)
|
| 153 |
+
return {"ok": False, "path": "", "format": "", "captured_at": "", "error": detail}
|
| 154 |
+
|
| 155 |
+
if target.suffix.lower() == ".xwd":
|
| 156 |
+
if not explicit_path and not chat_scoped:
|
| 157 |
+
prune_context_screenshots(context_id=context_id, keep_path=raw_path)
|
| 158 |
+
return {
|
| 159 |
+
"ok": True,
|
| 160 |
+
"path": str(raw_path),
|
| 161 |
+
"a0_path": normalize_a0_path(raw_path),
|
| 162 |
+
"format": "xwd",
|
| 163 |
+
"captured_at": iso_now(),
|
| 164 |
+
"recent": True,
|
| 165 |
+
"ephemeral": not explicit_path and not chat_scoped,
|
| 166 |
+
"chat_scoped": chat_scoped,
|
| 167 |
+
"context_id": safe_context,
|
| 168 |
+
"error": "",
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
try:
|
| 172 |
+
from PIL import Image
|
| 173 |
+
|
| 174 |
+
with Image.open(raw_path) as image:
|
| 175 |
+
image.save(target)
|
| 176 |
+
width = int(image.width)
|
| 177 |
+
height = int(image.height)
|
| 178 |
+
raw_path.unlink(missing_ok=True)
|
| 179 |
+
if ephemeral_ref:
|
| 180 |
+
return ephemeral_screenshot_result(
|
| 181 |
+
target,
|
| 182 |
+
context_id=context_id,
|
| 183 |
+
image_format=target.suffix.lower().lstrip(".") or "png",
|
| 184 |
+
width=width,
|
| 185 |
+
height=height,
|
| 186 |
+
)
|
| 187 |
+
if not explicit_path and not chat_scoped:
|
| 188 |
+
prune_context_screenshots(context_id=context_id, keep_path=target)
|
| 189 |
+
return {
|
| 190 |
+
"ok": True,
|
| 191 |
+
"path": str(target),
|
| 192 |
+
"a0_path": normalize_a0_path(target),
|
| 193 |
+
"format": target.suffix.lower().lstrip(".") or "png",
|
| 194 |
+
"width": width,
|
| 195 |
+
"height": height,
|
| 196 |
+
"captured_at": iso_now(),
|
| 197 |
+
"recent": True,
|
| 198 |
+
"ephemeral": not explicit_path and not chat_scoped,
|
| 199 |
+
"chat_scoped": chat_scoped,
|
| 200 |
+
"context_id": safe_context,
|
| 201 |
+
"error": "",
|
| 202 |
+
}
|
| 203 |
+
except Exception as exc:
|
| 204 |
+
try:
|
| 205 |
+
converted = convert_xwd_to_image(raw_path, target)
|
| 206 |
+
raw_path.unlink(missing_ok=True)
|
| 207 |
+
if ephemeral_ref:
|
| 208 |
+
return ephemeral_screenshot_result(
|
| 209 |
+
target,
|
| 210 |
+
context_id=context_id,
|
| 211 |
+
image_format=target.suffix.lower().lstrip(".") or "png",
|
| 212 |
+
width=converted["width"],
|
| 213 |
+
height=converted["height"],
|
| 214 |
+
)
|
| 215 |
+
if not explicit_path and not chat_scoped:
|
| 216 |
+
prune_context_screenshots(context_id=context_id, keep_path=target)
|
| 217 |
+
return {
|
| 218 |
+
"ok": True,
|
| 219 |
+
"path": str(target),
|
| 220 |
+
"a0_path": normalize_a0_path(target),
|
| 221 |
+
"format": target.suffix.lower().lstrip(".") or "png",
|
| 222 |
+
"width": converted["width"],
|
| 223 |
+
"height": converted["height"],
|
| 224 |
+
"captured_at": iso_now(),
|
| 225 |
+
"recent": True,
|
| 226 |
+
"ephemeral": not explicit_path and not chat_scoped,
|
| 227 |
+
"chat_scoped": chat_scoped,
|
| 228 |
+
"context_id": safe_context,
|
| 229 |
+
"error": "",
|
| 230 |
+
}
|
| 231 |
+
except Exception as fallback_exc:
|
| 232 |
+
message = f"Pillow could not convert the XWD screenshot: {exc}; fallback parser failed: {fallback_exc}"
|
| 233 |
+
local_errors.append(message)
|
| 234 |
+
if ephemeral_ref:
|
| 235 |
+
raw_path.unlink(missing_ok=True)
|
| 236 |
+
target.unlink(missing_ok=True)
|
| 237 |
+
return {
|
| 238 |
+
"ok": False,
|
| 239 |
+
"path": "",
|
| 240 |
+
"format": "",
|
| 241 |
+
"captured_at": iso_now(),
|
| 242 |
+
"recent": False,
|
| 243 |
+
"ephemeral": True,
|
| 244 |
+
"context_id": safe_context,
|
| 245 |
+
"error": message,
|
| 246 |
+
}
|
| 247 |
+
return {
|
| 248 |
+
"ok": True,
|
| 249 |
+
"path": str(raw_path),
|
| 250 |
+
"a0_path": normalize_a0_path(raw_path),
|
| 251 |
+
"format": "xwd",
|
| 252 |
+
"captured_at": iso_now(),
|
| 253 |
+
"recent": True,
|
| 254 |
+
"ephemeral": not explicit_path and not chat_scoped,
|
| 255 |
+
"chat_scoped": chat_scoped,
|
| 256 |
+
"context_id": safe_context,
|
| 257 |
+
"error": message,
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def convert_xwd_to_image(raw_path: Path, target: Path) -> dict[str, int]:
|
| 262 |
+
from PIL import Image
|
| 263 |
+
|
| 264 |
+
data = raw_path.read_bytes()
|
| 265 |
+
header, _ = parse_xwd_header(data)
|
| 266 |
+
width = header["pixmap_width"]
|
| 267 |
+
height = header["pixmap_height"]
|
| 268 |
+
bytes_per_line = header["bytes_per_line"]
|
| 269 |
+
bits_per_pixel = header["bits_per_pixel"]
|
| 270 |
+
color_table_size = header["ncolors"] * 12
|
| 271 |
+
pixel_offset = header["header_size"] + color_table_size
|
| 272 |
+
if width <= 0 or height <= 0 or bytes_per_line <= 0:
|
| 273 |
+
raise ValueError("invalid XWD dimensions")
|
| 274 |
+
pixel_size = height * bytes_per_line
|
| 275 |
+
if pixel_offset + pixel_size > len(data):
|
| 276 |
+
raise ValueError("truncated XWD pixel data")
|
| 277 |
+
|
| 278 |
+
if (header["red_mask"], header["green_mask"], header["blue_mask"]) != (
|
| 279 |
+
0x00FF0000,
|
| 280 |
+
0x0000FF00,
|
| 281 |
+
0x000000FF,
|
| 282 |
+
):
|
| 283 |
+
raise ValueError("unsupported XWD visual masks")
|
| 284 |
+
raw_mode = {
|
| 285 |
+
(24, 0): "BGR",
|
| 286 |
+
(24, 1): "RGB",
|
| 287 |
+
(32, 0): "BGRX",
|
| 288 |
+
(32, 1): "XRGB",
|
| 289 |
+
}.get((bits_per_pixel, header["byte_order"]))
|
| 290 |
+
if not raw_mode:
|
| 291 |
+
raise ValueError(f"unsupported XWD pixel layout: {bits_per_pixel} bpp")
|
| 292 |
+
|
| 293 |
+
image = Image.frombytes(
|
| 294 |
+
"RGB",
|
| 295 |
+
(width, height),
|
| 296 |
+
data[pixel_offset : pixel_offset + pixel_size],
|
| 297 |
+
"raw",
|
| 298 |
+
raw_mode,
|
| 299 |
+
bytes_per_line,
|
| 300 |
+
1,
|
| 301 |
+
)
|
| 302 |
+
image.save(target)
|
| 303 |
+
return {"width": width, "height": height}
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def parse_xwd_header(data: bytes) -> tuple[dict[str, int], str]:
|
| 307 |
+
if len(data) < 100:
|
| 308 |
+
raise ValueError("XWD header is too short")
|
| 309 |
+
field_names = (
|
| 310 |
+
"header_size",
|
| 311 |
+
"file_version",
|
| 312 |
+
"pixmap_format",
|
| 313 |
+
"pixmap_depth",
|
| 314 |
+
"pixmap_width",
|
| 315 |
+
"pixmap_height",
|
| 316 |
+
"xoffset",
|
| 317 |
+
"byte_order",
|
| 318 |
+
"bitmap_unit",
|
| 319 |
+
"bitmap_bit_order",
|
| 320 |
+
"bitmap_pad",
|
| 321 |
+
"bits_per_pixel",
|
| 322 |
+
"bytes_per_line",
|
| 323 |
+
"visual_class",
|
| 324 |
+
"red_mask",
|
| 325 |
+
"green_mask",
|
| 326 |
+
"blue_mask",
|
| 327 |
+
"bits_per_rgb",
|
| 328 |
+
"colormap_entries",
|
| 329 |
+
"ncolors",
|
| 330 |
+
"window_width",
|
| 331 |
+
"window_height",
|
| 332 |
+
"window_x",
|
| 333 |
+
"window_y",
|
| 334 |
+
"window_bdrwidth",
|
| 335 |
+
)
|
| 336 |
+
for endian in ("big", "little"):
|
| 337 |
+
values = [int.from_bytes(data[index : index + 4], endian, signed=False) for index in range(0, 100, 4)]
|
| 338 |
+
header = dict(zip(field_names, values, strict=True))
|
| 339 |
+
if 100 <= header["header_size"] <= len(data) and header["file_version"] == 7:
|
| 340 |
+
return header, endian
|
| 341 |
+
raise ValueError("unsupported XWD header")
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def resolve_environment(*, errors: list[str] | None = None, session_id: str = SESSION_ID) -> dict[str, str]:
|
| 345 |
+
local_errors = errors if errors is not None else []
|
| 346 |
+
manifest = session_manifest_path(session_id)
|
| 347 |
+
payload: dict[str, Any] = {}
|
| 348 |
+
if manifest.exists():
|
| 349 |
+
try:
|
| 350 |
+
payload = json.loads(manifest.read_text(encoding="utf-8"))
|
| 351 |
+
except Exception as exc:
|
| 352 |
+
local_errors.append(f"Desktop session manifest is unreadable: {exc}")
|
| 353 |
+
elif not (os.environ.get("A0_DESKTOP_DISPLAY") or os.environ.get("DISPLAY")):
|
| 354 |
+
local_errors.append(f"Desktop session manifest not found at {manifest}; open the Desktop canvas before GUI control.")
|
| 355 |
+
|
| 356 |
+
display_value = str(
|
| 357 |
+
os.environ.get("A0_DESKTOP_DISPLAY")
|
| 358 |
+
or payload.get("display")
|
| 359 |
+
or os.environ.get("DISPLAY")
|
| 360 |
+
or ""
|
| 361 |
+
).strip()
|
| 362 |
+
if display_value.startswith(":"):
|
| 363 |
+
display = display_value
|
| 364 |
+
elif display_value:
|
| 365 |
+
display = f":{display_value}"
|
| 366 |
+
else:
|
| 367 |
+
display = ""
|
| 368 |
+
local_errors.append("Desktop DISPLAY is unavailable; the persistent Desktop session is not running.")
|
| 369 |
+
|
| 370 |
+
profile_dir = _state_path_from_retired_root(
|
| 371 |
+
Path(
|
| 372 |
+
os.environ.get("A0_DESKTOP_PROFILE")
|
| 373 |
+
or os.environ.get("A0_DESKTOP_HOME")
|
| 374 |
+
or payload.get("profile_dir")
|
| 375 |
+
or os.environ.get("HOME")
|
| 376 |
+
or PROFILE_DIR / session_id
|
| 377 |
+
)
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
return {
|
| 381 |
+
"display": display,
|
| 382 |
+
"profile_dir": str(profile_dir),
|
| 383 |
+
"manifest": str(manifest),
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def _state_path_from_retired_root(path: Path) -> Path:
|
| 388 |
+
try:
|
| 389 |
+
relative = path.resolve(strict=False).relative_to(
|
| 390 |
+
RETIRED_STATE_DIR.resolve(strict=False)
|
| 391 |
+
)
|
| 392 |
+
except ValueError:
|
| 393 |
+
return path
|
| 394 |
+
return STATE_DIR / relative
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def display_env(*, display: str, profile_dir: str) -> dict[str, str]:
|
| 398 |
+
env = {
|
| 399 |
+
**os.environ,
|
| 400 |
+
"HOME": profile_dir,
|
| 401 |
+
"XDG_CONFIG_HOME": os.environ.get("XDG_CONFIG_HOME") or str(Path(profile_dir) / ".config"),
|
| 402 |
+
"XDG_DATA_HOME": os.environ.get("XDG_DATA_HOME") or str(Path(profile_dir) / ".local" / "share"),
|
| 403 |
+
"XDG_CACHE_HOME": os.environ.get("XDG_CACHE_HOME") or str(Path(profile_dir) / ".cache"),
|
| 404 |
+
"XDG_CURRENT_DESKTOP": os.environ.get("XDG_CURRENT_DESKTOP") or "XFCE",
|
| 405 |
+
}
|
| 406 |
+
if display:
|
| 407 |
+
env["DISPLAY"] = display
|
| 408 |
+
xauthority = os.environ.get("A0_DESKTOP_XAUTHORITY") or str(Path(profile_dir) / ".Xauthority")
|
| 409 |
+
if Path(xauthority).exists():
|
| 410 |
+
env["XAUTHORITY"] = xauthority
|
| 411 |
+
return env
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def collect_capabilities() -> dict[str, str]:
|
| 415 |
+
return {
|
| 416 |
+
name: shutil.which(name) or ""
|
| 417 |
+
for name in (
|
| 418 |
+
"xdotool",
|
| 419 |
+
"xrandr",
|
| 420 |
+
"xwininfo",
|
| 421 |
+
"xprop",
|
| 422 |
+
"xwd",
|
| 423 |
+
"xclip",
|
| 424 |
+
)
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def collect_display_size(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> dict[str, int]:
|
| 429 |
+
if not capabilities.get("xrandr"):
|
| 430 |
+
return {"width": 0, "height": 0}
|
| 431 |
+
result = run([capabilities["xrandr"], "-q"], env=env, timeout=4)
|
| 432 |
+
if result.returncode != 0:
|
| 433 |
+
errors.append(command_output(result) or "xrandr could not read the Desktop display.")
|
| 434 |
+
return {"width": 0, "height": 0}
|
| 435 |
+
match = re.search(r"\bcurrent\s+(\d+)\s+x\s+(\d+)", result.stdout)
|
| 436 |
+
if not match:
|
| 437 |
+
errors.append("xrandr output did not include the current Desktop size.")
|
| 438 |
+
return {"width": 0, "height": 0}
|
| 439 |
+
return {"width": int(match.group(1)), "height": int(match.group(2))}
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def collect_pointer(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> dict[str, int]:
|
| 443 |
+
if not capabilities.get("xdotool"):
|
| 444 |
+
return {"x": 0, "y": 0, "screen": 0, "window": 0}
|
| 445 |
+
result = run([capabilities["xdotool"], "getmouselocation", "--shell"], env=env, timeout=3)
|
| 446 |
+
if result.returncode != 0:
|
| 447 |
+
errors.append(command_output(result) or "xdotool could not read the pointer location.")
|
| 448 |
+
return {"x": 0, "y": 0, "screen": 0, "window": 0}
|
| 449 |
+
values = parse_shell_values(result.stdout)
|
| 450 |
+
return {
|
| 451 |
+
"x": int_value(values.get("X")),
|
| 452 |
+
"y": int_value(values.get("Y")),
|
| 453 |
+
"screen": int_value(values.get("SCREEN")),
|
| 454 |
+
"window": int_value(values.get("WINDOW")),
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def collect_active_window(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> dict[str, Any] | None:
|
| 459 |
+
if not capabilities.get("xdotool"):
|
| 460 |
+
return None
|
| 461 |
+
result = run([capabilities["xdotool"], "getactivewindow"], env=env, timeout=3)
|
| 462 |
+
if result.returncode != 0:
|
| 463 |
+
return None
|
| 464 |
+
window_id = result.stdout.strip().splitlines()[0] if result.stdout.strip() else ""
|
| 465 |
+
if not window_id:
|
| 466 |
+
return None
|
| 467 |
+
return collect_window(env, capabilities, window_id, errors)
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
def collect_windows(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> list[dict[str, Any]]:
|
| 471 |
+
if not capabilities.get("xdotool"):
|
| 472 |
+
return []
|
| 473 |
+
result = run([capabilities["xdotool"], "search", "--onlyvisible", "--name", "."], env=env, timeout=4)
|
| 474 |
+
if result.returncode != 0:
|
| 475 |
+
detail = command_output(result)
|
| 476 |
+
if detail:
|
| 477 |
+
errors.append(detail)
|
| 478 |
+
return []
|
| 479 |
+
windows: list[dict[str, Any]] = []
|
| 480 |
+
seen: set[str] = set()
|
| 481 |
+
for window_id in result.stdout.splitlines():
|
| 482 |
+
window_id = window_id.strip()
|
| 483 |
+
if not window_id or window_id in seen:
|
| 484 |
+
continue
|
| 485 |
+
seen.add(window_id)
|
| 486 |
+
windows.append(collect_window(env, capabilities, window_id, errors))
|
| 487 |
+
return windows
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def collect_window(
|
| 491 |
+
env: dict[str, str],
|
| 492 |
+
capabilities: dict[str, str],
|
| 493 |
+
window_id: str,
|
| 494 |
+
errors: list[str],
|
| 495 |
+
) -> dict[str, Any]:
|
| 496 |
+
props = collect_window_props(env, capabilities, window_id)
|
| 497 |
+
geometry = collect_window_geometry(env, capabilities, window_id)
|
| 498 |
+
return {
|
| 499 |
+
"id": str(window_id),
|
| 500 |
+
"title": props.get("title", ""),
|
| 501 |
+
"class": props.get("class", ""),
|
| 502 |
+
"name": props.get("name", ""),
|
| 503 |
+
"pid": int_value(props.get("pid")),
|
| 504 |
+
"geometry": geometry,
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
def collect_window_geometry(env: dict[str, str], capabilities: dict[str, str], window_id: str) -> dict[str, int]:
|
| 509 |
+
geometry = {"x": 0, "y": 0, "width": 0, "height": 0}
|
| 510 |
+
if not capabilities.get("xwininfo"):
|
| 511 |
+
return geometry
|
| 512 |
+
result = run([capabilities["xwininfo"], "-id", str(window_id)], env=env, timeout=3)
|
| 513 |
+
if result.returncode != 0:
|
| 514 |
+
return geometry
|
| 515 |
+
patterns = {
|
| 516 |
+
"x": r"Absolute upper-left X:\s*(-?\d+)",
|
| 517 |
+
"y": r"Absolute upper-left Y:\s*(-?\d+)",
|
| 518 |
+
"width": r"Width:\s*(\d+)",
|
| 519 |
+
"height": r"Height:\s*(\d+)",
|
| 520 |
+
}
|
| 521 |
+
for key, pattern in patterns.items():
|
| 522 |
+
match = re.search(pattern, result.stdout)
|
| 523 |
+
if match:
|
| 524 |
+
geometry[key] = int(match.group(1))
|
| 525 |
+
return geometry
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
def collect_window_props(env: dict[str, str], capabilities: dict[str, str], window_id: str) -> dict[str, str]:
|
| 529 |
+
props = {"title": "", "class": "", "name": "", "pid": ""}
|
| 530 |
+
xdotool = capabilities.get("xdotool")
|
| 531 |
+
if xdotool:
|
| 532 |
+
result = run([xdotool, "getwindowname", str(window_id)], env=env, timeout=3)
|
| 533 |
+
if result.returncode == 0:
|
| 534 |
+
props["title"] = result.stdout.strip()
|
| 535 |
+
xprop = capabilities.get("xprop")
|
| 536 |
+
if not xprop:
|
| 537 |
+
return props
|
| 538 |
+
result = run([xprop, "-id", str(window_id), "WM_CLASS", "WM_NAME", "_NET_WM_NAME", "_NET_WM_PID"], env=env, timeout=3)
|
| 539 |
+
if result.returncode != 0:
|
| 540 |
+
return props
|
| 541 |
+
parsed = parse_xprop(result.stdout)
|
| 542 |
+
title = parsed.get("_NET_WM_NAME") or parsed.get("WM_NAME") or props["title"]
|
| 543 |
+
props["title"] = title
|
| 544 |
+
props["class"] = parsed.get("WM_CLASS_CLASS", "")
|
| 545 |
+
props["name"] = parsed.get("WM_CLASS_NAME", "")
|
| 546 |
+
props["pid"] = parsed.get("_NET_WM_PID", "")
|
| 547 |
+
return props
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def parse_xprop(output: str) -> dict[str, str]:
|
| 551 |
+
values: dict[str, str] = {}
|
| 552 |
+
for line in output.splitlines():
|
| 553 |
+
if "=" not in line:
|
| 554 |
+
continue
|
| 555 |
+
key, raw_value = line.split("=", 1)
|
| 556 |
+
key = key.strip().split("(", 1)[0]
|
| 557 |
+
raw_value = raw_value.strip()
|
| 558 |
+
quoted = re.findall(r'"([^"]*)"', raw_value)
|
| 559 |
+
if key == "WM_CLASS" and quoted:
|
| 560 |
+
values["WM_CLASS_NAME"] = quoted[0]
|
| 561 |
+
values["WM_CLASS_CLASS"] = quoted[-1]
|
| 562 |
+
continue
|
| 563 |
+
if quoted:
|
| 564 |
+
values[key] = quoted[-1]
|
| 565 |
+
continue
|
| 566 |
+
match = re.search(r"-?\d+", raw_value)
|
| 567 |
+
values[key] = match.group(0) if match else raw_value
|
| 568 |
+
return values
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
def latest_screenshot(*, context_id: str = "") -> dict[str, Any]:
|
| 572 |
+
chat_dir = chat_screenshot_dir(context_id)
|
| 573 |
+
chat_latest = _latest_screenshot_from_dir(
|
| 574 |
+
chat_dir,
|
| 575 |
+
context_id=context_id,
|
| 576 |
+
ephemeral=False,
|
| 577 |
+
chat_scoped=True,
|
| 578 |
+
prune_older=False,
|
| 579 |
+
)
|
| 580 |
+
if chat_latest.get("ok"):
|
| 581 |
+
return chat_latest
|
| 582 |
+
|
| 583 |
+
prune_context_screenshots(context_id=context_id, max_age_seconds=RECENT_SCREENSHOT_SECONDS)
|
| 584 |
+
screenshot_dir = context_screenshot_dir(context_id)
|
| 585 |
+
return _latest_screenshot_from_dir(
|
| 586 |
+
screenshot_dir,
|
| 587 |
+
context_id=context_id,
|
| 588 |
+
ephemeral=True,
|
| 589 |
+
chat_scoped=False,
|
| 590 |
+
prune_older=True,
|
| 591 |
+
)
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def _latest_screenshot_from_dir(
|
| 595 |
+
screenshot_dir: Path,
|
| 596 |
+
*,
|
| 597 |
+
context_id: str = "",
|
| 598 |
+
ephemeral: bool,
|
| 599 |
+
chat_scoped: bool,
|
| 600 |
+
prune_older: bool,
|
| 601 |
+
) -> dict[str, Any]:
|
| 602 |
+
if not screenshot_dir.exists():
|
| 603 |
+
return {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False}
|
| 604 |
+
candidates = [
|
| 605 |
+
path
|
| 606 |
+
for path in screenshot_dir.iterdir()
|
| 607 |
+
if path.is_file() and path.suffix.lower() in _SCREENSHOT_SUFFIXES
|
| 608 |
+
]
|
| 609 |
+
if not candidates:
|
| 610 |
+
return {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False}
|
| 611 |
+
latest = max(candidates, key=lambda item: item.stat().st_mtime)
|
| 612 |
+
if prune_older:
|
| 613 |
+
for candidate in candidates:
|
| 614 |
+
if candidate != latest:
|
| 615 |
+
candidate.unlink(missing_ok=True)
|
| 616 |
+
age = max(0.0, time.time() - latest.stat().st_mtime)
|
| 617 |
+
return {
|
| 618 |
+
"ok": True,
|
| 619 |
+
"path": str(latest),
|
| 620 |
+
"a0_path": normalize_a0_path(latest),
|
| 621 |
+
"format": latest.suffix.lower().lstrip("."),
|
| 622 |
+
"captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(latest.stat().st_mtime)),
|
| 623 |
+
"recent": age <= RECENT_SCREENSHOT_SECONDS,
|
| 624 |
+
"ephemeral": ephemeral,
|
| 625 |
+
"chat_scoped": chat_scoped,
|
| 626 |
+
"context_id": _safe_context_id(context_id),
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
def stable_state(
|
| 631 |
+
*,
|
| 632 |
+
display: str,
|
| 633 |
+
profile_dir: str,
|
| 634 |
+
context_id: str = "",
|
| 635 |
+
size: dict[str, int] | None = None,
|
| 636 |
+
pointer: dict[str, int] | None = None,
|
| 637 |
+
active_window: dict[str, Any] | None = None,
|
| 638 |
+
windows: list[dict[str, Any]] | None = None,
|
| 639 |
+
screenshot: dict[str, Any] | None = None,
|
| 640 |
+
capabilities: dict[str, str] | None = None,
|
| 641 |
+
errors: list[str] | None = None,
|
| 642 |
+
) -> dict[str, Any]:
|
| 643 |
+
clean_errors = [str(error) for error in errors or [] if str(error)]
|
| 644 |
+
return {
|
| 645 |
+
"ok": not clean_errors,
|
| 646 |
+
"context_id": _safe_context_id(context_id),
|
| 647 |
+
"display": display,
|
| 648 |
+
"profile_dir": profile_dir,
|
| 649 |
+
"size": size or {"width": 0, "height": 0},
|
| 650 |
+
"pointer": pointer or {"x": 0, "y": 0, "screen": 0, "window": 0},
|
| 651 |
+
"active_window": active_window,
|
| 652 |
+
"windows": windows or [],
|
| 653 |
+
"screenshot": screenshot or {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False},
|
| 654 |
+
"capabilities": capabilities or collect_capabilities(),
|
| 655 |
+
"errors": clean_errors,
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
def compact_prompt_context(state: dict[str, Any] | None = None) -> str:
|
| 660 |
+
state = state if state is not None else collect_state(include_screenshot=False)
|
| 661 |
+
if not state.get("display"):
|
| 662 |
+
return ""
|
| 663 |
+
lines = ["[DESKTOP STATE]"]
|
| 664 |
+
size = state.get("size") or {}
|
| 665 |
+
pointer = state.get("pointer") or {}
|
| 666 |
+
lines.append(
|
| 667 |
+
f"- display={state.get('display', '')} size={size.get('width', 0)}x{size.get('height', 0)} "
|
| 668 |
+
f"pointer={pointer.get('x', 0)},{pointer.get('y', 0)}"
|
| 669 |
+
)
|
| 670 |
+
active = state.get("active_window") or {}
|
| 671 |
+
if active:
|
| 672 |
+
lines.append(
|
| 673 |
+
f"- active={active.get('title', '') or '<untitled>'} "
|
| 674 |
+
f"class={active.get('class', '') or active.get('name', '')}"
|
| 675 |
+
)
|
| 676 |
+
visible = []
|
| 677 |
+
for window in state.get("windows") or []:
|
| 678 |
+
title = window.get("title") or "<untitled>"
|
| 679 |
+
window_class = window.get("class") or window.get("name") or ""
|
| 680 |
+
visible.append(f"{title} ({window_class})" if window_class else title)
|
| 681 |
+
if len(visible) >= 5:
|
| 682 |
+
break
|
| 683 |
+
if visible:
|
| 684 |
+
lines.append("- visible=" + "; ".join(visible))
|
| 685 |
+
screenshot = state.get("screenshot") or {}
|
| 686 |
+
if screenshot.get("recent") and screenshot.get("path"):
|
| 687 |
+
ephemeral = " ephemeral" if screenshot.get("ephemeral") else ""
|
| 688 |
+
screenshot_ref = screenshot.get("a0_path") or screenshot["path"]
|
| 689 |
+
lines.append(f"- recent_screenshot={screenshot_ref}{ephemeral}")
|
| 690 |
+
context_id = str(state.get("context_id") or "").strip()
|
| 691 |
+
if context_id:
|
| 692 |
+
lines.append(f"- screenshot_context={context_id}")
|
| 693 |
+
context_arg = f" --context-id {context_id}" if context_id else ""
|
| 694 |
+
lines.append(
|
| 695 |
+
"- next=plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh state --json"
|
| 696 |
+
f"{context_arg} for structured checks; use observe --json --screenshot"
|
| 697 |
+
f"{context_arg} "
|
| 698 |
+
"before coordinate or visual-OCR actions; prefer sequence/focus/key/paste/save/app-native helpers first."
|
| 699 |
+
)
|
| 700 |
+
lines.append(
|
| 701 |
+
"- verify=for terminal/CLI-agent output, use the screenshot path from a fresh final "
|
| 702 |
+
"observe --json --screenshot captured after the response appears."
|
| 703 |
+
)
|
| 704 |
+
if state.get("errors"):
|
| 705 |
+
lines.append("- errors=" + "; ".join(str(item) for item in state["errors"][:2]))
|
| 706 |
+
return "\n".join(lines)
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
def parse_shell_values(output: str) -> dict[str, str]:
|
| 710 |
+
values: dict[str, str] = {}
|
| 711 |
+
for line in output.splitlines():
|
| 712 |
+
if "=" not in line:
|
| 713 |
+
continue
|
| 714 |
+
key, value = line.split("=", 1)
|
| 715 |
+
values[key.strip()] = value.strip().strip('"')
|
| 716 |
+
return values
|
| 717 |
+
|
| 718 |
+
|
| 719 |
+
def int_value(value: Any, default: int = 0) -> int:
|
| 720 |
+
try:
|
| 721 |
+
return int(str(value).strip())
|
| 722 |
+
except (TypeError, ValueError):
|
| 723 |
+
return default
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
def run(command: list[str], *, env: dict[str, str], timeout: float) -> subprocess.CompletedProcess[str]:
|
| 727 |
+
try:
|
| 728 |
+
return subprocess.run(
|
| 729 |
+
command,
|
| 730 |
+
check=False,
|
| 731 |
+
capture_output=True,
|
| 732 |
+
text=True,
|
| 733 |
+
timeout=timeout,
|
| 734 |
+
env=env,
|
| 735 |
+
)
|
| 736 |
+
except OSError as exc:
|
| 737 |
+
return subprocess.CompletedProcess(command, 127, "", str(exc))
|
| 738 |
+
except subprocess.TimeoutExpired as exc:
|
| 739 |
+
stdout = exc.stdout.decode("utf-8", errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
| 740 |
+
stderr = exc.stderr.decode("utf-8", errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
| 741 |
+
return subprocess.CompletedProcess(command, 124, stdout, stderr or "command timed out")
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
def command_output(result: subprocess.CompletedProcess[str]) -> str:
|
| 745 |
+
return (result.stderr or result.stdout or "").strip()
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
def image_width(path: Path) -> int:
|
| 749 |
+
try:
|
| 750 |
+
from PIL import Image
|
| 751 |
+
|
| 752 |
+
with Image.open(path) as image:
|
| 753 |
+
return int(image.width)
|
| 754 |
+
except Exception:
|
| 755 |
+
return 0
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
def image_height(path: Path) -> int:
|
| 759 |
+
try:
|
| 760 |
+
from PIL import Image
|
| 761 |
+
|
| 762 |
+
with Image.open(path) as image:
|
| 763 |
+
return int(image.height)
|
| 764 |
+
except Exception:
|
| 765 |
+
return 0
|
| 766 |
+
|
| 767 |
+
|
| 768 |
+
def ephemeral_screenshot_result(
|
| 769 |
+
path: Path,
|
| 770 |
+
*,
|
| 771 |
+
context_id: str = "",
|
| 772 |
+
image_format: str = "png",
|
| 773 |
+
width: int = 0,
|
| 774 |
+
height: int = 0,
|
| 775 |
+
) -> dict[str, Any]:
|
| 776 |
+
from helpers import ephemeral_images
|
| 777 |
+
|
| 778 |
+
mime = "image/jpeg" if image_format.lower() in {"jpg", "jpeg"} else "image/png"
|
| 779 |
+
safe_context = _safe_context_id(context_id)
|
| 780 |
+
ref = ephemeral_images.put_image_bytes(
|
| 781 |
+
context_id=str(context_id or "").strip(),
|
| 782 |
+
mime=mime,
|
| 783 |
+
payload=path.read_bytes(),
|
| 784 |
+
name=path.name,
|
| 785 |
+
)
|
| 786 |
+
path.unlink(missing_ok=True)
|
| 787 |
+
prune_context_screenshots(context_id=context_id)
|
| 788 |
+
return {
|
| 789 |
+
"ok": True,
|
| 790 |
+
"path": "",
|
| 791 |
+
"format": image_format,
|
| 792 |
+
"mime": mime,
|
| 793 |
+
"width": width,
|
| 794 |
+
"height": height,
|
| 795 |
+
"captured_at": iso_now(),
|
| 796 |
+
"recent": True,
|
| 797 |
+
"ephemeral": True,
|
| 798 |
+
"ephemeral_ref": ref,
|
| 799 |
+
"context_id": safe_context,
|
| 800 |
+
"vision_load": {
|
| 801 |
+
"tool_name": "vision_load",
|
| 802 |
+
"tool_args": {"paths": [ref]},
|
| 803 |
+
},
|
| 804 |
+
"error": "",
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
|
| 808 |
+
def prune_context_screenshots(
|
| 809 |
+
*,
|
| 810 |
+
context_id: str = "",
|
| 811 |
+
keep_path: Path | None = None,
|
| 812 |
+
max_age_seconds: float | None = None,
|
| 813 |
+
) -> None:
|
| 814 |
+
screenshot_dir = context_screenshot_dir(context_id)
|
| 815 |
+
if not screenshot_dir.exists():
|
| 816 |
+
return
|
| 817 |
+
keep = keep_path.resolve(strict=False) if keep_path else None
|
| 818 |
+
now = time.time()
|
| 819 |
+
for candidate in screenshot_dir.iterdir():
|
| 820 |
+
if not candidate.is_file() or candidate.suffix.lower() not in _SCREENSHOT_SUFFIXES:
|
| 821 |
+
continue
|
| 822 |
+
if keep is not None and candidate.resolve(strict=False) == keep:
|
| 823 |
+
continue
|
| 824 |
+
if max_age_seconds is not None:
|
| 825 |
+
try:
|
| 826 |
+
if now - candidate.stat().st_mtime <= max_age_seconds:
|
| 827 |
+
continue
|
| 828 |
+
except OSError:
|
| 829 |
+
pass
|
| 830 |
+
candidate.unlink(missing_ok=True)
|
| 831 |
+
try:
|
| 832 |
+
screenshot_dir.rmdir()
|
| 833 |
+
except OSError:
|
| 834 |
+
pass
|
| 835 |
+
|
| 836 |
+
|
| 837 |
+
def iso_now() -> str:
|
| 838 |
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
def main(argv: list[str] | None = None) -> int:
|
| 842 |
+
parser = argparse.ArgumentParser(description="Observe the Agent Zero persistent Linux Desktop state.")
|
| 843 |
+
subparsers = parser.add_subparsers(dest="command")
|
| 844 |
+
|
| 845 |
+
state_parser = subparsers.add_parser("state")
|
| 846 |
+
state_parser.add_argument("--json", action="store_true")
|
| 847 |
+
state_parser.add_argument("--screenshot", action="store_true")
|
| 848 |
+
state_parser.add_argument("--context-id", default="")
|
| 849 |
+
|
| 850 |
+
observe_parser = subparsers.add_parser("observe")
|
| 851 |
+
observe_parser.add_argument("--json", action="store_true")
|
| 852 |
+
observe_parser.add_argument("--screenshot", action="store_true")
|
| 853 |
+
observe_parser.add_argument("--context-id", default="")
|
| 854 |
+
|
| 855 |
+
screenshot_parser = subparsers.add_parser("screenshot")
|
| 856 |
+
screenshot_parser.add_argument("path", nargs="?")
|
| 857 |
+
screenshot_parser.add_argument("--json", action="store_true")
|
| 858 |
+
screenshot_parser.add_argument("--context-id", default="")
|
| 859 |
+
|
| 860 |
+
args = parser.parse_args(argv)
|
| 861 |
+
command = args.command or "state"
|
| 862 |
+
if command in {"state", "observe"}:
|
| 863 |
+
payload = collect_state(
|
| 864 |
+
include_screenshot=bool(args.screenshot),
|
| 865 |
+
context_id=str(args.context_id or ""),
|
| 866 |
+
screenshot_transport="path",
|
| 867 |
+
)
|
| 868 |
+
print(json.dumps(payload, sort_keys=True))
|
| 869 |
+
return 0 if payload.get("ok") else 1
|
| 870 |
+
|
| 871 |
+
if command == "screenshot":
|
| 872 |
+
errors: list[str] = []
|
| 873 |
+
env_info = resolve_environment(errors=errors)
|
| 874 |
+
payload = capture_screenshot(
|
| 875 |
+
display_env(display=env_info["display"], profile_dir=env_info["profile_dir"]),
|
| 876 |
+
collect_capabilities(),
|
| 877 |
+
path=args.path,
|
| 878 |
+
errors=errors,
|
| 879 |
+
context_id=str(args.context_id or ""),
|
| 880 |
+
transport="path",
|
| 881 |
+
)
|
| 882 |
+
if args.json:
|
| 883 |
+
print(json.dumps(payload, sort_keys=True))
|
| 884 |
+
else:
|
| 885 |
+
print(payload.get("path") or payload.get("error") or "")
|
| 886 |
+
return 0 if payload.get("ok") else 1
|
| 887 |
+
|
| 888 |
+
parser.print_help()
|
| 889 |
+
return 2
|
| 890 |
+
|
| 891 |
+
|
| 892 |
+
if __name__ == "__main__":
|
| 893 |
+
raise SystemExit(main())
|
plugins/_desktop/helpers/prompt_context.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from plugins._desktop.helpers import desktop_state
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def build_context(context_id: str = "") -> str:
|
| 7 |
+
if not desktop_state.session_manifest_exists():
|
| 8 |
+
return ""
|
| 9 |
+
try:
|
| 10 |
+
return desktop_state.compact_prompt_context(
|
| 11 |
+
desktop_state.collect_state(include_screenshot=False, context_id=context_id),
|
| 12 |
+
)
|
| 13 |
+
except Exception as exc:
|
| 14 |
+
return (
|
| 15 |
+
"[DESKTOP STATE]\n"
|
| 16 |
+
f"- unavailable={exc}\n"
|
| 17 |
+
"- next=Open the Desktop surface manually, then run plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh observe --json."
|
| 18 |
+
)
|
plugins/_desktop/hooks.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import shutil
|
| 5 |
+
import subprocess
|
| 6 |
+
import tempfile
|
| 7 |
+
import threading
|
| 8 |
+
import time
|
| 9 |
+
import urllib.request
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from helpers import files, state_migration, system_packages
|
| 14 |
+
|
| 15 |
+
LIBREOFFICE_RUNTIME_PACKAGES = (
|
| 16 |
+
"libreoffice-core",
|
| 17 |
+
"libreoffice-writer",
|
| 18 |
+
"libreoffice-calc",
|
| 19 |
+
"libreoffice-impress",
|
| 20 |
+
"libreoffice-gtk3",
|
| 21 |
+
"python3-uno",
|
| 22 |
+
)
|
| 23 |
+
XPRA_SOURCE_FILE = Path("/etc/apt/sources.list.d/xpra.sources")
|
| 24 |
+
XPRA_KEYRING_FILE = Path("/usr/share/keyrings/xpra.asc")
|
| 25 |
+
XPRA_KEY_URL = "https://xpra.org/xpra.asc"
|
| 26 |
+
XPRA_VERSION = "6.5.2-r0-1"
|
| 27 |
+
GTK_RUNTIME_PACKAGE = "gir1.2-gtk-3.0"
|
| 28 |
+
KALI_ROLLING_SOURCE = "deb http://http.kali.org/kali kali-rolling main contrib non-free non-free-firmware\n"
|
| 29 |
+
XPRA_VERSIONED_RUNTIME_PACKAGES = frozenset(
|
| 30 |
+
{
|
| 31 |
+
"xpra-common",
|
| 32 |
+
"xpra-server",
|
| 33 |
+
"xpra-client",
|
| 34 |
+
"xpra-client-gtk3",
|
| 35 |
+
"xpra-x11",
|
| 36 |
+
}
|
| 37 |
+
)
|
| 38 |
+
RUNTIME_PACKAGES = (
|
| 39 |
+
*LIBREOFFICE_RUNTIME_PACKAGES,
|
| 40 |
+
GTK_RUNTIME_PACKAGE,
|
| 41 |
+
"xpra-common",
|
| 42 |
+
"xpra-server",
|
| 43 |
+
"xpra-client",
|
| 44 |
+
"xpra-client-gtk3",
|
| 45 |
+
"xpra-x11",
|
| 46 |
+
"xpra-html5",
|
| 47 |
+
"xfce4-session",
|
| 48 |
+
"xfwm4",
|
| 49 |
+
"xfce4-panel",
|
| 50 |
+
"xfdesktop4",
|
| 51 |
+
"xfce4-settings",
|
| 52 |
+
"thunar",
|
| 53 |
+
"gvfs",
|
| 54 |
+
"libglib2.0-bin",
|
| 55 |
+
"xfce4-terminal",
|
| 56 |
+
"x11-xserver-utils",
|
| 57 |
+
"x11-utils",
|
| 58 |
+
"x11-apps",
|
| 59 |
+
"xdotool",
|
| 60 |
+
"xclip",
|
| 61 |
+
"xauth",
|
| 62 |
+
"dbus-x11",
|
| 63 |
+
"python3-pil",
|
| 64 |
+
"fonts-dejavu",
|
| 65 |
+
"fonts-liberation",
|
| 66 |
+
"fonts-crosextra-caladea",
|
| 67 |
+
"fonts-crosextra-carlito",
|
| 68 |
+
"fonts-noto-core",
|
| 69 |
+
"fonts-noto-cjk",
|
| 70 |
+
"fonts-noto-color-emoji",
|
| 71 |
+
)
|
| 72 |
+
RETIRED_RUNTIME_PACKAGES = (
|
| 73 |
+
"firefox-esr",
|
| 74 |
+
)
|
| 75 |
+
PLUGIN_NAME = "_desktop"
|
| 76 |
+
STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME))
|
| 77 |
+
RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME))
|
| 78 |
+
_preparation_lock = threading.RLock()
|
| 79 |
+
_preparation_state: dict[str, Any] = {
|
| 80 |
+
"preparing": False,
|
| 81 |
+
"active_count": 0,
|
| 82 |
+
"started_at": 0.0,
|
| 83 |
+
"completed_at": 0.0,
|
| 84 |
+
"result": None,
|
| 85 |
+
"error": "",
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
|
| 90 |
+
"""Prepare the Linux Desktop runtime and reap stale Desktop sessions."""
|
| 91 |
+
|
| 92 |
+
_begin_runtime_preparation()
|
| 93 |
+
result: dict[str, Any] | None = None
|
| 94 |
+
error = ""
|
| 95 |
+
try:
|
| 96 |
+
installed: list[str] = []
|
| 97 |
+
removed: list[str] = []
|
| 98 |
+
migrated: list[str] = []
|
| 99 |
+
warnings: list[str] = []
|
| 100 |
+
errors: list[str] = []
|
| 101 |
+
|
| 102 |
+
_migrate_retired_plugin_state(migrated, warnings, errors)
|
| 103 |
+
_remove_persisted_screenshots(warnings, errors)
|
| 104 |
+
|
| 105 |
+
retired_packages = _installed_packages(RETIRED_RUNTIME_PACKAGES)
|
| 106 |
+
if retired_packages:
|
| 107 |
+
_purge_packages(removed, errors, installed_packages=retired_packages)
|
| 108 |
+
|
| 109 |
+
_ensure_runtime_dependencies(installed, errors)
|
| 110 |
+
_cleanup_desktop_sessions(errors)
|
| 111 |
+
|
| 112 |
+
result = {
|
| 113 |
+
"ok": not errors,
|
| 114 |
+
"skipped": False,
|
| 115 |
+
"removed": removed,
|
| 116 |
+
"installed": installed,
|
| 117 |
+
"migrated": migrated,
|
| 118 |
+
"warnings": warnings,
|
| 119 |
+
"errors": errors,
|
| 120 |
+
}
|
| 121 |
+
return result
|
| 122 |
+
except Exception as exc:
|
| 123 |
+
error = str(exc)
|
| 124 |
+
raise
|
| 125 |
+
finally:
|
| 126 |
+
_finish_runtime_preparation(result=result, error=error)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def runtime_preparation_status() -> dict[str, Any]:
|
| 130 |
+
with _preparation_lock:
|
| 131 |
+
return {
|
| 132 |
+
"preparing": bool(_preparation_state["preparing"]),
|
| 133 |
+
"active_count": int(_preparation_state["active_count"]),
|
| 134 |
+
"started_at": float(_preparation_state["started_at"]),
|
| 135 |
+
"completed_at": float(_preparation_state["completed_at"]),
|
| 136 |
+
"result": _preparation_state["result"],
|
| 137 |
+
"error": str(_preparation_state["error"]),
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _migrate_retired_plugin_state(
|
| 142 |
+
migrated: list[str],
|
| 143 |
+
warnings: list[str],
|
| 144 |
+
errors: list[str],
|
| 145 |
+
) -> None:
|
| 146 |
+
state_migration.migrate_retired_state_tree(
|
| 147 |
+
source=RETIRED_STATE_DIR,
|
| 148 |
+
destination=STATE_DIR,
|
| 149 |
+
owner="Desktop",
|
| 150 |
+
migrated=migrated,
|
| 151 |
+
warnings=warnings,
|
| 152 |
+
errors=errors,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _remove_persisted_screenshots(
|
| 157 |
+
warnings: list[str],
|
| 158 |
+
errors: list[str],
|
| 159 |
+
) -> None:
|
| 160 |
+
screenshots_dir = STATE_DIR / "screenshots"
|
| 161 |
+
if not screenshots_dir.exists():
|
| 162 |
+
return
|
| 163 |
+
try:
|
| 164 |
+
shutil.rmtree(screenshots_dir)
|
| 165 |
+
warnings.append(f"Removed retired persistent Desktop screenshots: {screenshots_dir}")
|
| 166 |
+
except Exception as exc:
|
| 167 |
+
errors.append(f"Failed to remove retired persistent Desktop screenshots at {screenshots_dir}: {exc}")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _begin_runtime_preparation() -> None:
|
| 171 |
+
with _preparation_lock:
|
| 172 |
+
if not _preparation_state["active_count"]:
|
| 173 |
+
_preparation_state["preparing"] = True
|
| 174 |
+
_preparation_state["started_at"] = time.time()
|
| 175 |
+
_preparation_state["completed_at"] = 0.0
|
| 176 |
+
_preparation_state["result"] = None
|
| 177 |
+
_preparation_state["error"] = ""
|
| 178 |
+
_preparation_state["active_count"] = int(_preparation_state["active_count"]) + 1
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _finish_runtime_preparation(
|
| 182 |
+
*,
|
| 183 |
+
result: dict[str, Any] | None = None,
|
| 184 |
+
error: str = "",
|
| 185 |
+
) -> None:
|
| 186 |
+
with _preparation_lock:
|
| 187 |
+
active_count = max(0, int(_preparation_state["active_count"]) - 1)
|
| 188 |
+
_preparation_state["active_count"] = active_count
|
| 189 |
+
if result is not None:
|
| 190 |
+
_preparation_state["result"] = result
|
| 191 |
+
if error:
|
| 192 |
+
_preparation_state["error"] = error
|
| 193 |
+
if active_count:
|
| 194 |
+
return
|
| 195 |
+
_preparation_state["preparing"] = False
|
| 196 |
+
_preparation_state["completed_at"] = time.time()
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _installed_packages(packages: tuple[str, ...]) -> list[str]:
|
| 200 |
+
if not shutil.which("dpkg-query"):
|
| 201 |
+
return []
|
| 202 |
+
return [package for package in packages if _package_installed(package)]
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _package_installed(package: str) -> bool:
|
| 206 |
+
result = subprocess.run(
|
| 207 |
+
["dpkg-query", "-W", "-f=${Status}", package],
|
| 208 |
+
check=False,
|
| 209 |
+
text=True,
|
| 210 |
+
capture_output=True,
|
| 211 |
+
timeout=8,
|
| 212 |
+
)
|
| 213 |
+
return result.returncode == 0 and "install ok installed" in result.stdout
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _package_version(package: str) -> str:
|
| 217 |
+
result = subprocess.run(
|
| 218 |
+
["dpkg-query", "-W", "-f=${Version}", package],
|
| 219 |
+
check=False,
|
| 220 |
+
text=True,
|
| 221 |
+
capture_output=True,
|
| 222 |
+
timeout=8,
|
| 223 |
+
)
|
| 224 |
+
return result.stdout.strip() if result.returncode == 0 else ""
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _purge_packages(
|
| 228 |
+
removed: list[str],
|
| 229 |
+
errors: list[str],
|
| 230 |
+
*,
|
| 231 |
+
installed_packages: list[str] | None = None,
|
| 232 |
+
) -> None:
|
| 233 |
+
if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"):
|
| 234 |
+
return
|
| 235 |
+
installed = installed_packages if installed_packages is not None else []
|
| 236 |
+
if not installed:
|
| 237 |
+
return
|
| 238 |
+
result = _run_apt_command(["apt-get", "purge", "-y", *installed], timeout=180)
|
| 239 |
+
if result.returncode == 0:
|
| 240 |
+
removed.extend(installed)
|
| 241 |
+
return
|
| 242 |
+
errors.append((result.stderr or result.stdout or "apt-get purge failed").strip())
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> None:
|
| 246 |
+
if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"):
|
| 247 |
+
return
|
| 248 |
+
if not _ensure_kali_gtk_runtime(installed, errors):
|
| 249 |
+
return
|
| 250 |
+
missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
|
| 251 |
+
if not missing:
|
| 252 |
+
return
|
| 253 |
+
|
| 254 |
+
if not _apt_update(errors):
|
| 255 |
+
return
|
| 256 |
+
|
| 257 |
+
required_xpra_missing = [package for package in missing if package.startswith("xpra")]
|
| 258 |
+
if required_xpra_missing and not _package_candidates_available(required_xpra_missing):
|
| 259 |
+
previous_error_count = len(errors)
|
| 260 |
+
_ensure_xpra_repository(installed, errors)
|
| 261 |
+
if len(errors) > previous_error_count or not _apt_update(errors):
|
| 262 |
+
return
|
| 263 |
+
missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
|
| 264 |
+
if not missing:
|
| 265 |
+
return
|
| 266 |
+
|
| 267 |
+
if missing:
|
| 268 |
+
_install_runtime_packages(missing, installed, errors)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _ensure_kali_gtk_runtime(installed: list[str], errors: list[str]) -> bool:
|
| 272 |
+
if (
|
| 273 |
+
GTK_RUNTIME_PACKAGE not in RUNTIME_PACKAGES
|
| 274 |
+
or _package_installed(GTK_RUNTIME_PACKAGE)
|
| 275 |
+
or _read_os_release().get("ID") != "kali"
|
| 276 |
+
):
|
| 277 |
+
return True
|
| 278 |
+
|
| 279 |
+
with tempfile.TemporaryDirectory(prefix="a0-desktop-apt-") as directory:
|
| 280 |
+
source = Path(directory) / "kali-rolling.list"
|
| 281 |
+
source.write_text(KALI_ROLLING_SOURCE, encoding="utf-8")
|
| 282 |
+
options = [
|
| 283 |
+
"-o",
|
| 284 |
+
f"Dir::Etc::sourcelist={source}",
|
| 285 |
+
"-o",
|
| 286 |
+
"Dir::Etc::sourceparts=-",
|
| 287 |
+
]
|
| 288 |
+
result = _run_apt_command(["apt-get", *options, "update"], timeout=300)
|
| 289 |
+
if result.returncode == 0:
|
| 290 |
+
result = _run_apt_command(
|
| 291 |
+
[
|
| 292 |
+
"apt-get",
|
| 293 |
+
*options,
|
| 294 |
+
"install",
|
| 295 |
+
"-y",
|
| 296 |
+
"--no-install-recommends",
|
| 297 |
+
GTK_RUNTIME_PACKAGE,
|
| 298 |
+
],
|
| 299 |
+
timeout=900,
|
| 300 |
+
)
|
| 301 |
+
if result.returncode == 0:
|
| 302 |
+
installed.append(GTK_RUNTIME_PACKAGE)
|
| 303 |
+
return True
|
| 304 |
+
errors.append((result.stderr or result.stdout or "GTK runtime install failed").strip())
|
| 305 |
+
return False
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def _install_runtime_packages(
|
| 309 |
+
packages: list[str],
|
| 310 |
+
installed: list[str],
|
| 311 |
+
errors: list[str],
|
| 312 |
+
) -> bool:
|
| 313 |
+
xpra_version = ""
|
| 314 |
+
if any(package in XPRA_VERSIONED_RUNTIME_PACKAGES for package in packages):
|
| 315 |
+
xpra_version = _package_version("xpra-common") or XPRA_VERSION
|
| 316 |
+
package_specs = [
|
| 317 |
+
f"{package}={xpra_version}" if package in XPRA_VERSIONED_RUNTIME_PACKAGES else package
|
| 318 |
+
for package in packages
|
| 319 |
+
]
|
| 320 |
+
result = _run_apt_command(
|
| 321 |
+
["apt-get", "install", "-y", "--no-install-recommends", *package_specs],
|
| 322 |
+
timeout=900,
|
| 323 |
+
)
|
| 324 |
+
if result.returncode == 0:
|
| 325 |
+
installed.extend(packages)
|
| 326 |
+
return True
|
| 327 |
+
output = (result.stderr or result.stdout or "apt-get install failed").strip()
|
| 328 |
+
errors.append(output)
|
| 329 |
+
return False
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _apt_update(errors: list[str]) -> bool:
|
| 333 |
+
result = _run_apt_command(["apt-get", "update"], timeout=300)
|
| 334 |
+
if result.returncode == 0:
|
| 335 |
+
return True
|
| 336 |
+
errors.append((result.stderr or result.stdout or "apt-get update failed").strip())
|
| 337 |
+
return False
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def _package_candidate_available(package: str) -> bool:
|
| 341 |
+
if not shutil.which("apt-cache"):
|
| 342 |
+
return True
|
| 343 |
+
result = subprocess.run(
|
| 344 |
+
["apt-cache", "policy", package],
|
| 345 |
+
check=False,
|
| 346 |
+
text=True,
|
| 347 |
+
capture_output=True,
|
| 348 |
+
timeout=15,
|
| 349 |
+
)
|
| 350 |
+
if result.returncode != 0:
|
| 351 |
+
return True
|
| 352 |
+
if not result.stdout.strip():
|
| 353 |
+
return False
|
| 354 |
+
return "Candidate: (none)" not in result.stdout
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def _package_candidates_available(packages: list[str]) -> bool:
|
| 358 |
+
return all(_package_candidate_available(package) for package in packages)
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def _ensure_xpra_repository(installed: list[str], errors: list[str]) -> None:
|
| 362 |
+
if not _package_installed("ca-certificates"):
|
| 363 |
+
result = _run_apt_command(
|
| 364 |
+
["apt-get", "install", "-y", "--no-install-recommends", "ca-certificates"],
|
| 365 |
+
timeout=180,
|
| 366 |
+
)
|
| 367 |
+
if result.returncode != 0:
|
| 368 |
+
errors.append((result.stderr or result.stdout or "apt-get install ca-certificates failed").strip())
|
| 369 |
+
return
|
| 370 |
+
installed.append("ca-certificates")
|
| 371 |
+
|
| 372 |
+
try:
|
| 373 |
+
key = _download(XPRA_KEY_URL)
|
| 374 |
+
XPRA_KEYRING_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 375 |
+
if not XPRA_KEYRING_FILE.exists() or XPRA_KEYRING_FILE.read_bytes() != key:
|
| 376 |
+
XPRA_KEYRING_FILE.write_bytes(key)
|
| 377 |
+
|
| 378 |
+
XPRA_SOURCE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 379 |
+
source = _xpra_repository_source()
|
| 380 |
+
if not XPRA_SOURCE_FILE.exists() or XPRA_SOURCE_FILE.read_text(encoding="utf-8") != source:
|
| 381 |
+
XPRA_SOURCE_FILE.write_text(source, encoding="utf-8")
|
| 382 |
+
except Exception as exc:
|
| 383 |
+
errors.append(f"Xpra repository setup failed: {exc}")
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def _download(url: str) -> bytes:
|
| 387 |
+
with urllib.request.urlopen(url, timeout=45) as response:
|
| 388 |
+
return response.read()
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def _run_apt_command(command: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
|
| 392 |
+
return system_packages.run_apt_with_retries(
|
| 393 |
+
lambda: subprocess.run(
|
| 394 |
+
command,
|
| 395 |
+
check=False,
|
| 396 |
+
text=True,
|
| 397 |
+
capture_output=True,
|
| 398 |
+
timeout=timeout,
|
| 399 |
+
env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
|
| 400 |
+
)
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def _xpra_repository_source() -> str:
|
| 405 |
+
os_release = _read_os_release()
|
| 406 |
+
os_id = os_release.get("ID", "")
|
| 407 |
+
codename = os_release.get("VERSION_CODENAME", "")
|
| 408 |
+
arch = _dpkg_architecture()
|
| 409 |
+
|
| 410 |
+
uri = "https://xpra.org"
|
| 411 |
+
suite = "trixie" if os_id == "kali" or codename in {"sid", "forky"} else codename or "trixie"
|
| 412 |
+
|
| 413 |
+
return (
|
| 414 |
+
f"Types: deb\n"
|
| 415 |
+
f"URIs: {uri}\n"
|
| 416 |
+
f"Suites: {suite}\n"
|
| 417 |
+
f"Components: main\n"
|
| 418 |
+
f"Signed-By: {XPRA_KEYRING_FILE}\n"
|
| 419 |
+
f"Architectures: {arch}\n"
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def _read_os_release() -> dict[str, str]:
|
| 424 |
+
path = Path("/etc/os-release")
|
| 425 |
+
if not path.exists():
|
| 426 |
+
return {}
|
| 427 |
+
values: dict[str, str] = {}
|
| 428 |
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
| 429 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 430 |
+
continue
|
| 431 |
+
key, value = line.split("=", 1)
|
| 432 |
+
values[key] = value.strip().strip('"')
|
| 433 |
+
return values
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def _dpkg_architecture() -> str:
|
| 437 |
+
result = subprocess.run(
|
| 438 |
+
["dpkg", "--print-architecture"],
|
| 439 |
+
check=False,
|
| 440 |
+
text=True,
|
| 441 |
+
capture_output=True,
|
| 442 |
+
timeout=8,
|
| 443 |
+
)
|
| 444 |
+
if result.returncode == 0 and result.stdout.strip():
|
| 445 |
+
return result.stdout.strip()
|
| 446 |
+
return "amd64"
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def _cleanup_desktop_sessions(errors: list[str]) -> None:
|
| 450 |
+
try:
|
| 451 |
+
from plugins._desktop.helpers import desktop_session
|
| 452 |
+
|
| 453 |
+
result = desktop_session.cleanup_stale_runtime_state()
|
| 454 |
+
errors.extend(str(item) for item in result.get("errors") or [])
|
| 455 |
+
except Exception as exc:
|
| 456 |
+
errors.append(f"Desktop cleanup failed: {exc}")
|
plugins/_desktop/plugin.yaml
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: _desktop
|
| 2 |
+
title: Desktop
|
| 3 |
+
description: Owns the Agent Zero Linux Desktop runtime, Xpra/Xfce sessions, and live Desktop surface.
|
| 4 |
+
version: 1.0.0
|
| 5 |
+
always_enabled: false
|
plugins/_desktop/prompts/agent.extras.desktop_state.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{{desktop_state}}
|
plugins/_desktop/skills/linux-desktop/SKILL.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: linux-desktop
|
| 3 |
+
description: Use only for Agent Zero's built-in Docker/Xpra Linux Desktop, XFCE apps, LibreOffice GUI apps, file manager, terminal, or visual workflows inside the Agent Zero runtime. Not for A0 CLI /computer-use or computer_use_remote host control.
|
| 4 |
+
version: "0.3.0"
|
| 5 |
+
author: "Agent Zero Core Team"
|
| 6 |
+
tags: ["linux", "desktop", "xfce", "libreoffice", "gui", "files", "terminal"]
|
| 7 |
+
triggers:
|
| 8 |
+
- "Agent Zero Desktop"
|
| 9 |
+
- "built-in Linux Desktop"
|
| 10 |
+
- "Xpra Desktop"
|
| 11 |
+
- "XFCE Desktop"
|
| 12 |
+
- "LibreOffice GUI in Desktop"
|
| 13 |
+
- "Desktop file manager"
|
| 14 |
+
- "Docker desktop terminal"
|
| 15 |
+
allowed_tools:
|
| 16 |
+
- office_artifact
|
| 17 |
+
- code_execution_tool
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
# Linux Desktop Interface
|
| 21 |
+
|
| 22 |
+
## Boundary With Host Computer Use
|
| 23 |
+
|
| 24 |
+
This skill is only for the built-in Desktop surface running inside Agent Zero's Docker/runtime Xpra session. It does not control the user's host OS, real monitor, local Ubuntu/Wayland session, macOS desktop, or Windows desktop.
|
| 25 |
+
|
| 26 |
+
If the user asks for `/computer-use`, "my computer", "host machine", "local desktop", screenshots of the user's screen, hiding/minimizing a window on their screen, or any connected A0 CLI Computer Use task, do not use this skill, `desktopctl.sh`, Xpra, `code_execution_tool`, or Docker shell commands. Load `host-computer-use` and use `computer_use_remote`; if unavailable, tell the user to enable `/computer-use on`.
|
| 27 |
+
|
| 28 |
+
`desktopctl.sh` only targets the internal Agent Zero Xpra display. It cannot observe or control the user's connected host screen.
|
| 29 |
+
|
| 30 |
+
Use the Desktop as a full Linux GUI when the user explicitly needs a visual workflow, an installed desktop app, or manual layout polish that is awkward through structured file edits alone. Agent Zero may warm the persistent Desktop runtime during initial startup, but visible Desktop surface use remains opt-in. The Desktop is opt-in at the UI level: do not open a surface just because the user asks for a document. Use structured tools first for deterministic content changes, then use the Desktop for inspection, GUI-only actions, and final visual confirmation.
|
| 31 |
+
|
| 32 |
+
## Operating Model
|
| 33 |
+
|
| 34 |
+
The Desktop is a structured-first X11 control surface. Use this decision hierarchy:
|
| 35 |
+
|
| 36 |
+
1. Prefer structured tools such as `office_artifact` for deterministic Office file creation, reads, and edits.
|
| 37 |
+
2. Prefer structured Desktop state and window commands: `check`, `state --json`, `windows`, `active-window`, `geometry`, and `wait-window`.
|
| 38 |
+
3. Prefer app-native helpers for visible live edits, such as `desktopctl.sh calc-set-cell` for Calc/UNO spreadsheet changes.
|
| 39 |
+
4. Prefer launcher commands, window focus, keyboard shortcuts, menus, paste, and save commands. Batch multi-step actions with `sequence`/`batch` so one helper process drives the flow.
|
| 40 |
+
5. Use screenshots for visual inspection, OCR, coordinate work, and final evidence when the task depends on pixels. Do not take screenshots merely to learn window titles or readiness that structured state already reports.
|
| 41 |
+
6. Use coordinate clicks only as a last resort, and only after a fresh screenshot observation.
|
| 42 |
+
7. After any GUI action, verify through Desktop state, active window titles, screenshots when visually necessary, saved file state, or exported output.
|
| 43 |
+
8. For terminal or CLI-agent work, prefer deterministic command output or saved transcripts. When exact visible terminal text matters, verify against a fresh final `observe --json --screenshot` captured after the command has finished or visibly returned to an input prompt. Agent-facing Desktop screenshots are ephemeral refs; `desktopctl` shell observations with `--context-id` return chat-scoped screenshot paths. Do not report from an earlier screenshot path.
|
| 44 |
+
|
| 45 |
+
Keep these standing rules:
|
| 46 |
+
|
| 47 |
+
1. Treat Markdown as first-class, but use the separate Editor surface for it. For writing, notes, reports, and drafts with no explicit binary Office requirement, create Markdown and let the user open it in Editor instead of Desktop.
|
| 48 |
+
2. Treat ODF as first-class for LibreOffice office work: ODT in Writer, ODS in Calc, ODP in Impress. Use DOCX/XLSX/PPTX only for explicit OOXML compatibility.
|
| 49 |
+
3. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation.
|
| 50 |
+
4. Never open the Desktop surface automatically from a tool result if the user has not opened it. Offer an explicit Open in Desktop action instead.
|
| 51 |
+
5. Launch common apps from the Desktop icons, the header buttons, or `/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh`.
|
| 52 |
+
6. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version.
|
| 53 |
+
7. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success. If exact terminal text matters, load or inspect the screenshot path returned by the final observation, not a screenshot captured before the text appeared.
|
| 54 |
+
|
| 55 |
+
## Control Flow
|
| 56 |
+
|
| 57 |
+
Use the helper script when the Desktop is already open and you need reliable app launches, clicks, keystrokes, or window checks from the agent shell. In the live Agent Zero runtime, prefer the absolute path so the command works from any current directory:
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
|
| 61 |
+
$DESKTOP check
|
| 62 |
+
$DESKTOP state --json
|
| 63 |
+
$DESKTOP observe --json
|
| 64 |
+
$DESKTOP launch calc
|
| 65 |
+
$DESKTOP wait-window LibreOffice
|
| 66 |
+
$DESKTOP windows LibreOffice
|
| 67 |
+
$DESKTOP focus LibreOffice
|
| 68 |
+
$DESKTOP key ctrl+s
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
The script targets the persistent `agent-zero-desktop` X display, sets `DISPLAY`, `XAUTHORITY`, and `HOME` to the XFCE profile, then uses `xdotool` for input. Startup normally prepares this session. If `check` fails during explicit Desktop work, report that the Desktop runtime is not ready instead of installing packages ad hoc.
|
| 72 |
+
|
| 73 |
+
If `state --json` or `observe --json` shows a reachable display and visible Desktop/window entries, the Desktop is usable even when `active_window` is `null`; a bare XFCE desktop can have no active application window. Treat missing display or unavailable `xdotool` as blockers and stop with the specific readiness message instead of repeating clicks or inventing a fallback. Use `observe --json --screenshot` only when you need pixels, and treat unavailable `xwd` as a screenshot blocker rather than a general Desktop blocker. Shell screenshots captured with `--context-id` live in the owning chat's screenshot folder; screenshots without a chat context remain temporary.
|
| 74 |
+
|
| 75 |
+
For direct app launches without coordinates:
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
|
| 79 |
+
$DESKTOP launch writer
|
| 80 |
+
$DESKTOP launch calc
|
| 81 |
+
$DESKTOP launch impress
|
| 82 |
+
$DESKTOP launch terminal
|
| 83 |
+
$DESKTOP launch settings
|
| 84 |
+
$DESKTOP open-path /a0/usr/workdir
|
| 85 |
+
$DESKTOP focus "LibreOffice"
|
| 86 |
+
$DESKTOP paste-text "Text to insert"
|
| 87 |
+
$DESKTOP key ctrl+s
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
For multi-step window or terminal actions, batch commands through one helper process:
|
| 91 |
+
|
| 92 |
+
```bash
|
| 93 |
+
DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
|
| 94 |
+
$DESKTOP sequence - <<'EOF'
|
| 95 |
+
focus Terminal
|
| 96 |
+
paste-text echo ready
|
| 97 |
+
key Return
|
| 98 |
+
state --json
|
| 99 |
+
EOF
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
Use one command per line. `paste-text` joins the remaining words on that line, so it is suitable for ordinary command text; for complex multiline payloads, write the payload to a file or invoke `paste-text` directly.
|
| 103 |
+
|
| 104 |
+
For live spreadsheet coworking, use the Calc helper instead of hand-written UNO snippets:
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
|
| 108 |
+
$DESKTOP calc-set-cell /a0/usr/workdir/example.xlsx Sheet1 B2 "Cowork verified live"
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
This opens the workbook in the visible Desktop Calc session if needed, changes the cell through LibreOffice, saves the workbook, and verifies the `.xlsx` on disk. Because the edit happens through the running LibreOffice session, the user can see the sheet update without refreshing the Desktop surface.
|
| 112 |
+
|
| 113 |
+
For coordinate actions, clicks are explicitly last resort. First try `launch`, `open-path`, `wait-window`, `focus`, `key`, `paste-text`, `save`, or an app-native helper. If a coordinate action is still necessary, base it on a fresh screenshot observation and verify immediately afterward:
|
| 114 |
+
|
| 115 |
+
```bash
|
| 116 |
+
DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
|
| 117 |
+
$DESKTOP observe --json --screenshot
|
| 118 |
+
$DESKTOP click 120 180
|
| 119 |
+
$DESKTOP dblclick 120 180
|
| 120 |
+
$DESKTOP right-click 120 180
|
| 121 |
+
$DESKTOP drag 120 180 400 180
|
| 122 |
+
$DESKTOP scroll down 3
|
| 123 |
+
$DESKTOP type "Text to enter"
|
| 124 |
+
$DESKTOP observe --json
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
When browser automation is available, the higher-level QA flow is:
|
| 128 |
+
|
| 129 |
+
1. Open `http://127.0.0.1:32080`.
|
| 130 |
+
2. Open the Desktop surface from the UI or with `Alpine.store("rightCanvas").open("desktop")`.
|
| 131 |
+
3. Use browser mouse events into the Xpra iframe for real user-path testing.
|
| 132 |
+
4. Cross-check with `desktopctl.sh location` and `desktopctl.sh windows PATTERN`.
|
| 133 |
+
5. Capture the browser screenshot as visual evidence.
|
| 134 |
+
|
| 135 |
+
## Terminal And CLI Agent Verification
|
| 136 |
+
|
| 137 |
+
Terminal apps are visual state, not structured logs. When the task depends on exact terminal output, follow this stricter loop:
|
| 138 |
+
|
| 139 |
+
1. Run `desktopctl.sh state --json` or `desktopctl.sh windows Terminal` before acting to confirm the target window. Add `observe --json --screenshot` only if the current prompt or screen contents must be read visually.
|
| 140 |
+
2. Use `sequence`, `focus`, `paste-text` or `type`, and `key Return` to drive the terminal. Prefer CLI-native commands and keyboard input over clicks.
|
| 141 |
+
3. Wait until the CLI has visibly produced a response or returned to an input prompt.
|
| 142 |
+
4. If exact visible text matters and no deterministic transcript was saved, run a new final `desktopctl.sh observe --json --screenshot`.
|
| 143 |
+
5. Verify exact text only from the screenshot path returned by that final observation, or from a newer screenshot. Never use an earlier screenshot path as final evidence.
|
| 144 |
+
6. If the final screenshot is cropped, stale, or unreadable, capture another screenshot or report the result as unverified with that specific reason.
|
| 145 |
+
|
| 146 |
+
For nested CLI agents, a successful proof requires both the input prompt and the nested agent's visible response in the final screenshot, or another deterministic saved transcript produced by the CLI itself.
|
| 147 |
+
|
| 148 |
+
Guard the boundary between the shell and the target CLI carefully:
|
| 149 |
+
|
| 150 |
+
- A shell prompt such as `root@...#` means the target CLI is not currently receiving chat input. Never paste natural-language text into that shell prompt unless it is deliberately quoted as an argument to a shell command.
|
| 151 |
+
- If launching a CLI by name can fail, use a shell-safe fallback command and wait for the CLI's own prompt before sending the user's natural-language message.
|
| 152 |
+
- After a launch failure such as `command not found`, do not continue by sending the chat message. Start the fallback CLI command or report the blocker.
|
| 153 |
+
- A prompt like `>`, `Implement {feature}`, or a CLI-specific input box inside the terminal is different from a shell prompt. Only then should `paste-text "natural language"` followed by `key Return` be used as chat input.
|
| 154 |
+
|
| 155 |
+
Example for a nested CLI-agent smoke test:
|
| 156 |
+
|
| 157 |
+
```bash
|
| 158 |
+
DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
|
| 159 |
+
$DESKTOP sequence - <<'EOF'
|
| 160 |
+
focus Terminal
|
| 161 |
+
paste-text TARGET_CLI="example-cli-agent"; FALLBACK_CMD=""; if command -v "$TARGET_CLI" >/dev/null 2>&1; then "$TARGET_CLI"; elif [ -n "$FALLBACK_CMD" ]; then sh -lc "$FALLBACK_CMD"; else echo "CLI agent not found: $TARGET_CLI"; fi
|
| 162 |
+
key Return
|
| 163 |
+
EOF
|
| 164 |
+
$DESKTOP observe --json --screenshot
|
| 165 |
+
# Verify the screenshot shows the target CLI prompt, not a shell prompt, before sending natural language:
|
| 166 |
+
$DESKTOP sequence - <<'EOF'
|
| 167 |
+
paste-text Reply with exactly the requested smoke-test token.
|
| 168 |
+
key Return
|
| 169 |
+
EOF
|
| 170 |
+
$DESKTOP observe --json --screenshot
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
## Desktop Locations
|
| 174 |
+
|
| 175 |
+
The Desktop exposes stable folders for common user work:
|
| 176 |
+
|
| 177 |
+
- `Workdir` -> configured Agent Zero workdir (default `/a0/usr/workdir`)
|
| 178 |
+
- `Projects` -> `/a0/usr/projects`
|
| 179 |
+
- `Skills` -> `/a0/usr/skills`
|
| 180 |
+
- `Agents` -> `/a0/usr/agents`
|
| 181 |
+
- `Downloads` -> `/a0/usr/downloads`
|
| 182 |
+
|
| 183 |
+
Use these folders when the user asks to inspect or manipulate project files, skills, agent profiles, or downloaded artifacts from the GUI.
|
| 184 |
+
|
| 185 |
+
## App Map
|
| 186 |
+
|
| 187 |
+
- `LibreOffice Writer`: ODT word processing and DOCX compatibility layout.
|
| 188 |
+
- `LibreOffice Calc`: ODS spreadsheets, formulas, tables, charts, and XLSX compatibility.
|
| 189 |
+
- `LibreOffice Impress`: ODP presentations, slide polish, and PPTX compatibility.
|
| 190 |
+
- `Workdir`: graphical file management with Thunar at the configured Agent Zero workdir (default `/a0/usr/workdir`).
|
| 191 |
+
- `Terminal`: shell work inside the Agent Zero runtime.
|
| 192 |
+
- `Settings`: XFCE system settings.
|
| 193 |
+
|
| 194 |
+
## Practical Rules
|
| 195 |
+
|
| 196 |
+
- Keep installs inside normal plugin hook or image install flows; do not install packages ad hoc just to complete one desktop action.
|
| 197 |
+
- The persistent Desktop can be running in the background while the canvas stays closed; that is expected and still respects user ownership of the visible UI.
|
| 198 |
+
- Do not treat closing a document tab as closing the whole Desktop. The Desktop is persistent while Agent Zero is running.
|
| 199 |
+
- Save before sync or final verification when the GUI app has edited a file.
|
| 200 |
+
- If a GUI action is flaky, switch to structured file editing for content and return to the Desktop only for visual confirmation.
|
| 201 |
+
- For live Calc edits that the user should see immediately, prefer `desktopctl.sh calc-set-cell FILE SHEET CELL VALUE`.
|
| 202 |
+
- For enterprise workflows, leave printing available.
|
plugins/_desktop/skills/linux-desktop/scripts/calc_set_cell.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Set a Calc cell through the visible LibreOffice desktop session."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import os
|
| 8 |
+
import shutil
|
| 9 |
+
import subprocess
|
| 10 |
+
import sys
|
| 11 |
+
import time
|
| 12 |
+
import zipfile
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
import xml.etree.ElementTree as ET
|
| 15 |
+
|
| 16 |
+
for candidate in ("/usr/lib/python3/dist-packages", "/usr/lib/libreoffice/program"):
|
| 17 |
+
if candidate not in sys.path:
|
| 18 |
+
sys.path.insert(0, candidate)
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
import uno
|
| 22 |
+
except Exception as exc: # pragma: no cover - exercised in the container runtime.
|
| 23 |
+
raise SystemExit(f"LibreOffice UNO is not available: {exc}") from exc
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
UNO_URL = "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext"
|
| 27 |
+
OOXML_MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
| 28 |
+
OOXML_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
| 29 |
+
REL_PACKAGE_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
| 30 |
+
NS = {"m": OOXML_MAIN_NS, "r": OOXML_REL_NS, "rel": REL_PACKAGE_NS}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def parse_args() -> argparse.Namespace:
|
| 34 |
+
parser = argparse.ArgumentParser(
|
| 35 |
+
description="Open an XLSX in visible LibreOffice Calc, edit one cell, save, and verify."
|
| 36 |
+
)
|
| 37 |
+
parser.add_argument("file")
|
| 38 |
+
parser.add_argument("sheet")
|
| 39 |
+
parser.add_argument("cell")
|
| 40 |
+
parser.add_argument("value", nargs=argparse.REMAINDER)
|
| 41 |
+
args = parser.parse_args()
|
| 42 |
+
if not args.value:
|
| 43 |
+
parser.error("value is required")
|
| 44 |
+
args.value = " ".join(args.value)
|
| 45 |
+
return args
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def run_quiet(command: list[str], *, timeout: float | None = None) -> subprocess.CompletedProcess:
|
| 49 |
+
return subprocess.run(
|
| 50 |
+
command,
|
| 51 |
+
stdout=subprocess.DEVNULL,
|
| 52 |
+
stderr=subprocess.DEVNULL,
|
| 53 |
+
check=False,
|
| 54 |
+
timeout=timeout,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def dismiss_document_in_use_dialogs() -> None:
|
| 59 |
+
xdotool = shutil.which("xdotool")
|
| 60 |
+
if not xdotool:
|
| 61 |
+
return
|
| 62 |
+
found = subprocess.run(
|
| 63 |
+
[xdotool, "search", "--onlyvisible", "--name", "Document in Use"],
|
| 64 |
+
stdout=subprocess.PIPE,
|
| 65 |
+
stderr=subprocess.DEVNULL,
|
| 66 |
+
text=True,
|
| 67 |
+
check=False,
|
| 68 |
+
)
|
| 69 |
+
for window_id in found.stdout.splitlines():
|
| 70 |
+
if window_id.strip():
|
| 71 |
+
run_quiet([xdotool, "windowclose", window_id.strip()], timeout=2)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def connect_once():
|
| 75 |
+
local_ctx = uno.getComponentContext()
|
| 76 |
+
resolver = local_ctx.ServiceManager.createInstanceWithContext(
|
| 77 |
+
"com.sun.star.bridge.UnoUrlResolver",
|
| 78 |
+
local_ctx,
|
| 79 |
+
)
|
| 80 |
+
return resolver.resolve(os.environ.get("A0_DESKTOP_UNO_URL", UNO_URL))
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def desktop_from_context(ctx):
|
| 84 |
+
return ctx.ServiceManager.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def component_url(component) -> str:
|
| 88 |
+
try:
|
| 89 |
+
return getattr(component, "URL", "") or ""
|
| 90 |
+
except Exception:
|
| 91 |
+
return ""
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def component_title(component) -> str:
|
| 95 |
+
try:
|
| 96 |
+
return getattr(component, "Title", "") or ""
|
| 97 |
+
except Exception:
|
| 98 |
+
return ""
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def component_has_sheets(component) -> bool:
|
| 102 |
+
try:
|
| 103 |
+
component.getSheets()
|
| 104 |
+
return True
|
| 105 |
+
except Exception:
|
| 106 |
+
return False
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def url_to_path(url: str) -> str:
|
| 110 |
+
if not url:
|
| 111 |
+
return ""
|
| 112 |
+
try:
|
| 113 |
+
return os.path.realpath(uno.fileUrlToSystemPath(url))
|
| 114 |
+
except Exception:
|
| 115 |
+
return ""
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def find_open_document(desktop, path: Path):
|
| 119 |
+
target_path = os.path.realpath(str(path))
|
| 120 |
+
target_name = path.name
|
| 121 |
+
try:
|
| 122 |
+
enum = desktop.getComponents().createEnumeration()
|
| 123 |
+
except Exception:
|
| 124 |
+
return None
|
| 125 |
+
while enum.hasMoreElements():
|
| 126 |
+
component = enum.nextElement()
|
| 127 |
+
if not component_has_sheets(component):
|
| 128 |
+
continue
|
| 129 |
+
if url_to_path(component_url(component)) == target_path:
|
| 130 |
+
return component
|
| 131 |
+
if target_name and target_name in component_title(component):
|
| 132 |
+
return component
|
| 133 |
+
return None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def libreoffice_command(path: Path) -> list[str]:
|
| 137 |
+
soffice = os.environ.get("SOFFICE") or shutil.which("soffice") or "soffice"
|
| 138 |
+
home = os.environ.get("HOME", "")
|
| 139 |
+
user_installation = f"-env:UserInstallation=file://{home}" if home else ""
|
| 140 |
+
command = [
|
| 141 |
+
soffice,
|
| 142 |
+
"--norestore",
|
| 143 |
+
"--nofirststartwizard",
|
| 144 |
+
"--nolockcheck",
|
| 145 |
+
"--accept=socket,host=localhost,port=2002;urp;StarOffice.ComponentContext",
|
| 146 |
+
"--calc",
|
| 147 |
+
str(path),
|
| 148 |
+
]
|
| 149 |
+
if user_installation:
|
| 150 |
+
command.insert(4, user_installation)
|
| 151 |
+
return command
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def launch_visible_calc(path: Path) -> None:
|
| 155 |
+
subprocess.Popen(
|
| 156 |
+
libreoffice_command(path),
|
| 157 |
+
stdout=subprocess.DEVNULL,
|
| 158 |
+
stderr=subprocess.DEVNULL,
|
| 159 |
+
start_new_session=True,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def wait_for_document(path: Path, timeout_seconds: float):
|
| 164 |
+
started = False
|
| 165 |
+
last_error: Exception | None = None
|
| 166 |
+
deadline = time.time() + timeout_seconds
|
| 167 |
+
while time.time() < deadline:
|
| 168 |
+
dismiss_document_in_use_dialogs()
|
| 169 |
+
try:
|
| 170 |
+
ctx = connect_once()
|
| 171 |
+
desktop = desktop_from_context(ctx)
|
| 172 |
+
document = find_open_document(desktop, path)
|
| 173 |
+
if document is not None:
|
| 174 |
+
return document
|
| 175 |
+
if not started:
|
| 176 |
+
launch_visible_calc(path)
|
| 177 |
+
started = True
|
| 178 |
+
except Exception as exc:
|
| 179 |
+
last_error = exc
|
| 180 |
+
if not started:
|
| 181 |
+
launch_visible_calc(path)
|
| 182 |
+
started = True
|
| 183 |
+
time.sleep(0.5)
|
| 184 |
+
detail = f": {last_error!r}" if last_error else ""
|
| 185 |
+
raise RuntimeError(f"Timed out waiting for LibreOffice Calc to open {path}{detail}")
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def focus_document_window(document) -> None:
|
| 189 |
+
try:
|
| 190 |
+
document.CurrentController.Frame.ContainerWindow.setFocus()
|
| 191 |
+
except Exception:
|
| 192 |
+
pass
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def sheet_path_for_name(zip_file: zipfile.ZipFile, sheet_name: str) -> str:
|
| 196 |
+
workbook = ET.fromstring(zip_file.read("xl/workbook.xml"))
|
| 197 |
+
rel_id = None
|
| 198 |
+
sheets = workbook.find("m:sheets", NS)
|
| 199 |
+
if sheets is None:
|
| 200 |
+
raise RuntimeError("Workbook has no sheets collection")
|
| 201 |
+
for sheet in sheets:
|
| 202 |
+
if sheet.attrib.get("name") == sheet_name:
|
| 203 |
+
rel_id = sheet.attrib.get(f"{{{OOXML_REL_NS}}}id")
|
| 204 |
+
break
|
| 205 |
+
if not rel_id:
|
| 206 |
+
raise RuntimeError(f"Sheet not found on disk: {sheet_name}")
|
| 207 |
+
rels = ET.fromstring(zip_file.read("xl/_rels/workbook.xml.rels"))
|
| 208 |
+
target = None
|
| 209 |
+
for rel in rels:
|
| 210 |
+
if rel.attrib.get("Id") == rel_id:
|
| 211 |
+
target = rel.attrib.get("Target")
|
| 212 |
+
break
|
| 213 |
+
if not target:
|
| 214 |
+
raise RuntimeError(f"Sheet relationship not found on disk: {sheet_name}")
|
| 215 |
+
target = target.lstrip("/")
|
| 216 |
+
if target.startswith("xl/"):
|
| 217 |
+
return target
|
| 218 |
+
return f"xl/{target}"
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def shared_strings(zip_file: zipfile.ZipFile) -> list[str]:
|
| 222 |
+
if "xl/sharedStrings.xml" not in zip_file.namelist():
|
| 223 |
+
return []
|
| 224 |
+
root = ET.fromstring(zip_file.read("xl/sharedStrings.xml"))
|
| 225 |
+
values: list[str] = []
|
| 226 |
+
for item in root.findall("m:si", NS):
|
| 227 |
+
parts = [
|
| 228 |
+
text_node.text or ""
|
| 229 |
+
for text_node in item.iter(f"{{{OOXML_MAIN_NS}}}t")
|
| 230 |
+
]
|
| 231 |
+
values.append("".join(parts))
|
| 232 |
+
return values
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def read_xlsx_cell(path: Path, sheet_name: str, cell_name: str) -> str | None:
|
| 236 |
+
with zipfile.ZipFile(path) as zip_file:
|
| 237 |
+
sheet_path = sheet_path_for_name(zip_file, sheet_name)
|
| 238 |
+
sheet = ET.fromstring(zip_file.read(sheet_path))
|
| 239 |
+
strings = shared_strings(zip_file)
|
| 240 |
+
for cell in sheet.iter(f"{{{OOXML_MAIN_NS}}}c"):
|
| 241 |
+
if cell.attrib.get("r") != cell_name:
|
| 242 |
+
continue
|
| 243 |
+
cell_type = cell.attrib.get("t")
|
| 244 |
+
if cell_type == "inlineStr":
|
| 245 |
+
return "".join(
|
| 246 |
+
text_node.text or ""
|
| 247 |
+
for text_node in cell.iter(f"{{{OOXML_MAIN_NS}}}t")
|
| 248 |
+
)
|
| 249 |
+
value_node = cell.find("m:v", NS)
|
| 250 |
+
raw_value = "" if value_node is None or value_node.text is None else value_node.text
|
| 251 |
+
if cell_type == "s" and raw_value != "":
|
| 252 |
+
return strings[int(raw_value)]
|
| 253 |
+
return raw_value
|
| 254 |
+
return None
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def wait_for_disk_value(path: Path, sheet: str, cell: str, expected: str) -> str | None:
|
| 258 |
+
latest = None
|
| 259 |
+
for _ in range(24):
|
| 260 |
+
latest = read_xlsx_cell(path, sheet, cell)
|
| 261 |
+
if latest == expected:
|
| 262 |
+
return latest
|
| 263 |
+
time.sleep(0.25)
|
| 264 |
+
return latest
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def is_disposed_error(exc: BaseException) -> bool:
|
| 268 |
+
return "DisposedException" in type(exc).__name__ or "DisposedException" in str(exc)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def edit_visible_calc_cell(path: Path, sheet_name: str, cell_name: str, value: str) -> tuple[str, str, str | None]:
|
| 272 |
+
last_error: Exception | None = None
|
| 273 |
+
for _ in range(4):
|
| 274 |
+
document = wait_for_document(
|
| 275 |
+
path,
|
| 276 |
+
timeout_seconds=float(os.environ.get("A0_CALC_OPEN_TIMEOUT", "30")),
|
| 277 |
+
)
|
| 278 |
+
try:
|
| 279 |
+
focus_document_window(document)
|
| 280 |
+
sheets = document.getSheets()
|
| 281 |
+
if not sheets.hasByName(sheet_name):
|
| 282 |
+
raise SystemExit(f"Sheet not found: {sheet_name}")
|
| 283 |
+
sheet = sheets.getByName(sheet_name)
|
| 284 |
+
target_cell = sheet.getCellRangeByName(cell_name)
|
| 285 |
+
before = target_cell.getString()
|
| 286 |
+
target_cell.setString(value)
|
| 287 |
+
document.store()
|
| 288 |
+
after = target_cell.getString()
|
| 289 |
+
disk_value = wait_for_disk_value(path, sheet_name, cell_name, value)
|
| 290 |
+
return before, after, disk_value
|
| 291 |
+
except Exception as exc:
|
| 292 |
+
if not is_disposed_error(exc):
|
| 293 |
+
raise
|
| 294 |
+
last_error = exc
|
| 295 |
+
time.sleep(1)
|
| 296 |
+
raise RuntimeError(f"LibreOffice UNO bridge stayed disposed while editing {path}: {last_error!r}")
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def main() -> int:
|
| 300 |
+
args = parse_args()
|
| 301 |
+
path = Path(args.file).expanduser().resolve()
|
| 302 |
+
if not path.exists():
|
| 303 |
+
raise SystemExit(f"Workbook not found: {path}")
|
| 304 |
+
|
| 305 |
+
before, after, disk_value = edit_visible_calc_cell(path, args.sheet, args.cell, args.value)
|
| 306 |
+
if after != args.value or disk_value != args.value:
|
| 307 |
+
raise SystemExit(
|
| 308 |
+
f"Calc edit verification failed: before={before!r} after={after!r} disk={disk_value!r}"
|
| 309 |
+
)
|
| 310 |
+
print(f"updated {path} {args.sheet}!{args.cell}: {before!r} -> {after!r}")
|
| 311 |
+
return 0
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
if __name__ == "__main__":
|
| 315 |
+
raise SystemExit(main())
|
plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
SESSION="${A0_DESKTOP_SESSION:-agent-zero-desktop}"
|
| 5 |
+
BASE_DIR="${A0_BASE_DIR:-/a0}"
|
| 6 |
+
PROFILE_DIR="${A0_DESKTOP_PROFILE:-$BASE_DIR/usr/plugins/_desktop/profiles/$SESSION}"
|
| 7 |
+
MANIFEST="${A0_DESKTOP_MANIFEST:-$BASE_DIR/usr/plugins/_desktop/sessions/$SESSION.json}"
|
| 8 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 9 |
+
DESKTOP_STATE_HELPER="$SCRIPT_DIR/../../../helpers/desktop_state.py"
|
| 10 |
+
DESKTOP_STATE_PYTHON="${A0_DESKTOP_STATE_PYTHON:-$(command -v /usr/bin/python3 || command -v python3 || true)}"
|
| 11 |
+
|
| 12 |
+
display_from_manifest() {
|
| 13 |
+
if [ ! -f "$MANIFEST" ] || ! command -v python3 >/dev/null 2>&1; then
|
| 14 |
+
return 0
|
| 15 |
+
fi
|
| 16 |
+
python3 - "$MANIFEST" <<'PY'
|
| 17 |
+
import json
|
| 18 |
+
import sys
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
with open(sys.argv[1], "r", encoding="utf-8") as handle:
|
| 22 |
+
value = json.load(handle).get("display", "")
|
| 23 |
+
except Exception:
|
| 24 |
+
value = ""
|
| 25 |
+
if value != "":
|
| 26 |
+
print(value)
|
| 27 |
+
PY
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
DISPLAY_VALUE="${A0_DESKTOP_DISPLAY:-$(display_from_manifest || true)}"
|
| 31 |
+
DISPLAY_VALUE="${DISPLAY_VALUE:-120}"
|
| 32 |
+
case "$DISPLAY_VALUE" in
|
| 33 |
+
:*) export DISPLAY="$DISPLAY_VALUE" ;;
|
| 34 |
+
*) export DISPLAY=":$DISPLAY_VALUE" ;;
|
| 35 |
+
esac
|
| 36 |
+
|
| 37 |
+
export XAUTHORITY="${A0_DESKTOP_XAUTHORITY:-$PROFILE_DIR/.Xauthority}"
|
| 38 |
+
export HOME="${A0_DESKTOP_HOME:-$PROFILE_DIR}"
|
| 39 |
+
export A0_DESKTOP_SESSION="$SESSION"
|
| 40 |
+
export A0_DESKTOP_MANIFEST="$MANIFEST"
|
| 41 |
+
export A0_DESKTOP_PROFILE="$PROFILE_DIR"
|
| 42 |
+
export A0_DESKTOP_DISPLAY="$DISPLAY"
|
| 43 |
+
export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
|
| 44 |
+
export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
| 45 |
+
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
|
| 46 |
+
export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"
|
| 47 |
+
|
| 48 |
+
command_name="${1:-help}"
|
| 49 |
+
shift || true
|
| 50 |
+
|
| 51 |
+
usage() {
|
| 52 |
+
cat <<'EOF'
|
| 53 |
+
Usage: desktopctl.sh <command> [args]
|
| 54 |
+
|
| 55 |
+
Commands:
|
| 56 |
+
env Print the X11 environment used for the Desktop.
|
| 57 |
+
check Verify that xdotool can reach the Desktop display.
|
| 58 |
+
state --json [--screenshot] [--context-id ID]
|
| 59 |
+
Return structured Desktop state as JSON.
|
| 60 |
+
observe --json [--screenshot] [--context-id ID]
|
| 61 |
+
Return structured state, optionally with a fresh screenshot.
|
| 62 |
+
screenshot [PATH] [--context-id ID]
|
| 63 |
+
Capture the Desktop to PATH, or to the chat screenshot directory when context-id is set.
|
| 64 |
+
active-window Print the active window name.
|
| 65 |
+
geometry PATTERN Print the first matching visible window geometry.
|
| 66 |
+
wait-window PATTERN Wait for a visible matching window and print its id.
|
| 67 |
+
location Print the current X pointer location.
|
| 68 |
+
windows [PATTERN] List visible window names matching PATTERN.
|
| 69 |
+
focus PATTERN Focus the first visible window matching PATTERN.
|
| 70 |
+
scroll DIRECTION [UNITS] Scroll up, down, left, or right; UNITS defaults to 5 clicks.
|
| 71 |
+
drag X1 Y1 X2 Y2 Drag from X1,Y1 to X2,Y2 in Desktop coordinates.
|
| 72 |
+
right-click X Y Move and right-click at X,Y in Desktop coordinates.
|
| 73 |
+
paste-text TEXT Put TEXT on the Desktop clipboard and paste it with an app-native shortcut.
|
| 74 |
+
sequence FILE|- Run a newline-delimited command sequence in this process.
|
| 75 |
+
batch FILE|- Alias for sequence.
|
| 76 |
+
key KEY... Send one or more xdotool key names.
|
| 77 |
+
type TEXT Type text into the focused window.
|
| 78 |
+
click X Y Move and click at X,Y in Desktop coordinates.
|
| 79 |
+
dblclick X Y Move and double-click at X,Y in Desktop coordinates.
|
| 80 |
+
launch APP Launch writer, calc, impress, terminal, settings, or workdir.
|
| 81 |
+
open-path [PATH] Open PATH in Thunar, defaulting to /a0/usr/workdir.
|
| 82 |
+
calc-set-cell FILE SHEET CELL VALUE
|
| 83 |
+
Open FILE in visible Calc, set SHEET!CELL, save, and verify.
|
| 84 |
+
save Send Ctrl+S to the focused app.
|
| 85 |
+
EOF
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
require_xdotool() {
|
| 89 |
+
if ! command -v xdotool >/dev/null 2>&1; then
|
| 90 |
+
echo "xdotool is required for Desktop control." >&2
|
| 91 |
+
exit 2
|
| 92 |
+
fi
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
ensure_display() {
|
| 96 |
+
require_xdotool
|
| 97 |
+
if ! xdotool getmouselocation >/dev/null 2>&1; then
|
| 98 |
+
echo "Desktop X display is not reachable. Open the Desktop surface first." >&2
|
| 99 |
+
exit 2
|
| 100 |
+
fi
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
desktop_state() {
|
| 104 |
+
if [ ! -f "$DESKTOP_STATE_HELPER" ]; then
|
| 105 |
+
echo "Desktop state helper not found: $DESKTOP_STATE_HELPER" >&2
|
| 106 |
+
exit 2
|
| 107 |
+
fi
|
| 108 |
+
"$DESKTOP_STATE_PYTHON" "$DESKTOP_STATE_HELPER" "$@"
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
run_detached() {
|
| 112 |
+
( "$@" >/tmp/a0-desktopctl.log 2>&1 & )
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
close_blocking_dialogs() {
|
| 116 |
+
require_xdotool
|
| 117 |
+
for title in "Document in Use"; do
|
| 118 |
+
window_ids="$(xdotool search --onlyvisible --name "$title" 2>/dev/null || true)"
|
| 119 |
+
printf '%s\n' "$window_ids" | while read -r window_id; do
|
| 120 |
+
[ -n "$window_id" ] && xdotool windowclose "$window_id" >/dev/null 2>&1 || true
|
| 121 |
+
done
|
| 122 |
+
done
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
first_window() {
|
| 126 |
+
pattern="$1"
|
| 127 |
+
xdotool search --onlyvisible --name "$pattern" 2>/dev/null | head -n 1 || true
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
active_window_id() {
|
| 131 |
+
xdotool getactivewindow 2>/dev/null || true
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
active_window_class() {
|
| 135 |
+
window_id="$(active_window_id)"
|
| 136 |
+
if [ -z "$window_id" ]; then
|
| 137 |
+
return 0
|
| 138 |
+
fi
|
| 139 |
+
if command -v xprop >/dev/null 2>&1; then
|
| 140 |
+
xprop -id "$window_id" WM_CLASS 2>/dev/null | awk -F'"' '/WM_CLASS/ { print $(NF - 1); exit }'
|
| 141 |
+
fi
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
active_window_class_lower() {
|
| 145 |
+
active_window_class | tr '[:upper:]' '[:lower:]'
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
active_window_is_terminal() {
|
| 149 |
+
window_class="$(active_window_class_lower)"
|
| 150 |
+
case "$window_class" in
|
| 151 |
+
*terminal*|xterm|uxterm|rxvt|urxvt|kitty|alacritty|wezterm|konsole)
|
| 152 |
+
return 0
|
| 153 |
+
;;
|
| 154 |
+
*)
|
| 155 |
+
return 1
|
| 156 |
+
;;
|
| 157 |
+
esac
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
paste_key_for_active_window() {
|
| 161 |
+
printf '%s\n' "${A0_DESKTOP_PASTE_KEY:-ctrl+v}"
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
window_geometry() {
|
| 165 |
+
window_id="$1"
|
| 166 |
+
if command -v xwininfo >/dev/null 2>&1; then
|
| 167 |
+
xwininfo -id "$window_id" 2>/dev/null | awk '
|
| 168 |
+
/Absolute upper-left X:/ { x=$4 }
|
| 169 |
+
/Absolute upper-left Y:/ { y=$4 }
|
| 170 |
+
/Width:/ { w=$2 }
|
| 171 |
+
/Height:/ { h=$2 }
|
| 172 |
+
END { if (w != "") printf "X=%s\nY=%s\nWIDTH=%s\nHEIGHT=%s\n", x, y, w, h }
|
| 173 |
+
'
|
| 174 |
+
else
|
| 175 |
+
xdotool getwindowgeometry --shell "$window_id"
|
| 176 |
+
fi
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
wait_window() {
|
| 180 |
+
pattern="$1"
|
| 181 |
+
timeout="${2:-15}"
|
| 182 |
+
end=$((SECONDS + timeout))
|
| 183 |
+
while [ "$SECONDS" -le "$end" ]; do
|
| 184 |
+
window_id="$(first_window "$pattern")"
|
| 185 |
+
if [ -n "$window_id" ]; then
|
| 186 |
+
printf '%s\n' "$window_id"
|
| 187 |
+
return 0
|
| 188 |
+
fi
|
| 189 |
+
sleep 0.25
|
| 190 |
+
done
|
| 191 |
+
echo "Timed out waiting for visible window: $pattern" >&2
|
| 192 |
+
return 1
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
scroll_desktop() {
|
| 196 |
+
direction="$1"
|
| 197 |
+
units="${2:-5}"
|
| 198 |
+
case "$direction" in
|
| 199 |
+
up) button=4 ;;
|
| 200 |
+
down) button=5 ;;
|
| 201 |
+
left) button=6 ;;
|
| 202 |
+
right) button=7 ;;
|
| 203 |
+
*)
|
| 204 |
+
echo "scroll direction must be up, down, left, or right." >&2
|
| 205 |
+
exit 2
|
| 206 |
+
;;
|
| 207 |
+
esac
|
| 208 |
+
xdotool click --repeat "$units" "$button"
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
paste_text() {
|
| 212 |
+
text="$*"
|
| 213 |
+
if active_window_is_terminal; then
|
| 214 |
+
xdotool type --delay "${A0_DESKTOP_PASTE_TYPE_DELAY_MS:-${A0_DESKTOP_TYPE_DELAY_MS:-4}}" -- "$text"
|
| 215 |
+
return
|
| 216 |
+
fi
|
| 217 |
+
if command -v xclip >/dev/null 2>&1; then
|
| 218 |
+
printf '%s' "$text" | xclip -selection clipboard
|
| 219 |
+
xdotool key --clearmodifiers "$(paste_key_for_active_window)"
|
| 220 |
+
return
|
| 221 |
+
fi
|
| 222 |
+
xdotool type --delay "${A0_DESKTOP_TYPE_DELAY_MS:-1}" -- "$text"
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
run_sequence_line() {
|
| 226 |
+
line="$1"
|
| 227 |
+
[ -z "$line" ] && return 0
|
| 228 |
+
case "$line" in
|
| 229 |
+
\#*) return 0 ;;
|
| 230 |
+
esac
|
| 231 |
+
# shellcheck disable=SC2086
|
| 232 |
+
dispatch_command $line
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
run_sequence() {
|
| 236 |
+
source_file="$1"
|
| 237 |
+
if [ "$source_file" = "-" ]; then
|
| 238 |
+
while IFS= read -r line; do
|
| 239 |
+
run_sequence_line "$line"
|
| 240 |
+
done
|
| 241 |
+
return
|
| 242 |
+
fi
|
| 243 |
+
if [ ! -f "$source_file" ]; then
|
| 244 |
+
echo "sequence requires an existing FILE or - for stdin." >&2
|
| 245 |
+
exit 2
|
| 246 |
+
fi
|
| 247 |
+
while IFS= read -r line || [ -n "$line" ]; do
|
| 248 |
+
run_sequence_line "$line"
|
| 249 |
+
done < "$source_file"
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
launch_app() {
|
| 253 |
+
app="${1:-}"
|
| 254 |
+
soffice="${SOFFICE:-$(command -v soffice || true)}"
|
| 255 |
+
soffice="${soffice:-soffice}"
|
| 256 |
+
case "$app" in
|
| 257 |
+
writer)
|
| 258 |
+
run_detached "$soffice" --norestore --nofirststartwizard --nolockcheck "-env:UserInstallation=file://$HOME" --writer
|
| 259 |
+
;;
|
| 260 |
+
calc|spreadsheet)
|
| 261 |
+
run_detached "$soffice" --norestore --nofirststartwizard --nolockcheck "-env:UserInstallation=file://$HOME" --calc
|
| 262 |
+
;;
|
| 263 |
+
impress|presentation)
|
| 264 |
+
run_detached "$soffice" --norestore --nofirststartwizard --nolockcheck "-env:UserInstallation=file://$HOME" --impress
|
| 265 |
+
;;
|
| 266 |
+
terminal)
|
| 267 |
+
run_detached xfce4-terminal --working-directory=/a0/usr/workdir
|
| 268 |
+
;;
|
| 269 |
+
settings)
|
| 270 |
+
run_detached xfce4-settings-manager
|
| 271 |
+
;;
|
| 272 |
+
workdir|files|file-manager)
|
| 273 |
+
run_detached thunar /a0/usr/workdir
|
| 274 |
+
;;
|
| 275 |
+
*)
|
| 276 |
+
echo "Unknown app: ${app:-<empty>}" >&2
|
| 277 |
+
echo "Expected: writer, calc, impress, terminal, settings, or workdir." >&2
|
| 278 |
+
exit 2
|
| 279 |
+
;;
|
| 280 |
+
esac
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
dispatch_command() {
|
| 284 |
+
local command_name="${1:-help}"
|
| 285 |
+
shift || true
|
| 286 |
+
|
| 287 |
+
case "$command_name" in
|
| 288 |
+
help|-h|--help)
|
| 289 |
+
usage
|
| 290 |
+
;;
|
| 291 |
+
env)
|
| 292 |
+
printf 'export DISPLAY=%q\n' "$DISPLAY"
|
| 293 |
+
printf 'export XAUTHORITY=%q\n' "$XAUTHORITY"
|
| 294 |
+
printf 'export HOME=%q\n' "$HOME"
|
| 295 |
+
;;
|
| 296 |
+
check)
|
| 297 |
+
ensure_display
|
| 298 |
+
xdotool getmouselocation --shell
|
| 299 |
+
;;
|
| 300 |
+
state)
|
| 301 |
+
if [ "${1:-}" != "--json" ]; then
|
| 302 |
+
echo "state currently requires --json." >&2
|
| 303 |
+
exit 2
|
| 304 |
+
fi
|
| 305 |
+
shift
|
| 306 |
+
desktop_state state --json "$@"
|
| 307 |
+
;;
|
| 308 |
+
observe)
|
| 309 |
+
if [ "${1:-}" != "--json" ]; then
|
| 310 |
+
echo "observe currently requires --json." >&2
|
| 311 |
+
exit 2
|
| 312 |
+
fi
|
| 313 |
+
shift
|
| 314 |
+
desktop_state observe --json "$@"
|
| 315 |
+
;;
|
| 316 |
+
screenshot)
|
| 317 |
+
if [ "${1:-}" = "--json" ]; then
|
| 318 |
+
shift
|
| 319 |
+
desktop_state screenshot --json "$@"
|
| 320 |
+
elif [ "$#" -gt 0 ]; then
|
| 321 |
+
desktop_state screenshot "$@"
|
| 322 |
+
else
|
| 323 |
+
desktop_state screenshot
|
| 324 |
+
fi
|
| 325 |
+
;;
|
| 326 |
+
active-window)
|
| 327 |
+
ensure_display
|
| 328 |
+
window_id="$(active_window_id)"
|
| 329 |
+
if [ -z "$window_id" ]; then
|
| 330 |
+
echo "No active window." >&2
|
| 331 |
+
exit 1
|
| 332 |
+
fi
|
| 333 |
+
xdotool getwindowname "$window_id"
|
| 334 |
+
;;
|
| 335 |
+
geometry)
|
| 336 |
+
ensure_display
|
| 337 |
+
pattern="${1:?geometry requires a window name pattern}"
|
| 338 |
+
window_id="$(first_window "$pattern")"
|
| 339 |
+
if [ -z "$window_id" ]; then
|
| 340 |
+
echo "No visible window matched: $pattern" >&2
|
| 341 |
+
exit 1
|
| 342 |
+
fi
|
| 343 |
+
window_geometry "$window_id"
|
| 344 |
+
;;
|
| 345 |
+
wait-window)
|
| 346 |
+
ensure_display
|
| 347 |
+
pattern="${1:?wait-window requires a window name pattern}"
|
| 348 |
+
wait_window "$pattern" "${2:-15}"
|
| 349 |
+
;;
|
| 350 |
+
location)
|
| 351 |
+
ensure_display
|
| 352 |
+
xdotool getmouselocation --shell
|
| 353 |
+
;;
|
| 354 |
+
windows)
|
| 355 |
+
ensure_display
|
| 356 |
+
pattern="${1:-.}"
|
| 357 |
+
xdotool search --onlyvisible --name "$pattern" getwindowname %@ 2>/dev/null || true
|
| 358 |
+
;;
|
| 359 |
+
focus)
|
| 360 |
+
ensure_display
|
| 361 |
+
pattern="${1:?focus requires a window name pattern}"
|
| 362 |
+
window_id="$(first_window "$pattern")"
|
| 363 |
+
if [ -z "$window_id" ]; then
|
| 364 |
+
echo "No visible window matched: $pattern" >&2
|
| 365 |
+
exit 1
|
| 366 |
+
fi
|
| 367 |
+
xdotool windowactivate --sync "$window_id"
|
| 368 |
+
;;
|
| 369 |
+
scroll)
|
| 370 |
+
ensure_display
|
| 371 |
+
scroll_desktop "${1:?scroll requires DIRECTION}" "${2:-5}"
|
| 372 |
+
;;
|
| 373 |
+
drag)
|
| 374 |
+
ensure_display
|
| 375 |
+
x1="${1:?drag requires X1}"
|
| 376 |
+
y1="${2:?drag requires Y1}"
|
| 377 |
+
x2="${3:?drag requires X2}"
|
| 378 |
+
y2="${4:?drag requires Y2}"
|
| 379 |
+
xdotool mousemove --sync "$x1" "$y1" mousedown 1 mousemove --sync "$x2" "$y2" mouseup 1
|
| 380 |
+
;;
|
| 381 |
+
right-click)
|
| 382 |
+
ensure_display
|
| 383 |
+
x="${1:?right-click requires X}"
|
| 384 |
+
y="${2:?right-click requires Y}"
|
| 385 |
+
xdotool mousemove --sync "$x" "$y" click 3
|
| 386 |
+
;;
|
| 387 |
+
paste-text)
|
| 388 |
+
ensure_display
|
| 389 |
+
if [ "$#" -eq 0 ]; then
|
| 390 |
+
echo "paste-text requires TEXT." >&2
|
| 391 |
+
exit 2
|
| 392 |
+
fi
|
| 393 |
+
paste_text "$@"
|
| 394 |
+
;;
|
| 395 |
+
sequence|batch)
|
| 396 |
+
source_file="${1:?sequence requires FILE or -}"
|
| 397 |
+
run_sequence "$source_file"
|
| 398 |
+
;;
|
| 399 |
+
key)
|
| 400 |
+
ensure_display
|
| 401 |
+
if [ "$#" -eq 0 ]; then
|
| 402 |
+
echo "key requires at least one xdotool key name." >&2
|
| 403 |
+
exit 2
|
| 404 |
+
fi
|
| 405 |
+
xdotool key --clearmodifiers "$@"
|
| 406 |
+
;;
|
| 407 |
+
type)
|
| 408 |
+
ensure_display
|
| 409 |
+
text="$*"
|
| 410 |
+
xdotool type --delay "${A0_DESKTOP_TYPE_DELAY_MS:-1}" -- "$text"
|
| 411 |
+
;;
|
| 412 |
+
click)
|
| 413 |
+
ensure_display
|
| 414 |
+
x="${1:?click requires X}"
|
| 415 |
+
y="${2:?click requires Y}"
|
| 416 |
+
xdotool mousemove --sync "$x" "$y" click 1
|
| 417 |
+
;;
|
| 418 |
+
dblclick)
|
| 419 |
+
ensure_display
|
| 420 |
+
x="${1:?dblclick requires X}"
|
| 421 |
+
y="${2:?dblclick requires Y}"
|
| 422 |
+
xdotool mousemove --sync "$x" "$y" click --repeat 2 --delay "${A0_DESKTOP_DBLCLICK_DELAY_MS:-150}" 1
|
| 423 |
+
;;
|
| 424 |
+
launch)
|
| 425 |
+
ensure_display
|
| 426 |
+
launch_app "${1:-}"
|
| 427 |
+
;;
|
| 428 |
+
open-path)
|
| 429 |
+
ensure_display
|
| 430 |
+
path="${1:-/a0/usr/workdir}"
|
| 431 |
+
run_detached thunar "$path"
|
| 432 |
+
;;
|
| 433 |
+
calc-set-cell)
|
| 434 |
+
ensure_display
|
| 435 |
+
file="${1:?calc-set-cell requires FILE}"
|
| 436 |
+
sheet="${2:?calc-set-cell requires SHEET}"
|
| 437 |
+
cell="${3:?calc-set-cell requires CELL}"
|
| 438 |
+
shift 3
|
| 439 |
+
if [ "$#" -eq 0 ]; then
|
| 440 |
+
echo "calc-set-cell requires VALUE." >&2
|
| 441 |
+
exit 2
|
| 442 |
+
fi
|
| 443 |
+
close_blocking_dialogs
|
| 444 |
+
export PYTHONPATH="${PYTHONPATH:-/usr/lib/python3/dist-packages:/usr/lib/libreoffice/program:}"
|
| 445 |
+
python3 "$SCRIPT_DIR/calc_set_cell.py" "$file" "$sheet" "$cell" "$@"
|
| 446 |
+
;;
|
| 447 |
+
save)
|
| 448 |
+
ensure_display
|
| 449 |
+
xdotool key --clearmodifiers ctrl+s
|
| 450 |
+
;;
|
| 451 |
+
*)
|
| 452 |
+
echo "Unknown command: $command_name" >&2
|
| 453 |
+
usage >&2
|
| 454 |
+
exit 2
|
| 455 |
+
;;
|
| 456 |
+
esac
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
dispatch_command "$command_name" "$@"
|
plugins/_desktop/webui/desktop-panel.html
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<html>
|
| 2 |
+
<head>
|
| 3 |
+
<script type="module">
|
| 4 |
+
import { store } from "/plugins/_desktop/webui/desktop-store.js";
|
| 5 |
+
</script>
|
| 6 |
+
</head>
|
| 7 |
+
<body>
|
| 8 |
+
<div x-data>
|
| 9 |
+
<template x-if="$store.desktop">
|
| 10 |
+
<div class="office-panel" x-create="$store.desktop.onMount($el, xAttrs($el) || {})" x-destroy="$store.desktop.cleanup()">
|
| 11 |
+
<div class="office-shell">
|
| 12 |
+
<div class="office-document-header" x-show="$store.desktop.hasActiveFile()" style="display: none;">
|
| 13 |
+
<div class="office-document-title" :title="$store.desktop.tabLabel($store.desktop.session)">
|
| 14 |
+
<x-icon class="office-document-icon" aria-hidden="true" :name="$store.desktop.tabIcon($store.desktop.session)"></x-icon>
|
| 15 |
+
<span class="office-document-name" x-text="$store.desktop.tabTitle($store.desktop.session)"></span>
|
| 16 |
+
<span class="office-document-dirty" x-show="$store.desktop.dirty" aria-hidden="true">*</span>
|
| 17 |
+
</div>
|
| 18 |
+
|
| 19 |
+
<button
|
| 20 |
+
type="button"
|
| 21 |
+
class="office-icon-button office-document-save-button"
|
| 22 |
+
title="Save"
|
| 23 |
+
aria-label="Save"
|
| 24 |
+
:class="{ 'is-primary': $store.desktop.dirty }"
|
| 25 |
+
:disabled="$store.desktop.saving"
|
| 26 |
+
@click="$store.desktop.save()"
|
| 27 |
+
>
|
| 28 |
+
<x-icon :class="{ spinning: $store.desktop.saving }" :name="$store.desktop.saving ? 'progress_activity' : 'save'"></x-icon>
|
| 29 |
+
</button>
|
| 30 |
+
|
| 31 |
+
<div class="office-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
|
| 32 |
+
<button
|
| 33 |
+
type="button"
|
| 34 |
+
class="office-icon-button office-file-menu-button"
|
| 35 |
+
title="File actions"
|
| 36 |
+
aria-label="File actions"
|
| 37 |
+
aria-haspopup="menu"
|
| 38 |
+
:aria-expanded="open.toString()"
|
| 39 |
+
:disabled="$store.desktop.saving"
|
| 40 |
+
@click.stop="open = !open"
|
| 41 |
+
>
|
| 42 |
+
<x-icon name="more_vert"></x-icon>
|
| 43 |
+
</button>
|
| 44 |
+
<div class="office-new-menu office-file-menu" role="menu" x-show="open" @click.stop>
|
| 45 |
+
<button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.desktop.saving" @click="open = false; $store.desktop.renameActiveFile()">
|
| 46 |
+
<x-icon aria-hidden="true" name="edit"></x-icon>
|
| 47 |
+
<span>Rename</span>
|
| 48 |
+
</button>
|
| 49 |
+
<button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.desktop.loading" @click="open = false; $store.desktop.closeActiveFile()">
|
| 50 |
+
<x-icon aria-hidden="true" name="close"></x-icon>
|
| 51 |
+
<span>Close File</span>
|
| 52 |
+
</button>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div class="office-state-line" x-show="$store.desktop.message || $store.desktop.error || $store.desktop.loading" style="display: none;">
|
| 58 |
+
<x-icon :class="{ spinning: $store.desktop.loading }" :name="$store.desktop.loading ? 'progress_activity' : ($store.desktop.error ? 'error' : 'check_circle')"></x-icon>
|
| 59 |
+
<span x-text="$store.desktop.error || $store.desktop.message || 'Working'"></span>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div class="office-body">
|
| 63 |
+
<div class="office-editor-wrap" x-show="$store.desktop.session" style="display: none;">
|
| 64 |
+
<div class="office-editor-scroll" :class="{ 'is-desktop': $store.desktop.hasOfficialOffice() }" @click.self="$store.desktop.focusEditor()">
|
| 65 |
+
<template x-if="$store.desktop.hasOfficialOffice()">
|
| 66 |
+
<div
|
| 67 |
+
class="office-desktop-wrap"
|
| 68 |
+
data-office-desktop-host
|
| 69 |
+
x-init="$nextTick(() => $store.desktop.mountDesktopFrameHost($el))"
|
| 70 |
+
>
|
| 71 |
+
</div>
|
| 72 |
+
</template>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<div class="office-desktop-empty" x-show="$store.desktop.shouldShowDesktopEmptyState()" style="display: none;">
|
| 77 |
+
<x-icon aria-hidden="true" name="power_settings_new"></x-icon>
|
| 78 |
+
<span class="office-desktop-empty-title">Desktop is shut down</span>
|
| 79 |
+
<button type="button" class="office-icon-button office-command-button" @click="$store.desktop.restartDesktopSession()">
|
| 80 |
+
<x-icon aria-hidden="true" name="restart_alt"></x-icon>
|
| 81 |
+
<span class="office-button-label">Restart Desktop</span>
|
| 82 |
+
</button>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
</template>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<style>
|
| 91 |
+
.office-panel,
|
| 92 |
+
.office-shell {
|
| 93 |
+
display: flex;
|
| 94 |
+
flex: 1 1 auto;
|
| 95 |
+
flex-direction: column;
|
| 96 |
+
width: 100%;
|
| 97 |
+
height: 100%;
|
| 98 |
+
min-width: 0;
|
| 99 |
+
min-height: 0;
|
| 100 |
+
background: var(--color-background);
|
| 101 |
+
color: var(--color-text);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.office-panel {
|
| 105 |
+
container-type: inline-size;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.modal-inner.office-modal {
|
| 109 |
+
box-sizing: border-box;
|
| 110 |
+
width: min(1120px, calc(100vw - 32px));
|
| 111 |
+
height: min(820px, calc(100vh - 32px));
|
| 112 |
+
min-width: min(720px, calc(100vw - 16px));
|
| 113 |
+
min-height: min(520px, calc(100vh - 16px));
|
| 114 |
+
max-width: none;
|
| 115 |
+
max-height: none;
|
| 116 |
+
resize: none;
|
| 117 |
+
overflow: hidden;
|
| 118 |
+
will-change: width, height, left, top;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.modal-inner.office-modal.is-resizing,
|
| 122 |
+
.modal-inner.office-modal.is-dragging {
|
| 123 |
+
user-select: none;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.modal-inner.office-modal.is-focus-mode {
|
| 127 |
+
border-radius: 6px;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.modal-inner.office-modal .modal-scroll {
|
| 131 |
+
display: flex;
|
| 132 |
+
flex: 1 1 auto;
|
| 133 |
+
min-height: 0;
|
| 134 |
+
max-height: none;
|
| 135 |
+
overflow: hidden;
|
| 136 |
+
padding: 0;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.modal-inner.office-modal .modal-header {
|
| 140 |
+
grid-template-columns: minmax(0, 1fr) auto auto;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.office-modal-input-shield {
|
| 144 |
+
position: absolute;
|
| 145 |
+
inset: 42px 0 0 0;
|
| 146 |
+
z-index: 4;
|
| 147 |
+
display: none;
|
| 148 |
+
background: transparent;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.office-modal-resizer {
|
| 152 |
+
position: absolute;
|
| 153 |
+
z-index: 5;
|
| 154 |
+
display: block;
|
| 155 |
+
touch-action: none;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.office-modal-resizer.is-right {
|
| 159 |
+
top: 42px;
|
| 160 |
+
right: -4px;
|
| 161 |
+
bottom: 12px;
|
| 162 |
+
width: 10px;
|
| 163 |
+
cursor: ew-resize;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.office-modal-resizer.is-bottom {
|
| 167 |
+
right: 12px;
|
| 168 |
+
bottom: -4px;
|
| 169 |
+
left: 0;
|
| 170 |
+
height: 10px;
|
| 171 |
+
cursor: ns-resize;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.office-modal-resizer.is-corner {
|
| 175 |
+
right: 0;
|
| 176 |
+
bottom: 0;
|
| 177 |
+
width: 22px;
|
| 178 |
+
height: 22px;
|
| 179 |
+
cursor: nwse-resize;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.office-modal-resizer.is-corner::after {
|
| 183 |
+
content: "";
|
| 184 |
+
position: absolute;
|
| 185 |
+
right: 6px;
|
| 186 |
+
bottom: 6px;
|
| 187 |
+
width: 9px;
|
| 188 |
+
height: 9px;
|
| 189 |
+
border-right: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
|
| 190 |
+
border-bottom: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
|
| 191 |
+
border-radius: 1px;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.modal-inner.office-modal.is-focus-mode .office-modal-resizer {
|
| 195 |
+
display: none;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.modal-inner.office-modal .modal-bd.office-modal-body,
|
| 199 |
+
.modal-inner.office-modal .modal-bd.office-modal-body > x-component,
|
| 200 |
+
.modal-inner.office-modal .modal-bd.office-modal-body > x-component > div[x-data],
|
| 201 |
+
.modal-inner.office-modal .modal-bd.office-modal-body > x-component > .office-panel,
|
| 202 |
+
.modal-inner.office-modal .modal-bd.office-modal-body > x-component > div[x-data] > .office-panel {
|
| 203 |
+
display: flex;
|
| 204 |
+
flex: 1 1 auto;
|
| 205 |
+
min-height: 0;
|
| 206 |
+
min-width: 0;
|
| 207 |
+
width: 100%;
|
| 208 |
+
height: 100%;
|
| 209 |
+
padding: 0;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.office-toolbar {
|
| 213 |
+
display: flex;
|
| 214 |
+
flex-direction: row;
|
| 215 |
+
align-items: stretch;
|
| 216 |
+
flex-wrap: nowrap;
|
| 217 |
+
min-height: 0;
|
| 218 |
+
padding: 6px 10px;
|
| 219 |
+
overflow: hidden;
|
| 220 |
+
border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 20%);
|
| 221 |
+
background: color-mix(in srgb, var(--color-background), var(--color-panel) 48%);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.office-toolbar-row {
|
| 225 |
+
display: flex;
|
| 226 |
+
align-items: center;
|
| 227 |
+
flex: 0 0 auto;
|
| 228 |
+
flex-wrap: nowrap;
|
| 229 |
+
gap: 6px;
|
| 230 |
+
width: 100%;
|
| 231 |
+
min-width: 0;
|
| 232 |
+
min-height: 32px;
|
| 233 |
+
overflow-x: auto;
|
| 234 |
+
overflow-y: hidden;
|
| 235 |
+
scrollbar-width: thin;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.office-tool-group {
|
| 239 |
+
display: flex;
|
| 240 |
+
align-items: center;
|
| 241 |
+
flex-wrap: nowrap;
|
| 242 |
+
flex: 0 0 auto;
|
| 243 |
+
gap: 4px;
|
| 244 |
+
min-width: 0;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.office-editor-tools {
|
| 248 |
+
gap: 3px;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.office-tool-actions {
|
| 252 |
+
justify-content: flex-end;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.office-toolbar-spacer {
|
| 256 |
+
flex: 1 1 16px;
|
| 257 |
+
min-width: 8px;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.office-toolbar-divider {
|
| 261 |
+
flex: 0 0 auto;
|
| 262 |
+
width: 1px;
|
| 263 |
+
height: 22px;
|
| 264 |
+
margin-inline: 2px;
|
| 265 |
+
background: color-mix(in srgb, var(--color-border), transparent 18%);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.office-document-header {
|
| 269 |
+
display: flex;
|
| 270 |
+
align-items: center;
|
| 271 |
+
gap: 10px;
|
| 272 |
+
min-height: 38px;
|
| 273 |
+
padding: 5px 10px 5px 12px;
|
| 274 |
+
border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
|
| 275 |
+
background: color-mix(in srgb, var(--color-panel), var(--color-background) 30%);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.office-document-title {
|
| 279 |
+
display: flex;
|
| 280 |
+
align-items: center;
|
| 281 |
+
gap: 7px;
|
| 282 |
+
min-width: 0;
|
| 283 |
+
flex: 1 1 auto;
|
| 284 |
+
color: var(--color-text);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
.office-document-icon {
|
| 288 |
+
flex: 0 0 auto;
|
| 289 |
+
font-size: 19px;
|
| 290 |
+
line-height: 1;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.office-document-name {
|
| 294 |
+
min-width: 0;
|
| 295 |
+
overflow: hidden;
|
| 296 |
+
text-overflow: ellipsis;
|
| 297 |
+
white-space: nowrap;
|
| 298 |
+
font-size: 13px;
|
| 299 |
+
font-weight: 750;
|
| 300 |
+
letter-spacing: 0;
|
| 301 |
+
line-height: 1.2;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.office-document-dirty {
|
| 305 |
+
flex: 0 0 auto;
|
| 306 |
+
color: #2ca58d;
|
| 307 |
+
font-size: 14px;
|
| 308 |
+
font-weight: 800;
|
| 309 |
+
line-height: 1;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
.office-file-actions {
|
| 313 |
+
position: relative;
|
| 314 |
+
display: inline-flex;
|
| 315 |
+
align-items: center;
|
| 316 |
+
flex: 0 0 auto;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.office-document-save-button,
|
| 320 |
+
.office-file-menu-button {
|
| 321 |
+
width: 30px;
|
| 322 |
+
height: 30px;
|
| 323 |
+
min-width: 30px;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.office-header-actions {
|
| 327 |
+
position: relative;
|
| 328 |
+
display: inline-flex;
|
| 329 |
+
align-items: center;
|
| 330 |
+
flex: 0 0 auto;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
.office-header-new-button {
|
| 334 |
+
appearance: none;
|
| 335 |
+
display: inline-flex;
|
| 336 |
+
align-items: center;
|
| 337 |
+
justify-content: center;
|
| 338 |
+
gap: 4px;
|
| 339 |
+
height: 34px;
|
| 340 |
+
min-height: 34px;
|
| 341 |
+
padding: 0 9px 0 8px;
|
| 342 |
+
border: 1px solid transparent;
|
| 343 |
+
border-radius: 7px;
|
| 344 |
+
background: transparent;
|
| 345 |
+
color: var(--color-text);
|
| 346 |
+
cursor: pointer;
|
| 347 |
+
font: inherit;
|
| 348 |
+
font-size: 12px;
|
| 349 |
+
font-weight: 750;
|
| 350 |
+
letter-spacing: 0;
|
| 351 |
+
line-height: 1;
|
| 352 |
+
opacity: 0.82;
|
| 353 |
+
white-space: nowrap;
|
| 354 |
+
transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
.office-header-new-button:hover,
|
| 358 |
+
.office-header-actions.is-open .office-header-new-button {
|
| 359 |
+
opacity: 1;
|
| 360 |
+
border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
|
| 361 |
+
background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.office-header-new-button .material-symbols-outlined {
|
| 365 |
+
font-size: 18px;
|
| 366 |
+
line-height: 1;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.office-header-new-button .office-new-chevron {
|
| 370 |
+
margin-left: -2px;
|
| 371 |
+
font-size: 16px;
|
| 372 |
+
opacity: 0.8;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.office-new-menu {
|
| 376 |
+
position: absolute;
|
| 377 |
+
top: calc(100% + 6px);
|
| 378 |
+
right: 0;
|
| 379 |
+
z-index: 100;
|
| 380 |
+
min-width: 184px;
|
| 381 |
+
padding: 5px;
|
| 382 |
+
border: 1px solid color-mix(in srgb, var(--color-border), transparent 10%);
|
| 383 |
+
border-radius: 8px;
|
| 384 |
+
background: color-mix(in srgb, var(--color-panel), var(--color-background) 10%);
|
| 385 |
+
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.34);
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
.right-canvas-desktop-actions .office-new-menu {
|
| 389 |
+
z-index: 4000;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
.office-new-menu[hidden] {
|
| 393 |
+
display: none;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
.office-new-menu-item {
|
| 397 |
+
appearance: none;
|
| 398 |
+
display: flex;
|
| 399 |
+
align-items: center;
|
| 400 |
+
gap: 8px;
|
| 401 |
+
width: 100%;
|
| 402 |
+
height: 32px;
|
| 403 |
+
padding: 0 8px;
|
| 404 |
+
border: 1px solid transparent;
|
| 405 |
+
border-radius: 6px;
|
| 406 |
+
background: transparent;
|
| 407 |
+
color: var(--color-text);
|
| 408 |
+
cursor: pointer;
|
| 409 |
+
font: inherit;
|
| 410 |
+
font-size: 12px;
|
| 411 |
+
font-weight: 650;
|
| 412 |
+
letter-spacing: 0;
|
| 413 |
+
line-height: 1;
|
| 414 |
+
text-align: left;
|
| 415 |
+
white-space: nowrap;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.office-new-menu-item:hover {
|
| 419 |
+
border-color: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
| 420 |
+
background: color-mix(in srgb, var(--color-background-hover) 76%, transparent);
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
.office-new-menu-item:disabled {
|
| 424 |
+
cursor: default;
|
| 425 |
+
opacity: 0.46;
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
.office-new-menu-item .material-symbols-outlined {
|
| 429 |
+
flex: 0 0 auto;
|
| 430 |
+
width: 18px;
|
| 431 |
+
font-size: 18px;
|
| 432 |
+
line-height: 1;
|
| 433 |
+
text-align: center;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
.office-icon-button,
|
| 437 |
+
.office-tab,
|
| 438 |
+
.office-tab-close {
|
| 439 |
+
border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
|
| 440 |
+
border-radius: 8px;
|
| 441 |
+
background: color-mix(in srgb, var(--color-panel), var(--color-background) 16%);
|
| 442 |
+
color: inherit;
|
| 443 |
+
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.office-icon-button {
|
| 447 |
+
display: inline-grid;
|
| 448 |
+
place-items: center;
|
| 449 |
+
width: 32px;
|
| 450 |
+
height: 32px;
|
| 451 |
+
min-width: 32px;
|
| 452 |
+
padding: 0;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.office-command-button {
|
| 456 |
+
display: inline-flex;
|
| 457 |
+
align-items: center;
|
| 458 |
+
justify-content: center;
|
| 459 |
+
gap: 5px;
|
| 460 |
+
width: auto;
|
| 461 |
+
max-width: 126px;
|
| 462 |
+
padding: 0 8px;
|
| 463 |
+
white-space: nowrap;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.office-command-button .office-button-label {
|
| 467 |
+
min-width: 0;
|
| 468 |
+
overflow: hidden;
|
| 469 |
+
text-overflow: ellipsis;
|
| 470 |
+
font-size: 11px;
|
| 471 |
+
font-weight: 700;
|
| 472 |
+
line-height: 1;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
.office-icon-button.is-primary {
|
| 476 |
+
border-color: color-mix(in srgb, #2c7be5, var(--color-border) 20%);
|
| 477 |
+
background: color-mix(in srgb, #2c7be5, var(--color-panel) 82%);
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
.office-icon-button.is-active {
|
| 481 |
+
border-color: color-mix(in srgb, #2ca58d, var(--color-border) 24%);
|
| 482 |
+
background: color-mix(in srgb, #2ca58d, var(--color-panel) 84%);
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
.office-icon-button:hover:not(:disabled),
|
| 486 |
+
.office-tab:hover,
|
| 487 |
+
.office-tab-close:hover {
|
| 488 |
+
border-color: color-mix(in srgb, #2c7be5, var(--color-border) 45%);
|
| 489 |
+
background: color-mix(in srgb, var(--color-panel), #2c7be5 8%);
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
.office-icon-button:disabled {
|
| 493 |
+
cursor: default;
|
| 494 |
+
opacity: 0.42;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.office-icon-button .material-symbols-outlined,
|
| 498 |
+
.office-tab-icon {
|
| 499 |
+
font-size: 19px;
|
| 500 |
+
line-height: 1;
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
.office-tabs {
|
| 504 |
+
display: flex;
|
| 505 |
+
gap: 6px;
|
| 506 |
+
min-height: 42px;
|
| 507 |
+
padding: 7px 10px;
|
| 508 |
+
overflow-x: auto;
|
| 509 |
+
border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
|
| 510 |
+
background: color-mix(in srgb, var(--color-panel), var(--color-background) 28%);
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
.office-tab-shell {
|
| 514 |
+
display: grid;
|
| 515 |
+
grid-template-columns: minmax(0, 1fr) 28px;
|
| 516 |
+
align-items: center;
|
| 517 |
+
min-width: 150px;
|
| 518 |
+
max-width: 240px;
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
.office-tab-shell.is-system {
|
| 522 |
+
grid-template-columns: minmax(0, 1fr);
|
| 523 |
+
min-width: 172px;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
.office-tab,
|
| 527 |
+
.office-tab-close {
|
| 528 |
+
height: 28px;
|
| 529 |
+
min-height: 28px;
|
| 530 |
+
border-radius: 7px;
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
.office-tab {
|
| 534 |
+
display: flex;
|
| 535 |
+
align-items: center;
|
| 536 |
+
gap: 6px;
|
| 537 |
+
min-width: 0;
|
| 538 |
+
border-top-right-radius: 0;
|
| 539 |
+
border-bottom-right-radius: 0;
|
| 540 |
+
padding: 0 8px;
|
| 541 |
+
text-align: left;
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
.office-tab-shell.is-system .office-tab {
|
| 545 |
+
border-radius: 7px;
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
.office-tab-close {
|
| 549 |
+
display: grid;
|
| 550 |
+
place-items: center;
|
| 551 |
+
border-left: 0;
|
| 552 |
+
border-top-left-radius: 0;
|
| 553 |
+
border-bottom-left-radius: 0;
|
| 554 |
+
padding: 0;
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
.office-tab-close .material-symbols-outlined {
|
| 558 |
+
font-size: 17px;
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
.office-tab-shell.is-active .office-tab,
|
| 562 |
+
.office-tab-shell.is-active .office-tab-close {
|
| 563 |
+
border-color: color-mix(in srgb, #2c7be5, var(--color-border) 36%);
|
| 564 |
+
background: color-mix(in srgb, #2c7be5, var(--color-panel) 88%);
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
.office-tab-shell.is-dirty .office-tab-title::after {
|
| 568 |
+
content: " *";
|
| 569 |
+
color: #2ca58d;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.office-tab-title {
|
| 573 |
+
min-width: 0;
|
| 574 |
+
overflow: hidden;
|
| 575 |
+
text-overflow: ellipsis;
|
| 576 |
+
white-space: nowrap;
|
| 577 |
+
font-size: 12px;
|
| 578 |
+
line-height: 1;
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
.office-state-line {
|
| 582 |
+
display: flex;
|
| 583 |
+
align-items: center;
|
| 584 |
+
flex: 0 0 auto;
|
| 585 |
+
gap: 8px;
|
| 586 |
+
width: 100%;
|
| 587 |
+
min-width: 0;
|
| 588 |
+
min-height: 34px;
|
| 589 |
+
box-sizing: border-box;
|
| 590 |
+
padding: 6px 12px;
|
| 591 |
+
border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 28%);
|
| 592 |
+
color: var(--color-text-secondary);
|
| 593 |
+
font-size: 12px;
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
.office-state-line > .material-symbols-outlined {
|
| 597 |
+
flex: 0 0 auto;
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
.office-state-line > span:not(.material-symbols-outlined) {
|
| 601 |
+
min-width: 0;
|
| 602 |
+
overflow: hidden;
|
| 603 |
+
text-overflow: ellipsis;
|
| 604 |
+
white-space: nowrap;
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
.office-body {
|
| 608 |
+
position: relative;
|
| 609 |
+
display: flex;
|
| 610 |
+
flex: 1 1 auto;
|
| 611 |
+
min-height: 0;
|
| 612 |
+
overflow: hidden;
|
| 613 |
+
background: var(--color-background);
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
.office-body.is-source {
|
| 617 |
+
background: transparent;
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
.office-editor-wrap {
|
| 621 |
+
display: flex;
|
| 622 |
+
flex: 1 1 auto;
|
| 623 |
+
flex-direction: column;
|
| 624 |
+
min-width: 0;
|
| 625 |
+
min-height: 0;
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
.office-editor-scroll {
|
| 629 |
+
flex: 1 1 auto;
|
| 630 |
+
min-height: 0;
|
| 631 |
+
overflow: auto;
|
| 632 |
+
padding: 30px 24px;
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
.office-editor-scroll.is-source {
|
| 636 |
+
display: flex;
|
| 637 |
+
overflow: hidden;
|
| 638 |
+
padding: var(--spacing-md);
|
| 639 |
+
background: transparent;
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
.office-editor-scroll.is-desktop {
|
| 643 |
+
display: flex;
|
| 644 |
+
overflow: hidden;
|
| 645 |
+
padding: 0;
|
| 646 |
+
background: #1f2329;
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
.office-desktop-wrap {
|
| 650 |
+
display: flex;
|
| 651 |
+
flex: 1 1 auto;
|
| 652 |
+
width: 100%;
|
| 653 |
+
height: 100%;
|
| 654 |
+
min-width: 0;
|
| 655 |
+
min-height: 0;
|
| 656 |
+
aspect-ratio: auto;
|
| 657 |
+
background: #1f2329;
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
.office-desktop-empty {
|
| 661 |
+
display: grid;
|
| 662 |
+
flex: 1 1 auto;
|
| 663 |
+
place-items: center;
|
| 664 |
+
align-content: center;
|
| 665 |
+
gap: 12px;
|
| 666 |
+
min-width: 0;
|
| 667 |
+
min-height: 0;
|
| 668 |
+
padding: 24px;
|
| 669 |
+
color: var(--color-text-secondary);
|
| 670 |
+
text-align: center;
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
.office-desktop-empty > .material-symbols-outlined {
|
| 674 |
+
font-size: 32px;
|
| 675 |
+
color: color-mix(in srgb, var(--color-text) 68%, transparent);
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
.office-desktop-empty-title {
|
| 679 |
+
font-size: 13px;
|
| 680 |
+
font-weight: 700;
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
.office-desktop-frame {
|
| 684 |
+
flex: 1 1 auto;
|
| 685 |
+
width: 100%;
|
| 686 |
+
height: 100%;
|
| 687 |
+
min-height: 0;
|
| 688 |
+
aspect-ratio: auto;
|
| 689 |
+
border: 0;
|
| 690 |
+
background: #20242a;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
.office-panel .spinning {
|
| 694 |
+
animation: office-spin 0.8s linear infinite;
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
@keyframes office-spin {
|
| 698 |
+
to { transform: rotate(360deg); }
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
@container (max-width: 680px) {
|
| 702 |
+
.office-toolbar {
|
| 703 |
+
padding-inline: 8px;
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
.office-toolbar-row {
|
| 707 |
+
gap: 5px;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
.office-command-button {
|
| 711 |
+
width: 32px;
|
| 712 |
+
max-width: 32px;
|
| 713 |
+
padding-inline: 0;
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
.office-command-button .office-button-label {
|
| 717 |
+
position: absolute;
|
| 718 |
+
width: 1px;
|
| 719 |
+
height: 1px;
|
| 720 |
+
overflow: hidden;
|
| 721 |
+
clip: rect(0 0 0 0);
|
| 722 |
+
white-space: nowrap;
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
/* While the canvas is being resized, the left-edge drag sweeps the cursor across this
|
| 728 |
+
iframe, which captures the pointermove and freezes shrinking (widening drags away, so
|
| 729 |
+
it works). Disable hit-testing on the frame during the drag — right-canvas-store.js
|
| 730 |
+
toggles `right-canvas-resizing` on <body> for the duration of the drag. */
|
| 731 |
+
body.right-canvas-resizing .office-desktop-frame { pointer-events: none; }
|
| 732 |
+
</style>
|
| 733 |
+
</body>
|
| 734 |
+
</html>
|
plugins/_desktop/webui/desktop-store.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
plugins/_desktop/webui/main.html
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<html
|
| 2 |
+
class="surface-modal office-modal modal-no-backdrop"
|
| 3 |
+
data-surface-id="desktop"
|
| 4 |
+
data-surface-modal-path="/plugins/_desktop/webui/main.html"
|
| 5 |
+
data-surface-dock-title="Open Desktop in canvas"
|
| 6 |
+
data-surface-dock-icon="dock_to_right"
|
| 7 |
+
data-canvas-surface="desktop"
|
| 8 |
+
data-canvas-modal-path="/plugins/_desktop/webui/main.html"
|
| 9 |
+
data-canvas-dock-title="Open Desktop in canvas"
|
| 10 |
+
data-canvas-dock-icon="dock_to_right"
|
| 11 |
+
>
|
| 12 |
+
<head>
|
| 13 |
+
<title>Desktop</title>
|
| 14 |
+
<script type="module">
|
| 15 |
+
import { store } from "/plugins/_desktop/webui/desktop-store.js";
|
| 16 |
+
</script>
|
| 17 |
+
</head>
|
| 18 |
+
<body class="office-modal-body">
|
| 19 |
+
<x-component path="/plugins/_desktop/webui/desktop-panel.html" mode="modal"></x-component>
|
| 20 |
+
</body>
|
| 21 |
+
</html>
|
plugins/_desktop/webui/thumbnail.jpg
ADDED
|
|
plugins/_discovery/AGENTS.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Plugin Discovery DOX
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
|
| 5 |
+
- Own contextual plugin discovery cards and welcome-screen promotions.
|
| 6 |
+
|
| 7 |
+
## Ownership
|
| 8 |
+
|
| 9 |
+
- `plugin.yaml` owns metadata and always-enabled status.
|
| 10 |
+
- `extensions/` owns backend or WebUI discovery contributions.
|
| 11 |
+
- `webui/discovery-store.js` owns discovery UI state and actions.
|
| 12 |
+
|
| 13 |
+
## Local Contracts
|
| 14 |
+
|
| 15 |
+
- Discovery cards must be accurate, dismissible where appropriate, and avoid advertising already-complete setup.
|
| 16 |
+
- CTA actions must match supported welcome-screen action contracts.
|
| 17 |
+
- Keep card IDs unique and plugin-prefixed.
|
| 18 |
+
- The Welcome screen `welcome-actions-end` surface renders feature channel cards plus the compact OAuth account-provider card; other hero discovery cards stay out of the lower welcome grid.
|
| 19 |
+
- Do not hide discovery cards solely because model setup is incomplete; model setup gating belongs to the chat thread.
|
| 20 |
+
|
| 21 |
+
## Work Guidance
|
| 22 |
+
|
| 23 |
+
- Prefer backend status checks before surfacing setup or integration prompts.
|
| 24 |
+
|
| 25 |
+
## Verification
|
| 26 |
+
|
| 27 |
+
- Smoke-test welcome-screen discovery cards, ordering, dismissal, and CTA behavior after changes.
|
| 28 |
+
|
| 29 |
+
## Child DOX Index
|
| 30 |
+
|
| 31 |
+
No child DOX files.
|
plugins/_discovery/extensions/python/banners/10_discovery_cards.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from helpers.extension import Extension
|
| 2 |
+
from helpers import plugins
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class DiscoveryCardsExtension(Extension):
|
| 6 |
+
"""Injects discovery cards into the banners list."""
|
| 7 |
+
|
| 8 |
+
def _oauth_summary(self) -> dict:
|
| 9 |
+
try:
|
| 10 |
+
from plugins._oauth.helpers.providers import provider_registry
|
| 11 |
+
from plugins._oauth.helpers.route_bootstrap import is_installed
|
| 12 |
+
from plugins._oauth.helpers.summary import build_oauth_status_summary
|
| 13 |
+
|
| 14 |
+
return build_oauth_status_summary(
|
| 15 |
+
provider_registry=provider_registry,
|
| 16 |
+
routes_installed=is_installed,
|
| 17 |
+
)
|
| 18 |
+
except Exception:
|
| 19 |
+
return {}
|
| 20 |
+
|
| 21 |
+
def _oauth_usage_windows(self, summary: dict) -> list[dict]:
|
| 22 |
+
windows: list[dict] = []
|
| 23 |
+
accounts = summary.get("oauth_accounts") if isinstance(summary, dict) else {}
|
| 24 |
+
connected = accounts.get("connected", []) if isinstance(accounts, dict) else []
|
| 25 |
+
for account in connected:
|
| 26 |
+
if not isinstance(account, dict):
|
| 27 |
+
continue
|
| 28 |
+
provider_name = account.get("short_name") or account.get("display_name") or "Account"
|
| 29 |
+
for window in account.get("usage_windows") or []:
|
| 30 |
+
if not isinstance(window, dict):
|
| 31 |
+
continue
|
| 32 |
+
windows.append({
|
| 33 |
+
**window,
|
| 34 |
+
"key": f'{account.get("provider_id", "oauth")}-{window.get("key", "usage")}',
|
| 35 |
+
"title": f'{provider_name} {window.get("title") or "Usage"}',
|
| 36 |
+
})
|
| 37 |
+
return windows[:4]
|
| 38 |
+
|
| 39 |
+
def _oauth_account_chips(self, summary: dict) -> list[dict]:
|
| 40 |
+
accounts = summary.get("oauth_accounts") if isinstance(summary, dict) else {}
|
| 41 |
+
if not isinstance(accounts, dict):
|
| 42 |
+
return []
|
| 43 |
+
connected = accounts.get("connected") or []
|
| 44 |
+
available = accounts.get("available") or []
|
| 45 |
+
source = connected if connected else available
|
| 46 |
+
chips = []
|
| 47 |
+
for account in source[:4]:
|
| 48 |
+
if not isinstance(account, dict):
|
| 49 |
+
continue
|
| 50 |
+
chips.append({
|
| 51 |
+
"provider_id": account.get("provider_id") or "",
|
| 52 |
+
"label": account.get("short_name") or account.get("display_name") or account.get("provider_id"),
|
| 53 |
+
"detail": account.get("account_label") if account.get("connected") else "Available",
|
| 54 |
+
"connected": bool(account.get("connected")),
|
| 55 |
+
})
|
| 56 |
+
return chips
|
| 57 |
+
|
| 58 |
+
async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
|
| 59 |
+
# Optional logic: only show specific cards if plugins aren't already configured.
|
| 60 |
+
# Telegram, Email, Whatsapp are built-in, so we only need to check if they've been configured.
|
| 61 |
+
|
| 62 |
+
telegram_config = plugins.get_plugin_config("_telegram_integration") or {}
|
| 63 |
+
email_config = plugins.get_plugin_config("_email_integration") or {}
|
| 64 |
+
whatsapp_config = plugins.get_plugin_config("_whatsapp_integration") or {}
|
| 65 |
+
oauth_summary = self._oauth_summary()
|
| 66 |
+
oauth_accounts = oauth_summary.get("oauth_accounts") if isinstance(oauth_summary, dict) else {}
|
| 67 |
+
connected_count = int(oauth_accounts.get("connected_count") or 0) if isinstance(oauth_accounts, dict) else 0
|
| 68 |
+
total_count = int(oauth_accounts.get("total_count") or 0) if isinstance(oauth_accounts, dict) else 0
|
| 69 |
+
|
| 70 |
+
# 1. Plugin Hub Hero
|
| 71 |
+
banners.append({
|
| 72 |
+
"id": "discovery-plugin-hub",
|
| 73 |
+
"type": "hero",
|
| 74 |
+
"title": "Discover the Plugin Hub",
|
| 75 |
+
"description": "Extend Agent Zero with integrations, tools, and automations from the community.",
|
| 76 |
+
"thumbnail": "/plugins/_discovery/webui/assets/hero-plugin-hub.png",
|
| 77 |
+
"icon": "extension",
|
| 78 |
+
"cta_text": "Explore Plugins",
|
| 79 |
+
"cta_action": "open-plugin-hub",
|
| 80 |
+
"dismissible": True,
|
| 81 |
+
"priority": 100,
|
| 82 |
+
"show_in_onboarding": True
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
# 2. Telegram
|
| 86 |
+
if not telegram_config.get("bot_token"):
|
| 87 |
+
banners.append({
|
| 88 |
+
"id": "discovery-telegram",
|
| 89 |
+
"type": "feature",
|
| 90 |
+
"title": "Telegram",
|
| 91 |
+
"description": "Chat on Telegram wherever you are.",
|
| 92 |
+
"thumbnail": "/plugins/_discovery/webui/assets/thumb-telegram.png",
|
| 93 |
+
"icon": "send",
|
| 94 |
+
"cta_text": "Connect",
|
| 95 |
+
"cta_action": "open-plugin-config:_telegram_integration",
|
| 96 |
+
"dismissible": True,
|
| 97 |
+
"priority": 50,
|
| 98 |
+
"show_in_onboarding": True
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
# 3. Email
|
| 102 |
+
email_handlers = email_config.get("handlers") or []
|
| 103 |
+
email_is_configured = any((handler or {}).get("username") for handler in email_handlers)
|
| 104 |
+
if not email_is_configured:
|
| 105 |
+
banners.append({
|
| 106 |
+
"id": "discovery-email",
|
| 107 |
+
"type": "feature",
|
| 108 |
+
"title": "Email",
|
| 109 |
+
"description": "Let Agent Zero read and send emails on your behalf.",
|
| 110 |
+
"thumbnail": "/plugins/_discovery/webui/assets/thumb-email.png",
|
| 111 |
+
"icon": "mail",
|
| 112 |
+
"cta_text": "Connect",
|
| 113 |
+
"cta_action": "open-plugin-config:_email_integration",
|
| 114 |
+
"dismissible": True,
|
| 115 |
+
"priority": 50,
|
| 116 |
+
"show_in_onboarding": True
|
| 117 |
+
})
|
| 118 |
+
|
| 119 |
+
# 4. WhatsApp
|
| 120 |
+
if not whatsapp_config.get("phone_number_id"):
|
| 121 |
+
banners.append({
|
| 122 |
+
"id": "discovery-whatsapp",
|
| 123 |
+
"type": "feature",
|
| 124 |
+
"title": "WhatsApp",
|
| 125 |
+
"description": "Send and receive WhatsApp messages through A0.",
|
| 126 |
+
"thumbnail": "/plugins/_discovery/webui/assets/thumb-whatsapp.png",
|
| 127 |
+
"icon": "chat",
|
| 128 |
+
"cta_text": "Connect",
|
| 129 |
+
"cta_action": "open-plugin-config:_whatsapp_integration",
|
| 130 |
+
"dismissible": True,
|
| 131 |
+
"priority": 50,
|
| 132 |
+
"show_in_onboarding": True
|
| 133 |
+
})
|
| 134 |
+
|
| 135 |
+
# 5. OAuth account providers
|
| 136 |
+
oauth_card = {
|
| 137 |
+
"id": "discovery-oauth-accounts",
|
| 138 |
+
"type": "hero",
|
| 139 |
+
"placement": "after-features",
|
| 140 |
+
"title": "Your AI accounts",
|
| 141 |
+
"description": "Use your subscription-backed logins for model access."
|
| 142 |
+
if not connected_count
|
| 143 |
+
else "",
|
| 144 |
+
"cta_text": "Connect accounts" if not connected_count else "Manage accounts",
|
| 145 |
+
"cta_action": "open-plugin-config:_oauth",
|
| 146 |
+
"dismissible": True,
|
| 147 |
+
"priority": 40,
|
| 148 |
+
"show_in_onboarding": True,
|
| 149 |
+
"account_chips": self._oauth_account_chips(oauth_summary),
|
| 150 |
+
}
|
| 151 |
+
usage_windows = self._oauth_usage_windows(oauth_summary)
|
| 152 |
+
if usage_windows:
|
| 153 |
+
oauth_card["usage_windows"] = usage_windows
|
| 154 |
+
banners.append(oauth_card)
|
plugins/_discovery/extensions/webui/initFw_end/auto-modal.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { callJsonApi } from "/js/api.js";
|
| 2 |
+
import { isModalOpen } from "/js/modals.js";
|
| 3 |
+
|
| 4 |
+
const DEFAULT_SURFACE = "welcome";
|
| 5 |
+
const STARTUP_DELAY_MS = 650;
|
| 6 |
+
const SUPPRESSION_PREFIX = "discovery_auto_modal_closed";
|
| 7 |
+
|
| 8 |
+
let initialized = false;
|
| 9 |
+
let lastOpened = null;
|
| 10 |
+
let checking = false;
|
| 11 |
+
|
| 12 |
+
function cleanPath(path = "") {
|
| 13 |
+
return String(path || "").replace(/^\/+/, "");
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function currentContextId(fallback = "") {
|
| 17 |
+
try {
|
| 18 |
+
return fallback || globalThis.getContext?.() || sessionStorage.getItem("lastSelectedChat") || "welcome";
|
| 19 |
+
} catch {
|
| 20 |
+
return fallback || "welcome";
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function bannerSupportsSurface(banner, surface) {
|
| 25 |
+
const surfaces = banner?.auto_modal_surfaces;
|
| 26 |
+
return !Array.isArray(surfaces) || surfaces.length === 0 || surfaces.includes(surface);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function suppressionKey({ surface, ctxid, reason, path }) {
|
| 30 |
+
return `${SUPPRESSION_PREFIX}:${surface}:${ctxid || "none"}:${reason || "unknown"}:${cleanPath(path)}`;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function isSuppressed(item) {
|
| 34 |
+
try {
|
| 35 |
+
return sessionStorage.getItem(suppressionKey(item)) === "1";
|
| 36 |
+
} catch {
|
| 37 |
+
return false;
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function suppress(item) {
|
| 42 |
+
try {
|
| 43 |
+
sessionStorage.setItem(suppressionKey(item), "1");
|
| 44 |
+
} catch {
|
| 45 |
+
// no-op
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function modalAlreadyOpen(path) {
|
| 50 |
+
return isModalOpen(path);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
async function fetchAutoModalBanner(surface, ctxid) {
|
| 54 |
+
const response = await callJsonApi("/banners", {
|
| 55 |
+
banners: [],
|
| 56 |
+
context: {
|
| 57 |
+
is_welcome: surface === "welcome",
|
| 58 |
+
is_onboarding: document.body.dataset.mode === "onboarding",
|
| 59 |
+
surface,
|
| 60 |
+
ctxid,
|
| 61 |
+
},
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
const banners = Array.isArray(response?.banners) ? response.banners : [];
|
| 65 |
+
return banners
|
| 66 |
+
.filter((banner) => banner?.auto_modal_path)
|
| 67 |
+
.filter((banner) => bannerSupportsSurface(banner, surface))
|
| 68 |
+
.sort((left, right) => (Number(right.auto_modal_priority || 0) - Number(left.auto_modal_priority || 0)))[0] || null;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
async function maybeOpenAutoModal(surface = DEFAULT_SURFACE, detail = {}) {
|
| 72 |
+
if (checking) return;
|
| 73 |
+
checking = true;
|
| 74 |
+
const ctxid = currentContextId(detail.ctxid || "");
|
| 75 |
+
|
| 76 |
+
try {
|
| 77 |
+
const banner = await fetchAutoModalBanner(surface, ctxid);
|
| 78 |
+
if (!banner?.auto_modal_path) return;
|
| 79 |
+
|
| 80 |
+
const item = {
|
| 81 |
+
surface,
|
| 82 |
+
ctxid,
|
| 83 |
+
path: banner.auto_modal_path,
|
| 84 |
+
reason: banner.auto_modal_reason || banner.id || "auto-modal",
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
if (isSuppressed(item) || modalAlreadyOpen(item.path)) return;
|
| 88 |
+
|
| 89 |
+
const opener = globalThis.ensureModalOpen || globalThis.openModal;
|
| 90 |
+
if (!opener) return;
|
| 91 |
+
|
| 92 |
+
lastOpened = item;
|
| 93 |
+
await opener(item.path);
|
| 94 |
+
} catch (error) {
|
| 95 |
+
console.error("Discovery auto-modal check failed:", error);
|
| 96 |
+
} finally {
|
| 97 |
+
checking = false;
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function handleModalClosed(event) {
|
| 102 |
+
const closedPath = event?.detail?.modalPath || "";
|
| 103 |
+
if (lastOpened && cleanPath(closedPath) === cleanPath(lastOpened.path)) {
|
| 104 |
+
suppress(lastOpened);
|
| 105 |
+
const surface = lastOpened.surface;
|
| 106 |
+
const ctxid = lastOpened.ctxid;
|
| 107 |
+
lastOpened = null;
|
| 108 |
+
window.setTimeout(() => {
|
| 109 |
+
void maybeOpenAutoModal(surface, { ctxid });
|
| 110 |
+
}, 250);
|
| 111 |
+
return;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
window.setTimeout(() => {
|
| 115 |
+
void maybeOpenAutoModal(DEFAULT_SURFACE);
|
| 116 |
+
}, 250);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function handleChatCreated(event) {
|
| 120 |
+
const ctxid = event?.detail?.ctxid || "";
|
| 121 |
+
window.setTimeout(() => {
|
| 122 |
+
void maybeOpenAutoModal("chat-created", { ctxid });
|
| 123 |
+
}, 300);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
export default function initDiscoveryAutoModal() {
|
| 127 |
+
if (initialized) return;
|
| 128 |
+
initialized = true;
|
| 129 |
+
|
| 130 |
+
document.addEventListener("modal-closed", handleModalClosed);
|
| 131 |
+
document.addEventListener("chat-created", handleChatCreated);
|
| 132 |
+
|
| 133 |
+
window.setTimeout(() => {
|
| 134 |
+
void maybeOpenAutoModal(DEFAULT_SURFACE);
|
| 135 |
+
}, STARTUP_DELAY_MS);
|
| 136 |
+
}
|
plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html
ADDED
|
@@ -0,0 +1,597 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script type="module">
|
| 2 |
+
import { store } from "/plugins/_discovery/webui/discovery-store.js";
|
| 3 |
+
</script>
|
| 4 |
+
|
| 5 |
+
<div x-data>
|
| 6 |
+
<template x-if="$store.discoveryStore">
|
| 7 |
+
<div class="discovery-slot"
|
| 8 |
+
@modal-closed.window="$store.discoveryStore.refreshCards()"
|
| 9 |
+
x-create="$store.discoveryStore.refreshCards()"
|
| 10 |
+
x-show="$store.discoveryStore.cards.length > 0 || $store.discoveryStore.hasDismissedCards">
|
| 11 |
+
|
| 12 |
+
<div class="discovery-section">
|
| 13 |
+
|
| 14 |
+
<div class="discovery-features" x-show="$store.discoveryStore.featureCards.length > 0" style="text-align: left;">
|
| 15 |
+
<template x-for="card in $store.discoveryStore.featureCards" :key="card.id">
|
| 16 |
+
<article class="discovery-feature-card"
|
| 17 |
+
role="button"
|
| 18 |
+
tabindex="0"
|
| 19 |
+
@click="$store.discoveryStore.executeCta(card.cta_action)"
|
| 20 |
+
@keydown.enter.prevent="$store.discoveryStore.executeCta(card.cta_action)"
|
| 21 |
+
@keydown.space.prevent="$store.discoveryStore.executeCta(card.cta_action)">
|
| 22 |
+
|
| 23 |
+
<div class="discovery-feature-head">
|
| 24 |
+
<div class="discovery-feature-thumb">
|
| 25 |
+
<template x-if="card.thumbnail">
|
| 26 |
+
<img :src="card.thumbnail" :alt="card.title" loading="lazy">
|
| 27 |
+
</template>
|
| 28 |
+
<template x-if="!card.thumbnail && card.icon">
|
| 29 |
+
<x-icon class="discovery-feature-icon" :name="card.icon"></x-icon>
|
| 30 |
+
</template>
|
| 31 |
+
</div>
|
| 32 |
+
<h4 class="discovery-feature-title" x-text="card.title"></h4>
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<button class="btn btn primary"
|
| 36 |
+
type="button"
|
| 37 |
+
@click.stop="$store.discoveryStore.executeCta(card.cta_action)">
|
| 38 |
+
<span x-text="card.cta_text"></span>
|
| 39 |
+
<x-icon name="arrow_forward"></x-icon>
|
| 40 |
+
</button>
|
| 41 |
+
</article>
|
| 42 |
+
</template>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
<template x-for="card in $store.discoveryStore.heroCards.filter(card => !['discovery-codex-oauth', 'discovery-oauth-accounts'].includes(card.id))" :key="card.id">
|
| 47 |
+
<article class="discovery-hero"
|
| 48 |
+
role="button"
|
| 49 |
+
tabindex="0"
|
| 50 |
+
@click="$store.discoveryStore.executeCta(card.cta_action)"
|
| 51 |
+
@keydown.enter.prevent="$store.discoveryStore.executeCta(card.cta_action)"
|
| 52 |
+
@keydown.space.prevent="$store.discoveryStore.executeCta(card.cta_action)">
|
| 53 |
+
<button class="discovery-dismiss"
|
| 54 |
+
type="button"
|
| 55 |
+
x-show="card.dismissible"
|
| 56 |
+
@click.stop="$store.discoveryStore.dismissCard(card.id)"
|
| 57 |
+
:aria-label="`Dismiss ${card.title}`"
|
| 58 |
+
title="Dismiss">
|
| 59 |
+
<x-icon name="close"></x-icon>
|
| 60 |
+
</button>
|
| 61 |
+
|
| 62 |
+
<div class="discovery-hero-content">
|
| 63 |
+
<h3 class="discovery-hero-title" x-text="card.title"></h3>
|
| 64 |
+
<p class="discovery-hero-desc" x-text="card.description"></p>
|
| 65 |
+
<button class="btn btn-ok"
|
| 66 |
+
type="button"
|
| 67 |
+
@click.stop="$store.discoveryStore.executeCta(card.cta_action)">
|
| 68 |
+
<span x-text="card.cta_text"></span>
|
| 69 |
+
<x-icon name="arrow_forward"></x-icon>
|
| 70 |
+
</button>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div class="discovery-hero-thumb">
|
| 74 |
+
<template x-if="card.thumbnail">
|
| 75 |
+
<img :src="card.thumbnail" :alt="card.title" loading="lazy">
|
| 76 |
+
</template>
|
| 77 |
+
<template x-if="!card.thumbnail && card.icon">
|
| 78 |
+
<x-icon class="discovery-hero-icon" :name="card.icon"></x-icon>
|
| 79 |
+
</template>
|
| 80 |
+
</div>
|
| 81 |
+
</article>
|
| 82 |
+
</template>
|
| 83 |
+
|
| 84 |
+
<article class="discovery-cli-card" aria-labelledby="discovery-cli-title">
|
| 85 |
+
<div class="discovery-cli-content">
|
| 86 |
+
<span class="discovery-cli-kicker">CLI Connector</span>
|
| 87 |
+
<h3 class="discovery-cli-title" id="discovery-cli-title">Connect Agent Zero to your desktop</h3>
|
| 88 |
+
<p class="discovery-cli-desc">
|
| 89 |
+
Install the CLI Connector when you want A0 to use your host terminal,
|
| 90 |
+
local files, and a browser from your computer.
|
| 91 |
+
</p>
|
| 92 |
+
<div class="discovery-cli-commands" aria-label="CLI Connector install commands">
|
| 93 |
+
<div class="discovery-cli-command">
|
| 94 |
+
<span class="discovery-cli-label">macOS / Linux</span>
|
| 95 |
+
<code>curl -LsSf https://cli.agent-zero.ai/install.sh | sh</code>
|
| 96 |
+
</div>
|
| 97 |
+
<div class="discovery-cli-command">
|
| 98 |
+
<span class="discovery-cli-label">Windows PowerShell</span>
|
| 99 |
+
<code>irm https://cli.agent-zero.ai/install.ps1 | iex</code>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<figure class="discovery-cli-preview" aria-label="Agent Zero CLI Connector preview">
|
| 105 |
+
<img src="/plugins/_discovery/webui/assets/cli-connector-preview.png"
|
| 106 |
+
alt="Agent Zero CLI Connector terminal preview"
|
| 107 |
+
loading="lazy">
|
| 108 |
+
</figure>
|
| 109 |
+
</article>
|
| 110 |
+
|
| 111 |
+
<div class="discovery-undismiss" x-show="$store.discoveryStore.hasDismissedCards">
|
| 112 |
+
<button type="button"
|
| 113 |
+
class="discovery-undismiss-btn"
|
| 114 |
+
@click="$store.discoveryStore.undismissCards()">
|
| 115 |
+
<x-icon name="undo"></x-icon>
|
| 116 |
+
<span>Show dismissed suggestions</span>
|
| 117 |
+
</button>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
</template>
|
| 122 |
+
|
| 123 |
+
<style>
|
| 124 |
+
.discovery-slot {
|
| 125 |
+
grid-column: 1 / -1;
|
| 126 |
+
width: 100%;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.discovery-section {
|
| 130 |
+
display: flex;
|
| 131 |
+
flex-direction: column;
|
| 132 |
+
gap: 0.75rem;
|
| 133 |
+
width: 100%;
|
| 134 |
+
margin-top: 0.85rem;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.discovery-hero,
|
| 138 |
+
.discovery-feature-card,
|
| 139 |
+
.discovery-cli-card {
|
| 140 |
+
position: relative;
|
| 141 |
+
border: 1px solid var(--color-border);
|
| 142 |
+
border-radius: 10px;
|
| 143 |
+
background: var(--color-panel);
|
| 144 |
+
text-align: left;
|
| 145 |
+
transition:
|
| 146 |
+
border-color 0.2s ease,
|
| 147 |
+
background-color 0.2s ease,
|
| 148 |
+
box-shadow 0.2s ease,
|
| 149 |
+
transform 0.2s ease;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.discovery-hero:hover,
|
| 153 |
+
.discovery-feature-card:hover,
|
| 154 |
+
.discovery-cli-card:hover,
|
| 155 |
+
.discovery-hero:focus-visible,
|
| 156 |
+
.discovery-feature-card:focus-visible,
|
| 157 |
+
.discovery-cli-card:focus-visible {
|
| 158 |
+
border-color: color-mix(in srgb, var(--color-primary) 72%, white 6%);
|
| 159 |
+
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
|
| 160 |
+
outline: none;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.discovery-hero {
|
| 164 |
+
display: flex;
|
| 165 |
+
align-items: center;
|
| 166 |
+
gap: 1rem;
|
| 167 |
+
min-height: 126px;
|
| 168 |
+
overflow: hidden;
|
| 169 |
+
padding: 1rem 1.15rem 1rem 1.25rem;
|
| 170 |
+
background:
|
| 171 |
+
linear-gradient(
|
| 172 |
+
135deg,
|
| 173 |
+
color-mix(in srgb, var(--color-primary) 10%, transparent),
|
| 174 |
+
transparent 58%
|
| 175 |
+
),
|
| 176 |
+
radial-gradient(
|
| 177 |
+
circle at 92% 24%,
|
| 178 |
+
color-mix(in srgb, var(--color-primary) 12%, transparent),
|
| 179 |
+
transparent 42%
|
| 180 |
+
),
|
| 181 |
+
var(--color-panel);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.discovery-hero-content,
|
| 185 |
+
.discovery-hero-thumb {
|
| 186 |
+
position: relative;
|
| 187 |
+
z-index: 1;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.discovery-hero-content {
|
| 191 |
+
flex: 1 1 auto;
|
| 192 |
+
min-width: 0;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.discovery-hero-title {
|
| 196 |
+
margin: 0 0 0.35rem;
|
| 197 |
+
color: var(--color-text);
|
| 198 |
+
font-size: 1.02rem;
|
| 199 |
+
font-weight: 600;
|
| 200 |
+
letter-spacing: 0;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.discovery-hero-desc {
|
| 204 |
+
margin: 0 0 0.75rem;
|
| 205 |
+
color: var(--color-text);
|
| 206 |
+
font-size: 0.86rem;
|
| 207 |
+
line-height: 1.42;
|
| 208 |
+
max-width: 38ch;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.discovery-cta-link,
|
| 212 |
+
.discovery-undismiss-btn {
|
| 213 |
+
display: inline-flex;
|
| 214 |
+
align-items: center;
|
| 215 |
+
justify-content: center;
|
| 216 |
+
gap: 0.35rem;
|
| 217 |
+
cursor: pointer;
|
| 218 |
+
transition:
|
| 219 |
+
background-color 0.2s ease,
|
| 220 |
+
border-color 0.2s ease,
|
| 221 |
+
color 0.2s ease,
|
| 222 |
+
transform 0.2s ease;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.discovery-hero-thumb {
|
| 226 |
+
flex: 0 0 112px;
|
| 227 |
+
width: 112px;
|
| 228 |
+
height: 86px;
|
| 229 |
+
display: flex;
|
| 230 |
+
align-items: center;
|
| 231 |
+
justify-content: center;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.discovery-hero-thumb img {
|
| 235 |
+
width: 100%;
|
| 236 |
+
height: 100%;
|
| 237 |
+
object-fit: contain;
|
| 238 |
+
filter: drop-shadow(0 12px 18px rgba(0, 0, 0, 0.12));
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.discovery-hero-icon {
|
| 242 |
+
font-size: 3rem;
|
| 243 |
+
color: var(--color-highlight-dark);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.discovery-features {
|
| 247 |
+
display: grid;
|
| 248 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 249 |
+
gap: 0.75rem;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.discovery-feature-card {
|
| 253 |
+
display: grid;
|
| 254 |
+
grid-template-columns: minmax(0, 1fr) auto;
|
| 255 |
+
gap: 0.75rem;
|
| 256 |
+
align-items: center;
|
| 257 |
+
padding: 0.72rem 0.9rem;
|
| 258 |
+
background:
|
| 259 |
+
linear-gradient(
|
| 260 |
+
180deg,
|
| 261 |
+
color-mix(in srgb, var(--color-primary) 4%, transparent),
|
| 262 |
+
transparent 38%
|
| 263 |
+
),
|
| 264 |
+
var(--color-panel);
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.discovery-feature-head {
|
| 268 |
+
display: flex;
|
| 269 |
+
flex-direction: row;
|
| 270 |
+
align-items: center;
|
| 271 |
+
gap: 0.62rem;
|
| 272 |
+
width: 100%;
|
| 273 |
+
min-width: 0;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.discovery-feature-thumb {
|
| 277 |
+
flex: 0 0 auto;
|
| 278 |
+
width: 32px;
|
| 279 |
+
height: 32px;
|
| 280 |
+
display: flex;
|
| 281 |
+
align-items: center;
|
| 282 |
+
justify-content: center;
|
| 283 |
+
border-radius: 12px;
|
| 284 |
+
overflow: hidden;
|
| 285 |
+
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
| 286 |
+
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 10%, transparent);
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.discovery-feature-thumb img {
|
| 290 |
+
width: 100%;
|
| 291 |
+
height: 100%;
|
| 292 |
+
object-fit: cover;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
.discovery-feature-icon {
|
| 296 |
+
font-size: 1.45rem;
|
| 297 |
+
color: var(--color-highlight-dark);
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
.discovery-feature-title {
|
| 301 |
+
margin: 0;
|
| 302 |
+
flex: 1 1 auto;
|
| 303 |
+
min-width: 0;
|
| 304 |
+
color: var(--color-text);
|
| 305 |
+
font-size: 0.88rem;
|
| 306 |
+
font-weight: 600;
|
| 307 |
+
line-height: 1.25;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.discovery-feature-card > .btn {
|
| 311 |
+
min-height: 36px;
|
| 312 |
+
padding: 0.44rem 0.78rem;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.discovery-feature-card > .btn .material-symbols-outlined {
|
| 316 |
+
font-size: 1.15rem;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.discovery-cli-card {
|
| 320 |
+
display: flex;
|
| 321 |
+
align-items: center;
|
| 322 |
+
gap: 1.1rem;
|
| 323 |
+
min-height: 134px;
|
| 324 |
+
overflow: hidden;
|
| 325 |
+
padding: 1rem 1.15rem 1rem 1.25rem;
|
| 326 |
+
background:
|
| 327 |
+
linear-gradient(
|
| 328 |
+
135deg,
|
| 329 |
+
color-mix(in srgb, var(--color-primary) 12%, transparent),
|
| 330 |
+
transparent 52%
|
| 331 |
+
),
|
| 332 |
+
radial-gradient(
|
| 333 |
+
circle at 86% 18%,
|
| 334 |
+
color-mix(in srgb, #00b7ff 14%, transparent),
|
| 335 |
+
transparent 34%
|
| 336 |
+
),
|
| 337 |
+
var(--color-panel);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.discovery-cli-content {
|
| 341 |
+
flex: 1 1 auto;
|
| 342 |
+
min-width: 0;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
.discovery-cli-kicker {
|
| 346 |
+
display: inline-flex;
|
| 347 |
+
width: max-content;
|
| 348 |
+
margin-bottom: 0.3rem;
|
| 349 |
+
border: 1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border));
|
| 350 |
+
border-radius: 999px;
|
| 351 |
+
padding: 0.16rem 0.48rem;
|
| 352 |
+
color: color-mix(in srgb, var(--color-primary) 72%, white 20%);
|
| 353 |
+
font-size: 0.68rem;
|
| 354 |
+
font-weight: 700;
|
| 355 |
+
line-height: 1;
|
| 356 |
+
text-transform: uppercase;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.discovery-cli-title {
|
| 360 |
+
margin: 0 0 0.28rem;
|
| 361 |
+
color: var(--color-text);
|
| 362 |
+
font-size: 1.02rem;
|
| 363 |
+
font-weight: 700;
|
| 364 |
+
line-height: 1.2;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.discovery-cli-desc {
|
| 368 |
+
max-width: 62ch;
|
| 369 |
+
margin: 0 0 0.65rem;
|
| 370 |
+
color: var(--color-secondary);
|
| 371 |
+
font-size: 0.8rem;
|
| 372 |
+
line-height: 1.38;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.discovery-cli-commands {
|
| 376 |
+
display: grid;
|
| 377 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 378 |
+
gap: 0.55rem;
|
| 379 |
+
max-width: 790px;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.discovery-cli-command {
|
| 383 |
+
min-width: 0;
|
| 384 |
+
border: 1px solid color-mix(in srgb, var(--color-border) 82%, transparent);
|
| 385 |
+
border-radius: 8px;
|
| 386 |
+
background: color-mix(in srgb, #05080c 86%, var(--color-panel));
|
| 387 |
+
padding: 0.48rem 0.55rem;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.discovery-cli-label {
|
| 391 |
+
display: block;
|
| 392 |
+
margin-bottom: 0.22rem;
|
| 393 |
+
color: var(--color-secondary);
|
| 394 |
+
font-size: 0.68rem;
|
| 395 |
+
font-weight: 700;
|
| 396 |
+
line-height: 1.1;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
.discovery-cli-command code {
|
| 400 |
+
display: block;
|
| 401 |
+
overflow-x: auto;
|
| 402 |
+
color: #72d8ff;
|
| 403 |
+
font-family: var(--font-family-monospace, ui-monospace, SFMono-Regular, Consolas, monospace);
|
| 404 |
+
font-size: 0.72rem;
|
| 405 |
+
line-height: 1.35;
|
| 406 |
+
white-space: nowrap;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
.discovery-cli-preview {
|
| 410 |
+
flex: 0 0 218px;
|
| 411 |
+
width: 218px;
|
| 412 |
+
margin: 0;
|
| 413 |
+
border: 1px solid color-mix(in srgb, #00b7ff 24%, var(--color-border));
|
| 414 |
+
border-radius: 8px;
|
| 415 |
+
background: #070b10;
|
| 416 |
+
box-shadow:
|
| 417 |
+
0 12px 28px rgba(0, 0, 0, 0.2),
|
| 418 |
+
0 0 28px color-mix(in srgb, var(--color-primary) 12%, transparent);
|
| 419 |
+
overflow: hidden;
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
.discovery-cli-preview img {
|
| 423 |
+
display: block;
|
| 424 |
+
width: 100%;
|
| 425 |
+
height: auto;
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
.discovery-cta-link {
|
| 429 |
+
margin-top: 0.95rem;
|
| 430 |
+
border: 1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border));
|
| 431 |
+
border-radius: 8px;
|
| 432 |
+
background: transparent;
|
| 433 |
+
color: var(--color-primary);
|
| 434 |
+
padding: 0.42rem 0.72rem;
|
| 435 |
+
font-size: 0.79rem;
|
| 436 |
+
font-weight: 600;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
.discovery-cta-link:hover,
|
| 440 |
+
.discovery-cta-link:focus-visible {
|
| 441 |
+
background: var(--color-primary);
|
| 442 |
+
border-color: var(--color-primary);
|
| 443 |
+
color: #fff;
|
| 444 |
+
outline: none;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
.discovery-dismiss {
|
| 448 |
+
position: absolute;
|
| 449 |
+
top: 0.65rem;
|
| 450 |
+
right: 0.65rem;
|
| 451 |
+
display: flex;
|
| 452 |
+
align-items: center;
|
| 453 |
+
justify-content: center;
|
| 454 |
+
width: 28px;
|
| 455 |
+
height: 28px;
|
| 456 |
+
border: none;
|
| 457 |
+
border-radius: 8px;
|
| 458 |
+
background: transparent;
|
| 459 |
+
color: var(--color-secondary);
|
| 460 |
+
cursor: pointer;
|
| 461 |
+
opacity: 0;
|
| 462 |
+
transition:
|
| 463 |
+
opacity 0.2s ease,
|
| 464 |
+
background-color 0.2s ease,
|
| 465 |
+
color 0.2s ease;
|
| 466 |
+
z-index: 2;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
.discovery-dismiss-small {
|
| 470 |
+
top: 0.55rem;
|
| 471 |
+
right: 0.55rem;
|
| 472 |
+
width: 26px;
|
| 473 |
+
height: 26px;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
.discovery-hero:hover .discovery-dismiss,
|
| 477 |
+
.discovery-hero:focus-visible .discovery-dismiss,
|
| 478 |
+
.discovery-feature-card:hover .discovery-dismiss,
|
| 479 |
+
.discovery-feature-card:focus-visible .discovery-dismiss {
|
| 480 |
+
opacity: 0.68;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
.discovery-dismiss:hover,
|
| 484 |
+
.discovery-dismiss:focus-visible {
|
| 485 |
+
opacity: 1;
|
| 486 |
+
color: var(--color-text);
|
| 487 |
+
background: color-mix(in srgb, var(--color-border) 84%, transparent);
|
| 488 |
+
outline: none;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
.discovery-dismiss .material-symbols-outlined {
|
| 492 |
+
font-size: 1rem;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
.discovery-undismiss {
|
| 496 |
+
display: flex;
|
| 497 |
+
justify-content: flex-start;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
.discovery-undismiss-btn {
|
| 501 |
+
border: none;
|
| 502 |
+
background: transparent;
|
| 503 |
+
padding: 0;
|
| 504 |
+
color: var(--color-secondary);
|
| 505 |
+
font-size: 0.8rem;
|
| 506 |
+
font-weight: 500;
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
.discovery-undismiss-btn:hover,
|
| 510 |
+
.discovery-undismiss-btn:focus-visible {
|
| 511 |
+
color: var(--color-primary);
|
| 512 |
+
outline: none;
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
.discovery-cta-link .material-symbols-outlined,
|
| 516 |
+
.discovery-undismiss-btn .material-symbols-outlined {
|
| 517 |
+
font-size: 1rem;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
@media (hover: none) {
|
| 521 |
+
.discovery-dismiss {
|
| 522 |
+
opacity: 0.68;
|
| 523 |
+
}
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
@media (max-width: 768px) {
|
| 527 |
+
.discovery-section {
|
| 528 |
+
gap: 0.85rem;
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.discovery-hero {
|
| 532 |
+
min-height: 0;
|
| 533 |
+
padding: 1rem;
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
.discovery-hero-thumb {
|
| 537 |
+
flex-basis: 92px;
|
| 538 |
+
width: 92px;
|
| 539 |
+
height: 76px;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
.discovery-features {
|
| 543 |
+
grid-template-columns: 1fr;
|
| 544 |
+
gap: 0.75rem;
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
.discovery-cta-link {
|
| 548 |
+
margin-top: 0;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
.discovery-cli-card {
|
| 552 |
+
flex-direction: column;
|
| 553 |
+
align-items: flex-start;
|
| 554 |
+
gap: 0.75rem;
|
| 555 |
+
min-height: 0;
|
| 556 |
+
padding: 0.95rem;
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
.discovery-cli-commands {
|
| 560 |
+
grid-template-columns: 1fr;
|
| 561 |
+
width: 100%;
|
| 562 |
+
max-width: none;
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
.discovery-cli-preview {
|
| 566 |
+
flex: 0 0 auto;
|
| 567 |
+
width: min(100%, 280px);
|
| 568 |
+
}
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
@media (max-width: 520px) {
|
| 572 |
+
.discovery-slot {
|
| 573 |
+
margin-top: 0.15rem;
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
.discovery-hero {
|
| 577 |
+
padding-right: 0.95rem;
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
.discovery-hero-thumb {
|
| 581 |
+
display: none;
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
.discovery-hero-title {
|
| 585 |
+
font-size: 1rem;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
.discovery-cli-title {
|
| 589 |
+
font-size: 0.98rem;
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
.discovery-cli-preview {
|
| 593 |
+
width: 100%;
|
| 594 |
+
}
|
| 595 |
+
}
|
| 596 |
+
</style>
|
| 597 |
+
</div>
|
plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script type="module">
|
| 2 |
+
import { store } from "/plugins/_discovery/webui/discovery-store.js";
|
| 3 |
+
</script>
|
| 4 |
+
|
| 5 |
+
<div x-data>
|
| 6 |
+
<template x-if="$store.discoveryStore">
|
| 7 |
+
<div class="discovery-slot"
|
| 8 |
+
@modal-closed.window="$store.discoveryStore.refreshCards()"
|
| 9 |
+
x-create="$store.discoveryStore.refreshCards()"
|
| 10 |
+
x-show="$store.discoveryStore.featureCards.length > 0 || $store.discoveryStore.oauthAccountCards.length > 0 || $store.discoveryStore.hasDismissedCards">
|
| 11 |
+
|
| 12 |
+
<section class="discovery-features-panel" x-show="$store.discoveryStore.featureCards.length > 0">
|
| 13 |
+
<header class="discovery-panel-header">
|
| 14 |
+
<div class="discovery-panel-copy">
|
| 15 |
+
<h2>Connect Channels</h2>
|
| 16 |
+
<p>Extend Agent Zero across your favorite platforms.</p>
|
| 17 |
+
</div>
|
| 18 |
+
<button class="discovery-dismiss discovery-panel-dismiss"
|
| 19 |
+
type="button"
|
| 20 |
+
x-show="$store.discoveryStore.featureCards.some(card => card.dismissible !== false)"
|
| 21 |
+
@click.stop="$store.discoveryStore.dismissFeatureCards()"
|
| 22 |
+
aria-label="Dismiss Connect Channels"
|
| 23 |
+
title="Dismiss">
|
| 24 |
+
<x-icon name="close"></x-icon>
|
| 25 |
+
</button>
|
| 26 |
+
</header>
|
| 27 |
+
|
| 28 |
+
<div class="discovery-features">
|
| 29 |
+
<template x-for="card in $store.discoveryStore.featureCards" :key="card.id">
|
| 30 |
+
<button class="discovery-feature-card"
|
| 31 |
+
type="button"
|
| 32 |
+
@click="$store.discoveryStore.executeCta(card.cta_action)">
|
| 33 |
+
<span class="discovery-feature-thumb">
|
| 34 |
+
<template x-if="card.thumbnail">
|
| 35 |
+
<img :src="card.thumbnail" :alt="card.title" loading="lazy">
|
| 36 |
+
</template>
|
| 37 |
+
<template x-if="!card.thumbnail && card.icon">
|
| 38 |
+
<x-icon class="discovery-feature-icon" :name="card.icon"></x-icon>
|
| 39 |
+
</template>
|
| 40 |
+
</span>
|
| 41 |
+
<span class="discovery-feature-title" x-text="`${card.cta_text || 'Connect'} ${card.title}`"></span>
|
| 42 |
+
</button>
|
| 43 |
+
</template>
|
| 44 |
+
</div>
|
| 45 |
+
</section>
|
| 46 |
+
|
| 47 |
+
<template x-for="card in $store.discoveryStore.oauthAccountCards" :key="card.id">
|
| 48 |
+
<article class="discovery-account-card">
|
| 49 |
+
<button class="discovery-dismiss"
|
| 50 |
+
type="button"
|
| 51 |
+
x-show="card.dismissible"
|
| 52 |
+
@click.stop="$store.discoveryStore.dismissCard(card.id)"
|
| 53 |
+
:aria-label="`Dismiss ${card.title}`"
|
| 54 |
+
title="Dismiss">
|
| 55 |
+
<x-icon name="close"></x-icon>
|
| 56 |
+
</button>
|
| 57 |
+
|
| 58 |
+
<header class="discovery-account-header">
|
| 59 |
+
<div class="discovery-account-copy">
|
| 60 |
+
<h2 x-text="card.title"></h2>
|
| 61 |
+
<p x-show="card.description" x-text="card.description"></p>
|
| 62 |
+
</div>
|
| 63 |
+
<button class="btn btn primary discovery-account-cta"
|
| 64 |
+
type="button"
|
| 65 |
+
@click="$store.discoveryStore.executeCta(card.cta_action)">
|
| 66 |
+
<span x-text="card.cta_text"></span>
|
| 67 |
+
<x-icon name="arrow_forward"></x-icon>
|
| 68 |
+
</button>
|
| 69 |
+
</header>
|
| 70 |
+
|
| 71 |
+
<div class="discovery-account-chips" x-show="(card.account_chips || []).length > 0">
|
| 72 |
+
<template x-for="chip in card.account_chips || []" :key="chip.provider_id || chip.label">
|
| 73 |
+
<span class="discovery-account-chip" :class="{ 'is-connected': chip.connected }">
|
| 74 |
+
<span class="discovery-account-chip-dot" aria-hidden="true"></span>
|
| 75 |
+
<span class="discovery-account-chip-label" x-text="chip.label"></span>
|
| 76 |
+
<span class="discovery-account-chip-detail" x-show="chip.detail" x-text="chip.detail"></span>
|
| 77 |
+
</span>
|
| 78 |
+
</template>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<div class="discovery-account-usage" x-show="(card.usage_windows || []).length > 0">
|
| 82 |
+
<template x-for="window in card.usage_windows || []" :key="window.key || window.title">
|
| 83 |
+
<div class="discovery-account-usage-row">
|
| 84 |
+
<div class="discovery-account-usage-label">
|
| 85 |
+
<span x-text="window.title"></span>
|
| 86 |
+
<small x-show="$store.discoveryStore.formatReset(window)"
|
| 87 |
+
x-text="`Resets in ${$store.discoveryStore.formatReset(window)}`"></small>
|
| 88 |
+
</div>
|
| 89 |
+
<div class="discovery-account-usage-meter">
|
| 90 |
+
<span class="discovery-account-usage-track">
|
| 91 |
+
<span class="discovery-account-usage-fill"
|
| 92 |
+
:style="`width: ${$store.discoveryStore.usageWidth(window)}`"></span>
|
| 93 |
+
</span>
|
| 94 |
+
<span class="discovery-account-usage-value"
|
| 95 |
+
x-text="$store.discoveryStore.formatRemainingPercent(window)"></span>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</template>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
</article>
|
| 102 |
+
</template>
|
| 103 |
+
|
| 104 |
+
<div class="discovery-undismiss" x-show="$store.discoveryStore.hasDismissedCards">
|
| 105 |
+
<button type="button"
|
| 106 |
+
class="discovery-undismiss-btn"
|
| 107 |
+
@click="$store.discoveryStore.undismissCards()">
|
| 108 |
+
<x-icon name="undo"></x-icon>
|
| 109 |
+
<span>Show dismissed suggestions</span>
|
| 110 |
+
</button>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
</template>
|
| 114 |
+
|
| 115 |
+
<style>
|
| 116 |
+
.discovery-slot {
|
| 117 |
+
display: grid;
|
| 118 |
+
gap: 1rem;
|
| 119 |
+
min-width: 0;
|
| 120 |
+
width: 100%;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.discovery-features-panel,
|
| 124 |
+
.discovery-account-card {
|
| 125 |
+
border: 1px solid color-mix(in srgb, var(--color-border) 66%, transparent);
|
| 126 |
+
border-radius: 8px;
|
| 127 |
+
background: color-mix(in srgb, var(--color-panel) 78%, var(--color-background) 22%);
|
| 128 |
+
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.026);
|
| 129 |
+
backdrop-filter: blur(18px);
|
| 130 |
+
text-align: left;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.discovery-features-panel {
|
| 134 |
+
min-height: 100%;
|
| 135 |
+
padding: 1.25rem 1.45rem;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.discovery-panel-header {
|
| 139 |
+
display: grid;
|
| 140 |
+
grid-template-columns: minmax(0, 1fr) auto;
|
| 141 |
+
gap: 0.7rem;
|
| 142 |
+
align-items: start;
|
| 143 |
+
margin-bottom: 1.28rem;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.discovery-panel-copy {
|
| 147 |
+
display: grid;
|
| 148 |
+
gap: 0.42rem;
|
| 149 |
+
min-width: 0;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.discovery-panel-header h2 {
|
| 153 |
+
margin: 0;
|
| 154 |
+
color: var(--color-text);
|
| 155 |
+
font-size: 1.05rem;
|
| 156 |
+
font-weight: 520;
|
| 157 |
+
line-height: 1.2;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.discovery-panel-header p {
|
| 161 |
+
margin: 0;
|
| 162 |
+
color: color-mix(in srgb, var(--color-text) 58%, transparent);
|
| 163 |
+
font-size: 0.92rem;
|
| 164 |
+
line-height: 1.35;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.discovery-features {
|
| 168 |
+
display: grid;
|
| 169 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 170 |
+
gap: 0.65rem;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.discovery-feature-card {
|
| 174 |
+
display: inline-flex;
|
| 175 |
+
align-items: center;
|
| 176 |
+
gap: 0.5rem;
|
| 177 |
+
min-width: 0;
|
| 178 |
+
min-height: 3.1rem;
|
| 179 |
+
border: 1px solid color-mix(in srgb, var(--color-border) 52%, transparent);
|
| 180 |
+
border-radius: 8px;
|
| 181 |
+
padding: 0.56rem 0.58rem;
|
| 182 |
+
background: color-mix(in srgb, var(--color-background) 50%, transparent);
|
| 183 |
+
color: inherit;
|
| 184 |
+
cursor: pointer;
|
| 185 |
+
font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
|
| 186 |
+
text-align: left;
|
| 187 |
+
transition:
|
| 188 |
+
border-color 0.18s ease,
|
| 189 |
+
background-color 0.18s ease;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.discovery-feature-thumb {
|
| 193 |
+
flex: 0 0 auto;
|
| 194 |
+
width: 32px;
|
| 195 |
+
height: 32px;
|
| 196 |
+
display: flex;
|
| 197 |
+
align-items: center;
|
| 198 |
+
justify-content: center;
|
| 199 |
+
border-radius: 999px;
|
| 200 |
+
overflow: hidden;
|
| 201 |
+
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
| 202 |
+
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 10%, transparent);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.discovery-feature-thumb img {
|
| 206 |
+
width: 100%;
|
| 207 |
+
height: 100%;
|
| 208 |
+
object-fit: cover;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.discovery-feature-icon {
|
| 212 |
+
font-size: 1.45rem;
|
| 213 |
+
color: var(--color-highlight-dark);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.discovery-feature-title {
|
| 217 |
+
margin: 0;
|
| 218 |
+
flex: 1 1 auto;
|
| 219 |
+
min-width: 0;
|
| 220 |
+
color: var(--color-text);
|
| 221 |
+
font-size: 0.84rem;
|
| 222 |
+
font-weight: 520;
|
| 223 |
+
line-height: 1.25;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.discovery-feature-card:hover,
|
| 227 |
+
.discovery-feature-card:focus-visible {
|
| 228 |
+
border-color: color-mix(in srgb, var(--color-border) 88%, var(--color-text) 12%);
|
| 229 |
+
background: color-mix(in srgb, var(--color-panel) 86%, var(--color-background) 14%);
|
| 230 |
+
outline: none;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.discovery-dismiss {
|
| 234 |
+
position: absolute;
|
| 235 |
+
top: 0.65rem;
|
| 236 |
+
right: 0.65rem;
|
| 237 |
+
display: flex;
|
| 238 |
+
align-items: center;
|
| 239 |
+
justify-content: center;
|
| 240 |
+
width: 28px;
|
| 241 |
+
height: 28px;
|
| 242 |
+
border: none;
|
| 243 |
+
border-radius: 8px;
|
| 244 |
+
background: transparent;
|
| 245 |
+
color: var(--color-secondary);
|
| 246 |
+
cursor: pointer;
|
| 247 |
+
opacity: 0;
|
| 248 |
+
transition:
|
| 249 |
+
opacity 0.2s ease,
|
| 250 |
+
background-color 0.2s ease,
|
| 251 |
+
color 0.2s ease;
|
| 252 |
+
z-index: 2;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.discovery-dismiss-small {
|
| 256 |
+
top: 0.55rem;
|
| 257 |
+
right: 0.55rem;
|
| 258 |
+
width: 26px;
|
| 259 |
+
height: 26px;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.discovery-panel-dismiss {
|
| 263 |
+
position: static;
|
| 264 |
+
opacity: 0.68;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.discovery-account-card > .discovery-dismiss {
|
| 268 |
+
opacity: 0.68;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.discovery-dismiss:hover,
|
| 272 |
+
.discovery-dismiss:focus-visible {
|
| 273 |
+
opacity: 1;
|
| 274 |
+
color: var(--color-text);
|
| 275 |
+
background: color-mix(in srgb, var(--color-border) 84%, transparent);
|
| 276 |
+
outline: none;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
.discovery-dismiss .material-symbols-outlined {
|
| 280 |
+
font-size: 1rem;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.discovery-account-card {
|
| 284 |
+
position: relative;
|
| 285 |
+
display: grid;
|
| 286 |
+
gap: 1rem;
|
| 287 |
+
padding: 1.25rem 1.45rem 1.25rem;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.discovery-account-header {
|
| 291 |
+
display: grid;
|
| 292 |
+
grid-template-columns: minmax(0, 1fr) auto;
|
| 293 |
+
gap: 0.95rem;
|
| 294 |
+
align-items: center;
|
| 295 |
+
min-width: 0;
|
| 296 |
+
padding-right: 2.1rem;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.discovery-account-copy {
|
| 300 |
+
display: grid;
|
| 301 |
+
gap: 0.3rem;
|
| 302 |
+
min-width: 0;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.discovery-account-copy h2 {
|
| 306 |
+
margin: 0;
|
| 307 |
+
color: var(--color-text);
|
| 308 |
+
font-size: 1.05rem;
|
| 309 |
+
font-weight: 520;
|
| 310 |
+
line-height: 1.2;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.discovery-account-copy p {
|
| 314 |
+
margin: 0;
|
| 315 |
+
color: color-mix(in srgb, var(--color-text) 58%, transparent);
|
| 316 |
+
font-size: 0.9rem;
|
| 317 |
+
line-height: 1.42;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.discovery-account-chips {
|
| 321 |
+
display: flex;
|
| 322 |
+
flex-wrap: wrap;
|
| 323 |
+
gap: 0.5rem;
|
| 324 |
+
min-width: 0;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.discovery-account-chip {
|
| 328 |
+
display: inline-grid;
|
| 329 |
+
grid-template-columns: auto auto minmax(0, auto);
|
| 330 |
+
align-items: center;
|
| 331 |
+
gap: 0.4rem;
|
| 332 |
+
max-width: 100%;
|
| 333 |
+
min-height: 2rem;
|
| 334 |
+
padding: 0.32rem 0.58rem;
|
| 335 |
+
border: 1px solid color-mix(in srgb, var(--color-border) 52%, transparent);
|
| 336 |
+
border-radius: 8px;
|
| 337 |
+
background: color-mix(in srgb, var(--color-background) 50%, transparent);
|
| 338 |
+
color: color-mix(in srgb, var(--color-text) 74%, transparent);
|
| 339 |
+
font-size: 0.8rem;
|
| 340 |
+
line-height: 1.2;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.discovery-account-chip-dot {
|
| 344 |
+
width: 0.44rem;
|
| 345 |
+
height: 0.44rem;
|
| 346 |
+
border-radius: 999px;
|
| 347 |
+
background: color-mix(in srgb, var(--color-text) 34%, transparent);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.discovery-account-chip.is-connected .discovery-account-chip-dot {
|
| 351 |
+
background: #22c55e;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
.discovery-account-chip-label,
|
| 355 |
+
.discovery-account-chip-detail {
|
| 356 |
+
min-width: 0;
|
| 357 |
+
overflow: hidden;
|
| 358 |
+
text-overflow: ellipsis;
|
| 359 |
+
white-space: nowrap;
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
.discovery-account-chip-label {
|
| 363 |
+
color: var(--color-text);
|
| 364 |
+
font-weight: 500;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.discovery-account-chip-detail {
|
| 368 |
+
color: color-mix(in srgb, var(--color-text) 52%, transparent);
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
.discovery-account-usage {
|
| 372 |
+
display: grid;
|
| 373 |
+
gap: 0.74rem;
|
| 374 |
+
padding-top: 0.12rem;
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.discovery-account-usage-row {
|
| 378 |
+
display: grid;
|
| 379 |
+
grid-template-columns: minmax(0, 1fr) minmax(8rem, 0.82fr);
|
| 380 |
+
gap: 1rem;
|
| 381 |
+
align-items: center;
|
| 382 |
+
min-width: 0;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
.discovery-account-usage-label {
|
| 386 |
+
display: grid;
|
| 387 |
+
gap: 0.22rem;
|
| 388 |
+
min-width: 0;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.discovery-account-usage-label span,
|
| 392 |
+
.discovery-account-usage-label small {
|
| 393 |
+
min-width: 0;
|
| 394 |
+
overflow: hidden;
|
| 395 |
+
text-overflow: ellipsis;
|
| 396 |
+
white-space: nowrap;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
.discovery-account-usage-label span {
|
| 400 |
+
color: color-mix(in srgb, var(--color-text) 74%, transparent);
|
| 401 |
+
font-size: 0.84rem;
|
| 402 |
+
line-height: 1.25;
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
.discovery-account-usage-label small {
|
| 406 |
+
color: color-mix(in srgb, var(--color-text) 44%, transparent);
|
| 407 |
+
font-size: 0.72rem;
|
| 408 |
+
line-height: 1.2;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
.discovery-account-usage-meter {
|
| 412 |
+
display: grid;
|
| 413 |
+
grid-template-columns: minmax(0, 1fr) auto;
|
| 414 |
+
gap: 0.58rem;
|
| 415 |
+
align-items: center;
|
| 416 |
+
min-width: 0;
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
.discovery-account-usage-track {
|
| 420 |
+
height: 0.52rem;
|
| 421 |
+
overflow: hidden;
|
| 422 |
+
border-radius: 999px;
|
| 423 |
+
background: color-mix(in srgb, var(--color-border) 70%, transparent);
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
.discovery-account-usage-fill {
|
| 427 |
+
display: block;
|
| 428 |
+
height: 100%;
|
| 429 |
+
border-radius: inherit;
|
| 430 |
+
background: #22c55e;
|
| 431 |
+
transition: width 0.22s ease;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
.discovery-account-usage-value {
|
| 435 |
+
color: color-mix(in srgb, var(--color-text) 70%, transparent);
|
| 436 |
+
font-family: var(--font-family-code, "Roboto Mono", monospace);
|
| 437 |
+
font-size: 0.74rem;
|
| 438 |
+
line-height: 1;
|
| 439 |
+
white-space: nowrap;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.discovery-account-cta {
|
| 443 |
+
justify-self: end;
|
| 444 |
+
display: inline-flex;
|
| 445 |
+
align-items: center;
|
| 446 |
+
justify-content: center;
|
| 447 |
+
gap: 0.36rem;
|
| 448 |
+
min-height: 2.35rem;
|
| 449 |
+
min-width: 8.65rem;
|
| 450 |
+
border: 0;
|
| 451 |
+
border-radius: 8px;
|
| 452 |
+
padding: 0.55rem 0.9rem;
|
| 453 |
+
background-color: #4248f1;
|
| 454 |
+
color: #fff;
|
| 455 |
+
font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
|
| 456 |
+
font-size: 0.88rem;
|
| 457 |
+
font-weight: 460;
|
| 458 |
+
white-space: nowrap;
|
| 459 |
+
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
.discovery-account-cta:hover,
|
| 463 |
+
.discovery-account-cta:focus-visible {
|
| 464 |
+
background-color: #353bc5;
|
| 465 |
+
filter: none;
|
| 466 |
+
outline: none;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
.discovery-account-cta .material-symbols-outlined {
|
| 470 |
+
font-size: 1.05rem;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
.discovery-undismiss {
|
| 474 |
+
display: flex;
|
| 475 |
+
justify-content: flex-start;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
.discovery-undismiss-btn {
|
| 479 |
+
display: inline-flex;
|
| 480 |
+
align-items: center;
|
| 481 |
+
justify-content: center;
|
| 482 |
+
gap: 0.35rem;
|
| 483 |
+
border: none;
|
| 484 |
+
background: transparent;
|
| 485 |
+
padding: 0;
|
| 486 |
+
color: var(--color-secondary);
|
| 487 |
+
cursor: pointer;
|
| 488 |
+
font-size: 0.8rem;
|
| 489 |
+
font-weight: 500;
|
| 490 |
+
transition: color 0.2s ease;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
.discovery-undismiss-btn:hover,
|
| 494 |
+
.discovery-undismiss-btn:focus-visible {
|
| 495 |
+
color: var(--color-primary);
|
| 496 |
+
outline: none;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
.discovery-undismiss-btn .material-symbols-outlined {
|
| 500 |
+
font-size: 1rem;
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
@media (hover: none) {
|
| 504 |
+
.discovery-dismiss {
|
| 505 |
+
opacity: 0.68;
|
| 506 |
+
}
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
@media (max-width: 768px) {
|
| 510 |
+
.discovery-features {
|
| 511 |
+
grid-template-columns: 1fr;
|
| 512 |
+
gap: 0.75rem;
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
.discovery-feature-card {
|
| 516 |
+
display: inline-flex;
|
| 517 |
+
min-height: 0;
|
| 518 |
+
padding: 0.62rem 0.7rem;
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
.discovery-feature-thumb {
|
| 522 |
+
width: 32px;
|
| 523 |
+
height: 32px;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
.discovery-account-usage-row {
|
| 527 |
+
grid-template-columns: 1fr;
|
| 528 |
+
gap: 0.45rem;
|
| 529 |
+
}
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
@media (max-width: 520px) {
|
| 533 |
+
.discovery-slot {
|
| 534 |
+
margin-top: 0.15rem;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
.discovery-feature-card {
|
| 538 |
+
padding-right: 0.7rem;
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
.discovery-account-card {
|
| 542 |
+
padding: 1.05rem;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
.discovery-account-header {
|
| 546 |
+
grid-template-columns: 1fr;
|
| 547 |
+
padding-right: 2.2rem;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
.discovery-account-cta {
|
| 551 |
+
grid-column: 1 / -1;
|
| 552 |
+
justify-self: start;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
}
|
| 556 |
+
</style>
|
| 557 |
+
</div>
|
plugins/_discovery/plugin.yaml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: _discovery
|
| 2 |
+
title: Plugin Discovery
|
| 3 |
+
description: Contextual discovery cards on the welcome screen promoting plugins and integrations.
|
| 4 |
+
version: 1.0.0
|
| 5 |
+
settings_sections: []
|
| 6 |
+
always_enabled: true
|
| 7 |
+
per_project_config: false
|
| 8 |
+
per_agent_config: false
|
plugins/_discovery/webui/assets/cli-connector-preview.png
ADDED
|
plugins/_discovery/webui/assets/hero-openai-oauth.png
ADDED
|
plugins/_discovery/webui/assets/hero-plugin-hub.png
ADDED
|
plugins/_discovery/webui/assets/thumb-email.png
ADDED
|
|
plugins/_discovery/webui/assets/thumb-telegram.png
ADDED
|
|
plugins/_discovery/webui/assets/thumb-whatsapp.png
ADDED
|
|
plugins/_discovery/webui/discovery-store.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createStore } from "/js/AlpineStore.js";
|
| 2 |
+
import { toastFrontendError } from "/components/notifications/notification-store.js";
|
| 3 |
+
import { store as pluginListStore } from "/components/plugins/list/pluginListStore.js";
|
| 4 |
+
import * as API from "/js/api.js";
|
| 5 |
+
|
| 6 |
+
const STORAGE_KEY = "dismissed_discovery_cards";
|
| 7 |
+
|
| 8 |
+
const model = {
|
| 9 |
+
// --- State ---
|
| 10 |
+
/** @type {any[]} */
|
| 11 |
+
cards: [],
|
| 12 |
+
hasDismissedCards: false,
|
| 13 |
+
_initialized: false,
|
| 14 |
+
_isLoading: false,
|
| 15 |
+
|
| 16 |
+
// --- Lifecycle ---
|
| 17 |
+
init() {
|
| 18 |
+
if (this._initialized) return;
|
| 19 |
+
this._initialized = true;
|
| 20 |
+
},
|
| 21 |
+
|
| 22 |
+
// --- Actions ---
|
| 23 |
+
|
| 24 |
+
async refreshCards() {
|
| 25 |
+
if (this._isLoading) return;
|
| 26 |
+
this._isLoading = true;
|
| 27 |
+
|
| 28 |
+
try {
|
| 29 |
+
const response = await API.callJsonApi("/banners", {
|
| 30 |
+
banners: [],
|
| 31 |
+
context: {
|
| 32 |
+
is_onboarding: document.body.dataset.mode === "onboarding"
|
| 33 |
+
},
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
const banners = response?.banners || [];
|
| 37 |
+
const dismissed = this._getDismissedIds();
|
| 38 |
+
|
| 39 |
+
// Filter out standard banners, keep only hero and feature
|
| 40 |
+
// Also respect the onboarding filtering
|
| 41 |
+
const is_onboarding = document.body.dataset.mode === "onboarding";
|
| 42 |
+
|
| 43 |
+
this.cards = banners
|
| 44 |
+
.filter((card) => card.type === "hero" || card.type === "feature")
|
| 45 |
+
.filter((card) => !is_onboarding || card.show_in_onboarding === true)
|
| 46 |
+
.filter((card) => !dismissed.has(card.id))
|
| 47 |
+
.sort((left, right) => (right.priority || 0) - (left.priority || 0));
|
| 48 |
+
|
| 49 |
+
this.hasDismissedCards = dismissed.size > 0;
|
| 50 |
+
} catch (error) {
|
| 51 |
+
console.error("Failed to fetch discovery cards:", error);
|
| 52 |
+
} finally {
|
| 53 |
+
this._isLoading = false;
|
| 54 |
+
}
|
| 55 |
+
},
|
| 56 |
+
|
| 57 |
+
dismissCard(cardId) {
|
| 58 |
+
const dismissed = this._getDismissedIds();
|
| 59 |
+
dismissed.add(cardId);
|
| 60 |
+
this._persistDismissedIds(dismissed);
|
| 61 |
+
|
| 62 |
+
// Optimistically update UI
|
| 63 |
+
this.cards = this.cards.filter(c => c.id !== cardId);
|
| 64 |
+
this.hasDismissedCards = true;
|
| 65 |
+
},
|
| 66 |
+
|
| 67 |
+
dismissFeatureCards() {
|
| 68 |
+
const featureIds = this.featureCards
|
| 69 |
+
.filter((card) => card.dismissible !== false)
|
| 70 |
+
.map((card) => card.id)
|
| 71 |
+
.filter(Boolean);
|
| 72 |
+
|
| 73 |
+
if (!featureIds.length) return;
|
| 74 |
+
|
| 75 |
+
const dismissed = this._getDismissedIds();
|
| 76 |
+
const dismissSet = new Set(featureIds);
|
| 77 |
+
for (const id of dismissSet) {
|
| 78 |
+
dismissed.add(id);
|
| 79 |
+
}
|
| 80 |
+
this._persistDismissedIds(dismissed);
|
| 81 |
+
|
| 82 |
+
this.cards = this.cards.filter((card) => !dismissSet.has(card.id));
|
| 83 |
+
this.hasDismissedCards = true;
|
| 84 |
+
},
|
| 85 |
+
|
| 86 |
+
undismissCards() {
|
| 87 |
+
localStorage.removeItem(STORAGE_KEY);
|
| 88 |
+
this.refreshCards();
|
| 89 |
+
},
|
| 90 |
+
|
| 91 |
+
async executeCta(action) {
|
| 92 |
+
if (!action) return;
|
| 93 |
+
|
| 94 |
+
try {
|
| 95 |
+
if (action === "open-plugin-hub") {
|
| 96 |
+
await pluginListStore.open("pluginHub");
|
| 97 |
+
return;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
if (action.startsWith("open-plugin-config:")) {
|
| 101 |
+
const pluginName = action.split(":")[1];
|
| 102 |
+
await pluginListStore.openPluginConfig(pluginName);
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
if (action.startsWith("open-url:")) {
|
| 107 |
+
const url = action.slice("open-url:".length);
|
| 108 |
+
if (url) {
|
| 109 |
+
window.open(url, "_blank", "noopener,noreferrer");
|
| 110 |
+
}
|
| 111 |
+
return;
|
| 112 |
+
}
|
| 113 |
+
} catch (error) {
|
| 114 |
+
console.error("Discovery action failed:", error);
|
| 115 |
+
const message = error instanceof Error ? error.message : String(error);
|
| 116 |
+
await toastFrontendError(message, "Discovery");
|
| 117 |
+
}
|
| 118 |
+
},
|
| 119 |
+
|
| 120 |
+
usageWidth(window) {
|
| 121 |
+
const value = Math.max(0, Math.min(100, this.remainingPercent(window)));
|
| 122 |
+
return `${value}%`;
|
| 123 |
+
},
|
| 124 |
+
|
| 125 |
+
remainingPercent(window) {
|
| 126 |
+
const remaining = Number(window?.remaining_percent);
|
| 127 |
+
if (Number.isFinite(remaining)) return remaining;
|
| 128 |
+
const used = Number(window?.used_percent);
|
| 129 |
+
if (Number.isFinite(used)) return 100 - used;
|
| 130 |
+
return Number.NaN;
|
| 131 |
+
},
|
| 132 |
+
|
| 133 |
+
formatRemainingPercent(window) {
|
| 134 |
+
const number = this.remainingPercent(window);
|
| 135 |
+
if (!Number.isFinite(number)) return "0%";
|
| 136 |
+
return `${Math.round(number * 10) / 10}% left`;
|
| 137 |
+
},
|
| 138 |
+
|
| 139 |
+
formatReset(window) {
|
| 140 |
+
const seconds = Number(window?.reset_at || 0);
|
| 141 |
+
if (!Number.isFinite(seconds) || seconds <= 0) return "";
|
| 142 |
+
const remainingMs = Math.max(0, seconds * 1000 - Date.now());
|
| 143 |
+
const minutes = Math.round(remainingMs / 60000);
|
| 144 |
+
if (minutes < 60) return `${minutes}m`;
|
| 145 |
+
const hours = Math.round(minutes / 60);
|
| 146 |
+
if (hours < 48) return `${hours}h`;
|
| 147 |
+
return `${Math.round(hours / 24)}d`;
|
| 148 |
+
},
|
| 149 |
+
|
| 150 |
+
// --- Helpers (Private-ish) ---
|
| 151 |
+
|
| 152 |
+
_getDismissedIds() {
|
| 153 |
+
try {
|
| 154 |
+
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
| 155 |
+
if (!Array.isArray(raw)) return new Set();
|
| 156 |
+
return new Set(raw);
|
| 157 |
+
} catch {
|
| 158 |
+
return new Set();
|
| 159 |
+
}
|
| 160 |
+
},
|
| 161 |
+
|
| 162 |
+
_persistDismissedIds(ids) {
|
| 163 |
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(ids)));
|
| 164 |
+
},
|
| 165 |
+
|
| 166 |
+
// --- Computed ---
|
| 167 |
+
|
| 168 |
+
get topHeroCards() {
|
| 169 |
+
return this.cards.filter((card) => card.type === "hero" && card.placement !== "after-features");
|
| 170 |
+
},
|
| 171 |
+
|
| 172 |
+
get bottomHeroCards() {
|
| 173 |
+
return this.cards.filter((card) => card.type === "hero" && card.placement === "after-features");
|
| 174 |
+
},
|
| 175 |
+
|
| 176 |
+
get oauthAccountCards() {
|
| 177 |
+
return this.bottomHeroCards.filter((card) => card.id === "discovery-oauth-accounts");
|
| 178 |
+
},
|
| 179 |
+
|
| 180 |
+
get heroCards() {
|
| 181 |
+
return [...this.topHeroCards, ...this.bottomHeroCards];
|
| 182 |
+
},
|
| 183 |
+
|
| 184 |
+
get featureCards() {
|
| 185 |
+
return this.cards.filter((card) => card.type === "feature");
|
| 186 |
+
},
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
const store = createStore("discoveryStore", model);
|
| 190 |
+
export { store };
|
plugins/_discovery/webui/thumbnail.jpg
ADDED
|
|
plugins/_document_query/AGENTS.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Document Query Plugin DOX
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
|
| 5 |
+
- Own document loading, parsing, indexing, and Q&A over local and remote documents.
|
| 6 |
+
|
| 7 |
+
## Ownership
|
| 8 |
+
|
| 9 |
+
- `tools/document_query.py` owns the agent-facing query tool.
|
| 10 |
+
- `helpers/` owns fetching, parser routing, indexing, and query orchestration.
|
| 11 |
+
- `prompts/` owns document-query tool and optimization prompts.
|
| 12 |
+
- `hooks.py`, `default_config.yaml`, `plugin.yaml`, `skills/`, `extensions/`, and `webui/` own dependency bootstrap, settings, metadata, skill guidance, hooks, and UI.
|
| 13 |
+
|
| 14 |
+
## Local Contracts
|
| 15 |
+
|
| 16 |
+
- Respect configured size, timeout, retry, parser concurrency, and OCR limits.
|
| 17 |
+
- Fetch local and remote resources safely and avoid leaking document contents beyond intended prompt/tool use.
|
| 18 |
+
- Keep optional dependency installation targeted to the framework runtime.
|
| 19 |
+
|
| 20 |
+
## Work Guidance
|
| 21 |
+
|
| 22 |
+
- Coordinate parser changes with timeout handling and fallback behavior.
|
| 23 |
+
- Keep prompt and skill guidance clear that image files, screenshots, scans, charts, photos, and diagrams should go to vision tools first when available; `document_query` image OCR is a text-focused fallback when vision is unavailable or cannot read the needed text.
|
| 24 |
+
|
| 25 |
+
## Verification
|
| 26 |
+
|
| 27 |
+
- Smoke-test representative PDF, text/HTML, image/OCR, and remote-document queries after parser changes.
|
| 28 |
+
|
| 29 |
+
## Child DOX Index
|
| 30 |
+
|
| 31 |
+
No child DOX files.
|
plugins/_document_query/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Document Query Plugin
|
| 2 |
+
|
| 3 |
+
Load, parse, index, and Q&A over local and remote documents with configurable
|
| 4 |
+
timeouts and thread-safe parsers.
|
| 5 |
+
|
| 6 |
+
## Features
|
| 7 |
+
|
| 8 |
+
- **Strategy-pattern parsers** - MIME-type routing to dedicated parser classes
|
| 9 |
+
- **Centralized fetching** - local and HTTP(S) resources are fetched once, size-checked, then passed to parsers
|
| 10 |
+
- **LiteParse first path** - fast local parsing for PDFs and supported document/image formats, with legacy fallbacks
|
| 11 |
+
- **Adaptive OCR** - long PDFs skip OCR automatically to avoid pathological parse times
|
| 12 |
+
- **Adaptive indexing** - very large extracted documents increase chunk size to keep embedding work bounded
|
| 13 |
+
- **Bounded parser execution** - sync parsers are offloaded to asyncio.to_thread and globally capped across chats
|
| 14 |
+
- **Configurable timeouts** - per-document and gather-level timeouts
|
| 15 |
+
- **Expanded format support** - PDF, HTML, text, YAML, XML, TOML, JS, TS, images, and catch-all Unstructured
|
| 16 |
+
|
| 17 |
+
## Configuration
|
| 18 |
+
|
| 19 |
+
See default_config.yaml for all options. Key settings:
|
| 20 |
+
|
| 21 |
+
| Setting | Default | Description |
|
| 22 |
+
|---------|---------|-------------|
|
| 23 |
+
| fetch_timeout | 30 | HTTP fetch timeout (seconds) |
|
| 24 |
+
| fetch_retries | 3 | HTTP retry attempts |
|
| 25 |
+
| max_remote_bytes | 52428800 | Max remote document size |
|
| 26 |
+
| per_document_timeout | 60 | Max time for a single document parse |
|
| 27 |
+
| gather_timeout | 120 | Max time for all documents combined |
|
| 28 |
+
| parser_concurrency | 1 | Max parser jobs running across all chats in one process |
|
| 29 |
+
| context_intro_chunks | 2 | Leading chunks included per document for title/abstract grounding |
|
| 30 |
+
| chunk_size | 1000 | Text splitter chunk size |
|
| 31 |
+
| chunk_overlap | 100 | Text splitter overlap |
|
| 32 |
+
| max_index_chunks | 1200 | Maximum indexed chunks before adaptive chunk sizing, or 0 for no cap |
|
| 33 |
+
| search_threshold | 0.5 | Similarity search threshold |
|
| 34 |
+
| liteparse_enabled | true | Prefer LiteParse before legacy parser fallbacks |
|
| 35 |
+
| liteparse_num_workers | 2 | Max LiteParse OCR workers per parser job |
|
| 36 |
+
| liteparse_ocr_auto_disable_pages | 30 | Disable OCR for PDFs at or above this effective page count |
|
| 37 |
+
| thread_offload | true | Offload sync parsers to thread pool |
|
| 38 |
+
|
| 39 |
+
LiteParse is installed into the Agent Zero framework runtime from hooks.py during
|
| 40 |
+
plugin install/startup. If installation fails, the plugin logs the error and
|
| 41 |
+
continues with the legacy parser fallbacks.
|
| 42 |
+
|
| 43 |
+
LiteParse always runs in a child process so native parser and OCR failures stay
|
| 44 |
+
isolated from the Web UI process.
|
| 45 |
+
|
| 46 |
+
## Parsers
|
| 47 |
+
|
| 48 |
+
| Parser | MIME Types | Backend |
|
| 49 |
+
|--------|-----------|---------|
|
| 50 |
+
| LiteParseParser | PDF, Office/OpenDocument, images | LiteParse |
|
| 51 |
+
| PdfParser | application/pdf | PyMuPDF + Tesseract OCR fallback |
|
| 52 |
+
| HtmlParser | text/html | Markdownify transformer |
|
| 53 |
+
| TextParser | text/*, application/json, YAML, XML, TOML, JS, TS, shell | Direct read |
|
| 54 |
+
| ImageParser | image/* | UnstructuredLoader |
|
| 55 |
+
| UnstructuredParser | * (catch-all) | UnstructuredLoader hi-res |
|
| 56 |
+
|
| 57 |
+
## Adding a new parser
|
| 58 |
+
|
| 59 |
+
1. Create helpers/parsers/<format>.py extending BaseParser
|
| 60 |
+
2. Set mimetypes class attribute
|
| 61 |
+
3. Implement _parse_sync(document, config)
|
| 62 |
+
4. Register in helpers/parsers/__init__.py
|