diff --git a/dots/.config/quickshell/ii/modules/common/models/gCloud/GCloudApi.qml b/dots/.config/quickshell/ii/modules/common/models/gCloud/GCloudApi.qml new file mode 100644 index 000000000..a314909fd --- /dev/null +++ b/dots/.config/quickshell/ii/modules/common/models/gCloud/GCloudApi.qml @@ -0,0 +1,52 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import qs.modules.common.functions +import qs.modules.common.utils +import qs.services +import qs.modules.common +import ".." + +NestableObject { + id: root + + enum State { + Done, Preparing, Processing, Error + } + + signal finished() + signal error(message: string) + property int errorCode + property string errorMessage: "" + property var outputData + property var state: GCloudApi.State.Done + + function resetState() { + root.state = GCloudApi.State.Done; + root.errorMessage = ""; + root.outputData = undefined; + } + + function handleApiOutput(out: string): bool { + try { + root.outputData = JSON.parse(out); + if (outputData.error) { + print("API error: " + JSON.stringify(outputData.error, null, 2)) + root.state = GCloudApi.State.Error; + root.errorCode = outputData.error.code; + root.errorMessage = outputData.error.message; + root.error(outputData.error.message); + return false; + } + root.finished(); + root.state = GCloudApi.State.Done; + return true + } catch (e) { + print("Failed to parse API response: " + e + "\n" + out) + root.state = GCloudApi.State.Error; + root.errorMessage = "Failed to parse API response"; + root.error(root.errorMessage); + return false; + } + } +} diff --git a/dots/.config/quickshell/ii/modules/common/models/gCloud/GCloudTranslate.qml b/dots/.config/quickshell/ii/modules/common/models/gCloud/GCloudTranslate.qml index d117e8d3a..01604ac0a 100644 --- a/dots/.config/quickshell/ii/modules/common/models/gCloud/GCloudTranslate.qml +++ b/dots/.config/quickshell/ii/modules/common/models/gCloud/GCloudTranslate.qml @@ -5,17 +5,9 @@ import qs.modules.common.utils import qs.services import ".." -NestableObject { +GCloudApi { id: root - enum State { - Done, Preparing, Processing - } - - signal finished() - property var outputData - property var state: GCloudTranslate.State.Done - property list pendingStrings property bool setupReady: false readonly property bool preparationReady: GoogleCloud.tokenReady && setupReady @@ -24,13 +16,13 @@ NestableObject { GoogleCloud.load(); root.setupReady = false; root.pendingStrings = strings; - root.state = GCloudTranslate.State.Preparing; + root.state = GCloudApi.State.Preparing; root.setupReady = true; } onPreparationReadyChanged: { if (!preparationReady) return; - root.state = GCloudTranslate.State.Processing; + root.state = GCloudApi.State.Processing; const targetLang = Translation.languageCode; const payload = { @@ -53,11 +45,7 @@ NestableObject { ]); seq.push(((out) => { - // print(out) - root.outputData = JSON.parse(out); - root.pendingStrings = []; - root.finished(); - root.state = GCloudTranslate.State.Done; + root.handleApiOutput(out); })); multiproc.runSequence(seq); diff --git a/dots/.config/quickshell/ii/modules/common/models/gCloud/GCloudVision.qml b/dots/.config/quickshell/ii/modules/common/models/gCloud/GCloudVision.qml index 3e02fd561..0f5553c7f 100644 --- a/dots/.config/quickshell/ii/modules/common/models/gCloud/GCloudVision.qml +++ b/dots/.config/quickshell/ii/modules/common/models/gCloud/GCloudVision.qml @@ -7,18 +7,9 @@ import qs.services import qs.modules.common import ".." -NestableObject { +GCloudApi { id: root - enum State { - Done, Uploading, Processing, Error - } - - signal finished() - signal error() - property var outputData - property var state: GCloudVision.State.Done - readonly property string imageBase64FilePath: `${Directories.screenshotTemp}/vision_base64.txt` readonly property string payloadFilePath: `${Directories.screenshotTemp}/vision_payload.json` property string uploadEndpoint: "https://uguu.se/upload" @@ -28,7 +19,8 @@ NestableObject { readonly property bool preparationReady: tokenReady && onlineImageReady function annotateImage(imageUri: string) { - root.state = GCloudVision.State.Uploading; + resetState(); + root.state = GCloudApi.State.Preparing; root.onlineImageReady = false GoogleCloud.load(); @@ -49,11 +41,11 @@ NestableObject { onPreparationReadyChanged: { if (!preparationReady) return; if (GoogleCloud.tokenError || GoogleCloud.keyError) { - root.state = GCloudVision.State.Error; - root.error(); + root.state = GCloudApi.State.Error; + root.error(Translation.tr("Set your Google Cloud service account key")); return; } - root.state = GCloudVision.State.Processing; + root.state = GCloudApi.State.Processing; var seq = []; // command sequence // Construct the JSON payload using jq to read from the base64 file @@ -75,9 +67,7 @@ https://vision.googleapis.com/v1/images:annotate \ ]); seq.push((out) => { - root.outputData = JSON.parse(out); - root.finished(); - root.state = GCloudVision.State.Done; + root.handleApiOutput(out); }); lookMultiproc.runSequence(seq); diff --git a/dots/.config/quickshell/ii/modules/ii/screenTranslator/ScreenTextOverlay.qml b/dots/.config/quickshell/ii/modules/ii/screenTranslator/ScreenTextOverlay.qml index cd1c6a202..8236fb189 100644 --- a/dots/.config/quickshell/ii/modules/ii/screenTranslator/ScreenTextOverlay.qml +++ b/dots/.config/quickshell/ii/modules/ii/screenTranslator/ScreenTextOverlay.qml @@ -34,6 +34,7 @@ Item { } property bool error: false + property string errorMessage: "" function showError() { error = true; } @@ -45,13 +46,17 @@ Item { cloudVision.annotateImage(screenshotPath); } + function reattemptAsNeeded() { + if (root.visionParagraphs == [] && GoogleCloud.tokenReady && !GoogleCloud.tokenError) { + root.error = false; + cloudVision.annotateImage(root.screenshotPath); + } + } + Connections { target: GoogleCloud - function onTokenChanged() { - if (GoogleCloud.tokenReady && !GoogleCloud.tokenError) { - root.error = false; - cloudVision.annotateImage(root.screenshotPath); - } + function onTokenReadyChanged() { + root.reattemptAsNeeded(); } } @@ -76,15 +81,15 @@ Item { StyledText { anchors.horizontalCenter: parent.horizontalCenter text: { - if (cloudVision.state == GCloudVision.State.Uploading) + if (cloudVision.state == GCloudApi.State.Preparing) return Translation.tr("Uploading image"); - else if (cloudVision.state == GCloudVision.State.Processing) + else if (cloudVision.state == GCloudApi.State.Processing) return Translation.tr("Reading image"); - else if (cloudVision.state == GCloudVision.State.Error) + else if (cloudVision.state == GCloudApi.State.Error) return Translation.tr("Error"); - else if (cloudTrans.state == GCloudTranslate.State.Preparing) + else if (cloudTrans.state == GCloudApi.State.Preparing) return Translation.tr("Getting ready to translate"); - else if (cloudTrans.state == GCloudTranslate.State.Processing) + else if (cloudTrans.state == GCloudApi.State.Processing) return Translation.tr("Translating"); else return " "; @@ -111,19 +116,19 @@ Item { } StyledText { anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(root.windowWidth / 2, 800) * root.scaleFactor horizontalAlignment: Text.AlignHCenter textFormat: Text.MarkdownText - text: `**${Translation.tr("Screen Translator")}**\n\n${Translation.tr("Set your Google Cloud service account key")}\n\n__[${Translation.tr("See how on the wiki")}](${root.wikiLink})__` + wrapMode: Text.Wrap + text: `**${Translation.tr("Screen Translator")}**\n\n${root.errorMessage}\n\n__[${Translation.tr("See setup instructions on the wiki")}](${root.wikiLink})__` font.pixelSize: Appearance.font.pixelSize.small * root.scaleFactor color: root.textColor - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { - Qt.openUrlExternally(root.wikiLink) - GlobalStates.screenTranslatorOpen = false - } + onLinkActivated: (link) => { + Qt.openUrlExternally(link) + GlobalStates.screenTranslatorOpen = false } + + PointingHandLinkHover {} } } } @@ -132,10 +137,16 @@ Item { id: gcr } + function handleError(msg) { + if (msg?.length > 0) root.errorMessage = msg; + else root.errorMessage = Translation.tr("Set your Google Cloud service account key"); + root.showError(); + } + GCloudVision { id: cloudVision - onError: { - root.showError(); + onError: (msg) => { + root.handleError(msg); } onFinished: { gcr.initializeWithData(outputData); @@ -149,6 +160,9 @@ Item { GCloudTranslate { id: cloudTrans + onError: (msg) => { + root.handleError(msg); + } onFinished: { var values = outputData.translations.map(translation => translation.translatedText); const keys = root.translationKeys; diff --git a/dots/.config/quickshell/ii/services/GoogleCloud.qml b/dots/.config/quickshell/ii/services/GoogleCloud.qml index e90ae5dfd..9cb4e8f99 100644 --- a/dots/.config/quickshell/ii/services/GoogleCloud.qml +++ b/dots/.config/quickshell/ii/services/GoogleCloud.qml @@ -8,10 +8,11 @@ Singleton { id: root property var keyContent: ({}) - property string keyProjectId: keyContent.project_id + property string keyProjectId: keyContent?.project_id property bool keyError: false property bool keyReady: false property string token: "" + property date tokenExpiry property bool tokenError: false property bool tokenReady: false readonly property string projectId: keyProjectId @@ -21,12 +22,27 @@ Singleton { readonly property string tokenForKeyScriptPath: Quickshell.shellPath("services/gCloud/token-from-key-venv.sh") function load() { - // Dummy for init + // Init load will be handled by Component.onCompleted + if (!tokenReady) return; + // We just reload if key expired + if (new Date() >= root.tokenExpiry) { + root.tokenReady = false; + root.keyReady = false; + loadKeyIfPossible(); + } + } + + function unready() { + root.keyReady = false; + root.tokenReady = false; + root.keyError = false; + root.tokenError = false; } function setKeyJson(str: string): bool { try { var keyData = JSON.parse(str) + root.unready(); KeyringStorage.setNestedField(["googleCloud", "serviceAccountKey"], keyData); return true; } catch(e) { @@ -41,20 +57,26 @@ Singleton { return; } tokenProc.runSequence([(() => { // prep token fetcher - tokenProc.environment.SERVICE_KEY_CONTENT = JSON.stringify(root.keyContent); - tokenProc.command = [ // - "bash", "-c" // - , `${tokenForKeyScriptPath} "$SERVICE_KEY_CONTENT"`]; - }), [] // run token fetcher - , (out => { - if (out.startsWith("Error")) { - root.tokenError = true; - } else { - root.tokenError = false; - root.token = out.trim(); - } - root.tokenReady = true; - })]); + tokenProc.environment.SERVICE_KEY_CONTENT = JSON.stringify(root.keyContent); + tokenProc.command = [ // + "bash", "-c" // + , `${tokenForKeyScriptPath} "$SERVICE_KEY_CONTENT"`]; + }), // + [], // run token fetcher + ((out) => { + try { + const data = JSON.parse(out) + root.token = data.token + // Js wants millis instead of seconds + root.tokenExpiry = new Date(data.expiry * 1000) + root.tokenError = false; + } catch(e) { + root.tokenError = true; + print("[GoogleCloud] Failed to parse token response: " + e + "\n" + out) + } + root.tokenReady = true; + } + )]); } function loadKeyIfPossible() { diff --git a/dots/.config/quickshell/ii/services/gCloud/token_from_key.py b/dots/.config/quickshell/ii/services/gCloud/token_from_key.py index f40a50661..0a0a21a9e 100755 --- a/dots/.config/quickshell/ii/services/gCloud/token_from_key.py +++ b/dots/.config/quickshell/ii/services/gCloud/token_from_key.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import calendar import sys import json import google.auth.transport.requests @@ -16,8 +17,15 @@ def get_token(json_str): # Refresh to get the access token request = google.auth.transport.requests.Request() scoped_creds.refresh(request) + + token = scoped_creds.token + expiry = int(calendar.timegm(scoped_creds.expiry.utctimetuple())) - print(scoped_creds.token) + print(json.dumps({ + "token": token, + "expiry": expiry + })) + except Exception as e: sys.stderr.write(f"Error: {str(e)}\n") sys.exit(1)