diff --git a/packages/media/cpp/CMakeLists.txt b/packages/media/cpp/CMakeLists.txt index 2febd5d6..9bb36947 100644 --- a/packages/media/cpp/CMakeLists.txt +++ b/packages/media/cpp/CMakeLists.txt @@ -137,6 +137,17 @@ add_library(laserpants_dotenv INTERFACE) target_include_directories(laserpants_dotenv INTERFACE ${laserpants_dotenv_SOURCE_DIR}/include) add_library(laserpants::dotenv ALIAS laserpants_dotenv) +# libsodium — encrypted UI settings (Windows: secretbox + DPAPI key file; see src/win/settings_store.cpp) +if(WIN32) + set(SODIUM_DISABLE_TESTS ON CACHE BOOL "" FORCE) + FetchContent_Declare( + libsodium_cmake + GIT_REPOSITORY https://github.com/robinlinden/libsodium-cmake.git + GIT_TAG cfebfd3da486d5a86c644c8b47067e5411c7599c + ) + FetchContent_MakeAvailable(libsodium_cmake) +endif() + find_package(Vips REQUIRED) add_executable(pm-image @@ -156,6 +167,7 @@ if(WIN32) "${CMAKE_CURRENT_SOURCE_DIR}/src/win/resize_ui.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/win/resize_progress_ui.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/win/ui_singleton.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/win/settings_store.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/win/media-img-win.manifest" ) @@ -205,6 +217,7 @@ if(WIN32) "${_UI_NEXT_DIR}/LogPanel.cpp" "${_UI_NEXT_DIR}/FileInfoPanel.cpp" "${_UI_NEXT_DIR}/SettingsPanel.cpp" + "${_UI_NEXT_DIR}/ProviderDlg.cpp" "${_UI_NEXT_DIR}/Mainfrm.cpp" "${_UI_NEXT_DIR}/launch_ui_next.cpp" "${_UI_NEXT_DIR}/Resource.rc" @@ -256,6 +269,9 @@ target_link_libraries(pm-image PRIVATE CURL::libcurl Vips::vips ) +if(WIN32) + target_link_libraries(pm-image PRIVATE sodium) +endif() # GObject (g_object_ref / g_object_unref) — not re-exported through libvips import lib on MSVC. if(WIN32) diff --git a/packages/media/cpp/dist/pm-image.exe b/packages/media/cpp/dist/pm-image.exe index 43b8ff15..e45dc546 100644 Binary files a/packages/media/cpp/dist/pm-image.exe and b/packages/media/cpp/dist/pm-image.exe differ diff --git a/packages/media/cpp/dist/pm-image.pdb b/packages/media/cpp/dist/pm-image.pdb index 4b47fb58..a9629a80 100644 Binary files a/packages/media/cpp/dist/pm-image.pdb and b/packages/media/cpp/dist/pm-image.pdb differ diff --git a/packages/media/cpp/installer.nsi b/packages/media/cpp/installer.nsi index c26a5890..d3e86c60 100644 --- a/packages/media/cpp/installer.nsi +++ b/packages/media/cpp/installer.nsi @@ -126,6 +126,10 @@ !define PRODUCT_PUBLISHER "PolyMech" !define PRODUCT_WEB_SITE "https://service.polymech.info/user/cgo/pages/polymech-media-tools" !define PRODUCT_EXE "pm-image.exe" +; Per-user encrypted UI settings (libsodium secretbox; created at runtime by pm-image.exe): +; %APPDATA%\PolyMech\pm-image\settings.json +; %APPDATA%\PolyMech\pm-image\.settings-key.dat (DPAPI-protected 32-byte key) +!define PRODUCT_APPDATA_SUBDIR "PolyMech\pm-image" !define PRODUCT_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" !define ENV_KEY "Environment" diff --git a/packages/media/cpp/ref/images-ai/ProviderManagement.tsx b/packages/media/cpp/ref/images-ai/ProviderManagement.tsx new file mode 100644 index 00000000..07daf6d7 --- /dev/null +++ b/packages/media/cpp/ref/images-ai/ProviderManagement.tsx @@ -0,0 +1,950 @@ +/** + * Full CRUD interface for managing AI provider configurations + * Manages provider_configs table including API keys in settings field + * User-scoped: Shows only the current user's providers + */ + +import React, { useState, useEffect } from 'react'; +import { useAuth } from '@/hooks/useAuth'; +import { + fetchProviderConfigs, + createProviderConfig, + updateProviderConfig, + deleteProviderConfig, + type ProviderConfigRow, +} from '@/modules/providers/client-providers'; +import { DEFAULT_PROVIDERS, fetchProviderModelInfo } from '@/llm/filters/providers'; +import { groupModelsByCompany } from '@/llm/filters/providers/openrouter'; +import { groupOpenAIModelsByType } from '@/llm/filters/providers/openai'; +import { getUserApiKeys as getUserSecrets, updateUserSecrets } from '@/modules/user/client-user'; +import { cn } from '@/lib/utils'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + Plus, + Pencil, + Trash2, + Loader2, + Server, + Brain, + Zap, + Globe, + Eye, + EyeOff, + Save, + X, + AlertCircle, + CheckCircle2, + Check, + ChevronsUpDown +} from 'lucide-react'; +import { toast } from 'sonner'; +import { Alert, AlertDescription } from '@/components/ui/alert'; + +type ProviderConfig = ProviderConfigRow; + +interface ProviderSettings { + apiKey?: string; + defaultModel?: string; + [key: string]: any; +} + +const providerIcons = { + openai: Brain, + anthropic: Zap, + google: Globe, + openrouter: Server, +}; + +const getProviderIcon = (name: string) => { + const IconComponent = providerIcons[name as keyof typeof providerIcons] || Server; + return IconComponent; +}; + +export const ProviderManagement: React.FC = () => { + const { user, loading: authLoading } = useAuth(); + const [providers, setProviders] = useState([]); + const [loading, setLoading] = useState(true); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [editingProvider, setEditingProvider] = useState(null); + const [deletingProvider, setDeletingProvider] = useState(null); + + // Load user's providers from database + const loadProviders = async () => { + if (!user) { + setProviders([]); + setLoading(false); + return; + } + + setLoading(true); + try { + const [loadedProvidersRaw, userSecrets] = await Promise.all([ + fetchProviderConfigs(user.id), + getUserSecrets(user.id) + ]); + + let loadedProviders = loadedProvidersRaw || []; + + // Merge secrets into provider configurations for display + if (userSecrets) { + loadedProviders = loadedProviders.map(p => { + if (p.name === 'openai' && userSecrets['openai_api_key']) { + const settings = (p.settings as ProviderSettings) || {}; + return { ...p, settings: { ...settings, apiKey: userSecrets['openai_api_key'] } }; + } + if (p.name === 'google' && userSecrets['google_api_key']) { + const settings = (p.settings as ProviderSettings) || {}; + return { ...p, settings: { ...settings, apiKey: userSecrets['google_api_key'] } }; + } + return p; + }); + } + + setProviders(loadedProviders); + } catch (error: any) { + console.error('Failed to load providers:', error); + toast.error('Failed to load providers: ' + error.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!authLoading) { + loadProviders(); + } + }, [user, authLoading]); + + const handleCreateProvider = () => { + setEditingProvider(null); + setIsCreateDialogOpen(true); + }; + + const handleEditProvider = (provider: ProviderConfig) => { + setEditingProvider(provider); + setIsCreateDialogOpen(true); + }; + + const handleDeleteProvider = async () => { + if (!deletingProvider) return; + + try { + await deleteProviderConfig(user.id, deletingProvider.id); + + toast.success(`Provider "${deletingProvider.display_name}" deleted successfully`); + setDeletingProvider(null); + loadProviders(); + } catch (error: any) { + console.error('Failed to delete provider:', error); + toast.error('Failed to delete provider: ' + error.message); + } + }; + + if (authLoading || loading) { + return ( +
+ +
+ ); + } + + if (!user) { + return ( + + + +

Authentication Required

+

+ Please sign in to manage your AI providers +

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

My AI Providers

+

+ Manage your personal AI service providers and configurations +

+
+ +
+ + {/* Providers List */} + {providers.length === 0 ? ( + + + +

No providers yet

+

+ Add your first AI provider to start using custom LLM services +

+ +
+
+ ) : ( +
+ {providers.map((provider) => { + const Icon = getProviderIcon(provider.name); + const settings = (provider.settings as ProviderSettings) || {}; + const hasApiKey = !!settings.apiKey; + + return ( + + +
+
+
+ +
+
+ {provider.display_name} + {provider.name} +
+
+
+ + +
+
+
+ +
+
+ Status: + + {provider.is_active ? 'Active' : 'Inactive'} + +
+
+ API Key: + + {hasApiKey ? ( + <> + + Configured + + ) : ( + <> + + Not Set + + )} + +
+
+ Models: + + {Array.isArray(provider.models) ? provider.models.length : 0} + +
+
+ +
+ {provider.base_url} +
+
+
+ ); + })} +
+ )} + + {/* Create/Edit Dialog */} + { + loadProviders(); + setIsCreateDialogOpen(false); + }} + /> + + {/* Delete Confirmation Dialog */} + setDeletingProvider(null)}> + + + Delete Provider + + Are you sure you want to delete "{deletingProvider?.display_name}"? This action cannot be undone. + + + + + + + + +
+ ); +}; + +// Provider Edit Dialog Component +interface ProviderEditDialogProps { + provider: ProviderConfig | null; + user: any; + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: () => void; +} + +const ProviderEditDialog: React.FC = ({ + provider, + user, + open, + onOpenChange, + onSave, +}) => { + const isEdit = !!provider; + const [saving, setSaving] = useState(false); + const [showApiKey, setShowApiKey] = useState(false); + + // Form state + const [formData, setFormData] = useState({ + name: '', + display_name: '', + base_url: '', + models: [] as string[], + rate_limits: {} as Record, + is_active: true, + settings: {} as ProviderSettings, + }); + + const [selectedModel, setSelectedModel] = useState(''); + const [apiKeyInput, setApiKeyInput] = useState(''); + const [rateLimitRPM, setRateLimitRPM] = useState(''); + const [rateLimitTPM, setRateLimitTPM] = useState(''); + const [fetchingModels, setFetchingModels] = useState(false); + const [modelInfoList, setModelInfoList] = useState([]); + const [comboboxOpen, setComboboxOpen] = useState(false); + + // Load preset from DEFAULT_PROVIDERS + const handleLoadPreset = (presetName: string) => { + const preset = DEFAULT_PROVIDERS[presetName]; + if (!preset) return; + + setFormData({ + name: preset.name, + display_name: preset.displayName, + base_url: preset.baseUrl, + models: preset.models, + rate_limits: preset.rateLimits, + is_active: preset.isActive, + settings: preset.settings || {}, + }); + setRateLimitRPM(preset.rateLimits.requests_per_minute?.toString() || ''); + setRateLimitTPM(preset.rateLimits.tokens_per_minute?.toString() || ''); + + // Set first model as default if available + if (preset.models.length > 0) { + setSelectedModel(preset.models[0]); + } + + toast.success(`Loaded ${preset.displayName} preset`); + }; + + // Fetch models from provider API + const handleFetchModels = async () => { + if (!formData.name) { + toast.error('Please set provider name first'); + return; + } + + setFetchingModels(true); + try { + const models = await fetchProviderModelInfo(formData.name, apiKeyInput); + + // Store full model info + setModelInfoList(models); + + // Update form data models array + const modelIds = models.map((m: any) => m.id); + setFormData(prev => ({ ...prev, models: modelIds })); + + // Set first as default if available + if (modelIds.length > 0) { + setSelectedModel(modelIds[0]); + } + + toast.success(`Fetched ${modelIds.length} models from ${formData.display_name || formData.name}`); + } catch (error: any) { + console.error('Failed to fetch models:', error); + toast.error('Failed to fetch models: ' + error.message); + } finally { + setFetchingModels(false); + } + }; + + // Check if provider supports model fetching + const canFetchModels = () => { + // OpenRouter and OpenAI are implemented + return formData.name === 'openrouter' || formData.name === 'openai'; + }; + + useEffect(() => { + if (provider) { + const settings = (provider.settings as ProviderSettings) || {}; + const models = Array.isArray(provider.models) ? (provider.models as string[]) : []; + setFormData({ + name: provider.name, + display_name: provider.display_name, + base_url: provider.base_url, + models: models, + rate_limits: (provider.rate_limits as Record) || {}, + is_active: provider.is_active ?? true, + settings: settings, + }); + setSelectedModel(settings.defaultModel || ''); + setApiKeyInput(settings.apiKey || ''); + + const limits = (provider.rate_limits as Record) || {}; + setRateLimitRPM(limits.requests_per_minute?.toString() || ''); + setRateLimitTPM(limits.tokens_per_minute?.toString() || ''); + + // For existing providers, create simple modelInfo from saved models + if (models.length > 0) { + const simpleModelInfo = models.map(modelId => ({ + id: modelId, + name: modelId, + description: '', + isFree: false, + supportsTools: false, + supportsImages: false, + supportsText: true, + })); + setModelInfoList(simpleModelInfo); + } + } else { + // Reset form for new provider + setFormData({ + name: '', + display_name: '', + base_url: '', + models: [], + rate_limits: {}, + is_active: true, + settings: {}, + }); + setSelectedModel(''); + setApiKeyInput(''); + setRateLimitRPM(''); + setRateLimitTPM(''); + setModelInfoList([]); + } + setShowApiKey(false); + }, [provider, open]); + + const handleSave = async () => { + // Validation + if (!formData.name.trim()) { + toast.error('Provider name is required'); + return; + } + if (!formData.display_name.trim()) { + toast.error('Display name is required'); + return; + } + if (!formData.base_url.trim()) { + toast.error('Base URL is required'); + return; + } + + setSaving(true); + try { + // Build rate limits object + const rate_limits: Record = {}; + if (rateLimitRPM) { + rate_limits.requests_per_minute = parseInt(rateLimitRPM, 10); + } + if (rateLimitTPM) { + rate_limits.tokens_per_minute = parseInt(rateLimitTPM, 10); + } + + // Build settings object + const settings: ProviderSettings = { ...formData.settings }; + if (apiKeyInput.trim()) { + settings.apiKey = apiKeyInput.trim(); + } + if (selectedModel) { + settings.defaultModel = selectedModel; + } + + const data = { + name: formData.name.trim(), + display_name: formData.display_name.trim(), + base_url: formData.base_url.trim(), + models: formData.models, + rate_limits: rate_limits, + is_active: formData.is_active, + settings: settings, + }; + + if (isEdit) { + // Special handling for OpenAI/Google keys -> user_secrets + if (formData.name === 'openai' || formData.name === 'google') { + try { + const secretUpdate: Record = {}; + if (formData.name === 'openai') secretUpdate['openai_api_key'] = settings.apiKey || ''; + if (formData.name === 'google') secretUpdate['google_api_key'] = settings.apiKey || ''; + + await updateUserSecrets(user.id, secretUpdate); + } catch (secretError) { + console.error('Failed to update user secrets:', secretError); + toast.error('Failed to update secure storage, but saving config...'); + } + } + + // Update existing provider + await updateProviderConfig(user.id, provider.id, data); + toast.success('Provider updated successfully'); + } else { + // Create new provider with user_id + if (formData.name === 'openai' || formData.name === 'google') { + try { + const secretUpdate: Record = {}; + if (formData.name === 'openai') secretUpdate['openai_api_key'] = settings.apiKey || ''; + if (formData.name === 'google') secretUpdate['google_api_key'] = settings.apiKey || ''; + await updateUserSecrets(user.id, secretUpdate); + } catch (secretError) { + console.error('Failed to update user secrets:', secretError); + } + } + await createProviderConfig(user.id, { ...data, user_id: user.id }); + toast.success('Provider created successfully'); + } + + onSave(); + } catch (error: any) { + console.error('Failed to save provider:', error); + toast.error('Failed to save provider: ' + error.message); + } finally { + setSaving(false); + } + }; + + return ( + + + + {isEdit ? 'Edit Provider' : 'Create New Provider'} + + {isEdit + ? 'Update the provider configuration and API settings' + : 'Add a new AI service provider to the system'} + + + +
+ {/* Preset Selector - Only show when creating */} + {!isEdit && ( + + +
+ + +

+ Pre-fill form with default configuration for popular providers +

+
+
+
+ )} + + {/* Basic Information */} +
+

