]()
24 |
25 | func load() {
26 | if let data = UserDefaults.standard.data(forKey: Self.monitorItemsKey) {
27 | if let items = try? JSONDecoder().decode([MonitorItem].self, from: data) {
28 | self.items = items
29 | for item in items {
30 | let images = item.url.imageContents
31 | whiteList[item.url] = Set(images)
32 | }
33 | if !items.isEmpty {
34 | updateMonitoring()
35 | }
36 | }
37 | }
38 | }
39 |
40 | func save() {
41 | if let data = try? JSONEncoder().encode(items) {
42 | UserDefaults.standard.set(data, forKey: Self.monitorItemsKey)
43 | }
44 | }
45 |
46 | func add(_ item: MonitorItem) {
47 | if contains(item) {
48 | return
49 | }
50 | items.append(item)
51 | whiteList[item.url] = Set(item.url.imageContents)
52 | save()
53 | updateMonitoring()
54 | }
55 |
56 | func remove(_ item: MonitorItem) {
57 | whiteList.removeValue(forKey: item.url)
58 | items.removeAll(where: { $0.id == item.id })
59 | save()
60 | updateMonitoring()
61 | }
62 |
63 | func removeAll() {
64 | directoryWatcher.stopAllWatching()
65 | items.removeAll()
66 | whiteList.removeAll()
67 | save()
68 | }
69 |
70 | func update(_ item: MonitorItem) {
71 | if let index = items.firstIndex(where: { $0.id == item.id }) {
72 | let origin = items[index]
73 | items[index] = item
74 | whiteList.removeValue(forKey: origin.url)
75 | whiteList[item.url] = Set(item.url.imageContents)
76 | save()
77 | updateMonitoring()
78 | }
79 | }
80 |
81 | func contains(_ item: MonitorItem) -> Bool {
82 | return items.contains(where: { $0.url == item.url })
83 | }
84 |
85 | private func startMonitor() {
86 | let enabledDirectories = items.filter { $0.enabled }.map { $0.url }
87 | directoryWatcher.startWatching(directories: enabledDirectories)
88 | }
89 |
90 | private func stopMonitor() {
91 | directoryWatcher.stopAllWatching()
92 | }
93 |
94 | private func updateMonitoring() {
95 | let enabledDirectories = items.filter { $0.enabled }.map { $0.url }
96 | if enabledDirectories.isEmpty {
97 | directoryWatcher.stopAllWatching()
98 | } else {
99 | directoryWatcher.startWatching(directories: enabledDirectories)
100 | }
101 | }
102 |
103 | private func processDirectoryChange(at changedPath: URL) {
104 | guard let item = items.first(where: { $0.url == changedPath && $0.enabled }) else {
105 | return
106 | }
107 |
108 | let images = Set(changedPath.imageContents)
109 | let whiteList = self.whiteList[item.url] ?? []
110 | let targetImages = Array(images.subtracting(whiteList))
111 |
112 | guard !targetImages.isEmpty else {
113 | // Update whitelist when no processing tasks are running
114 | if UpscaylData.shared.items.filter({ $0.state == .processing }).isEmpty {
115 | self.whiteList[item.url] = images
116 | }
117 | return
118 | }
119 |
120 | // Update whitelist with new images
121 | self.whiteList[item.url]?.formUnion(targetImages)
122 |
123 | // Add predicted output image names to whitelist
124 | let compressedImages = targetImages.map {
125 | return Self.makeOutputURL(for: $0)
126 | }
127 | self.whiteList[item.url]?.formUnion(Set(compressedImages))
128 |
129 | // Trigger upscaling
130 | Upscayl.process(targetImages, by: UpscaylData.shared, source: .automated)
131 | }
132 |
133 | private static func makeOutputURL(for url: URL) -> URL {
134 | var newURL = url
135 |
136 | let ext = {
137 | switch HiPixelConfiguration.shared.saveImageAs {
138 | case .png: return "png"
139 | case .jpg: return "jpeg"
140 | case .webp: return "webp"
141 | case .original: return url.imageIdentifier ?? "png"
142 | }
143 | }()
144 |
145 | if HiPixelConfiguration.shared.enableSaveOutputFolder,
146 | let saveFolder = HiPixelConfiguration.shared.saveOutputFolder,
147 | let baseDir = URL(string: "file://" + saveFolder)
148 | {
149 | newURL = baseDir.appendingPathComponent(url.lastPathComponent)
150 | }
151 |
152 | let postfix =
153 | "_hipixel_\(Int(HiPixelConfiguration.shared.imageScale))x_\(HiPixelConfiguration.shared.upscaleModel.id)"
154 | return newURL.appendingPostfix(postfix).changingPathExtension(to: ext)
155 | }
156 | }
157 |
158 | // MARK: - DirectoryWatcherDelegate
159 | extension MonitorService: FSWatcher.DirectoryWatcherDelegate {
160 | func directoryDidChange(at url: URL) {
161 | DispatchQueue.main.async { [weak self] in
162 | self?.processDirectoryChange(at: url)
163 | }
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/HiPixel/HiPixelApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HiPixelApp.swift
3 | // HiPixel
4 | //
5 | // Created by 十里 on 2024/6/16.
6 | //
7 |
8 | import SettingsAccess
9 | import Sparkle
10 | import SwiftUI
11 |
12 | @main
13 | struct HiPixelApp: App {
14 |
15 | @AppStorage(HiPixelConfiguration.Keys.ColorScheme)
16 | var colorScheme: HiPixelConfiguration.ColorScheme = .system
17 |
18 | @AppStorage(HiPixelConfiguration.Keys.ShowMenuBarExtra)
19 | var showMenuBarExtra: Bool = false
20 |
21 | @NSApplicationDelegateAdaptor(AppDelegate.self)
22 | var appDelegate
23 |
24 | @State private var aboutWindow: NSWindow?
25 |
26 | @StateObject var upscaylData = UpscaylData.shared
27 |
28 | private let updaterController: SPUStandardUpdaterController
29 |
30 | init() {
31 | updaterController = SPUStandardUpdaterController(
32 | startingUpdater: true,
33 | updaterDelegate: nil,
34 | userDriverDelegate: nil
35 | )
36 | }
37 |
38 | var body: some Scene {
39 | Window("HiPixel", id: "HiPixel") {
40 | ContentView()
41 | .frame(minWidth: 640, idealWidth: 720, minHeight: 480, idealHeight: 640)
42 | .onAppear {
43 | HiPixelConfiguration.ColorScheme.change(to: colorScheme)
44 | }
45 | .onChange(of: colorScheme) { newValue in
46 | HiPixelConfiguration.ColorScheme.change(to: newValue)
47 | }
48 | .environmentObject(upscaylData)
49 | .ignoresSafeArea(.all)
50 | .openSettingsAccess()
51 | }
52 | .windowStyle(.hiddenTitleBar)
53 | .windowResizability(.contentSize)
54 | .defaultSize(.init(width: 720, height: 480))
55 | .defaultPosition(.center)
56 | .commands {
57 | CommandGroup(replacing: .appInfo) {
58 | Button {
59 | AboutWindowController.shared.showWindow(nil)
60 | } label: {
61 | Label("About", systemImage: "info.circle")
62 | }
63 | }
64 |
65 | CommandGroup(after: .appInfo) {
66 | CheckForUpdatesView(updater: updaterController.updater)
67 | }
68 |
69 | CommandGroup(after: .toolbar) {
70 | Picker(selection: $colorScheme) {
71 | ForEach(HiPixelConfiguration.ColorScheme.allCases, id: \.self) { scheme in
72 | Label(scheme.localized, systemImage: scheme.icon)
73 | .tag(scheme)
74 | }
75 | } label: {
76 | Label("Appearance", systemImage: "sun.lefthalf.filled")
77 | }
78 | }
79 | }
80 |
81 | Settings {
82 | SettingsView()
83 | }
84 |
85 | MenuBarExtra("HiPixel", image: "MenuBarIcon", isInserted: $showMenuBarExtra) {
86 | MenuBarExtraView(updater: updaterController.updater)
87 | }
88 | }
89 | }
90 |
91 | class AppDelegate: NSObject, NSApplicationDelegate {
92 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
93 | return false
94 | }
95 |
96 | func applicationDidFinishLaunching(_ notification: Notification) {
97 | AppIconManager.shared.applyAppIcon()
98 | MonitorService.shared.load()
99 | // Apply dock icon setting on launch
100 | _ = DockIconService.shared.setDockIconHidden(HiPixelConfiguration.shared.hideDockIcon)
101 |
102 | // Handle silent launch - hide main window if enabled
103 | if HiPixelConfiguration.shared.launchSilently {
104 | // Hide all windows on silent launch
105 | DispatchQueue.main.async {
106 | NSApplication.shared.windows.forEach { window in
107 | if window.title == "HiPixel" {
108 | window.orderOut(nil)
109 | }
110 | }
111 | }
112 | }
113 | }
114 |
115 | func application(_ application: NSApplication, open urls: [URL]) {
116 | urls.forEach { url in
117 | URLSchemeHandler.shared.handle(url)
118 | }
119 | }
120 | }
121 |
122 | struct MenuBarExtraView: View {
123 | @Environment(\.openWindow) private var openWindow
124 | @ObservedObject private var checkForUpdatesViewModel: CheckForUpdatesViewModel
125 |
126 | private let updater: SPUUpdater
127 |
128 | init(updater: SPUUpdater) {
129 | self.updater = updater
130 | self.checkForUpdatesViewModel = CheckForUpdatesViewModel(updater: updater)
131 | }
132 |
133 | var body: some View {
134 | Button {
135 | showMainWindow()
136 | } label: {
137 | Label("Main Window", systemImage: "macwindow")
138 | }
139 |
140 | if #available(macOS 14.0, *) {
141 | SettingsLink {
142 | Label("Settings...", systemImage: "gearshape")
143 | }
144 | } else {
145 | Button {
146 | showSettingsWindow()
147 | } label: {
148 | Label("Settings...", systemImage: "gearshape")
149 | }
150 | }
151 |
152 | Button {
153 | updater.checkForUpdates()
154 | } label: {
155 | Label("Check for Updates…", systemImage: "arrow.trianglehead.2.counterclockwise")
156 | }
157 | .disabled(!checkForUpdatesViewModel.canCheckForUpdates)
158 |
159 | Divider()
160 |
161 | Button {
162 | NSApplication.shared.terminate(nil)
163 | } label: {
164 | Label("Quit HiPixel", systemImage: "power")
165 | }
166 | }
167 |
168 | private func showMainWindow() {
169 | // Activate the app first
170 | NSApp.activate(ignoringOtherApps: true)
171 |
172 | // Find the main window by title
173 | if let window = NSApplication.shared.windows.first(where: { $0.title == "HiPixel" }) {
174 | window.makeKeyAndOrderFront(nil)
175 | } else {
176 | // If no window exists, use openWindow to create a new one
177 | openWindow(id: "HiPixel")
178 | }
179 | }
180 |
181 | private func showSettingsWindow() {
182 | // Open settings window using standard macOS action
183 | NSApp.activate(ignoringOtherApps: true)
184 | // Use performSelector to avoid Selector warning
185 | let selector: Selector
186 | if #available(macOS 13.0, *) {
187 | selector = NSSelectorFromString("showSettingsWindow:")
188 | } else {
189 | selector = NSSelectorFromString("showPreferencesWindow:")
190 | }
191 | if NSApp.responds(to: selector) {
192 | NSApp.perform(selector, with: nil)
193 | }
194 | }
195 | }
196 |
--------------------------------------------------------------------------------
/HiPixel/Utilities/EXIFMetadataManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EXIFMetadataManager.swift
3 | // HiPixel
4 | //
5 | // Created by 十里 on 2025/09/02.
6 | //
7 |
8 | import Foundation
9 | import ImageIO
10 | import CoreGraphics
11 |
12 | class EXIFMetadataManager {
13 |
14 | // MARK: - Public Methods
15 |
16 | /// Extract metadata from image file
17 | /// - Parameter url: Image file URL
18 | /// - Returns: Metadata dictionary or nil if extraction failed
19 | static func extractMetadata(from url: URL) -> CFDictionary? {
20 | guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil) else {
21 | Common.logger.error("Failed to create image source for: \(url.path)")
22 | return nil
23 | }
24 |
25 | let metadata = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil)
26 | return metadata
27 | }
28 |
29 | /// Apply metadata to an image file
30 | /// - Parameters:
31 | /// - metadata: Metadata dictionary to apply
32 | /// - url: Target image file URL
33 | /// - Returns: Success status
34 | @discardableResult
35 | static func applyMetadata(_ metadata: CFDictionary, to url: URL) -> Bool {
36 | // Read the original image data
37 | guard let imageData = try? Data(contentsOf: url) else {
38 | Common.logger.error("Failed to read image data from: \(url.path)")
39 | return false
40 | }
41 |
42 | guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil) else {
43 | Common.logger.error("Failed to create image source from data")
44 | return false
45 | }
46 |
47 | // Get the UTI type for the image
48 | guard let imageUTI = CGImageSourceGetType(imageSource) else {
49 | Common.logger.error("Failed to get image UTI type")
50 | return false
51 | }
52 |
53 | // Create destination with the same format
54 | let destinationData = NSMutableData()
55 | guard let imageDestination = CGImageDestinationCreateWithData(
56 | destinationData, imageUTI, 1, nil
57 | ) else {
58 | Common.logger.error("Failed to create image destination")
59 | return false
60 | }
61 |
62 | // Get the image
63 | guard let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else {
64 | Common.logger.error("Failed to create CGImage from source")
65 | return false
66 | }
67 |
68 | // Filter metadata based on format compatibility
69 | let filteredMetadata = filterMetadataForFormat(metadata, format: imageUTI)
70 |
71 | // Add image with metadata to destination
72 | CGImageDestinationAddImage(imageDestination, cgImage, filteredMetadata)
73 |
74 | // Finalize the destination
75 | guard CGImageDestinationFinalize(imageDestination) else {
76 | Common.logger.error("Failed to finalize image destination")
77 | return false
78 | }
79 |
80 | // Write the data back to file
81 | do {
82 | try destinationData.write(to: url, options: .atomic)
83 | Common.logger.info("Successfully applied metadata to: \(url.lastPathComponent)")
84 | return true
85 | } catch {
86 | Common.logger.error("Failed to write image with metadata: \(error)")
87 | return false
88 | }
89 | }
90 |
91 | /// Compare and copy metadata from source to destination if different
92 | /// - Parameters:
93 | /// - sourceURL: Source image URL
94 | /// - destinationURL: Destination image URL
95 | /// - Returns: Success status
96 | @discardableResult
97 | static func compareAndCopyMetadata(from sourceURL: URL, to destinationURL: URL) -> Bool {
98 | guard let sourceMetadata = extractMetadata(from: sourceURL) else {
99 | Common.logger.warning("No metadata found in source image: \(sourceURL.lastPathComponent)")
100 | return false
101 | }
102 |
103 | let destinationMetadata = extractMetadata(from: destinationURL)
104 |
105 | // If destination has no metadata or metadata is different, apply source metadata
106 | if destinationMetadata == nil || !metadataIsEqual(sourceMetadata, destinationMetadata!) {
107 | Common.logger.info("Applying metadata from \(sourceURL.lastPathComponent) to \(destinationURL.lastPathComponent)")
108 | return applyMetadata(sourceMetadata, to: destinationURL)
109 | } else {
110 | Common.logger.info("Metadata already matches, skipping: \(destinationURL.lastPathComponent)")
111 | return true
112 | }
113 | }
114 |
115 | // MARK: - Private Methods
116 |
117 | /// Filter metadata based on format compatibility
118 | /// - Parameters:
119 | /// - metadata: Original metadata dictionary
120 | /// - format: Target image format UTI
121 | /// - Returns: Filtered metadata dictionary
122 | private static func filterMetadataForFormat(_ metadata: CFDictionary, format: CFString) -> CFDictionary {
123 | let mutableMetadata = NSMutableDictionary(dictionary: metadata)
124 | let formatString = format as String
125 |
126 | // For PNG format, remove EXIF-specific properties that are not supported
127 | if formatString.contains("png") {
128 | // PNG supports limited metadata, keep only basic properties
129 | let supportedKeys = [
130 | kCGImagePropertyOrientation,
131 | kCGImagePropertyColorModel,
132 | kCGImagePropertyPixelWidth,
133 | kCGImagePropertyPixelHeight,
134 | kCGImagePropertyDPIWidth,
135 | kCGImagePropertyDPIHeight
136 | ]
137 |
138 | let filteredDict = NSMutableDictionary()
139 | for key in supportedKeys {
140 | if let value = mutableMetadata[key] {
141 | filteredDict[key] = value
142 | }
143 | }
144 |
145 | // Add PNG-specific metadata if available
146 | if let pngDict = mutableMetadata[kCGImagePropertyPNGDictionary] {
147 | filteredDict[kCGImagePropertyPNGDictionary] = pngDict
148 | }
149 |
150 | return filteredDict
151 | }
152 |
153 | // For WEBP format, keep basic metadata
154 | if formatString.contains("webp") {
155 | let supportedKeys = [
156 | kCGImagePropertyOrientation,
157 | kCGImagePropertyPixelWidth,
158 | kCGImagePropertyPixelHeight,
159 | kCGImagePropertyDPIWidth,
160 | kCGImagePropertyDPIHeight
161 | ]
162 |
163 | let filteredDict = NSMutableDictionary()
164 | for key in supportedKeys {
165 | if let value = mutableMetadata[key] {
166 | filteredDict[key] = value
167 | }
168 | }
169 |
170 | return filteredDict
171 | }
172 |
173 | // For JPEG and other formats, return all metadata
174 | return metadata
175 | }
176 |
177 | /// Compare two metadata dictionaries for equality
178 | /// - Parameters:
179 | /// - metadata1: First metadata dictionary
180 | /// - metadata2: Second metadata dictionary
181 | /// - Returns: True if metadata is equivalent
182 | private static func metadataIsEqual(_ metadata1: CFDictionary, _ metadata2: CFDictionary) -> Bool {
183 | let dict1 = metadata1 as NSDictionary
184 | let dict2 = metadata2 as NSDictionary
185 |
186 | // Focus on key properties that matter most
187 | let importantKeys = [
188 | kCGImagePropertyOrientation,
189 | kCGImagePropertyExifDictionary,
190 | kCGImagePropertyGPSDictionary,
191 | kCGImagePropertyTIFFDictionary,
192 | kCGImagePropertyIPTCDictionary
193 | ]
194 |
195 | for key in importantKeys {
196 | let value1 = dict1[key]
197 | let value2 = dict2[key]
198 |
199 | // If both are nil, continue
200 | if value1 == nil && value2 == nil {
201 | continue
202 | }
203 |
204 | // If one is nil and the other is not, they're different
205 | if (value1 == nil) != (value2 == nil) {
206 | return false
207 | }
208 |
209 | // Compare the values
210 | if let val1 = value1 as? NSDictionary,
211 | let val2 = value2 as? NSDictionary {
212 | if !val1.isEqual(to: val2 as! [AnyHashable : Any]) {
213 | return false
214 | }
215 | } else if let val1 = value1,
216 | let val2 = value2,
217 | !isEqual(val1, val2) {
218 | return false
219 | }
220 | }
221 |
222 | return true
223 | }
224 |
225 | /// Helper method to compare two values
226 | /// - Parameters:
227 | /// - value1: First value
228 | /// - value2: Second value
229 | /// - Returns: True if values are equal
230 | private static func isEqual(_ value1: Any, _ value2: Any) -> Bool {
231 | if let str1 = value1 as? String, let str2 = value2 as? String {
232 | return str1 == str2
233 | }
234 | if let num1 = value1 as? NSNumber, let num2 = value2 as? NSNumber {
235 | return num1 == num2
236 | }
237 | if let dict1 = value1 as? NSDictionary, let dict2 = value2 as? NSDictionary {
238 | return dict1.isEqual(to: dict2 as! [AnyHashable : Any])
239 | }
240 | return false
241 | }
242 | }
--------------------------------------------------------------------------------
/README.zh-CN.md:
--------------------------------------------------------------------------------
1 | # HiPixel
2 |
3 | 使用我的应用也是 [支持我](https://5km.tech) 的一种方式:
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | ---
14 |
15 |
16 |
17 |
18 |
19 | HiPixel
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | English | 中文
38 |
39 |
40 | ---
41 |
42 | ## macOS 原生的 AI 图像超分辨率工具
43 |
44 | HiPixel 是一款原生 macOS 应用程序,用于 AI 图像超分辨率处理,使用 SwiftUI 构建,并采用 Upscayl 的强大 AI 模型。
45 |
46 |
47 |
48 |
49 |
50 | ## ✨ 功能特点
51 |
52 | - 🖥️ 原生 macOS 应用程序,使用 SwiftUI 界面
53 | - 🎨 使用 AI 模型进行高质量图像放大
54 | - 🚀 GPU 加速,处理速度快
55 | - 🖼️ 支持多种图像格式
56 | - 📁 文件夹监控功能,自动处理新增图像
57 | - 💻 现代化直观的用户界面
58 |
59 | ### 💡 为什么选择 HiPixel?
60 |
61 | 虽然 [Upscayl](https://github.com/upscayl/upscayl) 已经提供了一个优秀的 macOS 应用程序,但是 HiPixel 是为了特定的目标而开发的:
62 |
63 | 1. **原生 macOS 体验**
64 | - 以原生 SwiftUI 应用程序的形式构建,同时利用 Upscayl 的强大二进制工具和 AI 模型
65 | - 提供一种无缝的、平台原生的体验,感觉就像在 macOS 上一样
66 |
67 | 2. **提高工作流效率**
68 | - 简化交互,支持拖放处理 - 图像在放下时会自动处理
69 | - 支持批量处理,能够同时处理多张图像
70 | - 支持 URL Scheme,能够与第三方应用程序集成,实现自动化和工作流扩展
71 | - 文件夹监控功能,自动处理添加到指定文件夹中的新图像
72 | - 简化界面,专注于最常用的功能,使得图像放大过程更加直接
73 |
74 | HiPixel 旨在通过提供一种专注于工作流效率和原生 macOS 集成的替代方法来补充 Upscayl,同时建立在 Upscayl 优秀的 AI 图像放大基础之上。
75 |
76 | ### 🔗 URL Scheme 使用说明
77 |
78 | HiPixel 支持 URL Scheme,可通过外部应用程序或脚本处理图像。您可以通过 URL 查询参数指定图像处理选项,这些选项会覆盖应用程序中的默认设置。
79 |
80 | #### 基本 URL 格式
81 |
82 | ```text
83 | hipixel://?path=/path/to/image1&path=/path/to/image2
84 | ```
85 |
86 | #### URL 参数说明
87 |
88 | | 参数 | 类型 | 说明 | 示例值 |
89 | |------|------|------|--------|
90 | | `path` | String | **必需。** 图像文件或文件夹的路径。可以通过重复此参数指定多个路径。 | `/Users/username/Pictures/image.jpg` |
91 | | `saveImageAs` | String | 输出图像格式。 | `PNG`, `JPG`, `WEBP`, `Original` |
92 | | `imageScale` | Number | 放大倍数(乘数)。 | `2.0`, `4.0`, `8.0` |
93 | | `imageCompression` | Number | 压缩级别(0-99)。仅在未使用 Zipic 压缩时生效。 | `0`, `50`, `90` |
94 | | `enableZipicCompression` | Boolean | 启用 Zipic 压缩(需要安装 Zipic 应用)。 | `true`, `false`, `1`, `0` |
95 | | `enableSaveOutputFolder` | Boolean | 将输出保存到自定义文件夹,而不是源文件所在目录。 | `true`, `false`, `1`, `0` |
96 | | `saveOutputFolder` | String | 自定义输出文件夹路径(URL 编码)。需要 `enableSaveOutputFolder=true`。 | `/Users/username/Output` |
97 | | `overwritePreviousUpscale` | Boolean | 如果已存在放大后的图像,是否覆盖。 | `true`, `false`, `1`, `0` |
98 | | `gpuID` | String | 用于处理的 GPU ID。空字符串使用默认 GPU。 | `0`, `1`, `2` |
99 | | `customTileSize` | Number | 处理的自定义图块大小。`0` 表示使用默认值。 | `0`, `128`, `256`, `512` |
100 | | `customModelsFolder` | String | AI 模型的自定义文件夹路径(URL 编码)。 | `/Users/username/Models` |
101 | | `upscaylModel` | String | 要使用的内置 AI 模型。 | `upscayl-standard-4x`, `upscayl-lite-4x`, `high-fidelity-4x`, `digital-art-4x` |
102 | | `selectedCustomModel` | String | 要使用的自定义模型名称。与 `upscaylModel` 冲突(自定义模型优先)。 | `my-custom-model` |
103 | | `doubleUpscayl` | Boolean | 启用双重放大(放大两次以获得更高分辨率)。 | `true`, `false`, `1`, `0` |
104 | | `enableTTA` | Boolean | 启用测试时间增强以获得更好的质量(处理速度较慢)。 | `true`, `false`, `1`, `0` |
105 |
106 | #### 使用示例
107 |
108 | **终端:**
109 |
110 | ```bash
111 | # 使用默认设置处理单张图像
112 | open "hipixel://?path=/Users/username/Pictures/image.jpg"
113 |
114 | # 处理多张图像
115 | open "hipixel://?path=/Users/username/Pictures/image1.jpg&path=/Users/username/Pictures/image2.jpg"
116 |
117 | # 使用自定义选项:4倍放大、PNG 格式、启用双重放大
118 | open "hipixel://?path=/Users/username/Pictures/image.jpg&imageScale=4.0&saveImageAs=PNG&doubleUpscayl=true"
119 |
120 | # 使用自定义输出文件夹和 Zipic 压缩
121 | open "hipixel://?path=/Users/username/Pictures/image.jpg&enableSaveOutputFolder=true&saveOutputFolder=/Users/username/Output&enableZipicCompression=true"
122 |
123 | # 使用特定 AI 模型并启用 TTA
124 | open "hipixel://?path=/Users/username/Pictures/image.jpg&upscaylModel=high-fidelity-4x&enableTTA=true"
125 | ```
126 |
127 | **AppleScript:**
128 |
129 | ```applescript
130 | tell application "Finder"
131 | set selectedFiles to selection as alias list
132 | set urlString to "hipixel://"
133 | set firstFile to true
134 | repeat with theFile in selectedFiles
135 | if firstFile then
136 | set urlString to urlString & "?path=" & POSIX path of theFile
137 | set firstFile to false
138 | else
139 | set urlString to urlString & "&path=" & POSIX path of theFile
140 | end if
141 | end repeat
142 | -- 添加处理选项
143 | set urlString to urlString & "&imageScale=4.0&saveImageAs=PNG"
144 | open location urlString
145 | end tell
146 | ```
147 |
148 | **Shell 脚本:**
149 |
150 | ```bash
151 | #!/bin/bash
152 | # 使用自定义设置处理文件夹中的所有图像
153 |
154 | IMAGE_PATH="/Users/username/Pictures"
155 | OUTPUT_FOLDER="/Users/username/Upscaled"
156 |
157 | for image in "$IMAGE_PATH"/*.{jpg,jpeg,png}; do
158 | if [ -f "$image" ]; then
159 | open "hipixel://?path=$image&imageScale=4.0&enableSaveOutputFolder=true&saveOutputFolder=$OUTPUT_FOLDER&doubleUpscayl=true"
160 | fi
161 | done
162 | ```
163 |
164 | #### 注意事项
165 |
166 | - 除 `path` 外的所有参数都是可选的。如果未指定,应用程序将使用在应用偏好设置中配置的默认设置。
167 | - 可以指定多个 `path` 参数,以在单次调用中处理多张图像或文件夹。
168 | - 布尔值接受:`true`, `false`, `1`, `0`, `yes`, `no`, `on`, `off`(不区分大小写)。
169 | - 包含特殊字符的文件路径和文件夹路径应进行 URL 编码。
170 | - 当同时指定 `upscaylModel` 和 `selectedCustomModel` 时,`selectedCustomModel` 优先。
171 | - 如果 `enableSaveOutputFolder=true` 但未提供 `saveOutputFolder`,输出将保存在源图像所在的目录中。
172 |
173 | ### 🚀 安装方法
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 | 1. 访问 [hipixel.5km.tech](https://hipixel.5km.tech) 下载最新版本
182 | 2. 将 HiPixel.app 移动到应用程序文件夹
183 | 3. 启动 HiPixel
184 |
185 | > **注意**:HiPixel 需要 macOS 13.0 (Ventura) 或更高版本。
186 |
187 | ### 🛠️ 从源代码构建
188 |
189 | 1. 克隆仓库:
190 |
191 | ```bash
192 | git clone https://github.com/okooo5km/hipixel
193 | cd hipixel
194 | ```
195 |
196 | 2. 在 Xcode 中打开 HiPixel.xcodeproj
197 | 3. 构建并运行项目
198 |
199 | ### 📝 许可证
200 |
201 | HiPixel 采用 GNU Affero 通用公共许可证第3版 (AGPLv3) 授权。这意味着:
202 |
203 | - ✅ 您可以使用、修改和分发此软件
204 | - ✅ 如果您修改了软件,您必须:
205 | - 在相同的许可证下提供您的修改
206 | - 提供完整源代码的访问
207 | - 保留所有版权声明和归属
208 |
209 | 本软件使用 Upscayl 的二进制文件和 AI 模型,这些也都采用 AGPLv3 许可。
210 |
211 | ### ☕️ 支持项目
212 |
213 | 如果您觉得 HiPixel 对您有帮助,可以通过以下方式支持项目的开发:
214 |
215 | - ⭐️ 在 GitHub 上给项目点星
216 | - 🐛 报告问题或提出建议
217 | - 💝 赞助支持:
218 |
219 |
220 |
221 |
222 |
223 |
224 | 更多支持方式
225 |
226 | - 🛍️ **[通过 LemonSqueezy 一次性支持](https://okooo5km.lemonsqueezy.com/buy/4f1e3249-2683-4000-acd4-6b05ae117b40?discount=0)**
227 |
228 | - **微信支付**
229 |
230 |
231 |
232 |
233 | - **支付宝**
234 |
235 |
236 |
237 |
238 |
239 |
240 | 您的支持将帮助我们持续改进 HiPixel!
241 |
242 | ### 👉 推荐工具
243 |
244 | - **[Zipic](https://zipic.app)** - 智能图像压缩工具,搭配 AI 优化技术
245 | - 🔄 **完美搭配**: 使用 HiPixel 放大图像后,用 Zipic 进行智能压缩,在保持清晰度的同时减小文件体积
246 | - 🎯 **工作流建议**: HiPixel 放大 → Zipic 压缩 → 输出优化图像
247 | - ✨ **效果提升**: 相比单独使用任一工具,联合使用可获得质量与体积的最佳平衡
248 |
249 | 探索更多 [5KM Tech](https://5km.tech) 为复杂任务带来简单解决方案的产品。
250 |
251 | ### 🙏 致谢
252 |
253 | HiPixel 使用了以下来自 [Upscayl](https://github.com/upscayl/upscayl) 的组件:
254 |
255 | - upscayl-bin - AI 超分辨率处理工具
256 | - AI Models - 图像超分辨率模型
257 |
258 | 特别感谢 [zaotang.xyz](https://zaotang.xyz) 为 HiPixel v0.2 版本设计了全新的应用图标和主窗口交互界面。
259 |
260 | HiPixel 还使用了:
261 |
262 | - [FSWatcher](https://github.com/okooo5km/FSWatcher) - 高性能的 Swift 原生文件系统监控库,支持 macOS 和 iOS 智能监听 (MIT 许可证)
263 | - [Sparkle](https://github.com/sparkle-project/Sparkle) - macOS 应用程序的软件更新框架 (MIT 许可证)
264 | - [NotchNotification](https://github.com/Lakr233/NotchNotification) - 适用于 macOS 的刘海屏样式通知横幅 (MIT 许可证)
265 | - [GeneralNotification](https://github.com/okooo5km/GeneralNotification) - 适用于 macOS 的自定义通知横幅 (MIT 许可证)
266 |
267 |
--------------------------------------------------------------------------------
/HiPixel/Services/UpscaleImagesIntent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UpscaleImagesIntent.swift
3 | // HiPixel
4 | //
5 | // Created by 十里 on 2025/11/10.
6 | //
7 |
8 | import AppIntents
9 | import Foundation
10 | import SwiftUI
11 |
12 | struct UpscaleImagesIntent: AppIntent {
13 | static var title: LocalizedStringResource = "Upscale Images"
14 | static var description = IntentDescription("Upscale images using AI with HiPixel.")
15 |
16 | @Parameter(title: "Files", description: "Image files or folders to upscale")
17 | var files: [IntentFile]
18 |
19 | @Parameter(title: "Image Scale", description: "Upscaling factor (multiplier)", default: 4.0)
20 | var imageScale: Double?
21 |
22 | @Parameter(title: "Save Format", description: "Output image format")
23 | var saveImageAs: ImageFormatEnum?
24 |
25 | @Parameter(title: "Double Upscayl", description: "Enable double upscaling (upscale twice)", default: false)
26 | var doubleUpscayl: Bool?
27 |
28 | @Parameter(title: "Enable TTA", description: "Enable Test Time Augmentation for better quality", default: false)
29 | var enableTTA: Bool?
30 |
31 | @Parameter(title: "Compression Level", description: "Compression level (0-99)")
32 | var imageCompression: Int?
33 |
34 | @Parameter(
35 | title: "Enable Zipic Compression", description: "Enable Zipic compression (requires Zipic app)", default: false)
36 | var enableZipicCompression: Bool?
37 |
38 | @Parameter(title: "Custom Output Folder", description: "Custom folder path for output images")
39 | var saveOutputFolder: String?
40 |
41 | @Parameter(title: "GPU ID", description: "GPU ID to use for processing")
42 | var gpuID: String?
43 |
44 | @Parameter(title: "Custom Tile Size", description: "Custom tile size for processing (0 uses default)")
45 | var customTileSize: Int?
46 |
47 | @Parameter(title: "Upscayl Model", description: "Built-in AI model to use")
48 | var upscaylModel: UpscaylModelEnum?
49 |
50 | @Parameter(title: "Selected Custom Model", description: "Custom model name to use")
51 | var selectedCustomModel: String?
52 |
53 | static var parameterSummary: some ParameterSummary {
54 | Summary {
55 | \.$files
56 | \.$imageScale
57 | \.$saveImageAs
58 | \.$doubleUpscayl
59 | \.$enableTTA
60 | \.$imageCompression
61 | \.$enableZipicCompression
62 | \.$saveOutputFolder
63 | \.$gpuID
64 | \.$customTileSize
65 | \.$upscaylModel
66 | \.$selectedCustomModel
67 | }
68 | }
69 |
70 | func perform() async throws -> some IntentResult & ProvidesDialog {
71 | // Convert IntentFile to URLs
72 | let urls = files.compactMap { file -> URL? in
73 | // Prefer fileURL if available
74 | if let url = file.fileURL, url.isFileURL {
75 | return url
76 | }
77 | // Fallback: try to write data to temporary file if it's image data
78 | let data = file.data
79 | if !data.isEmpty {
80 | // Check if data looks like image data by checking first bytes
81 | let imageSignatures: [Data] = [
82 | Data([0x89, 0x50, 0x4E, 0x47]), // PNG
83 | Data([0xFF, 0xD8, 0xFF]), // JPEG
84 | ]
85 | if imageSignatures.contains(where: { data.prefix($0.count) == $0 }) {
86 | let tmp = FileManager.default.temporaryDirectory
87 | .appendingPathComponent(UUID().uuidString)
88 | .appendingPathExtension("jpg")
89 | try? data.write(to: tmp)
90 | return tmp
91 | }
92 | }
93 | return nil
94 | }
95 |
96 | guard !urls.isEmpty else {
97 | return .result(dialog: IntentDialog(stringLiteral: String(localized: "No valid image files provided.")))
98 | }
99 |
100 | // Validate URLs - check if they exist and are images or folders
101 | let validURLs = urls.compactMap { url -> URL? in
102 | guard FileManager.default.fileExists(atPath: url.path) else {
103 | return nil
104 | }
105 | guard url.isImageFile || url.isFile(ofTypes: [.folder]) else {
106 | return nil
107 | }
108 | return url
109 | }
110 |
111 | guard !validURLs.isEmpty else {
112 | return .result(
113 | dialog: IntentDialog(stringLiteral: String(localized: "No valid image files or folders found.")))
114 | }
115 |
116 | // Build UpscaylOptions from parameters
117 | let options = buildOptions()
118 |
119 | // Collect all image URLs to process
120 | var allImageURLs: [URL] = []
121 | for url in validURLs {
122 | if url.hasDirectoryPath {
123 | allImageURLs.append(contentsOf: url.imageContents)
124 | } else {
125 | allImageURLs.append(url)
126 | }
127 | }
128 |
129 | guard !allImageURLs.isEmpty else {
130 | return .result(
131 | dialog: IntentDialog(stringLiteral: String(localized: "No valid image files or folders found.")))
132 | }
133 |
134 | // Process images sequentially and wait for completion
135 | var successCount = 0
136 | var failedCount = 0
137 |
138 | for imageURL in allImageURLs {
139 | guard imageURL.fileSize > 0 else { continue }
140 |
141 | let item = UpscaylDataItem(imageURL)
142 |
143 | // Process each image and wait for completion using continuation
144 | let result = await withCheckedContinuation { (continuation: CheckedContinuation) in
145 | Upscayl.process(
146 | item,
147 | progressHandler: { _, _ in
148 | // Progress updates can be handled here if needed
149 | },
150 | completedHandler: { outputURL in
151 | continuation.resume(returning: outputURL)
152 | },
153 | options: options,
154 | source: .automated
155 | )
156 | }
157 |
158 | if result != nil {
159 | successCount += 1
160 | } else {
161 | failedCount += 1
162 | }
163 | }
164 |
165 | // Return result based on processing outcome
166 | if failedCount == 0 {
167 | let message = String(localized: "Successfully processed %lld file(s).")
168 | return .result(dialog: IntentDialog(stringLiteral: String(format: message, successCount)))
169 | } else {
170 | let message = String(localized: "Processed %lld file(s), %lld succeeded, %lld failed.")
171 | return .result(
172 | dialog: IntentDialog(
173 | stringLiteral: String(format: message, successCount + failedCount, successCount, failedCount)))
174 | }
175 | }
176 |
177 | private func buildOptions() -> UpscaylOptions {
178 | var options = UpscaylOptions()
179 |
180 | if let imageScale = imageScale {
181 | options.imageScale = imageScale
182 | }
183 |
184 | if let saveImageAs = saveImageAs {
185 | options.saveImageAs = saveImageAs.toImageFormat()
186 | }
187 |
188 | if let doubleUpscayl = doubleUpscayl {
189 | options.doubleUpscayl = doubleUpscayl
190 | }
191 |
192 | if let enableTTA = enableTTA {
193 | options.enableTTA = enableTTA
194 | }
195 |
196 | if let imageCompression = imageCompression {
197 | options.imageCompression = imageCompression
198 | }
199 |
200 | if let enableZipicCompression = enableZipicCompression {
201 | options.enableZipicCompression = enableZipicCompression
202 | }
203 |
204 | if let saveOutputFolder = saveOutputFolder {
205 | options.saveOutputFolder = saveOutputFolder
206 | options.enableSaveOutputFolder = true
207 | }
208 |
209 | if let gpuID = gpuID {
210 | options.gpuID = gpuID
211 | }
212 |
213 | if let customTileSize = customTileSize {
214 | options.customTileSize = customTileSize
215 | }
216 |
217 | if let upscaylModel = upscaylModel {
218 | options.upscaylModel = upscaylModel.toUpscaylModel()
219 | }
220 |
221 | if let selectedCustomModel = selectedCustomModel {
222 | options.selectedCustomModel = selectedCustomModel
223 | }
224 |
225 | return options
226 | }
227 | }
228 |
229 | // MARK: - Enum Types for AppIntent Parameters
230 |
231 | enum ImageFormatEnum: String, AppEnum {
232 | case png = "PNG"
233 | case jpg = "JPG"
234 | case webp = "WEBP"
235 | case original = "Original"
236 |
237 | static var typeDisplayRepresentation: TypeDisplayRepresentation = "Image Format"
238 | static var caseDisplayRepresentations: [ImageFormatEnum: DisplayRepresentation] = [
239 | .png: "PNG",
240 | .jpg: "JPG",
241 | .webp: "WEBP",
242 | .original: "Original",
243 | ]
244 |
245 | func toImageFormat() -> HiPixelConfiguration.ImageFormat {
246 | switch self {
247 | case .png: return .png
248 | case .jpg: return .jpg
249 | case .webp: return .webp
250 | case .original: return .original
251 | }
252 | }
253 | }
254 |
255 | enum UpscaylModelEnum: String, AppEnum {
256 | case standard = "upscayl-standard-4x"
257 | case lite = "upscayl-lite-4x"
258 | case highFidelity = "high-fidelity-4x"
259 | case digitalArt = "digital-art-4x"
260 |
261 | static var typeDisplayRepresentation: TypeDisplayRepresentation = "Upscayl Model"
262 | static var caseDisplayRepresentations: [UpscaylModelEnum: DisplayRepresentation] = [
263 | .standard: "Standard",
264 | .lite: "Lite",
265 | .highFidelity: "High Fidelity",
266 | .digitalArt: "Digital Art",
267 | ]
268 |
269 | func toUpscaylModel() -> HiPixelConfiguration.UpscaylModel {
270 | switch self {
271 | case .standard: return .Upscayl_Standard
272 | case .lite: return .Upscayl_Lite
273 | case .highFidelity: return .High_Fidenlity
274 | case .digitalArt: return .Digital_Art
275 | }
276 | }
277 | }
278 |
279 | struct HiPixelShortcuts: AppShortcutsProvider {
280 | static var appShortcuts: [AppShortcut] {
281 | let shortcut = AppShortcut(
282 | intent: UpscaleImagesIntent(),
283 | phrases: [
284 | "Upscale images with \(.applicationName)",
285 | "Process images with \(.applicationName)",
286 | ],
287 | shortTitle: "Upscale Images",
288 | systemImageName: "photo.stack"
289 | )
290 | return [shortcut]
291 | }
292 | }
293 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # HiPixel
2 |
3 | Using my apps is also a way to [support me](https://5km.tech):
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | ---
14 |
15 |
16 |
17 |
18 |
19 | HiPixel
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | English | 中文
38 |
39 |
40 | ---
41 |
42 | ## AI-Powered Image Super-Resolution for macOS
43 |
44 | HiPixel is a native macOS application for AI-powered image super-resolution, built with SwiftUI and leveraging Upscayl's powerful AI models.
45 |
46 |
47 |
48 |
49 |
50 | ## ✨ Features
51 |
52 | - 🖥️ Native macOS application with SwiftUI interface
53 | - 🎨 High-quality image upscaling using AI models
54 | - 🚀 Fast processing with GPU acceleration
55 | - 🖼️ Supports various image formats
56 | - 📁 Folder monitoring for automatic processing of newly added images
57 | - 💻 Modern, intuitive user interface
58 |
59 | ### 💡 Why HiPixel?
60 |
61 | While [Upscayl](https://github.com/upscayl/upscayl) already offers an excellent macOS application, HiPixel was developed with specific goals in mind:
62 |
63 | 1. **Native macOS Experience**
64 | - Built as a native SwiftUI application while utilizing Upscayl's powerful binary tools and AI models
65 | - Provides a seamless, platform-native experience that feels right at home on macOS
66 |
67 | 2. **Enhanced Workflow Efficiency**
68 | - Streamlined interaction with drag-and-drop processing - images are processed automatically upon dropping
69 | - Batch processing support for handling multiple images simultaneously
70 | - URL Scheme support for third-party integration, enabling automation and workflow extensions
71 | - Folder monitoring capability that automatically processes new images added to designated folders
72 | - Simplified interface focusing on the most commonly used features, making the upscaling process more straightforward
73 |
74 | HiPixel aims to complement Upscayl by offering an alternative approach focused on workflow efficiency and native macOS integration, while building upon Upscayl's excellent AI upscaling foundation.
75 |
76 | ### 🔗 URL Scheme Support
77 |
78 | HiPixel supports URL Scheme for processing images via external applications or scripts. You can specify image processing options via URL query parameters, which will override the default settings in the app.
79 |
80 | #### Basic URL Format
81 |
82 | ```text
83 | hipixel://?path=/path/to/image1&path=/path/to/image2
84 | ```
85 |
86 | #### URL Parameters
87 |
88 | | Parameter | Type | Description | Example Values |
89 | |-----------|------|-------------|----------------|
90 | | `path` | String | **Required.** Path to image file(s) or folder(s). Multiple paths can be specified by repeating this parameter. | `/Users/username/Pictures/image.jpg` |
91 | | `saveImageAs` | String | Output image format. | `PNG`, `JPG`, `WEBP`, `Original` |
92 | | `imageScale` | Number | Upscaling factor (multiplier). | `2.0`, `4.0`, `8.0` |
93 | | `imageCompression` | Number | Compression level (0-99). Only applies when not using Zipic compression. | `0`, `50`, `90` |
94 | | `enableZipicCompression` | Boolean | Enable Zipic compression (requires Zipic app installed). | `true`, `false`, `1`, `0` |
95 | | `enableSaveOutputFolder` | Boolean | Save output to a custom folder instead of the same directory as source. | `true`, `false`, `1`, `0` |
96 | | `saveOutputFolder` | String | Custom output folder path (URL encoded). Requires `enableSaveOutputFolder=true`. | `/Users/username/Output` |
97 | | `overwritePreviousUpscale` | Boolean | Overwrite existing upscaled images if they already exist. | `true`, `false`, `1`, `0` |
98 | | `gpuID` | String | GPU ID to use for processing. Empty string uses default GPU. | `0`, `1`, `2` |
99 | | `customTileSize` | Number | Custom tile size for processing. `0` uses default. | `0`, `128`, `256`, `512` |
100 | | `customModelsFolder` | String | Custom folder path for AI models (URL encoded). | `/Users/username/Models` |
101 | | `upscaylModel` | String | Built-in AI model to use. | `upscayl-standard-4x`, `upscayl-lite-4x`, `high-fidelity-4x`, `digital-art-4x` |
102 | | `selectedCustomModel` | String | Custom model name to use. Conflicts with `upscaylModel` (custom model takes precedence). | `my-custom-model` |
103 | | `doubleUpscayl` | Boolean | Enable double upscaling (upscale twice for higher resolution). | `true`, `false`, `1`, `0` |
104 | | `enableTTA` | Boolean | Enable Test Time Augmentation for better quality (slower processing). | `true`, `false`, `1`, `0` |
105 |
106 | #### Example Usage
107 |
108 | **Terminal:**
109 |
110 | ```bash
111 | # Process a single image with default settings
112 | open "hipixel://?path=/Users/username/Pictures/image.jpg"
113 |
114 | # Process multiple images
115 | open "hipixel://?path=/Users/username/Pictures/image1.jpg&path=/Users/username/Pictures/image2.jpg"
116 |
117 | # Process with custom options: 4x scale, PNG format, double upscaling enabled
118 | open "hipixel://?path=/Users/username/Pictures/image.jpg&imageScale=4.0&saveImageAs=PNG&doubleUpscayl=true"
119 |
120 | # Process with custom output folder and Zipic compression
121 | open "hipixel://?path=/Users/username/Pictures/image.jpg&enableSaveOutputFolder=true&saveOutputFolder=/Users/username/Output&enableZipicCompression=true"
122 |
123 | # Process with specific AI model and TTA enabled
124 | open "hipixel://?path=/Users/username/Pictures/image.jpg&upscaylModel=high-fidelity-4x&enableTTA=true"
125 | ```
126 |
127 | **AppleScript:**
128 |
129 | ```applescript
130 | tell application "Finder"
131 | set selectedFiles to selection as alias list
132 | set urlString to "hipixel://"
133 | set firstFile to true
134 | repeat with theFile in selectedFiles
135 | if firstFile then
136 | set urlString to urlString & "?path=" & POSIX path of theFile
137 | set firstFile to false
138 | else
139 | set urlString to urlString & "&path=" & POSIX path of theFile
140 | end if
141 | end repeat
142 | -- Add processing options
143 | set urlString to urlString & "&imageScale=4.0&saveImageAs=PNG"
144 | open location urlString
145 | end tell
146 | ```
147 |
148 | **Shell Script:**
149 |
150 | ```bash
151 | #!/bin/bash
152 | # Process all images in a folder with custom settings
153 |
154 | IMAGE_PATH="/Users/username/Pictures"
155 | OUTPUT_FOLDER="/Users/username/Upscaled"
156 |
157 | for image in "$IMAGE_PATH"/*.{jpg,jpeg,png}; do
158 | if [ -f "$image" ]; then
159 | open "hipixel://?path=$image&imageScale=4.0&enableSaveOutputFolder=true&saveOutputFolder=$OUTPUT_FOLDER&doubleUpscayl=true"
160 | fi
161 | done
162 | ```
163 |
164 | #### Notes
165 |
166 | - All parameters except `path` are optional. If not specified, the app will use the default settings configured in the app preferences.
167 | - Multiple `path` parameters can be specified to process multiple images or folders in a single call.
168 | - Boolean values accept: `true`, `false`, `1`, `0`, `yes`, `no`, `on`, `off` (case-insensitive).
169 | - File paths and folder paths containing special characters should be URL encoded.
170 | - When both `upscaylModel` and `selectedCustomModel` are specified, `selectedCustomModel` takes precedence.
171 | - If `enableSaveOutputFolder=true` but `saveOutputFolder` is not provided, the output will be saved in the same directory as the source image.
172 |
173 | ### 🚀 Installation
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 | 1. Visit [hipixel.5km.tech](https://hipixel.5km.tech) to download the latest version
182 | 2. Move HiPixel.app to your Applications folder
183 | 3. Launch HiPixel
184 |
185 | > **Note**: HiPixel requires macOS 13.0 (Ventura) or later.
186 |
187 | ### 🛠️ Building from Source
188 |
189 | 1. Clone the repository:
190 |
191 | ```bash
192 | git clone https://github.com/okooo5km/hipixel
193 | cd hipixel
194 | ```
195 |
196 | 2. Open HiPixel.xcodeproj in Xcode
197 | 3. Build and run the project
198 |
199 | ### 📝 License
200 |
201 | HiPixel is licensed under the GNU Affero General Public License v3.0 (AGPLv3). This means:
202 |
203 | - ✅ You can use, modify, and distribute this software
204 | - ✅ If you modify the software, you must:
205 | - Make your modifications available under the same license
206 | - Provide access to the complete source code
207 | - Preserve all copyright notices and attributions
208 |
209 | This software uses Upscayl's binaries and AI models, which are also licensed under AGPLv3.
210 |
211 | ### ☕️ Support the Project
212 |
213 | If you find HiPixel helpful, please consider supporting its development:
214 |
215 | - ⭐️ Star the project on GitHub
216 | - 🐛 Report bugs or suggest features
217 | - 💝 Support via:
218 |
219 |
220 |
221 |
222 |
223 |
224 | More ways to support
225 |
226 | - 🛍️ **[One-time Support via LemonSqueezy](https://okooo5km.lemonsqueezy.com/buy/4f1e3249-2683-4000-acd4-6b05ae117b40?discount=0)**
227 |
228 | - **WeChat Pay**
229 |
230 |
231 |
232 |
233 |
234 | - **Alipay**
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 | Your support helps maintain and improve HiPixel!
243 |
244 | ### 👉 Recommended Tool
245 |
246 | - **[Zipic](https://zipic.app)** - Smart image compression tool with AI optimization
247 | - 🔄 **Perfect Pairing**: After upscaling images with HiPixel, use Zipic for intelligent compression to reduce file size while maintaining clarity
248 | - 🎯 **Workflow Suggestion**: HiPixel upscaling → Zipic compression → Optimized output image
249 | - ✨ **Enhanced Results**: Compared to using either tool alone, combined use provides the optimal balance of quality and file size
250 |
251 | Explore more [5KM Tech](https://5km.tech) products that bring simplicity to complex tasks.
252 |
253 | ### 🙏 Attribution
254 |
255 | HiPixel uses the following components from [Upscayl](https://github.com/upscayl/upscayl):
256 |
257 | - upscayl-bin - The binary tool for AI upscaling (AGPLv3)
258 | - AI Models - The AI models for image super-resolution (AGPLv3)
259 |
260 | Special thanks to [zaotang.xyz](https://zaotang.xyz) for designing the new application icon and main window interaction interface for HiPixel v0.2.
261 |
262 | HiPixel also uses:
263 |
264 | - [FSWatcher](https://github.com/okooo5km/FSWatcher) - A high-performance, Swift-native file system watcher for macOS and iOS with intelligent monitoring (MIT License)
265 | - [Sparkle](https://github.com/sparkle-project/Sparkle) - A software update framework for macOS applications (MIT License)
266 | - [NotchNotification](https://github.com/Lakr233/NotchNotification) - A custom notch-style notification banner for macOS (MIT License)
267 | - [GeneralNotification](https://github.com/okooo5km/GeneralNotification) - A custom notification banner for macOS (MIT License)
268 |
--------------------------------------------------------------------------------
/HiPixel/Views/ImageComparationViewer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ImageComparationViewer.swift
3 | // HiPixel
4 | //
5 | // Created by 十里 on 2024/11/3.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ImageComparationViewer: View {
11 |
12 | var leftImage: URL
13 | var rightImage: URL
14 |
15 | @State
16 | private var sliderPosition: CGFloat = 0.5 // Initial slider position
17 |
18 | @State
19 | private var hoveringOnSlider = false
20 |
21 | @State
22 | private var magnification: CGFloat = 1.0 // Zoom scale
23 |
24 | @State
25 | private var offset: CGSize = .zero // Drag offset
26 |
27 | @State
28 | private var lastMagnification: CGFloat = 1.0
29 |
30 | @State
31 | private var lastOffset: CGSize = .zero
32 |
33 | @State
34 | private var isDraggingSlider = false // Track if slider is being dragged
35 |
36 | @State
37 | private var hoveredZoomButton: ZoomButtonType? = nil // Track hovered button
38 |
39 | private let minMagnification: CGFloat = 1.0
40 | private let maxMagnification: CGFloat = 5.0
41 | private let zoomStep: CGFloat = 0.25 // Zoom step size
42 |
43 | var body: some View {
44 | GeometryReader { geometry in
45 | let imageSize = NSImage(contentsOf: leftImage)?.size ?? geometry.size
46 | let imageWidth = imageSize.width * geometry.size.height / imageSize.height
47 |
48 | // Calculate scaled image size and drag limits
49 | let scaledWidth = geometry.size.width * magnification
50 | let scaledHeight = geometry.size.height * magnification
51 | let maxOffsetX = max(0, (scaledWidth - geometry.size.width) / 2)
52 | let maxOffsetY = max(0, (scaledHeight - geometry.size.height) / 2)
53 |
54 | // Calculate slider drag range considering zoom
55 | let scaledImageWidth = imageWidth * magnification
56 | let scaledImageHalfWidth = scaledImageWidth / 2
57 |
58 | // If scaled image width >= view width, slider can drag to edges
59 | // Otherwise calculate range based on scaled image size
60 | let minPosition =
61 | scaledImageWidth >= geometry.size.width
62 | ? 0.001
63 | : max(0.5 - scaledImageHalfWidth / geometry.size.width, 0.001)
64 | let maxPosition =
65 | scaledImageWidth >= geometry.size.width
66 | ? 0.999
67 | : min(0.5 + scaledImageHalfWidth / geometry.size.width, 0.999)
68 |
69 | ZStack(alignment: .leading) {
70 | AsyncImage(url: rightImage) { image in
71 | image
72 | .resizable()
73 | .aspectRatio(contentMode: .fit)
74 | .frame(width: geometry.size.width, height: geometry.size.height)
75 | .scaleEffect(magnification)
76 | .offset(offset)
77 | } placeholder: {
78 | Color.gray
79 | }
80 | .mask(
81 | HStack {
82 | Spacer()
83 | Rectangle()
84 | .frame(width: (1 - sliderPosition) * geometry.size.width)
85 | }
86 | )
87 |
88 | AsyncImage(url: leftImage) { image in
89 | image
90 | .resizable()
91 | .aspectRatio(contentMode: .fit)
92 | .frame(width: geometry.size.width, height: geometry.size.height)
93 | .scaleEffect(magnification)
94 | .offset(offset)
95 | } placeholder: {
96 | Color.gray
97 | }
98 | .mask(
99 | HStack {
100 | Rectangle()
101 | .frame(width: sliderPosition * geometry.size.width)
102 | Spacer()
103 | }
104 | )
105 |
106 | ZStack {
107 | VStack(spacing: 0) {
108 | Rectangle()
109 | .frame(width: 2)
110 | .foregroundStyle(.clear)
111 | .background(.white.opacity(0.9))
112 |
113 | Rectangle()
114 | .frame(width: 2, height: 36)
115 | .foregroundStyle(.clear)
116 | .background(.clear)
117 |
118 | Rectangle()
119 | .frame(width: 2)
120 | .foregroundStyle(.clear)
121 | .background(.white.opacity(0.9))
122 | }
123 | .shadow(radius: 1, x: 0, y: 0)
124 |
125 | Circle()
126 | .fill(.white.opacity(0.8))
127 | .frame(width: 36, height: 36)
128 | .shadow(radius: 1, x: 0, y: 0)
129 |
130 | Circle()
131 | .stroke(.white, lineWidth: 2)
132 | .frame(width: 36, height: 36)
133 | .shadow(radius: 1, x: 0, y: 0)
134 |
135 | HStack(spacing: 4) {
136 | Image(systemName: "arrowtriangle.left.fill")
137 | Image(systemName: "arrowtriangle.right.fill")
138 | }
139 | .font(.system(size: 10))
140 | .foregroundStyle(.white)
141 | }
142 | .opacity(hoveringOnSlider ? 1.0 : 0.8)
143 | .offset(x: sliderPosition * geometry.size.width - 18)
144 | .highPriorityGesture(
145 | DragGesture()
146 | .onChanged { value in
147 | isDraggingSlider = true
148 | sliderPosition = min(max(minPosition, value.location.x / geometry.size.width), maxPosition)
149 | }
150 | .onEnded { _ in
151 | isDraggingSlider = false
152 | }
153 | )
154 | .onHover { hovering in
155 | if hovering {
156 | NSCursor.resizeLeftRight.set()
157 | hoveringOnSlider = true
158 | } else {
159 | NSCursor.arrow.set()
160 | hoveringOnSlider = false
161 | }
162 | }
163 | }
164 | .cornerRadius(6)
165 | .overlay(alignment: .bottomLeading) {
166 | ZoomControlsView(
167 | magnification: $magnification,
168 | lastMagnification: $lastMagnification,
169 | offset: $offset,
170 | lastOffset: $lastOffset,
171 | hoveredButton: $hoveredZoomButton,
172 | minMagnification: minMagnification,
173 | maxMagnification: maxMagnification,
174 | zoomStep: zoomStep
175 | )
176 | .padding(8)
177 | }
178 | .gesture(
179 | MagnificationGesture()
180 | .onChanged { value in
181 | let newMagnification = lastMagnification * value
182 | magnification = min(max(1.0, newMagnification), 5.0) // Limit zoom range 1x-5x
183 | }
184 | .onEnded { value in
185 | lastMagnification = magnification
186 | }
187 | )
188 | .simultaneousGesture(
189 | DragGesture(minimumDistance: 5)
190 | .onChanged { value in
191 | // Ignore image drag if slider is being dragged
192 | guard !isDraggingSlider else { return }
193 |
194 | // Only allow drag when zoomed in
195 | guard magnification > 1.0 else { return }
196 |
197 | // Check if in slider area to avoid conflict with slider drag
198 | let sliderX = sliderPosition * geometry.size.width
199 | let sliderRect = CGRect(
200 | x: sliderX - 30,
201 | y: 0,
202 | width: 60,
203 | height: geometry.size.height
204 | )
205 | if sliderRect.contains(value.startLocation) {
206 | return
207 | }
208 |
209 | let newOffsetX = lastOffset.width + value.translation.width
210 | let newOffsetY = lastOffset.height + value.translation.height
211 |
212 | // Limit drag range
213 | offset = CGSize(
214 | width: min(max(-maxOffsetX, newOffsetX), maxOffsetX),
215 | height: min(max(-maxOffsetY, newOffsetY), maxOffsetY)
216 | )
217 | }
218 | .onEnded { _ in
219 | if !isDraggingSlider {
220 | lastOffset = offset
221 | }
222 | }
223 | )
224 | .onTapGesture(count: 2) {
225 | // Double tap to reset zoom and position
226 | resetZoom()
227 | }
228 | }
229 | .clipShape(RoundedRectangle(cornerRadius: 6))
230 | .contentShape(RoundedRectangle(cornerRadius: 6))
231 | }
232 |
233 | private func resetZoom() {
234 | withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
235 | magnification = 1.0
236 | offset = .zero
237 | lastMagnification = 1.0
238 | lastOffset = .zero
239 | }
240 | }
241 | }
242 |
243 | // MARK: - Zoom Controls
244 |
245 | enum ZoomButtonType {
246 | case zoomIn
247 | case zoomOut
248 | }
249 |
250 | struct ZoomControlsView: View {
251 | @Binding var magnification: CGFloat
252 | @Binding var lastMagnification: CGFloat
253 | @Binding var offset: CGSize
254 | @Binding var lastOffset: CGSize
255 | @Binding var hoveredButton: ZoomButtonType?
256 |
257 | let minMagnification: CGFloat
258 | let maxMagnification: CGFloat
259 | let zoomStep: CGFloat
260 |
261 | private let zoomLevels: [CGFloat] = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]
262 |
263 | var body: some View {
264 | HStack(spacing: 8) {
265 | // Zoom out button
266 | ZoomButton(
267 | icon: "minus.magnifyingglass",
268 | isEnabled: magnification > minMagnification,
269 | isHovered: hoveredButton == .zoomOut
270 | ) {
271 | hoveredButton = .zoomOut
272 | } onExit: {
273 | hoveredButton = nil
274 | } action: {
275 | let newMagnification = max(magnification - zoomStep, minMagnification)
276 | withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) {
277 | magnification = newMagnification
278 | lastMagnification = newMagnification
279 | if newMagnification == 1.0 {
280 | offset = .zero
281 | lastOffset = .zero
282 | }
283 | }
284 | }
285 |
286 | // Percentage display and selector
287 | Menu {
288 | ForEach(zoomLevels, id: \.self) { level in
289 | Button {
290 | withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) {
291 | magnification = level
292 | lastMagnification = level
293 | if level == 1.0 {
294 | offset = .zero
295 | lastOffset = .zero
296 | }
297 | }
298 | } label: {
299 | HStack {
300 | Text("\(Int(level * 100))%")
301 | if magnification == level {
302 | Image(systemName: "checkmark")
303 | }
304 | }
305 | }
306 | }
307 | } label: {
308 | Text("\(Int(magnification * 100))%")
309 | .font(.caption)
310 | .fontWeight(.medium)
311 | .fontDesign(.monospaced)
312 | .padding(.horizontal, 8)
313 | .padding(.vertical, 6)
314 | .background(
315 | RoundedRectangle(cornerRadius: 6)
316 | .fill(.background.opacity(0.7))
317 | )
318 | .foregroundStyle(.primary)
319 | }
320 | .menuStyle(.borderlessButton)
321 | .buttonStyle(.plain)
322 |
323 | // Zoom in button
324 | ZoomButton(
325 | icon: "plus.magnifyingglass",
326 | isEnabled: magnification < maxMagnification,
327 | isHovered: hoveredButton == .zoomIn
328 | ) {
329 | hoveredButton = .zoomIn
330 | } onExit: {
331 | hoveredButton = nil
332 | } action: {
333 | let newMagnification = min(magnification + zoomStep, maxMagnification)
334 | withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) {
335 | magnification = newMagnification
336 | lastMagnification = newMagnification
337 | }
338 | }
339 | }
340 | .padding(6)
341 | .background(
342 | RoundedRectangle(cornerRadius: 8)
343 | .fill(.background.opacity(0.8))
344 | .shadow(color: .black.opacity(0.15), radius: 1, x: 0, y: 0.4)
345 | )
346 | }
347 | }
348 |
349 | struct ZoomButton: View {
350 | let icon: String
351 | let isEnabled: Bool
352 | let isHovered: Bool
353 | let onHover: () -> Void
354 | let onExit: () -> Void
355 | let action: () -> Void
356 |
357 | init(
358 | icon: String,
359 | isEnabled: Bool,
360 | isHovered: Bool,
361 | onHover: @escaping () -> Void,
362 | onExit: @escaping () -> Void,
363 | action: @escaping () -> Void
364 | ) {
365 | self.icon = icon
366 | self.isEnabled = isEnabled
367 | self.isHovered = isHovered
368 | self.onHover = onHover
369 | self.onExit = onExit
370 | self.action = action
371 | }
372 |
373 | var body: some View {
374 | Button(action: action) {
375 | Image(systemName: icon)
376 | .frame(width: 20, height: 20)
377 | .background(
378 | RoundedRectangle(cornerRadius: 6)
379 | .fill(.background.opacity(isHovered ? 0.9 : 0.6))
380 | )
381 | .scaleEffect(isHovered ? 1.1 : 1.0)
382 | .foregroundStyle(isEnabled ? .primary : Color.secondary.opacity(0.5))
383 | }
384 | .buttonStyle(.plain)
385 | .disabled(!isEnabled)
386 | .onHover { hovering in
387 | if hovering {
388 | onHover()
389 | } else {
390 | onExit()
391 | }
392 | withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
393 | // Animation handled by isHovered binding
394 | }
395 | }
396 | }
397 | }
398 |
--------------------------------------------------------------------------------
/HiPixel/Services/ResourceDownloadManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ResourceDownloadManager.swift
3 | // HiPixel
4 | //
5 | // Created by 十里 on 2025/7/28.
6 | //
7 |
8 | import Foundation
9 | import CryptoKit
10 | import SwiftUI
11 |
12 | @MainActor
13 | class ResourceDownloadManager: NSObject, ObservableObject {
14 | static let shared = ResourceDownloadManager()
15 |
16 | @Published var downloadState: DownloadState = .idle
17 | @Published var downloadProgress: Double = 0
18 | @Published var downloadSpeed: String = ""
19 | @Published var currentVersion: String = ""
20 | @Published var remoteVersion: String = ""
21 | @Published var errorMessage: String = ""
22 |
23 | private let fileManager = FileManager.default
24 | private var downloadTask: URLSessionDownloadTask?
25 |
26 | enum DownloadState: Equatable, CustomStringConvertible {
27 | case idle
28 | case checking
29 | case downloading(String) // resource name
30 | case installing
31 | case completed
32 | case error(String)
33 |
34 | var description: String {
35 | switch self {
36 | case .idle:
37 | return "idle"
38 | case .checking:
39 | return "checking"
40 | case .downloading(let resource):
41 | return "downloading(\(resource))"
42 | case .installing:
43 | return "installing"
44 | case .completed:
45 | return "completed"
46 | case .error(let message):
47 | return "error(\(message))"
48 | }
49 | }
50 | }
51 |
52 | private override init() {
53 | super.init()
54 | loadCurrentVersion()
55 | checkResourcesExist()
56 | }
57 |
58 | // MARK: - Public Methods
59 |
60 | func checkForUpdates() async {
61 | // Prevent multiple concurrent downloads
62 | switch downloadState {
63 | case .checking, .downloading(_), .installing:
64 | Common.logger.info("Download already in progress, skipping checkForUpdates")
65 | return
66 | default:
67 | break
68 | }
69 |
70 | downloadState = .checking
71 |
72 | do {
73 | let toolcastInfo = try await ToolcastInfo.fetch()
74 | remoteVersion = toolcastInfo.version
75 |
76 | if needsUpdate(remote: toolcastInfo.version) {
77 | await downloadResources(toolcastInfo: toolcastInfo)
78 | } else {
79 | downloadState = .completed
80 | }
81 | } catch {
82 | downloadState = .error(error.localizedDescription)
83 | errorMessage = error.localizedDescription
84 | Common.logger.error("Failed to check for updates: \(error)")
85 | }
86 | }
87 |
88 | func downloadResourcesIfNeeded() async {
89 | // Prevent multiple concurrent downloads
90 | switch downloadState {
91 | case .checking, .downloading(_), .installing:
92 | Common.logger.info("Download already in progress, skipping downloadResourcesIfNeeded")
93 | return
94 | default:
95 | break
96 | }
97 |
98 | // First check if resources exist
99 | checkResourcesExist()
100 |
101 | let binExists = fileManager.fileExists(atPath: binPath.path) &&
102 | fileManager.fileExists(atPath: binPath.appendingPathComponent("upscayl-bin").path)
103 | let modelsExists = fileManager.fileExists(atPath: modelsPath.path) &&
104 | hasModelFiles() // Check if models directory has content
105 |
106 | if !binExists || !modelsExists {
107 | Common.logger.info("Resources missing - bin exists: \(binExists), models exists: \(modelsExists)")
108 | await checkForUpdates()
109 | } else {
110 | // Just check for updates without forcing download
111 | downloadState = .checking
112 | do {
113 | let toolcastInfo = try await ToolcastInfo.fetch()
114 | remoteVersion = toolcastInfo.version
115 |
116 | if needsUpdate(remote: toolcastInfo.version) {
117 | Common.logger.info("Update available: \(self.currentVersion) -> \(self.remoteVersion)")
118 | await downloadResources(toolcastInfo: toolcastInfo)
119 | } else {
120 | downloadState = .completed
121 | }
122 | } catch {
123 | // If we can't check for updates but have local resources, continue
124 | downloadState = .completed
125 | Common.logger.error("Failed to check for updates, using local resources: \(error)")
126 | }
127 | }
128 | }
129 |
130 | // MARK: - Private Methods
131 |
132 | private func downloadResources(toolcastInfo: ToolcastInfo) async {
133 | do {
134 | // Download bin.zip
135 | downloadState = .downloading("bin")
136 | let binZipPath = try await downloadFile(url: toolcastInfo.bin.url, expectedSHA256: toolcastInfo.bin.sha256)
137 |
138 | // Download models.zip
139 | downloadState = .downloading("models")
140 | let modelsZipPath = try await downloadFile(url: toolcastInfo.models.url, expectedSHA256: toolcastInfo.models.sha256)
141 |
142 | // Install resources
143 | downloadState = .installing
144 | try await installResources(binZip: binZipPath, modelsZip: modelsZipPath, version: toolcastInfo.version)
145 |
146 | // Clean up
147 | try fileManager.removeItem(at: binZipPath)
148 | try fileManager.removeItem(at: modelsZipPath)
149 |
150 | downloadState = .completed
151 | currentVersion = toolcastInfo.version
152 |
153 | } catch {
154 | downloadState = .error(error.localizedDescription)
155 | errorMessage = error.localizedDescription
156 | Common.logger.error("Failed to download resources: \(error)")
157 | }
158 | }
159 |
160 | private func downloadFile(url: String, expectedSHA256: String) async throws -> URL {
161 | guard let downloadURL = URL(string: url) else {
162 | throw ResourceError.invalidURL
163 | }
164 |
165 | let tempURL = Common.directory.appendingPathComponent(UUID().uuidString + ".zip")
166 |
167 | return try await withCheckedThrowingContinuation { continuation in
168 | downloadTask = URLSession.shared.downloadTask(with: downloadURL) { [weak self] localURL, response, error in
169 | Task { @MainActor in
170 | if let error = error {
171 | continuation.resume(throwing: error)
172 | return
173 | }
174 |
175 | guard let localURL = localURL else {
176 | continuation.resume(throwing: ResourceError.downloadFailed)
177 | return
178 | }
179 |
180 | do {
181 | // Move to temp location
182 | try self?.fileManager.moveItem(at: localURL, to: tempURL)
183 |
184 | // Verify SHA256
185 | let actualSHA256 = try self?.calculateSHA256(url: tempURL) ?? ""
186 | guard actualSHA256 == expectedSHA256 else {
187 | try? self?.fileManager.removeItem(at: tempURL)
188 | continuation.resume(throwing: ResourceError.checksumMismatch)
189 | return
190 | }
191 |
192 | continuation.resume(returning: tempURL)
193 | } catch {
194 | continuation.resume(throwing: error)
195 | }
196 | }
197 | }
198 |
199 | // Add progress tracking
200 | downloadTask?.progress.addObserver(self as NSObject, forKeyPath: "fractionCompleted", options: .new, context: nil)
201 | downloadTask?.resume()
202 | }
203 | }
204 |
205 | private func installResources(binZip: URL, modelsZip: URL, version: String) async throws {
206 | // Backup existing resources
207 | let backupDir = Common.directory.appendingPathComponent("backup")
208 | try? fileManager.createDirectory(at: backupDir, withIntermediateDirectories: true, attributes: nil)
209 |
210 | // Backup bin
211 | if fileManager.fileExists(atPath: binPath.path) {
212 | let binBackup = backupDir.appendingPathComponent("bin")
213 | try? fileManager.removeItem(at: binBackup)
214 | try? fileManager.moveItem(at: binPath, to: binBackup)
215 | }
216 |
217 | // Backup models
218 | if fileManager.fileExists(atPath: modelsPath.path) {
219 | let modelsBackup = backupDir.appendingPathComponent("models")
220 | try? fileManager.removeItem(at: modelsBackup)
221 | try? fileManager.moveItem(at: modelsPath, to: modelsBackup)
222 | }
223 |
224 | do {
225 | // Unzip bin
226 | try await unzip(file: binZip, to: Common.directory)
227 |
228 | // Unzip models
229 | try await unzip(file: modelsZip, to: Common.directory)
230 |
231 | // Make bin executable
232 | try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binPath.appendingPathComponent("upscayl-bin").path)
233 |
234 | // Save version
235 | try version.write(to: versionFilePath, atomically: true, encoding: .utf8)
236 |
237 | // Remove backup
238 | try? fileManager.removeItem(at: backupDir)
239 |
240 | } catch {
241 | // Restore backup on failure
242 | try? fileManager.removeItem(at: binPath)
243 | try? fileManager.removeItem(at: modelsPath)
244 |
245 | let binBackup = backupDir.appendingPathComponent("bin")
246 | let modelsBackup = backupDir.appendingPathComponent("models")
247 |
248 | try? fileManager.moveItem(at: binBackup, to: binPath)
249 | try? fileManager.moveItem(at: modelsBackup, to: modelsPath)
250 |
251 | throw error
252 | }
253 | }
254 |
255 | private func unzip(file: URL, to destination: URL) async throws {
256 | return try await withCheckedThrowingContinuation { continuation in
257 | let process = Process()
258 | process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
259 | process.arguments = ["-o", file.path, "-d", destination.path]
260 |
261 | do {
262 | try process.run()
263 | process.terminationHandler = { process in
264 | if process.terminationStatus == 0 {
265 | continuation.resume()
266 | } else {
267 | continuation.resume(throwing: ResourceError.unzipFailed)
268 | }
269 | }
270 | } catch {
271 | continuation.resume(throwing: error)
272 | }
273 | }
274 | }
275 |
276 | private func calculateSHA256(url: URL) throws -> String {
277 | let data = try Data(contentsOf: url)
278 | let hash = SHA256.hash(data: data)
279 | return hash.compactMap { String(format: "%02x", $0) }.joined()
280 | }
281 |
282 | private func needsUpdate(remote: String) -> Bool {
283 | return currentVersion != remote
284 | }
285 |
286 | private func loadCurrentVersion() {
287 | if let version = try? String(contentsOf: versionFilePath, encoding: .utf8) {
288 | currentVersion = version.trimmingCharacters(in: .whitespacesAndNewlines)
289 | } else {
290 | // No version file found, set as empty for now
291 | currentVersion = ""
292 | }
293 | }
294 |
295 | private func checkResourcesExist() {
296 | let binExists = fileManager.fileExists(atPath: binPath.path) &&
297 | fileManager.fileExists(atPath: binPath.appendingPathComponent("upscayl-bin").path)
298 | let modelsExists = fileManager.fileExists(atPath: modelsPath.path) && hasModelFiles()
299 |
300 | Common.logger.info("Resources check: bin=\(binExists), models=\(modelsExists), currentVersion='\(self.currentVersion)'")
301 |
302 | if !binExists || !modelsExists {
303 | downloadState = .idle
304 | currentVersion = "Not installed"
305 | Common.logger.info("Resources missing - setting currentVersion to 'Not installed'")
306 | } else if currentVersion.isEmpty || currentVersion == "Not installed" {
307 | // Resources exist but no version info, we still need to check for updates
308 | downloadState = .idle
309 | currentVersion = "Unknown"
310 | Common.logger.info("Resources exist but version unknown")
311 | } else {
312 | downloadState = .completed
313 | Common.logger.info("Resources exist with version: \(self.currentVersion)")
314 | }
315 | }
316 |
317 | private func hasModelFiles() -> Bool {
318 | do {
319 | let contents = try fileManager.contentsOfDirectory(atPath: modelsPath.path)
320 | return !contents.isEmpty && contents.contains { $0.hasSuffix(".bin") || $0.hasSuffix(".param") }
321 | } catch {
322 | return false
323 | }
324 | }
325 |
326 | // MARK: - Progress Observation
327 |
328 | override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
329 | if keyPath == "fractionCompleted" {
330 | Task { @MainActor in
331 | if let progress = downloadTask?.progress {
332 | downloadProgress = progress.fractionCompleted
333 |
334 | // Calculate download speed
335 | let bytesReceived = progress.completedUnitCount
336 | let timeElapsed = progress.estimatedTimeRemaining ?? 0
337 |
338 | if timeElapsed > 0 {
339 | let speed = Double(bytesReceived) / timeElapsed
340 | downloadSpeed = ByteCountFormatter().string(fromByteCount: Int64(speed)) + "/s"
341 | }
342 | }
343 | }
344 | }
345 | }
346 |
347 | // MARK: - Helper Properties
348 |
349 | private var binPath: URL {
350 | Common.directory.appendingPathComponent("bin")
351 | }
352 |
353 | private var modelsPath: URL {
354 | Common.directory.appendingPathComponent("models")
355 | }
356 |
357 | private var versionFilePath: URL {
358 | Common.directory.appendingPathComponent("version.txt")
359 | }
360 | }
361 |
362 | // MARK: - ResourceError
363 | enum ResourceError: LocalizedError {
364 | case invalidURL
365 | case downloadFailed
366 | case checksumMismatch
367 | case unzipFailed
368 |
369 | var errorDescription: String? {
370 | switch self {
371 | case .invalidURL:
372 | return "Invalid download URL"
373 | case .downloadFailed:
374 | return "Download failed"
375 | case .checksumMismatch:
376 | return "File checksum verification failed"
377 | case .unzipFailed:
378 | return "Failed to extract files"
379 | }
380 | }
381 | }
382 |
--------------------------------------------------------------------------------