Skip to content
Open
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
};

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);
Comment on lines +111 to +124

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 Share URL parsing mangles IDs

When a valid share URL has a trailing slash or query parameters, rsplit('/').next() produces an empty ID or retains the query string in the ID. The metadata request then receives an invalid videoId and returns 400 or 404 for an otherwise valid video.

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: 111-124

Comment:
**Share URL parsing mangles IDs**

When a valid share URL has a trailing slash or query parameters, `rsplit('/').next()` produces an empty ID or retains the query string in the ID. The metadata request then receives an invalid `videoId` and returns 400 or 404 for an otherwise valid video.

**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 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(())
}
}
}
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
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
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
40 changes: 40 additions & 0 deletions apps/web/app/api/developer/v1/[...route]/videos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,43 @@ app.get("/:id/status", async (c) => {
},
});
});

app.get("/:id/transcript", async (c) => {
const appId = c.get("developerAppId");
const videoId = c.req.param("id");

const [video] = await db()
.select()
.from(developerVideos)
.where(
and(
eq(developerVideos.id, videoId),
eq(developerVideos.appId, appId),
isNull(developerVideos.deletedAt),
),
)
.limit(1);

if (!video) {
return c.json({ error: "Video not found" }, 404);
}

if (video.transcriptionStatus === "PROCESSING") {
return c.json({ error: "Transcript still processing" }, 202);
}

if (video.transcriptionStatus !== "COMPLETE") {
return c.json(
{ error: "No transcript available", status: video.transcriptionStatus },
400,
);
}

return c.json({
data: {
id: video.id,
transcriptionStatus: video.transcriptionStatus,
s3Key: video.s3Key,
},
});
Comment on lines +158 to +164

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 Transcript response returns video key

When transcription is complete, this endpoint returns developerVideos.s3Key, which identifies the raw uploaded video rather than the separately stored transcription.vtt object. Because the response includes neither transcript content nor a signed transcript URL, API consumers cannot download the completed transcript.

Knowledge Base Used: Infra, Storage and Config

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/developer/v1/[...route]/videos.ts
Line: 158-164

Comment:
**Transcript response returns video key**

When transcription is complete, this endpoint returns `developerVideos.s3Key`, which identifies the raw uploaded video rather than the separately stored `transcription.vtt` object. Because the response includes neither transcript content nor a signed transcript URL, API consumers cannot download the completed transcript.

**Knowledge Base Used:** [Infra, Storage and Config](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/docs/infra-storage-config.md)

---

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

});
Loading