├── .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 | 
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 | 
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
--------------------------------------------------------------------------------