diff --git a/open-sse/config/googleTtsLanguages.js b/open-sse/config/googleTtsLanguages.js
new file mode 100644
index 00000000..e7d35c2b
--- /dev/null
+++ b/open-sse/config/googleTtsLanguages.js
@@ -0,0 +1,62 @@
+export const GOOGLE_TTS_LANGUAGES = [
+ { id: "af", name: "Afrikaans", type: "tts" },
+ { id: "ar", name: "Arabic", type: "tts" },
+ { id: "bg", name: "Bulgarian", type: "tts" },
+ { id: "bn", name: "Bengali", type: "tts" },
+ { id: "bs", name: "Bosnian", type: "tts" },
+ { id: "ca", name: "Catalan", type: "tts" },
+ { id: "cs", name: "Czech", type: "tts" },
+ { id: "cy", name: "Welsh", type: "tts" },
+ { id: "da", name: "Danish", type: "tts" },
+ { id: "de", name: "German", type: "tts" },
+ { id: "el", name: "Greek", type: "tts" },
+ { id: "en", name: "English", type: "tts" },
+ { id: "eo", name: "Esperanto", type: "tts" },
+ { id: "es", name: "Spanish", type: "tts" },
+ { id: "et", name: "Estonian", type: "tts" },
+ { id: "fi", name: "Finnish", type: "tts" },
+ { id: "fr", name: "French", type: "tts" },
+ { id: "gu", name: "Gujarati", type: "tts" },
+ { id: "hi", name: "Hindi", type: "tts" },
+ { id: "hr", name: "Croatian", type: "tts" },
+ { id: "hu", name: "Hungarian", type: "tts" },
+ { id: "hy", name: "Armenian", type: "tts" },
+ { id: "id", name: "Indonesian", type: "tts" },
+ { id: "is", name: "Icelandic", type: "tts" },
+ { id: "it", name: "Italian", type: "tts" },
+ { id: "ja", name: "Japanese", type: "tts" },
+ { id: "jw", name: "Javanese", type: "tts" },
+ { id: "km", name: "Khmer", type: "tts" },
+ { id: "kn", name: "Kannada", type: "tts" },
+ { id: "ko", name: "Korean", type: "tts" },
+ { id: "la", name: "Latin", type: "tts" },
+ { id: "lv", name: "Latvian", type: "tts" },
+ { id: "mk", name: "Macedonian", type: "tts" },
+ { id: "ml", name: "Malayalam", type: "tts" },
+ { id: "mr", name: "Marathi", type: "tts" },
+ { id: "my", name: "Myanmar (Burmese)", type: "tts" },
+ { id: "ne", name: "Nepali", type: "tts" },
+ { id: "nl", name: "Dutch", type: "tts" },
+ { id: "no", name: "Norwegian", type: "tts" },
+ { id: "pl", name: "Polish", type: "tts" },
+ { id: "pt", name: "Portuguese", type: "tts" },
+ { id: "ro", name: "Romanian", type: "tts" },
+ { id: "ru", name: "Russian", type: "tts" },
+ { id: "si", name: "Sinhala", type: "tts" },
+ { id: "sk", name: "Slovak", type: "tts" },
+ { id: "sq", name: "Albanian", type: "tts" },
+ { id: "sr", name: "Serbian", type: "tts" },
+ { id: "su", name: "Sundanese", type: "tts" },
+ { id: "sv", name: "Swedish", type: "tts" },
+ { id: "sw", name: "Swahili", type: "tts" },
+ { id: "ta", name: "Tamil", type: "tts" },
+ { id: "te", name: "Telugu", type: "tts" },
+ { id: "th", name: "Thai", type: "tts" },
+ { id: "tl", name: "Filipino", type: "tts" },
+ { id: "tr", name: "Turkish", type: "tts" },
+ { id: "uk", name: "Ukrainian", type: "tts" },
+ { id: "ur", name: "Urdu", type: "tts" },
+ { id: "vi", name: "Vietnamese", type: "tts" },
+ { id: "zh-CN", name: "Chinese (Simplified)", type: "tts" },
+ { id: "zh-TW", name: "Chinese (Traditional)", type: "tts" },
+];
diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js
index abfe6c50..008cce03 100644
--- a/open-sse/config/providerModels.js
+++ b/open-sse/config/providerModels.js
@@ -1,4 +1,5 @@
import { PROVIDERS } from "./providers.js";
+import { GOOGLE_TTS_LANGUAGES } from "./googleTtsLanguages.js";
// Provider models - Single source of truth
// Key = alias (cc, cx, gc, qw, if, ag, gh for OAuth; id for API Key)
@@ -371,15 +372,7 @@ export const PROVIDER_MODELS = {
"local-device": [
{ id: "default", name: "System Default Voice", type: "tts" },
],
- "google-tts": [
- { id: "en", name: "English", type: "tts" },
- { id: "vi", name: "Vietnamese", type: "tts" },
- { id: "zh-CN", name: "Chinese (Simplified)", type: "tts" },
- { id: "fr", name: "French", type: "tts" },
- { id: "de", name: "German", type: "tts" },
- { id: "ja", name: "Japanese", type: "tts" },
- { id: "ko", name: "Korean", type: "tts" },
- ],
+ "google-tts": GOOGLE_TTS_LANGUAGES,
// OpenAI TTS voices (hardcoded — no public API to list them)
// Used by ttsCore.js when provider = openai
"openai-tts-voices": [
diff --git a/open-sse/handlers/ttsCore.js b/open-sse/handlers/ttsCore.js
index 18c84705..3883101e 100644
--- a/open-sse/handlers/ttsCore.js
+++ b/open-sse/handlers/ttsCore.js
@@ -174,24 +174,64 @@ async function bingTts(text, voiceId) {
return Buffer.from(buf).toString("base64"); // base64 MP3
}
-// ── Local Device TTS (macOS `say` + ffmpeg) ───────────────────
+// ── Local Device TTS (macOS `say` + Windows SAPI + ffmpeg) ──────
let _localVoicesCache = null;
+async function fetchLocalDeviceVoicesMac() {
+ const { stdout } = await execFileAsync("say", ["-v", "?"]);
+ const voices = [];
+ for (const line of stdout.split("\n")) {
+ // Format: "Name locale # sample"
+ const m = line.match(/^([^\s].*?)\s{2,}([a-z]{2}_[A-Z]{2})/);
+ if (!m) continue;
+ const name = m[1].trim();
+ const locale = m[2].trim(); // e.g. en_US
+ const lang = locale.split("_")[0];
+ const country = locale.split("_")[1];
+ voices.push({ id: name, name, locale, lang, country, gender: "" });
+ }
+ return voices;
+}
+
+async function fetchLocalDeviceVoicesWin() {
+ // Use -WindowStyle Hidden to suppress PowerShell popup window
+ const script = [
+ "Add-Type -AssemblyName System.Speech;",
+ "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;",
+ "$s.GetInstalledVoices() | ForEach-Object { $v = $_.VoiceInfo;",
+ "[PSCustomObject]@{ Name=$v.Name; Culture=$v.Culture.Name; Gender=$v.Gender } }",
+ "| ConvertTo-Json -Compress",
+ ].join(" ");
+ const { stdout } = await execFileAsync(
+ "powershell.exe",
+ ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", script],
+ { windowsHide: true }
+ );
+ const raw = JSON.parse(stdout.trim() || "[]");
+ // Normalize: single object → array
+ const list = Array.isArray(raw) ? raw : [raw];
+ return list.map((v) => {
+ const culture = v.Culture || "en-US";
+ const [lang, country = ""] = culture.split("-");
+ // Gender: 0=NotSet, 1=Male, 2=Female (SAPI enum)
+ const genderMap = { 1: "Male", 2: "Female", Male: "Male", Female: "Female" };
+ return {
+ id: v.Name,
+ name: v.Name,
+ locale: culture.replace("-", "_"),
+ lang,
+ country,
+ gender: genderMap[v.Gender] || "",
+ };
+ });
+}
+
export async function fetchLocalDeviceVoices() {
if (_localVoicesCache) return _localVoicesCache;
try {
- const { stdout } = await execFileAsync("say", ["-v", "?"]);
- const voices = [];
- for (const line of stdout.split("\n")) {
- // Format: "Name locale # sample"
- const m = line.match(/^([^\s].*?)\s{2,}([a-z]{2}_[A-Z]{2})/);
- if (!m) continue;
- const name = m[1].trim();
- const locale = m[2].trim(); // e.g. en_US
- const lang = locale.split("_")[0];
- const country = locale.split("_")[1];
- voices.push({ id: name, name, locale, lang, country, gender: "" });
- }
+ const voices = process.platform === "win32"
+ ? await fetchLocalDeviceVoicesWin()
+ : await fetchLocalDeviceVoicesMac();
_localVoicesCache = voices;
return voices;
} catch {
diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js
index 58c0c65b..5119f6c9 100644
--- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js
+++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js
@@ -22,6 +22,9 @@ export default function APIPageClient({ machineId }) {
const [createdKey, setCreatedKey] = useState(null);
const [requireApiKey, setRequireApiKey] = useState(false);
+ const [requireLogin, setRequireLogin] = useState(true);
+ const [hasPassword, setHasPassword] = useState(true);
+ const [tunnelDashboardAccess, setTunnelDashboardAccess] = useState(false);
// Cloudflare Tunnel state
const [tunnelChecking, setTunnelChecking] = useState(true);
@@ -74,6 +77,9 @@ export default function APIPageClient({ machineId }) {
if (settingsRes.ok) {
const data = await settingsRes.json();
setRequireApiKey(data.requireApiKey || false);
+ setRequireLogin(data.requireLogin !== false);
+ setHasPassword(data.hasPassword || false);
+ setTunnelDashboardAccess(data.tunnelDashboardAccess || false);
}
if (statusRes.ok) {
const data = await statusRes.json();
@@ -135,6 +141,19 @@ export default function APIPageClient({ machineId }) {
}
};
+ const handleTunnelDashboardAccess = async (value) => {
+ try {
+ const res = await fetch("/api/settings", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tunnelDashboardAccess: value }),
+ });
+ if (res.ok) setTunnelDashboardAccess(value);
+ } catch (error) {
+ console.log("Error updating tunnelDashboardAccess:", error);
+ }
+ };
+
const handleRequireApiKey = async (value) => {
try {
const res = await fetch("/api/settings", {
@@ -688,10 +707,49 @@ export default function APIPageClient({ machineId }) {
)}
+
+ {/* Security warnings when tunnel or tailscale is active */}
+ {(tunnelEnabled || tsEnabled) && (
+
+ {!requireApiKey && (
+
+ )}
+ {(!requireLogin || !hasPassword) && (
+
+ )}
+
+ )}
+
+ {/* Tunnel dashboard access option */}
+ {(tunnelEnabled || tsEnabled) && (
+
+
handleTunnelDashboardAccess(!tunnelDashboardAccess)}
+ />
+
+
Allow dashboard access via tunnel
+
+
+
+ )}
{/* API Keys */}
-
+
API Keys
@@ -647,7 +669,10 @@ function TtsExampleCard({ providerId }) {
)}
) : (
- Audio will appear here after running.
+
+
Response
+
{DEFAULT_TTS_RESPONSE_EXAMPLE}
+
)}
diff --git a/src/app/api/settings/require-login/route.js b/src/app/api/settings/require-login/route.js
index 1e1a14d6..08f0661c 100644
--- a/src/app/api/settings/require-login/route.js
+++ b/src/app/api/settings/require-login/route.js
@@ -5,7 +5,8 @@ export async function GET() {
try {
const settings = await getSettings();
const requireLogin = settings.requireLogin !== false;
- return NextResponse.json({ requireLogin });
+ const tunnelDashboardAccess = settings.tunnelDashboardAccess === true;
+ return NextResponse.json({ requireLogin, tunnelDashboardAccess });
} catch (error) {
return NextResponse.json({ requireLogin: true }, { status: 200 });
}
diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js
index bff3b230..15b2c666 100644
--- a/src/dashboardGuard.js
+++ b/src/dashboardGuard.js
@@ -69,29 +69,41 @@ export async function proxy(request) {
}
+
// Protect all dashboard routes
if (pathname.startsWith("/dashboard")) {
- const token = request.cookies.get("auth_token")?.value;
+ const origin = request.nextUrl.origin;
+ let requireLogin = true;
+ let tunnelDashboardAccess = false;
+ try {
+ const res = await fetch(`${origin}/api/settings/require-login`);
+ const data = await res.json();
+ requireLogin = data.requireLogin !== false;
+ tunnelDashboardAccess = data.tunnelDashboardAccess === true;
+ } catch {
+ // On error, keep defaults (require login, block tunnel)
+ }
+
+ // Block tunnel access if disabled (checked before token to enforce the setting)
+ if (!isLocalRequest(request) && !tunnelDashboardAccess) {
+ return NextResponse.redirect(new URL("/login", request.url));
+ }
+
+ // If login not required, allow through
+ if (!requireLogin) return NextResponse.next();
+
+ // Verify JWT token
+ const token = request.cookies.get("auth_token")?.value;
if (token) {
try {
await jwtVerify(token, SECRET);
return NextResponse.next();
- } catch (err) {
+ } catch {
return NextResponse.redirect(new URL("/login", request.url));
}
}
- const origin = request.nextUrl.origin;
- try {
- const res = await fetch(`${origin}/api/settings/require-login`);
- const data = await res.json();
- if (data.requireLogin === false) {
- return NextResponse.next();
- }
- } catch (err) {
- // On error, require login
- }
return NextResponse.redirect(new URL("/login", request.url));
}
diff --git a/src/lib/localDb.js b/src/lib/localDb.js
index 76cf935b..581afcfe 100644
--- a/src/lib/localDb.js
+++ b/src/lib/localDb.js
@@ -62,6 +62,7 @@ const defaultData = {
comboStrategy: "fallback",
comboStrategies: {},
requireLogin: true,
+ tunnelDashboardAccess: true,
observabilityEnabled: true,
observabilityMaxRecords: 1000,
observabilityBatchSize: 20,
@@ -99,6 +100,7 @@ function cloneDefaultData() {
comboStrategy: "fallback",
comboStrategies: {},
requireLogin: true,
+ tunnelDashboardAccess: true,
observabilityEnabled: true,
observabilityMaxRecords: 1000,
observabilityBatchSize: 20,
diff --git a/src/lib/tunnel/tailscale.js b/src/lib/tunnel/tailscale.js
index 91ae8fe4..3b442d5d 100644
--- a/src/lib/tunnel/tailscale.js
+++ b/src/lib/tunnel/tailscale.js
@@ -2,7 +2,7 @@ import fs from "fs";
import path from "path";
import os from "os";
import { execSync, spawn } from "child_process";
-import { execWithPassword, executeElevatedPowerShell } from "@/mitm/dns/dnsConfig";
+import { execWithPassword } from "@/mitm/dns/dnsConfig";
import { saveTailscalePid, loadTailscalePid, clearTailscalePid } from "./state.js";
const BIN_DIR = path.join(os.homedir(), ".9router", "bin");
@@ -86,8 +86,11 @@ export function getTailscaleFunnelUrl(port) {
*/
export async function installTailscale(sudoPassword, hostname, onProgress) {
const log = onProgress || (() => {});
- if (IS_WINDOWS) await installTailscaleWindows(log);
- else if (IS_MAC) await installTailscaleMac(sudoPassword, log);
+ if (IS_WINDOWS) {
+ await installTailscaleWindows(log);
+ return { success: true };
+ }
+ if (IS_MAC) await installTailscaleMac(sudoPassword, log);
else await installTailscaleLinux(sudoPassword, log);
log("Starting daemon...");
@@ -215,18 +218,36 @@ async function installTailscaleLinux(sudoPassword, log) {
async function installTailscaleWindows(log) {
const msiUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi";
const msiPath = path.join(os.tmpdir(), "tailscale-setup.msi");
- const psScriptPath = path.join(os.tmpdir(), `tailscale-install-${Date.now()}.ps1`);
+ // Download MSI via PowerShell with streaming output
log("Downloading Tailscale installer...");
- const psScript = [
- `Invoke-WebRequest -Uri '${msiUrl}' -OutFile '${msiPath}'`,
- `Start-Process msiexec.exe -ArgumentList '/i','${msiPath}','/quiet','/norestart' -Wait`,
- `Remove-Item '${msiPath}' -Force -ErrorAction SilentlyContinue`,
- ].join("\n");
+ await new Promise((resolve, reject) => {
+ const child = spawn("powershell", [
+ "-NoProfile", "-NonInteractive", "-Command",
+ `Invoke-WebRequest -Uri '${msiUrl}' -OutFile '${msiPath}'`
+ ], { stdio: ["ignore", "pipe", "pipe"] });
+ child.stdout.on("data", (d) => { const l = d.toString().trim(); if (l) log(l); });
+ child.stderr.on("data", (d) => { const l = d.toString().trim(); if (l) log(l); });
+ child.on("close", (c) => c === 0 ? resolve() : reject(new Error("Download failed")));
+ child.on("error", reject);
+ });
- fs.writeFileSync(psScriptPath, psScript, "utf8");
- log("Installing (UAC prompt may appear)...");
- await executeElevatedPowerShell(psScriptPath, 120000);
+ // Install MSI silently — Windows Installer handles UAC elevation automatically
+ log("Installing Tailscale (UAC prompt may appear)...");
+ await new Promise((resolve, reject) => {
+ const child = spawn("msiexec", ["/i", msiPath, "/quiet", "/norestart"], {
+ stdio: ["ignore", "pipe", "pipe"]
+ });
+ child.stdout.on("data", (d) => { const l = d.toString().trim(); if (l) log(l); });
+ child.stderr.on("data", (d) => { const l = d.toString().trim(); if (l) log(l); });
+ child.on("close", (c) => {
+ try { fs.unlinkSync(msiPath); } catch { /* ignore */ }
+ c === 0 ? resolve() : reject(new Error(`msiexec failed (code ${c})`));
+ });
+ child.on("error", reject);
+ });
+
+ log("Installation complete.");
}
/** Start tailscaled with sudo (TUN mode required for Funnel) */
diff --git a/src/shared/constants/ttsProviders.js b/src/shared/constants/ttsProviders.js
index 0117febb..dbe1a136 100644
--- a/src/shared/constants/ttsProviders.js
+++ b/src/shared/constants/ttsProviders.js
@@ -4,10 +4,10 @@
*/
export const TTS_PROVIDER_CONFIG = {
"google-tts": {
- hasLanguageDropdown: true,
+ hasLanguageDropdown: false,
hasModelSelector: false,
- hasBrowseButton: false,
- voiceSource: "hardcoded", // from providerModels
+ hasBrowseButton: true,
+ voiceSource: "hardcoded", // languages built from providerModels at runtime
},
"openai": {
hasLanguageDropdown: false,