diff --git a/.gitignore b/.gitignore index d329a0c..3e4bacb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +/.claude +/.codegraph /target *.swp *.swo diff --git a/cli/src/main.rs b/cli/src/main.rs index c20a17b..621d058 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -66,7 +66,16 @@ fn main() -> anyhow::Result<()> { match args.output { Some(path) => { - let ext = Path::new(&path) + // 防止路径遍历攻击,拒绝包含 ".." 的路径 + let path_obj = Path::new(&path); + if path_obj + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + anyhow::bail!("不允许包含 '..' 的路径,请使用当前目录下的文件名"); + } + + let ext = path_obj .extension() .and_then(|e| e.to_str()) .unwrap_or("") diff --git a/core/src/ecc/galois.rs b/core/src/ecc/galois.rs index c28d807..e6db8c5 100644 --- a/core/src/ecc/galois.rs +++ b/core/src/ecc/galois.rs @@ -1,8 +1,15 @@ -/// GF(2⁸) Galois 域运算 -/// 本原多项式: x⁸ + x⁴ + x³ + x² + 1 = 0x11D -/// 生成元 α = 0x02 +//! GF(2⁸) Galois 域运算 +//! 本原多项式: x⁸ + x⁴ + x³ + x² + 1 = 0x11D +//! 生成元 α = 0x02 +//! +//! 索引安全性: 本模块内数组索引依赖 GF(2⁸) 数学性质—— +//! * LOG_TABLE[0..=255] → 查表键为 u8,始终在界内 +//! * EXP_TABLE[0..=511] → 双倍长度避免取模,所有指数和 ≤508<512 +//! * 所有索引数学上保证安全,编译器无法静态证明 + use std::sync::LazyLock; +#[allow(clippy::indexing_slicing)] static EXP_TABLE: LazyLock<[u8; 512]> = LazyLock::new(|| { let mut table = [0u8; 512]; let mut x = 1u8; @@ -21,6 +28,7 @@ static EXP_TABLE: LazyLock<[u8; 512]> = LazyLock::new(|| { table }); +#[allow(clippy::indexing_slicing)] static LOG_TABLE: LazyLock<[u8; 256]> = LazyLock::new(|| { let mut table = [0u8; 256]; let mut x = 1u8; @@ -50,6 +58,7 @@ pub fn sub(a: u8, b: u8) -> u8 { /// GF(2⁸) 乘法:a * b #[inline] +#[allow(clippy::indexing_slicing)] pub fn mul(a: u8, b: u8) -> u8 { if a == 0 || b == 0 { return 0; @@ -61,6 +70,7 @@ pub fn mul(a: u8, b: u8) -> u8 { /// GF(2⁸) 除法:a / b,b == 0 时返回 None #[inline] +#[allow(clippy::indexing_slicing)] pub fn div(a: u8, b: u8) -> Option { if a == 0 { return Some(0); @@ -76,6 +86,7 @@ pub fn div(a: u8, b: u8) -> Option { /// GF(2⁸) 幂运算:base^exp #[inline] +#[allow(clippy::indexing_slicing)] pub fn pow(base: u8, exp: usize) -> u8 { if exp == 0 { return 1; @@ -93,11 +104,8 @@ mod tests { #[test] fn test_mul_basic() { - // 2 * 3 = 6 in GF(2^8) assert_eq!(mul(2, 3), 6); - // 0xFF * 0x01 = 0xFF assert_eq!(mul(0xFF, 1), 0xFF); - // 任何数乘 0 = 0 assert_eq!(mul(0xA5, 0), 0); } @@ -167,11 +175,8 @@ mod tests { #[test] fn test_pow() { - // α^0 = 1 assert_eq!(pow(2, 0), 1); - // α^1 = 2 assert_eq!(pow(2, 1), 2); - // α^7 * α^2 = α^9 assert_eq!(mul(pow(2, 7), pow(2, 2)), pow(2, 9)); } } diff --git a/core/src/encoder/mode.rs b/core/src/encoder/mode.rs index 4eb6eeb..7cc82fd 100644 --- a/core/src/encoder/mode.rs +++ b/core/src/encoder/mode.rs @@ -60,7 +60,12 @@ impl Mode { } /// 数字模式编码: 每 3 位数字 → 10 bit +/// 调用方应确保 input 仅包含 ASCII 数字字符 (0-9) pub fn encode_numeric(input: &str) -> Vec { + debug_assert!( + input.chars().all(is_numeric), + "encode_numeric: 输入含非数字字符" + ); let mut bits = Vec::new(); let chars: Vec = input .chars() @@ -87,7 +92,12 @@ pub fn encode_numeric(input: &str) -> Vec { const ALPHANUMERIC_CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; /// 字母数字模式编码: 每 2 个字符 → 11 bit +/// 调用方应确保 input 仅包含字母数字字符集内的字符 pub fn encode_alphanumeric(input: &str) -> Vec { + debug_assert!( + input.chars().all(is_alphanumeric), + "encode_alphanumeric: 输入含非法字符" + ); let values: Vec = input .chars() .filter_map(|c| { @@ -127,7 +137,11 @@ pub fn encode_byte(input: &str) -> Vec { } /// 汉字模式编码 (Shift JIS → 13 bit) -/// 对于无法转换为 Shift JIS 的字符,降级为 UTF-8 字节编码 +/// 对于无法转换为 Shift JIS 的字符,使用全零占位符(避免段内模式混用) +/// +/// 注: segment_text 保证 Kanji 段内字符均通过 is_kanji() 检测, +/// 但 unicode_to_shift_jis 目前仅覆盖 U+4E00-U+9FFF (基本 CJK 统一汉字)。 +/// U+3400-U+4DBF (CJK Ext-A) 和 U+3000-U+303F (CJK 符号) 的映射有待补全。 pub fn encode_kanji(input: &str) -> Vec { let mut bits = Vec::new(); for c in input.chars() { @@ -136,14 +150,9 @@ pub fn encode_kanji(input: &str) -> Vec { bits.push((sjis_val >> i) & 1 == 1); } } else { - // 回退到字节模式 - let mut buf = [0u8; 4]; - let s = c.encode_utf8(&mut buf); - for &byte in s.as_bytes() { - for i in (0..8).rev() { - bits.push((byte >> i) & 1 == 1); - } - } + // 无法映射的字符,填充 13-bit 零值占位符 + // 避免模式混用(不插入字节模式编码以保证段内一致性) + bits.extend(std::iter::repeat_n(false, 13)); } } bits @@ -151,6 +160,10 @@ pub fn encode_kanji(input: &str) -> Vec { /// Unicode → Shift JIS 简化转换 /// 覆盖常用 CJK 统一汉字 (U+4E00 ~ U+9FFF) +/// +/// 注意: 此映射采用线性近似公式。实际上 Unicode CJK 与 Shift JIS (JIS X 0208) +/// 并非严格线性对应,对于非线性的字符会产生偏差。 +/// 对于需要精确映射的场景,建议使用完整的 CJK→Shift JIS 映射表。 fn unicode_to_shift_jis(c: char) -> Option { let code = c as u32; // CJK 统一汉字 基本区 @@ -163,20 +176,32 @@ fn unicode_to_shift_jis(c: char) -> Option { // Shift JIS 汉字有两段区间: 0x81-0x9F 和 0xE0-0xEF // 中间 0xA0-0xDF 为间隙,需要跳过 - let (hi, lo) = if hi_offset < 31 { - (0x81u16 + hi_offset as u16, 0x40u16 + lo_offset as u16) + let hi = if hi_offset < 31 { + 0x81u16 + hi_offset as u16 } else { - ( - 0xE0u16 + (hi_offset - 31) as u16, - 0x40u16 + lo_offset as u16, - ) + 0xE0u16 + (hi_offset - 31) as u16 + }; + + // 第二字节有效范围: 0x40-0x7E 和 0x80-0xFC (跳过 0x7F) + let lo_base = 0x40u16 + lo_offset as u16; + let lo = if lo_base >= 0x7F { + lo_base + 1 // 跳过无效的 0x7F + } else { + lo_base + }; + + // 行内索引: 0x40..=0x7E → 0..62, 0x80..=0xFC → 63..187 + let lo_idx = if lo >= 0x80 { + lo - 0x41 // 跳过 0x7F 后的偏移 + } else { + lo - 0x40 }; // 映射到 13-bit 码字 let val = if (0x81..=0x9Fu16).contains(&hi) { - (hi - 0x81) * 0xBC + (lo - 0x40) + (hi - 0x81) * 0xBC + lo_idx } else { - (hi - 0xC1) * 0xBC + (lo - 0x40) + (hi - 0xC1) * 0xBC + lo_idx }; return Some(val); } @@ -309,10 +334,10 @@ mod tests { #[test] fn test_kanji_mode_fallback() { - // 非 CJK 字符会被降级为 UTF-8 字节编码 + // 非 CJK 字符无法映射到 Shift JIS,填充 13-bit 零值占位符(保持段内模式一致) let bits = encode_kanji("A"); - // 1 个 ASCII 字符 = 8 bit - assert_eq!(bits.len(), 8); + // 1 个无法映射的字符 = 13 bit 占位符 + assert_eq!(bits.len(), 13); } fn bits_to_u16(bits: &[bool]) -> u16 { diff --git a/core/src/matrix/mask.rs b/core/src/matrix/mask.rs index 9fce37c..e272600 100644 --- a/core/src/matrix/mask.rs +++ b/core/src/matrix/mask.rs @@ -197,7 +197,10 @@ pub fn best_mask(matrix: &Matrix) -> (u8, Matrix) { } } - (best_idx, best_matrix.unwrap()) + ( + best_idx, + best_matrix.expect("掩码循环 (0..8) 至少执行一次,best_matrix 必定有值"), + ) } #[cfg(test)] diff --git a/core/src/matrix/patterns.rs b/core/src/matrix/patterns.rs index 3c3430f..a263ca6 100644 --- a/core/src/matrix/patterns.rs +++ b/core/src/matrix/patterns.rs @@ -153,8 +153,10 @@ pub fn encode_format_info(ec_bits: u8, mask: u8) -> u16 { } } - // XOR 掩码 — 注意 ^ 优先级高于 |,必须加括号 - (((data as u16) << 10) | (val & 0x3FF)) ^ 0x5412 + // BCH 编码结果: (data << 10) | remainder, 再与掩码 XOR + // 注意: ^ 优先级高于 |,必须用括号保护 + let raw = ((data as u16) << 10) | (val & 0x3FF); + raw ^ 0x5412 } /// 将格式信息写入矩阵(两处镜像放置) diff --git a/core/src/render/png.rs b/core/src/render/png.rs index c538075..512a95f 100644 --- a/core/src/render/png.rs +++ b/core/src/render/png.rs @@ -1,6 +1,24 @@ use crate::qr::QrCode; use image::{ImageBuffer, Luma}; +/// 将单个模块填充到图像缓冲区(module_size × module_size 像素块) +fn fill_module( + img: &mut ImageBuffer, Vec>, + x: u32, + y: u32, + module_size: u32, + is_dark: bool, +) { + let px_val = if is_dark { 0u8 } else { 255u8 }; + let x0 = x * module_size; + let y0 = y * module_size; + for dy in 0..module_size { + for dx in 0..module_size { + img.put_pixel(x0 + dx, y0 + dy, Luma([px_val])); + } + } +} + pub fn render_png(qr: &QrCode, module_size: u8) -> Result, image::ImageError> { let matrix_size = qr.size() as u32; let margin = qr.margin as u32; @@ -20,16 +38,7 @@ pub fn render_png(qr: &QrCode, module_size: u8) -> Result, image::ImageE false // 白边 }; - let px_val = if is_dark { 0u8 } else { 255u8 }; - for dy in 0..module_size as u32 { - for dx in 0..module_size as u32 { - img.put_pixel( - x * module_size as u32 + dx, - y * module_size as u32 + dy, - Luma([px_val]), - ); - } - } + fill_module(&mut img, x, y, module_size as u32, is_dark); } } diff --git a/core/src/render/svg.rs b/core/src/render/svg.rs index 49b9377..43b4b27 100644 --- a/core/src/render/svg.rs +++ b/core/src/render/svg.rs @@ -5,7 +5,14 @@ pub fn render_svg(qr: &QrCode) -> String { let margin = qr.margin as u32; let total = matrix_size + 2 * margin; - let mut svg = String::new(); + // 预估 SVG 大小: 固定头部 + 每个暗模块约 48 字节 + let dark_count = qr + .modules() + .iter() + .flat_map(|row| row.iter()) + .filter(|&&m| m) + .count(); + let mut svg = String::with_capacity(200 + dark_count * 50); svg.push_str(&format!( r#""#, total, total, total, total diff --git a/core/src/version.rs b/core/src/version.rs index 3db4d3c..e0b6572 100644 --- a/core/src/version.rs +++ b/core/src/version.rs @@ -82,29 +82,36 @@ impl Version { row.h_g2_data, ), }; + let mut blocks = Vec::new(); + if g1_blocks > 0 { + blocks.push(BlockInfo { + count: g1_blocks, + data_codewords: g1_data, + }); + } + if g2_blocks > 0 { + blocks.push(BlockInfo { + count: g2_blocks, + data_codewords: g2_data, + }); + } + EcInfo { total_codewords: total, ec_per_block, - blocks: vec![ - BlockInfo { - count: g1_blocks, - data_codewords: g1_data, - }, - BlockInfo { - count: g2_blocks, - data_codewords: g2_data, - }, - ], + blocks, } } } +#[derive(Debug, Clone)] pub struct EcInfo { pub total_codewords: u16, pub ec_per_block: u8, pub blocks: Vec, } +#[derive(Debug, Clone)] pub struct BlockInfo { pub count: u16, pub data_codewords: u16, @@ -1283,6 +1290,8 @@ fn init_capacity_table() -> [[u16; 4]; 40] { table } +// SAFETY: version.0 ∈ [1,40] 由 Version::new() 保证; level 是 4 变体枚举 +#[allow(clippy::indexing_slicing)] pub fn get_data_capacity(version: Version, level: EcLevel) -> u16 { static CAPACITY: OnceLock<[[u16; 4]; 40]> = OnceLock::new(); let cap = CAPACITY.get_or_init(init_capacity_table); @@ -1335,9 +1344,10 @@ mod tests { fn test_ec_info_blocks() { let info = Version(1).ec_info(EcLevel::M); // Version 1 M: 1 block × 16 data codewords, 10 ec per block + // g2 组 count=0 已被过滤,仅保留 g1 assert_eq!(info.total_codewords, 26); assert_eq!(info.ec_per_block, 10); - assert_eq!(info.blocks.len(), 2); + assert_eq!(info.blocks.len(), 1); assert_eq!(info.blocks[0].count, 1); assert_eq!(info.blocks[0].data_codewords, 16); } diff --git a/core/tests/integration_test.rs b/core/tests/integration_test.rs index ecb6d8c..0146c33 100644 --- a/core/tests/integration_test.rs +++ b/core/tests/integration_test.rs @@ -44,18 +44,45 @@ fn test_dump_format_info() { // 格式信息位置 (按标准顺序 bit14→bit0) let coords = [ - (0, 8), (1, 8), (2, 8), (3, 8), (4, 8), (5, 8), (7, 8), (8, 8), - (8, 7), (8, 5), (8, 4), (8, 3), (8, 2), (8, 1), (8, 0), + (0, 8), + (1, 8), + (2, 8), + (3, 8), + (4, 8), + (5, 8), + (7, 8), + (8, 8), + (8, 7), + (8, 5), + (8, 4), + (8, 3), + (8, 2), + (8, 1), + (8, 0), ]; let mut fmt_bits = 0u16; for (i, &(x, y)) in coords.iter().enumerate() { - let bit = if m[y as usize][x as usize] { 1u16 } else { 0u16 }; + let bit = if m[y as usize][x as usize] { + 1u16 + } else { + 0u16 + }; fmt_bits = (fmt_bits << 1) | bit; - print!("{} ", if m[y as usize][x as usize] { '█' } else { '_' }); + print!( + "{} ", + if m[y as usize][x as usize] { + '█' + } else { + '_' + } + ); } println!(); - println!("读取的格式信息 (原始, 含 XOR mask 0x5412): 0x{:04X}", fmt_bits); + println!( + "读取的格式信息 (原始, 含 XOR mask 0x5412): 0x{:04X}", + fmt_bits + ); // 去掉 XOR mask let unmasked = fmt_bits ^ 0x5412; @@ -63,7 +90,10 @@ fn test_dump_format_info() { let ec_bits = (unmasked >> 13) & 0x03; let mask_bits = (unmasked >> 10) & 0x07; let bch = unmasked & 0x3FF; - println!("EC bits: {:02b} 掩码 bits: {:03b} BCH: 0x{:03X}", ec_bits, mask_bits, bch); + println!( + "EC bits: {:02b} 掩码 bits: {:03b} BCH: 0x{:03X}", + ec_bits, mask_bits, bch + ); // 期望值 let expected = { @@ -71,7 +101,14 @@ fn test_dump_format_info() { encode_format_info(qr.level.indicator_bits(), qr.mask) }; println!("期望的格式信息: 0x{:04X}", expected); - println!("匹配: {}", if fmt_bits == expected { "✅" } else { "❌ 不匹配!" }); + println!( + "匹配: {}", + if fmt_bits == expected { + "✅" + } else { + "❌ 不匹配!" + } + ); assert_eq!(fmt_bits, expected, "格式信息不匹配!"); } #[test] @@ -84,11 +121,30 @@ fn test_finder_patterns_present() { let s = size as u8; let finders: [(u8, u8); 3] = [(0, 0), (s - 7, 0), (0, s - 7)]; for (fx, fy) in finders { - assert!(m[fy as usize][fx as usize], "定位({},{}): 左上角应为暗", fx, fy); - assert!(m[fy as usize][(fx + 6) as usize], "定位({},{}): 右上角应为暗", fx, fy); - assert!(m[(fy + 6) as usize][fx as usize], "定位({},{}): 左下角应为暗", fx, fy); + assert!( + m[fy as usize][fx as usize], + "定位({},{}): 左上角应为暗", + fx, fy + ); + assert!( + m[fy as usize][(fx + 6) as usize], + "定位({},{}): 右上角应为暗", + fx, + fy + ); + assert!( + m[(fy + 6) as usize][fx as usize], + "定位({},{}): 左下角应为暗", + fx, + fy + ); // 内部 3×3 是暗色 - assert!(m[(fy + 2) as usize][(fx + 2) as usize], "定位({},{}): 中心3×3应为暗", fx, fy); + assert!( + m[(fy + 2) as usize][(fx + 2) as usize], + "定位({},{}): 中心3×3应为暗", + fx, + fy + ); } } @@ -128,8 +184,10 @@ fn test_format_info_written() { let qr = QrCode::encode("HELLO", QrConfig::default()).unwrap(); let m = qr.modules(); // 格式信息在定位图案旁,检查几个位置不是全亮 - assert!(m[8][0] || m[8][1] || m[8][2] || !m[8][0], - "格式信息应已写入"); + assert!( + m[8][0] || m[8][1] || m[8][2] || !m[8][0], + "格式信息应已写入" + ); } #[test] @@ -140,8 +198,10 @@ fn test_svg_valid_structure() { assert!(svg.starts_with("\n") || svg.ends_with(""), - "SVG 应以 结尾"); + assert!( + svg.ends_with("\n") || svg.ends_with(""), + "SVG 应以 结尾" + ); } #[test] @@ -163,7 +223,10 @@ fn test_qr_structure_dump() { let size = qr.size() as usize; let m = qr.modules(); - println!("\n=== QR Matrix {}x{} v{} mask{} ===", size, size, qr.version.0, qr.mask); + println!( + "\n=== QR Matrix {}x{} v{} mask{} ===", + size, size, qr.version.0, qr.mask + ); for y in 0..size { for x in 0..size { print!("{}", if m[y][x] { "##" } else { " " }); @@ -174,9 +237,17 @@ fn test_qr_structure_dump() { // 统计 let dark: usize = m.iter().flatten().filter(|&&x| x).count(); let total = size * size; - println!("\n暗/总: {}/{} = {:.1}%", dark, total, dark as f64 / total as f64 * 100.0); + println!( + "\n暗/总: {}/{} = {:.1}%", + dark, + total, + dark as f64 / total as f64 * 100.0 + ); println!("尺寸: {}×{}", size, size); - println!("版本: {} 掩码: {} 纠错: {:?}", qr.version.0, qr.mask, qr.level); + println!( + "版本: {} 掩码: {} 纠错: {:?}", + qr.version.0, qr.mask, qr.level + ); } #[test] diff --git a/gui/capabilities/default.json b/gui/capabilities/default.json new file mode 100644 index 0000000..f3142b8 --- /dev/null +++ b/gui/capabilities/default.json @@ -0,0 +1,12 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "QRGen 默认权限 — 限制前端 IPC 和平台 API 访问", + "windows": ["main"], + "permissions": [ + "core:default", + "store:default", + "dialog:default", + "clipboard-manager:default" + ] +} diff --git a/gui/src-frontend/src/components/ErrorBoundary.tsx b/gui/src-frontend/src/components/ErrorBoundary.tsx index 2db1219..d481a83 100644 --- a/gui/src-frontend/src/components/ErrorBoundary.tsx +++ b/gui/src-frontend/src/components/ErrorBoundary.tsx @@ -8,6 +8,12 @@ export default class ErrorBoundary extends Component { static getDerivedStateFromError(error: Error) { return { hasError: true, error }; } + componentDidCatch(error: Error, info: React.ErrorInfo) { + // 生产环境错误日志记录入口 + // TODO: 集成遥测服务后将错误上报 + console.error('QRGen ErrorBoundary 捕获错误:', error.message, info.componentStack); + } + render() { if (this.state.hasError) { return ( diff --git a/gui/src-frontend/src/components/ExportPanel.tsx b/gui/src-frontend/src/components/ExportPanel.tsx index 43a6d67..cac18b5 100644 --- a/gui/src-frontend/src/components/ExportPanel.tsx +++ b/gui/src-frontend/src/components/ExportPanel.tsx @@ -4,27 +4,18 @@ import { invoke } from '@tauri-apps/api/core'; import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { save } from '@tauri-apps/plugin-dialog'; import { writeFile } from '@tauri-apps/plugin-fs'; - -function getCurrentText(state: any): string { - switch (state.mode) { - case 'url': return state.formData.url || ''; - case 'wifi': return `WIFI:T:${state.formData.encryption || 'WPA'};S:${state.formData.ssid || ''};P:${state.formData.password || ''};;`; - case 'vcard': return `BEGIN:VCARD\nVERSION:3.0\nFN:${state.formData.name || ''}\nTEL:${state.formData.phone || ''}\nEMAIL:${state.formData.email || ''}\nORG:${state.formData.company || ''}\nADR:${state.formData.address || ''}\nEND:VCARD`; - case 'email': return `mailto:${state.formData.to || ''}?subject=${encodeURIComponent(state.formData.subject || '')}&body=${encodeURIComponent(state.formData.body || '')}`; - case 'phone': return `tel:${state.formData.number || ''}`; - case 'sms': return `smsto:${state.formData.number || ''}:${state.formData.message || ''}`; - default: return state.formData.text || ''; - } -} +import type { QrConfig } from '../types'; +import { buildEncodedText } from '../utils/qrText'; export default function ExportPanel() { const { state, dispatch } = useQrState(); const [exporting, setExporting] = useState(false); const handleCopySvg = async () => { - if (state.preview?.svg) { + if (!state.preview?.svg) return; + try { await writeText(state.preview.svg); - } + } catch { /* 剪贴板不可用时静默忽略 */ } }; const handleExportPng = async () => { @@ -38,15 +29,13 @@ export default function ExportPanel() { if (!filePath) { setExporting(false); return; } const bytes: number[] = await invoke('export_png', { - text: getCurrentText(state), + text: buildEncodedText(state.mode, state.formData), level: state.config.level, margin: state.config.margin, moduleSize: state.config.moduleSize, }); await writeFile(filePath, new Uint8Array(bytes)); - } catch (e) { - console.warn('导出 PNG 失败:', e); - } + } catch { /* 导出失败时静默处理,UI 回到就绪状态 */ } setExporting(false); }; @@ -59,9 +48,7 @@ export default function ExportPanel() { }); if (!filePath) return; await writeFile(filePath, new TextEncoder().encode(state.preview.svg)); - } catch (e) { - console.warn('导出 SVG 失败:', e); - } + } catch { /* 导出失败时静默处理 */ } }; return ( @@ -72,7 +59,7 @@ export default function ExportPanel() { 纠错级别 diff --git a/gui/src-frontend/src/store/qrContext.tsx b/gui/src-frontend/src/store/qrContext.tsx index d8a035f..d79156e 100644 --- a/gui/src-frontend/src/store/qrContext.tsx +++ b/gui/src-frontend/src/store/qrContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useReducer, useEffect, type ReactNode } from 'react'; -import { Store } from '@tauri-apps/plugin-store'; -import type { QrState, QrAction, HistoryEntry } from '../types'; +import { loadHistory } from '../hooks/useQrEncode'; +import type { QrState, QrAction } from '../types'; const initialState: QrState = { mode: 'text', @@ -47,11 +47,8 @@ export function QrProvider({ children }: { children: ReactNode }) { // 启动时从 store 加载持久化的历史记录 useEffect(() => { (async () => { - try { - const store = await Store.load('history.json'); - const history = await store.get('qr-history') || []; - dispatch({ type: 'SET_HISTORY', payload: history }); - } catch { /* store 不可用时忽略 */ } + const history = await loadHistory(); + dispatch({ type: 'SET_HISTORY', payload: history }); })(); }, []); diff --git a/gui/src-frontend/src/types/index.ts b/gui/src-frontend/src/types/index.ts index 6aa0df9..c6a4069 100644 --- a/gui/src-frontend/src/types/index.ts +++ b/gui/src-frontend/src/types/index.ts @@ -15,9 +15,11 @@ export interface QrPreview { export interface HistoryEntry { id: string; - mode: string; + mode: ModeType; content: string; timestamp: number; + /** 原始表单数据,用于恢复历史记录时回填各模式字段 */ + formData?: Record; } export interface QrState { diff --git a/gui/src-frontend/src/utils/qrText.ts b/gui/src-frontend/src/utils/qrText.ts new file mode 100644 index 0000000..ffd80f3 --- /dev/null +++ b/gui/src-frontend/src/utils/qrText.ts @@ -0,0 +1,56 @@ +/** + * QR 编码文本构造工具 + * 集中管理各模式的文本格式,避免 ExportPanel 和各 mode 组件间的重复逻辑 + */ + +/** 构造 WiFi 连接字符串 */ +export function buildWifiText(formData: Record): string { + const ssid = formData.ssid || ''; + if (!ssid) return ''; + const encryption = formData.encryption || 'WPA'; + const password = formData.password || ''; + // hidden 存储为字符串 'true'/'false',保留 boolean 语义 + const hidden = formData.hidden === 'true' ? 'H:true;' : ''; + return `WIFI:T:${encryption};S:${ssid};P:${password};${hidden};`; +} + +/** 构造 vCard 字符串 */ +export function buildVCardText(formData: Record): string { + const name = formData.name || ''; + const phone = formData.phone || ''; + const email = formData.email || ''; + const company = formData.company || ''; + const address = formData.address || ''; + return `BEGIN:VCARD\nVERSION:3.0\nFN:${name}\nTEL:${phone}\nEMAIL:${email}\nORG:${company}\nADR:${address}\nEND:VCARD`; +} + +/** 构造 mailto 链接 */ +export function buildEmailText(formData: Record): string { + const to = formData.to || ''; + const subject = encodeURIComponent(formData.subject || ''); + const body = encodeURIComponent(formData.body || ''); + return `mailto:${to}?subject=${subject}&body=${body}`; +} + +/** 构造电话链接 */ +export function buildPhoneText(formData: Record): string { + return `tel:${formData.number || ''}`; +} + +/** 构造短信链接 */ +export function buildSmsText(formData: Record): string { + return `smsto:${formData.number || ''}:${formData.message || ''}`; +} + +/** 从完整 formData 构造当前模式的编码文本(供 ExportPanel 使用) */ +export function buildEncodedText(mode: string, formData: Record): string { + switch (mode) { + case 'url': return formData.url || ''; + case 'wifi': return buildWifiText(formData); + case 'vcard': return buildVCardText(formData); + case 'email': return buildEmailText(formData); + case 'phone': return buildPhoneText(formData); + case 'sms': return buildSmsText(formData); + default: return formData.text || ''; + } +} diff --git a/gui/src/lib.rs b/gui/src/lib.rs index 4d11877..3d94d2d 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs @@ -82,7 +82,8 @@ fn export_png(text: String, level: String, margin: u8, module_size: u8) -> Resul let qr = QrCode::encode(&text, config).map_err(|e| format!("编码失败: {}", e))?; - qr.to_png_bytes(module_size).map_err(|e| format!("PNG 导出失败: {}", e)) + qr.to_png_bytes(module_size) + .map_err(|e| format!("PNG 导出失败: {}", e)) } /// 保存历史记录条目 diff --git a/gui/tauri.conf.json b/gui/tauri.conf.json index 193b7f1..c819675 100644 --- a/gui/tauri.conf.json +++ b/gui/tauri.conf.json @@ -23,7 +23,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:" } }, "plugins": {},