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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ tauri.windows.conf.json
# Cursor
.cursor
.env*.local
.docs/
16 changes: 16 additions & 0 deletions apps/desktop/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,21 @@ export default defineConfig({
define: {
"import.meta.vitest": "undefined",
},
optimizeDeps: {
include: [
"@tauri-apps/plugin-os",
"@tanstack/solid-query",
"@tauri-apps/api/webviewWindow",
"@tauri-apps/plugin-dialog",
"@tauri-apps/plugin-store",
"posthog-js",
"uuid",
"@tauri-apps/plugin-clipboard-manager",
"@tauri-apps/api/window",
"@tauri-apps/api/core",
"@tauri-apps/api/event",
"cva",
],
},
}),
});
4 changes: 2 additions & 2 deletions apps/desktop/src-tauri/src/general_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ fn default_enable_native_camera_preview() -> bool {
}

fn default_enable_new_recording_flow() -> bool {
true
false
}

fn no(_: &bool) -> bool {
Expand Down Expand Up @@ -259,7 +259,7 @@ pub fn init(app: &AppHandle) {
};

if !store.recording_picker_preference_set {
store.enable_new_recording_flow = true;
store.enable_new_recording_flow = false;
store.recording_picker_preference_set = true;
}

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2699,6 +2699,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
async move {
if !permissions.screen_recording.permitted()
|| !permissions.accessibility.permitted()
|| !permissions.microphone.permitted()
|| !permissions.camera.permitted()
Comment on lines 2700 to +2703
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Read context around lines 2700-2703 to understand the permission flow
sed -n '2685,2730p' apps/desktop/src-tauri/src/lib.rs

Repository: CapSoftware/Cap

Length of output: 1862


🏁 Script executed:

# Find where the permissions check is implemented
rg "fn do_permissions_check" --max-count 5 -B 2 -A 20

Repository: CapSoftware/Cap

Length of output: 2006


🏁 Script executed:

# Find the OSPermissionsCheck struct and its permitted() method
rg "struct OSPermissionsCheck|fn permitted" --max-count 10 -B 2 -A 5

Repository: CapSoftware/Cap

Length of output: 1107


🏁 Script executed:

# Look for the Setup window implementation to see if it's skippable
rg "ShowCapWindow::Setup" --max-count 10 -B 5 -A 10

Repository: CapSoftware/Cap

Length of output: 2610


🏁 Script executed:

# Look for the Setup window file or implementation
fd "setup" --type f --extension tsx --extension ts --extension rs | head -20

Repository: CapSoftware/Cap

Length of output: 265


🏁 Script executed:

# Read the setup window implementation
cat -n apps/desktop/src/routes/\(window-chrome\)/setup.tsx | head -100

Repository: CapSoftware/Cap

Length of output: 3350


🏁 Script executed:

# Continue reading the setup window implementation
sed -n '100,250p' apps/desktop/src/routes/\(window-chrome\)/setup.tsx

Repository: CapSoftware/Cap

Length of output: 4244


Confirm that mandatory microphone and camera permissions at startup align with product goals.

The Setup window requires users to grant all four permissions (screen recording, accessibility, microphone, and camera) before accessing the main application. The "Continue" button is disabled until every permission shows as granted or not needed. Users cannot proceed without granting all permissions, even if they only intend to use screen recording features.

While the PR indicates this expansion is intentional, verify with product/design that forcing microphone and camera grants at initial onboarding is the desired UX pattern. This creates a hard gate on features that users may not need, potentially causing drop-off during onboarding. If these permissions are truly optional features, consider deferring their requests until users attempt to use those specific features.

🤖 Prompt for AI Agents
In apps/desktop/src-tauri/src/lib.rs around lines 2700-2703, the setup gating
logic requires microphone and camera permissions alongside screen recording and
accessibility before enabling Continue; confirm with product/design whether mic
and camera should be mandatory at onboarding, and if they are optional change
the gating logic to only require truly mandatory permissions (e.g., screen
recording and accessibility) and defer requesting mic/camera until the user
invokes features that need them; alternatively, if they must remain optional but
still shown, update the UI to indicate they are optional and allow Continue when
required permissions are granted, and implement lazy permission prompts where
mic/camera requests are triggered at feature use.

|| GeneralSettingsStore::get(&app)
.ok()
.flatten()
Expand Down
110 changes: 106 additions & 4 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { Router, useCurrentMatches } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { Route, Router, useCurrentMatches } from "@solidjs/router";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import {
getCurrentWebviewWindow,
type WebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import { message } from "@tauri-apps/plugin-dialog";
import { createEffect, onCleanup, onMount, Suspense } from "solid-js";
import { createEffect, lazy, onCleanup, onMount, Suspense } from "solid-js";
import { Toaster } from "solid-toast";

import "@cap/ui-solid/main.css";
Expand All @@ -19,6 +18,61 @@ import { initAnonymousUser } from "./utils/analytics";
import { type AppTheme, commands } from "./utils/tauri";
import titlebar from "./utils/titlebar-state";

const WindowChromeLayout = lazy(() => import("./routes/(window-chrome)"));
const MainPage = lazy(() => import("./routes/(window-chrome)/(main)"));
const NewMainPage = lazy(() => import("./routes/(window-chrome)/new-main"));
const SetupPage = lazy(() => import("./routes/(window-chrome)/setup"));
const SettingsLayout = lazy(() => import("./routes/(window-chrome)/settings"));
const SettingsGeneralPage = lazy(
() => import("./routes/(window-chrome)/settings/general"),
);
const SettingsRecordingsPage = lazy(
() => import("./routes/(window-chrome)/settings/recordings"),
);
const SettingsScreenshotsPage = lazy(
() => import("./routes/(window-chrome)/settings/screenshots"),
);
const SettingsHotkeysPage = lazy(
() => import("./routes/(window-chrome)/settings/hotkeys"),
);
const SettingsChangelogPage = lazy(
() => import("./routes/(window-chrome)/settings/changelog"),
);
const SettingsFeedbackPage = lazy(
() => import("./routes/(window-chrome)/settings/feedback"),
);
const SettingsExperimentalPage = lazy(
() => import("./routes/(window-chrome)/settings/experimental"),
);
const SettingsLicensePage = lazy(
() => import("./routes/(window-chrome)/settings/license"),
);
const SettingsIntegrationsPage = lazy(
() => import("./routes/(window-chrome)/settings/integrations"),
);
const SettingsS3ConfigPage = lazy(
() => import("./routes/(window-chrome)/settings/integrations/s3-config"),
);
const UpgradePage = lazy(() => import("./routes/(window-chrome)/upgrade"));
const UpdatePage = lazy(() => import("./routes/(window-chrome)/update"));
const CameraPage = lazy(() => import("./routes/camera"));
const CaptureAreaPage = lazy(() => import("./routes/capture-area"));
const DebugPage = lazy(() => import("./routes/debug"));
const EditorPage = lazy(() => import("./routes/editor"));
const InProgressRecordingPage = lazy(
() => import("./routes/in-progress-recording"),
);
const ModeSelectPage = lazy(() => import("./routes/mode-select"));
const NotificationsPage = lazy(() => import("./routes/notifications"));
const RecordingsOverlayPage = lazy(() => import("./routes/recordings-overlay"));
const ScreenshotEditorPage = lazy(() => import("./routes/screenshot-editor"));
const TargetSelectOverlayPage = lazy(
() => import("./routes/target-select-overlay"),
);
const WindowCaptureOccluderPage = lazy(
() => import("./routes/window-capture-occluder"),
);

const queryClient = new QueryClient({
defaultOptions: {
queries: {
Expand Down Expand Up @@ -97,7 +151,55 @@ function Inner() {
);
}}
>
<FileRoutes />
<Route path="/" component={WindowChromeLayout}>
<Route path="/" component={MainPage} />
<Route path="/new-main" component={NewMainPage} />
<Route path="/setup" component={SetupPage} />
<Route path="/settings" component={SettingsLayout}>
<Route path="/" component={SettingsGeneralPage} />
<Route path="/general" component={SettingsGeneralPage} />
<Route path="/recordings" component={SettingsRecordingsPage} />
<Route path="/screenshots" component={SettingsScreenshotsPage} />
<Route path="/hotkeys" component={SettingsHotkeysPage} />
<Route path="/changelog" component={SettingsChangelogPage} />
<Route path="/feedback" component={SettingsFeedbackPage} />
<Route
path="/experimental"
component={SettingsExperimentalPage}
/>
<Route path="/license" component={SettingsLicensePage} />
<Route
path="/integrations"
component={SettingsIntegrationsPage}
/>
<Route
path="/integrations/s3-config"
component={SettingsS3ConfigPage}
/>
</Route>
<Route path="/upgrade" component={UpgradePage} />
<Route path="/update" component={UpdatePage} />
</Route>
<Route path="/camera" component={CameraPage} />
<Route path="/capture-area" component={CaptureAreaPage} />
<Route path="/debug" component={DebugPage} />
<Route path="/editor" component={EditorPage} />
<Route
path="/in-progress-recording"
component={InProgressRecordingPage}
/>
<Route path="/mode-select" component={ModeSelectPage} />
<Route path="/notifications" component={NotificationsPage} />
<Route path="/recordings-overlay" component={RecordingsOverlayPage} />
<Route path="/screenshot-editor" component={ScreenshotEditorPage} />
<Route
path="/target-select-overlay"
component={TargetSelectOverlayPage}
/>
<Route
path="/window-capture-occluder"
component={WindowCaptureOccluderPage}
/>
</Router>
</CapErrorBoundary>
</>
Expand Down
16 changes: 13 additions & 3 deletions apps/desktop/src/entry-client.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client";
import { type } from "@tauri-apps/plugin-os";

document.documentElement.classList.add(`platform-${type()}`);
mount(() => <StartClient />, document.getElementById("app")!);
async function initApp() {
try {
const { type } = await import("@tauri-apps/plugin-os");
const osType = type();
document.documentElement.classList.add(`platform-${osType}`);
} catch (error) {
console.error("Failed to get OS type:", error);
}

mount(() => <StartClient />, document.getElementById("app")!);
}

initApp();
6 changes: 0 additions & 6 deletions apps/desktop/src/routes/(window-chrome).tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,6 @@ import {
WindowChromeContext,
} from "./(window-chrome)/Context";

export const route = {
info: {
AUTO_SHOW_WINDOW: false,
},
};

export default function (props: RouteSectionProps) {
let unlistenResize: UnlistenFn | undefined;

Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/routes/(window-chrome)/setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ const permissions = [
description:
"During recording, Cap collects mouse activity locally to generate automatic zoom in segments.",
},
{
name: "Microphone",
key: "microphone" as const,
description: "This permission is required to record audio in your Caps.",
},
{
name: "Camera",
key: "camera" as const,
description:
"This permission is required to record your camera in your Caps.",
},
] as const;

export default function () {
Expand Down Expand Up @@ -83,7 +94,6 @@ export default function () {
);

const handleContinue = () => {
// Just proceed to the main window without saving mode to store
commands.showWindow({ Main: { init_target_mode: null } }).then(() => {
getCurrentWindow().close();
});
Expand Down
120 changes: 110 additions & 10 deletions apps/desktop/src/routes/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,15 @@ import { ExportDialog } from "./ExportDialog";
import { Header } from "./Header";
import { PlayerContent } from "./Player";
import { Timeline } from "./Timeline";
import { Dialog, DialogContent, EditorButton, Input, Subfield } from "./ui";
import {
Dialog,
DialogContent,
EditorButton,
Input,
Slider,
Subfield,
} from "./ui";
import { formatTime } from "./utils";

const DEFAULT_TIMELINE_HEIGHT = 260;
const MIN_PLAYER_CONTENT_HEIGHT = 320;
Expand Down Expand Up @@ -414,13 +422,61 @@ function Dialogs() {
})()}
>
{(dialog) => {
const { setProject: setState, editorInstance } =
useEditorContext();
const {
setProject: setState,
editorInstance,
editorState,
totalDuration,
project,
} = useEditorContext();
const display = editorInstance.recordings.segments[0].display;

let cropperRef: CropperRef | undefined;
let videoRef: HTMLVideoElement | undefined;
const [crop, setCrop] = createSignal(CROP_ZERO);
const [aspect, setAspect] = createSignal<Ratio | null>(null);
const [previewTime, setPreviewTime] = createSignal(
editorState.playbackTime,
);
const [videoLoaded, setVideoLoaded] = createSignal(false);

const currentSegment = createMemo(() => {
const time = previewTime();
let elapsed = 0;
for (const seg of project.timeline?.segments ?? []) {
const segDuration = (seg.end - seg.start) / seg.timescale;
if (time < elapsed + segDuration) {
return {
index: seg.recordingSegment ?? 0,
localTime: seg.start / seg.timescale + (time - elapsed),
};
}
elapsed += segDuration;
}
return { index: 0, localTime: 0 };
});
Copy link

Choose a reason for hiding this comment

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

Bug: Slider at end position shows wrong frame

The currentSegment memo uses strict less-than comparison (time < elapsed + segDuration) which fails when the slider is at the maximum position. When previewTime() equals totalDuration(), the condition time < elapsed + segDuration evaluates to false for all segments, causing the fallback { index: 0, localTime: 0 } to be returned. This displays the first frame of the first segment instead of the last frame of the last segment when the user drags the timeline slider to the end.

Fix in Cursor Fix in Web


const videoSrc = createMemo(() =>
convertFileSrc(
`${editorInstance.path}/content/segments/segment-${currentSegment().index}/display.mp4`,
),
);

createEffect(
on(
() => currentSegment().index,
() => {
setVideoLoaded(false);
},
{ defer: true },
),
);

createEffect(() => {
if (videoRef && videoLoaded()) {
videoRef.currentTime = currentSegment().localTime;
}
});

const initialBounds = {
x: dialog().position.x,
Expand Down Expand Up @@ -582,16 +638,60 @@ function Dialogs() {
allowLightMode={true}
onContextMenu={(e) => showCropOptionsMenu(e, true)}
>
<img
class="shadow pointer-events-none max-h-[70vh]"
alt="screenshot"
src={convertFileSrc(
`${editorInstance.path}/screenshots/display.jpg`,
)}
/>
<div class="relative">
<img
class="shadow pointer-events-none max-h-[70vh]"
alt="screenshot"
src={convertFileSrc(
`${editorInstance.path}/screenshots/display.jpg`,
)}
style={{
opacity: videoLoaded() ? 0 : 1,
transition: "opacity 150ms ease-out",
}}
/>
<video
ref={videoRef}
src={videoSrc()}
class="absolute inset-0 w-full h-full object-contain shadow pointer-events-none"
preload="auto"
muted
onLoadedData={() => setVideoLoaded(true)}
onError={() => setVideoLoaded(false)}
style={{
opacity: videoLoaded() ? 1 : 0,
transition: "opacity 150ms ease-out",
}}
/>
<Show when={!videoLoaded()}>
<div class="absolute bottom-2 right-2 flex items-center gap-1.5 bg-gray-500/80 rounded-full px-2 py-1">
<IconLucideLoaderCircle class="size-3 text-white animate-spin" />
<span class="text-[10px] text-white font-medium">
Loading
</span>
</div>
</Show>
</div>
</Cropper>
</div>
</div>
<div class="flex items-center gap-3 mt-4 px-2">
<span class="text-gray-11 text-sm tabular-nums min-w-[3rem]">
{formatTime(previewTime())}
</span>
<Slider
class="flex-1"
minValue={0}
maxValue={totalDuration()}
step={1 / FPS}
value={[previewTime()]}
onChange={([v]) => setPreviewTime(v)}
aria-label="Video timeline"
/>
<span class="text-gray-11 text-sm tabular-nums min-w-[3rem] text-right">
{formatTime(totalDuration())}
</span>
</div>
</Dialog.Content>
<Dialog.Footer>
<Button
Expand Down
Loading
Loading