The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .gitignore
├── CHANGE_LOG.md
├── CHANGE_LOG_ZH.md
├── LICENSE
├── README.md
├── README_ZH.md
├── TinyPNG4Mac
    ├── .gitignore
    ├── TinyPNG4Mac.xcodeproj
    │   ├── project.pbxproj
    │   └── project.xcworkspace
    │   │   ├── contents.xcworkspacedata
    │   │   └── xcshareddata
    │   │       └── swiftpm
    │   │           └── Package.resolved
    └── TinyPNG4Mac
    │   ├── Assets.xcassets
    │       ├── AccentColor.colorset
    │       │   └── Contents.json
    │       ├── AppIcon.appiconset
    │       │   ├── Contents.json
    │       │   ├── icon_128x128.png
    │       │   ├── icon_128x128@2x.png
    │       │   ├── icon_16x16.png
    │       │   ├── icon_16x16@2x.png
    │       │   ├── icon_256x256.png
    │       │   ├── icon_256x256@2x.png
    │       │   ├── icon_32x32.png
    │       │   ├── icon_32x32@2x.png
    │       │   ├── icon_512x512.png
    │       │   └── icon_512x512@2x.png
    │       ├── Contents.json
    │       ├── appIcon.imageset
    │       │   ├── Contents.json
    │       │   ├── icon_128x128.png
    │       │   └── icon_128x128@2x.png
    │       ├── mainViewBackground.colorset
    │       │   └── Contents.json
    │       ├── placeholder.imageset
    │       │   ├── Contents.json
    │       │   ├── placeholder-dark.png
    │       │   ├── placeholder-dark@2x.png
    │       │   ├── placeholder.png
    │       │   └── placeholder@2x.png
    │       ├── settingViewBackground.colorset
    │       │   └── Contents.json
    │       ├── settingViewBackgroundBorder.colorset
    │       │   └── Contents.json
    │       ├── taskPreviewStroke.colorset
    │       │   └── Contents.json
    │       ├── taskRowBackground.colorset
    │       │   └── Contents.json
    │       ├── taskRowShadow.colorset
    │       │   └── Contents.json
    │       ├── taskRowStroke.colorset
    │       │   └── Contents.json
    │       ├── textBody.colorset
    │       │   └── Contents.json
    │       ├── textBodyAbout.colorset
    │       │   └── Contents.json
    │       ├── textCaption.colorset
    │       │   └── Contents.json
    │       ├── textGreen.colorset
    │       │   └── Contents.json
    │       ├── textMainTitle.colorset
    │       │   └── Contents.json
    │       ├── textRed.colorset
    │       │   └── Contents.json
    │       ├── textSecondary.colorset
    │       │   └── Contents.json
    │       └── textSecondaryAbout.colorset
    │       │   └── Contents.json
    │   ├── Info.plist
    │   ├── Localizable.xcstrings
    │   ├── Preview Content
    │       └── Preview Assets.xcassets
    │       │   └── Contents.json
    │   ├── TinyPNG4Mac.entitlements
    │   ├── TinyPNG4MacApp.swift
    │   ├── app
    │       ├── AppConfig.swift
    │       └── AppContext.swift
    │   ├── client
    │       ├── TPClient.swift
    │       ├── TPQueue.swift
    │       └── model
    │       │   └── models.swift
    │   ├── model
    │       ├── Errors.swift
    │       └── TaskInfo.swift
    │   ├── utils
    │       ├── AppUtils.swift
    │       ├── Extensions.swift
    │       ├── FileUtils.swift
    │       ├── UIUtils.swift
    │       └── URLUtils.swift
    │   ├── views
    │       ├── DebugView.swift
    │       ├── DropFileView.swift
    │       ├── HorizontalDivider.swift
    │       ├── TaskRowView.swift
    │       └── settings
    │       │   └── SettingsItem.swift
    │   ├── vms
    │       ├── DebugViewModel.swift
    │       └── MainViewModel.swift
    │   └── windows
    │       ├── AboutView.swift
    │       ├── MainContentView.swift
    │       └── SettingsView.swift
├── create_dmg.sh
├── images
    └── dmg_background.png
└── preview
    ├── banner.png
    └── icon.png


/.gitignore:
--------------------------------------------------------------------------------
 1 | # Xcode
 2 | #
 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
 4 | 
 5 | ## Build generated
 6 | build/
 7 | DerivedData/
 8 | 
 9 | ## Various settings
10 | *.pbxuser
11 | !default.pbxuser
12 | *.mode1v3
13 | !default.mode1v3
14 | *.mode2v3
15 | !default.mode2v3
16 | *.perspectivev3
17 | !default.perspectivev3
18 | xcuserdata/
19 | 
20 | ## Other
21 | *.moved-aside
22 | *.xcuserstate
23 | 
24 | ## Obj-C/Swift specific
25 | *.hmap
26 | *.ipa
27 | *.dSYM.zip
28 | *.dSYM
29 | 
30 | ## Playgrounds
31 | timeline.xctimeline
32 | playground.xcworkspace
33 | 
34 | # Swift Package Manager
35 | #
36 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
37 | # Packages/
38 | .build/
39 | 
40 | # CocoaPods
41 | #
42 | # We recommend against adding the Pods directory to your .gitignore. However
43 | # you should judge for yourself, the pros and cons are mentioned at:
44 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
45 | #
46 | source/Pods/
47 | 
48 | # Carthage
49 | #
50 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
51 | # Carthage/Checkouts
52 | 
53 | Carthage/Build
54 | 
55 | # fastlane
56 | #
57 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
58 | # screenshots whenever they are needed.
59 | # For more information about the recommended setup visit:
60 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md
61 | 
62 | fastlane/report.xml
63 | fastlane/Preview.html
64 | fastlane/screenshots
65 | fastlane/test_output
66 | 
67 | .DS_Store
68 | 
69 | 
70 | # dmg
71 | dmg_source/
72 | *.dmg
73 | 


--------------------------------------------------------------------------------
/CHANGE_LOG.md:
--------------------------------------------------------------------------------
 1 | # Change Log
 2 | 
 3 | **Version 2.0.0**
 4 | 
 5 | 1. New designed task list with more task info, such as file sizes, api usage.
 6 | 2. New overwrite mode and image restoration.
 7 | 3. Context menu and batch control.
 8 | 4. Metadata preservation. (Supported by TinyPNG)
 9 | 5. Resizible window.
