9 Commits

Author SHA1 Message Date
Markus F.J. Busche
fd5ff20743 Add disclaimer 2025-09-20 19:31:50 +02:00
Markus F.J. Busche
87449641f6 chore: remove scripts/upload_asset_direct.sh (deprecated) 2025-09-20 19:29:49 +02:00
Markus F.J. Busche
731cd207dd chore: stage remaining changes 2025-09-20 18:29:54 +02:00
Markus F.J. Busche
a5489de212 docs: korrigiere Screenshot-Dateiname auf .png 2025-09-20 18:24:47 +02:00
Markus F.J. Busche
c0d4448dc1 docs: symlink docs/ -> octoprint_tailscale_funnel/octoprint_tailscale_funnel/docs 2025-09-20 18:24:24 +02:00
Markus F.J. Busche
0d43e181c8 docs: Screenshot der Funnel-Einstellungen in README verlinkt 2025-09-20 18:18:37 +02:00
Markus F.J. Busche
f60068b01f release: update scripts/release_gitea.sh to jq-based flow; skip existing assets 2025-09-20 18:13:22 +02:00
Markus F.J. Busche
4d484c5023 Bump to v0.1.6.2 2025-09-20 18:07:27 +02:00
Markus F.J. Busche
547cad7a21 v0.1.6.1: UI: Link-Button statt Copy, Disable idempotent; build artifacts updated 2025-09-20 17:48:54 +02:00
9 changed files with 185 additions and 43 deletions

1
docs Symbolic link
View File

@@ -0,0 +1 @@
octoprint_tailscale_funnel/octoprint_tailscale_funnel/docs

View File

