From 355e533f03f50ef1cf05afbc771a0eeda536fd1d Mon Sep 17 00:00:00 2001 From: ranokay Date: Wed, 12 Aug 2026 16:22:52 +0300 Subject: [PATCH 1/7] feat: replace HTML rendering with Rust --- .gitignore | 10 +- Glance.xcodeproj/project.pbxproj | 139 +- .../Utils/SupportedPreviewRegistry.swift | 2 +- HTMLConverter/.golangci.yml | 43 - HTMLConverter/go.mod | 28 - HTMLConverter/go.sum | 108 -- HTMLConverter/htmlconverter.go | 129 -- HTMLConverter/htmlconverter_test.go | 124 -- PreviewCore/Cargo.lock | 1295 +++++++++++++++++ PreviewCore/Cargo.toml | 31 + PreviewCore/THIRD_PARTY_LICENSES.md | 38 + PreviewCore/build-xcode.sh | 54 + PreviewCore/include/glance_preview_core.h | 32 + PreviewCore/src/error.rs | 63 + PreviewCore/src/ffi.rs | 353 +++++ PreviewCore/src/highlight.rs | 243 ++++ PreviewCore/src/lib.rs | 67 + PreviewCore/src/markdown.rs | 65 + PreviewCore/src/model.rs | 33 + PreviewCore/src/notebook.rs | 343 +++++ PreviewCore/src/sevenzip.rs | 270 ++++ PreviewCore/src/tar.rs | 607 ++++++++ PreviewCore/src/tsv.rs | 114 ++ PreviewCore/src/zip.rs | 444 ++++++ QLPlugin/Resources/shared/shared-chroma.css | 175 +-- QLPlugin/Utils/HTMLRenderer.swift | 82 +- QLPlugin/Utils/PreviewCoreBridge.swift | 145 ++ QLPlugin/Utils/PreviewExecutor.swift | 20 + QLPlugin/Views/Previews/CodePreview.swift | 50 +- QLPlugin/Views/Previews/JupyterPreview.swift | 51 +- QLPlugin/Views/Previews/MarkdownPreview.swift | 47 +- mise.toml | 81 +- module.modulemap | 6 +- 33 files changed, 4496 insertions(+), 796 deletions(-) delete mode 100644 HTMLConverter/.golangci.yml delete mode 100644 HTMLConverter/go.mod delete mode 100644 HTMLConverter/go.sum delete mode 100644 HTMLConverter/htmlconverter.go delete mode 100644 HTMLConverter/htmlconverter_test.go create mode 100644 PreviewCore/Cargo.lock create mode 100644 PreviewCore/Cargo.toml create mode 100644 PreviewCore/THIRD_PARTY_LICENSES.md create mode 100755 PreviewCore/build-xcode.sh create mode 100644 PreviewCore/include/glance_preview_core.h create mode 100644 PreviewCore/src/error.rs create mode 100644 PreviewCore/src/ffi.rs create mode 100644 PreviewCore/src/highlight.rs create mode 100644 PreviewCore/src/lib.rs create mode 100644 PreviewCore/src/markdown.rs create mode 100644 PreviewCore/src/model.rs create mode 100644 PreviewCore/src/notebook.rs create mode 100644 PreviewCore/src/sevenzip.rs create mode 100644 PreviewCore/src/tar.rs create mode 100644 PreviewCore/src/tsv.rs create mode 100644 PreviewCore/src/zip.rs create mode 100644 QLPlugin/Utils/PreviewCoreBridge.swift create mode 100644 QLPlugin/Utils/PreviewExecutor.swift diff --git a/.gitignore b/.gitignore index 34aa670..4115e8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,9 @@ # Xcode project.xcworkspace/ -!Glance.xcodeproj/project.xcworkspace/ -!Glance.xcodeproj/project.xcworkspace/xcshareddata/ -!Glance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/ -!Glance.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved xcuserdata/ build/ .DS_Store -# `go build` output -HTMLConverter/htmlconverter*.a -HTMLConverter/htmlconverter*.h +# Rust build output +PreviewCore/build/ +PreviewCore/target/ diff --git a/Glance.xcodeproj/project.pbxproj b/Glance.xcodeproj/project.pbxproj index c287dcb..b25c7ba 100644 --- a/Glance.xcodeproj/project.pbxproj +++ b/Glance.xcodeproj/project.pbxproj @@ -9,9 +9,11 @@ /* Begin PBXBuildFile section */ 102D56C526206A36000B5C0E /* MainVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102D56C426206A36000B5C0E /* MainVC.swift */; }; 103654F42F726241006F2538 /* SevenZipPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 103654F32F72623D006F2538 /* SevenZipPreview.swift */; }; - 10779BC42621502C008A903C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 10779BC32621502C008A903C /* ZIPFoundation */; }; - 10779BEB26217F25008A903C /* SWCompression in Frameworks */ = {isa = PBXBuildFile; productRef = 10779BEA26217F25008A903C /* SWCompression */; }; - 10779C50262198EA008A903C /* htmlconverter.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E42B61D245857D9008D7116 /* htmlconverter.a */; }; + 10779C50262198EA008A903C /* libglance_preview_core.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E42B61D245857D9008D7116 /* libglance_preview_core.a */; }; + A5A000000000000000000003 /* PreviewCoreBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A000000000000000000001 /* PreviewCoreBridge.swift */; }; + A5A000000000000000000004 /* PreviewCoreBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A000000000000000000001 /* PreviewCoreBridge.swift */; }; + A5A000000000000000000005 /* PreviewExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A000000000000000000002 /* PreviewExecutor.swift */; }; + A5A000000000000000000006 /* PreviewExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A000000000000000000002 /* PreviewExecutor.swift */; }; A19F4D4226218B2B008A903C /* PreviewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19F4D4526218B2B008A903C /* PreviewSupport.swift */; }; A19F4D4326218B2B008A903C /* PreviewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19F4D4526218B2B008A903C /* PreviewSupport.swift */; }; A19F4D4426218B2B008A903C /* PreviewSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19F4D4626218B2B008A903C /* PreviewSupportTests.swift */; }; @@ -20,10 +22,7 @@ C0DE00000000000000000203 /* PreviewFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000103 /* PreviewFactoryTests.swift */; }; C0DE00000000000000000204 /* PreviewSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000104 /* PreviewSmokeTests.swift */; }; C0DE00000000000000000205 /* PlistCoverageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000105 /* PlistCoverageTests.swift */; }; - C0DE00000000000000000206 /* SwiftCSV in Frameworks */ = {isa = PBXBuildFile; productRef = 7E616BAF244997420043F7AB /* SwiftCSV */; }; - C0DE00000000000000000207 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 10779BC32621502C008A903C /* ZIPFoundation */; }; - C0DE00000000000000000208 /* SWCompression in Frameworks */ = {isa = PBXBuildFile; productRef = 10779BEA26217F25008A903C /* SWCompression */; }; - C0DE00000000000000000209 /* htmlconverter.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E42B61D245857D9008D7116 /* htmlconverter.a */; }; + C0DE00000000000000000209 /* libglance_preview_core.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E42B61D245857D9008D7116 /* libglance_preview_core.a */; }; C0DE00000000000000000301 /* PreviewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19F4D4526218B2B008A903C /* PreviewSupport.swift */; }; C0DE00000000000000000302 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE10F82244F6774007214D8 /* Log.swift */; }; C0DE00000000000000000303 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E21C89024390E3100818EB2 /* Date.swift */; }; @@ -87,7 +86,6 @@ 7E458AF0243944810091BD0F /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E458AEF243944810091BD0F /* URL.swift */; }; 7E458AF52439481F0091BD0F /* Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E458AF42439481F0091BD0F /* Menu.swift */; }; 7E45F899244DA45500BFB869 /* FileTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E45F898244DA45500BFB869 /* FileTree.swift */; }; - 7E616BB0244997420043F7AB /* SwiftCSV in Frameworks */ = {isa = PBXBuildFile; productRef = 7E616BAF244997420043F7AB /* SwiftCSV */; }; 7E616BB2244998650043F7AB /* MainVC.xib in Resources */ = {isa = PBXBuildFile; fileRef = 7E616BB1244998650043F7AB /* MainVC.xib */; }; 7E6EF1FD240CC802009E4199 /* Quartz.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E6EF1FC240CC802009E4199 /* Quartz.framework */; }; 7E6EF208240CC802009E4199 /* QLPlugin.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7E6EF1FA240CC802009E4199 /* QLPlugin.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -191,6 +189,8 @@ 10779C1126218B2B008A903C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A19F4D4526218B2B008A903C /* PreviewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewSupport.swift; sourceTree = ""; }; A19F4D4626218B2B008A903C /* PreviewSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewSupportTests.swift; sourceTree = ""; }; + A5A000000000000000000001 /* PreviewCoreBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewCoreBridge.swift; sourceTree = ""; }; + A5A000000000000000000002 /* PreviewExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewExecutor.swift; sourceTree = ""; }; C0DE00000000000000000101 /* FileTreeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeTests.swift; sourceTree = ""; }; C0DE00000000000000000102 /* FileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTests.swift; sourceTree = ""; }; C0DE00000000000000000103 /* PreviewFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewFactoryTests.swift; sourceTree = ""; }; @@ -223,8 +223,8 @@ 7E21C89024390E3100818EB2 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; 7E3DF744242CBE7F00DE7CD6 /* Main.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; 7E413F7F2418DD6200CFBB1D /* TSVPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSVPreview.swift; sourceTree = ""; }; - 7E42B61C245857D9008D7116 /* htmlconverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = htmlconverter.h; path = HTMLConverter/htmlconverter.h; sourceTree = ""; }; - 7E42B61D245857D9008D7116 /* htmlconverter.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = htmlconverter.a; path = HTMLConverter/htmlconverter.a; sourceTree = ""; }; + 7E42B61C245857D9008D7116 /* glance_preview_core.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = glance_preview_core.h; path = PreviewCore/include/glance_preview_core.h; sourceTree = ""; }; + 7E42B61D245857D9008D7116 /* libglance_preview_core.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libglance_preview_core.a; path = PreviewCore/build/libglance_preview_core.a; sourceTree = ""; }; 7E458AEF243944810091BD0F /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; 7E458AF42439481F0091BD0F /* Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Menu.swift; sourceTree = ""; }; 7E45F898244DA45500BFB869 /* FileTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree.swift; sourceTree = ""; }; @@ -288,10 +288,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - C0DE00000000000000000208 /* SWCompression in Frameworks */, - C0DE00000000000000000209 /* htmlconverter.a in Frameworks */, - C0DE00000000000000000206 /* SwiftCSV in Frameworks */, - C0DE00000000000000000207 /* ZIPFoundation in Frameworks */, + C0DE00000000000000000209 /* libglance_preview_core.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -299,11 +296,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 7E616BB0244997420043F7AB /* SwiftCSV in Frameworks */, - 10779C50262198EA008A903C /* htmlconverter.a in Frameworks */, + 10779C50262198EA008A903C /* libglance_preview_core.a in Frameworks */, 7E6EF1FD240CC802009E4199 /* Quartz.framework in Frameworks */, - 10779BEB26217F25008A903C /* SWCompression in Frameworks */, - 10779BC42621502C008A903C /* ZIPFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -343,6 +337,8 @@ 7EAC01EB240D220B009505D0 /* File.swift */, 7E45F898244DA45500BFB869 /* FileTree.swift */, 7E1B16912455D93E00E2B84D /* HTMLRenderer.swift */, + A5A000000000000000000001 /* PreviewCoreBridge.swift */, + A5A000000000000000000002 /* PreviewExecutor.swift */, 7E9F0D7924168870007F1008 /* WebAsset */, ); path = Utils; @@ -449,8 +445,8 @@ 7E6EF1FB240CC802009E4199 /* Frameworks */ = { isa = PBXGroup; children = ( - 7E42B61D245857D9008D7116 /* htmlconverter.a */, - 7E42B61C245857D9008D7116 /* htmlconverter.h */, + 7E42B61D245857D9008D7116 /* libglance_preview_core.a */, + 7E42B61C245857D9008D7116 /* glance_preview_core.h */, 7E6EF1FC240CC802009E4199 /* Quartz.framework */, ); name = Frameworks; @@ -606,9 +602,6 @@ ); name = GlanceTests; packageProductDependencies = ( - 7E616BAF244997420043F7AB /* SwiftCSV */, - 10779BC32621502C008A903C /* ZIPFoundation */, - 10779BEA26217F25008A903C /* SWCompression */, ); productName = GlanceTests; productReference = 10779C0D26218B2B008A903C /* GlanceTests.xctest */; @@ -630,9 +623,6 @@ ); name = QLPlugin; packageProductDependencies = ( - 7E616BAF244997420043F7AB /* SwiftCSV */, - 10779BC32621502C008A903C /* ZIPFoundation */, - 10779BEA26217F25008A903C /* SWCompression */, ); productName = QLPlugin; productReference = 7E6EF1FA240CC802009E4199 /* QLPlugin.appex */; @@ -692,9 +682,6 @@ ); mainGroup = 7ECC8CE5240CB4CC000D6970; packageReferences = ( - 7E616BAE244997420043F7AB /* XCRemoteSwiftPackageReference "SwiftCSV" */, - 10779BC22621502C008A903C /* XCRemoteSwiftPackageReference "ZIPFoundation" */, - 10779BE926217F25008A903C /* XCRemoteSwiftPackageReference "SWCompression" */, ); productRefGroup = 7ECC8CEF240CB4CC000D6970 /* Products */; projectDirPath = ""; @@ -775,19 +762,29 @@ ); inputPaths = ( "$(PROJECT_DIR)/mise.toml", - "$(PROJECT_DIR)/HTMLConverter/go.mod", - "$(PROJECT_DIR)/HTMLConverter/go.sum", - "$(PROJECT_DIR)/HTMLConverter/htmlconverter.go", + "$(PROJECT_DIR)/PreviewCore/Cargo.toml", + "$(PROJECT_DIR)/PreviewCore/Cargo.lock", + "$(PROJECT_DIR)/PreviewCore/build-xcode.sh", + "$(PROJECT_DIR)/PreviewCore/src/error.rs", + "$(PROJECT_DIR)/PreviewCore/src/ffi.rs", + "$(PROJECT_DIR)/PreviewCore/src/highlight.rs", + "$(PROJECT_DIR)/PreviewCore/src/lib.rs", + "$(PROJECT_DIR)/PreviewCore/src/markdown.rs", + "$(PROJECT_DIR)/PreviewCore/src/model.rs", + "$(PROJECT_DIR)/PreviewCore/src/notebook.rs", + "$(PROJECT_DIR)/PreviewCore/src/sevenzip.rs", + "$(PROJECT_DIR)/PreviewCore/src/tar.rs", + "$(PROJECT_DIR)/PreviewCore/src/tsv.rs", + "$(PROJECT_DIR)/PreviewCore/src/zip.rs", ); outputFileListPaths = ( ); outputPaths = ( - "$(PROJECT_DIR)/HTMLConverter/htmlconverter.a", - "$(PROJECT_DIR)/HTMLConverter/htmlconverter.h", + "$(PROJECT_DIR)/PreviewCore/build/libglance_preview_core.a", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "cd \"$PROJECT_DIR/HTMLConverter\"\nset -e\nif [ \"$(uname -m)\" != \"arm64\" ]; then\n\techo \"Apple Silicon is required to build HTMLConverter\" >&2\n\texit 1\nfi\n[ ! -d /opt/homebrew ] || eval \"$(/opt/homebrew/bin/brew shellenv)\"\nMISE_BIN=\"${MISE_BIN:-}\"\nif [ -z \"$MISE_BIN\" ]; then\n\tif command -v mise >/dev/null 2>&1; then\n\t\tMISE_BIN=\"$(command -v mise)\"\n\telif [ -x \"$HOME/.local/bin/mise\" ]; then\n\t\tMISE_BIN=\"$HOME/.local/bin/mise\"\n\telif [ -x \"/opt/homebrew/bin/mise\" ]; then\n\t\tMISE_BIN=\"/opt/homebrew/bin/mise\"\n\telif [ -x \"/usr/local/bin/mise\" ]; then\n\t\tMISE_BIN=\"/usr/local/bin/mise\"\n\tfi\nfi\nif [ -z \"$MISE_BIN\" ]; then\n\techo \"mise is required to build HTMLConverter with the pinned Go toolchain\" >&2\n\texit 1\nfi\nexport MISE_TRUSTED_CONFIG_PATHS=\"$PROJECT_DIR${MISE_TRUSTED_CONFIG_PATHS:+:$MISE_TRUSTED_CONFIG_PATHS}\"\nrm -f htmlconverter*.a htmlconverter*.h\nexport MACOSX_DEPLOYMENT_TARGET=\"${MACOSX_DEPLOYMENT_TARGET:-26.0}\"\nexport CGO_CFLAGS=\"-mmacosx-version-min=${MACOSX_DEPLOYMENT_TARGET} ${CGO_CFLAGS}\"\nexport CGO_LDFLAGS=\"-mmacosx-version-min=${MACOSX_DEPLOYMENT_TARGET} ${CGO_LDFLAGS}\"\n# Force rebuilds so cached Go objects use the selected macOS deployment target.\nGOOS=darwin GOARCH=arm64 CGO_ENABLED=1 \"$MISE_BIN\" exec -- go build -a --buildmode=c-archive -ldflags \"-s -w\" -o ./htmlconverter.a\n"; + shellScript = "\"$PROJECT_DIR/PreviewCore/build-xcode.sh\"\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -799,6 +796,8 @@ D1A000000000000000000003 /* DirectoryPreview.swift in Sources */, D1A000000000000000000005 /* DirectoryPreviewTests.swift in Sources */, D2A000000000000000000003 /* DirectoryThumbnailLoader.swift in Sources */, + A5A000000000000000000004 /* PreviewCoreBridge.swift in Sources */, + A5A000000000000000000006 /* PreviewExecutor.swift in Sources */, D2A000000000000000000005 /* DirectoryThumbnailTests.swift in Sources */, D4A000000000000000000003 /* NestedPreviewProvider.swift in Sources */, D4A000000000000000000005 /* NestedPreviewTests.swift in Sources */, @@ -849,6 +848,8 @@ files = ( D1A000000000000000000002 /* DirectoryPreview.swift in Sources */, D2A000000000000000000002 /* DirectoryThumbnailLoader.swift in Sources */, + A5A000000000000000000003 /* PreviewCoreBridge.swift in Sources */, + A5A000000000000000000005 /* PreviewExecutor.swift in Sources */, D4A000000000000000000002 /* NestedPreviewProvider.swift in Sources */, D5A000000000000000000002 /* OpenWithService.swift in Sources */, D6A000000000000000000002 /* OpenWithBridge.swift in Sources */, @@ -936,7 +937,7 @@ "@loader_path/../Frameworks", ); LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/HTMLConverter", + "$(PROJECT_DIR)/PreviewCore/build", ); PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance.GlanceTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -964,7 +965,7 @@ "@loader_path/../Frameworks", ); LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/HTMLConverter", + "$(PROJECT_DIR)/PreviewCore/build", ); PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance.GlanceTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -982,7 +983,7 @@ CODE_SIGN_ENTITLEMENTS = QLPlugin/QLPlugin.entitlements; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -994,9 +995,9 @@ "@executable_path/../../../../Frameworks", ); LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/HTMLConverter", + "$(PROJECT_DIR)/PreviewCore/build", ); - MARKETING_VERSION = 1.5.9; + MARKETING_VERSION = 1.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance.QLPlugin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1013,7 +1014,7 @@ CODE_SIGN_ENTITLEMENTS = QLPlugin/QLPlugin.entitlements; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -1025,9 +1026,9 @@ "@executable_path/../../../../Frameworks", ); LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/HTMLConverter", + "$(PROJECT_DIR)/PreviewCore/build", ); - MARKETING_VERSION = 1.5.9; + MARKETING_VERSION = 1.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance.QLPlugin; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1168,7 +1169,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -1177,7 +1178,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.5.9; + MARKETING_VERSION = 1.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1194,7 +1195,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -1203,7 +1204,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.5.9; + MARKETING_VERSION = 1.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1252,50 +1253,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 10779BC22621502C008A903C /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/weichsel/ZIPFoundation"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.9.20; - }; - }; - 10779BE926217F25008A903C /* XCRemoteSwiftPackageReference "SWCompression" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/tsolomko/SWCompression"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 4.9.1; - }; - }; - 7E616BAE244997420043F7AB /* XCRemoteSwiftPackageReference "SwiftCSV" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/swiftcsv/SwiftCSV"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.10.0; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 10779BC32621502C008A903C /* ZIPFoundation */ = { - isa = XCSwiftPackageProductDependency; - package = 10779BC22621502C008A903C /* XCRemoteSwiftPackageReference "ZIPFoundation" */; - productName = ZIPFoundation; - }; - 10779BEA26217F25008A903C /* SWCompression */ = { - isa = XCSwiftPackageProductDependency; - package = 10779BE926217F25008A903C /* XCRemoteSwiftPackageReference "SWCompression" */; - productName = SWCompression; - }; - 7E616BAF244997420043F7AB /* SwiftCSV */ = { - isa = XCSwiftPackageProductDependency; - package = 7E616BAE244997420043F7AB /* XCRemoteSwiftPackageReference "SwiftCSV" */; - productName = SwiftCSV; - }; -/* End XCSwiftPackageProductDependency section */ }; rootObject = 7ECC8CE6240CB4CC000D6970 /* Project object */; } diff --git a/Glance/Shared/Utils/SupportedPreviewRegistry.swift b/Glance/Shared/Utils/SupportedPreviewRegistry.swift index 3452cf0..7748e53 100644 --- a/Glance/Shared/Utils/SupportedPreviewRegistry.swift +++ b/Glance/Shared/Utils/SupportedPreviewRegistry.swift @@ -45,7 +45,7 @@ struct SupportedPreviewType: Equatable { let searchTokens: [String] let previewFileType: PreviewFileType - /// The Chroma lexer name to use for syntax highlighting. Only meaningful for `.code` entries. + /// The syntax name or alias to use for highlighting. Only meaningful for `.code` entries. /// When `nil`, `getCodeLexer` falls back to the file extension or `"autodetect"`. let codeLexer: String? diff --git a/HTMLConverter/.golangci.yml b/HTMLConverter/.golangci.yml deleted file mode 100644 index a9e6f69..0000000 --- a/HTMLConverter/.golangci.yml +++ /dev/null @@ -1,43 +0,0 @@ -version: "2" -linters: - default: all - disable: - - depguard - - dupword - - exhaustruct - - funlen - - gochecknoglobals - - gocognit - - godot - - nlreturn - - nolintlint - - nonamedreturns - - paralleltest - - revive - - testifylint - - testpackage - - varnamelen - - wsl - settings: - lll: - line-length: 100 - tab-width: 2 - exclusions: - generated: lax - rules: - - path: (.+)\.go$ - text: Line contains TODO/BUG/FIXME - paths: - - third_party$ - - builtin$ - - examples$ -issues: - max-issues-per-linter: 0 - max-same-issues: 0 -formatters: - exclusions: - generated: lax - paths: - - third_party$ - - builtin$ - - examples$ diff --git a/HTMLConverter/go.mod b/HTMLConverter/go.mod deleted file mode 100644 index 0c10780..0000000 --- a/HTMLConverter/go.mod +++ /dev/null @@ -1,28 +0,0 @@ -module github.com/chamburr/glance - -go 1.26 - -require ( - github.com/alecthomas/chroma/v2 v2.27.0 - github.com/samuelmeuli/nbtohtml v0.5.0 - github.com/stretchr/testify v1.11.1 - github.com/tdewolff/minify/v2 v2.24.14 - github.com/yuin/goldmark v1.8.5 - github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc -) - -require ( - github.com/alecthomas/chroma v0.10.0 // indirect - github.com/aymerick/douceur v0.2.0 // indirect - github.com/buildkite/terminal-to-html v3.2.0+incompatible // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dlclark/regexp2 v1.12.0 // indirect - github.com/dlclark/regexp2/v2 v2.2.1 // indirect - github.com/gorilla/css v1.0.1 // indirect - github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/tdewolff/parse/v2 v2.8.14 // indirect - github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 // indirect - golang.org/x/net v0.56.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/HTMLConverter/go.sum b/HTMLConverter/go.sum deleted file mode 100644 index 587c02f..0000000 --- a/HTMLConverter/go.sum +++ /dev/null @@ -1,108 +0,0 @@ -github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= -github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0= -github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= -github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma v0.7.2-0.20200305040604-4f3623dce67a/go.mod h1:fv5SzZPFJbwp2NXJWpFIX7DZS4HgV1K4ew4Pc2OZD9s= -github.com/alecthomas/chroma v0.7.2/go.mod h1:fv5SzZPFJbwp2NXJWpFIX7DZS4HgV1K4ew4Pc2OZD9s= -github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= -github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= -github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= -github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= -github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= -github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= -github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= -github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE= -github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA= -github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= -github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= -github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= -github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= -github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/buildkite/terminal-to-html v3.2.0+incompatible h1:WdXzl7ZmYzCAz4pElZosPaUlRTW+qwVx/SkQSCa1jXs= -github.com/buildkite/terminal-to-html v3.2.0+incompatible/go.mod h1:BFFdFecOxCgjdcarqI+8izs6v85CU/1RA/4Bqh4GR7E= -github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= -github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= -github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= -github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI= -github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= -github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= -github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= -github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= -github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/samuelmeuli/nbtohtml v0.5.0 h1:Xl4DCwcMapYW8koN4EACicIzeVe/9uxapahLuyVKn4g= -github.com/samuelmeuli/nbtohtml v0.5.0/go.mod h1:VTd3c3K+UDBYJPa4swssK8d4T7iQcZ1F+i+yQ5yI6Bg= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tdewolff/minify/v2 v2.24.14 h1:y05Dj1YHaCJ0g6nZa5wC6vyI+x5iziYBY20gsC6xvbo= -github.com/tdewolff/minify/v2 v2.24.14/go.mod h1:ducvuHPTtUvKKK6uCI4p0fR2Mxvoy+XpFy9cC8QDv1c= -github.com/tdewolff/parse/v2 v2.8.14 h1:GuhuMMPKAqj5BhP9hZUz3r3JqX/lOvQ1E6wxW6p/FhY= -github.com/tdewolff/parse/v2 v2.8.14/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= -github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= -github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= -github.com/yuin/goldmark v1.1.22/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.4.5/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= -github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= -github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/yuin/goldmark-highlighting v0.0.0-20200307114337-60d527fdb691/go.mod h1:YLF3kDffRfUH/bTxOxHhV6lxwIB3Vfj91rEwNMS9MXo= -github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 h1:yHfZyN55+5dp1wG7wDKv8HQ044moxkyGq12KFFMFDxg= -github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594/go.mod h1:U9ihbh+1ZN7fR5Se3daSPoz1CGF9IYtSvWwVQtnzGHU= -github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= -github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/HTMLConverter/htmlconverter.go b/HTMLConverter/htmlconverter.go deleted file mode 100644 index 88ad40a..0000000 --- a/HTMLConverter/htmlconverter.go +++ /dev/null @@ -1,129 +0,0 @@ -package main - -import ( - "C" - "bytes" - "fmt" - "regexp" - - "github.com/alecthomas/chroma/v2" - htmlFormatter "github.com/alecthomas/chroma/v2/formatters/html" - "github.com/alecthomas/chroma/v2/lexers" - "github.com/alecthomas/chroma/v2/styles" - "github.com/samuelmeuli/nbtohtml" - "github.com/yuin/goldmark" - highlighting "github.com/yuin/goldmark-highlighting/v2" - "github.com/yuin/goldmark/extension" -) - -// Regex for YAML front matter in a Markdown document. -var markdownFrontMatterRegex = regexp.MustCompile(`^---\r?\n([\s\S]*?)\r?\n---\r?\n`) - -// Enable syntax highlighting in Markdown -var markdownParser = goldmark.New( - goldmark.WithExtensions( - extension.GFM, - highlighting.NewHighlighting( - highlighting.WithFormatOptions( - htmlFormatter.WithClasses(true), - ), - ), - ), -) - -// Functions for conversion between C and Go strings. Required here because cgo cannot be used in -// tests. - -func convertToCString(goString string) *C.char { - return C.CString(goString) -} - -func convertToGoString(cString *C.char) string { - return C.GoString(cString) -} - -// Convention: Because all functions return C strings, errors are implemented as return values which -// start with "error: ". - -// convertCodeToHTML converts the provided source code string to HTML. Classes for syntax -// highlighting are generated using Chroma. -// -//export convertCodeToHTML -func convertCodeToHTML(source *C.char, lexer *C.char) *C.char { - sourceString := convertToGoString(source) - lexerString := convertToGoString(lexer) - htmlBuffer := new(bytes.Buffer) - - // Set up lexer for programming language - var l chroma.Lexer - if lexerString != "" { - l = lexers.Get(lexerString) - } - if l == nil { - l = lexers.Analyse(sourceString) - } - if l == nil { - l = lexers.Fallback - } - l = chroma.Coalesce(l) - - // Use classes instead of inline styles - formatter := htmlFormatter.New(htmlFormatter.WithClasses(true)) - - iterator, err := l.Tokenise(nil, sourceString) - if err != nil { - errMessage := fmt.Sprintf("error: Could not render source code (tokenization error): %v", err) - return convertToCString(errMessage) - } - - err = formatter.Format(htmlBuffer, styles.GitHub, iterator) - if err != nil { - errMessage := fmt.Sprintf("error: Could not render source code (formatting error): %v", err) - return convertToCString(errMessage) - } - - // Chroma escapes tags, so HTML should be safe from code injection - htmlString := htmlBuffer.String() - return convertToCString(htmlString) -} - -// convertMarkdownToHTML converts the provided Markdown string to HTML using goldmark. Classes for -// syntax highlighting inside code blocks are generated using Chroma. -// -//export convertMarkdownToHTML -func convertMarkdownToHTML(source *C.char) *C.char { - sourceString := convertToGoString(source) - - // Render YAML front matter as code block - sourceString = markdownFrontMatterRegex.ReplaceAllString(sourceString, "```yaml\n$1\n```\n") - - // Convert Markdown to HTML - var htmlBuffer bytes.Buffer - err := markdownParser.Convert([]byte(sourceString), &htmlBuffer) - if err != nil { - errMessage := fmt.Sprintf("error: Could not convert Markdown to HTML: %v", err) - return convertToCString(errMessage) - } - // goldmark does not render raw HTML or potentially-dangerous URLs, so HTML should be safe from - // code injection - return convertToCString(htmlBuffer.String()) -} - -// convertNotebookToHTML converts the provided Jupyter Notebook JSON to HTML using `nbtohtml`. -// -//export convertNotebookToHTML -func convertNotebookToHTML(source *C.char) *C.char { - sourceString := convertToGoString(source) - - html := new(bytes.Buffer) - err := nbtohtml.ConvertString(html, sourceString) - if err != nil { - errMessage := fmt.Sprintf("error: Could not convert Notebook to HTML: %v", err) - return convertToCString(errMessage) - } - htmlString := html.String() - return convertToCString(htmlString) -} - -// Main function is required for `c-archive` builds. -func main() {} diff --git a/HTMLConverter/htmlconverter_test.go b/HTMLConverter/htmlconverter_test.go deleted file mode 100644 index 51347e1..0000000 --- a/HTMLConverter/htmlconverter_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package main - -import ( - "fmt" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/tdewolff/minify/v2" - "github.com/tdewolff/minify/v2/html" -) - -var minifier *minify.M - -func minifyHTML(htmlString string) string { - // Initialize minifier if necessary - if minifier == nil { - minifier = minify.New() - minifier.Add("text/html", &html.Minifier{KeepEndTags: true, KeepQuotes: true}) - } - - minified, err := minifier.String("text/html", htmlString) - if err != nil { - panic(fmt.Sprintf("Could not minify HTML: %v", err)) - } - - return minified -} - -func TestConvertCodeToHTML(t *testing.T) { - source := `const print = (text) => console.log(text); -print("Hello world");` - actual := convertToGoString(convertCodeToHTML(convertToCString(source), convertToCString("js"))) - actualTrimmed := strings.TrimSpace(actual) - assert.True(t, strings.HasPrefix(actualTrimmed, `
`))
-	assert.True(t, strings.HasSuffix(actualTrimmed, `
`)) -} - -func TestConvertCodeToHTMLEmptyInput(t *testing.T) { - actual := convertToGoString(convertCodeToHTML(convertToCString(""), convertToCString("swift"))) - actualTrimmed := strings.TrimSpace(actual) - - assert.True(t, strings.HasPrefix(actualTrimmed, `
`))
-	assert.True(t, strings.HasSuffix(actualTrimmed, `
`)) -} - -func TestConvertCodeToHTMLUnknownLexerUsesFallback(t *testing.T) { - actual := convertToGoString(convertCodeToHTML( - convertToCString("plain text"), - convertToCString("not-a-real-lexer"), - )) - actualTrimmed := strings.TrimSpace(actual) - - assert.True(t, strings.HasPrefix(actualTrimmed, `
`))
-	assert.True(t, strings.HasSuffix(actualTrimmed, `
`)) -} - -func TestConvertMarkdownToHTML(t *testing.T) { - source := `# Heading - -Text` - expected := "