10 | 6. Both image files and directories supports.
11 | 7. Dropping images and directories to dock icon supports.
12 | 
13 | **Version 1.0.7**
14 | 
15 | 1. Add support for webp files.
16 | 2. Update Alamofire to latest version.
17 | 3. Change minimal supported macOS version to 10.13.
18 | 4. Change dependency management from CocoaPods to SPM.
19 | 
20 | **Version 1.0.5**
21 | 
22 | 1. Support Apple silicon. [#47](https://github.com/kyleduo/TinyPNG4Mac/pull/47) Thanks [@limuyang2](https://github.com/limuyang2)
23 | 
24 | 
25 | **Version 1.0.4**
26 | 
27 | 1. Support reserving origin file's permission. [#11](https://github.com/kyleduo/TinyPNG4Mac/issues/11) Thanks [PR by @Enoooch](https://github.com/kyleduo/TinyPNG4Mac/pull/40) 
28 | 
29 | **Version 1.0.3**
30 | 
31 | 1. Support compress folder recursively. [#14](https://github.com/kyleduo/TinyPNG4Mac/issues/14) [#33](https://github.com/kyleduo/TinyPNG4Mac/issues/33)
32 | 
33 | **Version 1.0.2**
34 | 
35 | 1. Fixed [#29](https://github.com/kyleduo/TinyPNG4Mac/issues/29)
36 | 2. Fixed a typo.
37 | 
38 | **Version 1.0.1**
39 | 
40 | 1. Migrate to Swift 5.0, thanks [@gewill](https://github.com/gewill)
41 | 2. Downward compatibility to macOS 10.10
42 | 3. Fixed [#19](https://github.com/kyleduo/TinyPNG4Mac/issues/19), [#22](
43 | 
44 | **Version 1.0.0**
45 | 
46 | 1. New icon and interface
47 | 2. Support "in place"
48 | 3. Improve stability and fix bugs
49 | 
50 | **Version 0.9.3**
51 | 
52 | 1. Update to **Swift 3**
53 | 2. Add `Pods/` to `.gitignore`
54 | 3. Display progress when uploading/downloaing.
55 | 
56 | **Version 0.9.2**
57 | 
58 | 1. Support **JPG** and **JPEG**.
59 | 
60 | **Version 0.9 brings a lot of change.**
61 | 
62 | 1. Whole new design UI.
63 | 2. New workflow and easy to use.
64 | 3. Custom ouput path support.
65 | 4. Sorted task list.
66 | 5. Chinese support.


--------------------------------------------------------------------------------
/CHANGE_LOG_ZH.md:
--------------------------------------------------------------------------------
 1 | # 更新日志
 2 | 
 3 | **2.0.0**
 4 | 
 5 | 1. 全新的任务列表,显示更多任务信息,如文件大小和 API 使用情况。
 6 | 2. 新的覆盖模式和图像恢复功能。
 7 | 3. 支持右键菜单和批量控制。
 8 | 4. 元数据保留。(由 TinyPNG 支持)
 9 | 5. 可调整大小的窗口。
10 | 6. 支持图片文件和目录。
11 | 7. 支持将图片和目录拖拽到 Dock 图标。
12 | 
13 | **版本 1.0.7**
14 | 
15 | 1. 添加对 WebP 文件的支持。
16 | 2. 更新 Alamofire 至最新版本。
17 | 3. 将最低支持的 macOS 版本更改为 10.13。
18 | 4. 将依赖管理从 CocoaPods 转为 SPM(Swift 包管理)。
19 | 
20 | **版本 1.0.5**
21 | 
22 | 1. 支持 Apple Silicon。[#47](https://github.com/kyleduo/TinyPNG4Mac/pull/47) 感谢 [@limuyang2](https://github.com/limuyang2)
23 | 
24 | **版本 1.0.4**
25 | 
26 | 1. 支持保留原始文件的权限。[#11](https://github.com/kyleduo/TinyPNG4Mac/issues/11) 感谢 [@Enoooch](https://github.com/kyleduo/TinyPNG4Mac/pull/40) 提交的 PR。
27 | 
28 | **版本 1.0.3**
29 | 
30 | 1. 支持递归压缩文件夹。[#14](https://github.com/kyleduo/TinyPNG4Mac/issues/14) [#33](https://github.com/kyleduo/TinyPNG4Mac/issues/33)
31 | 
32 | **版本 1.0.2**
33 | 
34 | 1. 修复了 [#29](https://github.com/kyleduo/TinyPNG4Mac/issues/29)。
35 | 2. 修复了一个拼写错误。
36 | 
37 | **版本 1.0.1**
38 | 
39 | 1. 迁移至 Swift 5.0,感谢 [@gewill](https://github.com/gewill)。
40 | 2. 向下兼容 macOS 10.10。
41 | 3. 修复了 [#19](https://github.com/kyleduo/TinyPNG4Mac/issues/19)、[#22](https://github.com/kyleduo/TinyPNG4Mac/issues/22)。
42 | 
43 | **版本 1.0.0**
44 | 
45 | 1. 新的图标和界面。
46 | 2. 支持“就地压缩”。
47 | 3. 提升稳定性并修复了多个 bug。
48 | 
49 | **版本 0.9.3**
50 | 
51 | 1. 更新至 **Swift 3**。
52 | 2. 将 `Pods/` 添加到 `.gitignore`。
53 | 3. 上传/下载时显示进度。
54 | 
55 | **版本 0.9.2**
56 | 
57 | 1. 支持 **JPG** 和 **JPEG** 格式。
58 | 
59 | **版本 0.9 带来了很多变化**
60 | 
61 | 1. 全新的 UI 设计。
62 | 2. 新的工作流,更易于使用。
63 | 3. 支持自定义输出路径。
64 | 4. 排序后的任务列表。
65 | 5. 支持中文。


--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
 1 | The MIT License (MIT)
 2 | 
 3 | Copyright (c) 2016 kyleduo
 4 | 
 5 | Permission is hereby granted, free of charge, to any person obtaining a copy
 6 | of this software and associated documentation files (the "Software"), to deal
 7 | in the Software without restriction, including without limitation the rights
 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 | 
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 | 
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
 1 | # Tiny Image
 2 | 
 3 | >  The App's name is renamed to Image Image for property sake, since previous name contains TinyPNG and macOS.
 4 | 
 5 | ![preview](./preview/banner.png)
 6 | 
 7 | 
 8 | 
 9 | TinyPNG for macOS is a 3rd-party client of [TinyPNG](https://tinypng.com). With which you can compress images without opening browser.
10 | 
11 | [中文](./README_ZH.md)
12 | 
13 | 
14 | 
15 | ### 2.1.0 Release Note
16 | 
17 | > Version 2.0.0+ supports macOS 13 Ventura and later. For lower version systems, please use the previous version.
18 | 
19 | 1. Fix issue #65: task error when drag images to dock icon when App closed.
20 | 2. Disable SandBox mode. Now Tiny Image can automatically create output folder if it's not exits.
21 | 3. Show current save mode (Overwrite Mode / Save As Mode) in Main window.
22 | 4. Add output folder icon to Main window which supports click to open.
23 | 5. Add frequency used function entries to task row.
24 | 
25 | [Change Log](./CHANGE_LOG.md)
26 | 
27 | 
28 | 
29 | ### Usage
30 | 
31 | 1. Register an **API key** at [link](https://tinypng.com/developers).
32 | 2. Paste your API key to `Settings` window. (You can edit it when you need to)
33 | 3. Drag images or directories containing images to the window.
34 | 
35 | 
36 | 
37 | ### Download
38 | 
39 | Through [Release Page](https://github.com/kyleduo/TinyPNG4Mac/releases)
40 | 
41 | Check  `System Settings -> Security & privacy` page if you can not open this app.
42 | 
43 | 
44 | 
45 | ### Thanks
46 | 
47 | [droptogif](https://github.com/mortenjust/droptogif) -- A very useful client for convert video to gif. I learnt how to create window from that project.
48 | 
49 | 
50 | 
51 | ### Licenses
52 | 
53 | Developed by [@kyleduo](https://github.com/kyleduo) and available under the [MIT](http://opensource.org/licenses/MIT) license.
54 | 


--------------------------------------------------------------------------------
/README_ZH.md:
--------------------------------------------------------------------------------
 1 | # Tiny Image
 2 | 
 3 | > 出于知识产权角度考虑,应用名修改为 Tiny Image,因为原名包含 TinyPNG 和 macOS。
 4 | 
 5 | ![preview](./preview/banner.png)
 6 | 
 7 | 
 8 | 
 9 | TinyPNG for macOS 是 [TinyPNG](https://tinypng.com) 的第三方客户端,使用它,无需打开浏览器即可压缩图片。
10 | 
11 | [English](./README.md)
12 | 
13 | 
14 | 
15 | ### 2.1.0 更新说明
16 | 
17 | > 2.0.0 及以后版本支持 macOS 13 Ventura 及更新的系统。更低版本的系统请使用历史版本。
18 | 
19 | 1. 修复 issue #65:当应用关闭时,将图片拖到 Dock 图标导致任务错误的问题。
20 | 2. 禁用 SandBox 模式。现在 Tiny Image 可以在输出文件夹不存在时自动创建文件夹。
21 | 3. 在主窗口显示当前的保存模式(覆写模式 / 另存为模式)。
22 | 4. 在主窗口新增输出文件夹图标,支持点击打开文件夹。
23 | 5. 在任务列表添加常用功能入口。
24 | 
25 | [更新日志](./CHANGE_LOG_ZH.md)
26 | 
27 | 
28 | 
29 | ### 使用方法
30 | 
31 | 1. 在 [这里](https://tinypng.com/developers) 注册 **API key**。
32 | 2. 将 API key 粘贴到 `设置` 窗口中。(如果需要,您可以随时修改)
33 | 3. 将图片或包含图片的文件夹拖拽到窗口中。
34 | 
35 | 
36 | 
37 | 
38 | ### 下载
39 | 
40 | 通过 [发布页面](https://github.com/kyleduo/TinyPNG4Mac/releases) 下载。
41 | 
42 | 如果无法打开该应用,请检查 `系统设置 -> 安全性与隐私` 页面。
43 | 
44 | 
45 | 
46 | ### 感谢
47 | 
48 | [droptogif](https://github.com/mortenjust/droptogif) —— 一个非常实用的将视频转换为 gif 的客户端。我从这个项目中学会了如何创建窗口。
49 | 
50 | 
51 | 
52 | ### 许可
53 | 
54 | Developed by [@kyleduo](https://github.com/kyleduo) and available under the [MIT](http://opensource.org/licenses/MIT) license.
55 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/.gitignore:
--------------------------------------------------------------------------------
1 | **/xcuserdata/**
2 | **/.DS_Store


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
  1 | // !$*UTF8*$!
  2 | {
  3 | 	archiveVersion = 1;
  4 | 	classes = {
  5 | 	};
  6 | 	objectVersion = 77;
  7 | 	objects = {
  8 | 
  9 | /* Begin PBXBuildFile section */
 10 | 		6C1F98FE2CF3082800BF6501 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 6C1F98FD2CF3082800BF6501 /* Alamofire */; };
 11 | /* End PBXBuildFile section */
 12 | 
 13 | /* Begin PBXFileReference section */
 14 | 		6CCB4BA52CE7AAB100DAB8A2 /* Tiny Image.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Tiny Image.app"; sourceTree = BUILT_PRODUCTS_DIR; };
 15 | /* End PBXFileReference section */
 16 | 
 17 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
 18 | 		6C5DDA742D2D96E10096E8C0 /* Exceptions for "TinyPNG4Mac" folder in "Tiny Image" target */ = {
 19 | 			isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
 20 | 			membershipExceptions = (
 21 | 				Info.plist,
 22 | 			);
 23 | 			target = 6CCB4BA42CE7AAB100DAB8A2 /* Tiny Image */;
 24 | 		};
 25 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
 26 | 
 27 | /* Begin PBXFileSystemSynchronizedRootGroup section */
 28 | 		6CCB4BA72CE7AAB100DAB8A2 /* TinyPNG4Mac */ = {
 29 | 			isa = PBXFileSystemSynchronizedRootGroup;
 30 | 			exceptions = (
 31 | 				6C5DDA742D2D96E10096E8C0 /* Exceptions for "TinyPNG4Mac" folder in "Tiny Image" target */,
 32 | 			);
 33 | 			path = TinyPNG4Mac;
 34 | 			sourceTree = "<group>";
 35 | 		};
 36 | /* End PBXFileSystemSynchronizedRootGroup section */
 37 | 
 38 | /* Begin PBXFrameworksBuildPhase section */
 39 | 		6CCB4BA22CE7AAB100DAB8A2 /* Frameworks */ = {
 40 | 			isa = PBXFrameworksBuildPhase;
 41 | 			buildActionMask = 2147483647;
 42 | 			files = (
 43 | 				6C1F98FE2CF3082800BF6501 /* Alamofire in Frameworks */,
 44 | 			);
 45 | 			runOnlyForDeploymentPostprocessing = 0;
 46 | 		};
 47 | /* End PBXFrameworksBuildPhase section */
 48 | 
 49 | /* Begin PBXGroup section */
 50 | 		6CCB4B9C2CE7AAB100DAB8A2 = {
 51 | 			isa = PBXGroup;
 52 | 			children = (
 53 | 				6CCB4BA72CE7AAB100DAB8A2 /* TinyPNG4Mac */,
 54 | 				6CCB4BA62CE7AAB100DAB8A2 /* Products */,
 55 | 			);
 56 | 			sourceTree = "<group>";
 57 | 		};
 58 | 		6CCB4BA62CE7AAB100DAB8A2 /* Products */ = {
 59 | 			isa = PBXGroup;
 60 | 			children = (
 61 | 				6CCB4BA52CE7AAB100DAB8A2 /* Tiny Image.app */,
 62 | 			);
 63 | 			name = Products;
 64 | 			sourceTree = "<group>";
 65 | 		};
 66 | /* End PBXGroup section */
 67 | 
 68 | /* Begin PBXNativeTarget section */
 69 | 		6CCB4BA42CE7AAB100DAB8A2 /* Tiny Image */ = {
 70 | 			isa = PBXNativeTarget;
 71 | 			buildConfigurationList = 6CCB4BB62CE7AAB200DAB8A2 /* Build configuration list for PBXNativeTarget "Tiny Image" */;
 72 | 			buildPhases = (
 73 | 				6CCB4BA12CE7AAB100DAB8A2 /* Sources */,
 74 | 				6CCB4BA22CE7AAB100DAB8A2 /* Frameworks */,
 75 | 				6CCB4BA32CE7AAB100DAB8A2 /* Resources */,
 76 | 			);
 77 | 			buildRules = (
 78 | 			);
 79 | 			dependencies = (
 80 | 			);
 81 | 			fileSystemSynchronizedGroups = (
 82 | 				6CCB4BA72CE7AAB100DAB8A2 /* TinyPNG4Mac */,
 83 | 			);
 84 | 			name = "Tiny Image";
 85 | 			packageProductDependencies = (
 86 | 				6C1F98FD2CF3082800BF6501 /* Alamofire */,
 87 | 			);
 88 | 			productName = TinePNG4Mac;
 89 | 			productReference = 6CCB4BA52CE7AAB100DAB8A2 /* Tiny Image.app */;
 90 | 			productType = "com.apple.product-type.application";
 91 | 		};
 92 | /* End PBXNativeTarget section */
 93 | 
 94 | /* Begin PBXProject section */
 95 | 		6CCB4B9D2CE7AAB100DAB8A2 /* Project object */ = {
 96 | 			isa = PBXProject;
 97 | 			attributes = {
 98 | 				BuildIndependentTargetsInParallel = 1;
 99 | 				LastSwiftUpdateCheck = 1610;
100 | 				LastUpgradeCheck = 1610;
101 | 				TargetAttributes = {
102 | 					6CCB4BA42CE7AAB100DAB8A2 = {
103 | 						CreatedOnToolsVersion = 16.1;
104 | 					};
105 | 				};
106 | 			};
107 | 			buildConfigurationList = 6CCB4BA02CE7AAB100DAB8A2 /* Build configuration list for PBXProject "TinyPNG4Mac" */;
108 | 			developmentRegion = en;
109 | 			hasScannedForEncodings = 0;
110 | 			knownRegions = (
111 | 				en,
112 | 				Base,
113 | 				"zh-Hans",
114 | 			);
115 | 			mainGroup = 6CCB4B9C2CE7AAB100DAB8A2;
116 | 			minimizedProjectReferenceProxies = 1;
117 | 			packageReferences = (
118 | 				6C1F98FC2CF3082800BF6501 /* XCRemoteSwiftPackageReference "Alamofire" */,
119 | 			);
120 | 			preferredProjectObjectVersion = 77;
121 | 			productRefGroup = 6CCB4BA62CE7AAB100DAB8A2 /* Products */;
122 | 			projectDirPath = "";
123 | 			projectRoot = "";
124 | 			targets = (
125 | 				6CCB4BA42CE7AAB100DAB8A2 /* Tiny Image */,
126 | 			);
127 | 		};
128 | /* End PBXProject section */
129 | 
130 | /* Begin PBXResourcesBuildPhase section */
131 | 		6CCB4BA32CE7AAB100DAB8A2 /* Resources */ = {
132 | 			isa = PBXResourcesBuildPhase;
133 | 			buildActionMask = 2147483647;
134 | 			files = (
135 | 			);
136 | 			runOnlyForDeploymentPostprocessing = 0;
137 | 		};
138 | /* End PBXResourcesBuildPhase section */
139 | 
140 | /* Begin PBXSourcesBuildPhase section */
141 | 		6CCB4BA12CE7AAB100DAB8A2 /* Sources */ = {
142 | 			isa = PBXSourcesBuildPhase;
143 | 			buildActionMask = 2147483647;
144 | 			files = (
145 | 			);
146 | 			runOnlyForDeploymentPostprocessing = 0;
147 | 		};
148 | /* End PBXSourcesBuildPhase section */
149 | 
150 | /* Begin XCBuildConfiguration section */
151 | 		6CCB4BB42CE7AAB200DAB8A2 /* Debug */ = {
152 | 			isa = XCBuildConfiguration;
153 | 			buildSettings = {
154 | 				ALWAYS_SEARCH_USER_PATHS = NO;
155 | 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
156 | 				CLANG_ANALYZER_NONNULL = YES;
157 | 				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
158 | 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
159 | 				CLANG_ENABLE_MODULES = YES;
160 | 				CLANG_ENABLE_OBJC_ARC = YES;
161 | 				CLANG_ENABLE_OBJC_WEAK = YES;
162 | 				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
163 | 				CLANG_WARN_BOOL_CONVERSION = YES;
164 | 				CLANG_WARN_COMMA = YES;
165 | 				CLANG_WARN_CONSTANT_CONVERSION = YES;
166 | 				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
167 | 				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
168 | 				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
169 | 				CLANG_WARN_EMPTY_BODY = YES;
170 | 				CLANG_WARN_ENUM_CONVERSION = YES;
171 | 				CLANG_WARN_INFINITE_RECURSION = YES;
172 | 				CLANG_WARN_INT_CONVERSION = YES;
173 | 				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
174 | 				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
175 | 				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
176 | 				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
177 | 				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
178 | 				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
179 | 				CLANG_WARN_STRICT_PROTOTYPES = YES;
180 | 				CLANG_WARN_SUSPICIOUS_MOVE = YES;
181 | 				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
182 | 				CLANG_WARN_UNREACHABLE_CODE = YES;
183 | 				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
184 | 				COPY_PHASE_STRIP = NO;
185 | 				DEBUG_INFORMATION_FORMAT = dwarf;
186 | 				ENABLE_STRICT_OBJC_MSGSEND = YES;
187 | 				ENABLE_TESTABILITY = YES;
188 | 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
189 | 				GCC_C_LANGUAGE_STANDARD = gnu17;
190 | 				GCC_DYNAMIC_NO_PIC = NO;
191 | 				GCC_NO_COMMON_BLOCKS = YES;
192 | 				GCC_OPTIMIZATION_LEVEL = 0;
193 | 				GCC_PREPROCESSOR_DEFINITIONS = (
194 | 					"DEBUG=1",
195 | 					"$(inherited)",
196 | 				);
197 | 				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
198 | 				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
199 | 				GCC_WARN_UNDECLARED_SELECTOR = YES;
200 | 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
201 | 				GCC_WARN_UNUSED_FUNCTION = YES;
202 | 				GCC_WARN_UNUSED_VARIABLE = YES;
203 | 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
204 | 				MACOSX_DEPLOYMENT_TARGET = 15.1;
205 | 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
206 | 				MTL_FAST_MATH = YES;
207 | 				ONLY_ACTIVE_ARCH = YES;
208 | 				SDKROOT = macosx;
209 | 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
210 | 				SWIFT_EMIT_LOC_STRINGS = YES;
211 | 				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
212 | 			};
213 | 			name = Debug;
214 | 		};
215 | 		6CCB4BB52CE7AAB200DAB8A2 /* Release */ = {
216 | 			isa = XCBuildConfiguration;
217 | 			buildSettings = {
218 | 				ALWAYS_SEARCH_USER_PATHS = NO;
219 | 				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
220 | 				CLANG_ANALYZER_NONNULL = YES;
221 | 				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
222 | 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
223 | 				CLANG_ENABLE_MODULES = YES;
224 | 				CLANG_ENABLE_OBJC_ARC = YES;
225 | 				CLANG_ENABLE_OBJC_WEAK = YES;
226 | 				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
227 | 				CLANG_WARN_BOOL_CONVERSION = YES;
228 | 				CLANG_WARN_COMMA = YES;
229 | 				CLANG_WARN_CONSTANT_CONVERSION = YES;
230 | 				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
231 | 				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
232 | 				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
233 | 				CLANG_WARN_EMPTY_BODY = YES;
234 | 				CLANG_WARN_ENUM_CONVERSION = YES;
235 | 				CLANG_WARN_INFINITE_RECURSION = YES;
236 | 				CLANG_WARN_INT_CONVERSION = YES;
237 | 				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
238 | 				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
239 | 				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
240 | 				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
241 | 				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
242 | 				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
243 | 				CLANG_WARN_STRICT_PROTOTYPES = YES;
244 | 				CLANG_WARN_SUSPICIOUS_MOVE = YES;
245 | 				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
246 | 				CLANG_WARN_UNREACHABLE_CODE = YES;
247 | 				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
248 | 				COPY_PHASE_STRIP = NO;
249 | 				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
250 | 				ENABLE_NS_ASSERTIONS = NO;
251 | 				ENABLE_STRICT_OBJC_MSGSEND = YES;
252 | 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
253 | 				GCC_C_LANGUAGE_STANDARD = gnu17;
254 | 				GCC_NO_COMMON_BLOCKS = YES;
255 | 				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
256 | 				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
257 | 				GCC_WARN_UNDECLARED_SELECTOR = YES;
258 | 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
259 | 				GCC_WARN_UNUSED_FUNCTION = YES;
260 | 				GCC_WARN_UNUSED_VARIABLE = YES;
261 | 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
262 | 				MACOSX_DEPLOYMENT_TARGET = 15.1;
263 | 				MTL_ENABLE_DEBUG_INFO = NO;
264 | 				MTL_FAST_MATH = YES;
265 | 				SDKROOT = macosx;
266 | 				SWIFT_COMPILATION_MODE = wholemodule;
267 | 				SWIFT_EMIT_LOC_STRINGS = YES;
268 | 			};
269 | 			name = Release;
270 | 		};
271 | 		6CCB4BB72CE7AAB200DAB8A2 /* Debug */ = {
272 | 			isa = XCBuildConfiguration;
273 | 			buildSettings = {
274 | 				ARCHS = "$(ARCHS_STANDARD)";
275 | 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
276 | 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
277 | 				ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
278 | 				CODE_SIGN_ENTITLEMENTS = TinyPNG4Mac/TinyPNG4Mac.entitlements;
279 | 				CODE_SIGN_IDENTITY = "Apple Development";
280 | 				"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
281 | 				CODE_SIGN_STYLE = Automatic;
282 | 				COMBINE_HIDPI_IMAGES = YES;
283 | 				CURRENT_PROJECT_VERSION = 10;
284 | 				DEVELOPMENT_ASSET_PATHS = "\"TinyPNG4Mac/Preview Content\"";
285 | 				DEVELOPMENT_TEAM = 3MUKZC576Z;
286 | 				ENABLE_HARDENED_RUNTIME = YES;
287 | 				ENABLE_PREVIEWS = YES;
288 | 				GENERATE_INFOPLIST_FILE = YES;
289 | 				INFOPLIST_FILE = TinyPNG4Mac/Info.plist;
290 | 				INFOPLIST_KEY_CFBundleDisplayName = "Tiny Image";
291 | 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
292 | 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
293 | 				LD_RUNPATH_SEARCH_PATHS = (
294 | 					"$(inherited)",
295 | 					"@executable_path/../Frameworks",
296 | 				);
297 | 				MACOSX_DEPLOYMENT_TARGET = 13;
298 | 				MARKETING_VERSION = 2.1.0;
299 | 				PRODUCT_BUNDLE_IDENTIFIER = com.kyleduo.app.TinyPNG4Mac;
300 | 				PRODUCT_NAME = "$(TARGET_NAME)";
301 | 				PROVISIONING_PROFILE_SPECIFIER = "";
302 | 				SWIFT_EMIT_LOC_STRINGS = YES;
303 | 				SWIFT_VERSION = 5.0;
304 | 			};
305 | 			name = Debug;
306 | 		};
307 | 		6CCB4BB82CE7AAB200DAB8A2 /* Release */ = {
308 | 			isa = XCBuildConfiguration;
309 | 			buildSettings = {
310 | 				ARCHS = "$(ARCHS_STANDARD)";
311 | 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
312 | 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
313 | 				ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
314 | 				CODE_SIGN_ENTITLEMENTS = TinyPNG4Mac/TinyPNG4Mac.entitlements;
315 | 				CODE_SIGN_IDENTITY = "Apple Development";
316 | 				"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
317 | 				CODE_SIGN_STYLE = Automatic;
318 | 				COMBINE_HIDPI_IMAGES = YES;
319 | 				CURRENT_PROJECT_VERSION = 10;
320 | 				DEVELOPMENT_ASSET_PATHS = "\"TinyPNG4Mac/Preview Content\"";
321 | 				DEVELOPMENT_TEAM = 3MUKZC576Z;
322 | 				ENABLE_HARDENED_RUNTIME = YES;
323 | 				ENABLE_PREVIEWS = YES;
324 | 				GENERATE_INFOPLIST_FILE = YES;
325 | 				INFOPLIST_FILE = TinyPNG4Mac/Info.plist;
326 | 				INFOPLIST_KEY_CFBundleDisplayName = "Tiny Image";
327 | 				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
328 | 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
329 | 				LD_RUNPATH_SEARCH_PATHS = (
330 | 					"$(inherited)",
331 | 					"@executable_path/../Frameworks",
332 | 				);
333 | 				MACOSX_DEPLOYMENT_TARGET = 13;
334 | 				MARKETING_VERSION = 2.1.0;
335 | 				ONLY_ACTIVE_ARCH = NO;
336 | 				PRODUCT_BUNDLE_IDENTIFIER = com.kyleduo.app.TinyPNG4Mac;
337 | 				PRODUCT_NAME = "$(TARGET_NAME)";
338 | 				PROVISIONING_PROFILE_SPECIFIER = "";
339 | 				SWIFT_EMIT_LOC_STRINGS = YES;
340 | 				SWIFT_VERSION = 5.0;
341 | 			};
342 | 			name = Release;
343 | 		};
344 | /* End XCBuildConfiguration section */
345 | 
346 | /* Begin XCConfigurationList section */
347 | 		6CCB4BA02CE7AAB100DAB8A2 /* Build configuration list for PBXProject "TinyPNG4Mac" */ = {
348 | 			isa = XCConfigurationList;
349 | 			buildConfigurations = (
350 | 				6CCB4BB42CE7AAB200DAB8A2 /* Debug */,
351 | 				6CCB4BB52CE7AAB200DAB8A2 /* Release */,
352 | 			);
353 | 			defaultConfigurationIsVisible = 0;
354 | 			defaultConfigurationName = Release;
355 | 		};
356 | 		6CCB4BB62CE7AAB200DAB8A2 /* Build configuration list for PBXNativeTarget "Tiny Image" */ = {
357 | 			isa = XCConfigurationList;
358 | 			buildConfigurations = (
359 | 				6CCB4BB72CE7AAB200DAB8A2 /* Debug */,
360 | 				6CCB4BB82CE7AAB200DAB8A2 /* Release */,
361 | 			);
362 | 			defaultConfigurationIsVisible = 0;
363 | 			defaultConfigurationName = Release;
364 | 		};
365 | /* End XCConfigurationList section */
366 | 
367 | /* Begin XCRemoteSwiftPackageReference section */
368 | 		6C1F98FC2CF3082800BF6501 /* XCRemoteSwiftPackageReference "Alamofire" */ = {
369 | 			isa = XCRemoteSwiftPackageReference;
370 | 			repositoryURL = "https://github.com/Alamofire/Alamofire";
371 | 			requirement = {
372 | 				kind = upToNextMajorVersion;
373 | 				minimumVersion = 5.10.1;
374 | 			};
375 | 		};
376 | /* End XCRemoteSwiftPackageReference section */
377 | 
378 | /* Begin XCSwiftPackageProductDependency section */
379 | 		6C1F98FD2CF3082800BF6501 /* Alamofire */ = {
380 | 			isa = XCSwiftPackageProductDependency;
381 | 			package = 6C1F98FC2CF3082800BF6501 /* XCRemoteSwiftPackageReference "Alamofire" */;
382 | 			productName = Alamofire;
383 | 		};
384 | /* End XCSwiftPackageProductDependency section */
385 | 	};
386 | 	rootObject = 6CCB4B9D2CE7AAB100DAB8A2 /* Project object */;
387 | }
388 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 | <?xml version="1.0" encoding="UTF-8"?>
2 | <Workspace
3 |    version = "1.0">
4 |    <FileRef
5 |       location = "self:">
6 |    </FileRef>
7 | </Workspace>
8 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
 1 | {
 2 |   "originHash" : "11b78eba97192d19796cff581fdf69b3e65b441188b1448a1b67e5d7b825a354",
 3 |   "pins" : [
 4 |     {
 5 |       "identity" : "alamofire",
 6 |       "kind" : "remoteSourceControl",
 7 |       "location" : "https://github.com/Alamofire/Alamofire",
 8 |       "state" : {
 9 |         "revision" : "e16d3481f5ed35f0472cb93350085853d754913f",
10 |         "version" : "5.10.1"
11 |       }
12 |     }
13 |   ],
14 |   "version" : 3
15 | }
16 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0xB9",
 9 |           "green" : "0x7B",
10 |           "red" : "0x3A"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.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 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_128x128.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_16x16.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_256x256.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_32x32.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_512x512.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 |   "info" : {
3 |     "author" : "xcode",
4 |     "version" : 1
5 |   }
6 | }
7 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/appIcon.imageset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "images" : [
 3 |     {
 4 |       "filename" : "icon_128x128.png",
 5 |       "idiom" : "mac",
 6 |       "scale" : "1x"
 7 |     },
 8 |     {
 9 |       "filename" : "icon_128x128@2x.png",
10 |       "idiom" : "mac",
11 |       "scale" : "2x"
12 |     }
13 |   ],
14 |   "info" : {
15 |     "author" : "xcode",
16 |     "version" : 1
17 |   }
18 | }
19 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/appIcon.imageset/icon_128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/appIcon.imageset/icon_128x128.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/appIcon.imageset/icon_128x128@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/appIcon.imageset/icon_128x128@2x.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/mainViewBackground.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0xAE",
 9 |           "green" : "0x73",
10 |           "red" : "0x35"
11 |         }
12 |       },
13 |       "idiom" : "mac"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0x49",
27 |           "green" : "0x2E",
28 |           "red" : "0x17"
29 |         }
30 |       },
31 |       "idiom" : "mac"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/placeholder.imageset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "images" : [
 3 |     {
 4 |       "filename" : "placeholder.png",
 5 |       "idiom" : "mac",
 6 |       "scale" : "1x"
 7 |     },
 8 |     {
 9 |       "appearances" : [
10 |         {
11 |           "appearance" : "luminosity",
12 |           "value" : "dark"
13 |         }
14 |       ],
15 |       "filename" : "placeholder-dark.png",
16 |       "idiom" : "mac",
17 |       "scale" : "1x"
18 |     },
19 |     {
20 |       "filename" : "placeholder@2x.png",
21 |       "idiom" : "mac",
22 |       "scale" : "2x"
23 |     },
24 |     {
25 |       "appearances" : [
26 |         {
27 |           "appearance" : "luminosity",
28 |           "value" : "dark"
29 |         }
30 |       ],
31 |       "filename" : "placeholder-dark@2x.png",
32 |       "idiom" : "mac",
33 |       "scale" : "2x"
34 |     }
35 |   ],
36 |   "info" : {
37 |     "author" : "xcode",
38 |     "version" : 1
39 |   }
40 | }
41 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/placeholder.imageset/placeholder-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/placeholder.imageset/placeholder-dark.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/placeholder.imageset/placeholder-dark@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/placeholder.imageset/placeholder-dark@2x.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/placeholder.imageset/placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/placeholder.imageset/placeholder.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/placeholder.imageset/placeholder@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/placeholder.imageset/placeholder@2x.png


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/settingViewBackground.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0xE1",
 9 |           "green" : "0xE1",
10 |           "red" : "0xE0"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0x1A",
27 |           "green" : "0x1A",
28 |           "red" : "0x19"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/settingViewBackgroundBorder.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0xDA",
 9 |           "green" : "0xDA",
10 |           "red" : "0xDA"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0x18",
27 |           "green" : "0x18",
28 |           "red" : "0x18"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/taskPreviewStroke.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "0.050",
 8 |           "blue" : "0x00",
 9 |           "green" : "0x00",
10 |           "red" : "0x00"
11 |         }
12 |       },
13 |       "idiom" : "mac"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "0.100",
26 |           "blue" : "0xFF",
27 |           "green" : "0xFF",
28 |           "red" : "0xFF"
29 |         }
30 |       },
31 |       "idiom" : "mac"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/taskRowBackground.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x9B",
 9 |           "green" : "0x65",
10 |           "red" : "0x2C"
11 |         }
12 |       },
13 |       "idiom" : "mac"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0x3B",
27 |           "green" : "0x24",
28 |           "red" : "0x11"
29 |         }
30 |       },
31 |       "idiom" : "mac"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/taskRowShadow.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "0.200",
 8 |           "blue" : "0x00",
 9 |           "green" : "0x00",
10 |           "red" : "0x00"
11 |         }
12 |       },
13 |       "idiom" : "mac"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "0.200",
26 |           "blue" : "0x00",
27 |           "green" : "0x00",
28 |           "red" : "0x00"
29 |         }
30 |       },
31 |       "idiom" : "mac"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/taskRowStroke.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x8A",
 9 |           "green" : "0x5A",
10 |           "red" : "0x27"
11 |         }
12 |       },
13 |       "idiom" : "mac"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "0.080",
26 |           "blue" : "0xFF",
27 |           "green" : "0xFF",
28 |           "red" : "0xFF"
29 |         }
30 |       },
31 |       "idiom" : "mac"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/textBody.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0xFF",
 9 |           "green" : "0xFF",
10 |           "red" : "0xFE"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "0.900",
26 |           "blue" : "0xFF",
27 |           "green" : "0xFF",
28 |           "red" : "0xFF"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/textBodyAbout.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "0.820",
 8 |           "blue" : "0x00",
 9 |           "green" : "0x00",
10 |           "red" : "0x00"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "0.900",
26 |           "blue" : "0xFF",
27 |           "green" : "0xFF",
28 |           "red" : "0xFF"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/textCaption.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "0.300",
 8 |           "blue" : "0xFF",
 9 |           "green" : "0xFF",
