diff --git a/src-tauri/src/analytics.rs b/src-tauri/src/analytics.rs index e2694ae8..ca8d9f2e 100644 --- a/src-tauri/src/analytics.rs +++ b/src-tauri/src/analytics.rs @@ -1,89 +1,109 @@ use log::{debug, warn}; +use serde::{Deserialize, Serialize}; use sqlx::types::JsonValue; use tauri::{async_runtime, AppHandle, Manager}; use crate::is_dev; +// serializable +#[derive(Serialize, Deserialize)] pub enum AnalyticsResource { App, - // Workspace, - // Environment, - // Folder, - // HttpRequest, - // HttpResponse, + Workspace, + Environment, + Folder, + HttpRequest, + HttpResponse, } +#[derive(Serialize, Deserialize)] pub enum AnalyticsAction { Launch, - // Create, - // Update, - // Upsert, - // Delete, - // Send, - // Duplicate, + Create, + Update, + Upsert, + Delete, + DeleteMany, + Send, + Duplicate, } fn resource_name(resource: AnalyticsResource) -> &'static str { match resource { AnalyticsResource::App => "app", - // AnalyticsResource::Workspace => "workspace", - // AnalyticsResource::Environment => "environment", - // AnalyticsResource::Folder => "folder", - // AnalyticsResource::HttpRequest => "http_request", - // AnalyticsResource::HttpResponse => "http_response", + AnalyticsResource::Workspace => "workspace", + AnalyticsResource::Environment => "environment", + AnalyticsResource::Folder => "folder", + AnalyticsResource::HttpRequest => "http_request", + AnalyticsResource::HttpResponse => "http_response", } } fn action_name(action: AnalyticsAction) -> &'static str { match action { AnalyticsAction::Launch => "launch", - // AnalyticsAction::Create => "create", - // AnalyticsAction::Update => "update", - // AnalyticsAction::Upsert => "upsert", - // AnalyticsAction::Delete => "delete", - // AnalyticsAction::Send => "send", - // AnalyticsAction::Duplicate => "duplicate", + AnalyticsAction::Create => "create", + AnalyticsAction::Update => "update", + AnalyticsAction::Upsert => "upsert", + AnalyticsAction::Delete => "delete", + AnalyticsAction::DeleteMany => "delete_many", + AnalyticsAction::Send => "send", + AnalyticsAction::Duplicate => "duplicate", } } -pub fn track_event( +pub fn track_event_blocking( app_handle: &AppHandle, resource: AnalyticsResource, action: AnalyticsAction, attributes: Option, ) { async_runtime::block_on(async move { - let event = format!("{}.{}", resource_name(resource), action_name(action)); - let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string(); - let info = app_handle.package_info(); - let tz = datetime::sys_timezone().unwrap_or("unknown".to_string()); - let params = vec![ - ("e", event.clone()), - ("a", attributes_json.clone()), - ("id", "site_zOK0d7jeBy2TLxFCnZ".to_string()), - ("v", info.version.clone().to_string()), - ("os", get_os().to_string()), - ("tz", tz), - ("xy", get_window_size(app_handle)), - ]; - let url = "https://t.yaak.app/t/e".to_string(); - let req = reqwest::Client::builder() - .build() - .unwrap() - .get(&url) - .query(¶ms); + track_event(app_handle, resource, action, attributes).await; + }); +} - if is_dev() { - debug!("Send event (dev): {}", event); - } else if let Err(e) = req.send().await { - warn!( +pub async fn track_event( + app_handle: &AppHandle, + resource: AnalyticsResource, + action: AnalyticsAction, + attributes: Option, +) { + let event = format!("{}.{}", resource_name(resource), action_name(action)); + let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string(); + let info = app_handle.package_info(); + let tz = datetime::sys_timezone().unwrap_or("unknown".to_string()); + let site = match is_dev() { + true => "site_TkHWjoXwZPq3HfhERb", + false => "site_zOK0d7jeBy2TLxFCnZ", + }; + let base_url = match is_dev() { + true => "http://localhost:7194", + false => "https://t.yaak.app" + }; + let params = vec![ + ("e", event.clone()), + ("a", attributes_json.clone()), + ("id", site.to_string()), + ("v", info.version.clone().to_string()), + ("os", get_os().to_string()), + ("tz", tz), + ("xy", get_window_size(app_handle)), + ]; + let req = reqwest::Client::builder() + .build() + .unwrap() + .get(format!("{base_url}/t/e")) + .query(¶ms); + + if let Err(e) = req.send().await { + warn!( "Error sending analytics event: {} {} {:?}", e, event, params ); - } else { - debug!("Send event: {}: {:?}", event, params); - } - }); + } else { + debug!("Send event: {}: {:?}", event, params); + } } fn get_os() -> &'static str { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 47257ba3..48b23269 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -16,6 +16,7 @@ use fern::colors::ColoredLevelConfig; use log::{debug, info, warn}; use rand::random; use serde::Serialize; +use serde_json::Value; use sqlx::{Pool, Sqlite, SqlitePool}; use sqlx::migrate::Migrator; use sqlx::types::Json; @@ -29,7 +30,7 @@ use tokio::sync::Mutex; use window_ext::TrafficLightWindowExt; -use crate::analytics::{AnalyticsAction, AnalyticsResource, track_event}; +use crate::analytics::{AnalyticsAction, AnalyticsResource}; use crate::plugin::{ImportResources, ImportResult}; use crate::send::actually_send_request; use crate::updates::{update_mode_from_str, UpdateMode, YaakUpdater}; @@ -224,6 +225,22 @@ async fn response_err( Ok(response) } +#[tauri::command] +async fn track_event( + window: Window, + resource: AnalyticsResource, + action: AnalyticsAction, + attributes: Option, +) -> Result<(), String> { + analytics::track_event( + &window.app_handle(), + resource, + action, + attributes, + ).await; + Ok(()) +} + #[tauri::command] async fn set_update_mode( update_mode: &str, @@ -726,6 +743,7 @@ fn main() { send_request, set_key_value, set_update_mode, + track_event, update_environment, update_folder, update_request, @@ -762,7 +780,7 @@ fn main() { w.restore_state(StateFlags::all()) .expect("Failed to restore window state"); - track_event( + analytics::track_event_blocking( app_handle, AnalyticsResource::App, AnalyticsAction::Launch, diff --git a/src-tauri/src/send.rs b/src-tauri/src/send.rs index 8cbc0b1d..4e923e98 100644 --- a/src-tauri/src/send.rs +++ b/src-tauri/src/send.rs @@ -35,8 +35,11 @@ pub async fn actually_send_request( } let client = reqwest::Client::builder() - .redirect(Policy::none()) - // .danger_accept_invalid_certs(true) + .redirect(Policy::none()) // TODO: Handle redirect manually + .danger_accept_invalid_certs(false) // TODO: Make this configurable + .connection_verbose(true) // TODO: Capture this log somehow + .tls_info(true) // TODO: Capture this log somehow + // .use_rustls_tls() // TODO: Make this configurable .build() .expect("Failed to build client"); @@ -237,6 +240,9 @@ pub async fn actually_send_request( } Ok(response) } - Err(e) => response_err(response, e.to_string(), app_handle, pool).await, + Err(e) => { + println!("Yo: {}", e); + response_err(response, e.to_string(), app_handle, pool).await + }, } } diff --git a/src-web/components/GlobalHooks.tsx b/src-web/components/GlobalHooks.tsx index 5461509c..348d7a7f 100644 --- a/src-web/components/GlobalHooks.tsx +++ b/src-web/components/GlobalHooks.tsx @@ -2,7 +2,6 @@ import { useQueryClient } from '@tanstack/react-query'; import { appWindow } from '@tauri-apps/api/window'; import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; -import { useEffectOnce } from 'react-use'; import { keyValueQueryKey } from '../hooks/useKeyValue'; import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent'; import { useRecentEnvironments } from '../hooks/useRecentEnvironments'; @@ -13,7 +12,6 @@ import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey'; import { responsesQueryKey } from '../hooks/useResponses'; import { useSyncWindowTitle } from '../hooks/useSyncWindowTitle'; import { workspacesQueryKey } from '../hooks/useWorkspaces'; -import { trackPage } from '../lib/analytics'; import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore'; import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models'; import { modelsEq } from '../lib/models'; @@ -39,10 +37,6 @@ export function GlobalHooks() { setPathname(location.pathname).catch(console.error); }, [location.pathname]); - useEffectOnce(() => { - trackPage('/'); - }); - useListenToTauriEvent('created_model', ({ payload, windowLabel }) => { if (shouldIgnoreEvent(payload, windowLabel)) return; diff --git a/src-web/components/KeyboardShortcutsDialog.tsx b/src-web/components/KeyboardShortcutsDialog.tsx new file mode 100644 index 00000000..d380e980 --- /dev/null +++ b/src-web/components/KeyboardShortcutsDialog.tsx @@ -0,0 +1,10 @@ +import { hotkeyActions } from '../hooks/useHotkey'; +import { HotKeyList } from './core/HotKeyList'; + +export const KeyboardShortcutsDialog = () => { + return ( +
+ +
+ ); +}; diff --git a/src-web/components/SettingsDropdown.tsx b/src-web/components/SettingsDropdown.tsx index 8281910a..8d42d033 100644 --- a/src-web/components/SettingsDropdown.tsx +++ b/src-web/components/SettingsDropdown.tsx @@ -12,6 +12,7 @@ import { Icon } from './core/Icon'; import { IconButton } from './core/IconButton'; import { VStack } from './core/Stacks'; import { useDialog } from './DialogContext'; +import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog'; export function SettingsDropdown() { const importData = useImportData(); @@ -66,7 +67,18 @@ export function SettingsDropdown() { onSelect: toggleAppearance, leftSlot: , }, - { type: 'separator', label: `v${appVersion.data}` }, + { + key: 'hotkeys', + label: 'Keyboard shortcuts', + onSelect: () => + dialog.show({ + title: 'Keyboard Shortcuts', + size: 'dynamic', + render: () => , + }), + leftSlot: , + }, + { type: 'separator', label: `Yaak v${appVersion.data}` }, { key: 'update-mode', label: updateMode === 'stable' ? 'Enable Beta' : 'Disable Beta', diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 3a50d090..ac10da7b 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -290,27 +290,13 @@ function getExtensions({ // Handle onChange EditorView.updateListener.of((update) => { - if (onChange && update.docChanged && isViewUpdateFromUserInput(update)) { + if (onChange && update.docChanged) { onChange.current?.(update.state.doc.toString()); } }), ]; } -function isViewUpdateFromUserInput(viewUpdate: ViewUpdate) { - // Make sure document has changed, ensuring user events like selections don't count. - if (viewUpdate.docChanged) { - // Check transactions for any that are direct user input, not changes from Y.js or another extension. - for (const transaction of viewUpdate.transactions) { - // Not using Transaction.isUserEvent because that only checks for a specific User event type ( "input", "delete", etc.). Checking the annotation directly allows for any type of user event. - const userEventType = transaction.annotation(Transaction.userEvent); - if (userEventType) return userEventType; - } - } - - return false; -} - const syncGutterBg = ({ parent, className = '', diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index 66f400c2..b97b31f8 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -26,6 +26,7 @@ const icons = { gear: I.GearIcon, hamburger: I.HamburgerMenuIcon, home: I.HomeIcon, + keyboard: I.KeyboardIcon, listBullet: I.ListBulletIcon, magicWand: I.MagicWandIcon, magnifyingGlass: I.MagnifyingGlassIcon, diff --git a/src-web/hooks/useCreateEnvironment.ts b/src-web/hooks/useCreateEnvironment.ts index 06a3a058..5659452c 100644 --- a/src-web/hooks/useCreateEnvironment.ts +++ b/src-web/hooks/useCreateEnvironment.ts @@ -13,8 +13,6 @@ export function useCreateEnvironment() { const prompt = usePrompt(); const workspaceId = useActiveWorkspaceId(); const queryClient = useQueryClient(); - const environments = useEnvironments(); - const workspaces = useWorkspaces(); return useMutation({ mutationFn: async () => { @@ -26,7 +24,7 @@ export function useCreateEnvironment() { }); return invoke('create_environment', { name, variables: [], workspaceId }); }, - onSettled: () => trackEvent('environment', 'create'), + onSettled: () => trackEvent('Environment', 'Create'), onSuccess: async (environment) => { if (workspaceId == null) return; routes.setEnvironment(environment); diff --git a/src-web/hooks/useCreateFolder.ts b/src-web/hooks/useCreateFolder.ts index 92c64c5a..844c77d8 100644 --- a/src-web/hooks/useCreateFolder.ts +++ b/src-web/hooks/useCreateFolder.ts @@ -18,7 +18,7 @@ export function useCreateFolder() { patch.sortPriority = patch.sortPriority || -Date.now(); return invoke('create_folder', { workspaceId, ...patch }); }, - onSettled: () => trackEvent('folder', 'create'), + onSettled: () => trackEvent('Folder', 'Create'), onSuccess: async (request) => { await queryClient.invalidateQueries(foldersQueryKey({ workspaceId: request.workspaceId })); }, diff --git a/src-web/hooks/useCreateRequest.ts b/src-web/hooks/useCreateRequest.ts index 33135755..bed53ec7 100644 --- a/src-web/hooks/useCreateRequest.ts +++ b/src-web/hooks/useCreateRequest.ts @@ -28,7 +28,7 @@ export function useCreateRequest() { patch.folderId = patch.folderId || activeRequest?.folderId; return invoke('create_request', { workspaceId, name: '', ...patch }); }, - onSettled: () => trackEvent('http_request', 'create'), + onSettled: () => trackEvent('HttpRequest', 'Create'), onSuccess: async (request) => { queryClient.setQueryData( requestsQueryKey({ workspaceId: request.workspaceId }), diff --git a/src-web/hooks/useCreateWorkspace.ts b/src-web/hooks/useCreateWorkspace.ts index db781b69..39c510c3 100644 --- a/src-web/hooks/useCreateWorkspace.ts +++ b/src-web/hooks/useCreateWorkspace.ts @@ -12,7 +12,7 @@ export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean } mutationFn: (patch) => { return invoke('create_workspace', patch); }, - onSettled: () => trackEvent('workspace', 'create'), + onSettled: () => trackEvent('Workspace', 'Create'), onSuccess: async (workspace) => { queryClient.setQueryData(workspacesQueryKey({}), (workspaces) => [ ...(workspaces ?? []), diff --git a/src-web/hooks/useDeleteAnyRequest.tsx b/src-web/hooks/useDeleteAnyRequest.tsx index 5ae3b9aa..9eb86145 100644 --- a/src-web/hooks/useDeleteAnyRequest.tsx +++ b/src-web/hooks/useDeleteAnyRequest.tsx @@ -28,7 +28,7 @@ export function useDeleteAnyRequest() { if (!confirmed) return null; return invoke('delete_request', { requestId: id }); }, - onSettled: () => trackEvent('http_request', 'delete'), + onSettled: () => trackEvent('HttpRequest', 'Delete'), onSuccess: async (request) => { // Was it cancelled? if (request === null) return; diff --git a/src-web/hooks/useDeleteEnvironment.tsx b/src-web/hooks/useDeleteEnvironment.tsx index 53dce26f..824da04b 100644 --- a/src-web/hooks/useDeleteEnvironment.tsx +++ b/src-web/hooks/useDeleteEnvironment.tsx @@ -24,7 +24,7 @@ export function useDeleteEnvironment(environment: Environment | null) { if (!confirmed) return null; return invoke('delete_environment', { environmentId: environment?.id }); }, - onSettled: () => trackEvent('environment', 'delete'), + onSettled: () => trackEvent('Environment', 'Delete'), onSuccess: async (environment) => { if (environment === null) return; diff --git a/src-web/hooks/useDeleteFolder.tsx b/src-web/hooks/useDeleteFolder.tsx index e437fdc1..ac7888a7 100644 --- a/src-web/hooks/useDeleteFolder.tsx +++ b/src-web/hooks/useDeleteFolder.tsx @@ -27,7 +27,7 @@ export function useDeleteFolder(id: string | null) { if (!confirmed) return null; return invoke('delete_folder', { folderId: id }); }, - onSettled: () => trackEvent('folder', 'delete'), + onSettled: () => trackEvent('Folder', 'Delete'), onSuccess: async (folder) => { // Was it cancelled? if (folder === null) return; diff --git a/src-web/hooks/useDeleteResponse.ts b/src-web/hooks/useDeleteResponse.ts index 168c68f5..e3aa6572 100644 --- a/src-web/hooks/useDeleteResponse.ts +++ b/src-web/hooks/useDeleteResponse.ts @@ -10,7 +10,7 @@ export function useDeleteResponse(id: string | null) { mutationFn: async () => { return await invoke('delete_response', { id: id }); }, - onSettled: () => trackEvent('http_response', 'delete'), + onSettled: () => trackEvent('HttpResponse', 'Delete'), onSuccess: ({ requestId, id: responseId }) => { queryClient.setQueryData(responsesQueryKey({ requestId }), (responses) => (responses ?? []).filter((response) => response.id !== responseId), diff --git a/src-web/hooks/useDeleteResponses.ts b/src-web/hooks/useDeleteResponses.ts index c6dc5e6a..933cf1b3 100644 --- a/src-web/hooks/useDeleteResponses.ts +++ b/src-web/hooks/useDeleteResponses.ts @@ -10,7 +10,7 @@ export function useDeleteResponses(requestId?: string) { if (requestId === undefined) return; await invoke('delete_all_responses', { requestId }); }, - onSettled: () => trackEvent('http_response', 'delete_many'), + onSettled: () => trackEvent('HttpResponse', 'DeleteMany'), onSuccess: async () => { if (requestId === undefined) return; queryClient.setQueryData(responsesQueryKey({ requestId }), []); diff --git a/src-web/hooks/useDeleteWorkspace.tsx b/src-web/hooks/useDeleteWorkspace.tsx index fda3e14b..9adfcdd5 100644 --- a/src-web/hooks/useDeleteWorkspace.tsx +++ b/src-web/hooks/useDeleteWorkspace.tsx @@ -29,7 +29,7 @@ export function useDeleteWorkspace(workspace: Workspace | null) { if (!confirmed) return null; return invoke('delete_workspace', { workspaceId: workspace?.id }); }, - onSettled: () => trackEvent('workspace', 'delete'), + onSettled: () => trackEvent('Workspace', 'Delete'), onSuccess: async (workspace) => { if (workspace === null) return; diff --git a/src-web/hooks/useDuplicateRequest.ts b/src-web/hooks/useDuplicateRequest.ts index a8d013da..16c32731 100644 --- a/src-web/hooks/useDuplicateRequest.ts +++ b/src-web/hooks/useDuplicateRequest.ts @@ -23,7 +23,7 @@ export function useDuplicateRequest({ if (id === null) throw new Error("Can't duplicate a null request"); return invoke('duplicate_request', { id }); }, - onSettled: () => trackEvent('http_request', 'duplicate'), + onSettled: () => trackEvent('HttpRequest', 'Duplicate'), onSuccess: async (request) => { queryClient.setQueryData( requestsQueryKey({ workspaceId: request.workspaceId }), diff --git a/src-web/hooks/useHotkey.ts b/src-web/hooks/useHotkey.ts index 030185a2..a840a54f 100644 --- a/src-web/hooks/useHotkey.ts +++ b/src-web/hooks/useHotkey.ts @@ -22,6 +22,8 @@ const hotkeys: Record = { 'environmentEditor.toggle': ['CmdCtrl+e'], }; +export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[]; + interface Options { enable?: boolean; } diff --git a/src-web/hooks/useSendAnyRequest.ts b/src-web/hooks/useSendAnyRequest.ts index 0f95b838..4adc4b9b 100644 --- a/src-web/hooks/useSendAnyRequest.ts +++ b/src-web/hooks/useSendAnyRequest.ts @@ -10,7 +10,7 @@ export function useSendAnyRequest() { const alert = useAlert(); return useMutation({ mutationFn: (id) => invoke('send_request', { requestId: id, environmentId }), - onSettled: () => trackEvent('http_request', 'send'), + onSettled: () => trackEvent('HttpRequest', 'Send'), onError: (err) => alert({ title: 'Export Failed', body: err }), }); } diff --git a/src-web/hooks/useSendFolder.ts b/src-web/hooks/useSendFolder.ts index 5a506ec3..d6f3701a 100644 --- a/src-web/hooks/useSendFolder.ts +++ b/src-web/hooks/useSendFolder.ts @@ -10,6 +10,5 @@ export function useSendManyRequests() { sendAnyRequest.mutate(id); } }, - onSettled: () => trackEvent('http_request', 'send'), }); } diff --git a/src-web/lib/analytics.ts b/src-web/lib/analytics.ts index 2293bff3..079cf701 100644 --- a/src-web/lib/analytics.ts +++ b/src-web/lib/analytics.ts @@ -1,58 +1,20 @@ -import { getVersion } from '@tauri-apps/api/app'; -import type { Environment, Folder, HttpRequest, HttpResponse, KeyValue, Workspace } from './models'; - -const appVersion = await getVersion(); +import { invoke } from '@tauri-apps/api'; export function trackEvent( resource: - | Workspace['model'] - | Environment['model'] - | Folder['model'] - | HttpRequest['model'] - | HttpResponse['model'] - | KeyValue['model'], - event: 'create' | 'update' | 'delete' | 'delete_many' | 'send' | 'duplicate', + | 'App' + | 'Workspace' + | 'Environment' + | 'Folder' + | 'HttpRequest' + | 'HttpResponse' + | 'KeyValue', + action: 'Launch' | 'Create' | 'Update' | 'Delete' | 'DeleteMany' | 'Send' | 'Duplicate', attributes: Record = {}, ) { - send('/e', [ - { name: 'e', value: `${resource}.${event}` }, - { name: 'a', value: JSON.stringify({ ...attributes, version: appVersion }) }, - ]); -} - -export function trackPage(pathname: string) { - if (pathname === sessionStorage.lastPathName) { - return; - } - - sessionStorage.lastPathName = pathname; - send('/p', [ - { - name: 'h', - value: 'desktop.yaak.app', - }, - { name: 'p', value: pathname }, - ]); -} - -function send(path: string, params: { name: string; value: string | number }[]) { - if (localStorage.disableAnalytics === 'true') { - console.log('Analytics disabled', path, params); - } - - params.push({ name: 'id', value: 'site_zOK0d7jeBy2TLxFCnZ' }); - params.push({ - name: 'tz', - value: Intl.DateTimeFormat().resolvedOptions().timeZone, - }); - params.push({ name: 'xy', value: screensize() }); - const qs = params.map((v) => `${v.name}=${encodeURIComponent(v.value)}`).join('&'); - const url = `https://t.yaak.app/t${path}?${qs}`; - fetch(url, { mode: 'no-cors' }).catch((err) => console.log('Error:', err)); -} - -function screensize() { - const w = window.screen.width; - const h = window.screen.height; - return `${Math.round(w / 100) * 100}x${Math.round(h / 100) * 100}`; + invoke('track_event', { + resource: resource, + action, + attributes, + }).catch(console.error); }