Heading

Text

" - actual := convertToGoString(convertMarkdownToHTML(convertToCString(source))) - assert.Equal(t, expected, minifyHTML(actual)) -} - -func TestConvertMarkdownToHTMLEmptyInput(t *testing.T) { - actual := convertToGoString(convertMarkdownToHTML(convertToCString(""))) - - assert.Equal(t, "", actual) -} - -func TestConvertMarkdownToHTMLWithFrontMatter(t *testing.T) { - source := `--- -key: Value -key2: Another value ---- - -# Heading - -Text` - actual := convertToGoString(convertMarkdownToHTML(convertToCString(source))) - assert.True(t, strings.Contains(actual, `
`))
-	assert.True(t, strings.Contains(minifyHTML(actual), `

Heading

Text

`)) -} - -func TestConvertMarkdownToHTMLWithCRLFFrontMatter(t *testing.T) { - source := "---\r\nkey: Value\r\n---\r\n\r\n# Heading\r\n\r\nText" - actual := convertToGoString(convertMarkdownToHTML(convertToCString(source))) - - assert.True(t, strings.Contains(actual, `
`))
-	assert.True(t, strings.Contains(minifyHTML(actual), `

Heading

Text

`)) -} - -func TestConvertMarkdownToHTMLWithSyntaxHighlighting(t *testing.T) { - source := "# Heading\n\nText\n\n```js\nconst print = (text) => console.log(text);\nprint(\"Hello world\");\n```" // nolint:lll - actual := convertToGoString(convertMarkdownToHTML(convertToCString(source))) - assert.True(t, strings.Contains(actual, `
`))
-}
-
-func TestConvertMarkdownToHTMLSanitizesRawHTMLAndUnsafeLinks(t *testing.T) {
-	source := `
-
-[bad](javascript:alert("bad"))`
-	actual := convertToGoString(convertMarkdownToHTML(convertToCString(source)))
-	actualLower := strings.ToLower(actual)
-
-	assert.NotContains(t, actualLower, "`))
-	assert.True(t, strings.HasSuffix(actualTrimmed, ``))
-}
-
-func TestConvertNotebookToHTMLInvalid(t *testing.T) {
-	source := "This is not a valid JSON file."
-	actual := convertToGoString(convertNotebookToHTML(convertToCString(source)))
-	assert.True(t, strings.HasPrefix(actual, "error: "))
-}
diff --git a/PreviewCore/Cargo.lock b/PreviewCore/Cargo.lock
new file mode 100644
index 0000000..ed7fc26
--- /dev/null
+++ b/PreviewCore/Cargo.lock
@@ -0,0 +1,1295 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "ammonia"
+version = "4.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
+dependencies = [
+ "cssparser",
+ "html5ever",
+ "maplit",
+ "url",
+]
+
+[[package]]
+name = "ansi-to-html"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1747e9bde0e06cdfa780fcba909df1fa220f82421ead1fd4a93427763d2cbda6"
+dependencies = [
+ "memchr",
+ "regex",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bzip2"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
+dependencies = [
+ "libbz2-rs-sys",
+]
+
+[[package]]
+name = "caseless"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
+dependencies = [
+ "unicode-normalization",
+]
+
+[[package]]
+name = "cc"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "comrak"
+version = "0.54.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d5910408554659ed848ff469e67ec83b30f179e72cec286cfdae64d1616f466"
+dependencies = [
+ "caseless",
+ "entities",
+ "finl_unicode",
+ "jetscii",
+ "phf",
+ "phf_codegen",
+ "rustc-hash",
+ "smallvec",
+ "typed-arena",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "cssparser"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
+dependencies = [
+ "dtoa-short",
+ "itoa",
+ "smallvec",
+]
+
+[[package]]
+name = "csv"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
+dependencies = [
+ "csv-core",
+ "itoa",
+ "ryu",
+ "serde_core",
+]
+
+[[package]]
+name = "csv-core"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
+[[package]]
+name = "entities"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
+
+[[package]]
+name = "finl_unicode"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "glance-preview-core"
+version = "0.1.0"
+dependencies = [
+ "ammonia",
+ "ansi-to-html",
+ "base64 0.23.1",
+ "comrak",
+ "csv",
+ "flate2",
+ "serde",
+ "serde_json",
+ "sevenz-rust2",
+ "two-face",
+ "zip",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "html5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jetscii"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libbz2-rs-sys"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "linked-hash-map"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "lzma-rust2"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "243cb2271683c2855cad62142cbf38199db439b1b1e1ccdd5a011342894dd7cc"
+
+[[package]]
+name = "maplit"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
+
+[[package]]
+name = "markup5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "onig"
+version = "6.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2"
+dependencies = [
+ "bitflags",
+ "libc",
+ "once_cell",
+ "onig_sys",
+]
+
+[[package]]
+name = "onig_sys"
+version = "69.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plist"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
+dependencies = [
+ "base64 0.22.1",
+ "indexmap",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppmd-rust"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24"
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "sevenz-rust2"
+version = "0.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20acd38118edb830eb5a0bfacaf83c687b9995f48d6983cdac3cb18ce1d571cc"
+dependencies = [
+ "bzip2",
+ "crc32fast",
+ "js-sys",
+ "lzma-rust2",
+ "ppmd-rust",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "string_cache"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "syntect"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
+dependencies = [
+ "bincode",
+ "flate2",
+ "fnv",
+ "once_cell",
+ "onig",
+ "plist",
+ "regex-syntax",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "thiserror",
+ "walkdir",
+ "yaml-rust",
+]
+
+[[package]]
+name = "tendril"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
+dependencies = [
+ "new_debug_unreachable",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "js-sys",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "two-face"
+version = "0.5.2+bat-0.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "915be7adc2ff6f4338acbf71f3eda0a146f46003b992a1669c674308610efdac"
+dependencies = [
+ "serde",
+ "serde_derive",
+ "syntect",
+]
+
+[[package]]
+name = "typed-arena"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
+
+[[package]]
+name = "typed-path"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web_atoms"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yaml-rust"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
+dependencies = [
+ "linked-hash-map",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zip"
+version = "8.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
+dependencies = [
+ "crc32fast",
+ "indexmap",
+ "memchr",
+ "time",
+ "typed-path",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/PreviewCore/Cargo.toml b/PreviewCore/Cargo.toml
new file mode 100644
index 0000000..ce30560
--- /dev/null
+++ b/PreviewCore/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "glance-preview-core"
+version = "0.1.0"
+edition = "2024"
+license = "MIT"
+publish = false
+
+[lib]
+crate-type = ["staticlib", "rlib"]
+
+[dependencies]
+ammonia = "4.1.4"
+ansi-to-html = "0.2.3"
+base64 = "0.23.1"
+comrak = { version = "0.54.0", default-features = false }
+csv = "=1.4.0"
+flate2 = { version = "=1.1.9", default-features = false, features = ["rust_backend"] }
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.151"
+sevenz-rust2 = { version = "=0.21.4", default-features = false, features = [
+	"bzip2",
+	"ppmd",
+] }
+two-face = { version = "=0.5.2", default-features = false, features = [
+	"syntect-default-onig",
+] }
+zip = { version = "=8.6.0", default-features = false, features = ["time"] }
+
+[profile.release]
+codegen-units = 1
+lto = "thin"
diff --git a/PreviewCore/THIRD_PARTY_LICENSES.md b/PreviewCore/THIRD_PARTY_LICENSES.md
new file mode 100644
index 0000000..9dd8cb2
--- /dev/null
+++ b/PreviewCore/THIRD_PARTY_LICENSES.md
@@ -0,0 +1,38 @@
+# PreviewCore third-party licenses
+
+`Cargo.lock` is the authoritative dependency-version record. The table below records the published
+license expressions for PreviewCore's direct dependencies and the transitive native libraries with
+additional license families.
+
+License texts are available from the linked standard licenses and each crate's source package:
+[MIT](https://opensource.org/license/mit), [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0),
+[BSD-2-Clause](https://opensource.org/license/bsd-2-clause),
+[MPL-2.0](https://www.mozilla.org/MPL/2.0/), [Unicode licenses](https://www.unicode.org/license.txt),
+[Unlicense](https://unlicense.org/), [Zlib](https://www.zlib.net/zlib_license.html),
+[BSL-1.0](https://www.boost.org/LICENSE_1_0.txt), [CC0-1.0](https://creativecommons.org/publicdomain/zero/1.0/),
+and [bzip2](https://sourceware.org/bzip2/).
+
+| Crate | Version | License expression |
+| --- | --- | --- |
+| ammonia | 4.1.4 | MIT OR Apache-2.0 |
+| ansi-to-html | 0.2.3 | MIT |
+| base64 | 0.22.1, 0.23.1 | MIT OR Apache-2.0 |
+| bzip2 | 0.6.1 | MIT OR Apache-2.0 |
+| comrak | 0.54.0 | BSD-2-Clause |
+| cssparser, dtoa-short | 0.37.0, 0.3.5 | MPL-2.0 |
+| csv, csv-core | 1.4.0, 0.1.13 | Unlicense OR MIT |
+| finl_unicode | 1.4.0 | (MIT OR Apache-2.0) AND Unicode-DFS-2016 |
+| flate2 | 1.1.9 | MIT OR Apache-2.0 |
+| ICU4X Unicode data crates | locked versions | Unicode-3.0 |
+| libbz2-rs-sys | 0.2.5 | bzip2-1.0.6 |
+| lzma-rust2 | 0.18.1 | Apache-2.0 |
+| miniz_oxide | 0.8.9 | MIT OR Zlib OR Apache-2.0 |
+| onig, onig_sys | 6.5.3, 69.9.3 | MIT |
+| ppmd-rust | 1.4.0 | CC0-1.0 OR MIT-0 |
+| ryu | 1.0.23 | Apache-2.0 OR BSL-1.0 |
+| serde, serde_core, serde_derive | 1.0.229 | MIT OR Apache-2.0 |
+| serde_json | 1.0.151 | MIT OR Apache-2.0 |
+| sevenz-rust2 | 0.21.4 | Apache-2.0 |
+| syntect | 5.3.0 | MIT |
+| two-face | 0.5.2+bat-0.26.1 | MIT OR Apache-2.0 |
+| zip | 8.6.0 | MIT |
diff --git a/PreviewCore/build-xcode.sh b/PreviewCore/build-xcode.sh
new file mode 100755
index 0000000..2230232
--- /dev/null
+++ b/PreviewCore/build-xcode.sh
@@ -0,0 +1,54 @@
+#!/bin/sh
+
+set -eu
+
+if [ "$(uname -m)" != "arm64" ]; then
+	echo "Apple silicon is required to build PreviewCore" >&2
+	exit 1
+fi
+
+PROJECT_ROOT="${PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
+CORE_ROOT="$PROJECT_ROOT/PreviewCore"
+TARGET="aarch64-apple-darwin"
+OUTPUT_DIRECTORY="$CORE_ROOT/build"
+export CARGO_TARGET_DIR="$CORE_ROOT/target"
+export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-26.0}"
+
+MISE_BIN="${MISE_BIN:-}"
+if [ -z "$MISE_BIN" ]; then
+	if command -v mise >/dev/null 2>&1; then
+		MISE_BIN="$(command -v mise)"
+	elif [ -x "$HOME/.local/bin/mise" ]; then
+		MISE_BIN="$HOME/.local/bin/mise"
+	elif [ -x "/opt/homebrew/bin/mise" ]; then
+		MISE_BIN="/opt/homebrew/bin/mise"
+	elif [ -x "/usr/local/bin/mise" ]; then
+		MISE_BIN="/usr/local/bin/mise"
+	fi
+fi
+if [ -z "$MISE_BIN" ]; then
+	echo "mise is required to build PreviewCore with the pinned Rust toolchain" >&2
+	exit 1
+fi
+
+export MISE_TRUSTED_CONFIG_PATHS="$PROJECT_ROOT${MISE_TRUSTED_CONFIG_PATHS:+:$MISE_TRUSTED_CONFIG_PATHS}"
+
+PROFILE="debug"
+PROFILE_ARGUMENT=""
+case "${CONFIGURATION:-Debug}" in
+	Release|Profile)
+		PROFILE="release"
+		PROFILE_ARGUMENT="--release"
+		;;
+esac
+
+"$MISE_BIN" exec -- cargo build \
+	--locked \
+	--manifest-path "$CORE_ROOT/Cargo.toml" \
+	--target "$TARGET" \
+	$PROFILE_ARGUMENT
+
+/usr/bin/install -d "$OUTPUT_DIRECTORY"
+/usr/bin/install -m 0644 \
+	"$CARGO_TARGET_DIR/$TARGET/$PROFILE/libglance_preview_core.a" \
+	"$OUTPUT_DIRECTORY/libglance_preview_core.a"
diff --git a/PreviewCore/include/glance_preview_core.h b/PreviewCore/include/glance_preview_core.h
new file mode 100644
index 0000000..d0cc04a
--- /dev/null
+++ b/PreviewCore/include/glance_preview_core.h
@@ -0,0 +1,32 @@
+#ifndef GLANCE_PREVIEW_CORE_H
+#define GLANCE_PREVIEW_CORE_H
+
+#include 
+#include 
+#include 
+
+typedef struct GlanceRenderResult {
+	uint8_t *data;
+	size_t length;
+	int32_t status;
+} GlanceRenderResult;
+
+GlanceRenderResult glance_render_code(
+	const uint8_t *source_data,
+	size_t source_length,
+	const uint8_t *lexer_data,
+	size_t lexer_length
+);
+GlanceRenderResult glance_render_markdown(const uint8_t *source_data, size_t source_length);
+GlanceRenderResult glance_render_notebook(const uint8_t *source_data, size_t source_length);
+GlanceRenderResult glance_parse_tsv(const uint8_t *data, size_t data_length);
+GlanceRenderResult glance_scan_zip(const uint8_t *path_data, size_t path_length);
+GlanceRenderResult glance_scan_tar(
+	const uint8_t *path_data,
+	size_t path_length,
+	bool is_gzipped
+);
+GlanceRenderResult glance_scan_seven_zip(const uint8_t *path_data, size_t path_length);
+void glance_render_buffer_free(uint8_t *data, size_t length);
+
+#endif
diff --git a/PreviewCore/src/error.rs b/PreviewCore/src/error.rs
new file mode 100644
index 0000000..6439abf
--- /dev/null
+++ b/PreviewCore/src/error.rs
@@ -0,0 +1,63 @@
+use std::fmt;
+
+#[derive(Debug)]
+pub(crate) struct RenderError(String);
+
+impl RenderError {
+    pub(crate) fn new(message: impl Into) -> Self {
+        Self(message.into())
+    }
+}
+
+impl fmt::Display for RenderError {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        formatter.write_str(&self.0)
+    }
+}
+
+impl std::error::Error for RenderError {}
+
+#[derive(Debug)]
+pub(crate) enum CoreError {
+    InvalidInput(String),
+    Parse(String),
+    Io(String),
+    ResourceLimit(String),
+    Unsupported(String),
+}
+
+impl CoreError {
+    pub(crate) fn invalid(message: impl Into) -> Self {
+        Self::InvalidInput(message.into())
+    }
+
+    pub(crate) fn parse(message: impl Into) -> Self {
+        Self::Parse(message.into())
+    }
+
+    pub(crate) fn io(message: impl Into) -> Self {
+        Self::Io(message.into())
+    }
+
+    pub(crate) fn limit(message: impl Into) -> Self {
+        Self::ResourceLimit(message.into())
+    }
+
+    pub(crate) fn unsupported(message: impl Into) -> Self {
+        Self::Unsupported(message.into())
+    }
+}
+
+impl fmt::Display for CoreError {
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::InvalidInput(message)
+            | Self::Parse(message)
+            | Self::Io(message)
+            | Self::ResourceLimit(message)
+            | Self::Unsupported(message) => formatter.write_str(message),
+        }
+    }
+}
+
+impl std::error::Error for CoreError {}
diff --git a/PreviewCore/src/ffi.rs b/PreviewCore/src/ffi.rs
new file mode 100644
index 0000000..5cedab1
--- /dev/null
+++ b/PreviewCore/src/ffi.rs
@@ -0,0 +1,353 @@
+use crate::error::{CoreError, RenderError};
+use std::ffi::OsStr;
+use std::os::unix::ffi::OsStrExt;
+use std::panic::{AssertUnwindSafe, catch_unwind};
+use std::path::Path;
+use std::ptr;
+use std::slice;
+use std::str;
+
+const STATUS_OK: i32 = 0;
+const STATUS_INVALID_INPUT: i32 = 1;
+const STATUS_PARSE_ERROR: i32 = 2;
+const STATUS_INTERNAL_ERROR: i32 = 3;
+const STATUS_IO_ERROR: i32 = 4;
+const STATUS_RESOURCE_LIMIT: i32 = 5;
+const STATUS_UNSUPPORTED: i32 = 6;
+
+#[repr(C)]
+pub struct GlanceRenderResult {
+    pub data: *mut u8,
+    pub length: usize,
+    pub status: i32,
+}
+
+/// Renders source code supplied as UTF-8 bytes.
+///
+/// # Safety
+///
+/// Each non-null pointer must be valid for reads of its paired length for the duration of this
+/// call. A null pointer is accepted only when its paired length is zero.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_render_code(
+    source_data: *const u8,
+    source_length: usize,
+    lexer_data: *const u8,
+    lexer_length: usize,
+) -> GlanceRenderResult {
+    ffi_call(|| unsafe {
+        let source = utf8_input(source_data, source_length)?;
+        let lexer = utf8_input(lexer_data, lexer_length)?;
+        crate::highlight::render_code(source, lexer)
+            .map_err(CoreError::from)
+            .map(String::into_bytes)
+    })
+}
+
+/// Renders Markdown supplied as UTF-8 bytes.
+///
+/// # Safety
+///
+/// The pointer must be valid for reads of `source_length` bytes for the duration of this call. A
+/// null pointer is accepted only when `source_length` is zero.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_render_markdown(
+    source_data: *const u8,
+    source_length: usize,
+) -> GlanceRenderResult {
+    ffi_call(|| unsafe {
+        let source = utf8_input(source_data, source_length)?;
+        crate::markdown::render_markdown(source)
+            .map_err(CoreError::from)
+            .map(String::into_bytes)
+    })
+}
+
+/// Renders a Jupyter notebook supplied as UTF-8 JSON bytes.
+///
+/// # Safety
+///
+/// The pointer must be valid for reads of `source_length` bytes for the duration of this call. A
+/// null pointer is accepted only when `source_length` is zero.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_render_notebook(
+    source_data: *const u8,
+    source_length: usize,
+) -> GlanceRenderResult {
+    ffi_call(|| unsafe {
+        let source = utf8_input(source_data, source_length)?;
+        crate::notebook::render_notebook(source)
+            .map_err(CoreError::from)
+            .map(String::into_bytes)
+    })
+}
+
+/// Parses TSV bytes into a typed JSON payload.
+///
+/// # Safety
+///
+/// The pointer must be valid for reads of `data_length` bytes for the duration of this call. A
+/// null pointer is accepted only when `data_length` is zero.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_parse_tsv(
+    data: *const u8,
+    data_length: usize,
+) -> GlanceRenderResult {
+    ffi_call(|| unsafe {
+        let data = byte_input(data, data_length, "TSV")?;
+        json_bytes(&crate::tsv::parse_tsv(data)?)
+    })
+}
+
+/// Scans a ZIP/JAR/EAR/WAR archive at a raw filesystem path into a typed JSON payload.
+///
+/// # Safety
+///
+/// The pointer must be valid for reads of `path_length` bytes for the duration of this call.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_scan_zip(
+    path_data: *const u8,
+    path_length: usize,
+) -> GlanceRenderResult {
+    ffi_call(|| unsafe { json_bytes(&crate::zip::scan_zip(path_input(path_data, path_length)?)?) })
+}
+
+/// Scans a TAR or gzip-compressed TAR archive at a raw filesystem path.
+///
+/// # Safety
+///
+/// The pointer must be valid for reads of `path_length` bytes for the duration of this call.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_scan_tar(
+    path_data: *const u8,
+    path_length: usize,
+    is_gzipped: bool,
+) -> GlanceRenderResult {
+    ffi_call(|| unsafe {
+        json_bytes(&crate::tar::scan_tar(
+            path_input(path_data, path_length)?,
+            is_gzipped,
+        )?)
+    })
+}
+
+/// Scans a 7z archive at a raw filesystem path into a typed JSON payload.
+///
+/// # Safety
+///
+/// The pointer must be valid for reads of `path_length` bytes for the duration of this call.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_scan_seven_zip(
+    path_data: *const u8,
+    path_length: usize,
+) -> GlanceRenderResult {
+    ffi_call(|| unsafe {
+        json_bytes(&crate::sevenzip::scan_seven_zip(path_input(
+            path_data,
+            path_length,
+        )?)?)
+    })
+}
+
+/// Releases a renderer result buffer.
+///
+/// # Safety
+///
+/// `data` and `length` must be the unchanged values returned together in one `GlanceRenderResult`.
+/// Each non-null buffer may be released exactly once.
+#[unsafe(no_mangle)]
+pub unsafe extern "C" fn glance_render_buffer_free(data: *mut u8, length: usize) {
+    if data.is_null() {
+        return;
+    }
+    let slice = ptr::slice_from_raw_parts_mut(data, length);
+    unsafe {
+        drop(Box::from_raw(slice));
+    }
+}
+
+fn ffi_call(operation: impl FnOnce() -> Result, CoreError>) -> GlanceRenderResult {
+    match catch_unwind(AssertUnwindSafe(operation)) {
+        Ok(Ok(output)) => result_from_bytes(output, STATUS_OK),
+        Ok(Err(error)) => {
+            let status = match error {
+                CoreError::InvalidInput(_) => STATUS_INVALID_INPUT,
+                CoreError::Parse(_) => STATUS_PARSE_ERROR,
+                CoreError::Io(_) => STATUS_IO_ERROR,
+                CoreError::ResourceLimit(_) => STATUS_RESOURCE_LIMIT,
+                CoreError::Unsupported(_) => STATUS_UNSUPPORTED,
+            };
+            result_from_bytes(error.to_string().into_bytes(), status)
+        }
+        Err(_) => result_from_bytes(
+            b"The Rust preview core stopped after an internal panic".to_vec(),
+            STATUS_INTERNAL_ERROR,
+        ),
+    }
+}
+
+fn result_from_bytes(bytes: Vec, status: i32) -> GlanceRenderResult {
+    if bytes.is_empty() {
+        return GlanceRenderResult {
+            data: ptr::null_mut(),
+            length: 0,
+            status,
+        };
+    }
+    let mut bytes = bytes.into_boxed_slice();
+    let result = GlanceRenderResult {
+        data: bytes.as_mut_ptr(),
+        length: bytes.len(),
+        status,
+    };
+    std::mem::forget(bytes);
+    result
+}
+
+unsafe fn utf8_input<'a>(data: *const u8, length: usize) -> Result<&'a str, CoreError> {
+    let bytes = unsafe { byte_input(data, length, "Renderer")? };
+    str::from_utf8(bytes)
+        .map_err(|error| CoreError::invalid(format!("Renderer input is not valid UTF-8: {error}")))
+}
+
+unsafe fn byte_input<'a>(
+    data: *const u8,
+    length: usize,
+    input_name: &str,
+) -> Result<&'a [u8], CoreError> {
+    if length == 0 {
+        return Ok(&[]);
+    }
+    if data.is_null() {
+        return Err(CoreError::invalid(format!(
+            "{input_name} input pointer is null for a non-empty buffer"
+        )));
+    }
+    Ok(unsafe { slice::from_raw_parts(data, length) })
+}
+
+unsafe fn path_input<'a>(data: *const u8, length: usize) -> Result<&'a Path, CoreError> {
+    let bytes = unsafe { byte_input(data, length, "Archive path")? };
+    if bytes.is_empty() {
+        return Err(CoreError::invalid("Archive path must not be empty"));
+    }
+    if bytes.contains(&0) {
+        return Err(CoreError::invalid("Archive path contains a null byte"));
+    }
+    Ok(Path::new(OsStr::from_bytes(bytes)))
+}
+
+fn json_bytes(value: &impl serde::Serialize) -> Result, CoreError> {
+    serde_json::to_vec(value)
+        .map_err(|error| CoreError::parse(format!("Could not encode preview payload: {error}")))
+}
+
+impl From for CoreError {
+    fn from(error: RenderError) -> Self {
+        Self::parse(error.to_string())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn take(result: GlanceRenderResult) -> (i32, String) {
+        let output = if result.length == 0 {
+            String::new()
+        } else {
+            let bytes = unsafe { slice::from_raw_parts(result.data, result.length) };
+            String::from_utf8(bytes.to_vec()).unwrap()
+        };
+        unsafe { glance_render_buffer_free(result.data, result.length) };
+        (result.status, output)
+    }
+
+    #[test]
+    fn returns_and_frees_successful_buffers() {
+        let input = b"# Heading";
+        let result = unsafe { glance_render_markdown(input.as_ptr(), input.len()) };
+        let (status, html) = take(result);
+        assert_eq!(status, STATUS_OK);
+        assert!(html.contains("

Heading

")); + } + + #[test] + fn validates_pointers_and_utf8() { + let result = unsafe { glance_render_markdown(ptr::null(), 1) }; + let (status, message) = take(result); + assert_eq!(status, STATUS_INVALID_INPUT); + assert!(message.contains("null")); + + let invalid = [0xff]; + let result = unsafe { glance_render_markdown(invalid.as_ptr(), invalid.len()) }; + let (status, message) = take(result); + assert_eq!(status, STATUS_INVALID_INPUT); + assert!(message.contains("UTF-8")); + + let malformed_notebook = b"not json"; + let result = unsafe { + glance_render_notebook(malformed_notebook.as_ptr(), malformed_notebook.len()) + }; + let (status, message) = take(result); + assert_eq!(status, STATUS_PARSE_ERROR); + assert!(message.contains("notebook JSON")); + } + + #[test] + fn accepts_empty_buffers() { + let result = unsafe { glance_render_markdown(ptr::null(), 0) }; + let (status, html) = take(result); + assert_eq!(status, STATUS_OK); + assert!(html.is_empty()); + + unsafe { glance_render_buffer_free(ptr::null_mut(), 0) }; + } + + #[test] + fn parses_tsv_and_validates_archive_paths() { + let input = b"name\tvalue\nhello\tworld\n"; + let result = unsafe { glance_parse_tsv(input.as_ptr(), input.len()) }; + let (status, json) = take(result); + assert_eq!(status, STATUS_OK); + assert!(json.contains("\"headers\":[\"name\",\"value\"]")); + + let result = unsafe { glance_scan_zip(ptr::null(), 1) }; + let (status, message) = take(result); + assert_eq!(status, STATUS_INVALID_INPUT); + assert!(message.contains("null")); + + let result = unsafe { glance_scan_tar(ptr::null(), 0, false) }; + let (status, message) = take(result); + assert_eq!(status, STATUS_INVALID_INPUT); + assert!(message.contains("must not be empty")); + } + + #[test] + fn reports_every_error_status_without_unwinding() { + let malformed = b"name\tvalue\nmissing\n"; + let result = unsafe { glance_parse_tsv(malformed.as_ptr(), malformed.len()) }; + assert_eq!(take(result).0, STATUS_PARSE_ERROR); + + let oversized = vec![b'a'; crate::tsv::MAX_FILE_SIZE + 1]; + let result = unsafe { glance_parse_tsv(oversized.as_ptr(), oversized.len()) }; + assert_eq!(take(result).0, STATUS_RESOURCE_LIMIT); + + let missing = Path::new("/definitely/missing/glance-preview.zip"); + let missing_bytes = missing.as_os_str().as_bytes(); + let result = unsafe { glance_scan_zip(missing_bytes.as_ptr(), missing_bytes.len()) }; + assert_eq!(take(result).0, STATUS_IO_ERROR); + + let encrypted = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../GlanceTests/TestFiles/archives/encrypted.7z"); + let encrypted_bytes = encrypted.as_os_str().as_bytes(); + let result = + unsafe { glance_scan_seven_zip(encrypted_bytes.as_ptr(), encrypted_bytes.len()) }; + assert_eq!(take(result).0, STATUS_UNSUPPORTED); + + let result = ffi_call(|| panic!("FFI panic test")); + let (status, message) = take(result); + assert_eq!(status, STATUS_INTERNAL_ERROR); + assert!(message.contains("internal panic")); + } +} diff --git a/PreviewCore/src/highlight.rs b/PreviewCore/src/highlight.rs new file mode 100644 index 0000000..f2922f1 --- /dev/null +++ b/PreviewCore/src/highlight.rs @@ -0,0 +1,243 @@ +use crate::error::RenderError; +use std::fmt; +use std::sync::OnceLock; +use two_face::re_exports::syntect::html::{ClassStyle, ClassedHTMLGenerator}; +use two_face::re_exports::syntect::parsing::{SyntaxReference, SyntaxSet}; +use two_face::re_exports::syntect::util::LinesWithEndings; + +static SYNTAX_SET: OnceLock = OnceLock::new(); + +fn syntax_set() -> &'static SyntaxSet { + SYNTAX_SET.get_or_init(two_face::syntax::extra_newlines) +} + +pub(crate) fn render_code(source: &str, lexer: &str) -> Result { + let highlighted = render_code_body(source, lexer)?; + Ok(format!( + "
{highlighted}
" + )) +} + +pub(crate) fn render_code_body(source: &str, lexer: &str) -> Result { + let syntax_set = syntax_set(); + let syntax = select_syntax(syntax_set, source, lexer); + let mut generator = + ClassedHTMLGenerator::new_with_class_style(syntax, syntax_set, ClassStyle::Spaced); + + for line in LinesWithEndings::from(source) { + generator + .parse_html_for_line_which_includes_newline(line) + .map_err(|error| { + RenderError::new(format!("Could not highlight source code: {error}")) + })?; + } + + Ok(generator.finalize()) +} + +fn select_syntax<'a>(syntax_set: &'a SyntaxSet, source: &str, lexer: &str) -> &'a SyntaxReference { + if !lexer.is_empty() && lexer != "autodetect" { + let extension = lexer.trim_start_matches('.'); + for candidate in lexer_candidates(lexer) + .iter() + .copied() + .chain(std::iter::once(extension)) + { + if let Some(syntax) = syntax_set.find_syntax_by_token(candidate) { + return syntax; + } + if let Some(syntax) = syntax_set.find_syntax_by_extension(candidate) { + return syntax; + } + if let Some(syntax) = syntax_set.find_syntax_by_name(candidate) { + return syntax; + } + } + } + + let first_line = source.lines().next().unwrap_or_default(); + if let Some(syntax) = syntax_set.find_syntax_by_first_line(first_line) { + return syntax; + } + + let trimmed = source.trim_start(); + if (trimmed.starts_with('{') || trimmed.starts_with('[')) + && serde_json::from_str::(source).is_ok() + && let Some(syntax) = syntax_set.find_syntax_by_token("json") + { + return syntax; + } + if trimmed.starts_with('<') + && let Some(syntax) = syntax_set.find_syntax_by_token("xml") + { + return syntax; + } + + syntax_set.find_syntax_plain_text() +} + +fn lexer_candidates(lexer: &str) -> &'static [&'static str] { + match lexer.trim_start_matches('.').to_ascii_lowercase().as_str() { + "applescript" | "scpt" | "scptd" => &["AppleScript", "applescript"], + "bash" | "bashrc" | "zsh" | "zshrc" => &["Bash", "Shell-Unix-Generic", "sh"], + "c" => &["C", "c"], + "dockerfile" => &["Dockerfile"], + "elisp" => &["Lisp", "lisp"], + "gemfile" | "rakefile" => &["Ruby", "rb"], + "handlebars" => &["HTML (Handlebars)", "HTML", "html"], + "hcl" => &["Terraform", "HCL", "tf"], + "ini" => &["INI", "ini"], + "js" => &["JavaScript", "js"], + "json" => &["JSON", "json"], + "makefile" => &["Makefile"], + "pkgbuild" => &["Bash", "sh"], + "swift" => &["Swift", "swift"], + "tex" => &["LaTeX", "TeX", "tex"], + "txt" => &["Plain Text", "txt"], + "twig" => &["Jinja2", "HTML", "html"], + "vimrc" => &["VimL", "vim"], + "xml" => &["XML", "xml"], + "yaml" | "yml" => &["YAML", "yaml"], + _ => &[], + } +} + +pub(crate) struct MarkdownHighlighter; + +impl comrak::adapters::SyntaxHighlighterAdapter for MarkdownHighlighter { + fn write_highlighted( + &self, + output: &mut dyn fmt::Write, + language: Option<&str>, + code: &str, + ) -> fmt::Result { + let highlighted = + render_code_body(code, language.unwrap_or("autodetect")).map_err(|_| fmt::Error)?; + output.write_str(&highlighted) + } + + fn write_pre_tag<'a>( + &self, + output: &mut dyn fmt::Write, + _attributes: std::collections::HashMap<&'static str, std::borrow::Cow<'a, str>>, + ) -> fmt::Result { + output.write_str("
")
+    }
+
+    fn write_code_tag<'a>(
+        &self,
+        output: &mut dyn fmt::Write,
+        _attributes: std::collections::HashMap<&'static str, std::borrow::Cow<'a, str>>,
+    ) -> fmt::Result {
+        output.write_str("")
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn highlights_explicit_and_detected_source() {
+        let empty = render_code("", "swift").unwrap();
+        assert_eq!(empty, "
"); + + let swift = render_code("let value = 42\n", "swift").unwrap(); + assert!(swift.starts_with("
"));
+        assert!(swift.contains("span"));
+
+        let unicode = render_code("let cafe = \"\u{2615}\"\n", "swift").unwrap();
+        assert!(unicode.contains('\u{2615}'));
+
+        let shell = render_code("#!/bin/zsh\necho hello\n", "autodetect").unwrap();
+        assert!(shell.contains("span"));
+
+        let json = render_code("{\"value\": 42}", "unknown-extension").unwrap();
+        assert!(json.contains("span"));
+
+        let rust = select_syntax(syntax_set(), "fn main() {}\n", "rs");
+        assert_ne!(rust.name, "Plain Text");
+
+        let xml = select_syntax(syntax_set(), "\n", "unknown-extension");
+        assert_ne!(xml.name, "Plain Text");
+
+        let plain = select_syntax(syntax_set(), "ordinary prose\n", "unknown-extension");
+        assert_eq!(plain.name, "Plain Text");
+    }
+
+    #[test]
+    fn escapes_source_html() {
+        let html = render_code("\n", "js").unwrap();
+        assert!(!html.contains("\n\n[bad](javascript:alert('bad'))\n")
+                .unwrap();
+        let lowercase = html.to_ascii_lowercase();
+        assert!(!lowercase.contains(",
+    pub rows: Vec>,
+}
+
+#[derive(Debug, PartialEq, Serialize)]
+pub(crate) struct ArchivePayload {
+    pub entries: Vec,
+    pub compressed_size: u64,
+    pub uncompressed_size: u64,
+    pub scanned_uncompressed_size: Option,
+    pub truncated: bool,
+}
+
+#[derive(Debug, PartialEq, Serialize)]
+pub(crate) struct ArchiveEntry {
+    pub path: String,
+    pub entry_type: ArchiveEntryType,
+    pub size: u64,
+    pub modified_unix_seconds: Option,
+}
+
+#[derive(Debug, PartialEq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub(crate) enum ArchiveEntryType {
+    File,
+    Directory,
+    Other,
+}
diff --git a/PreviewCore/src/notebook.rs b/PreviewCore/src/notebook.rs
new file mode 100644
index 0000000..bf54bbd
--- /dev/null
+++ b/PreviewCore/src/notebook.rs
@@ -0,0 +1,343 @@
+use crate::error::RenderError;
+use crate::highlight::render_code;
+use crate::markdown::render_markdown;
+use base64::Engine;
+use base64::engine::general_purpose::STANDARD as BASE64;
+use serde::Deserialize;
+use std::fmt::Write;
+
+#[derive(Debug, Default, Deserialize)]
+struct Notebook {
+    #[serde(default)]
+    cells: Vec,
+    #[serde(default)]
+    metadata: Metadata,
+    #[serde(default)]
+    nbformat: i64,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Metadata {
+    #[serde(default)]
+    language_info: LanguageInfo,
+    #[serde(default)]
+    kernelspec: KernelSpec,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct LanguageInfo {
+    file_extension: Option,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct KernelSpec {
+    language: Option,
+    name: Option,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Cell {
+    #[serde(default)]
+    cell_type: String,
+    execution_count: Option,
+    #[serde(default)]
+    source: NotebookText,
+    #[serde(default)]
+    outputs: Vec,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Output {
+    #[serde(default)]
+    output_type: String,
+    execution_count: Option,
+    #[serde(default)]
+    text: NotebookText,
+    #[serde(default)]
+    traceback: Vec,
+    #[serde(default)]
+    data: OutputData,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct OutputData {
+    #[serde(rename = "text/html")]
+    text_html: Option,
+    #[serde(rename = "application/pdf")]
+    application_pdf: Option,
+    #[serde(rename = "text/latex")]
+    text_latex: Option,
+    #[serde(rename = "image/svg+xml")]
+    image_svg: Option,
+    #[serde(rename = "image/png")]
+    image_png: Option,
+    #[serde(rename = "image/jpeg")]
+    image_jpeg: Option,
+    #[serde(rename = "text/markdown")]
+    text_markdown: Option,
+    #[serde(rename = "text/plain")]
+    text_plain: Option,
+}
+
+#[derive(Debug, Default, Deserialize)]
+#[serde(untagged)]
+enum NotebookText {
+    One(String),
+    Many(Vec),
+    #[default]
+    Missing,
+}
+
+impl NotebookText {
+    fn joined(&self) -> String {
+        match self {
+            Self::One(value) => value.clone(),
+            Self::Many(values) => values.concat(),
+            Self::Missing => String::new(),
+        }
+    }
+}
+
+pub(crate) fn render_notebook(source: &str) -> Result {
+    let notebook: Notebook = serde_json::from_str(source)
+        .map_err(|error| RenderError::new(format!("Could not parse notebook JSON: {error}")))?;
+    if notebook.nbformat < 4 {
+        return Err(RenderError::new(
+            "The provided Jupyter Notebook uses an old format; version 4 or newer is required",
+        ));
+    }
+
+    let language = notebook_language(¬ebook);
+    let mut html = String::from("
"); + for cell in notebook.cells { + let input = render_cell_input(&cell, &language)?; + let cell_class = class_token(&cell.cell_type); + write!( + html, + "
{}
{input}
", + render_prompt(cell.execution_count) + ) + .expect("writing to a string cannot fail"); + + for output in cell.outputs { + let class_name = class_token(&output.output_type.replace('_', "-")); + let rendered = render_output(&output)?; + write!( + html, + "
{}
{rendered}
", + render_prompt(output.execution_count) + ) + .expect("writing to a string cannot fail"); + } + html.push_str("
"); + } + html.push_str("
"); + Ok(html) +} + +fn notebook_language(notebook: &Notebook) -> String { + notebook + .metadata + .language_info + .file_extension + .as_deref() + .map(|extension| extension.trim_start_matches('.').to_owned()) + .filter(|language| !language.is_empty()) + .or_else(|| notebook.metadata.kernelspec.language.clone()) + .or_else(|| notebook.metadata.kernelspec.name.clone()) + .unwrap_or_else(|| "autodetect".to_owned()) +} + +fn render_cell_input(cell: &Cell, language: &str) -> Result { + let source = cell.source.joined(); + match cell.cell_type.as_str() { + "markdown" => render_markdown(&source), + "code" => render_code(&source, language), + "raw" => Ok(format!("
{}
", escape_html(&source))), + _ => Ok(String::new()), + } +} + +fn render_output(output: &Output) -> Result { + Ok(match output.output_type.as_str() { + "display_data" => render_data_output(&output.data)?, + "execute_result" => render_data_output(&output.data)?, + "error" => render_error_output(output), + "stream" => format!("
{}
", escape_html(&output.text.joined())), + _ => String::new(), + }) +} + +fn class_token(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '-' { + character + } else { + '-' + } + }) + .collect() +} + +fn render_data_output(data: &OutputData) -> Result { + if let Some(value) = &data.text_html { + let html = value.joined(); + let unwrapped = html + .strip_prefix("
") + .and_then(|html| html.strip_suffix("
")) + .unwrap_or(&html); + return Ok(ammonia::Builder::default().clean(unwrapped).to_string()); + } + if data.application_pdf.is_some() { + return Ok("
PDF output
".to_owned()); + } + if data.text_latex.is_some() { + return Ok("
LaTeX output
".to_owned()); + } + if data.image_svg.is_some() { + return Ok("
SVG output
".to_owned()); + } + if let Some(value) = &data.image_png { + return render_image("png", &value.joined()); + } + if let Some(value) = &data.image_jpeg { + return render_image("jpeg", &value.joined()); + } + if let Some(value) = &data.text_markdown { + return render_markdown(&value.joined()); + } + if let Some(value) = &data.text_plain { + return Ok(format!("
{}
", escape_html(&value.joined()))); + } + Ok(String::new()) +} + +fn render_image(kind: &str, encoded: &str) -> Result { + let encoded = encoded + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::(); + BASE64 + .decode(encoded.as_bytes()) + .map_err(|_| RenderError::new(format!("Invalid base64 data for image/{kind} output")))?; + Ok(format!( + "\"Notebook" + )) +} + +fn render_error_output(output: &Output) -> String { + if output.traceback.is_empty() { + return "
An unknown error occurred
".to_owned(); + } + + let converted = output + .traceback + .iter() + .map(|line| ansi_to_html::convert(line).unwrap_or_else(|_| escape_html(line))) + .collect::>() + .join("\n"); + format!("
{converted}
") +} + +fn render_prompt(execution_count: Option) -> String { + execution_count + .map(|count| format!("[{count}]:")) + .unwrap_or_default() +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOTEBOOK: &str = r##"{ + "cells": [ + {"cell_type":"markdown","source":["# Heading"]}, + {"cell_type":"code","execution_count":1,"source":["print('hi')"],"outputs":[ + {"output_type":"stream","text":["\\n"]}, + {"output_type":"error","traceback":["\\u001b[31mError\\u001b[0m"]}, + {"output_type":"display_data","data":{"image/png":"aGVsbG8="}}, + {"output_type":"execute_result","execution_count":1,"data":{"text/html":["
safe
"]}} + ]}, + {"cell_type":"raw","source":""} + ], + "metadata":{"kernelspec":{"language":"python","name":"python3"}}, + "nbformat":4, + "nbformat_minor":4 + }"##; + + #[test] + fn renders_supported_cells_and_outputs_safely() { + let html = render_notebook(NOTEBOOK).unwrap(); + assert!(html.starts_with("
")); + assert!(html.contains("

Heading

")); + assert!(html.contains("cell-code")); + assert!(html.contains("output-stream")); + assert!(html.contains("data:image/png;base64,aGVsbG8=")); + assert!(html.contains("<unsafe>")); + assert!(html.contains("<raw>")); + assert!(!html.contains(" + [bad](javascript:alert("bad")) + """ + let markdownHTML = try HTMLRenderer.renderMarkdown(markdown) + let notebook = """ + {"cells":[{"cell_type":"code","execution_count":1,"metadata":{},"source":["print('ok')"],"outputs":[{"name":"stdout","output_type":"stream","text":["ok\\n"]}]}],"metadata":{"kernelspec":{"language":"python"}},"nbformat":4,"nbformat_minor":5} + """ + let notebookHTML = try HTMLRenderer.renderNotebook(notebook) + + XCTAssertTrue(markdownHTML.contains(#"
"#))
+		XCTAssertTrue(markdownHTML.contains(""))
+		XCTAssertTrue(markdownHTML.contains(#"type="checkbox""#))
+		XCTAssertFalse(markdownHTML.lowercased().contains(" Void
+	) async throws {
+		try await operation()
+		var durations = [UInt64]()
+		durations.reserveCapacity(iterations)
+		for _ in 0 ..< iterations {
+			let start = DispatchTime.now().uptimeNanoseconds
+			try await operation()
+			durations.append(DispatchTime.now().uptimeNanoseconds - start)
+		}
+		durations.sort()
+		let median = durations[durations.count / 2]
+		let p95Index = (durations.count * 95 + 99) / 100 - 1
+		let p95 = durations[p95Index]
+		print(
+			String(
+				format: "PARSER %@ median=%.3fms p95=%.3fms",
+				name,
+				Double(median) / 1_000_000,
+				Double(p95) / 1_000_000
+			)
+		)
+	}
+
+	private func XCTAssertThrowsErrorAsync(
+		_ operation: () async throws -> Void,
+		errorHandler: (Error) -> Void = { _ in },
+		file: StaticString = #filePath,
+		line: UInt = #line
+	) async {
+		do {
+			try await operation()
+			XCTFail("Expected operation to throw", file: file, line: line)
+		} catch {
+			errorHandler(error)
 		}
 	}
 
@@ -406,7 +609,7 @@ final class PreviewSmokeTests: XCTestCase {
 		let expression = try NSRegularExpression(pattern: #"url\(([^)]+)\)"#)
 		let matches = expression.matches(
 			in: stylesheet,
-			range: NSRange(stylesheet.startIndex.. URL? in
@@ -422,13 +625,17 @@ final class PreviewSmokeTests: XCTestCase {
 		}
 	}
 
-	private func tarHeader(name: String, sizeField: [UInt8], typeFlag: UInt8 = UInt8(ascii: "0")) -> Data {
+	private func tarHeader(
+		name: String,
+		sizeField: [UInt8],
+		typeFlag: UInt8 = UInt8(ascii: "0")
+	) -> Data {
 		var header = Data(repeating: 0, count: 512)
 		write(Array(name.utf8), to: &header, at: 0, maxLength: 100)
 		write(sizeField, to: &header, at: 124, maxLength: 12)
 		header[156] = typeFlag
 
-		for index in 148..<156 {
+		for index in 148 ..< 156 {
 			header[index] = UInt8(ascii: " ")
 		}
 		let checksum = header.reduce(0) { $0 + Int($1) }
@@ -441,34 +648,13 @@ final class PreviewSmokeTests: XCTestCase {
 		var bytes = [UInt8](repeating: 0, count: 12)
 		var remaining = UInt64(bitPattern: value)
 		for index in stride(from: 11, through: 0, by: -1) {
-			bytes[index] = UInt8(remaining & 0xff)
+			bytes[index] = UInt8(remaining & 0xFF)
 			remaining >>= 8
 		}
 		bytes[0] |= 0x80
 		return bytes
 	}
 
-	private func sevenZipUnencodedFileInfoHeader(numFiles: UInt8) -> Data {
-		sevenZipSignatureHeader(nextHeader: Data([0x01, 0x05, numFiles]))
-	}
-
-	private func sevenZipSignatureHeader(nextHeader: Data) -> Data {
-		var data = Data([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0x00, 0x04])
-		data.append(Data(repeating: 0, count: 4))
-		appendLittleEndianUInt64(0, to: &data)
-
-		appendLittleEndianUInt64(UInt64(nextHeader.count), to: &data)
-		data.append(Data(repeating: 0, count: 4))
-		data.append(nextHeader)
-		return data
-	}
-
-	private func appendLittleEndianUInt64(_ value: UInt64, to data: inout Data) {
-		for index in 0..<8 {
-			data.append(UInt8((value >> (8 * index)) & 0xff))
-		}
-	}
-
 	private func write(_ bytes: [UInt8], to data: inout Data, at offset: Int, maxLength: Int) {
 		for (index, byte) in bytes.prefix(maxLength).enumerated() {
 			data[offset + index] = byte
diff --git a/GlanceTests/TestFiles/archives/encrypted.7z b/GlanceTests/TestFiles/archives/encrypted.7z
new file mode 100644
index 0000000000000000000000000000000000000000..4add411ddf439e4e5ea1f1679567c5bc25ce58a3
GIT binary patch
literal 374
zcmV-+0g3)Mdc3bE8~_BR-0zP70RR9100011000000000lh7G=f>r(qM`z<}dAMk8i
z##3cL(X98yGvtxGa}w{cX{=d5CgQi^83Wm=z&fGmAUo2&2lWD87}vXGc9>&7&#)DO0pE*O#zR0as(6?F`z-v
z=>V2nMna1S%=XKu713BFbZ^X;K9SG+wD7^c;KAdcs&EW;1WZ~C>$MZqye=QWOcm+}
zReYll16>u5N#2%a{kqXC-iz`&NDi3U^gf!2lByLxq3NXuR`1M@$MYhWW=)?T^`A$C<1~35$fY1s7n?A?400#>J
z00JZi@dp7S$o~XN`td6Vt{F~)j!E_*2#xESBBIgVy->P}5)gC
z(+^9{I@tTH{(hM9J1^DSj2jXc&J(v53Y_xBlUr@ufZAw
z45K>^obOn3%fE7B{siNf^8LO^yKXcbsBQS+rYa(zvuN1~q3zS3G2Kc$Y&fmiIJv3Q
zp}p~IulT0B=ATafPGS&eQ)T3An90RBarKVL4D8&D42;UmjEtcom.sun.java-archive 
 				com.sun.web-application-archive 
 				dyn.ah62d4rv4ge80n2pw 
+				org.gnu.gnu-zip-archive 
 				org.gnu.gnu-zip-tar-archive 
 				public.tar-archive 
 				public.zip-archive 
diff --git a/QLPlugin/MainVC.swift b/QLPlugin/MainVC.swift
index 8c14d5c..7799684 100644
--- a/QLPlugin/MainVC.swift
+++ b/QLPlugin/MainVC.swift
@@ -22,6 +22,12 @@ class MainVC: NSViewController, QLPreviewingController {
 	let stats = Stats()
 	var nestedPreviewProvider: NestedPreviewProviding = DefaultNestedPreviewProvider()
 	var openWithService = OpenWithService()
+	var containingAppIsRunning = {
+		!NSRunningApplication.runningApplications(
+			withBundleIdentifier: MainVC.containingAppBundleID
+		).isEmpty
+	}
+
 	private(set) var currentPreviewController: PreviewVC?
 	private(set) var topLevelPreviewController: PreviewVC?
 	private(set) var folderPreviewController: OutlinePreviewVC?
@@ -36,6 +42,9 @@ class MainVC: NSViewController, QLPreviewingController {
 	private(set) var openWithTargetURL: URL?
 	private var baseStatusText = ""
 	private var statusResetTask: Task?
+	private var previewPreparationTask: Task?
+	private var previewPreparationID: UUID?
+	private var nestedPreviewTask: Task?
 	private weak var boundStatusProvider: (any PreviewStatusProviding)?
 
 	override func loadView() {
@@ -126,68 +135,83 @@ class MainVC: NSViewController, QLPreviewingController {
 		at fileUrl: URL,
 		completionHandler handler: @escaping @Sendable (Error?) -> Void
 	) {
-		DispatchQueue.main.async {
-			// Only preview files when the containing app is running
-			if NSRunningApplication.runningApplications(
-				withBundleIdentifier: Self.containingAppBundleID
-			).isEmpty {
-				Log.general.info("Glance app is not running, declining preview")
-				let error = NSError(
-					domain: "com.chamburr.Glance.QLPlugin",
-					code: 1,
-					userInfo: [NSLocalizedDescriptionKey: "Glance app is not running"]
-				)
-				handler(error)
+		Task { @MainActor [weak self] in
+			guard let self else {
+				handler(CancellationError())
 				return
 			}
+			startPreviewPreparation(at: fileUrl, completionHandler: handler)
+		}
+	}
 
-			// Read information about the file to preview
-			var file: File
-			do {
-				file = try File(url: fileUrl)
-			} catch {
-				Log.general.error(
-					"Could not obtain information about file \(fileUrl.path, privacy: .private): \(error.localizedDescription, privacy: .private)"
-				)
-				handler(error)
-				return
+	private func startPreviewPreparation(
+		at fileURL: URL,
+		completionHandler handler: @escaping @Sendable (Error?) -> Void
+	) {
+		previewPreparationTask?.cancel()
+		let preparationID = UUID()
+		previewPreparationID = preparationID
+		let task = Task { @MainActor [weak self] in
+			guard let self else {
+				throw CancellationError()
 			}
-
-			// Skip preview if the file is too large
+			try await preparePreview(at: fileURL)
+		}
+		previewPreparationTask = task
+		Task { @MainActor [weak self] in
 			do {
-				try PreviewPolicy.validateFileSize(file)
+				try await task.value
+				handler(nil)
 			} catch {
-				// Log error and fall back to default preview (by calling the completion handler
-				// with the error)
-				Log.general
-					.error(
-						"Skipping file preview: \(error.localizedDescription, privacy: .private)"
-					)
 				handler(error)
-				return
 			}
-
-			// Render file preview
-			Log.general.info("Generating preview for file \(file.path, privacy: .private)")
-			do {
-				try self.previewFile(file: file)
-			} catch {
-				// Log error and fall back to default preview (by calling the completion handler
-				// with the error)
-				Log.general.error(
-					"Could not generate preview for file \(file.path, privacy: .private): \(error.localizedDescription, privacy: .private)"
-				)
-				handler(error)
-				return
+			if self?.previewPreparationID == preparationID {
+				self?.previewPreparationTask = nil
+				self?.previewPreparationID = nil
 			}
+		}
+	}
+
+	private func preparePreview(at fileURL: URL) async throws {
+		guard containingAppIsRunning() else {
+			Log.general.info("Glance app is not running, declining preview")
+			throw NSError(
+				domain: "com.chamburr.Glance.QLPlugin",
+				code: 1,
+				userInfo: [NSLocalizedDescriptionKey: "Glance app is not running"]
+			)
+		}
 
-			// Hide preview loading spinner
-			handler(nil)
+		let file: File
+		do {
+			file = try File(url: fileURL)
+		} catch {
+			Log.general.error(
+				"Could not obtain information about file \(fileURL.path, privacy: .private): \(error.localizedDescription, privacy: .private)"
+			)
+			throw error
+		}
+		do {
+			try PreviewPolicy.validateFileSize(file)
+		} catch {
+			Log.general
+				.error("Skipping file preview: \(error.localizedDescription, privacy: .private)")
+			throw error
+		}
+
+		Log.general.info("Generating preview for file \(file.path, privacy: .private)")
+		do {
+			try await previewFile(file: file)
+		} catch {
+			Log.general.error(
+				"Could not generate preview for file \(file.path, privacy: .private): \(error.localizedDescription, privacy: .private)"
+			)
+			throw error
 		}
 	}
 
 	/// Generates a preview of the selected file and adds the corresponding child view controller.
-	func previewFile(file: File) throws {
+	func previewFile(file: File) async throws {
 		// Initialize `PreviewVC` for the file type
 		if let previewInitializerType = PreviewVCFactory.getPreviewInitializer(
 			fileURL: file.url,
@@ -195,7 +219,7 @@ class MainVC: NSViewController, QLPreviewingController {
 		) {
 			// Generate file preview
 			let previewInitializer = previewInitializerType.init()
-			let previewVC = try previewInitializer.createPreviewVC(file: file)
+			let previewVC = try await previewInitializer.createPreviewVC(file: file)
 
 			installTopLevelPreview(previewVC, file: file)
 
@@ -349,24 +373,35 @@ class MainVC: NSViewController, QLPreviewingController {
 		guard nestedPreviewController == nil, let folderPreviewController else {
 			return
 		}
-		do {
-			let previewVC = try nestedPreviewProvider.makePreviewController(for: node)
-			folderPreviewController.view.isHidden = true
-			nestedPreviewController = previewVC
-			currentPreviewController = previewVC
-			bindStatus(to: previewVC)
-			show(previewVC)
-			backButton.isHidden = false
-		} catch {
-			Log.general.error(
-				"Could not generate nested preview for \(node.name, privacy: .private): \(error.localizedDescription, privacy: .private)"
-			)
-			showTransientError("Couldn’t preview \(node.name)")
+		nestedPreviewTask?.cancel()
+		nestedPreviewTask = Task { @MainActor [weak self] in
+			do {
+				guard let self else {
+					return
+				}
+				let previewVC = try await nestedPreviewProvider.makePreviewController(for: node)
+				try Task.checkCancellation()
+				folderPreviewController.view.isHidden = true
+				nestedPreviewController = previewVC
+				currentPreviewController = previewVC
+				bindStatus(to: previewVC)
+				show(previewVC)
+				backButton.isHidden = false
+			} catch is CancellationError {
+				return
+			} catch {
+				Log.general.error(
+					"Could not generate nested preview for \(node.name, privacy: .private): \(error.localizedDescription, privacy: .private)"
+				)
+				self?.showTransientError("Couldn’t preview \(node.name)")
+			}
 		}
 	}
 
 	@objc
 	func showFolderPreview() {
+		nestedPreviewTask?.cancel()
+		nestedPreviewTask = nil
 		guard let nestedPreviewController, let folderPreviewController else {
 			return
 		}
@@ -381,6 +416,8 @@ class MainVC: NSViewController, QLPreviewingController {
 	}
 
 	private func clearPreviewControllers() {
+		nestedPreviewTask?.cancel()
+		nestedPreviewTask = nil
 		statusResetTask?.cancel()
 		boundStatusProvider?.previewStatusDidChange = nil
 		boundStatusProvider = nil
diff --git a/QLPlugin/Views/NestedPreviewProvider.swift b/QLPlugin/Views/NestedPreviewProvider.swift
index 0d34384..b1c1329 100644
--- a/QLPlugin/Views/NestedPreviewProvider.swift
+++ b/QLPlugin/Views/NestedPreviewProvider.swift
@@ -26,12 +26,12 @@ enum NestedPreviewRoute {
 
 @MainActor
 protocol NestedPreviewProviding {
-	func makePreviewController(for node: FileTreeNode) throws -> PreviewVC
+	func makePreviewController(for node: FileTreeNode) async throws -> PreviewVC
 }
 
 @MainActor
 struct DefaultNestedPreviewProvider: NestedPreviewProviding {
-	func makePreviewController(for node: FileTreeNode) throws -> PreviewVC {
+	func makePreviewController(for node: FileTreeNode) async throws -> PreviewVC {
 		guard let fileURL = node.fileURL else {
 			throw NestedPreviewError.missingFileURL(name: node.name)
 		}
@@ -43,7 +43,7 @@ struct DefaultNestedPreviewProvider: NestedPreviewProviding {
 			case let .glance(previewType):
 				let file = try File(url: fileURL)
 				try PreviewPolicy.validateFileSize(file)
-				return try previewType.init().createPreviewVC(file: file)
+				return try await previewType.init().createPreviewVC(file: file)
 			case .native:
 				let previewVC = NativePreviewVC(fileURL: fileURL)
 				previewVC.loadViewIfNeeded()
diff --git a/QLPlugin/Views/PreviewVC.swift b/QLPlugin/Views/PreviewVC.swift
index de2ec65..634246f 100644
--- a/QLPlugin/Views/PreviewVC.swift
+++ b/QLPlugin/Views/PreviewVC.swift
@@ -29,5 +29,5 @@ protocol PreviewStatusProviding: AnyObject {
 protocol Preview {
 	init()
 	@MainActor
-	func createPreviewVC(file: File) throws -> PreviewVC
+	func createPreviewVC(file: File) async throws -> PreviewVC
 }
diff --git a/QLPlugin/Views/Previews/DirectoryPreview.swift b/QLPlugin/Views/Previews/DirectoryPreview.swift
index f68790d..d1a5643 100644
--- a/QLPlugin/Views/Previews/DirectoryPreview.swift
+++ b/QLPlugin/Views/Previews/DirectoryPreview.swift
@@ -21,16 +21,6 @@ enum DirectoryPreviewError: LocalizedError {
 class DirectoryPreview: Preview {
 	static let defaultMaxItemCount = 500
 	static let defaultMaxDepth = 5
-	private static let sortLocale = Locale(identifier: "en_US_POSIX")
-	private static let resourceKeys: [URLResourceKey] = [
-		.contentModificationDateKey,
-		.fileSizeKey,
-		.isDirectoryKey,
-		.isPackageKey,
-		.isSymbolicLinkKey,
-		.contentTypeKey,
-	]
-
 	private static let defaultExcludedRootURLs = [
 		FileManager.default.temporaryDirectory,
 		URL(fileURLWithPath: "/private/var/folders", isDirectory: true),
@@ -65,7 +55,7 @@ class DirectoryPreview: Preview {
 		self.excludedRootURLs = excludedRootURLs.map(\.standardizedFileURL)
 	}
 
-	func createPreviewVC(file: File) throws -> PreviewVC {
+	func createPreviewVC(file: File) async throws -> PreviewVC {
 		guard file.isDirectory else {
 			throw DirectoryPreviewError.notDirectory(path: file.path)
 		}
@@ -73,21 +63,93 @@ class DirectoryPreview: Preview {
 			throw DirectoryPreviewError.temporaryDirectory(path: file.path)
 		}
 
-		let scanResult = try scanDirectory(rootURL: file.url)
+		let rootURL = file.url
+		let scanner = DirectoryScanner(
+			fileManager: fileManager,
+			maxItemCount: maxItemCount,
+			maxDepth: maxDepth
+		)
+		let scanResult = try await PreviewExecutor.run {
+			try scanner.scan(rootURL: rootURL)
+		}
+		let fileTree = makeFileTree(from: scanResult.entries)
 		let itemSuffix = scanResult.isTruncated ? "+" : ""
 		let itemNoun = scanResult.itemCount == 1 && !scanResult.isTruncated ? "item" : "items"
 		let labelText = "\(scanResult.itemCount)\(itemSuffix) \(itemNoun)"
 
 		return OutlinePreviewVC(
-			rootNodes: scanResult.fileTree.root.childrenList,
+			rootNodes: fileTree.root.childrenList,
 			labelText: labelText,
 			expandAll: true,
 			showsFileThumbnails: true
 		)
 	}
 
-	private func scanDirectory(rootURL: URL) throws -> DirectoryScanResult {
+	private func makeFileTree(from entries: [DirectoryPreviewEntry]) -> FileTree {
 		let fileTree = FileTree()
+		for entry in entries {
+			do {
+				try fileTree.addNode(
+					path: entry.relativePath,
+					isDirectory: entry.isDirectory,
+					size: entry.size,
+					dateModified: entry.dateModified,
+					fileURL: entry.fileURL,
+					isPackage: entry.isPackage,
+					isSymbolicLink: entry.isSymbolicLink,
+					contentTypeIdentifier: entry.contentTypeIdentifier
+				)
+			} catch {
+				Log.parse.error("\(error.localizedDescription, privacy: .private)")
+			}
+		}
+		return fileTree
+	}
+
+	private func isExcluded(_ url: URL) -> Bool {
+		let path = url.standardizedFileURL.path
+		return excludedRootURLs.contains { rootURL in
+			let rootPath = rootURL.path
+			return path == rootPath || path.hasPrefix("\(rootPath)/")
+		}
+	}
+}
+
+private struct DirectoryScanResult {
+	let entries: [DirectoryPreviewEntry]
+	let itemCount: Int
+	let isTruncated: Bool
+}
+
+private struct DirectoryPreviewEntry {
+	let relativePath: String
+	let isDirectory: Bool
+	let size: Int
+	let dateModified: Date?
+	let fileURL: URL
+	let isPackage: Bool
+	let isSymbolicLink: Bool
+	let contentTypeIdentifier: String?
+}
+
+/// FileManager instances are confined to the detached scan that owns this value.
+private struct DirectoryScanner: @unchecked Sendable {
+	private static let sortLocale = Locale(identifier: "en_US_POSIX")
+	private static let resourceKeys: Set = [
+		.contentModificationDateKey,
+		.fileSizeKey,
+		.isDirectoryKey,
+		.isPackageKey,
+		.isSymbolicLinkKey,
+		.contentTypeKey,
+	]
+
+	let fileManager: FileManager
+	let maxItemCount: Int
+	let maxDepth: Int
+
+	func scan(rootURL: URL) throws -> DirectoryScanResult {
+		var entries = [DirectoryPreviewEntry]()
 		var itemCount = 0
 		var isTruncated = false
 		try scanDirectory(
@@ -95,12 +157,12 @@ class DirectoryPreview: Preview {
 			relativePath: "",
 			depth: 0,
 			isRoot: true,
-			fileTree: fileTree,
+			entries: &entries,
 			itemCount: &itemCount,
 			isTruncated: &isTruncated
 		)
 		return DirectoryScanResult(
-			fileTree: fileTree,
+			entries: entries,
 			itemCount: itemCount,
 			isTruncated: isTruncated
 		)
@@ -111,10 +173,11 @@ class DirectoryPreview: Preview {
 		relativePath: String,
 		depth: Int,
 		isRoot: Bool,
-		fileTree: FileTree,
+		entries: inout [DirectoryPreviewEntry],
 		itemCount: inout Int,
 		isTruncated: inout Bool
 	) throws {
+		try Task.checkCancellation()
 		guard depth < maxDepth else {
 			return
 		}
@@ -140,14 +203,14 @@ class DirectoryPreview: Preview {
 		}
 
 		for itemURL in contents {
+			try Task.checkCancellation()
 			guard itemCount < maxItemCount else {
 				isTruncated = true
 				return
 			}
-
 			let resourceValues: URLResourceValues
 			do {
-				resourceValues = try itemURL.resourceValues(forKeys: Set(Self.resourceKeys))
+				resourceValues = try itemURL.resourceValues(forKeys: Self.resourceKeys)
 			} catch {
 				Log.general.error(
 					"Could not read item metadata \(itemURL.path, privacy: .private): \(error.localizedDescription, privacy: .private)"
@@ -159,9 +222,9 @@ class DirectoryPreview: Preview {
 			let itemRelativePath = relativePath.isEmpty
 				? itemURL.lastPathComponent
 				: "\(relativePath)/\(itemURL.lastPathComponent)"
-			do {
-				try fileTree.addNode(
-					path: itemRelativePath,
+			entries.append(
+				DirectoryPreviewEntry(
+					relativePath: itemRelativePath,
 					isDirectory: isDirectory,
 					size: isDirectory ? 0 : resourceValues.fileSize ?? 0,
 					dateModified: resourceValues.contentModificationDate,
@@ -170,11 +233,8 @@ class DirectoryPreview: Preview {
 					isSymbolicLink: resourceValues.isSymbolicLink ?? false,
 					contentTypeIdentifier: resourceValues.contentType?.identifier
 				)
-				itemCount += 1
-			} catch {
-				Log.parse.error("\(error.localizedDescription, privacy: .private)")
-				continue
-			}
+			)
+			itemCount += 1
 
 			guard isDirectory,
 			      resourceValues.isSymbolicLink != true,
@@ -182,13 +242,12 @@ class DirectoryPreview: Preview {
 			else {
 				continue
 			}
-
 			try scanDirectory(
 				at: itemURL,
 				relativePath: itemRelativePath,
 				depth: depth + 1,
 				isRoot: false,
-				fileTree: fileTree,
+				entries: &entries,
 				itemCount: &itemCount,
 				isTruncated: &isTruncated
 			)
@@ -196,8 +255,7 @@ class DirectoryPreview: Preview {
 		isTruncated = isTruncated || hasMoreContents
 	}
 
-	/// Scans every directory entry so the selection stays deterministic, but retains and sorts only
-	/// enough URLs to satisfy the global preview limit. This bounds memory for very large folders.
+	/// Retains only the deterministic prefix needed by the global item limit.
 	private func sortedDirectoryContents(
 		at directoryURL: URL,
 		retaining requestedLimit: Int
@@ -206,7 +264,7 @@ class DirectoryPreview: Preview {
 		var enumerationError: Error?
 		guard let enumerator = fileManager.enumerator(
 			at: directoryURL,
-			includingPropertiesForKeys: Self.resourceKeys,
+			includingPropertiesForKeys: Array(Self.resourceKeys),
 			options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants],
 			errorHandler: { _, error in
 				enumerationError = error
@@ -219,6 +277,7 @@ class DirectoryPreview: Preview {
 		var contents = [URL]()
 		var itemTotal = 0
 		for case let itemURL as URL in enumerator {
+			try Task.checkCancellation()
 			itemTotal += 1
 			let insertionIndex = Self.insertionIndex(for: itemURL, in: contents)
 			contents.insert(itemURL, at: insertionIndex)
@@ -257,18 +316,4 @@ class DirectoryPreview: Preview {
 		)
 		return comparison == .orderedSame ? lhsName < rhsName : comparison == .orderedAscending
 	}
-
-	private func isExcluded(_ url: URL) -> Bool {
-		let path = url.standardizedFileURL.path
-		return excludedRootURLs.contains { rootURL in
-			let rootPath = rootURL.path
-			return path == rootPath || path.hasPrefix("\(rootPath)/")
-		}
-	}
-}
-
-private struct DirectoryScanResult {
-	let fileTree: FileTree
-	let itemCount: Int
-	let isTruncated: Bool
 }
diff --git a/QLPlugin/Views/Previews/SevenZipPreview.swift b/QLPlugin/Views/Previews/SevenZipPreview.swift
index 2d62b5a..e5b4d11 100644
--- a/QLPlugin/Views/Previews/SevenZipPreview.swift
+++ b/QLPlugin/Views/Previews/SevenZipPreview.swift
@@ -1,525 +1,61 @@
 import Foundation
-import SWCompression
 
 class SevenZipPreview: Preview {
-	static let defaultMaxArchiveFileSize = 200 * 1_024 * 1_024
-	static let defaultMaxMetadataHeaderSize = 8 * 1_024 * 1_024
-	static let defaultMaxEntryCount = 50_000
-
 	let byteCountFormatter = ByteCountFormatter()
-	private let maxArchiveFileSize: Int
-	private let maxMetadataHeaderSize: Int
-	private let maxEntryCount: Int
-
-	required convenience init() {
-		self.init(
-			maxArchiveFileSize: SevenZipPreview.defaultMaxArchiveFileSize,
-			maxMetadataHeaderSize: SevenZipPreview.defaultMaxMetadataHeaderSize,
-			maxEntryCount: SevenZipPreview.defaultMaxEntryCount
-		)
-	}
 
-	init(maxArchiveFileSize: Int, maxMetadataHeaderSize: Int, maxEntryCount: Int) {
-		self.maxArchiveFileSize = max(0, maxArchiveFileSize)
-		self.maxMetadataHeaderSize = max(0, maxMetadataHeaderSize)
-		self.maxEntryCount = max(0, maxEntryCount)
-	}
-
-	convenience init(maxArchiveFileSize: Int, maxEntryCount: Int) {
-		self.init(
-			maxArchiveFileSize: maxArchiveFileSize,
-			maxMetadataHeaderSize: SevenZipPreview.defaultMaxMetadataHeaderSize,
-			maxEntryCount: maxEntryCount
-		)
-	}
+	required init() {}
 
-	func createPreviewVC(file: File) throws -> PreviewVC {
-		guard file.size <= maxArchiveFileSize else {
-			throw SevenZipPreviewError.archiveSizeLimitExceeded(maxSize: maxArchiveFileSize)
+	func createPreviewVC(file: File) async throws -> PreviewVC {
+		let fileURL = file.url
+		let archiveSize = file.size
+		let payload = try await PreviewExecutor.run {
+			try PreviewCoreBridge.scanSevenZip(at: fileURL)
 		}
+		let fileTree = try makeFileTree(from: payload.entries)
+		let totalUncompressed = try checkedInt(payload.uncompressedSize)
+		let compressionRatio = totalUncompressed == 0
+			? 0.0
+			: 100.0 - Double(archiveSize) / Double(totalUncompressed) * 100.0
 
-		let archiveURL = URL(fileURLWithPath: file.path)
-		let fileData = try Data(contentsOf: archiveURL, options: .mappedIfSafe)
-		try SevenZipMetadataPreflight(
-			data: fileData,
-			maxEntryCount: maxEntryCount,
-			maxHeaderSize: maxMetadataHeaderSize
-		).validate()
-		let entries = try SevenZipContainer.info(container: fileData)
-		guard entries.count <= maxEntryCount else {
-			throw SevenZipPreviewError.entryCountLimitExceeded(maxEntryCount: maxEntryCount)
-		}
+		let labelText = """
+		Compressed: \(byteCountFormatter.string(for: archiveSize) ?? "--")
+		Uncompressed: \(byteCountFormatter.string(for: totalUncompressed) ?? "--")
+		Compression ratio: \(String(compressionRatio)) %
+		"""
+		return OutlinePreviewVC(rootNodes: fileTree.root.childrenList, labelText: labelText)
+	}
 
+	private func makeFileTree(from entries: [ArchivePreviewEntry]) throws -> FileTree {
 		let fileTree = FileTree()
-		var totalUncompressed = 0
-
 		for entry in entries {
-			let size = entry.size ?? 0
-			guard size >= 0 else {
-				throw SevenZipPreviewError.metadataSizeLimitExceeded
-			}
-			try Self.checkedAdd(size, to: &totalUncompressed)
-			let isDirectory = entry.type == .directory
 			do {
 				try fileTree.addNode(
-					path: entry.name,
-					isDirectory: isDirectory,
-					size: size,
-					dateModified: entry.modificationTime
+					path: entry.path,
+					isDirectory: entry.entryType == .directory,
+					size: try checkedInt(entry.size),
+					dateModified: entry.modifiedUnixSeconds.map(Date.init(timeIntervalSince1970:))
 				)
+			} catch let error as SevenZipPreviewError {
+				throw error
 			} catch {
 				Log.parse.error("\(error.localizedDescription, privacy: .private)")
 			}
 		}
-
-		let compressed = file.size
-		let compressionRatio = totalUncompressed == 0
-			? 0.0
-			: 100.0 - Double(compressed) / Double(totalUncompressed) * 100.0
-
-		let labelText = """
-		Compressed: \(byteCountFormatter.string(for: compressed) ?? "--")
-		Uncompressed: \(byteCountFormatter.string(for: totalUncompressed) ?? "--")
-		Compression ratio: \(String(compressionRatio)) %
-		"""
-
-		return OutlinePreviewVC(rootNodes: fileTree.root.childrenList, labelText: labelText)
+		return fileTree
 	}
 
-	private static func checkedAdd(_ value: Int, to total: inout Int) throws {
-		let result = total.addingReportingOverflow(value)
-		guard !result.overflow else {
+	private func checkedInt(_ value: UInt64) throws -> Int {
+		guard value <= UInt64(Int.max) else {
 			throw SevenZipPreviewError.metadataSizeLimitExceeded
 		}
-		total = result.partialValue
+		return Int(value)
 	}
 }
 
 private enum SevenZipPreviewError: LocalizedError {
-	case archiveSizeLimitExceeded(maxSize: Int)
-	case entryCountLimitExceeded(maxEntryCount: Int)
 	case metadataSizeLimitExceeded
 
 	var errorDescription: String? {
-		switch self {
-			case let .archiveSizeLimitExceeded(maxSize):
-				NSLocalizedString(
-					"7z archive exceeds the \(maxSize) byte preview limit",
-					comment: ""
-				)
-			case let .entryCountLimitExceeded(maxEntryCount):
-				NSLocalizedString(
-					"7z archive metadata exceeds the \(maxEntryCount) entry preview limit",
-					comment: ""
-				)
-			case .metadataSizeLimitExceeded:
-				NSLocalizedString("7z archive metadata is too large to preview safely", comment: "")
-		}
-	}
-}
-
-private enum SevenZipPreflightError: Error {
-	case encodedHeader
-	case malformedHeader
-}
-
-private struct SevenZipMetadataPreflight {
-	private let data: Data
-	private let maxEntryCount: Int
-	private let maxHeaderSize: Int
-
-	init(data: Data, maxEntryCount: Int, maxHeaderSize: Int) {
-		self.data = data
-		self.maxEntryCount = maxEntryCount
-		self.maxHeaderSize = maxHeaderSize
-	}
-
-	func validate() throws {
-		do {
-			try validateUnencodedHeader()
-		} catch let error as SevenZipPreflightError {
-			switch error {
-				case .encodedHeader:
-					return
-				case .malformedHeader:
-					throw error
-			}
-		}
-	}
-
-	private func validateUnencodedHeader() throws {
-		guard data.count >= 32 else {
-			return
-		}
-		guard Array(data[0..<6]) == [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C] else {
-			return
-		}
-		guard let nextHeaderOffset = littleEndianUInt64(at: 12),
-		      let nextHeaderSize = littleEndianUInt64(at: 20)
-		else {
-			throw SevenZipPreflightError.malformedHeader
-		}
-		guard nextHeaderSize <= UInt64(maxHeaderSize) else {
-			throw SevenZipPreviewError.metadataSizeLimitExceeded
-		}
-		guard nextHeaderOffset <= UInt64(Int.max),
-		      nextHeaderSize <= UInt64(Int.max)
-		else {
-			throw SevenZipPreviewError.metadataSizeLimitExceeded
-		}
-
-		let start = 32.addingReportingOverflow(Int(nextHeaderOffset))
-		let end = start.partialValue.addingReportingOverflow(Int(nextHeaderSize))
-		guard !start.overflow, !end.overflow else {
-			throw SevenZipPreviewError.metadataSizeLimitExceeded
-		}
-		guard end.partialValue <= data.count else {
-			throw SevenZipPreflightError.malformedHeader
-		}
-
-		var reader = SevenZipMetadataReader(
-			data: data,
-			offset: start.partialValue,
-			endOffset: end.partialValue
-		)
-		let headerType = try reader.readByte()
-		switch headerType {
-			case 0x01:
-				try validateHeader(reader: &reader)
-			case 0x17:
-				throw SevenZipPreflightError.encodedHeader
-			default:
-				throw SevenZipPreflightError.malformedHeader
-		}
-	}
-
-	private func validateHeader(reader: inout SevenZipMetadataReader) throws {
-		var type = try reader.readByte()
-
-		if type == 0x02 {
-			try skipProperties(reader: &reader)
-			type = try reader.readByte()
-		}
-		if type == 0x04 {
-			try skipStreamInfo(reader: &reader)
-			type = try reader.readByte()
-		}
-		if type == 0x00 {
-			return
-		}
-		guard type == 0x05 else {
-			throw SevenZipPreflightError.malformedHeader
-		}
-
-		let numFiles = try reader.readMultiByteInteger()
-		guard numFiles <= maxEntryCount else {
-			throw SevenZipPreviewError.entryCountLimitExceeded(maxEntryCount: maxEntryCount)
-		}
-	}
-
-	private func skipStreamInfo(reader: inout SevenZipMetadataReader) throws {
-		var type = try reader.readByte()
-
-		if type == 0x06 {
-			try skipPackInfo(reader: &reader)
-			type = try reader.readByte()
-		}
-
-		var coderInfo = SevenZipCoderSummary.empty
-		if type == 0x07 {
-			coderInfo = try skipCoderInfo(reader: &reader)
-			type = try reader.readByte()
-		}
-
-		if type == 0x08 {
-			try skipSubstreamInfo(reader: &reader, coderInfo: coderInfo)
-			type = try reader.readByte()
-		}
-
-		guard type == 0x00 else {
-			throw SevenZipPreflightError.malformedHeader
-		}
-	}
-
-	private func skipProperties(reader: inout SevenZipMetadataReader) throws {
-		while true {
-			let propertyType = try reader.readByte()
-			if propertyType == 0x00 {
-				return
-			}
-			let propertySize = try reader.readMultiByteInteger()
-			try reader.skip(propertySize)
-		}
-	}
-
-	private func skipPackInfo(reader: inout SevenZipMetadataReader) throws {
-		_ = try reader.readMultiByteInteger()
-		let numPackStreams = try validatedMetadataCount(reader.readMultiByteInteger())
-
-		var type = try reader.readByte()
-		if type == 0x09 {
-			for _ in 0.. SevenZipCoderSummary {
-		let coderInfoType = try reader.readByte()
-		guard coderInfoType == 0x0B else {
-			throw SevenZipPreflightError.malformedHeader
-		}
-
-		let numFolders = try validatedMetadataCount(reader.readMultiByteInteger())
-		let external = try reader.readByte()
-		guard external == 0 else {
-			throw SevenZipPreflightError.malformedHeader
-		}
-
-		var outputStreamsByFolder = [Int]()
-		for _ in 0.. Int {
-		let numCoders = try validatedMetadataCount(reader.readMultiByteInteger())
-		var totalInputStreams = 0
-		var totalOutputStreams = 0
-
-		for _ in 0.. 0 else {
-			throw SevenZipPreflightError.malformedHeader
-		}
-		let numBindPairs = totalOutputStreams - 1
-		for _ in 0..= numBindPairs else {
-			throw SevenZipPreflightError.malformedHeader
-		}
-		let numPackedStreams = totalInputStreams - numBindPairs
-		if numPackedStreams != 1 {
-			for _ in 0.. 0 {
-				for _ in 0..<(numStreams - 1) {
-					_ = try reader.readMultiByteInteger()
-				}
-			}
-			type = try reader.readByte()
-		}
-
-		if type == 0x0A {
-			let definedCount = try reader.readDefinedBitCount(count: totalUnpackStreams)
-			try reader.skipChecksums(count: definedCount)
-			type = try reader.readByte()
-		}
-
-		guard type == 0x00 else {
-			throw SevenZipPreflightError.malformedHeader
-		}
-	}
-
-	private func validatedMetadataCount(_ count: @autoclosure () throws -> Int) throws -> Int {
-		let value = try count()
-		guard value <= maxEntryCount else {
-			throw SevenZipPreviewError.metadataSizeLimitExceeded
-		}
-		return value
-	}
-
-	private func checkedAdd(_ lhs: Int, _ rhs: Int) throws -> Int {
-		let result = lhs.addingReportingOverflow(rhs)
-		guard !result.overflow else {
-			throw SevenZipPreviewError.metadataSizeLimitExceeded
-		}
-		return result.partialValue
-	}
-
-	private func littleEndianUInt64(at offset: Int) -> UInt64? {
-		guard offset >= 0, offset <= data.count - 8 else {
-			return nil
-		}
-
-		var value: UInt64 = 0
-		for index in 0..<8 {
-			value |= UInt64(data[offset + index]) << (8 * index)
-		}
-		return value
-	}
-}
-
-private struct SevenZipCoderSummary {
-	static let empty = SevenZipCoderSummary(numFolders: 0)
-
-	let numFolders: Int
-}
-
-private struct SevenZipMetadataReader {
-	private let data: Data
-	private(set) var offset: Int
-	private let endOffset: Int
-
-	init(data: Data, offset: Int, endOffset: Int) {
-		self.data = data
-		self.offset = offset
-		self.endOffset = endOffset
-	}
-
-	mutating func readByte() throws -> UInt8 {
-		guard offset < endOffset, offset < data.count else {
-			throw SevenZipPreflightError.malformedHeader
-		}
-		defer {
-			offset += 1
-		}
-		return data[offset]
-	}
-
-	mutating func readMultiByteInteger() throws -> Int {
-		let firstByte = try readByte()
-		var mask: UInt8 = 0x80
-		var value: UInt64 = 0
-
-		for index in 0..<8 {
-			if firstByte & mask == 0 {
-				value |= UInt64(firstByte & (mask &- 1)) << (8 * index)
-				return try checkedInt(value)
-			}
-			value |= UInt64(try readByte()) << (8 * index)
-			mask >>= 1
-		}
-
-		return try checkedInt(value)
-	}
-
-	mutating func readDefinedBitCount(count: Int) throws -> Int {
-		guard count >= 0 else {
-			throw SevenZipPreflightError.malformedHeader
-		}
-
-		let allDefined = try readByte()
-		if allDefined != 0 {
-			return count
-		}
-
-		let byteCount = (count + 7) / 8
-		var definedCount = 0
-		for byteIndex in 0..> bitIndex) != 0 {
-				definedCount += 1
-			}
-		}
-		return definedCount
-	}
-
-	mutating func skip(_ count: Int) throws {
-		guard count >= 0, count <= endOffset - offset else {
-			throw SevenZipPreflightError.malformedHeader
-		}
-		offset += count
-	}
-
-	mutating func skipChecksums(count: Int) throws {
-		for _ in 0.. Int {
-		guard value <= UInt64(Int.max) else {
-			throw SevenZipPreviewError.metadataSizeLimitExceeded
-		}
-		return Int(value)
+		NSLocalizedString("7z archive metadata is too large to preview safely", comment: "")
 	}
 }
diff --git a/QLPlugin/Views/Previews/TARPreview.swift b/QLPlugin/Views/Previews/TARPreview.swift
index 26c4e82..c57ba35 100644
--- a/QLPlugin/Views/Previews/TARPreview.swift
+++ b/QLPlugin/Views/Previews/TARPreview.swift
@@ -1,573 +1,87 @@
 import Foundation
-import zlib
 
-/// View controller for previewing tarballs (may be gzipped).
+/// Presentation adapter for TAR and gzip-compressed TAR metadata parsed by PreviewCore.
 class TARPreview: Preview {
 	let byteCountFormatter = ByteCountFormatter()
 	private let maxEntryCount = 50_000
-	private let maxMetadataEntrySize: Int64 = 1_048_576
-	private let maxGzippedUncompressedScanSize: Int64 = 200 * 1_024 * 1_024
 
 	required init() {}
 
-	private func scanArchive(file: File, isGzipped: Bool) throws -> TarScanResult {
-		let reader: TarByteReader = try {
-			if isGzipped {
-				return try GzipTarByteReader(url: file.url)
-			}
-			return try FileTarByteReader(url: file.url, fileSize: Int64(file.size))
-		}()
-		defer {
-			try? reader.close()
-		}
-
-		let scanner = TarHeaderScanner(
-			reader: reader,
-			maxEntryCount: maxEntryCount,
-			maxMetadataEntrySize: maxMetadataEntrySize,
-			maxGzippedUncompressedScanSize: maxGzippedUncompressedScanSize,
-			shouldLimitPayloadSkips: isGzipped
-		)
-		let result = try scanner.scan()
-		try reader.close()
-		return result
-	}
-
-	private func compressionRatioText(compressed: Int64, uncompressed: Int64) -> String {
-		if uncompressed == 0 {
-			return "0.0"
-		}
-		let ratio = 100.0 - Double(compressed) / Double(uncompressed) * 100.0
-		return String(format: "%.1f", ratio)
-	}
-
-	func createPreviewVC(file: File) throws -> PreviewVC {
+	func createPreviewVC(file: File) async throws -> PreviewVC {
+		let fileURL = file.url
+		let archiveSize = file.size
 		let normalizedPath = file.path.lowercased()
 		let isGzipped = normalizedPath.hasSuffix(".tar.gz") || normalizedPath.hasSuffix(".tgz")
-
-		let scanResult = try scanArchive(file: file, isGzipped: isGzipped)
+		let payload = try await PreviewExecutor.run {
+			try PreviewCoreBridge.scanTAR(at: fileURL, isGzipped: isGzipped)
+		}
+		let fileTree = makeFileTree(from: payload.entries)
+		let scannedSize = try checkedInt64(payload.scannedUncompressedSize ?? 0)
 		var labelText =
-			"\(isGzipped ? "Compressed" : "Size"): \(byteCountFormatter.string(fromByteCount: Int64(file.size)))"
+			"\(isGzipped ? "Compressed" : "Size"): \(byteCountFormatter.string(fromByteCount: Int64(archiveSize)))"
 
 		if isGzipped {
-			let uncompressedPrefix = scanResult.isTruncated ? "at least " : ""
+			let uncompressedPrefix = payload.truncated ? "at least " : ""
 			labelText += """
 
-			Uncompressed: \(uncompressedPrefix)\(byteCountFormatter.string(fromByteCount: scanResult.uncompressedByteCount))
+			Uncompressed: \(uncompressedPrefix)\(byteCountFormatter
+				.string(fromByteCount: scannedSize))
 			"""
-			if scanResult.isTruncated {
-				labelText += "\nPreview truncated after scanning \(byteCountFormatter.string(fromByteCount: scanResult.uncompressedByteCount))"
+			if payload.truncated {
+				labelText +=
+					"\nPreview truncated after scanning \(byteCountFormatter.string(fromByteCount: scannedSize))"
 			} else {
-				labelText += "\nCompression ratio: \(compressionRatioText(compressed: Int64(file.size), uncompressed: scanResult.uncompressedByteCount)) %"
+				labelText +=
+					"\nCompression ratio: \(compressionRatioText(compressed: Int64(archiveSize), uncompressed: scannedSize)) %"
 			}
-		} else if scanResult.isTruncated {
+		} else if payload.truncated {
 			labelText += "\nPreview truncated after \(maxEntryCount) entries"
 		}
 
-		return OutlinePreviewVC(rootNodes: scanResult.fileTree.root.childrenList, labelText: labelText)
-	}
-}
-
-private struct TarScanResult {
-	let fileTree: FileTree
-	let uncompressedByteCount: Int64
-	let isTruncated: Bool
-}
-
-private enum TARPreviewError: LocalizedError {
-	case truncatedArchive
-	case invalidHeader
-	case gzipOpenFailed(path: String)
-	case gzipReadFailed(path: String, message: String)
-
-	var errorDescription: String? {
-		switch self {
-			case .truncatedArchive:
-				"TAR archive is truncated"
-			case .invalidHeader:
-				"TAR archive has an invalid header"
-			case let .gzipOpenFailed(path):
-				"Could not open gzipped TAR archive \(path)"
-			case let .gzipReadFailed(path, message):
-				"Could not read gzipped TAR archive \(path): \(message)"
-		}
-	}
-}
-
-private protocol TarByteReader: AnyObject {
-	func read(upToCount count: Int) throws -> Data
-	func skip(count: Int64) throws
-	func close() throws
-}
-
-private final class FileTarByteReader: TarByteReader {
-	private let handle: FileHandle
-	private let fileSize: Int64
-	private var isClosed = false
-
-	init(url: URL, fileSize: Int64) throws {
-		handle = try FileHandle(forReadingFrom: url)
-		self.fileSize = fileSize
-	}
-
-	func read(upToCount count: Int) throws -> Data {
-		try handle.read(upToCount: count) ?? Data()
-	}
-
-	func skip(count: Int64) throws {
-		guard count >= 0 else {
-			throw TARPreviewError.invalidHeader
-		}
-
-		let currentOffset = Int64(try handle.offset())
-		guard currentOffset <= fileSize, count <= fileSize - currentOffset else {
-			throw TARPreviewError.truncatedArchive
-		}
-		let targetOffset = currentOffset + count
-		try handle.seek(toOffset: UInt64(targetOffset))
-	}
-
-	func close() throws {
-		guard !isClosed else {
-			return
-		}
-		isClosed = true
-		try handle.close()
+		return OutlinePreviewVC(rootNodes: fileTree.root.childrenList, labelText: labelText)
 	}
 
-	deinit {
-		try? close()
-	}
-}
-
-private final class GzipTarByteReader: TarByteReader {
-	private let path: String
-	private var gzipFile: gzFile?
-
-	init(url: URL) throws {
-		path = url.path
-		gzipFile = gzopen(path, "rb")
-		guard gzipFile != nil else {
-			throw TARPreviewError.gzipOpenFailed(path: path)
-		}
-	}
-
-	func read(upToCount count: Int) throws -> Data {
-		guard let gzipFile else {
-			return Data()
-		}
-
-		var data = Data(count: count)
-		let readCount = data.withUnsafeMutableBytes { bytes in
-			gzread(gzipFile, bytes.baseAddress, UInt32(count))
-		}
-		guard readCount >= 0 else {
-			throw TARPreviewError.gzipReadFailed(path: path, message: gzipErrorMessage())
-		}
-
-		data.removeSubrange(Int(readCount)..= 0 else {
-			throw TARPreviewError.invalidHeader
-		}
-
-		var remaining = count
-		while remaining > 0 {
-			let chunkSize = Int(min(remaining, 64 * 1_024))
-			let data = try read(upToCount: chunkSize)
-			guard !data.isEmpty else {
-				throw TARPreviewError.truncatedArchive
+	private func makeFileTree(from entries: [ArchivePreviewEntry]) -> FileTree {
+		let fileTree = FileTree()
+		for entry in entries {
+			do {
+				try fileTree.addNode(
+					path: entry.path,
+					isDirectory: entry.entryType == .directory,
+					size: clampedInt(entry.size),
+					dateModified: entry.modifiedUnixSeconds.map(Date.init(timeIntervalSince1970:))
+				)
+			} catch {
+				Log.parse.error("\(error.localizedDescription, privacy: .private)")
 			}
-			remaining -= Int64(data.count)
-		}
-	}
-
-	func close() throws {
-		guard let gzipFile else {
-			return
-		}
-		self.gzipFile = nil
-		let result = gzclose(gzipFile)
-		guard result == Z_OK else {
-			throw TARPreviewError.gzipReadFailed(path: path, message: "zlib close failed with status \(result)")
 		}
+		return fileTree
 	}
 
-	private func gzipErrorMessage() -> String {
-		guard let gzipFile else {
-			return "gzip stream is closed"
-		}
-		var errorNumber: Int32 = 0
-		guard let message = gzerror(gzipFile, &errorNumber) else {
-			return "unknown gzip error"
+	private func checkedInt64(_ value: UInt64) throws -> Int64 {
+		guard value <= UInt64(Int64.max) else {
+			throw TARPreviewError.metadataSizeLimitExceeded
 		}
-		return String(cString: message)
+		return Int64(value)
 	}
 
-	deinit {
-		try? close()
-	}
-}
-
-private final class TarHeaderScanner {
-	private let reader: TarByteReader
-	private let maxEntryCount: Int
-	private let maxMetadataEntrySize: Int64
-	private let maxGzippedUncompressedScanSize: Int64
-	private let shouldLimitPayloadSkips: Bool
-	private let fileTree = FileTree()
-	private var uncompressedByteCount: Int64 = 0
-	private var entryCount = 0
-	private var isTruncated = false
-	private var pendingLongName: String?
-	private var pendingLocalPAX = TarPAXHeaders()
-	private var globalPAX = TarPAXHeaders()
-
-	init(
-		reader: TarByteReader,
-		maxEntryCount: Int,
-		maxMetadataEntrySize: Int64,
-		maxGzippedUncompressedScanSize: Int64,
-		shouldLimitPayloadSkips: Bool
-	) {
-		self.reader = reader
-		self.maxEntryCount = maxEntryCount
-		self.maxMetadataEntrySize = maxMetadataEntrySize
-		self.maxGzippedUncompressedScanSize = maxGzippedUncompressedScanSize
-		self.shouldLimitPayloadSkips = shouldLimitPayloadSkips
+	private func clampedInt(_ value: UInt64) -> Int {
+		value > UInt64(Int.max) ? Int.max : Int(value)
 	}
 
-	func scan() throws -> TarScanResult {
-		scanLoop: while true {
-			let headerData = try readExactly(512, allowEmptyAtEOF: true)
-			if headerData.isEmpty {
-				break
-			}
-			try addScannedBytes(512)
-
-			if headerData.isZeroTarBlock {
-				let endMarkerData = try readExactly(512, allowEmptyAtEOF: true)
-				if !endMarkerData.isEmpty {
-					try addScannedBytes(512)
-				}
-				break
-			}
-
-			let header = try TarHeader(data: headerData)
-			let payloadSize = header.entryType.usesPAXSizeOverride
-				? pendingLocalPAX.size ?? globalPAX.size ?? header.size
-				: header.size
-			let paddedPayloadSize = try paddedTarSize(payloadSize)
-
-			switch header.entryType {
-				case .globalPAX:
-					if try shouldStopBeforeSkipping(paddedPayloadSize) {
-						break scanLoop
-					}
-					if let payload = try readMetadataPayload(size: payloadSize, paddedSize: paddedPayloadSize) {
-						globalPAX = TarPAXHeaders(data: payload)
-					}
-				case .localPAX:
-					if try shouldStopBeforeSkipping(paddedPayloadSize) {
-						break scanLoop
-					}
-					if let payload = try readMetadataPayload(size: payloadSize, paddedSize: paddedPayloadSize) {
-						pendingLocalPAX = TarPAXHeaders(data: payload)
-					}
-				case .longName:
-					if try shouldStopBeforeSkipping(paddedPayloadSize) {
-						break scanLoop
-					}
-					pendingLongName = try readMetadataPayload(size: payloadSize, paddedSize: paddedPayloadSize)
-						.flatMap(Self.stringFromMetadataPayload)
-				case .longLinkName:
-					if try shouldStopBeforeSkipping(paddedPayloadSize) {
-						break scanLoop
-					}
-					try reader.skip(count: paddedPayloadSize)
-					try addScannedBytes(paddedPayloadSize)
-				case .file, .directory, .other:
-					try addEntry(from: header, payloadSize: payloadSize)
-					if isTruncated {
-						break scanLoop
-					}
-					if try shouldStopBeforeSkipping(paddedPayloadSize) {
-						break scanLoop
-					}
-					try reader.skip(count: paddedPayloadSize)
-					try addScannedBytes(paddedPayloadSize)
-					pendingLongName = nil
-					pendingLocalPAX = TarPAXHeaders()
-			}
-		}
-
-		return TarScanResult(
-			fileTree: fileTree,
-			uncompressedByteCount: uncompressedByteCount,
-			isTruncated: isTruncated
-		)
-	}
-
-	private func addEntry(from header: TarHeader, payloadSize: Int64) throws {
-		guard entryCount < maxEntryCount else {
-			isTruncated = true
-			return
-		}
-
-		entryCount += 1
-		let path = pendingLocalPAX.path ?? pendingLongName ?? globalPAX.path ?? header.path
-		guard !path.isEmpty else {
-			return
-		}
-
-		let isDirectory = header.entryType == .directory || path.hasSuffix("/")
-		do {
-			try fileTree.addNode(
-				path: path,
-				isDirectory: isDirectory,
-				size: isDirectory ? 0 : clampedSize(payloadSize),
-				dateModified: pendingLocalPAX.modificationTime ?? globalPAX.modificationTime ?? header.modificationTime
-			)
-		} catch {
-			Log.parse.error("\(error.localizedDescription, privacy: .private)")
-		}
-	}
-
-	private func readMetadataPayload(size: Int64, paddedSize: Int64) throws -> Data? {
-		guard size <= maxMetadataEntrySize else {
-			try reader.skip(count: paddedSize)
-			try addScannedBytes(paddedSize)
-			return nil
-		}
-
-		let payload = try readExactly(Int(size), allowEmptyAtEOF: false)
-		try reader.skip(count: paddedSize - size)
-		try addScannedBytes(paddedSize)
-		return payload
-	}
-
-	private func readExactly(_ count: Int, allowEmptyAtEOF: Bool) throws -> Data {
-		var result = Data()
-		while result.count < count {
-			let chunk = try reader.read(upToCount: count - result.count)
-			if chunk.isEmpty {
-				if allowEmptyAtEOF, result.isEmpty {
-					return Data()
-				}
-				throw TARPreviewError.truncatedArchive
-			}
-			result.append(chunk)
-		}
-		return result
-	}
-
-	private func shouldStopBeforeSkipping(_ paddedPayloadSize: Int64) throws -> Bool {
-		guard shouldLimitPayloadSkips else {
-			return false
-		}
-		guard uncompressedByteCount <= maxGzippedUncompressedScanSize,
-		      paddedPayloadSize <= maxGzippedUncompressedScanSize - uncompressedByteCount
-		else {
-			isTruncated = true
-			return true
-		}
-		return false
-	}
-
-	private func addScannedBytes(_ count: Int64) throws {
-		guard count >= 0, uncompressedByteCount <= Int64.max - count else {
-			throw TARPreviewError.invalidHeader
-		}
-		uncompressedByteCount += count
-	}
-
-	private func paddedTarSize(_ size: Int64) throws -> Int64 {
-		guard size >= 0, size <= Int64.max - 511 else {
-			throw TARPreviewError.invalidHeader
-		}
-		let remainder = size % 512
-		return remainder == 0 ? size : size + 512 - remainder
-	}
-
-	private func clampedSize(_ size: Int64) -> Int {
-		if size > Int64(Int.max) {
-			return Int.max
-		}
-		return Int(size)
-	}
-
-	private static func stringFromMetadataPayload(_ data: Data) -> String {
-		let string = String(decoding: data, as: UTF8.self)
-		return string.split(separator: "\0", maxSplits: 1, omittingEmptySubsequences: false)
-			.first
-			.map(String.init) ?? ""
-	}
-}
-
-private struct TarHeader {
-	let path: String
-	let size: Int64
-	let modificationTime: Date?
-	let entryType: TarEntryType
-
-	init(data: Data) throws {
-		guard data.count == 512, !data.isZeroTarBlock else {
-			throw TARPreviewError.invalidHeader
-		}
-
-		let storedChecksum = try data.tarOctalInteger(in: 148..<156)
-		let computedChecksum = data.tarChecksum()
-		guard storedChecksum == computedChecksum else {
-			throw TARPreviewError.invalidHeader
-		}
-
-		let name = data.tarString(in: 0..<100)
-		let prefix = data.tarString(in: 345..<500)
-		path = prefix.isEmpty ? name : "\(prefix)/\(name)"
-		size = try data.tarOctalInteger(in: 124..<136)
-
-		let modificationTimestamp = try data.tarOctalInteger(in: 136..<148)
-		modificationTime = modificationTimestamp > 0
-			? Date(timeIntervalSince1970: TimeInterval(modificationTimestamp))
-			: nil
-		entryType = TarEntryType(typeFlag: data[156])
-	}
-}
-
-private enum TarEntryType {
-	case file
-	case directory
-	case globalPAX
-	case localPAX
-	case longName
-	case longLinkName
-	case other
-
-	init(typeFlag: UInt8) {
-		switch typeFlag {
-			case 0, UInt8(ascii: "0"):
-				self = .file
-			case UInt8(ascii: "5"):
-				self = .directory
-			case UInt8(ascii: "g"):
-				self = .globalPAX
-			case UInt8(ascii: "x"):
-				self = .localPAX
-			case UInt8(ascii: "L"):
-				self = .longName
-			case UInt8(ascii: "K"):
-				self = .longLinkName
-			default:
-				self = .other
-		}
-	}
-
-	var usesPAXSizeOverride: Bool {
-		switch self {
-			case .file, .directory, .other:
-				true
-			case .globalPAX, .localPAX, .longName, .longLinkName:
-				false
-		}
-	}
-}
-
-private struct TarPAXHeaders {
-	let path: String?
-	let size: Int64?
-	let modificationTime: Date?
-
-	init(path: String? = nil, size: Int64? = nil, modificationTime: Date? = nil) {
-		self.path = path
-		self.size = size
-		self.modificationTime = modificationTime
-	}
-
-	init(data: Data) {
-		var values = [String: String]()
-		let bytes = Array(data)
-		var offset = 0
-
-		while offset < bytes.count {
-			guard let spaceIndex = bytes[offset...].firstIndex(of: UInt8(ascii: " ")) else {
-				break
-			}
-			let lengthText = String(decoding: bytes[offset.. 0 else {
-				break
-			}
-			let recordEnd = offset + length
-			guard recordEnd <= bytes.count, spaceIndex + 1 < recordEnd else {
-				break
-			}
-
-			let recordBytes = bytes[(spaceIndex + 1)..) -> String {
-		let bytes = Array(self[range])
-		let endIndex = bytes.firstIndex(of: 0) ?? bytes.count
-		return String(decoding: bytes[..) throws -> Int64 {
-		let bytes = Array(self[range])
-		if let firstByte = bytes.first, firstByte & 0x80 != 0 {
-			return try tarBase256Integer(bytes: bytes)
-		}
-
-		let text = String(
-			decoding: bytes.filter { $0 != 0 && $0 != UInt8(ascii: " ") },
-			as: UTF8.self
-		)
-		guard !text.isEmpty else {
-			return 0
-		}
-		guard let value = Int64(text, radix: 8) else {
-			throw TARPreviewError.invalidHeader
-		}
-		return value
-	}
-
-	fileprivate func tarChecksum() -> Int64 {
-		enumerated().reduce(0) { partialResult, byte in
-			partialResult + Int64(byte.offset >= 148 && byte.offset < 156 ? UInt8(ascii: " ") : byte.element)
-		}
-	}
-
-	private func tarBase256Integer(bytes: [UInt8]) throws -> Int64 {
-		var bytes = bytes
-		bytes[0] &= 0x7f
+private enum TARPreviewError: LocalizedError {
+	case metadataSizeLimitExceeded
 
-		var value: Int64 = 0
-		for byte in bytes {
-			guard value <= (Int64.max - Int64(byte)) / 256 else {
-				throw TARPreviewError.invalidHeader
-			}
-			value = value * 256 + Int64(byte)
-		}
-		return value
+	var errorDescription: String? {
+		"TAR archive metadata is too large to preview safely"
 	}
 }
diff --git a/QLPlugin/Views/Previews/TSVPreview.swift b/QLPlugin/Views/Previews/TSVPreview.swift
index 4222caf..c5d0310 100644
--- a/QLPlugin/Views/Previews/TSVPreview.swift
+++ b/QLPlugin/Views/Previews/TSVPreview.swift
@@ -1,114 +1,14 @@
 import Foundation
-import SwiftCSV
 
 class TSVPreview: Preview {
-	static let defaultMaxFileSize = 25 * 1_024 * 1_024
-	static let defaultMaxRows = 5_000
-	static let defaultMaxColumns = 512
+	required init() {}
 
-	private let maxFileSize: Int
-	private let maxRows: Int
-	private let maxColumns: Int
-
-	required convenience init() {
-		self.init(
-			maxFileSize: TSVPreview.defaultMaxFileSize,
-			maxRows: TSVPreview.defaultMaxRows,
-			maxColumns: TSVPreview.defaultMaxColumns
-		)
-	}
-
-	init(maxFileSize: Int, maxRows: Int, maxColumns: Int) {
-		self.maxFileSize = max(0, maxFileSize)
-		self.maxRows = max(0, maxRows)
-		self.maxColumns = max(0, maxColumns)
-	}
-
-	convenience init(maxRows: Int, maxColumns: Int) {
-		self.init(
-			maxFileSize: TSVPreview.defaultMaxFileSize,
-			maxRows: maxRows,
-			maxColumns: maxColumns
-		)
-	}
-
-	func createPreviewVC(file: File) throws -> PreviewVC {
-		guard file.size <= maxFileSize else {
-			throw TSVPreviewError.fileSizeLimitExceeded(maxSize: maxFileSize)
-		}
-
-		// Read and parse TSV file
-		var csv: NamedCSV
-		do {
-			let contents = try file.read()
-			try validateHeaderColumnCount(in: contents)
-			csv = try NamedCSV(
-				string: contents,
-				delimiter: .tab,
-				loadColumns: false,
-				rowLimit: maxRows
-			)
-		} catch {
-			Log.parse
-				.error("Could not parse TSV file: \(error.localizedDescription, privacy: .private)")
-			throw error
-		}
-
-		return TablePreviewVC(headers: csv.header, cells: csv.rows)
-	}
-
-	private func validateHeaderColumnCount(in contents: String) throws {
-		let columnCount = headerColumnCount(in: contents)
-		guard columnCount <= maxColumns else {
-			throw TSVPreviewError.columnLimitExceeded(maxColumns: maxColumns)
-		}
-	}
-
-	private func headerColumnCount(in contents: String) -> Int {
-		guard !contents.isEmpty else {
-			return 0
-		}
-
-		var columnCount = 1
-		var isInsideQuotes = false
-		var index = contents.startIndex
-		while index < contents.endIndex {
-			let character = contents[index]
-			if character == "\n" || character == "\r" {
-				break
-			}
-			if character == "\"" {
-				let nextIndex = contents.index(after: index)
-				if isInsideQuotes, nextIndex < contents.endIndex, contents[nextIndex] == "\"" {
-					index = nextIndex
-				} else {
-					isInsideQuotes.toggle()
-				}
-			} else if character == "\t", !isInsideQuotes {
-				columnCount += 1
-			}
-			index = contents.index(after: index)
-		}
-		return columnCount
-	}
-}
-
-private enum TSVPreviewError: LocalizedError {
-	case fileSizeLimitExceeded(maxSize: Int)
-	case columnLimitExceeded(maxColumns: Int)
-
-	var errorDescription: String? {
-		switch self {
-			case let .fileSizeLimitExceeded(maxSize):
-				NSLocalizedString(
-					"TSV file exceeds the \(maxSize) byte preview limit",
-					comment: ""
-				)
-			case let .columnLimitExceeded(maxColumns):
-				NSLocalizedString(
-					"TSV header exceeds the \(maxColumns) column preview limit",
-					comment: ""
-				)
+	func createPreviewVC(file: File) async throws -> PreviewVC {
+		let fileURL = file.url
+		let payload = try await PreviewExecutor.run {
+			let data = try Data(contentsOf: fileURL, options: .mappedIfSafe)
+			return try PreviewCoreBridge.parseTSV(data)
 		}
+		return TablePreviewVC(headers: payload.headers, cells: payload.rows)
 	}
 }
diff --git a/QLPlugin/Views/Previews/ZIPPreview.swift b/QLPlugin/Views/Previews/ZIPPreview.swift
index 20b585e..cd07fbd 100644
--- a/QLPlugin/Views/Previews/ZIPPreview.swift
+++ b/QLPlugin/Views/Previews/ZIPPreview.swift
@@ -1,102 +1,69 @@
 import Foundation
-import ZIPFoundation
 
 class ZIPPreview: Preview {
 	let byteCountFormatter = ByteCountFormatter()
-	private let maxEntryCount = 50_000
 
 	required init() {}
 
-	private func makeFileTree(from archive: Archive) throws -> (
-		fileTree: FileTree,
-		uncompressedSize: Int,
-		compressedSize: Int
-	) {
-		let fileTree = FileTree()
-		var uncompressedSize = 0
-		var compressedSize = 0
-		var entryCount = 0
-
-		for entry in archive {
-			guard entryCount < maxEntryCount else {
-				throw ZIPPreviewError.entryCountLimitExceeded(maxEntryCount: maxEntryCount)
-			}
-			entryCount += 1
-
-			let entryUncompressedSize = try Self.checkedArchiveSize(entry.uncompressedSize)
-			let entryCompressedSize = try Self.checkedArchiveSize(entry.compressedSize)
-			try Self.checkedAdd(entryUncompressedSize, to: &uncompressedSize)
-			try Self.checkedAdd(entryCompressedSize, to: &compressedSize)
+	func createPreviewVC(file: File) async throws -> PreviewVC {
+		let fileURL = file.url
+		let archiveSize = file.size
+		let payload = try await PreviewExecutor.run {
+			try PreviewCoreBridge.scanZIP(at: fileURL)
+		}
+		let fileTree = try makeFileTree(from: payload.entries)
+		let uncompressedSize = try checkedInt(payload.uncompressedSize)
 
-			if entry.path == "__MACOSX" || entry.path.hasPrefix("__MACOSX/") {
-				continue
-			}
+		let labelText = """
+		Compressed: \(byteCountFormatter.string(for: archiveSize) ?? "--")
+		Uncompressed: \(byteCountFormatter.string(for: uncompressedSize) ?? "--")
+		Compression ratio: \(compressionRatioText(
+			compressed: payload.compressedSize,
+			uncompressed: payload.uncompressedSize
+		)) %
+		"""
+		return OutlinePreviewVC(rootNodes: fileTree.root.childrenList, labelText: labelText)
+	}
 
+	private func makeFileTree(from entries: [ArchivePreviewEntry]) throws -> FileTree {
+		let fileTree = FileTree()
+		for entry in entries {
 			do {
 				try fileTree.addNode(
 					path: entry.path,
-					isDirectory: entry.type == .directory,
-					size: entryUncompressedSize,
-					dateModified: entry.fileAttributes[.modificationDate] as? Date
+					isDirectory: entry.entryType == .directory,
+					size: try checkedInt(entry.size),
+					dateModified: entry.modifiedUnixSeconds.map(Date.init(timeIntervalSince1970:))
 				)
+			} catch let error as PreviewCoreBridgeError {
+				throw error
 			} catch {
 				Log.parse.error("\(error.localizedDescription, privacy: .private)")
 			}
 		}
-
-		return (fileTree, uncompressedSize, compressedSize)
+		return fileTree
 	}
 
-	static func checkedArchiveSize(_ size: UInt64) throws -> Int {
-		guard size <= UInt64(Int.max) else {
+	private func checkedInt(_ value: UInt64) throws -> Int {
+		guard value <= UInt64(Int.max) else {
 			throw ZIPPreviewError.metadataSizeLimitExceeded
 		}
-		return Int(size)
+		return Int(value)
 	}
 
-	private static func checkedAdd(_ value: Int, to total: inout Int) throws {
-		let result = total.addingReportingOverflow(value)
-		guard !result.overflow else {
-			throw ZIPPreviewError.metadataSizeLimitExceeded
-		}
-		total = result.partialValue
-	}
-
-	private func compressionRatioText(compressed: Int, uncompressed: Int) -> String {
-		if uncompressed == 0 {
+	private func compressionRatioText(compressed: UInt64, uncompressed: UInt64) -> String {
+		guard uncompressed != 0 else {
 			return "0.0"
 		}
 		let ratio = 100.0 - Double(compressed) / Double(uncompressed) * 100.0
 		return String(format: "%.1f", ratio)
 	}
-
-	func createPreviewVC(file: File) throws -> PreviewVC {
-		let archive = try Archive(url: file.url, accessMode: .read)
-		let (fileTree, uncompressedSize, compressedSize) = try makeFileTree(from: archive)
-
-		let labelText = """
-		Compressed: \(byteCountFormatter.string(for: file.size) ?? "--")
-		Uncompressed: \(byteCountFormatter.string(for: uncompressedSize) ?? "--")
-		Compression ratio: \(compressionRatioText(compressed: compressedSize, uncompressed: uncompressedSize)) %
-		"""
-
-		return OutlinePreviewVC(rootNodes: fileTree.root.childrenList, labelText: labelText)
-	}
 }
 
 enum ZIPPreviewError: LocalizedError {
-	case entryCountLimitExceeded(maxEntryCount: Int)
 	case metadataSizeLimitExceeded
 
 	var errorDescription: String? {
-		switch self {
-			case let .entryCountLimitExceeded(maxEntryCount):
-				NSLocalizedString(
-					"ZIP archive metadata exceeds the \(maxEntryCount) entry preview limit",
-					comment: ""
-				)
-			case .metadataSizeLimitExceeded:
-				NSLocalizedString("ZIP archive metadata is too large to preview safely", comment: "")
-		}
+		NSLocalizedString("ZIP archive metadata is too large to preview safely", comment: "")
 	}
 }

From b718e743f4d60b2d0792330834c5fee69cc01860 Mon Sep 17 00:00:00 2001
From: ranokay 
Date: Wed, 12 Aug 2026 16:23:45 +0300
Subject: [PATCH 3/7] chore: prepare 1.6.0 release

---
 .github/workflows/release.yml        | 61 ++++++------------
 .github/workflows/verify.yml         | 59 ++++++++++++++++++
 AppStore/Listing/Description.txt     |  2 +-
 Glance/Credits.rtf                   | 92 +++++-----------------------
 GlanceTests/PlistCoverageTests.swift | 23 ++++---
 README.md                            | 17 ++---
 RELEASE_NOTES.md                     | 16 +++++
 7 files changed, 134 insertions(+), 136 deletions(-)
 create mode 100644 .github/workflows/verify.yml
 create mode 100644 RELEASE_NOTES.md

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e5be218..8a9a098 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -65,53 +65,15 @@ jobs:
           mise trust mise.toml
           mise install
 
-      - name: Run tests
+      - name: Run production verification
         run: |
           set -euo pipefail
-          mise run test
+          mise run verify
 
-      - name: Build release app
+      - name: Verify release app
         run: |
           set -euo pipefail
-          mise run clean
-          mise run build
-
-      - name: Verify Apple Silicon artifacts
-        run: |
-          set -euo pipefail
-
-          APP_PATH="build/Build/Products/Release/Glance.app"
-          test -d "$APP_PATH"
-          lipo -info HTMLConverter/htmlconverter.a | tee /tmp/htmlconverter-lipo.txt
-          if grep -q "x86_64" /tmp/htmlconverter-lipo.txt; then
-            echo "HTMLConverter archive contains x86_64" >&2
-            exit 1
-          fi
-          if ! grep -q "arm64" /tmp/htmlconverter-lipo.txt; then
-            echo "HTMLConverter archive does not contain arm64" >&2
-            exit 1
-          fi
-
-          found_macho=0
-          while IFS= read -r -d '' candidate; do
-            if info="$(lipo -info "$candidate" 2>/dev/null)"; then
-              found_macho=1
-              echo "$info"
-              if echo "$info" | grep -q "x86_64"; then
-                echo "Bundle artifact contains x86_64: $candidate" >&2
-                exit 1
-              fi
-              if ! echo "$info" | grep -q "arm64"; then
-                echo "Bundle artifact does not contain arm64: $candidate" >&2
-                exit 1
-              fi
-            fi
-          done < <(find "$APP_PATH" -type f -print0)
-
-          if [ "$found_macho" -eq 0 ]; then
-            echo "No Mach-O artifacts found in $APP_PATH" >&2
-            exit 1
-          fi
+          mise run verify:app
 
       - name: Package DMG
         env:
@@ -155,11 +117,24 @@ jobs:
           DMG_PATH="dist/Glance-${VERSION}.dmg"
           MOUNT_DIR="$(mktemp -d)"
 
+          (cd dist && shasum -a 256 -c "Glance-${VERSION}.dmg.sha256")
           hdiutil attach "$DMG_PATH" -mountpoint "$MOUNT_DIR" -nobrowse -quiet
           trap 'hdiutil detach "$MOUNT_DIR" -quiet || true; rmdir "$MOUNT_DIR" || true' EXIT
 
           test -d "$MOUNT_DIR/Glance.app"
           test -d "$MOUNT_DIR/Glance.app/Contents/PlugIns/QLPlugin.appex"
+          test "$(plutil -extract CFBundleShortVersionString raw -o - "$MOUNT_DIR/Glance.app/Contents/Info.plist")" = "$VERSION"
+          codesign --verify --deep --strict --verbose=2 "$MOUNT_DIR/Glance.app"
+          QL_EXECUTABLE="$MOUNT_DIR/Glance.app/Contents/PlugIns/QLPlugin.appex/Contents/MacOS/QLPlugin"
+          lipo -info "$QL_EXECUTABLE" | grep -q "arm64"
+          if lipo -info "$QL_EXECUTABLE" | grep -q "x86_64"; then
+            echo "Published Quick Look executable contains x86_64" >&2
+            exit 1
+          fi
+          if strings "$QL_EXECUTABLE" | grep -Eq 'go\.buildid|runtime\.morestack|github\.com/alecthomas/chroma|github\.com/yuin/goldmark'; then
+            echo "Published Quick Look executable contains the removed Go runtime" >&2
+            exit 1
+          fi
 
           hdiutil detach "$MOUNT_DIR" -quiet
           trap - EXIT
@@ -178,4 +153,4 @@ jobs:
             "dist/Glance-${VERSION}.dmg.sha256" \
             --repo "$GITHUB_REPOSITORY" \
             --title "Glance ${VERSION}" \
-            --notes "Glance ${VERSION} for Apple silicon Macs running macOS 26 or later. This DMG is unsigned and unnotarized, so you may need to remove quarantine after installing. Verify the download with the attached SHA-256 checksum."
+            --notes-file RELEASE_NOTES.md
diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
new file mode 100644
index 0000000..695ad8f
--- /dev/null
+++ b/.github/workflows/verify.yml
@@ -0,0 +1,59 @@
+name: Production Verification
+
+on:
+  pull_request:
+    branches:
+      - main
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+concurrency:
+  group: verify-${{ github.event.pull_request.number || github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  verify:
+    name: Rust, Xcode, and release gates
+    runs-on: macos-26
+    timeout-minutes: 45
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+        with:
+          persist-credentials: false
+
+      - name: Assert macOS 26 Apple Silicon toolchain
+        run: |
+          set -euo pipefail
+          test "$(uname -m)" = "arm64"
+          test "$(sw_vers -productVersion | cut -d. -f1)" = "26"
+          test "$(xcodebuild -version | awk 'NR == 1 { split($2, version, "."); print version[1] }')" = "26"
+
+      - name: Install checksum-verified mise
+        run: |
+          set -euo pipefail
+          MISE_VERSION="2026.6.0"
+          MISE_TARBALL="mise-v${MISE_VERSION}-macos-arm64.tar.gz"
+          curl -fsSLO "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/${MISE_TARBALL}"
+          echo "8830cf720025583bfbc5ca77bcf15958bf6efb4743fb84be6b572fd85d3a6cd0  ${MISE_TARBALL}" | shasum -a 256 -c -
+          tar -xzf "$MISE_TARBALL"
+          mkdir -p "$HOME/.local/bin"
+          cp mise/bin/mise "$HOME/.local/bin/mise"
+          echo "$HOME/.local/bin" >> "$GITHUB_PATH"
+
+      - name: Install pinned tools
+        env:
+          MISE_YES: "1"
+        run: |
+          set -euo pipefail
+          mise trust mise.toml
+          mise install
+
+      - name: Run production verification
+        run: |
+          set -euo pipefail
+          mise run verify
+          mise run verify:app
diff --git a/AppStore/Listing/Description.txt b/AppStore/Listing/Description.txt
index 8987317..ea19ce7 100644
--- a/AppStore/Listing/Description.txt
+++ b/AppStore/Listing/Description.txt
@@ -2,7 +2,7 @@ See what's in your files without opening them!
 
 Glance boosts your productivity by providing Quick Look previews for files that macOS doesn't support out of the box.
 
-Version 1.5.9 is built for Apple silicon Macs running macOS 26 or later.
+Version 1.6.0 is built for Apple silicon Macs running macOS 26 or later.
 
 Features:
 • Beautiful file previews for various file types
diff --git a/Glance/Credits.rtf b/Glance/Credits.rtf
index 53dc845..7782928 100644
--- a/Glance/Credits.rtf
+++ b/Glance/Credits.rtf
@@ -32,11 +32,12 @@ Portions of this software may utilize the following copyrighted material, the us
 \
 \pard\pardeftab720\partightenfactor0
 
-\f0\b \cf2 Chroma\
+\f0\b \cf2 two-face and syntect\
 \pard\pardeftab720\partightenfactor0
 
 \f1\b0 \cf2 \
-Copyright (C) 2017 Alec Thomas\
+Copyright (c) 2023-2025 The two-face developers\
+Copyright (c) 2017 Tristan Hume, Keith Hall, Google Inc and other contributors\
 \
 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\
 \
@@ -64,30 +65,32 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
 \
 \pard\pardeftab720\partightenfactor0
 
-\f0\b \cf2 goldmark\
+\f0\b \cf2 Comrak\
 \pard\pardeftab720\partightenfactor0
 
 \f1\b0 \cf2 \
-MIT License\
+Copyright (c) 2017-2025, Comrak contributors\
 \
-Copyright (c) 2019 Yusuke Inuzuka\
+All rights reserved.\
 \
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:\
 \
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\
+Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.\
 \
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\
+Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.\
+\
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\
 \
 \
 \pard\pardeftab720\partightenfactor0
 
-\f0\b \cf2 nbtohtml\
+\f0\b \cf2 Ammonia\
 \
 \pard\pardeftab720\partightenfactor0
 
 \f1\b0 \cf2 MIT License\
 \
-Copyright (c) 2020 Samuel Meuli\
+Copyright (c) 2015-2022 The ammonia Developers\
 \
 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\
 \
@@ -97,73 +100,10 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
 \
 \
 
-\f0\b \cf0 \kerning1\expnd0\expndtw0 SWCompression
-\f1\b0 \cf2 \expnd0\expndtw0\kerning0
-\
-\
-MIT License\
-\
-Copyright (c) 2021 Timofey Solomko\
-\
-Permission is hereby granted, free of charge, to any person obtaining a copy\
-of this software and associated documentation files (the "Software"), to deal\
-in the Software without restriction, including without limitation the rights\
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\
-copies of the Software, and to permit persons to whom the Software is\
-furnished to do so, subject to the following conditions:\
-\
-The above copyright notice and this permission notice shall be included in all\
-copies or substantial portions of the Software.\
-\
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\
-SOFTWARE.\
-\
-\
-\pard\pardeftab720\partightenfactor0
-
-\f0\b \cf2 SwiftCSV\
+\f0\b \cf2 PreviewCore parser and support libraries\
 \pard\pardeftab720\partightenfactor0
 
 \f1\b0 \cf2 \
-The MIT License (MIT)\
-\
-Copyright (c) 2014 Naoto Kaneko\
-\
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\
-\
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\
-\
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\
-\
-\
-
-\f0\b \cf0 \kerning1\expnd0\expndtw0 ZIPFoundation
-\f1\b0 \cf2 \expnd0\expndtw0\kerning0
-\
-\
-MIT License\
+Glance's Rust preview core directly uses ansi-to-html (MIT), base64 (MIT or Apache-2.0), csv (Unlicense or MIT), flate2 (MIT or Apache-2.0), serde and serde_json (MIT or Apache-2.0), sevenz-rust2 (Apache-2.0), and zip (MIT). The complete locked dependency graph and license identifiers are documented in PreviewCore/THIRD_PARTY_LICENSES.md.\
 \
-Copyright (c) 2017-2021 Thomas Zoechling (https://www.peakstep.com)\
-\
-Permission is hereby granted, free of charge, to any person obtaining a copy\
-of this software and associated documentation files (the "Software"), to deal\
-in the Software without restriction, including without limitation the rights\
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\
-copies of the Software, and to permit persons to whom the Software is\
-furnished to do so, subject to the following conditions:\
-\
-The above copyright notice and this permission notice shall be included in all\
-copies or substantial portions of the Software.\
-\
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\
-SOFTWARE.}
\ No newline at end of file
+The MIT License text reproduced above applies where selected. Apache-2.0 licensed components are distributed under the Apache License, Version 2.0, available at https://www.apache.org/licenses/LICENSE-2.0. Components offering the Unlicense are available under https://unlicense.org/.}
diff --git a/GlanceTests/PlistCoverageTests.swift b/GlanceTests/PlistCoverageTests.swift
index 8f369e8..5eaec57 100644
--- a/GlanceTests/PlistCoverageTests.swift
+++ b/GlanceTests/PlistCoverageTests.swift
@@ -8,6 +8,7 @@ final class PlistCoverageTests: XCTestCase {
 			"org.7-zip.7-zip-archive",
 			"com.sun.java-archive",
 			"com.sun.web-application-archive",
+			"org.gnu.gnu-zip-archive",
 			"org.gnu.gnu-zip-tar-archive",
 			"public.tar-archive",
 			"public.zip-archive",
@@ -71,9 +72,15 @@ final class PlistCoverageTests: XCTestCase {
 				.appendingPathComponent("release.yml"),
 			encoding: .utf8
 		)
+		let rustBuildScriptContents = try String(
+			contentsOf: repositoryRoot()
+				.appendingPathComponent("PreviewCore", isDirectory: true)
+				.appendingPathComponent("build-xcode.sh"),
+			encoding: .utf8
+		)
 
 		XCTAssertTrue(projectContents.contains("MACOSX_DEPLOYMENT_TARGET = 26.0;"))
-		XCTAssertTrue(projectContents.contains("MACOSX_DEPLOYMENT_TARGET:-26.0"))
+		XCTAssertTrue(rustBuildScriptContents.contains("MACOSX_DEPLOYMENT_TARGET:-26.0"))
 		XCTAssertFalse(projectContents.contains("MACOSX_DEPLOYMENT_TARGET = 15.0;"))
 		XCTAssertTrue(workflowContents.contains("runs-on: macos-26"))
 		XCTAssertTrue(workflowContents.contains("Release builds require Xcode 26"))
@@ -85,13 +92,13 @@ final class PlistCoverageTests: XCTestCase {
 			encoding: .utf8
 		)
 
-		XCTAssertTrue(miseContents.contains("go = \"1.26.5\""))
+		XCTAssertTrue(miseContents.contains("rust = \"1.97.1\""))
 		XCTAssertTrue(miseContents.contains("swiftformat = \"0.61.1\""))
 		XCTAssertTrue(miseContents.contains("swiftlint = \"0.63.3\""))
 		XCTAssertFalse(miseContents.contains("= \"latest\""))
 	}
 
-	func testReleaseMetadataUsesVersion1_5_9Build19() throws {
+	func testReleaseMetadataUsesVersion1_6_0Build20() throws {
 		let projectContents = try String(
 			contentsOf: repositoryRoot()
 				.appendingPathComponent("Glance.xcodeproj", isDirectory: true)
@@ -111,16 +118,16 @@ final class PlistCoverageTests: XCTestCase {
 		)
 
 		XCTAssertEqual(
-			projectContents.components(separatedBy: "MARKETING_VERSION = 1.5.9;").count - 1,
+			projectContents.components(separatedBy: "MARKETING_VERSION = 1.6.0;").count - 1,
 			4
 		)
 		XCTAssertEqual(
-			projectContents.components(separatedBy: "CURRENT_PROJECT_VERSION = 19;").count - 1,
+			projectContents.components(separatedBy: "CURRENT_PROJECT_VERSION = 20;").count - 1,
 			4
 		)
-		XCTAssertTrue(readmeContents.contains("Version 1.5.9 (build 19)"))
-		XCTAssertTrue(readmeContents.contains("Version **1.5.9** (build **19**)"))
-		XCTAssertTrue(listingContents.contains("Version 1.5.9"))
+		XCTAssertTrue(readmeContents.contains("Version 1.6.0 (build 20)"))
+		XCTAssertTrue(readmeContents.contains("Version **1.6.0** (build **20**)"))
+		XCTAssertTrue(listingContents.contains("Version 1.6.0"))
 	}
 
 	func testUserFacingRepositoryLinksPointToMaintainedFork() throws {
diff --git a/README.md b/README.md
index 306f3ae..66c6592 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
 	

Glance

All-in-one Quick Look plugin

Glance provides Quick Look previews for files that macOS doesn't support out of the box.

-

Version 1.5.9 (build 19) · macOS 26 or later · Apple silicon

+

Version 1.6.0 (build 20) · macOS 26 or later · Apple silicon

Installation Steps

@@ -21,6 +21,7 @@ > - `.ini`, `.toml`, `.ttml`, and `.elrc` source-code previews > - expanded archive support > - safer bundled WebKit rendering +> - a bounded Rust preview core with responsive off-main rendering and parsing > - pinned `mise` build and test tooling with expanded automated coverage > - downloadable unsigned DMGs from this fork's GitHub Releases > - Settings controls for hiding the Dock icon @@ -31,7 +32,7 @@ Glance extends the native Quick Look experience in Finder, Spotlight, and the Space-bar preview window. This maintained fork builds on [chamburr/glance](https://github.com/chamburr/glance) and the [original Glance plugin](https://github.com/samuelmeuli/glance), modernizing the app for current macOS releases while retaining local, lightweight previews. -Version **1.5.9** (build **19**) requires an Apple silicon Mac running macOS 26 or later. It uses native macOS materials and controls without stacking custom glass effects inside Quick Look's own window chrome. +Version **1.6.0** (build **20**) requires an Apple silicon Mac running macOS 26 or later. It uses native macOS materials and controls without stacking custom glass effects inside Quick Look's own window chrome. ## Installation @@ -63,11 +64,11 @@ Release DMGs are currently unsigned and unnotarized. To install Glance: Look host forwards that key. The utility bar can open the top-level file or a selected nested item once with any compatible app without changing its macOS default. -- **Source code and text** (with [Chroma](https://github.com/alecthomas/chroma) syntax highlighting): `.cpp`, `.elrc`, `.ini`, `.js`, `.json`, `.py`, `.swift`, `.toml`, `.ttml`, `.yml`, common extensionless configuration files, and many more +- **Source code and text** (with [two-face/syntect](https://github.com/CosmicHorrorDev/two-face) syntax highlighting): `.cpp`, `.elrc`, `.ini`, `.js`, `.json`, `.py`, `.swift`, `.toml`, `.ttml`, `.yml`, common extensionless configuration files, and many more

-- **Markdown** (rendered using [goldmark](https://github.com/yuin/goldmark)): `.md`, `.markdown`, `.mdown`, `.mkdn`, `.mkd`, `.Rmd`, `.qmd` +- **Markdown** (rendered using [Comrak](https://github.com/kivikakk/comrak)): `.md`, `.markdown`, `.mdown`, `.mkdn`, `.mkd`, `.Rmd`, `.qmd`

@@ -75,11 +76,11 @@ Release DMGs are currently unsigned and unnotarized. To install Glance:

-- **Jupyter Notebook** (rendered using [nbtohtml](https://github.com/samuelmeuli/nbtohtml)): `.ipynb` +- **Jupyter Notebook** (rendered by Glance's Rust preview core): `.ipynb`

-- **Tab-separated values** (parsed using [SwiftCSV](https://github.com/swiftcsv/SwiftCSV)): `.tab`, `.tsv` +- **Tab-separated values** (parsed by Glance's bounded Rust preview core): `.tab`, `.tsv`

@@ -134,10 +135,10 @@ Suggestions and contributions are always welcome! Please discuss larger changes Building requires Xcode 26 on an Apple silicon Mac running macOS 26, plus [mise-en-place](https://mise.jdx.dev/). From the repository root, run `mise install` once to install -the pinned Go, SwiftFormat, and SwiftLint versions. +the pinned Rust, SwiftFormat, and SwiftLint versions. Common local commands are: -- `mise run test` to run the Go and Xcode test suites +- `mise run test` to run the Rust and Xcode test suites - `mise run build` to build the app in the `build` directory - `mise run verify` to run tests and a release build - `mise run all` to build, install into `/Applications`, register the Quick Look extension, and diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..a3071c2 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,16 @@ +# Glance 1.6.0 + +Glance 1.6.0 replaces its HTML renderer and untrusted file-format parsers with a bounded Rust +preview core while preserving the native Swift/AppKit Quick Look experience. + +- Source, Markdown, and Jupyter rendering now use the Rust preview core with the existing visual + styles and stricter handling of unsafe HTML and URLs. +- TSV, ZIP/JAR/EAR/WAR, TAR/tgz, and 7z metadata parsing now runs in Rust with explicit input, + entry-count, metadata, and scan limits; Swift retains the bounded archive-tree construction. +- Preview rendering, parser work, JSON decoding, and directory enumeration run outside the main + thread and discard cancelled results. +- The Go runtime and the SwiftCSV, ZIPFoundation, SWCompression, and BitByteData packages have been + removed from the shipping app. + +This release remains Apple-silicon-only for macOS 26 or later. Its DMG is unsigned and +unnotarized; verify the published SHA-256 checksum before installation. From 026c9d961dba2a1fc53a77c6c5fb644c2bf8ff2e Mon Sep 17 00:00:00 2001 From: ranokay Date: Wed, 12 Aug 2026 16:32:35 +0300 Subject: [PATCH 4/7] fix: install Rust lint components in CI --- .github/workflows/release.yml | 1 + .github/workflows/verify.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a9a098..9824414 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,6 +64,7 @@ jobs: set -euo pipefail mise trust mise.toml mise install + mise exec -- rustup component add rustfmt clippy - name: Run production verification run: | diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 695ad8f..66d44f1 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -51,6 +51,7 @@ jobs: set -euo pipefail mise trust mise.toml mise install + mise exec -- rustup component add rustfmt clippy - name: Run production verification run: | From ce271ebe8e3bbfcb4a6a2e51fb773d6d6f653b02 Mon Sep 17 00:00:00 2001 From: ranokay Date: Wed, 12 Aug 2026 16:37:51 +0300 Subject: [PATCH 5/7] fix: address preview migration review findings --- GlanceTests/PreviewSmokeTests.swift | 25 ++++++++++++ PreviewCore/Cargo.lock | 1 + PreviewCore/Cargo.toml | 1 + PreviewCore/THIRD_PARTY_LICENSES.md | 1 + PreviewCore/src/zip.rs | 51 ++++++++++++------------ QLPlugin/MainVC.swift | 5 +++ QLPlugin/Views/Previews/ZIPPreview.swift | 3 +- 7 files changed, 61 insertions(+), 26 deletions(-) diff --git a/GlanceTests/PreviewSmokeTests.swift b/GlanceTests/PreviewSmokeTests.swift index 3f857ec..0b5be06 100644 --- a/GlanceTests/PreviewSmokeTests.swift +++ b/GlanceTests/PreviewSmokeTests.swift @@ -250,6 +250,31 @@ final class PreviewSmokeTests: XCTestCase { XCTAssertTrue(mainVC.currentPreviewController is WebPreviewVC) } + func testMainVCSupersededPreparationCompletesWithoutReplacingLatestPreview() async throws { + let firstURL = try writeFile(named: "first.swift", contents: "let first = true\n") + let latestURL = try writeFile(named: "latest.swift", contents: "let latest = true\n") + let mainVC = MainVC() + mainVC.containingAppIsRunning = { true } + mainVC.loadViewIfNeeded() + let firstCompletion = expectation(description: "superseded preparation completed") + let latestCompletion = expectation(description: "latest preparation completed") + firstCompletion.assertForOverFulfill = true + latestCompletion.assertForOverFulfill = true + + mainVC.preparePreviewOfFile(at: firstURL) { error in + XCTAssertNil(error) + firstCompletion.fulfill() + } + mainVC.preparePreviewOfFile(at: latestURL) { error in + XCTAssertNil(error) + latestCompletion.fulfill() + } + + await fulfillment(of: [firstCompletion, latestCompletion], timeout: 5) + XCTAssertEqual(mainVC.topLevelFile?.url, latestURL) + XCTAssertTrue(mainVC.currentPreviewController is WebPreviewVC) + } + func testTSVPreviewHandlesQuotedTabsUnicodeAndBlankCells() async throws { let tsv = """ name\tvalue diff --git a/PreviewCore/Cargo.lock b/PreviewCore/Cargo.lock index ed7fc26..d9cb402 100644 --- a/PreviewCore/Cargo.lock +++ b/PreviewCore/Cargo.lock @@ -285,6 +285,7 @@ dependencies = [ "comrak", "csv", "flate2", + "libc", "serde", "serde_json", "sevenz-rust2", diff --git a/PreviewCore/Cargo.toml b/PreviewCore/Cargo.toml index ce30560..a36eccb 100644 --- a/PreviewCore/Cargo.toml +++ b/PreviewCore/Cargo.toml @@ -15,6 +15,7 @@ base64 = "0.23.1" comrak = { version = "0.54.0", default-features = false } csv = "=1.4.0" flate2 = { version = "=1.1.9", default-features = false, features = ["rust_backend"] } +libc = "=0.2.189" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" sevenz-rust2 = { version = "=0.21.4", default-features = false, features = [ diff --git a/PreviewCore/THIRD_PARTY_LICENSES.md b/PreviewCore/THIRD_PARTY_LICENSES.md index 9dd8cb2..1173602 100644 --- a/PreviewCore/THIRD_PARTY_LICENSES.md +++ b/PreviewCore/THIRD_PARTY_LICENSES.md @@ -25,6 +25,7 @@ and [bzip2](https://sourceware.org/bzip2/). | flate2 | 1.1.9 | MIT OR Apache-2.0 | | ICU4X Unicode data crates | locked versions | Unicode-3.0 | | libbz2-rs-sys | 0.2.5 | bzip2-1.0.6 | +| libc | 0.2.189 | MIT OR Apache-2.0 | | lzma-rust2 | 0.18.1 | Apache-2.0 | | miniz_oxide | 0.8.9 | MIT OR Zlib OR Apache-2.0 | | onig, onig_sys | 6.5.3, 69.9.3 | MIT | diff --git a/PreviewCore/src/zip.rs b/PreviewCore/src/zip.rs index 3994361..287bc3e 100644 --- a/PreviewCore/src/zip.rs +++ b/PreviewCore/src/zip.rs @@ -60,7 +60,7 @@ pub(crate) fn scan_zip(path: &Path) -> Result { path, entry_type, size: entry.size(), - modified_unix_seconds: entry.last_modified().map(zip_datetime_to_unix), + modified_unix_seconds: entry.last_modified().and_then(zip_datetime_to_unix), }); } @@ -196,26 +196,19 @@ fn map_zip_error(error: ZipError) -> CoreError { } } -fn zip_datetime_to_unix(date: zip::DateTime) -> f64 { - let days = days_from_civil(i64::from(date.year()), date.month(), date.day()); - (days * 86_400 - + i64::from(date.hour()) * 3_600 - + i64::from(date.minute()) * 60 - + i64::from(date.second())) as f64 -} - -fn days_from_civil(year: i64, month: u8, day: u8) -> i64 { - let adjusted_year = year - i64::from(month <= 2); - let era = if adjusted_year >= 0 { - adjusted_year - } else { - adjusted_year - 399 - } / 400; - let year_of_era = adjusted_year - era * 400; - let shifted_month = i64::from(month) + if month > 2 { -3 } else { 9 }; - let day_of_year = (153 * shifted_month + 2) / 5 + i64::from(day) - 1; - let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; - era * 146_097 + day_of_era - 719_468 +fn zip_datetime_to_unix(date: zip::DateTime) -> Option { + // ZIP DOS timestamps are local wall-clock values without a time-zone offset. + // `mktime` applies the previewing Mac's current time zone and daylight-saving rules. + let mut local_time: libc::tm = unsafe { std::mem::zeroed() }; + local_time.tm_sec = i32::from(date.second()); + local_time.tm_min = i32::from(date.minute()); + local_time.tm_hour = i32::from(date.hour()); + local_time.tm_mday = i32::from(date.day()); + local_time.tm_mon = i32::from(date.month()) - 1; + local_time.tm_year = i32::from(date.year()) - 1900; + local_time.tm_isdst = -1; + let timestamp = unsafe { libc::mktime(&mut local_time) }; + (timestamp != -1).then_some(timestamp as f64) } fn read_u16(bytes: &[u8], offset: usize) -> Option { @@ -292,11 +285,19 @@ mod tests { } #[test] - fn converts_dos_dates_to_unix_seconds() { - let date = zip::DateTime::from_date_and_time(1980, 1, 1, 0, 0, 0).unwrap(); - assert_eq!(zip_datetime_to_unix(date), 315_532_800.0); + fn preserves_dos_timestamps_as_local_wall_time() { let date = zip::DateTime::from_date_and_time(2024, 2, 29, 12, 34, 56).unwrap(); - assert_eq!(zip_datetime_to_unix(date), 1_709_210_096.0); + let timestamp = zip_datetime_to_unix(date).unwrap() as libc::time_t; + let mut local_time: libc::tm = unsafe { std::mem::zeroed() }; + let converted = unsafe { libc::localtime_r(×tamp, &mut local_time) }; + + assert!(!converted.is_null()); + assert_eq!(local_time.tm_year + 1900, 2024); + assert_eq!(local_time.tm_mon + 1, 2); + assert_eq!(local_time.tm_mday, 29); + assert_eq!(local_time.tm_hour, 12); + assert_eq!(local_time.tm_min, 34); + assert_eq!(local_time.tm_sec, 56); } #[test] diff --git a/QLPlugin/MainVC.swift b/QLPlugin/MainVC.swift index 7799684..81db0c1 100644 --- a/QLPlugin/MainVC.swift +++ b/QLPlugin/MainVC.swift @@ -162,6 +162,10 @@ class MainVC: NSViewController, QLPreviewingController { do { try await task.value handler(nil) + } catch is CancellationError { + // Superseded requests still complete exactly once without asking Quick Look + // to replace the newer preview with its fallback UI. + handler(nil) } catch { handler(error) } @@ -220,6 +224,7 @@ class MainVC: NSViewController, QLPreviewingController { // Generate file preview let previewInitializer = previewInitializerType.init() let previewVC = try await previewInitializer.createPreviewVC(file: file) + try Task.checkCancellation() installTopLevelPreview(previewVC, file: file) diff --git a/QLPlugin/Views/Previews/ZIPPreview.swift b/QLPlugin/Views/Previews/ZIPPreview.swift index cd07fbd..f332b45 100644 --- a/QLPlugin/Views/Previews/ZIPPreview.swift +++ b/QLPlugin/Views/Previews/ZIPPreview.swift @@ -28,11 +28,12 @@ class ZIPPreview: Preview { private func makeFileTree(from entries: [ArchivePreviewEntry]) throws -> FileTree { let fileTree = FileTree() for entry in entries { + let size = try checkedInt(entry.size) do { try fileTree.addNode( path: entry.path, isDirectory: entry.entryType == .directory, - size: try checkedInt(entry.size), + size: size, dateModified: entry.modifiedUnixSeconds.map(Date.init(timeIntervalSince1970:)) ) } catch let error as PreviewCoreBridgeError { From d38fcae501a47b4b1ac7aee2c2638246c7ce0f8b Mon Sep 17 00:00:00 2001 From: ranokay Date: Wed, 12 Aug 2026 16:53:34 +0300 Subject: [PATCH 6/7] fix: address production review findings --- Glance.xcodeproj/project.pbxproj | 12 ++--- GlanceTests/PreviewSmokeTests.swift | 23 ++++++-- PreviewCore/Cargo.toml | 3 ++ PreviewCore/THIRD_PARTY_LICENSES.md | 4 +- PreviewCore/build-xcode.sh | 13 +++-- PreviewCore/include/glance_preview_core.h | 16 ++++++ PreviewCore/src/ffi.rs | 12 +++++ PreviewCore/src/markdown.rs | 1 + PreviewCore/src/notebook.rs | 25 ++++++--- PreviewCore/src/sevenzip.rs | 36 +++++++------ PreviewCore/src/tar.rs | 53 ++++++++++++------- PreviewCore/src/tsv.rs | 37 +++++++++++-- PreviewCore/src/zip.rs | 21 ++++++-- QLPlugin/MainVC.swift | 5 ++ QLPlugin/Utils/HTMLRenderer.swift | 2 +- QLPlugin/Utils/PreviewCoreBridge.swift | 4 +- QLPlugin/Utils/PreviewExecutor.swift | 3 ++ QLPlugin/Views/Previews/CodePreview.swift | 2 + .../Views/Previews/DirectoryPreview.swift | 2 +- QLPlugin/Views/Previews/JupyterPreview.swift | 2 + QLPlugin/Views/Previews/SevenZipPreview.swift | 2 +- QLPlugin/Views/Previews/TSVPreview.swift | 2 +- RELEASE_NOTES.md | 4 +- mise.toml | 2 +- 24 files changed, 215 insertions(+), 71 deletions(-) diff --git a/Glance.xcodeproj/project.pbxproj b/Glance.xcodeproj/project.pbxproj index b25c7ba..2fd0fc6 100644 --- a/Glance.xcodeproj/project.pbxproj +++ b/Glance.xcodeproj/project.pbxproj @@ -224,7 +224,7 @@ 7E3DF744242CBE7F00DE7CD6 /* Main.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; 7E413F7F2418DD6200CFBB1D /* TSVPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSVPreview.swift; sourceTree = ""; }; 7E42B61C245857D9008D7116 /* glance_preview_core.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = glance_preview_core.h; path = PreviewCore/include/glance_preview_core.h; sourceTree = ""; }; - 7E42B61D245857D9008D7116 /* libglance_preview_core.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libglance_preview_core.a; path = PreviewCore/build/libglance_preview_core.a; sourceTree = ""; }; + 7E42B61D245857D9008D7116 /* libglance_preview_core.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libglance_preview_core.a; path = "PreviewCore/build/$(CONFIGURATION)/libglance_preview_core.a"; sourceTree = ""; }; 7E458AEF243944810091BD0F /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; 7E458AF42439481F0091BD0F /* Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Menu.swift; sourceTree = ""; }; 7E45F898244DA45500BFB869 /* FileTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTree.swift; sourceTree = ""; }; @@ -780,7 +780,7 @@ outputFileListPaths = ( ); outputPaths = ( - "$(PROJECT_DIR)/PreviewCore/build/libglance_preview_core.a", + "$(PROJECT_DIR)/PreviewCore/build/$(CONFIGURATION)/libglance_preview_core.a", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -937,7 +937,7 @@ "@loader_path/../Frameworks", ); LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/PreviewCore/build", + "$(PROJECT_DIR)/PreviewCore/build/$(CONFIGURATION)", ); PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance.GlanceTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -965,7 +965,7 @@ "@loader_path/../Frameworks", ); LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/PreviewCore/build", + "$(PROJECT_DIR)/PreviewCore/build/$(CONFIGURATION)", ); PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance.GlanceTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -995,7 +995,7 @@ "@executable_path/../../../../Frameworks", ); LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/PreviewCore/build", + "$(PROJECT_DIR)/PreviewCore/build/$(CONFIGURATION)", ); MARKETING_VERSION = 1.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance.QLPlugin; @@ -1026,7 +1026,7 @@ "@executable_path/../../../../Frameworks", ); LIBRARY_SEARCH_PATHS = ( - "$(PROJECT_DIR)/PreviewCore/build", + "$(PROJECT_DIR)/PreviewCore/build/$(CONFIGURATION)", ); MARKETING_VERSION = 1.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.chamburr.Glance.QLPlugin; diff --git a/GlanceTests/PreviewSmokeTests.swift b/GlanceTests/PreviewSmokeTests.swift index 0b5be06..f5c453d 100644 --- a/GlanceTests/PreviewSmokeTests.swift +++ b/GlanceTests/PreviewSmokeTests.swift @@ -246,10 +246,27 @@ final class PreviewSmokeTests: XCTestCase { } await fulfillment(of: [completion], timeout: 5) - try await Task.sleep(for: .milliseconds(50)) + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(5)) + while !(mainVC.currentPreviewController is WebPreviewVC), clock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } XCTAssertTrue(mainVC.currentPreviewController is WebPreviewVC) } + func testMainVCDeclinesUnsupportedGzipForSystemFallback() async throws { + let fileURL = try writeFile(named: "plain.gz", contents: "not a tarball") + let mainVC = MainVC() + mainVC.loadViewIfNeeded() + + await XCTAssertThrowsErrorAsync { + try await mainVC.previewFile(file: File(url: fileURL)) + } errorHandler: { error in + XCTAssertEqual((error as NSError).code, 2) + } + XCTAssertNil(mainVC.currentPreviewController) + } + func testMainVCSupersededPreparationCompletesWithoutReplacingLatestPreview() async throws { let firstURL = try writeFile(named: "first.swift", contents: "let first = true\n") let latestURL = try writeFile(named: "latest.swift", contents: "let latest = true\n") @@ -537,7 +554,7 @@ final class PreviewSmokeTests: XCTestCase { while webView.isLoading, Date() < deadline { RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) } - XCTAssertFalse(webView.isLoading) + XCTAssertFalse(webView.isLoading, "Web view did not finish loading within \(timeout) seconds") } private func waitForWebViewToFinishLoadingAsync( @@ -549,7 +566,7 @@ final class PreviewSmokeTests: XCTestCase { while webView.isLoading, clock.now < deadline { try await Task.sleep(for: .milliseconds(10)) } - XCTAssertFalse(webView.isLoading) + XCTAssertFalse(webView.isLoading, "Web view did not finish loading within \(timeout)") } private func waitForWebViewToBecomeVisible(_ webView: WKWebView, timeout: TimeInterval = 15) { diff --git a/PreviewCore/Cargo.toml b/PreviewCore/Cargo.toml index a36eccb..21c9814 100644 --- a/PreviewCore/Cargo.toml +++ b/PreviewCore/Cargo.toml @@ -9,6 +9,8 @@ publish = false crate-type = ["staticlib", "rlib"] [dependencies] +# These migration-critical renderer/parser versions are exact by design; Cargo.lock pins the +# complete transitive graph used by all locked production builds. ammonia = "4.1.4" ansi-to-html = "0.2.3" base64 = "0.23.1" @@ -30,3 +32,4 @@ zip = { version = "=8.6.0", default-features = false, features = ["time"] } [profile.release] codegen-units = 1 lto = "thin" +panic = "unwind" diff --git a/PreviewCore/THIRD_PARTY_LICENSES.md b/PreviewCore/THIRD_PARTY_LICENSES.md index 1173602..d619ce3 100644 --- a/PreviewCore/THIRD_PARTY_LICENSES.md +++ b/PreviewCore/THIRD_PARTY_LICENSES.md @@ -10,7 +10,9 @@ License texts are available from the linked standard licenses and each crate's s [MPL-2.0](https://www.mozilla.org/MPL/2.0/), [Unicode licenses](https://www.unicode.org/license.txt), [Unlicense](https://unlicense.org/), [Zlib](https://www.zlib.net/zlib_license.html), [BSL-1.0](https://www.boost.org/LICENSE_1_0.txt), [CC0-1.0](https://creativecommons.org/publicdomain/zero/1.0/), -and [bzip2](https://sourceware.org/bzip2/). +[MIT-0](https://opensource.org/license/mit-0), +[Unicode-DFS-2016](https://spdx.org/licenses/Unicode-DFS-2016.html), and +[bzip2-1.0.6](https://github.com/libarchive/bzip2/blob/bzip2-1.0.6/LICENSE). | Crate | Version | License expression | | --- | --- | --- | diff --git a/PreviewCore/build-xcode.sh b/PreviewCore/build-xcode.sh index 2230232..dc64141 100755 --- a/PreviewCore/build-xcode.sh +++ b/PreviewCore/build-xcode.sh @@ -10,7 +10,7 @@ fi PROJECT_ROOT="${PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" CORE_ROOT="$PROJECT_ROOT/PreviewCore" TARGET="aarch64-apple-darwin" -OUTPUT_DIRECTORY="$CORE_ROOT/build" +OUTPUT_DIRECTORY="$CORE_ROOT/build/${CONFIGURATION:-Debug}" export CARGO_TARGET_DIR="$CORE_ROOT/target" export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-26.0}" @@ -34,19 +34,24 @@ fi export MISE_TRUSTED_CONFIG_PATHS="$PROJECT_ROOT${MISE_TRUSTED_CONFIG_PATHS:+:$MISE_TRUSTED_CONFIG_PATHS}" PROFILE="debug" -PROFILE_ARGUMENT="" +set -- case "${CONFIGURATION:-Debug}" in Release|Profile) PROFILE="release" - PROFILE_ARGUMENT="--release" + set -- --release ;; esac +if ! "$MISE_BIN" exec -- rustup target list --installed | /usr/bin/grep -Fxq "$TARGET"; then + echo "Rust target $TARGET is not installed; run 'mise exec -- rustup target add $TARGET'" >&2 + exit 1 +fi + "$MISE_BIN" exec -- cargo build \ --locked \ --manifest-path "$CORE_ROOT/Cargo.toml" \ --target "$TARGET" \ - $PROFILE_ARGUMENT + "$@" /usr/bin/install -d "$OUTPUT_DIRECTORY" /usr/bin/install -m 0644 \ diff --git a/PreviewCore/include/glance_preview_core.h b/PreviewCore/include/glance_preview_core.h index d0cc04a..ca139f8 100644 --- a/PreviewCore/include/glance_preview_core.h +++ b/PreviewCore/include/glance_preview_core.h @@ -11,6 +11,22 @@ typedef struct GlanceRenderResult { int32_t status; } GlanceRenderResult; +enum { + GLANCE_STATUS_OK = 0, + GLANCE_STATUS_INVALID_INPUT = 1, + GLANCE_STATUS_PARSE_ERROR = 2, + GLANCE_STATUS_INTERNAL_ERROR = 3, + GLANCE_STATUS_IO_ERROR = 4, + GLANCE_STATUS_RESOURCE_LIMIT = 5, + GLANCE_STATUS_UNSUPPORTED = 6, +}; + +/* + * Successful results contain payload bytes. Nonzero statuses contain a UTF-8 error message. + * The caller owns every non-NULL data buffer and must release it exactly once with + * glance_render_buffer_free, passing the returned length unchanged. + */ + GlanceRenderResult glance_render_code( const uint8_t *source_data, size_t source_length, diff --git a/PreviewCore/src/ffi.rs b/PreviewCore/src/ffi.rs index 5cedab1..e2161f2 100644 --- a/PreviewCore/src/ffi.rs +++ b/PreviewCore/src/ffi.rs @@ -204,12 +204,24 @@ fn result_from_bytes(bytes: Vec, status: i32) -> GlanceRenderResult { result } +/// Converts one validated FFI pointer/length pair into UTF-8. +/// +/// # Safety +/// +/// The returned reference must not outlive the enclosing FFI call. Its lifetime is not tied to +/// the raw input pointer by the type system. unsafe fn utf8_input<'a>(data: *const u8, length: usize) -> Result<&'a str, CoreError> { let bytes = unsafe { byte_input(data, length, "Renderer")? }; str::from_utf8(bytes) .map_err(|error| CoreError::invalid(format!("Renderer input is not valid UTF-8: {error}"))) } +/// Converts one validated FFI pointer/length pair into bytes. +/// +/// # Safety +/// +/// The returned reference must not outlive the enclosing FFI call. Its lifetime is not tied to +/// the raw input pointer by the type system. unsafe fn byte_input<'a>( data: *const u8, length: usize, diff --git a/PreviewCore/src/markdown.rs b/PreviewCore/src/markdown.rs index 74a8371..32755b7 100644 --- a/PreviewCore/src/markdown.rs +++ b/PreviewCore/src/markdown.rs @@ -7,6 +7,7 @@ use std::borrow::Cow; pub(crate) fn render_markdown(source: &str) -> Result { let source = rewrite_front_matter(source); let mut options = Options::default(); + options.render.r#unsafe = false; options.extension.autolink = true; options.extension.strikethrough = true; options.extension.table = true; diff --git a/PreviewCore/src/notebook.rs b/PreviewCore/src/notebook.rs index bf54bbd..7c273dd 100644 --- a/PreviewCore/src/notebook.rs +++ b/PreviewCore/src/notebook.rs @@ -12,8 +12,7 @@ struct Notebook { cells: Vec, #[serde(default)] metadata: Metadata, - #[serde(default)] - nbformat: i64, + nbformat: Option, } #[derive(Debug, Default, Deserialize)] @@ -101,10 +100,18 @@ impl NotebookText { pub(crate) fn render_notebook(source: &str) -> Result { let notebook: Notebook = serde_json::from_str(source) .map_err(|error| RenderError::new(format!("Could not parse notebook JSON: {error}")))?; - if notebook.nbformat < 4 { - return Err(RenderError::new( - "The provided Jupyter Notebook uses an old format; version 4 or newer is required", - )); + match notebook.nbformat { + None => { + return Err(RenderError::new( + "The provided Jupyter Notebook does not declare an nbformat version", + )); + } + Some(version) if version < 4 => { + return Err(RenderError::new( + "The provided Jupyter Notebook uses an old format; version 4 or newer is required", + )); + } + Some(_) => {} } let language = notebook_language(¬ebook); @@ -326,6 +333,12 @@ mod tests { #[test] fn rejects_invalid_notebooks_and_images() { assert!(render_notebook("not json").is_err()); + assert!( + render_notebook(r#"{"cells":[],"metadata":{}}"#) + .unwrap_err() + .to_string() + .contains("does not declare an nbformat") + ); assert!(render_notebook(r#"{"cells":[],"metadata":{},"nbformat":3}"#).is_err()); assert!( render_notebook( diff --git a/PreviewCore/src/sevenzip.rs b/PreviewCore/src/sevenzip.rs index d6badd5..bd34329 100644 --- a/PreviewCore/src/sevenzip.rs +++ b/PreviewCore/src/sevenzip.rs @@ -4,6 +4,7 @@ use sevenz_rust2::{ArchiveReader, EncoderMethod, Error as SevenZipError, Passwor use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; +use std::sync::OnceLock; use std::time::{SystemTime, UNIX_EPOCH}; const SIGNATURE: &[u8; 6] = b"7z\xBC\xAF\x27\x1C"; @@ -62,8 +63,7 @@ pub(crate) fn scan_seven_zip(path: &Path) -> Result { size: entry.size, modified_unix_seconds: entry .has_last_modified_date - .then(|| system_time_to_unix(entry.last_modified_date.into())) - .flatten(), + .then(|| system_time_to_unix(entry.last_modified_date.into())), }); } @@ -151,26 +151,30 @@ fn map_seven_zip_error(error: SevenZipError) -> CoreError { } } -fn system_time_to_unix(time: SystemTime) -> Option { +fn system_time_to_unix(time: SystemTime) -> f64 { match time.duration_since(UNIX_EPOCH) { - Ok(duration) => Some(duration.as_secs_f64()), - Err(error) => Some(-error.duration().as_secs_f64()), + Ok(duration) => duration.as_secs_f64(), + Err(error) => -error.duration().as_secs_f64(), } } fn crc32(bytes: &[u8]) -> u32 { - let mut table = [0_u32; 256]; - for (index, value) in table.iter_mut().enumerate() { - let mut crc = index as u32; - for _ in 0..8 { - crc = if crc & 1 == 1 { - 0xedb8_8320 ^ (crc >> 1) - } else { - crc >> 1 - }; + static TABLE: OnceLock<[u32; 256]> = OnceLock::new(); + let table = TABLE.get_or_init(|| { + let mut table = [0_u32; 256]; + for (index, value) in table.iter_mut().enumerate() { + let mut crc = index as u32; + for _ in 0..8 { + crc = if crc & 1 == 1 { + 0xedb8_8320 ^ (crc >> 1) + } else { + crc >> 1 + }; + } + *value = crc; } - *value = crc; - } + table + }); let mut crc = u32::MAX; for byte in bytes { crc = table[((crc ^ u32::from(*byte)) & 0xff) as usize] ^ (crc >> 8); diff --git a/PreviewCore/src/tar.rs b/PreviewCore/src/tar.rs index 5edeaf1..82b41ce 100644 --- a/PreviewCore/src/tar.rs +++ b/PreviewCore/src/tar.rs @@ -177,7 +177,7 @@ impl TarScanner { } self.pending_long_name = self .read_metadata(payload_size, padded_size)? - .map(|payload| metadata_string(&payload)); + .map(|payload| tar_string(&payload)); } TarEntryType::LongLink => { if self.should_stop_before_skipping(padded_size) { @@ -326,18 +326,23 @@ struct TarHeader { impl TarHeader { fn parse(block: &[u8; BLOCK_SIZE]) -> Result { let stored_checksum = tar_integer(&block[148..156])?; - let computed_checksum = block - .iter() - .enumerate() - .fold(0_u64, |total, (index, byte)| { - total - + u64::from(if (148..156).contains(&index) { + let (unsigned_checksum, signed_checksum) = + block + .iter() + .enumerate() + .fold((0_i64, 0_i64), |(unsigned, signed), (index, byte)| { + let byte = if (148..156).contains(&index) { b' ' } else { *byte - }) - }); - if stored_checksum != computed_checksum { + }; + (unsigned + i64::from(byte), signed + i64::from(byte as i8)) + }); + let checksum_matches = match i64::try_from(stored_checksum) { + Ok(stored) => stored == unsigned_checksum || stored == signed_checksum, + Err(_) => false, + }; + if !checksum_matches { return Err(CoreError::parse( "TAR archive has an invalid header checksum", )); @@ -447,14 +452,6 @@ fn tar_string(bytes: &[u8]) -> String { String::from_utf8_lossy(&bytes[..end]).into_owned() } -fn metadata_string(bytes: &[u8]) -> String { - let end = bytes - .iter() - .position(|byte| *byte == 0) - .unwrap_or(bytes.len()); - String::from_utf8_lossy(&bytes[..end]).into_owned() -} - fn tar_integer(bytes: &[u8]) -> Result { if bytes.first().is_some_and(|byte| byte & 0x80 != 0) { let mut value = 0_u64; @@ -522,6 +519,26 @@ mod tests { fs::remove_file(path).unwrap(); } + #[test] + fn accepts_historic_signed_header_checksum() { + let path = temporary_path("tar"); + let mut header = tar_header("signed.txt", 0, b'0'); + header[265] = 0xff; + header[148..156].fill(b' '); + let checksum = header + .iter() + .map(|byte| i64::from(*byte as i8)) + .sum::(); + header[148..156].copy_from_slice(format!("{checksum:06o}\0 ").as_bytes()); + let mut archive = header.to_vec(); + archive.extend_from_slice(&[0_u8; BLOCK_SIZE * 2]); + fs::write(&path, archive).unwrap(); + + let payload = scan_tar(&path, false).unwrap(); + assert_eq!(payload.entries[0].path, "signed.txt"); + fs::remove_file(path).unwrap(); + } + #[test] fn parses_base_256_numbers_and_rejects_overflow() { let mut bytes = [0_u8; 12]; diff --git a/PreviewCore/src/tsv.rs b/PreviewCore/src/tsv.rs index 6cc5423..5fad9e6 100644 --- a/PreviewCore/src/tsv.rs +++ b/PreviewCore/src/tsv.rs @@ -1,7 +1,7 @@ use crate::error::CoreError; use crate::model::TsvPayload; use csv::ReaderBuilder; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; pub(crate) const MAX_FILE_SIZE: usize = 25 * 1_024 * 1_024; pub(crate) const MAX_ROWS: usize = 5_000; @@ -36,17 +36,18 @@ fn parse_tsv_with_limits( .has_headers(true) .flexible(false) .from_reader(data); - let headers = reader + let raw_headers = reader .headers() .map_err(|error| CoreError::parse(format!("Could not parse TSV header: {error}")))? .iter() .map(str::to_owned) .collect::>(); - if headers.len() > max_columns { + if raw_headers.len() > max_columns { return Err(CoreError::limit(format!( "TSV header exceeds the {max_columns} column preview limit" ))); } + let headers = unique_headers(raw_headers); let mut rows = Vec::with_capacity(max_rows.min(256)); for record in reader.records().take(max_rows) { @@ -63,6 +64,26 @@ fn parse_tsv_with_limits( Ok(TsvPayload { headers, rows }) } +fn unique_headers(headers: Vec) -> Vec { + let mut used = BTreeSet::new(); + headers + .into_iter() + .map(|header| { + if used.insert(header.clone()) { + return header; + } + let mut suffix = 2; + loop { + let candidate = format!("{header} ({suffix})"); + if used.insert(candidate.clone()) { + return candidate; + } + suffix += 1; + } + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -92,6 +113,16 @@ mod tests { assert_eq!(payload.rows[0]["a"], "1"); } + #[test] + fn disambiguates_duplicate_and_pre_suffixed_headers() { + let payload = parse_tsv(b"name\tname (2)\tname\nfirst\tmiddle\tlast\n").unwrap(); + + assert_eq!(payload.headers, ["name", "name (2)", "name (3)"]); + assert_eq!(payload.rows[0]["name"], "first"); + assert_eq!(payload.rows[0]["name (2)"], "middle"); + assert_eq!(payload.rows[0]["name (3)"], "last"); + } + #[test] fn rejects_malformed_data_and_limits() { assert!(matches!( diff --git a/PreviewCore/src/zip.rs b/PreviewCore/src/zip.rs index 287bc3e..41d34d3 100644 --- a/PreviewCore/src/zip.rs +++ b/PreviewCore/src/zip.rs @@ -38,17 +38,16 @@ pub(crate) fn scan_zip(path: &Path) -> Result { "Encrypted ZIP archives are not supported", )); } + let path = entry.name().to_owned(); + if path == "__MACOSX" || path.starts_with("__MACOSX/") { + continue; + } compressed_size = compressed_size .checked_add(entry.compressed_size()) .ok_or_else(|| CoreError::limit("ZIP archive metadata size overflow"))?; uncompressed_size = uncompressed_size .checked_add(entry.size()) .ok_or_else(|| CoreError::limit("ZIP archive metadata size overflow"))?; - - let path = entry.name().to_owned(); - if path == "__MACOSX" || path.starts_with("__MACOSX/") { - continue; - } let entry_type = if entry.is_dir() { ArchiveEntryType::Directory } else if entry.is_file() { @@ -260,6 +259,18 @@ mod tests { assert!(payload.uncompressed_size > 0); } + #[test] + fn filtered_macos_metadata_does_not_affect_totals() { + let path = temporary_path(); + fs::write(&path, single_file_zip(b"__MACOSX/metadata", 0, false)).unwrap(); + + let payload = scan_zip(&path).unwrap(); + assert!(payload.entries.is_empty()); + assert_eq!(payload.compressed_size, 0); + assert_eq!(payload.uncompressed_size, 0); + fs::remove_file(path).unwrap(); + } + #[test] fn rejects_corruption_and_central_directory_limit_before_parsing() { let malformed = std::env::temp_dir().join(format!( diff --git a/QLPlugin/MainVC.swift b/QLPlugin/MainVC.swift index 81db0c1..a07b9c8 100644 --- a/QLPlugin/MainVC.swift +++ b/QLPlugin/MainVC.swift @@ -234,6 +234,11 @@ class MainVC: NSViewController, QLPreviewingController { Log.general.info( "Skipping preview for file \(file.path, privacy: .private): File type not supported" ) + throw NSError( + domain: "com.chamburr.Glance.QLPlugin", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "File type is not supported"] + ) } } diff --git a/QLPlugin/Utils/HTMLRenderer.swift b/QLPlugin/Utils/HTMLRenderer.swift index d4bd819..4a1e768 100644 --- a/QLPlugin/Utils/HTMLRenderer.swift +++ b/QLPlugin/Utils/HTMLRenderer.swift @@ -70,7 +70,7 @@ enum HTMLRenderer { ) } - guard result.status == 0 else { + guard result.status == GLANCE_STATUS_OK else { throw HTMLRendererError.rendererError( fileType: fileType, errorMessage: output.isEmpty ? "renderer failed without details" : output diff --git a/QLPlugin/Utils/PreviewCoreBridge.swift b/QLPlugin/Utils/PreviewCoreBridge.swift index b4c4a84..c12f855 100644 --- a/QLPlugin/Utils/PreviewCoreBridge.swift +++ b/QLPlugin/Utils/PreviewCoreBridge.swift @@ -124,7 +124,7 @@ enum PreviewCoreBridge { do { return try JSONDecoder().decode(type, from: data) } catch { - throw PreviewCoreBridgeError.invalidPayload(error.localizedDescription) + throw PreviewCoreBridgeError.invalidPayload(String(describing: error)) } } @@ -134,7 +134,7 @@ enum PreviewCoreBridge { throw PreviewCoreBridgeError.invalidBuffer } let data = result.data.map { Data(bytes: $0, count: result.length) } ?? Data() - guard result.status == 0 else { + guard result.status == GLANCE_STATUS_OK else { guard let message = String(data: data, encoding: .utf8) else { throw PreviewCoreBridgeError.invalidUTF8 } diff --git a/QLPlugin/Utils/PreviewExecutor.swift b/QLPlugin/Utils/PreviewExecutor.swift index 2669894..05b6801 100644 --- a/QLPlugin/Utils/PreviewExecutor.swift +++ b/QLPlugin/Utils/PreviewExecutor.swift @@ -1,6 +1,9 @@ import Foundation /// Runs preview preparation outside the main actor and discards results after cancellation. +/// +/// Cancellation does not interrupt a synchronous operation. The caller waits for it to finish, +/// then its result is discarded; bounded parser limits keep that wait finite. enum PreviewExecutor { static func run( _ operation: @escaping @Sendable () throws -> Output diff --git a/QLPlugin/Views/Previews/CodePreview.swift b/QLPlugin/Views/Previews/CodePreview.swift index f961024..d0bef22 100644 --- a/QLPlugin/Views/Previews/CodePreview.swift +++ b/QLPlugin/Views/Previews/CodePreview.swift @@ -30,6 +30,8 @@ class CodePreview: Preview { return try HTMLRenderer.renderCode(source, lexer: lexer) } return WebPreviewVC(html: html, stylesheets: getStylesheets()) + } catch let error as CancellationError { + throw error } catch { Log.render.error( "Could not generate code HTML: \(error.localizedDescription, privacy: .private)" diff --git a/QLPlugin/Views/Previews/DirectoryPreview.swift b/QLPlugin/Views/Previews/DirectoryPreview.swift index d1a5643..ee6bcc2 100644 --- a/QLPlugin/Views/Previews/DirectoryPreview.swift +++ b/QLPlugin/Views/Previews/DirectoryPreview.swift @@ -100,7 +100,7 @@ class DirectoryPreview: Preview { contentTypeIdentifier: entry.contentTypeIdentifier ) } catch { - Log.parse.error("\(error.localizedDescription, privacy: .private)") + Log.general.error("\(error.localizedDescription, privacy: .private)") } } return fileTree diff --git a/QLPlugin/Views/Previews/JupyterPreview.swift b/QLPlugin/Views/Previews/JupyterPreview.swift index f8aef08..4ee457f 100644 --- a/QLPlugin/Views/Previews/JupyterPreview.swift +++ b/QLPlugin/Views/Previews/JupyterPreview.swift @@ -86,6 +86,8 @@ class JupyterPreview: Preview { stylesheets: getStylesheets(), scripts: getScripts() ) + } catch let error as CancellationError { + throw error } catch { Log.render.error( "Could not generate Jupyter Notebook HTML: \(error.localizedDescription, privacy: .private)" diff --git a/QLPlugin/Views/Previews/SevenZipPreview.swift b/QLPlugin/Views/Previews/SevenZipPreview.swift index e5b4d11..b6f6055 100644 --- a/QLPlugin/Views/Previews/SevenZipPreview.swift +++ b/QLPlugin/Views/Previews/SevenZipPreview.swift @@ -20,7 +20,7 @@ class SevenZipPreview: Preview { let labelText = """ Compressed: \(byteCountFormatter.string(for: archiveSize) ?? "--") Uncompressed: \(byteCountFormatter.string(for: totalUncompressed) ?? "--") - Compression ratio: \(String(compressionRatio)) % + Compression ratio: \(String(format: "%.1f", compressionRatio)) % """ return OutlinePreviewVC(rootNodes: fileTree.root.childrenList, labelText: labelText) } diff --git a/QLPlugin/Views/Previews/TSVPreview.swift b/QLPlugin/Views/Previews/TSVPreview.swift index c5d0310..ab0dbc2 100644 --- a/QLPlugin/Views/Previews/TSVPreview.swift +++ b/QLPlugin/Views/Previews/TSVPreview.swift @@ -6,7 +6,7 @@ class TSVPreview: Preview { func createPreviewVC(file: File) async throws -> PreviewVC { let fileURL = file.url let payload = try await PreviewExecutor.run { - let data = try Data(contentsOf: fileURL, options: .mappedIfSafe) + let data = try Data(contentsOf: fileURL) return try PreviewCoreBridge.parseTSV(data) } return TablePreviewVC(headers: payload.headers, cells: payload.rows) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a3071c2..d31966d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -12,5 +12,5 @@ preview core while preserving the native Swift/AppKit Quick Look experience. - The Go runtime and the SwiftCSV, ZIPFoundation, SWCompression, and BitByteData packages have been removed from the shipping app. -This release remains Apple-silicon-only for macOS 26 or later. Its DMG is unsigned and -unnotarized; verify the published SHA-256 checksum before installation. +This release remains Apple-silicon-only for macOS 26 or later. The app is ad hoc signed; its DMG +is not Developer ID-signed or notarized, so verify the published SHA-256 checksum before installation. diff --git a/mise.toml b/mise.toml index 5bd14a0..461b204 100644 --- a/mise.toml +++ b/mise.toml @@ -153,7 +153,7 @@ run = ''' set -e APP_NAME="${APP_NAME:-Glance}" APP_PATH="build/Build/Products/Release/$APP_NAME.app" -RUST_ARCHIVE="PreviewCore/build/libglance_preview_core.a" +RUST_ARCHIVE="PreviewCore/build/Release/libglance_preview_core.a" QL_EXECUTABLE="$APP_PATH/Contents/PlugIns/QLPlugin.appex/Contents/MacOS/QLPlugin" test -d "$APP_PATH" From 56bbc151cac4abdc128a434b581c5e71aa4300b3 Mon Sep 17 00:00:00 2001 From: ranokay Date: Wed, 12 Aug 2026 16:59:02 +0300 Subject: [PATCH 7/7] fix: authenticate CI tool downloads --- .github/workflows/release.yml | 1 + .github/workflows/verify.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9824414..96a8ef2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,6 +59,7 @@ jobs: - name: Install pinned tools env: + GITHUB_TOKEN: ${{ github.token }} MISE_YES: "1" run: | set -euo pipefail diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 66d44f1..3a9610c 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -46,6 +46,7 @@ jobs: - name: Install pinned tools env: + GITHUB_TOKEN: ${{ github.token }} MISE_YES: "1" run: | set -euo pipefail