Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: CI

on:
push:
branches: [master, fix/*]
pull_request:
branches: [master]

jobs:
test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm

- name: Install frontend dependencies
run: npm ci

- name: Frontend unit tests
run: npm test

- name: Svelte typecheck
run: npm run check

- name: Rust unit tests
run: cargo test
working-directory: src-tauri
4 changes: 2 additions & 2 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ Knowledge workers, researchers, lawyers, consultants, and power users who live i
## Non-Functional Requirements
- 100% local (LiteParse v2 native OCR; optional first-run Tesseract language data).
- Native macOS look & feel (dark mode, SF Symbols, accessibility, haptics).
- Zero telemetry, zero network except LiteParse necessities.
- Zero telemetry. Network use is limited to optional GitHub release checks (updater) and first-run OCR language data downloads when needed.
- Minimal CPU/memory; smart concurrency (max 4-6 workers on M-series).
- Signed & notarized .app build ready.
- Ad-hoc signed `.app` builds (see `docs/RELEASING.md`); notarization is planned for public distribution.
6 changes: 3 additions & 3 deletions brand.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,13 @@ ParseKit uses custom CSS variables (not shadcn). Backup at `src/index.css.bak`.

Body, primary CTA, and muted text pairs verified for WCAG AA in light and dark derivations.

## Typography — Instrument Serif + Inter + JetBrains Mono
## Typography — Instrument Serif + System Sans + JetBrains Mono

- **Display (app title, about header):** Instrument Serif
- **Body (UI):** Inter
- **Body (UI):** System sans (`-apple-system`, SF Pro Text) for native macOS feel
- **Mono (counts, workers):** JetBrains Mono

Loaded via Google Fonts in `src/index.css`. Variables: `--font-serif`, `--font-sans`, `--font-mono`.
Instrument Serif and JetBrains Mono are bundled locally in `src/index.css`. Variables: `--font-serif`, `--font-sans`, `--font-mono`.

## Gradients

Expand Down
77 changes: 69 additions & 8 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,26 @@ fn imagemagick_available() -> bool {
)
}

#[cfg(test)]
mod security_path_tests {
use super::sanitize_report_file_name;

#[test]
fn sanitize_report_file_name_accepts_simple_names() {
assert_eq!(
sanitize_report_file_name("parsekit-errors.txt").unwrap(),
"parsekit-errors.txt"
);
}

#[test]
fn sanitize_report_file_name_rejects_traversal() {
assert!(sanitize_report_file_name("../secrets.txt").is_err());
assert!(sanitize_report_file_name("sub/file.txt").is_err());
assert!(sanitize_report_file_name("").is_err());
}
}

#[cfg(test)]
mod dependency_detect_tests {
use super::*;
Expand Down Expand Up @@ -760,10 +780,41 @@ fn gatekeeper_fix_command() -> String {
"xattr -cr /Applications/ParseKit.app && xattr -d com.apple.FinderInfo /Applications/ParseKit.app 2>/dev/null || true".to_string()
}

/// Reject path components in report filenames (IPC boundary).
pub(crate) fn sanitize_report_file_name(file_name: &str) -> Result<String, String> {
let trimmed = file_name.trim();
if trimmed.is_empty() {
return Err("File name is empty".into());
}
if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
return Err("Invalid file name".into());
}
if trimmed.chars().any(|c| c.is_control()) {
return Err("Invalid file name".into());
}
Ok(trimmed.to_string())
}

/// Ensure the parent directory of `target` stays under `base_dir`.
fn path_stays_within_base(base_dir: &std::path::Path, target: &std::path::Path) -> Result<(), String> {
let base = std::fs::canonicalize(base_dir)
.map_err(|e| format!("Could not resolve output folder: {e}"))?;
let parent = target
.parent()
.ok_or_else(|| "Invalid report path".to_string())?;
let parent_resolved = std::fs::canonicalize(parent)
.map_err(|e| format!("Could not resolve report folder: {e}"))?;
if !parent_resolved.starts_with(&base) {
return Err("Report path escapes the output folder".into());
}
Ok(())
}

/// Write a text report (error log) into `dir/file_name`, then reveal it in Finder.
/// Returns the full path of the written file.
#[tauri::command]
fn save_error_report(dir: String, file_name: String, contents: String) -> Result<String, String> {
let safe_name = sanitize_report_file_name(&file_name)?;
let mut path = std::path::PathBuf::from(&dir);
if !path.is_dir() {
// Fall back to ~/Downloads if the original output dir is gone.
Expand All @@ -776,7 +827,9 @@ fn save_error_report(dir: String, file_name: String, contents: String) -> Result
std::path::PathBuf::from(home)
};
}
path.push(&file_name);
let base_dir = path.clone();
path.push(&safe_name);
path_stays_within_base(&base_dir, &path)?;
std::fs::write(&path, contents).map_err(|e| format!("Could not write report: {e}"))?;
let path_str = path.to_string_lossy().to_string();
#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -870,6 +923,9 @@ pub(crate) const SUPPORTED_EXTENSIONS: &[&str] = &[
"xlsm", "ods", "csv", "tsv", "png", "jpg", "jpeg", "gif", "bmp", "tiff", "tif", "webp", "svg",
];

const SCAN_MAX_DEPTH: usize = 32;
const SCAN_MAX_FILES: usize = 10_000;

pub(crate) fn scan_directory_sync(path: String) -> Result<Vec<String>, String> {
let path = normalize_user_path(path);
let dir = Path::new(&path);
Expand All @@ -878,9 +934,19 @@ pub(crate) fn scan_directory_sync(path: String) -> Result<Vec<String>, String> {
}

let mut files = Vec::new();
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
for entry in WalkDir::new(dir)
.max_depth(SCAN_MAX_DEPTH)
.follow_links(false)
.into_iter()
{
let entry = entry.map_err(|e| format!("Could not scan directory: {e}"))?;
let entry_path = entry.path();
if entry_path.is_file() {
if files.len() >= SCAN_MAX_FILES {
return Err(format!(
"Too many supported files (max {SCAN_MAX_FILES}). Choose a smaller folder."
));
}
if let Some(ext) = entry_path.extension().and_then(|e| e.to_str()) {
let ext_lower = ext.to_lowercase();
if SUPPORTED_EXTENSIONS.contains(&ext_lower.as_str()) {
Expand Down Expand Up @@ -1003,11 +1069,6 @@ fn open_in_finder(path: String) -> Result<(), String> {
Ok(())
}

#[tauri::command]
fn copy_file_to_clipboard(path: String) -> Result<Vec<u8>, String> {
std::fs::read(&path).map_err(|e| format!("Failed to read file: {}", e))
}

#[tauri::command]
fn show_main_window(
app: tauri::AppHandle,
Expand Down Expand Up @@ -1276,7 +1337,6 @@ pub fn run() {
path_is_directory,
canonicalize_paths,
open_in_finder,
copy_file_to_clipboard,
save_error_report,
get_system_info,
check_dependencies,
Expand Down Expand Up @@ -1503,6 +1563,7 @@ pub fn run() {
}

// Test-only: same `install_update` path as the gold banner "Install & Restart" button.
#[cfg(debug_assertions)]
if std::env::var("PARSEKIT_E2E_INSTALL_UPDATE").as_deref() == Ok("1") {
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
Expand Down
21 changes: 21 additions & 0 deletions src-tauri/src/token_stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};

const STATS_FILE_NAME: &str = "token-stats.json";
const MAX_TOKEN_EVENTS: usize = 500;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct FileTypeStats {
Expand Down Expand Up @@ -128,6 +129,10 @@ fn apply_record(stats: &mut TokenStats, input: &RecordInput) {
tokens_saved,
pages_unlocked,
});
if stats.events.len() > MAX_TOKEN_EVENTS {
let overflow = stats.events.len() - MAX_TOKEN_EVENTS;
stats.events.drain(0..overflow);
}
}

#[cfg(test)]
Expand Down Expand Up @@ -250,6 +255,22 @@ mod tests {
assert_eq!(load(), TokenStats::default());
}

#[test]
fn events_are_capped_at_max() {
let _guard = TestDirGuard::new();
for i in 0..(MAX_TOKEN_EVENTS + 25) {
record(RecordInput {
file_type: format!("pdf{i}"),
tokens_saved: 1,
pages_unlocked: 0,
documents_unlocked: 0,
})
.expect("record");
}
let stats = load();
assert_eq!(stats.events.len(), MAX_TOKEN_EVENTS);
}

#[test]
fn save_load_roundtrip() {
let _guard = TestDirGuard::new();
Expand Down
15 changes: 12 additions & 3 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@
collapseSlideOut,
hintFadeIn,
hintFadeOut,
panelBlurFlyIn,
panelBlurFlyInParams,
panelBlurFlyOut,
panelBlurFlyOutParams,
panelFadeIn,
Expand All @@ -87,6 +89,7 @@
import "./index.css";

const reducedMotion = $derived(prefersReducedMotion.current);
const mainPanelIn = $derived(panelBlurFlyInParams(reducedMotion));
const mainPanelOut = $derived(panelBlurFlyOutParams(reducedMotion));
const mainFadeIn = $derived(panelFadeIn(reducedMotion));
const mainFadeOut = $derived(panelFadeOut(reducedMotion));
Expand Down Expand Up @@ -934,13 +937,14 @@
<div class="shell">
{#if !showSettings && !showHistory}
{#key "main"}
<div class="motion-panel" out:panelBlurFlyOut={mainPanelOut}>
<div class="motion-panel" in:panelBlurFlyIn={mainPanelIn} out:panelBlurFlyOut={mainPanelOut}>
<div class="motion-panel-content">
<header>
<span>{t("app.name")}</span>
{#if appVersion}<span class="header-ver">{appVersion}</span>{/if}
<div class="header-actions">
<button
type="button"
class="icon-btn icon-btn-settings"
onclick={() => openSettings()}
onmouseenter={warmDependencies}
Expand All @@ -964,6 +968,7 @@
</svg>
</button>
<button
type="button"
class="icon-btn"
onclick={quitApp}
title={t("header.quit")}
Expand Down Expand Up @@ -1014,6 +1019,8 @@
<button
type="button"
class="config-collapse-btn"
in:fade={hintFadeInParams}
out:fade={hintFadeOutParams}
onclick={toggleConfigCollapsed}
aria-expanded={!configCollapsed}
>
Expand Down Expand Up @@ -1185,7 +1192,7 @@

{#if showHistory}
{#key "history"}
<div class="motion-panel" out:panelBlurFlyOut={mainPanelOut}>
<div class="motion-panel" in:panelBlurFlyIn={mainPanelIn} out:panelBlurFlyOut={mainPanelOut}>
<div class="motion-panel-content">
<HistoryScreen
batches={recentBatches}
Expand All @@ -1201,12 +1208,14 @@

{#if showSettings}
{#key "settings"}
<div class="motion-panel" out:panelBlurFlyOut={mainPanelOut}>
<div class="motion-panel" in:panelBlurFlyIn={mainPanelIn} out:panelBlurFlyOut={mainPanelOut}>
<div class="motion-panel-content">
{#key showAbout}
<div class="subview-fill" out:fade={subviewFadeOutParams}>
{#if showAbout}
<div in:fade={mainFadeIn} out:fade={mainFadeOut}>
<AboutScreen onClose={() => (showAbout = false)} />
</div>
{:else}
<SettingsScreen
locale={getLocale()}
Expand Down
7 changes: 5 additions & 2 deletions src/components/FormatSelector.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { t } from "../lib/i18n.svelte";
import type { OutputFormat } from "../lib/types";

let { value, onChange }: { value: OutputFormat; onChange: (format: OutputFormat) => void } = $props();
Expand All @@ -10,14 +11,16 @@
];
</script>

<div class="segmented-control">
<div class="segmented-control" role="group" aria-label={t("config.format")}>
{#each formats as fmt}
<button
type="button"
class="segment"
class:active={value === fmt.value}
aria-pressed={value === fmt.value}
onclick={() => onChange(fmt.value)}
>
{fmt.label}
</button>
{/each}
</div>
</div>
1 change: 1 addition & 0 deletions src/components/OutputFolderPicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
type="button"
class="output-folder-path-btn"
title={value || t("config.downloads")}
aria-label="{t('config.outputFolder')}: {value || t('config.downloads')}"
onclick={pick}
>
<span class="output-folder-path-text">{displayPath}</span>
Expand Down
17 changes: 15 additions & 2 deletions src/components/ProgressHud.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<script lang="ts">
import { fade } from "svelte/transition";
import { prefersReducedMotion } from "svelte/motion";
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { hintFadeIn, hintFadeOut } from "../lib/motion";
import { t } from "../lib/i18n.svelte";
import { isConverterDependencyError } from "../lib/converterErrors";
import {
Expand All @@ -23,6 +26,10 @@
let selectedErrorId = $state<string | null>(null);
let dismissTimer: ReturnType<typeof setTimeout> | undefined;

const reducedMotion = $derived(prefersReducedMotion.current);
const fadeInParams = $derived(hintFadeIn(reducedMotion));
const fadeOutParams = $derived(hintFadeOut(reducedMotion));

const total = $derived(hudState.total || hudState.files.length);
const finished = $derived(
hudState.files.filter(
Expand Down Expand Up @@ -120,7 +127,7 @@
</div>

{#if isComplete}
<div class="progress-hud-complete">
<div class="progress-hud-complete" in:fade={fadeInParams} out:fade={fadeOutParams}>
{#if tokensSaved > 0}
<span class="progress-hud-tokens">
{t("progressHud.tokensSaved", {
Expand All @@ -139,7 +146,13 @@
{/if}

{#if errorCount > 0 && selectedError}
<div class="progress-hud-error-detail" role="region" aria-label={selectedError.name}>
<div
class="progress-hud-error-detail"
role="region"
aria-label={selectedError.name}
in:fade={fadeInParams}
out:fade={fadeOutParams}
>
<button
type="button"
class="progress-hud-error-file"
Expand Down
Loading
Loading