diff --git a/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..92d8298
--- /dev/null
+++ b/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,68 @@
+{
+ "images" : [
+ {
+ "filename" : "Icon-16.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "16x16"
+ },
+ {
+ "filename" : "Icon-32 1.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "16x16"
+ },
+ {
+ "filename" : "Icon-32.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "32x32"
+ },
+ {
+ "filename" : "Icon-64.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "32x32"
+ },
+ {
+ "filename" : "Icon-128.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "128x128"
+ },
+ {
+ "filename" : "Icon-256 1.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "128x128"
+ },
+ {
+ "filename" : "Icon-256.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "256x256"
+ },
+ {
+ "filename" : "Icon-512 1.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "256x256"
+ },
+ {
+ "filename" : "Icon-512.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "512x512"
+ },
+ {
+ "filename" : "Icon-1024.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "512x512"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-1024.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-1024.png
new file mode 100644
index 0000000..f1bdca3
Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-1024.png differ
diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-128.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-128.png
new file mode 100644
index 0000000..749ff7c
Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-128.png differ
diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-16.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-16.png
new file mode 100644
index 0000000..efbe3c0
Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-16.png differ
diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-256 1.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-256 1.png
new file mode 100644
index 0000000..6eb8abe
Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-256 1.png differ
diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-256.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-256.png
new file mode 100644
index 0000000..6eb8abe
Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-256.png differ
diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-32 1.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-32 1.png
new file mode 100644
index 0000000..5071f0b
Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-32 1.png differ
diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-32.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-32.png
new file mode 100644
index 0000000..5071f0b
Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-32.png differ
diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-512 1.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-512 1.png
new file mode 100644
index 0000000..69cdc73
Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-512 1.png differ
diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-512.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-512.png
new file mode 100644
index 0000000..69cdc73
Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-512.png differ
diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-64.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-64.png
new file mode 100644
index 0000000..f51283e
Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/Icon-64.png differ
diff --git a/App/Resources/Assets.xcassets/Contents.json b/App/Resources/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/App/Resources/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/App/Resources/FengNiao.entitlements b/App/Resources/FengNiao.entitlements
new file mode 100644
index 0000000..19afff1
--- /dev/null
+++ b/App/Resources/FengNiao.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.files.user-selected.read-write
+
+
+
diff --git a/App/fengniao.xcodeproj/project.pbxproj b/App/fengniao.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..e88b0cc
--- /dev/null
+++ b/App/fengniao.xcodeproj/project.pbxproj
@@ -0,0 +1,391 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 6AAF22A82AA1D0F400DB0B5F /* FengNiaoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AAF22A72AA1D0F400DB0B5F /* FengNiaoApp.swift */; };
+ 6AAF22AA2AA1D0F400DB0B5F /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AAF22A92AA1D0F400DB0B5F /* AppView.swift */; };
+ 6AAF22AC2AA1D0F500DB0B5F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6AAF22AB2AA1D0F500DB0B5F /* Assets.xcassets */; };
+ 6AAF22AF2AA1D0F500DB0B5F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6AAF22AE2AA1D0F500DB0B5F /* Preview Assets.xcassets */; };
+ 6AE869FC2AA1D41600DCF78B /* FengNiaoKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6AE869FB2AA1D41600DCF78B /* FengNiaoKit */; };
+ 6AE869FE2AA1D58E00DCF78B /* AppViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AE869FD2AA1D58E00DCF78B /* AppViewModel.swift */; };
+ 6AE86A002AA1D5D800DCF78B /* DeleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AE869FF2AA1D5D800DCF78B /* DeleteView.swift */; };
+ 6AE86A022AA1D5F700DCF78B /* DeleteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AE86A012AA1D5F700DCF78B /* DeleteViewModel.swift */; };
+ 6AE86A042AA1D61600DCF78B /* ExportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AE86A032AA1D61600DCF78B /* ExportView.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 6AAF22A42AA1D0F400DB0B5F /* FengNiao.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FengNiao.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 6AAF22A72AA1D0F400DB0B5F /* FengNiaoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FengNiaoApp.swift; sourceTree = ""; };
+ 6AAF22A92AA1D0F400DB0B5F /* AppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = ""; };
+ 6AAF22AB2AA1D0F500DB0B5F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 6AAF22AE2AA1D0F500DB0B5F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
+ 6AAF22B02AA1D0F500DB0B5F /* FengNiao.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FengNiao.entitlements; sourceTree = ""; };
+ 6AE869F92AA1D35500DCF78B /* FengNiao */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FengNiao; path = ..; sourceTree = ""; };
+ 6AE869FD2AA1D58E00DCF78B /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = ""; };
+ 6AE869FF2AA1D5D800DCF78B /* DeleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteView.swift; sourceTree = ""; };
+ 6AE86A012AA1D5F700DCF78B /* DeleteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteViewModel.swift; sourceTree = ""; };
+ 6AE86A032AA1D61600DCF78B /* ExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportView.swift; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 6AAF22A12AA1D0F400DB0B5F /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 6AE869FC2AA1D41600DCF78B /* FengNiaoKit in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 6AAF229B2AA1D0F400DB0B5F = {
+ isa = PBXGroup;
+ children = (
+ 6AE869F92AA1D35500DCF78B /* FengNiao */,
+ 6AAF22A62AA1D0F400DB0B5F /* macOS */,
+ 6AAF22A52AA1D0F400DB0B5F /* Products */,
+ 6AAF22B62AA1D1C900DB0B5F /* Resources */,
+ 6AE869FA2AA1D41600DCF78B /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 6AAF22A52AA1D0F400DB0B5F /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 6AAF22A42AA1D0F400DB0B5F /* FengNiao.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 6AAF22A62AA1D0F400DB0B5F /* macOS */ = {
+ isa = PBXGroup;
+ children = (
+ 6AAF22A92AA1D0F400DB0B5F /* AppView.swift */,
+ 6AE869FD2AA1D58E00DCF78B /* AppViewModel.swift */,
+ 6AE869FF2AA1D5D800DCF78B /* DeleteView.swift */,
+ 6AE86A012AA1D5F700DCF78B /* DeleteViewModel.swift */,
+ 6AE86A032AA1D61600DCF78B /* ExportView.swift */,
+ 6AAF22A72AA1D0F400DB0B5F /* FengNiaoApp.swift */,
+ 6AAF22AD2AA1D0F500DB0B5F /* Preview Content */,
+ );
+ path = macOS;
+ sourceTree = "";
+ };
+ 6AAF22AD2AA1D0F500DB0B5F /* Preview Content */ = {
+ isa = PBXGroup;
+ children = (
+ 6AAF22AE2AA1D0F500DB0B5F /* Preview Assets.xcassets */,
+ );
+ path = "Preview Content";
+ sourceTree = "";
+ };
+ 6AAF22B62AA1D1C900DB0B5F /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ 6AAF22B02AA1D0F500DB0B5F /* FengNiao.entitlements */,
+ 6AAF22AB2AA1D0F500DB0B5F /* Assets.xcassets */,
+ );
+ path = Resources;
+ sourceTree = "";
+ };
+ 6AE869FA2AA1D41600DCF78B /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 6AAF22A32AA1D0F400DB0B5F /* fengniao */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 6AAF22B32AA1D0F500DB0B5F /* Build configuration list for PBXNativeTarget "fengniao" */;
+ buildPhases = (
+ 6AAF22A02AA1D0F400DB0B5F /* Sources */,
+ 6AAF22A12AA1D0F400DB0B5F /* Frameworks */,
+ 6AAF22A22AA1D0F400DB0B5F /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = fengniao;
+ packageProductDependencies = (
+ 6AE869FB2AA1D41600DCF78B /* FengNiaoKit */,
+ );
+ productName = fengniao;
+ productReference = 6AAF22A42AA1D0F400DB0B5F /* FengNiao.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 6AAF229C2AA1D0F400DB0B5F /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1430;
+ LastUpgradeCheck = 1430;
+ TargetAttributes = {
+ 6AAF22A32AA1D0F400DB0B5F = {
+ CreatedOnToolsVersion = 14.3;
+ };
+ };
+ };
+ buildConfigurationList = 6AAF229F2AA1D0F400DB0B5F /* Build configuration list for PBXProject "fengniao" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 6AAF229B2AA1D0F400DB0B5F;
+ productRefGroup = 6AAF22A52AA1D0F400DB0B5F /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 6AAF22A32AA1D0F400DB0B5F /* fengniao */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 6AAF22A22AA1D0F400DB0B5F /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 6AAF22AF2AA1D0F500DB0B5F /* Preview Assets.xcassets in Resources */,
+ 6AAF22AC2AA1D0F500DB0B5F /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 6AAF22A02AA1D0F400DB0B5F /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 6AE86A022AA1D5F700DCF78B /* DeleteViewModel.swift in Sources */,
+ 6AAF22AA2AA1D0F400DB0B5F /* AppView.swift in Sources */,
+ 6AE86A042AA1D61600DCF78B /* ExportView.swift in Sources */,
+ 6AE86A002AA1D5D800DCF78B /* DeleteView.swift in Sources */,
+ 6AAF22A82AA1D0F400DB0B5F /* FengNiaoApp.swift in Sources */,
+ 6AE869FE2AA1D58E00DCF78B /* AppViewModel.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 6AAF22B12AA1D0F500DB0B5F /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 6AAF22B22AA1D0F500DB0B5F /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ };
+ name = Release;
+ };
+ 6AAF22B42AA1D0F500DB0B5F /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Resources/FengNiao.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "\"macOS/Preview Content\"";
+ DEVELOPMENT_TEAM = "";
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MARKETING_VERSION = 1.0.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.fengniao;
+ PRODUCT_NAME = FengNiao;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ 6AAF22B52AA1D0F500DB0B5F /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Resources/FengNiao.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "\"macOS/Preview Content\"";
+ DEVELOPMENT_TEAM = "";
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 12.0;
+ MARKETING_VERSION = 1.0.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.onevcat.fengniao;
+ PRODUCT_NAME = FengNiao;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 6AAF229F2AA1D0F400DB0B5F /* Build configuration list for PBXProject "fengniao" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 6AAF22B12AA1D0F500DB0B5F /* Debug */,
+ 6AAF22B22AA1D0F500DB0B5F /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 6AAF22B32AA1D0F500DB0B5F /* Build configuration list for PBXNativeTarget "fengniao" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 6AAF22B42AA1D0F500DB0B5F /* Debug */,
+ 6AAF22B52AA1D0F500DB0B5F /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 6AE869FB2AA1D41600DCF78B /* FengNiaoKit */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = FengNiaoKit;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = 6AAF229C2AA1D0F400DB0B5F /* Project object */;
+}
diff --git a/App/fengniao.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/App/fengniao.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/App/fengniao.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/App/fengniao.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/App/fengniao.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/App/fengniao.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/App/fengniao.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/App/fengniao.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000..c6062d0
--- /dev/null
+++ b/App/fengniao.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,43 @@
+{
+ "object": {
+ "pins": [
+ {
+ "package": "CommandLineKit",
+ "repositoryURL": "https://github.com/benoit-pereira-da-silva/CommandLine.git",
+ "state": {
+ "branch": null,
+ "revision": "3eaafd5941e359f025a411e3b5947f96d82d1bc9",
+ "version": "4.0.9"
+ }
+ },
+ {
+ "package": "PathKit",
+ "repositoryURL": "https://github.com/kylef/PathKit.git",
+ "state": {
+ "branch": null,
+ "revision": "3bfd2737b700b9a36565a8c94f4ad2b050a5e574",
+ "version": "1.0.1"
+ }
+ },
+ {
+ "package": "Rainbow",
+ "repositoryURL": "https://github.com/onevcat/Rainbow.git",
+ "state": {
+ "branch": null,
+ "revision": "626c3d4b6b55354b4af3aa309f998fae9b31a3d9",
+ "version": "3.2.0"
+ }
+ },
+ {
+ "package": "Spectre",
+ "repositoryURL": "https://github.com/kylef/Spectre.git",
+ "state": {
+ "branch": null,
+ "revision": "26cc5e9ae0947092c7139ef7ba612e34646086c7",
+ "version": "0.10.1"
+ }
+ }
+ ]
+ },
+ "version": 1
+}
diff --git a/App/fengniao.xcodeproj/xcshareddata/xcschemes/FengNiao-macOS.xcscheme b/App/fengniao.xcodeproj/xcshareddata/xcschemes/FengNiao-macOS.xcscheme
new file mode 100644
index 0000000..ae34a81
--- /dev/null
+++ b/App/fengniao.xcodeproj/xcshareddata/xcschemes/FengNiao-macOS.xcscheme
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/App/macOS/AppView.swift b/App/macOS/AppView.swift
new file mode 100644
index 0000000..c3d5b29
--- /dev/null
+++ b/App/macOS/AppView.swift
@@ -0,0 +1,393 @@
+import SwiftUI
+import FengNiaoKit
+import PathKit
+import QuickLook
+
+struct AppView: View {
+ @StateObject private var viewModel: AppViewModel = AppViewModel()
+
+ // MARK: Text Field
+
+ @State private var projectPath: String = ""
+ @State private var excludePaths: String = ""
+ @State private var resourcesExtensions: String = Constants.defaultResourcesExtension
+ @FocusState private var focusedField: FocusedField?
+
+ // MARK: Show alert
+
+ @State private var showDeleteAllAlert: Bool = false
+ @State private var showDeleteAlert: Bool = false
+ @State private var showErrorAlert: Bool = false
+
+ // MARK: Toggle View
+
+ @State private var showDeleteAllView: Bool = false
+ @State private var showDeleteView: Bool = false
+ @State private var showExportView: Bool = false
+
+ // MARK: Table
+
+ @State private var fileSelection = Set()
+ @State private var fileNameSortOrder = [
+ KeyPathComparator(\FengNiaoKit.FileInfo.fileName),
+ KeyPathComparator(\FengNiaoKit.FileInfo.size),
+ KeyPathComparator(\FengNiaoKit.FileInfo.path)
+ ]
+ @State private var previewImageUrl: URL?
+
+ // MARK: Checkbox
+
+ @State private var toggleStates = [
+ ToggleState(fileExtension: "h", isOn: true),
+ ToggleState(fileExtension: "m", isOn: true),
+ ToggleState(fileExtension: "mm", isOn: true),
+ ToggleState(fileExtension: "swift", isOn: true),
+ ToggleState(fileExtension: "xib", isOn: true),
+ ToggleState(fileExtension: "storyboard", isOn: true),
+ ToggleState(fileExtension: "plist", isOn: true),
+ ToggleState(fileExtension: "pbxproj", isOn: true)
+ ]
+
+ // MARK: - View
+
+ var body: some View {
+ VStack {
+ configView
+
+ Divider()
+
+ unusedResourcesTable
+
+ Spacer(minLength: 16)
+
+ resultView
+ }
+ .animation(.default, value: viewModel.contentState)
+ .padding()
+ .onChange(of: viewModel.contentState) { state in
+ if state == .error {
+ showErrorAlert.toggle()
+ }
+ }
+ .sheet(isPresented: $showDeleteAllView) {
+ deleteView(filesToDelete: viewModel.unusedFiles)
+ .onDisappear {
+ fetchUnusedFiles()
+ }
+ }
+ .sheet(isPresented: $showDeleteView) {
+ let fileToDelete = viewModel.unusedFiles.filter { fileSelection.contains($0.id) }
+ deleteView(filesToDelete: fileToDelete)
+ .onDisappear {
+ viewModel.unusedFiles.removeAll(where: { fileToDelete.contains($0) } )
+ fileSelection = []
+ }
+ }
+ .sheet(isPresented: $showExportView) {
+ ExportView(unusedFiles: viewModel.unusedFiles)
+ .frame(minWidth: 500, minHeight: 300, idealHeight: 500)
+ }
+ .alert(
+ deleteItemTitle,
+ isPresented: $showDeleteAlert
+ ) {
+ Button("Delete", role: .destructive) {
+ showDeleteView.toggle()
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("This item will be delete immediately.\nYou can't undo this action.")
+ }
+ .alert(
+ "Are you sure you want to delete all items?",
+ isPresented: $showDeleteAllAlert
+ ) {
+ Button("Delete All") {
+ showDeleteAllView.toggle()
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("You can't undo this action.")
+ }
+ .alert(
+ "Something went wrong",
+ isPresented: $showErrorAlert
+ ) {
+ // Only OK action, no need to implement
+ } message: {
+ if let error = viewModel.error as? FengNiaoError {
+ switch error {
+ case .noResourceExtension:
+ Text("You need to specify some resource extensions as search target.")
+ case .noFileExtension:
+ Text("You need to specify some file extensions to search in.")
+ }
+ } else {
+ Text("Unknown Error: \(viewModel.error?.localizedDescription ?? "")")
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var configView: some View {
+ VStack(alignment: .leading) {
+ Text("Configurations")
+ .font(.headline)
+
+ HStack {
+ Text("Project Path")
+ TextField("Root path of your Xcode project", text: $projectPath)
+ .focused($focusedField, equals: .project)
+ Button("Browse...") {
+ handleOpenFile()
+ }
+ .disabled(viewModel.isLoading)
+ }
+
+ HStack {
+ Text("Exclude Paths")
+ TextField(
+ "Exclude paths from search, separates with space. Example: Pods Carthage",
+ text: $excludePaths
+ )
+ .focused($focusedField, equals: .excludes)
+ }
+
+ HStack {
+ Text("File Extensions")
+ ForEach(toggleStates.indices, id: \.self) { index in
+ Toggle(toggleStates[index].fileExtension, isOn: $toggleStates[index].isOn)
+ }
+ }
+
+ HStack {
+ Text("Resources Extensions")
+ TextField(
+ "Resource file extensions, separates with space. Default is 'imageset jpg png gif pdf'",
+ text: $resourcesExtensions
+ )
+ .focused($focusedField, equals: .resources)
+ Button("Restore") {
+ resourcesExtensions = Constants.defaultResourcesExtension
+ }
+ }
+ HStack {
+ Spacer()
+ Button(viewModel.isLoading ? "Searching... " : "Search...") {
+ if projectPath.isEmpty {
+ focusedField = .project
+ return
+ }
+ fetchUnusedFiles()
+ focusedField = nil
+ }
+ .disabled(viewModel.isLoading)
+ .tint(Color.accentColor)
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ .onTapGesture {
+ focusedField = nil
+ }
+ }
+
+ @ViewBuilder
+ private var unusedResourcesTable: some View {
+ VStack(alignment: .leading) {
+ Text("Unused Files")
+ .font(.headline)
+ Table(
+ viewModel.unusedFiles,
+ selection: $fileSelection,
+ sortOrder: $fileNameSortOrder
+ ) {
+ TableColumn("File Name", value: \.fileName) { file in
+ Text(file.fileName)
+ .contentShape(Rectangle())
+ .help(file.fileName)
+ .contextMenu {
+ Button("Copy") {
+ let pasteboard = NSPasteboard.general
+ pasteboard.declareTypes([.string], owner: nil)
+ pasteboard.setString(file.fileName, forType: .string)
+ }
+ Button("Show in Finder") {
+ NSWorkspace.shared.open(URL(fileURLWithPath: file.path.string))
+ }
+ }
+ }
+ .width(min: 150, ideal: 150, max: 300)
+
+ TableColumn("Size", value: \.size) {
+ Text($0.size.fn_readableSize)
+ }
+ .width(min: 50, max: 150)
+
+ TableColumn("Full Path", value: \.path.string) { file in
+ HStack {
+ Text(file.path.string)
+ Button {
+ previewImageUrl = URL(fileURLWithPath: file.path.string)
+ } label: {
+ Image(systemName: "eye")
+ }
+ .keyboardShortcut(.space)
+ }
+ .quickLookPreview($previewImageUrl)
+ .contentShape(Rectangle())
+ .contextMenu {
+ Button("Copy") {
+ let pasteboard = NSPasteboard.general
+ pasteboard.declareTypes([.string], owner: nil)
+ pasteboard.setString(file.path.string, forType: .string)
+ }
+ Button("Show in Finder") {
+ NSWorkspace.shared.open(URL(fileURLWithPath: file.path.string))
+ }
+ }
+ .help(file.path.string)
+ }
+ }
+ .animation(.default, value: viewModel.unusedFiles)
+ .onChange(of: fileNameSortOrder) { sortOrder in
+ viewModel.unusedFiles.sort(using: sortOrder)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var resultView: some View {
+ if viewModel.contentState == .content {
+ HStack {
+ if viewModel.unusedFiles.isEmpty {
+ Text("🎉 You have no unused resources in path: \(Path(projectPath).absolute().string)")
+ } else {
+ let size = viewModel.unusedFiles
+ .reduce(0) { $0 + $1.size }.fn_readableSize
+ Text("\(viewModel.unusedFiles.count) files are found. Total Size: \(size)")
+ }
+
+ Spacer()
+
+ Button("Delete") {
+ showDeleteAlert.toggle()
+ }
+ .disabled(fileSelection.isEmpty)
+ .disabled(viewModel.unusedFiles.isEmpty)
+
+ Button("Delete All") {
+ showDeleteAllAlert.toggle()
+ }
+ .disabled(viewModel.unusedFiles.isEmpty)
+
+ Button("Export CSV") {
+ showExportView.toggle()
+ }
+ .disabled(viewModel.unusedFiles.isEmpty)
+ }
+ } else if viewModel.contentState == .loading {
+ HStack {
+ HStack(alignment: .center, spacing: 8) {
+ ProgressView()
+ .controlSize(.small)
+ Text("Searching unused file. This may take a while...")
+ }
+ Spacer()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func deleteView(filesToDelete: [FengNiaoKit.FileInfo]) -> some View {
+ DeleteView(
+ projectPath: self.projectPath,
+ filesToDelete: filesToDelete
+ )
+ .frame(width: 500, height: 200)
+ }
+
+ // MARK: Side Effects - Private
+
+ private var deleteItemTitle: String {
+ if fileSelection.count == 1 {
+ if let firstItem = fileSelection.first,
+ let selectedUnusedFile = viewModel.unusedFiles.first(where: { $0.id == firstItem } ) {
+ return "Are you sure you want to delete \"\(selectedUnusedFile.fileName)\""
+ }
+ } else {
+ return "Are you sure you want to delete the \(fileSelection.count) selected items?"
+ }
+ return ""
+ }
+
+ private func handleOpenFile() {
+ let panel = NSOpenPanel()
+ panel.allowsMultipleSelection = false
+ panel.canChooseDirectories = true
+ if panel.runModal() == .OK {
+ if let chosenFile = panel.url {
+ let path = chosenFile.path
+ projectPath = path
+ }
+ }
+ }
+
+ private func fetchUnusedFiles() {
+ let fileExtensions: [String] = toggleStates
+ .filter { $0.isOn }
+ .map { $0.fileExtension }
+
+ viewModel.fetchUnusedFiles(
+ from: projectPath,
+ excludePaths: excludePaths,
+ fileExtensions: fileExtensions,
+ resourcesExtensions: resourcesExtensions
+ )
+ }
+}
+
+// MARK: - FocusedField
+
+extension AppView {
+ enum FocusedField {
+ case project, excludes, resources
+ }
+}
+
+// MARK: - Constants
+
+enum Constants {
+ static let defaultResourcesExtension: String = "imageset jpg png gif pdf heic"
+}
+
+// MARK: - Preview
+
+struct MainContentView_Previews: PreviewProvider {
+ static var previews: some View {
+ AppView()
+ .frame(width: 800, height: 800)
+ }
+}
+
+// MARK: - FengNiaoKit.FileInfo + Identifiable + Hashable
+
+extension FengNiaoKit.FileInfo: Identifiable, Hashable {
+ public var id: String {
+ path.string
+ }
+
+ public static func == (lhs: FileInfo, rhs: FileInfo) -> Bool {
+ lhs.id == rhs.id
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+}
+
+// MARK: - ToggleState
+
+struct ToggleState: Hashable {
+ let fileExtension: String
+ var isOn: Bool
+}
diff --git a/App/macOS/AppViewModel.swift b/App/macOS/AppViewModel.swift
new file mode 100644
index 0000000..c392216
--- /dev/null
+++ b/App/macOS/AppViewModel.swift
@@ -0,0 +1,58 @@
+import FengNiaoKit
+import SwiftUI
+
+final class AppViewModel: ObservableObject {
+ @Published var unusedFiles: [FengNiaoKit.FileInfo] = []
+ @Published private(set) var contentState: ContentState = .idling
+ @Published private(set) var error: Error?
+ /// Scanning project path to display info in result text.
+ private(set) var scanningProjectPath: String = ""
+
+ var isLoading: Bool {
+ contentState == .loading
+ }
+
+ private let queue = DispatchQueue(label: "com.onevcat.fengniao", attributes: .concurrent)
+
+ func fetchUnusedFiles(
+ from path: String,
+ excludePaths: String,
+ fileExtensions: [String],
+ resourcesExtensions: String
+ ) {
+ contentState = .loading
+
+ scanningProjectPath = path
+ queue.async {
+ let fengNiao = FengNiao(
+ projectPath: path,
+ excludedPaths: excludePaths.split(separator: " ").map(String.init),
+ resourceExtensions: resourcesExtensions.split(separator: " ").map(String.init),
+ searchInFileExtensions: fileExtensions
+ )
+
+ do {
+ let files = try fengNiao.unusedFiles()
+
+ DispatchQueue.main.async {
+ self.unusedFiles = files
+ self.contentState = .content
+ }
+ } catch {
+ DispatchQueue.main.async {
+ self.contentState = .error
+ self.error = error
+ }
+ }
+ }
+ }
+}
+
+extension AppViewModel {
+ enum ContentState: Equatable {
+ case idling
+ case loading
+ case content
+ case error
+ }
+}
diff --git a/App/macOS/DeleteView.swift b/App/macOS/DeleteView.swift
new file mode 100644
index 0000000..721429f
--- /dev/null
+++ b/App/macOS/DeleteView.swift
@@ -0,0 +1,82 @@
+import SwiftUI
+import FengNiaoKit
+
+struct DeleteView: View {
+ @Environment(\.dismiss) private var dismiss
+ @State private var showDetailStatus: Bool = true
+ @StateObject private var viewModel: DeleteStatusViewModel
+
+ init(
+ projectPath: String,
+ filesToDelete: [FengNiaoKit.FileInfo]
+ ) {
+ _viewModel = StateObject(
+ wrappedValue: DeleteStatusViewModel(
+ projectPath: projectPath,
+ unusedFilesToDelete: filesToDelete
+ )
+ )
+ }
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ HStack {
+ Image(systemName: showDetailStatus ? "chevron.down" : "chevron.right")
+ .font(.subheadline)
+ Text("Deleting unused files...")
+ .font(.body)
+ .bold()
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ showDetailStatus.toggle()
+ }
+ .animation(.spring(response: 0.15), value: showDetailStatus)
+
+ if showDetailStatus {
+ Text(viewModel.consoleStatus)
+ .font(.subheadline)
+ .foregroundColor(Color(NSColor.secondaryLabelColor))
+ .animation(.spring(response: 0.3), value: showDetailStatus)
+ }
+
+ Spacer()
+
+ ProgressView(
+ viewModel.deleteAmount == 100 ? "Finished!" : "Deleting...",
+ value: viewModel.deleteAmount,
+ total: 100
+ )
+ .foregroundColor(Color(NSColor.secondaryLabelColor))
+
+ Spacer()
+
+ HStack {
+ Spacer()
+ Button("Done") {
+ dismiss()
+ }
+ .disabled(viewModel.deleteAmount < 100)
+ .tint(Color.accentColor)
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ .padding()
+ .onAppear {
+ viewModel.deleteUnusedFiles()
+ }
+ .alert("Something went wrong", isPresented: $viewModel.showError) {
+ // only need OK button
+ } message: {
+ Text(viewModel.errorAlertMessage)
+ }
+
+ }
+}
+
+struct DeleteStatusView_Previews: PreviewProvider {
+ static var previews: some View {
+ DeleteView(projectPath: "", filesToDelete: [])
+ .frame(width: 500, height: 200)
+ }
+}
diff --git a/App/macOS/DeleteViewModel.swift b/App/macOS/DeleteViewModel.swift
new file mode 100644
index 0000000..ed7eba6
--- /dev/null
+++ b/App/macOS/DeleteViewModel.swift
@@ -0,0 +1,80 @@
+import FengNiaoKit
+import Foundation
+import Combine
+import PathKit
+
+final class DeleteStatusViewModel: ObservableObject {
+ @Published var unusedFilesToDelete: [FengNiaoKit.FileInfo] = []
+ @Published var deleteAmount = 0.0
+ @Published var consoleStatus: String = ""
+ @Published var showError: Bool = false
+ @Published var errorAlertMessage: String = ""
+
+ private let projectPath: String
+ private let queue = DispatchQueue(label: "com.onevcat.fengniao", attributes: .concurrent)
+ private let timer = Timer.publish(every: 0.1, on: .current, in: .default).autoconnect()
+ private var cancellable: AnyCancellable?
+
+ init(
+ projectPath: String,
+ unusedFilesToDelete: [FengNiaoKit.FileInfo]
+ ) {
+ self.projectPath = projectPath
+ self.unusedFilesToDelete = unusedFilesToDelete
+ }
+
+ func deleteUnusedFiles() {
+ let increasePercentagePerFiles = unusedFilesToDelete.count / 100
+
+ queue.async {
+ let (deleted, failed) = FengNiao.delete(self.unusedFilesToDelete)
+
+ // FengNiaoKit doesn't public functions to calculate the exact download percentage,
+ // so using this hack to make progress continually running
+ self.cancellable = self.timer.sink { [weak self] _ in
+ DispatchQueue.main.async {
+ guard let self else { return }
+ if self.deleteAmount < 99 {
+ self.deleteAmount += Double(increasePercentagePerFiles)
+ }
+ }
+ }
+
+ guard failed.isEmpty else {
+ self.showError.toggle()
+
+ self.errorAlertMessage = "\(self.unusedFilesToDelete.count - failed.count) unused files are deleted. But we encountered some error while deleting these \(failed.count) files:"
+ for (fileInfo, err) in failed {
+ self.errorAlertMessage += "\(fileInfo.path.string) - \(err.localizedDescription)"
+ self.errorAlertMessage += "\n"
+ }
+ return
+ }
+
+ DispatchQueue.main.async {
+ let content = self.unusedFilesToDelete.count > 1 ?
+ "\(self.unusedFilesToDelete.count) unused files are deleted" :
+ "\(self.unusedFilesToDelete.first?.fileName ?? "") is deleted"
+ self.consoleStatus += content
+ }
+
+ if let children = try? Path(self.projectPath).absolute().children() {
+ DispatchQueue.main.async {
+ self.consoleStatus += "\nNow deleting unused reference in project.pbxproj..."
+ }
+
+ let xcodeprojFiles = children.filter { $0.lastComponent.hasSuffix("xcodeproj") }
+ let pbxprojFiles = xcodeprojFiles.map { $0 + "project.pbxproj" }
+ for pbxprojFile in pbxprojFiles {
+ FengNiao.deleteReference(projectFilePath: pbxprojFile, deletedFiles: deleted)
+ }
+ }
+
+ DispatchQueue.main.async {
+ self.consoleStatus += "\nUnused reference delete successfully"
+ self.cancellable = nil
+ self.deleteAmount = 100
+ }
+ }
+ }
+}
diff --git a/App/macOS/ExportView.swift b/App/macOS/ExportView.swift
new file mode 100644
index 0000000..a13bbaf
--- /dev/null
+++ b/App/macOS/ExportView.swift
@@ -0,0 +1,96 @@
+import SwiftUI
+import FengNiaoKit
+import Cocoa
+
+struct ExportView: View {
+ let unusedFiles: [FengNiaoKit.FileInfo]
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var copyButtonTitle: String = "Copy CSV"
+ @State private var downloadButtonTitle: String = "Download .csv"
+ @State private var csvContent: String = ""
+ @State private var isShowingSavePanel = false
+
+ var body: some View {
+ VStack {
+ Text("Export CSV")
+ .font(.title2)
+ .bold()
+ HStack {
+ Button(copyButtonTitle) {
+ copyButtonTitle = "Copied!"
+ let pasteboard = NSPasteboard.general
+ pasteboard.declareTypes([.string], owner: nil)
+ pasteboard.setString(csvContent, forType: .string)
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
+ copyButtonTitle = "Copy CSV"
+ }
+ }
+
+ Button(downloadButtonTitle) {
+ downloadCSV()
+ downloadButtonTitle = "Downloaded!"
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
+ downloadButtonTitle = "Download .csv"
+ }
+ }
+ }
+
+ TextEditor(text: $csvContent)
+
+ HStack {
+ Spacer()
+ Button("Done") {
+ dismiss()
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(Color.accentColor)
+ }
+ }
+ .padding()
+ .onAppear {
+ makeCSVContent()
+ }
+ }
+
+ private func makeCSVContent() {
+ var csvText = "File Name,Size,Full Path\n"
+ for fileInfo in unusedFiles {
+ let row = "\(fileInfo.fileName),\(fileInfo.size.fn_readableSize),\(fileInfo.path.string)\n"
+ csvText.append(row)
+ }
+ self.csvContent = csvText
+ }
+
+ private func downloadCSV() {
+ do {
+ guard let fileURL = getCSVFileURL() else { return }
+ try self.csvContent.write(to: fileURL, atomically: true, encoding: .utf8)
+
+ isShowingSavePanel = true
+ } catch {
+ print("Error exporting CSV file: \(error.localizedDescription)")
+ }
+ }
+
+ private func getCSVFileURL() -> URL? {
+ let savePanel = NSSavePanel()
+ savePanel.allowedContentTypes = [.commaSeparatedText]
+ savePanel.nameFieldStringValue = "export.csv"
+ savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
+
+ guard savePanel.runModal() == .OK, let fileURL = savePanel.url else {
+ return nil
+ }
+
+ return fileURL
+ }
+}
+
+struct ExportView_Previews: PreviewProvider {
+ static var previews: some View {
+ ExportView(unusedFiles: [])
+ .frame(minWidth: 300, idealWidth: 300, minHeight: 300, idealHeight: 500)
+ }
+}
diff --git a/App/macOS/FengNiaoApp.swift b/App/macOS/FengNiaoApp.swift
new file mode 100644
index 0000000..4e29a94
--- /dev/null
+++ b/App/macOS/FengNiaoApp.swift
@@ -0,0 +1,11 @@
+import SwiftUI
+
+@main
+struct FengNiaoApp: App {
+ var body: some Scene {
+ WindowGroup {
+ AppView()
+ .frame(minWidth: 300, idealWidth: 650, minHeight: 500)
+ }
+ }
+}
diff --git a/App/macOS/Preview Content/Preview Assets.xcassets/Contents.json b/App/macOS/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/App/macOS/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Package.swift b/Package.swift
index 780635e..a7190ac 100644
--- a/Package.swift
+++ b/Package.swift
@@ -6,6 +6,7 @@ let package = Package(
name: "FengNiao",
products: [
.executable(name: "FengNiao", targets: ["FengNiao"]),
+ .library(name: "FengNiaoKit", targets: ["FengNiaoKit"])
],
dependencies: [
.package(url: "https://github.com/onevcat/Rainbow.git", from: "3.1.1"),
diff --git a/Package@swift-4.2.swift b/Package@swift-4.2.swift
index cb789bf..194c6e0 100644
--- a/Package@swift-4.2.swift
+++ b/Package@swift-4.2.swift
@@ -6,6 +6,7 @@ let package = Package(
name: "FengNiao",
products: [
.executable(name: "FengNiao", targets: ["FengNiao"]),
+ .library(name: "FengNiaoKit", targets: ["FengNiaoKit"])
],
dependencies: [
.package(url: "https://github.com/onevcat/Rainbow.git", from: "3.1.1"),
diff --git a/Package@swift-5.0.swift b/Package@swift-5.0.swift
index ce36218..5d34261 100644
--- a/Package@swift-5.0.swift
+++ b/Package@swift-5.0.swift
@@ -6,7 +6,8 @@ let package = Package(
name: "FengNiao",
platforms: [.macOS(.v10_10)],
products: [
- .executable(name: "FengNiao", targets: ["FengNiao"])
+ .executable(name: "FengNiao", targets: ["FengNiao"]),
+ .library(name: "FengNiaoKit", targets: ["FengNiaoKit"])
],
dependencies: [
.package(url: "https://github.com/onevcat/Rainbow.git", from: "3.1.1"),
diff --git a/Sources/FengNiaoKit/FengNiao.swift b/Sources/FengNiaoKit/FengNiao.swift
index c07adda..ef7421b 100644
--- a/Sources/FengNiaoKit/FengNiao.swift
+++ b/Sources/FengNiaoKit/FengNiao.swift
@@ -60,7 +60,7 @@ public struct FileInfo {
public let size: Int
public let fileName: String
- init(path: String) {
+ public init(path: String) {
self.path = Path(path)
self.size = self.path.size
self.fileName = self.path.lastComponent
@@ -129,7 +129,7 @@ public struct FengNiao {
}
// Return a failed list of deleting
- static public func delete(_ unusedFiles: [FileInfo]) -> (deleted: [FileInfo], failed :[(FileInfo, Error)]) {
+ static public func delete(_ unusedFiles: [FileInfo]) -> (deleted: [FileInfo], failed: [(FileInfo, Error)]) {
var deleted = [FileInfo]()
var failed = [(FileInfo, Error)]()
for file in unusedFiles {
@@ -192,7 +192,7 @@ public struct FengNiao {
// Skip the folders which suffix with a non-folder extension.
// eg: We need to skip a folder with such name: /myfolder.png/ (although we should blame the one who named it)
let filePath = Path(file)
- if let ext = filePath.extension, filePath.isDirectory && nonDirExtensions.contains(ext) {
+ if let ext = filePath.extension, filePath.isDirectory && nonDirExtensions.contains(ext) {
continue
}
@@ -301,6 +301,3 @@ extension String {
}
}
}
-
-
-
diff --git a/Sources/FengNiaoKit/FileSearchRule.swift b/Sources/FengNiaoKit/FileSearchRule.swift
index fd2cfd5..417ef15 100644
--- a/Sources/FengNiaoKit/FileSearchRule.swift
+++ b/Sources/FengNiaoKit/FileSearchRule.swift
@@ -89,4 +89,3 @@ struct PbxprojImageSearchRule: RegPatternSearchRule {
let extensions: [String]
let patterns = ["ASSETCATALOG_COMPILER_APPICON_NAME = \"?(.*?)\"?;", "ASSETCATALOG_COMPILER_COMPLICATION_NAME = \"?(.*?)\"?;"]
}
-