commit 9757aeab85ff7f28aebec73a499c93b41b5122b2 Author: Matteo Benedetto Date: Fri Aug 21 16:37:34 2026 +0200 feat: initial commit for Antigravity Usage Plasmoid (Plasma 6) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c4da97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +dist/ +*.tar.gz +.DS_Store +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..57eb7ec --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Antigravity Usage Plasmoid (`org.enne2.antigravity.usage`) + +Widget desktop per **KDE Plasma 6** che visualizza in tempo reale le quote di utilizzo e i countdown di reset dei modelli disponibili tramite Google CloudCode PA (Antigravity): + +- **Gemini 3.x** (Gemini 3.1 Pro, Gemini 3.5/3.6/3.7 Flash) +- **Claude 4.6** (Claude Sonnet 4.6, Claude Opus 4.6 Thinking) +- **GPT-OSS** (GPT-OSS 120B Medium) + +--- + +## Caratteristiche + +- **3 card di monitoraggio**: barra percentuale residua, percentuale usata e countdown preciso al reset della quota. +- **Autenticazione OAuth e Refresh automatico**: legge il token da `~/.gemini/antigravity-cli/antigravity-oauth-token` ed esegue il refresh OAuth se necessario senza interruzione. +- **Colori adattivi**: soglie configurabili per avviso (giallo) e critico (rosso), con accento cyberpunk indaco/viola. +- **Formato Desktop**: progettato per la griglia desktop (224×224 px), integrabile a fianco dei widget OpenAI Codex e Ollama Cloud. + +--- + +## Installazione + +```bash +# Packaging +tar -czvf dist/org.enne2.antigravity.usage.tar.gz -C . \ + metadata.json contents/ + +# Installazione Plasma 6 +kpackagetool6 -t Plasma/Applet --install dist/org.enne2.antigravity.usage.tar.gz + +# Oppure aggiornamento +kpackagetool6 -t Plasma/Applet --upgrade dist/org.enne2.antigravity.usage.tar.gz +``` + +--- + +## Licenza + +GPL-3.0 diff --git a/contents/config/main.xml b/contents/config/main.xml new file mode 100644 index 0000000..ef9b5db --- /dev/null +++ b/contents/config/main.xml @@ -0,0 +1,28 @@ + + + + + + 300 + 30 + 3600 + + Quanto spesso il plasmoide aggiorna le quote Antigravity. Minimo 30s. Default 300s (5 minuti). + + + 60 + 0 + 100 + + + + 30 + 0 + 100 + + + + diff --git a/contents/scripts/usage.sh b/contents/scripts/usage.sh new file mode 100755 index 0000000..366728c --- /dev/null +++ b/contents/scripts/usage.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Backend per il plasmoide KDE Plasma 6: Antigravity Usage Dashboard. + +Interroga il gateway Google CloudCode PA (https://daily-cloudcode-pa.googleapis.com) +leggendo il token OAuth da ~/.gemini/antigravity-cli/antigravity-oauth-token +ed eseguendo il refresh automatico se scaduto o non valido. +Restituisce lo stato delle quote per le 3 famiglie di modelli: +- Gemini (Gemini 3.x Flash / Pro) +- Claude (Claude Sonnet 4.6 / Opus 4.6) +- GPT-OSS (GPT-OSS 120B) +""" + +import json +import os +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone + +TOKEN_FILE = os.path.expanduser("~/.gemini/antigravity-cli/antigravity-oauth-token") +OAUTH_URL = "https://oauth2.googleapis.com/token" +BASE_URL = "https://daily-cloudcode-pa.googleapis.com" +CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" +CLIENT_SECRET = "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" +UA = "antigravity/cli/1.1.17" +API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1" +METADATA = json.dumps({"ideType": "ANTIGRAVITY", "platform": "LINUX", "pluginType": "GEMINI"}) + + +def get_token_data(): + if not os.path.exists(TOKEN_FILE): + return None + try: + with open(TOKEN_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return None + + +def save_token_data(data): + try: + tmp = TOKEN_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + os.replace(tmp, TOKEN_FILE) + except Exception: + pass + + +def refresh_token(refresh_tok): + req_body = json.dumps({ + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "refresh_token": refresh_tok, + "grant_type": "refresh_token" + }).encode("utf-8") + req = urllib.request.Request(OAUTH_URL, data=req_body, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=15) as resp: + res = json.loads(resp.read().decode("utf-8")) + return res.get("access_token"), int(res.get("expires_in", 3600)) + + +def call_api(path, token, payload): + url = f"{BASE_URL}{path}" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": UA, + "X-Goog-Api-Client": API_CLIENT, + "Client-Metadata": METADATA + } + body = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(url, data=body, headers=headers) + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def parse_iso_epoch(ts): + if not ts: + return 0 + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + return int(dt.timestamp()) + except Exception: + return 0 + + +def main(): + data = get_token_data() + if not data or "token" not in data: + print(json.dumps({ + "ok": False, + "error": "no_token", + "detail": f"Nessun token OAuth in {TOKEN_FILE}" + })) + sys.exit(0) + + tok = data["token"] + access_tok = tok.get("access_token") + refresh_tok = tok.get("refresh_token") + + project_id = "healthy-topic-dwr0v" + try: + quota_data = call_api("/v1internal:retrieveUserQuota", access_tok, {"project": project_id}) + except urllib.error.HTTPError as e: + if e.code in (401, 403) and refresh_tok: + try: + new_acc, exp_in = refresh_token(refresh_tok) + tok["access_token"] = new_acc + save_token_data(data) + access_tok = new_acc + quota_data = call_api("/v1internal:retrieveUserQuota", access_tok, {"project": project_id}) + except Exception as e2: + print(json.dumps({ + "ok": False, + "error": "auth_error", + "detail": f"Refresh fallito: {e2}" + })) + sys.exit(0) + else: + print(json.dumps({ + "ok": False, + "error": f"http_{e.code}", + "detail": f"Errore server Google HTTP {e.code}" + })) + sys.exit(0) + except Exception as e: + print(json.dumps({ + "ok": False, + "error": "network_error", + "detail": str(e) + })) + sys.exit(0) + + buckets = quota_data.get("buckets", []) + bucket_map = {b.get("modelId"): b for b in buckets} + + # 1. Gemini Family + gem_b = (bucket_map.get("gemini-3.1-pro-high") or + bucket_map.get("gemini-3.7-flash-high") or + bucket_map.get("gemini-3.5-flash-low") or {}) + gem_rem = float(gem_b.get("remainingFraction", 1.0)) + gem_reset_str = gem_b.get("resetTime", "") + gemini_info = { + "id": "gemini", + "name": "Gemini 3.x", + "models": "Pro / Flash", + "remaining": round(gem_rem, 4), + "used_percent": round((1.0 - gem_rem) * 100, 1), + "reset_time": gem_reset_str, + "reset_at": parse_iso_epoch(gem_reset_str) + } + + # 2. Claude Family + cld_b = (bucket_map.get("claude-sonnet-4-6") or + bucket_map.get("claude-opus-4-6-thinking") or {}) + cld_rem = float(cld_b.get("remainingFraction", 1.0)) + cld_reset_str = cld_b.get("resetTime", "") + claude_info = { + "id": "claude", + "name": "Claude 4.6", + "models": "Sonnet / Opus", + "remaining": round(cld_rem, 4), + "used_percent": round((1.0 - cld_rem) * 100, 1), + "reset_time": cld_reset_str, + "reset_at": parse_iso_epoch(cld_reset_str) + } + + # 3. GPT-OSS Family + gpt_b = bucket_map.get("gpt-oss-120b-medium") or {} + gpt_rem = float(gpt_b.get("remainingFraction", 1.0)) + gpt_reset_str = gpt_b.get("resetTime", "") + gpt_info = { + "id": "gpt-oss", + "name": "GPT-OSS", + "models": "120B Medium", + "remaining": round(gpt_rem, 4), + "used_percent": round((1.0 - gpt_rem) * 100, 1), + "reset_time": gpt_reset_str, + "reset_at": parse_iso_epoch(gpt_reset_str) + } + + out = { + "ok": True, + "project": project_id, + "groups": [gemini_info, claude_info, gpt_info] + } + print(json.dumps(out, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/contents/ui/configGeneral.qml b/contents/ui/configGeneral.qml new file mode 100644 index 0000000..a39fecf --- /dev/null +++ b/contents/ui/configGeneral.qml @@ -0,0 +1,57 @@ +/* + * configGeneral.qml — pagina di configurazione del plasmoide Antigravity Usage. + */ +import QtQuick +import QtQuick.Layouts +import org.kde.plasma.components 3.0 as PC3 +import org.kde.kirigami as Kirigami + +Item { + id: page + + property alias cfg_refreshIntervalSec: refreshSpin.value + property alias cfg_warnThreshold: warnSpin.value + property alias cfg_critThreshold: critSpin.value + + ColumnLayout { + anchors.fill: parent + anchors.margins: Kirigami.Units.largeSpacing + spacing: Kirigami.Units.smallSpacing + + PC3.Label { + text: i18n("Intervallo di aggiornamento (secondi)") + font.bold: true + } + PC3.SpinBox { + id: refreshSpin + from: 30 + to: 3600 + stepSize: 30 + Layout.fillWidth: true + } + + PC3.Label { + text: i18n("Soglia residuo giallo (%)") + font.bold: true + } + PC3.SpinBox { + id: warnSpin + from: 0 + to: 100 + Layout.fillWidth: true + } + + PC3.Label { + text: i18n("Soglia residuo rosso (%)") + font.bold: true + } + PC3.SpinBox { + id: critSpin + from: 0 + to: 100 + Layout.fillWidth: true + } + + Item { Layout.fillHeight: true } + } +} diff --git a/contents/ui/main.qml b/contents/ui/main.qml new file mode 100644 index 0000000..0ed1fff --- /dev/null +++ b/contents/ui/main.qml @@ -0,0 +1,320 @@ +/* Antigravity Usage — dashboard desktop per KDE Plasma 6. */ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 +import org.kde.plasma.plasmoid +import org.kde.plasma.core as PlasmaCore +import org.kde.plasma.components 3.0 as PC3 +import org.kde.plasma.plasma5support as Plasma5Support +import org.kde.kirigami as Kirigami + +PlasmoidItem { + id: root + + property var data: null // { ok, project, groups: [ { id, name, models, remaining, used_percent, reset_time, reset_at } ] } + property string lastUpdate: "" + property string errorMsg: "" + property bool loading: false + property int clockTick: 0 + + property int refreshSecs: Plasmoid.configuration.refreshIntervalSec + property int warnThreshold: Plasmoid.configuration.warnThreshold + property int critThreshold: Plasmoid.configuration.critThreshold + + readonly property color themeViolet: "#a855f7" + readonly property color themeIndigo: "#818cf8" + readonly property color themeCyan: "#38bdf8" + readonly property color themeGreen: "#4ade80" + + Plasmoid.title: i18n("Antigravity") + Plasmoid.icon: "compass" + Plasmoid.backgroundHints: PlasmaCore.Types.DefaultBackground + toolTipMainText: i18n("Antigravity — quote modelli") + toolTipSubText: root.data && root.data.groups && root.data.groups.length > 0 + ? i18n("Gemini: %1% · Claude: %2%", + Math.round((root.data.groups[0].remaining || 0) * 100), + Math.round((root.data.groups.length > 1 ? root.data.groups[1].remaining || 0 : 0) * 100)) + : (root.errorMsg || "") + + preferredRepresentation: fullRepresentation + switchWidth: 150 + switchHeight: 150 + + Plasma5Support.DataSource { + id: executable + engine: "executable" + connectedSources: [] + + onNewData: function(sourceName, result) { + root.loading = false + executable.disconnectSource(sourceName) + if (result["exit code"] !== 0) { + root.errorMsg = i18n("comando fallito (exit %1)", result["exit code"]) + return + } + try { + const parsed = JSON.parse((result["stdout"] || "").trim()) + if (parsed && parsed.ok === false) { + root.errorMsg = parsed.error + (parsed.detail ? " — " + parsed.detail : "") + root.data = null + } else if (parsed && parsed.ok && parsed.groups) { + root.data = parsed + root.errorMsg = "" + root.lastUpdate = new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss") + } else { + root.errorMsg = i18n("risposta inattesa") + root.data = null + } + } catch (e) { + root.errorMsg = i18n("JSON non valido: %1", e.message) + root.data = null + } + } + } + + function refresh() { + if (root.loading) return + root.loading = true + const path = Qt.resolvedUrl("../scripts/usage.sh").toString().replace(/^file:\/\//, "") + executable.connectSource("/bin/bash " + shellQuote(path)) + } + + function shellQuote(s) { + return "'" + String(s).replace(/'/g, "'\"'\"'") + "'" + } + + function accentColorFor(pct) { + if (pct <= root.critThreshold) return Kirigami.Theme.negativeTextColor + if (pct <= root.warnThreshold) return Kirigami.Theme.neutralTextColor + return root.themeIndigo + } + + function countdownFor(resetAt) { + const tick = root.clockTick + if (!resetAt || resetAt <= 0) return "—" + let mins = Math.max(0, Math.floor((resetAt * 1000 - Date.now()) / 60000)) + if (mins === 0) return i18n("ora") + const days = Math.floor(mins / 1440) + mins -= days * 1440 + const hours = Math.floor(mins / 60) + mins -= hours * 60 + return days > 0 ? i18n("%1g %2h", days, hours) : i18n("%1h %2m", hours, mins) + } + + Component.onCompleted: root.refresh() + + Timer { + interval: Math.max(30, root.refreshSecs) * 1000 + repeat: true + running: true + onTriggered: root.refresh() + } + + Timer { + interval: 30000 + repeat: true + running: true + onTriggered: root.clockTick += 1 + } + + compactRepresentation: Component { + Item { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + Kirigami.Icon { + anchors.fill: parent + source: Plasmoid.icon + color: root.data ? root.themeIndigo : Kirigami.Theme.disabledTextColor + } + MouseArea { + anchors.fill: parent + onClicked: Plasmoid.expanded = !Plasmoid.expanded + } + } + } + + fullRepresentation: Component { + Item { + implicitWidth: 224 + implicitHeight: 224 + Layout.preferredWidth: 224 + Layout.preferredHeight: 224 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 4 + + // Header + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Kirigami.Icon { + source: Plasmoid.icon + color: root.themeViolet + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + } + + ColumnLayout { + spacing: 0 + PC3.Label { + text: i18n("ANTIGRAVITY") + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize + 1 + } + PC3.Label { + text: root.data && root.data.project ? "CLOUDCODE-PA" : "GOOGLE AI" + color: root.themeViolet + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + + Item { Layout.fillWidth: true } + + Rectangle { + Layout.preferredWidth: 8 + Layout.preferredHeight: 8 + radius: 4 + color: root.errorMsg ? Kirigami.Theme.negativeTextColor : root.themeGreen + } + + PC3.Button { + icon.name: "view-refresh" + display: QQC2.AbstractButton.IconOnly + enabled: !root.loading + onClicked: root.refresh() + } + } + + // Separatore + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Qt.rgba(0.66, 0.33, 0.97, 0.35) + } + + // Visualizzazione Errore + PC3.Label { + visible: root.errorMsg.length > 0 + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Kirigami.Theme.negativeTextColor + text: i18n("Errore: %1", root.errorMsg) + } + + // Lista delle 3 quote (Gemini, Claude, GPT-OSS) + Repeater { + model: root.data && root.data.groups ? root.data.groups : [] + + delegate: Rectangle { + required property var modelData + required property int index + + Layout.fillWidth: true + Layout.preferredHeight: 46 + radius: 5 + color: Qt.rgba(0, 0, 0, 0.28) + border.width: 1 + border.color: root.accentColorFor(Math.round((modelData.remaining || 0) * 100)) + + ColumnLayout { + anchors.fill: parent + anchors.margins: 4 + spacing: 2 + + // Riga 1: Nome gruppo + Modelli + % Residuo + RowLayout { + Layout.fillWidth: true + spacing: 4 + + PC3.Label { + text: modelData.name || "" + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 1 + } + + PC3.Label { + text: "· " + (modelData.models || "") + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + Layout.fillWidth: true + elide: Text.ElideRight + } + + PC3.Label { + text: Math.round((modelData.remaining || 0) * 100) + "%" + color: root.accentColorFor(Math.round((modelData.remaining || 0) * 100)) + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize + } + } + + // Riga 2: Barra di avanzamento + Item { + Layout.fillWidth: true + Layout.preferredHeight: 5 + + Rectangle { + anchors.fill: parent + radius: 2.5 + color: Qt.rgba(0, 0, 0, 0.5) + } + + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: parent.width * Math.max(0, Math.min(1, modelData.remaining || 0)) + height: parent.height + radius: 2.5 + color: root.accentColorFor(Math.round((modelData.remaining || 0) * 100)) + } + } + + // Riga 3: Dettaglio usato + Reset countdown + RowLayout { + Layout.fillWidth: true + spacing: 4 + + PC3.Label { + text: i18n("%1% usato", modelData.used_percent !== undefined ? modelData.used_percent : 0) + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + + Item { Layout.fillWidth: true } + + PC3.Label { + text: i18n("reset %1", root.countdownFor(modelData.reset_at)) + color: root.themeIndigo + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + } + } + } + + Item { Layout.fillHeight: true } + + // Footer + RowLayout { + Layout.fillWidth: true + PC3.Label { + text: root.lastUpdate ? i18n("SYNC %1", root.lastUpdate) : i18n("SYNC…") + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + Item { Layout.fillWidth: true } + PC3.Label { + text: "DAILY" + color: root.themeViolet + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + } + } + } +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..1a5a0da --- /dev/null +++ b/metadata.json @@ -0,0 +1,20 @@ +{ + "KPackageStructure": "Plasma/Applet", + "KPlugin": { + "Id": "org.enne2.antigravity.usage", + "Name": "Antigravity Usage", + "Name[it]": "Utilizzo Antigravity", + "Description": "Shows the usage quotas and reset countdowns for Antigravity models (Gemini 3.x, Claude 4.6, GPT-OSS 120B)", + "Description[it]": "Mostra le quote di utilizzo e i countdown di reset per i modelli Antigravity (Gemini 3.x, Claude 4.6, GPT-OSS 120B)", + "Icon": "compass", + "Category": "System Information", + "License": "GPL-3.0", + "Version": "1.0.0", + "Authors": [ + { + "Name": "enne2" + } + ] + }, + "X-Plasma-API-Minimum-Version": "6.0" +}