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.
Disclaimer: *This plugin was partially vibe-coded*.
## Features
* 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
* Configure the port to expose via Funnel
## Screenshot
![Tailscale Funnel Settings](octoprint_tailscale_funnel/octoprint_tailscale_funnel/docs/screenshots/funnel-settings.png)
## Requirements
* 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.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

View File

@@ -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):
"""

View File

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

View File

@@ -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.2"
# The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin
# 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})."