diff --git a/.gitignore b/.gitignore index 2054e6e1..1f3ef2fd 100644 --- a/.gitignore +++ b/.gitignore @@ -178,6 +178,7 @@ tests/ # Compose overrides compose/speaker-compose.yaml +config/tailscale-serve.json # pixi environments .pixi/* !.pixi/config.toml diff --git a/ushadow/frontend/src/components/layout/Layout.tsx b/ushadow/frontend/src/components/layout/Layout.tsx index 14c71f88..d93844c8 100644 --- a/ushadow/frontend/src/components/layout/Layout.tsx +++ b/ushadow/frontend/src/components/layout/Layout.tsx @@ -1,7 +1,7 @@ import { Link, useLocation, Outlet } from 'react-router-dom' import React, { useState, useRef, useEffect } from 'react' -import { Layers, MessageSquare, Plug, Bot, Workflow, Server, Settings, LogOut, Sun, Moon, Users, Search, Bell, User, ChevronDown, Brain, Home, QrCode, Calendar } from 'lucide-react' -import { LayoutDashboard, Network, Flag, FlaskConical, Cloud, Mic, MicOff, Loader2, Sparkles, Zap, Archive } from 'lucide-react' +import { Layers, MessageSquare, Plug, Bot, Workflow, Server, Settings, LogOut, Sun, Moon, Users, Search, Bell, User, ChevronDown, Brain, Home } from 'lucide-react' +import { LayoutDashboard, Network, Flag, FlaskConical, Cloud, Mic, MicOff, Loader2, Sparkles } from 'lucide-react' import { useAuth } from '../../contexts/AuthContext' import { useTheme } from '../../contexts/ThemeContext' import { useFeatureFlags } from '../../contexts/FeatureFlagsContext' @@ -31,7 +31,7 @@ export default function Layout() { const { isDark, toggleTheme } = useTheme() const { isEnabled, flags } = useFeatureFlags() const { getSetupLabel } = useWizard() - const { isConnected: isChronicleConnected, recording } = useChronicle() + const { isConnected: isChronicleConnected, isCheckingConnection: isChronicleChecking, connectionError: chronicleError, recording } = useChronicle() const [userMenuOpen, setUserMenuOpen] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [featureFlagsDrawerOpen, setFeatureFlagsDrawerOpen] = useState(false) @@ -210,82 +210,36 @@ export default function Layout() { {/* Header Actions */}
- {/* Record Button with Mode Selector - show when Chronicle connected OR desktop-mic is wired */} - {(isChronicleConnected || isDesktopMicWired) && ( -
- {/* Mode Toggle - only show when not recording */} - {!recording.isRecording && !isRecordingProcessing && ( -
- - -
+ {/* Chronicle Record Button - only show when connected */} + {isChronicleConnected && ( + -
+ + {recording.isRecording + ? recording.formatDuration(recording.recordingDuration) + : isRecordingProcessing + ? 'Starting...' + : 'Record'} + + )} {/* Test Feature Flag Indicator */} diff --git a/ushadow/mobile/app/(tabs)/index.tsx b/ushadow/mobile/app/(tabs)/index.tsx index 3d72fd6c..433320e8 100644 --- a/ushadow/mobile/app/(tabs)/index.tsx +++ b/ushadow/mobile/app/(tabs)/index.tsx @@ -195,7 +195,6 @@ export default function HomeScreen() { visible={showLoginScreen} onClose={() => setShowLoginScreen(false)} onLoginSuccess={handleLoginSuccess} - initialApiUrl="https://blue.spangled-kettle.ts.net" /> {/* Connection Log Viewer Modal */} diff --git a/ushadow/mobile/app/_utils/authStorage.ts b/ushadow/mobile/app/_utils/authStorage.ts index ee21cb23..d69ee18d 100644 --- a/ushadow/mobile/app/_utils/authStorage.ts +++ b/ushadow/mobile/app/_utils/authStorage.ts @@ -7,9 +7,11 @@ */ import AsyncStorage from '@react-native-async-storage/async-storage'; +import AppConfig from '../config'; const AUTH_TOKEN_KEY = '@ushadow_auth_token'; const API_URL_KEY = '@ushadow_api_url'; +const DEFAULT_SERVER_URL_KEY = '@ushadow_default_server_url'; /** * Store the auth token @@ -138,3 +140,60 @@ export function appendTokenToUrl(wsUrl: string, token: string): string { const separator = wsUrl.includes('?') ? '&' : '?'; return `${wsUrl}${separator}token=${token}`; } + +/** + * Get the default server URL. + * Returns user-configured default if set, otherwise returns app config default. + */ +export async function getDefaultServerUrl(): Promise { + try { + const customDefault = await AsyncStorage.getItem(DEFAULT_SERVER_URL_KEY); + if (customDefault) { + return customDefault; + } + } catch (error) { + console.error('[AuthStorage] Failed to get default server URL:', error); + } + return AppConfig.DEFAULT_SERVER_URL; +} + +/** + * Set a custom default server URL. + * This will be used instead of the app config default. + */ +export async function setDefaultServerUrl(url: string): Promise { + try { + await AsyncStorage.setItem(DEFAULT_SERVER_URL_KEY, url); + console.log('[AuthStorage] Default server URL saved:', url); + } catch (error) { + console.error('[AuthStorage] Failed to save default server URL:', error); + throw error; + } +} + +/** + * Clear the custom default server URL (revert to app config default). + */ +export async function clearDefaultServerUrl(): Promise { + try { + await AsyncStorage.removeItem(DEFAULT_SERVER_URL_KEY); + console.log('[AuthStorage] Default server URL cleared'); + } catch (error) { + console.error('[AuthStorage] Failed to clear default server URL:', error); + throw error; + } +} + +/** + * Get the effective server URL to use. + * Priority: stored API URL > custom default > app config default + */ +export async function getEffectiveServerUrl(): Promise { + // First check if there's a stored API URL from login + const storedUrl = await getApiUrl(); + if (storedUrl) { + return storedUrl; + } + // Otherwise return the default + return getDefaultServerUrl(); +} diff --git a/ushadow/mobile/app/components/LoginScreen.tsx b/ushadow/mobile/app/components/LoginScreen.tsx index 4e98515d..3ebaccc3 100644 --- a/ushadow/mobile/app/components/LoginScreen.tsx +++ b/ushadow/mobile/app/components/LoginScreen.tsx @@ -11,7 +11,7 @@ * - Returns JWT token valid for both ushadow and Chronicle services */ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { View, Text, @@ -42,11 +42,21 @@ export const LoginScreen: React.FC = ({ }) => { // Server URL format: https://{tailscale-host} // Login will POST to {serverUrl}/api/auth/login - const [apiUrl, setApiUrl] = useState(initialApiUrl || 'https://blue.spangled-kettle.ts.net'); + const [apiUrl, setApiUrl] = useState(initialApiUrl || ''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [saveAsDefault, setSaveAsDefault] = useState(false); + + // Load the default server URL when modal opens + useEffect(() => { + if (visible && !initialApiUrl) { + getDefaultServerUrl().then((defaultUrl) => { + setApiUrl(defaultUrl); + }); + } + }, [visible, initialApiUrl]); const handleLogin = async () => { if (!apiUrl.trim() || !email.trim() || !password.trim()) { @@ -102,9 +112,16 @@ export const LoginScreen: React.FC = ({ await saveAuthToken(token); await saveApiUrl(baseUrl); + // Optionally save as default server URL + if (saveAsDefault) { + await setDefaultServerUrl(baseUrl); + console.log('[Login] Saved as default server URL'); + } + // Clear form setEmail(''); setPassword(''); + setSaveAsDefault(false); // Notify parent onLoginSuccess(token, baseUrl); @@ -162,6 +179,17 @@ export const LoginScreen: React.FC = ({ keyboardType="url" testID="login-api-url" /> + {/* Save as default checkbox */} + setSaveAsDefault(!saveAsDefault)} + testID="login-save-default" + > + + {saveAsDefault && } + + Save as default server + {/* Email */} @@ -291,6 +319,34 @@ const styles = StyleSheet.create({ color: theme.textPrimary, fontSize: fontSize.base, }, + checkboxRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.sm, + }, + checkbox: { + width: 20, + height: 20, + borderRadius: 4, + borderWidth: 2, + borderColor: theme.textMuted, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm, + }, + checkboxChecked: { + backgroundColor: theme.primaryButton, + borderColor: theme.primaryButton, + }, + checkmark: { + color: theme.primaryButtonText, + fontSize: 12, + fontWeight: 'bold', + }, + checkboxLabel: { + fontSize: fontSize.sm, + color: theme.textSecondary, + }, errorContainer: { backgroundColor: colors.error.bgSolid, padding: spacing.md, diff --git a/ushadow/mobile/app/config.ts b/ushadow/mobile/app/config.ts new file mode 100644 index 00000000..d668c4f8 --- /dev/null +++ b/ushadow/mobile/app/config.ts @@ -0,0 +1,25 @@ +/** + * App Configuration + * + * Central configuration for the Ushadow mobile app. + * The default server URL can be changed here or overridden by the user during setup. + */ + +export const AppConfig = { + /** + * Default server URL. + * This is used as the initial value when the app is first installed. + * Users can change this in the login screen. + * + * Format: https://{your-tailscale-host} + * Example: https://blue.spangled-kettle.ts.net + */ + DEFAULT_SERVER_URL: 'https://ushadow.wolf-tawny.ts.net', + + /** + * App version info + */ + APP_NAME: 'Ushadow Mobile', +}; + +export default AppConfig; diff --git a/ushadow/mobile/app/services/chronicleApi.ts b/ushadow/mobile/app/services/chronicleApi.ts index 3c3e73f3..688cb918 100644 --- a/ushadow/mobile/app/services/chronicleApi.ts +++ b/ushadow/mobile/app/services/chronicleApi.ts @@ -101,10 +101,11 @@ async function getChronicleApiUrl(): Promise { return chronicleUrl; } - // Default fallback - console.log('[ChronicleAPI] Using default Chronicle generic proxy'); - return 'https://blue.spangled-kettle.ts.net/api/services/chronicle-backend/proxy'; -} + // Default fallback - use configured default server URL + const defaultUrl = await getDefaultServerUrl(); + console.log(`[ChronicleAPI] Using default Chronicle generic proxy: ${defaultUrl}`); + return `${defaultUrl}/api/services/chronicle-backend/proxy`; +e} /** * Get the auth token from active UNode or global storage.