diff --git a/README.md b/README.md index b219865..bd9dc7f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ --- +

+ Strata hex editor screenshot +

+ ## Why? I built this (heavily) with the help of [Claude Code](https://claude.ai/claude-code) because I've always wanted a macOS-native hex editor that is almost identical to [HxD](https://mh-nexus.de/en/hxd/) — because I don't like change. It also seemed like a good test for how far I could push almost entirely AI-driven development of a brand new application to serve my specific use case. diff --git a/Strata/Resources/Info.plist b/Strata/Resources/Info.plist index 3e8bca7..e966694 100644 --- a/Strata/Resources/Info.plist +++ b/Strata/Resources/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.1.0 + 1.1.1 CFBundleVersion - 2 + 3 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright diff --git a/Strata/Sources/AppDelegate+Actions.swift b/Strata/Sources/AppDelegate+Actions.swift index 3e7e936..35b5ba4 100644 --- a/Strata/Sources/AppDelegate+Actions.swift +++ b/Strata/Sources/AppDelegate+Actions.swift @@ -219,6 +219,76 @@ extension AppDelegate { let alert = NSAlert(error: error) alert.beginSheetModal(for: win) } + + // MARK: - Update Check + + /// GitHub owner/repo for release checks. + static let gitHubRepo = "ConnorHowell/strata" + + @objc func checkForUpdatesAction() { + let urlString = "https://api.github.com/repos/" + + "\(AppDelegate.gitHubRepo)/releases/latest" + guard let url = URL(string: urlString) else { return } + + var request = URLRequest(url: url) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.timeoutInterval = 10 + + URLSession.shared.dataTask(with: request) { [weak self] data, _, error in + DispatchQueue.main.async { + self?.handleUpdateResponse(data: data, error: error) + } + }.resume() + } + + private func handleUpdateResponse(data: Data?, error: Error?) { + guard let win = window else { return } + + if let error { + let alert = NSAlert() + alert.messageText = "Update Check Failed" + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.beginSheetModal(for: win) + return + } + + guard let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tagName = json["tag_name"] as? String else { + let alert = NSAlert() + alert.messageText = "Update Check Failed" + alert.informativeText = "Could not read release information from GitHub." + alert.alertStyle = .warning + alert.beginSheetModal(for: win) + return + } + + let remote = tagName.trimmingCharacters(in: CharacterSet(charactersIn: "vV")) + let current = Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String ?? "0.0.0" + + let alert = NSAlert() + if remote.compare(current, options: .numeric) == .orderedDescending { + alert.messageText = "Update Available" + alert.informativeText = "Version \(remote) is available " + + "(you have \(current))." + alert.addButton(withTitle: "Open Release Page") + alert.addButton(withTitle: "Later") + alert.beginSheetModal(for: win) { response in + if response == .alertFirstButtonReturn, + let htmlURL = json["html_url"] as? String, + let url = URL(string: htmlURL) { + NSWorkspace.shared.open(url) + } + } + } else { + alert.messageText = "You\u{2019}re Up to Date" + alert.informativeText = "Strata \(current) is the latest version." + alert.beginSheetModal(for: win) + } + } } // MARK: - Delegate Conformances diff --git a/Strata/Sources/AppDelegate+Menus.swift b/Strata/Sources/AppDelegate+Menus.swift index b5de6e2..db6e61e 100644 --- a/Strata/Sources/AppDelegate+Menus.swift +++ b/Strata/Sources/AppDelegate+Menus.swift @@ -42,6 +42,11 @@ extension AppDelegate { action: #selector(showAboutPanel), keyEquivalent: "" ) + menu.addItem( + withTitle: "Check for Updates\u{2026}", + action: #selector(checkForUpdatesAction), + keyEquivalent: "" + ) menu.addItem(.separator()) menu.addItem( withTitle: "Quit Strata", diff --git a/Strata/Sources/AppDelegate+Tools.swift b/Strata/Sources/AppDelegate+Tools.swift index f800e74..e89c8b1 100644 --- a/Strata/Sources/AppDelegate+Tools.swift +++ b/Strata/Sources/AppDelegate+Tools.swift @@ -362,47 +362,18 @@ extension AppDelegate: StringsSheetDelegate { guard let session = sessionManager.activeSession else { return } let fileName = session.fileName - // Resolve file URL on main thread to avoid capturing session - // in the background closure. - let fileURL: URL? - let useFile: Bool - if region == .entireFile, !session.isModified { - fileURL = session.fileURL - useFile = fileURL != nil - } else { - fileURL = nil - useFile = false - } - - // Resolve data on the main thread to avoid accessing UI - // properties (e.g. hexGrid.selectedRange) from a background thread. - let regionData: Data? - if !useFile { - regionData = dataForRegion(region) - } else { - regionData = nil - } + // Always use piece table data — reading from the file URL + // directly fails in sandboxed builds where security-scoped + // access has expired. + guard let regionData = dataForRegion(region) else { return } let progress = showProgressPanel(title: "Extracting strings\u{2026}") DispatchQueue.global(qos: .userInitiated).async { [weak self] in - let matches: [StringMatch] - if useFile, let url = fileURL { - matches = StringsEngine.scanFile( - url: url, - minLength: minLength, - encodings: encodings - ) - } else { - guard let data = regionData else { - DispatchQueue.main.async { progress.close() } - return - } - matches = StringsEngine.scan( - data: data, - minLength: minLength, - encodings: encodings - ) - } + let matches = StringsEngine.scan( + data: regionData, + minLength: minLength, + encodings: encodings + ) DispatchQueue.main.async { progress.close() guard let self else { return } diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..ff6a8ce Binary files /dev/null and b/screenshot.png differ