10 |           "red" : "0xFE"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "0.200",
26 |           "blue" : "0xFF",
27 |           "green" : "0xFF",
28 |           "red" : "0xFF"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/textGreen.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "0.800",
 8 |           "blue" : "0x93",
 9 |           "green" : "0xE6",
10 |           "red" : "0x42"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "0.800",
26 |           "blue" : "0x5E",
27 |           "green" : "0xAC",
28 |           "red" : "0x10"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/textMainTitle.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "0.900",
 8 |           "blue" : "0xFF",
 9 |           "green" : "0xFF",
10 |           "red" : "0xFF"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "0.900",
26 |           "blue" : "0xFF",
27 |           "green" : "0xFF",
28 |           "red" : "0xFF"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/textRed.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x22",
 9 |           "green" : "0x1D",
10 |           "red" : "0xE1"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0x18",
27 |           "green" : "0x18",
28 |           "red" : "0xD4"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/textSecondary.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "0.600",
 8 |           "blue" : "0xFF",
 9 |           "green" : "0xFF",
10 |           "red" : "0xFE"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "0.650",
26 |           "blue" : "0xFF",
27 |           "green" : "0xFF",
28 |           "red" : "0xFF"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Assets.xcassets/textSecondaryAbout.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "0.400",
 8 |           "blue" : "0x00",
 9 |           "green" : "0x00",
10 |           "red" : "0x00"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "0.650",
26 |           "blue" : "0xFF",
27 |           "green" : "0xFF",
28 |           "red" : "0xFF"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Info.plist:
--------------------------------------------------------------------------------
 1 | <?xml version="1.0" encoding="UTF-8"?>
 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 3 | <plist version="1.0">
 4 | <dict>
 5 | 	<key>CFBundleDocumentTypes</key>
 6 | 	<array>
 7 | 		<dict>
 8 | 			<key>CFBundleTypeName</key>
 9 | 			<string>Image Files</string>