Basic Information

+ +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., openai, anthropic" + disabled={isEdit} // Don't allow changing name on edit + /> +

+ Unique identifier (lowercase, no spaces) +

+
+ +
+ + setFormData({ ...formData, display_name: e.target.value })} + placeholder="e.g., OpenAI, Anthropic" + /> +
+
+ +
+ + setFormData({ ...formData, base_url: e.target.value })} + placeholder="https://api.example.com/v1" + /> +
+ +
+ setFormData({ ...formData, is_active: checked })} + /> + +
+
+ + + + {/* API Settings */} +
+

API Settings

+ +
+ +
+ setApiKeyInput(e.target.value)} + placeholder="sk-..." + className="pr-10" + /> + +
+

+ Stored securely in the settings field +

+
+
+ + + + {/* Models */} +
+
+

Available Models

+ +
+ + {/* Show model count when models are available */} + {formData.models.length > 0 && ( +
+
+ {formData.models.length} models available +
+
+ Models fetched from {formData.display_name || formData.name} +
+
+ )} + + {/* Default Model Selector */} + {formData.models.length > 0 && ( +
+ + + + + + + + + No models found. +
+ + {(() => { + // Use different grouping based on provider + const groupedModels = formData.name === 'openai' + ? groupOpenAIModelsByType(modelInfoList) + : groupModelsByCompany(modelInfoList); + + return Object.entries(groupedModels) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([groupName, models]) => ( + + {models.map((modelInfo: any) => ( + { + setSelectedModel(modelInfo.id); + setComboboxOpen(false); + }} + > +
+ +
+
+ {modelInfo.name} +
+
+ + {modelInfo.isFree ? 'Free' : 'Paid'} + + {modelInfo.supportsTools && ( + + Tools + + )} + {modelInfo.supportsImages && ( + + Images + + )} + {!modelInfo.supportsImages && modelInfo.supportsText && ( + + Text Only + + )} +
+
+
+
+ ))} +
+ )); + })()} +
+
+
+
+
+

+ This model will be used by default when using this provider +

+
+ )} + + {/* Help text when no models */} + {formData.models.length === 0 && !canFetchModels() && ( +
+
+ 💡 Model fetching is not yet available for this provider. You can still create the provider and models will be loaded from presets. +
+
+ )} +
+ + + + {/* Rate Limits */} +
+

Rate Limits

