├── screenshot.jpeg ├── HiPixel ├── Assets.xcassets │ ├── Contents.json │ ├── upscayl.imageset │ │ ├── upscayl.png │ │ └── Contents.json │ ├── magicbar.imageset │ │ ├── magicbar.png │ │ ├── magicbar@2x.png │ │ ├── magicbar@3x.png │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── icon_16x16.png │ │ ├── icon_32x32.png │ │ ├── icon_128x128.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_256x256.png │ │ ├── icon_32x32@2x.png │ │ ├── icon_512x512.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_512x512@2x.png │ │ └── Contents.json │ ├── alipay-qr.imageset │ │ ├── alipay-qr.jpeg │ │ └── Contents.json │ ├── MenuBarIcon.imageset │ │ ├── MenuBarIcon.png │ │ ├── @2xMenuBarIcon.png │ │ ├── @3xMenuBarIcon.png │ │ └── Contents.json │ ├── wechatpay-qr.imageset │ │ ├── webpay-qr.jpeg │ │ └── Contents.json │ ├── hipixelboard1.imageset │ │ ├── hipixelboard1.png │ │ ├── hipixelboard1@2x.png │ │ ├── hipixelboard1@3x.png │ │ └── Contents.json │ ├── hipixelboard2.imageset │ │ ├── hipixelboard2.png │ │ ├── hipixelboard2@2x.png │ │ ├── hipixelboard2@3x.png │ │ └── Contents.json │ ├── SecondaryAppIcon.appiconset │ │ ├── icon_128x128.png │ │ ├── icon_16x16.png │ │ ├── icon_256x256.png │ │ ├── icon_32x32.png │ │ ├── icon_512x512.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_32x32@2x.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_512x512@2x.png │ │ └── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── twitter.imageset │ │ ├── twitter.svg │ │ └── Contents.json │ ├── bmc.imageset │ │ ├── Contents.json │ │ └── BMC.fill.svg │ ├── github.imageset │ │ ├── Contents.json │ │ └── github.svg │ ├── alipay.imageset │ │ ├── Contents.json │ │ └── alipay.fill.svg │ └── wechatpay.imageset │ │ ├── Contents.json │ │ └── wechatpay.fill.svg ├── Resources │ └── .gitkeep ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Common │ ├── Common.swift │ ├── Extensions │ │ ├── String+Extensions.swift │ │ └── URL+Extensions.swift │ ├── Logging │ │ └── Logger.swift │ ├── Constants │ │ ├── AppInfo.swift │ │ └── Directories.swift │ ├── FormatString.swift │ ├── UI │ │ └── Extensions │ │ │ ├── Color+Hex.swift │ │ │ ├── NSImage+Extensions.swift │ │ │ └── Button+Style.swift │ └── OpenPanel.swift ├── Models │ ├── MonitorItem.swift │ ├── ToolcastInfo.swift │ └── UnifiedModel.swift ├── Utilities │ ├── AppInstallationChecker.swift │ ├── ResourceManager.swift │ ├── AppIconManager.swift │ ├── QueueManager.swift │ ├── SoundManager.swift │ └── EXIFMetadataManager.swift ├── HiPixel.entitlements ├── Views │ ├── VibrantBackground.swift │ ├── RotatingProcessingView.swift │ ├── AppInfoView.swift │ ├── RoundedBGViewModifier.swift │ ├── ThumbnailView.swift │ ├── MonitorItemView.swift │ ├── SettingItem.swift │ ├── AboutView.swift │ ├── ResourceDownloadSheet.swift │ └── ImageComparationViewer.swift ├── Extensions │ ├── Button+Configuration.swift │ └── Color+LinearGradient.swift ├── Services │ ├── CheckForUpdates.swift │ ├── DockIconService.swift │ ├── URLSchemeHandler.swift │ ├── ZipicCompressor.swift │ ├── CustomModelManager.swift │ ├── NotificationX.swift │ ├── MonitorService.swift │ ├── UpscaleImagesIntent.swift │ └── ResourceDownloadManager.swift ├── ViewModels │ ├── WindowControllers │ │ └── AboutWindowController.swift │ └── UpscaylData.swift ├── Info.plist └── HiPixelApp.swift ├── HiPixel.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcuserdata │ │ └── 5km.xcuserdatad │ │ └── WorkspaceSettings.xcsettings ├── xcuserdata │ └── 5km.xcuserdatad │ │ ├── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist │ │ └── xcschemes │ │ └── xcschememanagement.plist └── xcshareddata │ └── xcschemes │ ├── HiPixel.xcscheme │ └── HiPixel-CN.xcscheme ├── .markdownlint.json ├── .gitignore ├── README.zh-CN.md └── README.md /screenshot.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/screenshot.jpeg -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /HiPixel/Resources/.gitkeep: -------------------------------------------------------------------------------- 1 | # This file keeps the Resources directory in git 2 | # Large files (bin and models) are now downloaded at runtime -------------------------------------------------------------------------------- /HiPixel/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/upscayl.imageset/upscayl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/upscayl.imageset/upscayl.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/magicbar.imageset/magicbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/magicbar.imageset/magicbar.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/alipay-qr.imageset/alipay-qr.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/alipay-qr.imageset/alipay-qr.jpeg -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/magicbar.imageset/magicbar@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/magicbar.imageset/magicbar@2x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/magicbar.imageset/magicbar@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/magicbar.imageset/magicbar@3x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/MenuBarIcon.imageset/MenuBarIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/MenuBarIcon.imageset/MenuBarIcon.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/wechatpay-qr.imageset/webpay-qr.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/wechatpay-qr.imageset/webpay-qr.jpeg -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/MenuBarIcon.imageset/@2xMenuBarIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/MenuBarIcon.imageset/@2xMenuBarIcon.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/MenuBarIcon.imageset/@3xMenuBarIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/MenuBarIcon.imageset/@3xMenuBarIcon.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/hipixelboard1.imageset/hipixelboard1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/hipixelboard1.imageset/hipixelboard1.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/hipixelboard2.imageset/hipixelboard2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/hipixelboard2.imageset/hipixelboard2.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/hipixelboard1.imageset/hipixelboard1@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/hipixelboard1.imageset/hipixelboard1@2x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/hipixelboard1.imageset/hipixelboard1@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/hipixelboard1.imageset/hipixelboard1@3x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/hipixelboard2.imageset/hipixelboard2@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/hipixelboard2.imageset/hipixelboard2@2x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/hipixelboard2.imageset/hipixelboard2@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/hipixelboard2.imageset/hipixelboard2@3x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okooo5km/HiPixel/HEAD/HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /HiPixel.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /HiPixel.xcodeproj/xcuserdata/5km.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD033": false, 3 | "MD001": false, 4 | "MD029": false, 5 | "MD040": false, 6 | "MD013": { 7 | "line_length": 250, 8 | "code_blocks": false, 9 | "tables": false, 10 | "headings": false 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/upscayl.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "upscayl.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /HiPixel.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /HiPixel/Common/Common.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Common.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/03/11. 6 | // 7 | 8 | import Foundation 9 | 10 | // Empty enum as namespace 11 | // All actual functionality is implemented through extensions 12 | enum Common { 13 | 14 | } 15 | -------------------------------------------------------------------------------- /HiPixel/Common/Extensions/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Extensions.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/3/11. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | var localized: String { 12 | NSLocalizedString(self, comment: "") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /HiPixel/Models/MonitorItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MonitorItem.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/03/11. 6 | // 7 | 8 | import Foundation 9 | 10 | struct MonitorItem: Codable, Identifiable { 11 | var id: String = UUID().uuidString 12 | var url: URL 13 | var enabled: Bool = true 14 | } 15 | -------------------------------------------------------------------------------- /HiPixel.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /HiPixel/Common/Logging/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/03/11. 6 | // 7 | 8 | import Foundation 9 | import os 10 | 11 | extension Common { 12 | static let logger = Logger( 13 | subsystem: Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "tech.5km.HiPixel", 14 | category: "main" 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/alipay-qr.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "alipay-qr.jpeg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/wechatpay-qr.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "webpay-qr.jpeg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/twitter.imageset/twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /HiPixel/Common/Constants/AppInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppInfo.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/03/11. 6 | // 7 | 8 | import Foundation 9 | 10 | enum AppInfo { 11 | static let appName = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "HiPixel" 12 | static let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" 13 | static let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" 14 | } 15 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/bmc.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "BMC.fill.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/github.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "github.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/alipay.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "alipay.fill.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/magicbar.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "magicbar.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "magicbar@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "magicbar@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/twitter.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "twitter.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/wechatpay.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "wechatpay.fill.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "template-rendering-intent" : "template" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/hipixelboard1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "hipixelboard1.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "hipixelboard1@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "hipixelboard1@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/hipixelboard2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "hipixelboard2.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "hipixelboard2@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "hipixelboard2@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/github.imageset/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /HiPixel.xcodeproj/project.xcworkspace/xcuserdata/5km.xcuserdatad/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildLocationStyle 6 | UseAppPreferences 7 | CustomBuildLocationType 8 | RelativeToDerivedData 9 | DerivedDataLocationStyle 10 | Default 11 | ShowSharedSchemesAutomaticallyEnabled 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /HiPixel/Utilities/AppInstallationChecker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppInstallationChecker.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/11/10. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | 11 | enum AppInstallationChecker { 12 | static func isAppInstalled(bundleIdentifier: String) -> Bool { 13 | NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) != nil 14 | } 15 | 16 | static func openAppStore(url: String) { 17 | guard let url = URL(string: url) else { return } 18 | NSWorkspace.shared.open(url) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/MenuBarIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "MenuBarIcon.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "@2xMenuBarIcon.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "@3xMenuBarIcon.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /HiPixel/Common/FormatString.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormatString.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/03/11. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Common { 11 | static func timeString(from seconds: Int, simplified: Bool = false) -> String { 12 | let minutes = seconds % 3600 / 60 13 | let hours = seconds / 3600 14 | let remainingSeconds = seconds % 60 15 | if hours == 0 && simplified { 16 | return String(format: "%02d:%02d", minutes, remainingSeconds) 17 | } 18 | return String(format: "%02d:%02d:%02d", hours, minutes, remainingSeconds) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /HiPixel/HiPixel.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.inherit 8 | 9 | com.apple.security.temporary-exception.mach-lookup.global-name 10 | 11 | $(PRODUCT_BUNDLE_IDENTIFIER)-spks 12 | $(PRODUCT_BUNDLE_IDENTIFIER)-spki 13 | 14 | com.apple.developer.siri 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /HiPixel/Views/VibrantBackground.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VibrantBackground.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2024/6/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct VibrantBackground: NSViewRepresentable { 11 | var material: NSVisualEffectView.Material = .underWindowBackground 12 | 13 | func makeNSView(context: Context) -> NSVisualEffectView { 14 | let view = NSVisualEffectView() 15 | view.material = .underWindowBackground 16 | view.blendingMode = .behindWindow 17 | view.state = .active 18 | 19 | return view 20 | } 21 | 22 | func updateNSView(_ nsView: NSVisualEffectView, context: Context) { 23 | // Nothing to update 24 | nsView.blendingMode = .behindWindow 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /HiPixel/Extensions/Button+Configuration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Botton+Configuration.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/3/11. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension GradientButtonConfiguration { 11 | static let buyMeACoffee = GradientButtonConfiguration( 12 | startColor: Color(hex: "#FFDD00")!, 13 | endColor: Color(hex: "#FFD000")!, 14 | foregroundColor: .black.opacity(0.8) 15 | ) 16 | 17 | static let alipay = GradientButtonConfiguration( 18 | startColor: Color(hex: "#1677FF")!, 19 | endColor: Color(hex: "#0E5FD8")! 20 | ) 21 | 22 | static let wechatPay = GradientButtonConfiguration( 23 | startColor: Color(hex: "#07C160")!, 24 | endColor: Color(hex: "#059B4C")! 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /HiPixel/Models/ToolcastInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolcastInfo.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/7/28. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ToolcastInfo: Codable { 11 | let version: String 12 | let bin: ResourceInfo 13 | let models: ResourceInfo 14 | } 15 | 16 | struct ResourceInfo: Codable { 17 | let url: String 18 | let sha256: String 19 | } 20 | 21 | // MARK: - ToolcastInfo Extensions 22 | extension ToolcastInfo { 23 | static func fetch() async throws -> ToolcastInfo { 24 | let url = URL(string: "https://releases.5km.tech/hipixel/toolscast.json")! 25 | let (data, _) = try await URLSession.shared.data(from: url) 26 | return try JSONDecoder().decode(ToolcastInfo.self, from: data) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /HiPixel.xcodeproj/xcuserdata/5km.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | HiPixel-CN.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 1 11 | 12 | HiPixel.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 0 16 | 17 | 18 | SuppressBuildableAutocreation 19 | 20 | 6BE1BEE62C1F14A5004C9187 21 | 22 | primary 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /HiPixel/Common/Constants/Directories.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Directories.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/03/11. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Common { 11 | static let directory: URL = { 12 | let fileManager = FileManager.default 13 | let appSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask) 14 | .first! 15 | let dir = appSupportURL.appendingPathComponent("hipixel") 16 | DispatchQueue.main.async { 17 | do { 18 | try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) 19 | } catch { 20 | Common.logger.error("Create \(dir) failed: \(error)") 21 | } 22 | } 23 | return dir 24 | }() 25 | } 26 | -------------------------------------------------------------------------------- /HiPixel/Extensions/Color+LinearGradient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+LinearGradient.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/3/11. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension LinearGradient { 11 | 12 | static let lblue = LinearGradient( 13 | colors: [Color(hex: "#307FE1")!, Color(hex: "#03367E")!], startPoint: .top, endPoint: .bottom) 14 | 15 | static let dblue = LinearGradient( 16 | colors: [Color(hex: "#A9DDFF")!, Color(hex: "#348ACC")!], startPoint: .top, endPoint: .bottom) 17 | 18 | static let lgreen = LinearGradient( 19 | colors: [Color(hex: "#9FE130")!, Color(hex: "#3A7E03")!], startPoint: .top, endPoint: .bottom) 20 | 21 | static let dgreen = LinearGradient( 22 | colors: [Color(hex: "#B1DF88")!, Color(hex: "#8FCC34")!], startPoint: .top, endPoint: .bottom) 23 | 24 | } 25 | -------------------------------------------------------------------------------- /HiPixel/Services/CheckForUpdates.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CheckForUpdates.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/2/4. 6 | // 7 | 8 | import Sparkle 9 | import SwiftUI 10 | 11 | final class CheckForUpdatesViewModel: ObservableObject { 12 | @Published var canCheckForUpdates = false 13 | 14 | init(updater: SPUUpdater) { 15 | updater.publisher(for: \.canCheckForUpdates) 16 | .assign(to: &$canCheckForUpdates) 17 | } 18 | } 19 | 20 | struct CheckForUpdatesView: View { 21 | @ObservedObject private var checkForUpdatesViewModel: CheckForUpdatesViewModel 22 | private let updater: SPUUpdater 23 | 24 | init(updater: SPUUpdater) { 25 | self.updater = updater 26 | self.checkForUpdatesViewModel = CheckForUpdatesViewModel(updater: updater) 27 | } 28 | 29 | var body: some View { 30 | Button(action: updater.checkForUpdates) { 31 | Label("Check for Updates…", systemImage: "arrow.trianglehead.2.counterclockwise") 32 | } 33 | .disabled(!checkForUpdatesViewModel.canCheckForUpdates) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /HiPixel/Views/RotatingProcessingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RotatingProcessingView.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2024/11/4. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RotatingProcessingView: View { 11 | @State private var rotationAngle: Double = 0 12 | 13 | var body: some View { 14 | Image(systemName: "rectangle.pattern.checkered") 15 | .rotationEffect(.degrees(rotationAngle)) 16 | .animation(.linear(duration: 1).delay(1).repeatForever(autoreverses: false), value: rotationAngle) 17 | .onAppear { 18 | rotationAngle += 180 19 | } 20 | } 21 | } 22 | 23 | struct BreathingProcessingView: View { 24 | @State private var isBreathing = false 25 | 26 | var body: some View { 27 | Image(systemName: "livephoto") 28 | .resizable() 29 | .frame(width: 32, height: 32) 30 | .scaleEffect(isBreathing ? 0.9 : 1.2) 31 | .animation(.easeInOut(duration: 1).repeatForever(autoreverses: true), value: isBreathing) 32 | .onAppear { 33 | isBreathing.toggle() 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /HiPixel/Views/AppInfoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppInfoView.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2024/10/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AppInfoView: View { 11 | var body: some View { 12 | HStack { 13 | Image(nsImage: NSApplication.shared.applicationIconImage) 14 | .resizable() 15 | .frame(width: 96, height: 96) 16 | 17 | VStack(alignment: .leading, spacing: 8) { 18 | HStack { 19 | Text(AppInfo.appName) 20 | .font(.largeTitle) 21 | .fontWeight(.bold) 22 | Text("Version \(AppInfo.version) (\(AppInfo.build))") 23 | .font(.subheadline) 24 | .foregroundColor(.secondary) 25 | } 26 | 27 | Text("AI lmage Upscaler for macOS\nBased on **[Upscayl](https://upscayl.org/)**") 28 | .lineSpacing(4) 29 | .opacity(0.8) 30 | .frame(height: 36) 31 | } 32 | } 33 | } 34 | } 35 | 36 | #Preview { 37 | AppInfoView() 38 | } 39 | -------------------------------------------------------------------------------- /HiPixel/Utilities/ResourceManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResourceManager.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/2/3. 6 | // 7 | 8 | import Foundation 9 | 10 | class ResourceManager { 11 | 12 | static func binaryPath() -> URL { 13 | Common.directory.appendingPathComponent("bin").appendingPathComponent("upscayl-bin") 14 | } 15 | 16 | static var modelsURL: URL { 17 | Common.directory.appendingPathComponent("models") 18 | } 19 | 20 | static func prepareModels() async throws { 21 | await ResourceDownloadManager.shared.downloadResourcesIfNeeded() 22 | 23 | // Verify resources are available 24 | let fileManager = FileManager.default 25 | guard fileManager.fileExists(atPath: binaryPath().path) else { 26 | throw NSError(domain: "ResourceManagerError", code: 1, userInfo: [NSLocalizedDescriptionKey: "upscayl-bin not found"]) 27 | } 28 | 29 | guard fileManager.fileExists(atPath: modelsURL.path) else { 30 | throw NSError(domain: "ResourceManagerError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Models directory not found"]) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /HiPixel/Services/DockIconService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DockIconService.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/11/10. 6 | // 7 | 8 | import AppKit 9 | 10 | @MainActor 11 | class DockIconService { 12 | static let shared = DockIconService() 13 | 14 | private init() {} 15 | 16 | /// Hide or show the dock icon based on the provided value 17 | /// - Parameter hidden: true to hide dock icon, false to show it 18 | /// - Returns: true if user should be warned about menu bar access, false otherwise 19 | func setDockIconHidden(_ hidden: Bool) -> Bool { 20 | if hidden { 21 | NSApp.setActivationPolicy(.accessory) 22 | // Check if menu bar extra is enabled, if not, return true to warn user 23 | return !HiPixelConfiguration.shared.showMenuBarExtra 24 | } else { 25 | NSApp.setActivationPolicy(.regular) 26 | return false 27 | } 28 | } 29 | 30 | /// Get current dock icon visibility state 31 | /// - Returns: true if dock icon is hidden, false if visible 32 | var isDockIconHidden: Bool { 33 | return NSApp.activationPolicy() == .accessory 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /HiPixel/Utilities/AppIconManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppIconManager.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/11/10. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @MainActor 11 | class AppIconManager { 12 | static let shared = AppIconManager() 13 | 14 | @AppStorage(HiPixelConfiguration.Keys.SelectedAppIcon) var selectedAppIcon: HiPixelConfiguration.AppIcon? 15 | 16 | func applyAppIcon() { 17 | guard let selectedAppIcon else { return } 18 | setAppIcon(selectedAppIcon) 19 | } 20 | 21 | func getCurrentAppIcon() -> HiPixelConfiguration.AppIcon { 22 | if let bundleIcon = Bundle.main.object(forInfoDictionaryKey: "CFBundleIconFile") as? String { 23 | return HiPixelConfiguration.AppIcon(rawValue: bundleIcon) ?? .primary 24 | } 25 | return .primary 26 | } 27 | 28 | func setAppIcon(_ icon: HiPixelConfiguration.AppIcon) { 29 | UserDefaults.standard.set(icon.rawValue, forKey: "SelectedAppIcon") 30 | if icon == .primary { 31 | NSApplication.shared.applicationIconImage = nil 32 | } else { 33 | NSApplication.shared.applicationIconImage = icon.previewImage 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /HiPixel/Utilities/QueueManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueueManager.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2024/10/31. 6 | // 7 | 8 | import Foundation 9 | 10 | class QueueManager { 11 | 12 | var maxConcurrentOperationCount: Double = 4 13 | 14 | static let shared = QueueManager(num: 2) 15 | 16 | var queues = [OperationQueue]() 17 | var counts = [Int]() 18 | 19 | let num: Int 20 | 21 | var index = 1 22 | 23 | init(num: Int) { 24 | self.num = num 25 | for _ in 1...num { 26 | let queue = OperationQueue() 27 | queue.qualityOfService = .userInitiated 28 | queue.maxConcurrentOperationCount = Int(maxConcurrentOperationCount) 29 | queues.append(queue) 30 | counts.append(0) 31 | } 32 | } 33 | 34 | func allocate(count: Int) -> OperationQueue { 35 | if let minCount = counts.min(), let indexOfminValue = counts.firstIndex(of: minCount) { 36 | index = indexOfminValue 37 | } else { 38 | index += 1 39 | if index >= num { 40 | index = 0 41 | } 42 | } 43 | counts[index] += count 44 | return queues[index] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /HiPixel/ViewModels/WindowControllers/AboutWindowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutWindowController.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2024/10/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | class AboutWindowController: NSWindowController { 11 | 12 | static let shared = AboutWindowController() 13 | 14 | convenience init() { 15 | let aboutView = AboutView() 16 | let hostingController = NSHostingController(rootView: aboutView) 17 | let newWindow = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 360, height: 0), 18 | styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], 19 | backing: .buffered, 20 | defer: false) 21 | newWindow.contentView = hostingController.view 22 | newWindow.isMovableByWindowBackground = true 23 | newWindow.titlebarAppearsTransparent = true 24 | newWindow.titleVisibility = .hidden 25 | 26 | // Add this to make the window size fit the content 27 | newWindow.setContentSize(hostingController.view.fittingSize) 28 | 29 | newWindow.center() 30 | 31 | self.init(window: newWindow) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/wechatpay.imageset/wechatpay.fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/bmc.imageset/BMC.fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/alipay.imageset/alipay.fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /HiPixel/Utilities/SoundManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SoundManager.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/11/10. 6 | // 7 | 8 | import AppKit 9 | 10 | class SoundManager { 11 | static let shared = SoundManager() 12 | private var sound: NSSound? 13 | private var soundName: String? 14 | private var soundVolume: Float = 1.0 15 | 16 | private init() {} 17 | 18 | func loadSound(named name: String, volume: Float = 1.0) { 19 | if sound != nil && soundName == name && soundVolume == volume { 20 | return 21 | } 22 | 23 | if sound != nil { 24 | sound?.stop() 25 | sound = nil 26 | soundName = nil 27 | } 28 | 29 | guard let newSound = NSSound(named: name) else { 30 | print("Sound not loaded. Please load it first.") 31 | return 32 | } 33 | 34 | newSound.volume = volume 35 | sound = newSound 36 | soundName = name 37 | soundVolume = volume 38 | } 39 | 40 | func playSound() { 41 | guard let sound = sound else { 42 | print("Sound not loaded. Please load it first.") 43 | return 44 | } 45 | 46 | // Stop current playback and restart immediately 47 | sound.stop() 48 | sound.currentTime = 0 49 | sound.play() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/SecondaryAppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128x128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /HiPixel/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDocumentTypes 6 | 7 | 8 | CFBundleTypeName 9 | Image File 10 | CFBundleTypeRole 11 | Editor 12 | LSHandlerRank 13 | Default 14 | LSItemContentTypes 15 | 16 | public.png 17 | public.jpeg 18 | public.webp 19 | 20 | 21 | 22 | CFBundleTypeName 23 | Folder 24 | CFBundleTypeRole 25 | Editor 26 | LSHandlerRank 27 | Default 28 | LSItemContentTypes 29 | 30 | public.folder 31 | 32 | 33 | 34 | CFBundleURLTypes 35 | 36 | 37 | CFBundleTypeRole 38 | Editor 39 | CFBundleURLName 40 | tech.5km.HiPixel 41 | CFBundleURLSchemes 42 | 43 | hipixel 44 | 45 | 46 | 47 | SUEnableDownloaderService 48 | 49 | SUEnableInstallerLauncherService 50 | 51 | SUFeedURL 52 | https://releases.5km.tech/hipixel/appcast.xml 53 | SUPublicEDKey 54 | U0mxe9F6FlB/zDEiNcDPTkNRIFlDLE+7s+4yGx+WxkE= 55 | 56 | 57 | -------------------------------------------------------------------------------- /HiPixel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "8846323b42514a32e0f64cd0b422be1c224a2cbda4d866d4079903389fa95716", 3 | "pins" : [ 4 | { 5 | "identity" : "fswatcher", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/okooo5km/FSWatcher.git", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "ebfb29887b615b58e347bd9e98dfa6a13b033d26" 11 | } 12 | }, 13 | { 14 | "identity" : "generalnotification", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/okooo5km/GeneralNotification", 17 | "state" : { 18 | "branch" : "main", 19 | "revision" : "17c1f90b2d80a2e33da11de8dc44af3df5415843" 20 | } 21 | }, 22 | { 23 | "identity" : "notchnotification", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/Lakr233/NotchNotification.git", 26 | "state" : { 27 | "branch" : "main", 28 | "revision" : "05d81d6d82ce27da458af338fe0c6368a834143f" 29 | } 30 | }, 31 | { 32 | "identity" : "settingsaccess", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/orchetect/SettingsAccess", 35 | "state" : { 36 | "revision" : "08e80c35501f273afa2f5d6f737429bbe395ff81", 37 | "version" : "2.1.0" 38 | } 39 | }, 40 | { 41 | "identity" : "sparkle", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/sparkle-project/Sparkle", 44 | "state" : { 45 | "revision" : "0ef1ee0220239b3776f433314515fd849025673f", 46 | "version" : "2.6.4" 47 | } 48 | } 49 | ], 50 | "version" : 3 51 | } 52 | -------------------------------------------------------------------------------- /HiPixel/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "filename" : "icon_16x16.png", 10 | "idiom" : "mac", 11 | "scale" : "1x", 12 | "size" : "16x16" 13 | }, 14 | { 15 | "filename" : "icon_16x16@2x.png", 16 | "idiom" : "mac", 17 | "scale" : "2x", 18 | "size" : "16x16" 19 | }, 20 | { 21 | "filename" : "icon_32x32.png", 22 | "idiom" : "mac", 23 | "scale" : "1x", 24 | "size" : "32x32" 25 | }, 26 | { 27 | "filename" : "icon_32x32@2x.png", 28 | "idiom" : "mac", 29 | "scale" : "2x", 30 | "size" : "32x32" 31 | }, 32 | { 33 | "filename" : "icon_128x128.png", 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "128x128" 37 | }, 38 | { 39 | "filename" : "icon_128x128@2x.png", 40 | "idiom" : "mac", 41 | "scale" : "2x", 42 | "size" : "128x128" 43 | }, 44 | { 45 | "filename" : "icon_256x256.png", 46 | "idiom" : "mac", 47 | "scale" : "1x", 48 | "size" : "256x256" 49 | }, 50 | { 51 | "filename" : "icon_256x256@2x.png", 52 | "idiom" : "mac", 53 | "scale" : "2x", 54 | "size" : "256x256" 55 | }, 56 | { 57 | "filename" : "icon_512x512.png", 58 | "idiom" : "mac", 59 | "scale" : "1x", 60 | "size" : "512x512" 61 | }, 62 | { 63 | "filename" : "icon_512x512@2x.png", 64 | "idiom" : "mac", 65 | "scale" : "2x", 66 | "size" : "512x512" 67 | } 68 | ], 69 | "info" : { 70 | "author" : "xcode", 71 | "version" : 1 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /HiPixel/Views/RoundedBGViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoundedBGViewModifier.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2024/10/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RoundedBackgroundModifier: ViewModifier where S : ShapeStyle { 11 | 12 | var cornerRadius: CGFloat 13 | var strokeColor: Color 14 | var strokeWidth: CGFloat = 1 15 | var fill: S 16 | var shadow: Bool = false 17 | 18 | func body(content: Content) -> some View { 19 | content 20 | .background { 21 | if shadow { 22 | RoundedRectangle(cornerRadius: cornerRadius) 23 | .fill(fill) 24 | .shadow(radius: 1, y: 0) 25 | } else { 26 | RoundedRectangle(cornerRadius: cornerRadius) 27 | .fill(fill) 28 | } 29 | } 30 | .overlay( 31 | RoundedRectangle(cornerRadius: cornerRadius) 32 | .stroke(strokeColor, lineWidth: shadow ? 0 : strokeWidth) 33 | ) 34 | } 35 | } 36 | 37 | extension View { 38 | func background( 39 | cornerRadius: CGFloat = 8, 40 | strokeColor: Color = .primary.opacity(0.1), 41 | strokeWidth: CGFloat = 1, 42 | fill: S = BackgroundStyle.background.opacity(0.3), 43 | shadow: Bool = false 44 | ) -> some View where S : ShapeStyle { 45 | self.modifier( 46 | RoundedBackgroundModifier( 47 | cornerRadius: cornerRadius, 48 | strokeColor: strokeColor, 49 | strokeWidth: strokeWidth, 50 | fill: fill, 51 | shadow: shadow 52 | ) 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /HiPixel/Common/UI/Extensions/Color+Hex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+Hex.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/3/11. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Color { 11 | init?(hex: String) { 12 | var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) 13 | hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") 14 | 15 | var rgb: UInt64 = 0 16 | 17 | var r: CGFloat = 0.0 18 | var g: CGFloat = 0.0 19 | var b: CGFloat = 0.0 20 | var a: CGFloat = 1.0 21 | 22 | let length = hexSanitized.count 23 | 24 | guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil } 25 | 26 | if length == 6 { 27 | r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0 28 | g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0 29 | b = CGFloat(rgb & 0x0000FF) / 255.0 30 | 31 | } else if length == 8 { 32 | r = CGFloat((rgb & 0xFF00_0000) >> 24) / 255.0 33 | g = CGFloat((rgb & 0x00FF_0000) >> 16) / 255.0 34 | b = CGFloat((rgb & 0x0000_FF00) >> 8) / 255.0 35 | a = CGFloat(rgb & 0x0000_00FF) / 255.0 36 | 37 | } else if length == 3 { 38 | r = CGFloat((rgb & 0xF00) >> 8) / 15.0 39 | g = CGFloat((rgb & 0x0F0) >> 4) / 15.0 40 | b = CGFloat(rgb & 0x00F) / 15.0 41 | 42 | } else if length == 4 { 43 | r = CGFloat((rgb & 0xF000) >> 12) / 15.0 44 | g = CGFloat((rgb & 0x0F00) >> 8) / 15.0 45 | b = CGFloat((rgb & 0x00F0) >> 4) / 15.0 46 | a = CGFloat(rgb & 0x000F) / 15.0 47 | 48 | } else { 49 | return nil 50 | } 51 | 52 | self.init(.sRGB, red: r, green: g, blue: b, opacity: a) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /HiPixel/Services/URLSchemeHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSchemeHandler.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/11/10. 6 | // 7 | 8 | import AppKit 9 | import Foundation 10 | 11 | class URLSchemeHandler { 12 | static let shared = URLSchemeHandler() 13 | private init() {} 14 | 15 | func handle(_ url: URL, options: UpscaylOptions? = nil) { 16 | guard url.scheme?.lowercased() == "hipixel" else { return } 17 | 18 | guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return } 19 | 20 | // Parse path parameters 21 | let paths = 22 | components.queryItems? 23 | .filter { $0.name == "path" } 24 | .compactMap { $0.value } 25 | .map { $0.removingPercentEncoding ?? $0 } ?? [] 26 | 27 | guard !paths.isEmpty else { return } 28 | 29 | // Parse options from query parameters if provided 30 | var effectiveOptions = options ?? UpscaylOptions() 31 | if let queryItems = components.queryItems { 32 | let urlOptions = UpscaylOptions.fromURLQueryItems(queryItems) 33 | effectiveOptions = effectiveOptions.merge(with: urlOptions) 34 | } 35 | 36 | let urls = paths.compactMap { path -> URL? in 37 | let url = URL(fileURLWithPath: path) 38 | guard FileManager.default.fileExists(atPath: path) else { 39 | return nil 40 | } 41 | guard url.isImageFile || url.isFile(ofTypes: [.folder]) else { 42 | return nil 43 | } 44 | return url 45 | } 46 | 47 | guard !urls.isEmpty else { return } 48 | 49 | DispatchQueue.main.async { 50 | Upscayl.process(urls, by: UpscaylData.shared, options: effectiveOptions, source: .automated) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /HiPixel/Common/OpenPanel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OpenPanel.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/03/11. 6 | // 7 | 8 | import AppKit 9 | 10 | extension Common { 11 | static func openPanel( 12 | from url: URL? = nil, 13 | message: String = NSLocalizedString("Please select a directory", comment: "Please select a directory"), 14 | windowTitle: String = "HiPixel", 15 | _ completion: @escaping (_: URL) -> Void 16 | ) { 17 | let openPanel = NSOpenPanel() 18 | openPanel.message = message 19 | openPanel.canChooseFiles = false 20 | openPanel.canChooseDirectories = true 21 | openPanel.canCreateDirectories = true 22 | openPanel.allowsMultipleSelection = false 23 | if let url = url { 24 | openPanel.directoryURL = url 25 | } 26 | openPanel.prompt = NSLocalizedString("choose", comment: "Please select a directory") 27 | var window: NSWindow? 28 | for win in NSApplication.shared.windows { 29 | if win.title == windowTitle { 30 | window = win 31 | break 32 | } 33 | } 34 | if let win = window { 35 | openPanel.beginSheetModal(for: win) { (result) -> Void in 36 | if result == .OK, let url = openPanel.url { 37 | completion(url) 38 | } else { 39 | Common.logger.info("Open Panel to Get Save Directory failed!") 40 | } 41 | } 42 | } else { 43 | openPanel.begin { (result) -> Void in 44 | if result == .OK, let url = openPanel.url { 45 | completion(url) 46 | } else { 47 | Common.logger.info("Open Panel to Get Save Directory: canceled!") 48 | } 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /HiPixel/Views/ThumbnailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThumbnailView.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2024/11/1. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ThumbnailView: View { 11 | 12 | let item: UpscaylDataItem 13 | 14 | @EnvironmentObject var upscaylData: UpscaylData 15 | 16 | private var cornerRadius: CGFloat { 17 | if #available(macOS 26.0, *) { 18 | return 12 19 | } else { 20 | return 8 21 | } 22 | } 23 | 24 | var body: some View { 25 | AsyncImage(url: item.thumbnail) { phase in 26 | switch phase { 27 | case .empty: 28 | ProgressView() 29 | case .success(let imageView): 30 | imageView 31 | .resizable() 32 | .aspectRatio(contentMode: .fill) 33 | case .failure(let error): 34 | Image(systemName: "questionmark") 35 | .font(.headline) 36 | .help(error.localizedDescription) 37 | @unknown default: 38 | Image(systemName: "questionmark") 39 | .font(.headline) 40 | } 41 | } 42 | .frame(width: 64, height: 64) 43 | .aspectRatio(contentMode: .fill) 44 | .cornerRadius(cornerRadius) 45 | .background( 46 | cornerRadius: cornerRadius, 47 | strokeColor: .primary.opacity(item.id == upscaylData.selectedItem?.id ? 0.1: 0.02), 48 | fill: .background.opacity(item.id == upscaylData.selectedItem?.id ? 0.8 : 0.5) 49 | ) 50 | .overlay { 51 | if item.state == .processing { 52 | ProgressView(value: item.progress / 100) 53 | .progressViewStyle(.circular) 54 | .tint(.primary) 55 | .frame(width: 64, height: 64) 56 | .background(cornerRadius: cornerRadius, fill: .background.opacity(0.8)) 57 | } 58 | } 59 | .background(alignment: .bottom) { 60 | RoundedRectangle(cornerRadius: cornerRadius) 61 | .fill(Color.primary.opacity(item.id == upscaylData.selectedItem?.id ? 1 : 0)) 62 | .frame(width: 24, height: 3) 63 | .offset(y: 5) 64 | } 65 | .onTapGesture { 66 | withAnimation(.spring) { 67 | upscaylData.selectedItem = item 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /HiPixel/Common/UI/Extensions/NSImage+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Ext+NSImage.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2024/10/31. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension NSImage { 11 | 12 | var pixelSize: CGSize { 13 | if let bitmapRep = self.representations.first as? NSBitmapImageRep { 14 | CGSize(width: bitmapRep.pixelsWide, height: bitmapRep.pixelsHigh) 15 | } else { 16 | self.size 17 | } 18 | } 19 | 20 | func thumbnail(with width: CGFloat) -> NSImage { 21 | let widthOfRect = width 22 | let thumbnailSize = NSSize(width: widthOfRect, height: widthOfRect / self.size.width * self.size.height) 23 | let thumbnailRect = NSRect(x: 0, y: 0, width: widthOfRect, height: thumbnailSize.height) 24 | 25 | let thumbnailImage = NSImage(size: thumbnailSize) 26 | thumbnailImage.lockFocus() 27 | self.draw(in: thumbnailRect, from: NSRect.zero, operation: .sourceOver, fraction: 1.0) 28 | thumbnailImage.unlockFocus() 29 | return thumbnailImage 30 | } 31 | 32 | func saveAtThumbnailDirectory(as fileName: String) -> URL? { 33 | guard let imageData = self.tiffRepresentation else { 34 | return nil 35 | } 36 | let bitmap = NSBitmapImageRep(data: imageData) 37 | guard let pngData = bitmap?.representation(using: .png, properties: [:]) else { 38 | return nil 39 | } 40 | let thumbnailsDirectory = Common.directory.appendingPathComponent("thumbnails") 41 | guard let imageUrl = URL(string: fileName) else { 42 | return nil 43 | } 44 | 45 | if !FileManager.default.fileExists(atPath: thumbnailsDirectory.path) { 46 | do { 47 | try FileManager.default.createDirectory(at: thumbnailsDirectory, withIntermediateDirectories: true, attributes: nil) 48 | } catch { 49 | Common.logger.error("create the thumbnails directory failed:\(error)") 50 | return nil 51 | } 52 | } 53 | 54 | let thumbnailURL = thumbnailsDirectory.appendingPathComponent(imageUrl.lastPathComponent).changingPathExtension(to: "png") 55 | 56 | do{ 57 | try pngData.write(to: thumbnailURL, options: .atomic) 58 | } catch { 59 | Common.logger.error("save \(fileName) failed!") 60 | return nil 61 | } 62 | 63 | return thumbnailURL 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /HiPixel/Services/ZipicCompressor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ZipicCompressor.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/11/10. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | 11 | enum ZipicCompressor { 12 | static func compress(url: URL, saveDirectory: URL?, format: String? = nil, level: Double = 3.0) { 13 | var components = URLComponents(string: "zipic://compress") 14 | var queryItems = [URLQueryItem]() 15 | 16 | // Add source URL 17 | queryItems.append(URLQueryItem(name: "url", value: url.path)) 18 | 19 | // Add compression level 20 | queryItems.append(URLQueryItem(name: "level", value: "\(level)")) 21 | 22 | // Add format if specified 23 | if let format = format { 24 | queryItems.append(URLQueryItem(name: "format", value: format)) 25 | } 26 | 27 | // Add save directory if specified 28 | if let saveDirectory = saveDirectory { 29 | queryItems.append(URLQueryItem(name: "location", value: "custom")) 30 | queryItems.append(URLQueryItem(name: "directory", value: saveDirectory.path)) 31 | } 32 | 33 | components?.queryItems = queryItems 34 | 35 | guard let zipicURL = components?.url else { return } 36 | NSWorkspace.shared.open(zipicURL) 37 | } 38 | 39 | static func compressMultiple(urls: [URL], saveDirectory: URL?, format: String? = nil, level: Double = 3.0) { 40 | var components = URLComponents(string: "zipic://compress") 41 | var queryItems = [URLQueryItem]() 42 | 43 | // Add all source URLs 44 | for url in urls { 45 | queryItems.append(URLQueryItem(name: "url", value: url.path)) 46 | } 47 | 48 | // Add compression level 49 | queryItems.append(URLQueryItem(name: "level", value: "\(level)")) 50 | 51 | // Add format if specified 52 | if let format = format { 53 | queryItems.append(URLQueryItem(name: "format", value: format)) 54 | } 55 | 56 | // Add save directory if specified 57 | if let saveDirectory = saveDirectory { 58 | queryItems.append(URLQueryItem(name: "location", value: "custom")) 59 | queryItems.append(URLQueryItem(name: "directory", value: saveDirectory.path)) 60 | } 61 | 62 | components?.queryItems = queryItems 63 | 64 | guard let zipicURL = components?.url else { return } 65 | NSWorkspace.shared.open(zipicURL) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /HiPixel/ViewModels/UpscaylData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpscaylData.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2024/10/31. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct UpscaylDataItem: Identifiable { 11 | var id: UUID 12 | var startAt: Date 13 | var url: URL 14 | var thumbnail: URL? 15 | 16 | var newURL: URL { 17 | didSet { 18 | newFileSize = newURL.fileSize 19 | } 20 | } 21 | 22 | var fileName: String = "" 23 | var size: CGSize = CGSize() 24 | var newSize: CGSize = CGSize() 25 | var fileSize: Int = 0 26 | var newFileSize: Int = 0 27 | var timeInterval: TimeInterval = 0 28 | var state: ProcessState 29 | var progress: Double = 0 30 | var processingStage: Int = 1 31 | 32 | init(_ url: URL) { 33 | self.url = url 34 | newSize = size 35 | fileSize = url.fileSize 36 | startAt = Date.now 37 | state = .processing 38 | newURL = url 39 | fileName = url.lastPathComponent 40 | id = UUID() 41 | } 42 | } 43 | 44 | class UpscaylData: ObservableObject { 45 | 46 | static let shared = UpscaylData() 47 | 48 | @Published 49 | var items: [UpscaylDataItem] = [] 50 | 51 | @Published 52 | var selectedItem: UpscaylDataItem? = nil 53 | 54 | func append(_ item: UpscaylDataItem) { 55 | remove(item) 56 | items.append(item) 57 | items.sort(by: { $0.startAt > $1.startAt}) 58 | } 59 | 60 | func update(_ item: UpscaylDataItem) { 61 | if let index = items.firstIndex(where: { $0.url == item.url }) { 62 | items[index] = item 63 | } 64 | } 65 | 66 | func remove(_ item: UpscaylDataItem) { 67 | if items.contains(where: { $0.url == item.url }) { 68 | items.removeAll(where: { $0.url == item.url }) 69 | } 70 | } 71 | 72 | func removeAll() { 73 | items.removeAll() 74 | Self.removeThumbnails() 75 | } 76 | 77 | static func removeThumbnails() { 78 | let thumbnailDir = Common.directory.appendingPathComponent("thumbnails") 79 | do { 80 | try FileManager.default.removeItem(at: thumbnailDir) 81 | } catch { 82 | Common.logger.error("Remove thumbnails(\(thumbnailDir.path)) failed, \(error)") 83 | } 84 | } 85 | } 86 | 87 | enum ProcessState: String, CaseIterable { 88 | case processing = "Processing" 89 | case success = "Done" 90 | case failed = "Error" 91 | 92 | var localized: String { 93 | return NSLocalizedString(rawValue, comment: "") 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /HiPixel/Services/CustomModelManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomModelManager.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/01/03. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | class CustomModelManager: ObservableObject { 12 | static let shared = CustomModelManager() 13 | 14 | @Published var customModels: [CustomModel] = [] 15 | 16 | private init() { 17 | loadCustomModels() 18 | } 19 | 20 | struct CustomModel: Identifiable, Hashable { 21 | let id = UUID() 22 | let name: String 23 | let path: String 24 | let displayName: String 25 | 26 | init(name: String, path: String) { 27 | self.name = name 28 | self.path = path 29 | self.displayName = name.replacingOccurrences(of: "_", with: " ").capitalized 30 | } 31 | } 32 | 33 | func loadCustomModels() { 34 | guard let folderPath = HiPixelConfiguration.shared.customModelsFolder else { 35 | customModels = [] 36 | return 37 | } 38 | 39 | scanForCustomModels(in: folderPath) 40 | } 41 | 42 | func scanForCustomModels(in folderPath: String) { 43 | let url = URL(fileURLWithPath: folderPath) 44 | let fileManager = FileManager.default 45 | 46 | do { 47 | let files = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) 48 | let binFiles = files.filter { $0.pathExtension.lowercased() == "bin" } 49 | 50 | var foundModels: [CustomModel] = [] 51 | 52 | for binFile in binFiles { 53 | let modelName = binFile.deletingPathExtension().lastPathComponent 54 | let paramFile = url.appendingPathComponent("\(modelName).param") 55 | 56 | if fileManager.fileExists(atPath: paramFile.path) { 57 | let customModel = CustomModel(name: modelName, path: url.path) 58 | foundModels.append(customModel) 59 | } 60 | } 61 | 62 | customModels = foundModels.sorted { $0.name < $1.name } 63 | } catch { 64 | print("Error scanning custom models directory: \(error)") 65 | customModels = [] 66 | } 67 | } 68 | 69 | func getModelPath(for modelName: String) -> String? { 70 | return customModels.first { $0.name == modelName }?.path 71 | } 72 | 73 | func isCustomModel(_ modelName: String) -> Bool { 74 | return customModels.contains { $0.name == modelName } 75 | } 76 | 77 | func getAllModelNames() -> [String] { 78 | return customModels.map { $0.name } 79 | } 80 | } -------------------------------------------------------------------------------- /HiPixel/Views/MonitorItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MonitorItemView.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/03/11. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MonitiorItemView: View { 11 | 12 | @ObservedObject 13 | var monitorService = MonitorService.shared 14 | 15 | @State 16 | var item: MonitorItem 17 | 18 | var body: some View { 19 | HStack(spacing: 0) { 20 | HStack(spacing: 0) { 21 | Button( 22 | action: { 23 | Common.openPanel( 24 | message: String(localized: "Select a folder to auto-hipixel new images"), 25 | windowTitle: "HiPixel" 26 | ) { url in 27 | let newOne = MonitorItem(id: item.id, url: url, enabled: item.enabled) 28 | monitorService.update(newOne) 29 | } 30 | }, 31 | label: { 32 | HStack(spacing: 4) { 33 | Image(systemName: "folder.badge.gearshape") 34 | .font(.headline) 35 | Text(item.url.path) 36 | .lineLimit(1) 37 | .truncationMode(.middle) 38 | } 39 | .padding(.vertical, 8) 40 | .padding(.horizontal, 8) 41 | } 42 | ) 43 | .buttonStyle(.plain) 44 | 45 | Spacer() 46 | 47 | Toggle( 48 | isOn: $item.enabled, 49 | label: { 50 | } 51 | ) 52 | .toggleStyle(.switch) 53 | .controlSize(.mini) 54 | .padding(.trailing, 8) 55 | .onChange(of: item.enabled) { newValue in 56 | let newOne = MonitorItem(id: item.id, url: item.url, enabled: newValue) 57 | monitorService.update(newOne) 58 | } 59 | } 60 | .font(.caption) 61 | .background(cornerRadius: 6, strokeColor: .primary.opacity(0.04), fill: .background.opacity(0.4)) 62 | 63 | Button( 64 | action: { 65 | monitorService.remove(item) 66 | }, 67 | label: { 68 | Image(systemName: "minus.circle.fill") 69 | .font(.caption) 70 | .padding(.vertical, 4) 71 | .padding(.horizontal, 4) 72 | } 73 | ) 74 | .buttonStyle(.plain) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | # Icon must end with two \r 7 | Icon 8 | 9 | # Thumbnails 10 | ._* 11 | 12 | # Files that might appear in the root of a volume 13 | .DocumentRevisions-V100 14 | .fseventsd 15 | .Spotlight-V100 16 | .TemporaryItems 17 | .Trashes 18 | .VolumeIcon.icns 19 | .com.apple.timemachine.donotpresent 20 | 21 | # Directories potentially created on remote AFP share 22 | .AppleDB 23 | .AppleDesktop 24 | Network Trash Folder 25 | Temporary Items 26 | .apdisk 27 | 28 | # Xcode 29 | # 30 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 31 | 32 | ## User settings 33 | xcuserdata/ 34 | 35 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 36 | *.xcscmblueprint 37 | *.xccheckout 38 | 39 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 40 | build/ 41 | DerivedData/ 42 | *.moved-aside 43 | *.pbxuser 44 | !default.pbxuser 45 | *.mode1v3 46 | !default.mode1v3 47 | *.mode2v3 48 | !default.mode2v3 49 | *.perspectivev3 50 | !default.perspectivev3 51 | 52 | ## Obj-C/Swift specific 53 | *.hmap 54 | 55 | ## App packaging 56 | *.ipa 57 | *.dSYM.zip 58 | *.dSYM 59 | 60 | ## Playgrounds 61 | timeline.xctimeline 62 | playground.xcworkspace 63 | 64 | # Swift Package Manager 65 | # 66 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 67 | # Packages/ 68 | # Package.pins 69 | # Package.resolved 70 | # *.xcodeproj 71 | # 72 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 73 | # hence it is not needed unless you have added a package configuration file to your project 74 | # .swiftpm 75 | 76 | .build/ 77 | 78 | # CocoaPods 79 | # 80 | # We recommend against adding the Pods directory to your .gitignore. However 81 | # you should judge for yourself, the pros and cons are mentioned at: 82 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 83 | # 84 | # Pods/ 85 | # 86 | # Add this line if you want to avoid checking in source code from the Xcode workspace 87 | # *.xcworkspace 88 | 89 | # Carthage 90 | # 91 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 92 | # Carthage/Checkouts 93 | 94 | Carthage/Build/ 95 | 96 | # Accio dependency management 97 | Dependencies/ 98 | .accio/ 99 | 100 | # fastlane 101 | # 102 | # It is recommended to not store the screenshots in the git repo. 103 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 104 | # For more information about the recommended setup visit: 105 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 106 | 107 | fastlane/report.xml 108 | fastlane/Preview.html 109 | fastlane/screenshots/**/*.png 110 | fastlane/test_output 111 | 112 | # Code Injection 113 | # 114 | # After new code Injection tools there's a generated folder /iOSInjectionProject 115 | # https://github.com/johnno1962/injectionforxcode 116 | 117 | iOSInjectionProject/ 118 | 119 | # Claude.ai Code 120 | CLAUDE.md -------------------------------------------------------------------------------- /HiPixel.xcodeproj/xcshareddata/xcschemes/HiPixel.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 44 | 46 | 52 | 53 | 54 | 55 | 61 | 63 | 69 | 70 | 71 | 72 | 74 | 75 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /HiPixel.xcodeproj/xcshareddata/xcschemes/HiPixel-CN.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 44 | 46 | 52 | 53 | 54 | 55 | 61 | 63 | 69 | 70 | 71 | 72 | 74 | 75 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /HiPixel/Models/UnifiedModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnifiedModel.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/01/03. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | protocol ModelProtocol { 12 | var id: String { get } 13 | var displayName: String { get } 14 | var isBuiltIn: Bool { get } 15 | var modelPath: String? { get } 16 | } 17 | 18 | enum UnifiedModel: Hashable, Identifiable, ModelProtocol { 19 | case builtIn(HiPixelConfiguration.UpscaylModel) 20 | case custom(CustomModelManager.CustomModel) 21 | 22 | var id: String { 23 | switch self { 24 | case .builtIn(let model): 25 | return "builtin_\(model.id)" 26 | case .custom(let model): 27 | return "custom_\(model.name)" 28 | } 29 | } 30 | 31 | var displayName: String { 32 | switch self { 33 | case .builtIn(let model): 34 | return model.text 35 | case .custom(let model): 36 | return model.displayName 37 | } 38 | } 39 | 40 | var isBuiltIn: Bool { 41 | switch self { 42 | case .builtIn(_): 43 | return true 44 | case .custom(_): 45 | return false 46 | } 47 | } 48 | 49 | var modelPath: String? { 50 | switch self { 51 | case .builtIn(_): 52 | return nil // Built-in models use default path 53 | case .custom(let model): 54 | return model.path 55 | } 56 | } 57 | 58 | var modelName: String { 59 | switch self { 60 | case .builtIn(let model): 61 | return model.id 62 | case .custom(let model): 63 | return model.name 64 | } 65 | } 66 | 67 | // Convert back to original types when needed 68 | var upscaylModel: HiPixelConfiguration.UpscaylModel? { 69 | switch self { 70 | case .builtIn(let model): 71 | return model 72 | case .custom(_): 73 | return nil 74 | } 75 | } 76 | 77 | var customModel: CustomModelManager.CustomModel? { 78 | switch self { 79 | case .builtIn(_): 80 | return nil 81 | case .custom(let model): 82 | return model 83 | } 84 | } 85 | 86 | // Static methods to create unified model lists 87 | static func getAllModels() -> [UnifiedModel] { 88 | var models: [UnifiedModel] = [] 89 | 90 | // Add built-in models 91 | for builtInModel in HiPixelConfiguration.UpscaylModel.allCases { 92 | models.append(.builtIn(builtInModel)) 93 | } 94 | 95 | // Add custom models 96 | for customModel in CustomModelManager.shared.customModels { 97 | models.append(.custom(customModel)) 98 | } 99 | 100 | return models 101 | } 102 | 103 | static func fromStoredValue(_ storedModel: HiPixelConfiguration.UpscaylModel, customModelName: String?) -> UnifiedModel { 104 | // If there's a custom model name, try to find it 105 | if let customName = customModelName, 106 | let customModel = CustomModelManager.shared.customModels.first(where: { $0.name == customName }) { 107 | return .custom(customModel) 108 | } 109 | 110 | // Otherwise return the built-in model 111 | return .builtIn(storedModel) 112 | } 113 | } 114 | 115 | // Extension to help with storage 116 | extension UnifiedModel { 117 | var storageData: (builtInModel: HiPixelConfiguration.UpscaylModel, customModelName: String?) { 118 | switch self { 119 | case .builtIn(let model): 120 | return (model, nil) 121 | case .custom(let model): 122 | // Use a default built-in model as fallback 123 | return (HiPixelConfiguration.UpscaylModel.Upscayl_Standard, model.name) 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /HiPixel/Common/Extensions/URL+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+Extensions.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/3/11. 6 | // 7 | 8 | import SwiftUI 9 | import UniformTypeIdentifiers 10 | 11 | extension URL { 12 | var fileSize: Int { 13 | if !isFileURL { return 0 } 14 | do { 15 | return try FileManager.default.attributesOfItem(atPath: path)[.size] as! Int 16 | } catch { 17 | return 0 18 | } 19 | } 20 | 21 | var isImageFile: Bool { 22 | isFile(ofTypes: [.png, .jpeg, .webP]) 23 | } 24 | 25 | var uti: UTType? { 26 | hasDirectoryPath ? nil : UTType(filenameExtension: pathExtension) 27 | } 28 | 29 | func isFile(ofTypes types: [UTType]) -> Bool { 30 | guard let uti = self.uti else { 31 | return false 32 | } 33 | 34 | return types.contains(where: { uti.conforms(to: $0) }) 35 | } 36 | 37 | var imageRepType: NSBitmapImageRep.FileType? { 38 | if !isImageFile { 39 | return nil 40 | } 41 | switch uti { 42 | case UTType.png, UTType.webP: 43 | return .png 44 | case UTType.jpeg, UTType.heic: 45 | return .jpeg 46 | default: 47 | return nil 48 | } 49 | } 50 | 51 | var imageIdentifier: String? { 52 | if !isImageFile { 53 | return nil 54 | } 55 | switch uti { 56 | case UTType.png: 57 | return "png" 58 | case UTType.jpeg: 59 | return "jpeg" 60 | case UTType.webP: 61 | return "webp" 62 | default: 63 | return self.lastPathComponent.components(separatedBy: ".").last 64 | } 65 | } 66 | 67 | func changingPathExtension(to newExtension: String) -> URL { 68 | return self.deletingPathExtension().appendingPathExtension(newExtension) 69 | } 70 | 71 | func appendingPostfix(_ postfix: String) -> URL { 72 | let filename = self.deletingPathExtension().lastPathComponent 73 | let newFilename = "\(filename)\(postfix)" 74 | return self.deletingLastPathComponent().appendingPathComponent(newFilename).appendingPathExtension( 75 | pathExtension) 76 | } 77 | 78 | var imageContents: [URL] { 79 | guard hasDirectoryPath else { return [] } 80 | 81 | let fileManager = FileManager.default 82 | var urls = [URL]() 83 | 84 | do { 85 | let contents = try fileManager.contentsOfDirectory( 86 | at: self, 87 | includingPropertiesForKeys: nil, 88 | options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] 89 | ) 90 | 91 | for childURL in contents { 92 | if childURL.hasDirectoryPath { 93 | urls.append(contentsOf: childURL.imageContents) 94 | } else if childURL.isImageFile { 95 | urls.append(childURL) 96 | } 97 | } 98 | } catch { 99 | Common.logger.error(("Failed to retrieve directory contents: \(error)")) 100 | } 101 | return urls 102 | } 103 | 104 | var exists: Bool { 105 | // var count = 0 106 | // if !FileManager.default.fileExists(atPath: self.path) { 107 | // do { 108 | // try FileManager.default.startDownloadingUbiquitousItem(at: self) 109 | // } catch { 110 | // Common.logger.error("sycn \(self.path) failed: \(error)") 111 | // } 112 | // } else { 113 | // return true 114 | // } 115 | // while !FileManager.default.fileExists(atPath: self.path) { 116 | // count += 1 117 | // if count > 10 { 118 | // break 119 | // } 120 | // Thread.sleep(forTimeInterval: 1) 121 | // } 122 | 123 | return FileManager.default.fileExists(atPath: self.path) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /HiPixel/Views/SettingItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingItem.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2024/10/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SettingItem: View { 11 | 12 | var title: LocalizedStringKey 13 | var description: LocalizedStringKey? 14 | var icon: String 15 | var trailingView: AnyView 16 | var bodyView: AnyView 17 | 18 | @State private var isPresented: Bool = false 19 | @State private var bodyHeight: CGFloat = 0 20 | @State private var isHovered: Bool = false 21 | 22 | private var cornerRadius: CGFloat { 23 | if #available(macOS 26.0, *) { 24 | return 12 25 | } else { 26 | return 8 27 | } 28 | } 29 | 30 | init( 31 | title: LocalizedStringKey, 32 | icon: String, 33 | description: LocalizedStringKey? = nil, 34 | trailingView: AnyView, 35 | bodyView: AnyView 36 | ) { 37 | self.title = title 38 | self.icon = icon 39 | self.description = description 40 | self.trailingView = trailingView 41 | self.bodyView = bodyView 42 | } 43 | 44 | init( 45 | title: LocalizedStringKey, 46 | icon: String, 47 | description: LocalizedStringKey? = nil, 48 | trailingView: some View = EmptyView(), 49 | bodyView: some View = EmptyView() 50 | ) { 51 | self.init( 52 | title: title, 53 | icon: icon, 54 | description: description, 55 | trailingView: AnyView(trailingView), 56 | bodyView: AnyView(bodyView) 57 | ) 58 | } 59 | 60 | var body: some View { 61 | VStack(alignment: .leading, spacing: bodyHeight == 0 ? 0 : 8) { 62 | HStack { 63 | Label(title, systemImage: icon) 64 | .font(.caption) 65 | .fontWeight(.bold) 66 | 67 | if let description = description { 68 | Image(systemName: "info.circle.fill") 69 | .font(.caption) 70 | .foregroundStyle(isHovered ? .primary : .secondary) 71 | .popover(isPresented: $isPresented, content: { 72 | VStack(alignment: .leading, spacing: 6) { 73 | HStack { 74 | Label(title, systemImage: icon) 75 | .fontWeight(.bold) 76 | Spacer() 77 | } 78 | Text(description) 79 | .multilineTextAlignment(.leading) 80 | .opacity(0.6) 81 | .lineSpacing(4) 82 | } 83 | .font(.caption) 84 | .frame(width: 240) 85 | .padding(12) 86 | }) 87 | .onHover { hovering in 88 | withAnimation { 89 | isHovered = hovering 90 | } 91 | } 92 | .onTapGesture { 93 | withAnimation { 94 | isPresented.toggle() 95 | } 96 | } 97 | } 98 | 99 | Spacer() 100 | trailingView 101 | } 102 | 103 | bodyView 104 | .background { 105 | GeometryReader { geometry in 106 | Color.clear 107 | .onAppear { 108 | bodyHeight = geometry.size.height 109 | } 110 | .onChange(of: geometry.size.height) { newHeight in 111 | bodyHeight = newHeight 112 | } 113 | } 114 | } 115 | } 116 | .padding(8) 117 | .background(cornerRadius: cornerRadius, strokeColor: .primary.opacity(0.06), fill: .background.opacity(0.6)) 118 | } 119 | } 120 | 121 | #Preview { 122 | SettingItem( 123 | title: "Notification", 124 | icon: "wand.and.stars", 125 | description: "", 126 | trailingView: EmptyView(), 127 | bodyView: Group { 128 | Picker("", selection: .constant(HiPixelConfiguration.ColorScheme.light)) { 129 | ForEach(HiPixelConfiguration.ColorScheme.allCases, id: \.self) { 130 | Text($0.localized) 131 | .tag($0) 132 | } 133 | } 134 | .pickerStyle(.segmented) 135 | } 136 | ) 137 | .padding() 138 | .frame(width: 320) 139 | } 140 | -------------------------------------------------------------------------------- /HiPixel/Views/AboutView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutView.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/11/10. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AboutView: View { 11 | var body: some View { 12 | VStack(spacing: 16) { 13 | AppInfoView() 14 | 15 | // Open source components section 16 | SettingItem( 17 | title: "OPEN SOURCE COMPONENTS", 18 | icon: "cube.transparent", 19 | bodyView: VStack(alignment: .leading, spacing: 12) { 20 | HStack(spacing: 8) { 21 | Image("upscayl") 22 | .resizable() 23 | .frame(width: 24, height: 24) 24 | Link("Upscayl", destination: URL(string: "https://github.com/upscayl/upscayl")!) 25 | .font(.headline) 26 | .fontWeight(.bold) 27 | .foregroundColor(.accentColor) 28 | } 29 | 30 | VStack(alignment: .leading, spacing: 8) { 31 | Text("Components used:") 32 | .font(.subheadline) 33 | .foregroundColor(.secondary) 34 | 35 | VStack(alignment: .leading, spacing: 6) { 36 | HStack(alignment: .top, spacing: 8) { 37 | Text("•") 38 | Text("upscayl-bin") 39 | .bold() 40 | + Text(" - The binary tool for AI upscaling") 41 | } 42 | 43 | HStack(alignment: .top, spacing: 8) { 44 | Text("•") 45 | Text("AI Models") 46 | .bold() 47 | + Text(" - The image super-resolution models") 48 | } 49 | } 50 | .font(.caption) 51 | 52 | Link("Licensed under AGPLv3", destination: URL(string: "https://www.gnu.org/licenses/agpl-3.0.en.html")!) 53 | .font(.caption) 54 | .foregroundColor(.accentColor) 55 | } 56 | } 57 | ) 58 | 59 | // Design section 60 | SettingItem( 61 | title: "DESIGN", 62 | icon: "paintpalette", 63 | bodyView: VStack(alignment: .leading, spacing: 12) { 64 | HStack(spacing: 8) { 65 | Link("zaotang.xyz", destination: URL(string: "https://zaotang.xyz")!) 66 | .font(.headline) 67 | .fontWeight(.bold) 68 | .foregroundColor(.accentColor) 69 | } 70 | 71 | Text("App Icon & UI Design (v0.2)") 72 | .font(.caption) 73 | .foregroundColor(.secondary) 74 | } 75 | ) 76 | 77 | // Links section 78 | HStack(spacing: 16) { 79 | Button(action: { 80 | if let url = URL(string: "https://github.com/okooo5km/HiPixel") { 81 | NSWorkspace.shared.open(url) 82 | } 83 | }) { 84 | HStack { 85 | Image(.github) 86 | .resizable() 87 | .aspectRatio(contentMode: .fit) 88 | .frame(width: 16, height: 16) 89 | Text("Source Code") 90 | } 91 | } 92 | .buttonStyle(.gradient(configuration: .primary)) 93 | 94 | Button(action: { 95 | if let url = URL(string: "https://twitter.com/okooo5km") { 96 | NSWorkspace.shared.open(url) 97 | } 98 | }) { 99 | HStack { 100 | Image(.twitter) 101 | .resizable() 102 | .aspectRatio(contentMode: .fit) 103 | .frame(width: 16, height: 16) 104 | Text("Follow Me") 105 | } 106 | } 107 | .buttonStyle(.gradient(configuration: .primary)) 108 | } 109 | 110 | // Copyright section 111 | VStack(spacing: 4) { 112 | Text(" \(String(format: "%d", Calendar.current.component(.year, from: Date()))) 5KM Software Tech Co., Ltd.") 113 | Text("Licensed under AGPLv3") 114 | } 115 | .font(.caption) 116 | .foregroundColor(.secondary) 117 | } 118 | .padding(20) 119 | .background( 120 | VibrantBackground() 121 | .edgesIgnoringSafeArea(.all) 122 | ) 123 | .focusable(false) 124 | } 125 | } 126 | 127 | #Preview { 128 | AboutView() 129 | } 130 | -------------------------------------------------------------------------------- /HiPixel/Services/NotificationX.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationX.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2024/6/22. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import UserNotifications 11 | import GeneralNotification 12 | import NotchNotification 13 | 14 | enum NotificationX { 15 | static func check() { 16 | let center = UNUserNotificationCenter.current() 17 | 18 | // Call getNotificationSettings method to get current notification settings 19 | center.getNotificationSettings { settings in 20 | // In the completionHandler, determine if there's permission based on the value of settings.authorizationStatus 21 | switch settings.authorizationStatus { 22 | case .authorized: 23 | // Has permission, can send and receive notifications 24 | Common.logger.info("Notification Authorized") 25 | case .denied: 26 | // No permission, cannot send and receive notifications 27 | Common.logger.info("Notification Denied") 28 | case .notDetermined: 29 | // Haven't requested permission yet, need to call requestAuthorization method to request permission 30 | Common.logger.info("Notification Not Determined") 31 | center.requestAuthorization(options: [.alert, .badge, .sound]) { success, error in 32 | if success { 33 | Common.logger.info("Notification Authorization has been set!") 34 | } else if let error = error { 35 | Common.logger.error("\(error.localizedDescription)") 36 | } 37 | } 38 | Common.logger.info("Notification Determined") 39 | case .provisional: 40 | // Temporary authorization, can send and receive silent notifications (won't disturb the user) 41 | Common.logger.info("Notification Provisional") 42 | case .ephemeral: 43 | // Temporary authorization, can send and receive ephemeral notifications (will disappear from the screen) 44 | Common.logger.info("Notification Ephemeral") 45 | @unknown default: 46 | // Unknown status, might be a newly added enum value 47 | Common.logger.warning("Notification Unknown") 48 | } 49 | } 50 | } 51 | 52 | static func push(title: String = "HiPixel", message: String) { 53 | switch HiPixelConfiguration.shared.notification { 54 | case .HiPixel: 55 | GeneralNotification.present( 56 | bodyView: HStack { 57 | Image(nsImage: NSApplication.shared.applicationIconImage) 58 | .resizable() 59 | .aspectRatio(contentMode: .fit) 60 | .frame(width: 48, height: 48) 61 | 62 | VStack(alignment: .leading) { 63 | Text(title) 64 | .font(.headline) 65 | Text(message) 66 | .font(.caption) 67 | } 68 | 69 | Image(systemName: "bell.fill") 70 | .font(.system(size: 16)) 71 | .padding(8) 72 | }, 73 | interval: 3 74 | ) 75 | NSSound.beep() 76 | case .Notch: 77 | NotchNotification.present( 78 | leadingView: Image(systemName: "bell.fill"), 79 | trailingView: Image(systemName: "photo.badge.checkmark.fill"), 80 | bodyView: Text(message).font(.system(size: 11, weight: .medium)), 81 | interval: 3 82 | ) 83 | NSSound.beep() 84 | case .System: 85 | let content = UNMutableNotificationContent() 86 | let notificationCenter = UNUserNotificationCenter.current() 87 | 88 | notificationCenter.getNotificationSettings { (settings) in 89 | // Do not schedule notifications if not authorized. 90 | guard settings.authorizationStatus == .authorized else { 91 | notificationCenter.requestAuthorization(options: [.alert, .sound]) 92 | { (granted, error) in 93 | // Enable or disable features based on authorization. 94 | } 95 | return 96 | } 97 | } 98 | 99 | content.title = NSLocalizedString(title, comment: "app name") 100 | content.subtitle = NSLocalizedString(message, comment: "notification message") 101 | content.sound = UNNotificationSound.default 102 | 103 | // show this notification five seconds from now 104 | let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false) 105 | 106 | // choose a random identifier 107 | let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger) 108 | 109 | // add our notification request 110 | notificationCenter.add(request) 111 | case .None: 112 | return 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /HiPixel/Common/UI/Extensions/Button+Style.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Ext+Button.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/1/18. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // 1. Define configuration struct 11 | struct GradientButtonConfiguration { 12 | // Basic style 13 | var cornerRadius: CGFloat = 12 14 | var horizontalPadding: CGFloat = 12 15 | var verticalPadding: CGFloat = 8 16 | 17 | // Color configuration 18 | var startColor: Color = Color(hex: "#55AAEF")! 19 | var endColor: Color = .blue 20 | var foregroundColor: Color = .white 21 | 22 | // Gradient direction 23 | var gradientStartPoint: UnitPoint = .top 24 | var gradientEndPoint: UnitPoint = .bottom 25 | 26 | // Border configuration 27 | var borderWidth: CGFloat = 1 28 | var borderStartColor: Color = .white.opacity(0.4) 29 | var borderEndColor: Color = .clear 30 | 31 | // Interaction effects 32 | var pressedScale: CGFloat = 0.95 33 | var pressedOpacity: Double = 0.8 34 | var animation: Animation = .easeInOut(duration: 0.2) 35 | 36 | // Shadow configuration 37 | var shadowColor: Color = .clear 38 | var shadowRadius: CGFloat = 0 39 | var shadowX: CGFloat = 0 40 | var shadowY: CGFloat = 0 41 | 42 | // Disabled state configuration 43 | var disabledOpacity: Double = 0.6 44 | 45 | static let `default` = GradientButtonConfiguration() 46 | } 47 | 48 | // 2. Define button style 49 | struct GradientButtonStyle: ButtonStyle { 50 | let configuration: GradientButtonConfiguration 51 | @Environment(\.isEnabled) private var isEnabled 52 | 53 | init(configuration: GradientButtonConfiguration = .default) { 54 | self.configuration = configuration 55 | } 56 | 57 | func makeBody(configuration: Configuration) -> some View { 58 | configuration.label 59 | .foregroundStyle(self.configuration.foregroundColor) 60 | .padding(.horizontal, self.configuration.horizontalPadding) 61 | .padding(.vertical, self.configuration.verticalPadding) 62 | .background( 63 | RoundedRectangle(cornerRadius: self.configuration.cornerRadius) 64 | .fill( 65 | LinearGradient( 66 | colors: [ 67 | self.configuration.startColor, 68 | self.configuration.endColor, 69 | ], 70 | startPoint: self.configuration.gradientStartPoint, 71 | endPoint: self.configuration.gradientEndPoint 72 | ) 73 | ) 74 | ) 75 | .overlay( 76 | RoundedRectangle(cornerRadius: self.configuration.cornerRadius) 77 | .strokeBorder( 78 | LinearGradient( 79 | colors: [ 80 | self.configuration.borderStartColor, 81 | self.configuration.borderEndColor, 82 | ], 83 | startPoint: self.configuration.gradientStartPoint, 84 | endPoint: self.configuration.gradientEndPoint 85 | ), 86 | lineWidth: self.configuration.borderWidth 87 | ) 88 | ) 89 | .shadow( 90 | color: self.configuration.shadowColor, 91 | radius: self.configuration.shadowRadius, 92 | x: self.configuration.shadowX, 93 | y: self.configuration.shadowY 94 | ) 95 | .scaleEffect(configuration.isPressed ? self.configuration.pressedScale : 1.0) 96 | .opacity(configuration.isPressed ? self.configuration.pressedOpacity : 1.0) 97 | .opacity(isEnabled ? 1.0 : self.configuration.disabledOpacity) 98 | .animation(self.configuration.animation, value: configuration.isPressed) 99 | } 100 | } 101 | 102 | // 3. Add convenience extension 103 | extension ButtonStyle where Self == GradientButtonStyle { 104 | static var gradient: GradientButtonStyle { .init() } 105 | 106 | static func gradient( 107 | configuration: GradientButtonConfiguration = .default 108 | ) -> GradientButtonStyle { 109 | .init(configuration: configuration) 110 | } 111 | } 112 | 113 | // 4. Preset styles 114 | extension GradientButtonConfiguration { 115 | static let primary = GradientButtonConfiguration( 116 | startColor: Color(hex: "#55AAEF")!, 117 | endColor: .blue 118 | ) 119 | 120 | static let secondary = GradientButtonConfiguration( 121 | startColor: Color.secondary.opacity(0.8), 122 | endColor: Color.secondary 123 | ) 124 | 125 | static let success = GradientButtonConfiguration( 126 | startColor: .green.opacity(0.8), 127 | endColor: .green 128 | ) 129 | 130 | static let danger = GradientButtonConfiguration( 131 | startColor: .pink.opacity(0.8), 132 | endColor: .pink 133 | ) 134 | 135 | static let fancy = GradientButtonConfiguration( 136 | cornerRadius: 20, 137 | horizontalPadding: 20, 138 | verticalPadding: 12, 139 | startColor: .purple, 140 | endColor: .pink, 141 | shadowRadius: 8, 142 | shadowY: 4 143 | ) 144 | } 145 | -------------------------------------------------------------------------------- /HiPixel/Views/ResourceDownloadSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResourceDownloadSheet.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/7/28. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ResourceDownloadSheet: View { 11 | @StateObject private var downloadManager = ResourceDownloadManager.shared 12 | @State private var canDismiss = false 13 | 14 | var body: some View { 15 | VStack(spacing: 16) { 16 | // Header 17 | VStack(spacing: 12) { 18 | Image(systemName: "arrow.down.circle.fill") 19 | .font(.system(size: 32)) 20 | .foregroundColor(.blue) 21 | 22 | Text("Download Dependencies") 23 | .font(.title3) 24 | .fontWeight(.semibold) 25 | 26 | Text("HiPixel needs to download AI models and processing tools to function properly.") 27 | .foregroundColor(.secondary) 28 | .multilineTextAlignment(.center) 29 | } 30 | 31 | // Download progress 32 | VStack(spacing: 12) { 33 | switch downloadManager.downloadState { 34 | case .idle: 35 | ProgressView() 36 | .scaleEffect(0.5) 37 | 38 | case .checking: 39 | ProgressView() 40 | .scaleEffect(0.5) 41 | Text("Checking for latest version...") 42 | .font(.body) 43 | 44 | case .downloading(_): 45 | ProgressView(value: downloadManager.downloadProgress) 46 | .progressViewStyle(LinearProgressViewStyle()) 47 | 48 | HStack { 49 | Text("\(Int(downloadManager.downloadProgress * 100))%") 50 | Spacer() 51 | if !downloadManager.downloadSpeed.isEmpty { 52 | Text(downloadManager.downloadSpeed) 53 | .foregroundColor(.secondary) 54 | } 55 | } 56 | .font(.caption) 57 | 58 | case .installing: 59 | ProgressView() 60 | .scaleEffect(0.5) 61 | Text("Installing...") 62 | .font(.body) 63 | 64 | case .completed: 65 | Image(systemName: "checkmark.circle.fill") 66 | .font(.system(size: 32)) 67 | .foregroundColor(.green) 68 | Text("Download Complete!") 69 | .font(.headline) 70 | 71 | Button("Continue") { 72 | canDismiss = true 73 | } 74 | .buttonStyle(.gradient(configuration: .primary)) 75 | 76 | .onAppear { 77 | // Auto dismiss after 1 second 78 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 79 | canDismiss = true 80 | } 81 | } 82 | 83 | case .error(let message): 84 | Image(systemName: "exclamationmark.triangle.fill") 85 | .font(.system(size: 32)) 86 | .foregroundColor(.red) 87 | Text("Download Failed") 88 | .font(.headline) 89 | Text(message) 90 | .font(.caption) 91 | .foregroundColor(.secondary) 92 | .multilineTextAlignment(.center) 93 | 94 | HStack { 95 | Button("Retry") { 96 | Task { 97 | await downloadManager.checkForUpdates() 98 | } 99 | } 100 | .buttonStyle(.gradient(configuration: .primary)) 101 | 102 | Button("Skip for Now") { 103 | canDismiss = true 104 | } 105 | .buttonStyle(.gradient(configuration: .danger)) 106 | } 107 | } 108 | } 109 | } 110 | .padding(16) 111 | .frame(width: 320) 112 | .background(.regularMaterial) 113 | .interactiveDismissDisabled(!canDismiss) 114 | .onAppear { 115 | // Start download immediately when sheet appears 116 | if downloadManager.downloadState == .idle { 117 | Task { 118 | await downloadManager.checkForUpdates() 119 | } 120 | } 121 | } 122 | } 123 | 124 | // MARK: - Helper Methods 125 | 126 | private func isErrorState(_ state: ResourceDownloadManager.DownloadState) -> Bool { 127 | if case .error(_) = state { 128 | return true 129 | } 130 | return false 131 | } 132 | } 133 | 134 | // MARK: - Preview 135 | #Preview { 136 | ResourceDownloadSheet() 137 | } 138 | -------------------------------------------------------------------------------- /HiPixel/Services/MonitorService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MonitorService.swift 3 | // HiPixel 4 | // 5 | // Created by 十里 on 2025/03/11. 6 | // 7 | 8 | import SwiftUI 9 | import FSWatcher 10 | 11 | class MonitorService: ObservableObject { 12 | static let shared = MonitorService() 13 | 14 | private init() { 15 | directoryWatcher.delegate = self 16 | } 17 | 18 | static let monitorItemsKey = "monitorItems" 19 | 20 | @Published var items: [MonitorItem] = [] 21 | 22 | private let directoryWatcher = FSWatcher.MultiDirectoryWatcher() 23 | private var whiteList = [URL: Set]() 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 | Zipic 7 | Orchard 8 | TimeGo Clock 9 | KeygenGo 10 | HiPixel 11 |