10 | 			<key>CFBundleTypeRole</key>
11 | 			<string>Editor</string>
12 | 			<key>LSHandlerRank</key>
13 | 			<string>Default</string>
14 | 			<key>LSItemContentTypes</key>
15 | 			<array>
16 | 				<string>public.image</string>
17 | 			</array>
18 | 		</dict>
19 | 		<dict>
20 | 			<key>LSHandlerRank</key>
21 | 			<string>Default</string>
22 | 			<key>CFBundleTypeName</key>
23 | 			<string>Directory</string>
24 | 			<key>CFBundleTypeRole</key>
25 | 			<string>Editor</string>
26 | 			<key>LSItemContentTypes</key>
27 | 			<array>
28 | 				<string>public.folder</string>
29 | 			</array>
30 | 		</dict>
31 | 	</array>
32 | </dict>
33 | </plist>
34 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Localizable.xcstrings:
--------------------------------------------------------------------------------
  1 | {
  2 |   "sourceLanguage" : "en",
  3 |   "strings" : {
  4 |     "" : {
  5 |       "shouldTranslate" : false
  6 |     },
  7 |     "\"Save As Mode\" is selected. Please config the output directory first." : {
  8 |       "localizations" : {
  9 |         "zh-Hans" : {
 10 |           "stringUnit" : {
 11 |             "state" : "translated",
 12 |             "value" : "“另存为模式”已启用,请先设置输出目录"
 13 |           }
 14 |         }
 15 |       }
 16 |     },
 17 |     "\"Tiny Image\" (TinyPNG4Mac) is a 3rd-party client for [TinyPNG](https://tinypng.com)." : {
 18 |       "localizations" : {
 19 |         "zh-Hans" : {
 20 |           "stringUnit" : {
 21 |             "state" : "translated",
 22 |             "value" : "“Tiny Image” (TinyPNG4Mac) 是 [TinyPNG](https://tinypng.com) 的第三方客户端。"
 23 |           }
 24 |         }
 25 |       }
 26 |     },
 27 |     "[D]Clear output directory" : {
 28 |       "shouldTranslate" : false
 29 |     },
 30 |     "%lld" : {
 31 |       "shouldTranslate" : false
 32 |     },
 33 |     "%lld tasks, %@" : {
 34 |       "localizations" : {
 35 |         "en" : {
 36 |           "stringUnit" : {
 37 |             "state" : "new",
 38 |             "value" : "%1$lld tasks, %2$@"
 39 |           }
 40 |         },
 41 |         "zh-Hans" : {
 42 |           "stringUnit" : {
 43 |             "state" : "translated",
 44 |             "value" : "%1$lld 任务,%2$@ "
 45 |           }
 46 |         }
 47 |       }
 48 |     },
 49 |     "About Tiny Image" : {
 50 |       "localizations" : {
 51 |         "zh-Hans" : {
 52 |           "stringUnit" : {
 53 |             "state" : "translated",
 54 |             "value" : "关于 Tiny Image"
 55 |           }
 56 |         }
 57 |       }
 58 |     },
 59 |     "About..." : {
 60 |       "localizations" : {
 61 |         "zh-Hans" : {
 62 |           "stringUnit" : {
 63 |             "state" : "translated",
 64 |             "value" : "关于…"
 65 |           }
 66 |         }
 67 |       }
 68 |     },
 69 |     "All compressed images will be replaced with the origin file." : {
 70 |       "localizations" : {
 71 |         "zh-Hans" : {
 72 |           "stringUnit" : {
 73 |             "state" : "translated",
 74 |             "value" : "所有压缩完成的图片将会被恢复成原始文件。"
 75 |           }
 76 |         }
 77 |       }
 78 |     },
 79 |     "API key:" : {
 80 |       "shouldTranslate" : false
 81 |     },
 82 |     "Cancel" : {
 83 |       "localizations" : {
 84 |         "zh-Hans" : {
 85 |           "stringUnit" : {
 86 |             "state" : "translated",
 87 |             "value" : "取消"
 88 |           }
 89 |         }
 90 |       }
 91 |     },
 92 |     "Cancelled" : {
 93 |       "localizations" : {
 94 |         "zh-Hans" : {
 95 |           "stringUnit" : {
 96 |             "state" : "translated",
 97 |             "value" : "已取消"
 98 |           }
 99 |         }
100 |       }
101 |     },
102 |     "Clear all finished tasks" : {
103 |       "localizations" : {
104 |         "zh-Hans" : {
105 |           "stringUnit" : {
106 |             "state" : "translated",
107 |             "value" : "清除全部已结束任务"
108 |           }
109 |         }
110 |       }
111 |     },
112 |     "Clear all tasks" : {
113 |       "localizations" : {
114 |         "zh-Hans" : {
115 |           "stringUnit" : {
116 |             "state" : "translated",
117 |             "value" : "清除全部任务"
118 |           }
119 |         }
120 |       }
121 |     },
122 |     "Click to open: " : {
123 |       "localizations" : {
124 |         "zh-Hans" : {
125 |           "stringUnit" : {
126 |             "state" : "translated",
127 |             "value" : "点击打开:"
128 |           }
129 |         }
130 |       }
131 |     },
132 |     "Completed" : {
133 |       "localizations" : {
134 |         "zh-Hans" : {
135 |           "stringUnit" : {
136 |             "state" : "translated",
137 |             "value" : "已完成"
138 |           }
139 |         }
140 |       }
141 |     },
142 |     "Completed:" : {
143 |       "localizations" : {
144 |         "zh-Hans" : {
145 |           "stringUnit" : {
146 |             "state" : "translated",
147 |             "value" : "已完成:"
148 |           }
149 |         }
150 |       }
151 |     },
152 |     "Compress again" : {
153 |       "localizations" : {
154 |         "zh-Hans" : {
155 |           "stringUnit" : {
156 |             "state" : "translated",
157 |             "value" : "再次压缩"
158 |           }
159 |         }
160 |       }
161 |     },
162 |     "Concurrent tasks:" : {
163 |       "localizations" : {
164 |         "zh-Hans" : {
165 |           "stringUnit" : {
166 |             "state" : "translated",
167 |             "value" : "并行任务数量:"
168 |           }
169 |         }
170 |       }
171 |     },
172 |     "Confirm quit?" : {
173 |       "localizations" : {
174 |         "zh-Hans" : {
175 |           "stringUnit" : {
176 |             "state" : "translated",
177 |             "value" : "确认退出?"
178 |           }
179 |         }
180 |       }
181 |     },
182 |     "Confirm to restore all the images?" : {
183 |       "localizations" : {
184 |         "zh-Hans" : {
185 |           "stringUnit" : {
186 |             "state" : "translated",
187 |             "value" : "确认恢复所有图片?"
188 |           }
189 |         }
190 |       }
191 |     },
192 |     "Confirm to restore the image?" : {
193 |       "localizations" : {
194 |         "zh-Hans" : {
195 |           "stringUnit" : {
196 |             "state" : "translated",
197 |             "value" : "确认恢复图片?"
198 |           }
199 |         }
200 |       }
201 |     },
202 |     "Copyright" : {
203 |       "localizations" : {
204 |         "zh-Hans" : {
205 |           "stringUnit" : {
206 |             "state" : "translated",
207 |             "value" : "版权信息(Copyright)"
208 |           }
209 |         }
210 |       }
211 |     },
212 |     "Creation" : {
213 |       "localizations" : {
214 |         "zh-Hans" : {
215 |           "stringUnit" : {
216 |             "state" : "translated",
217 |             "value" : "创建者信息(Creation)"
218 |           }
219 |         }
220 |       }
221 |     },
222 |     "Disable \"Overwrite Mode\" after selecting the output directory." : {
223 |       "localizations" : {
224 |         "zh-Hans" : {
225 |           "stringUnit" : {
226 |             "state" : "translated",
227 |             "value" : "请选择输出目录后禁用“覆盖模式”"
228 |           }
229 |         }
230 |       }
231 |     },
232 |     "Downloading" : {
233 |       "localizations" : {
234 |         "zh-Hans" : {
235 |           "stringUnit" : {
236 |             "state" : "translated",
237 |             "value" : "下载中"
238 |           }
239 |         }
240 |       }
241 |     },
242 |     "Drop images or folders here!" : {
243 |       "localizations" : {
244 |         "zh-Hans" : {
245 |           "stringUnit" : {
246 |             "state" : "translated",
247 |             "value" : "拖拽图片或目录开始"
248 |           }
249 |         }
250 |       }
251 |     },
252 |     "Failed" : {
253 |       "localizations" : {
254 |         "zh-Hans" : {
255 |           "stringUnit" : {
256 |             "state" : "translated",
257 |             "value" : "失败"
258 |           }
259 |         }
260 |       }
261 |     },
262 |     "Failed to create output directory: %@, please re-select the output directory." : {
263 |       "localizations" : {
264 |         "zh-Hans" : {
265 |           "stringUnit" : {
266 |             "state" : "translated",
267 |             "value" : "创建输出目录 “%@” 失败,请重新设置输出目录。"
268 |           }
269 |         }
270 |       }
271 |     },
272 |     "Failed to save output directory" : {
273 |       "localizations" : {
274 |         "zh-Hans" : {
275 |           "stringUnit" : {
276 |             "state" : "translated",
277 |             "value" : "保存输出目录失败"
278 |           }
279 |         }
280 |       }
281 |     },
282 |     "File does not exists" : {
283 |       "localizations" : {
284 |         "zh-Hans" : {
285 |           "stringUnit" : {
286 |             "state" : "translated",
287 |             "value" : "文件不存在"
288 |           }
289 |         }
290 |       }
291 |     },
292 |     "Images compressed this month: %@" : {
293 |       "localizations" : {
294 |         "zh-Hans" : {
295 |           "stringUnit" : {
296 |             "state" : "translated",
297 |             "value" : "当月已压缩图片数量: %@"
298 |           }
299 |         }
300 |       }
301 |     },
302 |     "Location" : {
303 |       "localizations" : {
304 |         "zh-Hans" : {
305 |           "stringUnit" : {
306 |             "state" : "translated",
307 |             "value" : "位置信息(Location)"
308 |           }
309 |         }
310 |       }
311 |     },
312 |     "Made by [@kyleduo](https://github.com/kyleduo)  ❤︎  Open-sourced on [Github](https://github.com/kyleduo/TinyPNG4Mac)" : {
313 |       "localizations" : {
314 |         "zh-Hans" : {
315 |           "stringUnit" : {
316 |             "state" : "translated",
317 |             "value" : "由 [@kyleduo](https://github.com/kyleduo) 制作  ❤︎  在 [Github](https://github.com/kyleduo/TinyPNG4mac) 上开源"
318 |           }
319 |         }
320 |       }
321 |     },
322 |     "No write permission of output folder %@, please re-select the output directory." : {
323 |       "localizations" : {
324 |         "zh-Hans" : {
325 |           "stringUnit" : {
326 |             "state" : "translated",
327 |             "value" : "缺少输出目录 “%@” 的权限,请重新选择输出目录。"
328 |           }
329 |         }
330 |       }
331 |     },
332 |     "OK" : {
333 |       "localizations" : {
334 |         "zh-Hans" : {
335 |           "stringUnit" : {
336 |             "state" : "translated",
337 |             "value" : "好"
338 |           }
339 |         }
340 |       }
341 |     },
342 |     "Open Compressed Image" : {
343 |       "localizations" : {
344 |         "zh-Hans" : {
345 |           "stringUnit" : {
346 |             "state" : "translated",
347 |             "value" : "打开已压缩图片"
348 |           }
349 |         }
350 |       }
351 |     },
352 |     "Open Origin Image" : {
353 |       "localizations" : {
354 |         "zh-Hans" : {
355 |           "stringUnit" : {
356 |             "state" : "translated",
357 |             "value" : "打开原始图片"
358 |           }
359 |         }
360 |       }
361 |     },
362 |     "Output directory is not set yet, please select it in the settings window." : {
363 |       "localizations" : {
364 |         "zh-Hans" : {
365 |           "stringUnit" : {
366 |             "state" : "translated",
367 |             "value" : "输出目录尚未设置,请在设置窗口中设置。"
368 |           }
369 |         }
370 |       }
371 |     },
372 |     "Output directory:" : {
373 |       "localizations" : {
374 |         "zh-Hans" : {
375 |           "stringUnit" : {
376 |             "state" : "translated",
377 |             "value" : "输出目录:"
378 |           }
379 |         }
380 |       }
381 |     },
382 |     "Overwrite" : {
383 |       "extractionState" : "manual",
384 |       "localizations" : {
385 |         "zh-Hans" : {
386 |           "stringUnit" : {
387 |             "state" : "translated",
388 |             "value" : "覆盖"
389 |           }
390 |         }
391 |       }
392 |     },
393 |     "Overwrite Mode:\nThe compressed image will replace the original file. The original image is kept temporarily and can be restored before exit the app.\n\nSave As Mode:\nThe compressed image is saved as a new file, leaving the original image unchanged. You can choose where to save the compressed images." : {
394 |       "localizations" : {
395 |         "zh-Hans" : {
396 |           "stringUnit" : {
397 |             "state" : "translated",
398 |             "value" : "覆盖模式:\n压缩后的图像将替换原始文件。原始图像会暂时保留,并且可以在退出应用之前恢复。\n\n另存为模式:\n压缩后的图像会保存为一个新文件,原始图像不会被修改。你可以在下面选择压缩图像保存的位置。"
399 |           }
400 |         }
401 |       }
402 |     },
403 |     "Pending" : {
404 |       "localizations" : {
405 |         "zh-Hans" : {
406 |           "stringUnit" : {
407 |             "state" : "translated",
408 |             "value" : "排队中"
409 |           }
410 |         }
411 |       }
412 |     },
413 |     "Please select a different directory." : {
414 |       "localizations" : {
415 |         "zh-Hans" : {
416 |           "stringUnit" : {
417 |             "state" : "translated",
418 |             "value" : "请选择其他目录。"
419 |           }
420 |         }
421 |       }
422 |     },
423 |     "Please set the API key first." : {
424 |       "localizations" : {
425 |         "zh-Hans" : {
426 |           "stringUnit" : {
427 |             "state" : "translated",
428 |             "value" : "请先设置 API key"
429 |           }
430 |         }
431 |       }
432 |     },
433 |     "Preserve:" : {
434 |       "localizations" : {
435 |         "zh-Hans" : {
436 |           "stringUnit" : {
437 |             "state" : "translated",
438 |             "value" : "保留信息:"
439 |           }
440 |         }
441 |       }
442 |     },
443 |     "Processing" : {
444 |       "localizations" : {
445 |         "zh-Hans" : {
446 |           "stringUnit" : {
447 |             "state" : "translated",
448 |             "value" : "处理中"
449 |           }
450 |         }
451 |       }
452 |     },
453 |     "Quit" : {
454 |       "localizations" : {
455 |         "zh-Hans" : {
456 |           "stringUnit" : {
457 |             "state" : "translated",
458 |             "value" : "退出"
459 |           }
460 |         }
461 |       }
462 |     },
463 |     "Restore" : {
464 |       "localizations" : {
465 |         "zh-Hans" : {
466 |           "stringUnit" : {
467 |             "state" : "translated",
468 |             "value" : "恢复"
469 |           }
470 |         }
471 |       }
472 |     },
473 |     "Restore all compressed images" : {
474 |       "localizations" : {
475 |         "zh-Hans" : {
476 |           "stringUnit" : {
477 |             "state" : "translated",
478 |             "value" : "恢复全部已压缩图片"
479 |           }
480 |         }
481 |       }
482 |     },
483 |     "Restore Origin Image" : {
484 |       "localizations" : {
485 |         "zh-Hans" : {
486 |           "stringUnit" : {
487 |             "state" : "translated",
488 |             "value" : "恢复原始图片"
489 |           }
490 |         }
491 |       }
492 |     },
493 |     "Restored" : {
494 |       "localizations" : {
495 |         "zh-Hans" : {
496 |           "stringUnit" : {
497 |             "state" : "translated",
498 |             "value" : "已恢复"
499 |           }
500 |         }
501 |       }
502 |     },
503 |     "Retry all failed tasks" : {
504 |       "localizations" : {
505 |         "zh-Hans" : {
506 |           "stringUnit" : {
507 |             "state" : "translated",
508 |             "value" : "重试全部失败任务"
509 |           }
510 |         }
511 |       }
512 |     },
513 |     "Retry the task" : {
514 |       "localizations" : {
515 |         "zh-Hans" : {
516 |           "stringUnit" : {
517 |             "state" : "translated",
518 |             "value" : "重试任务"
519 |           }
520 |         }
521 |       }
522 |     },
523 |     "Reveal Compressed Image in Finder" : {
524 |       "localizations" : {
525 |         "zh-Hans" : {
526 |           "stringUnit" : {
527 |             "state" : "translated",
528 |             "value" : "打开压缩图片目录"
529 |           }
530 |         }
531 |       }
532 |     },
533 |     "Reveal Origin Image in Finder" : {
534 |       "localizations" : {
535 |         "zh-Hans" : {
536 |           "stringUnit" : {
537 |             "state" : "translated",
538 |             "value" : "打开已压缩图片目录"
539 |           }
540 |         }
541 |       }
542 |     },
543 |     "Save As" : {
544 |       "extractionState" : "manual",
545 |       "localizations" : {
546 |         "zh-Hans" : {
547 |           "stringUnit" : {
548 |             "state" : "translated",
549 |             "value" : "另存为"
550 |           }
551 |         }
552 |       }
553 |     },
554 |     "Save Mode:" : {
555 |       "localizations" : {
556 |         "zh-Hans" : {
557 |           "stringUnit" : {
558 |             "state" : "translated",
559 |             "value" : "保存模式:"
560 |           }
561 |         }
562 |       }
563 |     },
564 |     "Select output directory" : {
565 |       "localizations" : {
566 |         "zh-Hans" : {
567 |           "stringUnit" : {
568 |             "state" : "translated",
569 |             "value" : "选择输出目录"
570 |           }
571 |         }
572 |       }
573 |     },
574 |     "Select..." : {
575 |       "localizations" : {
576 |         "zh-Hans" : {
577 |           "stringUnit" : {
578 |             "state" : "translated",
579 |             "value" : "选择..."
580 |           }
581 |         }
582 |       }
583 |     },
584 |     "Supports WebP, PNG, and JPEG images." : {
585 |       "localizations" : {
586 |         "zh-Hans" : {
587 |           "stringUnit" : {
588 |             "state" : "translated",
589 |             "value" : "支持 WebP, PNG 和 JPEG 图片"
590 |           }
591 |         }
592 |       }
593 |     },
594 |     "Tasks" : {
595 |       "localizations" : {
596 |         "zh-Hans" : {
597 |           "stringUnit" : {
598 |             "state" : "translated",
599 |             "value" : "任务"
600 |           }
601 |         }
602 |       }
603 |     },
604 |     "The config is not ready" : {
605 |       "localizations" : {
606 |         "zh-Hans" : {
607 |           "stringUnit" : {
608 |             "state" : "translated",
609 |             "value" : "请先完成设置"
610 |           }
611 |         }
612 |       }
613 |     },
614 |     "The image at \"%@\" will be replaced with the origin file." : {
615 |       "localizations" : {
616 |         "zh-Hans" : {
617 |           "stringUnit" : {
618 |             "state" : "translated",
619 |             "value" : "\"%@\" 位置的图片将被替换成原始文件"
620 |           }
621 |         }
622 |       }
623 |     },
624 |     "The output directory does not exist. It will be automatically created after any task is completed." : {
625 |       "localizations" : {
626 |         "zh-Hans" : {
627 |           "stringUnit" : {
628 |             "state" : "translated",
629 |             "value" : "输出目录不存在。任意任务完成后会自动创建。"
630 |           }
631 |         }
632 |       }
633 |     },
634 |     "There are ongoing tasks. Quitting will cancel them all." : {
635 |       "localizations" : {
636 |         "zh-Hans" : {
637 |           "stringUnit" : {
638 |             "state" : "translated",
639 |             "value" : "有正在进行中的任务,退出应用会取消所有任务。"
640 |           }
641 |         }
642 |       }
643 |     },
644 |     "Tiny Image" : {
645 |       "localizations" : {
646 |         "zh-Hans" : {
647 |           "stringUnit" : {
648 |             "state" : "translated",
649 |             "value" : "Tiny Image"
650 |           }
651 |         }
652 |       }
653 |     },
654 |     "TinyPNG" : {
655 |       "shouldTranslate" : false
656 |     },
657 |     "TinyPNG holds the final right of interpretation regarding the image compression functionality and results." : {
658 |       "localizations" : {
659 |         "zh-Hans" : {
660 |           "stringUnit" : {
661 |             "state" : "translated",
662 |             "value" : "TinyPNG 保留图片压缩功能和压缩结果的最终解释权。"
663 |           }
664 |         }
665 |       }
666 |     },
667 |     "Total:" : {
668 |       "localizations" : {
669 |         "zh-Hans" : {
670 |           "stringUnit" : {
671 |             "state" : "translated",
672 |             "value" : "全部:"
673 |           }
674 |         }
675 |       }
676 |     },
677 |     "Uploading" : {
678 |       "localizations" : {
679 |         "zh-Hans" : {
680 |           "stringUnit" : {
681 |             "state" : "translated",
682 |             "value" : "上传中"
683 |           }
684 |         }
685 |       }
686 |     },
687 |     "Visit [https://tinypng.com/developers](https://tinypng.com/developers) to request an API key." : {
688 |       "localizations" : {
689 |         "zh-Hans" : {
690 |           "stringUnit" : {
691 |             "state" : "translated",
692 |             "value" : "访问 [https://tinypng.com/developers](https://tinypng.com/developers) 申请 API key。"
693 |           }
694 |         }
695 |       }
696 |     },
697 |     "When \"Save As Mode\" is enabled, the compressed image will be saved to this directory. If a file with the same name exists, it will be overwritten." : {
698 |       "localizations" : {
699 |         "zh-Hans" : {
700 |           "stringUnit" : {
701 |             "state" : "translated",
702 |             "value" : "当启用“另存为模式”时,压缩后的图像将保存到该目录。如果存在同名文件,将被覆盖。"
703 |           }
704 |         }
705 |       }
706 |     }
707 |   },
708 |   "version" : "1.0"
709 | }


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 |   "info" : {
3 |     "author" : "xcode",
4 |     "version" : 1
5 |   }
6 | }
7 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/TinyPNG4Mac.entitlements:
--------------------------------------------------------------------------------
1 | <?xml version="1.0" encoding="UTF-8"?>
2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 | <plist version="1.0">
4 | <dict/>
5 | </plist>
6 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/TinyPNG4MacApp.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  TinyPNG4MacApp.swift
  3 | //  TinyPNG4Mac
  4 | //
  5 | //  Created by kyleduo on 2024/11/16.
  6 | //
  7 | 
  8 | import SwiftData
  9 | import SwiftUI
 10 | 
 11 | @main
 12 | struct TinyPNG4MacApp: App {
 13 |     @Environment(\.openWindow) private var openWindow
 14 | 
 15 |     @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelgate
 16 |     @StateObject var appContext = AppContext.shared
 17 |     @StateObject var vm: MainViewModel = MainViewModel()
 18 |     @StateObject var debugVM: DebugViewModel = DebugViewModel.shared
 19 | 
 20 |     @State var firstAppear: Bool = true
 21 |     @State var lastTaskCount = 0
 22 | 
 23 |     var body: some Scene {
 24 |         Window("Tiny Image", id: "main") {
 25 |             MainContentView(vm: vm)
 26 |                 .frame(
 27 |                     minWidth: appContext.minSize.width,
 28 |                     idealWidth: appContext.minSize.width,
 29 |                     maxWidth: appContext.maxSize.width,
 30 |                     minHeight: appContext.minSize.height,
 31 |                     idealHeight: appContext.minSize.height
 32 |                 )
 33 |                 .onAppear {
 34 |                     if !firstAppear {
 35 |                         return
 36 |                     }
 37 |                     firstAppear = false
 38 | 
 39 |                     appDelgate.updateViewModel(vm: vm)
 40 |                 }
 41 |                 .environmentObject(appContext)
 42 |                 .environmentObject(debugVM)
 43 |         }
 44 |         .windowStyle(HiddenTitleBarWindowStyle())
 45 |         .windowResizability(.contentSize)
 46 |         .defaultSize(appContext.minSize)
 47 |         .commands {
 48 |             CommandGroup(replacing: CommandGroupPlacement.appInfo) {
 49 |                 Button(action: {
 50 |                     // Open the "about" window
 51 |                     openWindow(id: "about")
 52 |                 }, label: {
 53 |                     Text("About...")
 54 |                 })
 55 |             }
 56 |         }
 57 | 
 58 |         // Note the id "about" here
 59 |         Window("About Tiny Image", id: "about") {
 60 |             AboutView()
 61 |         }
 62 |         .windowResizability(.contentMinSize)
 63 | 
 64 |         Settings {
 65 |             SettingsView()
 66 |         }
 67 |     }
 68 | 
 69 |     func animateWindowFrame(_ window: NSWindow, newFrame: NSRect) {
 70 |         let animation = NSViewAnimation()
 71 |         animation.viewAnimations = [
 72 |             [
 73 |                 NSViewAnimation.Key.target: window,
 74 |                 NSViewAnimation.Key.startFrame: NSValue(rect: window.frame),
 75 |                 NSViewAnimation.Key.endFrame: NSValue(rect: newFrame),
 76 |             ],
 77 |         ]
 78 |         animation.duration = 0.3
 79 |         animation.animationCurve = .easeOut
 80 |         animation.start()
 81 |     }
 82 | }
 83 | 
 84 | class AppDelegate: NSObject, NSApplicationDelegate {
 85 |     private var vm: MainViewModel?
 86 | 
 87 |     private var openUrls: [URL]?
 88 |     private var appDidFinishLaunching = false
 89 | 
 90 |     func updateViewModel(vm: MainViewModel) {
 91 |         if self.vm == nil {
 92 |             self.vm = vm
 93 |         }
 94 |     }
 95 | 
 96 |     func applicationDidFinishLaunching(_ notification: Notification) {
 97 |         FileUtils.initPaths()
 98 | 
 99 |         if let window = NSApp.windows.first {
100 |             window.titleVisibility = .hidden
101 |             window.titlebarAppearsTransparent = true
102 |         }
103 | 
104 |         appDidFinishLaunching = true
105 | 
106 |         tryHandleOpenUrls()
107 |     }
108 | 
109 |     func application(_ application: NSApplication, open urls: [URL]) {
110 |         openUrls = urls
111 |         if appDidFinishLaunching {
112 |             DispatchQueue.main.async {
113 |                 self.tryHandleOpenUrls()
114 |             }
115 |         }
116 |     }
117 | 
118 |     func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
119 |         return false
120 |     }
121 | 
122 |     func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
123 |         guard let vm = vm else {
124 |             return .terminateNow
125 |         }
126 | 
127 |         if !vm.shouldTerminate() {
128 |             vm.showRunnningTasksAlert()
129 |             return .terminateCancel
130 |         } else {
131 |             return .terminateNow
132 |         }
133 |     }
134 | 
135 |     private func tryHandleOpenUrls() {
136 |         if let urls = openUrls {
137 |             openUrls = nil
138 |             let imageUrls = FileUtils.findImageFiles(urls: urls)
139 |             vm?.createTasks(imageURLs: imageUrls)
140 |         }
141 |     }
142 | }
143 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/app/AppConfig.swift:
--------------------------------------------------------------------------------
  1 | ////
  2 | //  AppConfig.swift
  3 | //  TinyPNG4Mac
  4 | //
  5 | //  Created by kyleduo on 2024/12/1.
  6 | //
  7 | 
  8 | import Foundation
  9 | 
 10 | class AppConfig {
 11 |     static let key_apiKey = "apikey"
 12 |     static let key_preserveCopyright = "preserveCopyright"
 13 |     static let key_preserveCreation = "preserveCreation"
 14 |     static let key_preserveLocation = "preserveLocation"
 15 |     static let key_concurrentTaskCount = "concurrentTaskCount"
 16 |     static let key_saveMode = "saveMode"
 17 |     static let key_outputDirectory = "outputDirectory"
 18 | 
 19 |     private static let key_migrated = "migrated"
 20 | 
 21 |     // Deprecated
 22 |     static let key_replaceMode = "replaceMode"
 23 |     // Deprecated
 24 |     private static let key_outputFilepathBookmark = "outputFilepathBookmark"
 25 | 
 26 |     private static let defaultOutputDirectoryName = "tinyimage_output"
 27 | 
 28 |     static let saveModeNameOverwrite = "Overwrite"
 29 |     static let saveModeNameSaveAs = "Save As"
 30 |     private static let defaultSaveModeName = saveModeNameSaveAs
 31 |     static let saveModeKeys = [
 32 |         saveModeNameOverwrite,
 33 |         saveModeNameSaveAs,
 34 |     ]
 35 | 
 36 |     private(set) var apiKey: String = ""
 37 |     private(set) var concurrentTaskCount: Int = 3
 38 |     private(set) var saveMode: String = saveModeNameOverwrite
 39 |     private(set) var outputDirectoryUrl: URL?
 40 |     private(set) var preserveCopyright: Bool = false
 41 |     private(set) var preserveCreation: Bool = false
 42 |     private(set) var preserveLocation: Bool = false
 43 | 
 44 |     private var hasMigrated = false
 45 | 
 46 |     init() {
 47 |         migrateDeprecatedKeys()
 48 | 
 49 |         update()
 50 |     }
 51 | 
 52 |     func isOverwriteMode() -> Bool {
 53 |         return saveMode == AppConfig.saveModeNameOverwrite
 54 |     }
 55 | 
 56 |     func isSaveAsMode() -> Bool {
 57 |         return saveMode == AppConfig.saveModeNameSaveAs
 58 |     }
 59 | 
 60 |     func update() {
 61 |         let ud = UserDefaults.standard
 62 | 
 63 |         let apiKey = ud.string(forKey: AppConfig.key_apiKey) ?? ""
 64 | 
 65 |         self.apiKey = apiKey
 66 | 
 67 |         let concurrentTaskCountValue = ud.integer(forKey: AppConfig.key_concurrentTaskCount)
 68 |         concurrentTaskCount = concurrentTaskCountValue > 0 ? concurrentTaskCountValue : 3
 69 | 
 70 |         preserveCopyright = ud.bool(forKey: AppConfig.key_preserveCopyright)
 71 |         preserveCreation = ud.bool(forKey: AppConfig.key_preserveCreation)
 72 |         preserveLocation = ud.bool(forKey: AppConfig.key_preserveLocation)
 73 | 
 74 |         if let saveMode = ud.string(forKey: AppConfig.key_saveMode) {
 75 |             self.saveMode = saveMode
 76 |         } else {
 77 |             saveMode = AppConfig.defaultSaveModeName
 78 |             ud.set(AppConfig.defaultSaveModeName, forKey: AppConfig.key_saveMode)
 79 |         }
 80 | 
 81 |         if let outputDirectoryUrl = ud.string(forKey: AppConfig.key_outputDirectory) {
 82 |             self.outputDirectoryUrl = URL(filePath: outputDirectoryUrl)
 83 |         } else {
 84 |             outputDirectoryUrl = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first?.appendingPathComponent(AppConfig.defaultOutputDirectoryName, isDirectory: true)
 85 |         }
 86 |     }
 87 | 
 88 |     private func migrateDeprecatedKeys() {
 89 |         if hasMigrated {
 90 |             return
 91 |         }
 92 | 
 93 |         let ud = UserDefaults.standard
 94 | 
 95 |         hasMigrated = ud.bool(forKey: AppConfig.key_migrated)
 96 |         if hasMigrated {
 97 |             return
 98 |         }
 99 | 
100 |         // save migrated
101 |         ud.set(true, forKey: AppConfig.key_migrated)
102 | 
103 |         // migrate key_replaceMode to key_saveMode
104 |         if ud.string(forKey: AppConfig.key_saveMode) == nil {
105 |             if ud.bool(forKey: AppConfig.key_replaceMode) {
106 |                 saveMode = AppConfig.saveModeNameOverwrite
107 |             } else {
108 |                 saveMode = AppConfig.saveModeNameSaveAs
109 |             }
110 |             ud.set(saveMode, forKey: AppConfig.key_saveMode)
111 |         }
112 |         ud.removeObject(forKey: AppConfig.key_replaceMode)
113 | 
114 |         if ud.string(forKey: AppConfig.key_outputDirectory) == nil {
115 |             let outputFolderBookmark = ud.data(forKey: AppConfig.key_outputFilepathBookmark)
116 |             if let outputFolderBookmark {
117 |                 var isStale = false
118 |                 do {
119 |                     let testURL = try URL(resolvingBookmarkData: outputFolderBookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
120 |                     outputDirectoryUrl = testURL
121 |                     if isStale {
122 |                         print("Bookmark is stale, consider updating it.")
123 |                     }
124 | 
125 |                     if testURL.startAccessingSecurityScopedResource() {
126 |                         print("Restored access to folder: \(testURL.path)")
127 |                     }
128 |                 } catch {
129 |                     print("Failed to restore folder access: \(error)")
130 |                 }
131 |             }
132 | 
133 |             if let outputDirectoryUrl = outputDirectoryUrl {
134 |                 ud.set(outputDirectoryUrl, forKey: AppConfig.key_outputDirectory)
135 |             }
136 |         }
137 |         ud.removeObject(forKey: AppConfig.key_outputFilepathBookmark)
138 |     }
139 | 
140 |     func saveBookmark(for folderURL: URL) throws {
141 |         let bookmark = try folderURL.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
142 |         UserDefaults.standard.set(bookmark, forKey: AppConfig.key_outputFilepathBookmark)
143 |     }
144 | 
145 |     func clearOutputFolder() {
146 |         UserDefaults.standard.removeObject(forKey: AppConfig.key_outputFilepathBookmark)
147 |         if let outputFolderUrl = outputDirectoryUrl {
148 |             outputFolderUrl.stopAccessingSecurityScopedResource()
149 |             outputDirectoryUrl = nil
150 |         }
151 |     }
152 | 
153 |     func needPreserveMetadata() -> Bool {
154 |         return preserveCopyright || preserveCreation || preserveLocation
155 |     }
156 | }
157 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/app/AppContext.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  AppContext.swift
 3 | //  TinyPNG4Mac
 4 | //
 5 | //  Created by kyleduo on 2024/11/16.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | class AppContext: ObservableObject {
11 |     static let shared = AppContext()
12 | 
13 |     let minSize = CGSize(width: 360, height: 440)
14 |     let maxSize = CGSize(width: 640, height: 640)
15 | 
16 |     var appConfig = AppConfig()
17 |     var isDebug: Bool {
18 |         #if DEBUG
19 |             true
20 |         #else
21 |             false
22 |         #endif
23 |     }
24 | }
25 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/client/TPClient.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  TPClient.swift
  3 | //  TinyPNG4Mac
  4 | //
  5 | //  Created by kyleduo on 2024/11/24.
  6 | //
  7 | 
  8 | import Alamofire
  9 | import Foundation
 10 | 
 11 | class TPClient {
 12 |     static let shared = TPClient()
 13 |     static let HEADER_COMPRESSION_COUNT = "Compression-Count"
 14 | 
 15 |     var apiKey: String {
 16 |         ProcessInfo.processInfo.environment["API_KEY"] ?? AppContext.shared.appConfig.apiKey
 17 |     }
 18 | 
 19 |     var maxConcurrencyCount: Int {
 20 |         AppContext.shared.appConfig.concurrentTaskCount
 21 |     }
 22 | 
 23 |     var mockEnabled = ProcessInfo.processInfo.environment["MOCK_ENABLED"] != nil
 24 | 
 25 |     var runningTasks = 0
 26 |     var callback: TPClientCallback?
 27 | 
 28 |     private var taskQueue = TPQueue<TaskInfo>()
 29 |     private let lock: NSLock = NSLock()
 30 | 
 31 |     private var currentRequests: [Request] = []
 32 | 
 33 |     func addTask(task: TaskInfo) {
 34 |         lock.withLock {
 35 |             if !taskQueue.contains(task) {
 36 |                 resetStatus(of: task)
 37 |                 taskQueue.enqueue(task)
 38 |             }
 39 |         }
 40 |         checkExecution()
 41 |     }
 42 | 
 43 |     func stopAllTask() {
 44 |         lock.withLock {
 45 |             currentRequests.forEach { request in
 46 |                 request.cancel()
 47 |             }
 48 |             currentRequests.removeAll()
 49 | 
 50 |             taskQueue.removeAll()
 51 |             runningTasks = 0
 52 |         }
 53 |     }
 54 | 
 55 |     private func checkExecution() {
 56 |         lock.withLock {
 57 |             while runningTasks < maxConcurrencyCount {
 58 |                 if let task = taskQueue.dequeue() {
 59 |                     runningTasks += 1
 60 |                     executeTask(task)
 61 |                 } else {
 62 |                     break
 63 |                 }
 64 |             }
 65 |         }
 66 |     }
 67 | 
 68 |     private func executeTask(_ task: TaskInfo) {
 69 |         do {
 70 |             guard let data = try? Data(contentsOf: task.originUrl) else {
 71 |                 print("error load image data")
 72 |                 return
 73 |             }
 74 | 
 75 |             let headers = requestHeaders()
 76 | 
 77 |             updateStatus(.uploading, of: task)
 78 | 
 79 |             if mockEnabled {
 80 |                 DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) {
 81 |                     self.updateStatus(.uploading, progress: 0.43237, of: task)
 82 |                 }
 83 | 
 84 |                 DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double.random(in: 0.8 ..< 1.5)) {
 85 |                     self.updateStatus(.processing, of: task)
 86 |                 }
 87 | 
 88 |                 DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2) {
 89 |                     self.updateStatus(.downloading, of: task)
 90 |                 }
 91 | 
 92 |                 DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) {
 93 |                     self.updateStatus(.downloading, progress: 0.331983218, of: task)
 94 |                 }
 95 | 
 96 |                 DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double.random(in: 5 ..< 7)) {
 97 |                     if Bool.random() {
 98 |                         self.completeTask(task, fileSizeFromResponse: 1028)
 99 |                     } else {
100 |                         self.failTask(task, error: TaskError.apiError(statusCode: 401, message: "Unauthorised. This custom implementation provides more control"))
101 |                     }
102 |                 }
103 |                 return
104 |             }
105 | 
106 |             let uploadRequest = AF.upload(data, to: TPAPI.shrink.rawValue, headers: headers)
107 |                 .uploadProgress { progress in
108 |                     if progress.fractionCompleted == 1 {
109 |                         self.updateStatus(.processing, of: task)
110 |                     } else {
111 |                         self.updateStatus(.uploading, progress: progress.fractionCompleted, of: task)
112 |                     }
113 |                 }
114 |             currentRequests.append(uploadRequest)
115 | 
116 |             uploadRequest.responseDecodable(of: TPShrinkResponse.self) { response in
117 |                 self.currentRequests.removeAll { $0.id == uploadRequest.id }
118 | 
119 |                 switch response.result {
120 |                 case let .success(responseData):
121 |                     if let usedQuota = Int(response.response?.value(forHTTPHeaderField: TPClient.HEADER_COMPRESSION_COUNT) ?? "") {
122 |                         self.updateUsedQuota(usedQuota)
123 |                     }
124 |                     if let output = responseData.output {
125 |                         self.downloadFile(task, response: output)
126 |                     } else if let error = responseData.error {
127 |                         let errorDescription = error + ": " + (responseData.message ?? "Unknown error")
128 |                         self.failTask(task, error: TaskError.apiError(statusCode: response.response?.statusCode ?? 0, message: errorDescription))
129 |                     } else {
130 |                         self.failTask(task, error: TaskError.apiError(statusCode: response.response?.statusCode ?? 0, message: "fail to parse response"))
131 |                     }
132 |                 case let .failure(error):
133 |                     self.failTask(task, error: TaskError.apiError(statusCode: response.response?.statusCode ?? 0, message: error.localizedDescription))
134 |                 }
135 |             }
136 |         }
137 |     }
138 | 
139 |     private func downloadFile(_ task: TaskInfo, response output: TPShrinkResponse.Output) {
140 |         guard let downloadUrl = task.downloadUrl else {
141 |             failTask(task)
142 |             return
143 |         }
144 | 
145 |         updateStatus(.downloading, progress: 0, of: task)
146 | 
147 |         let destination: DownloadRequest.Destination = { _, _ in
148 |             (downloadUrl, [.removePreviousFile])
149 |         }
150 | 
151 |         let downloadRequestBody = getDownloadRequestBody()
152 |         let request: DownloadRequest
153 | 
154 |         if !downloadRequestBody.isEmpty {
155 |             var req = URLRequest(url: URL(string: output.url)!)
156 |             req.httpMethod = HTTPMethod.post.rawValue
157 |             req.addValue(getAuthorization(), forHTTPHeaderField: "Authorization")
158 |             req.addValue("application/json", forHTTPHeaderField: "Content-Type")
159 |             req.httpBody = try? JSONSerialization.data(withJSONObject: downloadRequestBody, options: [])
160 | 
161 |             request = AF.download(req)
162 |         } else {
163 |             request = AF.download(output.url, to: destination)
164 |         }
165 | 
166 |         request.downloadProgress { progress in
167 |             print(progress)
168 |             self.updateStatus(.downloading, progress: progress.fractionCompleted, of: task)
169 |         }
170 |         request.validate()
171 | 
172 |         currentRequests.append(request)
173 | 
174 |         request.response { response in
175 |             self.currentRequests.removeAll { $0.id == request.id }
176 |             switch response.result {
177 |             case .success:
178 |                 do {
179 |                     guard let targetUrl = task.outputUrl else {
180 |                         throw FileError.noOutput
181 |                     }
182 | 
183 |                     let downloadedUrl: URL
184 |                     if !downloadRequestBody.isEmpty {
185 |                         try targetUrl.ensureDirectoryExists()
186 | 
187 |                         guard let afDownloadURL = response.fileURL else {
188 |                             throw FileError.notExists
189 |                         }
190 |                         downloadedUrl = afDownloadURL
191 |                     } else {
192 |                         downloadedUrl = downloadUrl
193 |                     }
194 | 
195 |                     try downloadedUrl.moveFileTo(targetUrl)
196 |                     if let filePermission = task.filePermission {
197 |                         targetUrl.setPosixPermissions(filePermission)
198 |                     }
199 |                     self.completeTask(task, fileSizeFromResponse: output.size)
200 |                 } catch {
201 |                     self.failTask(task, error: error)
202 |                 }
203 |             case let .failure(error):
204 |                 self.failTask(task, error: TaskError.apiError(statusCode: response.response?.statusCode ?? 0, message: error.localizedDescription))
205 |             }
206 |         }
207 |     }
208 | 
209 |     private func getDownloadRequestBody() -> [String: [String]] {
210 |         let config = AppContext.shared.appConfig
211 |         if !config.needPreserveMetadata() {
212 |             return [:]
213 |         }
214 | 
215 |         var preserveList: [String] = []
216 |         if config.preserveCopyright {
217 |             preserveList.append("copyright")
218 |         }
219 |         if config.preserveCreation {
220 |             preserveList.append("creation")
221 |         }
222 |         if config.preserveLocation {
223 |             preserveList.append("location")
224 |         }
225 |         if preserveList.isEmpty {
226 |             return [:]
227 |         }
228 |         return [
229 |             "preserve": preserveList,
230 |         ]
231 |     }
232 | 
233 |     private func requestHeaders() -> HTTPHeaders {
234 |         let authorization = getAuthorization()
235 | 
236 |         let headers: HTTPHeaders = [
237 |             .authorization(authorization),
238 |             .accept("application/json"),
239 |         ]
240 |         return headers
241 |     }
242 | 
243 |     private func getAuthorization() -> String {
244 |         let auth = "api:\(apiKey)"
245 |         let authData = auth.data(using: String.Encoding.utf8)?.base64EncodedString(options: NSData.Base64EncodingOptions.lineLength64Characters)
246 |         let authorization = "Basic " + authData!
247 |         return authorization
248 |     }
249 | 
250 |     private func completeTask(_ task: TaskInfo, fileSizeFromResponse: UInt64) {
251 |         let finalFileSize: UInt64
252 |         do {
253 |             finalFileSize = try task.outputUrl!.sizeOfFile()
254 |         } catch {
255 |             finalFileSize = fileSizeFromResponse
256 |         }
257 | 
258 |         task.status = .completed
259 |         task.finalSize = finalFileSize
260 |         notifyTaskUpdated(task)
261 | 
262 |         lock.withLock {
263 |             self.runningTasks -= 1
264 |         }
265 |         checkExecution()
266 |     }
267 | 
268 |     private func failTask(_ task: TaskInfo, error: Error? = nil) {
269 |         updateError(TaskError.from(error: error), of: task)
270 |         lock.withLock {
271 |             self.runningTasks -= 1
272 |         }
273 |         checkExecution()
274 |     }
275 | 
276 |     private func updateError(_ error: TaskError, of task: TaskInfo) {
277 |         task.status = .failed
278 |         task.error = error
279 |         notifyTaskUpdated(task)
280 |     }
281 | 
282 |     private func resetStatus(of task: TaskInfo) {
283 |         task.reset()
284 |         notifyTaskUpdated(task)
285 |     }
286 | 
287 |     private func updateStatus(_ status: TaskStatus, of task: TaskInfo) {
288 |         task.updateStatus(status)
289 |         notifyTaskUpdated(task)
290 |     }
291 | 
292 |     private func updateStatus(_ status: TaskStatus, progress: Double, of task: TaskInfo) {
293 |         task.updateStatus(status, progress: progress)
294 |         notifyTaskUpdated(task)
295 |     }
296 | 
297 |     private func updateUsedQuota(_ quota: Int) {
298 |         DispatchQueue.main.async {
299 |             self.callback?.onMonthlyUsedQuotaUpdated(quota: quota)
300 |         }
301 |     }
302 | 
303 |     private func notifyTaskUpdated(_ newTask: TaskInfo) {
304 |         DispatchQueue.main.async {
305 |             self.callback?.onTaskChanged(task: newTask)
306 |         }
307 |     }
308 | }
309 | 
310 | enum TPAPI: String {
311 |     case shrink = "https://api.tinify.com/shrink"
312 | }
313 | 
314 | protocol TPClientCallback {
315 |     func onTaskChanged(task: TaskInfo)
316 | 
317 |     func onMonthlyUsedQuotaUpdated(quota: Int)
318 | }
319 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/client/TPQueue.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  TPQueue.swift
 3 | //  tinypng
 4 | //
 5 | //  Created by kyle on 16/6/30.
 6 | //  Copyright © 2016年 kyleduo. All rights reserved.
 7 | //
 8 | 
 9 | import Foundation
