diff --git a/screenpipe-app-tauri/app/page.tsx b/screenpipe-app-tauri/app/page.tsx index d8ed15450..2d6aee52b 100644 --- a/screenpipe-app-tauri/app/page.tsx +++ b/screenpipe-app-tauri/app/page.tsx @@ -27,9 +27,12 @@ import { platform } from "@tauri-apps/plugin-os"; import PipeStore from "@/components/pipe-store"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import { useProfiles } from "@/lib/hooks/use-profiles"; +import { relaunch } from "@tauri-apps/plugin-process"; export default function Home() { const { settings } = useSettings(); + const { setActiveProfile } = useProfiles(); const posthog = usePostHog(); const { toast } = useToast(); const { showOnboarding, setShowOnboarding } = useOnboarding(); @@ -52,6 +55,25 @@ export default function Home() { title: 'recording stopped', description: 'screen recording has been stopped' }); + }), + + listen('switch-profile', async (event) => { + const profile = event.payload; + setActiveProfile(profile); + + toast({ + title: 'profile switched', + description: `switched to ${profile} profile, restarting screenpipe now` + }); + + await invoke("kill_all_sreenpipes"); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + await invoke("spawn_screenpipe"); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + relaunch(); }) ]); diff --git a/screenpipe-app-tauri/app/providers.tsx b/screenpipe-app-tauri/app/providers.tsx index 12fda4eb0..847517bbb 100644 --- a/screenpipe-app-tauri/app/providers.tsx +++ b/screenpipe-app-tauri/app/providers.tsx @@ -7,32 +7,36 @@ import { initOpenTelemetry } from "@/lib/opentelemetry"; import { OnboardingProvider } from "@/lib/hooks/use-onboarding"; import { ChangelogDialogProvider } from "@/lib/hooks/use-changelog-dialog"; import { forwardRef } from "react"; -import { store } from "@/lib/hooks/use-settings"; -import { StoreProvider } from "easy-peasy"; +import { store as SettingsStore } from "@/lib/hooks/use-settings"; +import { profilesStore as ProfilesStore } from "@/lib/hooks/use-profiles"; -export const Providers = forwardRef( - ({ children }, ref) => { - useEffect(() => { - if (typeof window !== "undefined") { - const isDebug = process.env.TAURI_ENV_DEBUG === "true"; - if (isDebug) return; - posthog.init("phc_Bt8GoTBPgkCpDrbaIZzJIEYt0CrJjhBiuLaBck1clce", { - api_host: "https://eu.i.posthog.com", - person_profiles: "identified_only", - capture_pageview: false, - }); - initOpenTelemetry("82688", new Date().toISOString()); - } - }, []); +export const Providers = forwardRef< + HTMLDivElement, + { children: React.ReactNode } +>(({ children }, ref) => { + useEffect(() => { + if (typeof window !== "undefined") { + const isDebug = process.env.TAURI_ENV_DEBUG === "true"; + if (isDebug) return; + posthog.init("phc_Bt8GoTBPgkCpDrbaIZzJIEYt0CrJjhBiuLaBck1clce", { + api_host: "https://eu.i.posthog.com", + person_profiles: "identified_only", + capture_pageview: false, + }); + initOpenTelemetry("82688", new Date().toISOString()); + } + }, []); return ( - - - - {children} - - - + + + + + {children} + + + + ); }); diff --git a/screenpipe-app-tauri/components/settings.tsx b/screenpipe-app-tauri/components/settings.tsx index a50c1fbed..efe773f32 100644 --- a/screenpipe-app-tauri/components/settings.tsx +++ b/screenpipe-app-tauri/components/settings.tsx @@ -1,33 +1,94 @@ "use client"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useSettings } from "@/lib/hooks/use-settings"; import { - Settings2, Brain, Video, Keyboard, User, - ArrowLeft, + ChevronDown, + Plus, + Trash2, + Check, } from "lucide-react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "./ui/dialog"; +import { DialogHeader, DialogTitle } from "./ui/dialog"; import { cn } from "@/lib/utils"; import { RecordingSettings } from "./recording-settings"; import { AccountSection } from "./settings/account-section"; import ShortcutSection from "./settings/shortcut-section"; import AISection from "./settings/ai-section"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { Input } from "./ui/input"; +import { Button } from "./ui/button"; +import { relaunch } from "@tauri-apps/plugin-process"; +import { invoke } from "@tauri-apps/api/core"; +import { useProfiles } from "@/lib/hooks/use-profiles"; +import { toast } from "./ui/use-toast"; type SettingsSection = "ai" | "shortcuts" | "recording" | "account"; export function Settings() { + // const { settings, switchProfile, deleteProfile } = useSettings(); + const { + profiles, + activeProfile, + createProfile, + deleteProfile, + setActiveProfile, + } = useProfiles(); const [activeSection, setActiveSection] = useState("account"); + const [isCreatingProfile, setIsCreatingProfile] = useState(false); + const [newProfileName, setNewProfileName] = useState(""); + const { settings } = useSettings(); + + const handleProfileChange = async () => { + toast({ + title: "Restarting Screenpipe", + description: "Please wait while we restart Screenpipe", + }); + await invoke("kill_all_sreenpipes"); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + await invoke("spawn_screenpipe"); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + relaunch(); + }; + + const handleCreateProfile = async () => { + if (newProfileName.trim() === "default") { + toast({ + title: "profile name is not allowed", + description: "Please choose a different name for your profile", + }); + return; + } + if (newProfileName.trim()) { + console.log("creating profile", newProfileName.trim()); + createProfile({ + profileName: newProfileName.trim(), + currentSettings: settings, + }); + setActiveProfile(newProfileName.trim()); + setNewProfileName(""); + setIsCreatingProfile(false); + handleProfileChange(); + } + }; + + const handleSwitchProfile = async (profileName: string) => { + setActiveProfile(profileName); + handleProfileChange(); + }; const renderSection = () => { switch (activeSection) { @@ -42,6 +103,10 @@ export function Settings() { } }; + useEffect(() => { + console.log(profiles, "profiles"); + }, [profiles]); + return (
{/* Sidebar */} @@ -50,6 +115,81 @@ export function Settings() { settings + {/* Profile Selector */} +
+ + + + + + {profiles?.map((profile) => ( + handleSwitchProfile(profile)} + > + {profile} + {activeProfile === profile && } + {profile !== "default" && ( + { + e.stopPropagation(); + deleteProfile(profile); + }} + /> + )} + + ))} + + {isCreatingProfile ? ( +
+
{ + e.preventDefault(); + handleCreateProfile(); + }} + className="flex gap-2" + > + setNewProfileName(e.target.value)} + placeholder="profile name" + className="h-8 font-mono" + autoFocus + /> + +
+
+ ) : ( + { + e.preventDefault(); + setIsCreatingProfile(true); + }} + className="gap-2" + > + + new profile + + )} +
+
+
+ + {/* Existing Settings Navigation */}
{[ { @@ -90,7 +230,7 @@ export function Settings() {
- {/* Content - Updated styles */} + {/* Content */}
{renderSection()}
diff --git a/screenpipe-app-tauri/components/settings/shortcut-section.tsx b/screenpipe-app-tauri/components/settings/shortcut-section.tsx index 5196cd05c..166259d9b 100644 --- a/screenpipe-app-tauri/components/settings/shortcut-section.tsx +++ b/screenpipe-app-tauri/components/settings/shortcut-section.tsx @@ -1,14 +1,20 @@ import React, { useEffect, useState } from "react"; -import { Shortcut, useSettings } from "@/lib/hooks/use-settings"; +import { Settings, Shortcut, useSettings } from "@/lib/hooks/use-settings"; +import { useProfiles } from "@/lib/hooks/use-profiles"; import { parseKeyboardShortcut } from "@/lib/utils"; import { Switch } from "@/components/ui/switch"; import { toast } from "@/components/ui/use-toast"; import { cn } from "@/lib/utils"; import { Pencil } from "lucide-react"; -import { listen } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; import hotkeys from "hotkeys-js"; +interface ShortcutRowProps { + shortcut: string; + title: string; + description: string; +} + interface ShortcutState { isRecording: boolean; pressedKeys: string[]; @@ -16,31 +22,47 @@ interface ShortcutState { const ShortcutSection = () => { const { settings, updateSettings } = useSettings(); + const { + profiles, + shortcuts, + updateShortcut: updateProfileShortcut, + } = useProfiles(); + const [shortcutStates, setShortcutStates] = useState< - Record - >({ - [Shortcut.SHOW_SCREENPIPE]: { isRecording: false, pressedKeys: [] }, - [Shortcut.START_RECORDING]: { isRecording: false, pressedKeys: [] }, - [Shortcut.STOP_RECORDING]: { isRecording: false, pressedKeys: [] }, - }); - - const updateShortcut = async (shortcut: Shortcut, keys: string) => { + Record + >(() => ({ + showScreenpipeShortcut: { isRecording: false, pressedKeys: [] }, + startRecordingShortcut: { isRecording: false, pressedKeys: [] }, + stopRecordingShortcut: { isRecording: false, pressedKeys: [] }, + ...Object.fromEntries( + profiles.map((profile) => [ + `profile_${profile}`, + { isRecording: false, pressedKeys: [] }, + ]) + ), + })); + + const updateShortcut = async (shortcutId: string, keys: string) => { try { - // Update the appropriate flat key - const updates = { - [Shortcut.SHOW_SCREENPIPE]: { showScreenpipeShortcut: keys }, - [Shortcut.START_RECORDING]: { startRecordingShortcut: keys }, - [Shortcut.STOP_RECORDING]: { stopRecordingShortcut: keys }, - }[shortcut]; - - // Update settings first - updateSettings(updates); + let updatedSettings = { ...settings }; + if (shortcutId.startsWith("profile_")) { + const profileName = shortcutId.replace("profile_", ""); + updateProfileShortcut({ profile: profileName, shortcut: keys }); + } else { + const updates: Partial = { + [shortcutId]: keys, + }; + updatedSettings = { ...settings, ...updates }; + updateSettings(updates); + } - // Then update Rust backend + // wait 2 seconds to make sure store has synced to disk + await new Promise((resolve) => setTimeout(resolve, 2000)); await invoke("update_global_shortcuts", { - showShortcut: settings.showScreenpipeShortcut, - startShortcut: settings.startRecordingShortcut, - stopShortcut: settings.stopRecordingShortcut, + showShortcut: updatedSettings.showScreenpipeShortcut, + startShortcut: updatedSettings.startRecordingShortcut, + stopShortcut: updatedSettings.stopRecordingShortcut, + profileShortcuts: shortcuts, }); return true; @@ -50,142 +72,68 @@ const ShortcutSection = () => { } }; - const toggleShortcut = async (shortcut: Shortcut, enabled: boolean) => { - const newDisabled = enabled - ? settings.disabledShortcuts.filter((s) => s !== shortcut) - : [...settings.disabledShortcuts, shortcut]; + const processKeyboardEvent = (event: KeyboardEvent, shortcutKey: string) => { + event.preventDefault(); - updateSettings({ - disabledShortcuts: newDisabled, - }); + const MODIFIER_KEYS = ["SUPER", "CTRL", "ALT", "SHIFT"] as const; + const KEY_CODE_MAP: Record = { + 91: "SUPER", // Command/Windows key + 93: "SUPER", // Right Command/Windows + 16: "SHIFT", + 17: "CTRL", + 18: "ALT", + }; - // Update Rust backend with current shortcuts - await invoke("update_global_shortcuts", { - showShortcut: settings.showScreenpipeShortcut, - startShortcut: settings.startRecordingShortcut, - stopShortcut: settings.stopRecordingShortcut, - }); - }; + const pressedKeys = hotkeys + .getPressedKeyCodes() + .map((code) => KEY_CODE_MAP[code] || String.fromCharCode(code)) + .filter((value, index, self) => self.indexOf(value) === index); - const getShortcut = (shortcut: Shortcut): string => { - switch (shortcut) { - case Shortcut.SHOW_SCREENPIPE: - return settings.showScreenpipeShortcut; - case Shortcut.START_RECORDING: - return settings.startRecordingShortcut; - case Shortcut.STOP_RECORDING: - return settings.stopRecordingShortcut; - } - }; + const modifiers = pressedKeys.filter((k) => + MODIFIER_KEYS.includes(k as any) + ); + const normalKeys = pressedKeys.filter( + (k) => !MODIFIER_KEYS.includes(k as any) + ); + const finalKeys = [...modifiers, ...normalKeys]; + + setShortcutStates((prev) => ({ + ...prev, + [shortcutKey]: { + ...prev[shortcutKey], + pressedKeys: finalKeys, + }, + })); - const isShortcutEnabled = (shortcut: Shortcut): boolean => { - return !settings.disabledShortcuts.includes(shortcut); + if (normalKeys.length > 0) { + handleShortcutUpdate(shortcutKey, finalKeys.join("+")); + } }; - // Handle keyboard events for shortcut recording useEffect(() => { const activeShortcut = Object.entries(shortcutStates).find( ([_, state]) => state.isRecording ); if (!activeShortcut) return; - const [shortcutKey] = activeShortcut; - - const handleKeyPress = (event: KeyboardEvent) => { - event.preventDefault(); - - // Get pressed keys in a consistent format - const keys = hotkeys - .getPressedKeyCodes() - .map((code) => { - // Map key codes to consistent names - switch (code) { - case 91: - case 93: - return "SUPER"; // Command/Windows key - case 16: - return "SHIFT"; - case 17: - return "CTRL"; - case 18: - return "ALT"; - default: - return String.fromCharCode(code); - } - }) - .filter((value, index, self) => self.indexOf(value) === index); // Remove duplicates - - // Sort modifiers to ensure consistent order - const modifiers = keys.filter((k) => - ["SUPER", "CTRL", "ALT", "SHIFT"].includes(k) - ); - const normalKeys = keys.filter( - (k) => !["SUPER", "CTRL", "ALT", "SHIFT"].includes(k) - ); - - const finalKeys = [...modifiers, ...normalKeys]; - - // Update pressed keys display - setShortcutStates((prev) => ({ - ...prev, - [shortcutKey]: { - ...prev[shortcutKey as Shortcut], - pressedKeys: finalKeys, - }, - })); - - // Only update if we have a non-modifier key - if (normalKeys.length > 0) { - handleShortcutUpdate(shortcutKey as Shortcut, finalKeys.join("+")); - } - }; - - // Enable all keys, including special ones hotkeys.filter = () => true; - hotkeys("*", handleKeyPress); + hotkeys("*", (event) => processKeyboardEvent(event, activeShortcut[0])); - return () => { - hotkeys.unbind("*"); - }; + return () => hotkeys.unbind("*"); }, [shortcutStates]); - const handleShortcutUpdate = async (shortcut: Shortcut, keys: string) => { - const success = await updateShortcut(shortcut, keys); - - if (success) { - toast({ - title: "shortcut updated", - description: `${shortcut} set to: ${parseKeyboardShortcut(keys)}`, - }); - } else { - toast({ - title: "error updating shortcut", - description: - "failed to register shortcut. please try a different combination.", - variant: "destructive", - }); - } - - // Reset recording state - setShortcutStates((prev) => ({ - ...prev, - [shortcut]: { isRecording: false, pressedKeys: [] }, - })); - }; + const ShortcutRow = ({ shortcut, title, description }: ShortcutRowProps) => { + const state = shortcutStates[shortcut] || { + isRecording: false, + pressedKeys: [], + }; + const currentValue = getShortcutValue(shortcut, settings, shortcuts); - const ShortcutRow = ({ - shortcut, - title, - description, - }: { - shortcut: Shortcut; - title: string; - description: string; - }) => { - const state = shortcutStates[shortcut]; const currentKeys = state.isRecording ? state.pressedKeys - : parseKeyboardShortcut(getShortcut(shortcut)).split("+"); + : currentValue + ? parseKeyboardShortcut(currentValue).split("+") + : ["Unassigned"]; return (
@@ -205,7 +153,8 @@ const ShortcutSection = () => { "relative min-w-[140px] rounded-md border px-3 py-2 text-sm", "bg-muted/50 hover:bg-muted/70 transition-colors", "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ring", - state.isRecording && "border-primary" + state.isRecording && "border-primary", + !currentValue && "text-muted-foreground" )} > {state.isRecording ? ( @@ -213,7 +162,13 @@ const ShortcutSection = () => { ) : ( {currentKeys.map((key, i) => ( - + {key} ))} @@ -223,36 +178,119 @@ const ShortcutSection = () => { toggleShortcut(shortcut, checked)} + checked={!!currentValue} + disabled={typeof currentValue === "undefined"} + onCheckedChange={async (checked) => { + if (!checked) { + handleShortcutUpdate(shortcut, "", true); + } + }} />
); }; + const handleShortcutUpdate = async ( + shortcut: string, + keys: string, + disable?: boolean + ) => { + const success = await updateShortcut(shortcut, keys); + if (!success) { + toast({ + title: "error updating shortcut", + description: + "failed to register shortcut. please try a different combination.", + variant: "destructive", + }); + return; + } + + if (disable) { + updateSettings({ + disabledShortcuts: [ + ...settings.disabledShortcuts, + shortcut as Shortcut, + ], + }); + toast({ + title: "shortcut disabled", + description: `${shortcut.replace(/_/g, " ")} disabled`, + }); + return; + } + + toast({ + title: "shortcut updated", + description: `${shortcut.replace( + /_/g, + " " + )} set to: ${parseKeyboardShortcut(keys)}`, + }); + + // Reset recording state + setShortcutStates((prev) => ({ + ...prev, + [shortcut]: { isRecording: false, pressedKeys: [] }, + })); + }; + + // Helper to get shortcut value + const getShortcutValue = ( + shortcut: string, + settings: Settings, + profileShortcuts: Record + ): string | undefined => { + if (shortcut.startsWith("profile_")) { + const profileName = shortcut.replace("profile_", ""); + return profileShortcuts[profileName]; + } + return settings[shortcut as keyof Settings] as string; + }; + return (

shortcuts

+ + {profiles.length > 1 && ( + <> +
+

profile shortcuts

+

+ assign shortcuts to quickly switch between profiles +

+
+ + {profiles.map((profile) => ( + + ))} + + )}
); diff --git a/screenpipe-app-tauri/lib/hooks/use-profiles.tsx b/screenpipe-app-tauri/lib/hooks/use-profiles.tsx new file mode 100644 index 000000000..c482cd053 --- /dev/null +++ b/screenpipe-app-tauri/lib/hooks/use-profiles.tsx @@ -0,0 +1,236 @@ +import { Action, action, persist } from "easy-peasy"; +import { LazyStore } from "@tauri-apps/plugin-store"; +import { localDataDir } from "@tauri-apps/api/path"; +import { flattenObject, FlattenObjectKeys, unflattenObject } from "../utils"; +import { createContextStore } from "easy-peasy"; +import { createDefaultSettingsObject, Settings } from "./use-settings"; +import { remove } from "@tauri-apps/plugin-fs"; +export interface ProfilesModel { + activeProfile: string; + profiles: string[]; + shortcuts: { + [profileName: string]: string; + }; + setActiveProfile: Action; + createProfile: Action< + ProfilesModel, + { + profileName: string; + currentSettings: Settings; + } + >; + deleteProfile: Action; + updateShortcut: Action; +} + +let profilesStorePromise: Promise | null = null; + +/** + * @warning Do not change autoSave to true, it causes race conditions + */ +const getProfilesStore = async () => { + if (!profilesStorePromise) { + profilesStorePromise = (async () => { + const dir = await localDataDir(); + console.log(dir, "dir"); + return new LazyStore(`${dir}/screenpipe/profiles.bin`, { + autoSave: false, + }); + })(); + } + return profilesStorePromise; +}; + +const profilesStorage = { + getItem: async (_key: string) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + const tauriStore = await getProfilesStore(); + const allKeys = await tauriStore.keys(); + const values: Record = {}; + + for (const k of allKeys) { + values[k] = await tauriStore.get(k); + } + + return unflattenObject(values); + }, + + setItem: async (_key: string, value: any) => { + const tauriStore = await getProfilesStore(); + const flattenedValue = flattenObject(value); + + const existingKeys = await tauriStore.keys(); + for (const key of existingKeys) { + await tauriStore.delete(key); + } + + for (const [key, val] of Object.entries(flattenedValue)) { + await tauriStore.set(key, val); + } + + await tauriStore.save(); + }, + removeItem: async (_key: string) => { + const tauriStore = await getProfilesStore(); + const keys = await tauriStore.keys(); + for (const key of keys) { + await tauriStore.delete(key); + } + await tauriStore.save(); + }, +}; + +const copyProfileSettings = async ( + profileName: string, + currentSettings: Settings +) => { + try { + const dir = await localDataDir(); + const fileName = `store-${profileName}.bin`; + + console.log(`copying profile settings to ${fileName}`); + + const store = new LazyStore(`${dir}/screenpipe/${fileName}`, { + autoSave: false, + }); + + // Start with default settings + const defaultSettings = createDefaultSettingsObject(); + const flattenedDefaults = flattenObject(defaultSettings); + + // Define keys to copy from current settings + const keysToCopy: FlattenObjectKeys[] = [ + // Account related + "user.token", + "user.id", + "user.email", + "user.name", + "user.image", + "user.clerk_id", + "user.credits.amount", + + // AI related + "aiProviderType", + "aiUrl", + "aiModel", + "aiMaxContextChars", + "openaiApiKey", + + // Shortcuts + "showScreenpipeShortcut", + "startRecordingShortcut", + "stopRecordingShortcut", + "disabledShortcuts", + ] as const; + + // Copy specific keys from current settings + const flattenedCurrentSettings = flattenObject(currentSettings); + for (const key of keysToCopy) { + const value = flattenedCurrentSettings[key]; + if (value !== undefined) { + await store.set(key, value); + } + } + + // Set all other keys to defaults + for (const [key, value] of Object.entries(flattenedDefaults)) { + if (!keysToCopy.includes(key as FlattenObjectKeys)) { + await store.set(key, value); + } + } + + await store.save(); + console.log(`successfully copied profile settings to ${fileName}`); + } catch (err) { + console.error(`failed to copy profile settings: ${err}`); + throw new Error(`failed to copy profile settings: ${err}`); + } +}; + +const deleteProfileFile = async (profile: string) => { + try { + const dir = await localDataDir(); + const file = profile === "default" ? "store.bin" : `store-${profile}.bin`; + await remove(`${dir}/screenpipe/${file}`); + } catch (err) { + console.error(`failed to delete profile file: ${err}`); + throw new Error(`failed to delete profile file: ${err}`); + } +}; + +export const profilesStore = createContextStore( + persist( + { + activeProfile: "default", + profiles: ["default"], + shortcuts: {}, + setActiveProfile: action((state, payload) => { + state.activeProfile = payload; + }), + updateShortcut: action((state, { profile, shortcut }) => { + if (shortcut === '') { + delete state.shortcuts[profile]; + } else { + state.shortcuts[profile] = shortcut; + } + }), + createProfile: action((state, payload) => { + state.profiles.push(payload.profileName); + copyProfileSettings(payload.profileName, payload.currentSettings).catch( + (err) => + console.error( + `failed to create profile ${payload.profileName}: ${err}` + ) + ); + }), + deleteProfile: action((state, payload) => { + if (payload === "default") { + console.error("cannot delete default profile"); + return; + } + state.profiles = state.profiles.filter( + (profile) => profile !== payload + ); + deleteProfileFile(payload).catch((err) => + console.error(`failed to delete profile ${payload}: ${err}`) + ); + }), + }, + { + storage: profilesStorage, + } + ) +); + +export const useProfiles = () => { + const { profiles, activeProfile, shortcuts } = profilesStore.useStoreState( + (state) => ({ + activeProfile: state.activeProfile, + profiles: state.profiles, + shortcuts: state.shortcuts, + }) + ); + + const setActiveProfile = profilesStore.useStoreActions( + (actions) => actions.setActiveProfile + ); + const createProfile = profilesStore.useStoreActions( + (actions) => actions.createProfile + ); + const deleteProfile = profilesStore.useStoreActions( + (actions) => actions.deleteProfile + ); + const updateShortcut = profilesStore.useStoreActions( + (actions) => actions.updateShortcut + ); + + return { + profiles, + activeProfile, + shortcuts, + setActiveProfile, + createProfile, + deleteProfile, + updateShortcut, + }; +}; diff --git a/screenpipe-app-tauri/lib/hooks/use-settings.tsx b/screenpipe-app-tauri/lib/hooks/use-settings.tsx index 7aeb8fc38..a8f238eae 100644 --- a/screenpipe-app-tauri/lib/hooks/use-settings.tsx +++ b/screenpipe-app-tauri/lib/hooks/use-settings.tsx @@ -3,12 +3,11 @@ import { platform } from "@tauri-apps/plugin-os"; import { Pipe } from "./use-pipes"; import { Language } from "@/lib/language"; import { - createStore as createStoreEasyPeasy, action, Action, persist, - createTypedHooks, PersistStorage, + createContextStore, } from "easy-peasy"; import { LazyStore, LazyStore as TauriStore } from "@tauri-apps/plugin-store"; import { localDataDir } from "@tauri-apps/api/path"; @@ -35,7 +34,7 @@ export enum Shortcut { STOP_RECORDING = "stop_recording", } -export interface User { +export type User = { id?: string; email?: string; name?: string; @@ -181,7 +180,7 @@ export interface StoreModel { resetSetting: Action; } -function createDefaultSettingsObject(): Settings { +export function createDefaultSettingsObject(): Settings { let defaultSettings = { ...DEFAULT_SETTINGS }; try { const currentPlatform = platform(); @@ -211,11 +210,22 @@ function createDefaultSettingsObject(): Settings { // Create a singleton store instance let storePromise: Promise | null = null; +/** + * @warning Do not change autoSave to true, it causes race conditions + */ const getStore = async () => { if (!storePromise) { storePromise = (async () => { const dir = await localDataDir(); - return new TauriStore(`${dir}/screenpipe/store.bin`); + const profilesStore = new TauriStore(`${dir}/screenpipe/profiles.bin`, { + autoSave: false, + }); + const activeProfile = await profilesStore.get("activeProfile") || "default"; + const file = activeProfile === "default" ? `store.bin` : `store-${activeProfile}.bin`; + console.log("activeProfile", activeProfile, file); + return new TauriStore(`${dir}/screenpipe/${file}`, { + autoSave: false, + }); })(); } return storePromise; @@ -261,7 +271,7 @@ const tauriStorage: PersistStorage = { }, }; -export const store = createStoreEasyPeasy( +export const store = createContextStore( persist( { settings: createDefaultSettingsObject(), @@ -286,15 +296,11 @@ export const store = createStoreEasyPeasy( ) ); -const typedHooks = createTypedHooks(); -const useStoreActions = typedHooks.useStoreActions; -const useStoreState = typedHooks.useStoreState; - export function useSettings() { - const settings = useStoreState((state) => state.settings); - const setSettings = useStoreActions((actions) => actions.setSettings); - const resetSettings = useStoreActions((actions) => actions.resetSettings); - const resetSetting = useStoreActions((actions) => actions.resetSetting); + const settings = store.useStoreState((state) => state.settings); + const setSettings = store.useStoreActions((actions) => actions.setSettings); + const resetSettings = store.useStoreActions((actions) => actions.resetSettings); + const resetSetting = store.useStoreActions((actions) => actions.resetSetting); const getDataDir = async () => { const homeDirPath = await homeDir(); @@ -323,4 +329,4 @@ export function useSettings() { resetSetting, getDataDir, }; -} +} \ No newline at end of file diff --git a/screenpipe-app-tauri/lib/utils.ts b/screenpipe-app-tauri/lib/utils.ts index a02e3737b..89410e986 100644 --- a/screenpipe-app-tauri/lib/utils.ts +++ b/screenpipe-app-tauri/lib/utils.ts @@ -3,6 +3,15 @@ import { platform } from "@tauri-apps/plugin-os"; import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; +export type FlattenObjectKeys< + T extends Record, + Key = keyof T +> = Key extends string + ? T[Key] extends Record | undefined + ? `${Key}.${FlattenObjectKeys>}` + : `${Key}` + : never; + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } diff --git a/screenpipe-app-tauri/src-tauri/src/main.rs b/screenpipe-app-tauri/src-tauri/src/main.rs index 1fbfc030f..423a32b2e 100755 --- a/screenpipe-app-tauri/src-tauri/src/main.rs +++ b/screenpipe-app-tauri/src-tauri/src/main.rs @@ -47,12 +47,15 @@ mod server; mod sidecar; mod tray; mod updates; +mod store; pub use commands::reset_all_pipes; pub use commands::set_tray_health_icon; pub use commands::set_tray_unhealth_icon; pub use server::spawn_server; pub use sidecar::kill_all_sreenpipes; pub use sidecar::spawn_screenpipe; +pub use store::get_store; +pub use store::get_profiles_store; use crate::commands::hide_main_window; pub use permissions::do_permissions_check; @@ -62,6 +65,8 @@ pub use permissions::request_permission; use tauri::AppHandle; use tauri_plugin_global_shortcut::GlobalShortcutExt; use tauri_plugin_global_shortcut::{Code, Modifiers, Shortcut}; +use std::collections::HashMap; + pub struct SidecarState(Arc>>); // New struct to hold shortcut configuration @@ -70,16 +75,31 @@ struct ShortcutConfig { show: String, start: String, stop: String, + profile_shortcuts: HashMap, disabled: Vec, } impl ShortcutConfig { async fn from_store(app: &AppHandle) -> Result { - let base_dir = get_base_dir(app, None).map_err(|e| e.to_string())?; - let path = base_dir.join("store.bin"); - let store = StoreBuilder::new(app, path) - .build() - .map_err(|e| e.to_string())?; + let store = get_store(app, None).map_err(|e| e.to_string())?; + + let profile_shortcuts = match get_profiles_store(app) { + Ok(profiles_store) => { + let profiles = profiles_store + .get("profiles") + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + .unwrap_or_default(); + + profiles.into_iter() + .filter_map(|profile| { + profiles_store.get(&format!("shortcuts.{}", profile)) + .and_then(|v| v.as_str().map(String::from)) + .map(|shortcut| (profile, shortcut)) + }) + .collect() + }, + Err(_) => HashMap::new() + }; Ok(Self { show: store @@ -94,6 +114,7 @@ impl ShortcutConfig { .get("stopRecordingShortcut") .and_then(|v| v.as_str().map(String::from)) .unwrap_or_else(|| "Alt+Shift+S".to_string()), + profile_shortcuts, disabled: store .get("disabledShortcuts") .and_then(|v| serde_json::from_value(v.clone()).ok()) @@ -137,11 +158,13 @@ async fn update_global_shortcuts( show_shortcut: String, start_shortcut: String, stop_shortcut: String, + profile_shortcuts: HashMap, ) -> Result<(), String> { let config = ShortcutConfig { show: show_shortcut, start: start_shortcut, stop: stop_shortcut, + profile_shortcuts, disabled: ShortcutConfig::from_store(&app).await?.disabled, }; apply_shortcuts(&app, &config).await @@ -154,7 +177,6 @@ async fn initialize_global_shortcuts(app: &AppHandle) -> Result<(), String> { async fn apply_shortcuts(app: &AppHandle, config: &ShortcutConfig) -> Result<(), String> { let global_shortcut = app.global_shortcut(); - // Unregister all existing shortcuts first global_shortcut.unregister_all().unwrap(); // Register show shortcut @@ -200,6 +222,20 @@ async fn apply_shortcuts(app: &AppHandle, config: &ShortcutConfig) -> Result<(), }, ) .await?; + info!("applying shortcuts for profiles {:?}", config.profile_shortcuts); + // Register only non-empty profile shortcuts + for (profile, shortcut) in &config.profile_shortcuts { + info!("profile: {}", profile); + info!("shortcut: {}", shortcut); + if !shortcut.is_empty() { + let profile = profile.clone(); + register_shortcut(app, shortcut, false, move |app| { + info!("switch-profile shortcut triggered for profile: {}", profile); + let _ = app.emit("switch-profile", profile.clone()); + }) + .await?; + } + } Ok(()) } @@ -264,15 +300,17 @@ fn send_recording_notification( } fn get_data_dir(app: &tauri::AppHandle) -> anyhow::Result { - let base_dir = get_base_dir(app, None)?; - let path = base_dir.join("store.bin"); - let store = StoreBuilder::new(app, path).build(); + // Create a new runtime for this synchronous function + + let store = get_store(app, None)?; let default_path = app.path().home_dir().unwrap().join(".screenpipe"); - let data_dir = store? + + let data_dir = store .get("dataDir") .and_then(|v| v.as_str().map(String::from)) .unwrap_or(String::from("default")); + if data_dir == "default" { Ok(default_path) } else { diff --git a/screenpipe-app-tauri/src-tauri/src/server.rs b/screenpipe-app-tauri/src-tauri/src/server.rs index cdedcee1c..c88c74f2d 100644 --- a/screenpipe-app-tauri/src-tauri/src/server.rs +++ b/screenpipe-app-tauri/src-tauri/src/server.rs @@ -1,4 +1,4 @@ -use crate::icons::AppIcon; +use crate::{get_store, icons::AppIcon}; use axum::{ extract::{Query, State}, http::{Method, StatusCode}, @@ -8,10 +8,8 @@ use http::header::HeaderValue; use serde::{Deserialize, Serialize}; use std::net::SocketAddr; use tauri::Emitter; -use tauri::Manager; #[allow(unused_imports)] use tauri_plugin_notification::NotificationExt; -use tauri_plugin_store::StoreBuilder; use tokio::sync::mpsc; use tower_http::cors::{Any, CorsLayer}; use tower_http::trace::{DefaultMakeSpan, TraceLayer}; @@ -186,15 +184,7 @@ async fn handle_auth( ) -> Result, (StatusCode, String)> { info!("received auth data: {:?}", payload); - let path = state - .app_handle - .path() - .local_data_dir() - .unwrap() - .join("screenpipe") - .join("store.bin"); - info!("store path: {:?}", path); - let store = StoreBuilder::new(&state.app_handle, path).build().unwrap(); + let store = get_store(&state.app_handle, None).unwrap(); if payload.token.is_some() { let auth_data = AuthData { diff --git a/screenpipe-app-tauri/src-tauri/src/sidecar.rs b/screenpipe-app-tauri/src-tauri/src/sidecar.rs index f8ff0136b..d73bd3cf0 100644 --- a/screenpipe-app-tauri/src-tauri/src/sidecar.rs +++ b/screenpipe-app-tauri/src-tauri/src/sidecar.rs @@ -1,4 +1,5 @@ use crate::{get_base_dir, SidecarState}; +use crate::get_store; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::Arc; @@ -144,9 +145,7 @@ pub async fn spawn_screenpipe( } fn spawn_sidecar(app: &tauri::AppHandle) -> Result { - let base_dir = get_base_dir(app, None).expect("Failed to ensure local data directory"); - let path = base_dir.join("store.bin"); - let store = StoreBuilder::new(&app.clone(), path).build().unwrap(); + let store = get_store(app, None).unwrap(); let audio_transcription_engine = store .get("audioTranscriptionEngine") @@ -517,9 +516,7 @@ impl SidecarManager { } async fn update_settings(&mut self, app: &tauri::AppHandle) -> Result<(), String> { - let base_dir = get_base_dir(app, None).expect("Failed to ensure local data directory"); - let path = base_dir.join("store.bin"); - let store = StoreBuilder::new(&app.clone(), path).build().unwrap(); + let store = get_store(app, None).unwrap(); let restart_interval = store .get("restartInterval") diff --git a/screenpipe-app-tauri/src-tauri/src/store.rs b/screenpipe-app-tauri/src-tauri/src/store.rs new file mode 100644 index 000000000..0476ae0c4 --- /dev/null +++ b/screenpipe-app-tauri/src-tauri/src/store.rs @@ -0,0 +1,82 @@ +use super::get_base_dir; +use std::sync::Arc; +use tauri::AppHandle; +use tauri_plugin_store::StoreBuilder; +use tracing::{info}; + + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ProfilesConfig { + active_profile: String, + profiles: Vec, +} + +impl Default for ProfilesConfig { + fn default() -> Self { + Self { + active_profile: "default".to_string(), + profiles: vec!["default".to_string()], + } + } +} + +pub fn get_profiles_store( + app: &AppHandle, +) -> anyhow::Result>> { + let base_dir = get_base_dir(app, None)?; + let profiles_path = base_dir.join("profiles.bin"); + Ok(StoreBuilder::new(app, profiles_path).build()?) +} + +pub fn get_store( + app: &AppHandle, + profile_name: Option, +) -> anyhow::Result>> { + let base_dir = get_base_dir(app, None)?; + let profiles_path = base_dir.join("profiles.bin"); + + // Try to load profiles configuration, fallback to default if file doesn't exist + let profile = if profiles_path.exists() { + let profiles_store = StoreBuilder::new(app, profiles_path.clone()).build()?; + match profile_name { + Some(name) => name, + None => profiles_store + .get("activeProfile") + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| "default".to_string()), + } + } else { + "default".to_string() + }; + + info!("Using settings profile: {}", profile); + + // Determine store file path based on profile + let store_path = if profile == "default" { + base_dir.join("store.bin") + } else { + base_dir.join(format!("store-{}.bin", profile)) + }; + + // Build and return the store wrapped in Arc + Ok(StoreBuilder::new(app, store_path) + .build() + .map_err(|e| anyhow::anyhow!(e))?) +} + +pub async fn get_profiles_config(app: &AppHandle) -> anyhow::Result { + let base_dir = get_base_dir(app, None)?; + let profiles_path = base_dir.join("profiles.bin"); + let profiles_store = StoreBuilder::new(app, profiles_path).build()?; + + Ok(ProfilesConfig { + active_profile: profiles_store + .get("activeProfile") + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| "default".to_string()), + profiles: profiles_store + .get("profiles") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_else(|| vec!["default".to_string()]), + }) +} diff --git a/screenpipe-app-tauri/src-tauri/ui_monitor-aarch64-apple-darwin b/screenpipe-app-tauri/src-tauri/ui_monitor-aarch64-apple-darwin index 54cd25ab8..6f4f0ba86 100755 Binary files a/screenpipe-app-tauri/src-tauri/ui_monitor-aarch64-apple-darwin and b/screenpipe-app-tauri/src-tauri/ui_monitor-aarch64-apple-darwin differ diff --git a/screenpipe-app-tauri/src-tauri/ui_monitor-x86_64-apple-darwin b/screenpipe-app-tauri/src-tauri/ui_monitor-x86_64-apple-darwin index 15b1fdab4..7d925294e 100755 Binary files a/screenpipe-app-tauri/src-tauri/ui_monitor-x86_64-apple-darwin and b/screenpipe-app-tauri/src-tauri/ui_monitor-x86_64-apple-darwin differ diff --git a/screenpipe-js/node.ts b/screenpipe-js/node.ts index 008fbb5e5..2c05c7ac6 100644 --- a/screenpipe-js/node.ts +++ b/screenpipe-js/node.ts @@ -106,28 +106,43 @@ class SettingsManager { const platform = process.platform; const home = os.homedir(); + // Get base screenpipe data directory path based on platform + let baseDir: string; switch (platform) { case "darwin": - return path.join( - home, - "Library", - "Application Support", - "screenpipe", - "store.bin" - ); + baseDir = path.join(home, "Library", "Application Support", "screenpipe"); + break; case "linux": - const xdgData = - process.env.XDG_DATA_HOME || path.join(home, ".local", "share"); - return path.join(xdgData, "screenpipe", "store.bin"); + const xdgData = process.env.XDG_DATA_HOME || path.join(home, ".local", "share"); + baseDir = path.join(xdgData, "screenpipe"); + break; case "win32": - return path.join( + baseDir = path.join( process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), - "screenpipe", - "store.bin" + "screenpipe" ); + break; default: throw new Error(`unsupported platform: ${platform}`); } + + // First check profiles.bin to get active profile + const profilesPath = path.join(baseDir, "profiles.bin"); + let activeProfile = "default"; + try { + const profilesData = await fs.readFile(profilesPath); + const profiles = JSON.parse(profilesData.toString()); + if (profiles.activeProfile) { + activeProfile = profiles.activeProfile; + } + } catch (error) { + // Profiles file doesn't exist yet, use default + } + + // Return store path for active profile + return activeProfile === "default" + ? path.join(baseDir, "store.bin") + : path.join(baseDir, `store-${activeProfile}.bin`); } async init(): Promise {