@@ -2,6 +2,8 @@
This plugin makes your OctoPrint instance accessible from anywhere via Tailscale Funnel, without needing to configure port forwarding, dynamic DNS, or complex firewall settings. This plugin makes your OctoPrint instance accessible from anywhere via Tailscale Funnel, without needing to configure port forwarding, dynamic DNS, or complex firewall settings.
Disclaimer: *This plugin was partially vibe-coded*.
## Features ## Features
* Enable/disable Tailscale Funnel access directly from OctoPrint's settings * Enable/disable Tailscale Funnel access directly from OctoPrint's settings
@@ -9,6 +11,10 @@ This plugin makes your OctoPrint instance accessible from anywhere via Tailscale
* Display the public URL for accessing OctoPrint remotely * Display the public URL for accessing OctoPrint remotely
* Configure the port to expose via Funnel * Configure the port to expose via Funnel
## Screenshot
![Tailscale Funnel Settings](octoprint_tailscale_funnel/octoprint_tailscale_funnel/docs/screenshots/funnel-settings.png)
## Requirements ## Requirements
* OctoPrint 1.3.0 or higher * OctoPrint 1.3.0 or higher

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -11,6 +11,13 @@ $(function() {
self.tailscaleInstalled = ko.observable(true); self.tailscaleInstalled = ko.observable(true);
self.tailscaleRunning = 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 // Button states
self.refreshInProgress = ko.observable(false); self.refreshInProgress = ko.observable(false);
self.toggleInProgress = ko.observable(false); self.toggleInProgress = ko.observable(false);
@@ -126,10 +133,12 @@ $(function() {
}); });
}; };
// Copy URL to clipboard // Copy URL to clipboard (robust, event-safe)
self.copyUrlToClipboard = function() { self.copyUrlToClipboard = function(_, event) {
if (self.publicUrl() && self.publicUrl() !== "Not available") { if (event && event.stopPropagation) event.stopPropagation();
navigator.clipboard.writeText(self.publicUrl()).then(function() { var url = self.currentUrl();
if (!url) return;
(navigator.clipboard && navigator.clipboard.writeText ? navigator.clipboard.writeText(url) : Promise.reject()).then(function() {
new PNotify({ new PNotify({
title: "Copied to Clipboard", title: "Copied to Clipboard",
text: "Public URL copied to clipboard", text: "Public URL copied to clipboard",
@@ -138,7 +147,7 @@ $(function() {
}, function() { }, function() {
// Fallback for older browsers // Fallback for older browsers
var textArea = document.createElement("textarea"); var textArea = document.createElement("textarea");
textArea.value = self.publicUrl(); textArea.value = url;
document.body.appendChild(textArea); document.body.appendChild(textArea);
textArea.select(); textArea.select();
try { try {
@@ -157,7 +166,6 @@ $(function() {
} }
document.body.removeChild(textArea); document.body.removeChild(textArea);
}); });
}
}; };
// Handle messages from the backend // Handle messages from the backend

View File

@@ -2,6 +2,7 @@
from __future__ import absolute_import from __future__ import absolute_import
import subprocess import subprocess
import time
import json import json
import re import re
@@ -201,22 +202,49 @@ class TailscaleInterface:
if not self.is_tailscale_installed(): if not self.is_tailscale_installed():
raise TailscaleNotInstalledError("Tailscale is not installed") raise TailscaleNotInstalledError("Tailscale is not installed")
# Try new CLI first, then legacy # Execute multiple disable attempts tolerantly (idempotent)
cmds = [ commands = [
# Newer: turn off serve mapping and remove funnel config # Try to disable funnel first (new + legacy)
"tailscale serve --http={p} off".format(p=port),
"tailscale funnel reset", "tailscale funnel reset",
# Legacy
"tailscale funnel {p} off".format(p=port), "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"]: any_succeeded = False
# Double-check disabled 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:
if not self.is_funnel_enabled():
return True
except Exception:
# If status check fails, assume disabled if we attempted commands
if any_succeeded:
return True
time.sleep(0.5)
# Final status check
try: try:
return not self.is_funnel_enabled() return not self.is_funnel_enabled()
except Exception: except Exception:
return True return any_succeeded
return False
def get_public_url(self): def get_public_url(self):
""" """

View File

@@ -34,9 +34,9 @@
<span id="tailscale_funnel_url" class="input-xlarge uneditable-input" data-bind="text: publicUrl"> <span id="tailscale_funnel_url" class="input-xlarge uneditable-input" data-bind="text: publicUrl">
Not available Not available
</span> </span>
<button id="tailscale_funnel_copy_url_btn" class="btn" type="button" data-bind="click: copyUrlToClipboard, enable: publicUrl() && publicUrl() !== 'Not available'"> <a id="tailscale_funnel_open_url_btn" class="btn" target="_blank" rel="noopener" data-bind="attr: { href: currentUrl }, visible: currentUrl()">
<i class="fas fa-copy"></i> Copy <i class="fas fa-external-link-alt"></i> Open
</button> </a>
</div> </div>
<span class="help-block">Public URL for accessing your OctoPrint instance</span> <span class="help-block">Public URL for accessing your OctoPrint instance</span>
</div> </div>

View File

@@ -14,7 +14,7 @@ plugin_package = "octoprint_tailscale_funnel"
plugin_name = "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 # 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.2"
# The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin
# module # module

View File

@@ -1,4 +1,4 @@
{ {
"version": "0.1.5" "version": "0.1.6.2"
} }

99
scripts/release_gitea.sh Normal file
View File

@@ -0,0 +1,99 @@
#!/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 <tag> -a <asset-zip> [-n <name>] [-b <body-file>]" >&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
# Derive asset name early for later use
ASSET_NAME="$(basename "$ASSET_PATH")"
BODY="Tailscale Funnel Plugin ${TAG}\n\nAutomated release."
if [ -n "$BODY_FILE" ] && [ -f "$BODY_FILE" ]; then
BODY=$(cat "$BODY_FILE")
fi
# Try to fetch existing release by tag first
get_resp=$(curl -sS -H "Authorization: token ${TOKEN}" "${API_URL}/repos/${OWNER}/${REPO}/releases/tags/${TAG}" || true)
rel_id=$(echo "$get_resp" | jq -r '.id // empty')
if [ -z "$rel_id" ]; then
# Build minimal JSON payload (use existing tag)
create_payload=$(jq -n --arg tag "$TAG" --arg name "$NAME" --arg body "$BODY" '{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:false}')
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 from create response
rel_id=$(echo "$create_resp" | jq -r '.id // empty')
fi
# Fallback: search releases list for matching tag if still empty
if [ -z "$rel_id" ]; then
list_resp=$(curl -sS -H "Authorization: token ${TOKEN}" "${API_URL}/repos/${OWNER}/${REPO}/releases?limit=100")
rel_id=$(echo "$list_resp" | jq -r --arg tag "$TAG" '[.[] | select(.tag_name==$tag)][0].id // empty')
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
# Check if asset exists (skip upload if present)
assets_json=$(curl -sS -H "Authorization: token ${TOKEN}" "${API_URL}/repos/${OWNER}/${REPO}/releases/${rel_id}/assets")
asset_exists=$(echo "$assets_json" | jq -r --arg name "$ASSET_NAME" 'any(.[]; .name==$name)')
if [ "$asset_exists" = "true" ]; then
echo "Asset already exists, skipping upload: ${ASSET_NAME}"
else
echo "Uploading asset: ${ASSET_NAME}"
upload_resp=$(curl -sS -H "Authorization: token ${TOKEN}" -F attachment=@"${ASSET_PATH}" "${API_URL}/repos/${OWNER}/${REPO}/releases/${rel_id}/assets?name=${ASSET_NAME}")
html_url=$(echo "$upload_resp" | jq -r '.browser_download_url // empty')
echo "Asset uploaded: ${html_url}"
fi
echo "Release ${TAG} ready (id=${rel_id})."