10 | 
11 | struct TPQueue<Element: Equatable> {
12 |     private var queue: [Element] = []
13 | 
14 |     mutating func enqueue(_ object: Element) {
15 |         queue.append(object)
16 |     }
17 | 
18 |     mutating func dequeue() -> Element? {
19 |         guard !queue.isEmpty else { return nil }
20 |         return queue.removeFirst()
21 |     }
22 | 
23 |     func isEmpty() -> Bool {
24 |         return queue.isEmpty
25 |     }
26 | 
27 |     func peek() -> Element? {
28 |         return queue.first
29 |     }
30 | 
31 |     func size() -> Int {
32 |         return queue.count
33 |     }
34 |     
35 |     func contains(_ element: Element) -> Bool {
36 |         return queue.contains { e in
37 |             e == element
38 |         }
39 |     }
40 |     
41 |     mutating func removeAll() {
42 |         queue.removeAll()
43 |     }
44 | }
45 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/client/model/models.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  models.swift
 3 | //  TinyPNG4Mac
 4 | //
 5 | //  Created by kyleduo on 2024/11/24.
 6 | //
 7 | 
 8 | struct TPShrinkResponse: Decodable {
 9 |     struct Input: Decodable {
10 |         let size: UInt64
11 |         let type: String
12 |     }
13 | 
14 |     struct Output: Decodable {
15 |         let height: Int
16 |         let width: Int
17 |         let size: UInt64
18 |         let ratio: Float
19 |         let type: String
20 |         let url: String
21 |     }
22 | 
23 |     let input: Input?
24 |     let output: Output?
25 |     let error: String?
26 |     let message: String?
27 | }
28 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/model/Errors.swift:
--------------------------------------------------------------------------------
 1 | ////
 2 | //  Errors.swift
 3 | //  TinyPNG4Mac
 4 | //
 5 | //  Created by kyleduo on 2024/11/24.
 6 | //
 7 | 
 8 | protocol TPError: Error {
 9 |     var code: Int { get }
10 |     var message: String { get }
11 | }
12 | 
13 | enum TaskError: Error, Equatable {
14 |     case apiError(statusCode: Int, message: String)
15 |     case general(message: String)
16 | }
17 | 
18 | enum FileError: Error {
19 |     case notExists
20 |     case dstAlreadyExists
21 |     /// Output file path not found
22 |     case noOutput
23 | }
24 | 
25 | extension TaskError {
26 |     static func from(error: Error?) -> TaskError {
27 |         switch error {
28 |         case is TaskError:
29 |             error as! TaskError
30 |         case is FileError:
31 |             TaskError.general(message: "File error. \(error?.localizedDescription ?? "unknown")")
32 |         default:
33 |             TaskError.general(message: error?.localizedDescription ?? "unknown")
34 |         }
35 |     }
36 | 
37 |     static func from(message: String) -> TaskError {
38 |         TaskError.general(message: message)
39 |     }
40 | }
41 | 
42 | extension TaskError {
43 |     func displayText() -> String {
44 |         switch self {
45 |         case let .apiError(statusCode, message):
46 |             return "[\(statusCode)]: \(message)"
47 |         case let .general(message):
48 |             return message
49 |         }
50 |     }
51 | }
52 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/model/TaskInfo.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  ImageTask.swift
  3 | //  TinyPNG4Mac
  4 | //
  5 | //  Created by kyleduo on 2024/11/17.
  6 | //
  7 | import Foundation
  8 | import SwiftUI
  9 | 
 10 | /// Image compression task
 11 | class TaskInfo: Identifiable {
 12 |     var id: String
 13 |     var originUrl: URL
 14 |     var filePermission: Int?
 15 |     var previewImage: NSImage?
 16 |     var backupUrl: URL?
 17 |     var downloadUrl: URL?
 18 |     var outputUrl: URL?
 19 |     var status: TaskStatus
 20 |     /// in byte
 21 |     var originSize: UInt64?
 22 |     /// Compressed image size
 23 |     /// in byte
 24 |     var finalSize: UInt64?
 25 |     var error: TaskError?
 26 |     /// upload / download progress
 27 |     var progress: Double = 0
 28 | 
 29 |     init(
 30 |         id: String,
 31 |         originUrl: URL,
 32 |         status: TaskStatus,
 33 |         filePermission: Int? = nil,
 34 |         previewImage: NSImage? = nil,
 35 |         backupUrl: URL? = nil,
 36 |         downloadUrl: URL? = nil,
 37 |         originSize: UInt64? = nil,
 38 |         finalSize: UInt64? = nil,
 39 |         error: TaskError? = nil,
 40 |         progress: Double = 0
 41 |     ) {
 42 |         self.id = id
 43 |         self.originUrl = originUrl
 44 |         self.status = status
 45 |         self.filePermission = filePermission
 46 |         self.previewImage = previewImage
 47 |         self.backupUrl = backupUrl
 48 |         self.downloadUrl = downloadUrl
 49 |         self.originSize = originSize
 50 |         self.finalSize = finalSize
 51 |         self.error = error
 52 |         self.progress = progress
 53 |     }
 54 | 
 55 |     init(originUrl: URL, backupUrl: URL, downloadUrl: URL, outputUrl: URL, originSize: UInt64, filePermission: Int, previewImage: NSImage) {
 56 |         id = UUID().uuidString
 57 |         status = .created
 58 |         self.previewImage = previewImage
 59 |         self.originUrl = originUrl
 60 |         self.backupUrl = backupUrl
 61 |         self.downloadUrl = downloadUrl
 62 |         self.outputUrl = outputUrl
 63 |         self.originSize = originSize
 64 |         self.filePermission = filePermission
 65 |     }
 66 | 
 67 |     init(originUrl: URL) {
 68 |         id = UUID().uuidString
 69 |         self.originUrl = originUrl
 70 |         filePermission = nil
 71 |         backupUrl = nil
 72 |         downloadUrl = nil
 73 |         status = .created
 74 |         originSize = 0
 75 |         finalSize = 0
 76 |         previewImage = nil
 77 |     }
 78 | }
 79 | 
 80 | extension TaskInfo: CustomStringConvertible {
 81 |     var description: String {
 82 |         return "Task(id: \(id), status: \(status), originUrl: \(originUrl.path(percentEncoded: false))"
 83 |     }
 84 | }
 85 | 
 86 | extension TaskInfo: Equatable {
 87 |     static func == (lhs: TaskInfo, rhs: TaskInfo) -> Bool {
 88 |         return lhs.id == rhs.id &&
 89 |             lhs.originUrl == rhs.originUrl &&
 90 |             lhs.status == rhs.status &&
 91 |             lhs.progress == rhs.progress &&
 92 |             lhs.error == rhs.error
 93 |     }
 94 | }
 95 | 
 96 | extension TaskInfo {
 97 |     func updateError(error: TaskError) {
 98 |         status = .failed
 99 |         self.error = error
100 |     }
101 | 
102 |     func updateStatus(_ newStatus: TaskStatus, progress: Double? = nil) {
103 |         status = newStatus
104 |         if let progress {
105 |             self.progress = progress
106 |         }
107 |     }
108 | 
109 |     func reset() {
110 |         status = .created
111 |         error = nil
112 |         finalSize = nil
113 |         progress = 0
114 |     }
115 | }
116 | 
117 | extension TaskInfo: Comparable {
118 |     static func < (lhs: TaskInfo, rhs: TaskInfo) -> Bool {
119 |         // Define the precedence of each status
120 |         let precedence: [TaskStatus: Int] = [
121 |             .failed: 0,
122 |             .uploading: 1,
123 |             .processing: 1,
124 |             .downloading: 1,
125 |             .created: 2,
126 |             .cancelled: 3,
127 |             .restored: 4,
128 |             .completed: 5,
129 |         ]
130 | 
131 |         return precedence[lhs.status, default: Int.max] < precedence[rhs.status, default: Int.max]
132 |     }
133 | }
134 | 
135 | enum TaskStatus {
136 |     case created
137 |     case cancelled
138 |     case failed
139 |     case completed
140 |     case uploading
141 |     case processing
142 |     case downloading
143 |     case restored
144 | }
145 | 
146 | extension TaskStatus {
147 |     /// In status where considered is finished
148 |     func isFinished() -> Bool {
149 |         return self == .cancelled || self == .completed || self == .restored
150 |     }
151 | }
152 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/utils/AppUtils.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  AppUtils.swift
 3 | //  SubTracker
 4 | //
 5 | //  Created by kyleduo on 2024/4/7.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | struct AppUtils {
11 |     /**
12 |      * 是否在 Preview 模式
13 |      * @return true 是
14 |      */
15 |     static func isPreviewMode() -> Bool {
16 |         #if DEBUG
17 |             return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
18 |         #else
19 |             return false
20 |         #endif
21 |     }
22 | }
23 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/utils/Extensions.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Extensions.swift
 3 | //  TinyPNG4Mac
 4 | //
 5 | //  Created by kyleduo on 2024/11/23.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | extension UInt64 {
11 |     func formatBytes() -> String {
12 |         let units = ["B", "KB", "MB", "GB"]
13 |         var size = Double(self)
14 |         var unitIndex = 0
15 | 
16 |         // Keep dividing the size by 1024 to find the most suitable unit
17 |         while size >= 1024 && unitIndex < units.count - 1 {
18 |             size /= 1024
19 |             unitIndex += 1
20 |         }
21 |         
22 |         size = (size * 10).rounded() / 10
23 | 
24 |         // Return the formatted string with one decimal point
25 |         return String(format: "%.1f %@", size, units[unitIndex])
26 |     }
27 | }
28 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/utils/FileUtils.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  DocumentUtils.swift
  3 | //  TinyPNG4Mac
  4 | //
  5 | //  Created by kyleduo on 2024/11/16.
  6 | //
  7 | 
  8 | import UniformTypeIdentifiers
  9 | 
 10 | struct FileUtils {
 11 |     private static let fileManager = FileManager.default
 12 | 
 13 |     private static let sessionId = UUID().uuidString
 14 |     private static let cacheRootDir = getCachesDirectory()
 15 |     private static let sessionRootDir = cacheRootDir.appendingPathComponent(sessionId, isDirectory: true)
 16 | 
 17 |     private static var backupDir: URL = sessionRootDir.appendingPathComponent("backup", isDirectory: true)
 18 |     private static var downloadDir: URL = sessionRootDir.appendingPathComponent("download", isDirectory: true)
 19 | 
 20 |     static func initPaths() {
 21 |         let pathsToCheck = [
 22 |             cacheRootDir,
 23 |             sessionRootDir,
 24 |             backupDir,
 25 |             downloadDir,
 26 |         ]
 27 | 
 28 |         for path in pathsToCheck {
 29 |             do {
 30 |                 if !fileManager.fileExists(atPath: path.path) {
 31 |                     // Create the directory if it does not exist
 32 |                     try fileManager.createDirectory(at: path, withIntermediateDirectories: true, attributes: nil)
 33 |                 }
 34 |             } catch {
 35 |                 print("Error creating directory at \(path.path): \(error.localizedDescription)")
 36 |             }
 37 |         }
 38 | 
 39 |         if !AppUtils.isPreviewMode() {
 40 |             Task {
 41 |                 cleanSandboxCacheDir()
 42 |                 cleanPreviousSessions()
 43 |             }
 44 |         }
 45 |         
 46 |         DebugViewModel.shared.debugMessages.append("initPaths complete")
 47 |     }
 48 | 
 49 |     private static func cleanSandboxCacheDir() {
 50 |         guard let identifier = Bundle.main.bundleIdentifier else {
 51 |             return
 52 |         }
 53 | 
 54 |         let userHomeDir = FileManager.default.homeDirectoryForCurrentUser
 55 |         let sandboxRootDir = URL(filePath: userHomeDir.rawPath() + "Library/Containers/\(identifier)/")
 56 | 
 57 |         if sandboxRootDir.fileExists() {
 58 |             do {
 59 |                 try fileManager.removeItem(at: sandboxRootDir)
 60 |                 print("Clean up sandbox dir.")
 61 |             } catch {
 62 |                 print("Clean up sandbox dir error. \(error)")
 63 |             }
 64 |         }
 65 |     }
 66 | 
 67 |     private static func cleanPreviousSessions() {
 68 |         do {
 69 |             let otherSessionsDir = try fileManager.contentsOfDirectory(atPath: cacheRootDir.path(percentEncoded: false))
 70 |             for dir in otherSessionsDir {
 71 |                 let dirUrl = cacheRootDir.appendingPathComponent(dir)
 72 |                 if dirUrl.isSameFilePath(as: sessionRootDir) {
 73 |                     continue
 74 |                 }
 75 |                 let dirPath = dirUrl.path(percentEncoded: false)
 76 |                 try fileManager.removeItem(atPath: dirPath)
 77 |                 print("Delete previous session folder: \(dirPath)")
 78 |             }
 79 |         } catch {
 80 |             print("Error delete other session caches: \(error)")
 81 |         }
 82 |     }
 83 | 
 84 |     static func getBackupUrl(id: String) -> URL {
 85 |         return backupDir.appendingPathComponent(id)
 86 |     }
 87 | 
 88 |     static func getDownloadUrl(id: String) -> URL {
 89 |         return downloadDir.appendingPathComponent(id)
 90 |     }
 91 | 
 92 |     // Function to get the Application Support Directory
 93 |     private static func getAppSupportDirectory() -> URL? {
 94 |         let fileManager = FileManager.default
 95 |         do {
 96 |             let appSupportDir = try fileManager.url(
 97 |                 for: .applicationSupportDirectory,
 98 |                 in: .userDomainMask,
 99 |                 appropriateFor: nil,
100 |                 create: true
101 |             )
102 |             return appSupportDir
103 |         } catch {
104 |             print("Error getting Application Support Directory: \(error.localizedDescription)")
105 |             return nil
106 |         }
107 |     }
108 | 
109 |     private static func getCachesDirectory() -> URL {
110 | //        // If disable Sandbox mode, "FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)" will return the global root cache dir: ~/Library/Caches
111 |         let userCacheRootDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
112 |         let identifier = Bundle.main.bundleIdentifier ?? "com.kyleduo.app.TinyPNG4Mac"
113 |         let cacheRootDir = userCacheRootDir.appendingPathComponent(identifier).appendingPathComponent("Caches")
114 |         return cacheRootDir
115 |     }
116 | 
117 |     static func copyFile(sourcePath: String, targetPath: String, override: Bool = false) throws {
118 |         if fileManager.fileExists(atPath: sourcePath) {
119 |             if fileManager.fileExists(atPath: targetPath) {
120 |                 if override {
121 |                     try fileManager.removeItem(atPath: targetPath)
122 |                 } else {
123 |                     throw FileError.dstAlreadyExists
124 |                 }
125 |             }
126 |             try fileManager.copyItem(atPath: sourcePath, toPath: targetPath)
127 |         } else {
128 |             throw FileError.notExists
129 |         }
130 |     }
131 | 
132 |     static func exists(path: String) -> Bool {
133 |         return fileManager.fileExists(atPath: path)
134 |     }
135 | 
136 |     static func hasReadAndWritePermission(path: String) -> Bool {
137 |         return fileManager.isReadableFile(atPath: path) && fileManager.isWritableFile(atPath: path)
138 |     }
139 | 
140 |     static func getFileSize(path: String) throws -> UInt64 {
141 |         let attributes = try fileManager.attributesOfItem(atPath: path)
142 |         if let fileSize = attributes[.size] as? NSNumber {
143 |             return fileSize.uint64Value
144 |         } else {
145 |             return 0
146 |         }
147 |     }
148 | 
149 |     static func getFilePermission(path: String) -> Int? {
150 |         guard let attributes = try? fileManager.attributesOfItem(atPath: path) else {
151 |             return nil
152 |         }
153 |         if let filePermission = attributes[.posixPermissions] as? NSNumber {
154 |             return filePermission.intValue
155 |         } else {
156 |             return nil
157 |         }
158 |     }
159 | 
160 |     static func setFilePermission(_ permission: Int, to filePath: String) throws {
161 |         try fileManager.setAttributes([FileAttributeKey.posixPermissions: permission], ofItemAtPath: filePath)
162 |     }
163 | 
164 |     static func moveFile(_ src: URL, to dst: URL) throws {
165 |         if fileManager.fileExists(atPath: dst.path(percentEncoded: false)) {
166 |             try fileManager.removeItem(at: dst) // Remove the existing file
167 |             try fileManager.moveItem(at: src, to: dst)
168 |         } else {
169 |             try fileManager.moveItem(at: src, to: dst)
170 |         }
171 |     }
172 | 
173 |     /// Find all the valid image files recursively
174 |     static func findImageFiles(urls: [URL]) -> [URL: URL] {
175 |         var imageFiles: [URL: URL] = [:]
176 |         findImageFiles(urls: urls, originUrl: nil, result: &imageFiles)
177 |         return imageFiles
178 |     }
179 | 
180 |     private static func findImageFiles(urls: [URL], originUrl: URL?, result: inout [URL: URL]) {
181 |         let validExtensions = ["jpeg", "jpg", "png", "webp", "avif"]
182 | 
183 |         for url in urls {
184 |             if url.hasDirectoryPath {
185 |                 if let folderFiles = listAllFiles(from: url) {
186 |                     findImageFiles(urls: folderFiles, originUrl: originUrl ?? url, result: &result)
187 |                 }
188 |             } else if isValidImageFile(url, withExtensions: validExtensions) {
189 |                 if !result.contains(where: { key, _ in
190 |                     key.isSameFilePath(as: url)
191 |                 }) {
192 |                     result[url] = originUrl ?? url
193 |                 }
194 |             }
195 |         }
196 |     }
197 | 
198 |     private static func listAllFiles(from folderURL: URL) -> [URL]? {
199 |         let options: FileManager.DirectoryEnumerationOptions = [.skipsHiddenFiles, .skipsSubdirectoryDescendants]
200 | 
201 |         if let enumerator = fileManager.enumerator(at: folderURL, includingPropertiesForKeys: nil, options: options) {
202 |             return enumerator.compactMap { $0 as? URL }
203 |         }
204 | 
205 |         return nil
206 |     }
207 | 
208 |     private static func isValidImageFile(_ url: URL, withExtensions validExtensions: [String]) -> Bool {
209 |         let fileExtension = url.pathExtension.lowercased()
210 |         return validExtensions.contains(fileExtension)
211 |     }
212 | 
213 |     static func getRelocatedRelativePath(of file: URL, fromDir: URL, toDir: URL) -> URL? {
214 |         // the file is input as a single file, return
215 |         if file.isSameFilePath(as: fromDir) {
216 |             return nil
217 |         }
218 |         guard file.path.hasPrefix(fromDir.path) else {
219 |             return nil
220 |         }
221 |         let relativePath = file.rawPath().replacingOccurrences(of: fromDir.path, with: "")
222 |         let newFileURL = toDir
223 |             .appendingPathComponent(fromDir.lastPathComponent)
224 |             .appendingPathComponent(relativePath)
225 |         return newFileURL
226 |     }
227 | 
228 |     static func ensureDirectoryExist(file: URL) throws {
229 |         if file.hasDirectoryPath {
230 |             try fileManager.createDirectory(at: file, withIntermediateDirectories: true)
231 |         } else {
232 |             try fileManager.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
233 |         }
234 |     }
235 | }
236 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/utils/UIUtils.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UIUtils.swift
 3 | //  TinyPNG4Mac
 4 | //
 5 | //  Created by kyleduo on 2024/11/23.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct UIUtils {
