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
18 changes: 16 additions & 2 deletions apps/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,8 @@ struct RecordingsArgs {
enum RecordingsCommands {
/// List '.cap' recordings discovered on disk
List(RecordingsListArgs),
/// Fetch AI summary, title, and chapters from a share link or video ID
Info(RecordingsInfoArgs),
}

#[derive(Args)]
Expand All @@ -396,6 +398,14 @@ struct RecordingsListArgs {
format: OutputFormat,
}

#[derive(Args)]
struct RecordingsInfoArgs {
/// Share URL or video ID (e.g. https://cap.so/s/abc123xyz or abc123xyz)
target: String,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
}

#[derive(Args)]
struct DesktopArgs {
#[command(subcommand)]
Expand Down Expand Up @@ -584,7 +594,7 @@ async fn run(cli: Cli) -> Result<(), String> {
None => args.run(json).await,
},
Commands::Screenshot(s) => s.run(json).await,
Commands::Recordings(args) => args.run(json),
Commands::Recordings(args) => args.run(json).await,
Commands::Upload(args) => args.run(json).await,
Commands::Update(args) => {
let format = resolve_format(json, args.format);
Expand Down Expand Up @@ -724,12 +734,16 @@ impl ProjectArgs {
}

impl RecordingsArgs {
fn run(self, json: bool) -> Result<(), String> {
async fn run(self, json: bool) -> Result<(), String> {
match self.command {
RecordingsCommands::List(args) => {
let format = resolve_format(json, args.format);
finish_json(format, recordings::list(args.dir, format))
}
RecordingsCommands::Info(args) => {
let format = resolve_format(json, args.format);
finish_json(format, recordings::info(args.target, format).await)
}
}
}
}
Expand Down
55 changes: 55 additions & 0 deletions apps/cli/src/recordings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,58 @@ pub fn list(dir: Option<PathBuf>, format: OutputFormat) -> Result<(), String> {
}
}
}

pub async fn info(url_or_id: String, format: OutputFormat) -> Result<(), String> {
let video_id = if url_or_id.contains('/') {
url_or_id
.rsplit('/')
.next()
.unwrap_or(&url_or_id)
.to_string()
} else {
url_or_id
Comment on lines +110 to +118

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Trailing slash empties video ID

When a valid share URL ends with /, rsplit('/').next() returns an empty string, so the metadata request sends an empty videoId and the command fails with a 400 response. Parse the URL and select the final non-empty path segment, as the existing CLI ID parser does.

Knowledge Base Used: Cap CLI (apps/cli)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/cli/src/recordings.rs
Line: 110-118

Comment:
**Trailing slash empties video ID**

When a valid share URL ends with `/`, `rsplit('/').next()` returns an empty string, so the metadata request sends an empty `videoId` and the command fails with a 400 response. Parse the URL and select the final non-empty path segment, as the existing CLI ID parser does.

**Knowledge Base Used:** [Cap CLI (`apps/cli`)](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/docs/cli.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

};

let server_url = std::env::var("CAP_SERVER_URL")
.unwrap_or_else(|_| "https://cap.so".to_string());

let endpoint = format!("{}/api/video/metadata?videoId={}", server_url.trim_end_matches('/'), video_id);
let client = reqwest::Client::new();
let response = client
.get(&endpoint)
.send()
.await
.map_err(|e| format!("Failed to fetch video info: {e}"))?;

if !response.status().is_success() {
return Err(format!("Server returned error status: {}", response.status()));
}

let val: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse response JSON: {e}"))?;

match format {
OutputFormat::Json => write_json(&val),
OutputFormat::Text => {
if let Some(title) = val.get("title").and_then(|v| v.as_str()) {
println!("Title: {}", title);
}
if let Some(summary) = val.get("summary").and_then(|v| v.as_str()) {
println!("Summary:\n{}", summary);
}
if let Some(chapters) = val.get("chapters").and_then(|v| v.as_array()) {
if !chapters.is_empty() {
println!("\nChapters:");
for chapter in chapters {
let t = chapter.get("title").and_then(|v| v.as_str()).unwrap_or("");
let s = chapter.get("start").and_then(|v| v.as_f64()).unwrap_or(0.0);
println!(" - [{:.1}s] {}", s, t);
}
}
}
Ok(())
}
}
}
2 changes: 2 additions & 0 deletions apps/desktop/src-tauri/src/general_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ pub struct GeneralSettingsStore {
pub enable_notifications: bool,
#[serde(default)]
pub disable_auto_open_links: bool,
#[serde(default)]
pub disable_animations: bool,
#[serde(default = "default_true")]
pub has_completed_startup: bool,
#[serde(default)]
Expand Down
39 changes: 34 additions & 5 deletions apps/desktop/src-tauri/src/hotkeys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,38 @@ use tracing::instrument;
#[derive(Serialize, Deserialize, Type, PartialEq, Clone, Copy, Debug)]
pub struct Hotkey {
#[specta(type = String)]
code: Code,
meta: bool,
ctrl: bool,
alt: bool,
shift: bool,
pub code: Code,
pub meta: bool,
pub ctrl: bool,
pub alt: bool,
pub shift: bool,
}

impl Hotkey {
pub fn to_accelerator_string(&self) -> String {
let mut parts = Vec::new();
if self.meta {
parts.push("CmdOrCtrl");
}
if self.ctrl {
parts.push("Ctrl");
}
if self.alt {
parts.push("Alt");
}
if self.shift {
parts.push("Shift");
}
let code_str = format!("{:?}", self.code);
parts.push(&code_str);
parts.join("+")
}
}

pub fn get_hotkey_accelerator(app: &AppHandle, action: HotkeyAction) -> Option<String> {
let state = app.try_state::<HotkeysState>()?;
let store = state.lock().ok()?;
store.hotkeys.get(&action).map(|h| h.to_accelerator_string())
}

impl From<Hotkey> for Shortcut {
Expand Down Expand Up @@ -362,6 +389,8 @@ pub fn set_hotkey(app: AppHandle, action: HotkeyAction, hotkey: Option<Hotkey>)
global_shortcut.register(Shortcut::from(hotkey)).ok();
}

tray::refresh_tray_menu_for_app(&app);

Ok(())
}

Expand Down
16 changes: 9 additions & 7 deletions apps/desktop/src-tauri/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,56 +451,58 @@ fn build_tray_menu(app: &AppHandle, cache: &PreviousItemsCache) -> tauri::Result
None::<&str>,
)?)?;

use crate::hotkeys::{HotkeyAction, get_hotkey_accelerator};

if is_screenshot_mode {
menu.append(&MenuItem::with_id(
app,
TrayItem::RecordDisplay,
"Screenshot Display",
true,
None::<&str>,
get_hotkey_accelerator(app, HotkeyAction::ScreenshotDisplay),
)?)?;
menu.append(&MenuItem::with_id(
app,
TrayItem::RecordWindow,
"Screenshot Window",
true,
None::<&str>,
get_hotkey_accelerator(app, HotkeyAction::ScreenshotWindow),
)?)?;
menu.append(&MenuItem::with_id(
app,
TrayItem::RecordArea,
"Screenshot Area",
true,
None::<&str>,
get_hotkey_accelerator(app, HotkeyAction::ScreenshotArea),
)?)?;
} else {
menu.append(&MenuItem::with_id(
app,
TrayItem::RecordDisplay,
"Record Display",
true,
None::<&str>,
get_hotkey_accelerator(app, HotkeyAction::OpenRecordingPickerDisplay),
)?)?;
menu.append(&MenuItem::with_id(
app,
TrayItem::RecordWindow,
"Record Window",
true,
None::<&str>,
get_hotkey_accelerator(app, HotkeyAction::OpenRecordingPickerWindow),
)?)?;
menu.append(&MenuItem::with_id(
app,
TrayItem::RecordArea,
"Record Area",
true,
None::<&str>,
get_hotkey_accelerator(app, HotkeyAction::OpenRecordingPickerArea),
)?)?;
menu.append(&MenuItem::with_id(
app,
TrayItem::TakeScreenshot,
"Take a Screenshot",
true,
None::<&str>,
get_hotkey_accelerator(app, HotkeyAction::ScreenshotDisplay),
)?)?;
}

Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/routes/(window-chrome)/settings/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,20 @@ function Inner(props: {
}}
/>

<Section
title="Interface & Performance"
description="Adjust interface visuals and animations for peak performance."
>
<SectionRows>
<ToggleSettingItem
label="Disable visual animations"
description="Turn off interface entry animations and capture overlays for a faster, low-latency workflow."
value={!!settings.disableAnimations}
onChange={(v) => handleChange("disableAnimations", v)}
/>
</SectionRows>
</Section>

{ostype === "macos" && (
<Section
title="App"
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/routes/teleprompter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,11 @@ export default function Teleprompter() {
return;
}

resizeEditor();
const element = scrollElement;
if (!element || !hasScript()) return;
const currentScrollTop = element.scrollTop;
resizeEditor();
element.scrollTop = currentScrollTop;
const maximumScroll = Math.max(
0,
element.scrollHeight - element.clientHeight,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/utils/general-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type GeneralSettingsStore = TauriGeneralSettingsStore & {
transcriptionHints?: string[];
enableTelemetry?: boolean;
outOfProcessMuxer?: boolean;
disableAnimations?: boolean;
};

export const DEFAULT_TRANSCRIPTION_HINTS = [
Expand Down Expand Up @@ -53,6 +54,7 @@ export function createDefaultGeneralSettings(): GeneralSettingsStore {
maxFps: 60,
transcriptionHints: [...DEFAULT_TRANSCRIPTION_HINTS],
enableTelemetry: true,
disableAnimations: false,
};
}

Expand Down
10 changes: 7 additions & 3 deletions apps/mobile/src/recording/TeleprompterOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,13 @@ export function TeleprompterOverlay({
};

const onTextLayout = (event: LayoutChangeEvent) => {
cancelAnimation(progress);
progress.value = 0;
setTextHeight(event.nativeEvent.layout.height);
const newHeight = event.nativeEvent.layout.height;
setTextHeight((prev) => {
if (prev === 0) {
progress.value = 0;
}
return newHeight;
});
};

return (
Expand Down
65 changes: 65 additions & 0 deletions apps/web/__tests__/unit/rate-limit-ids.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { RATE_LIMIT_IDS } from "../../lib/rate-limit";

// Rate limit IDs declared in advance for firewall rules or separate app packages
// that are intentionally not yet wired in apps/web endpoints.
const UNWIRED_RATE_LIMIT_IDS = new Set([
"AUTH_OTP_VERIFY",
"AUTH_OTP_SEND",
"LOOM_DOWNLOAD",
"MESSENGER_MESSAGE",
"DESKTOP_LOGS",
]);

function getAllTsFiles(dir: string): string[] {
let results: string[] = [];
const list = readdirSync(dir);
for (const file of list) {
const filePath = join(dir, file);
const stat = statSync(filePath);
if (stat && stat.isDirectory()) {
if (file !== "node_modules" && file !== ".next" && file !== "dist") {
results = results.concat(getAllTsFiles(filePath));
}
} else if (file.endsWith(".ts") || file.endsWith(".tsx")) {
if (!filePath.endsWith("lib/rate-limit.ts") && !filePath.endsWith("rate-limit-ids.test.ts")) {
results.push(filePath);
}
}
}
return results;
}

describe("RATE_LIMIT_IDS reference contract", () => {
it("ensures every active declared RATE_LIMIT_ID is referenced outside lib/rate-limit.ts", () => {
const webAppDir = join(process.cwd());
const tsFiles = getAllTsFiles(webAppDir);

let combinedSource = "";
for (const file of tsFiles) {
combinedSource += readFileSync(file, "utf8") + "\n";
}

const unreferencedKeys: string[] = [];

for (const [key, value] of Object.entries(RATE_LIMIT_IDS)) {
if (UNWIRED_RATE_LIMIT_IDS.has(key)) {
continue;
}

const hasKeyRef = combinedSource.includes(`RATE_LIMIT_IDS.${key}`);
const hasValueRef = combinedSource.includes(`"${value}"`) || combinedSource.includes(`'${value}'`);

if (!hasKeyRef && !hasValueRef) {
unreferencedKeys.push(key);
}
}

expect(
unreferencedKeys,
`The following RATE_LIMIT_IDS are declared but never referenced: ${unreferencedKeys.join(", ")}`,
).toEqual([]);
});
});
12 changes: 12 additions & 0 deletions apps/web/app/api/analytics/track/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
createAnonymousViewNotification,
sendFirstViewEmail,
} from "@/lib/Notification";
import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit";
import { runPromise } from "@/lib/server";

interface TrackPayload {
Expand Down Expand Up @@ -42,6 +43,17 @@ const decodeUrlEncodedHeaderValue = (value?: string | null) => {
};

export async function POST(request: NextRequest) {
if (
await isRateLimited(RATE_LIMIT_IDS.ANALYTICS_TRACK, {
headers: request.headers,
})
) {
return Response.json(
{ error: "Too many tracking requests. Please try again later." },
{ status: 429 },
);
}

let body: TrackPayload;
try {
body = (await request.json()) as TrackPayload;
Expand Down
Loading