diff --git a/Makefile b/Makefile index 9b9ee80..241bec7 100644 --- a/Makefile +++ b/Makefile @@ -13,11 +13,6 @@ license: LICENSE.FULL: pnpm tsx scripts/merge-license.ts -.PHONY: version -version: - pnpm tsx scripts/bump-version.ts - - CORE_NAME = core CORE_PKG_DIR = packages/core CORE_WASM_FILE = $(CORE_PKG_DIR)/$(CORE_NAME)_bg.wasm $(CORE_PKG_DIR)/$(CORE_NAME)_bg.wasm.d.ts diff --git a/dev-utility-tauri/src/lib.rs b/dev-utility-tauri/src/lib.rs index fe68453..95e5d21 100644 --- a/dev-utility-tauri/src/lib.rs +++ b/dev-utility-tauri/src/lib.rs @@ -111,10 +111,12 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ - #[cfg(desktop)] + // #[cfg(desktop)] // dev_utility_core::hardware::list_hid_devices, dev_utility_core::codec::decode_base64, dev_utility_core::codec::encode_base64, + #[cfg(desktop)] + dev_utility_core::codec::decode_jwt, dev_utility_core::cryptography::generate_rsa_key, dev_utility_core::cryptography::analyze_rsa_key, dev_utility_core::cryptography::generate_hashes, diff --git a/dev-utility-tauri/tauri.conf.json b/dev-utility-tauri/tauri.conf.json index 8f5db62..8c61978 100644 --- a/dev-utility-tauri/tauri.conf.json +++ b/dev-utility-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "DevUtility", - "version": "0.1.9", + "version": "1.0.0-alpha.2", "identifier": "dev.utility.app", "build": { "beforeDevCommand": "pnpm --filter @dev-utility/frontend dev", @@ -17,6 +17,8 @@ "titleBarStyle": "Overlay", "width": 1200, "height": 800, + "minWidth": 375, + "minHeight": 800, "transparent": true, "trafficLightPosition": { "x": 18, diff --git a/dev-utility-workers/Cargo.toml b/dev-utility-workers/Cargo.toml index b78b98e..1866588 100644 --- a/dev-utility-workers/Cargo.toml +++ b/dev-utility-workers/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dev-utility-workers" -version = "0.1.9" +version = "1.0.0-alpha.2" edition = "2021" authors = ["AprilNEA "] diff --git a/dev-utility/Cargo.toml b/dev-utility/Cargo.toml index 06c273d..e91fb0b 100644 --- a/dev-utility/Cargo.toml +++ b/dev-utility/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dev-utility-core" -version = "0.1.9" +version = "1.0.0-alpha.2" authors = ["AprilNEA "] description = "⚡ Universal developer toolkit for software, hardware, and security professionals." license-file = "../LICENSE" @@ -23,6 +23,7 @@ getrandom = { version = "0.3", features = ["wasm_js"] } # Codec base64 = "0.22.1" +jsonwebtoken = "9" # Cryptography rsa = "0.9.8" diff --git a/dev-utility/src/core/codec.rs b/dev-utility/src/core/codec.rs index 43dac8a..a7d32c2 100644 --- a/dev-utility/src/core/codec.rs +++ b/dev-utility/src/core/codec.rs @@ -12,12 +12,29 @@ // See LICENSE file for details or contact admin@aprilnea.com use crate::error::UtilityError; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use base64::{ + engine::general_purpose::STANDARD as BASE64, + engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL_SAFE, Engine as _, +}; +use jsonwebtoken::Algorithm; +use serde::{Deserialize, Serialize}; use universal_function_macro::universal_function; +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Base64Engine { + Standard, + UrlSafe, +} + #[universal_function] -pub fn decode_base64(input: &str) -> Result { - BASE64 +pub async fn decode_base64(input: &str, engine: Base64Engine) -> Result { + let base64_engine = match engine { + Base64Engine::Standard => BASE64, + Base64Engine::UrlSafe => BASE64_URL_SAFE, + }; + + base64_engine .decode(input) .map_err(|e| UtilityError::DecodeError(e.to_string())) .and_then(|bytes| { @@ -26,6 +43,80 @@ pub fn decode_base64(input: &str) -> Result { } #[universal_function] -pub fn encode_base64(input: &str) -> String { - BASE64.encode(input.as_bytes()) +pub async fn encode_base64(input: &str) -> Result { + Ok(BASE64.encode(input.as_bytes())) +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JwtDecodeStatus { + Valid, + Invalid, + Unverified, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JwtDecodeResult { + pub header: String, + pub payload: String, + pub signature: String, + pub status: JwtDecodeStatus, +} + +async fn decode_jwt_with_secret(input: &str) -> Result { + // If verification fails, try to decode without verification + let parts: Vec<&str> = input.split('.').collect(); + if parts.len() != 3 { + return Err(UtilityError::DecodeError("Invalid JWT format".to_string())); + } + + // Decode base64 parts, but handle signature as raw bytes since it may not be valid UTF-8 + let header = decode_base64(parts[0], Base64Engine::UrlSafe).await?; + let payload = decode_base64(parts[1], Base64Engine::UrlSafe).await?; + + // For signature, decode to bytes and convert to hex string to avoid UTF-8 issues + let base64_engine = BASE64_URL_SAFE; + let signature_bytes = base64_engine + .decode(parts[2]) + .map_err(|e| UtilityError::DecodeError(e.to_string()))?; + let signature = hex::encode(signature_bytes); + + Ok(JwtDecodeResult { + header, + payload, + signature, + status: JwtDecodeStatus::Unverified, + }) +} + +#[universal_function(desktop_only)] +pub async fn decode_jwt( + input: &str, + algorithm: Algorithm, + secret: Option<&str>, +) -> Result { + let parts = decode_jwt_with_secret(input).await?; + match secret { + Some(secret_key) => { + let validation = jsonwebtoken::Validation::new(algorithm); + match jsonwebtoken::decode::( + input, + &jsonwebtoken::DecodingKey::from_secret(secret_key.as_bytes()), + &validation, + ) { + Ok(_) => Ok(JwtDecodeResult { + header: parts.header, + payload: parts.payload, + signature: parts.signature, + status: JwtDecodeStatus::Valid, + }), + Err(_) => { + let mut result = decode_jwt_with_secret(input).await?; + result.status = JwtDecodeStatus::Invalid; + Ok(result) + } + } + } + None => decode_jwt_with_secret(input).await, + } } diff --git a/package.json b/package.json index e64d13f..a3522d9 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "dev-utility", "private": true, - "version": "0.1.9", + "version": "1.0.0-alpha.2", "type": "module", "scripts": { "tauri": "tauri", "dev": "pnpm tauri dev", - "web": "pnpm --filter @dev-utility/frontend dev:wasm" + "web": "pnpm --filter @dev-utility/frontend dev:wasm", + "bump-version": "pnpm tsx scripts/bump-version.ts" }, "dependencies": { "react": "^19.1.0", diff --git a/packages/frontend/src/components/layout/two-section.tsx b/packages/frontend/src/components/layout/two-section.tsx index 571be18..8bbe10d 100644 --- a/packages/frontend/src/components/layout/two-section.tsx +++ b/packages/frontend/src/components/layout/two-section.tsx @@ -91,19 +91,21 @@ const TwoSectionLayout = ({ classNames?.secondSection, )} > -
- {secondLabel && ( - - )} -
{secondToolbar}
-
+ {secondToolbar && ( +
+ {secondLabel && ( + + )} +
{secondToolbar}
+
+ )} {secondContent} diff --git a/packages/frontend/src/components/sidebar/index.tsx b/packages/frontend/src/components/sidebar/index.tsx index c3c91aa..593e7d7 100644 --- a/packages/frontend/src/components/sidebar/index.tsx +++ b/packages/frontend/src/components/sidebar/index.tsx @@ -58,7 +58,7 @@ import { SearchForm } from "./search-form"; import { ThemeSwitcher } from "./theme-switcher"; const InsetHeader: React.FC<{ title: string }> = ({ title }) => { - const { open } = useSidebar(); + const { open, isMobile } = useSidebar(); const setOnTop = async () => { await getCurrentWindow().setAlwaysOnTop(!isOnTop); @@ -71,7 +71,9 @@ const InsetHeader: React.FC<{ title: string }> = ({ title }) => { data-tauri-drag-region className="flex h-9 shrink-0 items-center gap-2 px-4" > - + = ({ title }) => { ); }; +const InsetContent: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const { isMobile } = useSidebar(); + + return ( + + {children} + + ); +}; + export default function AppSidebar({ children, ...props @@ -241,12 +260,12 @@ export default function AppSidebar({ - +
{children}
-
+ ); } diff --git a/packages/frontend/src/pages/settings.tsx b/packages/frontend/src/pages/settings.tsx index 45a88ca..84df8f6 100644 --- a/packages/frontend/src/pages/settings.tsx +++ b/packages/frontend/src/pages/settings.tsx @@ -68,7 +68,8 @@ const UpdateItem = () => { const { data: update, isLoading } = useSWR("updater_check", check); const [isUpdating, setIsUpdating] = useState(false); - const { data: process } = useSWRSubscription( + + useSWRSubscription( isUpdating ? "updater_download" : null, ( _, @@ -81,7 +82,7 @@ const UpdateItem = () => { contentLength?: number; }, Error - >, + > ) => { next(null, { status: "pending", @@ -98,7 +99,7 @@ const UpdateItem = () => { })); } console.log( - `started downloading ${event.data.contentLength} bytes`, + `started downloading ${event.data.contentLength} bytes` ); break; } @@ -120,6 +121,8 @@ const UpdateItem = () => { status: "finished", })); console.log("download finished"); + relaunch(); + setIsUpdating(false); break; } } @@ -130,16 +133,10 @@ const UpdateItem = () => { }; }, { - onSuccess(data) { - console.log(data); - if (data.status === "finished") { - relaunch(); - } - }, fallbackData: { status: "pending", }, - }, + } ); return ( @@ -409,7 +406,7 @@ export default function SettingsPage() { onClick={() => { window.open( "https://github.com/aprilnea/devutility/releases", - "_blank", + "_blank" ); }} className="flex items-center gap-1 hover:text-foreground transition-colors" @@ -422,7 +419,7 @@ export default function SettingsPage() { onClick={() => { window.open( "https://github.com/aprilnea/devutility/issues", - "_blank", + "_blank" ); }} className="flex items-center gap-1 hover:text-foreground transition-colors" diff --git a/packages/frontend/src/utilities/codec/base64.tsx b/packages/frontend/src/utilities/codec/base64.tsx index d5f4269..358a179 100644 --- a/packages/frontend/src/utilities/codec/base64.tsx +++ b/packages/frontend/src/utilities/codec/base64.tsx @@ -27,7 +27,7 @@ import { } from "@/components/tools"; import { Button } from "@/components/ui/button"; import { useUtilityInvoke } from "../invoke"; -import { InvokeFunction } from "../types"; +import { Base64Engine, InvokeFunction } from "../types"; export default function Base64CodecPage() { const [mode, setMode] = useState(CodecMode.Encode); @@ -56,7 +56,7 @@ export default function Base64CodecPage() { (input: string, mode: CodecMode) => { setError(""); if (mode === CodecMode.Decode) { - decode.trigger({ input }); + decode.trigger({ input, engine: Base64Engine.Standard }); } else { encode.trigger({ input }); } diff --git a/packages/frontend/src/utilities/codec/jwt.tsx b/packages/frontend/src/utilities/codec/jwt.tsx new file mode 100644 index 0000000..8af7100 --- /dev/null +++ b/packages/frontend/src/utilities/codec/jwt.tsx @@ -0,0 +1,261 @@ +/** + * Copyright (c) 2023-2025, AprilNEA LLC. + * + * Dual licensed under: + * - GPL-3.0 (open source) + * - Commercial license (contact us) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * See LICENSE file for details or contact admin@aprilnea.com + */ + +import { msg } from "@lingui/core/macro"; +import { Trans, useLingui } from "@lingui/react/macro"; +import { useDebouncedValue } from "foxact/use-debounced-value"; +import { ChevronDown, Copy } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { Callout } from "@/components/derived-ui/callout"; +import TwoSectionLayout from "@/components/layout/two-section"; +import { + ClearTool, + CopyTool, + LoadFileTool, + PasteTool, +} from "@/components/tools"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { copyToClipboard } from "@/lib/copyboard"; +import { cn } from "@/lib/utils"; +import { useUtilityInvoke } from "../invoke"; +import { InvokeFunction, JwtAlgorithm } from "../types"; + +// JWT算法枚举 + +const sampleJwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + +export default function JwtDecoderPage() { + const { t } = useLingui(); + const [input, setInput] = useState(""); + const [algorithm, setAlgorithm] = useState(JwtAlgorithm.HS256); + const [secretKey, setSecretKey] = useState(""); + + const debouncedInput = useDebouncedValue(input, 100, true); + const { data, error, trigger } = useUtilityInvoke(InvokeFunction.DecodeJwt); + + useEffect(() => { + if (debouncedInput) { + trigger({ + input: debouncedInput, + algorithm, + secret: secretKey || undefined, + }); + } + }, [debouncedInput, algorithm, secretKey, trigger]); + + const inputToolbar = ( + <> + {/* Algorithm Selector */} +
+ + + + + + setAlgorithm(v as JwtAlgorithm)} + > + {Object.values(JwtAlgorithm).map((alg) => ( + + {alg} + + ))} + + + +
+ { + setInput(text); + }} + /> + + { + setInput(""); + setSecretKey(""); + }, + }} + /> + + ); + + const inputContent = ( +
+