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}"
+