mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
# Description of Changes Follow on from #5949, expanding any type usage ban to the `desktop/` folder Also gets rid of a bunch of really verbose desktop logging that I don't think we really need anymore (or ever needed tbh, most of it doesn't make sense) because it was using a bunch of `any` typing and wasn't worth fixing.
1239 lines
38 KiB
TypeScript
1239 lines
38 KiB
TypeScript
import { invoke, isTauri } from "@tauri-apps/api/core";
|
|
import { fetch } from "@tauri-apps/plugin-http";
|
|
import { endpointAvailabilityService } from "@app/services/endpointAvailabilityService";
|
|
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
|
|
|
export type ConnectionMode = "saas" | "selfhosted" | "local";
|
|
|
|
export interface SSOProviderConfig {
|
|
id: string;
|
|
path: string;
|
|
label?: string;
|
|
}
|
|
|
|
export interface ServerConfig {
|
|
url: string;
|
|
enabledOAuthProviders?: SSOProviderConfig[];
|
|
loginMethod?: string;
|
|
}
|
|
|
|
export interface ConnectionConfig {
|
|
mode: ConnectionMode;
|
|
server_config: ServerConfig | null;
|
|
lock_connection_mode: boolean;
|
|
}
|
|
|
|
export interface DiagnosticResult {
|
|
stage: string;
|
|
success: boolean;
|
|
message: string;
|
|
duration?: number;
|
|
}
|
|
|
|
export interface ConnectionTestResult {
|
|
success: boolean;
|
|
error?: string;
|
|
errorCode?: string;
|
|
diagnostics?: DiagnosticResult[];
|
|
}
|
|
|
|
export const LOCAL_MODE_STORAGE_KEY = "stirling-local-mode";
|
|
export const JWT_EXPIRED_PROMPTED_KEY = "stirling-jwt-expired-prompted";
|
|
|
|
export class ConnectionModeService {
|
|
private static instance: ConnectionModeService;
|
|
private currentConfig: ConnectionConfig | null = null;
|
|
private configLoadedOnce = false;
|
|
private modeListeners = new Set<(config: ConnectionConfig) => void>();
|
|
|
|
static getInstance(): ConnectionModeService {
|
|
if (!ConnectionModeService.instance) {
|
|
ConnectionModeService.instance = new ConnectionModeService();
|
|
}
|
|
return ConnectionModeService.instance;
|
|
}
|
|
|
|
async getCurrentConfig(): Promise<ConnectionConfig> {
|
|
if (!this.configLoadedOnce) {
|
|
await this.loadConfig();
|
|
}
|
|
return (
|
|
this.currentConfig || {
|
|
mode: "saas",
|
|
server_config: null,
|
|
lock_connection_mode: false,
|
|
}
|
|
);
|
|
}
|
|
|
|
async getCurrentMode(): Promise<ConnectionMode> {
|
|
const config = await this.getCurrentConfig();
|
|
return config.mode;
|
|
}
|
|
|
|
async getServerConfig(): Promise<ServerConfig | null> {
|
|
const config = await this.getCurrentConfig();
|
|
return config.server_config;
|
|
}
|
|
|
|
subscribeToModeChanges(
|
|
listener: (config: ConnectionConfig) => void,
|
|
): () => void {
|
|
this.modeListeners.add(listener);
|
|
return () => {
|
|
this.modeListeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
private notifyListeners() {
|
|
if (this.currentConfig) {
|
|
this.modeListeners.forEach((listener) => listener(this.currentConfig!));
|
|
}
|
|
}
|
|
|
|
private async loadConfig(): Promise<void> {
|
|
try {
|
|
const config = await invoke<ConnectionConfig>("get_connection_config");
|
|
|
|
const localFlag = localStorage.getItem(LOCAL_MODE_STORAGE_KEY);
|
|
|
|
if (localFlag === "true") {
|
|
// User previously chose local-only mode (signed out or explicitly went offline).
|
|
// Applies to both 'saas' and 'selfhosted' store modes — the Rust guard on locked
|
|
// deployments can't change 'selfhosted' to 'saas' in the store, so we check the
|
|
// flag regardless of what the store says.
|
|
config.mode = "local";
|
|
} else if (
|
|
config.mode === "saas" &&
|
|
config.server_config === null &&
|
|
!config.lock_connection_mode &&
|
|
localFlag === null
|
|
) {
|
|
// Fresh install: Rust has never been given a server URL, the connection
|
|
// mode has never been explicitly set (no localStorage flag either direction),
|
|
// and there is no provisioning lock. Default to local so the user sees
|
|
// the bundled backend instead of a broken SaaS-mode UI.
|
|
// MSI installs with STIRLING_SERVER_URL are excluded because they have a
|
|
// non-null server_config; locked provisioned installs are excluded by the
|
|
// lock_connection_mode guard.
|
|
config.mode = "local";
|
|
}
|
|
|
|
this.currentConfig = config;
|
|
this.configLoadedOnce = true;
|
|
} catch (error) {
|
|
console.error("Failed to load connection config:", error);
|
|
// Default to local mode on error — safer than showing SaaS UI for a
|
|
// desktop app whose bundled backend is always available.
|
|
this.currentConfig = {
|
|
mode: "local",
|
|
server_config: null,
|
|
lock_connection_mode: false,
|
|
};
|
|
this.configLoadedOnce = true;
|
|
}
|
|
}
|
|
|
|
async switchToSaaS(saasServerUrl: string): Promise<void> {
|
|
if (this.currentConfig?.lock_connection_mode) {
|
|
throw new Error("Connection mode is locked by provisioning");
|
|
}
|
|
|
|
// Clear local-only flag and expiry-prompted flag when signing in
|
|
localStorage.removeItem(LOCAL_MODE_STORAGE_KEY);
|
|
localStorage.removeItem(JWT_EXPIRED_PROMPTED_KEY);
|
|
|
|
console.log("Switching to SaaS mode");
|
|
|
|
const serverConfig: ServerConfig = { url: saasServerUrl };
|
|
|
|
await invoke("set_connection_mode", {
|
|
mode: "saas",
|
|
serverConfig,
|
|
});
|
|
|
|
this.currentConfig = {
|
|
mode: "saas",
|
|
server_config: serverConfig,
|
|
lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false,
|
|
};
|
|
|
|
// Clear endpoint availability cache when mode changes
|
|
endpointAvailabilityService.clearCache();
|
|
console.log(
|
|
"Cleared endpoint availability cache due to connection mode change",
|
|
);
|
|
|
|
this.notifyListeners();
|
|
|
|
console.log("Switched to SaaS mode successfully");
|
|
}
|
|
|
|
async switchToLocal(): Promise<void> {
|
|
console.log("Switching to local-only mode");
|
|
|
|
// Persist local mode preference via localStorage so no Rust enum change is needed.
|
|
// The Rust store records this as 'saas' (same bundled-backend behaviour); we overlay
|
|
// the 'local' distinction purely on the TypeScript side.
|
|
localStorage.setItem(LOCAL_MODE_STORAGE_KEY, "true");
|
|
|
|
await invoke("set_connection_mode", {
|
|
mode: "saas",
|
|
serverConfig: null,
|
|
});
|
|
|
|
// For locked deployments, preserve server_config so the sign-in form can still
|
|
// show the correct server URL if the user wants to sign in later.
|
|
const isLocked = this.currentConfig?.lock_connection_mode ?? false;
|
|
const preservedServerConfig = isLocked
|
|
? (this.currentConfig?.server_config ?? null)
|
|
: null;
|
|
|
|
this.currentConfig = {
|
|
mode: "local",
|
|
server_config: preservedServerConfig,
|
|
lock_connection_mode: isLocked,
|
|
};
|
|
|
|
// Clear endpoint availability cache when mode changes
|
|
endpointAvailabilityService.clearCache();
|
|
|
|
this.notifyListeners();
|
|
}
|
|
|
|
async switchToSelfHosted(serverConfig: ServerConfig): Promise<void> {
|
|
// Clear local-only flag and expiry-prompted flag when signing in
|
|
localStorage.removeItem(LOCAL_MODE_STORAGE_KEY);
|
|
localStorage.removeItem(JWT_EXPIRED_PROMPTED_KEY);
|
|
|
|
console.log("Switching to self-hosted mode:", serverConfig);
|
|
|
|
await invoke("set_connection_mode", {
|
|
mode: "selfhosted",
|
|
serverConfig,
|
|
});
|
|
|
|
this.currentConfig = {
|
|
mode: "selfhosted",
|
|
server_config: serverConfig,
|
|
lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false,
|
|
};
|
|
|
|
// Clear endpoint availability cache when mode changes
|
|
endpointAvailabilityService.clearCache();
|
|
console.log(
|
|
"Cleared endpoint availability cache due to connection mode change",
|
|
);
|
|
|
|
// Single authoritative calling point for health monitoring — every path that
|
|
// switches to self-hosted mode funnels through here.
|
|
selfHostedServerMonitor.start(serverConfig.url);
|
|
|
|
this.notifyListeners();
|
|
|
|
console.log("Switched to self-hosted mode successfully");
|
|
}
|
|
|
|
/**
|
|
* Test connection to a server URL with comprehensive multi-stage diagnostics
|
|
* @returns Detailed test results with diagnostics and recommendations
|
|
*/
|
|
async testConnection(url: string): Promise<ConnectionTestResult> {
|
|
console.log(
|
|
`[ConnectionModeService] 🔍 Starting connection diagnostics for: ${url}`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] System: online=${navigator.onLine}, tauri=${isTauri()}, concurrency=${navigator.hardwareConcurrency || "unknown"}`,
|
|
);
|
|
|
|
const diagnostics: DiagnosticResult[] = [];
|
|
const healthUrl = `${url.replace(/\/$/, "")}/api/v1/info/status`;
|
|
const isLocal = this.isLocalAddress(url);
|
|
const isHttpUrl = url.startsWith("http://");
|
|
const isHttpsUrl = url.startsWith("https://");
|
|
|
|
console.log(`[ConnectionModeService] Connection Parameters:`);
|
|
console.log(`[ConnectionModeService] - Target URL: ${url}`);
|
|
console.log(`[ConnectionModeService] - Health endpoint: ${healthUrl}`);
|
|
console.log(`[ConnectionModeService] - Is local address: ${isLocal}`);
|
|
console.log(
|
|
`[ConnectionModeService] - Protocol: ${isHttpUrl ? "HTTP" : isHttpsUrl ? "HTTPS" : "Unknown"}`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] ================================================================`,
|
|
);
|
|
|
|
// STAGE 1: Test the protocol they specified
|
|
if (isHttpUrl) {
|
|
console.log(
|
|
`[ConnectionModeService] Stage 1: Testing HTTP (as specified in URL)`,
|
|
);
|
|
const stage1Result = await this.testHTTP(
|
|
healthUrl,
|
|
"Stage 1: HTTP (as specified)",
|
|
);
|
|
diagnostics.push(stage1Result);
|
|
|
|
if (stage1Result.success) {
|
|
console.log(
|
|
`[ConnectionModeService] ✅ Connection successful with HTTP`,
|
|
);
|
|
|
|
// Log success summary
|
|
console.log(
|
|
`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`,
|
|
);
|
|
console.log(`[ConnectionModeService] ✅ CONNECTION SUCCESSFUL`);
|
|
console.log(
|
|
`[ConnectionModeService] Protocol: HTTP (as requested by user)`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] Duration: ${stage1Result.duration}ms`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`,
|
|
);
|
|
|
|
return {
|
|
success: true,
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
// HTTP failed, try HTTPS as fallback
|
|
console.log(`[ConnectionModeService] Stage 2: HTTP failed, trying HTTPS`);
|
|
const httpsUrl = healthUrl.replace("http://", "https://");
|
|
const stage2Result = await this.testHTTPS(
|
|
httpsUrl,
|
|
"Stage 2: Trying HTTPS",
|
|
false,
|
|
);
|
|
diagnostics.push(stage2Result);
|
|
|
|
if (stage2Result.success) {
|
|
return {
|
|
success: false,
|
|
error: "Server is only accessible via HTTPS, not HTTP.",
|
|
errorCode: "HTTP_NOT_AVAILABLE",
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
// Both failed, continue with more diagnostics below
|
|
} else {
|
|
// HTTPS URL or no protocol - test HTTPS
|
|
console.log(
|
|
`[ConnectionModeService] Stage 1: Testing HTTPS with full certificate validation`,
|
|
);
|
|
const stage1Result = await this.testHTTPS(
|
|
healthUrl,
|
|
"Stage 1: Standard HTTPS",
|
|
false,
|
|
);
|
|
diagnostics.push(stage1Result);
|
|
|
|
if (stage1Result.success) {
|
|
console.log(
|
|
`[ConnectionModeService] ✅ Connection successful with standard HTTPS`,
|
|
);
|
|
|
|
// Log success summary
|
|
console.log(
|
|
`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`,
|
|
);
|
|
console.log(`[ConnectionModeService] ✅ CONNECTION SUCCESSFUL`);
|
|
console.log(
|
|
`[ConnectionModeService] Protocol: HTTPS with valid certificate`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] Duration: ${stage1Result.duration}ms`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`,
|
|
);
|
|
|
|
return { success: true, diagnostics };
|
|
}
|
|
|
|
// STAGE 2: Test with certificate validation disabled (diagnose cert issues)
|
|
console.log(
|
|
`[ConnectionModeService] Stage 2: Testing HTTPS with certificate validation disabled`,
|
|
);
|
|
const stage2Result = await this.testHTTPS(
|
|
healthUrl,
|
|
"Stage 2: HTTPS (no cert validation)",
|
|
true,
|
|
);
|
|
diagnostics.push(stage2Result);
|
|
|
|
if (stage2Result.success) {
|
|
console.log(
|
|
`[ConnectionModeService] ⚠️ Certificate issue detected - but connection works with bypass enabled`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] ✅ CONNECTION SUCCESSFUL (with certificate bypass)`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] Protocol: HTTPS with certificate validation disabled`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] Duration: ${stage2Result.duration}ms`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] Note: Server has missing intermediate certificate or invalid cert`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`,
|
|
);
|
|
return {
|
|
success: true,
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
// STAGE 3: Try HTTP instead (for local/internal servers)
|
|
console.log(
|
|
`[ConnectionModeService] Stage 3: Testing HTTP instead of HTTPS`,
|
|
);
|
|
const httpUrl = healthUrl.replace("https://", "http://");
|
|
const stage3Result = await this.testHTTP(
|
|
httpUrl,
|
|
"Stage 3: HTTP (unencrypted)",
|
|
);
|
|
diagnostics.push(stage3Result);
|
|
|
|
if (stage3Result.success) {
|
|
console.log(`[ConnectionModeService] ⚠️ HTTP works but HTTPS doesn't`);
|
|
return {
|
|
success: false,
|
|
error: "Server is only accessible via HTTP (not HTTPS).",
|
|
errorCode: "HTTPS_NOT_AVAILABLE",
|
|
diagnostics,
|
|
};
|
|
}
|
|
}
|
|
|
|
// STAGE 4: Test with longer timeout (diagnose slow connections)
|
|
console.log(
|
|
`[ConnectionModeService] Stage 4: Testing with extended timeout (30s)`,
|
|
);
|
|
const stage4Result = await this.testWithLongTimeout(healthUrl);
|
|
diagnostics.push(stage4Result);
|
|
|
|
if (stage4Result.success) {
|
|
console.log(
|
|
`[ConnectionModeService] ⚠️ Connection slow but eventually successful`,
|
|
);
|
|
return {
|
|
success: true,
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
// STAGE 5A: Test external connectivity with standard endpoint
|
|
console.log(
|
|
`[ConnectionModeService] Stage 5A: Testing external connectivity (google.com)`,
|
|
);
|
|
const stage5aResult = await this.testStage5_ExternalConnectivity();
|
|
diagnostics.push(stage5aResult);
|
|
|
|
// STAGE 5B: Test with alternative endpoint (in case google is blocked)
|
|
console.log(
|
|
`[ConnectionModeService] Stage 5B: Testing alternative endpoint (cloudflare.com)`,
|
|
);
|
|
const stage5bResult = await this.testAlternativeEndpoint();
|
|
diagnostics.push(stage5bResult);
|
|
|
|
// STAGE 5C: Test with HTTP vs HTTPS for external endpoint
|
|
console.log(
|
|
`[ConnectionModeService] Stage 5C: Testing HTTP external endpoint`,
|
|
);
|
|
const stage5cResult = await this.testHTTPExternal();
|
|
diagnostics.push(stage5cResult);
|
|
|
|
if (
|
|
!stage5aResult.success &&
|
|
!stage5bResult.success &&
|
|
!stage5cResult.success
|
|
) {
|
|
console.log(
|
|
`[ConnectionModeService] ❌ No external connectivity - network/firewall issue`,
|
|
);
|
|
return {
|
|
success: false,
|
|
error:
|
|
"No internet connectivity detected. All network requests are failing.",
|
|
errorCode: "NETWORK_BLOCKED",
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
// If some external endpoints work but not the target, it's more specific
|
|
if (
|
|
stage5aResult.success ||
|
|
stage5bResult.success ||
|
|
stage5cResult.success
|
|
) {
|
|
console.log(
|
|
`[ConnectionModeService] ✅ External connectivity confirmed - issue is specific to target server`,
|
|
);
|
|
}
|
|
|
|
// STAGE 6: Test DNS resolution for the target server
|
|
console.log(
|
|
`[ConnectionModeService] Stage 6: Testing DNS resolution for target server`,
|
|
);
|
|
const urlObj = new URL(url);
|
|
const stage6Result = await this.testStage6_DNSResolution(urlObj.hostname);
|
|
diagnostics.push(stage6Result);
|
|
|
|
if (
|
|
!stage6Result.success &&
|
|
stage6Result.message.includes("DNS lookup failed")
|
|
) {
|
|
console.log(
|
|
`[ConnectionModeService] ❌ DNS resolution failed for target server`,
|
|
);
|
|
return {
|
|
success: false,
|
|
error: `Cannot resolve hostname: ${urlObj.hostname}`,
|
|
errorCode: "DNS_RESOLUTION_FAILED",
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
// STAGE 7: Try different HTTP method (HEAD instead of GET)
|
|
console.log(`[ConnectionModeService] Stage 7: Testing with HEAD method`);
|
|
const stage7Result = await this.testWithHEADMethod(healthUrl);
|
|
diagnostics.push(stage7Result);
|
|
|
|
if (stage7Result.success) {
|
|
console.log(
|
|
`[ConnectionModeService] ⚠️ HEAD method works but GET doesn't - unusual server behavior`,
|
|
);
|
|
return {
|
|
success: false,
|
|
error: "Server responds to HEAD requests but not GET requests.",
|
|
errorCode: "METHOD_MISMATCH",
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
// STAGE 8: Try with modified User-Agent
|
|
console.log(
|
|
`[ConnectionModeService] Stage 8: Testing with browser User-Agent`,
|
|
);
|
|
const stage8Result = await this.testWithBrowserUserAgent(healthUrl);
|
|
diagnostics.push(stage8Result);
|
|
|
|
if (stage8Result.success) {
|
|
console.log(
|
|
`[ConnectionModeService] ⚠️ Works with browser User-Agent - server may be blocking desktop apps`,
|
|
);
|
|
return {
|
|
success: false,
|
|
error:
|
|
"Server blocks Tauri/desktop app User-Agent but allows browser User-Agent.",
|
|
errorCode: "USER_AGENT_BLOCKED",
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
// STAGE 9: Final analysis - server-specific issue
|
|
console.log(
|
|
`[ConnectionModeService] ❌ Server unreachable - all diagnostic tests failed`,
|
|
);
|
|
|
|
// Analyze timing patterns
|
|
const avgDuration =
|
|
diagnostics
|
|
.filter((d) => !d.success && d.duration)
|
|
.reduce((sum, d) => sum + (d.duration || 0), 0) /
|
|
diagnostics.filter((d) => !d.success && d.duration).length;
|
|
|
|
// Log comprehensive diagnostic summary
|
|
console.log(
|
|
`[ConnectionModeService] ==================== DIAGNOSTIC SUMMARY ====================`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] Total tests run: ${diagnostics.length}`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] Passed: ${diagnostics.filter((d) => d.success).length}`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] Failed: ${diagnostics.filter((d) => !d.success).length}`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] Average failure time: ${avgDuration.toFixed(0)}ms`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] ---------------------------------------------------------------`,
|
|
);
|
|
diagnostics.forEach((diag) => {
|
|
const icon = diag.success ? "✅" : "❌";
|
|
console.log(
|
|
`[ConnectionModeService] ${icon} ${diag.stage}: ${diag.message} (${diag.duration}ms)`,
|
|
);
|
|
});
|
|
console.log(
|
|
`[ConnectionModeService] ================================================================`,
|
|
);
|
|
console.log(`[ConnectionModeService] Error Code: SERVER_UNREACHABLE`);
|
|
|
|
// Log timing-based analysis
|
|
if (avgDuration < 100) {
|
|
console.log(
|
|
`[ConnectionModeService] Analysis: Immediate rejections (<${avgDuration.toFixed(0)}ms) suggest firewall/antivirus blocking`,
|
|
);
|
|
} else if (avgDuration > 5000) {
|
|
console.log(
|
|
`[ConnectionModeService] Analysis: Timeouts (avg ${(avgDuration / 1000).toFixed(1)}s) suggest server not responding or network route blocked`,
|
|
);
|
|
} else {
|
|
console.log(
|
|
`[ConnectionModeService] Analysis: Server may be down, blocking connections, or behind a firewall`,
|
|
);
|
|
}
|
|
|
|
console.log(
|
|
`[ConnectionModeService] ==================== DIAGNOSTIC SESSION END ====================`,
|
|
);
|
|
|
|
return {
|
|
success: false,
|
|
error:
|
|
"Cannot connect to server. Internet works but this specific server is unreachable.",
|
|
errorCode: "SERVER_UNREACHABLE",
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
private isLocalAddress(url: string): boolean {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
const hostname = urlObj.hostname.toLowerCase();
|
|
return (
|
|
hostname === "localhost" ||
|
|
hostname === "127.0.0.1" ||
|
|
hostname === "::1" ||
|
|
hostname.startsWith("192.168.") ||
|
|
hostname.startsWith("10.") ||
|
|
hostname.startsWith("172.16.") ||
|
|
hostname.endsWith(".local")
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async testHTTPS(
|
|
url: string,
|
|
stageName: string,
|
|
disableCertValidation: boolean,
|
|
): Promise<DiagnosticResult> {
|
|
const startTime = Date.now();
|
|
try {
|
|
console.log(
|
|
`[ConnectionModeService] 🔗 ${stageName}: Attempting fetch to ${url}`,
|
|
);
|
|
console.log(
|
|
`[ConnectionModeService] - Certificate validation: ${disableCertValidation ? "DISABLED" : "ENABLED"}`,
|
|
);
|
|
|
|
const fetchOptions: RequestInit & {
|
|
connectTimeout?: number;
|
|
danger?: {
|
|
acceptInvalidCerts: boolean;
|
|
acceptInvalidHostnames: boolean;
|
|
};
|
|
} = {
|
|
method: "GET",
|
|
connectTimeout: 10000,
|
|
};
|
|
|
|
if (disableCertValidation) {
|
|
fetchOptions.danger = {
|
|
acceptInvalidCerts: true,
|
|
acceptInvalidHostnames: true,
|
|
};
|
|
}
|
|
|
|
console.log(
|
|
`[ConnectionModeService] - Fetch options:`,
|
|
JSON.stringify(fetchOptions),
|
|
);
|
|
const response = await fetch(url, fetchOptions);
|
|
const duration = Date.now() - startTime;
|
|
|
|
console.log(
|
|
`[ConnectionModeService] ✅ ${stageName}: Response received - HTTP ${response.status} (${duration}ms)`,
|
|
);
|
|
|
|
if (response.ok) {
|
|
return {
|
|
stage: stageName,
|
|
success: true,
|
|
message: disableCertValidation
|
|
? "Connected successfully when certificate validation disabled"
|
|
: "Successfully connected with full certificate validation",
|
|
duration,
|
|
};
|
|
}
|
|
|
|
return {
|
|
stage: stageName,
|
|
success: false,
|
|
message: `Server returned HTTP ${response.status}`,
|
|
duration,
|
|
};
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
|
|
// Enhanced error logging
|
|
console.error(
|
|
`[ConnectionModeService] ❌ ${stageName}: Request failed (${duration}ms)`,
|
|
);
|
|
console.error(
|
|
`[ConnectionModeService] - Error type: ${error?.constructor?.name || typeof error}`,
|
|
);
|
|
console.error(
|
|
`[ConnectionModeService] - Error message: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
|
|
// Log full error object structure for debugging
|
|
if (error && typeof error === "object") {
|
|
console.error(
|
|
`[ConnectionModeService] - Error keys:`,
|
|
Object.keys(error),
|
|
);
|
|
console.error(
|
|
`[ConnectionModeService] - Error object:`,
|
|
JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
|
|
);
|
|
}
|
|
|
|
// Categorize error type
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
const errorLower = errorMsg.toLowerCase();
|
|
|
|
let detailedMessage = `Failed: ${errorMsg}`;
|
|
|
|
// Check for TLS version mismatch (TLS 1.0/1.1 not supported)
|
|
if (
|
|
errorLower.includes("peer is incompatible") ||
|
|
errorLower.includes("protocol version") ||
|
|
errorLower.includes("peerincompatible") ||
|
|
(errorLower.includes("handshake") &&
|
|
(errorLower.includes("tls") || errorLower.includes("ssl")))
|
|
) {
|
|
detailedMessage = `TLS version not supported - Server appears to use TLS 1.0 or 1.1 (desktop app requires TLS 1.2+). Please upgrade your server's TLS configuration or use the web version.`;
|
|
} else if (
|
|
errorLower.includes("timeout") ||
|
|
errorLower.includes("timed out")
|
|
) {
|
|
detailedMessage = `Timeout after ${duration}ms - server not responding`;
|
|
} else if (
|
|
errorLower.includes("certificate") ||
|
|
errorLower.includes("cert") ||
|
|
errorLower.includes("ssl") ||
|
|
errorLower.includes("tls")
|
|
) {
|
|
detailedMessage = `SSL/TLS error - ${errorMsg}`;
|
|
} else if (
|
|
errorLower.includes("connection refused") ||
|
|
errorLower.includes("econnrefused")
|
|
) {
|
|
detailedMessage = `Connection refused - server may not be running`;
|
|
} else if (
|
|
errorLower.includes("network") ||
|
|
errorLower.includes("dns") ||
|
|
errorLower.includes("enotfound")
|
|
) {
|
|
detailedMessage = `Network error - ${errorMsg}`;
|
|
} else if (
|
|
errorLower.includes("blocked") ||
|
|
errorLower.includes("filtered")
|
|
) {
|
|
detailedMessage = `Request blocked - possible firewall/antivirus`;
|
|
} else if (duration < 100) {
|
|
detailedMessage = `Immediate rejection (<${duration}ms) - likely blocked by firewall/antivirus`;
|
|
}
|
|
|
|
console.error(
|
|
`[ConnectionModeService] - Categorized as: ${detailedMessage}`,
|
|
);
|
|
|
|
return {
|
|
stage: stageName,
|
|
success: false,
|
|
message: detailedMessage,
|
|
duration,
|
|
};
|
|
}
|
|
}
|
|
|
|
private async testHTTP(
|
|
url: string,
|
|
stageName: string,
|
|
): Promise<DiagnosticResult> {
|
|
const startTime = Date.now();
|
|
try {
|
|
console.log(
|
|
`[ConnectionModeService] 🔗 ${stageName}: Attempting fetch to ${url}`,
|
|
);
|
|
|
|
const response = await fetch(url, {
|
|
method: "GET",
|
|
connectTimeout: 10000,
|
|
});
|
|
const duration = Date.now() - startTime;
|
|
|
|
console.log(
|
|
`[ConnectionModeService] ✅ ${stageName}: Response received - HTTP ${response.status} (${duration}ms)`,
|
|
);
|
|
|
|
if (response.ok) {
|
|
return {
|
|
stage: stageName,
|
|
success: true,
|
|
message: "Successfully connected using HTTP",
|
|
duration,
|
|
};
|
|
}
|
|
|
|
return {
|
|
stage: stageName,
|
|
success: false,
|
|
message: `Server returned HTTP ${response.status}`,
|
|
duration,
|
|
};
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
|
|
// Enhanced error logging
|
|
console.error(
|
|
`[ConnectionModeService] ❌ ${stageName}: Request failed (${duration}ms)`,
|
|
);
|
|
console.error(
|
|
`[ConnectionModeService] - Error type: ${error?.constructor?.name || typeof error}`,
|
|
);
|
|
console.error(
|
|
`[ConnectionModeService] - Error message: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
|
|
if (error && typeof error === "object") {
|
|
console.error(
|
|
`[ConnectionModeService] - Error object:`,
|
|
JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
|
|
);
|
|
}
|
|
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
const errorLower = errorMsg.toLowerCase();
|
|
|
|
let detailedMessage = `Failed: ${errorMsg}`;
|
|
|
|
if (errorLower.includes("timeout") || errorLower.includes("timed out")) {
|
|
detailedMessage = `Timeout after ${duration}ms - server not responding`;
|
|
} else if (duration < 100) {
|
|
detailedMessage = `Immediate rejection (<${duration}ms) - likely blocked by firewall/antivirus`;
|
|
}
|
|
|
|
console.error(
|
|
`[ConnectionModeService] - Categorized as: ${detailedMessage}`,
|
|
);
|
|
|
|
return {
|
|
stage: stageName,
|
|
success: false,
|
|
message: detailedMessage,
|
|
duration,
|
|
};
|
|
}
|
|
}
|
|
|
|
private async testWithLongTimeout(url: string): Promise<DiagnosticResult> {
|
|
const startTime = Date.now();
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "GET",
|
|
connectTimeout: 30000, // 30 seconds
|
|
});
|
|
const duration = Date.now() - startTime;
|
|
|
|
if (response.ok) {
|
|
return {
|
|
stage: "Stage 4: Extended timeout (30s)",
|
|
success: true,
|
|
message: `Connected after ${duration}ms (slow connection)`,
|
|
duration,
|
|
};
|
|
}
|
|
|
|
return {
|
|
stage: "Stage 4: Extended timeout (30s)",
|
|
success: false,
|
|
message: `Server returned HTTP ${response.status}`,
|
|
duration,
|
|
};
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
|
return {
|
|
stage: "Stage 4: Extended timeout (30s)",
|
|
success: false,
|
|
message: `Failed: ${errorMsg}`,
|
|
duration,
|
|
};
|
|
}
|
|
}
|
|
|
|
private async testStage5_ExternalConnectivity(): Promise<DiagnosticResult> {
|
|
const startTime = Date.now();
|
|
try {
|
|
console.log(
|
|
`[ConnectionModeService] 🌐 Stage 5A: Testing external connectivity (google.com)`,
|
|
);
|
|
|
|
// Test connectivity to a reliable external service
|
|
const response = await fetch("https://www.google.com", {
|
|
method: "HEAD",
|
|
connectTimeout: 5000,
|
|
});
|
|
const duration = Date.now() - startTime;
|
|
|
|
console.log(
|
|
`[ConnectionModeService] ✅ Stage 5A: External connectivity confirmed - HTTP ${response.status} (${duration}ms)`,
|
|
);
|
|
|
|
if (response.ok || response.status === 301 || response.status === 302) {
|
|
return {
|
|
stage: "Stage 5A: External (google.com)",
|
|
success: true,
|
|
message: "Internet connectivity confirmed via google.com",
|
|
duration,
|
|
};
|
|
}
|
|
|
|
return {
|
|
stage: "Stage 5A: External (google.com)",
|
|
success: false,
|
|
message: `Unexpected response from google.com: ${response.status}`,
|
|
duration,
|
|
};
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
console.error(
|
|
`[ConnectionModeService] ❌ Stage 5A: External connectivity test failed (${duration}ms)`,
|
|
);
|
|
console.error(`[ConnectionModeService] - Error:`, error);
|
|
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
stage: "Stage 5A: External (google.com)",
|
|
success: false,
|
|
message: `Failed: ${errorMsg}`,
|
|
duration,
|
|
};
|
|
}
|
|
}
|
|
|
|
private async testAlternativeEndpoint(): Promise<DiagnosticResult> {
|
|
const startTime = Date.now();
|
|
try {
|
|
console.log(
|
|
`[ConnectionModeService] 🌐 Stage 5B: Testing alternative endpoint (cloudflare.com)`,
|
|
);
|
|
|
|
const response = await fetch("https://1.1.1.1", {
|
|
method: "HEAD",
|
|
connectTimeout: 5000,
|
|
});
|
|
const duration = Date.now() - startTime;
|
|
|
|
console.log(
|
|
`[ConnectionModeService] ✅ Stage 5B: Alternative endpoint success - HTTP ${response.status} (${duration}ms)`,
|
|
);
|
|
|
|
if (
|
|
response.ok ||
|
|
response.status === 301 ||
|
|
response.status === 302 ||
|
|
response.status === 403
|
|
) {
|
|
return {
|
|
stage: "Stage 5B: External (cloudflare)",
|
|
success: true,
|
|
message: "Alternative endpoint (1.1.1.1) reachable",
|
|
duration,
|
|
};
|
|
}
|
|
|
|
return {
|
|
stage: "Stage 5B: External (cloudflare)",
|
|
success: false,
|
|
message: `Unexpected response: ${response.status}`,
|
|
duration,
|
|
};
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
console.error(
|
|
`[ConnectionModeService] ❌ Stage 5B: Alternative endpoint failed (${duration}ms)`,
|
|
);
|
|
console.error(`[ConnectionModeService] - Error:`, error);
|
|
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
stage: "Stage 5B: External (cloudflare)",
|
|
success: false,
|
|
message: `Failed: ${errorMsg}`,
|
|
duration,
|
|
};
|
|
}
|
|
}
|
|
|
|
private async testHTTPExternal(): Promise<DiagnosticResult> {
|
|
const startTime = Date.now();
|
|
try {
|
|
console.log(
|
|
`[ConnectionModeService] 🌐 Stage 5C: Testing HTTP external endpoint (httpbin.org)`,
|
|
);
|
|
|
|
// Try HTTP (not HTTPS) to see if TLS/SSL is the issue
|
|
const response = await fetch("http://httpbin.org/status/200", {
|
|
method: "GET",
|
|
connectTimeout: 5000,
|
|
});
|
|
const duration = Date.now() - startTime;
|
|
|
|
console.log(
|
|
`[ConnectionModeService] ✅ Stage 5C: HTTP endpoint success - HTTP ${response.status} (${duration}ms)`,
|
|
);
|
|
|
|
if (response.ok) {
|
|
return {
|
|
stage: "Stage 5C: External HTTP (no TLS)",
|
|
success: true,
|
|
message: "HTTP (unencrypted) connectivity works",
|
|
duration,
|
|
};
|
|
}
|
|
|
|
return {
|
|
stage: "Stage 5C: External HTTP (no TLS)",
|
|
success: false,
|
|
message: `Unexpected response: ${response.status}`,
|
|
duration,
|
|
};
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
console.error(
|
|
`[ConnectionModeService] ❌ Stage 5C: HTTP external failed (${duration}ms)`,
|
|
);
|
|
console.error(`[ConnectionModeService] - Error:`, error);
|
|
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
stage: "Stage 5C: External HTTP (no TLS)",
|
|
success: false,
|
|
message: `Failed: ${errorMsg}`,
|
|
duration,
|
|
};
|
|
}
|
|
}
|
|
|
|
private async testStage6_DNSResolution(
|
|
hostname: string,
|
|
): Promise<DiagnosticResult> {
|
|
const startTime = Date.now();
|
|
try {
|
|
console.log(
|
|
`[ConnectionModeService] 🔍 Stage 6: Testing DNS resolution for ${hostname}`,
|
|
);
|
|
|
|
// Try to resolve DNS by making a HEAD request to the base domain
|
|
// If DNS fails, we'll get an immediate error
|
|
const testUrl = `https://${hostname}`;
|
|
await fetch(testUrl, {
|
|
method: "HEAD",
|
|
connectTimeout: 3000,
|
|
});
|
|
const duration = Date.now() - startTime;
|
|
|
|
console.log(
|
|
`[ConnectionModeService] ✅ Stage 6: DNS resolved successfully (${duration}ms)`,
|
|
);
|
|
|
|
return {
|
|
stage: "Stage 6: DNS resolution",
|
|
success: true,
|
|
message: `DNS resolution successful for ${hostname}`,
|
|
duration,
|
|
};
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
const errorLower = errorMsg.toLowerCase();
|
|
|
|
console.error(
|
|
`[ConnectionModeService] ❌ Stage 6: DNS test failed (${duration}ms)`,
|
|
);
|
|
console.error(`[ConnectionModeService] - Error:`, errorMsg);
|
|
|
|
// Check if it's a DNS-specific error
|
|
if (
|
|
errorLower.includes("dns") ||
|
|
errorLower.includes("enotfound") ||
|
|
errorLower.includes("getaddrinfo")
|
|
) {
|
|
return {
|
|
stage: "Stage 6: DNS resolution",
|
|
success: false,
|
|
message: `DNS lookup failed - cannot resolve ${hostname}`,
|
|
duration,
|
|
};
|
|
}
|
|
|
|
// If we got here, DNS might be working but connection failed for other reasons
|
|
return {
|
|
stage: "Stage 6: DNS resolution",
|
|
success: false,
|
|
message: `DNS test inconclusive: ${errorMsg}`,
|
|
duration,
|
|
};
|
|
}
|
|
}
|
|
|
|
private async testWithHEADMethod(url: string): Promise<DiagnosticResult> {
|
|
const startTime = Date.now();
|
|
try {
|
|
console.log(
|
|
`[ConnectionModeService] 🔗 Stage 7: Testing with HEAD method`,
|
|
);
|
|
|
|
const response = await fetch(url, {
|
|
method: "HEAD",
|
|
connectTimeout: 10000,
|
|
});
|
|
const duration = Date.now() - startTime;
|
|
|
|
console.log(
|
|
`[ConnectionModeService] ✅ Stage 7: HEAD method success - HTTP ${response.status} (${duration}ms)`,
|
|
);
|
|
|
|
if (response.ok) {
|
|
return {
|
|
stage: "Stage 7: HEAD method",
|
|
success: true,
|
|
message: "HEAD method works (GET does not)",
|
|
duration,
|
|
};
|
|
}
|
|
|
|
return {
|
|
stage: "Stage 7: HEAD method",
|
|
success: false,
|
|
message: `HEAD method returned ${response.status}`,
|
|
duration,
|
|
};
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
console.error(
|
|
`[ConnectionModeService] ❌ Stage 7: HEAD method failed (${duration}ms)`,
|
|
);
|
|
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
stage: "Stage 7: HEAD method",
|
|
success: false,
|
|
message: `Failed: ${errorMsg}`,
|
|
duration,
|
|
};
|
|
}
|
|
}
|
|
|
|
private async testWithBrowserUserAgent(
|
|
url: string,
|
|
): Promise<DiagnosticResult> {
|
|
const startTime = Date.now();
|
|
try {
|
|
console.log(
|
|
`[ConnectionModeService] 🔗 Stage 8: Testing with browser User-Agent`,
|
|
);
|
|
|
|
// Try with a standard browser User-Agent instead of Tauri's default
|
|
const response = await fetch(url, {
|
|
method: "GET",
|
|
connectTimeout: 10000,
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
|
},
|
|
});
|
|
const duration = Date.now() - startTime;
|
|
|
|
console.log(
|
|
`[ConnectionModeService] ✅ Stage 8: Browser User-Agent success - HTTP ${response.status} (${duration}ms)`,
|
|
);
|
|
|
|
if (response.ok) {
|
|
return {
|
|
stage: "Stage 8: Browser User-Agent",
|
|
success: true,
|
|
message: "Works with browser User-Agent (blocked with desktop UA)",
|
|
duration,
|
|
};
|
|
}
|
|
|
|
return {
|
|
stage: "Stage 8: Browser User-Agent",
|
|
success: false,
|
|
message: `Browser UA returned ${response.status}`,
|
|
duration,
|
|
};
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
console.error(
|
|
`[ConnectionModeService] ❌ Stage 8: Browser User-Agent failed (${duration}ms)`,
|
|
);
|
|
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
stage: "Stage 8: Browser User-Agent",
|
|
success: false,
|
|
message: `Failed: ${errorMsg}`,
|
|
duration,
|
|
};
|
|
}
|
|
}
|
|
|
|
async isFirstLaunch(): Promise<boolean> {
|
|
try {
|
|
const result = await invoke<boolean>("is_first_launch");
|
|
return result;
|
|
} catch (error) {
|
|
console.error("Failed to check first launch:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async resetSetupCompletion(): Promise<void> {
|
|
if (this.currentConfig?.lock_connection_mode) {
|
|
return;
|
|
}
|
|
try {
|
|
await invoke("reset_setup_completion");
|
|
console.log("Setup completion flag reset successfully");
|
|
} catch (error) {
|
|
console.error("Failed to reset setup completion:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const connectionModeService = ConnectionModeService.getInstance();
|