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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added apps/electron-app/resources/favicon.ico
Binary file not shown.
Binary file added apps/electron-app/resources/tray.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/electron-app/resources/vibe.icns
Binary file not shown.
127 changes: 127 additions & 0 deletions apps/electron-app/src/main/browser/protocol-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { protocol } from "electron";
import { existsSync } from "fs";
import { readFile } from "fs/promises";
import { parse } from "path";
import { createLogger } from "@vibe/shared-types";

const logger = createLogger("protocol-handler");

type BufferLoader = (
filepath: string,
params: Record<string, any>,
) => Promise<Buffer>;

/**
* PDF to Image conversion function
* Converts PDF files to JPEG images using PDF.js and Canvas
*/
async function pdfToImage(
filepath: string,
_params: Record<string, any>,
): Promise<Buffer> {
try {
const content = await readFile(filepath);

// Use require for external dependencies to avoid TypeScript issues
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pdfjsLib = require("pdfjs-dist");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const canvas = require("canvas");

// Initialize PDF.js

pdfjsLib.GlobalWorkerOptions.workerSrc = require.resolve(
"pdfjs-dist/build/pdf.worker.js",
);

// Load PDF document
const pdfDoc = await pdfjsLib.getDocument({ data: content }).promise;
const page = await pdfDoc.getPage(1); // Get first page

// Get page viewport
const viewport = page.getViewport({ scale: 1.5 });

// Create canvas
const canvasElement = canvas.createCanvas(viewport.width, viewport.height);
const context = canvasElement.getContext("2d");

// Render page to canvas
const renderContext = {
canvasContext: context,
viewport: viewport,
};

await page.render(renderContext).promise;

// Convert canvas to buffer
const buffer = canvasElement.toBuffer("image/jpeg", { quality: 0.8 });

return buffer;
} catch (reason) {
logger.error("PDF conversion failed:", reason);

// Return placeholder image as fallback
const placeholderImage = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
"base64",
);
return placeholderImage;
}
}

/**
* Register the global img:// protocol handler
* This protocol handles local file serving with PDF conversion support
*/
export function registerImgProtocol(): void {
protocol.handle("img", async request => {
const url_string = request.url;
const url = new URL(url_string);
let loader: BufferLoader | undefined = undefined;

// Extract filepath from img:// URL
const filepath = url_string.substring("img://".length);

Comment on lines +82 to +84
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Critical security issue: Add path validation to prevent directory traversal attacks.

The filepath is extracted directly from the URL without any validation. This could allow malicious actors to access files outside the intended directory using path traversal techniques (e.g., img://../../../../etc/passwd).

Add path validation:

-    // Extract filepath from img:// URL
-    const filepath = url_string.substring("img://".length);
+    // Extract filepath from img:// URL
+    const rawPath = url_string.substring("img://".length);
+    
+    // Validate and normalize the path to prevent directory traversal
+    const filepath = path.normalize(rawPath);
+    
+    // Ensure the path doesn't contain traversal patterns
+    if (filepath.includes('..') || path.isAbsolute(filepath)) {
+      return new Response(null, { status: 403, statusText: 'Forbidden' });
+    }
+    
+    // Optional: Restrict to a specific base directory
+    // const safePath = path.join(ALLOWED_BASE_DIR, filepath);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/electron-app/src/main/browser/protocol-handler.ts around lines 82 to 84,
the filepath is extracted directly from the URL without validation, which risks
directory traversal attacks. To fix this, add validation to ensure the extracted
filepath does not contain any path traversal sequences like "../". Normalize the
path and restrict access to only allowed directories, rejecting or sanitizing
any paths that attempt to escape the intended base directory.

if (existsSync(filepath)) {
const { ext } = parse(filepath);
let blobType: string | undefined = undefined;

// Determine file type and loader
switch (ext.toLowerCase()) {
case ".jpg":
case ".jpeg":
blobType = "image/jpeg";
break;
case ".png":
blobType = "image/png";
break;
case ".svg":
blobType = "image/svg+xml";
break;
case ".pdf":
loader = pdfToImage;
blobType = "image/jpeg";
break;
}

// Load file content
const imageBuffer = loader
? await loader(filepath, { ...url.searchParams, mimeType: blobType })
: await readFile(filepath);

// Create response
const blob = new Blob([imageBuffer], {
type: blobType || "application/octet-stream",
});
return new Response(blob, {
status: 200,
headers: { "Content-Type": blob.type },
});
}

// File not found
return new Response(null, { status: 404 });
});

logger.info("✓ Registered img:// protocol handler globally");
}
57 changes: 57 additions & 0 deletions apps/electron-app/src/main/ipc/app/tray-control.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ipcMain, IpcMainInvokeEvent } from "electron";
import { createLogger } from "@vibe/shared-types";

const logger = createLogger("tray-control");

