diff --git a/content/developer-account.png b/content/developer-account.png new file mode 100644 index 0000000000..733b18b4e4 Binary files /dev/null and b/content/developer-account.png differ diff --git a/content/docs/pages/docs/plugins.mdx b/content/docs/pages/docs/plugins.mdx index 8e565fa0a1..3df04272bb 100644 --- a/content/docs/pages/docs/plugins.mdx +++ b/content/docs/pages/docs/plugins.mdx @@ -72,6 +72,37 @@ follow installation instructions & test your pipe locally bun dev ``` +### developer CLI + +for developers wanting to publish pipes to the store, we provide a dedicated CLI tool: + +![developer account](https://raw.githubusercontent.com/mediar-ai/screenpipe/main/content/developer-account.png) + +```bash copy +npm install -g @screenpipe/dev +``` + +prerequisite: connect your Stripe account in settings/account to obtain your developer API key. + +available commands: + +```bash copy +# authenticate with your API key +screenpipe login --apiKey + +# create a new pipe +screenpipe create --name my-pipe [--paid --price 9.99] + +# publish your pipe to the store +screenpipe publish --name my-pipe + +# list all versions of your pipe +screenpipe list-versions --name my-pipe + +# end current session +screenpipe logout +``` + you can deploy your pipe to your screenpipe app through the UI or using `screenpipe install ` and `screenpipe enable `. when you're ready to deploy, send a PR to the [screenpipe repo](https://github.com/mediar-ai/screenpipe) to add your pipe to the store. diff --git a/screenpipe-app-tauri/app/page.tsx b/screenpipe-app-tauri/app/page.tsx index b69892a540..01b913da5c 100644 --- a/screenpipe-app-tauri/app/page.tsx +++ b/screenpipe-app-tauri/app/page.tsx @@ -13,16 +13,17 @@ import { ChangelogDialog } from "@/components/changelog-dialog"; import { BreakingChangesInstructionsDialog } from "@/components/breaking-changes-instructions-dialog"; import { platform } from "@tauri-apps/plugin-os"; -import PipeStore from "@/components/pipe-store"; +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"; import { PipeApi } from "@/lib/api"; import localforage from "localforage"; +import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; export default function Home() { - const { settings } = useSettings(); + const { settings, updateSettings } = useSettings(); const { setActiveProfile } = useProfiles(); const posthog = usePostHog(); const { toast } = useToast(); @@ -36,6 +37,31 @@ export default function Home() { return devices; }; + const setupDeepLink = async () => { + const unsubscribeDeepLink = await onOpenUrl(async (urls) => { + console.log("received deep link urls:", urls); + for (const url of urls) { + if (url.includes("api_key=")) { + const apiKey = new URL(url).searchParams.get("api_key"); + if (apiKey) { + updateSettings({ user: { token: apiKey } }); + toast({ + title: "logged in!", + description: "your api key has been set", + }); + } + } + } + }); + return unsubscribeDeepLink; + }; + + let deepLinkUnsubscribe: (() => void) | undefined; + + setupDeepLink().then((unsubscribe) => { + deepLinkUnsubscribe = unsubscribe; + }); + const unlisten = Promise.all([ listen("shortcut-start-recording", async () => { await invoke("spawn_screenpipe"); @@ -142,6 +168,7 @@ export default function Home() { unlisten.then((listeners) => { listeners.forEach((unlistenFn) => unlistenFn()); }); + if (deepLinkUnsubscribe) deepLinkUnsubscribe(); }; }, []); diff --git a/screenpipe-app-tauri/components/login-dialog.tsx b/screenpipe-app-tauri/components/login-dialog.tsx new file mode 100644 index 0000000000..bac3e9a459 --- /dev/null +++ b/screenpipe-app-tauri/components/login-dialog.tsx @@ -0,0 +1,56 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { ExternalLinkIcon } from 'lucide-react'; +import { open as openUrl } from '@tauri-apps/plugin-shell'; +import { useState } from 'react'; + +interface LoginDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const LoginDialog: React.FC = ({ open, onOpenChange }) => { + return ( + + + + login required + + please login to continue. you will be redirected to screenpi.pe + + +
+ +
+
+
+ ); +}; + +export const useLoginCheck = () => { + const [showLoginDialog, setShowLoginDialog] = useState(false); + + const checkLogin = (user: any | null) => { + if (!user?.token) { + setShowLoginDialog(true); + return false; + } + return true; + }; + + return { showLoginDialog, setShowLoginDialog, checkLogin }; +}; \ No newline at end of file diff --git a/screenpipe-app-tauri/components/onboarding/login.tsx b/screenpipe-app-tauri/components/onboarding/login.tsx index bea305ae18..a2730f3ad9 100644 --- a/screenpipe-app-tauri/components/onboarding/login.tsx +++ b/screenpipe-app-tauri/components/onboarding/login.tsx @@ -6,7 +6,6 @@ import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { useSettings } from "@/lib/hooks/use-settings"; -import { useUser } from "@/lib/hooks/use-user"; import { toast } from "@/components/ui/use-toast"; import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; import OnboardingNavigation from "./navigation"; @@ -22,7 +21,6 @@ const OnboardingLogin: React.FC = ({ handlePrevSlide, handleNextSlide, }) => { - const { user, loadUser } = useUser(); const { settings, updateSettings } = useSettings(); useEffect(() => { @@ -68,7 +66,7 @@ const OnboardingLogin: React.FC = ({

credits & usage

- {user?.credits?.amount || 0} available + {settings.user?.credits?.amount || 0} available @@ -91,7 +89,6 @@ const OnboardingLogin: React.FC = ({ variant="secondary" size="sm" onClick={async () => { - await loadUser(settings.user?.token || ""); toast({ title: "key verified" }); handleNextSlide(); }} diff --git a/screenpipe-app-tauri/components/pipe-config-form.tsx b/screenpipe-app-tauri/components/pipe-config-form.tsx index 8e896c4e7f..d4a9522c50 100644 --- a/screenpipe-app-tauri/components/pipe-config-form.tsx +++ b/screenpipe-app-tauri/components/pipe-config-form.tsx @@ -12,7 +12,7 @@ import { } from "./ui/tooltip"; import { Layers, Layout, RefreshCw } from "lucide-react"; import { toast } from "./ui/use-toast"; -import { Pipe } from "./pipe-store"; +import { InstalledPipe, PipeWithStatus } from "./pipe-store/types"; import { SqlAutocompleteInput } from "./sql-autocomplete-input"; import { Select, @@ -30,7 +30,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import { FolderOpen } from "lucide-react"; type PipeConfigFormProps = { - pipe: Pipe; + pipe: PipeWithStatus; onConfigSave: (config: Record) => void; }; @@ -46,20 +46,23 @@ export const PipeConfigForm: React.FC = ({ pipe, onConfigSave, }) => { - const [config, setConfig] = useState(pipe.config); + const [config, setConfig] = useState(pipe.installed_config); useEffect(() => { - setConfig(pipe.config); + setConfig(pipe.installed_config); }, [pipe]); const handleInputChange = (name: string, value: any) => { if (!config) return; - setConfig((prevConfig) => ({ - ...prevConfig, - fields: prevConfig?.fields?.map((field: FieldConfig) => - field.name === name ? { ...field, value } : field - ), - })); + setConfig((prevConfig) => { + if (!prevConfig) return prevConfig; + return { + ...prevConfig, + fields: prevConfig.fields?.map((field: FieldConfig) => + field.name === name ? { ...field, value } : field + ), + }; + }); }; const renderConfigInput = (field: FieldConfig) => { diff --git a/screenpipe-app-tauri/components/pipe-store-markdown.tsx b/screenpipe-app-tauri/components/pipe-store-markdown.tsx index 308cd43022..2b04fa3966 100644 --- a/screenpipe-app-tauri/components/pipe-store-markdown.tsx +++ b/screenpipe-app-tauri/components/pipe-store-markdown.tsx @@ -6,6 +6,7 @@ import remarkMath from "remark-math"; import { useCopyToClipboard } from "@/lib/hooks/use-copy-to-clipboard"; import { Button } from "@/components/ui/button"; import { Copy, Check } from "lucide-react"; +import { convertHtmlToMarkdown } from "@/lib/utils"; interface MarkdownProps { content: string; @@ -19,6 +20,7 @@ export function PipeStoreMarkdown({ variant = "default", }: MarkdownProps) { const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }); + const processedContent = convertHtmlToMarkdown(content); return (
@@ -97,9 +99,18 @@ export function PipeStoreMarkdown({ ); }, + img({ node, ...props }) { + return ( + + ); + }, }} > - {content.replace(/Â/g, "")} + {processedContent.replace(/Â/g, "")}
); diff --git a/screenpipe-app-tauri/components/pipe-store.tsx b/screenpipe-app-tauri/components/pipe-store.tsx index 2e6351acda..cfcb000e2c 100644 --- a/screenpipe-app-tauri/components/pipe-store.tsx +++ b/screenpipe-app-tauri/components/pipe-store.tsx @@ -1,467 +1,177 @@ import React, { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { toast } from "./ui/use-toast"; -import { Input } from "./ui/input"; -import { Switch } from "./ui/switch"; -import { - Download, - Plus, - Trash2, - ExternalLink, - FolderOpen, - RefreshCw, - Search, - Power, - Puzzle, - X, - Loader2, - BanknoteIcon, -} from "lucide-react"; -import { PipeConfigForm } from "./pipe-config-form"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Loader2, Power, Search, Trash2 } from "lucide-react"; +import { toast } from "@/components/ui/use-toast"; import { useHealthCheck } from "@/lib/hooks/use-health-check"; -import posthog from "posthog-js"; -import { open } from "@tauri-apps/plugin-dialog"; -import { Command, open as openUrl } from "@tauri-apps/plugin-shell"; +import { Command } from "@tauri-apps/plugin-shell"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { readFile } from "@tauri-apps/plugin-fs"; -import { join } from "@tauri-apps/api/path"; -import { convertHtmlToMarkdown } from "@/lib/utils"; -import { LogFileButton } from "./log-file-button"; + PipeApi, + PipeDownloadError, + PurchaseHistoryItem, +} from "@/lib/api/store"; +import { open as openUrl } from "@tauri-apps/plugin-shell"; +import { BrokenPipe, InstalledPipe, PipeWithStatus } from "./pipe-store/types"; +import { PipeDetails } from "./pipe-store/pipe-details"; +import { PipeCard } from "./pipe-store/pipe-card"; +import { AddPipeForm } from "./pipe-store/add-pipe-form"; import { useSettings } from "@/lib/hooks/use-settings"; -import { useUser } from "@/lib/hooks/use-user"; -import { PipeStoreMarkdown } from "@/components/pipe-store-markdown"; -import { PublishDialog } from "./publish-dialog"; -import { invoke } from "@tauri-apps/api/core"; -import { Progress } from "@/components/ui/progress"; -import supabase from "@/lib/supabase/client"; -import { CreditPurchaseDialog } from "./store/credit-purchase-dialog"; -import localforage from "localforage"; +import posthog from "posthog-js"; +import { Progress } from "./ui/progress"; +import { open } from "@tauri-apps/plugin-dialog"; +import { LoginDialog, useLoginCheck } from "./login-dialog"; +import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; import { useStatusDialog } from "@/lib/hooks/use-status-dialog"; -export interface Pipe { - enabled: boolean; - id: string; - source: string; - fullDescription: string; - config?: Record; - author?: string; - port?: number; -} - -interface CorePipe { - id: string; - name: string; - description: string; - url: string; - credits: number; - paid: boolean; -} - -interface RunningPipe { - id: string; - port: number; - isRunning: boolean; -} - -const BROKEN_PIPES_KEY = "broken_pipes"; -interface BrokenPipe { - id: string; - lastAttempt: number; -} - -const fetchReadmeFromGithub = async (url: string): Promise => { - try { - // Convert github.com URL to raw.githubusercontent.com - const rawUrl = url - .replace("github.com", "raw.githubusercontent.com") - .replace("/tree/main", "/main"); - - const response = await fetch(`${rawUrl}/README.md`); - if (!response.ok) return "No description available."; - const text = await response.text(); - return convertHtmlToMarkdown(text); - } catch (error) { - console.error("failed to fetch readme:", error); - return "No description available."; - } -}; - -const corePipes: (CorePipe & { fullDescription?: string })[] = [ - { - id: "auto-pay", - name: "auto pay", - description: - "automatically trigger bank transfers based on screen activity. monitors your screen for payment-related information and initiates transfers through the Mercury API", - url: "https://github.com/different-ai/hypr-v0/tree/main/pipes/auto-pay", - credits: 15, - paid: true, - }, - { - id: "linkedin-ai-assistant", - name: "linkedin ai assistant", - description: "AI agent that automatically get new connections on linkedin", - url: "https://github.com/mediar-ai/screenpipe/tree/main/pipes/linkedin-ai-assistant", - credits: 20, - paid: true, - }, - { - id: "memories", - name: "memories gallery", - description: - "google-photo like gallery of your screen recordings memories, with AI-powered insights and timeline visualization", - url: "https://github.com/mediar-ai/screenpipe/tree/main/pipes/memories", - credits: 0, - paid: false, - }, - { - id: "data-table", - name: "data table", - description: - "explore your data in a powerful table view with filtering, sorting, and more", - url: "https://github.com/mediar-ai/screenpipe/tree/main/pipes/data-table", - credits: 0, - paid: false, - }, - { - id: "search", - name: "search", - description: - "search through your screen recordings and audio transcripts with AI", - url: "https://github.com/mediar-ai/screenpipe/tree/main/pipes/search", - credits: 0, - paid: false, - }, - { - id: "rewind", - name: "rewind", - description: - "rewind-like interface meet cursor-like AI chat interface", - url: "https://github.com/mediar-ai/screenpipe/tree/main/pipes/rewind", - credits: 20, - paid: true, - }, - { - id: "identify-speakers", - name: "speaker identification", - description: - "automatically identify and label different speakers in your recordings using AI voice recognition", - url: "https://github.com/mediar-ai/screenpipe/tree/main/pipes/identify-speakers", - credits: 0, - paid: false, - }, - { - id: "obsidian", - name: "obsidian v2", - description: - "write logs of your day in obsidian with local AI features, customization, and user friendly UI", - url: "https://github.com/mediar-ai/screenpipe/tree/main/pipes/obsidian", - credits: 20, - paid: true, - }, - { - id: "meeting", - name: "meeting assistant", - description: - "organize and summarize your meetings with AI - get transcripts, action items, and key insights, 100% local or using cloud models", - url: "https://github.com/mediar-ai/screenpipe/tree/main/pipes/meeting", - credits: 15, - paid: true, - }, - { - id: "pipe-for-loom", - name: "loom generator", - description: "generate looms from your screenpipe data", - url: "https://github.com/mediar-ai/screenpipe/tree/main/pipes/pipe-for-loom", - credits: 10, - paid: true, - }, - { - id: "pipe-simple-nextjs", - name: "keyword analytics", - description: "show most used keywords", - url: "https://github.com/mediar-ai/screenpipe/tree/main/pipes/pipe-simple-nextjs", - credits: 0, - paid: false, - }, - { - id: "reddit-auto-posts", - name: "reddit auto posts", - description: - "promote your content, grow your audience, or learn new things with this automated reddit post generator based on your screen recordings", - url: "https://github.com/mediar-ai/screenpipe/tree/main/pipes/reddit-auto-posts", - credits: 20, - paid: true, - }, -]; - -const getAuthorFromSource = (source: string): string => { - if (!source) return "Unknown"; - if (!source.startsWith("http")) return "Local"; - - try { - // Extract author from GitHub URL - // Format: https://github.com/author/repo/... - const match = source.match(/github\.com\/([^\/]+)/); - return match ? match[1] : "Unknown"; - } catch { - return "Unknown"; - } -}; - -const truncateDescription = (description: string, maxLines: number = 4) => { - if (!description) return ""; - const cleaned = description.replace(/Â/g, "").trim(); - - // Split into lines and track codeblock state - const lines = cleaned.split(/\r?\n/); - let inCodeBlock = false; - let visibleLines: string[] = []; - let lineCount = 0; - - for (const line of lines) { - // Check for codeblock markers - if (line.trim().startsWith("```")) { - inCodeBlock = !inCodeBlock; - visibleLines.push(line); - continue; - } - - // If we're in a codeblock, include the line - if (inCodeBlock) { - visibleLines.push(line); - continue; - } - - // For non-codeblock content, count lines normally - if (lineCount < maxLines) { - visibleLines.push(line); - if (line.trim()) lineCount++; - } - } - - // If we ended inside a codeblock, close it - if (inCodeBlock) { - visibleLines.push("```"); - } - - const result = visibleLines.join("\n"); - return lineCount >= maxLines ? result + "..." : result; -}; - -const getFriendlyName = (id: string, corePipes: CorePipe[]): string => { - const corePipe = corePipes.find((cp) => cp.id === id); - if (corePipe) return corePipe.name; - - // Convert pipe-name-format to Title Case if no match found - return id - .replace("pipe-", "") - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); -}; - -const normalizeId = (id: string): string => { - // Remove 'pipe-' prefix if it exists and convert to lowercase - return id.replace(/^pipe-/, "").toLowerCase(); -}; - -const DEFAULT_PIPES = [ +const corePipes: string[] = [ + "auto-pay", + "linkedin-ai-assistant", "memories", "data-table", "search", "timeline", "identify-speakers", + "obsidian", + "meeting", + "pipe-for-loom", + "pipe-simple-nextjs", + "reddit-auto-posts", ]; -const PipeStore: React.FC = () => { - const [newRepoUrl, setNewRepoUrl] = useState(""); - const [selectedPipe, setSelectedPipe] = useState(null); - const [pipes, setPipes] = useState([]); +export const PipeStore: React.FC = () => { + const { health } = useHealthCheck(); + const [selectedPipe, setSelectedPipe] = useState(null); + const { settings } = useSettings(); + const [pipes, setPipes] = useState([]); + const [installedPipes, setInstalledPipes] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [showInstalledOnly, setShowInstalledOnly] = useState(false); - const { health } = useHealthCheck(); - const { getDataDir } = useSettings(); - const { user, refreshUser } = useUser(); - const [showCreditDialog, setShowCreditDialog] = useState(false); - const [isEnabling, setIsEnabling] = useState(false); - const [runningPipes, setRunningPipes] = useState>({}); - const [startupAttempts, setStartupAttempts] = useState< - Record - >({}); - const [brokenPipes, setBrokenPipes] = useState([]); - const MAX_STARTUP_ATTEMPTS = 20; // Will give up after ~20 seconds (20 attempts * 2 second interval) - const [coreReadmes, setCoreReadmes] = useState>({}); + const [purchaseHistory, setPurchaseHistory] = useState( + [] + ); + const { showLoginDialog, setShowLoginDialog, checkLogin } = useLoginCheck(); const { open: openStatusDialog } = useStatusDialog(); - useEffect(() => { - fetchInstalledPipes(); - }, [health?.status]); - - useEffect(() => { - localforage.getItem(BROKEN_PIPES_KEY).then((stored) => { - if (stored) setBrokenPipes(stored); - }); - }, []); - - useEffect(() => { - const loadReadmes = async () => { - const readmes: Record = {}; - - await Promise.all( - corePipes.map(async (pipe) => { - readmes[pipe.id] = await fetchReadmeFromGithub(pipe.url); - }) - ); - - setCoreReadmes(readmes); - }; - - loadReadmes(); - }, []); // Run once on component mount - - // Add this new effect to install default pipes - useEffect(() => { - const installDefaultPipes = async () => { - if (!health || health?.status === "error") return; - - // Get currently installed pipes - const response = await fetch("http://localhost:3030/pipes/list"); - const data = await response.json(); - const installedPipeIds = data.data.map((p: Pipe) => p.id); - - // Find which default pipes need to be installed - const pipesToInstall = DEFAULT_PIPES.filter( - (id) => !installedPipeIds.includes(id) - ); - - if (pipesToInstall.length === 0) return; - - // Create initial toast - const t = toast({ - title: "installing core pipes", - description: "setting up your workspace...", - duration: 100000, - }); - - // Install each missing pipe - for (const pipeId of pipesToInstall) { - const pipe = corePipes.find((p) => p.id === pipeId); - if (pipe?.url) { - try { - await handleDownloadPipe(pipe.url, pipe.id); - } catch (error) { - console.error(`Failed to install ${pipeId}:`, error); - } - } - } - - t.update({ - id: t.id, - title: "core pipes installed", - description: "your workspace is ready!", - duration: 2000, - }); - - await fetchInstalledPipes(); - }; - - installDefaultPipes(); - }, [health?.status]); + const filteredPipes = pipes + .filter( + (pipe) => + pipe.id.toLowerCase().includes(searchQuery.toLowerCase()) && + (!showInstalledOnly || pipe.is_installed) + ) + .sort((a, b) => Number(b.is_paid) - Number(a.is_paid)); - const handleResetAllPipes = async () => { + const fetchStorePlugins = async () => { try { - toast({ - title: "resetting pipes", - description: "this will delete all your pipes.", - }); - const cmd = Command.sidecar("screenpipe", ["pipe", "purge", "-y"]); - await cmd.execute(); - await new Promise((resolve) => setTimeout(resolve, 1000)); - toast({ - title: "all pipes deleted", - description: "the pipes folder has been reset.", - }); - // Refresh the pipe list and installed pipes - await fetchInstalledPipes(); + const pipeApi = await PipeApi.create(settings.user?.token!); + const plugins = await pipeApi.listStorePlugins(); + + // Create PipeWithStatus objects for store plugins + const storePluginsWithStatus = plugins.map((plugin) => ({ + ...plugin, + is_installed: installedPipes.some((p) => p.config?.id === plugin.id), + installed_config: installedPipes.find((p) => p.config?.id === plugin.id) + ?.config, + has_purchased: purchaseHistory.some((p) => p.plugins.id === plugin.id), + is_core_pipe: corePipes.includes(plugin.name), + })); + + const customPipes = installedPipes + .filter((p) => !plugins.some((plugin) => plugin.id === p.config?.id)) + .map((p) => { + console.log(p.config); + + const pluginName = p.config?.source?.split("/").pop(); + return { + id: p.config?.id || "", + name: pluginName || "", + description: "", + version: p.config?.version || "0.0.0", + is_paid: false, + price: 0, + status: "active", + created_at: new Date().toISOString(), + developer_accounts: { developer_name: "You" }, + plugin_analytics: { downloads_count: 0 }, + is_installed: true, + installed_config: p.config, + has_purchased: true, + is_core_pipe: false, + }; + }); + + setPipes([...storePluginsWithStatus, ...customPipes]); } catch (error) { - console.error("failed to reset pipes:", error); + console.error("Failed to fetch store plugins:", error); toast({ - title: "error resetting pipes", - description: "please try again or check the logs for more information.", + title: "error loading store", + description: "failed to fetch available pipes", variant: "destructive", }); - } finally { - setPipes([]); } }; - const fetchInstalledPipes = async () => { - if (!health || health?.status === "error") return; + const fetchPurchaseHistory = async () => { + if (!settings.user?.token) return; + const pipeApi = await PipeApi.create(settings.user!.token!); + const purchaseHistory = await pipeApi.getUserPurchaseHistory(); + setPurchaseHistory(purchaseHistory); + }; - const dataDir = await getDataDir(); - try { - const response = await fetch("http://localhost:3030/pipes/list"); - const data = await response.json(); + // TODO: replace with actual IDs once published on the new store + const installDefaultPipes = async () => { + const DEFAULT_PIPES = [ + "memories", + "data-table", + "search", + "timeline", + "identify-speakers", + ]; + }; - if (!data.success) throw new Error("Failed to fetch installed pipes"); + const handlePurchasePipe = async ( + pipe: PipeWithStatus, + onComplete?: () => void + ) => { + try { + if (!checkLogin(settings.user)) return; - const pipes = data.data; - for (const pipe of pipes) { - const pathToReadme = await join(dataDir, "pipes", pipe.id, "README.md"); - try { - const readme = await readFile(pathToReadme); - pipe.fullDescription = convertHtmlToMarkdown( - new TextDecoder().decode(readme) - ); - } catch (error) { - pipe.fullDescription = "no description available for this pipe."; - } - } - console.log("pipes", pipes); - setPipes(pipes); - return pipes; + const pipeApi = await PipeApi.create(settings.user!.token!); + const response = await pipeApi.purchasePipe(pipe.id); + openUrl(response.data.checkout_url); + onComplete?.(); } catch (error) { - console.error("Error fetching installed pipes:", error); + console.error("error purchasing pipe:", error); toast({ - title: "error fetching installed pipes", - description: "please try again or check the logs for more information.", + title: "error purchasing pipe", + description: "please try again or check the logs", variant: "destructive", }); } }; - const handleDownloadPipe = async (url: string, id: string) => { + const handleInstallSideload = async (url: string) => { + posthog.capture("add_own_pipe", { + newRepoUrl: url, + }); try { - posthog.capture("download_pipe", { - pipe_id: url, - }); - - // Create initial toast with progress bar const t = toast({ - title: "downloading pipe", + title: "adding custom pipe", description: (
-

starting download...

+

starting installation...

), - duration: 100000, // long duration + duration: 100000, }); - let value = 0; - // Update progress periodically const progressInterval = setInterval(() => { value += 3; t.update({ id: t.id, - title: "downloading pipe", + title: "adding custom pipe", description: (
@@ -477,8 +187,9 @@ const PipeStore: React.FC = () => { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ url }), + body: JSON.stringify({ url: url }), }); + const data = await response.json(); clearInterval(progressInterval); @@ -489,7 +200,7 @@ const PipeStore: React.FC = () => { t.update({ id: t.id, - title: "pipe downloaded", + title: "pipe added", description: (
@@ -499,135 +210,153 @@ const PipeStore: React.FC = () => { duration: 2000, }); - // enable now - await fetch("http://localhost:3030/pipes/enable", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ pipe_id: id }), - }); - await fetchInstalledPipes(); - - const freshPipe = pipes.find( - (p) => normalizeId(p.id) === normalizeId(url) - ); - if (freshPipe) { - setSelectedPipe(freshPipe); - } t.dismiss(); } catch (error) { - console.error("Failed to download pipe:", error); + console.error("failed to add custom pipe:", error); toast({ - title: "error downloading pipe", - description: "please try again or check the logs for more information.", + title: "error adding custom pipe", + description: "please check the url and try again.", variant: "destructive", }); } }; - const checkExistingSubscription = async (pipeId: string) => { + const handleInstallPipe = async ( + pipe: PipeWithStatus, + onComplete?: () => void + ) => { try { - const { data, error } = await supabase - .from("subscriptions") - .select("*") - .eq("pipe_id", pipeId) - .eq("user_id", user?.id) - .single(); - - if (error) throw error; - return !!data; // returns true if subscription exists - } catch (error) { - console.error("failed to check subscription:", error); - return false; - } - }; + if (!checkLogin(settings.user)) return; - const handleToggleEnabled = async (pipe: Pipe) => { - try { - // Reset broken state when manually toggling - await updateBrokenPipes(pipe.id, false); - setStartupAttempts((prev) => { - const next = { ...prev }; - delete next[pipe.id]; - return next; + const t = toast({ + title: "downloading pipe", + description: ( +
+ +

downloading from server...

+
+ ), + duration: 100000, }); - // Set loading state when enabling - if (!pipe.enabled) { - setIsEnabling(true); - } + const pipeApi = await PipeApi.create(settings.user!.token!); + const response = await pipeApi.downloadPipe(pipe.id); - const corePipe = corePipes.find((cp) => cp.id === pipe.id); - console.log("attempting to toggle pipe:", { - pipeId: pipe.id, - isEnabled: pipe.enabled, - corePipe, - userToken: !!user?.token, - userCredits: user?.credits?.amount, + t.update({ + id: t.id, + title: "installing pipe", + description: ( +
+ +

installing dependencies...

+
+ ), + duration: 100000, }); - if (corePipe?.paid && !pipe.enabled) { - console.log("handling paid pipe enable flow"); - - if (!user?.token) { - console.log("user not authenticated, opening auth window"); - toast({ - title: "authentication required", - description: "please sign in in settings to use paid pipes", - variant: "destructive", - }); - return; + const downloadResponse = await fetch( + "http://localhost:3030/pipes/download-private", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + pipe_name: pipe.name, + pipe_id: pipe.id, + url: response.download_url, + }), } + ); + + const data = await downloadResponse.json(); + if (!data.success) { + throw new Error(data.error || "Failed to download pipe"); + } - const hasSubscription = await checkExistingSubscription(pipe.id); + await fetchInstalledPipes(); + + t.update({ + id: t.id, + title: "pipe installed", + description: ( +
+ +

completed successfully

+
+ ), + duration: 2000, + }); - console.log("subscription check:", { - hasSubscription, - pipeId: pipe.id, + onComplete?.(); + } catch (error) { + if ((error as Error).cause === PipeDownloadError.PURCHASE_REQUIRED) { + return toast({ + title: "paid pipe", + description: + "this pipe requires purchase. please visit screenpi.pe to buy credits.", + variant: "destructive", }); + } + toast({ + title: "error installing pipe", + description: (error as Error).message, + variant: "destructive", + }); + } + }; - if (!hasSubscription) { - const userCredits = user.credits?.amount || 0; - console.log("checking credits:", { - userCredits, - requiredCredits: corePipe.credits, - sufficient: userCredits >= corePipe.credits, - }); - - if (userCredits < corePipe.credits) { - console.log("insufficient credits, showing dialog"); - setShowCreditDialog(true); - return; - } + const fetchInstalledPipes = async () => { + if (!health || health?.status === "error") return; + try { + const response = await fetch("http://localhost:3030/pipes/list"); + const data = (await response.json()) as { + data: InstalledPipe[]; + success: boolean; + }; - console.log("attempting pipe purchase"); - const purchaseSuccess = await handlePipePurchase( - pipe, - corePipe.credits - ); - console.log("purchase result:", { purchaseSuccess }); + if (!data.success) throw new Error("Failed to fetch installed pipes"); - if (!purchaseSuccess) { - toast({ - title: "purchase failed", - description: "something went wrong, please try again", - variant: "destructive", - }); - return; - } + setInstalledPipes(data.data); + return data.data; + } catch (error) { + console.error("Error fetching installed pipes:", error); + toast({ + title: "error fetching installed pipes", + description: "please try again or check the logs", + variant: "destructive", + }); + } + }; - await refreshUser(); - console.log("user refreshed after purchase:", { - newCredits: user?.credits?.amount, - }); - } - } + const handleResetAllPipes = async () => { + try { + const cmd = Command.sidecar("screenpipe", ["pipe", "purge", "-y"]); + await cmd.execute(); + await fetchInstalledPipes(); + toast({ + title: "all pipes deleted", + description: "the pipes folder has been reset.", + }); + } catch (error) { + console.error("failed to reset pipes:", error); + toast({ + title: "error resetting pipes", + description: "please try again or check the logs", + variant: "destructive", + }); + } + }; + const handleTogglePipe = async ( + pipe: PipeWithStatus, + onComplete: () => void + ) => { + try { posthog.capture("toggle_pipe", { pipe_id: pipe.id, - enabled: !pipe.enabled, + enabled: !pipe.installed_config?.enabled, }); const t = toast({ @@ -641,40 +370,39 @@ const PipeStore: React.FC = () => { duration: 4000, }); - const endpoint = pipe.enabled ? "disable" : "enable"; - console.log(`calling ${endpoint} endpoint for pipe`); + const endpoint = pipe.installed_config?.enabled ? "disable" : "enable"; const response = await fetch(`http://localhost:3030/pipes/${endpoint}`, { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ pipe_id: pipe.id }), + body: JSON.stringify({ pipe_id: pipe.name }), }); const data = await response.json(); - console.log("toggle response:", data); if (!data.success) { throw new Error(data.error); } - // Wait for pipe to initialize - await new Promise((resolve) => setTimeout(resolve, 3000)); - const freshPipes = await fetchInstalledPipes(); - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const freshPipe = freshPipes.find((p: Pipe) => p.id === pipe.id); - if (freshPipe) { - setSelectedPipe(freshPipe); - } - toast({ title: `pipe ${endpoint}d`, }); + setSelectedPipe((prev) => { + if (!prev) return prev; + return { + ...prev, + installed_config: { + ...prev.installed_config!, + enabled: !pipe.installed_config?.enabled, + }, + }; + }); + onComplete(); } catch (error) { console.error( - `Failed to ${pipe.enabled ? "disable" : "enable"} pipe:`, + `Failed to ${pipe.installed_config?.enabled ? "disable" : "enable"} pipe:`, error ); toast({ @@ -682,105 +410,12 @@ const PipeStore: React.FC = () => { description: "please try again or check the logs for more information.", variant: "destructive", }); - } finally { - // Reset loading state - setIsEnabling(false); - } - }; - - const reloadPipeConfig = async (pipe: Pipe) => { - await fetchInstalledPipes(); - - const freshPipe = pipes.find( - (p) => normalizeId(p.id) === normalizeId(pipe.id) - ); - if (freshPipe) { - console.log("freshPipe", freshPipe); - - setSelectedPipe(freshPipe); } }; - const handleAddOwnPipe = async () => { - if (newRepoUrl) { - try { - posthog.capture("add_own_pipe", { - newRepoUrl, - }); - - // Create initial toast with progress bar - const t = toast({ - title: "adding custom pipe", - description: ( -
- -

starting installation...

-
- ), - duration: 100000, // long duration - }); - - let value = 0; - - // Update progress periodically - const progressInterval = setInterval(() => { - value += 3; - t.update({ - id: t.id, - title: "adding custom pipe", - description: ( -
- -