+ +
+
+ + setRateLimitRPM(e.target.value)} + placeholder="60" + /> +
+ +
+ + setRateLimitTPM(e.target.value)} + placeholder="150000" + /> +
+
+
+ + {/* Info Alert */} + + + + This provider will be private to your account. API keys are stored securely in the settings field and are only accessible by you. + + +
+ + + + + +
+
+ ); +}; + +export default ProviderManagement; diff --git a/packages/media/cpp/ref/images-ai/ProviderSelector.tsx b/packages/media/cpp/ref/images-ai/ProviderSelector.tsx new file mode 100644 index 00000000..14871018 --- /dev/null +++ b/packages/media/cpp/ref/images-ai/ProviderSelector.tsx @@ -0,0 +1,284 @@ +/** + * Provider selector component for the filter system + */ + +import React, { useState, useEffect } from 'react'; +import { ProviderConfig } from '@/llm/filters/types'; +import { fetchProviderConfigs } from '@/modules/providers/client-providers'; +import { useAuth } from '@/hooks/useAuth'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { ProviderManagement } from './ProviderManagement'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Loader2, Zap, Globe, Brain, Server, Settings, Check, ChevronsUpDown } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +interface ProviderSelectorProps { + provider: string; + model: string; + onProviderChange: (provider: string) => void; + onModelChange: (model: string) => void; + disabled?: boolean; + className?: string; + showManagement?: boolean; +} + +const providerIcons = { + openai: Brain, + anthropic: Zap, + google: Globe, + openrouter: Server, +}; + +export const ProviderSelector: React.FC = ({ + provider, + model, + onProviderChange, + onModelChange, + disabled = false, + className = '', + showManagement = true +}) => { + const { user, loading: authLoading } = useAuth(); + const [providers, setProviders] = useState([]); + const [loading, setLoading] = useState(true); + const [showProviderManagement, setShowProviderManagement] = useState(false); + const [modelComboboxOpen, setModelComboboxOpen] = useState(false); + + // Load providers function (extracted for reuse) + const loadProviders = async () => { + if (!user) { + setProviders([]); + setLoading(false); + return; + } + + setLoading(true); + try { + const userProviders = await fetchProviderConfigs(user.id, { is_active: true }); + if (userProviders) { + const providers = userProviders.map(dbProvider => ({ + name: dbProvider.name, + displayName: dbProvider.display_name, + baseUrl: dbProvider.base_url, + models: Array.isArray(dbProvider.models) ? dbProvider.models as string[] : [], + rateLimits: (dbProvider.rate_limits as Record) || {}, + isActive: dbProvider.is_active ?? true, + settings: (dbProvider.settings as Record) || {}, + })); + setProviders(providers); + } else { + setProviders([]); + } + } catch (error) { + console.error('Failed to load providers:', error); + setProviders([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!authLoading) { + loadProviders(); + } + }, [user, authLoading]); + + const handleProviderChange = (newProvider: string) => { + onProviderChange(newProvider); + + // Reset model when provider changes + const models = getCurrentModels(newProvider); + if (models.length > 0) { + // Use default model from settings if available, otherwise first model + const selectedProvider = providers.find(p => p.name === newProvider); + const defaultModel = selectedProvider?.settings?.defaultModel; + const modelToSelect = defaultModel && models.includes(defaultModel) ? defaultModel : models[0]; + onModelChange(modelToSelect); + } + }; + + const getProviderIcon = (providerName: string) => { + const IconComponent = providerIcons[providerName as keyof typeof providerIcons] || Server; + return ; + }; + + const getCurrentModels = (providerName?: string) => { + const targetProvider = providerName || provider; + const selectedProvider = providers.find(p => p.name === targetProvider); + return selectedProvider?.models || []; + }; + + if (authLoading || loading) { + return ( +
+ +
+ + Loading your providers... +
+
+ ); + } + + if (!user) { + return ( +
+ +
+
+ Sign in to manage AI providers +
+
+
+ ); + } + + return ( +
+ {/* Provider Selection */} +
+
+ + {showManagement && ( + + )} +
+ +
+ + {/* Model Selection */} +
+ + {getCurrentModels().length === 0 ? ( +
+
+ No models available +
+
+ Select a provider with configured models +
+
+ ) : ( + + + + + + + + No models found. +
+ + {getCurrentModels().map((modelName) => ( + { + onModelChange(modelName); + setModelComboboxOpen(false); + }} + > +
+ + {modelName} +
+
+ ))} +
+
+
+
+
+ )} +
+ + {/* Provider Management Dialog */} + { + setShowProviderManagement(open); + // Refresh providers when dialog closes to show any new/updated providers + if (!open && !authLoading) { + loadProviders(); + } + }}> + + + + +
+ ); +}; diff --git a/packages/media/cpp/ref/images-ai/client-user.ts b/packages/media/cpp/ref/images-ai/client-user.ts new file mode 100644 index 00000000..6fe7e2ca --- /dev/null +++ b/packages/media/cpp/ref/images-ai/client-user.ts @@ -0,0 +1,376 @@ +import { UserProfile } from "@/modules/posts/views/types"; +import { fetchWithDeduplication, apiClient, getAuthToken as getZitadelToken, serverUrl as serverBaseUrl } from "@/lib/db"; + +const serverUrl = (path: string) => { + const baseUrl = serverBaseUrl || window.location.origin; + return `${baseUrl}${path}`; +}; + +/** Fetch full profile data from server API endpoint */ +export const fetchProfileAPI = async (userId: string): Promise<{ profile: any; recentPosts: any[] } | null> => { + const res = await fetch(serverUrl(`/api/profile/${userId}`)); + if (!res.ok) { + if (res.status === 404) return null; + throw new Error(`Failed to fetch profile: ${res.statusText}`); + } + return await res.json(); +}; + +export const fetchAuthorProfile = async (userId: string): Promise => { + return fetchWithDeduplication(`profile-${userId}`, async () => { + const result = await fetchProfileAPI(userId); + return (result?.profile as UserProfile) ?? null; + }); +}; + +export const getUserSettings = async (userId: string) => { + return fetchWithDeduplication(`settings-${userId}`, async () => { + const token = await getAuthToken(); + const res = await fetch(serverUrl('/api/me/settings'), { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!res.ok) throw new Error(`Failed to fetch settings: ${res.statusText}`); + return await res.json(); + }, 100000); +}; + +export const updateUserSettings = async (userId: string, settings: any) => { + const token = await getAuthToken(); + const res = await fetch(serverUrl('/api/me/settings'), { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(settings) + }); + if (!res.ok) throw new Error(`Failed to update settings: ${res.statusText}`); +}; + +export const getUserOpenAIKey = async (userId: string) => { + return fetchWithDeduplication(`openai-${userId}`, async () => { + const token = await getAuthToken(); + const res = await fetch(serverUrl('/api/me/secrets'), { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!res.ok) throw new Error(`Failed to fetch secrets: ${res.statusText}`); + const data = await res.json(); + // Server returns masked keys — for checking existence, use has_key + // For actual key value, the server-side code reads it directly + return data.api_keys?.openai_api_key?.has_key ? '(set)' : null; + }); +} + +/** Get all API keys (masked) from server proxy */ +export const getUserApiKeys = async (userId: string): Promise | null> => { + return fetchWithDeduplication(`api-keys-${userId}`, async () => { + const token = await getAuthToken(); + const res = await fetch(serverUrl('/api/me/secrets'), { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!res.ok) throw new Error(`Failed to fetch secrets: ${res.statusText}`); + const data = await res.json(); + return data.api_keys || null; + }); +}; + +export const getUserGoogleApiKey = async (userId: string) => { + return fetchWithDeduplication(`google-${userId}`, async () => { + const token = await getAuthToken(); + const res = await fetch(serverUrl('/api/me/secrets'), { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!res.ok) throw new Error(`Failed to fetch secrets: ${res.statusText}`); + const data = await res.json(); + const ret = data.api_keys?.google_api_key || null; + return ret; + }); +} + +export const getUserSecrets = async (userId: string) => { + return fetchWithDeduplication(`user-secrets-${userId}`, async () => { + const token = await getAuthToken(); + const res = await fetch(serverUrl('/api/me/secrets'), { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!res.ok) throw new Error(`Failed to fetch secrets: ${res.statusText}`); + const data = await res.json(); + return data.variables || {}; + }); +}; + +export const fetchUserRoles = async (userId: string) => { + return fetchWithDeduplication(`roles-${userId}`, async () => { + const token = await getAuthToken(); + const res = await fetch(serverUrl('/api/me/roles'), { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!res.ok) { + console.error('Error fetching user roles:', res.statusText); + return []; + } + return await res.json(); + }); +}; + +/** + * Fetch the resolved app identity in one call: + * - `id` — app UUID (profiles.user_id) — use this everywhere in the app + * - `sub` — original Zitadel numeric sub from the OIDC token + * - `roles` — the user's roles array + * + * No cache key here since it's called once per auth session and + * the result drives the AuthContext state. + */ +export const fetchUserIdentity = async (): Promise<{ id: string; sub: string; roles: string[] }> => { + return apiClient<{ id: string; sub: string; roles: string[] }>('/api/me/identity'); +}; + +/** + * Update user secrets via server proxy (API keys are merged, other fields replaced). + */ +export const updateUserSecrets = async (userId: string, secrets: Record): Promise => { + try { + const token = await getAuthToken(); + const res = await fetch(serverUrl('/api/me/secrets'), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ api_keys: secrets }) + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to update secrets: ${res.statusText}`); + } + } catch (error) { + console.error('Error updating user secrets:', error); + throw error; + } +}; + +/** + * Get user variables via server proxy + */ +export const getUserVariables = async (userId: string): Promise | null> => { + try { + const token = await getAuthToken(); + const res = await fetch(serverUrl('/api/me/secrets'), { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!res.ok) return null; + const data = await res.json(); + return data.variables || {}; + } catch (error) { + console.error('Error fetching user variables:', error); + return null; + } +}; + +/** + * Update user variables via server proxy + */ +export const updateUserVariables = async (userId: string, variables: Record): Promise => { + try { + const token = await getAuthToken(); + const res = await fetch(serverUrl('/api/me/secrets'), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ variables }) + }); + if (!res.ok) throw new Error(`Failed to update variables: ${res.statusText}`); + } catch (error) { + console.error('Error updating user variables:', error); + throw error; + } +}; + +// ============================================= +// Shipping Addresses (stored in user_secrets.settings.shipping_addresses) +// ============================================= + +export interface SavedShippingAddress { + id: string; + label: string; + fullName: string; + email: string; + phone: string; + address: string; + city: string; + zip: string; + country: string; + note: string; + isDefault: boolean; +} + +/** Get shipping addresses via server proxy */ +export const getShippingAddresses = async (userId: string): Promise => { + try { + const token = await getAuthToken(); + const res = await fetch(serverUrl('/api/me/secrets'), { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!res.ok) return []; + const data = await res.json(); + return (data.shipping_addresses as SavedShippingAddress[]) || []; + } catch (error) { + console.error('Error fetching shipping addresses:', error); + return []; + } +}; + +/** Save shipping addresses via server proxy (full replace) */ +export const saveShippingAddresses = async (userId: string, addresses: SavedShippingAddress[]): Promise => { + try { + const token = await getAuthToken(); + const res = await fetch(serverUrl('/api/me/secrets'), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ shipping_addresses: addresses }) + }); + if (!res.ok) throw new Error(`Failed to save shipping addresses: ${res.statusText}`); + } catch (error) { + console.error('Error saving shipping addresses:', error); + throw error; + } +}; + +// ============================================= +// Vendor Profiles (stored in user_secrets.settings.vendor_profiles) +// ============================================= + +export interface VendorProfile { + id: string; + label: string; + companyName: string; + email: string; + phone: string; + vatId: string; + eoriNumber: string; + logoUrl: string; + address: string; + city: string; + zip: string; + country: string; + defaultCurrency: string; + defaultCountryOfOrigin: string; + note: string; + isDefault: boolean; +} + +/** Get vendor profiles via server proxy */ +export const getVendorProfiles = async (userId: string): Promise => { + try { + const token = await getAuthToken(); + const res = await fetch(serverUrl('/api/me/secrets'), { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!res.ok) return []; + const data = await res.json(); + return (data.vendor_profiles as VendorProfile[]) || []; + } catch (error) { + console.error('Error fetching vendor profiles:', error); + return []; + } +}; + +/** Save vendor profiles via server proxy (full replace) */ +export const saveVendorProfiles = async (userId: string, profiles: VendorProfile[]): Promise => { + try { + const token = await getAuthToken(); + const res = await fetch(serverUrl('/api/me/secrets'), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ vendor_profiles: profiles }) + }); + if (!res.ok) throw new Error(`Failed to save vendor profiles: ${res.statusText}`); + } catch (error) { + console.error('Error saving vendor profiles:', error); + throw error; + } +}; + +/** Update the current user's profile via API */ +export const updateProfileAPI = async (profileData: { + username?: string | null; + display_name?: string | null; + bio?: string | null; + avatar_url?: string | null; + settings?: any; +}): Promise => { + const token = await getZitadelToken(); + if (!token) throw new Error('Not authenticated'); + + const res = await fetch(serverUrl('/api/profile'), { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(profileData) + }); + if (!res.ok) throw new Error(`Failed to update profile: ${res.statusText}`); + return await res.json(); +}; + +/** Update the current user's email via server API */ +export const updateUserEmail = async (newEmail: string): Promise => { + const token = await getAuthToken(); + if (!token) throw new Error('Not authenticated'); + await apiClient('/api/me/email', { + method: 'PATCH', + body: JSON.stringify({ email: newEmail }), + }); +}; + +const getAuthToken = async (): Promise => { + const token = await getZitadelToken(); + if (!token) throw new Error('Not authenticated'); + return token; +}; + +/** Batch-fetch profiles for admin UIs (e.g. ACL subject column). GET /api/profiles?ids= */ +export const fetchProfilesByUserIds = async ( + userIds: string[], +): Promise> => { + if (userIds.length === 0) return {}; + const token = await getAuthToken(); + const params = new URLSearchParams(); + params.set('ids', userIds.join(',')); + const res = await fetch(serverUrl(`/api/profiles?${params.toString()}`), { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error('Failed to fetch profiles'); + const data: unknown = await res.json(); + if (!Array.isArray(data)) return {}; + const map: Record = {}; + for (const p of data as { user_id: string }[]) { + if (p?.user_id) map[p.user_id] = p as { display_name?: string; username?: string }; + } + return map; +}; + +export { + fetchAdminUsersAPI, + createAdminUserAPI, + updateAdminUserAPI, + deleteAdminUserAPI, +} from '@/modules/admin/client-admin'; + +/** Notify the server to flush its auth cache for the current user (best-effort, never throws) */ +export const notifyServerLogout = (token: string): void => { + fetch(serverUrl('/api/auth/logout'), { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }).catch(() => { /* best-effort — don't block logout */ }); +}; diff --git a/packages/media/cpp/src/win/settings_store.cpp b/packages/media/cpp/src/win/settings_store.cpp new file mode 100644 index 00000000..9cae5dc0 --- /dev/null +++ b/packages/media/cpp/src/win/settings_store.cpp @@ -0,0 +1,403 @@ +#include "settings_store.hpp" + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +using json = nlohmann::json; + +#pragma comment(lib, "Crypt32.lib") +#pragma comment(lib, "Shell32.lib") +#pragma comment(lib, "Ole32.lib") + +namespace fs = std::filesystem; + +namespace media::settings { + +namespace { + +constexpr char kMagic[4] = {'P', 'M', 'E', '1'}; +/** Must match installer.nsi comment / docs: %APPDATA%\PolyMech\pm-image */ +constexpr const wchar_t kAppDataSubdir[] = L"PolyMech\\pm-image"; +constexpr const wchar_t kKeyFileName[] = L".settings-key.dat"; + +bool read_file_bytes(const fs::path& p, std::vector& out) { + std::ifstream ifs(p, std::ios::binary); + if (!ifs.is_open()) + return false; + ifs.seekg(0, std::ios::end); + const auto sz = static_cast(ifs.tellg()); + ifs.seekg(0, std::ios::beg); + out.resize(sz); + if (sz) + ifs.read(reinterpret_cast(out.data()), static_cast(sz)); + return static_cast(ifs); +} + +bool write_file_bytes(const fs::path& p, const unsigned char* data, size_t len) { + std::error_code ec; + fs::create_directories(p.parent_path(), ec); + std::ofstream ofs(p, std::ios::binary | std::ios::trunc); + if (!ofs.is_open()) + return false; + if (len) + ofs.write(reinterpret_cast(data), static_cast(len)); + return static_cast(ofs); +} + +bool dpapi_protect(const std::vector& plain, std::vector& out) { + DATA_BLOB in{(DWORD)plain.size(), const_cast(plain.data())}; + DATA_BLOB out_blob{}; + if (!CryptProtectData(&in, L"pm-image-settings-key", nullptr, nullptr, nullptr, 0, &out_blob)) + return false; + out.assign(out_blob.pbData, out_blob.pbData + out_blob.cbData); + LocalFree(out_blob.pbData); + return true; +} + +bool dpapi_unprotect(const std::vector& enc, std::vector& plain) { + DATA_BLOB in{(DWORD)enc.size(), const_cast(enc.data())}; + DATA_BLOB out_blob{}; + if (!CryptUnprotectData(&in, nullptr, nullptr, nullptr, nullptr, 0, &out_blob)) + return false; + plain.assign(out_blob.pbData, out_blob.pbData + out_blob.cbData); + LocalFree(out_blob.pbData); + return true; +} + +bool ensure_sodium(std::string& err) { + static bool done = false; + if (done) + return true; + if (sodium_init() < 0) { + err = "sodium_init failed"; + return false; + } + done = true; + return true; +} + +bool load_or_create_secret_key(std::vector& key32, std::string& err) { + if (!ensure_sodium(err)) + return false; + key32.resize(crypto_secretbox_KEYBYTES); + + const fs::path key_path = get_config_dir() / kKeyFileName; + std::vector file_bytes; + if (fs::exists(key_path)) { + if (!read_file_bytes(key_path, file_bytes) || file_bytes.empty()) { + err = "failed to read settings key file"; + return false; + } + std::vector plain; + if (!dpapi_unprotect(file_bytes, plain) || plain.size() != crypto_secretbox_KEYBYTES) { + err = "CryptUnprotectData(settings key) failed"; + return false; + } + std::copy(plain.begin(), plain.end(), key32.begin()); + return true; + } + + randombytes_buf(key32.data(), key32.size()); + std::vector prot; + if (!dpapi_protect(key32, prot)) { + err = "CryptProtectData(settings key) failed"; + return false; + } + if (!write_file_bytes(key_path, prot.data(), prot.size())) { + err = "failed to write settings key file"; + return false; + } + return true; +} + +bool looks_like_json_plaintext(const std::vector& raw) { + size_t i = 0; + while (i < raw.size() && std::isspace(static_cast(raw[i]))) + ++i; + return i < raw.size() && raw[i] == '{'; +} + +} // namespace + +fs::path get_config_dir() { + PWSTR path_tmp = nullptr; + if (FAILED(SHGetKnownFolderPath(FOLDERID_RoamingAppData, 0, nullptr, &path_tmp))) { + throw std::runtime_error("SHGetKnownFolderPath(RoamingAppData) failed"); + } + fs::path path(path_tmp); + CoTaskMemFree(path_tmp); + path /= kAppDataSubdir; + std::error_code ec; + fs::create_directories(path, ec); + return path; +} + +fs::path get_settings_json_path() { + return get_config_dir() / "settings.json"; +} + +bool load_settings_utf8(std::string& out_json, std::string& err_out) { + err_out.clear(); + out_json.clear(); + std::string err; + if (!ensure_sodium(err)) { + err_out = err; + return false; + } + + const fs::path sp = get_settings_json_path(); + if (!fs::exists(sp)) + return true; + + std::vector raw; + if (!read_file_bytes(sp, raw) || raw.empty()) + return true; + + if (raw.size() >= sizeof(kMagic) && std::memcmp(raw.data(), kMagic, sizeof(kMagic)) == 0) { + if (raw.size() < sizeof(kMagic) + crypto_secretbox_NONCEBYTES + crypto_secretbox_MACBYTES) { + err_out = "encrypted settings truncated"; + return false; + } + std::vector key(crypto_secretbox_KEYBYTES); + if (!load_or_create_secret_key(key, err)) { + err_out = err; + return false; + } + const unsigned char* nonce = raw.data() + sizeof(kMagic); + const unsigned char* cipher = nonce + crypto_secretbox_NONCEBYTES; + const size_t cipher_len = raw.size() - sizeof(kMagic) - crypto_secretbox_NONCEBYTES; + if (cipher_len < crypto_secretbox_MACBYTES) { + err_out = "encrypted settings ciphertext too short"; + return false; + } + std::vector plain(cipher_len - crypto_secretbox_MACBYTES); + if (crypto_secretbox_open_easy(plain.data(), cipher, cipher_len, nonce, key.data()) != 0) { + err_out = "crypto_secretbox_open_easy failed (wrong key or corrupt file)"; + return false; + } + out_json.assign(reinterpret_cast(plain.data()), plain.size()); + return true; + } + + if (looks_like_json_plaintext(raw)) { + out_json.assign(reinterpret_cast(raw.data()), raw.size()); + return true; + } + + err_out = "settings.json: not PME1 encrypted and not valid plaintext JSON"; + return false; +} + +bool save_settings_utf8(const std::string& utf8_json, std::string& err_out) { + err_out.clear(); + std::string err; + if (!ensure_sodium(err)) { + err_out = err; + return false; + } + std::vector key(crypto_secretbox_KEYBYTES); + if (!load_or_create_secret_key(key, err)) { + err_out = err; + return false; + } + + unsigned char nonce[crypto_secretbox_NONCEBYTES]; + randombytes_buf(nonce, sizeof nonce); + + const size_t msg_len = utf8_json.size(); + std::vector cipher(msg_len + crypto_secretbox_MACBYTES); + if (crypto_secretbox_easy(cipher.data(), reinterpret_cast(utf8_json.data()), msg_len, nonce, + key.data()) != 0) { + err_out = "crypto_secretbox_easy failed"; + return false; + } + + std::vector out; + out.reserve(sizeof(kMagic) + sizeof nonce + cipher.size()); + out.insert(out.end(), std::begin(kMagic), std::end(kMagic)); + out.insert(out.end(), nonce, nonce + sizeof nonce); + out.insert(out.end(), cipher.begin(), cipher.end()); + + const fs::path sp = get_settings_json_path(); + if (!write_file_bytes(sp, out.data(), out.size())) { + err_out = "failed to write settings.json"; + return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Provider management +// --------------------------------------------------------------------------- + +static const ProviderDefaults kProviders[] = { + { "google", "Google / Gemini", "https://generativelanguage.googleapis.com/v1beta", + { "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview", "gemini-2.0-flash-exp" } }, + { "openai", "OpenAI", "https://api.openai.com/v1", + { "gpt-image-1", "dall-e-3", "gpt-4o" } }, + { "anthropic", "Anthropic", "https://api.anthropic.com/v1", + { "claude-opus-4-5", "claude-sonnet-4-5", "claude-3-5-haiku-20241022" } }, + { "openrouter", "OpenRouter", "https://openrouter.ai/api/v1", + { "openai/gpt-4o", "anthropic/claude-3-5-sonnet", "google/gemini-2-flash" } }, +}; + +const ProviderDefaults* known_providers(int* count_out) { + if (count_out) + *count_out = static_cast(std::size(kProviders)); + return kProviders; +} + +bool load_providers(ProviderMap& out, std::string& active_provider, std::string& err) { + out.clear(); + active_provider = "google"; + + // Initialise from defaults so missing entries still have base_url + int n = 0; + for (const auto* p = known_providers(&n); n--; ++p) { + ProviderEntry e; + e.base_url = p->base_url; + e.default_model = p->models.empty() ? "" : p->models[0]; + out[p->name] = std::move(e); + } + + std::string raw_json; + if (!load_settings_utf8(raw_json, err)) + return false; + if (raw_json.empty()) + return true; + + try { + auto j = json::parse(raw_json); + if (j.contains("active_provider") && j["active_provider"].is_string()) + active_provider = j["active_provider"].get(); + + if (j.contains("providers") && j["providers"].is_object()) { + for (auto& [name, val] : j["providers"].items()) { + ProviderEntry& e = out[name]; + if (val.contains("api_key") && val["api_key"].is_string()) + e.api_key = val["api_key"].get(); + if (val.contains("base_url") && val["base_url"].is_string()) + e.base_url = val["base_url"].get(); + if (val.contains("default_model") && val["default_model"].is_string()) + e.default_model = val["default_model"].get(); + } + } + } catch (const std::exception& ex) { + err = std::string("JSON parse error: ") + ex.what(); + return false; + } + return true; +} + +bool save_providers(const ProviderMap& providers, const std::string& active_provider, std::string& err) { + // Load existing JSON to preserve other keys (e.g. prompt_presets) + std::string raw_json; + json j = json::object(); + if (load_settings_utf8(raw_json, err) && !raw_json.empty()) { + try { j = json::parse(raw_json); } catch (...) { j = json::object(); } + } + + j["active_provider"] = active_provider; + auto& jproviders = j["providers"]; + if (!jproviders.is_object()) + jproviders = json::object(); + + for (auto& [name, e] : providers) { + jproviders[name]["api_key"] = e.api_key; + jproviders[name]["base_url"] = e.base_url; + jproviders[name]["default_model"] = e.default_model; + } + + return save_settings_utf8(j.dump(2), err); +} + +// --------------------------------------------------------------------------- +// Window / dock layout +// --------------------------------------------------------------------------- + +bool load_window_layout(WindowLayout& out, std::string& err) { + out = WindowLayout{}; + + std::string raw_json; + if (!load_settings_utf8(raw_json, err)) + return false; + if (raw_json.empty()) + return true; + + try { + auto j = json::parse(raw_json); + if (!j.contains("window")) + return true; + + const auto& w = j["window"]; + out.has_placement = true; + out.show_cmd = w.value("show_cmd", 1); + if (w.contains("min_pos") && w["min_pos"].is_array() && w["min_pos"].size() >= 2) { + out.min_pos.x = w["min_pos"][0].get(); + out.min_pos.y = w["min_pos"][1].get(); + } + if (w.contains("max_pos") && w["max_pos"].is_array() && w["max_pos"].size() >= 2) { + out.max_pos.x = w["max_pos"][0].get(); + out.max_pos.y = w["max_pos"][1].get(); + } + if (w.contains("normal_rect") && w["normal_rect"].is_array() && w["normal_rect"].size() >= 4) { + out.normal_rect.left = w["normal_rect"][0].get(); + out.normal_rect.top = w["normal_rect"][1].get(); + out.normal_rect.right = w["normal_rect"][2].get(); + out.normal_rect.bottom = w["normal_rect"][3].get(); + } + } catch (const std::exception& ex) { + err = std::string("layout JSON parse: ") + ex.what(); + return false; + } + return true; +} + +bool save_window_layout(const WindowLayout& layout, std::string& err) { + // Load existing JSON to preserve all other keys (providers, presets, etc.) + std::string raw_json; + json j = json::object(); + if (load_settings_utf8(raw_json, err) && !raw_json.empty()) { + try { j = json::parse(raw_json); } catch (...) { j = json::object(); } + } + + // Remove legacy dock/panel keys if present from older builds. + j.erase("layout"); + + auto& w = j["window"]; + w["show_cmd"] = layout.show_cmd; + w["min_pos"] = json::array({ layout.min_pos.x, layout.min_pos.y }); + w["max_pos"] = json::array({ layout.max_pos.x, layout.max_pos.y }); + w["normal_rect"] = json::array({ + layout.normal_rect.left, layout.normal_rect.top, + layout.normal_rect.right, layout.normal_rect.bottom }); + + return save_settings_utf8(j.dump(2), err); +} + +std::string get_active_api_key(std::string& provider_name_out) { + provider_name_out = "google"; + ProviderMap pm; + std::string err; + if (!load_providers(pm, provider_name_out, err)) + return {}; + auto it = pm.find(provider_name_out); + if (it == pm.end()) + return {}; + return it->second.api_key; +} + +} // namespace media::settings diff --git a/packages/media/cpp/src/win/settings_store.hpp b/packages/media/cpp/src/win/settings_store.hpp new file mode 100644 index 00000000..17a66694 --- /dev/null +++ b/packages/media/cpp/src/win/settings_store.hpp @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace media::settings { + +/** %APPDATA%\PolyMech\pm-image (created if missing). */ +std::filesystem::path get_config_dir(); + +/** Encrypted settings file: get_config_dir() / "settings.json" (binary PME1 blob or legacy UTF-8 JSON). */ +std::filesystem::path get_settings_json_path(); + +/** + * Load UTF-8 JSON string. Supports: + * - libsodium secretbox file (magic PME1 + nonce + ciphertext) + * - legacy plaintext JSON (trimmed first char '{') + */ +bool load_settings_utf8(std::string& out_json, std::string& err_out); + +/** + * Save UTF-8 JSON (always written as encrypted PME1 blob; 32-byte key in .settings-key.dat protected via DPAPI). + */ +bool save_settings_utf8(const std::string& utf8_json, std::string& err_out); + +// --------------------------------------------------------------------------- +// Provider key management (stored under "providers" key in settings.json) +// --------------------------------------------------------------------------- + +/** Per-provider configuration stored in settings.json. */ +struct ProviderEntry { + std::string api_key; + std::string base_url; + std::string default_model; +}; + +using ProviderMap = std::map; + +/** + * Known provider names and their default base URLs. + * Order matches the UI display order. + */ +struct ProviderDefaults { + const char* name; + const char* display_name; + const char* base_url; + std::vector models; +}; + +/** Static list of supported providers. */ +const ProviderDefaults* known_providers(int* count_out); + +/** + * Load provider entries from settings.json. + * Missing providers are returned with empty api_key and provider defaults for base_url. + */ +bool load_providers(ProviderMap& out, std::string& active_provider, std::string& err); + +/** + * Save provider entries (and active provider name) to settings.json. + * Non-provider keys in the JSON (e.g. prompt_presets) are preserved. + */ +bool save_providers(const ProviderMap& providers, const std::string& active_provider, std::string& err); + +/** + * Convenience: load the active provider's API key. + * Returns empty string on failure or if no key is configured. + */ +std::string get_active_api_key(std::string& provider_name_out); + +// --------------------------------------------------------------------------- +// Window placement persistence (settings.json) +// --------------------------------------------------------------------------- +// Dock topology (parent, style, size, floating rect, hidden state) is stored +// in the Win32++ registry under Software\Polymech\pm-image-ui\Dock Settings +// via SaveDockRegistrySettings / LoadDockRegistrySettings. +// --------------------------------------------------------------------------- + +/** + * Main window placement stored in settings.json. + * Multi-monitor safety is applied when loading: if the saved monitor is + * gone the window is recentred on the primary monitor. + */ +struct WindowLayout { + bool has_placement = false; + int show_cmd = 1; // SW_* constant + POINT min_pos = {-1, -1}; + POINT max_pos = {-1, -1}; + RECT normal_rect = {100, 100, 1300, 900}; +}; + +/** + * Load window layout from settings.json. + * On first run / missing data, returns default-initialised struct (err stays empty). + */ +bool load_window_layout(WindowLayout& out, std::string& err); + +/** + * Persist window layout to settings.json, preserving all other keys. + */ +bool save_window_layout(const WindowLayout& layout, std::string& err); + +} // namespace media::settings diff --git a/packages/media/cpp/src/win/ui_next/Mainfrm.cpp b/packages/media/cpp/src/win/ui_next/Mainfrm.cpp index 85385f23..0cf08caa 100644 --- a/packages/media/cpp/src/win/ui_next/Mainfrm.cpp +++ b/packages/media/cpp/src/win/ui_next/Mainfrm.cpp @@ -1,11 +1,14 @@ #include "stdafx.h" #include "Mainfrm.h" #include "Resource.h" +#include "ProviderDlg.h" #include "core/resize.hpp" #include "core/transform.hpp" +#include "win/settings_store.hpp" #include #include #include +#include #include namespace fs = std::filesystem; @@ -31,7 +34,11 @@ static std::wstring utf8_to_wide_mf(const std::string& s) { CMainFrame::CMainFrame() { - m_settingsPath = (fs::current_path() / "settings.json").string(); + try { + m_settingsPath = media::settings::get_settings_json_path().string(); + } catch (...) { + m_settingsPath = (fs::current_path() / "settings.json").string(); + } LoadPresets(); } @@ -71,6 +78,7 @@ STDMETHODIMP CMainFrame::Execute(UINT32 cmdID, UI_EXECUTIONVERB verb, case IDC_CMD_ADD_FILES: OnAddFiles(); break; case IDC_CMD_ADD_FOLDER: OnAddFolder(); break; case IDC_CMD_CLEAR: OnClearQueue(); break; + case IDC_CMD_SAVE_AS: OnSaveAs(); break; case IDC_CMD_RESIZE: SwitchSettingsMode(CSettingsView::MODE_RESIZE); OnResize(); @@ -83,8 +91,9 @@ STDMETHODIMP CMainFrame::Execute(UINT32 cmdID, UI_EXECUTIONVERB verb, SwitchSettingsMode(CSettingsView::MODE_TRANSFORM); OnRun(); break; - case IDC_CMD_PRESETS: OnPresets(); break; - case IDC_CMD_ABOUT: OnHelp(); break; + case IDC_CMD_PRESETS: OnPresets(); break; + case IDC_CMD_PROVIDER_KEYS: ShowProviderSettingsDlg(GetHwnd()); break; + case IDC_CMD_ABOUT: OnHelp(); break; case IDC_CMD_EXIT: OnExit(); break; case IDC_RIBBONHELP: OnHelp(); break; @@ -112,6 +121,35 @@ STDMETHODIMP CMainFrame::Execute(UINT32 cmdID, UI_EXECUTIONVERB verb, HandleSizeSelect(cmdID); break; + // View panel toggles — each knows its parent and dock side + case IDC_CMD_VIEW_QUEUE: + TogglePanelView(m_pDockQueue, DS_DOCKED_BOTTOM, + GetDockAncestor(), DpiScaleInt(220), IDC_CMD_VIEW_QUEUE); + break; + case IDC_CMD_VIEW_LOG: + TogglePanelView(m_pDockLog, DS_DOCKED_RIGHT, + m_pDockQueue, DpiScaleInt(360), IDC_CMD_VIEW_LOG); + break; + case IDC_CMD_VIEW_SETTINGS: + TogglePanelView(m_pDockSettings, DS_DOCKED_RIGHT, + GetDockAncestor(), DpiScaleInt(280), IDC_CMD_VIEW_SETTINGS); + break; + case IDC_CMD_VIEW_FILEINFO: + TogglePanelView(m_pDockFileInfo, DS_DOCKED_BOTTOM, + m_pDockGenPreview, DpiScaleInt(160), IDC_CMD_VIEW_FILEINFO); + break; + case IDC_CMD_VIEW_GENPREVIEW: + TogglePanelView(m_pDockGenPreview, DS_DOCKED_BOTTOM, + m_pDockSettings, DpiScaleInt(200), IDC_CMD_VIEW_GENPREVIEW); + break; + + case IDC_CMD_RESET_LAYOUT: + ResetLayout(); + break; + case IDC_CMD_DEBUG_STATE: + DebugDockState(); + break; + default: break; } } @@ -165,7 +203,18 @@ void CMainFrame::InvalidateToggle(UINT32 cmdID) bool CMainFrame::IsToggleSelected(UINT32 cmdID) const { - return (cmdID == m_selModel || cmdID == m_selAspect || cmdID == m_selSize); + if (cmdID == m_selModel || cmdID == m_selAspect || cmdID == m_selSize) + return true; + + // View panel toggles reflect current visibility + switch (cmdID) { + case IDC_CMD_VIEW_QUEUE: return IsPanelVisible(m_pDockQueue); + case IDC_CMD_VIEW_LOG: return IsPanelVisible(m_pDockLog); + case IDC_CMD_VIEW_SETTINGS: return IsPanelVisible(m_pDockSettings); + case IDC_CMD_VIEW_FILEINFO: return IsPanelVisible(m_pDockFileInfo); + case IDC_CMD_VIEW_GENPREVIEW: return IsPanelVisible(m_pDockGenPreview); + } + return false; } void CMainFrame::HandleModelSelect(UINT32 cmdID) @@ -238,42 +287,121 @@ BOOL CMainFrame::OnHelp() return TRUE; } -void CMainFrame::OnInitialUpdate() +// Called by LoadDockRegistrySettings() to instantiate the right CDocker subclass. +// Do NOT store the raw pointer here — Win32++ may call CloseAllDockers() on failure +// which deletes the objects and leaves our stored pointers dangling. +// Member pointers are fetched safely via GetDockFromID() after a successful load. +DockPtr CMainFrame::NewDockerFromID(int id) { - DWORD style = 0; + switch (id) { + case DOCK_ID_QUEUE: return std::make_unique(); + case DOCK_ID_LOG: return std::make_unique(); + case DOCK_ID_SETTINGS: return std::make_unique(); + case DOCK_ID_GENPREVIEW: return std::make_unique(); + case DOCK_ID_FILEINFO: return std::make_unique(); + } + return nullptr; +} + +// Apply container single-tab hiding to any non-null panel pointer. +static void SetupDockContainers(CDockQueue* q, CDockLog* l, CDockSettings* s, + CDockGenPreview* gp, CDockFileInfo* fi) +{ + if (q) q->GetQueueContainer().SetHideSingleTab(TRUE); + if (l) l->GetLogContainer().SetHideSingleTab(TRUE); + if (s) s->GetSettingsContainer().SetHideSingleTab(TRUE); + if (gp) gp->GetContainer().SetHideSingleTab(TRUE); + if (fi) fi->GetInfoContainer().SetHideSingleTab(TRUE); +} + +// Helper: build the hardcoded default panel hierarchy. +void CMainFrame::BuildDefaultDockLayout() +{ + CloseAllDockers(); // clear any partial state Win32++ might have + m_pDockQueue = nullptr; m_pDockLog = nullptr; m_pDockSettings = nullptr; + m_pDockGenPreview = nullptr; m_pDockFileInfo = nullptr; auto pDockQ = AddDockedChild(std::make_unique(), - DS_DOCKED_BOTTOM | style, DpiScaleInt(220)); + DS_DOCKED_BOTTOM, DpiScaleInt(220), DOCK_ID_QUEUE); m_pDockQueue = static_cast(pDockQ); - m_pDockQueue->GetQueueContainer().SetHideSingleTab(TRUE); auto pDockL = m_pDockQueue->AddDockedChild(std::make_unique(), - DS_DOCKED_RIGHT | style, DpiScaleInt(360)); + DS_DOCKED_RIGHT, DpiScaleInt(360), DOCK_ID_LOG); m_pDockLog = static_cast(pDockL); - m_pDockLog->GetLogContainer().SetHideSingleTab(TRUE); auto pDockS = AddDockedChild(std::make_unique(), - DS_DOCKED_RIGHT | style, DpiScaleInt(280)); + DS_DOCKED_RIGHT, DpiScaleInt(280), DOCK_ID_SETTINGS); m_pDockSettings = static_cast(pDockS); - m_pDockSettings->GetSettingsContainer().SetHideSingleTab(TRUE); auto pDockGP = m_pDockSettings->AddDockedChild(std::make_unique(), - DS_DOCKED_BOTTOM | style, DpiScaleInt(200)); + DS_DOCKED_BOTTOM, DpiScaleInt(200), DOCK_ID_GENPREVIEW); m_pDockGenPreview = static_cast(pDockGP); - m_pDockGenPreview->GetContainer().SetHideSingleTab(TRUE); auto pDockFI = m_pDockGenPreview->AddDockedChild(std::make_unique(), - DS_DOCKED_BOTTOM | style, DpiScaleInt(160)); + DS_DOCKED_BOTTOM, DpiScaleInt(160), DOCK_ID_FILEINFO); m_pDockFileInfo = static_cast(pDockFI); - m_pDockFileInfo->GetInfoContainer().SetHideSingleTab(TRUE); +} - // Modern flat dock caption styling +void CMainFrame::OnInitialUpdate() +{ + // Member pointers start null; they are set after a confirmed successful load + // (NOT inside NewDockerFromID, where they would dangle if Win32++ rolls back). + m_pDockQueue = nullptr; m_pDockLog = nullptr; m_pDockSettings = nullptr; + m_pDockGenPreview = nullptr; m_pDockFileInfo = nullptr; + + bool fromRegistry = false; + try { + // Attempt to restore the full dock topology (parent, style, size, floating + // rect, hidden state) from the Win32++ registry key saved at WM_CLOSE. + fromRegistry = LoadDockRegistrySettings(L"Polymech\\pm-image-ui"); + + if (fromRegistry) { + // Fetch member pointers by dock ID now that all dockers are safely alive. + // GetDockFromID walks the m_allDockers list built by LoadDockRegistrySettings. + m_pDockQueue = static_cast (GetDockFromID(DOCK_ID_QUEUE)); + m_pDockLog = static_cast (GetDockFromID(DOCK_ID_LOG)); + m_pDockSettings = static_cast (GetDockFromID(DOCK_ID_SETTINGS)); + m_pDockGenPreview = static_cast(GetDockFromID(DOCK_ID_GENPREVIEW)); + m_pDockFileInfo = static_cast (GetDockFromID(DOCK_ID_FILEINFO)); + + // If any panel is missing the registry save is incomplete — fall back. + if (!m_pDockQueue || !m_pDockLog || !m_pDockSettings || + !m_pDockGenPreview || !m_pDockFileInfo) + { + LogMessage(L"[Layout] Registry restore incomplete — using default layout."); + fromRegistry = false; + } + } + } + catch (const std::exception& ex) { + LogMessage(CString(L"[Layout] Registry restore threw: ") + ex.what()); + fromRegistry = false; + } + catch (...) { + LogMessage(L"[Layout] Registry restore threw unknown exception."); + fromRegistry = false; + } + + if (!fromRegistry) { + // Delete the stale/bad registry key so the next launch starts clean. + const CString appKey = _T("Software\\Polymech\\pm-image-ui"); + CRegKey k; + if (ERROR_SUCCESS == k.Open(HKEY_CURRENT_USER, appKey)) + k.RecurseDeleteKey(_T("Dock Settings")); + + BuildDefaultDockLayout(); + } + + SetupDockContainers(m_pDockQueue, m_pDockLog, m_pDockSettings, m_pDockGenPreview, m_pDockFileInfo); + + // Modern flat dock caption styling (null-safe). COLORREF capBg = RGB(240, 240, 240); COLORREF capFg = RGB(68, 68, 68); COLORREF capBgInv = RGB(240, 240, 240); COLORREF capFgInv = RGB(140, 140, 140); COLORREF capPen = RGB(220, 220, 220); auto styleDocker = [&](CDocker* d) { + if (!d) return; d->SetCaptionColors(capFg, capBg, capFgInv, capBgInv, capPen); d->SetCaptionHeight(22); }; @@ -286,6 +414,10 @@ void CMainFrame::OnInitialUpdate() DragAcceptFiles(TRUE); SetWindowText(L"pm-image"); GetStatusBar().SetPartText(0, L"Drop files or use Add Files to begin."); + + // Restore window placement and dock sizes saved from last session. + // Must run after all docks are created so SetDockSize is valid. + LoadLayout(); } void CMainFrame::SetupToolBar() @@ -381,6 +513,75 @@ void CMainFrame::OnClearQueue() } } +void CMainFrame::OnSaveAs() +{ + if (!m_pDockQueue) return; + auto& lv = m_pDockQueue->GetQueueContainer().GetListView(); + + // Use the first selected item, fall back to first item. + auto sel = GetSelectedQueueItems(); + int idx = sel.empty() ? 0 : sel.front(); + if (idx < 0 || idx >= lv.QueueCount()) { + ::MessageBoxW(GetHwnd(), L"No file selected in the queue.", L"Save As", MB_ICONINFORMATION); + return; + } + + CString srcPath = lv.GetItemPath(idx); + if (srcPath.IsEmpty()) return; + + // Derive a suggested filename from the source path. + fs::path src(srcPath.c_str()); + std::wstring suggestedName = src.filename().wstring(); + std::wstring ext = src.extension().wstring(); + // ext includes the dot, e.g. L".jpg" + if (ext.empty()) ext = L".*"; + + // Build the filter string: "JPEG Files (*.jpg)\0*.jpg\0All Files (*.*)\0*.*\0" + // We need a double-null-terminated sequence. + std::wstring extNoDot = ext.size() > 1 ? ext.substr(1) : L"*"; + std::wstring extUpper = extNoDot; + for (auto& c : extUpper) c = towupper(c); + + wchar_t filter[256] = {}; + int fpos = 0; + auto appendStr = [&](const std::wstring& s) { + for (wchar_t c : s) filter[fpos++] = c; + filter[fpos++] = L'\0'; + }; + appendStr(extUpper + L" Files (*." + extNoDot + L")"); + appendStr(L"*." + extNoDot); + appendStr(L"All Files (*.*)"); + appendStr(L"*.*"); + + wchar_t destPath[MAX_PATH] = {}; + wcscpy_s(destPath, suggestedName.c_str()); + + OPENFILENAMEW ofn{}; + ofn.lStructSize = sizeof(ofn); + ofn.hwndOwner = GetHwnd(); + ofn.lpstrFilter = filter; + ofn.lpstrFile = destPath; + ofn.nMaxFile = MAX_PATH; + ofn.lpstrTitle = L"Save As"; + ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR; + ofn.lpstrDefExt = extNoDot.c_str(); + + if (!::GetSaveFileNameW(&ofn)) + return; // user cancelled + + std::error_code ec; + fs::copy_file(src, fs::path(destPath), fs::copy_options::overwrite_existing, ec); + if (ec) { + std::wstring msg = L"Copy failed: " + utf8_to_wide_mf(ec.message()); + ::MessageBoxW(GetHwnd(), msg.c_str(), L"Save As", MB_ICONERROR); + } else { + CString log; + log.Format(L"Saved: %s", destPath); + LogMessage(log); + GetStatusBar().SetPartText(0, log); + } +} + void CMainFrame::OnResize() { if (m_processing) { @@ -604,20 +805,32 @@ void CMainFrame::OnRun() std::string promptUtf8 = wide_to_utf8(m_lastPrompt); - std::string api_key; - const char* env_key = std::getenv("IMAGE_TRANSFORM_GOOGLE_API_KEY"); - if (env_key && env_key[0] != '\0') api_key = env_key; + // Look up API key: settings store first, env var as fallback. + std::string active_provider; + std::string api_key = media::settings::get_active_api_key(active_provider); if (api_key.empty()) { - ::MessageBox(GetHwnd(), - L"API key not found.\nSet IMAGE_TRANSFORM_GOOGLE_API_KEY in your .env file.", - L"pm-image", MB_ICONERROR); + // Legacy env-var fallback (Google only) + const char* env_key = std::getenv("IMAGE_TRANSFORM_GOOGLE_API_KEY"); + if (env_key && env_key[0] != '\0') { + api_key = env_key; + active_provider = "google"; + } + } + + if (api_key.empty()) { + int choice = ::MessageBoxW(GetHwnd(), + L"No API key configured.\n\nOpen the API Keys dialog now?", + L"pm-image", MB_ICONWARNING | MB_YESNO); + if (choice == IDYES) + ShowProviderSettingsDlg(GetHwnd()); return; } media::TransformOptions base_opts = BuildTransformOptions(); - base_opts.prompt = promptUtf8; - base_opts.api_key = api_key; + base_opts.provider = active_provider; + base_opts.prompt = promptUtf8; + base_opts.api_key = api_key; auto& lv = m_pDockQueue->GetQueueContainer().GetListView(); std::vector> items; @@ -783,38 +996,136 @@ LRESULT CMainFrame::OnGeneratedFile(WPARAM wparam) void CMainFrame::UpdateFileInfoForSelection(int idx) { if (!m_pDockQueue) return; - CString path = m_pDockQueue->GetQueueContainer().GetListView().GetItemPath(idx); + auto& lv = m_pDockQueue->GetQueueContainer().GetListView(); + if (idx < 0 || idx >= lv.QueueCount()) return; + CString path = lv.GetItemPath(idx); if (path.IsEmpty()) return; - // Update file info panel - if (m_pDockFileInfo) + if (m_pDockFileInfo && ::IsWindowVisible(m_pDockFileInfo->GetHwnd())) m_pDockFileInfo->GetInfoContainer().GetInfoView().ShowFileInfo(path.c_str()); - // In transform mode, check if a generated version exists and show it - if (m_pDockGenPreview && m_pDockSettings) { + if (m_pDockGenPreview && ::IsWindowVisible(m_pDockGenPreview->GetHwnd()) && m_pDockSettings) { auto& sv = m_pDockSettings->GetSettingsContainer().GetSettingsView(); if (sv.GetMode() == CSettingsView::MODE_TRANSFORM) { std::wstring srcKey(path.c_str()); auto it = m_generatedMap.find(srcKey); auto& gp = m_pDockGenPreview->GetContainer().GetPreview(); - if (it != m_generatedMap.end()) { + if (it != m_generatedMap.end()) gp.LoadPicture(it->second.c_str()); - } else { + else gp.ClearPicture(); - } } } } +void CMainFrame::ResetLayout() +{ + // Dock hierarchy (must restore parents before children): + // ancestor → Queue (BOTTOM) → Log (RIGHT of Queue) + // ancestor → Settings (RIGHT) → GenPreview (BOTTOM) → FileInfo (BOTTOM of GenPreview) + + // Ensure a docker is undocked before we re-dock it elsewhere. + auto ensureUndocked = [this](CDocker* p) { + if (!p) return; + if (p->IsDocked() || IsPanelVisible(p)) + p->Hide(); // undocks + hides + }; + + // Undock everything first (children before parents to avoid layout thrash) + ensureUndocked(m_pDockFileInfo); + ensureUndocked(m_pDockGenPreview); + ensureUndocked(m_pDockLog); + ensureUndocked(m_pDockQueue); + ensureUndocked(m_pDockSettings); + + // Re-dock in hierarchy order: parents first + CDocker* ancestor = GetDockAncestor(); + if (m_pDockQueue && ancestor) { + ancestor->Dock(m_pDockQueue, DS_DOCKED_BOTTOM); + m_pDockQueue->SetDockSize(DpiScaleInt(220)); + InvalidateToggle(IDC_CMD_VIEW_QUEUE); + } + if (m_pDockLog && m_pDockQueue) { + m_pDockQueue->Dock(m_pDockLog, DS_DOCKED_RIGHT); + m_pDockLog->SetDockSize(DpiScaleInt(360)); + InvalidateToggle(IDC_CMD_VIEW_LOG); + } + if (m_pDockSettings && ancestor) { + ancestor->Dock(m_pDockSettings, DS_DOCKED_RIGHT); + m_pDockSettings->SetDockSize(DpiScaleInt(280)); + InvalidateToggle(IDC_CMD_VIEW_SETTINGS); + } + if (m_pDockGenPreview && m_pDockSettings) { + m_pDockSettings->Dock(m_pDockGenPreview, DS_DOCKED_BOTTOM); + m_pDockGenPreview->SetDockSize(DpiScaleInt(200)); + InvalidateToggle(IDC_CMD_VIEW_GENPREVIEW); + } + if (m_pDockFileInfo && m_pDockGenPreview) { + m_pDockGenPreview->Dock(m_pDockFileInfo, DS_DOCKED_BOTTOM); + m_pDockFileInfo->SetDockSize(DpiScaleInt(160)); + InvalidateToggle(IDC_CMD_VIEW_FILEINFO); + } + + // Centre window on the primary monitor + HMONITOR hMon = ::MonitorFromWindow(GetHwnd(), MONITOR_DEFAULTTOPRIMARY); + MONITORINFO mi{sizeof(mi)}; + ::GetMonitorInfoW(hMon, &mi); + const RECT& wa = mi.rcWork; + int w = std::min(1280L, wa.right - wa.left - 80); + int h = std::min(860L, wa.bottom - wa.top - 80); + int x = wa.left + (wa.right - wa.left - w) / 2; + int y = wa.top + (wa.bottom - wa.top - h) / 2; + ::SetWindowPos(GetHwnd(), nullptr, x, y, w, h, SWP_NOZORDER | SWP_SHOWWINDOW); + + RecalcLayout(); + + // Persist the clean defaults so the next session starts fresh. + // Save dock topology first (before settings.json window placement clear). + SaveDockRegistrySettings(L"Polymech\\pm-image-ui"); + std::string err; + media::settings::WindowLayout defaults; + media::settings::save_window_layout(defaults, err); +} + +void CMainFrame::DebugDockState() +{ + auto dockState = [](const CDocker* p, const char* name) -> std::string { + if (!p) return std::string("\"") + name + "\": null"; + std::string s = std::string("\"") + name + "\": { "; + s += "\"docked\": "; s += p->IsDocked() ? "true" : "false"; s += ", "; + s += "\"undocked\": "; s += p->IsUndocked() ? "true" : "false"; s += ", "; + s += "\"visible\": "; s += ::IsWindowVisible(p->GetHwnd()) ? "true" : "false"; s += ", "; + s += "\"size\": "; s += std::to_string(p->GetDockSize()); s += " }"; + return s; + }; + + std::string json = "{\n "; + json += dockState(m_pDockQueue, "Queue"); json += ",\n "; + json += dockState(m_pDockLog, "Log"); json += ",\n "; + json += dockState(m_pDockSettings, "Settings"); json += ",\n "; + json += dockState(m_pDockGenPreview, "GenPreview"); json += ",\n "; + json += dockState(m_pDockFileInfo, "FileInfo"); + json += "\n}"; + + LogMessage(CString(L"── Dock State ──────────────────────")); + // Print line by line so it's readable in the log + std::istringstream ss(json); + std::string line; + while (std::getline(ss, line)) + LogMessage(CString(utf8_to_wide_mf(line).c_str())); +} + // ── Presets ───────────────────────────────────────────── void CMainFrame::LoadPresets() { m_presets.clear(); try { - std::ifstream ifs(m_settingsPath); - if (!ifs.is_open()) return; - json j = json::parse(ifs, nullptr, false); + std::string raw; + std::string err; + if (!media::settings::load_settings_utf8(raw, err) || raw.empty()) + return; + json j = json::parse(raw, nullptr, false); if (j.is_discarded()) return; if (j.contains("transform") && j["transform"].contains("prompt_presets")) { for (auto& p : j["transform"]["prompt_presets"]) { @@ -832,10 +1143,13 @@ void CMainFrame::SavePresets() { json j; try { - std::ifstream ifs(m_settingsPath); - if (ifs.is_open()) { - j = json::parse(ifs, nullptr, false); + std::string existing; + std::string err; + if (media::settings::load_settings_utf8(existing, err) && !existing.empty()) { + j = json::parse(existing, nullptr, false); if (j.is_discarded()) j = json::object(); + } else { + j = json::object(); } } catch (...) { j = json::object(); } @@ -845,8 +1159,8 @@ void CMainFrame::SavePresets() j["transform"]["prompt_presets"] = arr; try { - std::ofstream ofs(m_settingsPath); - ofs << j.dump(2); + std::string err; + media::settings::save_settings_utf8(j.dump(2), err); } catch (...) {} } @@ -864,6 +1178,91 @@ void CMainFrame::RemovePreset(int index) } } +// ── Layout persistence ────────────────────────────────────────────────── + +bool CMainFrame::IsPanelVisible(const CDocker* pDock) const +{ + return pDock && ::IsWindowVisible(pDock->GetHwnd()); +} + +// Re-dock pDock into pParent with dockStyle if it is currently hidden or floating. +// If it is already docked, hide it instead (toggle semantics). +void CMainFrame::TogglePanelView(CDocker* pDock, UINT dockStyle, CDocker* pParent, int defaultSize, UINT32 cmdID) +{ + if (!pDock || !pParent) return; + + if (IsPanelVisible(pDock)) { + // Docked or floating → hide (also undocks if docked) + pDock->Hide(); + } else { + // Hidden or not docked → re-dock into original parent + if (pDock->IsDocked()) { + // Shouldn't normally happen, but ensure it's undocked first + pDock->Hide(); + } + pParent->Dock(pDock, dockStyle); + if (defaultSize > 0) + pDock->SetDockSize(defaultSize); + } + + InvalidateToggle(cmdID); +} + +void CMainFrame::SaveLayout() +{ + // Dock topology (parent, style, size, floating rect, hidden) is saved by + // SaveDockRegistrySettings() in the WM_CLOSE handler. This function only + // persists the main window placement to settings.json. + WINDOWPLACEMENT wp{sizeof(wp)}; + if (!GetWindowPlacement(wp)) + return; + + media::settings::WindowLayout layout; + layout.has_placement = true; + layout.show_cmd = (wp.showCmd == SW_SHOWMINIMIZED) ? SW_SHOWNORMAL : (int)wp.showCmd; + layout.min_pos = wp.ptMinPosition; + layout.max_pos = wp.ptMaxPosition; + layout.normal_rect = wp.rcNormalPosition; + + std::string err; + media::settings::save_window_layout(layout, err); +} + +void CMainFrame::LoadLayout() +{ + // Dock topology (parent, style, size, floating rect, hidden) was already + // restored by LoadDockRegistrySettings() at the top of OnInitialUpdate(). + // Here we only restore the main window placement from settings.json. + media::settings::WindowLayout layout; + std::string err; + if (!media::settings::load_window_layout(layout, err) || !layout.has_placement) + return; + + // ── Multi-monitor safety ────────────────────────────────────────── + POINT centre = { + (layout.normal_rect.left + layout.normal_rect.right) / 2, + (layout.normal_rect.top + layout.normal_rect.bottom) / 2, + }; + HMONITOR hMon = ::MonitorFromPoint(centre, MONITOR_DEFAULTTONULL); + if (!hMon) { + HMONITOR hPrimary = ::MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY); + MONITORINFO mi{sizeof(mi)}; + ::GetMonitorInfoW(hPrimary, &mi); + const RECT& wa = mi.rcWork; + int w = std::min(1200L, wa.right - wa.left); + int h = std::min(800L, wa.bottom - wa.top); + layout.normal_rect = { wa.left + 40, wa.top + 40, wa.left + 40 + w, wa.top + 40 + h }; + layout.show_cmd = SW_SHOWNORMAL; + } + + WINDOWPLACEMENT wp{sizeof(wp)}; + wp.showCmd = (UINT)layout.show_cmd; + wp.ptMinPosition = layout.min_pos; + wp.ptMaxPosition = layout.max_pos; + wp.rcNormalPosition = layout.normal_rect; + SetWindowPlacement(wp); +} + void CMainFrame::OnPresets() { ShowPresetsMenu(); @@ -997,6 +1396,13 @@ LRESULT CMainFrame::WndProc(UINT msg, WPARAM wparam, LPARAM lparam) { try { switch (msg) { + case WM_CLOSE: + // Save dock layout (parent, style, size, floating rect, hidden) + // to Win32++ registry BEFORE OnClose() hides & destroys the window. + SaveDockRegistrySettings(L"Polymech\\pm-image-ui"); + // Save window placement to settings.json. + SaveLayout(); + return WndProcDefault(msg, wparam, lparam); case WM_GETMINMAXINFO: return OnGetMinMaxInfo(msg, wparam, lparam); case UWM_QUEUE_PROGRESS: return OnQueueProgress(wparam, lparam); case UWM_QUEUE_DONE: return OnQueueDone(wparam, lparam); @@ -1006,11 +1412,19 @@ LRESULT CMainFrame::WndProc(UINT msg, WPARAM wparam, LPARAM lparam) case UWM_GENERATED_FILE: return OnGeneratedFile(wparam); case UWM_QUEUE_ITEM_CLICKED: { int idx = static_cast(wparam); - if (m_pDockQueue) { - CString path = m_pDockQueue->GetQueueContainer().GetListView().GetItemPath(idx); - if (!path.IsEmpty()) - m_view.LoadPicture(path.c_str()); - UpdateFileInfoForSelection(idx); + try { + if (m_pDockQueue) { + CString path = m_pDockQueue->GetQueueContainer().GetListView().GetItemPath(idx); + if (!path.IsEmpty()) + m_view.LoadPicture(path.c_str()); + UpdateFileInfoForSelection(idx); + } + } + catch (const std::exception& ex) { + LogMessage(CString(L"[Queue click] ") + ex.what()); + } + catch (...) { + LogMessage(L"[Queue click] Unknown exception — panel state may be invalid."); } return 0; } diff --git a/packages/media/cpp/src/win/ui_next/Mainfrm.h b/packages/media/cpp/src/win/ui_next/Mainfrm.h index 201236ba..444a49a2 100644 --- a/packages/media/cpp/src/win/ui_next/Mainfrm.h +++ b/packages/media/cpp/src/win/ui_next/Mainfrm.h @@ -19,6 +19,13 @@ struct PromptPreset { class CMainFrame : public CRibbonDockFrame { public: + // Stable IDs stored in the Win32++ dock registry — must never change. + static constexpr int DOCK_ID_QUEUE = 1; + static constexpr int DOCK_ID_LOG = 2; + static constexpr int DOCK_ID_SETTINGS = 3; + static constexpr int DOCK_ID_GENPREVIEW = 4; + static constexpr int DOCK_ID_FILEINFO = 5; + CMainFrame(); virtual ~CMainFrame() override = default; virtual HWND Create(HWND parent = nullptr) override; @@ -45,6 +52,7 @@ private: void OnAddFolder(); void OnClearQueue(); void OnResize(); + void OnSaveAs(); void OnPrompt(); void OnRun(); void OnExit(); @@ -65,6 +73,17 @@ private: void AddPreset(const std::string& name, const std::string& prompt); void RemovePreset(int index); + // Win32++ dock persistence — creates the correct docker type from a registry ID. + virtual DockPtr NewDockerFromID(int dockID) override; + + void BuildDefaultDockLayout(); + void SaveLayout(); + void LoadLayout(); + void ResetLayout(); + void DebugDockState(); + void TogglePanelView(CDocker* pDock, UINT dockStyle, CDocker* pParent, int defaultSize, UINT32 cmdID); + bool IsPanelVisible(const CDocker* pDock) const; + LRESULT OnGetMinMaxInfo(UINT msg, WPARAM wparam, LPARAM lparam); LRESULT OnQueueProgress(WPARAM wparam, LPARAM lparam); LRESULT OnQueueDone(WPARAM wparam, LPARAM lparam); diff --git a/packages/media/cpp/src/win/ui_next/ProviderDlg.cpp b/packages/media/cpp/src/win/ui_next/ProviderDlg.cpp new file mode 100644 index 00000000..0448940e --- /dev/null +++ b/packages/media/cpp/src/win/ui_next/ProviderDlg.cpp @@ -0,0 +1,362 @@ +#include "stdafx.h" +#include "ProviderDlg.h" +#include "Resource.h" +#include "win/settings_store.hpp" + +#include +#include +#include + +#pragma comment(lib, "Comctl32.lib") + +// ============================================================ +// String helpers (matching Mainfrm.cpp's helpers) +// ============================================================ + +static std::string prov_wide_to_utf8(const std::wstring& w) { + if (w.empty()) return {}; + int n = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), nullptr, 0, nullptr, nullptr); + if (n <= 0) return {}; + std::string s(n, '\0'); + WideCharToMultiByte(CP_UTF8, 0, w.c_str(), (int)w.size(), s.data(), n, nullptr, nullptr); + return s; +} + +static std::wstring prov_utf8_to_wide(const std::string& s) { + if (s.empty()) return {}; + int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), nullptr, 0); + if (n <= 0) return {}; + std::wstring w(n, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), w.data(), n); + return w; +} + +// ============================================================ +// Control ID layout +// Each provider row uses a block of 5 IDs: +// base + 0 = password EDIT (API key) +// base + 1 = show/hide BUTTON +// base + 2 = model COMBOBOX +// base + 3 = base-URL EDIT +// base + 4 = (label static — no ID needed, stored in vector) +// Active-provider COMBOBOX: IDC_PROVDLG_ACTIVE +// Save / Cancel: IDOK / IDCANCEL +// ============================================================ + +static constexpr int IDC_PROVDLG_ACTIVE = 750; +static constexpr int IDC_PROVDLG_ROW0 = 760; // Google +static constexpr int IDC_PROVDLG_ROW1 = 765; // OpenAI +static constexpr int IDC_PROVDLG_ROW2 = 770; // Anthropic +static constexpr int IDC_PROVDLG_ROW3 = 775; // OpenRouter +static constexpr int IDC_PROVDLG_STRIDE = 5; // IDs per provider + +static constexpr int kProviderCount = 4; + +struct ProviderRow { + HWND hKeyEdit{}; + HWND hShowBtn{}; + HWND hModelCombo{}; + HWND hUrlEdit{}; + bool keyVisible = false; +}; + +struct DlgState { + media::settings::ProviderMap providers; + std::string active_provider; + ProviderRow rows[kProviderCount]; + HWND hActiveCombo{}; +}; + +// ============================================================ +// Layout constants (all in pixels) +// ============================================================ + +static constexpr int kDlgW = 490; +static constexpr int kDlgH = 560; +static constexpr int kX0 = 12; +static constexpr int kLblW = 100; +static constexpr int kEditW = 240; +static constexpr int kBtnW = 56; +static constexpr int kComboW = 260; +static constexpr int kUrlW = 340; +static constexpr int kEH = 22; +static constexpr int kGap = 6; +static constexpr int kRowH = 118; // height of each provider group + +// ============================================================ +// Dialog proc helpers +// ============================================================ + +static void LayoutProviderRow(HWND dlg, int rowIdx, const media::settings::ProviderDefaults& def, + const media::settings::ProviderEntry& entry, ProviderRow& row) +{ + HINSTANCE inst = ::GetModuleHandleW(nullptr); + const int yBase = 68 + rowIdx * kRowH; + const int baseID = IDC_PROVDLG_ROW0 + rowIdx * IDC_PROVDLG_STRIDE; + + // Group box + std::wstring groupTitle = prov_utf8_to_wide(def.display_name); + ::CreateWindowExW(0, L"BUTTON", groupTitle.c_str(), + WS_CHILD | WS_VISIBLE | BS_GROUPBOX, + kX0, yBase, kDlgW - 2*kX0, kRowH - 6, dlg, nullptr, inst, nullptr); + + int y = yBase + 20; + + // API key row + ::CreateWindowExW(0, L"STATIC", L"API Key:", WS_CHILD | WS_VISIBLE | SS_RIGHT, + kX0 + 6, y + 3, kLblW, 16, dlg, nullptr, inst, nullptr); + + row.hKeyEdit = ::CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT", + prov_utf8_to_wide(entry.api_key).c_str(), + WS_CHILD | WS_VISIBLE | ES_AUTOHSCROLL | ES_PASSWORD, + kX0 + kLblW + kGap + 6, y, kEditW, kEH, dlg, + (HMENU)(UINT_PTR)(baseID + 0), inst, nullptr); + + row.hShowBtn = ::CreateWindowExW(0, L"BUTTON", L"Show", + WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, + kX0 + kLblW + kGap + kEditW + kGap + 6, y, kBtnW, kEH, dlg, + (HMENU)(UINT_PTR)(baseID + 1), inst, nullptr); + row.keyVisible = false; + + y += kEH + kGap; + + // Default model row + ::CreateWindowExW(0, L"STATIC", L"Default model:", WS_CHILD | WS_VISIBLE | SS_RIGHT, + kX0 + 6, y + 3, kLblW, 16, dlg, nullptr, inst, nullptr); + + row.hModelCombo = ::CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", L"", + WS_CHILD | WS_VISIBLE | CBS_DROPDOWN | CBS_HASSTRINGS | WS_VSCROLL, + kX0 + kLblW + kGap + 6, y - 2, kComboW, 160, dlg, + (HMENU)(UINT_PTR)(baseID + 2), inst, nullptr); + for (const char* m : def.models) { + ::SendMessageW(row.hModelCombo, CB_ADDSTRING, 0, + (LPARAM)prov_utf8_to_wide(m).c_str()); + } + if (!entry.default_model.empty()) + ::SetWindowTextW(row.hModelCombo, prov_utf8_to_wide(entry.default_model).c_str()); + + y += kEH + kGap; + + // Base URL row + ::CreateWindowExW(0, L"STATIC", L"Base URL:", WS_CHILD | WS_VISIBLE | SS_RIGHT, + kX0 + 6, y + 3, kLblW, 16, dlg, nullptr, inst, nullptr); + + row.hUrlEdit = ::CreateWindowExW(WS_EX_CLIENTEDGE, L"EDIT", + prov_utf8_to_wide(entry.base_url).c_str(), + WS_CHILD | WS_VISIBLE | ES_AUTOHSCROLL, + kX0 + kLblW + kGap + 6, y, kUrlW, kEH, dlg, + (HMENU)(UINT_PTR)(baseID + 3), inst, nullptr); +} + +static void ReadProviderRow(const ProviderRow& row, media::settings::ProviderEntry& out) +{ + wchar_t buf[2048]{}; + ::GetWindowTextW(row.hKeyEdit, buf, (int)std::size(buf)); + out.api_key = prov_wide_to_utf8(buf); + + buf[0] = L'\0'; + ::GetWindowTextW(row.hModelCombo, buf, (int)std::size(buf)); + out.default_model = prov_wide_to_utf8(buf); + + buf[0] = L'\0'; + ::GetWindowTextW(row.hUrlEdit, buf, (int)std::size(buf)); + out.base_url = prov_wide_to_utf8(buf); +} + +static void ToggleKeyVisibility(ProviderRow& row) { + row.keyVisible = !row.keyVisible; + // ES_PASSWORD is toggled by removing / adding it via SetWindowLong + LONG style = ::GetWindowLongW(row.hKeyEdit, GWL_STYLE); + if (row.keyVisible) { + style &= ~ES_PASSWORD; + ::SetWindowLongW(row.hKeyEdit, GWL_STYLE, style); + // EM_SETPASSWORDCHAR 0 = no password char + ::SendMessageW(row.hKeyEdit, EM_SETPASSWORDCHAR, 0, 0); + ::SetWindowTextW(row.hShowBtn, L"Hide"); + } else { + style |= ES_PASSWORD; + ::SetWindowLongW(row.hKeyEdit, GWL_STYLE, style); + ::SendMessageW(row.hKeyEdit, EM_SETPASSWORDCHAR, (WPARAM)L'\u2022', 0); + ::SetWindowTextW(row.hShowBtn, L"Show"); + } + ::InvalidateRect(row.hKeyEdit, nullptr, TRUE); + ::UpdateWindow(row.hKeyEdit); +} + +// ============================================================ +// Dialog proc +// ============================================================ + +static INT_PTR CALLBACK ProviderDlgProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + auto* state = reinterpret_cast(::GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + + switch (msg) { + case WM_INITDIALOG: { + ::SetWindowLongPtrW(hwnd, GWLP_USERDATA, lp); + state = reinterpret_cast(lp); + + HINSTANCE inst = ::GetModuleHandleW(nullptr); + HFONT hFont = static_cast(::GetStockObject(DEFAULT_GUI_FONT)); + + // --- Header: active provider --- + ::CreateWindowExW(0, L"STATIC", L"Active provider:", + WS_CHILD | WS_VISIBLE | SS_LEFT, + kX0, 12, kLblW + 40, 18, hwnd, nullptr, inst, nullptr); + + state->hActiveCombo = ::CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", L"", + WS_CHILD | WS_VISIBLE | CBS_DROPDOWNLIST | CBS_HASSTRINGS, + kX0 + kLblW + 46, 10, 200, 140, hwnd, + (HMENU)(UINT_PTR)IDC_PROVDLG_ACTIVE, inst, nullptr); + + int n = 0; + const auto* defs = media::settings::known_providers(&n); + for (int i = 0; i < n; ++i) { + ::SendMessageW(state->hActiveCombo, CB_ADDSTRING, 0, + (LPARAM)prov_utf8_to_wide(defs[i].display_name).c_str()); + } + + // Pre-select the active provider + int activeSel = 0; + for (int i = 0; i < n; ++i) { + if (defs[i].name == state->active_provider) { activeSel = i; break; } + } + ::SendMessageW(state->hActiveCombo, CB_SETCURSEL, activeSel, 0); + + // --- Provider rows --- + for (int i = 0; i < kProviderCount && i < n; ++i) { + const auto& def = defs[i]; + auto it = state->providers.find(def.name); + media::settings::ProviderEntry blank; + blank.base_url = def.base_url; + blank.default_model = def.models.empty() ? "" : def.models[0]; + const auto& entry = (it != state->providers.end()) ? it->second : blank; + LayoutProviderRow(hwnd, i, def, entry, state->rows[i]); + } + + // --- Note --- + int noteY = 68 + kProviderCount * kRowH + 4; + ::CreateWindowExW(0, L"STATIC", + L"\u26BF Keys are encrypted with libsodium + DPAPI and stored in " + L"%APPDATA%\\PolyMech\\pm-image\\settings.json", + WS_CHILD | WS_VISIBLE | SS_LEFT, + kX0, noteY, kDlgW - 2*kX0, 28, hwnd, nullptr, inst, nullptr); + + // --- Buttons --- + int btnY = kDlgH - 42; + HWND hSave = ::CreateWindowExW(0, L"BUTTON", L"Save", + WS_CHILD | WS_VISIBLE | BS_DEFPUSHBUTTON | WS_TABSTOP, + kDlgW - 2*(kBtnW + kGap) - kX0, btnY, kBtnW + 20, 28, hwnd, + (HMENU)(UINT_PTR)IDOK, inst, nullptr); + ::CreateWindowExW(0, L"BUTTON", L"Cancel", + WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON | WS_TABSTOP, + kDlgW - kBtnW - kX0 - 12, btnY, kBtnW + 12, 28, hwnd, + (HMENU)(UINT_PTR)IDCANCEL, inst, nullptr); + + // Apply font to all children + ::EnumChildWindows(hwnd, [](HWND h, LPARAM f) -> BOOL { + ::SendMessageW(h, WM_SETFONT, (WPARAM)f, TRUE); + return TRUE; + }, (LPARAM)hFont); + + ::SetFocus(state->rows[0].hKeyEdit); + return FALSE; + } + + case WM_COMMAND: { + if (!state) break; + const int id = LOWORD(wp); + + if (id == IDCANCEL || (id == IDOK && HIWORD(wp) == BN_CLICKED && id == IDCANCEL)) { + ::EndDialog(hwnd, IDCANCEL); + return TRUE; + } + if (id == IDOK) { + // Read all rows back + int n = 0; + const auto* defs = media::settings::known_providers(&n); + for (int i = 0; i < kProviderCount && i < n; ++i) { + auto& entry = state->providers[defs[i].name]; + ReadProviderRow(state->rows[i], entry); + } + // Active provider + int sel = (int)::SendMessageW(state->hActiveCombo, CB_GETCURSEL, 0, 0); + if (sel >= 0 && sel < n) + state->active_provider = defs[sel].name; + + // Persist + std::string err; + if (!media::settings::save_providers(state->providers, state->active_provider, err)) { + std::wstring wmsg = L"Failed to save provider settings:\n" + prov_utf8_to_wide(err); + ::MessageBoxW(hwnd, wmsg.c_str(), L"pm-image", MB_ICONERROR); + return TRUE; + } + ::EndDialog(hwnd, IDOK); + return TRUE; + } + + // Show/Hide toggle buttons + for (int i = 0; i < kProviderCount; ++i) { + int base = IDC_PROVDLG_ROW0 + i * IDC_PROVDLG_STRIDE; + if (id == base + 1 && HIWORD(wp) == BN_CLICKED) { + ToggleKeyVisibility(state->rows[i]); + return TRUE; + } + } + break; + } + + case WM_CLOSE: + ::EndDialog(hwnd, IDCANCEL); + return TRUE; + } + return FALSE; +} + +// ============================================================ +// Public API +// ============================================================ + +bool ShowProviderSettingsDlg(HWND parent) +{ + DlgState state; + std::string err; + if (!media::settings::load_providers(state.providers, state.active_provider, err)) { + std::wstring wmsg = L"Could not load provider settings:\n" + prov_utf8_to_wide(err); + ::MessageBoxW(parent, wmsg.c_str(), L"pm-image", MB_ICONWARNING); + // Continue anyway with defaults so user can still enter keys + } + + // Build dialog template in memory + alignas(DWORD) BYTE buf[4096]{}; + DLGTEMPLATE* dlg = reinterpret_cast(buf); + + dlg->style = DS_MODALFRAME | DS_CENTER | DS_SETFONT + | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_VISIBLE; + dlg->cdit = 0; // no items — all created in WM_INITDIALOG via CreateWindowEx + dlg->x = 0; dlg->y = 0; + // Convert pixels to dialog units (~4px per DU on 96 DPI) + dlg->cx = (SHORT)(kDlgW * 4 / 7); + dlg->cy = (SHORT)(kDlgH * 8 / 15); + + WORD* p = reinterpret_cast(dlg + 1); + *p++ = 0; // menu atom: none + *p++ = 0; // class atom: default dialog + // title + const wchar_t title[] = L"AI Provider Settings"; + memcpy(p, title, sizeof(title)); + p += sizeof(title) / sizeof(WORD); + // font (for DS_SETFONT) + *p++ = 9; // point size + const wchar_t font[] = L"Segoe UI"; + memcpy(p, font, sizeof(font)); + p += sizeof(font) / sizeof(WORD); + + INT_PTR result = ::DialogBoxIndirectParamW( + ::GetModuleHandleW(nullptr), + dlg, parent, + ProviderDlgProc, + reinterpret_cast(&state)); + + return result == IDOK; +} diff --git a/packages/media/cpp/src/win/ui_next/ProviderDlg.h b/packages/media/cpp/src/win/ui_next/ProviderDlg.h new file mode 100644 index 00000000..9c059e2d --- /dev/null +++ b/packages/media/cpp/src/win/ui_next/ProviderDlg.h @@ -0,0 +1,16 @@ +#ifndef PM_UI_PROVIDERDLG_H +#define PM_UI_PROVIDERDLG_H + +#include + +/** + * Show the modal "AI Provider Settings" dialog. + * Lets the user enter/edit API keys for all known providers, choose the + * active provider, and optionally override the default model and base URL. + * Changes are persisted to the encrypted settings store on OK. + * + * Returns true if the user pressed Save (OK), false if they cancelled. + */ +bool ShowProviderSettingsDlg(HWND parent); + +#endif // PM_UI_PROVIDERDLG_H diff --git a/packages/media/cpp/src/win/ui_next/Resource.h b/packages/media/cpp/src/win/ui_next/Resource.h index 160c4fcc..78135a29 100644 --- a/packages/media/cpp/src/win/ui_next/Resource.h +++ b/packages/media/cpp/src/win/ui_next/Resource.h @@ -35,6 +35,20 @@ #define IDC_COMBO_AI_ASPECT 711 #define IDC_COMBO_AI_SIZE 712 +// Provider Settings dialog (IDC_PROVDLG_* defined in ProviderDlg.cpp) +// Ribbon button +#define IDC_CMD_PROVIDER_KEYS 346 + +// View tab — panel visibility toggles +#define IDC_CMD_VIEW_QUEUE 350 +#define IDC_CMD_VIEW_LOG 351 +#define IDC_CMD_VIEW_SETTINGS 352 +#define IDC_CMD_VIEW_FILEINFO 353 +#define IDC_CMD_VIEW_GENPREVIEW 354 +#define IDC_CMD_RESET_LAYOUT 355 +#define IDC_CMD_DEBUG_STATE 356 +#define IDC_CMD_SAVE_AS 357 + // User messages #define UWM_QUEUE_PROGRESS (WM_USER + 100) #define UWM_QUEUE_DONE (WM_USER + 101) diff --git a/packages/media/cpp/src/win/ui_next/Ribbon.bml b/packages/media/cpp/src/win/ui_next/Ribbon.bml index 860780a0..d0147034 100644 Binary files a/packages/media/cpp/src/win/ui_next/Ribbon.bml and b/packages/media/cpp/src/win/ui_next/Ribbon.bml differ diff --git a/packages/media/cpp/src/win/ui_next/Ribbon.xml b/packages/media/cpp/src/win/ui_next/Ribbon.xml index e3aa5d20..a925d60c 100644 --- a/packages/media/cpp/src/win/ui_next/Ribbon.xml +++ b/packages/media/cpp/src/win/ui_next/Ribbon.xml @@ -20,6 +20,21 @@ Actions + + + Save As… + + + Save the selected output file to a chosen location + + res/SaveS.bmp + res/SaveL.bmp + + + + File + + @@ -183,6 +198,57 @@ res/PresetsL.bmp res/PresetsS.bmp + + API Keys + res/PresetsL.bmp + res/PresetsS.bmp + + + + + View + + + Panels + + + Reset + + + Queue + res/AddFilesS.bmp + res/AddFilesL.bmp + + + Log + res/ClearS.bmp + res/ClearL.bmp + + + Settings + res/PresetsS.bmp + res/PresetsL.bmp + + + File Info + res/AddFilesS.bmp + res/AddFilesL.bmp + + + Preview + res/PromptS.bmp + res/PromptL.bmp + + + Reset Layout + res/ClearS.bmp + res/ClearL.bmp + + + Debug + res/ClearS.bmp + res/ClearL.bmp + @@ -216,9 +282,11 @@ + + @@ -227,6 +295,9 @@