Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
aefe0e6
feat(database): add index for owner non-screenshot video counts
richiemcilroy May 11, 2026
4158d11
feat(web-domain): add shareable link usage and limit error schemas
richiemcilroy May 11, 2026
f296f09
feat(web-domain): add shareable link limit to Loom HTTP and workflow
richiemcilroy May 11, 2026
1629a4d
feat(web-backend): add shareable link monthly quota helpers
richiemcilroy May 11, 2026
4f62d0c
feat(web-backend): enforce shareable link quota on video insert
richiemcilroy May 11, 2026
76efd38
feat(web-backend): map loom create activity to shareable link limit
richiemcilroy May 11, 2026
6a570a4
feat(web): add UTC month day ordinal date formatter
richiemcilroy May 11, 2026
923a6ea
feat(web): return monthly shareable link usage from billing API
richiemcilroy May 11, 2026
bfc1908
feat(web): load shareable link usage in dashboard layout
richiemcilroy May 11, 2026
545c79f
feat(web): show monthly share link quota in usage sidebar
richiemcilroy May 11, 2026
0afa11a
feat(web): enforce shareable link rules in desktop video API
richiemcilroy May 11, 2026
97a8ddc
feat(web): enforce shareable link limits on upload routes
richiemcilroy May 11, 2026
17c11d8
feat(web): validate free duration on media server progress complete
richiemcilroy May 11, 2026
9ce4a17
feat(web): enforce shareable quota in video server actions
richiemcilroy May 11, 2026
2b7f130
feat(web): enforce shareable quota in Loom import action
richiemcilroy May 11, 2026
e5b2ab9
feat(web): block over-limit durations in processing workflows
richiemcilroy May 11, 2026
bcb5656
feat(web): show shareable link usage on share page for owners
richiemcilroy May 11, 2026
94dd2dc
style(web): sentence-case upgrade CTA labels
richiemcilroy May 11, 2026
87b68f6
feat(web): toast shareable link limit in web recorder
richiemcilroy May 11, 2026
ce50fd1
test(web): mock shareable link quota in loom import test
richiemcilroy May 11, 2026
706b080
test(web): add shareable link usage helper tests
richiemcilroy May 11, 2026
2251dbe
feat(desktop): track plan shareable usage and drop upgrade window
richiemcilroy May 11, 2026
2f31950
feat(desktop): surface share link quota and open pricing externally
richiemcilroy May 11, 2026
974b129
feat(web-backend): add markShareableLinkUploadRejected helper
richiemcilroy May 11, 2026
4f7e2ec
fix(web-backend): detect shareable limits on nested Error causes
richiemcilroy May 11, 2026
f3299e6
fix(web-backend): skip error-phase uploads in shareable quotas
richiemcilroy May 11, 2026
0078056
test(web): cover wrapped shareable link limit errors
richiemcilroy May 11, 2026
480bede
fix(desktop): allow only http/https upgrade base URLs
richiemcilroy May 11, 2026
33627fb
fix(settings): expose true video totals in billing usage
richiemcilroy May 11, 2026
8709954
fix(webhooks): centralize quota rejection and reply 200 on limits
richiemcilroy May 11, 2026
9639e2b
refactor(web): unify shareable quota rejection in workflows
richiemcilroy May 11, 2026
81ed85e
feat(web): persist quota rejects when creating uploads
richiemcilroy May 11, 2026
160da3b
fix(upload): honor screenshot uploads in multipart quota check
richiemcilroy May 11, 2026
46d87c3
feat(upload): persist quota rejects on multipart completion
richiemcilroy May 11, 2026
a4fa658
feat(upload): mark quota rejects on recording-complete failures
richiemcilroy May 11, 2026
ccf2838
feat(upload): mark quota rejects on signed upload failures
richiemcilroy May 11, 2026
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
16 changes: 16 additions & 0 deletions apps/desktop/src-tauri/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ pub struct Plan {
pub upgraded: bool,
pub manual: bool,
pub last_checked: i32,
#[serde(default, rename = "shareableLinkUsage")]
pub shareable_link_usage: Option<ShareableLinkUsage>,
}

