diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7c4c15..3d058ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,12 +3,27 @@ name: CI on: push: branches: [main] + tags: ["v*"] pull_request: branches: [main] jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.1.1 + with: + fetch-depth: 0 + lfs: true + + - run: make lint-ci + - run: make format-ci + build: runs-on: macos-14 + permissions: + # https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions + id-token: write steps: - uses: actions/checkout@v4.1.1 with: @@ -26,6 +41,6 @@ jobs: key: v0-${{ runner.os }}-swiftpm-${{ hashFiles('**/Package.resolved') }} restore-keys: v0-${{ runner.os }}-swiftpm- - - run: npm run build - - - run: file build/Release/PreviewExtension.appex/Contents/MacOS/* + - run: npm publish --provenance --dry-run + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..53329f8 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,3 @@ +--swiftversion 5.2 +--indent 2 +--maxwidth 120 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..753d6e6 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,15 @@ +strict: true + +excluded: + - .build + - .swiftpm-packages + +disabled_rules: + - function_body_length + - type_body_length + - file_length + - cyclomatic_complexity + - opening_brace # handled by swiftformat + +trailing_comma: + mandatory_comma: true diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1d03ef7 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: build +build: + # https://developer.apple.com/documentation/xcode/building-swift-packages-or-apps-that-use-them-in-continuous-integration-workflows + # https://stackoverflow.com/questions/4969932/separate-build-directory-using-xcodebuild + xcodebuild \ + -disableAutomaticPackageResolution \ + -clonedSourcePackagesDirPath .swiftpm-packages \ + -destination generic/platform=macOS \ + -scheme PreviewExtension \ + SYMROOT=$(PWD)/build \ + -configuration Release \ + clean build + lipo build/Release/PreviewExtension.appex/Contents/MacOS/PreviewExtension -verify_arch arm64 x86_64 + rm -rf dist + mkdir -p dist + cp -R build/Release/PreviewExtension.appex dist/ + cp PreviewExtension/PreviewExtension.entitlements dist/ + +.PHONY: lint-ci +lint-ci: + docker run -t --rm -v $(PWD):/work -w /work ghcr.io/realm/swiftlint:0.53.0 + +.PHONY: format-ci +format-ci: + docker run -t --rm -v $(PWD):/work ghcr.io/nicklockwood/swiftformat:0.53.7 --lint /work diff --git a/PreviewExtension/Configuration.swift b/PreviewExtension/Configuration.swift index e522c9c..092ce0e 100644 --- a/PreviewExtension/Configuration.swift +++ b/PreviewExtension/Configuration.swift @@ -17,7 +17,8 @@ struct Configuration { extension Configuration { static func fromMainBundle() throws -> Configuration { - guard let dict = (Bundle.main.localizedInfoDictionary ?? Bundle.main.infoDictionary)?["QLJS"] as? [String: Any] else { + guard let dict = (Bundle.main.localizedInfoDictionary ?? Bundle.main.infoDictionary)?["QLJS"] as? [String: Any] + else { throw GenericError(message: "Could not read QLJS configuration from Info.plist") } @@ -46,6 +47,7 @@ extension Configuration { return Configuration( loadingStrategy: loadingStrategy, pageURL: pageURL, - preferredContentSize: preferredContentSize) + preferredContentSize: preferredContentSize + ) } } diff --git a/PreviewExtension/PreviewViewController.swift b/PreviewExtension/PreviewViewController.swift index e7b2de5..deacc01 100644 --- a/PreviewExtension/PreviewViewController.swift +++ b/PreviewExtension/PreviewViewController.swift @@ -14,7 +14,7 @@ struct GenericError: Error, LocalizedError { let message: String var errorDescription: String? { - return message + message } } @@ -29,8 +29,13 @@ enum HandlerName { } extension WKWebView { - func callAsyncJavaScript(_ functionBody: String, arguments: [String : Any] = [:], in frame: WKFrameInfo? = nil, in contentWorld: WKContentWorld) -> Future { - return Future { promise in + func callAsyncJavaScript( + _ functionBody: String, + arguments: [String: Any] = [:], + in frame: WKFrameInfo? = nil, + in contentWorld: WKContentWorld + ) -> Future { + Future { promise in self.callAsyncJavaScript(functionBody, arguments: arguments, in: frame, in: contentWorld) { promise($0.map(Optional.some)) } @@ -39,7 +44,7 @@ extension WKWebView { } private func makeMouseEvent(_ type: NSEvent.EventType, at location: NSPoint, in window: NSWindow) -> NSEvent? { - return NSEvent.mouseEvent( + NSEvent.mouseEvent( with: type, location: location, modifierFlags: [], @@ -48,11 +53,13 @@ private func makeMouseEvent(_ type: NSEvent.EventType, at location: NSPoint, in context: nil, eventNumber: 0, clickCount: 1, - pressure: 0) + pressure: 0 + ) } -class PreviewViewController: NSViewController, QLPreviewingController, WKUIDelegate, WKNavigationDelegate, WKScriptMessageHandlerWithReply { - +class PreviewViewController: NSViewController, QLPreviewingController, WKUIDelegate, WKNavigationDelegate, + WKScriptMessageHandlerWithReply +{ let webView: WKWebView let configuration: Configuration @@ -61,7 +68,8 @@ class PreviewViewController: NSViewController, QLPreviewingController, WKUIDeleg var loadCompleteFuture: Future var loadCompletePromise: Future.Promise? - required init?(coder: NSCoder) { fatalError() } + @available(*, unavailable) + required init?(coder _: NSCoder) { fatalError() } override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { log.logLevel = .debug log.debug("init PreviewViewController") @@ -75,7 +83,7 @@ class PreviewViewController: NSViewController, QLPreviewingController, WKUIDeleg do { configuration = try Configuration.fromMainBundle() - } catch let error { + } catch { log.error("Unable to load QuickLookJS configuration: \(error.localizedDescription)") fatalError("Unable to load QuickLookJS configuration: \(error.localizedDescription)") } @@ -91,40 +99,49 @@ class PreviewViewController: NSViewController, QLPreviewingController, WKUIDeleg } override func loadView() { - self.view = webView + view = webView webView.uiDelegate = self webView.navigationDelegate = self - webView.configuration.userContentController.addScriptMessageHandler(self, contentWorld: .page, name: HandlerName.default) - webView.configuration.userContentController.addScriptMessageHandler(self, contentWorld: .page, name: HandlerName.internal) - - // WKWebView doesn't provide a way to send complex objects (such as File) between content worlds, so any supporting code must go directly in the page world. + webView.configuration.userContentController.addScriptMessageHandler( + self, + contentWorld: .page, + name: HandlerName.default + ) + webView.configuration.userContentController.addScriptMessageHandler( + self, + contentWorld: .page, + name: HandlerName.internal + ) + + // WKWebView doesn't provide a way to send complex objects (such as File) between content worlds, so any supporting + // code must go directly in the page world. webView.configuration.userContentController.addUserScript(WKUserScript(source: """ -let resolve, reject; -window.quicklookPreviewedFile = new Promise((res, rej) => { - resolve = res; - reject = rej; -}); -window.quicklookPreviewedFile.resolve = resolve; -window.quicklookPreviewedFile.reject = reject; - -window.quicklook = { - async finishedLoading() { - return webkit.messageHandlers.quicklook.postMessage({ action: "finishedLoading" }); - }, - async getPreviewedFile() { - await webkit.messageHandlers.quicklook.postMessage({ action: "getPreviewedFile" }); - return window.quicklookPreviewedFile; - }, -}; - -window.addEventListener("error", (event) => { - webkit.messageHandlers.quicklookInternal.postMessage({ action: "error", message: event.message }); -}); -window.addEventListener("unhandledrejection", (event) => { - webkit.messageHandlers.quicklookInternal.postMessage({ action: "error", message: event.reason.toString() }); -}); -""", injectionTime: .atDocumentStart, forMainFrameOnly: true)) + let resolve, reject; + window.quicklookPreviewedFile = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + window.quicklookPreviewedFile.resolve = resolve; + window.quicklookPreviewedFile.reject = reject; + + window.quicklook = { + async finishedLoading() { + return webkit.messageHandlers.quicklook.postMessage({ action: "finishedLoading" }); + }, + async getPreviewedFile() { + await webkit.messageHandlers.quicklook.postMessage({ action: "getPreviewedFile" }); + return window.quicklookPreviewedFile; + }, + }; + + window.addEventListener("error", (event) => { + webkit.messageHandlers.quicklookInternal.postMessage({ action: "error", message: event.message }); + }); + window.addEventListener("unhandledrejection", (event) => { + webkit.messageHandlers.quicklookInternal.postMessage({ action: "error", message: event.reason.toString() }); + }); + """, injectionTime: .atDocumentStart, forMainFrameOnly: true)) } override func viewDidLoad() { @@ -137,7 +154,7 @@ window.addEventListener("unhandledrejection", (event) => { previewedFileURL = url loadCompleteFuture.sink { log.debug("preparation ended: \($0)") - if case .failure(let error) = $0 { + if case let .failure(error) = $0 { completionHandler(error) } } receiveValue: { @@ -147,7 +164,11 @@ window.addEventListener("unhandledrejection", (event) => { .store(in: &cancellables) } - func userContentController(_ userContentController: WKUserContentController, didReceive scriptMessage: WKScriptMessage, replyHandler: @escaping (Any?, String?) -> Void) { + func userContentController( + _: WKUserContentController, + didReceive scriptMessage: WKScriptMessage, + replyHandler: @escaping (Any?, String?) -> Void + ) { log.debug("got message for \(scriptMessage.name): \(scriptMessage.body)") let publisher: AnyPublisher @@ -160,14 +181,16 @@ window.addEventListener("unhandledrejection", (event) => { publisher = Result { try JSONDecoder().decode( ScriptMessage.self, - from: JSONSerialization.data(withJSONObject: scriptMessage.body)) + from: JSONSerialization.data(withJSONObject: scriptMessage.body) + ) }.publisher.flatMap(handleMessage).eraseToAnyPublisher() case HandlerName.internal: publisher = Result { try JSONDecoder().decode( InternalScriptMessage.self, - from: JSONSerialization.data(withJSONObject: scriptMessage.body)) + from: JSONSerialization.data(withJSONObject: scriptMessage.body) + ) }.publisher.flatMap(handleInternalMessage).eraseToAnyPublisher() default: @@ -178,7 +201,7 @@ window.addEventListener("unhandledrejection", (event) => { publisher .sink { log.debug("message handler ended: \($0)") - if case .failure(let error) = $0 { + if case let .failure(error) = $0 { replyHandler(nil, error.localizedDescription) } } receiveValue: { @@ -196,46 +219,47 @@ window.addEventListener("unhandledrejection", (event) => { case .getPreviewedFile: return webView.callAsyncJavaScript(""" -const toHex = (num) => num.toString(16).padStart(2, "0"); -const id = Array.from(crypto.getRandomValues(new Uint8Array(16)), toHex).join(""); - -const input = document.body.appendChild(document.createElement("input")); -input.id = id; -input.type = "file"; -input.style.display = "none"; - -const label = document.body.appendChild(document.createElement("label")); -label.htmlFor = id; -label.style.position = "fixed"; -label.style.display = "block"; -label.style.margin = "0"; -label.style.padding = "0"; -label.style.top = "0"; -label.style.right = "0"; -label.style.bottom = "0"; -label.style.left = "0"; - -input.onchange = (event) => { - if (event.target.files[0]) { - window.quicklookPreviewedFile.resolve(event.target.files[0]); - } else { - window.quicklookPreviewedFile.reject(new Error("no file was received")); - } -}; - -try { - await webkit.messageHandlers.quicklookInternal.postMessage({action: "clickFileInput"}); - await window.quicklookPreviewedFile; -} finally { - label.remove(); - input.remove(); -} -""", arguments: [:], in: nil, in: .page) + const toHex = (num) => num.toString(16).padStart(2, "0"); + const id = Array.from(crypto.getRandomValues(new Uint8Array(16)), toHex).join(""); + + const input = document.body.appendChild(document.createElement("input")); + input.id = id; + input.type = "file"; + input.style.display = "none"; + + const label = document.body.appendChild(document.createElement("label")); + label.htmlFor = id; + label.style.position = "fixed"; + label.style.display = "block"; + label.style.margin = "0"; + label.style.padding = "0"; + label.style.top = "0"; + label.style.right = "0"; + label.style.bottom = "0"; + label.style.left = "0"; + + input.onchange = (event) => { + if (event.target.files[0]) { + window.quicklookPreviewedFile.resolve(event.target.files[0]); + } else { + window.quicklookPreviewedFile.reject(new Error("no file was received")); + } + }; + + try { + await webkit.messageHandlers.quicklookInternal.postMessage({action: "clickFileInput"}); + await window.quicklookPreviewedFile; + } finally { + label.remove(); + input.remove(); + } + """, arguments: [:], in: nil, in: .page) .mapError { error in // Expose the actual underlying error message if let wkError = error as? WKError, wkError.code == WKError.javaScriptExceptionOccurred, - let message = wkError.userInfo["WKJavaScriptExceptionMessage"] as? String { + let message = wkError.userInfo["WKJavaScriptExceptionMessage"] as? String + { return GenericError(message: message) } return error @@ -246,7 +270,7 @@ try { private func handleInternalMessage(_ message: InternalScriptMessage) -> AnyPublisher { switch message { - case .error(let message): + case let .error(message): log.error("preview page error: \(message)") return Just(nil).setFailureType(to: Error.self).eraseToAnyPublisher() @@ -270,7 +294,12 @@ try { } } - func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping ([URL]?) -> Void) { + func webView( + _: WKWebView, + runOpenPanelWith _: WKOpenPanelParameters, + initiatedByFrame _: WKFrameInfo, + completionHandler: @escaping ([URL]?) -> Void + ) { if let previewedFileURL = previewedFileURL { log.debug("responding to open panel with previewed file url") completionHandler([previewedFileURL]) @@ -280,20 +309,21 @@ try { } } - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + func webView(_: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { log.error("provisional navigation failed", metadata: [ "error": "\(error.localizedDescription)", - "navigation": "\(ObjectIdentifier(navigation))" + "navigation": "\(ObjectIdentifier(navigation))", ]) // Can't pass the full error object due to: - // -[NSXPCEncoder _checkObject:]: This coder only encodes objects that adopt NSSecureCoding (object is of class 'WKReloadFrameErrorRecoveryAttempter'). + // -[NSXPCEncoder _checkObject:]: This coder only encodes objects that adopt NSSecureCoding (object is of class + // 'WKReloadFrameErrorRecoveryAttempter'). // Passing a GenericError for some reason results in Quick Look showing a password prompt. loadCompletePromise?(.failure(CocoaError(.fileReadUnknown))) } - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + func webView(_: WKWebView, didFinish navigation: WKNavigation!) { log.debug("navigation finished", metadata: [ - "navigation": "\(ObjectIdentifier(navigation))" + "navigation": "\(ObjectIdentifier(navigation))", ]) switch configuration.loadingStrategy { @@ -302,7 +332,6 @@ try { loadCompletePromise?(.success(())) case .waitForSignal: log.debug("waiting for completion signal") - break } } } diff --git a/PreviewExtension/ScriptMessage.swift b/PreviewExtension/ScriptMessage.swift index 5ec75bd..002aa64 100644 --- a/PreviewExtension/ScriptMessage.swift +++ b/PreviewExtension/ScriptMessage.swift @@ -31,7 +31,8 @@ enum ScriptMessage: Decodable { } enum InternalScriptMessage: Decodable { - /// Used during `ScriptMessage.getPreviewedFile` to signal that the page is ready to accept a fake click event in order to trigger the file input. + /// Used during `ScriptMessage.getPreviewedFile` to signal that the page is ready to accept a fake click event in + /// order to trigger the file input. case clickFileInput case error(String) @@ -50,7 +51,7 @@ enum InternalScriptMessage: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) switch try container.decode(Actions.self, forKey: .action) { case .clickFileInput: self = .clickFileInput - case .error: self = .error(try container.decode(String.self, forKey: .message)) + case .error: self = try .error(container.decode(String.self, forKey: .message)) } } } diff --git a/package.json b/package.json index 510b738..f1183e7 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,7 @@ }, "types": "index.d.ts", "scripts": { - "build": "xcodebuild -disableAutomaticPackageResolution -clonedSourcePackagesDirPath .swiftpm-packages -scheme PreviewExtension SYMROOT=$(pwd)/build -configuration Release clean build", - "postbuild": "rm -rf dist && mkdir -p dist && cp -R build/Release/PreviewExtension.appex dist/ && cp PreviewExtension/PreviewExtension.entitlements dist/", - "prepack": "npm run build" + "prepack": "make build" }, "files": [ "dist",