diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 31a3b620..9972da85 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,6 +1,6 @@ --- name: Feature request -about: A new format, preset option or idea +about: A preset option, engine or idea (a file type Filee can't open goes in "Format request") labels: enhancement --- diff --git a/.github/ISSUE_TEMPLATE/format_request.yml b/.github/ISSUE_TEMPLATE/format_request.yml new file mode 100644 index 00000000..9776cea2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/format_request.yml @@ -0,0 +1,49 @@ +name: Format request +description: A file type Filee can't open yet +title: "Format request: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + > [!IMPORTANT] + > **Filee can't convert files that are encrypted or copy-protected (DRM), whatever their format.** Converting them means breaking that protection, so these requests are closed. + > + > Some examples, not a complete list: + > - music downloaded in a streaming app (NetEase Cloud Music `.ncm`, QQ Music `.qmc` / `.mflac` / `.mgg`, Kugou `.kgm`, Apple Music, Spotify) + > - e-books bought with DRM (Kindle `.azw` / `.kfx`, Adobe DRM EPUB or PDF) + > - videos saved by streaming apps or sites that don't allow downloading + > - files locked with a password you don't have + > + > If the app that made the file can export or save it in a common format, that is the way to go. + - type: input + id: extension + attributes: + label: File extension + placeholder: ".xyz" + validations: + required: true + - type: input + id: source + attributes: + label: What makes these files? + description: The program, device or website. + validations: + required: true + - type: input + id: target + attributes: + label: What should Filee convert them to? + placeholder: "PDF, MP3, PNG…" + - type: textarea + id: details + attributes: + label: Anything else? + description: A sample file if you can share one, a link to the format's documentation, or an open-source tool that reads it. + - type: checkboxes + id: not-protected + attributes: + label: Protection + options: + - label: These files aren't encrypted or copy-protected (DRM), and I can open them without a password. + required: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59faee05..4065b353 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,11 @@ Thanks for helping! Filee is built to be easy to extend: most contributions touc Engines lets you choose a specific copy. Microsoft Office is not used. 4. `dotnet run --project src/Filee.App`, `dotnet test`. +To try a build next to the Filee you use every day (testing, measuring memory), set `FILEE_DATA_DIR` to an empty +folder first. Settings, logs and downloaded engines then live there, the copy is its own single instance (later +launches with the same variable, `--quit` included, reach it and never your installed Filee), and it registers no +startup entry, context menu or Explorer menu. + Any IDE works (Visual Studio 2022+, Rider, VS Code with C# Dev Kit). ## Linux and macOS development diff --git a/README.ko.md b/README.ko.md index 6a3d6ae4..01f2a94c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -57,7 +57,7 @@ ## 기능 - **커서 옆 도넛**: 키를 누른 채 파일을 끌어 형식 위에 놓으면 끝. 파일에 맞는 형식만 나와요. -- **파일 종류마다 툴바**를 드래그로 배치하고, 품질·크기·DPI·PDF 페이지·동영상·오디오·저장 위치·파일 이름을 +- **파일 종류마다 툴바**를 드래그로 배치하고, 품질·크기·DPI·PDF 페이지와 압축·동영상·오디오·저장 위치·파일 이름을 프리셋으로 정해요. - **여러 파일을 한 번에** 병렬로 변환해요. 여러 단계가 필요한 변환(HWP → HWPX → DOCX)도 알아서 찾아요. - **자동 변환 폴더**에 들어온 파일을 바로 변환해요. @@ -83,7 +83,7 @@ |---|---| | **이미지**: JPG, PNG, WEBP, AVIF, TIFF, BMP, GIF, APNG, ICO, ICNS, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, HEIC, QOI, DDS, PCX, MNG, CUR 등; GIMP XCF, 카메라 RAW(CR2, CR3, NEF, ARW, DNG 등), DICOM, OpenEXR, HDR은 읽기 | 위 형식끼리(HEIC⁵), PDF, SVG(벡터화), 크기 조정, 흑백 | | **벡터**: SVG, SVGZ, EMF, WMF, AI · EPS, PS¹ · CDR, VSD, CGM, ODG, FODG² | PDF(벡터), PNG 등 이미지, SVG ↔ SVGZ, SVG → DXF/DWG, PDF → EPS/PS¹, SVG → EMF, WMF, ODG² | -| **PDF** | DOCX, HWPX, TXT, PNG, JPG, TIFF, CBZ, PPTX(페이지마다 그림 한 장), SVG²; 합치기, 나누기, 페이지 범위 | +| **PDF** | DOCX, HWPX, TXT, PNG, JPG, TIFF, CBZ, PPTX(페이지마다 그림 한 장), SVG²; 합치기, 나누기, 페이지 범위, 압축 (더 작은 PDF) | | **문서**: DOCX, DOCM, DOTX, EML, Outlook MSG · DOC, ODT, FODT, OTT, RTF, WPS, WPD, Pages, ClarisWorks, MacWrite, T602 등² | PDF, DOCX, HWPX, EPUB, TXT, HTML; MSG → EML · ODT, FODT, OTT² | | **스프레드시트**: XLSX, XLSM, XLS, XLT, ODS, CSV, TSV · OTS, FODS, Numbers, ET² | PDF, XLSX, ODS, CSV, TSV, JSON, HWPX, HTML · XLT, OTS² | | **프레젠테이션**: PPTX, PPTM, POTX, PPSX · PPT, ODP, OTP, FODP, Keynote² | PDF, PNG, JPG, DOCX, HWPX · OTP² | diff --git a/README.md b/README.md index 0547710b..4d175151 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Filee shows new releases in its menu and updates itself, keeping your settings a ## Features - **A donut at the cursor**: hold a key, drag files and drop them on a format. Only formats that fit the files appear. -- **One toolbar per file type**, arranged by drag and drop, with presets for quality, size, DPI, PDF pages, +- **One toolbar per file type**, arranged by drag and drop, with presets for quality, size, DPI, PDF pages and compression, video and audio, output folder and file names. - **Many files at once**, in parallel. Routes with several steps (HWP → HWPX → DOCX) are found automatically. - **Watch folders** convert every file that lands in them. @@ -83,7 +83,7 @@ Filee shows new releases in its menu and updates itself, keeping your settings a |---|---| | **Images**: JPG, PNG, WEBP, AVIF, TIFF, BMP, GIF, APNG, ICO, ICNS, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, HEIC, QOI, DDS, PCX, MNG, CUR, …; GIMP XCF, camera RAW (CR2, CR3, NEF, ARW, DNG, …), DICOM, OpenEXR, HDR read | any of these (HEIC⁵), PDF, SVG (traced), resized, grayscale | | **Vector**: SVG, SVGZ, EMF, WMF, AI · EPS, PS¹ · CDR, VSD, CGM, ODG, FODG² | PDF (vector), PNG and other images, SVG ↔ SVGZ, SVG → DXF/DWG, PDF → EPS/PS¹, SVG → EMF, WMF, ODG² | -| **PDF** | DOCX, HWPX, TXT, PNG, JPG, TIFF, CBZ, PPTX (a picture of each page), SVG²; merge, split, page ranges | +| **PDF** | DOCX, HWPX, TXT, PNG, JPG, TIFF, CBZ, PPTX (a picture of each page), SVG²; merge, split, page ranges, compress (smaller PDFs) | | **Documents**: DOCX, DOCM, DOTX, EML, Outlook MSG · DOC, ODT, FODT, OTT, RTF, WPS, WPD, Pages, ClarisWorks, MacWrite, T602, …² | PDF, DOCX, HWPX, EPUB, TXT, HTML; MSG → EML · ODT, FODT, OTT² | | **Spreadsheets**: XLSX, XLSM, XLS, XLT, ODS, CSV, TSV · OTS, FODS, Numbers, ET² | PDF, XLSX, ODS, CSV, TSV, JSON, HWPX, HTML · XLT, OTS² | | **Presentations**: PPTX, PPTM, POTX, PPSX · PPT, ODP, OTP, FODP, Keynote² | PDF, PNG, JPG, DOCX, HWPX · OTP² | diff --git a/README.zh-CN.md b/README.zh-CN.md index c6fe1dce..8cf5d8f0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -57,7 +57,7 @@ ## 功能 - **光标旁的圆环**,按住按键拖动文件,放到格式上即可。只显示适合这些文件的格式。 -- **每种文件类型一个工具栏**,拖放排列。质量、尺寸、DPI、PDF 页面、视频音频、保存位置和文件名都能用预设设置。 +- **每种文件类型一个工具栏**,拖放排列。质量、尺寸、DPI、PDF 页面和压缩、视频音频、保存位置和文件名都能用预设设置。 - **一次转换多个文件**,并行处理。需要多步的转换(HWP → HWPX → DOCX)会自动找到路线。 - **监视文件夹**,放入的文件自动转换。 - **其他方式** 右键菜单、键盘快捷键、拖放区域和 [`filee` 命令](#命令行)。 @@ -82,7 +82,7 @@ |---|---| | **图片**:JPG、PNG、WEBP、AVIF、TIFF、BMP、GIF、APNG、ICO、ICNS、JPEG XL、JPEG 2000、PSD/PSB、TGA、PPM、HEIC、QOI、DDS、PCX、MNG、CUR 等;GIMP XCF、相机 RAW(CR2、CR3、NEF、ARW、DNG 等)、DICOM、OpenEXR、HDR 仅读取 | 上述格式互转(HEIC⁵)、PDF、SVG(描摹)、缩放、灰度 | | **矢量图**:SVG、SVGZ、EMF、WMF、AI · EPS、PS¹ · CDR、VSD、CGM、ODG、FODG² | PDF(矢量)、PNG 等图片、SVG ↔ SVGZ、SVG → DXF/DWG、PDF → EPS/PS¹、SVG → EMF、WMF、ODG² | -| **PDF** | DOCX、HWPX、TXT、PNG、JPG、TIFF、CBZ、PPTX(每页一张图片)、SVG²;合并、拆分、页码范围 | +| **PDF** | DOCX、HWPX、TXT、PNG、JPG、TIFF、CBZ、PPTX(每页一张图片)、SVG²;合并、拆分、页码范围、压缩(更小的 PDF) | | **文档**:DOCX、DOCM、DOTX、EML、Outlook MSG · DOC、ODT、FODT、OTT、RTF、WPS、WPD、Pages、ClarisWorks、MacWrite、T602 等² | PDF、DOCX、HWPX、EPUB、TXT、HTML、MSG → EML · ODT、FODT、OTT² | | **表格**:XLSX、XLSM、XLS、XLT、ODS、CSV、TSV · OTS、FODS、Numbers、ET² | PDF、XLSX、ODS、CSV、TSV、JSON、HWPX、HTML · XLT、OTS² | | **演示文稿**:PPTX、PPTM、POTX、PPSX · PPT、ODP、OTP、FODP、Keynote² | PDF、PNG、JPG、DOCX、HWPX · OTP² | diff --git a/docs/ENGINES.md b/docs/ENGINES.md index e9d6873c..549d4cb9 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -8,7 +8,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | ImageMagick (Magick.NET) | built in (library) | JPG, PNG, WEBP, TIFF, BMP, GIF, ICO, AVIF, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, MNG, QOI, DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster, MIFF ↔ each other, and **APNG** (Filee's own reader and writer); HEIC (written with FFmpeg), XCF, camera RAW (LibRaw), DICOM (uncompressed ones by Filee's own reader), Photo CD, OpenEXR, Radiance HDR and EMF/WMF (Windows) read | | Vector graphics | built in (Svg.Skia, SkiaSharp, ACadSharp) | **SVG/SVGZ → PDF (vector) and images**, SVG ↔ SVGZ, **SVG → DXF** (→ DWG with the CAD engine), **raster images → SVG (tracing)**, PDF-compatible AI → PDF | | ICNS | built in | images → ICNS (16–1024 px), ICNS → images | -| PDFsharp | built in (library) | images → PDF, merge, split, page ranges | +| PDFsharp | built in (library) | images → PDF, merge, split, page ranges, **compression** (smaller PDFs, see below) | | PDFium | built in (library) | PDF → images (multi-page TIFF/GIF supported) | | Unhwp | built in (library) | HWP/HWPX → TXT, Markdown, HTML | | Markdown (Markdig) | built in (library) | **Markdown → HTML, TXT** (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) | @@ -29,8 +29,8 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | Archives (7-Zip) | bundled with the installer (`engines/7zip`, ~2.5 MB) + built-in readers | ZIP, 7Z, RAR, TAR (+ GZ/BZ2/XZ/Z/7Z/LZ), CAB, ISO, DMG, … **→ folder, ZIP, 7Z, TAR, TAR.GZ/BZ2/XZ**; ALZ, EGG, lzip read in-process; "Compress into one archive" for any files | | LibreOffice + H2Orestart + Java | **optional download** (~420 MB, 1.3 GB on disk) | older and rare formats only: DOC, XLS, PPT, RTF and OpenDocument → PDF and to each other, output as DOCX/ODT/ODS/ODP; HWP/HWPX → DOCX/ODT; read-only formats (see below) → PDF and their kind's editable formats; FODT, OpenDocument templates (OTT, OTS, OTP) and XLT out; SVG → EMF/WMF/ODG, a PDF page → SVG | | Pandoc | **optional download** (~42 MB, 240 MB on disk) | Markdown ↔ DOCX/ODT/RTF, DOCX/ODT/HTML/RTF → Markdown, HTML ↔ DOCX/ODT, markup languages (reStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter, Textile, man, OPML) ↔ Markdown/HTML/DOCX/ODT and each other (and → RTF, EPUB, TXT), Markdown and markup → PPTX slides | -| Ghostscript | **optional download** (~20 MB, 31 MB on disk) | EPS/PS → PDF, PDF → EPS/PS, PostScript-based AI → PDF (images through PDF and PDFium) | -| FFmpeg | **optional download** (~100 MB, 272 MB on disk) | **all video and audio**: video ↔ video (H.264, HEVC, AV1), video → animated GIF / APNG or a still frame, audio and subtitle extraction, audio ↔ audio (also APE, WavPack, TTA, DSD, DTS, ALAC, …), GIF, APNG and photos → MP4/WEBM/MOV, images → HEIC | +| Ghostscript | **optional download** (~20 MB, 31 MB on disk) | EPS/PS → PDF, PDF → EPS/PS, PostScript-based AI → PDF (images through PDF and PDFium); smaller PDFs when a preset asks for it | +| FFmpeg | **optional download** (~100 MB, 272 MB on disk) | **all video and audio**: video ↔ video (H.264, HEVC, AV1), video → animated GIF / APNG, a still frame or every frame, audio and subtitle extraction, audio ↔ audio (also APE, WavPack, TTA, DSD, DTS, ALAC, …), GIF, APNG and photos → MP4/WEBM/MOV, images → HEIC | | Calibre | **optional download** (~216 MB, 660 MB on disk) | rare e-book formats: LIT, LRF, CHM, PDB, PML, RB, SNB, TCR, OEB → EPUB; EPUB → MOBI, AZW3, LIT, LRF, PDB, PML, RB, SNB, TCR | DOCX, XLSX, XLS, ODS and PPTX → PDF need neither Microsoft Office nor LibreOffice: they are read in-process, written as HWPX @@ -423,6 +423,49 @@ editable**: there are no text boxes to change, the PDF's layout is not rebuilt. Password-protected PDFs fail with the same message as PDF → DOCX. +## PDF compression + +"Compress PDF" (and any PDF → PDF preset with *Compress* set, also with a page range or one file per page) makes a +PDF smaller while it stays a PDF, for e-mail attachments and upload limits. `Pdf/PdfCompressor.cs` does it with +PDFsharp and Magick.NET; nothing has to be installed. + +| Level | Pictures scaled down to | JPEG quality | +|---|---|---| +| Smallest (`Screen`) | 72 dpi | 55 | +| Balanced (`Ebook`, the preset's) | 150 dpi | 65 | +| High quality (`Print`) | 300 dpi | 85 | + +- **Pictures**: `Pdf/PdfPictureUses.cs` follows the content streams of the pages, of the forms they draw and of + annotation appearances (`q`/`Q`/`cm`/`Do`) to find the largest size each picture is drawn at. A picture drawn at more + than 1.5 × the level's resolution is scaled down to it (as Ghostscript does); a picture whose drawing can't be + followed is taken as large as the page holds it, so it is never scaled down too far. Photos are saved as JPEG at the + level's quality, pictures with up to 256 colours (charts, screenshots, logos) losslessly (Flate with PNG + prediction). A JPEG that keeps its size is encoded again only when that saves at least 10 %. A picture is replaced + only when the result is smaller. +- **Which pictures**: 8 bits per component, gray, RGB (also ICC-based and calibrated) and CMYK, stored as JPEG, Flate + (with or without PNG prediction), RunLength or uncompressed. CMYK JPEGs, 1-bit pictures and masks, indexed + (palette) pictures, spot colours, JPEG 2000, JBIG2, CCITT and pictures with a colour-key mask stay as they are. +- **Transparency**: a soft mask is scaled with its picture and kept lossless, so transparent parts stay transparent. +- **Duplicates**: a picture stored more than once with the same data (a logo embedded again on every page) is kept once. +- **Everything else**: every stream is deflated at the highest level when that's smaller; page thumbnails and other + applications' private data (`PieceInfo`, such as Illustrator's editing data) are removed, as are unused objects. + When the preset doesn't keep metadata, the XMP metadata of pages and pictures goes too. The document's own XMP is + written afresh from its document information by PDFsharp, as for every PDF it saves. +- **Unchanged**: text, fonts, vector drawings, links, bookmarks and form fields. The whole file is compressed as it + is; a page range or single pages are first copied into new PDFs, as without compression (which leaves bookmarks and + form fields behind), and so is a PDF locked only against changes. +- **Never bigger**: when nothing gets smaller (a PDF of text only, one compressed before), the PDF is written as it was + and the toast (and `filee convert`) says that it couldn't be made smaller. Password-protected PDFs fail with the same + message as the other PDF conversions. +- **Ghostscript** (optional, *Also try Ghostscript* in the preset): when it is installed, the PDF is also run through + `pdfwrite` with `-dPDFSETTINGS=/screen`, `/ebook` or `/printer` (the same 72, 150 and 300 dpi) and + `-dCompatibilityLevel=1.6`, and the smallest of the two results is kept; when Ghostscript fails, the built-in result + stands. It is off by default: Ghostscript rewrites the whole file (fonts are subset again, structure and form fields + may change), so it is the user's choice. It also writes object streams, which PDFsharp can't, and its JPEGs are + somewhat smaller at the Balanced level. +- **Not done**: object streams (PDFsharp writes none), font subsetting or removal of unused glyphs, converting + colour to gray, flattening transparency or form fields, recompressing JBIG2/CCITT scans. + ## Office Open XML variants `Office/OoxmlConverter.cs` converts between the variants of a family (Word, Excel, PowerPoint) by copying the @@ -801,8 +844,8 @@ Filee (with the GPL source offer), then an `engines.json` component and an optio ## FFmpeg `Media/FfmpegConverter.cs` converts every video format to every video format, to animated GIF or APNG, to a still PNG -frame, to every audio format (the sound track) and to SRT, ASS and WebVTT (the subtitles); every audio format to every -audio format; and GIF, APNG and PNG to MP4, WEBM and MOV. +frame or a folder of every frame, to every audio format (the sound track) and to SRT, ASS and WebVTT (the subtitles); +every audio format to every audio format; and GIF, APNG and PNG to MP4, WEBM and MOV. Same-format edges exist too, so "MP4 720p" works on MP4 files and "MP3 128k" on MP3 files. - The download is the gyan.dev **9.0.2 "full_build-shared"** Windows build (GPL-3.0, run as a separate program). @@ -856,6 +899,29 @@ Same-format edges exist too, so "MP4 720p" works on MP4 files and "MP3 128k" on for the rest. The encoders are looked up with `ffmpeg -encoders` once per FFmpeg, so a copy the user picked without x265 or AV1 fails with a clear message instead of an FFmpeg error. x265 and SVT-AV1 log only errors (`-x265-params log-level=error`, `SVT_LOG=1`). +- **Hardware encoding** (*Hardware acceleration* in the video card, `MediaOptions.HardwareEncoding`; off by default and + for older presets, so nobody's results change without asking). H.264 and HEVC encodes (not VP9, AV1 and the + MPEG-style codecs, not stream copies, not pictures → video) go to the graphics chip (`Media/HardwareVideo.cs`, + `Media/HardwareProbe.cs`): + + | System | Tried in this order | + |---|---| + | Windows | `*_nvenc` (NVIDIA), `*_qsv` (Intel Quick Sync), `*_amf` (AMD) | + | macOS | `*_videotoolbox` with constant quality (Apple Silicon), then with a bitrate (Intel Macs reject `-q:v`) | + | Linux | `*_nvenc`, then `*_vaapi` on `/dev/dri/renderD128` (`-vaapi_device`, frames uploaded with `format=nv12,hwupload`) | + + FFmpeg lists encoders the computer can't run (NVENC without an NVIDIA card), so only the ones `ffmpeg -encoders` + lists are tried, each first with a test encode with the real options (0.1 s of `testsrc` at 256 × 144 to the null + muxer, 15 s limit). The first that succeeds is used; results are kept per FFmpeg executable, encoder, rate control + and bit depth until Filee restarts, and written to the log. Quality: *High/Balanced/Small* become quantizer 19/24/29 + for H.264 and 23/27/31 for HEVC (one above x264's and x265's CRF: hardware encoders spend bits less wisely) as NVENC + `-rc vbr -cq N -b:v 0 -preset p5`, Quick Sync `-global_quality N -preset medium` (ICQ), AMF `-rc cqp -qp_p N -qp_i N−2` + (`-quality balanced`) and VA-API `-rc_mode CQP -qp N`; VideoToolbox gets `-q:v` 70/60/50, or on Intel Macs + `-b:v` from 0.14/0.09/0.05 bits per pixel per frame (× 0.65 for HEVC). 8-bit video is NV12 (H.264 High, HEVC Main); + 10-bit and HDR sources stay P010 HEVC Main 10 with their colour tags when the encoder passes a 10-bit test encode, + otherwise x265 encodes them. HEVC keeps the `hvc1` tag in MP4/M4V/MOV. When a hardware encode fails anyway (a + picture too large for the chip, a driver error), the partial output is deleted and the file is encoded again with + x264/x265; that is logged, not shown as a failure. - DSD (DSF, DFF) decodes at 2.8 MHz or more: lossless and PCM targets get 88.2 kHz, Vorbis and WMA 44.1 kHz, the other lossy encoders their nearest supported rate. - Subtitle extraction takes the first text subtitle track (SubRip, ASS/SSA, WebVTT, MP4 timed text, CEA-608, …). @@ -867,12 +933,34 @@ Same-format edges exist too, so "MP4 720p" works on MP4 files and "MP3 128k" on FLAC. Portrait phone videos are turned upright. Odd sizes are evened out, frame rates that MPEG-2/MXF/MPEG-4 part 2 cannot store are converted, and channels WMA or AMR cannot hold are mixed down. - Progress comes from `-progress` (position ÷ duration from ffprobe); the GIF palette pass reports per-frame stats of - a discarded copy of its frames. Cancelling kills the process tree and deletes the partial output. There is no - time limit, but FFmpeg is stopped when it reports nothing for 10 minutes. At most two conversions run at a time - (FFmpeg's encoders use every core themselves). + a discarded copy of its frames. Cancelling kills the process tree, waits (up to 10 seconds) until it has exited and + deletes the partial output. There is no time limit, but FFmpeg is stopped when it reports nothing for 10 minutes. + At most two conversions run at a time (FFmpeg's encoders use every core themselves). - Video → PNG writes one still frame (a tenth into the video, where the picture has usually faded in); the image engines turn it into JPG, WEBP, PDF, …. The GIF edge costs more, so these routes never go through an animated GIF of the whole video. +- **Every frame** (*Save every frame of a video* on a PNG or JPG preset, `MediaOptions.SaveEveryFrame`; the presets + "Extract frames (PNG)" and "Extract frames (JPG)", on no donut): `Media/FrameSequenceWriter.cs` writes + `frame_000001.png`, `frame_000002.png`, … into one folder, named by the output rule with `_frames` added + (`clip_frames`, `clip_frames (2)`; skipped, or filled over like an extracted archive, by the conflict rule). + - Routes: the frame edges (`ConversionEdge.FrameSequence`, video → PNG and video → JPG) are taken only when + `Preset.SavesFramesOf` the file (the option is set, the target is PNG or JPG and the file is a video), and then as + the whole route (`RoutePlanner.Plan(from, to, frames: true)`). Without the option video → JPG stays the still frame + above, which the image engines turn, resize and so on; other files and targets ignore the option. + - *Every Nth frame* (1–1000) keeps frames 1, N + 1, 2N + 1, … (`select`, `-fps_mode vfr`). Otherwise every decoded + frame is written once (`-fps_mode passthrough`): a variable frame rate phone video gives as many pictures as it + has frames, not one per 1/30 s with the slow parts repeated. The pictures are numbered, not named by time. + - Only the first video stream is read (`-map 0:V:0`). Pixels are made square and portrait videos are turned + upright, as for the still frame; nothing else of the image options is applied (no resizing, rotating or + metadata). JPG uses the preset's quality (100 → `-q:v 2`, 90 → 5, 1 → 31). HDR video (PQ, HLG) is not + tone-mapped, so its frames look flat and grey; 10-bit video becomes 16-bit PNG. + - Before anything is written the pictures are estimated (duration × frame rate ÷ interval). More than 100,000 + (`MediaOptions.MaxFrames`, about an hour at 30 fps), or more than the free space of the output drive (estimated at + 1.5 bytes a pixel for PNG, 3 for 10-bit video, 0.25 for JPG), fails with a reason in the user's language + (`ConversionRefusedException`); a file that doesn't say its length or frame rate is not checked. The editor shows + how many pictures a minute of 30 fps video gives at the chosen interval. + - The frames go into a hidden folder next to the output and are moved into place when FFmpeg has finished, so a + cancelled or failed run leaves no half-filled folder. - Pictures → MP4/WEBM/MOV go through a full-size PNG from the image engines (HEIC → PNG → MOV): a 5-second still at 30 fps, one quality step above the preset (identical frames cost almost nothing; x264 `-tune stillimage`). The PNG's ICC profile is read (`-flags2 +icc_profiles`), so primaries and transfer are tagged as the photo's (Display diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index 74ac774f..ba48ba9c 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -23,6 +23,9 @@ public partial class App : Application private IDisposable? _convertDebounce; private bool _selectionPending; + /// Forwarded launches and empty selections can come in bursts: a few lines a minute are enough. + private static readonly LogThrottle Throttle = new(TimeSpan.FromSeconds(30)); + public override void Initialize() { AvaloniaXamlLoader.Load(this); @@ -39,6 +42,9 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } + /// Ends Filee as the tray's Quit does. + public static void Quit() => (Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); + private void Start(IClassicDesktopStyleApplicationLifetime desktop) { // macOS: "Convert with Filee" in Finder's right-click menu. Registered first, so a service picked while @@ -47,12 +53,20 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) Filee.Platform.MacOS.MacOSServicesProvider.Register(files => Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(["--convert", .. files])))); var log = AppHost.Get>(); + // Exceptions in UI event handlers end Filee; write them down first (they are not marked handled). + Dispatcher.UIThread.UnhandledException += (_, e) => + log.LogCritical(e.Exception, "Unhandled exception on the UI thread"); + var watchdog = new UiWatchdog(action => Dispatcher.UIThread.Post(action), AppHost.Get>()); + watchdog.Start(); var store = AppHost.Get(); store.Load(); var loc = AppHost.Get(); loc.Attach(this); ApplySettings(store.Settings); + // Every engine's availability, checked once off the UI thread: the first donut or drop otherwise starts + // every engine's check (FFmpeg -version, folder searches) on the UI thread and freezes it meanwhile. + _ = AppHost.Get().CheckInBackgroundAsync(); // Global gestures → donut toolbar var triggers = AppHost.Get(); @@ -71,6 +85,11 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) ? await Task.Run(platform.GetFileManagerSelection) : platform.GetFileManagerSelection(); if (files.Count > 0 && !store.Settings.Paused) radial.ShowForFiles(e.X, e.Y, files); + else if (files.Count == 0 && Throttle.TryEnter("empty-selection", out var held)) + log.LogInformation("The selection shortcut found no selected files in {Window}{Elevation}{Held}", + platform.DescribeForegroundWindow() is { Length: > 0 } window ? window : "the foreground window", + Program.Elevation.ShellCannotReachFilee ? " (Filee runs as administrator and can't read Explorer)" : "", + LogThrottle.HeldText(held)); } catch (Exception ex) when (ex is not OutOfMemoryException) { @@ -109,13 +128,13 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) // Later launches (context menu, Send To, double-clicking the exe) forward their arguments here. if (Program.Instance is { } instance) - { - instance.ArgumentsReceived += args => Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(args))); - instance.StartListening(); - } + ListenForLaterLaunches(instance, log); + Program.InstanceAcquired += again => ListenForLaterLaunches(again, log); desktop.Exit += (_, _) => { + watchdog.Dispose(); + AppHost.Get().Dispose(); AppHost.Get().Dispose(); AppHost.Get().Dispose(); AppHost.Get().DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); @@ -123,6 +142,21 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) HandleCommandLine(Program.Options, firstLaunch: true); WatchForUpdates(store); + // Filee runs as administrator while Explorer doesn't (an installer started "as administrator" started it): + // say so once, next to the tray, and on the Home page for as long as it lasts. + if (AppHost.Get().ShellCannotReachFilee) + DispatcherTimer.RunOnce(() => AppHost.Get().ShowElevationNotice(), TimeSpan.FromSeconds(2)); + } + + private void ListenForLaterLaunches(SingleInstance instance, ILogger log) + { + instance.ArgumentsReceived += args => + { + if (Throttle.TryEnter("forwarded", out var held)) + log.LogInformation("A later launch handed over {Count} argument(s){Held}", args.Length, LogThrottle.HeldText(held)); + Dispatcher.UIThread.Post(() => HandleCommandLine(CommandLine.Parse(args), forwarded: true)); + }; + instance.StartListening(); } /// @@ -198,8 +232,13 @@ private static void ApplySettings(AppSettings settings) AppHost.Get().Apply(settings); } - private void HandleCommandLine(CommandLine options, bool firstLaunch = false) + private void HandleCommandLine(CommandLine options, bool firstLaunch = false, bool forwarded = false) { + // Pairs with "A later launch handed over …": the request reached the UI thread too. + if (forwarded && Throttle.TryEnter("forwarded-handled", out var held)) + AppHost.Get>().LogInformation("Handling a later launch: {Request}{Held}", + options.Quit ? "quit" : options.ConvertFiles.Count > 0 ? $"convert {options.ConvertFiles.Count} file(s)" : "show Filee", + LogThrottle.HeldText(held)); if (options.Quit) { // The installer is about to replace or remove Filee's files (Program.RunInstallerCommand waits for this). diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 6e8622ac..52c51462 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -30,6 +30,13 @@ "home.drop_hint_macos": "Or hold {0} while dragging files in Finder. The donut toolbar opens at your cursor.", "home.drop_hint_linux": "Or hold {0} while dragging files in your file manager. The donut toolbar opens at your cursor.", "home.accessibility_needed": "To open the donut by dragging with ⌥ Option, allow Filee under Accessibility.\nIt works as soon as you switch it on.", + "home.elevated": "Filee is running as administrator, so Explorer can't drop files on it or use its menu.\nThe shortcut can't read Explorer's selection either.\nRestart Filee without administrator rights to fix this.", + "home.elevated_restart": "Restart without administrator rights", + "home.elevated_restart_failed": "Filee couldn't restart itself.\nQuit Filee from the tray, then start it again.", + "elevation.notice_title": "Filee is running as administrator", + "elevation.notice_text": "Explorer can't drop files on Filee or use its menu while it runs this way.\nRestart it without administrator rights.", + "startup.elevated_other": "Another Filee is running as administrator.\nExplorer can't hand files to it.\nQuit Filee from the tray, then start it again.", + "startup.no_answer": "Filee is running but didn't answer.\nIf it runs as administrator, quit it from the tray and start it again.", "home.recent": "Recent conversions", "home.recent_empty": "Your converted files will show up here.", "home.open_folder": "Open folder", @@ -61,9 +68,15 @@ "toast.cancel": "Cancel", "error.unsupported_source": "This file type isn't supported", + "format_request.request": "Request this format", + "format_request.notice_title": "Filee can't convert {0} files yet", + "format_request.notice_text": "Tell us which format you need.\nRequests decide what Filee supports next.\nEncrypted or copy-protected (DRM) files can't be converted, whatever the format.", "error.no_route": "No engine can do this conversion", "error.cannot_turn": "Rotate and flip work on images only", + "error.too_many_frames": "Too many frames to save as pictures, raise \"Every Nth frame\"", + "error.not_enough_space": "Not enough free disk space for the pictures", "error.conversion_failed": "The engine reported an error", + "notice.pdf_not_smaller": "Couldn't make it smaller, so it was copied as it is", "engine.reason.not_installed": "Not installed", "engine.reason.error": "Error while checking", @@ -71,7 +84,7 @@ "engine.reason.needs_package": "Needs {0}. Install it in Settings → Engines", "engine.magick.description": "JPG, PNG, WEBP, AVIF, HEIC, TIFF, GIF, APNG, PSD, camera RAW and more.\nQuality, size and DPI follow your preset.", "engine.pdfium.description": "Renders PDF pages to PNG, JPG and TIFF (also multi-page TIFF and GIF) at the preset's resolution.", - "engine.pdfsharp.description": "Makes PDFs from images and merges, splits or cuts page ranges out of PDFs.", + "engine.pdfsharp.description": "Makes PDFs from images and merges, splits or cuts page ranges out of PDFs.\nAlso makes PDFs smaller by scaling down their pictures.", "engine.rhwp.description": "Renders HWP/HWPX to PDF and converts HWP ↔ HWPX.\nIt also draws documents that reach PDF through HWPX (DOCX, XLSX, PPTX, e-books…).", "engine.unhwp.description": "Extracts text, Markdown and HTML from HWP and HWPX.", "engine.markdown.description": "Markdown → HTML and plain text.", @@ -133,6 +146,7 @@ "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Merge PDF", "preset.split_pdf": "Split PDF", + "preset.compress_pdf": "Compress PDF", "preset.long_image": "Long image", "preset.pdf_slides": "PPTX (slides)", "preset.extract": "Extract", @@ -140,6 +154,8 @@ "preset.extract_subtitles": "Extract subtitles", "preset.trace_svg": "Trace to SVG", "preset.trace_line_art": "Line art to SVG", + "preset.extract_frames_png": "Extract frames (PNG)", + "preset.extract_frames_jpg": "Extract frames (JPG)", "format.folder": "Folder", "format.category.Image": "Image", @@ -373,6 +389,10 @@ "presets.webp_lossless": "Lossless WEBP", "presets.multipage_tiff": "Put every file into one multi-page TIFF", "presets.multipage_tiff_hint": "Each image and each PDF or TIFF page becomes a page, in order.\nOne PDF is enough for a multi-page TIFF.", + "presets.save_every_frame": "Save every frame of a video", + "presets.save_every_frame_hint": "A video becomes a folder with a picture of each frame instead of one still frame.\nA long video gives many files.", + "presets.frame_interval": "Every Nth frame", + "presets.frame_estimate": "One minute of 30 fps video gives {0} pictures, at most {1} per video.", "presets.ico_sizes": "Icon sizes", "presets.group.pdf": "PDF", "presets.merge": "Merge all files into one PDF", @@ -385,6 +405,15 @@ "presets.page_range": "Pages", "presets.page_range_hint": "All pages, or e.g. 1-3, 5", "presets.split": "One file per page", + "presets.pdf_compression": "Compress", + "presets.pdf_compression.None": "Don't compress", + "presets.pdf_compression.Screen": "Smallest (pictures at 72 dpi)", + "presets.pdf_compression.Ebook": "Balanced (pictures at 150 dpi)", + "presets.pdf_compression.Print": "High quality (pictures at 300 dpi)", + "presets.pdf_compression_hint": "Pictures are scaled down and saved as JPEG.\nText, fonts and drawings stay as they are.\nIf the result isn't smaller, the original is kept.", + "presets.pdf_ghostscript": "Also try Ghostscript (when installed)", + "presets.pdf_ghostscript_hint": "Often smaller, but it rewrites the whole file.\nThe smallest result is kept.", + "presets.pdf_keep_metadata": "Keep page and picture metadata (XMP)", "presets.combine_pages_hint": "Puts all pages one under another in a single image.\nVery long results are scaled to the format's limit (JPG 65,500 px, WebP 16,383 px).", "presets.combine_pages": "Stack the pages into one tall image (when a PDF is converted)", "presets.trim_page_margins": "Cut the blank space above and below each page", @@ -422,6 +451,8 @@ "presets.video_codec.vp9": "VP9", "presets.video_codec.hevc": "HEVC / H.265 (smaller)", "presets.video_codec.av1": "AV1 (smallest, slow)", + "presets.hardware_encoding": "Use hardware acceleration (graphics chip)", + "presets.hardware_encoding_hint": "Encodes H.264 and HEVC on the graphics chip.\nMuch faster and uses less power.\nSlightly lower quality at the same file size.\nWithout a usable chip, the video is encoded as usual.", "presets.audio_codec": "Audio codec", "presets.audio_codec.aac": "AAC", "presets.audio_codec.alac": "ALAC (lossless)", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 306cafc5..1c53de4d 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -30,6 +30,13 @@ "home.drop_hint_macos": "또는 Finder에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.", "home.drop_hint_linux": "또는 파일 관리자에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.", "home.accessibility_needed": "⌥ Option 드래그로 원형 툴바를 열려면 손쉬운 사용에서 Filee를 허용해 주세요.\n켜는 즉시 동작해요.", + "home.elevated": "Filee가 관리자 권한으로 실행 중이에요.\n그래서 탐색기에서 파일을 끌어 놓거나 메뉴를 쓸 수 없어요.\n단축키도 탐색기에서 선택한 파일을 읽지 못해요.\n관리자 권한 없이 다시 시작해 주세요.", + "home.elevated_restart": "관리자 권한 없이 다시 시작", + "home.elevated_restart_failed": "Filee를 다시 시작하지 못했어요.\n트레이에서 Filee를 종료한 뒤 다시 실행해 주세요.", + "elevation.notice_title": "Filee가 관리자 권한으로 실행 중이에요", + "elevation.notice_text": "이 상태에서는 탐색기에서 파일을 끌어 놓거나 메뉴를 쓸 수 없어요.\n관리자 권한 없이 다시 시작해 주세요.", + "startup.elevated_other": "다른 Filee가 관리자 권한으로 실행 중이에요.\n그래서 탐색기가 파일을 넘겨줄 수 없어요.\n트레이에서 Filee를 종료한 뒤 다시 실행해 주세요.", + "startup.no_answer": "Filee가 실행 중이지만 응답하지 않아요.\n관리자 권한으로 실행 중이라면 트레이에서 종료한 뒤 다시 실행해 주세요.", "home.recent": "최근 변환", "home.recent_empty": "변환한 파일이 여기에 표시됩니다.", "home.open_folder": "폴더 열기", @@ -61,9 +68,15 @@ "toast.cancel": "취소", "error.unsupported_source": "지원하지 않는 파일 형식이에요", + "format_request.request": "이 형식 요청하기", + "format_request.notice_title": "아직 {0} 파일은 변환할 수 없어요", + "format_request.notice_text": "필요한 형식을 알려주세요.\n요청을 보고 다음에 지원할 형식을 정해요.\n암호화되거나 DRM이 걸린 파일은 변환할 수 없어요.", "error.no_route": "이 변환을 할 수 있는 엔진이 없어요", "error.cannot_turn": "회전·반전은 이미지에만 할 수 있어요", + "error.too_many_frames": "프레임이 너무 많아요. 'N번째 프레임마다' 값을 늘려 주세요", + "error.not_enough_space": "이미지를 저장할 디스크 공간이 부족해요", "error.conversion_failed": "엔진에서 오류가 발생했어요", + "notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요", "engine.reason.not_installed": "설치되지 않음", "engine.reason.error": "확인 중 오류", @@ -71,7 +84,7 @@ "engine.reason.needs_package": "{0} 필요 (설정 → 변환 엔진에서 설치)", "engine.magick.description": "JPG, PNG, WEBP, AVIF, HEIC, TIFF, GIF, APNG, PSD, 카메라 RAW 등 이미지를 변환해요.\n품질·크기·DPI는 프리셋대로 맞춰요.", "engine.pdfium.description": "PDF 페이지를 PNG, JPG, TIFF(여러 페이지 TIFF·GIF 포함)로 프리셋 해상도에 맞춰 그려요.", - "engine.pdfsharp.description": "이미지로 PDF를 만들고, PDF를 합치거나 나누거나 원하는 페이지만 잘라내요.", + "engine.pdfsharp.description": "이미지로 PDF를 만들고, PDF를 합치거나 나누거나 원하는 페이지만 잘라내요.\nPDF 속 이미지를 줄여 파일을 작게 만들기도 해요.", "engine.rhwp.description": "HWP/HWPX를 PDF로 그리고, HWP ↔ HWPX를 바꿔요.\nHWPX를 거쳐 PDF가 되는 문서(DOCX, XLSX, PPTX, 전자책 등)도 그려요.", "engine.unhwp.description": "HWP와 HWPX에서 텍스트, 마크다운, HTML을 뽑아내요.", "engine.markdown.description": "마크다운 → HTML, 텍스트.", @@ -133,6 +146,7 @@ "preset.flip_vertical.short": "상하", "preset.merge_pdf": "PDF 병합", "preset.split_pdf": "PDF 분할", + "preset.compress_pdf": "PDF 압축", "preset.long_image": "긴 이미지", "preset.pdf_slides": "PPTX (슬라이드)", "preset.extract": "압축 풀기", @@ -140,6 +154,8 @@ "preset.extract_subtitles": "자막 추출", "preset.trace_svg": "벡터화", "preset.trace_line_art": "흑백 선화", + "preset.extract_frames_png": "프레임 추출 (PNG)", + "preset.extract_frames_jpg": "프레임 추출 (JPG)", "format.folder": "폴더", "format.category.Image": "이미지", @@ -373,6 +389,10 @@ "presets.webp_lossless": "무손실 WEBP", "presets.multipage_tiff": "모든 파일을 여러 페이지짜리 TIFF 하나로 합치기", "presets.multipage_tiff_hint": "이미지 한 장, PDF·TIFF 한 페이지가 순서대로 한 페이지씩 들어가요.\nPDF 하나만 넣어도 여러 페이지 TIFF가 돼요.", + "presets.save_every_frame": "동영상의 모든 프레임 저장하기", + "presets.save_every_frame_hint": "동영상에서 정지 이미지 한 장 대신 프레임마다 이미지를 만들어 폴더 하나에 담아요.\n긴 동영상은 파일이 아주 많이 생겨요.", + "presets.frame_interval": "N번째 프레임마다", + "presets.frame_estimate": "30fps 동영상 1분이면 이미지 {0}장이 생겨요. 동영상 하나에 최대 {1}장까지예요.", "presets.ico_sizes": "아이콘 크기", "presets.group.pdf": "PDF", "presets.merge": "모든 파일을 하나의 PDF로 합치기", @@ -385,6 +405,15 @@ "presets.page_range": "페이지", "presets.page_range_hint": "전체, 또는 예: 1-3, 5", "presets.split": "페이지마다 파일 하나", + "presets.pdf_compression": "압축", + "presets.pdf_compression.None": "압축 안 함", + "presets.pdf_compression.Screen": "가장 작게 (이미지 72 dpi)", + "presets.pdf_compression.Ebook": "균형 (이미지 150 dpi)", + "presets.pdf_compression.Print": "고화질 (이미지 300 dpi)", + "presets.pdf_compression_hint": "이미지를 줄이고 JPEG로 저장해요.\n글자, 글꼴, 도형은 그대로예요.\n결과가 더 작지 않으면 원본을 그대로 둬요.", + "presets.pdf_ghostscript": "Ghostscript로도 해 보기 (설치된 경우)", + "presets.pdf_ghostscript_hint": "더 작아질 때가 많지만 파일 전체를 다시 써요.\n가장 작은 결과를 남겨요.", + "presets.pdf_keep_metadata": "페이지·이미지 메타데이터(XMP) 유지", "presets.combine_pages_hint": "모든 페이지를 위아래로 이어 이미지 한 장으로 만들어요.\n너무 길면 형식의 최대 높이에 맞게 줄여요(JPG 65,500px, WebP 16,383px).", "presets.combine_pages": "모든 페이지를 세로로 이어 한 장으로 (PDF를 변환할 때)", "presets.trim_page_margins": "페이지마다 위아래 빈 여백 잘라내기", @@ -422,6 +451,8 @@ "presets.video_codec.vp9": "VP9", "presets.video_codec.hevc": "HEVC / H.265 (더 작게)", "presets.video_codec.av1": "AV1 (가장 작게, 느림)", + "presets.hardware_encoding": "하드웨어 가속 사용 (그래픽 칩)", + "presets.hardware_encoding_hint": "H.264와 HEVC를 그래픽 칩으로 인코딩해요.\n훨씬 빠르고 전력도 덜 써요.\n같은 용량이면 화질이 조금 낮아요.\n쓸 수 있는 칩이 없으면 평소처럼 인코딩해요.", "presets.audio_codec": "오디오 코덱", "presets.audio_codec.aac": "AAC", "presets.audio_codec.alac": "ALAC (무손실)", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 1a98c447..abb0b93a 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -30,6 +30,13 @@ "home.drop_hint_macos": "或在访达中按住 {0} 拖动文件,环形工具栏会在光标处打开。", "home.drop_hint_linux": "或在文件管理器中按住 {0} 拖动文件,环形工具栏会在光标处打开。", "home.accessibility_needed": "要按住 ⌥ Option 拖动打开环形工具栏,请在“辅助功能”中允许 Filee。\n打开后立即生效。", + "home.elevated": "Filee 正以管理员身份运行。\n因此无法从资源管理器拖放文件,也无法使用右键菜单。\n快捷键也无法读取资源管理器中选中的文件。\n请以非管理员身份重新启动 Filee。", + "home.elevated_restart": "以非管理员身份重新启动", + "home.elevated_restart_failed": "Filee 无法自行重新启动。\n请从托盘退出 Filee,然后再次启动。", + "elevation.notice_title": "Filee 正以管理员身份运行", + "elevation.notice_text": "此时无法从资源管理器拖放文件,也无法使用右键菜单。\n请以非管理员身份重新启动。", + "startup.elevated_other": "另一个 Filee 正以管理员身份运行。\n资源管理器无法把文件交给它。\n请从托盘退出 Filee,然后再次启动。", + "startup.no_answer": "Filee 正在运行,但没有响应。\n如果它以管理员身份运行,请从托盘退出,然后再次启动。", "home.recent": "最近的转换", "home.recent_empty": "转换后的文件会显示在这里。", "home.open_folder": "打开文件夹", @@ -61,9 +68,15 @@ "toast.cancel": "取消", "error.unsupported_source": "不支持此文件类型", + "format_request.request": "请求支持此格式", + "format_request.notice_title": "Filee 暂时无法转换 {0} 文件", + "format_request.notice_text": "告诉我们你需要的格式。\n我们会根据请求决定接下来支持哪些格式。\n加密或有复制保护 (DRM) 的文件,无论什么格式都无法转换。", "error.no_route": "没有引擎可以完成此转换", "error.cannot_turn": "只能旋转和翻转图片", + "error.too_many_frames": "帧数太多,请调大“每 N 帧”", + "error.not_enough_space": "磁盘空间不足,无法保存这些图片", "error.conversion_failed": "转换引擎出错", + "notice.pdf_not_smaller": "无法再缩小,已原样复制", "engine.reason.not_installed": "未安装", "engine.reason.error": "检查时出错", @@ -71,7 +84,7 @@ "engine.reason.needs_package": "需要 {0},请在“设置 → 转换引擎”中安装", "engine.magick.description": "JPG、PNG、WEBP、AVIF、HEIC、TIFF、GIF、APNG、PSD、相机 RAW 等图片。\n质量、尺寸和 DPI 按预设处理。", "engine.pdfium.description": "按预设分辨率将 PDF 页面渲染为 PNG、JPG、TIFF(也支持多页 TIFF 和 GIF)。", - "engine.pdfsharp.description": "用图片生成 PDF,合并、拆分 PDF 或截取指定页码。", + "engine.pdfsharp.description": "用图片生成 PDF,合并、拆分 PDF 或截取指定页码。\n还能缩小 PDF 中的图片来减小文件。", "engine.rhwp.description": "将 HWP/HWPX 渲染为 PDF,并在 HWP ↔ HWPX 之间转换。\n经 HWPX 转为 PDF 的文档(DOCX、XLSX、PPTX、电子书等)也由它渲染。", "engine.unhwp.description": "从 HWP 和 HWPX 中提取文本、Markdown 和 HTML。", "engine.markdown.description": "Markdown → HTML 和纯文本。", @@ -133,6 +146,7 @@ "preset.flip_vertical.short": "垂直", "preset.merge_pdf": "合并 PDF", "preset.split_pdf": "拆分 PDF", + "preset.compress_pdf": "压缩 PDF", "preset.long_image": "长图", "preset.pdf_slides": "PPTX(幻灯片)", "preset.extract": "解压", @@ -140,6 +154,8 @@ "preset.extract_subtitles": "提取字幕", "preset.trace_svg": "描摹", "preset.trace_line_art": "黑白描摹", + "preset.extract_frames_png": "提取帧 (PNG)", + "preset.extract_frames_jpg": "提取帧 (JPG)", "format.folder": "文件夹", "format.category.Image": "图片", @@ -373,6 +389,10 @@ "presets.webp_lossless": "无损 WEBP", "presets.multipage_tiff": "将所有文件合并为一个多页 TIFF", "presets.multipage_tiff_hint": "每张图片以及 PDF、TIFF 的每一页按顺序各成一页。\n只放一个 PDF 也能得到多页 TIFF。", + "presets.save_every_frame": "保存视频的每一帧", + "presets.save_every_frame_hint": "视频的每一帧各保存为一张图片,放在一个文件夹中,而不是只保存一张静态图片。\n长视频会生成大量文件。", + "presets.frame_interval": "每 N 帧", + "presets.frame_estimate": "30 fps 的视频每分钟生成 {0} 张图片,每个视频最多 {1} 张。", "presets.ico_sizes": "图标尺寸", "presets.group.pdf": "PDF", "presets.merge": "将所有文件合并为一个 PDF", @@ -385,6 +405,15 @@ "presets.page_range": "页面", "presets.page_range_hint": "全部,或如 1-3, 5", "presets.split": "每页一个文件", + "presets.pdf_compression": "压缩", + "presets.pdf_compression.None": "不压缩", + "presets.pdf_compression.Screen": "最小(图片 72 dpi)", + "presets.pdf_compression.Ebook": "均衡(图片 150 dpi)", + "presets.pdf_compression.Print": "高质量(图片 300 dpi)", + "presets.pdf_compression_hint": "图片会缩小并存为 JPEG。\n文字、字体和图形保持不变。\n结果没有变小时保留原文件。", + "presets.pdf_ghostscript": "也试试 Ghostscript(已安装时)", + "presets.pdf_ghostscript_hint": "通常更小,但会重写整个文件。\n保留最小的结果。", + "presets.pdf_keep_metadata": "保留页面和图片元数据 (XMP)", "presets.combine_pages_hint": "把所有页面上下相接,生成一张图片。\n过长时会缩小到该格式的最大高度(JPG 65,500 像素,WebP 16,383 像素)。", "presets.combine_pages": "把所有页面上下拼成一张长图(转换 PDF 时)", "presets.trim_page_margins": "裁掉每页上下的空白", @@ -422,6 +451,8 @@ "presets.video_codec.vp9": "VP9", "presets.video_codec.hevc": "HEVC / H.265(更小)", "presets.video_codec.av1": "AV1(最小,较慢)", + "presets.hardware_encoding": "使用硬件加速(显卡)", + "presets.hardware_encoding_hint": "用显卡编码 H.264 和 HEVC。\n速度快得多,也更省电。\n相同文件大小时画质略低。\n没有可用的显卡时照常编码。", "presets.audio_codec": "音频编码", "presets.audio_codec.aac": "AAC", "presets.audio_codec.alac": "ALAC(无损)", diff --git a/src/Filee.App/Program.cs b/src/Filee.App/Program.cs index 45d9e23c..bcd7fa63 100644 --- a/src/Filee.App/Program.cs +++ b/src/Filee.App/Program.cs @@ -1,6 +1,8 @@ // Entry point. Order matters: +// 0. The log and the handlers for unhandled exceptions, so even a start that fails leaves a line; then one line +// describing this launch (version, path, rights, OS). // 1. Single instance: a second launch (e.g. from the Explorer context menu) forwards its arguments -// to the running instance and exits. +// to the running instance and exits. When it can't (the running Filee is elevated, or hung) it says so. // 2. Installer commands (--quit, --uninstall-cleanup) run without any window. // 3. Start Avalonia. The app lives in the tray, so it only exits via the tray "Quit" command (or --quit). @@ -9,6 +11,10 @@ using Avalonia.Controls; using Avalonia.Media; using Filee.App.Services; +using Filee.Core.Platform; +using Filee.Core.Settings; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Filee.App; @@ -26,20 +32,67 @@ internal static class Program /// How long a later launch waits for a Filee that is still starting to take its arguments. private static readonly TimeSpan ForwardTimeout = TimeSpan.FromSeconds(30); + /// Integrity levels of this process and Explorer, read at start-up (Unknown off Windows). + public static ElevationState Elevation { get; private set; } = ElevationState.Unknown; + + /// Program's own log, set up before anything else so even a failed start leaves a line. + internal static ILogger Log { get; private set; } = NullLogger.Instance; + + /// Raised on the UI thread when the single-instance lock was taken again after a failed restart. + public static event Action? InstanceAcquired; + [STAThread] public static int Main(string[] args) { Options = CommandLine.Parse(args); - Instance = SingleInstance.Acquire(); - if (Options.Quit || Options.UninstallCleanup) - return RunInstallerCommand(Instance); - if (!Instance.IsFirst) + Log = StartupDiagnostics.CreateEarlyLogger(Path.Combine(UserDataStore.DefaultDirectory, "logs")); + StartupDiagnostics.LogUnhandledExceptions(Log); + Elevation = Filee.Platform.Windows.Elevation.Probe(); + var startup = StartupDiagnostics.Describe(UpdateService.CurrentVersion, Environment.ProcessPath, + StartupDiagnostics.InstallKind(UpdateService.IsInstalled, UpdateService.IsReleaseBuild), Elevation, + StartupDiagnostics.OperatingSystemText(), args.Length); + var installerCommand = Options.Quit || Options.UninstallCleanup; + + SingleInstance instance; + try { - Instance.ForwardToFirst(args, ForwardTimeout); - Instance.Dispose(); - return 0; + instance = SingleInstance.Acquire(); + } + catch (UnauthorizedAccessException ex) + { + // The lock belongs to a Filee running as administrator: this process may not even open it. Before this + // was caught, Filee ended here without a word (Explorer's menu showed a busy cursor, then nothing). + Log.LogWarning("{Startup}", startup); + Log.LogWarning(ex, "Another Filee is running with administrator rights; this launch can't reach it"); + if (!installerCommand) + TellUserOnce("startup.elevated_other"); + return 1; + } + Instance = instance; + if (installerCommand) + { + Log.LogInformation("{Startup}", startup); + return RunInstallerCommand(instance); + } + if (!instance.IsFirst) + { + var result = instance.Forward(args, ForwardTimeout); + if (result != ForwardResult.Delivered) + { + // Logged only when it failed: Explorer starts one Filee per selected file. + Log.LogWarning("{Startup}", startup); + Log.LogWarning("Could not hand {Count} argument(s) to the running Filee: {Result}", args.Length, result); + TellUserOnce(result == ForwardResult.AccessDenied ? "startup.elevated_other" : "startup.no_answer"); + } + ReleaseInstance(); + return result == ForwardResult.Delivered ? 0 : 1; } + Log.LogInformation("{Startup}", startup); + if (Elevation.ShellCannotReachFilee) + Log.LogWarning("Filee runs as administrator ({Elevation}) while Explorer doesn't: Explorer can't drop files on it, " + + "use its menu or have its selection read", Elevation); + try { return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args, ShutdownMode.OnExplicitShutdown); @@ -55,7 +108,66 @@ public static int Main(string[] args) } finally { - Instance.Dispose(); + ReleaseInstance(); + } + } + + /// Lets go of the single-instance lock (at exit, or before restarting without administrator rights). + public static void ReleaseInstance() + { + Instance?.Dispose(); + Instance = null; + } + + /// Takes the lock back after a restart that didn't happen, so later launches find this Filee again. + public static void ReacquireInstance() + { + if (Instance is not null) + return; + try + { + var instance = SingleInstance.Acquire(); + if (!instance.IsFirst) + { + // Another Filee started meanwhile (perhaps the restart did happen after all): it is the running one. + Log.LogWarning("Another Filee took the single-instance lock during the restart"); + instance.Dispose(); + return; + } + Instance = instance; + InstanceAcquired?.Invoke(instance); + } + catch (UnauthorizedAccessException ex) + { + Log.LogWarning(ex, "Could not take the single-instance lock back"); + } + } + + /// + /// Shows a launch problem in a system message box (Filee's own windows aren't available in this process). Explorer + /// starts one Filee per selected file: a lock shared between them lets only the first one show it. + /// + private static void TellUserOnce(string key) + { + if (!OperatingSystem.IsWindows()) + return; + try + { + using var shown = new Mutex(initiallyOwned: true, @"Local\" + SingleInstance.DefaultName + "-notice", out var createdNew); + if (!createdNew) + return; + try + { + Filee.Platform.Windows.Elevation.ShowWarning(LocalizationService.EarlyText(key)); + } + finally + { + shown.ReleaseMutex(); + } + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or WaitHandleCannotBeOpenedException) + { + Log.LogWarning(ex, "Could not show the launch problem"); } } diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index 5b03168f..ea18c16a 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -44,10 +44,11 @@ public static IServiceProvider Build(string? dataDirectory = null) services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Conversion engines services.AddSingleton(_ => new EngineEnvironment(dataDir)); - services.AddSingleton(sp => EngineRegistry.CreateAll(sp.GetRequiredService())); + services.AddSingleton(sp => EngineRegistry.CreateAll(sp.GetRequiredService(), sp.GetRequiredService())); services.AddSingleton(sp => new ConverterCatalog(sp.GetRequiredService>())); services.AddSingleton(sp => new JobQueue( sp.GetRequiredService(), @@ -65,8 +66,8 @@ public static IServiceProvider Build(string? dataDirectory = null) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); _services = services.BuildServiceProvider(); return _services; diff --git a/src/Filee.App/Services/ElevationService.cs b/src/Filee.App/Services/ElevationService.cs new file mode 100644 index 00000000..33291e8c --- /dev/null +++ b/src/Filee.App/Services/ElevationService.cs @@ -0,0 +1,81 @@ +// Filee running as administrator while Explorer doesn't (see ElevationState): the Home page shows why gestures, drops +// and the Explorer menu do nothing, and offers to restart Filee with Explorer's rights. The restart lets go of the +// single-instance lock first, so the new Filee becomes the running one instead of handing its arguments to this one +// (which it couldn't, being refused by an elevated process anyway), then quits this Filee. + +using CommunityToolkit.Mvvm.ComponentModel; +using Filee.Core.Platform; +using Microsoft.Extensions.Logging; + +namespace Filee.App.Services; + +/// Elevation state of this run and the restart without administrator rights. +public sealed partial class ElevationService : ObservableObject +{ + private readonly ILogger _log; + private readonly Func _relaunch; + private readonly Action _releaseInstance; + private readonly Action _reacquireInstance; + private readonly Action _shutdown; + + /// The app's service: the state Program read at start-up, the real relaunch and shutdown. + public ElevationService(ILogger log) : this(log, Program.Elevation, RelaunchThroughShell, + Program.ReleaseInstance, Program.ReacquireInstance, App.Quit) + { + } + + /// Starts the executable with Explorer's rights; returns null when started, else why not. + /// Lets go of the single-instance lock. + /// Takes the lock back when the restart failed. + /// Ends this Filee. + internal ElevationService(ILogger log, ElevationState state, Func relaunch, + Action releaseInstance, Action reacquireInstance, Action shutdown) + { + _log = log; + State = state; + _relaunch = relaunch; + _releaseInstance = releaseInstance; + _reacquireInstance = reacquireInstance; + _shutdown = shutdown; + } + + public ElevationState State { get; } + + /// Explorer can't drop files on Filee, use its menu, or have its selection read: show the warning. + public bool ShellCannotReachFilee => State.ShellCannotReachFilee; + + /// The last restart attempt failed (the Home page says so). + [ObservableProperty] private bool _restartFailed; + + /// + /// Starts Filee again through Explorer and quits this one. Returns false (and sets ) + /// when Explorer couldn't start it; this Filee then keeps running as before. + /// + public bool RestartWithoutAdministratorRights(string? executable = null) + { + executable ??= Environment.ProcessPath; + if (string.IsNullOrEmpty(executable)) + { + _log.LogWarning("Restart without administrator rights: the path of Filee.exe is unknown"); + RestartFailed = true; + return false; + } + + _log.LogInformation("Restarting without administrator rights through Explorer"); + _releaseInstance(); + var error = _relaunch(executable); + if (error is null) + { + RestartFailed = false; + _shutdown(); + return true; + } + _log.LogWarning("Restart without administrator rights failed: {Error}", error); + _reacquireInstance(); + RestartFailed = true; + return false; + } + + private static string? RelaunchThroughShell(string executable) => + Filee.Platform.Windows.Elevation.RelaunchThroughShell(executable, "", out var error) ? null : error ?? "unknown error"; +} diff --git a/src/Filee.App/Services/FileLogger.cs b/src/Filee.App/Services/FileLogger.cs index 4de17498..6a26a98e 100644 --- a/src/Filee.App/Services/FileLogger.cs +++ b/src/Filee.App/Services/FileLogger.cs @@ -6,8 +6,9 @@ namespace Filee.App.Services; public sealed class FileLoggerProvider : ILoggerProvider { + // Shared by every provider: Program's early logger and AppHost's write to the same file. + private static readonly Lock WriteLock = new(); private readonly string _directory; - private readonly object _lock = new(); public FileLoggerProvider(string directory) { @@ -16,7 +17,7 @@ public FileLoggerProvider(string directory) foreach (var old in Directory.EnumerateFiles(directory, "filee-*.log") .Where(f => File.GetLastWriteTime(f) < DateTime.Now.AddDays(-7))) { - try { File.Delete(old); } catch (IOException) { } + try { File.Delete(old); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } } } @@ -26,13 +27,13 @@ public void Dispose() { } private void Write(string line) { - lock (_lock) + lock (WriteLock) { try { File.AppendAllText(Path.Combine(_directory, $"filee-{DateTime.Now:yyyyMMdd}.log"), line + Environment.NewLine); } - catch (IOException) { } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } } } diff --git a/src/Filee.App/Services/FormatRequest.cs b/src/Filee.App/Services/FormatRequest.cs new file mode 100644 index 00000000..17a8b0c8 --- /dev/null +++ b/src/Filee.App/Services/FormatRequest.cs @@ -0,0 +1,37 @@ +// "Request this format": a file type Filee can't read becomes a GitHub issue (the "Format request" form) with its +// extension filled in. Only the extension goes in the link, never a file name; the user reads it and submits it. + +using Filee.Core.Formats; + +namespace Filee.App.Services; + +public static class FormatRequest +{ + /// Extensions shown in one request at most; the rest of a big mixed drop is left out. + private const int MaxExtensions = 3; + + /// + /// The extensions (".ncm") of that Filee doesn't know, lower-case and each once. Folders + /// and files without an extension have nothing to request. + /// + public static IReadOnlyList UnknownExtensions(IEnumerable paths) => + paths.Where(p => FormatRegistry.Detect(p) is null && !Directory.Exists(p)) + .Select(p => FormatRegistry.ExtensionOf(p).ToLowerInvariant()) + .Where(e => e.Length > 0) + .Distinct() + .Take(MaxExtensions) + .Select(e => "." + e) + .ToList(); + + /// ".ncm" or ".ncm, .qmc". + public static string Label(IReadOnlyList extensions) => string.Join(", ", extensions); + + /// The new-issue page of the "Format request" form, its title and extension field filled in. + public static Uri Url(IReadOnlyList extensions) + { + var label = Label(extensions); + return new Uri(UpdateService.RepositoryUrl.TrimEnd('/') + "/issues/new?template=format_request.yml" + + "&title=" + Uri.EscapeDataString("Format request: " + label) + + "&extension=" + Uri.EscapeDataString(label)); + } +} diff --git a/src/Filee.App/Services/LocalizationService.cs b/src/Filee.App/Services/LocalizationService.cs index f09685a0..9d1dbf83 100644 --- a/src/Filee.App/Services/LocalizationService.cs +++ b/src/Filee.App/Services/LocalizationService.cs @@ -100,13 +100,35 @@ public static string Resolve(string setting) return "en"; } - /// Reads a translation file. Missing files yield an empty dictionary (English is used instead). - internal static Dictionary LoadStrings(string code) + /// + /// Reads a translation file. Missing files yield an empty dictionary (English is used instead). + /// reads assets before Avalonia is set up (a message shown by Program). + /// + internal static Dictionary LoadStrings(string code, IAssetLoader? loader = null) { var uri = new Uri($"avares://Filee/Assets/i18n/{code}.json"); - if (!AssetLoader.Exists(uri)) + if (!(loader?.Exists(uri) ?? AssetLoader.Exists(uri))) return []; - using var stream = AssetLoader.Open(uri); + using var stream = loader?.Open(uri) ?? AssetLoader.Open(uri); return JsonSerializer.Deserialize(stream, AppJsonContext.Default.DictionaryStringString) ?? []; } + + /// + /// One string in the system's language, before Avalonia (and the settings) are loaded: for the messages a launch + /// shows when it can't reach the running Filee. Falls back to English, then to the key. + /// + internal static string EarlyText(string key) + { + try + { + var loader = new StandardAssetLoader(typeof(LocalizationService).Assembly); + var code = Resolve("auto"); + if (LoadStrings(code, loader).TryGetValue(key, out var text) || LoadStrings("en", loader).TryGetValue(key, out text)) + return text; + } + catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException or ArgumentException) + { + } + return key; + } } diff --git a/src/Filee.App/Services/LogThrottle.cs b/src/Filee.App/Services/LogThrottle.cs new file mode 100644 index 00000000..d539fecb --- /dev/null +++ b/src/Filee.App/Services/LogThrottle.cs @@ -0,0 +1,37 @@ +// Keeps diagnostics that can come in bursts to a few log lines: Explorer starts one Filee per selected file, and a +// gesture or shortcut can repeat many times a minute. The first event of a kind is logged, then at most one per +// interval, saying how many were held back in between. + +namespace Filee.App.Services; + +/// Rate limit for log lines, per kind of event. Thread-safe. +internal sealed class LogThrottle(TimeSpan interval, TimeProvider? time = null) +{ + private readonly TimeProvider _time = time ?? TimeProvider.System; + private readonly Dictionary _kinds = new(StringComparer.Ordinal); + private readonly Lock _lock = new(); + + /// + /// True when an event of kind may be logged now. is how many of + /// that kind were held back since the last one logged (0 when it returns false). + /// + public bool TryEnter(string kind, out int held) + { + lock (_lock) + { + var now = _time.GetUtcNow(); + if (_kinds.TryGetValue(kind, out var entry) && now - entry.Last < interval) + { + _kinds[kind] = (entry.Last, entry.Held + 1); + held = 0; + return false; + } + held = entry.Held; + _kinds[kind] = (now, 0); + return true; + } + } + + /// " (3 more since the last one)", or "" when none were held back. + public static string HeldText(int held) => held > 0 ? $" ({held} more since the last one)" : ""; +} diff --git a/src/Filee.App/Services/MemoryTrimmer.cs b/src/Filee.App/Services/MemoryTrimmer.cs new file mode 100644 index 00000000..85f94a38 --- /dev/null +++ b/src/Filee.App/Services/MemoryTrimmer.cs @@ -0,0 +1,115 @@ +// Gives memory back while Filee waits in the tray (issue #119): a while after the last conversion finished, and after +// the main window was released. Large images grow the managed heap and the engines' caches, and the garbage collector +// keeps that memory committed for the next allocation, so Filee stayed hundreds of MB above its idle size. + +using System.Diagnostics; +using Filee.Core.Conversion; +using Filee.Engines.Infrastructure; +using Filee.Platform.Windows; +using Microsoft.Extensions.Logging; + +namespace Filee.App.Services; + +public sealed class MemoryTrimmer : IDisposable +{ + /// How long no conversion may have run before memory is trimmed. + public static readonly TimeSpan IdleDelay = TimeSpan.FromSeconds(30); + + private readonly Func _busy; + private readonly Action _trim; + private readonly TimeSpan _delay; + private readonly Timer _timer; + private readonly Lock _lock = new(); + private bool _requested; + private bool _disposed; + private int _trims; + + public MemoryTrimmer(JobQueue queue, ILogger log) + : this(() => queue.IsBusy, () => Trim(log), IdleDelay) + { + queue.JobFinished += (_, _) => Request(); + } + + internal MemoryTrimmer(Func busy, Action trim, TimeSpan delay) + { + _busy = busy; + _trim = trim; + _delay = delay; + _timer = new Timer(_ => OnQuiet()); + } + + /// How often memory was trimmed. + internal int Trims => Volatile.Read(ref _trims); + + /// + /// Memory was freed (a job finished, the main window closed): trim once has passed without + /// another request and no conversion runs. A request meanwhile moves the time on; nothing happens without one. + /// + public void Request() + { + lock (_lock) + { + if (_disposed) + return; + _requested = true; + _timer.Change(_delay, Timeout.InfiniteTimeSpan); + } + } + + private void OnQuiet() + { + lock (_lock) + { + if (!_requested || _disposed) + return; + if (_busy()) + { + // A conversion started meanwhile: never during one. Look again later (its end requests too). + _timer.Change(_delay, Timeout.InfiniteTimeSpan); + return; + } + _requested = false; + } + _trim(); + Interlocked.Increment(ref _trims); + } + + /// Drops the engines' caches, collects and compacts every heap, then trims the working set (Windows). + internal static void Trim(ILogger log) + { + try + { + var watch = Stopwatch.StartNew(); + var before = PrivateMegabytes(); + EngineCaches.Release(); + // Without compacting: a compacting collection moves old objects, and native code (the UI's Skia renderer) + // can hold pointers into them; the test run crashed in Skia half a minute after such a trim. Garbage is + // freed and empty heap regions are decommitted all the same. + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: false); + GC.WaitForPendingFinalizers(); + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: false); + ProcessMemory.TrimWorkingSet(); + log.LogInformation("Trimmed memory: private {Before} MB → {After} MB (managed heap {Heap} MB) in {Elapsed} ms", + before, PrivateMegabytes(), GC.GetTotalMemory(false) / (1024 * 1024), watch.ElapsedMilliseconds); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + log.LogWarning(ex, "Could not trim memory"); + } + } + + private static long PrivateMegabytes() + { + using var process = Process.GetCurrentProcess(); + return process.PrivateMemorySize64 / (1024 * 1024); + } + + public void Dispose() + { + lock (_lock) + { + _disposed = true; + _timer.Dispose(); + } + } +} diff --git a/src/Filee.App/Services/Motion.cs b/src/Filee.App/Services/Motion.cs index 5b19cb77..db141a98 100644 --- a/src/Filee.App/Services/Motion.cs +++ b/src/Filee.App/Services/Motion.cs @@ -66,7 +66,10 @@ public static void Set(bool enabled) public static void Track(Window window) { lock (Windows) + { + Windows.RemoveAll(w => !w.TryGetTarget(out _)); // the main window is a new one each time it opens Windows.Add(new WeakReference(window)); + } Apply(window); } diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs index d3ffc447..742786eb 100644 --- a/src/Filee.App/Services/PresetAvailability.cs +++ b/src/Filee.App/Services/PresetAvailability.cs @@ -35,10 +35,11 @@ bool Possible(RoutePlanner planner) => formats.Any(f => var target = TargetFor(f); if (merge && f.Id == "pdf" && !turns) return true; // merged as it is + var frames = preset.SavesFramesOf(f.Id); if (!turns) - return planner.CanConvert(f.Id, target); + return planner.CanConvert(f.Id, target, frames); // Rotate / flip: a route only counts when an engine on it turns images (not a PDF or a video copied). - if (planner.Plan(f.Id, target) is not { } route) + if (planner.Plan(f.Id, target, frames) is not { } route) return false; if (route.FirstTransformStep() >= 0) return true; diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 202d9bc4..3456df0a 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -19,6 +19,7 @@ using Filee.App.Views; using Filee.Core.Conversion; using Filee.Core.Platform; +using Filee.Core.Profiles; using Filee.Core.Settings; using Microsoft.Extensions.Logging; @@ -46,6 +47,12 @@ public sealed class RadialController( private JobViewModel? _lastRingJob; // for "click the ✓ to open the folder" private IDisposable? _handOffTimer; private int _dragGeneration; + private bool _dragReachedDonut; + + /// A gesture over something that isn't a file drag (a rubber-band selection) is common: a line now and then. + private static readonly LogThrottle Throttle = new(TimeSpan.FromMinutes(1)); + private bool _converted; // a conversion started from the files on the donut now + private readonly HashSet _formatRequestsOffered = new(StringComparer.OrdinalIgnoreCase); /// /// How long a drag that never brought files may keep the invisible window up, in case the button release is @@ -69,6 +76,8 @@ public void ShowForDrag(int x, int y) // A ring from an earlier conversion (finished or handed to the toast) must not keep this gesture's window up. window.DonutControl.LeaveProgressMode(); _dropped = false; + _dragReachedDonut = false; + _converted = false; viewModel.ResetForDrag(); window.DonutControl.AllowContextEdit = false; PlaceAndShow(window, x, y, activate: false); @@ -89,6 +98,7 @@ public void ShowForFiles(int x, int y, IReadOnlyList files) ReleaseProgressToToast(); var window = EnsureWindow(); _dropped = false; + _converted = false; viewModel.Load(files, clickMode: true); viewModel.FitToThisDonut(); window.DonutControl.AllowContextEdit = true; @@ -101,6 +111,12 @@ public void OnDragGestureEnded() { if (!_visible || viewModel.IsClickMode) return; + // No drag ever entered the donut's window: nothing was dragged (a rubber-band selection), or Windows kept + // Explorer's drag out (Filee runs as administrator). Tells "the donut didn't react" reports apart. + if (!_dragReachedDonut && Throttle.TryEnter("no-drag-enter", out var held)) + log.LogInformation("Drag gesture ended without a drag entering the donut{Elevation}{Held}", + Program.Elevation.ShellCannotReachFilee ? " (Filee runs as administrator: Windows blocks drops from Explorer)" : "", + LogThrottle.HeldText(held)); // Nothing was brought to the donut: the window is invisible but catches clicks, so go right away. if (!viewModel.HasFiles && !_window!.DonutControl.IsInProgressMode) { @@ -144,6 +160,22 @@ public void Close(bool animate = true) }); else window.Hide(); + OfferFormatRequest(); + } + + /// + /// The donut closed on files Filee can't read at all (".ncm"): offer to request the format, once per extension + /// while Filee runs. Files it reads, even mixed with unknown ones, get a conversion and the toast instead. + /// + private void OfferFormatRequest() + { + if (_converted || viewModel.Files.Count == 0 || ProfileSelector.DetectFormats(viewModel.Files).Count > 0) + return; + var extensions = FormatRequest.UnknownExtensions(viewModel.Files).Where(_formatRequestsOffered.Add).ToList(); + if (extensions.Count == 0) + return; + log.LogInformation("Donut closed on files Filee can't read: {Extensions}", FormatRequest.Label(extensions)); + windows.ShowFormatRequestNotice(extensions); } private RadialWindow EnsureWindow() @@ -154,6 +186,7 @@ private RadialWindow EnsureWindow() var window = new RadialWindow { DataContext = viewModel }; var donut = window.DonutControl; + window.DragEntered += () => _dragReachedDonut = true; window.FilesEntered += files => { if (viewModel.IsClickMode || SameFiles(files, viewModel.Files)) @@ -214,6 +247,7 @@ private void Convert(int index, int sub, IReadOnlyList files) Close(); return; } + _converted = true; ShowProgress(job); } diff --git a/src/Filee.App/Services/SingleInstance.cs b/src/Filee.App/Services/SingleInstance.cs index 64285de3..93a25dfa 100644 --- a/src/Filee.App/Services/SingleInstance.cs +++ b/src/Filee.App/Services/SingleInstance.cs @@ -3,6 +3,7 @@ using System.IO.Pipes; using System.Text.Json; +using Filee.Core.Settings; namespace Filee.App.Services; @@ -10,8 +11,7 @@ namespace Filee.App.Services; public sealed class SingleInstance : IDisposable { // string.GetHashCode() is randomized per process, so a stable hash is required for both processes to agree. - private static readonly string DefaultName = "Filee-" + Convert.ToHexString( - System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(Environment.UserName)))[..12]; + internal static readonly string DefaultName = NameFor(Environment.UserName, UserDataStore.OverrideDirectory); private readonly Mutex _mutex; private readonly string _name; private readonly CancellationTokenSource _cts = new(); @@ -30,6 +30,16 @@ private SingleInstance(Mutex mutex, string name, bool isFirst) public static SingleInstance Acquire() => Acquire(DefaultName); + /// + /// The mutex and pipe name for a user. A copy started with FILEE_DATA_DIR () + /// gets a name of its own, so it neither forwards to nor receives from the Filee the user runs. + /// + internal static string NameFor(string userName, string? dataDirectoryOverride) + { + var key = dataDirectoryOverride is null ? userName : userName + "|" + dataDirectoryOverride.ToUpperInvariant(); + return "Filee-" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(key)))[..12]; + } + /// The instance under another name (tests, which must not reach a Filee that is running). internal static SingleInstance Acquire(string name) { @@ -67,6 +77,19 @@ public void StartListening() { // A client disconnected half-way; keep serving. } + catch (UnauthorizedAccessException) + { + // The pipe name is still held by a Filee with other rights (one that just restarted this one + // without administrator rights and is closing): try again once it is gone. + try + { + await Task.Delay(500, _cts.Token); + } + catch (OperationCanceledException) + { + return; + } + } } }); } @@ -75,11 +98,19 @@ public void StartListening() /// Sends this process's arguments to the first instance. It only listens once its services are up, which can take /// over ten seconds on a slow or busy computer: a second launch (or the installer's --quit) right after the first /// lost its arguments after one 3-second try. Tries again until has passed; false when the - /// first instance never answered (it is shutting down, or hung). + /// first instance never answered (it is shutting down, or hung) or refused this process. /// - public bool ForwardToFirst(string[] args, TimeSpan wait) + public bool ForwardToFirst(string[] args, TimeSpan wait) => Forward(args, wait) == ForwardResult.Delivered; + + /// + /// Like , telling why it failed. A pipe that keeps refusing access belongs to a Filee + /// running with other rights (as administrator, while this one isn't): that never changes, so it gives up after a + /// few tries instead of waiting the whole time. + /// + public ForwardResult Forward(string[] args, TimeSpan wait) { var deadline = DateTime.UtcNow + wait; + var refusals = 0; while (true) { var left = deadline - DateTime.UtcNow; @@ -89,17 +120,29 @@ public bool ForwardToFirst(string[] args, TimeSpan wait) client.Connect((int)Math.Clamp(left.TotalMilliseconds, 100, 1000)); Filee.Platform.Windows.Foreground.AllowOtherProcess(); // its donut takes the focus (Esc, click away) JsonSerializer.Serialize(client, args, AppJsonContext.Default.StringArray); - return true; + return ForwardResult.Delivered; } - catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) + catch (UnauthorizedAccessException) { + // Also thrown for a moment while the pipe is being recreated between two launches: try a few times. + if (++refusals >= RefusalsBeforeGivingUp) + return ForwardResult.AccessDenied; if (DateTime.UtcNow >= deadline) - return false; + return ForwardResult.NoAnswer; + Thread.Sleep(100); + } + catch (Exception ex) when (ex is IOException or TimeoutException) + { + if (DateTime.UtcNow >= deadline) + return ForwardResult.NoAnswer; Thread.Sleep(100); // the pipe is busy with another launch, or not there yet } } } + /// Access-denied answers in a row after which stops trying. + private const int RefusalsBeforeGivingUp = 5; + /// /// Waits until the first instance has exited (it releases the mutex then), e.g. after forwarding --quit. /// Returns false on timeout. @@ -121,8 +164,12 @@ public bool WaitForFirstToExit(TimeSpan timeout) return true; } + /// Stops listening and releases the mutex. Safe to call twice (a restart releases it before shutting down). public void Dispose() { + if (_disposed) + return; + _disposed = true; _cts.Cancel(); if (IsFirst) { @@ -134,4 +181,19 @@ public void Dispose() } _mutex.Dispose(); } + + private bool _disposed; +} + +/// Outcome of . +public enum ForwardResult +{ + /// The running Filee has the arguments. + Delivered, + + /// Nobody took them in time: the running Filee is starting, shutting down or hung. + NoAnswer, + + /// The running Filee's pipe refuses this process: it runs with other rights (as administrator). + AccessDenied, } diff --git a/src/Filee.App/Services/StartupDiagnostics.cs b/src/Filee.App/Services/StartupDiagnostics.cs new file mode 100644 index 00000000..aa6df551 --- /dev/null +++ b/src/Filee.App/Services/StartupDiagnostics.cs @@ -0,0 +1,56 @@ +// What Filee writes to its log before and around start-up: one line describing this launch, and every exception that +// would otherwise end the process (or vanish in a task) without a trace. A report of a Filee that ignored every +// gesture came with an empty log; these lines are what tells such cases apart. + +using System.Runtime.InteropServices; +using Filee.Core.Platform; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.App.Services; + +internal static class StartupDiagnostics +{ + /// + /// A logger for Program, before the service container exists. It writes to the same daily file. A log folder that + /// can't be created gives a logger that drops everything: logging must never stop Filee from starting. + /// + public static ILogger CreateEarlyLogger(string logDirectory) + { + try + { + return new FileLoggerProvider(logDirectory).CreateLogger("Filee.App.Program"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return NullLogger.Instance; + } + } + + /// + /// The start-up line: "Filee 1.11.0 starting: C:\…\Filee.exe (installed), integrity Medium, Explorer Medium, + /// Microsoft Windows 10.0.26200 X64, 2 argument(s)". Arguments are counted, not written: they are file paths. + /// + public static string Describe(string version, string? executable, string installKind, ElevationState elevation, + string operatingSystem, int argumentCount) => + $"Filee {version} starting: {executable ?? "(unknown path)"} ({installKind}), {elevation}, {operatingSystem}, " + + $"{argumentCount} argument(s)"; + + /// How this copy got here: Setup, a release archive, or a build from source. + public static string InstallKind(bool installed, bool release) => + installed ? "installed" : release ? "portable" : "development build"; + + /// "Microsoft Windows 10.0.26200 X64". + public static string OperatingSystemText() => + $"{RuntimeInformation.OSDescription.Trim()} {RuntimeInformation.OSArchitecture}"; + + /// Writes exceptions from any thread and unobserved task failures to . + public static void LogUnhandledExceptions(ILogger log) + { + AppDomain.CurrentDomain.UnhandledException += (_, e) => + log.LogCritical(e.ExceptionObject as Exception, "Unhandled exception (process ending: {Terminating}): {Object}", + e.IsTerminating, e.ExceptionObject is Exception ? "see below" : e.ExceptionObject); + TaskScheduler.UnobservedTaskException += (_, e) => + log.LogError(e.Exception, "A background task failed and nobody looked at the error"); + } +} diff --git a/src/Filee.App/Services/SystemIntegrationService.cs b/src/Filee.App/Services/SystemIntegrationService.cs index 569da464..a2097700 100644 --- a/src/Filee.App/Services/SystemIntegrationService.cs +++ b/src/Filee.App/Services/SystemIntegrationService.cs @@ -12,14 +12,28 @@ public sealed class SystemIntegrationService(IPlatformServices platform, ILocali private (bool Enabled, string Executable, string Label)? _menu; private bool _shortcutApplied; private string? _shortcut; + private bool _skipLogged; public string? Error { get; private set; } internal bool RegistrationEnabled { get; init; } = UpdateService.IsReleaseBuild; internal string? ExecutablePath { get; init; } = Environment.ProcessPath; + /// + /// A copy started with FILEE_DATA_DIR (): its startup entry, context + /// menu and Explorer menu would replace those of the Filee the user installed, so it registers nothing. + /// + internal bool SeparateDataDirectory { get; init; } = UserDataStore.OverrideDirectory is not null; + public void Apply(AppSettings settings) { Error = null; + if (SeparateDataDirectory) + { + if (!_skipLogged) + log.LogInformation("{Variable} is set: startup entry, context menu and Explorer menu are left alone", UserDataStore.DirectoryVariable); + _skipLogged = true; + return; + } if (!RegistrationEnabled || ExecutablePath is not { } executable) return; var errors = new List(); diff --git a/src/Filee.App/Services/UiWatchdog.cs b/src/Filee.App/Services/UiWatchdog.cs new file mode 100644 index 00000000..8d397c18 --- /dev/null +++ b/src/Filee.App/Services/UiWatchdog.cs @@ -0,0 +1,86 @@ +// Notices when the UI thread stops answering. A report said every gesture, shortcut and drop did nothing while the log +// stayed empty; a line saying the UI thread was stuck (and for how long) tells a hang from events that never arrived. +// A background timer posts a ping to the UI thread every second. While a ping waits longer than the threshold, one +// warning is written for that episode; when the ping finally runs, one line says how long the UI thread was gone. + +using Microsoft.Extensions.Logging; + +namespace Filee.App.Services; + +/// Logs UI thread stalls longer than a threshold, once per episode. +internal sealed class UiWatchdog : IDisposable +{ + /// How often the UI thread is pinged. + public static readonly TimeSpan Interval = TimeSpan.FromSeconds(1); + + /// Stalls shorter than this aren't worth a line (a big folder list, a first window). + public static readonly TimeSpan DefaultThreshold = TimeSpan.FromSeconds(5); + + private readonly Action _post; + private readonly ILogger _log; + private readonly TimeProvider _time; + private readonly TimeSpan _threshold; + private readonly Lock _lock = new(); + private DateTimeOffset? _pingSentAt; + private bool _reported; + private ITimer? _timer; + + /// Queues an action on the UI thread (Dispatcher.UIThread.Post in the app). + public UiWatchdog(Action post, ILogger log, TimeProvider? time = null, TimeSpan? threshold = null) + { + _post = post; + _log = log; + _time = time ?? TimeProvider.System; + _threshold = threshold ?? DefaultThreshold; + } + + /// Starts pinging every on a thread-pool timer. + public void Start() => _timer ??= _time.CreateTimer(_ => Tick(), null, Interval, Interval); + + /// One timer tick: sends a ping, or checks how long the last one has been waiting. + internal void Tick() + { + lock (_lock) + { + if (_pingSentAt is { } sent) + { + var waited = _time.GetUtcNow() - sent; + if (!_reported && waited > _threshold) + { + _reported = true; + _log.LogWarning("UI thread blocked for {Seconds:0} s", waited.TotalSeconds); + } + return; + } + _pingSentAt = _time.GetUtcNow(); + } + try + { + _post(Pong); + } + catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException) + { + // The dispatcher is shutting down: nothing left to watch. + lock (_lock) + _pingSentAt = null; + } + } + + /// Runs on the UI thread when it got to the ping. + private void Pong() + { + lock (_lock) + { + if (_reported && _pingSentAt is { } sent) + _log.LogInformation("UI thread responsive again after {Seconds:0.0} s", (_time.GetUtcNow() - sent).TotalSeconds); + _pingSentAt = null; + _reported = false; + } + } + + public void Dispose() + { + _timer?.Dispose(); + _timer = null; + } +} diff --git a/src/Filee.App/Services/UninstallCleanup.cs b/src/Filee.App/Services/UninstallCleanup.cs index 4d5cef3a..8d8a51a7 100644 --- a/src/Filee.App/Services/UninstallCleanup.cs +++ b/src/Filee.App/Services/UninstallCleanup.cs @@ -1,6 +1,7 @@ // Removes per-user integration and downloaded engines after Filee exits. User settings remain for reinstalling. using Filee.Core.Platform; +using Filee.Core.Settings; using Filee.Platform.Linux; using Filee.Platform.MacOS; using Filee.Platform.Windows; @@ -15,11 +16,16 @@ public static bool Run() : OperatingSystem.IsMacOS() ? new MacOSPlatformServices() : OperatingSystem.IsLinux() ? new LinuxPlatformServices() : new NullPlatformServices(); var succeeded = true; - Clean(() => platform.SetContextMenu(false, "", "")); - Clean(() => platform.SetStartWithSystem(false, "")); - if (OperatingSystem.IsLinux() && platform is LinuxPlatformServices linux) + // A copy with its own data folder (FILEE_DATA_DIR) never registered anything: only its engines go. + var integrated = UserDataStore.OverrideDirectory is null; + if (integrated) + { + Clean(() => platform.SetContextMenu(false, "", "")); + Clean(() => platform.SetStartWithSystem(false, "")); + } + if (integrated && OperatingSystem.IsLinux() && platform is LinuxPlatformServices linux) Clean(() => linux.SetThunarShortcut(null)); - if (OperatingSystem.IsWindows() && ExplorerMenuRegistration.IsSupportedOs) + if (integrated && OperatingSystem.IsWindows() && ExplorerMenuRegistration.IsSupportedOs) { Clean(() => { diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 1c23eec7..a5ded8cb 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -14,25 +14,52 @@ namespace Filee.App.Services; -public sealed class WindowService(ILocalizer loc, IServiceProvider services) +public sealed class WindowService(ILocalizer loc, IServiceProvider services, MemoryTrimmer trimmer) { private MainWindow? _main; + private MainWindowPlacement? _mainPlacement; private ToastWindow? _toast; private EngineSetupWindow? _engineSetup; private UpdateNoticeWindow? _updateNotice; private EngineUpdateNoticeWindow? _engineNotice; private FeedbackNoticeWindow? _feedbackNotice; + private FormatRequestNoticeWindow? _formatRequestNotice; + + internal FormatRequestNoticeWindow? FormatRequestNotice => _formatRequestNotice; public MainWindow? Main => _main; - /// Shows (or brings to front) the main window, optionally on a page. + /// + /// Shows (or brings to front) the main window, optionally on a page. Going to the tray closes it + /// (): a new one opens where and on the page the last one was. + /// public void ShowMain(string? page = null) { Motion.Refresh(); if (_main is null) { - _main = new MainWindow { DataContext = services.GetRequiredService() }; - _main.Closed += (_, _) => _main = null; + var model = ActivatorUtilities.CreateInstance(services); + var main = new MainWindow { DataContext = model }; + if (_mainPlacement is { } placement) + { + placement.ApplyTo(main); + if (page is null) + model.Navigate(placement.Page); + } + main.Closing += (_, _) => + { + // While it is still on screen: the release closes it hidden, when its position may no longer be known. + if (main.IsVisible) + _mainPlacement = MainWindowPlacement.Of(main, model, _mainPlacement); + }; + main.Closed += (_, _) => + { + if (_main == main) + _main = null; + model.Dispose(); + trimmer.Request(); + }; + _main = main; } if (page is not null && _main.DataContext is MainWindowViewModel vm) vm.Navigate(page); @@ -130,6 +157,33 @@ public void ShowFeedbackNotice() _feedbackNotice.Show(); } + /// Shows the "Filee is running as administrator" card in the bottom-right corner (once per run). + public void ShowElevationNotice() + { + if (_elevationNoticeShown) + return; + _elevationNoticeShown = true; + new ElevationNoticeWindow(services.GetRequiredService()).Show(); + } + + private bool _elevationNoticeShown; + + /// + /// Shows the "Filee can't convert .ncm files yet" card with "Request this format", replacing an earlier one. + /// + public void ShowFormatRequestNotice(IReadOnlyList extensions) + { + _formatRequestNotice?.Close(); + var notice = new FormatRequestNoticeWindow(extensions, loc.Format("format_request.notice_title", FormatRequest.Label(extensions))); + notice.Closed += (_, _) => + { + if (_formatRequestNotice == notice) + _formatRequestNotice = null; + }; + _formatRequestNotice = notice; + notice.Show(); + } + /// Opens the preset popup. Returns true when the user saved (the preset object was updated). public async Task EditPresetAsync(Preset preset, Window? owner) { @@ -248,4 +302,34 @@ private async Task ShowMessageAsync(Window? owner, string message, bool de private static Window? MainWindowOrNull() => (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + + /// Where the main window was and which page it showed, for the next one. + private sealed record MainWindowPlacement(PixelPoint? Position, Size? Size, bool Maximized, string Page) + { + /// The window's placement. Maximized or minimized, it keeps the position and size it had before. + public static MainWindowPlacement Of(MainWindow window, MainWindowViewModel vm, MainWindowPlacement? last) => + window.WindowState switch + { + WindowState.Normal => new(window.Position, window.ClientSize, false, vm.SelectedItem.Key), + WindowState.Maximized => (last ?? new(null, null, false, "")) with { Maximized = true, Page = vm.SelectedItem.Key }, + _ => (last ?? new(null, null, false, "")) with { Page = vm.SelectedItem.Key }, + }; + + public void ApplyTo(MainWindow window) + { + // Only on a screen that is still there (a monitor may have been unplugged since). + if (Position is { } position && window.Screens.ScreenFromPoint(position) is not null) + { + window.WindowStartupLocation = WindowStartupLocation.Manual; + window.Position = position; + } + if (Size is { } size) + { + window.Width = size.Width; + window.Height = size.Height; + } + if (Maximized) + window.WindowState = WindowState.Maximized; + } + } } diff --git a/src/Filee.App/ViewModels/JobViewModel.cs b/src/Filee.App/ViewModels/JobViewModel.cs index 3044df29..6cf19a25 100644 --- a/src/Filee.App/ViewModels/JobViewModel.cs +++ b/src/Filee.App/ViewModels/JobViewModel.cs @@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Filee.App.Services; using Filee.Core.Conversion; using Filee.Core.Localization; using Filee.Core.Platform; @@ -41,6 +42,16 @@ public JobViewModel(ConversionJob job, ILocalizer loc, IPlatformServices platfor [ObservableProperty] private string? _errorDetails; [ObservableProperty] private bool _canRetry; + /// Notes on files that converted (e.g. a PDF that couldn't be made smaller and was copied), one per line. + [ObservableProperty] private string? _notices; + [ObservableProperty] private bool _hasNotices; + /// Extensions of failed files Filee can't read (".ncm"), for "Request this format". + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanRequestFormat))] + private IReadOnlyList _unknownExtensions = []; + + public bool CanRequestFormat => UnknownExtensions.Count > 0; + /// Source files that failed. public IEnumerable FailedSources => Job.Files.Where(f => f.State == FileState.Failed).Select(f => f.SourcePath); @@ -60,6 +71,15 @@ public void Refresh() ErrorDetails = failed.Count == 0 ? null : string.Join(Environment.NewLine, failed.Select(f => $"{Path.GetFileName(f.SourcePath)} — {FailureText.Reason(_loc, f.ErrorKey, f.ErrorDetail)}")); CanRetry = _retry is not null && !IsRunning && failed.Count > 0; + IReadOnlyList unknown = IsRunning ? [] : FormatRequest.UnknownExtensions( + failed.Where(f => f.ErrorKey == "error.unsupported_source").Select(f => f.SourcePath)); + if (!unknown.SequenceEqual(UnknownExtensions)) + UnknownExtensions = unknown; + + var noted = Job.Files.Where(f => f.State == FileState.Done && f.NoticeKey is not null).ToList(); + HasNotices = noted.Count > 0; + Notices = noted.Count == 0 ? null : string.Join(Environment.NewLine, + noted.Select(f => $"{Path.GetFileName(f.SourcePath)} — {_loc[f.NoticeKey!]}")); Status = Job.State switch { diff --git a/src/Filee.App/ViewModels/MainWindowViewModel.cs b/src/Filee.App/ViewModels/MainWindowViewModel.cs index 629d9a81..07161c55 100644 --- a/src/Filee.App/ViewModels/MainWindowViewModel.cs +++ b/src/Filee.App/ViewModels/MainWindowViewModel.cs @@ -26,10 +26,15 @@ public sealed partial class NavItem(string key, string titleKey, string iconKey, public void Refresh(ILocalizer loc) => Title = loc[TitleKey]; } -public sealed partial class MainWindowViewModel : ObservableObject +/// +/// One per main window (); disposed with it, which drops its subscriptions on the +/// long-lived services and the current page. +/// +public sealed partial class MainWindowViewModel : ObservableObject, IDisposable { private readonly IServiceProvider _services; private readonly ILocalizer _loc; + private bool _disposed; /// The repository, for the GitHub button above the version. public string RepositoryUrl => UpdateService.RepositoryUrl; @@ -39,11 +44,7 @@ public MainWindowViewModel(IServiceProvider services, ILocalizer loc, UpdateServ _services = services; _loc = loc; Updates = updates; - updates.PropertyChanged += (_, e) => - { - if (UpdateTexts.Affects(e.PropertyName)) - RefreshVersionTexts(); - }; + updates.PropertyChanged += OnUpdatesChanged; RefreshVersionTexts(); Items = [ @@ -59,13 +60,7 @@ public MainWindowViewModel(IServiceProvider services, ILocalizer loc, UpdateServ ]; foreach (var item in Items) item.Refresh(loc); - loc.LanguageChanged += (_, _) => - { - foreach (var item in Items) - item.Refresh(loc); - RefreshVersionTexts(); - CurrentPage = CreatePage(SelectedItem); // rebuild so code-generated labels update too - }; + loc.LanguageChanged += OnLanguageChanged; _selectedItem = Items[0]; _currentPage = CreatePage(Items[0]); } @@ -93,6 +88,20 @@ public MainWindowViewModel(IServiceProvider services, ILocalizer loc, UpdateServ [RelayCommand] private Task DownloadUpdate() => Updates.UpdateAsync(); + private void OnUpdatesChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (UpdateTexts.Affects(e.PropertyName)) + RefreshVersionTexts(); + } + + private void OnLanguageChanged(object? sender, EventArgs e) + { + foreach (var item in Items) + item.Refresh(_loc); + RefreshVersionTexts(); + CurrentPage = CreatePage(SelectedItem); // rebuild so code-generated labels update too + } + private void RefreshVersionTexts() { VersionText = _loc.Format("about.version", UpdateService.CurrentVersion); @@ -122,4 +131,14 @@ partial void OnSelectedItemChanged(NavItem? oldValue, NavItem newValue) private ObservableObject CreatePage(NavItem item) => (ObservableObject)ActivatorUtilities.CreateInstance(_services, item.PageType); + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + Updates.PropertyChanged -= OnUpdatesChanged; + _loc.LanguageChanged -= OnLanguageChanged; + (CurrentPage as IDisposable)?.Dispose(); + } } diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs index d595e3bf..c67b9e2e 100644 --- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs @@ -86,10 +86,10 @@ partial void OnDefaultSubfolderChanged(string value) => [ObservableProperty] private Choice _closeChoice; - partial void OnCloseChoiceChanged(Choice value) + partial void OnCloseChoiceChanged(Choice? oldValue, Choice newValue) { - if (_store.Settings.CloseAction != value.Value) - Save(s => s.CloseAction = value.Value); + if (!Choices.Restore(oldValue, newValue, v => CloseChoice = v) && _store.Settings.CloseAction != newValue.Value) + Save(s => s.CloseAction = newValue.Value); } // The close dialog may answer "Don't ask again" while this page stays open behind the hidden window. diff --git a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs index 274e7c6d..189d3d26 100644 --- a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs @@ -83,17 +83,21 @@ public sealed partial class HomePageViewModel : ObservableObject, IDisposable private readonly IPlatformServices _platform; private readonly Services.Triggers.TriggerService? _triggers; private readonly ConversionService? _conversions; + private readonly ElevationService? _elevation; public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, Services.Triggers.TriggerService? triggers = null, - ConversionService? conversions = null) + ConversionService? conversions = null, ElevationService? elevation = null) { _store = store; _loc = loc; _platform = platform; _triggers = triggers; _conversions = conversions; + _elevation = elevation; if (triggers is not null) triggers.StatusChanged += OnTriggerStatusChanged; + if (elevation is not null) + elevation.PropertyChanged += OnElevationChanged; var drag = store.Settings.Triggers.FirstOrDefault(t => t.Enabled && t.Kind == TriggerKind.Drag); DropHint = loc.Format(OperatingSystem.IsMacOS() ? "home.drop_hint_macos" : OperatingSystem.IsLinux() ? "home.drop_hint_linux" : "home.drop_hint", drag is null ? "—" : GestureText.Modifiers(drag.Modifiers)); store.HistoryChanged += OnHistoryChanged; @@ -107,10 +111,27 @@ public void Dispose() _store.HistoryChanged -= OnHistoryChanged; if (_triggers is not null) _triggers.StatusChanged -= OnTriggerStatusChanged; + if (_elevation is not null) + _elevation.PropertyChanged -= OnElevationChanged; } private void OnTriggerStatusChanged(object? sender, EventArgs e) => OnPropertyChanged(nameof(NeedsAccessibility)); + private void OnElevationChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => + OnPropertyChanged(nameof(ElevationRestartFailed)); + + /// + /// Windows: Filee runs as administrator while Explorer doesn't, so drops, the Explorer menu and the shortcut can't + /// reach it (the warning above the drop zone). + /// + public bool IsElevated => _elevation?.ShellCannotReachFilee == true; + + /// The restart button didn't work: the warning says what to do instead. + public bool ElevationRestartFailed => _elevation?.RestartFailed == true; + + [RelayCommand] + private void RestartWithoutAdministratorRights() => _elevation?.RestartWithoutAdministratorRights(); + /// macOS: gestures wait for the Accessibility permission (the reminder above the drop zone). public bool NeedsAccessibility => _triggers?.NeedsPermission == true; diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index 2541b32a..cbba2856 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -80,6 +80,7 @@ .. FormatRegistry.Known Rotations = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.rotation.{v}"])).ToList(); Flips = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.flip.{v}"])).ToList(); PageSizes = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.page_size.{v}"])).ToList(); + CompressionLevels = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.pdf_compression.{v}"])).ToList(); Locations = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.location.{v}"])).ToList(); Conflicts = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.conflict.{v}"])).ToList(); TiffCompressions = Enum.GetValues().Select(v => new Choice(v, v.ToString().ToUpperInvariant())).ToList(); @@ -99,6 +100,8 @@ .. FormatRegistry.Known _rotation = Rotations.FirstOrDefault(r => r.Value == _edit.Image.Rotation) ?? Rotations[0]; _flip = Flips.FirstOrDefault(f => f.Value == _edit.Image.Flip) ?? Flips[0]; _pageSize = PageSizes.First(p => p.Value == _edit.Pdf.PageSize); + _compressionLevel = CompressionLevels.FirstOrDefault(c => c.Value == _edit.Pdf.Compression) ?? CompressionLevels[0]; + _compressWithGhostscript = _edit.Pdf.CompressWithGhostscript; _location = Locations.First(l => l.Value == _edit.Output.Location); _conflict = Conflicts.First(c => c.Value == _edit.Output.Conflict); _tiffCompression = TiffCompressions.First(t => t.Value == _edit.Image.TiffCompression); @@ -111,8 +114,11 @@ .. FormatRegistry.Known _wavFormat = WavFormats.First(f => f.Value == _edit.Media.WavFormat); _videoCodecValue = _edit.Media.VideoCodec; _audioCodecValue = _edit.Media.AudioCodec; + _hardwareEncoding = _edit.Media.HardwareEncoding; _archiveLevel = ArchiveLevels.First(l => l.Value == _edit.Archive.Level); _combineIntoOne = _edit.Archive.CombineIntoOne; + _saveEveryFrame = _edit.Media.SaveEveryFrame; + _frameInterval = _edit.Media.FrameInterval > 0 ? _edit.Media.FrameInterval : 1; _traceMode = TraceModes.First(m => m.Value == _edit.Trace.Mode); _traceColors = Math.Clamp(_edit.Trace.Colors, TracingOptions.MinColors, TracingOptions.MaxColors); _traceDetail = Math.Clamp(_edit.Trace.Detail, 0, 100); @@ -156,6 +162,9 @@ .. FormatRegistry.Known public IReadOnlyList> Rotations { get; } public IReadOnlyList> Flips { get; } public IReadOnlyList> PageSizes { get; } + + /// PDF → PDF: don't compress, or the level (pictures at 72, 150 or 300 dpi). + public IReadOnlyList> CompressionLevels { get; } public IReadOnlyList> Locations { get; } public IReadOnlyList> Conflicts { get; } public IReadOnlyList> TiffCompressions { get; } @@ -174,10 +183,12 @@ .. FormatRegistry.Known [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowImage), nameof(ShowTurnForPdf), nameof(ShowQuality), nameof(ShowTiff), nameof(ShowMultiPageTiff), nameof(ShowWebp), nameof(ShowIco), nameof(ShowPdf), nameof(ShowPdfBuild), nameof(ShowDocument), nameof(ShowPdfSplit), nameof(ShowPdfCombine), + nameof(ShowPdfCompression), nameof(ShowPdfCompressionOptions), nameof(ShowMedia), nameof(ShowMediaQuality), nameof(ShowMaxHeight), nameof(ShowAudioBitrate), nameof(ShowRemoveAudio), - nameof(ShowAudioChannels), nameof(ShowWav), nameof(VideoCodecs), nameof(VideoCodecChoice), nameof(ShowVideoCodec), + nameof(ShowAudioChannels), nameof(ShowWav), nameof(VideoCodecs), nameof(VideoCodecChoice), nameof(ShowVideoCodec), nameof(ShowHardwareEncoding), nameof(AudioCodecs), nameof(AudioCodecChoice), nameof(ShowAudioCodec), - nameof(ShowArchive), nameof(ShowArchiveOptions), nameof(ShowExtractNote), nameof(ShowTrace))] + nameof(ShowArchive), nameof(ShowArchiveOptions), nameof(ShowExtractNote), + nameof(ShowSaveEveryFrame), nameof(ShowTrace))] private Choice _target; private VideoCodec _videoCodecValue; @@ -209,6 +220,7 @@ public Choice VideoCodecChoice return; _videoCodecValue = value.Value; OnPropertyChanged(); + OnPropertyChanged(nameof(ShowHardwareEncoding)); } } @@ -293,6 +305,11 @@ public bool ColorProfileChecked [ObservableProperty] private string _pageRange; [ObservableProperty] private bool _splitPages; + [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowPdfCompressionOptions))] + private Choice _compressionLevel; + + [ObservableProperty] private bool _compressWithGhostscript; + /// PDF → image: one tall image of all pages. [ObservableProperty] private bool _combinePages; [ObservableProperty] private bool _trimPageMargins; @@ -309,8 +326,19 @@ public bool ColorProfileChecked [ObservableProperty] private Choice _sampleRate; [ObservableProperty] private Choice _wavFormat; [ObservableProperty] private bool _removeAudio; + + /// Encode H.264 / HEVC on the graphics chip when one works (MediaOptions.HardwareEncoding). + [ObservableProperty] private bool _hardwareEncoding; [ObservableProperty] private Choice _archiveLevel; [ObservableProperty] private bool _combineIntoOne; + [ObservableProperty] private bool _saveEveryFrame; + + [ObservableProperty, NotifyPropertyChangedFor(nameof(FrameEstimate))] + private int _frameInterval = 1; + + /// How many pictures a minute of 30 fps video gives at the chosen interval, and the limit per video. + public string FrameEstimate => _loc.Format("presets.frame_estimate", + (int)Math.Ceiling(1800.0 / Math.Clamp(FrameInterval, 1, 1000)), MediaOptions.MaxFrames.ToString("N0", System.Globalization.CultureInfo.InvariantCulture)); [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowTraceColors))] private Choice _traceMode; @@ -336,6 +364,8 @@ partial void OnTiffCompressionChanged(Choice? oldValue, Choice< partial void OnWavFormatChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => WavFormat = v); partial void OnArchiveLevelChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => ArchiveLevel = v); partial void OnTraceModeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => TraceMode = v); + partial void OnCompressionLevelChanged(Choice? oldValue, Choice newValue) => + Choices.Restore(oldValue, newValue, v => CompressionLevel = v); private string TargetId => Target.Value; private FormatCategory? TargetCategory => FormatRegistry.FindById(TargetId)?.Category; @@ -361,6 +391,10 @@ partial void OnTiffCompressionChanged(Choice? oldValue, Choice< public bool ShowPdfBuild => TargetId == "pdf"; public bool ShowPdfSplit => TargetId == "pdf"; + /// Compression is for PDF → PDF; Ghostscript and metadata only matter while it is on. + public bool ShowPdfCompression => TargetId == "pdf"; + public bool ShowPdfCompressionOptions => ShowPdfCompression && CompressionLevel.Value != PdfCompression.None; + /// PDF → a single-image format: the pages can be stacked into one tall image (TIFF, GIF, APNG and MNG keep pages). public bool ShowPdfCombine => TargetCategory == FormatCategory.Image && TargetId is not ("tiff" or "gif" or "apng" or "mng" or "ico" or "cur" or "icns" or "svg" or "pdf"); @@ -372,6 +406,8 @@ partial void OnTiffCompressionChanged(Choice? oldValue, Choice< public bool ShowWav => TargetId is "wav" or "w64"; /// MP4, MKV, MOV and WebM can be written with HEVC or AV1 besides their usual codec. public bool ShowVideoCodec => TargetIsVideo && VideoCodecs.Count > 1; + /// Hardware encoding applies to H.264 and HEVC only (not VP9, AV1, MPEG-2, …). + public bool ShowHardwareEncoding => TargetIsVideo && MediaCodecs.TakesHardwareEncoding(TargetId, _videoCodecValue); /// M4A / M4B can hold Apple Lossless besides AAC. public bool ShowAudioCodec => AudioCodecs.Count > 1; public bool ShowMediaQuality => TargetIsLossyMedia; @@ -382,6 +418,8 @@ partial void OnTiffCompressionChanged(Choice? oldValue, Choice< public bool ShowArchiveOptions => ShowArchive && TargetId != FormatRegistry.Folder; /// "Extract" has nothing to set; a line says what it does instead. public bool ShowExtractNote => TargetId == FormatRegistry.Folder; + /// "Save every frame" of a video: PNG and JPG (format ids, ). + public bool ShowSaveEveryFrame => MediaOptions.FrameTargets.Contains(TargetId); /// /// Tracing options: raster images reach SVG (and DXF / DWG through it) only by tracing. For other sources (a /// drawing, a CAD file) the options are ignored. @@ -448,6 +486,8 @@ public bool Apply() pdf.CombinePages = CombinePages; pdf.TrimPageMargins = TrimPageMargins; pdf.SplitTallImage = SplitTallImage; + pdf.Compression = CompressionLevel.Value; + pdf.CompressWithGhostscript = CompressWithGhostscript; _original.Document.PdfA = PdfA; var media = _original.Media; @@ -460,6 +500,10 @@ public bool Apply() media.WavFormat = WavFormat.Value; media.VideoCodec = MediaCodecs.Effective(TargetId, _videoCodecValue); media.AudioCodec = MediaCodecs.Effective(TargetId, _audioCodecValue); + media.HardwareEncoding = HardwareEncoding; + // Only where the option is shown: a box ticked for PNG and left behind by another target saves nothing. + media.SaveEveryFrame = ShowSaveEveryFrame && SaveEveryFrame; + media.FrameInterval = media.SaveEveryFrame ? Math.Clamp(FrameInterval, 1, 1000) : 1; _original.Archive.Level = ArchiveLevel.Value; _original.Archive.CombineIntoOne = CombineIntoOne; _original.Trace.Mode = TraceMode.Value; diff --git a/src/Filee.App/Views/ElevationNoticeWindow.axaml b/src/Filee.App/Views/ElevationNoticeWindow.axaml new file mode 100644 index 00000000..6eaa795a --- /dev/null +++ b/src/Filee.App/Views/ElevationNoticeWindow.axaml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + private static int _dragEnterLogged; + public HomePage() { InitializeComponent(); DragDrop.AddDragEnterHandler(DropZone, (_, e) => { DropZone.Classes.Add("over"); - e.DragEffects = e.DataTransfer.Contains(DataFormat.File) ? DragDropEffects.Copy : DragDropEffects.None; + var files = e.DataTransfer.Contains(DataFormat.File); + e.DragEffects = files ? DragDropEffects.Copy : DragDropEffects.None; + if (Interlocked.Exchange(ref _dragEnterLogged, 1) == 0) + AppHost.Get>().LogInformation("First drag over the drop zone (files: {Files})", files); }); DragDrop.AddDragOverHandler(DropZone, (_, e) => e.DragEffects = e.DataTransfer.Contains(DataFormat.File) ? DragDropEffects.Copy : DragDropEffects.None); diff --git a/src/Filee.App/Views/PresetEditorView.axaml b/src/Filee.App/Views/PresetEditorView.axaml index 01686024..cebecfbd 100644 --- a/src/Filee.App/Views/PresetEditorView.axaml +++ b/src/Filee.App/Views/PresetEditorView.axaml @@ -115,6 +115,17 @@ + + + + + + + + + + @@ -163,6 +174,10 @@ + + + + @@ -216,6 +231,21 @@ + + + + + + + + + + + + + + + diff --git a/src/Filee.App/Views/RadialWindow.axaml.cs b/src/Filee.App/Views/RadialWindow.axaml.cs index cf7b0407..0cfaab79 100644 --- a/src/Filee.App/Views/RadialWindow.axaml.cs +++ b/src/Filee.App/Views/RadialWindow.axaml.cs @@ -24,6 +24,9 @@ public RadialWindow() public DonutMenu DonutControl => Donut; + /// A drag entered the window, with or without files (for the log: did the drag reach the donut at all). + public event Action? DragEntered; + /// Files entered the window during a drag. public event Action>? FilesEntered; @@ -44,6 +47,7 @@ public RadialWindow() private void OnDragEnter(object? sender, DragEventArgs e) { + DragEntered?.Invoke(); var files = LocalFiles(e); if (files.Count == 0) { diff --git a/src/Filee.App/Views/ToastWindow.axaml b/src/Filee.App/Views/ToastWindow.axaml index 390c9f91..9d8498c9 100644 --- a/src/Filee.App/Views/ToastWindow.axaml +++ b/src/Filee.App/Views/ToastWindow.axaml @@ -83,6 +83,11 @@ + + +