12 | 13 | --- 14 | 15 |

16 | HiPixel Logo 17 |

18 | 19 |

HiPixel

20 | 21 |

22 | 23 | License: AGPL v3 24 | 25 | 26 | Swift 5.9 27 | 28 | 29 | Platform: macOS 30 | 31 | 32 | macOS 13.0+ 33 | 34 |

35 | 36 |

37 | English | 中文 38 |

39 | 40 | --- 41 | 42 | ## macOS 原生的 AI 图像超分辨率工具 43 | 44 | HiPixel 是一款原生 macOS 应用程序,用于 AI 图像超分辨率处理,使用 SwiftUI 构建,并采用 Upscayl 的强大 AI 模型。 45 | 46 |

47 | HiPixel 截图 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 | 下载 HiPixel 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 | Buy Me A Coffee 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 | Zipic 7 | Orchard 8 | TimeGo Clock 9 | KeygenGo 10 | HiPixel 11 |

12 | 13 | --- 14 | 15 |

16 | HiPixel Logo 17 |

18 | 19 |

HiPixel

20 | 21 |

22 | 23 | License: AGPL v3 24 | 25 | 26 | Swift 5.9 27 | 28 | 29 | Platform: macOS 30 | 31 | 32 | macOS 13.0+ 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 | HiPixel Screenshot 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 | Download HiPixel 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 | Buy Me A Coffee 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 | WeChat Pay QR Code 232 |

233 | 234 | - **Alipay** 235 | 236 |

237 | Alipay QR Code 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 | --------------------------------------------------------------------------------