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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
> ```
Expand All @@ -62,18 +67,21 @@ 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
```

**Add to NixOS configuration:**

1. Add to your flake inputs:

```nix
inputs.logia = {
url = "github:daschinmoy21/Logia";
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.)
Expand Down
26 changes: 26 additions & 0 deletions src-tauri/src/config_crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool, String> {
// 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::<serde_json::Value>(&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<String, String> {
// Try keyring first (works on Windows Credential Manager, macOS Keychain, Linux Secret Service)
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 18 additions & 14 deletions src/components/AiSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tab>("chat");

// Chat State - includes actionStatus for persistence
Expand All @@ -42,7 +42,6 @@ const AiSidebar = ({ isOpen, onClose: _onClose }: AiSidebarProps) => {
const [isLoading, setIsLoading] = useState(false);
const [streamingMessage, setStreamingMessage] = useState<string>("");
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const [googleClient, setGoogleClient] = useState<any>(null);

// Calendar State
const [currentDate, setCurrentDate] = useState(new Date());
Expand All @@ -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 };
Expand Down
4 changes: 2 additions & 2 deletions src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 (
<EditorProvider
Expand Down
23 changes: 15 additions & 8 deletions src/components/EditorProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BlockNoteEditor, filterSuggestionItems } from "@blocknote/core";
import { useCreateBlockNote } from "@blocknote/react";
import { ReactNode, createContext, useContext, useRef, useMemo, useEffect } from "react";
import { ReactNode, createContext, useContext, useRef, useMemo, useEffect, useState } from "react";
import { codeBlock } from "@blocknote/code-block";
import {
createAIExtension,
Expand Down Expand Up @@ -72,18 +72,25 @@ export function EditorProvider({
updateCurrentNoteContent: (content: string) => void;
updateCurrentNoteTitle: (title: string) => void;
}) {
const { googleApiKey, setEditor } = useUiStore();
const { ensureGoogleApiKey, hasGoogleApiKey, setEditor } = useUiStore();
const [resolvedKey, setResolvedKey] = useState<string>('');

// 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);

Expand Down Expand Up @@ -233,7 +240,7 @@ export function FormattingToolbarWithAI() {

// Slash menu with the AI option added
export function SuggestionMenuWithAI({ editor }: { editor: BlockNoteEditor<any, any, any> }) {
const { googleApiKey } = useUiStore();
const { hasGoogleApiKey } = useUiStore();

return (
<SuggestionMenuController
Expand All @@ -243,7 +250,7 @@ export function SuggestionMenuWithAI({ editor }: { editor: BlockNoteEditor<any,
[
...getDefaultReactSlashMenuItems(editor),
// add the default AI slash menu items only if API key is present
...(googleApiKey ? getAISlashMenuItems(editor) : []),
...(hasGoogleApiKey ? getAISlashMenuItems(editor) : []),
],
query,
)
Expand Down
2 changes: 1 addition & 1 deletion src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ export const Sidebar = () => {
}

// 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 || "[]") || [];
Expand Down
25 changes: 20 additions & 5 deletions src/store/UiStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface UiState {
isSidebarFloating: boolean;
expandedFolders: Set<string>;
googleApiKey: string;
hasGoogleApiKey: boolean;
gitSyncConfigured: boolean;
isSyncing: boolean; // [NEW]
lastSyncedAt: Date | null; // [NEW]
Expand All @@ -56,6 +57,7 @@ interface UiState {
setIsSidebarFloating: (isFloating: boolean) => void;
setExpandedFolders: (folders: Set<string>) => void;
setGoogleApiKey: (key: string) => void;
ensureGoogleApiKey: () => Promise<string>;
setGitSyncConfigured: (configured: boolean) => void;
setIsSyncing: (isSyncing: boolean) => void; // [NEW]
setLastSyncedAt: (date: Date | null) => void; // [NEW]
Expand Down Expand Up @@ -92,13 +94,13 @@ const useUiStore = create<UiState>((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<string>('get_google_api_key');
set({ googleApiKey: key });
const hasKey = await invoke<boolean>('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);
}
},

Expand All @@ -116,6 +118,7 @@ const useUiStore = create<UiState>((set) => ({
expandedFolders: new Set(),
deleteConfirmFolderId: null,
googleApiKey: '',
hasGoogleApiKey: false,
gitSyncConfigured: false,
isSyncing: false, // [NEW]
lastSyncedAt: null, // [NEW]
Expand Down Expand Up @@ -147,7 +150,19 @@ const useUiStore = create<UiState>((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<string> => {
const { googleApiKey } = useUiStore.getState();
if (googleApiKey) return googleApiKey;
try {
const key = await invoke<string>('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]
Expand Down
Loading