Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ tests/

# Compose overrides
compose/speaker-compose.yaml
config/tailscale-serve.json
# pixi environments
.pixi/*
!.pixi/config.toml
Expand Down
110 changes: 32 additions & 78 deletions ushadow/frontend/src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -210,82 +210,36 @@ export default function Layout() {

{/* Header Actions */}
<div className="flex items-center space-x-1">
{/* Record Button with Mode Selector - show when Chronicle connected OR desktop-mic is wired */}
{(isChronicleConnected || isDesktopMicWired) && (
<div className="flex items-center">
{/* Mode Toggle - only show when not recording */}
{!recording.isRecording && !isRecordingProcessing && (
<div
className="flex items-center mr-1 rounded-lg overflow-hidden"
style={{
backgroundColor: isDark ? 'var(--surface-700)' : '#f5f5f5',
border: isDark ? '1px solid var(--surface-500)' : '1px solid #e5e5e5',
}}
data-testid="header-mode-toggle"
>
<button
onClick={() => recording.setMode('streaming')}
className={`p-2 transition-all ${
recording.mode === 'streaming'
? 'bg-primary-600 text-white'
: ''
}`}
style={{
color: recording.mode !== 'streaming' ? (isDark ? 'var(--text-secondary)' : '#525252') : undefined,
}}
title="Streaming mode - audio sent in real-time"
data-testid="header-mode-streaming"
>
<Zap className="h-4 w-4" />
</button>
<button
onClick={() => recording.setMode('batch')}
className={`p-2 transition-all ${
recording.mode === 'batch'
? 'bg-primary-600 text-white'
: ''
}`}
style={{
color: recording.mode !== 'batch' ? (isDark ? 'var(--text-secondary)' : '#525252') : undefined,
}}
title="Batch mode - audio sent when you stop"
data-testid="header-mode-batch"
>
<Archive className="h-4 w-4" />
</button>
</div>
{/* Chronicle Record Button - only show when connected */}
{isChronicleConnected && (
<button
onClick={recording.isRecording ? recording.stopRecording : recording.startRecording}
disabled={!recording.canAccessMicrophone || (isRecordingProcessing && !recording.isRecording)}
className={`flex items-center space-x-2 px-3 py-2 rounded-lg font-medium transition-all ${
recording.isRecording
? 'bg-red-600 hover:bg-red-700 text-white animate-pulse'
: isRecordingProcessing
? 'bg-amber-500 text-white'
: 'bg-primary-600 hover:bg-primary-700 text-white'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title={recording.isRecording ? 'Stop Recording' : 'Start Recording'}
data-testid="chronicle-record-button"
>
{isRecordingProcessing && !recording.isRecording ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : recording.isRecording ? (
<MicOff className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}

{/* Record/Stop Button */}
<button
onClick={recording.isRecording ? recording.stopRecording : recording.startRecording}
disabled={!recording.canAccessMicrophone || (isRecordingProcessing && !recording.isRecording)}
className={`flex items-center space-x-2 px-3 py-2 rounded-lg font-medium transition-all ${
recording.isRecording
? 'bg-red-600 hover:bg-red-700 text-white animate-pulse'
: isRecordingProcessing
? 'bg-amber-500 text-white'
: 'bg-primary-600 hover:bg-primary-700 text-white'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title={recording.isRecording ? 'Stop Recording' : 'Start Recording'}
data-testid="chronicle-record-button"
>
{isRecordingProcessing && !recording.isRecording ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : recording.isRecording ? (
<MicOff className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
<span className="hidden sm:inline text-sm">
{recording.isRecording
? recording.formatDuration(recording.recordingDuration)
: isRecordingProcessing
? 'Starting...'
: 'Record'}
</span>
</button>
</div>
<span className="hidden sm:inline text-sm">
{recording.isRecording
? recording.formatDuration(recording.recordingDuration)
: isRecordingProcessing
? 'Starting...'
: 'Record'}
</span>
</button>
)}

{/* Test Feature Flag Indicator */}
Expand Down
1 change: 0 additions & 1 deletion ushadow/mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */}
Expand Down
59 changes: 59 additions & 0 deletions ushadow/mobile/app/_utils/authStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string> {
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<void> {
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<void> {
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<string> {
// 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();
}
60 changes: 58 additions & 2 deletions ushadow/mobile/app/components/LoginScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -42,11 +42,21 @@ export const LoginScreen: React.FC<LoginScreenProps> = ({
}) => {
// 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<string | null>(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()) {
Expand Down Expand Up @@ -102,9 +112,16 @@ export const LoginScreen: React.FC<LoginScreenProps> = ({
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);
Expand Down Expand Up @@ -162,6 +179,17 @@ export const LoginScreen: React.FC<LoginScreenProps> = ({
keyboardType="url"
testID="login-api-url"
/>
{/* Save as default checkbox */}
<TouchableOpacity
style={styles.checkboxRow}
onPress={() => setSaveAsDefault(!saveAsDefault)}
testID="login-save-default"
>
<View style={[styles.checkbox, saveAsDefault && styles.checkboxChecked]}>
{saveAsDefault && <Text style={styles.checkmark}>✓</Text>}
</View>
<Text style={styles.checkboxLabel}>Save as default server</Text>
</TouchableOpacity>
</View>

{/* Email */}
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions ushadow/mobile/app/config.ts
Original file line number Diff line number Diff line change
@@ -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',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think it’s a good idea to set the base URL here, because everyone will have to change it and it’ll create unnecessary Git diffs. Let’s move it to .env for convenience.

#136


/**
* App version info
*/
APP_NAME: 'Ushadow Mobile',
};

export default AppConfig;
9 changes: 5 additions & 4 deletions ushadow/mobile/app/services/chronicleApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,11 @@ async function getChronicleApiUrl(): Promise<string> {
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.
Expand Down