diff --git a/apps/chrome-extension/.gitignore b/apps/chrome-extension/.gitignore new file mode 100644 index 0000000000..6de233e8c6 --- /dev/null +++ b/apps/chrome-extension/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.zip +.DS_Store diff --git a/apps/chrome-extension/README.md b/apps/chrome-extension/README.md new file mode 100644 index 0000000000..0bba598d85 --- /dev/null +++ b/apps/chrome-extension/README.md @@ -0,0 +1,85 @@ +# Cap Chrome Extension + +A Chrome extension that launches Cap's web-based recorder for instant screen, tab, and camera recording. + +## Features + +- **Quick Access**: One-click launcher to open Cap's web recorder +- **Smart Tab Management**: Reuses existing recorder tabs instead of creating duplicates +- **Full Recording Suite**: Access to all Cap recording features via the web app: + - Multiple recording modes (screen, window, tab, camera) + - Recording controls (start/stop, pause/resume) + - Device selection (camera and microphone) + - Real-time recording timer + - Automatic upload to Cap + +## Installation + +### From Source + +1. Install dependencies: + ```bash + pnpm install + ``` + +2. Build the extension: + ```bash + pnpm build + ``` + +3. Load in Chrome: + - Open Chrome and navigate to `chrome://extensions/` + - Enable "Developer mode" (toggle in top right) + - Click "Load unpacked" + - Select the `dist` folder + + +## Usage + +### First Time Setup + +1. Click the Cap extension icon in your browser toolbar +2. Click "Sign In to Cap" to authenticate +3. You'll be redirected to Cap's authentication page +4. Once authenticated, you're ready to record! + +### Recording + +1. Click the Cap extension icon +2. Select your recording mode (Screen, Tab, or Camera) +3. Configure audio options: + - Enable/disable microphone + - Enable/disable camera overlay +4. Click "Start Recording" +5. Grant necessary permissions when prompted +6. Record your content +7. Click "Stop" when finished +8. Your recording will automatically upload to Cap + + +### Adding New Features + +1. Update the appropriate component file +2. If adding new permissions, update `manifest.json` +3. Test thoroughly in development mode +4. Build and test the production version + +## Browser Compatibility + +- Chrome 100+ +- Edge 100+ +- Other Chromium-based browsers + +## Permissions + +The extension requires the following permissions: + +- **`tabs`**: Access tab information for tab recording + +## Contributing + +Contributions are welcome! Please follow the existing code style and test thoroughly before submitting PRs. + +## License + +See the main Cap repository for license information. diff --git a/apps/chrome-extension/manifest.json b/apps/chrome-extension/manifest.json new file mode 100644 index 0000000000..0b458943f3 --- /dev/null +++ b/apps/chrome-extension/manifest.json @@ -0,0 +1,27 @@ +{ + "manifest_version": 3, + "name": "Cap - Screen Recorder", + "version": "1.0.0", + "description": "Record your screen, tab, or camera instantly with Cap", + "permissions": [ + "tabs" + ], + "background": { + "service_worker": "src/background/service-worker.js", + "type": "module" + }, + "action": { + "default_icon": { + "16": "src/assets/icons/icon-16.png", + "32": "src/assets/icons/icon-32.png", + "48": "src/assets/icons/icon-48.png", + "128": "src/assets/icons/icon-128.png" + } + }, + "icons": { + "16": "src/assets/icons/icon-16.png", + "32": "src/assets/icons/icon-32.png", + "48": "src/assets/icons/icon-48.png", + "128": "src/assets/icons/icon-128.png" + } +} diff --git a/apps/chrome-extension/package.json b/apps/chrome-extension/package.json new file mode 100644 index 0000000000..b57cd5e32f --- /dev/null +++ b/apps/chrome-extension/package.json @@ -0,0 +1,15 @@ +{ + "name": "@cap/chrome-extension", + "version": "1.0.0", + "type": "module", + "description": "Cap Chrome Extension - Screen recorder for the web", + "scripts": { + "build": "node scripts/build.js" + }, + "devDependencies": { + "archiver": "^7.0.1", + "chokidar": "^4.0.3", + "esbuild": "^0.24.2", + "sharp": "^0.34.5" + } +} diff --git a/apps/chrome-extension/scripts/build.js b/apps/chrome-extension/scripts/build.js new file mode 100644 index 0000000000..363171b8f8 --- /dev/null +++ b/apps/chrome-extension/scripts/build.js @@ -0,0 +1,60 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const rootDir = path.resolve(__dirname, '..'); +const srcDir = path.join(rootDir, 'src'); +const distDir = path.join(rootDir, 'dist'); + +function cleanDist() { + if (fs.existsSync(distDir)) { + fs.rmSync(distDir, { recursive: true, force: true }); + } + fs.mkdirSync(distDir, { recursive: true }); +} + +function copyDirectory(src, dest) { + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + copyDirectory(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +async function build() { + try { + console.log('Building extension...'); + + cleanDist(); + + fs.copyFileSync( + path.join(rootDir, 'manifest.json'), + path.join(distDir, 'manifest.json') + ); + console.log('Copied manifest.json'); + + copyDirectory(srcDir, path.join(distDir, 'src')); + console.log('Copied src/ directory'); + + console.log('Build complete!'); + } catch (error) { + console.error('Build failed:', error); + process.exit(1); + } +} + +build(); diff --git a/apps/chrome-extension/src/assets/icons/.gitkeep b/apps/chrome-extension/src/assets/icons/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/chrome-extension/src/assets/icons/icon-128.png b/apps/chrome-extension/src/assets/icons/icon-128.png new file mode 100644 index 0000000000..1051ca6ab0 Binary files /dev/null and b/apps/chrome-extension/src/assets/icons/icon-128.png differ diff --git a/apps/chrome-extension/src/assets/icons/icon-16.png b/apps/chrome-extension/src/assets/icons/icon-16.png new file mode 100644 index 0000000000..c0412608a0 Binary files /dev/null and b/apps/chrome-extension/src/assets/icons/icon-16.png differ diff --git a/apps/chrome-extension/src/assets/icons/icon-32.png b/apps/chrome-extension/src/assets/icons/icon-32.png new file mode 100644 index 0000000000..1b12dc5ede Binary files /dev/null and b/apps/chrome-extension/src/assets/icons/icon-32.png differ diff --git a/apps/chrome-extension/src/assets/icons/icon-48.png b/apps/chrome-extension/src/assets/icons/icon-48.png new file mode 100644 index 0000000000..cb5ce413bc Binary files /dev/null and b/apps/chrome-extension/src/assets/icons/icon-48.png differ diff --git a/apps/chrome-extension/src/background/service-worker.js b/apps/chrome-extension/src/background/service-worker.js new file mode 100644 index 0000000000..32e9d0aedd --- /dev/null +++ b/apps/chrome-extension/src/background/service-worker.js @@ -0,0 +1,18 @@ +const isDev = !('update_url' in chrome.runtime.getManifest()); +const WEB_RECORDER_URL = isDev ? 'http://localhost:3000/record' : 'https://cap.so/record'; + +chrome.runtime.onInstalled.addListener(() => { + console.log('Cap extension installed'); +}); + +chrome.action.onClicked.addListener(async () => { + const tabs = await chrome.tabs.query({ url: WEB_RECORDER_URL }); + + if (tabs.length > 0) { + const tab = tabs[0]; + await chrome.tabs.update(tab.id, { active: true }); + await chrome.windows.update(tab.windowId, { focused: true }); + } else { + chrome.tabs.create({ url: WEB_RECORDER_URL }); + } +}); diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/RecordingModeSelector.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/RecordingModeSelector.tsx index acbf7f9844..3d2cc5bf55 100644 --- a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/RecordingModeSelector.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/RecordingModeSelector.tsx @@ -83,7 +83,7 @@ export const RecordingModeSelector = ({ )} - + {Object.entries(recordingModeOptions).map(([value, option]) => { const OptionIcon = option.icon; const isFullscreen = value === "fullscreen"; diff --git a/apps/web/app/(org)/login/form.tsx b/apps/web/app/(org)/login/form.tsx index 8348b136ee..1359fa7c70 100644 --- a/apps/web/app/(org)/login/form.tsx +++ b/apps/web/app/(org)/login/form.tsx @@ -29,7 +29,7 @@ const MotionButton = motion(Button); export function LoginForm() { const searchParams = useSearchParams(); const router = useRouter(); - const next = searchParams?.get("next"); + const next = searchParams?.get("next") || searchParams?.get("callbackUrl"); const [email, setEmail] = useState(""); const [loading, setLoading] = useState(false); const [emailSent, setEmailSent] = useState(false); @@ -124,7 +124,9 @@ export function LoginForm() { ); setOrganizationName(data.name); - signIn("workos", undefined, { + signIn("workos", { + ...(next && next.length > 0 ? { callbackUrl: next } : {}), + }, { organization: data.organizationId, connection: data.connectionId, }); diff --git a/apps/web/app/(org)/record/RecorderPageContent.tsx b/apps/web/app/(org)/record/RecorderPageContent.tsx new file mode 100644 index 0000000000..4881f50ce9 --- /dev/null +++ b/apps/web/app/(org)/record/RecorderPageContent.tsx @@ -0,0 +1,491 @@ +"use client"; + +import { Button, LogoBadge, Switch } from "@cap/ui"; +import { useRouter } from "next/navigation"; +import { signOut } from "next-auth/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { ArrowLeftIcon } from "lucide-react"; +import { useCurrentUser } from "@/app/Layout/AuthContext"; +import { CogIcon, LogoutIcon } from "../dashboard/_components/AnimatedIcons"; +import { CameraPreviewWindow } from "../dashboard/caps/components/web-recorder-dialog/CameraPreviewWindow"; +import { CameraSelector } from "../dashboard/caps/components/web-recorder-dialog/CameraSelector"; +import { InProgressRecordingBar } from "../dashboard/caps/components/web-recorder-dialog/InProgressRecordingBar"; +import { MicrophoneSelector } from "../dashboard/caps/components/web-recorder-dialog/MicrophoneSelector"; +import { + type RecordingMode, + RecordingModeSelector, +} from "../dashboard/caps/components/web-recorder-dialog/RecordingModeSelector"; +import { useCameraDevices } from "../dashboard/caps/components/web-recorder-dialog/useCameraDevices"; +import { useDevicePreferences } from "../dashboard/caps/components/web-recorder-dialog/useDevicePreferences"; +import { useMediaPermission } from "../dashboard/caps/components/web-recorder-dialog/useMediaPermission"; +import { useMicrophoneDevices } from "../dashboard/caps/components/web-recorder-dialog/useMicrophoneDevices"; +import { useWebRecorder } from "../dashboard/caps/components/web-recorder-dialog/useWebRecorder"; +import { FREE_PLAN_MAX_RECORDING_MS } from "../dashboard/caps/components/web-recorder-dialog/web-recorder-constants"; + +export const RecorderPageContent = () => { + const router = useRouter(); + const [recordingMode, setRecordingMode] = + useState("fullscreen"); + const [cameraSelectOpen, setCameraSelectOpen] = useState(false); + const [micSelectOpen, setMicSelectOpen] = useState(false); + const [settingsPanelOpen, setSettingsPanelOpen] = useState(false); + const [isClient, setIsClient] = useState(false); + const startSoundRef = useRef(null); + const stopSoundRef = useRef(null); + + const user = useCurrentUser(); + + useEffect(() => { + setIsClient(true); + }, []); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const startSound = new Audio("/sounds/start-recording.ogg"); + startSound.preload = "auto"; + const stopSound = new Audio("/sounds/stop-recording.ogg"); + stopSound.preload = "auto"; + + startSoundRef.current = startSound; + stopSoundRef.current = stopSound; + + return () => { + startSound.pause(); + stopSound.pause(); + startSoundRef.current = null; + stopSoundRef.current = null; + }; + }, []); + + const playAudio = useCallback((audio: HTMLAudioElement | null) => { + if (!audio) { + return; + } + audio.currentTime = 0; + void audio.play().catch(() => {}); + }, []); + + const handleRecordingStartSound = useCallback(() => { + playAudio(startSoundRef.current); + }, [playAudio]); + + const handleRecordingStopSound = useCallback(() => { + playAudio(stopSoundRef.current); + }, [playAudio]); + + const { requestPermission: requestCameraPermission } = useMediaPermission( + "camera", + !!user, + ); + const { requestPermission: requestMicPermission } = useMediaPermission( + "microphone", + !!user, + ); + + const { devices: availableMics, refresh: refreshMics } = + useMicrophoneDevices(isClient); + const { devices: availableCameras, refresh: refreshCameras } = + useCameraDevices(isClient); + + useEffect(() => { + if (!user) return; + + const requestPermissions = async () => { + try { + await Promise.all([ + requestCameraPermission(), + requestMicPermission(), + ]); + refreshCameras(); + refreshMics(); + } catch (error) { + console.error("Permission request failed:", error); + } + }; + + requestPermissions(); + }, [user, requestCameraPermission, requestMicPermission, refreshCameras, refreshMics]); + const { + rememberDevices, + selectedCameraId, + selectedMicId, + setSelectedCameraId, + handleCameraChange, + handleMicChange, + handleRememberDevicesChange, + } = useDevicePreferences({ + open: isClient, + availableCameras, + availableMics, + }); + + const micEnabled = selectedMicId !== null; + const organisationId = user?.defaultOrgId ?? undefined; + + useEffect(() => { + if ( + recordingMode === "camera" && + !selectedCameraId && + availableCameras.length > 0 + ) { + setSelectedCameraId(availableCameras[0]?.deviceId ?? null); + } + }, [recordingMode, selectedCameraId, availableCameras, setSelectedCameraId]); + + const { + phase, + durationMs, + hasAudioTrack, + chunkUploads, + errorDownload, + isRecording, + isBusy, + isRestarting, + canStartRecording, + isBrowserSupported, + unsupportedReason, + supportsDisplayRecording, + supportCheckCompleted, + screenCaptureWarning, + startRecording, + pauseRecording, + resumeRecording, + stopRecording, + restartRecording, + } = useWebRecorder({ + organisationId, + selectedMicId, + micEnabled, + recordingMode, + selectedCameraId, + isProUser: user?.isPro ?? false, + onRecordingSurfaceDetected: (mode) => { + setRecordingMode(mode); + }, + onRecordingStart: handleRecordingStartSound, + onRecordingStop: handleRecordingStopSound, + }); + + + useEffect(() => { + if ( + !supportCheckCompleted || + supportsDisplayRecording || + recordingMode === "camera" + ) { + return; + } + + setRecordingMode("camera"); + }, [supportCheckCompleted, supportsDisplayRecording, recordingMode]); + + const handleStopClick = () => { + stopRecording().catch((err: unknown) => { + console.error("Stop recording error", err); + }); + }; + + const recordingTimerDisplayMs = user?.isPro + ? durationMs + : Math.max(0, FREE_PLAN_MAX_RECORDING_MS - durationMs); + + const showInProgressBar = isRecording || isBusy || phase === "error"; + + const formatDuration = (ms: number) => { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; + }; + + if (!user) { + return ( +
+
+ +
+

