diff --git a/octoprint_tailscale_funnel/octoprint_tailscale_funnel/static/js/tailscale_funnel.js b/octoprint_tailscale_funnel/octoprint_tailscale_funnel/static/js/tailscale_funnel.js index bf0d512..ef8d1ac 100644 --- a/octoprint_tailscale_funnel/octoprint_tailscale_funnel/static/js/tailscale_funnel.js +++ b/octoprint_tailscale_funnel/octoprint_tailscale_funnel/static/js/tailscale_funnel.js @@ -11,6 +11,13 @@ $(function() { self.tailscaleInstalled = ko.observable(true); self.tailscaleRunning = ko.observable(true); + // Computed URL used across UI and copy handler + self.currentUrl = ko.pureComputed(function() { + var url = (typeof self.publicUrl === 'function') ? self.publicUrl() : self.publicUrl; + if (!url || url === "Not available") return ""; + return ("" + url).trim(); + }); + // Button states self.refreshInProgress = ko.observable(false); self.toggleInProgress = ko.observable(false); @@ -126,38 +133,39 @@ $(function() { }); }; - // Copy URL to clipboard - self.copyUrlToClipboard = function() { - if (self.publicUrl() && self.publicUrl() !== "Not available") { - navigator.clipboard.writeText(self.publicUrl()).then(function() { + // Copy URL to clipboard (robust, event-safe) + self.copyUrlToClipboard = function(_, event) { + if (event && event.stopPropagation) event.stopPropagation(); + var url = self.currentUrl(); + if (!url) return; + (navigator.clipboard && navigator.clipboard.writeText ? navigator.clipboard.writeText(url) : Promise.reject()).then(function() { + new PNotify({ + title: "Copied to Clipboard", + text: "Public URL copied to clipboard", + type: "success" + }); + }, function() { + // Fallback for older browsers + var textArea = document.createElement("textarea"); + textArea.value = url; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand("copy"); new PNotify({ title: "Copied to Clipboard", text: "Public URL copied to clipboard", type: "success" }); - }, function() { - // Fallback for older browsers - var textArea = document.createElement("textarea"); - textArea.value = self.publicUrl(); - document.body.appendChild(textArea); - textArea.select(); - try { - document.execCommand("copy"); - new PNotify({ - title: "Copied to Clipboard", - text: "Public URL copied to clipboard", - type: "success" - }); - } catch (err) { - new PNotify({ - title: "Copy Failed", - text: "Failed to copy URL to clipboard", - type: "error" - }); - } - document.body.removeChild(textArea); - }); - } + } catch (err) { + new PNotify({ + title: "Copy Failed", + text: "Failed to copy URL to clipboard", + type: "error" + }); + } + document.body.removeChild(textArea); + }); }; // Handle messages from the backend diff --git a/octoprint_tailscale_funnel/octoprint_tailscale_funnel/tailscale_interface.py b/octoprint_tailscale_funnel/octoprint_tailscale_funnel/tailscale_interface.py index 15ca9fc..c2ceb10 100644 --- a/octoprint_tailscale_funnel/octoprint_tailscale_funnel/tailscale_interface.py +++ b/octoprint_tailscale_funnel/octoprint_tailscale_funnel/tailscale_interface.py @@ -2,6 +2,7 @@ from __future__ import absolute_import import subprocess +import time import json import re @@ -201,22 +202,49 @@ class TailscaleInterface: if not self.is_tailscale_installed(): raise TailscaleNotInstalledError("Tailscale is not installed") - # Try new CLI first, then legacy - cmds = [ - # Newer: turn off serve mapping and remove funnel config - "tailscale serve --http={p} off".format(p=port), + # Execute multiple disable attempts tolerantly (idempotent) + commands = [ + # Try to disable funnel first (new + legacy) "tailscale funnel reset", - # Legacy "tailscale funnel {p} off".format(p=port), + # Then disable serve mappings (newer variants) + "tailscale serve --http={p} off".format(p=port), + "tailscale serve --https={p} off".format(p=port), ] - result = self._run_first_success(cmds) - if result["success"]: - # Double-check disabled + + any_succeeded = False + for cmd in commands: + res = self._run_first_success([cmd]) + if res and res.get("success"): + any_succeeded = True + else: + # Treat "not enabled"/"no such" errors as harmless for idempotency + err = (res or {}).get("error") or "" + if isinstance(err, str) and ( + "not enabled" in err.lower() + or "no handlers" in err.lower() + or "no such" in err.lower() + or "not found" in err.lower() + or "already off" in err.lower() + ): + any_succeeded = True + + # Poll for a short time until status reflects disabled + for _ in range(6): # ~3.0s total try: - return not self.is_funnel_enabled() + if not self.is_funnel_enabled(): + return True except Exception: - return True - return False + # If status check fails, assume disabled if we attempted commands + if any_succeeded: + return True + time.sleep(0.5) + + # Final status check + try: + return not self.is_funnel_enabled() + except Exception: + return any_succeeded def get_public_url(self): """ diff --git a/octoprint_tailscale_funnel/octoprint_tailscale_funnel/templates/tailscale_funnel_settings.jinja2 b/octoprint_tailscale_funnel/octoprint_tailscale_funnel/templates/tailscale_funnel_settings.jinja2 index 59347a1..0249dcd 100644 --- a/octoprint_tailscale_funnel/octoprint_tailscale_funnel/templates/tailscale_funnel_settings.jinja2 +++ b/octoprint_tailscale_funnel/octoprint_tailscale_funnel/templates/tailscale_funnel_settings.jinja2 @@ -34,9 +34,9 @@ Not available - + + Open + Public URL for accessing your OctoPrint instance diff --git a/octoprint_tailscale_funnel/setup.py b/octoprint_tailscale_funnel/setup.py index 3170d7f..85e5bb9 100644 --- a/octoprint_tailscale_funnel/setup.py +++ b/octoprint_tailscale_funnel/setup.py @@ -14,7 +14,7 @@ plugin_package = "octoprint_tailscale_funnel" plugin_name = "OctoPrint-Tailscale-Funnel" # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module -plugin_version = "0.1.5" +plugin_version = "0.1.6.1" # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin # module diff --git a/octoprint_tailscale_funnel/update.json b/octoprint_tailscale_funnel/update.json index ae32c43..c1d6bee 100644 --- a/octoprint_tailscale_funnel/update.json +++ b/octoprint_tailscale_funnel/update.json @@ -1,4 +1,4 @@ { - "version": "0.1.5" + "version": "0.1.6.1" } diff --git a/scripts/release_gitea.sh b/scripts/release_gitea.sh new file mode 100644 index 0000000..21c781e --- /dev/null +++ b/scripts/release_gitea.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Load .env if present +if [ -f "$(dirname "$0")/../.env" ]; then + set -a + # shellcheck disable=SC1091 + . "$(dirname "$0")/../.env" + set +a +fi + +API_URL=${GITEA_API_URL:-"https://gitea.elpatron.me/api/v1"} +OWNER=${GITEA_OWNER:-"elpatron"} +REPO=${GITEA_REPO:-"octo-funnel"} +TOKEN=${GITEA_API_TOKEN:-""} + +TAG="" +NAME="" +ASSET_PATH="" +BODY_FILE="" + +usage() { + echo "Usage: $0 -t -a [-n ] [-b ]" >&2 +} + +while getopts ":t:a:n:b:" opt; do + case $opt in + t) TAG="$OPTARG" ;; + a) ASSET_PATH="$OPTARG" ;; + n) NAME="$OPTARG" ;; + b) BODY_FILE="$OPTARG" ;; + *) usage; exit 2 ;; + esac +done + +if [ -z "$TAG" ] || [ -z "$ASSET_PATH" ]; then + usage; exit 2 +fi +if [ -z "$NAME" ]; then NAME="$TAG"; fi +if [ ! -f "$ASSET_PATH" ]; then + echo "Asset not found: $ASSET_PATH" >&2; exit 1 +fi +if [ -z "$TOKEN" ]; then + echo "GITEA_API_TOKEN not set (in .env)." >&2; exit 1 +fi + +BODY="Tailscale Funnel Plugin ${TAG}\n\nAutomated release." +if [ -n "$BODY_FILE" ] && [ -f "$BODY_FILE" ]; then + BODY=$(cat "$BODY_FILE") +fi + +# Build JSON payload via python (robust quoting) +create_payload=$(REL_TAG="$TAG" REL_NAME="$NAME" REL_BODY_TXT="$BODY" python3 - <<'PY' +import json, os +payload = { + "tag_name": os.environ["REL_TAG"], + "name": os.environ["REL_NAME"], + "body": os.environ["REL_BODY_TXT"], + "draft": False, + "prerelease": False +} +print(json.dumps(payload)) +PY +) + +create_resp=$(curl -sS -X POST \ + -H 'Content-Type: application/json' \ + -H "Authorization: token ${TOKEN}" \ + -d "$create_payload" \ + "${API_URL}/repos/${OWNER}/${REPO}/releases" || true) + +# Extract id; if missing, fetch by tag +rel_id=$(python3 - <<'PY' +import sys, json +data=sys.stdin.read().strip() +if not data: + print("") +else: + try: + obj=json.loads(data) + print(obj.get('id','')) + except Exception: + print("") +PY +<<<"$create_resp") + +if [ -z "$rel_id" ]; then + get_resp=$(curl -sS -H "Authorization: token ${TOKEN}" "${API_URL}/repos/${OWNER}/${REPO}/releases/tags/${TAG}") + rel_id=$(python3 - <<'PY' +import sys, json +obj=json.loads(sys.stdin.read()) +print(obj.get('id','')) +PY +<<<"$get_resp") +fi + +if [ -z "$rel_id" ]; then + echo "Failed to get create/fetch release id" >&2 + echo "$create_resp" | head -c 400 >&2 + exit 1 +fi + +# Delete existing asset with same name (if any) +assets_json=$(curl -sS -H "Authorization: token ${TOKEN}" "${API_URL}/repos/${OWNER}/${REPO}/releases/${rel_id}/assets") +asset_id=$(python3 - <<'PY' +import sys, json, os +name=os.environ['ASSET_NAME'] +try: + arr=json.loads(sys.stdin.read()) + for a in arr: + if a.get('name')==name: + print(a.get('id','')) + break +except Exception: + pass +PY +ASSET_NAME="$(basename "$ASSET_PATH")" <<<"$assets_json") + +if [ -n "$asset_id" ]; then + curl -sS -X DELETE -H "Authorization: token ${TOKEN}" "${API_URL}/repos/${OWNER}/${REPO}/releases/${rel_id}/assets/${asset_id}" >/dev/null || true +fi + +# Upload asset +upload_resp=$(curl -sS -H "Authorization: token ${TOKEN}" -F attachment=@"${ASSET_PATH}" "${API_URL}/repos/${OWNER}/${REPO}/releases/${rel_id}/assets?name=$(basename "$ASSET_PATH")") + +html_url=$(python3 - <<'PY' +import sys, json +try: + print(json.loads(sys.stdin.read()).get('browser_download_url','')) +except Exception: + print("") +PY +<<<"$upload_resp") + +echo "Release ${TAG} created (id=${rel_id}). Asset uploaded: ${html_url}" +