diff --git a/.env.example b/.env.example index a688be4..820debc 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,10 @@ APP_NAME="Network Spy (DEV)" # APP_NAME="Network Spy (STAGING)" # APP_NAME="Network Spy" +API_BASE_URL= + +# These env is optional in development + # PostHog Analytics (Provide these in your GitHub Secrets as well) VITE_PUBLIC_POSTHOG_KEY="your_posthog_project_api_key" VITE_PUBLIC_POSTHOG_HOST="https://app.posthog.com" diff --git a/bun.lock b/bun.lock index ff91ef2..27b51ef 100644 --- a/bun.lock +++ b/bun.lock @@ -71,7 +71,6 @@ "@vitejs/plugin-react": "^3.0.0", "@vitest/browser": "^3.0.8", "@vitest/coverage-v8": "^3.0.8", - "daisyui": "^3.2.1", "playwright": "^1.51.0", "storybook": "^8.6.5", "typescript": "^4.9.5", @@ -883,8 +882,6 @@ "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], - "colord": ["colord@2.9.3", "", {}, "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="], - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], @@ -909,8 +906,6 @@ "css-in-js-utils": ["css-in-js-utils@2.0.1", "", { "dependencies": { "hyphenate-style-name": "^1.0.2", "isobject": "^3.0.1" } }, "sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA=="], - "css-selector-tokenizer": ["css-selector-tokenizer@0.8.0", "", { "dependencies": { "cssesc": "^3.0.0", "fastparse": "^1.1.2" } }, "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg=="], - "css-to-react-native": ["css-to-react-native@2.3.2", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^3.3.0" } }, "sha512-VOFaeZA053BqvvvqIA8c9n0+9vFppVBAHCp6JgFTtTMU3Mzi+XnelJ9XC9ul3BqFzZyQ5N+H0SnwsWT2Ebchxw=="], "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], @@ -941,8 +936,6 @@ "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], - "daisyui": ["daisyui@3.9.4", "", { "dependencies": { "colord": "^2.9", "css-selector-tokenizer": "^0.8", "postcss": "^8", "postcss-js": "^4", "tailwindcss": "^3.1" } }, "sha512-fvi2RGH4YV617/6DntOVGcOugOPym9jTGWW2XySb5ZpvdWO4L7bEG77VHirrnbRUEWvIEVXkBpxUz2KFj0rVnA=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], @@ -1051,8 +1044,6 @@ "fast-memoize": ["fast-memoize@2.5.2", "", {}, "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw=="], - "fastparse": ["fastparse@1.1.2", "", {}, "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ=="], - "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="], diff --git a/src-tauri/src/license.rs b/src-tauri/src/license.rs index 2f25d77..c01c5b1 100644 --- a/src-tauri/src/license.rs +++ b/src-tauri/src/license.rs @@ -309,7 +309,8 @@ pub fn license_check_feature(feature: String) -> bool { let plan = cache.plan.as_deref().unwrap_or("free"); let is_personal = plan == "personal"; let is_pro = plan == "pro"; - let is_licensed = is_personal || is_pro; + let is_lifetime = plan == "lifetime"; + let is_licensed = is_personal || is_pro || is_lifetime; // Check dynamic features first if they exist if let Some(features) = &cache.features { @@ -322,7 +323,7 @@ pub fn license_check_feature(feature: String) -> bool { match feature.as_str() { "scripting" | "breakpoints" | "mcp" | "premium" => is_licensed, - "custom_viewers" => is_pro, + "custom_viewers" => is_pro || is_lifetime, _ => false, } } @@ -338,7 +339,8 @@ pub fn license_get_limit(limit_name: String) -> i32 { let plan = cache.plan.as_deref().unwrap_or("free"); let is_personal = plan == "personal"; let is_pro = plan == "pro"; - let is_licensed = is_personal || is_pro; + let is_lifetime = plan == "lifetime"; + let is_licensed = is_personal || is_pro || is_lifetime; // Check dynamic features first if they exist if let Some(features) = &cache.features { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2d4adbe..6189fa0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -80,7 +80,7 @@ fn main() { let app_name = env!("APP_NAME"); let _guard = if let Some(dsn) = option_env!("SENTRY_DSN") { - if !dsn.is_empty() { + if dsn.starts_with("http") { Some(sentry::init((dsn, sentry::ClientOptions { release: sentry::release_name!(), ..Default::default() diff --git a/src/context/AnalyticsProvider.tsx b/src/context/AnalyticsProvider.tsx index edbe354..4875001 100644 --- a/src/context/AnalyticsProvider.tsx +++ b/src/context/AnalyticsProvider.tsx @@ -27,7 +27,7 @@ const DevIdentifier: React.FC = () => { export const AnalyticsProvider: React.FC = ({ children }) => { const apiKey = import.meta.env.VITE_PUBLIC_POSTHOG_KEY; - if (!apiKey) { + if (!apiKey || apiKey.startsWith('your_')) { return <>{children}; } diff --git a/src/context/FilterPresetContext.tsx b/src/context/FilterPresetContext.tsx index c83d9d8..7024cad 100644 --- a/src/context/FilterPresetContext.tsx +++ b/src/context/FilterPresetContext.tsx @@ -3,6 +3,8 @@ import { FilterNode, PredefinedFilter, FilterTypes, FilterOperators } from "../m import { invoke } from "@tauri-apps/api/core"; import { useLicense } from "../hooks/useLicense"; import { useUpgradeDialog } from "./UpgradeContext"; +import { useAtomValue } from "jotai"; +import { isLicensedAtom } from "../utils/trafficAtoms"; interface FilterPresetContextState { predefinedFilters: PredefinedFilter[]; @@ -105,6 +107,7 @@ export const FilterPresetProvider: React.FC<{ children: ReactNode }> = ({ childr const { getLimit } = useLicense(); const { openUpgradeDialog } = useUpgradeDialog(); + const isLicensed = useAtomValue(isLicensedAtom); // Load from DB on mount useEffect(() => { @@ -149,14 +152,16 @@ export const FilterPresetProvider: React.FC<{ children: ReactNode }> = ({ childr }; const addPreset = async (name: string, filters: FilterNode[], description?: string) => { - try { - const limit = await getLimit('max_filters'); - if (userFilters.length >= limit) { - openUpgradeDialog(); - return ""; - } - } catch (e) { - console.error("Failed to check filter limit", e); + if (!isLicensed) { + try { + const limit = await getLimit('max_filters'); + if (userFilters.length >= limit) { + openUpgradeDialog(); + return ""; + } + } catch (e) { + console.error("Failed to check filter limit", e); + } } const newId = `user-${Date.now()}`; diff --git a/src/layout.tsx b/src/layout.tsx index 6d7339e..86c1e4d 100644 --- a/src/layout.tsx +++ b/src/layout.tsx @@ -10,16 +10,24 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { twMerge } from "tailwind-merge"; import { useAppUpdater } from "./hooks/useAppUpdater"; -import { useAtom } from 'jotai'; -import { titleBarContentAtom, osAtom } from '@src/utils/trafficAtoms'; +import { useAtom, useSetAtom } from 'jotai'; +import { titleBarContentAtom, osAtom, isLicensedAtom } from '@src/utils/trafficAtoms'; +import { useLicense } from '@src/hooks/useLicense'; +import { AppPlan } from '@src/models/Plan'; export default function Layout() { useAppUpdater(); const [customContent] = useAtom(titleBarContentAtom); const [, setOs] = useAtom(osAtom); + const setIsLicensed = useSetAtom(isLicensedAtom); + const { getPlan } = useLicense(); useEffect(() => { setOs(platform()); + getPlan().then(plan => { + const appPlan = AppPlan.fromString(plan); + setIsLicensed(appPlan?.isPro ?? false); + }); }, []); const [isProDialogOpen, setIsProDialogOpen] = useState(false); const [isMainWindow] = useState(() => { diff --git a/src/main.tsx b/src/main.tsx index 0471139..f7c96fe 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -14,7 +14,8 @@ import { SettingsProvider } from "./context/SettingsProvider"; import CertificateInstaller from "./routes/certificate-installer"; import * as Sentry from "@sentry/react"; -if (import.meta.env.VITE_SENTRY_DSN) { +const sentryDsn = import.meta.env.VITE_SENTRY_DSN; +if (sentryDsn && sentryDsn.startsWith('http')) { Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, integrations: [ diff --git a/src/models/Plan.ts b/src/models/Plan.ts index 26bcd13..e9e1013 100644 --- a/src/models/Plan.ts +++ b/src/models/Plan.ts @@ -1,11 +1,13 @@ export class AppPlan { static readonly PERSONAL = new AppPlan("Personal"); static readonly PRO = new AppPlan("Pro"); + static readonly LIFETIME = new AppPlan("Lifetime"); constructor(public readonly name: string) {} get isPro(): boolean { - return this.name.toLowerCase() === "pro"; + const n = this.name.toLowerCase(); + return n === "pro" || n === "lifetime"; } get isPersonal(): boolean { @@ -21,6 +23,7 @@ export class AppPlan { switch (p.toLowerCase()) { case 'personal': return AppPlan.PERSONAL; case 'pro': return AppPlan.PRO; + case 'lifetime': return AppPlan.LIFETIME; default: return new AppPlan(p); } } diff --git a/src/packages/bottom-pane/BottomPaneComponents/Single/ImageViewerMode.tsx b/src/packages/bottom-pane/BottomPaneComponents/Single/ImageViewerMode.tsx index 22823c1..c56d717 100644 --- a/src/packages/bottom-pane/BottomPaneComponents/Single/ImageViewerMode.tsx +++ b/src/packages/bottom-pane/BottomPaneComponents/Single/ImageViewerMode.tsx @@ -26,13 +26,15 @@ export const ImageViewerMode = () => { return (
-
- {isImage ? ( + {isImage ? ( +
- ) : ( +
+ ) : ( +
Response is not an image ({data?.content_type || 'Unknown Type'})
- )} -
+
+ )}
); }; diff --git a/src/packages/bottom-pane/TabRenderer/ImageView.tsx b/src/packages/bottom-pane/TabRenderer/ImageView.tsx index 19749c5..518e845 100644 --- a/src/packages/bottom-pane/TabRenderer/ImageView.tsx +++ b/src/packages/bottom-pane/TabRenderer/ImageView.tsx @@ -1,4 +1,4 @@ -import { useMemo, useEffect } from "react"; +import { useMemo, useEffect, useRef, useState, useCallback } from "react"; export const ImageView = ({ data }: { data: Uint8Array }) => { const url = useMemo(() => { @@ -13,7 +13,76 @@ export const ImageView = ({ data }: { data: Uint8Array }) => { }; }, [url]); + const containerRef = useRef(null); + const imgRef = useRef(null); + const [scale, setScale] = useState(1); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const dragging = useRef(false); + const dragStart = useRef({ x: 0, y: 0 }); + const offsetAtDragStart = useRef({ x: 0, y: 0 }); + + const reset = useCallback(() => { + setScale(1); + setOffset({ x: 0, y: 0 }); + }, []); + + const onWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.1 : 0.1; + setScale(prev => Math.max(0.1, Math.min(prev + delta, 10))); + }, []); + + const onMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + dragging.current = true; + dragStart.current = { x: e.clientX, y: e.clientY }; + offsetAtDragStart.current = offset; + }, [offset]); + + const onMouseMove = useCallback((e: React.MouseEvent) => { + if (!dragging.current) return; + const dx = e.clientX - dragStart.current.x; + const dy = e.clientY - dragStart.current.y; + setOffset({ + x: offsetAtDragStart.current.x + dx, + y: offsetAtDragStart.current.y + dy, + }); + }, []); + + const onMouseUp = useCallback(() => { + dragging.current = false; + }, []); + if (!url) return
No image data
; - return response; + return ( +
+ response +
+ {Math.round(scale * 100)}% +
+
+ ); }; diff --git a/src/packages/filter-bar/FilterBar.tsx b/src/packages/filter-bar/FilterBar.tsx index 74d1a1a..8ff1202 100644 --- a/src/packages/filter-bar/FilterBar.tsx +++ b/src/packages/filter-bar/FilterBar.tsx @@ -91,7 +91,7 @@ const FilterNodeRenderer = ({ updateNode(node.id, { value: e.target.value })} @@ -171,7 +171,7 @@ const FilterNodeRenderer = ({ }; import { useAtom, useAtomValue } from "jotai"; -import { mainTrafficListSearchAtom, activeTabIdAtom } from "@src/utils/trafficAtoms"; +import { mainTrafficListSearchAtom, activeTabIdAtom, isLicensedAtom } from "@src/utils/trafficAtoms"; const countAllRules = (nodes: FilterNode[]): number => { return nodes.reduce((acc, node) => { @@ -200,6 +200,7 @@ export const FilterBar = () => { const activeTabId = useAtomValue(activeTabIdAtom); const [searchTerm, setSearchTerm] = useAtom(mainTrafficListSearchAtom(activeTabId)); const [errorMsg, setErrorMsg] = React.useState(null); + const isLicensed = useAtomValue(isLicensedAtom); React.useEffect(() => { if (errorMsg) { @@ -208,8 +209,6 @@ export const FilterBar = () => { } }, [errorMsg]); - - const { appPredefined, userPredefined } = React.useMemo(() => { const filtered = predefinedFilters.filter(f => f.name.toLowerCase().includes(searchTerm.toLowerCase()) @@ -221,10 +220,12 @@ export const FilterBar = () => { }, [predefinedFilters, searchTerm]); const addRule = async (parentId: string | null = null) => { - const limit = await getLimit('max_filters'); - if (countAllRules(filters) >= limit) { - openUpgradeDialog(); - return; + if (!isLicensed) { + const limit = await getLimit('max_filters'); + if (countAllRules(filters) >= limit) { + openUpgradeDialog(); + return; + } } const newRule: FilterRule = { @@ -256,10 +257,12 @@ export const FilterBar = () => { }; const addGroup = async (parentId: string | null = null) => { - const limit = await getLimit('max_filters'); - if (countAllRules(filters) >= limit) { - openUpgradeDialog(); - return; + if (!isLicensed) { + const limit = await getLimit('max_filters'); + if (countAllRules(filters) >= limit) { + openUpgradeDialog(); + return; + } } const newGroup: FilterGroup = { @@ -398,7 +401,7 @@ export const FilterBar = () => { setSearchTerm(e.target.value)} /> diff --git a/src/utils/trafficAtoms.ts b/src/utils/trafficAtoms.ts index ab2dcfe..7fb5e3b 100644 --- a/src/utils/trafficAtoms.ts +++ b/src/utils/trafficAtoms.ts @@ -38,3 +38,5 @@ export const statusInfoDialogAtom = atom<{ export const titleBarContentAtom = atom(null); export const osAtom = atom('macos'); + +export const isLicensedAtom = atom(null);