+ Sign in to Cap +

+

+ Sign in to start recording your screen +

+
+ +
+
+ ); + } + + if (!isClient) { + return ( +
+
+
+ + + + +

Loading...

+
+
+
+ ); + } + + return ( + <> +
+
+
+ +

Cap

+
+ +
+ +
+
+
+
+ {(user.name || user.email).charAt(0).toUpperCase()} +
+ + {user.name || user.email} + +
+
+ + +
+
+
+ +
+
+ +
+
+ + {screenCaptureWarning && ( +
+ {screenCaptureWarning} +
+ )} + +
+ { + setCameraSelectOpen(isOpen); + if (isOpen) { + setMicSelectOpen(false); + } + }} + onCameraChange={handleCameraChange} + onRefreshDevices={refreshCameras} + /> +
+ +
+ { + setMicSelectOpen(isOpen); + if (isOpen) { + setCameraSelectOpen(false); + } + }} + onMicChange={handleMicChange} + onRefreshDevices={refreshMics} + /> +
+ + {isRecording && ( +
+ + {formatDuration(recordingTimerDisplayMs)} +
+ )} + + + + {!isBrowserSupported && unsupportedReason && ( +
+ {unsupportedReason} +
+ )} + + + {settingsPanelOpen && ( + +
+ +

+ Recorder settings +

+ +
+
+
+
+

+ Automatically select your last webcam/microphone +

+

+ If available, the last used camera and mic will be + automatically selected. +

+
+ +
+
+
+ )} +
+
+ + {showInProgressBar && ( + + )} + + {selectedCameraId && ( + handleCameraChange(null)} + /> + )} + + ); +}; diff --git a/apps/web/app/(org)/record/layout.tsx b/apps/web/app/(org)/record/layout.tsx new file mode 100644 index 0000000000..98f75b25f8 --- /dev/null +++ b/apps/web/app/(org)/record/layout.tsx @@ -0,0 +1,18 @@ +import { AuthContextProvider } from "@/app/Layout/AuthContext"; +import { resolveCurrentUser } from "@/app/Layout/current-user"; +import { runPromise } from "@/lib/server"; +import { UploadingProvider } from "../dashboard/caps/UploadingContext"; + +export const dynamic = "force-dynamic"; + +export default async function RecordLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/(org)/record/page.tsx b/apps/web/app/(org)/record/page.tsx new file mode 100644 index 0000000000..14a73eb31d --- /dev/null +++ b/apps/web/app/(org)/record/page.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { RecorderPageContent } from "./RecorderPageContent"; + +export default function RecordPage() { + const router = useRouter(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return null; + } + + return ( +
+
+

+ Browser Recorder +

+

+ Record your screen, window, or tab directly from your browser. Select + your recording options and start capturing. +

+

+ Download the + + Cap desktop app + + to record over any browser or application. +

+
+ +
+ +
+
+ ); +}