diff --git a/README.md b/README.md index 6775c1e..d2800ae 100644 --- a/README.md +++ b/README.md @@ -32,27 +32,32 @@ Logia blends structured text editing with infinite visual canvases, ensuring you ### Setup 1. Clone the repository: + ```bash git clone https://github.com/daschinmoy21/Logia.git cd logia ``` 2. Install dependencies: + ```bash bun install ``` 3. Run in development mode: + ```bash bun run tauri dev ``` 4. Build for production: + ```bash bun tauri build ``` > **Note for Linux Users**: There is a known issue with linuxdeploy in Tauri which may cause build errors with AppImage creation. usage: +> > ```bash > NO_STRIP=true bun tauri build > ``` @@ -62,11 +67,13 @@ Logia blends structured text editing with infinite visual canvases, ensuring you Logia is available as a Nix flake for easy installation on NixOS or any system with Nix. **Run without installing:** + ```bash nix run github:daschinmoy21/Logia ``` **Install to user profile:** + ```bash nix profile install github:daschinmoy21/Logia ``` @@ -74,6 +81,7 @@ nix profile install github:daschinmoy21/Logia **Add to NixOS configuration:** 1. Add to your flake inputs: + ```nix inputs.logia = { url = "github:daschinmoy21/Logia"; @@ -82,6 +90,7 @@ nix profile install github:daschinmoy21/Logia ``` 2. Pass it through `specialArgs` and use in your config: + ```nix environment.systemPackages = [ logia.packages.${pkgs.system}.default @@ -101,6 +110,7 @@ Logia uses git for note synchronization. To enable sync: **Authentication**: Logia uses your system's git credentials — SSH keys, `gh auth`, or git credential helper. No cloud OAuth tokens are stored. **Development shell:** + ```bash nix develop # Enters a shell with all dependencies bun install @@ -129,6 +139,10 @@ Logia uses Google Generative AI for intelligent note generation and editing. To > **Note**: Logia no longer requires Google OAuth. Only an API key is needed. +### Security / AI Keys + +Logia uses a **Bring Your Own Key (BYOK)** model. The API key is stored in the OS keyring (Windows Credential Manager, macOS Keychain, Linux Secret Service) with an encrypted config file fallback — managed entirely in Rust. The key only enters the webview process when an AI feature is actively used (chat, slash-menu generation, or transcription structuring). Treat the machine as trusted; a full request-proxy through the Rust backend is a future hardening step. + ### Git Sync 1. Create a **private** git repository (GitHub, GitLab, etc.) diff --git a/src-tauri/src/config_crypto.rs b/src-tauri/src/config_crypto.rs index 710f4a0..fad15ee 100644 --- a/src-tauri/src/config_crypto.rs +++ b/src-tauri/src/config_crypto.rs @@ -182,6 +182,32 @@ fn try_delete_keyring(service: &str, username: &str) -> bool { const KEYRING_SERVICE: &str = "Logia"; const KEYRING_USERNAME: &str = "google_api_key"; +#[tauri::command] +pub async fn has_google_api_key(app_handle: tauri::AppHandle) -> Result { + // Check keyring first + if try_get_keyring(KEYRING_SERVICE, KEYRING_USERNAME).is_some() { + return Ok(true); + } + + // Check config.json for encrypted or legacy plain key + let config_dir = get_config_directory(&app_handle)?; + let config_file = config_dir.join("config.json"); + + if config_file.exists() { + if let Ok(content) = fs::read_to_string(&config_file) { + if let Ok(config) = serde_json::from_str::(&content) { + if config.get("encrypted_google_api_key").is_some() + || config.get("google_api_key").is_some() + { + return Ok(true); + } + } + } + } + + Ok(false) +} + #[tauri::command] pub async fn get_google_api_key(app_handle: tauri::AppHandle) -> Result { // Try keyring first (works on Windows Credential Manager, macOS Keychain, Linux Secret Service) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2a6d0a5..6db9afe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -793,6 +793,7 @@ pub fn run() { empty_trash, restore_from_trash, get_google_api_key, + has_google_api_key, save_google_api_key, remove_google_api_key, install_transcription_dependencies, diff --git a/src/components/AiSidebar.tsx b/src/components/AiSidebar.tsx index 5e20cd7..97abd73 100644 --- a/src/components/AiSidebar.tsx +++ b/src/components/AiSidebar.tsx @@ -32,7 +32,7 @@ type Tab = "chat" | "calendar" | "tags"; const AiSidebar = ({ isOpen, onClose: _onClose }: AiSidebarProps) => { const { currentNote } = useNotesStore(); - const { googleApiKey } = useUiStore(); + const { ensureGoogleApiKey, hasGoogleApiKey } = useUiStore(); const [activeTab, setActiveTab] = useState("chat"); // Chat State - includes actionStatus for persistence @@ -42,7 +42,6 @@ const AiSidebar = ({ isOpen, onClose: _onClose }: AiSidebarProps) => { const [isLoading, setIsLoading] = useState(false); const [streamingMessage, setStreamingMessage] = useState(""); const scrollableContainerRef = useRef(null); - const [googleClient, setGoogleClient] = useState(null); // Calendar State const [currentDate, setCurrentDate] = useState(new Date()); @@ -68,28 +67,33 @@ const AiSidebar = ({ isOpen, onClose: _onClose }: AiSidebarProps) => { } }, [messages, streamingMessage, activeTab]); - useEffect(() => { - if (!googleApiKey) { - setGoogleClient(null); + const handleSendMessage = async (message: string) => { + if (!message.trim()) return; + if (!hasGoogleApiKey) { + toast.error("Google API key not configured.", { + style: { background: "#333", color: "#fff" }, + }); return; } + + let key: string; try { - const client = createGoogleGenerativeAI({ apiKey: googleApiKey }); - setGoogleClient(() => client); - } catch (e) { - console.error("Failed to create Google AI client", e); - setGoogleClient(null); + key = await ensureGoogleApiKey(); + } catch { + toast.error("Failed to load API key.", { + style: { background: "#333", color: "#fff" }, + }); + return; } - }, [googleApiKey]); - const handleSendMessage = async (message: string) => { - if (!message.trim()) return; - if (!googleApiKey || !googleClient) { + if (!key) { toast.error("Google API key not configured.", { style: { background: "#333", color: "#fff" }, }); return; } + + const googleClient = createGoogleGenerativeAI({ apiKey: key }); if (!currentNote) return; const userMessage = { role: "user" as const, content: message }; diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 8089469..84ead2d 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -17,7 +17,7 @@ function Editor() { updateCurrentNoteContent, updateCurrentNoteTitle, } = useNotesStore(); - const { googleApiKey } = useUiStore(); + const { hasGoogleApiKey } = useUiStore(); // Show empty state when no note is selected if (!currentNote) { @@ -42,7 +42,7 @@ function Editor() { // Key based on API key + note ID ensures the editor remounts when: // 1. The user switches notes // 2. The user adds/removes/changes the API key (toggling AI features) - const editorKey = `${currentNote.id}-${googleApiKey ? 'ai' : 'no-ai'}`; + const editorKey = `${currentNote.id}-${hasGoogleApiKey ? 'ai' : 'no-ai'}`; return ( void; updateCurrentNoteTitle: (title: string) => void; }) { - const { googleApiKey, setEditor } = useUiStore(); + const { ensureGoogleApiKey, hasGoogleApiKey, setEditor } = useUiStore(); + const [resolvedKey, setResolvedKey] = useState(''); - // Create model only when API key changes + useEffect(() => { + if (hasGoogleApiKey) { + ensureGoogleApiKey().then(setResolvedKey); + } + }, [ensureGoogleApiKey, hasGoogleApiKey]); + + // Create model only when API key becomes available const model = useMemo(() => { - if (googleApiKey) { + if (resolvedKey) { const googleAI = createGoogleGenerativeAI({ - apiKey: googleApiKey, + apiKey: resolvedKey, }); return googleAI('gemini-2.5-flash'); } return null; - }, [googleApiKey]); + }, [resolvedKey]); const hasAI = Boolean(model); @@ -233,7 +240,7 @@ export function FormattingToolbarWithAI() { // Slash menu with the AI option added export function SuggestionMenuWithAI({ editor }: { editor: BlockNoteEditor }) { - const { googleApiKey } = useUiStore(); + const { hasGoogleApiKey } = useUiStore(); return ( { } // 3. Process with AI Utility - const googleApiKey = useUiStore.getState().googleApiKey; + const googleApiKey = await useUiStore.getState().ensureGoogleApiKey(); const editor = useUiStore.getState().editor; // Access editor instance const currentContent = JSON.parse(noteToUpdate.content || "[]") || []; diff --git a/src/store/UiStore.ts b/src/store/UiStore.ts index fc82cf8..814b68a 100644 --- a/src/store/UiStore.ts +++ b/src/store/UiStore.ts @@ -30,6 +30,7 @@ interface UiState { isSidebarFloating: boolean; expandedFolders: Set; googleApiKey: string; + hasGoogleApiKey: boolean; gitSyncConfigured: boolean; isSyncing: boolean; // [NEW] lastSyncedAt: Date | null; // [NEW] @@ -56,6 +57,7 @@ interface UiState { setIsSidebarFloating: (isFloating: boolean) => void; setExpandedFolders: (folders: Set) => void; setGoogleApiKey: (key: string) => void; + ensureGoogleApiKey: () => Promise; setGitSyncConfigured: (configured: boolean) => void; setIsSyncing: (isSyncing: boolean) => void; // [NEW] setLastSyncedAt: (date: Date | null) => void; // [NEW] @@ -92,13 +94,13 @@ const useUiStore = create((set) => ({ openCommandPalette: () => set({ isCommandPaletteOpen: true }), closeCommandPalette: () => set({ isCommandPaletteOpen: false, searchQuery: '', searchResults: [] }), - // Load API key on initialization + // Load API key existence on initialization (never the raw key) loadApiKey: async () => { try { - const key = await invoke('get_google_api_key'); - set({ googleApiKey: key }); + const hasKey = await invoke('has_google_api_key'); + set({ hasGoogleApiKey: hasKey }); } catch (error) { - console.error('Failed to load API key:', error); + console.error('Failed to check API key:', error); } }, @@ -116,6 +118,7 @@ const useUiStore = create((set) => ({ expandedFolders: new Set(), deleteConfirmFolderId: null, googleApiKey: '', + hasGoogleApiKey: false, gitSyncConfigured: false, isSyncing: false, // [NEW] lastSyncedAt: null, // [NEW] @@ -147,7 +150,19 @@ const useUiStore = create((set) => ({ setIsAiSidebarOpen: (isOpen) => set({ isAiSidebarOpen: isOpen }), setIsSidebarFloating: (isFloating) => set({ isSidebarFloating: isFloating }), setExpandedFolders: (expandedFolders) => set({ expandedFolders }), - setGoogleApiKey: (key) => set({ googleApiKey: key }), + setGoogleApiKey: (key) => set({ googleApiKey: key, hasGoogleApiKey: !!key }), + + ensureGoogleApiKey: async (): Promise => { + const { googleApiKey } = useUiStore.getState(); + if (googleApiKey) return googleApiKey; + try { + const key = await invoke('get_google_api_key'); + set({ googleApiKey: key, hasGoogleApiKey: !!key }); + return key; + } catch { + return ''; + } + }, setGitSyncConfigured: (configured) => set({ gitSyncConfigured: configured }), setIsSyncing: (isSyncing) => set({ isSyncing }), // [NEW] setLastSyncedAt: (date) => set({ lastSyncedAt: date }), // [NEW]