11 |     static func colorFromHex(_ hex: String) -> Color {
12 |         var cleanedHex = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
13 | 
14 |         // Remove '#' if it's present
15 |         if cleanedHex.hasPrefix("#") {
16 |             cleanedHex.removeFirst()
17 |         }
18 | 
19 |         // Ensure the hex string is valid
20 |         guard cleanedHex.count == 6 || cleanedHex.count == 8 else {
21 |             return Color.gray // Return gray color for invalid hex
22 |         }
23 | 
24 |         // Add alpha value if not present (defaults to 1.0)
25 |         if cleanedHex.count == 6 {
26 |             cleanedHex += "FF" // Default alpha to full opacity
27 |         }
28 | 
29 |         // Extract RGB and alpha components from hex string
30 |         let scanner = Scanner(string: cleanedHex)
31 |         var hexInt: UInt64 = 0
32 |         if scanner.scanHexInt64(&hexInt) {
33 |             let red = Double((hexInt >> 24) & 0xFF) / 255.0
34 |             let green = Double((hexInt >> 16) & 0xFF) / 255.0
35 |             let blue = Double((hexInt >> 8) & 0xFF) / 255.0
36 |             let alpha = Double(hexInt & 0xFF) / 255.0
37 |             return Color(red: red, green: green, blue: blue, opacity: alpha)
38 |         }
39 | 
40 |         return Color.gray // Return gray color for invalid hex
41 |     }
42 | }
43 | 
44 | extension Color {
45 |     init(hex: String) {
46 |         self = UIUtils.colorFromHex(hex)
47 |     }
48 | }
49 | 
50 | extension View {
51 |     func padding(vertical: CGFloat, horizontal: CGFloat) -> some View {
52 |         return padding(EdgeInsets(top: vertical, leading: horizontal, bottom: vertical, trailing: horizontal))
53 |     }
54 | }
55 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/utils/URLUtils.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  URLUtils.swift
 3 | //  TinyPNG4Mac
 4 | //
 5 | //  Created by kyleduo on 2024/11/23.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | extension URL {
11 |     /// Returns a shortened path in the format `../xxx/yyy` or `../xxx` based on the URL path components.
12 |     /// - Returns: A shortened version of the path, following the provided rules.
13 |     func shortPath() -> String {
14 |         let pathComponents = self.pathComponents
15 | 
16 |         // Rule 1: Keep the last 2 path components, or just the last one if the length is too long.
17 |         let shortComponents: [String]
18 | 
19 |         if pathComponents.count > 2 {
20 |             let lastTwo = Array(pathComponents.suffix(2))
21 |             let formattedPath = lastTwo.joined(separator: "/")
22 |             // Rule 3: If the length exceeds 40 characters, return only the last path component
23 |             if formattedPath.count > 40 {
24 |                 shortComponents = [lastTwo.last ?? ""]
25 |             } else {
26 |                 shortComponents = lastTwo
27 |             }
28 |         } else {
29 |             // If there are fewer than 2 components, return the full path
30 |             shortComponents = pathComponents
31 |         }
32 | 
33 |         // Format the shortened path in `../xxx/yyy` style
34 |         return "../" + shortComponents.joined(separator: "/")
35 |     }
36 | 
37 |     func isSameFilePath(as other: URL) -> Bool {
38 |         // Standardize both URLs to remove trailing slashes
39 |         let standardizedSelf = standardized.path
40 |         let standardizedOther = other.standardized.path
41 | 
42 |         // Compare the paths ignoring any trailing slashes
43 |         return standardizedSelf.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ==
44 |             standardizedOther.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
45 |     }
46 | 
47 |     /// - Returns: path without percent  encoded
48 |     func rawPath() -> String {
49 |         path(percentEncoded: false)
50 |     }
51 | 
52 |     /// Check whether the file exists
53 |     func fileExists() -> Bool {
54 |         FileUtils.exists(path: rawPath())
55 |     }
56 | 
57 |     /// Make a copy of current file to `target` path
58 |     func copyFileTo(_ target: URL, override: Bool = false) throws {
59 |         try FileUtils.copyFile(sourcePath: rawPath(), targetPath: target.rawPath(), override: override)
60 |     }
61 | 
62 |     func moveFileTo(_ dst: URL) throws {
63 |         try FileUtils.moveFile(self, to: dst)
64 |     }
65 | 
66 |     func hasPermission() -> Bool {
67 |         FileUtils.hasReadAndWritePermission(path: rawPath())
68 |     }
69 | 
70 |     func sizeOfFile() throws -> UInt64 {
71 |         try FileUtils.getFileSize(path: rawPath())
72 |     }
73 | 
74 |     func posixPermissionsOfFile() -> Int? {
75 |         FileUtils.getFilePermission(path: rawPath())
76 |     }
77 | 
78 |     func setPosixPermissions(_ permissions: Int) {
79 |         do {
80 |             try? FileUtils.setFilePermission(permissions, to: rawPath())
81 |         }
82 |     }
83 | 
84 |     func ensureDirectoryExists() throws {
85 |         try FileUtils.ensureDirectoryExist(file: self)
86 |     }
87 | }
88 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/views/DebugView.swift:
--------------------------------------------------------------------------------
 1 | ////
 2 | //  DebugView.swift
 3 | //  TinyPNG4Mac
 4 | //
 5 | //  Created by kyleduo on 2025/1/12.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | /// Display debug messages only in debug mode
11 | struct DebugView: View {
12 |     @EnvironmentObject var appContext: AppContext
13 |     @EnvironmentObject var debugVM: DebugViewModel
14 | 
15 |     var body: some View {
16 |         if appContext.isDebug {
17 |             VStack(alignment: .trailing) {
18 |                 ForEach(debugVM.debugMessages, id: \.self) { msg in
19 |                     Text(msg)
20 |                 }
21 |                 Spacer()
22 |             }
23 |             .frame(maxWidth: .infinity, alignment: .trailing)
24 |             .padding(vertical: 32, horizontal: 16)
25 |         }
26 |     }
27 | }
28 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/views/DropFileView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  DropFileView.swift
 3 | //  TinyPNG4Mac
 4 | //
 5 | //  Created by kyleduo on 2024/11/16.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | import UniformTypeIdentifiers
10 | 
11 | struct DropFileView: View {
12 |     @Binding var dropResult: [URL: URL]
13 | 
14 |     var body: some View {
15 |         Rectangle()
16 |             .fill(Color.clear)
17 |             .cornerRadius(10)
18 |             .onDrop(of: [.fileURL], isTargeted: nil) { providers in
19 |                 handleDrop(providers: providers)
20 |             }
21 |     }
22 | 
23 |     private func handleDrop(providers: [NSItemProvider]) -> Bool {
24 |         var ret: Bool = false
25 |         var urls: [URL] = Array()
26 |         let group = DispatchGroup() // To wait for all asynchronous calls
27 | 
28 |         for provider in providers {
29 |             if !provider.canLoadObject(ofClass: URL.self) {
30 |                 continue
31 |             }
32 |             group.enter()
33 |             let _ = provider.loadObject(ofClass: URL.self) { item, _ in
34 |                 if let url = item {
35 |                     urls.append(url)
36 |                 }
37 |                 group.leave()
38 |             }
39 |             ret = true
40 |         }
41 | 
42 |         group.notify(queue: .main) {
43 |             let imageUrls = FileUtils.findImageFiles(urls: urls)
44 |             dropResult = imageUrls
45 |         }
46 | 
47 |         return ret
48 |     }
49 | }
50 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/views/HorizontalDivider.swift:
--------------------------------------------------------------------------------
 1 | ////
 2 | //  Divider.swift
 3 | //  TinyPNG4Mac
 4 | //
 5 | //  Created by kyleduo on 2024/11/27.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct HorizontalDivider: View {
11 |     var body: some View {
12 |         VStack(spacing: 0) {
13 |             Rectangle()
14 |                 .fill(Color.black.opacity(0.14))
15 |                 .frame(height: 1)
16 |             Rectangle()
17 |                 .fill(Color.white.opacity(0.06))
18 |                 .frame(height: 1)
19 |         }
20 |         .frame(maxWidth: .infinity)
21 |     }
22 | }
23 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/views/TaskRowView.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  TaskRowView.swift
  3 | //  TinyPNG4Mac
  4 | //
  5 | //  Created by kyleduo on 2024/11/23.
  6 | //
  7 | 
  8 | import AppKit
  9 | import SwiftUI
 10 | 
 11 | struct TaskRowView: View {
 12 |     private let rowPadding: CGFloat = 8
 13 |     private let imageSize: CGFloat = 60
 14 | 
 15 |     @ObservedObject var vm: MainViewModel
 16 |     @Binding var task: TaskInfo
 17 |     var last: Bool
 18 | 
 19 |     @State var titleUnderline: Bool = false
 20 | 
 21 |     var body: some View {
 22 |         VStack(spacing: 0) {
 23 |             HStack(alignment: .top, spacing: 6) {
 24 |                 let uiImage = task.previewImage ?? NSImage(named: "placeholder")!
 25 | 
 26 |                 Image(nsImage: uiImage)
 27 |                     .resizable()
 28 |                     .scaledToFill()
 29 |                     .frame(width: imageSize, height: imageSize)
 30 |                     .clipShape(RoundedRectangle(cornerRadius: 4))
 31 |                     .overlay {
 32 |                         RoundedRectangle(cornerRadius: 4)
 33 |                             .stroke(Color("taskPreviewStroke"), lineWidth: 1)
 34 |                     }
 35 |            
 36 |                 VStack {
 37 |                     VStack(alignment: .leading, spacing: 2) {
 38 |                         // File path + Menu
 39 |                         HStack {
 40 |                             Text(task.originUrl.shortPath())
 41 |                                 .font(.system(size: 12, weight: .medium))
 42 |                                 .foregroundStyle(Color("textBody"))
 43 |                                 .underline(titleUnderline)
 44 |                                 .lineLimit(1)
 45 |                                 .truncationMode(.head)
 46 |                                 .frame(maxWidth: .infinity, alignment: .leading)
 47 |                                 .onTapGesture {
 48 |                                     NSWorkspace.shared.open(task.originUrl)
 49 |                                 }
 50 |                                 .onHover { hover in
 51 |                                     titleUnderline = hover
 52 |                                 }
 53 |                             
 54 |                             Menu {
 55 |                                 Button {
 56 |                                     NSWorkspace.shared.open(task.originUrl)
 57 |                                 } label: {
 58 |                                     Text("Open Origin Image")
 59 |                                 }
 60 | 
 61 |                                 Button {
 62 |                                     NSWorkspace.shared.open(task.originUrl.deletingLastPathComponent())
 63 |                                 } label: {
 64 |                                     Text("Reveal Origin Image in Finder")
 65 |                                 }
 66 | 
 67 |                                 Divider()
 68 | 
 69 |                                 if task.status == .restored {
 70 |                                     Button {
 71 |                                         vm.retry(task)
 72 |                                     } label: {
 73 |                                         Text("Compress again")
 74 |                                     }
 75 |                                 }
 76 | 
 77 |                                 Button {
 78 |                                     NSWorkspace.shared.open(task.outputUrl!)
 79 |                                 } label: {
 80 |                                     Text("Open Compressed Image")
 81 |                                 }
 82 |                                 .disabled(task.status != .completed)
 83 | 
 84 |                                 Button {
 85 |                                     NSWorkspace.shared.open(task.outputUrl!.deletingLastPathComponent())
 86 |                                 } label: {
 87 |                                     Text("Reveal Compressed Image in Finder")
 88 |                                 }
 89 | 
 90 |                                 Divider()
 91 | 
 92 |                                 Button {
 93 |                                     vm.restore(task)
 94 |                                 } label: {
 95 |                                     Text("Restore Origin Image")
 96 |                                 }
 97 |                                 .disabled(task.status != .completed)
 98 |                             } label: {
 99 |                                 Image(systemName: "ellipsis.circle.fill")
100 |                                     .font(.system(size: 12, weight: .medium))
101 |                                     .frame(width: 20, height: 20)
102 |                             }
103 |                             .menuStyle(.borderlessButton)
104 |                             .menuIndicator(.hidden)
105 |                             .frame(width: 20, height: 20)
106 |                             .tint(Color("textSecondary"))
107 |                         }
108 | 
109 |                         // File size
110 |                         HStack(alignment: .center, spacing: 4) {
111 |                             Text(task.originSize?.formatBytes() ?? "NaN")
112 |                                 .font(.system(size: 10, weight: .light))
113 |                                 .foregroundStyle(Color("textCaption"))
114 | 
115 |                             if let finalSize = task.finalSize, task.status == .completed {
116 |                                 Image(systemName: "arrow.forward")
117 |                                     .font(.system(size: 10, weight: .light))
118 |                                     .foregroundStyle(Color.white.opacity(0.3))
119 | 
120 |                                 Text(finalSize.formatBytes())
121 |                                     .font(.system(size: 10, weight: .regular))
122 |                                     .foregroundStyle(Color("textSecondary"))
123 |                             }
124 |                         }
125 |                     }
126 | 
127 |                     Spacer()
128 |                         .frame(minHeight: 0)
129 | 
130 |                     HStack(spacing: 2) {
131 |                         if task.status == .completed {
132 |                             Button {
133 |                                 NSWorkspace.shared.open(task.outputUrl!.deletingLastPathComponent())
134 |                             } label: {
135 |                                 Image(systemName: "folder.circle.fill")
136 |                                     .font(.system(size: 13, weight: .medium))
137 |                                     .foregroundColor(Color("textSecondary"))
138 |                             }
139 |                             .buttonStyle(BorderlessButtonStyle())
140 |                             .help("Reveal Compressed Image in Finder")
141 |                         }
142 |                         
143 |                         Spacer()
144 | 
145 |                         Text(task.statusText())
146 |                             .font(.system(size: 12, weight: statusTextWeight(task.status)))
147 |                             .foregroundStyle(statusTextColor(task.status))
148 |                     }
149 |                     .frame(height: 16)
150 |                     .padding(.trailing, 2)
151 |                 }
152 |             }
153 |             .padding(rowPadding)
154 |             .frame(height: imageSize + rowPadding * 2)
155 |             .frame(maxWidth: .infinity, alignment: .leading)
156 | 
157 |             if task.status == .failed {
158 |                 HorizontalDivider()
159 |                     .padding(vertical: 0, horizontal: rowPadding)
160 |                     .frame(height: 2)
161 | 
162 |                 HStack {
163 |                     let errorText = "Error: \(task.error?.displayText() ?? "unknown")"
164 |                     Text(errorText)
165 |                         .font(.system(size: 12))
166 |                         .foregroundStyle(Color("textSecondary"))
167 |                         .lineLimit(4)
168 |                         .multilineTextAlignment(.leading)
169 |                         .frame(maxWidth: .infinity, alignment: .leading)
170 | 
171 |                     Button {
172 |                         vm.retry(task)
173 |                     } label: {
174 |                         Image(systemName: "arrow.counterclockwise.circle.fill")
175 |                             .font(.system(size: 12, weight: .medium))
176 |                             .foregroundStyle(Color("textSecondary"))
177 |                             .frame(width: 20, height: 20)
178 |                     }
179 |                     .buttonStyle(BorderlessButtonStyle())
180 |                     .help("Retry the task")
181 |                 }
182 |                 .padding(.leading, rowPadding)
183 |                 .padding(.trailing, rowPadding)
184 |                 .padding(.bottom, rowPadding)
185 |                 .padding(.top, 8)
186 |             }
187 |         }
188 |         .background(
189 |             RoundedRectangle(cornerRadius: 8)
190 |                 .fill(Color("taskRowBackground"))
191 |                 .overlay {
192 |                     RoundedRectangle(cornerRadius: 8)
193 |                         .stroke(Color("taskRowStroke"), lineWidth: 1)
194 |                 }
195 |         )
196 |         .shadow(color: Color("taskRowShadow"), radius: 4, x: 0, y: 2)
197 |         .padding(.leading, 4)
198 |         .padding(.trailing, 4)
199 |         .padding(.top, 4)
200 |         .padding(.bottom, last ? 12 : 6)
201 |     }
202 | 
203 |     func statusTextColor(_ status: TaskStatus) -> Color {
204 |         switch status {
205 |         case .failed:
206 |             Color("textRed")
207 |         case .cancelled:
208 |             Color("textCaption")
209 |         case .completed:
210 |             Color("textGreen")
211 |         default:
212 |             Color("textSecondary")
213 |         }
214 |     }
215 | 
216 |     func statusTextWeight(_ status: TaskStatus) -> Font.Weight {
217 |         switch status {
218 |         case .completed:
219 |             .medium
220 |         default:
221 |             .regular
222 |         }
223 |     }
224 | }
225 | 
226 | extension TaskInfo {
227 |     fileprivate func statusText() -> String {
228 |         if (status == .uploading || status == .downloading) && progress > 0 {
229 |             status.displayText() + " (\(formatedProgress()))"
230 |         } else {
231 |             status.displayText()
232 |         }
233 |     }
234 | 
235 |     private func formatedProgress() -> String {
236 |         let formatter = NumberFormatter()
237 |         formatter.numberStyle = .percent
238 |         formatter.maximumFractionDigits = 1
239 |         formatter.minimumFractionDigits = 0
240 |         return formatter.string(from: NSNumber(value: progress)) ?? "\(progress)%"
241 |     }
242 | }
243 | 
244 | extension TaskStatus {
245 |     fileprivate func displayText() -> String {
246 |         switch self {
247 |         case .created:
248 |             String(localized: "Pending")
249 |         case .cancelled:
250 |             String(localized: "Cancelled")
251 |         case .failed:
252 |             String(localized: "Failed")
253 |         case .completed:
254 |             String(localized: "Completed")
255 |         case .uploading:
256 |             String(localized: "Uploading")
257 |         case .processing:
258 |             String(localized: "Processing")
259 |         case .downloading:
260 |             String(localized: "Downloading")
261 |         case .restored:
262 |             String(localized: "Restored")
263 |         }
264 |     }
265 | }
266 | 
267 | // #Preview {
268 | //     TaskRowView(vm: MainViewModel(), task: Binding(get: {
269 | //         TaskInfo(originUrl: URL(filePath: "/Users"))
270 | //     }, set: { _ in
271 | //         
272 | //     }), last: false)
273 | //     .frame(height: 76)
274 | // }
275 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/views/settings/SettingsItem.swift:
--------------------------------------------------------------------------------
 1 | ////
 2 | //  SettingsItem.swift
 3 | //  TinyPNG4Mac
 4 | //
 5 | //  Created by kyleduo on 2024/12/1.
 6 | //
 7 | 
 8 | 
 9 | import SwiftUI