installing dependencies...

-
- ), - duration: 100000, - }); - }, 500); - - const response = await fetch("http://localhost:3030/pipes/download", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ url: newRepoUrl }), - }); - - const data = await response.json(); - - clearInterval(progressInterval); - - if (!data.success) { - throw new Error(data.error || "Failed to download pipe"); - } - - t.update({ - id: t.id, - title: "pipe added", - description: ( -
- -

completed successfully

-
- ), - duration: 2000, - }); - - await fetchInstalledPipes(); - setNewRepoUrl(""); - t.dismiss(); - } catch (error) { - console.error("failed to add custom pipe:", error); - toast({ - title: "error adding custom pipe", - description: "please check the url and try again.", - variant: "destructive", - }); - } - } - }; - - const handleLoadFromLocalFolder = async () => { + const handleLoadFromLocalFolder = async ( + setNewRepoUrl: (url: string) => void + ) => { try { const selectedFolder = await open({ directory: true, @@ -788,6 +423,7 @@ const PipeStore: React.FC = () => { }); if (selectedFolder) { + console.log("loading from local folder", selectedFolder); // set in the bar setNewRepoUrl(selectedFolder); } @@ -800,17 +436,17 @@ const PipeStore: React.FC = () => { }); } }; + const handleConfigSave = async (config: Record) => { if (selectedPipe) { try { - setIsEnabling(true); const response = await fetch("http://localhost:3030/pipes/update", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - pipe_id: selectedPipe.id, + pipe_id: selectedPipe.name, config: config, }), }); @@ -825,8 +461,13 @@ const PipeStore: React.FC = () => { description: "The pipe configuration has been updated.", }); - await setSelectedPipe({ ...selectedPipe, config: config }); - setIsEnabling(false); + setSelectedPipe({ + ...selectedPipe, + installed_config: { + ...selectedPipe.installed_config!, + ...config, + }, + }); } catch (error) { console.error("Failed to save config:", error); toast({ @@ -835,13 +476,11 @@ const PipeStore: React.FC = () => { "please try again or check the logs for more information.", variant: "destructive", }); - setIsEnabling(false); } } }; - const handleDeletePipe = async (pipe: Pipe) => { + const handleDeletePipe = async (pipe: PipeWithStatus) => { try { - await updateBrokenPipes(pipe.id, false); posthog.capture("delete_pipe", { pipe_id: pipe.id, }); @@ -856,7 +495,7 @@ const PipeStore: React.FC = () => { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ pipe_id: pipe.id }), + body: JSON.stringify({ pipe_id: pipe.name }), }); const data = await response.json(); @@ -871,6 +510,8 @@ const PipeStore: React.FC = () => { title: "pipe deleted", description: "the pipe has been successfully removed", }); + + setSelectedPipe(null); } catch (error) { console.error("failed to delete pipe:", error); toast({ @@ -881,43 +522,64 @@ const PipeStore: React.FC = () => { } }; - const allPipes = [ - ...pipes, - ...corePipes - .filter( - (cp) => !pipes.some((p) => normalizeId(p.id) === normalizeId(cp.id)) - ) - .map((cp) => ({ - id: cp.id, - fullDescription: coreReadmes[cp.id] || cp.description, // Fallback to short description - source: cp.url, - enabled: false, - })), - ]; - - const filteredPipes = allPipes - .filter( - (pipe) => - pipe.id.toLowerCase().includes(searchQuery.toLowerCase()) && - (!showInstalledOnly || pipe.enabled) - ) - .sort((a, b) => { - const aPaid = corePipes.find((cp) => cp.id === a.id)?.paid ? 1 : 0; - const bPaid = corePipes.find((cp) => cp.id === b.id)?.paid ? 1 : 0; - return bPaid - aPaid; // Sort paid pipes first - }); + const handleRefreshFromDisk = async (pipe: PipeWithStatus) => { + try { + posthog.capture("refresh_pipe_from_disk", { + pipe_id: pipe.name, + }); + + toast({ + title: "refreshing pipe", + description: "please wait...", + }); + + const response = await fetch(`http://localhost:3030/pipes/download`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ url: pipe.installed_config?.source }), + }); + if (!response.ok) { + throw new Error("failed to refresh pipe"); + } - const handleCloseDetails = async () => { - setSelectedPipe(null); - window.location.reload(); // dirty hack + await fetchInstalledPipes(); + toast({ + title: "pipe refreshed", + description: "the pipe has been successfully refreshed from disk.", + }); + } catch (error) { + console.error("failed to refresh pipe from disk:", error); + toast({ + title: "error refreshing pipe", + description: "please try again or check the logs for more information.", + variant: "destructive", + }); + } finally { + setSelectedPipe(null); + } }; - const handleUpdatePipe = async (pipe: Pipe) => { + const handleUpdatePipe = async (pipe: PipeWithStatus) => { try { + if (!checkLogin(settings.user)) return; + posthog.capture("update_pipe", { - pipe_id: pipe.id, + pipe_id: pipe.name, }); + const currentVersion = pipe.installed_config?.version!; + const storeApi = await PipeApi.create(settings.user!.token!); + const update = await storeApi.checkUpdate(pipe.id, currentVersion); + if (!update.has_update) { + toast({ + title: "no update available", + description: "the pipe is already up to date", + }); + return; + } + // Create initial toast with progress bar const t = toast({ title: "updating pipe", @@ -934,7 +596,7 @@ const PipeStore: React.FC = () => { await handleDeletePipe(pipe); // Then download the new version - if (pipe.source) { + if (pipe.installed_config?.source) { t.update({ id: t.id, title: "updating pipe", @@ -947,7 +609,7 @@ const PipeStore: React.FC = () => { duration: 100000, }); - await handleDownloadPipe(pipe.source, pipe.id); + await handleInstallPipe(pipe); } t.update({ @@ -976,492 +638,53 @@ const PipeStore: React.FC = () => { } }; - const checkPipeRunning = async (port: number): Promise => { - try { - // Use Tauri's http client instead of fetch to avoid CORS - const response = await fetch(`http://localhost:${port}`, { - mode: "no-cors", // Add no-cors mode to avoid CORS errors - }); - return true; // If we get here, the port is responding - } catch { - return false; - } - }; - - const checkRunningPipes = async () => { - const runningStates: Record = {}; - - // First check all pipes - for (const pipe of pipes) { - if (pipe.enabled && pipe.config?.port) { - runningStates[pipe.id] = await checkPipeRunning(pipe.config.port); - } - } - - // Then update states based on results - setRunningPipes((prevRunning) => { - setStartupAttempts((prevAttempts) => { - const updatedAttempts = { ...prevAttempts }; - - for (const pipe of pipes) { - if (pipe.enabled && pipe.config?.port) { - const isRunning = runningStates[pipe.id]; - - if (!isRunning) { - updatedAttempts[pipe.id] = (prevAttempts[pipe.id] || 0) + 1; - console.log( - `Attempt ${updatedAttempts[pipe.id]} for pipe ${pipe.id}` - ); - - if (updatedAttempts[pipe.id] >= MAX_STARTUP_ATTEMPTS) { - handleDisablePipe(pipe.id); - delete updatedAttempts[pipe.id]; - updateBrokenPipes(pipe.id, true); - } - } else { - delete updatedAttempts[pipe.id]; - updateBrokenPipes(pipe.id, false); - } - } - } - return updatedAttempts; - }); - return runningStates; - }); - }; - - // Separate function to handle pipe disabling - const handleDisablePipe = async (pipeId: string) => { - try { - await fetch(`http://localhost:3030/pipes/disable`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ pipe_id: pipeId }), - }); - - toast({ - title: "pipe startup failed", - description: - "the pipe has been disabled. please check the logs for more information.", - variant: "destructive", - }); - - // unselect the pipe - setSelectedPipe(null); - - await fetchInstalledPipes(); - } catch (error) { - console.error("failed to disable pipe:", error); - } - }; - - // Add this effect to periodically check running pipes useEffect(() => { - checkRunningPipes(); - }, [pipes]); - - const updateBrokenPipes = async (pipeId: string, isBroken: boolean) => { - const updated = isBroken - ? [...brokenPipes, { id: pipeId, lastAttempt: Date.now() }] - : brokenPipes.filter((p) => p.id !== pipeId); - - setBrokenPipes(updated); - await localforage.setItem(BROKEN_PIPES_KEY, updated); - }; - - const renderPipeDetails = () => { - if (!selectedPipe) return null; - - const isPipeRunning = runningPipes[selectedPipe.id]; - - return ( -
- cp.id === selectedPipe.id)?.credits || 0 - : 0 - } - currentCredits={user?.credits?.amount || 0} - onCreditsUpdated={refreshUser} - /> -
-
- -

- {getFriendlyName(selectedPipe.id, corePipes)} -

- - by {getAuthorFromSource(selectedPipe.source)} - -
-
- -
-
-
-
-
-
- - - - - - -

- {selectedPipe.enabled ? "disable" : "enable"} pipe -

-
-
-
- - - - {selectedPipe.source?.startsWith("http") && ( - - - - - - -

update pipe

-
-
-
- )} - - {selectedPipe.source?.startsWith("http") && ( - - - - - - -

view source code

-
-
-
- )} - - {!selectedPipe.source?.startsWith("https://") && ( - - - - - - -

refresh the code from your local disk

-
-
-
- )} -
- {/* Only show delete button for non-core pipes */} - {/* {!corePipes.some((cp) => cp.id === selectedPipe.id) && ( */} - - - - - - -

delete pipe

-
-
-
- {/* )} */} -
-
- - {corePipes.find((cp) => cp.id === selectedPipe.id)?.paid && ( -
- requires{" "} - { - corePipes.find((cp) => cp.id === selectedPipe.id) - ?.credits - }{" "} - credits{" "} - {user?.credits ? `(you have ${user.credits.amount})` : ""} -
- )} -
-
- - {selectedPipe.enabled && ( -
- -
- )} -
-
+ fetchStorePlugins(); + }, [installedPipes, purchaseHistory]); -
-
- {selectedPipe.enabled && selectedPipe?.config?.port && ( -
-
-
- - -
-
-
- )} - - {selectedPipe.fullDescription && ( -
-

about this pipe

-
- -
-
- )} -
-
-
-
- ); - }; - - const handleRefreshFromDisk = async (pipe: Pipe) => { - try { - posthog.capture("refresh_pipe_from_disk", { - pipe_id: pipe.id, - }); - - toast({ - title: "refreshing pipe", - description: "please wait...", - }); - - const response = await fetch(`http://localhost:3030/pipes/download`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ url: pipe.source }), - }); - if (!response.ok) { - throw new Error("failed to refresh pipe"); - } + useEffect(() => { + fetchPurchaseHistory(); + }, [settings.user]); - await fetchInstalledPipes(); - toast({ - title: "pipe refreshed", - description: "the pipe has been successfully refreshed from disk.", - }); - } catch (error) { - console.error("failed to refresh pipe from disk:", error); - toast({ - title: "error refreshing pipe", - description: "please try again or check the logs for more information.", - variant: "destructive", - }); - } finally { - setSelectedPipe(null); - } - }; + useEffect(() => { + fetchInstalledPipes(); + }, [health]); - const handleCardClick = async (pipe: Pipe) => { - // Rest of the existing logic - const isInstalled = pipes.some((p) => p.id === pipe.id); - if (!isInstalled && pipe.source) { - try { - await handleDownloadPipe(pipe.source, pipe.id); - // Fetch the updated pipe data and wait for it - const response = await fetch("http://localhost:3030/pipes/list"); - const data = await response.json(); + useEffect(() => { + const interval = setInterval(() => { + fetchInstalledPipes(); + }, 1000); + return () => clearInterval(interval); + }, []); - if (!data.success) throw new Error("Failed to fetch installed pipes"); - - // Get the data dir and fetch README for the new pipe - const dataDir = await getDataDir(); - const updatedPipe = data.data.find((p: Pipe) => p.id === pipe.id); - - if (updatedPipe) { - const pathToReadme = await join( - dataDir, - "pipes", - pipe.id, - "README.md" - ); - try { - const readme = await readFile(pathToReadme); - updatedPipe.fullDescription = convertHtmlToMarkdown( - new TextDecoder().decode(readme) - ); - } catch (error) { - updatedPipe.fullDescription = - "no description available for this pipe."; + useEffect(() => { + const setupDeepLink = async () => { + const unsubscribeDeepLink = await onOpenUrl(async (urls) => { + console.log("received deep link urls:", urls); + for (const url of urls) { + if (url.includes("purchase-successful")) { + fetchPurchaseHistory(); + toast({ + title: "purchase successful", + description: "your purchase has been successful", + }); } - // Update pipes state and set selected pipe - setPipes(data.data); - setSelectedPipe(updatedPipe); } - } catch (error) { - console.error("Failed to download and show pipe:", error); - toast({ - title: "error showing pipe details", - description: - "please try again or check the logs for more information.", - variant: "destructive", - }); - } - } else { - const installedPipe = pipes.find((p) => p.id === pipe.id); - setSelectedPipe(installedPipe || pipe); - } - }; - - const handlePipePurchase = async (pipe: Pipe, requiredCredits: number) => { - try { - const { data, error } = await supabase.rpc("purchase_pipe", { - v_user_id: user?.id, - p_pipe_id: pipe.id, - p_credits_spent: requiredCredits, }); + return unsubscribeDeepLink; + }; - if (error) { - console.error("purchase error:", error); - toast({ - title: "purchase failed", - description: error.message, - variant: "destructive", - }); - return false; - } - - if (!data) { - toast({ - title: "purchase failed", - description: "unknown error occurred", - variant: "destructive", - }); - return false; - } - - // Update local user credits state - if (user?.credits) { - user.credits.amount -= requiredCredits; - } + let deepLinkUnsubscribe: (() => void) | undefined; - toast({ - title: "pipe purchased", - description: `${requiredCredits} credits deducted`, - }); - - return true; - } catch (error) { - console.error("purchase failed:", error); - toast({ - title: "purchase failed", - description: "please try again or contact support", - variant: "destructive", - }); - return false; - } - }; + setupDeepLink().then((unsubscribe) => { + deepLinkUnsubscribe = unsubscribe; + }); + return () => { + if (deepLinkUnsubscribe) deepLinkUnsubscribe(); + }; + }, []); - // Add this empty state render function - const renderEmptyState = () => { + if (health?.status === "error") { return (
@@ -1480,22 +703,27 @@ const PipeStore: React.FC = () => {
); - }; - - // Add this check at the start of the component render - if (health?.status === "error") { - return renderEmptyState(); } if (selectedPipe) { - return renderPipeDetails(); + return ( + setSelectedPipe(null)} + onToggle={handleTogglePipe} + onConfigSave={handleConfigSave} + onDelete={handleDeletePipe} + onRefreshFromDisk={handleRefreshFromDisk} + onUpdate={handleUpdatePipe} + /> + ); } return (
-
+
{ checked={showInstalledOnly} onCheckedChange={setShowInstalledOnly} /> - - - - - - -

remove all pipes and start fresh

-
-
-
+
-
-
-
- {filteredPipes.map((pipe) => ( -
handleCardClick(pipe)} - > -
-
-
-

- {getFriendlyName(pipe.id, corePipes)} -

-
- - by {getAuthorFromSource(pipe.source)} - - {pipe.source?.startsWith("http") ? ( - { - e.stopPropagation(); - openUrl(pipe.source); - }} - /> - ) : ( - - )} -
-
-
- {pipes.some((p) => p.id === pipe.id) ? ( - <> - {pipes.find((p) => p.id === pipe.id)?.config - ?.port && - pipes.find((p) => p.id === pipe.id)?.enabled ? ( - - ) : null} - - ) : ( - - )} -
-
-
- -
-
- Updated recently -
- {corePipes.find((cp) => cp.id === pipe.id)?.paid && ( -
- requires{" "} - {corePipes.find((cp) => cp.id === pipe.id)?.credits}{" "} - credits{" "} - {user?.credits - ? `(you have ${user.credits.amount})` - : ""} -
- )} -
-
- ))} -
- -
-
-