#[derive(Serialize, Deserialize, Type, Debug, Clone)]
pub struct ShareableLinkUsage {
pub used: i32,
pub limit: i32,
pub remaining: i32,
#[serde(rename = "resetAt")]
pub reset_at: String,
#[serde(rename = "maxDurationSeconds")]
pub max_duration_seconds: i32,
}

impl AuthStore {
Expand Down Expand Up @@ -91,6 +104,8 @@ impl AuthStore {
#[derive(Deserialize)]
struct Response {
upgraded: bool,
#[serde(rename = "shareableLinkUsage")]
shareable_link_usage: Option<ShareableLinkUsage>,
}

let plan_response: Response = response.json().await.map_err(|e| e.to_string())?;
Expand All @@ -99,6 +114,7 @@ impl AuthStore {
upgraded: plan_response.upgraded,
last_checked: chrono::Utc::now().timestamp() as i32,
manual: auth.plan.as_ref().is_some_and(|p| p.manual),
shareable_link_usage: plan_response.shareable_link_usage,
});
auth.organizations = api::fetch_organizations(app)
.await
Expand Down
65 changes: 48 additions & 17 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ mod window_position_persistence;
mod windows;

use audio::AppSounds;
use auth::{AuthStore, Plan};
use auth::{AuthStore, Plan, ShareableLinkUsage};
use camera::{CameraPreviewManager, CameraPreviewState};
use cap_editor::{EditorInstance, EditorState};
use cap_project::{
Expand Down Expand Up @@ -2951,9 +2951,15 @@ async fn upload_exported_video(
}
.await
{
Ok(data) => data,
Ok(data) => {
AuthStore::update_auth_plan(&app).await.ok();
data
}
Err(AuthedApiError::InvalidAuthentication) => return Ok(UploadResult::NotAuthenticated),
Err(AuthedApiError::UpgradeRequired) => return Ok(UploadResult::UpgradeRequired),
Err(AuthedApiError::UpgradeRequired) => {
AuthStore::update_auth_plan(&app).await.ok();
return Ok(UploadResult::UpgradeRequired);
}
Err(err) => return Err(err.to_string()),
};

Expand Down Expand Up @@ -2999,7 +3005,10 @@ async fn upload_exported_video(
NotificationType::ShareableLinkCopied.send(&app);
Ok(UploadResult::Success(uploaded_video.link))
}
Err(AuthedApiError::UpgradeRequired) => Ok(UploadResult::UpgradeRequired),
Err(AuthedApiError::UpgradeRequired) => {
AuthStore::update_auth_plan(&app).await.ok();
Ok(UploadResult::UpgradeRequired)
}
Err(e) => {
error!("Failed to upload video: {e}");

Expand Down Expand Up @@ -3031,7 +3040,7 @@ async fn upload_screenshot(
};

if !auth.is_upgraded() {
ShowCapWindow::Upgrade.show(&app).await.ok();
open_upgrade_page(app.clone())?;
return Ok(UploadResult::UpgradeRequired);
}

Expand Down Expand Up @@ -3334,6 +3343,10 @@ async fn check_upgraded_and_update(app: AppHandle) -> Result<bool, String> {
.get("upgraded")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let shareable_link_usage = plan_data
.get("shareableLinkUsage")
.cloned()
.and_then(|value| serde_json::from_value::<ShareableLinkUsage>(value).ok());
println!("Pro status: {is_pro}");
let updated_auth = AuthStore {
secret: auth.secret,
Expand All @@ -3342,6 +3355,7 @@ async fn check_upgraded_and_update(app: AppHandle) -> Result<bool, String> {
upgraded: is_pro,
manual: auth.plan.map(|p| p.manual).unwrap_or(false),
last_checked: chrono::Utc::now().timestamp() as i32,
shareable_link_usage,
}),
organizations: auth.organizations,
organizations_updated_at: auth.organizations_updated_at,
Expand All @@ -3362,6 +3376,34 @@ fn open_external_link(app: tauri::AppHandle, url: String) -> Result<(), String>
return Ok(());
}

app.shell()
.open(&url, None)
.map_err(|e| format!("Failed to open URL: {e}"))?;
if let Some(main) = CapWindowId::Main.get(&app) {
let _ = main.hide();
}
Ok(())
}

fn open_upgrade_page(app: tauri::AppHandle) -> Result<(), String> {
let server_url = GeneralSettingsStore::get(&app)
.ok()
.flatten()
.map(|settings| settings.server_url)
.unwrap_or_else(|| {
std::option_env!("VITE_SERVER_URL")
.unwrap_or("https://cap.so")
.to_string()
});
let server_url = reqwest::Url::parse(&server_url)
.ok()
.filter(|url| matches!(url.scheme(), "http" | "https"))
.map(|url| url.to_string())
.unwrap_or_else(|| "https://cap.so".to_string());
let url = format!(
"{}/pricing?utm_source=desktop&utm_campaign=upgrade",
server_url.trim_end_matches('/')
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since server_url can come from user settings, it may be worth validating the scheme before building/launching the URL (e.g. allow http:///https:// only) so we don’t accidentally open arbitrary schemes if someone misconfigures server_url.

);
app.shell()
.open(&url, None)
.map_err(|e| format!("Failed to open URL: {e}"))?;
Expand Down Expand Up @@ -4146,7 +4188,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
CapWindowId::Camera.label().as_str(),
CapWindowId::RecordingsOverlay.label().as_str(),
CapWindowId::RecordingControls.label().as_str(),
CapWindowId::Upgrade.label().as_str(),
"editor",
"screenshot-editor",
])
Expand Down Expand Up @@ -4643,7 +4684,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
#[cfg(target_os = "macos")]
return;
}
CapWindowId::Upgrade | CapWindowId::ModeSelect => {
CapWindowId::ModeSelect => {
for (label, window) in app.webview_windows() {
if let Ok(id) = CapWindowId::from_str(&label) {
match id {
Expand Down Expand Up @@ -4710,16 +4751,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
WindowEvent::Focused(focused) => {
let window_id = CapWindowId::from_str(label);

if matches!(window_id, Ok(CapWindowId::Upgrade)) {
for (label, window) in app.webview_windows() {
if let Ok(id) = CapWindowId::from_str(&label)
&& matches!(id, CapWindowId::TargetSelectOverlay { .. })
{
hide_overlay(&window);
}
}
}

if *focused
&& let Ok(window_id) = window_id
&& window_id.activates_dock()
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src-tauri/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,7 @@ pub async fn start_recording(
return Ok(RecordingAction::InvalidAuthentication);
}
Err(AuthedApiError::UpgradeRequired) => {
AuthStore::update_auth_plan(&app).await.ok();
return Ok(RecordingAction::UpgradeRequired);
}
Err(err) => {
Expand All @@ -791,6 +792,8 @@ pub async fn start_recording(
}
};

AuthStore::update_auth_plan(&app).await.ok();

let link = app.make_app_url(format!("/s/{}", s3_config.id)).await;
info!("Pre-created shareable link: {}", link);

Expand Down
42 changes: 0 additions & 42 deletions apps/desktop/src-tauri/src/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,6 @@ pub enum CapWindowId {
CaptureArea,
Camera,
RecordingControls,
Upgrade,
ModeSelect,
Debug,
ScreenshotEditor { id: u32 },
Expand All @@ -742,7 +741,6 @@ impl FromStr for CapWindowId {
// legacy identifier
"in-progress-recording" => Self::RecordingControls,
"recordings-overlay" => Self::RecordingsOverlay,
"upgrade" => Self::Upgrade,
"mode-select" => Self::ModeSelect,
"debug" => Self::Debug,
"onboarding" => Self::Onboarding,
Expand Down Expand Up @@ -790,7 +788,6 @@ impl std::fmt::Display for CapWindowId {
}
Self::RecordingControls => write!(f, "in-progress-recording"), // legacy identifier
Self::RecordingsOverlay => write!(f, "recordings-overlay"),
Self::Upgrade => write!(f, "upgrade"),
Self::ModeSelect => write!(f, "mode-select"),
Self::Editor { id } => write!(f, "editor-{id}"),
Self::Debug => write!(f, "debug"),
Expand Down Expand Up @@ -830,7 +827,6 @@ impl CapWindowId {
| Self::Editor { .. }
| Self::ScreenshotEditor { .. }
| Self::Settings
| Self::Upgrade
| Self::ModeSelect
| Self::Onboarding
)
Expand Down Expand Up @@ -884,7 +880,6 @@ impl CapWindowId {
Self::ScreenshotEditor { .. } => (800.0, 600.0),
Self::Settings => (800.0, 580.0),
Self::Camera => (200.0, 200.0),
Self::Upgrade => (950.0, 850.0),
Self::ModeSelect => (580.0, 340.0),
Self::Onboarding => (860.0, 690.0),
_ => return None,
Expand Down Expand Up @@ -922,7 +917,6 @@ pub enum ShowCapWindow {
#[serde(default)]
capture_target: Option<ScreenCaptureTarget>,
},
Upgrade,
ModeSelect,
ScreenshotEditor {
path: PathBuf,
Expand Down Expand Up @@ -1747,41 +1741,6 @@ impl ShowCapWindow {

window
}
Self::Upgrade => {
if let Some(main) = CapWindowId::Main.get(app) {
let _ = main.hide();
}

let window = self
.window_builder(app, "/upgrade")
.inner_size(950.0, 850.0)
.min_inner_size(950.0, 850.0)
.resizable(false)
.focused(true)
.always_on_top(true)
.maximized(false)
.shadow(true)
.build()?;

let (pos_x, pos_y) = cursor_monitor.center_position(950.0, 850.0);
let _ = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y));

#[cfg(windows)]
{
use tauri::LogicalSize;
if let Err(e) = window.set_size(LogicalSize::new(950.0, 850.0)) {
warn!("Failed to set Upgrade window size on Windows: {}", e);
}
if let Err(e) = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y)) {
warn!("Failed to position Upgrade window on Windows: {}", e);
}
}

window.show().ok();
window.set_focus().ok();

window
}
Self::ModeSelect => {
if let Some(main) = CapWindowId::Main.get(app) {
let _ = main.hide();
Expand Down Expand Up @@ -2571,7 +2530,6 @@ impl ShowCapWindow {
ShowCapWindow::CaptureArea { .. } => CapWindowId::CaptureArea,
ShowCapWindow::Camera { .. } => CapWindowId::Camera,
ShowCapWindow::InProgressRecording { .. } => CapWindowId::RecordingControls,
ShowCapWindow::Upgrade => CapWindowId::Upgrade,
ShowCapWindow::ModeSelect => CapWindowId::ModeSelect,
ShowCapWindow::Onboarding => CapWindowId::Onboarding,
ShowCapWindow::ScreenshotEditor { path } => {
Expand Down
2 changes: 0 additions & 2 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ const SettingsGoogleDriveConfigPage = lazy(
const OnboardingPage = lazy(
() => import("./routes/(window-chrome)/onboarding"),
);
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"));
Expand Down Expand Up @@ -185,7 +184,6 @@ function Inner() {
/>
</Route>
<Route path="/onboarding" component={OnboardingPage} />
<Route path="/upgrade" component={UpgradePage} />
<Route path="/update" component={UpdatePage} />
</Route>
<Route path="/camera" component={CameraPage} />
Expand Down
Loading
Loading