10 | 
11 | struct SettingsItem<Content: View>: View {
12 |     var title: LocalizedStringKey
13 |     var desc: LocalizedStringKey?
14 |     @ViewBuilder var content: () -> Content
15 |     
16 |     var body: some View {
17 |         HStack(alignment: .top) {
18 |             Text(title)
19 |                 .frame(width: 120, alignment: .leading)
20 | 
21 |             VStack(alignment: .leading) {
22 |                 content()
23 | 
24 |                 if let desc = self.desc {
25 |                     Text(desc)
26 |                         .font(.system(size: 10))
27 |                         .padding(.bottom, 8)
28 |                         .padding(.top, 2)
29 |                 }
30 |             }
31 |             .frame(maxWidth: 320, alignment: .leading)
32 |         }
33 |         .padding(.leading, 8)
34 |         .padding(.bottom, 8)
35 |     }
36 | }
37 | 
38 | 
39 | //#Preview {
40 | //    SettingsItem(title: "Preserve:", desc: "") {
41 | //        VStack(alignment: .leading) {
42 | //            Toggle("Copyright", isOn: Binding(get: { false }, set: { _ in }))
43 | //            Toggle("Creation", isOn: Binding(get: { false }, set: { _ in }))
44 | //            Toggle("Location", isOn: Binding(get: { false }, set: { _ in }))
45 | //        }
46 | //    }
47 | //}
48 | 
49 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/vms/DebugViewModel.swift:
--------------------------------------------------------------------------------
 1 | ////
 2 | //  DebugViewModel.swift
 3 | //  TinyPNG4Mac
 4 | //
 5 | //  Created by kyleduo on 2025/1/12.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | class DebugViewModel: ObservableObject {
11 |     static let shared = DebugViewModel()
12 | 
13 |     @Published var debugMessages: [String] = []
14 | }
15 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/vms/MainViewModel.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  MainViewModel.swift
  3 | //  TinyPNG4Mac
  4 | //
  5 | //  Created by kyleduo on 2024/11/17.
  6 | //
  7 | 
  8 | import SwiftUI
  9 | import UniformTypeIdentifiers
 10 | 
 11 | class MainViewModel: ObservableObject, TPClientCallback {
 12 |     @Published var tasks: [TaskInfo] = []
 13 |     @Published var monthlyUsedQuota: Int = -1
 14 |     @Published var restoreConfirmTask: TaskInfo?
 15 |     @Published var settingsNotReadyMessage: String? = nil
 16 |     @Published var showQuitWithRunningTasksAlert: Bool = false
 17 | 
 18 |     var totalOriginSize: UInt64 {
 19 |         tasks.reduce(0) { partialResult, task in
 20 |             partialResult + (task.originSize ?? 0)
 21 |         }
 22 |     }
 23 | 
 24 |     var totalFinalSize: UInt64 {
 25 |         tasks.filter { $0.status == .completed }
 26 |             .reduce(0) { partialResult, task in
 27 |                 partialResult + (task.finalSize ?? 0)
 28 |             }
 29 |     }
 30 | 
 31 |     var completedTaskCount: Int {
 32 |         tasks.count { $0.status == .completed }
 33 |     }
 34 | 
 35 |     init() {
 36 |         TPClient.shared.callback = self
 37 |     }
 38 | 
 39 |     var failedTaskCount: Int {
 40 |         tasks.count { $0.status == .failed }
 41 |     }
 42 | 
 43 |     func createTasks(imageURLs: [URL: URL]) {
 44 |         if !validateSettingsBeforeStartTask() {
 45 |             return
 46 |         }
 47 | 
 48 |         Task {
 49 |             for (url, inputUrl) in imageURLs {
 50 |                 let originUrl = url
 51 | 
 52 |                 let exist = tasks.contains(where: { task in
 53 |                     task.originUrl == originUrl && !task.status.isFinished()
 54 |                 })
 55 | 
 56 |                 if exist {
 57 |                     continue
 58 |                 }
 59 | 
 60 |                 if !originUrl.fileExists() {
 61 |                     let task = TaskInfo(originUrl: originUrl)
 62 |                     task.updateError(error: TaskError.from(message: String(localized: "File does not exists")))
 63 |                     appendTask(task: task)
 64 |                     continue
 65 |                 }
 66 | 
 67 |                 let uuid = UUID().uuidString
 68 | 
 69 |                 let backupUrl = FileUtils.getBackupUrl(id: uuid)
 70 |                 do {
 71 |                     try originUrl.copyFileTo(backupUrl)
 72 |                 } catch {
 73 |                     let task = TaskInfo(originUrl: originUrl)
 74 |                     task.updateError(error: TaskError.from(error: error))
 75 |                     appendTask(task: task)
 76 |                     continue
 77 |                 }
 78 | 
 79 |                 let downloadUrl = FileUtils.getDownloadUrl(id: uuid)
 80 |                 let previewImage = loadImagePreviewUsingCGImageSource(from: originUrl, maxDimension: 200)
 81 | 
 82 |                 let fileSize: UInt64
 83 |                 do {
 84 |                     fileSize = try originUrl.sizeOfFile()
 85 |                 } catch {
 86 |                     let task = TaskInfo(originUrl: originUrl)
 87 |                     task.updateError(error: TaskError.from(error: error))
 88 |                     appendTask(task: task)
 89 |                     continue
 90 |                 }
 91 | 
 92 |                 let outputUrl: URL
 93 |                 if AppContext.shared.appConfig.isOverwriteMode() {
 94 |                     outputUrl = originUrl
 95 |                 } else if let outputFolderUrl = AppContext.shared.appConfig.outputDirectoryUrl {
 96 |                     let relocatedUrl = FileUtils.getRelocatedRelativePath(of: originUrl, fromDir: inputUrl, toDir: outputFolderUrl)
 97 |                     outputUrl = relocatedUrl ?? outputFolderUrl.appendingPathComponent(originUrl.lastPathComponent)
 98 |                 } else {
 99 |                     let task = TaskInfo(originUrl: originUrl)
100 |                     task.updateError(error: TaskError.from(error: FileError.noOutput))
101 |                     appendTask(task: task)
102 |                     continue
103 |                 }
104 | 
105 |                 let task = TaskInfo(
106 |                     originUrl: originUrl,
107 |                     backupUrl: backupUrl,
108 |                     downloadUrl: downloadUrl,
109 |                     outputUrl: outputUrl,
110 |                     originSize: fileSize,
111 |                     filePermission: originUrl.posixPermissionsOfFile() ?? 0x644,
112 |                     previewImage: previewImage ?? NSImage(named: "placeholder")!
113 |                 )
114 | 
115 |                 print("Task created: \(task)")
116 | 
117 |                 appendTask(task: task)
118 | 
119 |                 TPClient.shared.addTask(task: task)
120 |             }
121 |         }
122 |     }
123 | 
124 |     func retry(_ task: TaskInfo) {
125 |         TPClient.shared.addTask(task: task)
126 |     }
127 | 
128 |     func restore(_ task: TaskInfo) {
129 |         guard task.status == .completed else {
130 |             return
131 |         }
132 | 
133 |         restoreConfirmTask = task
134 |     }
135 | 
136 |     func clearAllTask() {
137 |         TPClient.shared.stopAllTask()
138 |         tasks.removeAll()
139 |     }
140 | 
141 |     func clearFinishedTask() {
142 |         tasks.removeAll { $0.status.isFinished() }
143 |     }
144 | 
145 |     func retryAllFailedTask() {
146 |         tasks.filter { $0.status == .failed }
147 |             .forEach { task in
148 |                 retry(task)
149 |             }
150 |     }
151 | 
152 |     func restoreAll() {
153 |         Task {
154 |             for task in tasks {
155 |                 doRestore(task: task)
156 |             }
157 |         }
158 |     }
159 | 
160 |     func restoreConfirmConfirmed() {
161 |         guard let task = restoreConfirmTask else {
162 |             return
163 |         }
164 | 
165 |         defer { restoreConfirmTask = nil }
166 | 
167 |         Task {
168 |             doRestore(task: task)
169 |         }
170 |     }
171 | 
172 |     func cancelAllTask() {
173 |         TPClient.shared.stopAllTask()
174 |     }
175 | 
176 |     func shouldTerminate() -> Bool {
177 |         return TPClient.shared.runningTasks == 0
178 |     }
179 | 
180 |     func showRunnningTasksAlert() {
181 |         showQuitWithRunningTasksAlert = true
182 |     }
183 | 
184 |     /// Validate settings before create tasks.
185 |     /// - Returns true if the settings is valid
186 |     private func validateSettingsBeforeStartTask() -> Bool {
187 |         let config = AppContext.shared.appConfig
188 |         if config.apiKey.isEmpty {
189 |             DispatchQueue.main.async {
190 |                 self.settingsNotReadyMessage = String(localized: "Please set the API key first.")
191 |             }
192 |             return false
193 |         }
194 | 
195 |         if config.isSaveAsMode() {
196 |             if let outputFolderUrl = config.outputDirectoryUrl {
197 |                 if !outputFolderUrl.fileExists() {
198 |                     do {
199 |                         try outputFolderUrl.ensureDirectoryExists()
200 |                         return true
201 |                     } catch {
202 |                         DispatchQueue.main.async {
203 |                             self.settingsNotReadyMessage = String(localized: "Failed to create output directory: \(outputFolderUrl.rawPath()), please re-select the output directory.")
204 |                         }
205 |                         return false
206 |                     }
207 |                 }
208 | 
209 |                 if !FileUtils.hasReadAndWritePermission(path: outputFolderUrl.rawPath()) {
210 |                     DispatchQueue.main.async {
211 |                         self.settingsNotReadyMessage = String(localized: "No write permission of output folder \(outputFolderUrl.rawPath()), please re-select the output directory.")
212 |                     }
213 |                     return false
214 |                 }
215 |             } else {
216 |                 DispatchQueue.main.async {
217 |                     self.settingsNotReadyMessage = String(localized: "\"Save As Mode\" is selected. Please config the output directory first.")
218 |                 }
219 |                 return false
220 |             }
221 |         }
222 | 
223 |         return true
224 |     }
225 | 
226 |     private func doRestore(task: TaskInfo) {
227 |         if task.status != .completed {
228 |             return
229 |         }
230 | 
231 |         if let backupUrl = task.backupUrl {
232 |             do {
233 |                 try backupUrl.copyFileTo(task.originUrl, override: true)
234 |                 print("restore success")
235 |                 DispatchQueue.main.async {
236 |                     task.status = .restored
237 |                     self.notifyTaskChanged(task: task)
238 |                 }
239 |             } catch {
240 |                 print("restore fail \(error.localizedDescription)")
241 |             }
242 |         } else {
243 |             print("backup not found")
244 |         }
245 |     }
246 | 
247 |     func restoreConfirmCancel() {
248 |         restoreConfirmTask = nil
249 |     }
250 | 
251 |     private func appendTask(task: TaskInfo) {
252 |         DispatchQueue.main.async {
253 |             self.tasks.append(task)
254 |         }
255 |     }
256 | 
257 |     private func loadImagePreviewUsingCGImageSource(from url: URL, maxDimension: CGFloat) -> NSImage? {
258 |         // Create CGImageSource from the URL
259 |         guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
260 | 
261 |         // Get image properties to calculate aspect ratio
262 |         guard let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
263 |               let width = imageProperties[kCGImagePropertyPixelWidth] as? CGFloat,
264 |               let height = imageProperties[kCGImagePropertyPixelHeight] as? CGFloat else {
265 |             return nil
266 |         }
267 | 
268 |         // Calculate aspect ratio
269 |         let aspectRatio = width / height
270 | 
271 |         // Determine the size for the thumbnail while preserving the aspect ratio
272 |         var thumbnailSize: CGSize
273 |         if width > height {
274 |             thumbnailSize = CGSize(width: maxDimension, height: maxDimension / aspectRatio)
275 |         } else {
276 |             thumbnailSize = CGSize(width: maxDimension * aspectRatio, height: maxDimension)
277 |         }
278 | 
279 |         // Create options to generate thumbnail
280 |         let options: [CFString: Any] = [
281 |             kCGImageSourceThumbnailMaxPixelSize: maxDimension,
282 |             kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
283 |         ]
284 | 
285 |         // Generate the thumbnail image
286 |         guard let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else {
287 |             return nil
288 |         }
289 | 
290 |         // Create an NSImage from the CGImage
291 |         return NSImage(cgImage: cgImage, size: thumbnailSize)
292 |     }
293 | 
294 |     func onTaskChanged(task: TaskInfo) {
295 |         print("onTaskStatusChanged, \(task)")
296 | 
297 |         notifyTaskChanged(task: task)
298 |     }
299 | 
300 |     func onMonthlyUsedQuotaUpdated(quota: Int) {
301 |         debugPrint("onMonthlyUsedQuotaUpdated \(quota)")
302 |         monthlyUsedQuota = quota
303 |     }
304 | 
305 |     private func notifyTaskChanged(task: TaskInfo) {
306 |         if let index = tasks.firstIndex(where: { item in item.id == task.id }) {
307 |             tasks[index] = task
308 |             sortTasksInPlace(&tasks)
309 |         }
310 |     }
311 | 
312 |     private func sortTasksInPlace(_ tasks: inout [TaskInfo]) {
313 |         tasks.sort { $0 < $1 }
314 |     }
315 | }
316 | 
317 | struct AlertInfo {
318 |     var type: AlertType
319 |     var title: String
320 |     var message: String
321 | }
322 | 
323 | enum AlertType {
324 |     case restoreConfirm
325 | }
326 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/windows/AboutView.swift:
--------------------------------------------------------------------------------
 1 | ////
 2 | //  AboutView.swift
 3 | //  TinyPNG4Mac
 4 | //
 5 | //  Created by kyleduo on 2024/12/4.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct AboutView: View {