add your own pipe

- -
-
-
- setNewRepoUrl(e.target.value)} - autoCorrect="off" - autoComplete="off" - disabled={health?.status === "error"} - /> -
- - -
- -
+
+
+ {filteredPipes.map((pipe) => ( + + ))}
+ +
+
); }; - -export default PipeStore; diff --git a/screenpipe-app-tauri/components/pipe-store/add-pipe-form.tsx b/screenpipe-app-tauri/components/pipe-store/add-pipe-form.tsx new file mode 100644 index 0000000000..80b4d04c32 --- /dev/null +++ b/screenpipe-app-tauri/components/pipe-store/add-pipe-form.tsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Plus, FolderOpen, Puzzle } from 'lucide-react'; +import { open } from '@tauri-apps/plugin-dialog'; +import { PublishDialog } from '../publish-dialog'; +import { PipeStorePlugin } from '@/lib/api/store'; + +interface AddPipeFormProps { + onAddPipe: (url: string) => Promise; + onLoadFromLocalFolder: (setNewRepoUrl: (url: string) => void) => Promise; + isHealthy: boolean; +} + +export const AddPipeForm: React.FC = ({ + onAddPipe, + onLoadFromLocalFolder, + isHealthy, +}) => { + const [newRepoUrl, setNewRepoUrl] = useState(''); + + return ( +
+
+
+ setNewRepoUrl(e.target.value)} + autoCorrect="off" + autoComplete="off" + disabled={!isHealthy} + /> +
+ + +
+ +
+ ); +}; \ No newline at end of file diff --git a/screenpipe-app-tauri/components/pipe-store/pipe-card.tsx b/screenpipe-app-tauri/components/pipe-store/pipe-card.tsx new file mode 100644 index 0000000000..505252e5a1 --- /dev/null +++ b/screenpipe-app-tauri/components/pipe-store/pipe-card.tsx @@ -0,0 +1,162 @@ +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Download, Puzzle, UserIcon } from "lucide-react"; +import { PipeStoreMarkdown } from "@/components/pipe-store-markdown"; +import { PipeWithStatus } from "./types"; +import { invoke } from "@tauri-apps/api/core"; +import { toast } from "@/components/ui/use-toast"; + +interface PipeCardProps { + pipe: PipeWithStatus; + onInstall: (pipe: PipeWithStatus, onComplete: () => void) => Promise; + onPurchase: (pipe: PipeWithStatus, onComplete: () => void) => Promise; + onClick: (pipe: PipeWithStatus) => void; +} + +const truncateDescription = (description: string, maxLines: number = 4) => { + if (!description) return ""; + const cleaned = description.replace(/Â/g, "").trim(); + + // Split into lines and track codeblock state + const lines = cleaned.split(/\r?\n/); + let inCodeBlock = false; + let visibleLines: string[] = []; + let lineCount = 0; + + for (const line of lines) { + // Check for codeblock markers + if (line.trim().startsWith("```")) { + inCodeBlock = !inCodeBlock; + visibleLines.push(line); + continue; + } + + // If we're in a codeblock, include the line + if (inCodeBlock) { + visibleLines.push(line); + continue; + } + + // For non-codeblock content, count lines normally + if (lineCount < maxLines) { + visibleLines.push(line); + if (line.trim()) lineCount++; + } + } + + // If we ended inside a codeblock, close it + if (inCodeBlock) { + visibleLines.push("```"); + } + + const result = visibleLines.join("\n"); + return lineCount >= maxLines ? result + "..." : result; +}; + +export const PipeCard: React.FC = ({ + pipe, + onInstall, + onPurchase, + onClick, +}) => { + const [isLoading, setIsLoading] = useState(false); + const handleOpenWindow = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + if (pipe.installed_config?.port) { + await invoke("open_pipe_window", { + port: pipe.installed_config.port, + title: pipe.id, + }); + } + } catch (err) { + console.error("failed to open pipe window:", err); + toast({ + title: "error opening pipe window", + description: "please try again or check the logs", + variant: "destructive", + }); + } + }; + + return ( +
onClick(pipe)} + > +
+
+
+
+ +
+
+