// Reference to the main process tray variable
let mainTray: Electron.Tray | null = null;

// Function to set the main tray reference
export function setMainTray(tray: Electron.Tray | null) {
mainTray = tray;
}

/**
* Tray control IPC handlers
*/

ipcMain.handle("tray:create", async (_event: IpcMainInvokeEvent) => {
try {
if (mainTray) {
logger.info("Tray already exists");
return true;
}

// Import tray creation logic from main process
const { createTray } = await import("../../tray-manager");
mainTray = await createTray();

logger.info("Tray created successfully");
return true;
} catch (error) {
logger.error("Failed to create tray", { error });
return false;
}
});

ipcMain.handle("tray:destroy", async (_event: IpcMainInvokeEvent) => {
try {
if (!mainTray) {
logger.info("Tray does not exist");
return true;
}

mainTray.destroy();
mainTray = null;

logger.info("Tray destroyed successfully");
return true;
} catch (error) {
logger.error("Failed to destroy tray", { error });
return false;
}
});

ipcMain.handle("tray:is-visible", async (_event: IpcMainInvokeEvent) => {
return mainTray !== null;
});
33 changes: 33 additions & 0 deletions apps/electron-app/src/main/ipc/browser/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Notification, type NotificationConstructorOptions } from "electron";

export function createNotification({
click,
action,
...options
}: NotificationConstructorOptions & {
click?: () => void;
action?: (index: number) => void;
}) {
if (!Notification.isSupported()) {
return;
}

const notification = new Notification({
silent: true,
...options,
});

if (click) {
notification.once("click", click);
}

if (action) {
notification.once("action", (_event, index) => {
action?.(index);
});
}

notification.show();

return notification;
}
86 changes: 86 additions & 0 deletions apps/electron-app/src/main/ipc/profile/top-sites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { ipcMain } from "electron";
import { useUserProfileStore } from "@/store/user-profile-store";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix the module import path to resolve compilation error.

The TypeScript compiler cannot resolve the module path @/store/user-profile-store. This appears to be a path alias issue.

Verify the path alias configuration or use a relative import:

-import { useUserProfileStore } from "@/store/user-profile-store";
+import { useUserProfileStore } from "../../store/user-profile-store";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { useUserProfileStore } from "@/store/user-profile-store";
import { useUserProfileStore } from "../../store/user-profile-store";
🧰 Tools
🪛 GitHub Actions: CI

[error] 2-2: TypeScript error TS2307: Cannot find module '@/store/user-profile-store' or its corresponding type declarations.

🤖 Prompt for AI Agents
In apps/electron-app/src/main/ipc/profile/top-sites.ts at line 2, the import
path '@/store/user-profile-store' causes a compilation error due to unresolved
path alias. Fix this by either correcting the path alias configuration in your
tsconfig or webpack config, or replace the alias with a relative import path
that correctly points to the user-profile-store module.

import { createLogger } from "@vibe/shared-types";

const logger = createLogger("top-sites");

export function registerTopSitesHandlers(): void {
ipcMain.handle("profile:get-top-sites", async (_, limit: number = 3) => {
try {
const userProfileStore = useUserProfileStore.getState();
const activeProfile = userProfileStore.getActiveProfile();

if (!activeProfile) {
return { success: false, sites: [] };
}

// Get navigation history
const history = activeProfile.navigationHistory || [];

// Count visits per domain
const siteVisits = new Map<
string,
{
url: string;
title: string;
visitCount: number;
lastVisit: number;
}
>();

history.forEach(entry => {
try {
const url = new URL(entry.url);
const domain = url.hostname;

const existing = siteVisits.get(domain);
if (existing) {
existing.visitCount++;
existing.lastVisit = Math.max(existing.lastVisit, entry.timestamp);
// Update title if the new one is better (not empty)
if (entry.title && entry.title.trim()) {
existing.title = entry.title;
}
} else {
siteVisits.set(domain, {
url: entry.url,
title: entry.title || domain,
visitCount: 1,
lastVisit: entry.timestamp,
});
}
} catch {
// Skip invalid URLs
logger.debug("Skipping invalid URL:", entry.url);
}
});

// Sort by visit count and get top sites
const topSites = Array.from(siteVisits.values())
.sort((a, b) => {
// First sort by visit count
if (b.visitCount !== a.visitCount) {
return b.visitCount - a.visitCount;
}
// Then by last visit time
return b.lastVisit - a.lastVisit;
})
.slice(0, limit)
.map(site => ({
url: site.url,
title: site.title,
visitCount: site.visitCount,
// TODO: Add favicon support
favicon: undefined,
}));

return {
success: true,
sites: topSites,
};
} catch (error) {
logger.error("Failed to get top sites:", error);
return { success: false, sites: [] };
}
});
}
Loading
Loading