11 |     var body: some View {
12 |         VStack {
13 |             VStack(spacing: 12) {
14 |                 Image("appIcon")
15 |                     .resizable()
16 |                     .scaledToFit()
17 |                     .frame(width: 80, height: 80)
18 | 
19 |                 paragraph(text: "\"Tiny Image\" (TinyPNG4Mac) is a 3rd-party client for [TinyPNG](https://tinypng.com).")
20 |                     .padding(.top, 16)
21 | 
22 |                 paragraph(text: "TinyPNG holds the final right of interpretation regarding the image compression functionality and results.")
23 | 
24 |                 Spacer()
25 |                     .frame(height: 8)
26 |             }
27 |             .frame(maxHeight: .infinity)
28 | 
29 |             Text("Made by [@kyleduo](https://github.com/kyleduo)  ❤︎  Open-sourced on [Github](https://github.com/kyleduo/TinyPNG4Mac)")
30 |                 .font(.system(size: 12))
31 |                 .foregroundStyle(Color("textSecondaryAbout"))
32 |                 .padding(.bottom, 12)
33 |         }
34 |         .padding(24)
35 |         .frame(width: 440, height: 360)
36 |     }
37 | 
38 |     func paragraph(text: LocalizedStringKey) -> some View {
39 |         Text(text)
40 |             .font(.system(size: 13))
41 |             .foregroundStyle(Color("textBodyAbout"))
42 |             .multilineTextAlignment(.center)
43 |     }
44 | }
45 | 
46 | #Preview {
47 |     AboutView()
48 | }
49 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/windows/MainContentView.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  ContentView.swift
  3 | //  TinyPNG4Mac
  4 | //
  5 | //  Created by kyleduo on 2024/11/16.
  6 | //
  7 | 
  8 | import SwiftUI
  9 | 
 10 | struct MainContentView: View {
 11 |     @EnvironmentObject var appContext: AppContext
 12 |     @ObservedObject var vm: MainViewModel
 13 |     /// imageUrl : inputUrl
 14 |     @State private var dropResult: [URL: URL] = [:]
 15 |     @State private var showAlert = false
 16 |     @State private var showOpenPanel = false
 17 |     @State private var showRestoreAllConfirmAlert = false
 18 |     @State private var alertMessage: String? = nil
 19 |     @State private var showOutputDirectoryTips: Bool = false
 20 |     @State private var outputDirectoryButtonPosition: CGRect = CGRect.zero
 21 |     @State private var tipsSize: CGSize = CGSize.zero
 22 |     @State private var rootSize: CGSize = CGSize.zero
 23 |     @State private var hoverSaveModeButton: Bool = false
 24 | 
 25 |     @AppStorage(AppConfig.key_saveMode) var saveMode: String = AppContext.shared.appConfig.saveMode
 26 | 
 27 |     var body: some View {
 28 |         ZStack {
 29 |             DropFileView(dropResult: $dropResult)
 30 |                 .frame(maxWidth: .infinity, maxHeight: .infinity)
 31 |                 .background(Color("mainViewBackground"))
 32 | 
 33 |             VStack(spacing: 0) {
 34 |                 Text("Tiny Image")
 35 |                     .font(.system(size: 13, weight: .medium))
 36 |                     .foregroundStyle(Color("textMainTitle"))
 37 |                     .frame(height: 28)
 38 | 
 39 |                 if vm.tasks.isEmpty {
 40 |                     ZStack {
 41 |                         RoundedRectangle(cornerRadius: 12)
 42 |                             .stroke(style: StrokeStyle(
 43 |                                 lineWidth: 2,
 44 |                                 dash: [6, 3]
 45 |                             ))
 46 |                             .foregroundColor(Color.white.opacity(0.1))
 47 |                             .padding()
 48 | 
 49 |                         VStack(spacing: 12) {
 50 |                             Image(systemName: "photo.on.rectangle.angled")
 51 |                                 .resizable()
 52 |                                 .scaledToFit()
 53 |                                 .foregroundStyle(Color("textCaption"))
 54 |                                 .frame(width: 60, height: 60)
 55 |                                 .padding(.bottom, 12)
 56 | 
 57 |                             Text("Drop images or folders here!")
 58 |                                 .font(.system(size: 15, weight: .bold))
 59 |                                 .foregroundStyle(Color("textBody"))
 60 | 
 61 |                             Text("Supports WebP, PNG, and JPEG images.")
 62 |                                 .font(.system(size: 10))
 63 |                                 .foregroundStyle(Color("textSecondary"))
 64 |                         }
 65 |                     }
 66 |                     .frame(idealWidth: 360, maxWidth: .infinity, idealHeight: 360, maxHeight: .infinity)
 67 |                 } else {
 68 |                     List {
 69 |                         ForEach(vm.tasks.indices, id: \.self) { index in
 70 |                             TaskRowView(vm: vm, task: $vm.tasks[index], last: index == vm.tasks.count - 1)
 71 |                                 .listRowBackground(Color.clear)
 72 |                                 .listRowSeparator(.hidden)
 73 |                                 .listRowInsets(EdgeInsets())
 74 |                         }
 75 |                     }
 76 |                     .clipped()
 77 |                     .frame(maxWidth: appContext.maxSize.width)
 78 |                     .scrollContentBackground(.hidden)
 79 |                     .listStyle(PlainListStyle())
 80 |                     .environment(\.defaultMinListRowHeight, 0)
 81 |                 }
 82 | 
 83 |                 HorizontalDivider()
 84 |                     .padding(vertical: 0, horizontal: 12)
 85 |                     .padding(.top, 2)
 86 | 
 87 |                 HStack(alignment: .top) {
 88 |                     VStack(alignment: .leading, spacing: 2) {
 89 |                         KeyValueLabel(key: "Total:", value: "\(vm.tasks.count) tasks, \(vm.totalOriginSize.formatBytes())")
 90 | 
 91 |                         KeyValueLabel(key: "Completed:", value: "\(vm.completedTaskCount) tasks, \(vm.totalFinalSize.formatBytes())")
 92 |                     }
 93 |                     .frame(maxWidth: .infinity, alignment: .leading)
 94 | 
 95 |                     settingButton(useButtonStyle: false) {
 96 |                         KeyValueLabel(key: "Save Mode:", value: LocalizedStringKey(saveMode))
 97 |                             .padding(vertical: 2, horizontal: 4)
 98 |                             .background {
 99 |                                 if hoverSaveModeButton {
100 |                                     RoundedRectangle(cornerRadius: 4)
101 |                                         .fill(Color.white.opacity(0.15))
102 |                                 } else {
103 |                                     Color.clear
104 |                                 }
105 |                             }
106 |                             .onHover { hover in
107 |                                 hoverSaveModeButton = hover
108 |                             }
109 |                     }
110 |                 }
111 |                 .padding(EdgeInsets(top: 8, leading: 12, bottom: 0, trailing: 12))
112 | 
113 |                 HStack(alignment: .bottom, spacing: 6) {
114 |                     let usedQuota = vm.monthlyUsedQuota >= 0 ? String(vm.monthlyUsedQuota) : "--"
115 |                     Text("Images compressed this month: \(usedQuota)")
116 |                         .font(.system(size: 12))
117 |                         .foregroundStyle(Color("textSecondary"))
118 |                         .frame(maxWidth: .infinity, alignment: .leading)
119 | 
120 |                     if saveMode == AppConfig.saveModeNameSaveAs {
121 |                         Button {
122 |                             if let outputDir = appContext.appConfig.outputDirectoryUrl {
123 |                                 if outputDir.fileExists() {
124 |                                     NSWorkspace.shared.open(outputDir)
125 |                                 } else {
126 |                                     alertMessage = String(localized: "The output directory does not exist. It will be automatically created after any task is completed.")
127 |                                 }
128 |                             } else {
129 |                                 vm.settingsNotReadyMessage = String(localized: "Output directory is not set yet, please select it in the settings window.")
130 |                             }
131 |                         } label: {
132 |                             Image(systemName: "folder.circle.fill")
133 |                                 .font(.system(size: 13, weight: .medium))
134 |                                 .foregroundStyle(Color("textSecondary"))
135 |                                 .frame(width: 20, height: 20)
136 |                         }
137 |                         .buttonStyle(PlainButtonStyle())
138 |                         .background {
139 |                             GeometryReader { proxy in
140 |                                 Color.clear
141 |                                     .onAppear {
142 |                                         outputDirectoryButtonPosition = proxy.frame(in: .named("root"))
143 |                                     }
144 |                                     .onChange(of: proxy.frame(in: .named("root"))) { newFrame in
145 |                                         outputDirectoryButtonPosition = newFrame
146 |                                     }
147 |                             }
148 |                         }
149 |                         .onHover { hover in
150 |                             showOutputDirectoryTips = hover
151 |                         }
152 |                     }
153 | 
154 |                     menuEntry()
155 |                 }.padding(EdgeInsets(top: 6, leading: 12, bottom: 12, trailing: 12))
156 |             }
157 |             
158 |             DebugView()
159 | 
160 |             if let outputDir = appContext.appConfig.outputDirectoryUrl, showOutputDirectoryTips {
161 |                 Text(String(localized: "Click to open: ") + "\n\(outputDir.rawPath())")
162 |                     .font(.system(size: 12))
163 |                     .foregroundStyle(Color("textBody"))
164 |                     .lineLimit(2)
165 |                     .padding(vertical: 6, horizontal: 12)
166 |                     .background {
167 |                         RoundedRectangle(cornerRadius: 8)
168 |                             .fill(Color.black.opacity(0.8))
169 |                     }
170 |                     .padding(.leading, 12)
171 |                     .padding(.trailing, 12)
172 |                     .background {
173 |                         GeometryReader { proxy in
174 |                             Color.clear
175 |                                 .onAppear {
176 |                                     tipsSize = proxy.size
177 |                                 }
178 |                                 .onChange(of: proxy.size) { newSize in
179 |                                     tipsSize = newSize
180 |                                 }
181 |                         }
182 |                     }
183 |                     .position(x: rootSize.width / 2 + (rootSize.width - tipsSize.width) / 2, y: outputDirectoryButtonPosition.origin.y - tipsSize.height / 2 - 4)
184 |             }
185 |         }
186 |         .coordinateSpace(name: "root")
187 |         .background {
188 |             GeometryReader { proxy in
189 |                 Color.clear
190 |                     .onAppear {
191 |                         rootSize = proxy.size
192 |                     }
193 |                     .onChange(of: proxy.size) { newSize in
194 |                         rootSize = newSize
195 |                     }
196 |             }
197 |         }
198 |         .ignoresSafeArea()
199 |         .onChange(of: dropResult) { newValue in
200 |             if !newValue.isEmpty {
201 |                 dropResult = [:]
202 |                 vm.createTasks(imageURLs: newValue)
203 |             }
204 |         }
205 |         .alert("Confirm to restore the image?",
206 |                isPresented: Binding(
207 |                    get: { vm.restoreConfirmTask != nil },
208 |                    set: { if !$0 { } }
209 |                ),
210 |                actions: {
211 |                    Button("Restore") { vm.restoreConfirmConfirmed() }
212 |                    Button("Cancel", role: .cancel) { vm.restoreConfirmCancel() }
213 |                },
214 |                message: {
215 |                    let path = vm.restoreConfirmTask == nil ? "" : vm.restoreConfirmTask?.originUrl.rawPath() ?? ""
216 |                    Text("The image at \"\(path)\" will be replaced with the origin file.")
217 |                        .font(.system(size: 12))
218 |                }
219 |         )
220 |         .alert("The config is not ready",
221 |                isPresented: Binding(
222 |                    get: { vm.settingsNotReadyMessage != nil },
223 |                    set: { if !$0 { vm.settingsNotReadyMessage = nil } }
224 |                ),
225 |                actions: {
226 |                    settingButton(title: "Open Settings")
227 |                    Button("Cancel", role: .cancel) { }
228 |                },
229 |                message: {
230 |                    if let message = vm.settingsNotReadyMessage {
231 |                        Text(message)
232 |                    }
233 |                }
234 |         )
235 |         .alert("Confirm to restore all the images?",
236 |                isPresented: $showRestoreAllConfirmAlert,
237 |                actions: {
238 |                    Button("Restore") {
239 |                        vm.restoreAll()
240 |                    }
241 |                    Button("Cancel", role: .cancel) { }
242 |                },
243 |                message: {
244 |                    Text("All compressed images will be replaced with the origin file.")
245 |                }
246 |         )
247 |         .alert("Confirm quit?",
248 |                isPresented: $vm.showQuitWithRunningTasksAlert,
249 |                actions: {
250 |                    Button("Quit") {
251 |                        vm.cancelAllTask()
252 |                        NSApplication.shared.terminate(nil)
253 |                    }
254 |                    Button("Cancel", role: .cancel) {}
255 |                },
256 |                message: {
257 |                    Text("There are ongoing tasks. Quitting will cancel them all.")
258 |                })
259 |         .alert(alertMessage ?? "",
260 |                isPresented: Binding(
261 |                    get: { alertMessage != nil },
262 |                    set: { if !$0 { alertMessage = nil } }
263 |                ),
264 |                actions: {
265 |                    Button("OK") { }
266 |                }
267 |         )
268 |     }
269 | 
270 |     private func settingButton(title: String) -> some View {
271 |         settingButton {
272 |             Text(title)
273 |         }
274 |     }
275 | 
276 |     private func settingButton(useButtonStyle: Bool = true, @ViewBuilder view: () -> some View) -> some View {
277 |         if #available(macOS 14.0, *) {
278 |             AnyView(
279 |                 SettingsLink {
280 |                     view()
281 |                 }
282 |                 .modifier(PlainButtonStyleModifier(plainButtonStyle: !useButtonStyle))
283 |             )
284 |         } else {
285 |             AnyView(
286 |                 Button {
287 |                     if #available(macOS 13.0, *) {
288 |                         NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
289 |                     } else {
290 |                         NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
291 |                     }
292 |                 } label: {
293 |                     view()
294 |                 }
295 |                 .modifier(PlainButtonStyleModifier(plainButtonStyle: !useButtonStyle))
296 |             )
297 |         }
298 |     }
299 | 
300 |     private func menuEntry() -> some View {
301 |         Menu {
302 |             Button {
303 |                 vm.retryAllFailedTask()
304 |             } label: {
305 |                 Text("Retry all failed tasks")
306 |             }
307 |             .disabled(vm.failedTaskCount == 0)
308 | 
309 |             Divider()
310 | 
311 |             Button {
312 |                 vm.clearAllTask()
313 |             } label: {
314 |                 Text("Clear all tasks")
315 |             }
316 |             .disabled(vm.tasks.count == 0)
317 | 
318 |             Button {
319 |                 vm.clearFinishedTask()
320 |             } label: {
321 |                 Text("Clear all finished tasks")
322 |             }
323 |             .disabled(vm.tasks.count == 0)
324 | 
325 |             Divider()
326 | 
327 |             Button {
328 |                 showRestoreAllConfirmAlert = true
329 |             } label: {
330 |                 Text("Restore all compressed images")
331 |             }
332 |             .disabled(vm.completedTaskCount == 0)
333 |         } label: {
334 |             Image(systemName: "ellipsis.circle.fill")
335 |                 .font(.system(size: 12, weight: .medium))
336 |                 .frame(width: 20, height: 20)
337 |         }
338 |         .menuStyle(.borderlessButton)
339 |         .menuIndicator(.hidden)
340 |         .frame(width: 20, height: 20)
341 |         .tint(Color("textSecondary"))
342 |     }
343 | 
344 |     private func outputDirExist() -> Bool {
345 |         if let outputDir = appContext.appConfig.outputDirectoryUrl {
346 |             return outputDir.fileExists()
347 |         }
348 |         return false
349 |     }
350 | }
351 | 
352 | struct KeyValueLabel: View {
353 |     var key: LocalizedStringKey
354 |     var value: LocalizedStringKey
355 | 
356 |     var body: some View {
357 |         HStack(spacing: 2) {
358 |             Text(key)
359 |                 .font(.system(size: 12))
360 |                 .foregroundStyle(Color("textCaption"))
361 | 
362 |             Text(value)
363 |                 .font(.system(size: 12))
364 |                 .foregroundStyle(Color("textSecondary"))
365 |         }
366 |     }
367 | }
368 | 
369 | struct PlainButtonStyleModifier: ViewModifier {
370 |     var plainButtonStyle: Bool
371 | 
372 |     func body(content: Content) -> some View {
373 |         if plainButtonStyle {
374 |             content.buttonStyle(PlainButtonStyle())
375 |         } else {
376 |             content
377 |         }
378 |     }
379 | }
380 | 


--------------------------------------------------------------------------------
/TinyPNG4Mac/TinyPNG4Mac/windows/SettingsView.swift:
--------------------------------------------------------------------------------
  1 | ////
  2 | //  Settings.swift
  3 | //  TinyPNG4Mac
  4 | //
  5 | //  Created by kyleduo on 2024/12/1.
  6 | //
  7 | 
  8 | import SwiftUI
  9 | 
 10 | struct SettingsView: View {
 11 |     @AppStorage(AppConfig.key_apiKey) var apiKey: String = ""
 12 | 
 13 |     @AppStorage(AppConfig.key_preserveCopyright) var preserveCopyright: Bool = false
 14 |     @AppStorage(AppConfig.key_preserveCreation) var preserveCreation: Bool = false
 15 |     @AppStorage(AppConfig.key_preserveLocation) var preserveLocation: Bool = false
 16 | 
 17 |     @AppStorage(AppConfig.key_concurrentTaskCount) var concurrentCount: Int = AppContext.shared.appConfig.concurrentTaskCount
 18 |     private let concurrentCountOptions = Array(1 ... 6)
 19 | 
 20 |     @AppStorage(AppConfig.key_saveMode) var saveMode: String = AppContext.shared.appConfig.saveMode
 21 |     private let saveModeOptions = AppConfig.saveModeKeys
 22 | 
 23 |     @AppStorage(AppConfig.key_outputDirectory)
 24 |     var outputDirectory: String = AppContext.shared.appConfig.outputDirectoryUrl?.rawPath() ?? ""
 25 | 
 26 |     @FocusState private var isTextFieldFocused: Bool
 27 | 
 28 |     @State private var failedToSelectOutputDirectory: Bool = false
 29 |     @State private var enableSaveAsModeAfterSelect: Bool = false
 30 |     @State private var showSelectOutputFolder: Bool = false
 31 | 
 32 |     @State private var contentSize: CGSize = CGSize.zero
 33 | 
 34 |     var body: some View {
 35 |         VStack(alignment: .leading) {
 36 |             // Used to make sure content meature correctly
 37 |             ScrollView {
 38 |                 // Content of Settings
 39 |                 VStack(alignment: .leading) {
 40 |                     Text("TinyPNG")
 41 |                         .font(.system(size: 13, weight: .bold))
 42 | 
 43 |                     SettingsItem(title: "API key:", desc: "Visit [https://tinypng.com/developers](https://tinypng.com/developers) to request an API key.") {
 44 |                         TextField("", text: $apiKey)
 45 |                             .textFieldStyle(RoundedBorderTextFieldStyle())
 46 |                             .focused($isTextFieldFocused)
 47 |                             .onAppear {
 48 |                                 isTextFieldFocused = false
 49 |                             }
 50 |                     }
 51 | 
 52 |                     SettingsItem(title: "Preserve:", desc: nil) {
 53 |                         VStack(alignment: .leading) {
 54 |                             Toggle("Copyright", isOn: $preserveCopyright)
 55 |                             Toggle("Creation", isOn: $preserveCreation)
 56 |                             Toggle("Location", isOn: $preserveLocation)
 57 |                         }
 58 |                     }
 59 | 
 60 |                     Spacer()
 61 |                         .frame(height: 16)
 62 | 
 63 |                     Text("Tasks")
 64 |                         .font(.system(size: 13, weight: .bold))
 65 | 
 66 |                     SettingsItem(title: "Concurrent tasks:", desc: nil) {
 67 |                         Picker("", selection: $concurrentCount) {
 68 |                             ForEach(concurrentCountOptions, id: \.self) { count in
 69 |                                 Text("\(count)").tag(count)
 70 |                             }
 71 |                         }
 72 |                         .padding(.leading, -8)
 73 |                         .frame(maxWidth: 60)
 74 |                     }
 75 | 
 76 |                     SettingsItem(title: "Save Mode:", desc: "Overwrite Mode:\nThe compressed image will replace the original file. The original image is kept temporarily and can be restored before exit the app.\n\nSave As Mode:\nThe compressed image is saved as a new file, leaving the original image unchanged. You can choose where to save the compressed images.") {
 77 |                         Picker("", selection: $saveMode) {
 78 |                             ForEach(saveModeOptions, id: \.self) { mode in
 79 |                                 Text(mode).tag(mode)
 80 |                             }
 81 |                         }
 82 |                         .padding(.leading, -8)
 83 |                         .frame(maxWidth: 120)
 84 |                     }
 85 | 
 86 |                     SettingsItem(title: "Output directory:", desc: "When \"Save As Mode\" is enabled, the compressed image will be saved to this directory. If a file with the same name exists, it will be overwritten.") {
 87 |                         HStack(alignment: .top) {
 88 |                             Text(outputDirectory.isEmpty ? "--" : outputDirectory)
 89 |                                 .frame(maxWidth: .infinity, alignment: .leading)
 90 | 
 91 |                             Button {
 92 |                                 showSelectFolderPanel()
 93 |                             } label: {
 94 |                                 Text("Select...")
 95 |                             }
 96 |                         }
 97 |                     }
 98 | 
 99 |                     if AppContext.shared.isDebug {
100 |                         Button {
101 |                             AppContext.shared.appConfig.clearOutputFolder()
102 |                         } label: {
103 |                             Text("[D]Clear output directory")
104 |                         }
105 |                     }
106 |                 }
107 |                 .padding(24)
108 |                 .frame(width: 540)
109 |                 .background {
110 |                     GeometryReader { proxy in
111 |                         Color.clear
112 |                             .onAppear {
113 |                                 contentSize = proxy.size
114 |                             }
115 |                             .onChange(of: proxy.size) { newSize in
116 |                                 contentSize = newSize
117 |                             }
118 |                     }
119 |                 }
120 |             }
121 |             .scrollDisabled(true)
122 |             .background {
123 |                 RoundedRectangle(cornerRadius: 8)
124 |                     .fill(Color("settingViewBackground"))
125 |                     .overlay {
126 |                         RoundedRectangle(cornerRadius: 8)
127 |                             .stroke(Color("settingViewBackgroundBorder"), lineWidth: 1)
128 |                     }
129 |             }
130 |         }
131 |         .padding(16)
132 |         // Set the size of window.
133 |         .frame(width: contentSize.width + 32, height: contentSize.height + 32)
134 |         .onChange(of: saveMode) { newValue in
135 |             if newValue == AppConfig.saveModeNameSaveAs && outputDirectory.isEmpty {
136 |                 saveMode = AppConfig.saveModeNameOverwrite
137 |                 enableSaveAsModeAfterSelect = true
138 |                 showSelectOutputFolder = true
139 |             }
140 |         }
141 |         .onDisappear {
142 |             if outputDirectory.isEmpty {
143 |                 AppContext.shared.appConfig.clearOutputFolder()
144 |             }
145 |             AppContext.shared.appConfig.update()
146 |         }
147 |         .alert("Failed to save output directory",
148 |                isPresented: $failedToSelectOutputDirectory
149 |         ) {
150 |             Button("OK", role: .cancel) { }
151 |         } message: {
152 |             Text("Please select a different directory.")
153 |         }
154 |         .alert("Select output directory", isPresented: $showSelectOutputFolder) {
155 |             Button("OK") {
156 |                 DispatchQueue.main.async {
157 |                     enableSaveAsModeAfterSelect = false
158 |                     showSelectFolderPanel()
159 |                 }
160 |             }
161 |             Button("Cancel", role: .cancel) {}
162 |         } message: {
163 |             Text("Disable \"Overwrite Mode\" after selecting the output directory.")
164 |         }
165 |     }
166 | 
167 |     private func showSelectFolderPanel() {
168 |         let panel = NSOpenPanel()
169 |         panel.canChooseFiles = false
170 |         panel.canChooseDirectories = true
171 |         panel.allowsMultipleSelection = false
172 |         panel.canCreateDirectories = true
173 |         panel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
174 |         panel.prompt = "Select"
175 | 
176 |         panel.begin { result in
177 |             if result == .OK, let url = panel.url {
178 |                 print("User Select: \(url.rawPath())")
179 |                 outputDirectory = url.rawPath()
180 |             } else {
181 |                 print("User did not grant access.")
182 |             }
183 |         }
184 |     }
185 | }
186 | 
187 | #Preview {
188 |     SettingsView()
189 | }
190 | 


--------------------------------------------------------------------------------
/create_dmg.sh:
--------------------------------------------------------------------------------
 1 | #!/bin/bash
 2 | 
 3 | # 设置变量
 4 | DMG_NAME="Tiny-Image-Installer.dmg"
 5 | VOLUME_NAME="Tiny Image Installer"
 6 | BACKGROUND_IMAGE="images/dmg_background.png"
 7 | APP_PATH="dmg_source/"
 8 | ICON_NAME="Tiny Image.app"
 9 | 
10 | # 检查是否安装了 create-dmg
11 | if ! command -v create-dmg &> /dev/null; then
12 |   echo "Error: create-dmg is not installed. Please install it first."
13 |   exit 1
14 | fi
15 | 
16 | # 创建 DMG
17 | create-dmg \
18 |   --volname "$VOLUME_NAME" \
19 |   --background "$BACKGROUND_IMAGE" \
20 |   --window-size 640 420 \
21 |   --icon "$ICON_NAME" 180 220 \
22 |   --icon-size 100 \
23 |   --hide-extension "$ICON_NAME" \
24 |   --app-drop-link 440 220 \
25 |   "$DMG_NAME" \
26 |   "$APP_PATH"
27 | 
28 | # 检查命令是否成功
29 | if [ $? -eq 0 ]; then
30 |   echo "DMG created successfully: $DMG_NAME"
31 | else
32 |   echo "Failed to create DMG."
33 |   exit 1
34 | fi
35 | 


--------------------------------------------------------------------------------
/images/dmg_background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/images/dmg_background.png


--------------------------------------------------------------------------------
/preview/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/preview/banner.png


--------------------------------------------------------------------------------
/preview/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kyleduo/TinyPNG4Mac/c649da26fe22edbf9e47bafcb956e85790ed23d8/preview/icon.png


--------------------------------------------------------------------------------