+ {pipe.name} +

+

+ +

+
+
+
+ {pipe.is_installed ? ( + + ) : ( + + )} +
+
+ {pipe.installed_config?.source === 'store' && ( +
+
+
+ +
+ {pipe.developer_accounts.developer_name} +
+ {pipe.plugin_analytics.downloads_count != null && ( + + + {pipe.plugin_analytics.downloads_count} + + )} +
+ )} +
+
+ ); +}; diff --git a/screenpipe-app-tauri/components/pipe-store/pipe-details.tsx b/screenpipe-app-tauri/components/pipe-store/pipe-details.tsx new file mode 100644 index 0000000000..67c17a940f --- /dev/null +++ b/screenpipe-app-tauri/components/pipe-store/pipe-details.tsx @@ -0,0 +1,265 @@ +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + ExternalLink, + Power, + Puzzle, + RefreshCw, + Trash2, + X, +} from "lucide-react"; +import { PipeStoreMarkdown } from "@/components/pipe-store-markdown"; +import { PipeWithStatus } from "./types"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; +import { LogFileButton } from "../log-file-button"; +import { toast } from "../ui/use-toast"; +import { invoke } from "@tauri-apps/api/core"; +import { PipeConfigForm } from "../pipe-config-form"; +import { open as openUrl } from "@tauri-apps/plugin-shell"; +import { Badge } from "../ui/badge"; + +interface PipeDetailsProps { + pipe: PipeWithStatus; + onClose: () => void; + onToggle: (pipe: PipeWithStatus, onComplete: () => void) => void; + onConfigSave: (config: Record, onComplete: () => void) => void; + onUpdate: (pipe: PipeWithStatus, onComplete: () => void) => void; + onDelete: (pipe: PipeWithStatus, onComplete: () => void) => void; + onRefreshFromDisk: (pipe: PipeWithStatus, onComplete: () => void) => void; +} + +const isValidSource = (source?: string): boolean => { + if (!source) return false; + + // github url pattern + const githubPattern = /^https?:\/\/(?:www\.)?github\.com\/.+\/.+/i; + + // filesystem path patterns (unix and windows) + const unixPattern = /^(?:\/|~\/)/; + const windowsPattern = /^[a-zA-Z]:\\|^\\\\/; + + return ( + githubPattern.test(source) || + unixPattern.test(source) || + windowsPattern.test(source) + ); +}; + +export const PipeDetails: React.FC = ({ + pipe, + onClose, + onToggle, + onConfigSave, + onUpdate, + onDelete, + onRefreshFromDisk, +}) => { + const [isLoading, setIsLoading] = useState(false); + return ( +
+
+
+ +

{pipe.name}

+ + by {pipe.developer_accounts.developer_name} + +
+
+ +
+ {pipe.is_installed && ( +
+
+
+
+
+ + + + + + +

+ {pipe.installed_config?.enabled + ? "disable" + : "enable"}{" "} + pipe +

+
+
+
+ + + + {pipe.installed_config?.source && + isValidSource(pipe.installed_config.source) ? ( + + + + + + +

refresh the code from your local disk

+
+
+
+ ) : ( + + + + + + +

update pipe

+
+
+
+ )} + +
+ {/* Only show delete button for non-core pipes */} + {!pipe.is_core_pipe && ( + + + + + + +

delete pipe

+
+
+
+ )} +
+
+
+
+ + {pipe.installed_config?.enabled && ( +
+ { + onConfigSave(config, () => setIsLoading(false)); + }} + /> +
+ )} +
+
+ )} + +
+
+ {pipe.installed_config?.enabled && pipe.installed_config?.port && ( +
+
+
+ + +
+
+
+ )} + + {pipe.description && ( +
+

about this pipe

+
+ +
+
+ )} +
+
+
+
+ ); +}; diff --git a/screenpipe-app-tauri/components/pipe-store/types.ts b/screenpipe-app-tauri/components/pipe-store/types.ts new file mode 100644 index 0000000000..5b23260495 --- /dev/null +++ b/screenpipe-app-tauri/components/pipe-store/types.ts @@ -0,0 +1,29 @@ +import { PipeStorePlugin } from "@/lib/api/store"; + +export interface InstalledPipe { + config: { + id?: string; + enabled?: boolean; + is_nextjs: boolean; + port?: number; + source: string; + crons?: { + path: string; + schedule: string; + }[]; + fields?: Record; + version?: string; + }; +} + +export interface PipeWithStatus extends PipeStorePlugin { + is_installed: boolean; + installed_config?: InstalledPipe['config']; + has_purchased: boolean; + is_core_pipe: boolean; +} + +export interface BrokenPipe { + id: string; + lastAttempt: number; +} \ No newline at end of file diff --git a/screenpipe-app-tauri/components/publish-dialog.tsx b/screenpipe-app-tauri/components/publish-dialog.tsx index 216b40647a..cee1ab8622 100644 --- a/screenpipe-app-tauri/components/publish-dialog.tsx +++ b/screenpipe-app-tauri/components/publish-dialog.tsx @@ -20,10 +20,10 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { open as openUrl } from "@tauri-apps/plugin-shell"; -import { Pipe } from "./pipe-store"; +import { PipeWithStatus } from "./pipe-store/types"; interface PublishDialogProps { - app: Pipe | null; + app: PipeWithStatus | null; } export const PublishDialog: React.FC = ({ app }) => { diff --git a/screenpipe-app-tauri/components/recording-settings.tsx b/screenpipe-app-tauri/components/recording-settings.tsx index 1daae5af82..6bcb5acd0f 100644 --- a/screenpipe-app-tauri/components/recording-settings.tsx +++ b/screenpipe-app-tauri/components/recording-settings.tsx @@ -63,7 +63,6 @@ import { open } from "@tauri-apps/plugin-dialog"; import { exists } from "@tauri-apps/plugin-fs"; import { Command as ShellCommand } from "@tauri-apps/plugin-shell"; import { ToastAction } from "@/components/ui/toast"; -import { useUser } from "@/lib/hooks/use-user"; import { open as openUrl } from "@tauri-apps/plugin-shell"; import { Separator } from "./ui/separator"; import { MultiSelect } from "@/components/ui/multi-select"; @@ -142,8 +141,7 @@ export function RecordingSettings() { const [isMacOS, setIsMacOS] = useState(false); const [isSetupRunning, setIsSetupRunning] = useState(false); const [showApiKey, setShowApiKey] = useState(false); - const { user } = useUser(); - const { credits } = user || {}; + const { credits } = settings.user || {}; // Add new state to track if settings have changed const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); diff --git a/screenpipe-app-tauri/components/settings/account-section.tsx b/screenpipe-app-tauri/components/settings/account-section.tsx index d90d34377a..cf05a75723 100644 --- a/screenpipe-app-tauri/components/settings/account-section.tsx +++ b/screenpipe-app-tauri/components/settings/account-section.tsx @@ -21,6 +21,11 @@ import { Coins, UserCog, ExternalLinkIcon, + Key, + EyeOff, + Eye, + ArrowUpRight, + BookOpen, EyeIcon, EyeOffIcon, CopyIcon, @@ -28,10 +33,10 @@ import { import { toast } from "@/components/ui/use-toast"; -import { useUser } from "@/lib/hooks/use-user"; import { open as openUrl } from "@tauri-apps/plugin-shell"; import { Card } from "../ui/card"; import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; +import { invoke } from "@tauri-apps/api/core"; function PlanCard({ title, @@ -83,8 +88,7 @@ function PlanCard({ } export function AccountSection() { - const { user, loadUser } = useUser(); - const { settings, updateSettings } = useSettings(); + const { settings, updateSettings, loadUser } = useSettings(); const [isRefreshing, setIsRefreshing] = useState(false); const [selectedPlan, setSelectedPlan] = useState(null); const [isConnectingStripe, setIsConnectingStripe] = useState(false); @@ -99,7 +103,6 @@ export function AccountSection() { const apiKey = new URL(url).searchParams.get("api_key"); if (apiKey) { updateSettings({ user: { token: apiKey } }); - loadUser(apiKey); toast({ title: "logged in!", description: "your api key has been set", @@ -109,9 +112,15 @@ export function AccountSection() { if (url.includes("return") || url.includes("refresh")) { console.log("stripe connect url:", url); if (url.includes("/return")) { - if (user) { - const updatedUser = { ...user, stripe_connected: true }; - updateSettings({ user: updatedUser }); + const apiKey = new URL(url).searchParams.get("api_key")!; + if (settings.user) { + updateSettings({ + user: { + ...settings.user, + api_key: apiKey, + stripe_connected: true, + }, + }); } toast({ title: "stripe connected!", @@ -137,14 +146,13 @@ export function AccountSection() { return () => { if (deepLinkUnsubscribe) deepLinkUnsubscribe(); }; - }, [settings.user?.token, loadUser, updateSettings]); + }, [settings.user?.token, updateSettings]); const handleRefreshCredits = async () => { if (!settings.user?.token) return; setIsRefreshing(true); try { - await loadUser(settings.user.token); toast({ title: "credits refreshed", description: "your credit balance has been updated", @@ -160,8 +168,8 @@ export function AccountSection() { } }; - const clientRefId = `${user?.id}&customer_email=${encodeURIComponent( - user?.email ?? "" + const clientRefId = `${settings.user?.id}&customer_email=${encodeURIComponent( + settings.user?.email ?? "" )}`; const plans = [ @@ -197,14 +205,17 @@ export function AccountSection() { const handleConnectStripe = async () => { setIsConnectingStripe(true); try { - const host = "https://screenpi.pe/api/dev-stripe"; + const BASE_URL = + (await invoke("get_env", { name: "BASE_URL_PRIVATE" })) ?? + "https://screenpi.pe"; + const host = `${BASE_URL}/api/dev-stripe`; const response = await fetch(host, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - user_id: user?.id, + user_id: settings.user?.id, }), }); @@ -231,6 +242,13 @@ export function AccountSection() { } }; + useEffect(() => { + console.log("document visibility state:", document.visibilityState); + + const updatedUser = { ...settings.user, stripe_connected: true }; + updateSettings({ user: updatedUser }); + }, []); + return (
@@ -264,7 +282,7 @@ export function AccountSection() {

credits & usage

- {user?.credits?.amount || 0} available + {settings.user?.credits?.amount || 0} available
- + {settings.user?.stripe_connected ? ( + + ) : ( + + )}
- -
-
-
- - estimated earnings - - $1,385.00 -
-
- {[40, 35, 55, 45, 60, 75, 65].map((height, i) => ( -
- ))} -
-
- pending payout - coming soon + {settings.user?.api_key && ( +
+
+
+
+ +
+
+
+
api key
+ +
+

+ {showApiKey + ? settings.user?.api_key + : settings.user?.api_key?.replace(/./g, "*")} +

+
+
+
- -
- -
- $ screenpipe publish my-awesome-pipe + )} +
+
+
+
+
+ +
+
+
documentation
+

+ learn how to build and publish custom pipes +

+
+
+ + read docs + +
diff --git a/screenpipe-app-tauri/components/settings/ai-section.tsx b/screenpipe-app-tauri/components/settings/ai-section.tsx index cf3f7e6d08..8379e4a394 100644 --- a/screenpipe-app-tauri/components/settings/ai-section.tsx +++ b/screenpipe-app-tauri/components/settings/ai-section.tsx @@ -30,7 +30,6 @@ import { Textarea } from "../ui/textarea"; import { toast } from "../ui/use-toast"; import { invoke } from "@tauri-apps/api/core"; import { open as openUrl } from "@tauri-apps/plugin-shell"; -import { useUser } from "@/lib/hooks/use-user"; import { Button } from "../ui/button"; import { cn } from "@/lib/utils"; import { Card, CardContent } from "../ui/card"; @@ -124,9 +123,7 @@ const AISection = () => { >("idle"); const [showApiKey, setShowApiKey] = React.useState(false); - const { user } = useUser(); - const { credits } = user || {}; const handleApiKeyChange = (e: React.ChangeEvent) => { updateSettings({ openaiApiKey: e.target.value }); }; @@ -350,11 +347,11 @@ const AISection = () => { imageSrc="/images/screenpipe.png" selected={settings.aiProviderType === "screenpipe-cloud"} onClick={() => handleAiProviderChange("screenpipe-cloud")} - disabled={!user} + disabled={!settings.user} warningText={ - !user + !settings.user ? "login required" - : !credits?.amount + : !settings.user?.credits?.amount ? "requires credits" : undefined } diff --git a/screenpipe-app-tauri/components/store/credit-purchase-dialog.tsx b/screenpipe-app-tauri/components/store/credit-purchase-dialog.tsx index 4d56dbeb6d..e9a30789ab 100644 --- a/screenpipe-app-tauri/components/store/credit-purchase-dialog.tsx +++ b/screenpipe-app-tauri/components/store/credit-purchase-dialog.tsx @@ -8,8 +8,8 @@ import { import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { open as openUrl } from "@tauri-apps/plugin-shell"; -import { useUser } from "@/lib/hooks/use-user"; import { Loader2 } from "lucide-react"; +import { useSettings } from "@/lib/hooks/use-settings"; interface CreditPurchaseDialogProps { open: boolean; @@ -26,17 +26,17 @@ export function CreditPurchaseDialog({ currentCredits, onCreditsUpdated, }: CreditPurchaseDialogProps) { - const { refreshUser, user } = useUser(); + const { settings, loadUser } = useSettings(); const [showRefreshHint, setShowRefreshHint] = useState(false); const [isLoading, setIsLoading] = useState(false); const handlePurchase = async (url: string) => { setIsLoading(true); await openUrl( - `${url}?client_reference_id=${user?.id}&metadata[user_id]=${user?.id}` + `${url}?client_reference_id=${settings.user?.id}&metadata[user_id]=${settings.user?.id}` ); setTimeout(async () => { - await refreshUser(); + await loadUser(settings.user?.token!); onCreditsUpdated?.(); setShowRefreshHint(true); setIsLoading(false); @@ -71,7 +71,7 @@ export function CreditPurchaseDialog({ variant="outline" onClick={() => handlePurchase( - `https://buy.stripe.com/5kA6p79qefweacg5kJ?client_reference_id=${user?.id}&customer_email=${encodeURIComponent(user?.email ?? '')}` + `https://buy.stripe.com/5kA6p79qefweacg5kJ?client_reference_id=${settings.user?.id}&customer_email=${encodeURIComponent(settings.user?.email ?? '')}` ) } disabled={isLoading} @@ -99,7 +99,7 @@ export function CreditPurchaseDialog({ variant="outline" onClick={() => handlePurchase( - `https://buy.stripe.com/eVaeVD45UbfYeswcNd?client_reference_id=${user?.id}&customer_email=${encodeURIComponent(user?.email ?? '')}` + `https://buy.stripe.com/eVaeVD45UbfYeswcNd?client_reference_id=${settings.user?.id}&customer_email=${encodeURIComponent(settings.user?.email ?? '')}` ) } disabled={isLoading} diff --git a/screenpipe-app-tauri/lib/api/store/index.ts b/screenpipe-app-tauri/lib/api/store/index.ts new file mode 100644 index 0000000000..47ad9ead04 --- /dev/null +++ b/screenpipe-app-tauri/lib/api/store/index.ts @@ -0,0 +1,209 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface PipeStorePlugin { + id: string; + name: string; + description: string | null; + is_paid: boolean | null; + price: number | null; + status: string | null; + created_at: string | null; + developer_accounts: { + developer_name: string; + }; + plugin_analytics: { + downloads_count: number | null; + }; +} + +export interface PipeDownloadResponse { + download_url: string; + file_hash: string; + file_size: number; +} + +export enum PipeDownloadError { + PURCHASE_REQUIRED = "purchase required", + DOWNLOAD_FAILED = "failed to download pipe", +} + +type PurchaseHistoryResponse = PurchaseHistoryItem[]; + +export interface PurchaseHistoryItem { + id: string; + amount_paid: number; + currency: string; + stripe_payment_status: string; + created_at: string; + refunded_at: null; + plugins: Plugins; +} +interface Plugins { + id: string; + name: string; + description: string; + developer_accounts: Developer_accounts; +} +interface Developer_accounts { + developer_name: string; +} + +interface PurchaseUrlResponse { + data: { + checkout_url: string; + }; +} + +export interface CheckUpdateResponse { + has_update: boolean; + current_version: string; + latest_version: string; + latest_file_hash: string; + latest_file_size: number; +} + +export class PipeApi { + private baseUrl: string; + private authToken: string; + + private constructor(authToken: string) { + this.baseUrl = "https://screenpi.pe"; + this.authToken = authToken; + } + + static async create(authToken: string): Promise { + const api = new PipeApi(authToken); + await api.init(authToken); + return api; + } + + private async init(authToken: string) { + try { + const BASE_URL = await invoke("get_env", { name: "BASE_URL_PRIVATE" }); + if (BASE_URL) { + this.baseUrl = BASE_URL as string; + } + this.authToken = authToken; + } catch (error) { + console.error("error initializing base url:", error); + } + } + + async getUserPurchaseHistory(): Promise { + try { + const response = await fetch( + `${this.baseUrl}/api/plugins/user-purchase-history`, + { + headers: { + Authorization: `Bearer ${this.authToken}`, + }, + } + ); + if (!response.ok) { + const { error } = (await response.json()) as { error: string }; + throw new Error(`failed to fetch purchase history: ${error}`); + } + const data = (await response.json()) as PurchaseHistoryResponse; + return data; + } catch (error) { + console.error("error getting purchase history:", error); + throw error; + } + } + + async listStorePlugins(): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/plugins/registry`, { + headers: { + Authorization: `Bearer ${this.authToken}`, + }, + }); + if (!response.ok) { + const { error } = await response.json(); + throw new Error(`failed to fetch plugins: ${error}`); + } + const data: PipeStorePlugin[] = await response.json(); + return data; + } catch (error) { + console.error("error listing pipes:", error); + throw error; + } + } + + async purchasePipe(pipeId: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/plugins/purchase`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.authToken}`, + }, + body: JSON.stringify({ pipe_id: pipeId }), + }); + if (!response.ok) { + const { error } = await response.json(); + throw new Error(`failed to purchase pipe: ${error}`); + } + const data = (await response.json()) as PurchaseUrlResponse; + return data; + } catch (error) { + console.error("error purchasing pipe:", error); + throw error; + } + } + + async downloadPipe(pipeId: string): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/plugins/download`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.authToken}`, + }, + body: JSON.stringify({ pipe_id: pipeId }), + }); + + if (!response.ok) { + const { error } = (await response.json()) as { error: string }; + throw new Error(error!, { + cause: + response.status === 403 + ? PipeDownloadError.PURCHASE_REQUIRED + : PipeDownloadError.DOWNLOAD_FAILED, + }); + } + const data = (await response.json()) as PipeDownloadResponse; + return data; + } catch (error) { + console.error("error downloading pipe:", error); + throw error; + } + } + + async checkUpdate( + pipeId: string, + version: string + ): Promise { + try { + const response = await fetch(`${this.baseUrl}/api/plugins/check-update`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.authToken}`, + }, + body: JSON.stringify({ pipe_id: pipeId, version }), + }); + + if (!response.ok) { + const { error } = await response.json(); + throw new Error(`failed to check for updates: ${error}`); + } + + const data = await response.json(); + return data; + } catch (error) { + console.error("error checking for updates:", error); + throw error; + } + } +} diff --git a/screenpipe-app-tauri/lib/hooks/use-health-check.tsx b/screenpipe-app-tauri/lib/hooks/use-health-check.tsx index a9c1af981b..8a9fb49a51 100644 --- a/screenpipe-app-tauri/lib/hooks/use-health-check.tsx +++ b/screenpipe-app-tauri/lib/hooks/use-health-check.tsx @@ -117,7 +117,7 @@ export function useHealthCheck() { } }, [isServerDown, setIsLoading]); - const debouncedFetchHealth = useCallback(debounce(fetchHealth, 200), [ + const debouncedFetchHealth = useCallback(debounce(fetchHealth, 1000), [ fetchHealth, ]); diff --git a/screenpipe-app-tauri/lib/hooks/use-settings.tsx b/screenpipe-app-tauri/lib/hooks/use-settings.tsx index 2c8122c2d3..7fb7547de0 100644 --- a/screenpipe-app-tauri/lib/hooks/use-settings.tsx +++ b/screenpipe-app-tauri/lib/hooks/use-settings.tsx @@ -12,6 +12,7 @@ import { import { LazyStore, LazyStore as TauriStore } from "@tauri-apps/plugin-store"; import { localDataDir } from "@tauri-apps/api/path"; import { flattenObject, unflattenObject } from "../utils"; +import { invoke } from "@tauri-apps/api/core"; export type VadSensitivity = "low" | "medium" | "high"; @@ -41,6 +42,7 @@ export type User = { image?: string; token?: string; clerk_id?: string; + api_key?: string; credits?: { amount: number; }; @@ -341,10 +343,42 @@ export function useSettings() { : `${homeDirPath}\\.screenpipe`; }; + const loadUser = async (token: string) => { + try { + const BASE_URL = await invoke("get_env", { name: "BASE_URL_PRIVATE" }) ?? "https://screenpi.pe"; + + const response = await fetch(`${BASE_URL}/api/tauri`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ token }), + }); + + if (!response.ok) { + throw new Error("failed to verify token"); + } + + const data = await response.json(); + const userData = { + ...data.user, + stripe_connected: data.user.stripe_connected ?? false, + } as User; + + setSettings({ + user: userData + }); + + } catch (err) { + console.error("failed to load user:", err); + } + }; + return { settings, updateSettings: setSettings, resetSettings, + loadUser, resetSetting, getDataDir, }; diff --git a/screenpipe-app-tauri/lib/hooks/use-user.ts b/screenpipe-app-tauri/lib/hooks/use-user.ts deleted file mode 100644 index 538fccb79d..0000000000 --- a/screenpipe-app-tauri/lib/hooks/use-user.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { useEffect, useState } from "react"; -import { User, useSettings } from "./use-settings"; -import { useInterval } from "./use-interval"; -import { fetch } from "@tauri-apps/plugin-http"; - -async function verifyUserToken(token: string): Promise { - const response = await fetch("https://screenpi.pe/api/tauri", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ token }), - }); - - if (!response.ok) { - throw new Error("failed to verify token"); - } - - const data = await response.json(); - return { - ...data.user, - stripe_connected: data.user.stripe_connected ?? false, - } as User; -} - -export function useUser() { - const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const { settings, updateSettings } = useSettings(); - - // poll credits every 3 seconds if the settings dialog is open - useInterval(() => { - if (settings.user?.token) { - loadUser(settings.user.token); - } - }, 3000); - - const loadUser = async (token: string) => { - try { - const userData = await verifyUserToken(token); - // skip if user data did not change - if ( - userData.id === user?.id && - userData.credits?.amount === user?.credits?.amount - ) - return; - setUser(userData); - updateSettings({ user: userData }); - } catch (err) { - console.error("failed to load user:", err); - setError(err instanceof Error ? err.message : "failed to load user"); - } finally { - setIsLoading(false); - } - }; - - // explicit refresh function - const refreshUser = async () => { - if (settings.user?.token) { - await loadUser(settings.user.token); - } - }; - - // load from settings - useEffect(() => { - if (settings.user?.token) { - setUser(settings.user); - } - }, [settings.user?.token]); - - return { - user, - isSignedIn: !!user, - isLoading, - error, - loadUser, - refreshUser, // expose the refresh function - }; -} diff --git a/screenpipe-app-tauri/src-tauri/src/main.rs b/screenpipe-app-tauri/src-tauri/src/main.rs index c9468d3ed8..977f3bc15f 100755 --- a/screenpipe-app-tauri/src-tauri/src/main.rs +++ b/screenpipe-app-tauri/src-tauri/src/main.rs @@ -334,6 +334,11 @@ async fn apply_shortcuts(app: &AppHandle, config: &ShortcutConfig) -> Result<(), Ok(()) } +#[tauri::command] +fn get_env(name: &str) -> String { + std::env::var(String::from(name)).unwrap_or(String::from("")) +} + async fn get_pipe_port(pipe_id: &str) -> anyhow::Result { // Fetch pipe config from API let client = reqwest::Client::new(); @@ -672,6 +677,7 @@ async fn main() { commands::open_pipe_window, get_log_files, update_global_shortcuts, + get_env ]) .setup(|app| { // Logging setup diff --git a/screenpipe-app-tauri/src-tauri/tauri.conf.json b/screenpipe-app-tauri/src-tauri/tauri.conf.json index 2bcf4f5ef2..80a2a86eb2 100644 --- a/screenpipe-app-tauri/src-tauri/tauri.conf.json +++ b/screenpipe-app-tauri/src-tauri/tauri.conf.json @@ -63,6 +63,12 @@ "pathPrefix": [ "/stripe-connect" ] + }, + { + "host": "screenpi.pe", + "pathPrefix": [ + "/purchase-successful" + ] } ] } 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 9aba920f6c..509c1cea2b 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 4cab4bf60f..f5a5acf0d8 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-core/Cargo.toml b/screenpipe-core/Cargo.toml index 9a9b1316b4..c6ae00a8e4 100644 --- a/screenpipe-core/Cargo.toml +++ b/screenpipe-core/Cargo.toml @@ -49,6 +49,7 @@ once_cell = "1.19.0" cron = "0.13.0" chrono = "0.4.38" sentry = { workspace = true } +zip = "0.6.2" [features] default = ["pipes", "security"] diff --git a/screenpipe-core/src/pipes.rs b/screenpipe-core/src/pipes.rs index 2fc652674b..174e8e182e 100644 --- a/screenpipe-core/src/pipes.rs +++ b/screenpipe-core/src/pipes.rs @@ -1167,6 +1167,98 @@ mod pipes { use std::net::TcpListener; TcpListener::bind(("127.0.0.1", port)).is_ok() } + + pub async fn download_pipe_private(pipe_name: &str, source: &str, screenpipe_dir: PathBuf) -> anyhow::Result { + info!("processing private pipe from zip: {}", source); + + let dest_dir = screenpipe_dir.join("pipes").join(&pipe_name); + debug!("destination directory: {:?}", dest_dir); + + // Create temp directory for download + let temp_dir = dest_dir.with_extension("_temp"); + tokio::fs::create_dir_all(&temp_dir).await?; + + // Download zip file + debug!("downloading zip file from: {}", source); + let client = Client::new(); + let response = client.get(source).send().await?; + let zip_content = response.bytes().await?; + + // Create temporary zip file + let temp_zip = temp_dir.join("temp.zip"); + tokio::fs::write(&temp_zip, &zip_content).await?; + + // Unzip the file using tokio spawn_blocking to handle sync operations + debug!("unzipping file to temp directory"); + let temp_zip_path = temp_zip.clone(); + let temp_dir_path = temp_dir.clone(); + + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let zip_file = std::fs::File::open(&temp_zip_path)?; + let mut archive = zip::ZipArchive::new(zip_file)?; + + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + let name = file.name().to_string(); + let outpath = temp_dir_path.join(&name); + let ends_with_slash = name.ends_with('/'); + if ends_with_slash { + std::fs::create_dir_all(&outpath)?; + } else { + if let Some(p) = outpath.parent() { + if !p.exists() { + std::fs::create_dir_all(p)?; + } + } + let mut outfile = std::fs::File::create(&outpath)?; + std::io::copy(&mut file, &mut outfile)?; + } + } + Ok(()) + }).await??; + + // Remove the temporary zip file + tokio::fs::remove_file(&temp_zip).await?; + + // Move temp dir to final location + if dest_dir.exists() { + tokio::fs::remove_dir_all(&dest_dir).await?; + } + tokio::fs::rename(&temp_dir, &dest_dir).await?; + + // Check if it's a Next.js project + let package_json_path = dest_dir.join("package.json"); + let is_nextjs = if package_json_path.exists() { + let package_json = tokio::fs::read_to_string(&package_json_path).await?; + let package_data: Value = serde_json::from_str(&package_json)?; + package_data["dependencies"].get("next").is_some() + } else { + false + }; + + // Find bun path + let bun_path = find_bun_path().ok_or_else(|| anyhow::anyhow!("bun not found"))?; + + // Run bun install + info!("installing dependencies"); + retry_install(&bun_path, &dest_dir, 3).await?; + + if is_nextjs { + info!("detected next.js project, starting in production mode"); + // Update pipe.json to indicate it's a Next.js project + let pipe_json_path = dest_dir.join("pipe.json"); + if pipe_json_path.exists() { + let pipe_json = tokio::fs::read_to_string(&pipe_json_path).await?; + let mut pipe_config: Value = serde_json::from_str(&pipe_json)?; + pipe_config["is_nextjs"] = json!(true); + let updated_pipe_json = serde_json::to_string_pretty(&pipe_config)?; + tokio::fs::write(&pipe_json_path, updated_pipe_json).await?; + } + } + + info!("pipe downloaded and set up successfully at: {:?}", dest_dir); + Ok(dest_dir) + } } #[cfg(feature = "pipes")] diff --git a/screenpipe-server/Cargo.toml b/screenpipe-server/Cargo.toml index 1c878af541..d9fd3ab1c4 100644 --- a/screenpipe-server/Cargo.toml +++ b/screenpipe-server/Cargo.toml @@ -63,6 +63,7 @@ opentelemetry-semantic-conventions = "0.13" # Server axum = { version = "0.7.5", features = ["ws"] } +axum-macros = "0.5.0" async-stream = "0.3" tokio = { version = "1.15", features = ["full", "tracing"] } tower-http = { version = "0.5.2", features = ["cors", "trace"] } diff --git a/screenpipe-server/src/pipe_manager.rs b/screenpipe-server/src/pipe_manager.rs index 95681d93d7..86d12e03b6 100644 --- a/screenpipe-server/src/pipe_manager.rs +++ b/screenpipe-server/src/pipe_manager.rs @@ -2,7 +2,7 @@ use anyhow::Result; use killport::cli::Mode; use killport::killport::{Killport, KillportOperations}; use killport::signal::KillportSignal; -use screenpipe_core::{download_pipe, PipeState}; +use screenpipe_core::{download_pipe, download_pipe_private, PipeState}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; @@ -85,11 +85,12 @@ impl PipeManager { if let Value::Object(updates) = new_config { // Update top-level properties for (key, value) in updates.iter() { - if key != "fields" { // Handle non-fields properties directly + if key != "fields" { + // Handle non-fields properties directly existing_config.insert(key.clone(), value.clone()); } } - + // Handle fields separately if they exist if let Some(Value::Array(new_fields)) = updates.get("fields") { existing_config.insert("fields".to_string(), Value::Array(new_fields.clone())); @@ -215,6 +216,37 @@ impl PipeManager { Ok(pipe_dir.file_name().unwrap().to_string_lossy().into_owned()) } + pub async fn download_pipe_private(&self, url: &str, pipe_name: &str, pipe_id: &str) -> Result { + let pipe_dir = download_pipe_private(&pipe_name, &url, self.screenpipe_dir.clone()).await?; + + let package_json_path = pipe_dir.join("package.json"); + let version = if package_json_path.exists() { + let package_json = tokio::fs::read_to_string(&package_json_path).await?; + let package_data: Value = serde_json::from_str(&package_json)?; + package_data["version"].as_str().unwrap_or("1.0.0").to_string() + } else { + "1.0.0".to_string() + }; + + // update the config with the source url and version + self.update_config( + &pipe_dir.file_name().unwrap().to_string_lossy(), + serde_json::json!({ + "source": "store", + "version": version, + "id": pipe_id, + }), + ) + .await?; + + info!( + "pipe {} downloaded", + pipe_dir.file_name().unwrap().to_string_lossy() + ); + + Ok(pipe_dir.file_name().unwrap().to_string_lossy().into_owned()) + } + pub async fn purge_pipes(&self) -> Result<()> { let pipe_dir = self.screenpipe_dir.join("pipes"); tokio::fs::remove_dir_all(pipe_dir).await?; @@ -256,7 +288,8 @@ impl PipeManager { let killport = Killport; let signal: KillportSignal = "SIGKILL".parse().unwrap(); - match killport.kill_service_by_port(port, signal.clone(), Mode::Auto, false) { + match killport.kill_service_by_port(port, signal.clone(), Mode::Auto, false) + { Ok(killed_services) => { if killed_services.is_empty() { debug!("no services found using port {}", port); @@ -273,7 +306,9 @@ impl PipeManager { warn!("error killing port {}: {}", port, e); } } - }).await.map_err(|e| anyhow::anyhow!("Failed to kill port: {}", e))?; + }) + .await + .map_err(|e| anyhow::anyhow!("Failed to kill port: {}", e))?; } PipeState::Pid(pid) => { // Force kill the process if it's still running diff --git a/screenpipe-server/src/server.rs b/screenpipe-server/src/server.rs index 2ef2bd6be4..c7e8fc0996 100644 --- a/screenpipe-server/src/server.rs +++ b/screenpipe-server/src/server.rs @@ -8,6 +8,7 @@ use axum::{ routing::{get, post}, serve, Router, }; +use axum_macros::debug_handler; use futures::{ future::{try_join, try_join_all}, SinkExt, Stream, StreamExt, @@ -647,6 +648,13 @@ struct DownloadPipeRequest { url: String, } +#[derive(Deserialize)] +struct DownloadPipePrivateRequest { + url: String, + pipe_name: String, + pipe_id: String, +} + #[derive(Deserialize)] struct RunPipeRequest { pipe_id: String, @@ -685,6 +693,31 @@ async fn download_pipe_handler( } } +async fn download_pipe_private_handler( + State(state): State>, + JsonResponse(payload): JsonResponse, +) -> Result, (StatusCode, JsonResponse)> { + match state.pipe_manager.download_pipe_private(&payload.url, &payload.pipe_name, &payload.pipe_id).await { + Ok(pipe_dir) => Ok(JsonResponse(json!({ + "data": { + "pipe_id": pipe_dir, + "message": "pipe downloaded successfully" + }, + "success": true + }))), + Err(e) => { + error!("Failed to download pipe: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + JsonResponse(json!({ + "error": format!("failed to download pipe: {}", e), + "success": false + })), + )) + } + } +} + async fn run_pipe_handler( State(state): State>, JsonResponse(payload): JsonResponse, @@ -1813,6 +1846,7 @@ pub fn create_router() -> Router> { .route("/pipes/info/:pipe_id", get(get_pipe_info_handler)) .route("/pipes/list", get(list_pipes_handler)) .route("/pipes/download", post(download_pipe_handler)) + .route("/pipes/download-private", post(download_pipe_private_handler)) .route("/pipes/enable", post(run_pipe_handler)) .route("/pipes/disable", post(stop_pipe_handler)) .route("/pipes/update", post(update_pipe_config_handler))