From c719bc0192e12ee6e7e12f4d8c4d614b4db45369 Mon Sep 17 00:00:00 2001 From: Ramiz Kichibekov Date: Mon, 19 Oct 2026 16:05:28 +0300 Subject: [PATCH] Add FrameGenKit, the framegen tool and the backlog features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine (Packages/FrameGenKit, shared by the app and the tool): - XCFrameworkPipeline runs a BuildRequest and reports PipelineEvents; failure highlights come from the failing step's output only. - Projects and workspaces (CocoaPods): a sibling .xcworkspace is preferred, configurations come from the workspace's main project, missing paths are rejected up front. - Ten platforms (iOS, Mac Catalyst, macOS, tvOS, watchOS, visionOS and their simulators), detected from the scheme's SUPPORTED_PLATFORMS. - Optional XCFramework signing with a secure timestamp, before zipping. - Publishing to GitHub releases through gh, or with a shell command template (presets for GitLab, Bitbucket, S3, Artifactory); download URL templates give the binaryTarget snippet its real URL. Command-line tool: - framegen build/list/identities, embedded in FrameGen.app/Contents/Helpers and linked by the Homebrew cask; JSON output and stable exit codes. - "Copy Build Command" prints the same invocation from the app. App: - Version, signing, distribution and platform options; Publish (⌘U); notifications when a build finishes in the background. - Russian localization through String Catalogs. - Saved settings decode leniently and migrate to workspace keys. Tooling: CI runs swift test for the package; release.sh checks the embedded tool (arm64, hardened runtime, version). 174 package tests, 9 app tests. --- .github/workflows/ci.yml | 4 + .gitignore | 2 + FrameGen.xcodeproj/project.pbxproj | 240 +- .../xcshareddata/xcschemes/FrameGen.xcscheme | 4 +- FrameGen/Application/AppDelegate.swift | 2 +- FrameGen/Application/Info.plist | 1 + FrameGen/Application/Menus/MainMenu.swift | 66 +- FrameGen/Controllers/BuildController.swift | 158 +- .../BuildFailureViewController.swift | 12 +- .../BuildProgressViewController.swift | 31 +- .../BuildResultViewController.swift | 156 +- .../ConfigurationViewController.swift | 383 +++- .../Controllers/ProjectViewController.swift | 2 +- .../Controllers/SettingsViewController.swift | 40 +- .../Controllers/WelcomeViewController.swift | 37 +- FrameGen/Models/BuildState.swift | 50 +- FrameGen/Models/Platform+UI.swift | 97 + FrameGen/Models/Platform.swift | 73 - FrameGen/Models/Project.swift | 223 +- FrameGen/Models/ProjectInfo.swift | 39 - FrameGen/Models/Workspace.swift | 45 +- FrameGen/Resources/InfoPlist.xcstrings | 70 + FrameGen/Resources/Localizable.xcstrings | 2032 +++++++++++++++++ FrameGen/Services/BuildNotifier.swift | 53 + FrameGen/Services/Preferences.swift | 89 +- FrameGen/Services/ProjectLocator.swift | 75 - FrameGen/Services/ProjectPicker.swift | 44 + FrameGen/Services/XcodeCommand.swift | 58 - FrameGen/Views/CalloutView.swift | 5 + FrameGen/Views/CopyButton.swift | 6 +- FrameGen/Views/LogView.swift | 2 +- FrameGen/Views/RecentProjectCellView.swift | 2 +- FrameGen/Views/StatusHeaderView.swift | 2 +- FrameGen/Views/StepRowView.swift | 10 +- FrameGen/Window/MainWindowController.swift | 57 +- FrameGenCLI/main.swift | 13 + FrameGenTests/BuildStateTests.swift | 2 + FrameGenTests/ProjectInfoTests.swift | 62 - FrameGenTests/ProjectLocatorTests.swift | 73 - FrameGenTests/ProjectSettingsTests.swift | 74 + FrameGenTests/XcodeCommandTests.swift | 82 - Packages/FrameGenKit/Package.swift | 50 + .../Sources/FrameGenCLI/FrameGenTool.swift | 266 +++ .../BuildRequest+CommandLine.swift | 226 ++ .../Sources/FrameGenKit/BuildRequest.swift | 63 + .../Sources/FrameGenKit/Platform.swift | 135 ++ .../Sources/FrameGenKit}/ProcessRunner.swift | 56 +- .../FrameGenKit/ProjectContainer.swift | 48 + .../Sources/FrameGenKit/ProjectInfo.swift | 89 + .../Sources/FrameGenKit/ProjectLocator.swift | 67 + .../Sources/FrameGenKit/Publishing.swift | 200 ++ .../Sources/FrameGenKit/SchemeBuildInfo.swift | 75 + .../Sources/FrameGenKit/SigningIdentity.swift | 49 + .../Sources/FrameGenKit/Template.swift | 137 ++ .../FrameGenKit}/XCFrameworkBuilder.swift | 122 +- .../FrameGenKit/XCFrameworkPipeline.swift | 167 ++ .../Sources/FrameGenKit/XcodeCommand.swift | 83 + .../Sources/framegen-dev/main.swift | 12 + .../BuildArgumentsTests.swift | 413 ++++ .../FrameGenCLITests/FrameGenToolTests.swift | 197 ++ .../Tests/FrameGenCLITests/TestSupport.swift | 86 + .../FrameGenKitTests/BuildRequestTests.swift | 104 + .../FrameGenKitTests/GitRemoteTests.swift | 125 + .../FrameGenKitTests/LogAnalyzerTests.swift | 71 + .../FrameGenKitTests/PipelineTests.swift | 40 + .../FrameGenKitTests/PlatformTests.swift | 106 + .../ProcessRunnerTests.swift | 4 +- .../FrameGenKitTests/ProjectInfoTests.swift | 181 ++ .../ProjectLocatorTests.swift | 168 ++ .../SchemeBuildInfoTests.swift | 230 ++ .../SigningIdentityTests.swift | 104 + .../FrameGenKitTests/TemplateTests.swift | 234 ++ .../Tests/FrameGenKitTests}/TestSupport.swift | 2 +- .../XCFrameworkBuilderTests.swift | 98 +- .../FrameGenKitTests/XcodeCommandTests.swift | 186 ++ README.md | 72 +- docs/RELEASING.md | 2 + image/configuration.png | Bin 184017 -> 208320 bytes image/result.png | Bin 237403 -> 256203 bytes packaging/homebrew/framegen.rb | 1 + scripts/release.sh | 10 + 81 files changed, 7779 insertions(+), 976 deletions(-) create mode 100644 FrameGen/Models/Platform+UI.swift delete mode 100644 FrameGen/Models/Platform.swift delete mode 100644 FrameGen/Models/ProjectInfo.swift create mode 100644 FrameGen/Resources/InfoPlist.xcstrings create mode 100644 FrameGen/Resources/Localizable.xcstrings create mode 100644 FrameGen/Services/BuildNotifier.swift delete mode 100644 FrameGen/Services/ProjectLocator.swift create mode 100644 FrameGen/Services/ProjectPicker.swift delete mode 100644 FrameGen/Services/XcodeCommand.swift create mode 100644 FrameGenCLI/main.swift delete mode 100644 FrameGenTests/ProjectInfoTests.swift delete mode 100644 FrameGenTests/ProjectLocatorTests.swift create mode 100644 FrameGenTests/ProjectSettingsTests.swift delete mode 100644 FrameGenTests/XcodeCommandTests.swift create mode 100644 Packages/FrameGenKit/Package.swift create mode 100644 Packages/FrameGenKit/Sources/FrameGenCLI/FrameGenTool.swift create mode 100644 Packages/FrameGenKit/Sources/FrameGenKit/BuildRequest+CommandLine.swift create mode 100644 Packages/FrameGenKit/Sources/FrameGenKit/BuildRequest.swift create mode 100644 Packages/FrameGenKit/Sources/FrameGenKit/Platform.swift rename {FrameGen/Services => Packages/FrameGenKit/Sources/FrameGenKit}/ProcessRunner.swift (67%) create mode 100644 Packages/FrameGenKit/Sources/FrameGenKit/ProjectContainer.swift create mode 100644 Packages/FrameGenKit/Sources/FrameGenKit/ProjectInfo.swift create mode 100644 Packages/FrameGenKit/Sources/FrameGenKit/ProjectLocator.swift create mode 100644 Packages/FrameGenKit/Sources/FrameGenKit/Publishing.swift create mode 100644 Packages/FrameGenKit/Sources/FrameGenKit/SchemeBuildInfo.swift create mode 100644 Packages/FrameGenKit/Sources/FrameGenKit/SigningIdentity.swift create mode 100644 Packages/FrameGenKit/Sources/FrameGenKit/Template.swift rename {FrameGen/Services => Packages/FrameGenKit/Sources/FrameGenKit}/XCFrameworkBuilder.swift (59%) create mode 100644 Packages/FrameGenKit/Sources/FrameGenKit/XCFrameworkPipeline.swift create mode 100644 Packages/FrameGenKit/Sources/FrameGenKit/XcodeCommand.swift create mode 100644 Packages/FrameGenKit/Sources/framegen-dev/main.swift create mode 100644 Packages/FrameGenKit/Tests/FrameGenCLITests/BuildArgumentsTests.swift create mode 100644 Packages/FrameGenKit/Tests/FrameGenCLITests/FrameGenToolTests.swift create mode 100644 Packages/FrameGenKit/Tests/FrameGenCLITests/TestSupport.swift create mode 100644 Packages/FrameGenKit/Tests/FrameGenKitTests/BuildRequestTests.swift create mode 100644 Packages/FrameGenKit/Tests/FrameGenKitTests/GitRemoteTests.swift create mode 100644 Packages/FrameGenKit/Tests/FrameGenKitTests/LogAnalyzerTests.swift create mode 100644 Packages/FrameGenKit/Tests/FrameGenKitTests/PipelineTests.swift create mode 100644 Packages/FrameGenKit/Tests/FrameGenKitTests/PlatformTests.swift rename {FrameGenTests => Packages/FrameGenKit/Tests/FrameGenKitTests}/ProcessRunnerTests.swift (98%) create mode 100644 Packages/FrameGenKit/Tests/FrameGenKitTests/ProjectInfoTests.swift create mode 100644 Packages/FrameGenKit/Tests/FrameGenKitTests/ProjectLocatorTests.swift create mode 100644 Packages/FrameGenKit/Tests/FrameGenKitTests/SchemeBuildInfoTests.swift create mode 100644 Packages/FrameGenKit/Tests/FrameGenKitTests/SigningIdentityTests.swift create mode 100644 Packages/FrameGenKit/Tests/FrameGenKitTests/TemplateTests.swift rename {FrameGenTests => Packages/FrameGenKit/Tests/FrameGenKitTests}/TestSupport.swift (98%) rename {FrameGenTests => Packages/FrameGenKit/Tests/FrameGenKitTests}/XCFrameworkBuilderTests.swift (63%) create mode 100644 Packages/FrameGenKit/Tests/FrameGenKitTests/XcodeCommandTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d55b02..298bab5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,10 @@ jobs: CODE_SIGNING_ALLOWED=NO fi + - name: Test FrameGenKit and the command-line tool + shell: bash + run: swift test --package-path Packages/FrameGenKit + - name: Upload test results if: failure() uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 6dec9ff..22cb0e4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ xcuserdata/ # Build products build/ DerivedData/ +Packages/*/.build/ +*.profraw *.xcarchive diff --git a/FrameGen.xcodeproj/project.pbxproj b/FrameGen.xcodeproj/project.pbxproj index fbaa76f..3c98bd3 100644 --- a/FrameGen.xcodeproj/project.pbxproj +++ b/FrameGen.xcodeproj/project.pbxproj @@ -7,19 +7,20 @@ objects = { /* Begin PBXBuildFile section */ - 489D4094B3E6E7371963AF3C /* TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400431A229984A2BA6021A3A /* TestSupport.swift */; }; - FB936CD51DB1A7AA4E66A812 /* ProjectInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6559978338F272BEE8F5BEE0 /* ProjectInfoTests.swift */; }; - 8F8E65782BAD13F02DA168C3 /* XcodeCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99569F0C0D2242246D1458D /* XcodeCommandTests.swift */; }; + 7B043781095E748B046B6A7D /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A9297C6DD9393123074FF288 /* InfoPlist.xcstrings */; }; + D22DF401DCB3670954B05D8F /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A4BF25418661A63152306EC0 /* Localizable.xcstrings */; }; + 5BF23F17802A2D217FF0BDAA /* ProjectSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54EEE01D3B69A2E626A4B561 /* ProjectSettingsTests.swift */; }; + 1C5D76623698EAC9FE465567 /* FrameGenKit in Frameworks */ = {isa = PBXBuildFile; productRef = 88083ACF9EE0E11ECC3293A7 /* FrameGenKit */; }; + 450AF2350ED57D1ABD7680A0 /* FrameGenCLI in Frameworks */ = {isa = PBXBuildFile; productRef = 1E662F5B2A51E5928EA5DE5F /* FrameGenCLI */; }; + 1684B971E9688F48026891BB /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A796FE634C8D4105808032F2 /* main.swift */; }; + CA5BB0BCB8EE437DE7DF788C /* framegen in Embed Command Line Tool */ = {isa = PBXBuildFile; fileRef = 8C65D63BCC10E10D18999878 /* framegen */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + A675F55AC3110800DCF0CDD5 /* ProjectPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14EBF6728E80E402601F0EA /* ProjectPicker.swift */; }; + 1F23DEB89FECE631960103F6 /* BuildNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5EF0F48B45EE379940A5E52 /* BuildNotifier.swift */; }; + A4859D424E52E9356771FFD0 /* Platform+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC69462091F0A23644D25295 /* Platform+UI.swift */; }; 269B226DA50FBD34ADC50D92 /* BuildStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76E9A0DCFA013DC175158D45 /* BuildStateTests.swift */; }; - 94C9FB2B1653047CD3D88951 /* ProjectLocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27F8FBFB4AC5D119EA82AD2 /* ProjectLocatorTests.swift */; }; - 50EBD5583671FEDD263C30B8 /* XCFrameworkBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F45CABAA2BB083405EBA03BD /* XCFrameworkBuilderTests.swift */; }; - 68945E8C52E8DFDA51A444BB /* ProcessRunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7D1B7BC7E3148E64AD39E1 /* ProcessRunnerTests.swift */; }; 1C2B4D17E814365889F847CA /* ProjectViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C5AFC913CD2476947CAA048 /* ProjectViewController.swift */; }; - 1E4EAF5737CA5A801A30965E /* XcodeCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B8E679A3BEA53C6A6764CB /* XcodeCommand.swift */; }; - 2344326BDB2BA918039074D5 /* ProjectInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2301D103F3056B6F9E2C2E8A /* ProjectInfo.swift */; }; 2503C114253538F9FC97A806 /* MainMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161D8634A9AF92289AD7E263 /* MainMenu.swift */; }; 2597E05CC40E0C90671816AE /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC8A8EA20663C18ADBAFBDB /* main.swift */; }; - 3BD2CFAAA964DF83C19DF6BB /* Platform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF1ECD9C14BBEA60A7F12B5 /* Platform.swift */; }; 4263B4EBCAE604D628040CC6 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA69EB3841AECDDA8FE2CA3 /* Preferences.swift */; }; 4D5F3E69A95E08AE6A7DB554 /* CopyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F05721EF31C6FBA8F6997EA /* CopyButton.swift */; }; 56654DA6C6D188523CFAC8E4 /* BuildResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2ADBDC6A0E62B606F018CE9 /* BuildResultViewController.swift */; }; @@ -31,7 +32,6 @@ 90CF7EAAA83E5271BE1E6B16 /* Project.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8A81626F8D17947E596BD7 /* Project.swift */; }; 95BE9E0747F32914E5DF0C5E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 542D3D512896B00E109C50B9 /* AppDelegate.swift */; }; 9FB6582E9CF3B5E655518C9C /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA714FFE139D2B24154FE823 /* RootViewController.swift */; }; - A6C586AED3893B3971EF0E47 /* XCFrameworkBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F0109521FC69BF5C9CCA7D5 /* XCFrameworkBuilder.swift */; }; A75C65D568B16853AF922C2A /* BuildFailureViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FB9740B88E1413A0E101E2 /* BuildFailureViewController.swift */; }; A9DA7D28376389BB59D48E9C /* BuildController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20115268FEF39A7B7DD03FAE /* BuildController.swift */; }; AA475ABF6A527A9AC924422F /* AppKit+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D3CF33D3219D0380C3B71EC /* AppKit+Helpers.swift */; }; @@ -41,18 +41,37 @@ CDA37F41DA66C2E2BB0ED52A /* RecentProjectCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B7A6B784EA7222D67C3D67 /* RecentProjectCellView.swift */; }; D776777011E6892262EBA91C /* FormSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFDF40A968E5585F39C46B1E /* FormSectionView.swift */; }; D9C293F961041AED3D12059B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A597B54F34A37EE715070A5E /* Assets.xcassets */; }; - DA41911AA058F2CEB4B2007E /* ProcessRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74D9541D23263177A094521 /* ProcessRunner.swift */; }; DC6FFD5CE46BBB0AE9E0898B /* CalloutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE478835E220022471AA72BD /* CalloutView.swift */; }; DFFE6552400FF06C1ED2712B /* ObservationLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38968303FC1D994728815881 /* ObservationLoop.swift */; }; E46CBC25563ED06F9741CAB0 /* LogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1FD55B3D225874AAB28D70 /* LogView.swift */; }; E78E021CFB56A255344F6289 /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD71D8ABDA20B7FDE977313 /* WelcomeViewController.swift */; }; E8EEE8C6D022ABC749637026 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C03DDBD84F4DC30B7D63863 /* MainWindow.swift */; }; - EA6DB51A574BACC17EB5335A /* ProjectLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3B5DCD4782CBA9371395BB /* ProjectLocator.swift */; }; EC29396BF8521B43E7E15C9C /* ScrollingContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C1041CA7BE5CFB6B539B79 /* ScrollingContentView.swift */; }; F19DAA76C7C1B1E8FDD65F9E /* DropTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 583D28F8D69380D2DA9AA7FB /* DropTargetView.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXCopyFilesBuildPhase section */ + 569E894EAB1D3205E340DD4E /* Embed Command Line Tool */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/Helpers; + dstSubfolderSpec = 1; + files = ( + CA5BB0BCB8EE437DE7DF788C /* framegen in Embed Command Line Tool */, + ); + name = "Embed Command Line Tool"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXContainerItemProxy section */ + 59533D4F6DC66AD6E0340B19 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4B9E803E29E1DB6300DFC001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BFA0A85530AF45B5E004AEC8; + remoteInfo = FrameGenCLI; + }; DFAC54EC022B5FA0CE9E7935 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 4B9E803E29E1DB6300DFC001 /* Project object */; @@ -63,20 +82,21 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 400431A229984A2BA6021A3A /* TestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSupport.swift; sourceTree = ""; }; - 6559978338F272BEE8F5BEE0 /* ProjectInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectInfoTests.swift; sourceTree = ""; }; - C99569F0C0D2242246D1458D /* XcodeCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommandTests.swift; sourceTree = ""; }; + A9297C6DD9393123074FF288 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + A4BF25418661A63152306EC0 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 54EEE01D3B69A2E626A4B561 /* ProjectSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSettingsTests.swift; sourceTree = ""; }; + A796FE634C8D4105808032F2 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 8C65D63BCC10E10D18999878 /* framegen */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = framegen; sourceTree = BUILT_PRODUCTS_DIR; }; + C7AC304FBEF6790F70206381 /* FrameGenKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FrameGenKit; path = Packages/FrameGenKit; sourceTree = ""; }; + E14EBF6728E80E402601F0EA /* ProjectPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectPicker.swift"; sourceTree = ""; }; + A5EF0F48B45EE379940A5E52 /* BuildNotifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BuildNotifier.swift"; sourceTree = ""; }; + EC69462091F0A23644D25295 /* Platform+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Platform+UI.swift"; sourceTree = ""; }; 76E9A0DCFA013DC175158D45 /* BuildStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildStateTests.swift; sourceTree = ""; }; - E27F8FBFB4AC5D119EA82AD2 /* ProjectLocatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectLocatorTests.swift; sourceTree = ""; }; - F45CABAA2BB083405EBA03BD /* XCFrameworkBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCFrameworkBuilderTests.swift; sourceTree = ""; }; - 5C7D1B7BC7E3148E64AD39E1 /* ProcessRunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessRunnerTests.swift; sourceTree = ""; }; 6F9A1917669D5240DD64C7D3 /* FrameGenTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FrameGenTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 01A165050B2E052A7F7EF7E3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info.plist"; sourceTree = ""; }; 02B7A6B784EA7222D67C3D67 /* RecentProjectCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecentProjectCellView.swift"; sourceTree = ""; }; - 0F0109521FC69BF5C9CCA7D5 /* XCFrameworkBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCFrameworkBuilder.swift"; sourceTree = ""; }; 161D8634A9AF92289AD7E263 /* MainMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainMenu.swift"; sourceTree = ""; }; 20115268FEF39A7B7DD03FAE /* BuildController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BuildController.swift"; sourceTree = ""; }; - 2301D103F3056B6F9E2C2E8A /* ProjectInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectInfo.swift"; sourceTree = ""; }; 25C1041CA7BE5CFB6B539B79 /* ScrollingContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScrollingContentView.swift"; sourceTree = ""; }; 2C03DDBD84F4DC30B7D63863 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainWindow.swift"; sourceTree = ""; }; 35FB9740B88E1413A0E101E2 /* BuildFailureViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BuildFailureViewController.swift"; sourceTree = ""; }; @@ -85,11 +105,9 @@ 3CA69EB3841AECDDA8FE2CA3 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences.swift"; sourceTree = ""; }; 497574E1E99A9498BAC6CA79 /* MainWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainWindowController.swift"; sourceTree = ""; }; 4B9E804629E1DB6300DFC001 /* FrameGen.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FrameGen.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 4EF1ECD9C14BBEA60A7F12B5 /* Platform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Platform.swift"; sourceTree = ""; }; 4FC26B0688D03B1E6A31160F /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace.swift"; sourceTree = ""; }; 542D3D512896B00E109C50B9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate.swift"; sourceTree = ""; }; 583D28F8D69380D2DA9AA7FB /* DropTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DropTargetView.swift"; sourceTree = ""; }; - 5C3B5DCD4782CBA9371395BB /* ProjectLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectLocator.swift"; sourceTree = ""; }; 5D3CF33D3219D0380C3B71EC /* AppKit+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppKit+Helpers.swift"; sourceTree = ""; }; 5E1FD55B3D225874AAB28D70 /* LogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LogView.swift"; sourceTree = ""; }; 691DA398C1E950CFC39A7C38 /* StatusHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusHeaderView.swift"; sourceTree = ""; }; @@ -106,13 +124,19 @@ B45E7F623E222BDDFA1F654C /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsViewController.swift"; sourceTree = ""; }; CA714FFE139D2B24154FE823 /* RootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RootViewController.swift"; sourceTree = ""; }; D17DD1538E46A00CA7E1AC6E /* BuildState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BuildState.swift"; sourceTree = ""; }; - D5B8E679A3BEA53C6A6764CB /* XcodeCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XcodeCommand.swift"; sourceTree = ""; }; - D74D9541D23263177A094521 /* ProcessRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessRunner.swift"; sourceTree = ""; }; DB8A81626F8D17947E596BD7 /* Project.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Project.swift"; sourceTree = ""; }; FFDF40A968E5585F39C46B1E /* FormSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FormSectionView.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 2E429B78D01BAA5389B9B375 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 450AF2350ED57D1ABD7680A0 /* FrameGenCLI in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 0B0976A84F3A3080B1A0C9FB /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -124,22 +148,35 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1C5D76623698EAC9FE465567 /* FrameGenKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5D3FF7D5CC76A88ACFB9E3DF /* Resources */ = { + isa = PBXGroup; + children = ( + A4BF25418661A63152306EC0 /* Localizable.xcstrings */, + A9297C6DD9393123074FF288 /* InfoPlist.xcstrings */, + ); + path = Resources; + sourceTree = ""; + }; + A2EB0FF954BDFFBAE9E82BD2 /* FrameGenCLI */ = { + isa = PBXGroup; + children = ( + A796FE634C8D4105808032F2 /* main.swift */, + ); + path = FrameGenCLI; + sourceTree = ""; + }; 04FD085B5B8E47DAD5FC6F22 /* FrameGenTests */ = { isa = PBXGroup; children = ( - 400431A229984A2BA6021A3A /* TestSupport.swift */, - 6559978338F272BEE8F5BEE0 /* ProjectInfoTests.swift */, - C99569F0C0D2242246D1458D /* XcodeCommandTests.swift */, + 54EEE01D3B69A2E626A4B561 /* ProjectSettingsTests.swift */, 76E9A0DCFA013DC175158D45 /* BuildStateTests.swift */, - E27F8FBFB4AC5D119EA82AD2 /* ProjectLocatorTests.swift */, - F45CABAA2BB083405EBA03BD /* XCFrameworkBuilderTests.swift */, - 5C7D1B7BC7E3148E64AD39E1 /* ProcessRunnerTests.swift */, ); path = FrameGenTests; sourceTree = ""; @@ -147,6 +184,7 @@ 4B9E804829E1DB6300DFC001 /* FrameGen */ = { isa = PBXGroup; children = ( + 5D3FF7D5CC76A88ACFB9E3DF /* Resources */, 1F2B9BC7173AB73CCC8B7520 /* Application */, B6D0FAE1681BEF3FAFD1F4B8 /* Window */, C9AAEB47152BD95C8FFCBCAC /* Controllers */, @@ -207,8 +245,7 @@ 17D8D6279D7A52D62BCE90D5 /* Models */ = { isa = PBXGroup; children = ( - 4EF1ECD9C14BBEA60A7F12B5 /* Platform.swift */, - 2301D103F3056B6F9E2C2E8A /* ProjectInfo.swift */, + EC69462091F0A23644D25295 /* Platform+UI.swift */, DB8A81626F8D17947E596BD7 /* Project.swift */, D17DD1538E46A00CA7E1AC6E /* BuildState.swift */, 4FC26B0688D03B1E6A31160F /* Workspace.swift */, @@ -219,11 +256,9 @@ 59A4223E3A17BE169F5ED5EB /* Services */ = { isa = PBXGroup; children = ( - D74D9541D23263177A094521 /* ProcessRunner.swift */, - D5B8E679A3BEA53C6A6764CB /* XcodeCommand.swift */, - 5C3B5DCD4782CBA9371395BB /* ProjectLocator.swift */, + E14EBF6728E80E402601F0EA /* ProjectPicker.swift */, + A5EF0F48B45EE379940A5E52 /* BuildNotifier.swift */, 3CA69EB3841AECDDA8FE2CA3 /* Preferences.swift */, - 0F0109521FC69BF5C9CCA7D5 /* XCFrameworkBuilder.swift */, ); path = Services; sourceTree = ""; @@ -256,7 +291,9 @@ 4B9E803D29E1DB6300DFC001 = { isa = PBXGroup; children = ( + C7AC304FBEF6790F70206381 /* FrameGenKit */, 4B9E804829E1DB6300DFC001 /* FrameGen */, + A2EB0FF954BDFFBAE9E82BD2 /* FrameGenCLI */, 04FD085B5B8E47DAD5FC6F22 /* FrameGenTests */, 4B9E804729E1DB6300DFC001 /* Products */, ); @@ -266,6 +303,7 @@ isa = PBXGroup; children = ( 4B9E804629E1DB6300DFC001 /* FrameGen.app */, + 8C65D63BCC10E10D18999878 /* framegen */, 6F9A1917669D5240DD64C7D3 /* FrameGenTests.xctest */, ); name = Products; @@ -274,6 +312,25 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + BFA0A85530AF45B5E004AEC8 /* FrameGenCLI */ = { + isa = PBXNativeTarget; + buildConfigurationList = A9E9FB2E5104A070209EAEA1 /* Build configuration list for PBXNativeTarget "FrameGenCLI" */; + buildPhases = ( + 6EF8DDD50F7FE83A1611B80D /* Sources */, + 2E429B78D01BAA5389B9B375 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = FrameGenCLI; + packageProductDependencies = ( + 1E662F5B2A51E5928EA5DE5F /* FrameGenCLI */, + ); + productName = framegen; + productReference = 8C65D63BCC10E10D18999878 /* framegen */; + productType = "com.apple.product-type.tool"; + }; 3D43AFCF71BAC5808E8553C9 /* FrameGenTests */ = { isa = PBXNativeTarget; buildConfigurationList = 00ED3C2737010EC9CD749A93 /* Build configuration list for PBXNativeTarget "FrameGenTests" */; @@ -301,13 +358,16 @@ 4B9E804229E1DB6300DFC001 /* Sources */, 4B9E804329E1DB6300DFC001 /* Frameworks */, 4B9E804429E1DB6300DFC001 /* Resources */, + 569E894EAB1D3205E340DD4E /* Embed Command Line Tool */, ); buildRules = ( ); dependencies = ( + 67161631E807482F2046C644 /* PBXTargetDependency */, ); name = FrameGen; packageProductDependencies = ( + 88083ACF9EE0E11ECC3293A7 /* FrameGenKit */, ); productName = generateFramework; productReference = 4B9E804629E1DB6300DFC001 /* FrameGen.app */; @@ -324,6 +384,9 @@ LastUpgradeCheck = 2710; ORGANIZATIONNAME = "Ramiz Kichibekov"; TargetAttributes = { + BFA0A85530AF45B5E004AEC8 = { + CreatedOnToolsVersion = 27.1; + }; 4B9E804529E1DB6300DFC001 = { CreatedOnToolsVersion = 14.3; }; @@ -339,17 +402,20 @@ knownRegions = ( en, Base, + ru, ); mainGroup = 4B9E803D29E1DB6300DFC001; minimizedProjectReferenceProxies = 1; preferredProjectObjectVersion = 77; packageReferences = ( + BA75AD55B7EB7E9F3B58FA3A /* XCLocalSwiftPackageReference "Packages/FrameGenKit" */, ); productRefGroup = 4B9E804729E1DB6300DFC001 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 4B9E804529E1DB6300DFC001 /* FrameGen */, + BFA0A85530AF45B5E004AEC8 /* FrameGenCLI */, 3D43AFCF71BAC5808E8553C9 /* FrameGenTests */, ); }; @@ -367,6 +433,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7B043781095E748B046B6A7D /* InfoPlist.xcstrings in Resources */, + D22DF401DCB3670954B05D8F /* Localizable.xcstrings in Resources */, D9C293F961041AED3D12059B /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -374,17 +442,20 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 6EF8DDD50F7FE83A1611B80D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1684B971E9688F48026891BB /* main.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; C1D5A675EB10CD4EBD4D6C18 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 489D4094B3E6E7371963AF3C /* TestSupport.swift in Sources */, - FB936CD51DB1A7AA4E66A812 /* ProjectInfoTests.swift in Sources */, - 8F8E65782BAD13F02DA168C3 /* XcodeCommandTests.swift in Sources */, 269B226DA50FBD34ADC50D92 /* BuildStateTests.swift in Sources */, - 94C9FB2B1653047CD3D88951 /* ProjectLocatorTests.swift in Sources */, - 50EBD5583671FEDD263C30B8 /* XCFrameworkBuilderTests.swift in Sources */, - 68945E8C52E8DFDA51A444BB /* ProcessRunnerTests.swift in Sources */, + 5BF23F17802A2D217FF0BDAA /* ProjectSettingsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -392,6 +463,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A675F55AC3110800DCF0CDD5 /* ProjectPicker.swift in Sources */, + 1F23DEB89FECE631960103F6 /* BuildNotifier.swift in Sources */, + A4859D424E52E9356771FFD0 /* Platform+UI.swift in Sources */, 2597E05CC40E0C90671816AE /* main.swift in Sources */, 95BE9E0747F32914E5DF0C5E /* AppDelegate.swift in Sources */, 2503C114253538F9FC97A806 /* MainMenu.swift in Sources */, @@ -406,16 +480,10 @@ 56654DA6C6D188523CFAC8E4 /* BuildResultViewController.swift in Sources */, A75C65D568B16853AF922C2A /* BuildFailureViewController.swift in Sources */, BFA764D171B9D01638EA2F22 /* SettingsViewController.swift in Sources */, - 3BD2CFAAA964DF83C19DF6BB /* Platform.swift in Sources */, - 2344326BDB2BA918039074D5 /* ProjectInfo.swift in Sources */, 90CF7EAAA83E5271BE1E6B16 /* Project.swift in Sources */, 7199163DAC1FC47989EF33BF /* BuildState.swift in Sources */, 8FCDE32D67EB07691C5D42B2 /* Workspace.swift in Sources */, - DA41911AA058F2CEB4B2007E /* ProcessRunner.swift in Sources */, - 1E4EAF5737CA5A801A30965E /* XcodeCommand.swift in Sources */, - EA6DB51A574BACC17EB5335A /* ProjectLocator.swift in Sources */, 4263B4EBCAE604D628040CC6 /* Preferences.swift in Sources */, - A6C586AED3893B3971EF0E47 /* XCFrameworkBuilder.swift in Sources */, D776777011E6892262EBA91C /* FormSectionView.swift in Sources */, 667B8115596F3B5D06AD38AF /* StatusHeaderView.swift in Sources */, 7203F75BE629B48F6A108854 /* StepRowView.swift in Sources */, @@ -433,6 +501,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 67161631E807482F2046C644 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BFA0A85530AF45B5E004AEC8 /* FrameGenCLI */; + targetProxy = 59533D4F6DC66AD6E0340B19 /* PBXContainerItemProxy */; + }; 151FDAAC81FA51443326CBBC /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 4B9E804529E1DB6300DFC001 /* FrameGen */; @@ -441,6 +514,50 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + D8ED75007B966B3A8517006C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + CODE_SIGN_STYLE = Automatic; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + CURRENT_PROJECT_VERSION = 4; + DEVELOPMENT_TEAM = 8AD8N54BY6; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023–2026 Ramiz Kichibekov. All rights reserved."; + MARKETING_VERSION = 2.0; + PRODUCT_BUNDLE_IDENTIFIER = "ru.ramiz-kichibekov.frameGen.cli"; + PRODUCT_MODULE_NAME = FrameGenCommandLineTool; + PRODUCT_NAME = framegen; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 305254E9B33C06DD0D4F1CDA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + CODE_SIGN_STYLE = Automatic; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + CURRENT_PROJECT_VERSION = 4; + DEVELOPMENT_TEAM = 8AD8N54BY6; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023–2026 Ramiz Kichibekov. All rights reserved."; + MARKETING_VERSION = 2.0; + PRODUCT_BUNDLE_IDENTIFIER = "ru.ramiz-kichibekov.frameGen.cli"; + PRODUCT_MODULE_NAME = FrameGenCommandLineTool; + PRODUCT_NAME = framegen; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; 38928D5A7559732E301B8C90 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -669,6 +786,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + A9E9FB2E5104A070209EAEA1 /* Build configuration list for PBXNativeTarget "FrameGenCLI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D8ED75007B966B3A8517006C /* Debug */, + 305254E9B33C06DD0D4F1CDA /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 00ED3C2737010EC9CD749A93 /* Build configuration list for PBXNativeTarget "FrameGenTests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -697,6 +823,24 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + BA75AD55B7EB7E9F3B58FA3A /* XCLocalSwiftPackageReference "Packages/FrameGenKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/FrameGenKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 88083ACF9EE0E11ECC3293A7 /* FrameGenKit */ = { + isa = XCSwiftPackageProductDependency; + productName = FrameGenKit; + }; + 1E662F5B2A51E5928EA5DE5F /* FrameGenCLI */ = { + isa = XCSwiftPackageProductDependency; + productName = FrameGenCLI; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 4B9E803E29E1DB6300DFC001 /* Project object */; } diff --git a/FrameGen.xcodeproj/xcshareddata/xcschemes/FrameGen.xcscheme b/FrameGen.xcodeproj/xcshareddata/xcschemes/FrameGen.xcscheme index 764ab70..8f05e88 100644 --- a/FrameGen.xcodeproj/xcshareddata/xcschemes/FrameGen.xcscheme +++ b/FrameGen.xcodeproj/xcshareddata/xcschemes/FrameGen.xcscheme @@ -27,7 +27,9 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldAutocreateTestPlan = "YES" + language = "en" + region = "US"> NSApplication.TerminateReply { - mainWindowController.confirmStoppingBuild(action: "Quit") ? .terminateNow : .terminateCancel + mainWindowController.confirmStoppingBuild(action: String(localized: "Stop and Quit")) ? .terminateNow : .terminateCancel } func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { diff --git a/FrameGen/Application/Info.plist b/FrameGen/Application/Info.plist index 3b85472..a4465b6 100644 --- a/FrameGen/Application/Info.plist +++ b/FrameGen/Application/Info.plist @@ -15,6 +15,7 @@ com.apple.xcode.project public.folder + com.apple.dt.document.workspace diff --git a/FrameGen/Application/Menus/MainMenu.swift b/FrameGen/Application/Menus/MainMenu.swift index 63140fa..de7daa8 100644 --- a/FrameGen/Application/Menus/MainMenu.swift +++ b/FrameGen/Application/Menus/MainMenu.swift @@ -7,6 +7,7 @@ // import AppKit +import FrameGenKit /// Builds the standard Mac menu bar plus the Product menu. Items target the responder chain, /// so the main window controller handles and validates the project commands. @@ -16,7 +17,7 @@ final class MainMenu: NSObject { let menu = NSMenu(title: "Main Menu") private let applicationName = ProcessInfo.processInfo.processName - private let recentProjectsMenu = NSMenu(title: "Open Recent") + private let recentProjectsMenu = NSMenu(title: String(localized: "Open Recent")) // MARK: - Initial methods @@ -25,9 +26,9 @@ final class MainMenu: NSObject { recentProjectsMenu.delegate = self menu.items = [submenu(applicationName, items: applicationItems()), - submenu("File", items: fileItems()), - submenu("Edit", items: editItems()), - submenu("Product", items: productItems()), + submenu(String(localized: "File"), items: fileItems()), + submenu(String(localized: "Edit"), items: editItems()), + submenu(String(localized: "Product"), items: productItems()), windowMenu(), helpMenu()] } @@ -35,63 +36,66 @@ final class MainMenu: NSObject { // MARK: - Private methods private func applicationItems() -> [NSMenuItem] { - let services = NSMenuItem(title: "Services", action: nil, keyEquivalent: "") - services.submenu = NSMenu(title: "Services") + let services = NSMenuItem(title: String(localized: "Services"), action: nil, keyEquivalent: "") + services.submenu = NSMenu(title: String(localized: "Services")) NSApp.servicesMenu = services.submenu - return [item("About \(applicationName)", #selector(NSApplication.orderFrontStandardAboutPanel(_:))), + return [item(String(localized: "About \(applicationName)"), #selector(NSApplication.orderFrontStandardAboutPanel(_:))), .separator(), - item("Settings…", #selector(AppDelegate.showSettings(_:)), key: ","), + item(String(localized: "Settings…"), #selector(AppDelegate.showSettings(_:)), key: ","), .separator(), services, .separator(), - item("Hide \(applicationName)", #selector(NSApplication.hide(_:)), key: "h"), - item("Hide Others", #selector(NSApplication.hideOtherApplications(_:)), key: "h", modifiers: [.command, .option]), - item("Show All", #selector(NSApplication.unhideAllApplications(_:))), + item(String(localized: "Hide \(applicationName)"), #selector(NSApplication.hide(_:)), key: "h"), + item(String(localized: "Hide Others"), #selector(NSApplication.hideOtherApplications(_:)), key: "h", modifiers: [.command, .option]), + item(String(localized: "Show All"), #selector(NSApplication.unhideAllApplications(_:))), .separator(), - item("Quit \(applicationName)", #selector(NSApplication.terminate(_:)), key: "q")] + item(String(localized: "Quit \(applicationName)"), #selector(NSApplication.terminate(_:)), key: "q")] } private func fileItems() -> [NSMenuItem] { - let openRecent = NSMenuItem(title: "Open Recent", action: nil, keyEquivalent: "") + let openRecent = NSMenuItem(title: String(localized: "Open Recent"), action: nil, keyEquivalent: "") openRecent.submenu = recentProjectsMenu - return [item("Open Project…", #selector(MainWindowController.openDocument(_:)), key: "o"), + return [item(String(localized: "Open Project…"), #selector(MainWindowController.openDocument(_:)), key: "o"), openRecent, .separator(), - item("Close Project", #selector(MainWindowController.closeProject(_:)), key: "w", modifiers: [.command, .shift]), - item("Close Window", #selector(NSWindow.performClose(_:)), key: "w")] + item(String(localized: "Close Project"), #selector(MainWindowController.closeProject(_:)), key: "w", modifiers: [.command, .shift]), + item(String(localized: "Close Window"), #selector(NSWindow.performClose(_:)), key: "w")] } private func editItems() -> [NSMenuItem] { - [item("Undo", Selector(("undo:")), key: "z"), - item("Redo", Selector(("redo:")), key: "z", modifiers: [.command, .shift]), + [item(String(localized: "Undo"), Selector(("undo:")), key: "z"), + item(String(localized: "Redo"), Selector(("redo:")), key: "z", modifiers: [.command, .shift]), .separator(), - item("Cut", #selector(NSText.cut(_:)), key: "x"), - item("Copy", #selector(NSText.copy(_:)), key: "c"), - item("Paste", #selector(NSText.paste(_:)), key: "v"), - item("Select All", #selector(NSText.selectAll(_:)), key: "a")] + item(String(localized: "Cut"), #selector(NSText.cut(_:)), key: "x"), + item(String(localized: "Copy"), #selector(NSText.copy(_:)), key: "c"), + item(String(localized: "Paste"), #selector(NSText.paste(_:)), key: "v"), + item(String(localized: "Select All"), #selector(NSText.selectAll(_:)), key: "a")] } private func productItems() -> [NSMenuItem] { - [item("Build XCFramework", #selector(MainWindowController.buildXCFramework(_:)), key: "b"), - item("Stop", #selector(MainWindowController.stopBuild(_:)), key: "."), + [item(String(localized: "Build XCFramework"), #selector(MainWindowController.buildXCFramework(_:)), key: "b"), + item(String(localized: "Stop"), #selector(MainWindowController.stopBuild(_:)), key: "."), .separator(), - item("Show Output in Finder", #selector(MainWindowController.showOutputInFinder(_:)), key: "r", modifiers: [.command, .shift])] + item(String(localized: "Publish"), #selector(MainWindowController.publishXCFramework(_:)), key: "u"), + item(String(localized: "Copy Build Command"), #selector(MainWindowController.copyBuildCommand(_:)), key: "c", modifiers: [.command, .option]), + .separator(), + item(String(localized: "Show Output in Finder"), #selector(MainWindowController.showOutputInFinder(_:)), key: "r", modifiers: [.command, .shift])] } private func windowMenu() -> NSMenuItem { - let menuItem = submenu("Window", items: [item("Minimize", #selector(NSWindow.performMiniaturize(_:)), key: "m"), - item("Zoom", #selector(NSWindow.performZoom(_:))), + let menuItem = submenu(String(localized: "Window"), items: [item(String(localized: "Minimize"), #selector(NSWindow.performMiniaturize(_:)), key: "m"), + item(String(localized: "Zoom"), #selector(NSWindow.performZoom(_:))), .separator(), - item("Bring All to Front", #selector(NSApplication.arrangeInFront(_:)))]) + item(String(localized: "Bring All to Front"), #selector(NSApplication.arrangeInFront(_:)))]) NSApp.windowsMenu = menuItem.submenu return menuItem } private func helpMenu() -> NSMenuItem { - let menuItem = submenu("Help", items: []) + let menuItem = submenu(String(localized: "Help"), items: []) NSApp.helpMenu = menuItem.submenu return menuItem @@ -126,7 +130,7 @@ extension MainMenu: NSMenuDelegate { guard menu === recentProjectsMenu else { return } let recentProjects = Preferences.recentProjects - menu.items = recentProjects.map { url in + menu.items = recentProjects.map(\.url).map { url in let menuItem = NSMenuItem(title: url.deletingPathExtension().lastPathComponent, action: #selector(MainWindowController.openRecentProject(_:)), keyEquivalent: "") @@ -140,7 +144,7 @@ extension MainMenu: NSMenuDelegate { if !recentProjects.isEmpty { menu.addItem(.separator()) } - menu.addItem(item("Clear Menu", #selector(MainWindowController.clearRecentProjects(_:)))) + menu.addItem(item(String(localized: "Clear Menu"), #selector(MainWindowController.clearRecentProjects(_:)))) } } diff --git a/FrameGen/Controllers/BuildController.swift b/FrameGen/Controllers/BuildController.swift index 54a0a7f..ac3cade 100644 --- a/FrameGen/Controllers/BuildController.swift +++ b/FrameGen/Controllers/BuildController.swift @@ -7,83 +7,89 @@ // import AppKit +import FrameGenKit -/// Runs the build pipeline for a project: archive every platform, create the XCFramework, -/// zip it and compute the SwiftPM checksum. Progress is published through `Project.build`. +/// Runs `XCFrameworkPipeline` for a project and mirrors its events into `Project.build`, +/// then publishes the result on request. The same pipeline powers the `framegen` tool. final class BuildController { // MARK: - Properties private var task: Task? + private var publishTask: Task? + private let notifier = BuildNotifier() // MARK: - Public methods func start(_ project: Project) { - guard project.canBuild, let scheme = project.scheme, let configuration = project.configuration else { return } + guard project.canBuild, let request = project.makeBuildRequest() else { return } - let platforms = project.selectedPlatforms let state = project.build - state.start(steps: platforms.map { BuildStep(kind: .archive($0)) } - + [BuildStep(kind: .createXCFramework), BuildStep(kind: .package)]) + state.start(steps: []) + notifier.requestAuthorizationIfNeeded() task = Task { - await run(project: project.fileURL, - scheme: scheme, - configuration: configuration, - platforms: platforms, - outputDirectory: project.outputDirectory, - state: state) + await run(request, projectName: project.name, state: state) task = nil } } func cancel() { task?.cancel() + publishTask?.cancel() } - // MARK: - Private methods - - private func run(project: URL, - scheme: String, - configuration: String, - platforms: [Platform], - outputDirectory: URL, - state: BuildState) async { - let archivesDirectory = outputDirectory.appendingPathComponent("Archives", isDirectory: true) - var stepIndex = 0 - do { - var archiveURLs: [URL] = [] - for platform in platforms { - let archiveURL = archivesDirectory.appendingPathComponent("\(scheme)-\(platform.archiveSuffix).xcarchive") - try await perform(step: stepIndex, in: state) { - try await self.stream(XcodeCommand.archive(project: project, - scheme: scheme, - configuration: configuration, - platform: platform, - archiveURL: archiveURL), - into: state) - } - archiveURLs.append(archiveURL) - stepIndex += 1 - } - - let plan = try await perform(step: stepIndex, in: state) { - let plan = try await XCFrameworkBuilder.makePlan(archiveURLs: archiveURLs, outputDirectoryURL: outputDirectory) - try await self.stream(XcodeCommand.createXCFramework(slices: plan.slices, output: plan.xcframeworkURL), into: state) + func publish(_ project: Project) { + guard + case .succeeded(let summary) = project.build.phase, + let destination = project.publishDestination, + project.build.publishState != .publishing + else { + return + } - return plan + let state = project.build + let version = project.version + state.publishState = .publishing + publishTask = Task { + do { + let url = try await Publisher.publish(summary, version: version, to: destination) { state.appendLog($0) } + let updated = try summary.withDownloadURL(url) + state.finish(.succeeded(updated)) + state.publishState = .published(url) + } catch is CancellationError { + state.publishState = .idle + } catch ProcessError.canceled { + state.publishState = .idle + } catch let ProcessError.failed(_, output) { + state.publishState = .failed(LogAnalyzer.highlights(in: output.components(separatedBy: .newlines), limit: 5) + .joined(separator: "\n")) + } catch { + state.publishState = .failed(error.localizedDescription) } - stepIndex += 1 - - let summary = try await perform(step: stepIndex, in: state) { - try await self.stream(XcodeCommand.zip(source: plan.xcframeworkURL, destination: plan.zipURL), into: state) + publishTask = nil + } + } - return try await XCFrameworkBuilder.finalize(plan) - } + // MARK: - Private methods - if !Preferences.keepArchives { - try? FileManager.default.removeItem(at: archivesDirectory) + private func run(_ request: BuildRequest, projectName: String, state: BuildState) async { + do { + let summary = try await XCFrameworkPipeline.run(request) { event in + switch event { + case .planned(let steps): + state.steps = steps.map { BuildStep(kind: $0) } + case .stepStarted(let index): + state.steps[index].status = .running(since: .now) + case let .stepFinished(index, duration): + state.steps[index].status = .succeeded(duration: duration) + case .stepFailed(let index): + state.steps[index].status = .failed + case .log(let line): + state.appendLog(line) + } } state.finish(.succeeded(summary)) + notifier.notify(.succeeded(projectName: projectName, frameworkName: summary.plan.frameworkName)) if Preferences.revealInFinder { NSWorkspace.shared.activateFileViewerSelecting([summary.plan.xcframeworkURL]) } @@ -91,48 +97,22 @@ final class BuildController { state.reset() } catch ProcessError.canceled { state.reset() + } catch let error as PipelineError { + let step = BuildStep(kind: error.step) + let reason: String + if case let ProcessError.failed(status, _) = error.underlying { + reason = String(localized: "The command exited with code \(status). See the highlighted lines below or the full log.") + } else { + reason = error.underlying.localizedDescription + } + state.finish(.failed(BuildFailure(title: step.failureTitle, reason: reason, highlights: error.highlights))) + notifier.notify(.failed(projectName: projectName, title: step.failureTitle)) } catch { - state.finish(.failed(Self.failure(for: error, step: state.steps[stepIndex], log: state.log))) + state.finish(.failed(BuildFailure(title: String(localized: "The build couldn’t start"), + reason: error.localizedDescription, + highlights: []))) } NSApp.requestUserAttention(.informationalRequest) } - private func perform(step index: Int, in state: BuildState, _ body: () async throws -> T) async throws -> T { - let start = Date.now - state.steps[index].status = .running(since: start) - do { - let result = try await body() - state.steps[index].status = .succeeded(duration: Date.now.timeIntervalSince(start)) - - return result - } catch { - state.steps[index].status = Task.isCancelled ? .pending : .failed - throw error - } - } - - private func stream(_ runner: ProcessRunner, into state: BuildState) async throws { - for try await line in runner.lines() { - state.appendLog(line) - } - try Task.checkCancellation() - } - - private static func failure(for error: Error, step: BuildStep, log: [String]) -> BuildFailure { - let reason: String - if case let ProcessError.failed(status, _) = error { - reason = "The command exited with code \(status). See the highlighted lines below or the full log." - } else { - reason = error.localizedDescription - } - - var seen = Set() - let errors = log - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { $0.contains("error:") && seen.insert($0).inserted } - let highlights = errors.isEmpty ? log.suffix(20).filter { !$0.isEmpty } : Array(errors.prefix(20)) - - return BuildFailure(title: step.failureTitle, reason: reason, highlights: highlights) - } - } diff --git a/FrameGen/Controllers/BuildFailureViewController.swift b/FrameGen/Controllers/BuildFailureViewController.swift index 65b4a5f..15fc1e5 100644 --- a/FrameGen/Controllers/BuildFailureViewController.swift +++ b/FrameGen/Controllers/BuildFailureViewController.swift @@ -36,7 +36,7 @@ final class BuildFailureViewController: NSViewController { guard case .failed(let failure) = project.build.phase else { return } let header = StatusHeaderView() - header.setSymbol("xmark.octagon.fill", color: .systemRed, accessibilityDescription: "Failed") + header.setSymbol("xmark.octagon.fill", color: .systemRed, accessibilityDescription: String(localized: "Failed")) header.titleLabel.stringValue = failure.title header.subtitleLabel.stringValue = failure.reason contentView.stackView.addFullWidthArrangedSubview(header) @@ -51,7 +51,7 @@ final class BuildFailureViewController: NSViewController { contentView.stackView.addFullWidthArrangedSubview(stepsSection) if !failure.highlights.isEmpty { - let section = FormSectionView(title: "Errors") + let section = FormSectionView(title: String(localized: "Errors")) let text = NSTextField.text(failure.highlights.joined(separator: "\n"), font: .monospaced(.callout), color: .labelColor, lines: 0, selectable: true) text.preferredMaxLayoutWidth = Metrics.contentWidth - 24 @@ -63,8 +63,8 @@ final class BuildFailureViewController: NSViewController { contentView.stackView.addFullWidthArrangedSubview(section) } - let logTitle = NSTextField.text("Build Log", font: .preferred(.headline)) - let copyLog = CopyButton(title: "Copy Log") { [weak self] in + let logTitle = NSTextField.text(String(localized: "Build Log"), font: .preferred(.headline)) + let copyLog = CopyButton(title: String(localized: "Copy Log")) { [weak self] in self?.project.build.log.joined(separator: "\n") ?? "" } copyLog.controlSize = .small @@ -73,10 +73,10 @@ final class BuildFailureViewController: NSViewController { contentView.stackView.addFullWidthArrangedSubview(NSStackView.horizontal([logTitle, .spacer(), copyLog])) contentView.stackView.addFullWidthArrangedSubview(logView) - let backButton = NSButton.push("Edit Options", target: self, action: #selector(back(_:))) + let backButton = NSButton.push(String(localized: "Edit Options"), target: self, action: #selector(back(_:))) backButton.controlSize = .large backButton.keyEquivalent = "\u{1b}" - let retryButton = NSButton.push("Try Again", symbol: "arrow.clockwise", target: nil, + let retryButton = NSButton.push(String(localized: "Try Again"), symbol: "arrow.clockwise", target: nil, action: #selector(MainWindowController.buildXCFramework(_:))) retryButton.controlSize = .large retryButton.keyEquivalent = "\r" diff --git a/FrameGen/Controllers/BuildProgressViewController.swift b/FrameGen/Controllers/BuildProgressViewController.swift index 63df25f..ca1bc0f 100644 --- a/FrameGen/Controllers/BuildProgressViewController.swift +++ b/FrameGen/Controllers/BuildProgressViewController.swift @@ -7,6 +7,7 @@ // import AppKit +import FrameGenKit /// Live progress of a build: the step list, the current xcodebuild action and the log. final class BuildProgressViewController: NSViewController { @@ -50,17 +51,15 @@ final class BuildProgressViewController: NSViewController { activityLabel.lineBreakMode = .byTruncatingMiddle activityLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - stepRows = project.build.steps.map { _ in StepRowView() } - stepsSection.setRows(stepRows) logDisclosure.bezelStyle = .disclosure logDisclosure.setButtonType(.pushOnPushOff) logDisclosure.title = "" logDisclosure.target = self logDisclosure.action = #selector(toggleLog(_:)) - logDisclosure.setAccessibilityLabel("Show build log") - let logTitle = NSTextField.text("Build Log", font: .preferred(.headline)) - let copyLog = CopyButton(title: "Copy Log") { [weak self] in + logDisclosure.setAccessibilityLabel(String(localized: "Show build log")) + let logTitle = NSTextField.text(String(localized: "Build Log"), font: .preferred(.headline)) + let copyLog = CopyButton(title: String(localized: "Copy Log")) { [weak self] in self?.project.build.log.joined(separator: "\n") ?? "" } copyLog.controlSize = .small @@ -68,10 +67,10 @@ final class BuildProgressViewController: NSViewController { logHeightConstraint.isActive = true logView.isHidden = true - let stopButton = NSButton.push("Stop", symbol: "stop.fill", target: nil, action: #selector(MainWindowController.stopBuild(_:))) + let stopButton = NSButton.push(String(localized: "Stop"), symbol: "stop.fill", target: nil, action: #selector(MainWindowController.stopBuild(_:))) stopButton.controlSize = .large stopButton.keyEquivalent = "\u{1b}" - stopButton.toolTip = "Stop the build (⌘.)" + stopButton.toolTip = String(localized: "Stop the build (⌘.)") [header, activityLabel, stepsSection, logHeader, logView].forEach(contentView.stackView.addFullWidthArrangedSubview) contentView.stackView.setCustomSpacing(Metrics.spacing, after: header) @@ -105,22 +104,30 @@ final class BuildProgressViewController: NSViewController { private func update() { let build = project.build - let platformCount = build.steps.count - 2 - header.titleLabel.stringValue = "Building \(project.scheme ?? project.name)" + let platformCount = build.steps.filter { step in + if case .archive = step.kind { true } else { false } + }.count + header.titleLabel.stringValue = String(localized: "Building \(project.scheme ?? project.name)") activityLabel.stringValue = build.activity if !logView.isHidden { logView.update(lines: build.log) } updateClock() - header.subtitleLabel.stringValue = "\(project.configuration ?? "") · \(platformCount) platform\(platformCount == 1 ? "" : "s")" + let configuration = project.configuration ?? "" + header.subtitleLabel.stringValue = String(localized: "\(configuration) · \(platformCount) platforms") } private func updateClock() { let build = project.build + // Steps arrive with the pipeline's first event, after this screen is already shown. + if stepRows.count != build.steps.count { + stepRows = build.steps.map { _ in StepRowView() } + stepsSection.setRows(stepRows) + } for (row, step) in zip(stepRows, build.steps) { row.configure(with: step) } - view.window?.subtitle = "Building… \(Formatters.duration(build.duration))" + view.window?.subtitle = String(localized: "Building… \(Formatters.duration(build.duration))") } // MARK: - Actions @@ -128,7 +135,7 @@ final class BuildProgressViewController: NSViewController { @objc private func toggleLog(_ sender: NSButton) { logView.isHidden = sender.state == .off - sender.setAccessibilityLabel(logView.isHidden ? "Show build log" : "Hide build log") + sender.setAccessibilityLabel(logView.isHidden ? String(localized: "Show build log") : String(localized: "Hide build log")) if !logView.isHidden { logView.update(lines: project.build.log) } diff --git a/FrameGen/Controllers/BuildResultViewController.swift b/FrameGen/Controllers/BuildResultViewController.swift index 837d481..b624bd7 100644 --- a/FrameGen/Controllers/BuildResultViewController.swift +++ b/FrameGen/Controllers/BuildResultViewController.swift @@ -7,20 +7,26 @@ // import AppKit +import FrameGenKit -/// Shows what was built and everything needed to publish it as a SwiftPM binary target. +/// Shows what was built, publishes it, and gives everything needed for a SwiftPM binary target. final class BuildResultViewController: NSViewController { // MARK: - Properties private let project: Project + private let buildController: BuildController + private let contentView = ScrollingContentView() + private var observation: ObservationLoop? + private var renderedState: (XCFrameworkBuilder.Summary, PublishState)? private static let privacyManifestURL = URL(string: "https://developer.apple.com/documentation/bundleresources/privacy-manifest-files")! // MARK: - Initial methods - init(project: Project) { + init(project: Project, buildController: BuildController) { self.project = project + self.buildController = buildController super.init(nibName: nil, bundle: nil) } @@ -32,54 +38,129 @@ final class BuildResultViewController: NSViewController { // MARK: - Life cycle override func loadView() { - let contentView = ScrollingContentView() view = contentView + } + + override func viewDidLoad() { + super.viewDidLoad() + + observation = ObservationLoop { [weak self] in + self?.render() + } + } + + override func viewWillDisappear() { + super.viewWillDisappear() + observation?.cancel() + } + + // MARK: - Private methods + + /// Rebuilt whenever publishing changes the snippet or its own status. + private func render() { guard case .succeeded(let summary) = project.build.phase else { return } + let publishState = project.build.publishState + let destination = project.publishDestination + if let renderedState, renderedState.0 == summary, renderedState.1 == publishState { + return + } + renderedState = (summary, publishState) + + let stack = contentView.stackView + stack.arrangedSubviews.forEach { $0.removeFromSuperview() } + let header = StatusHeaderView() - header.setSymbol("checkmark.seal.fill", color: .systemGreen, accessibilityDescription: "Succeeded") - header.titleLabel.stringValue = "\(summary.plan.xcframeworkURL.lastPathComponent) is ready" - let platformCount = summary.plan.slices.count - header.subtitleLabel.stringValue = "Built in \(Formatters.duration(project.build.duration)) · " - + "\(platformCount) platform\(platformCount == 1 ? "" : "s") · \(Formatters.fileSize(summary.zipSize)) zipped" - contentView.stackView.addFullWidthArrangedSubview(header) + header.setSymbol("checkmark.seal.fill", color: .systemGreen, accessibilityDescription: String(localized: "Succeeded")) + header.titleLabel.stringValue = String(localized: "\(summary.plan.xcframeworkURL.lastPathComponent) is ready") + var details = [String(localized: "Built in \(Formatters.duration(project.build.duration))"), + String(localized: "\(summary.plan.slices.count) platforms"), + String(localized: "\(Formatters.fileSize(summary.zipSize)) zipped")] + if let signedBy = summary.signedBy { + details.append(String(localized: "signed by \(signedBy)")) + } + header.subtitleLabel.stringValue = details.joined(separator: " · ") + stack.addFullWidthArrangedSubview(header) + if let callout = publishCallout(publishState) { + stack.addFullWidthArrangedSubview(callout) + } if !summary.librariesWithoutPrivacyManifest.isEmpty { - let learnMore = NSButton.push("Learn About Privacy Manifests", target: self, action: #selector(openPrivacyDocumentation(_:))) + let learnMore = NSButton.push(String(localized: "Learn About Privacy Manifests"), target: self, action: #selector(openPrivacyDocumentation(_:))) learnMore.bezelStyle = .inline - contentView.stackView.addFullWidthArrangedSubview( + stack.addFullWidthArrangedSubview( CalloutView(style: .warning, - title: "No privacy manifest", - message: "PrivacyInfo.xcprivacy is missing in \(summary.librariesWithoutPrivacyManifest.joined(separator: ", ")). " - + "Apple requires privacy manifests in commonly used third-party SDKs.", + title: String(localized: "No privacy manifest"), + message: String(localized: "PrivacyInfo.xcprivacy is missing in \(summary.librariesWithoutPrivacyManifest.joined(separator: ", ")). Apple requires privacy manifests in commonly used third-party SDKs."), action: learnMore)) } + stack.addFullWidthArrangedSubview(makeFilesSection(summary)) + stack.addFullWidthArrangedSubview(makePackageSection(summary)) - contentView.stackView.addFullWidthArrangedSubview(makeFilesSection(summary)) - contentView.stackView.addFullWidthArrangedSubview(makePackageSection(summary)) - - let doneButton = NSButton.push("Done", target: self, action: #selector(done(_:))) + let doneButton = NSButton.push(String(localized: "Done"), target: self, action: #selector(done(_:))) doneButton.controlSize = .large doneButton.keyEquivalent = "\u{1b}" - let buildAgain = NSButton.push("Build Again", target: nil, action: #selector(MainWindowController.buildXCFramework(_:))) + let buildAgain = NSButton.push(String(localized: "Build Again"), target: nil, action: #selector(MainWindowController.buildXCFramework(_:))) buildAgain.controlSize = .large - let showInFinder = NSButton.push("Show in Finder", symbol: "folder", target: self, action: #selector(showInFinder(_:))) + let showInFinder = NSButton.push(String(localized: "Show in Finder"), symbol: "folder", target: self, action: #selector(showInFinder(_:))) showInFinder.controlSize = .large - showInFinder.keyEquivalent = "\r" - contentView.setBottomBar(leading: [doneButton], trailing: [buildAgain, showInFinder]) + var trailing: [NSView] = [buildAgain, showInFinder] + if destination != nil { + let publish = NSButton.push(publishTitle(destination), symbol: "arrow.up.circle", target: self, action: #selector(publish(_:))) + publish.controlSize = .large + publish.isEnabled = publishState != .publishing + publish.keyEquivalent = "\r" + trailing.append(publish) + } else { + showInFinder.keyEquivalent = "\r" + } + contentView.setBottomBar(leading: [doneButton], trailing: trailing) } - // MARK: - Private methods + private func publishTitle(_ destination: PublishDestination?) -> String { + if case .gitHub? = destination { + return String(localized: "Publish to GitHub") + } + + return String(localized: "Publish") + } + + private func publishCallout(_ state: PublishState) -> NSView? { + switch state { + case .idle: + return nil + case .publishing: + let spinner = NSProgressIndicator() + spinner.style = .spinning + spinner.controlSize = .small + spinner.startAnimation(nil) + + return NSStackView.horizontal([spinner, NSTextField.text(String(localized: "Uploading…"), color: .secondaryLabelColor), .spacer()]) + case .published(let url): + let open = NSButton.push(String(localized: "Copy URL"), target: self, action: #selector(copyDownloadURL(_:))) + open.bezelStyle = .inline + + return CalloutView(style: .success, + title: String(localized: "Published"), + message: String(localized: "SwiftPM can download the zip from \(url.absoluteString). The snippet below uses this URL."), + action: open) + case .failed(let message): + let retry = NSButton.push(String(localized: "Try Again"), target: self, action: #selector(publish(_:))) + + return CalloutView(style: .error, title: String(localized: "Publishing failed"), message: message, action: retry) + } + } private func makeFilesSection(_ summary: XCFrameworkBuilder.Summary) -> NSView { - let section = FormSectionView(title: "Files") + let section = FormSectionView(title: String(localized: "Files")) let rows = [summary.plan.xcframeworkURL, summary.plan.zipURL, summary.plan.snippetURL].map { url in - let reveal = NSButton(image: NSImage(systemSymbolName: "arrow.forward.circle.fill", accessibilityDescription: "Show in Finder")!, + let reveal = NSButton(image: NSImage(systemSymbolName: "arrow.forward.circle.fill", + accessibilityDescription: String(localized: "Show in Finder"))!, target: self, action: #selector(revealFile(_:))) reveal.isBordered = false reveal.contentTintColor = .secondaryLabelColor - reveal.toolTip = "Show in Finder" + reveal.toolTip = String(localized: "Show in Finder") reveal.identifier = NSUserInterfaceItemIdentifier(url.path) let row = FormRowView(title: url.lastPathComponent, subtitle: Formatters.path(url.deletingLastPathComponent()), @@ -94,17 +175,17 @@ final class BuildResultViewController: NSViewController { } private func makePackageSection(_ summary: XCFrameworkBuilder.Summary) -> NSView { - let section = FormSectionView(title: "Swift Package Manager") + let section = FormSectionView(title: String(localized: "Swift Package Manager")) let checksumLabel = NSTextField.text(summary.checksum, font: .monospaced(.callout), selectable: true) checksumLabel.lineBreakMode = .byTruncatingMiddle checksumLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let copyChecksum = CopyButton { summary.checksum } - let checksumRow = FormRowView(title: "Checksum", accessory: NSStackView.horizontal([checksumLabel, copyChecksum])) + let checksumRow = FormRowView(title: String(localized: "Checksum"), accessory: NSStackView.horizontal([checksumLabel, copyChecksum])) let snippet = NSTextField.text(summary.packageSnippet.trimmingCharacters(in: .newlines), font: .monospaced(.callout), lines: 0, selectable: true) - let copySnippet = CopyButton(title: "Copy Snippet") { summary.packageSnippet } + let copySnippet = CopyButton(title: String(localized: "Copy Snippet")) { summary.packageSnippet } let snippetStack = NSStackView.vertical([snippet, copySnippet], spacing: 10) let snippetContainer = NSView() snippetContainer.translatesAutoresizingMaskIntoConstraints = false @@ -112,7 +193,9 @@ final class BuildResultViewController: NSViewController { snippetStack.pinEdges(to: snippetContainer, insets: NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)) section.setRows([checksumRow, snippetContainer]) - section.footer = "Upload the zip, replace the URL in the snippet with its download link and add the target to Package.swift." + section.footer = summary.downloadURL == nil + ? String(localized: "Upload the zip, replace the URL in the snippet with its download link and add the target to Package.swift. Set a download URL or a publish destination in the project options to skip this step.") + : String(localized: "Add the target to Package.swift. Private hosts need credentials in ~/.netrc for SwiftPM to download the zip.") return section } @@ -124,6 +207,19 @@ final class BuildResultViewController: NSViewController { project.build.reset() } + @objc + private func publish(_ sender: Any?) { + buildController.publish(project) + } + + @objc + private func copyDownloadURL(_ sender: Any?) { + guard case .published(let url) = project.build.publishState else { return } + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url.absoluteString, forType: .string) + } + @objc private func showInFinder(_ sender: Any?) { guard case .succeeded(let summary) = project.build.phase else { return } diff --git a/FrameGen/Controllers/ConfigurationViewController.swift b/FrameGen/Controllers/ConfigurationViewController.swift index d525b65..3f226ab 100644 --- a/FrameGen/Controllers/ConfigurationViewController.swift +++ b/FrameGen/Controllers/ConfigurationViewController.swift @@ -7,8 +7,9 @@ // import AppKit +import FrameGenKit -/// The build options of a project: scheme, configuration, platforms and output folder. +/// The build options of a project: scheme, version, platforms, signing, output and distribution. final class ConfigurationViewController: NSViewController { // MARK: - Properties @@ -17,19 +18,32 @@ final class ConfigurationViewController: NSViewController { private var observation: ObservationLoop? private let contentView = ScrollingContentView() - private let buildSection = FormSectionView(title: "Build") - private let platformsSection = FormSectionView(title: "Platforms") - private let outputSection = FormSectionView(title: "Output") private let statusContainer = NSStackView.vertical([], alignment: .leading) + private let buildSection = FormSectionView(title: String(localized: "Build")) + private let platformsSection = FormSectionView(title: String(localized: "Platforms")) + private let signingSection = FormSectionView(title: String(localized: "Signing")) + private let outputSection = FormSectionView(title: String(localized: "Output")) + private let distributionSection = FormSectionView(title: String(localized: "Distribution")) private let schemePopUp = NSPopUpButton() private let configurationPopUp = NSPopUpButton() + private let versionField = NSTextField() private var platformSwitches: [Platform: NSSwitch] = [:] + private let identityPopUp = NSPopUpButton() private let outputPathControl = NSPathControl() private let resetOutputButton = NSButton() + private let distributionPopUp = NSPopUpButton() + private let downloadURLField = NSTextField() + private let publishCommandField = NSTextField() + private let tagField = NSTextField() + private let draftCheckbox = NSButton(checkboxWithTitle: String(localized: "Create as a draft"), target: nil, action: nil) private let summaryLabel = NSTextField.text(color: .secondaryLabelColor) private let buildButton = NSButton() - private var shownInfoState: Project.InfoState? + + private var shownStatus: [String] = [] + private var shownPlatformState: (Project.LoadState, Set)? + private var shownIdentities: [SigningIdentity]? + private var shownDistribution: (DistributionMode, GitRemote?)? // MARK: - Initial methods @@ -46,22 +60,27 @@ final class ConfigurationViewController: NSViewController { // MARK: - Life cycle override func loadView() { + configureControls() configureBuildSection() - configurePlatformsSection() configureOutputSection() - buildButton.title = "Build XCFramework" + buildButton.title = String(localized: "Build XCFramework") buildButton.image = NSImage(systemSymbolName: "play.fill", accessibilityDescription: nil) buildButton.imagePosition = .imageLeading buildButton.bezelStyle = .push buildButton.controlSize = .large buildButton.keyEquivalent = "\r" buildButton.action = #selector(MainWindowController.buildXCFramework(_:)) - buildButton.toolTip = "Build the XCFramework (⌘B)" + buildButton.toolTip = String(localized: "Build the XCFramework (⌘B)") + + let copyCommandButton = NSButton.push(String(localized: "Copy Command"), symbol: "terminal", target: nil, + action: #selector(MainWindowController.copyBuildCommand(_:))) + copyCommandButton.toolTip = String(localized: "Copy the equivalent framegen command for CI (⌥⌘C)") contentView.stackView.addFullWidthArrangedSubview(statusContainer) - [buildSection, platformsSection, outputSection].forEach(contentView.stackView.addFullWidthArrangedSubview) - contentView.setBottomBar(leading: [summaryLabel], trailing: [buildButton]) + [buildSection, platformsSection, signingSection, outputSection, distributionSection] + .forEach(contentView.stackView.addFullWidthArrangedSubview) + contentView.setBottomBar(leading: [copyCommandButton, summaryLabel], trailing: [buildButton]) view = contentView } @@ -84,35 +103,52 @@ final class ConfigurationViewController: NSViewController { NotificationCenter.default.removeObserver(self) } - // MARK: - Private methods + // MARK: - Configuration - private func configureBuildSection() { - schemePopUp.target = self + private func configureControls() { + for popUp in [schemePopUp, configurationPopUp, identityPopUp, distributionPopUp] { + popUp.translatesAutoresizingMaskIntoConstraints = false + popUp.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true + popUp.target = self + } schemePopUp.action = #selector(didSelectScheme(_:)) - schemePopUp.setAccessibilityLabel("Scheme") - configurationPopUp.target = self + schemePopUp.setAccessibilityLabel(String(localized: "Scheme")) configurationPopUp.action = #selector(didSelectConfiguration(_:)) - configurationPopUp.setAccessibilityLabel("Configuration") - [schemePopUp, configurationPopUp].forEach { - $0.translatesAutoresizingMaskIntoConstraints = false - $0.widthAnchor.constraint(greaterThanOrEqualToConstant: 200).isActive = true + configurationPopUp.setAccessibilityLabel(String(localized: "Configuration")) + identityPopUp.action = #selector(didSelectIdentity(_:)) + identityPopUp.setAccessibilityLabel(String(localized: "Signing identity")) + distributionPopUp.action = #selector(didSelectDistribution(_:)) + distributionPopUp.setAccessibilityLabel(String(localized: "Distribution")) + distributionPopUp.addItems(withTitles: [String(localized: "Upload manually"), + String(localized: "GitHub Release"), + String(localized: "Custom command")]) + + for field in [versionField, downloadURLField, publishCommandField, tagField] { + field.translatesAutoresizingMaskIntoConstraints = false + field.delegate = self + field.lineBreakMode = .byTruncatingTail + field.usesSingleLineMode = true + field.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true } - buildSection.setRows([FormRowView(title: "Scheme", accessory: schemePopUp), - FormRowView(title: "Configuration", accessory: configurationPopUp)]) + versionField.setAccessibilityLabel(String(localized: "Version")) + downloadURLField.placeholderString = "https://cdn.example.com/{name}/{version}/{zip}" + downloadURLField.setAccessibilityLabel(String(localized: "Download URL")) + downloadURLField.font = .monospaced(.callout) + publishCommandField.placeholderString = #"aws s3 cp {path} "s3://bucket/{name}/{version}/{zip}""# + publishCommandField.setAccessibilityLabel(String(localized: "Upload command")) + publishCommandField.font = .monospaced(.callout) + tagField.setAccessibilityLabel(String(localized: "Release tag")) + tagField.font = .monospaced(.callout) + draftCheckbox.target = self + draftCheckbox.action = #selector(didToggleDraft(_:)) } - private func configurePlatformsSection() { - let rows = Platform.allCases.map { platform in - let toggle = NSSwitch() - toggle.target = self - toggle.action = #selector(didTogglePlatform(_:)) - toggle.setAccessibilityLabel(platform.title) - platformSwitches[platform] = toggle - - return FormRowView(title: platform.title, subtitle: platform.detail, symbol: platform.systemImage, accessory: toggle) - } - platformsSection.setRows(rows) - platformsSection.footer = "Each platform is archived separately and becomes one slice of the XCFramework." + private func configureBuildSection() { + buildSection.setRows([FormRowView(title: String(localized: "Scheme"), accessory: schemePopUp), + FormRowView(title: String(localized: "Configuration"), accessory: configurationPopUp), + FormRowView(title: String(localized: "Version"), + subtitle: String(localized: "Used by {version} in URLs and release tags"), + accessory: versionField)]) } private func configureOutputSection() { @@ -123,71 +159,180 @@ final class ConfigurationViewController: NSViewController { outputPathControl.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) outputPathControl.target = self outputPathControl.doubleAction = #selector(showOutputFolder(_:)) - outputPathControl.setAccessibilityLabel("Output folder") + outputPathControl.setAccessibilityLabel(String(localized: "Output folder")) - let chooseButton = NSButton.push("Choose…", target: self, action: #selector(chooseOutputFolder(_:))) - resetOutputButton.title = "Use Default" + let chooseButton = NSButton.push(String(localized: "Choose…"), target: self, action: #selector(chooseOutputFolder(_:))) + resetOutputButton.title = String(localized: "Use Default") resetOutputButton.bezelStyle = .push resetOutputButton.target = self resetOutputButton.action = #selector(resetOutputFolder(_:)) - resetOutputButton.toolTip = "Use the location from Settings" - let buttons = NSStackView.horizontal([resetOutputButton, chooseButton]) - - let folderRow = FormRowView(title: "Folder", accessory: buttons) - let pathContainer = NSView() - pathContainer.translatesAutoresizingMaskIntoConstraints = false - pathContainer.addSubview(outputPathControl) - outputPathControl.pinEdges(to: pathContainer, insets: NSEdgeInsets(top: 0, left: 8, bottom: 10, right: 12)) - outputSection.setRows([folderRow, pathContainer]) + resetOutputButton.toolTip = String(localized: "Use the location from Settings") + + let folderRow = FormRowView(title: String(localized: "Folder"), accessory: NSStackView.horizontal([resetOutputButton, chooseButton])) + outputSection.setRows([folderRow, inset(outputPathControl)]) + outputSection.footer = String(localized: "Contains the .xcframework, its zip archive and a SwiftPM binaryTarget snippet. Intermediate archives go to the Archives subfolder.") } + // MARK: - Updates + private func update() { updateStatus() let isLoaded = project.infoState == .loaded reloadItems(of: schemePopUp, with: project.schemes, selected: project.scheme) reloadItems(of: configurationPopUp, with: project.configurations, selected: project.configuration) - schemePopUp.isEnabled = isLoaded + schemePopUp.isEnabled = isLoaded && !project.build.isRunning configurationPopUp.isEnabled = isLoaded - - for (platform, toggle) in platformSwitches { - toggle.state = project.platforms.contains(platform) ? .on : .off + versionField.placeholderString = project.schemeInfo?.marketingVersion ?? "1.0" + if versionField.currentEditor() == nil { + versionField.stringValue = project.customVersion ?? "" } + updatePlatforms() + updateSigning() + outputPathControl.url = project.outputDirectory resetOutputButton.isHidden = project.customOutputDirectory == nil - outputSection.footer = "Contains the .xcframework, its zip archive and a SwiftPM binaryTarget snippet. " - + "Intermediate archives go to the Archives subfolder." + + updateDistribution() buildButton.isEnabled = project.canBuild summaryLabel.stringValue = summary() } private func updateStatus() { - guard project.infoState != shownInfoState else { return } + var messages: [(title: String, message: String, retry: Bool)] = [] + if case .failed(let message) = project.infoState { + messages.append((String(localized: "Couldn’t read the project"), message, true)) + } else if case .failed(let message) = project.schemeInfoState { + messages.append((String(localized: "Couldn’t read the scheme’s build settings"), message, true)) + } + let key = messages.map(\.message) + [project.infoState == .loading ? "loading" : ""] + guard key != shownStatus else { return } - shownInfoState = project.infoState + shownStatus = key statusContainer.arrangedSubviews.forEach { $0.removeFromSuperview() } - switch project.infoState { - case .loading: - let spinner = NSProgressIndicator() - spinner.style = .spinning - spinner.controlSize = .small - spinner.startAnimation(nil) - let label = NSTextField.text("Reading schemes and configurations…", color: .secondaryLabelColor) - statusContainer.addFullWidthArrangedSubview(NSStackView.horizontal([spinner, label, .spacer()])) - case .loaded: - break - case .failed(let message): - let retry = NSButton.push("Try Again", target: self, action: #selector(reloadProject(_:))) - statusContainer.addFullWidthArrangedSubview(CalloutView(style: .error, - title: "Couldn’t read the project", - message: message, - action: retry)) + if project.infoState == .loading { + statusContainer.addFullWidthArrangedSubview(spinnerRow(String(localized: "Reading schemes and configurations…"))) + } + for message in messages { + let retry = NSButton.push(String(localized: "Try Again"), target: self, action: #selector(reloadProject(_:))) + statusContainer.addFullWidthArrangedSubview(CalloutView(style: .error, title: message.title, message: message.message, action: retry)) } statusContainer.isHidden = statusContainer.arrangedSubviews.isEmpty } + private func updatePlatforms() { + let state = (project.schemeInfoState, project.supportedPlatforms) + if shownPlatformState.map({ $0.0 != state.0 || $0.1 != state.1 }) ?? true { + shownPlatformState = state + platformSwitches = [:] + switch project.schemeInfoState { + case .loading: + platformsSection.setRows([inset(spinnerRow(String(localized: "Detecting supported platforms…")))]) + platformsSection.footer = nil + case .failed: + platformsSection.setRows([inset(NSTextField.text(String(localized: "Platforms appear once the scheme’s build settings are read."), + color: .secondaryLabelColor, lines: 0))]) + platformsSection.footer = nil + case .loaded: + let supported = Platform.allCases.filter(project.supportedPlatforms.contains) + platformsSection.setRows(supported.map { platform in + let toggle = NSSwitch() + toggle.target = self + toggle.action = #selector(didTogglePlatform(_:)) + toggle.setAccessibilityLabel(platform.title) + platformSwitches[platform] = toggle + + return FormRowView(title: platform.title, subtitle: platform.detail, symbol: platform.systemImage, accessory: toggle) + }) + let unsupported = Platform.Family.allCases + .filter { family in !supported.contains { $0.family == family } } + .map(\.title) + platformsSection.footer = unsupported.isEmpty + ? String(localized: "Each platform is archived separately and becomes one slice of the XCFramework.") + : String(localized: "Each platform is archived separately and becomes one slice of the XCFramework. Not supported by this scheme: \(unsupported.formatted(.list(type: .and))).") + } + } + let selected = Set(project.selectedPlatforms) + for (platform, toggle) in platformSwitches { + toggle.state = selected.contains(platform) ? .on : .off + } + } + + private func updateSigning() { + if shownIdentities != project.signingIdentities { + shownIdentities = project.signingIdentities + identityPopUp.removeAllItems() + identityPopUp.addItem(withTitle: String(localized: "Don’t Sign")) + for identity in project.signingIdentities { + identityPopUp.addItem(withTitle: identity.name) + identityPopUp.lastItem?.representedObject = identity.sha1 + identityPopUp.lastItem?.toolTip = identity.sha1 + } + signingSection.setRows([FormRowView(title: String(localized: "Identity"), symbol: "signature", accessory: identityPopUp)]) + signingSection.footer = project.signingIdentities.isEmpty + ? String(localized: "No signing certificates were found in the keychain.") + : String(localized: "Apps that embed the XCFramework can verify who built it. Apple requires signatures for commonly used SDKs. Signing needs a network connection for the timestamp.") + } + if let identity = project.signingIdentity, let index = project.signingIdentities.firstIndex(of: identity) { + identityPopUp.selectItem(at: index + 1) + } else { + identityPopUp.selectItem(at: 0) + } + } + + private func updateDistribution() { + let state = (project.distribution, project.gitRemote) + if shownDistribution.map({ $0.0 != state.0 || $0.1 != state.1 }) ?? true { + shownDistribution = state + var rows: [NSView] = [FormRowView(title: String(localized: "Publish"), symbol: "shippingbox", accessory: distributionPopUp)] + switch project.distribution { + case .manual: + rows.append(FormRowView(title: String(localized: "Download URL"), + subtitle: String(localized: "Optional; fills the binaryTarget snippet"), + accessory: downloadURLField)) + distributionSection.footer = String(localized: "Upload the zip anywhere SwiftPM can download it over HTTPS. Placeholders: {name}, {version}, {zip}.") + case .gitHubRelease: + let repository = project.gitRemote.map { "\($0.host)/\($0.path)" } ?? String(localized: "No GitHub remote") + rows.append(FormRowView(title: String(localized: "Repository"), subtitle: repository, symbol: nil, accessory: nil)) + rows.append(FormRowView(title: String(localized: "Release tag"), accessory: tagField)) + rows.append(FormRowView(title: String(localized: "Draft"), accessory: draftCheckbox)) + distributionSection.footer = project.gitRemote?.isGitHub == false + ? String(localized: "The origin remote isn’t on github.com; GitHub CLI must be signed in to that host.") + : String(localized: "Uses GitHub CLI (gh). The release is created if it doesn’t exist, otherwise the zip replaces its asset.") + case .command: + let presets = NSPopUpButton(frame: .zero, pullsDown: true) + presets.addItem(withTitle: String(localized: "Fill from Preset")) + for preset in PublishPreset.allCases { + presets.addItem(withTitle: preset.title) + presets.lastItem?.representedObject = preset.rawValue + } + presets.target = self + presets.action = #selector(didChoosePreset(_:)) + rows.append(FormRowView(title: String(localized: "Upload command"), + subtitle: String(localized: "Runs in zsh; {path} is the zip"), + accessory: NSStackView.horizontal([publishCommandField, presets]))) + rows.append(FormRowView(title: String(localized: "Download URL"), accessory: downloadURLField)) + distributionSection.footer = String(localized: "Works with GitLab, Bitbucket, S3, Artifactory, Nexus or any internal server. Values are shell-quoted; define tokens in ~/.zprofile. Placeholders: {name}, {version}, {zip}, {path}, {checksum}.") + } + distributionSection.setRows(rows) + } + distributionPopUp.selectItem(at: DistributionMode.allCases.firstIndex(of: project.distribution) ?? 0) + distributionPopUp.item(at: 1)?.isEnabled = project.gitRemote != nil + distributionPopUp.autoenablesItems = false + if downloadURLField.currentEditor() == nil { + downloadURLField.stringValue = project.downloadURLTemplate + } + if publishCommandField.currentEditor() == nil { + publishCommandField.stringValue = project.publishCommand + } + if tagField.currentEditor() == nil { + tagField.stringValue = project.tagTemplate + } + draftCheckbox.state = project.publishAsDraft ? .on : .off + } + private func reloadItems(of popUp: NSPopUpButton, with titles: [String], selected: String?) { if popUp.itemTitles != titles { popUp.removeAllItems() @@ -202,13 +347,32 @@ final class ConfigurationViewController: NSViewController { guard project.infoState == .loaded, let scheme = project.scheme, let configuration = project.configuration else { return "" } - guard !project.platforms.isEmpty else { - return "Turn on at least one platform." + guard project.schemeInfoState == .loaded else { + return "" + } + guard !project.selectedPlatforms.isEmpty else { + return String(localized: "Turn on at least one platform.") } - let count = project.platforms.count + return String(localized: "\(scheme) · \(configuration) · \(project.selectedPlatforms.count) platforms") + } + + private func spinnerRow(_ text: String) -> NSView { + let spinner = NSProgressIndicator() + spinner.style = .spinning + spinner.controlSize = .small + spinner.startAnimation(nil) + + return NSStackView.horizontal([spinner, NSTextField.text(text, color: .secondaryLabelColor), .spacer()]) + } + + private func inset(_ view: NSView) -> NSView { + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(view) + view.pinEdges(to: container, insets: NSEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)) - return "\(scheme) · \(configuration) · \(count) platform\(count == 1 ? "" : "s")" + return container } // MARK: - Actions @@ -227,16 +391,36 @@ final class ConfigurationViewController: NSViewController { private func didTogglePlatform(_ sender: NSSwitch) { guard let platform = platformSwitches.first(where: { $0.value === sender })?.key else { return } - if sender.state == .on { - project.platforms.insert(platform) - } else { - project.platforms.remove(platform) - } + project.setPlatform(platform, isOn: sender.state == .on) + } + + @objc + private func didSelectIdentity(_ sender: NSPopUpButton) { + let sha1 = sender.selectedItem?.representedObject as? String + project.signingIdentity = project.signingIdentities.first { $0.sha1 == sha1 } + } + + @objc + private func didSelectDistribution(_ sender: NSPopUpButton) { + project.distribution = DistributionMode.allCases[max(sender.indexOfSelectedItem, 0)] + } + + @objc + private func didChoosePreset(_ sender: NSPopUpButton) { + guard let rawValue = sender.selectedItem?.representedObject as? String, let preset = PublishPreset(rawValue: rawValue) else { return } + + project.publishCommand = preset.commandTemplate + project.downloadURLTemplate = preset.downloadURLTemplate + } + + @objc + private func didToggleDraft(_ sender: NSButton) { + project.publishAsDraft = sender.state == .on } @objc private func chooseOutputFolder(_ sender: Any?) { - guard let url = ProjectLocator.chooseFolder(title: "Choose Output Folder", startingAt: project.outputDirectory) else { return } + guard let url = ProjectPicker.chooseFolder(title: String(localized: "Choose Output Folder"), startingAt: project.outputDirectory) else { return } project.customOutputDirectory = url } @@ -264,3 +448,44 @@ final class ConfigurationViewController: NSViewController { } } + +// MARK: - NSTextFieldDelegate + +extension ConfigurationViewController: NSTextFieldDelegate { + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSTextField else { return } + + let value = field.stringValue.trimmingCharacters(in: .whitespaces) + switch field { + case versionField: + project.customVersion = value.isEmpty ? nil : value + case downloadURLField: + project.downloadURLTemplate = value + case publishCommandField: + project.publishCommand = value + case tagField: + project.tagTemplate = value.isEmpty ? "v{version}" : value + default: + break + } + } + +} + +extension PublishPreset { + + var title: String { + switch self { + case .gitLabPackageRegistry: + return String(localized: "GitLab Package Registry") + case .bitbucketDownloads: + return String(localized: "Bitbucket Downloads") + case .amazonS3: + return "Amazon S3" + case .artifactory: + return "JFrog Artifactory" + } + } + +} diff --git a/FrameGen/Controllers/ProjectViewController.swift b/FrameGen/Controllers/ProjectViewController.swift index e7f449c..7ad0b9d 100644 --- a/FrameGen/Controllers/ProjectViewController.swift +++ b/FrameGen/Controllers/ProjectViewController.swift @@ -73,7 +73,7 @@ final class ProjectViewController: NSViewController { case .progress: controller = BuildProgressViewController(project: project) case .result: - controller = BuildResultViewController(project: project) + controller = BuildResultViewController(project: project, buildController: buildController) case .failure: controller = BuildFailureViewController(project: project) } diff --git a/FrameGen/Controllers/SettingsViewController.swift b/FrameGen/Controllers/SettingsViewController.swift index e1b9b7f..d3f95dd 100644 --- a/FrameGen/Controllers/SettingsViewController.swift +++ b/FrameGen/Controllers/SettingsViewController.swift @@ -12,7 +12,7 @@ final class SettingsWindowController: NSWindowController { init() { let window = NSWindow(contentViewController: SettingsViewController()) - window.title = "Settings" + window.title = String(localized: "Settings") window.styleMask = [.titled, .closable] window.isReleasedWhenClosed = false window.setFrameAutosaveName("SettingsWindow") @@ -39,12 +39,13 @@ final class SettingsViewController: NSViewController { // MARK: - Properties private let defaults = UserDefaults.standard - private let insideProjectRadio = NSButton(radioButtonWithTitle: "In a “build” folder next to the project", target: nil, action: nil) - private let customFolderRadio = NSButton(radioButtonWithTitle: "In a shared folder:", target: nil, action: nil) + private let insideProjectRadio = NSButton(radioButtonWithTitle: String(localized: "In a “build” folder next to the project"), target: nil, action: nil) + private let customFolderRadio = NSButton(radioButtonWithTitle: String(localized: "In a shared folder:"), target: nil, action: nil) private let customPathControl = NSPathControl() private let chooseButton = NSButton() - private let revealCheckbox = NSButton(checkboxWithTitle: "Show the XCFramework in Finder", target: nil, action: nil) - private let keepArchivesCheckbox = NSButton(checkboxWithTitle: "Keep intermediate archives", target: nil, action: nil) + private let revealCheckbox = NSButton(checkboxWithTitle: String(localized: "Show the XCFramework in Finder"), target: nil, action: nil) + private let keepArchivesCheckbox = NSButton(checkboxWithTitle: String(localized: "Keep intermediate archives"), target: nil, action: nil) + private let notifyCheckbox = NSButton(checkboxWithTitle: String(localized: "Notify me when a build finishes in the background"), target: nil, action: nil) // MARK: - Life cycle @@ -57,30 +58,31 @@ final class SettingsViewController: NSViewController { customPathControl.isEditable = false customPathControl.backgroundColor = .clear customPathControl.translatesAutoresizingMaskIntoConstraints = false - customPathControl.setAccessibilityLabel("Shared output folder") - chooseButton.title = "Choose…" + customPathControl.setAccessibilityLabel(String(localized: "Shared output folder")) + chooseButton.title = String(localized: "Choose…") chooseButton.bezelStyle = .push chooseButton.target = self chooseButton.action = #selector(chooseFolder(_:)) - [revealCheckbox, keepArchivesCheckbox].forEach { + [revealCheckbox, keepArchivesCheckbox, notifyCheckbox].forEach { $0.target = self $0.action = #selector(didToggleOption(_:)) } - let locationHint = NSTextField.text("A project can override this in its Output section. Results go to a subfolder named after the project.", + let locationHint = NSTextField.text(String(localized: "A project can override this in its Output section. Results go to a subfolder named after the project."), font: .preferred(.footnote), color: .secondaryLabelColor, lines: 0) locationHint.preferredMaxLayoutWidth = 380 let customRow = NSStackView.horizontal([customPathControl, chooseButton]) customPathControl.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true - let archivesHint = NSTextField.text("Archives are needed only to rebuild the XCFramework by hand. dSYMs are already inside the XCFramework.", + let archivesHint = NSTextField.text(String(localized: "Archives are needed only to rebuild the XCFramework by hand. dSYMs are already inside the XCFramework."), font: .preferred(.footnote), color: .secondaryLabelColor, lines: 0) archivesHint.preferredMaxLayoutWidth = 380 - let grid = NSGridView(views: [[label("Save results:"), insideProjectRadio], + let grid = NSGridView(views: [[label(String(localized: "Save results:")), insideProjectRadio], [NSGridCell.emptyContentView, customFolderRadio], [NSGridCell.emptyContentView, customRow], [NSGridCell.emptyContentView, locationHint], - [label("After building:"), revealCheckbox], + [label(String(localized: "After building:")), revealCheckbox], + [NSGridCell.emptyContentView, notifyCheckbox], [NSGridCell.emptyContentView, keepArchivesCheckbox], [NSGridCell.emptyContentView, archivesHint]]) grid.column(at: 0).xPlacement = .trailing @@ -120,11 +122,12 @@ final class SettingsViewController: NSViewController { insideProjectRadio.state = location == .insideProject ? .on : .off customFolderRadio.state = location == .customFolder ? .on : .off customPathControl.url = customPath.map { URL(fileURLWithPath: $0, isDirectory: true) } - customPathControl.placeholderString = "No folder chosen" + customPathControl.placeholderString = String(localized: "No folder chosen") customPathControl.isEnabled = location == .customFolder chooseButton.isEnabled = location == .customFolder revealCheckbox.state = defaults.bool(forKey: PreferenceKey.revealInFinder) ? .on : .off keepArchivesCheckbox.state = defaults.bool(forKey: PreferenceKey.keepArchives) ? .on : .off + notifyCheckbox.state = defaults.bool(forKey: PreferenceKey.notifyWhenDone) ? .on : .off } // MARK: - Actions @@ -142,7 +145,7 @@ final class SettingsViewController: NSViewController { @objc private func chooseFolder(_ sender: Any?) { let current = customPath.map { URL(fileURLWithPath: $0, isDirectory: true) } - if let url = ProjectLocator.chooseFolder(title: "Choose a Folder for Results", startingAt: current) { + if let url = ProjectPicker.chooseFolder(title: String(localized: "Choose a Folder for Results"), startingAt: current) { defaults.set(url.path, forKey: PreferenceKey.customOutputPath) } else if customPath == nil { defaults.set(OutputLocation.insideProject.rawValue, forKey: PreferenceKey.outputLocation) @@ -152,7 +155,14 @@ final class SettingsViewController: NSViewController { @objc private func didToggleOption(_ sender: NSButton) { - let key = sender === revealCheckbox ? PreferenceKey.revealInFinder : PreferenceKey.keepArchives + let key = switch sender { + case revealCheckbox: + PreferenceKey.revealInFinder + case notifyCheckbox: + PreferenceKey.notifyWhenDone + default: + PreferenceKey.keepArchives + } defaults.set(sender.state == .on, forKey: key) } diff --git a/FrameGen/Controllers/WelcomeViewController.swift b/FrameGen/Controllers/WelcomeViewController.swift index 44983b4..bc4765b 100644 --- a/FrameGen/Controllers/WelcomeViewController.swift +++ b/FrameGen/Controllers/WelcomeViewController.swift @@ -7,6 +7,7 @@ // import AppKit +import FrameGenKit /// The start screen: what FrameGen does, how to open a project, and the recent projects. final class WelcomeViewController: NSViewController { @@ -16,10 +17,10 @@ final class WelcomeViewController: NSViewController { private let workspace: Workspace private let openProject: (URL) -> Void private var observation: ObservationLoop? - private var recentProjects: [URL] = [] + private var recentProjects: [ProjectContainer] = [] private let tableView = NSTableView() - private let emptyLabel = NSTextField.text("No Recent Projects", font: .preferred(.title3), color: .tertiaryLabelColor) + private let emptyLabel = NSTextField.text(String(localized: "No Recent Projects"), font: .preferred(.title3), color: .tertiaryLabelColor) // MARK: - Initial methods @@ -85,12 +86,12 @@ final class WelcomeViewController: NSViewController { icon.setAccessibilityElement(false) let title = NSTextField.text("FrameGen", font: .preferred(.largeTitle, weight: .bold)) - let subtitle = NSTextField.text("Turn an Xcode framework project into an XCFramework, a zip and a SwiftPM checksum.", + let subtitle = NSTextField.text(String(localized: "Turn an Xcode framework project into an XCFramework, a zip and a SwiftPM checksum."), font: .preferred(.body), color: .secondaryLabelColor, lines: 0) subtitle.alignment = .center subtitle.preferredMaxLayoutWidth = 300 - let openButton = NSButton.push("Open Project…", target: nil, action: #selector(MainWindowController.openDocument(_:))) + let openButton = NSButton.push(String(localized: "Open Project…"), target: nil, action: #selector(MainWindowController.openDocument(_:))) // Return opens the selected recent project, so this button relies on ⌘O instead of a default key equivalent. openButton.bezelColor = .controlAccentColor openButton.controlSize = .large @@ -113,7 +114,7 @@ final class WelcomeViewController: NSViewController { private func makeDropHint() -> NSView { let icon = NSImageView.symbol("arrow.down.doc", pointSize: 22, color: .secondaryLabelColor) - let label = NSTextField.text("or drop an .xcodeproj or its folder anywhere in this window", + let label = NSTextField.text(String(localized: "or drop an .xcodeproj or its folder anywhere in this window"), font: .preferred(.callout), color: .secondaryLabelColor, lines: 0) label.alignment = .center label.preferredMaxLayoutWidth = 220 @@ -136,7 +137,7 @@ final class WelcomeViewController: NSViewController { } private func makeRecentsPane() -> NSView { - let header = NSTextField.text("Recent Projects", font: .preferred(.headline), color: .secondaryLabelColor) + let header = NSTextField.text(String(localized: "Recent Projects"), font: .preferred(.headline), color: .secondaryLabelColor) let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("project")) tableView.addTableColumn(column) @@ -149,7 +150,7 @@ final class WelcomeViewController: NSViewController { tableView.target = self tableView.doubleAction = #selector(openClickedProject(_:)) tableView.menu = makeContextMenu() - tableView.setAccessibilityLabel("Recent projects") + tableView.setAccessibilityLabel(String(localized: "Recent projects")) let scrollView = NSScrollView() scrollView.documentView = tableView @@ -177,10 +178,10 @@ final class WelcomeViewController: NSViewController { private func makeContextMenu() -> NSMenu { let menu = NSMenu() - menu.addItem(withTitle: "Open", action: #selector(openClickedProject(_:)), keyEquivalent: "") - menu.addItem(withTitle: "Show in Finder", action: #selector(showClickedProjectInFinder(_:)), keyEquivalent: "") + menu.addItem(withTitle: String(localized: "Open"), action: #selector(openClickedProject(_:)), keyEquivalent: "") + menu.addItem(withTitle: String(localized: "Show in Finder"), action: #selector(showClickedProjectInFinder(_:)), keyEquivalent: "") menu.addItem(.separator()) - menu.addItem(withTitle: "Remove from Recent Projects", action: #selector(removeClickedProject(_:)), keyEquivalent: "") + menu.addItem(withTitle: String(localized: "Remove from Recent Projects"), action: #selector(removeClickedProject(_:)), keyEquivalent: "") menu.items.forEach { $0.target = self } return menu @@ -193,7 +194,7 @@ final class WelcomeViewController: NSViewController { } /// The right-clicked row for context menu actions, otherwise the selected one. - private var targetProject: URL? { + private var targetProject: ProjectContainer? { let row = tableView.clickedRow >= 0 ? tableView.clickedRow : tableView.selectedRow return recentProjects.indices.contains(row) ? recentProjects[row] : nil @@ -203,23 +204,23 @@ final class WelcomeViewController: NSViewController { @objc private func openClickedProject(_ sender: Any?) { - guard let url = targetProject else { return } + guard let container = targetProject else { return } - openProject(url) + openProject(container.url) } @objc private func showClickedProjectInFinder(_ sender: Any?) { - guard let url = targetProject else { return } + guard let container = targetProject else { return } - NSWorkspace.shared.activateFileViewerSelecting([url]) + NSWorkspace.shared.activateFileViewerSelecting([container.url]) } @objc private func removeClickedProject(_ sender: Any?) { - guard let url = targetProject else { return } + guard let container = targetProject else { return } - workspace.removeRecentProject(url) + workspace.removeRecentProject(container) } override func keyDown(with event: NSEvent) { @@ -246,7 +247,7 @@ extension WelcomeViewController: NSTableViewDataSource, NSTableViewDelegate { func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { let cell = tableView.makeView(withIdentifier: RecentProjectCellView.identifier, owner: self) as? RecentProjectCellView ?? RecentProjectCellView() - cell.configure(with: recentProjects[row]) + cell.configure(with: recentProjects[row].url) return cell } diff --git a/FrameGen/Models/BuildState.swift b/FrameGen/Models/BuildState.swift index 471cccd..3efba65 100644 --- a/FrameGen/Models/BuildState.swift +++ b/FrameGen/Models/BuildState.swift @@ -6,48 +6,45 @@ // Copyright © 2026 Ramiz Kichibekov. All rights reserved. // +import FrameGenKit import Foundation import Observation -struct BuildStep: Identifiable { - - enum Kind: Equatable { - case archive(Platform) - case createXCFramework - case package - } - +struct BuildStep { + enum Status: Equatable { case pending case running(since: Date) case succeeded(duration: TimeInterval) case failed } - - let kind: Kind + + let kind: StepKind var status: Status = .pending - - var id: String { title } - + var title: String { switch kind { case .archive(let platform): - return "Archive for \(platform.title)" + return String(localized: "Archive for \(platform.title)") case .createXCFramework: - return "Create XCFramework" + return String(localized: "Create XCFramework") + case .sign: + return String(localized: "Sign XCFramework") case .package: - return "Zip and compute checksum" + return String(localized: "Zip and compute checksum") } } - + var failureTitle: String { switch kind { case .archive(let platform): - return "Archiving for \(platform.title) failed" + return String(localized: "Archiving for \(platform.title) failed") case .createXCFramework: - return "Creating the XCFramework failed" + return String(localized: "Creating the XCFramework failed") + case .sign: + return String(localized: "Signing the XCFramework failed") case .package: - return "Packaging the XCFramework failed" + return String(localized: "Packaging the XCFramework failed") } } } @@ -59,6 +56,13 @@ struct BuildFailure { let highlights: [String] } +enum PublishState: Equatable { + case idle + case publishing + case published(URL) + case failed(String) +} + /// The progress and outcome of one XCFramework build. @Observable final class BuildState { @@ -73,6 +77,8 @@ final class BuildState { // MARK: - Properties var phase: Phase = .idle + /// Upload progress on the result screen; independent of the build itself. + var publishState: PublishState = .idle var steps: [BuildStep] = [] /// The most recent xcodebuild action, e.g. "SwiftCompile normal arm64 Compiling Kit.swift". var activity = "" @@ -96,8 +102,9 @@ final class BuildState { func start(steps: [BuildStep]) { self.steps = steps + publishState = .idle log = [] - activity = "Preparing…" + activity = String(localized: "Preparing…") startDate = .now endDate = nil phase = .running @@ -111,6 +118,7 @@ final class BuildState { func reset() { steps = [] + publishState = .idle log = [] activity = "" startDate = nil diff --git a/FrameGen/Models/Platform+UI.swift b/FrameGen/Models/Platform+UI.swift new file mode 100644 index 0000000..0e3670b --- /dev/null +++ b/FrameGen/Models/Platform+UI.swift @@ -0,0 +1,97 @@ +// +// Platform+UI.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import FrameGenKit +import Foundation + +/// Display names live in the app so they can be localized; FrameGenKit only knows build facts. +extension Platform { + + var title: String { + switch self { + case .iOS: + return "iOS" + case .iOSSimulator: + return String(localized: "iOS Simulator") + case .macCatalyst: + return "Mac Catalyst" + case .macOS: + return "macOS" + case .tvOS: + return "tvOS" + case .tvOSSimulator: + return String(localized: "tvOS Simulator") + case .watchOS: + return "watchOS" + case .watchOSSimulator: + return String(localized: "watchOS Simulator") + case .visionOS: + return "visionOS" + case .visionOSSimulator: + return String(localized: "visionOS Simulator") + } + } + + var detail: String { + switch self { + case .iOS: + return String(localized: "iPhone and iPad devices") + case .iOSSimulator, .tvOSSimulator, .watchOSSimulator, .visionOSSimulator: + return String(localized: "Simulator on Apple silicon and Intel Macs") + case .macCatalyst: + return String(localized: "iPad apps running on the Mac") + case .macOS: + return String(localized: "Native Mac apps") + case .tvOS: + return String(localized: "Apple TV devices") + case .watchOS: + return String(localized: "Apple Watch devices") + case .visionOS: + return String(localized: "Apple Vision Pro devices") + } + } + + var systemImage: String { + switch self { + case .iOS: + return "iphone" + case .iOSSimulator: + return "macbook.and.iphone" + case .macCatalyst: + return "macwindow" + case .macOS: + return "desktopcomputer" + case .tvOS, .tvOSSimulator: + return "appletv" + case .watchOS, .watchOSSimulator: + return "applewatch" + case .visionOS, .visionOSSimulator: + return "visionpro" + } + } + +} + +extension Platform.Family { + + var title: String { + switch self { + case .iOS: + return "iOS" + case .macOS: + return "macOS" + case .tvOS: + return "tvOS" + case .watchOS: + return "watchOS" + case .visionOS: + return "visionOS" + } + } + +} diff --git a/FrameGen/Models/Platform.swift b/FrameGen/Models/Platform.swift deleted file mode 100644 index 89e422e..0000000 --- a/FrameGen/Models/Platform.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// Platform.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 21.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Foundation - -/// A platform slice of the XCFramework, built by its own `xcodebuild archive` run. -nonisolated enum Platform: String, CaseIterable, Codable, Identifiable, Sendable { - case iOS - case iOSSimulator - case macCatalyst - - var id: String { rawValue } - - var title: String { - switch self { - case .iOS: - return "iOS" - case .iOSSimulator: - return "iOS Simulator" - case .macCatalyst: - return "Mac Catalyst" - } - } - - var detail: String { - switch self { - case .iOS: - return "iPhone and iPad devices, arm64" - case .iOSSimulator: - return "Simulator on Apple silicon and Intel Macs" - case .macCatalyst: - return "iPad apps running on the Mac" - } - } - - var systemImage: String { - switch self { - case .iOS: - return "iphone" - case .iOSSimulator: - return "macbook.and.iphone" - case .macCatalyst: - return "macwindow" - } - } - - var destination: String { - switch self { - case .iOS: - return "generic/platform=iOS" - case .iOSSimulator: - return "generic/platform=iOS Simulator" - case .macCatalyst: - return "generic/platform=macOS,variant=Mac Catalyst" - } - } - - var archiveSuffix: String { - switch self { - case .iOS: - return "iphoneos" - case .iOSSimulator: - return "iphonesimulator" - case .macCatalyst: - return "maccatalyst" - } - } -} diff --git a/FrameGen/Models/Project.swift b/FrameGen/Models/Project.swift index 435725a..14f1b94 100644 --- a/FrameGen/Models/Project.swift +++ b/FrameGen/Models/Project.swift @@ -6,14 +6,15 @@ // Copyright © 2026 Ramiz Kichibekov. All rights reserved. // +import FrameGenKit import Foundation import Observation -/// An opened Xcode project and the build options chosen for it. Options persist per project. +/// An opened project or workspace and the build options chosen for it. Options persist per project. @Observable final class Project { - enum InfoState: Equatable { + enum LoadState: Equatable { case loading case loaded case failed(String) @@ -21,86 +22,248 @@ final class Project { // MARK: - Properties - let fileURL: URL + let container: ProjectContainer let build = BuildState() - private(set) var infoState: InfoState = .loading + private(set) var infoState: LoadState = .loading private(set) var schemes: [String] = [] private(set) var configurations: [String] = [] + /// What the selected scheme's framework target supports; reloaded when the scheme changes. + private(set) var schemeInfoState: LoadState = .loading + private(set) var schemeInfo: SchemeBuildInfo? + private(set) var gitRemote: GitRemote? + private(set) var signingIdentities: [SigningIdentity] = [] var scheme: String? { - didSet { save() } + didSet { + save() + if scheme != oldValue { + Task { await loadSchemeInfo() } + } + } } var configuration: String? { didSet { save() } } - var platforms: Set { + /// The platforms the user chose; `nil` means every supported one. + var chosenPlatforms: Set? { didSet { save() } } /// `nil` means the default location from Settings. var customOutputDirectory: URL? { didSet { save() } } + var signingIdentity: SigningIdentity? { + didSet { save() } + } + /// Overrides the scheme's MARKETING_VERSION for templates and release tags. + var customVersion: String? { + didSet { save() } + } + var distribution: DistributionMode { + didSet { save() } + } + var downloadURLTemplate: String { + didSet { save() } + } + var publishCommand: String { + didSet { save() } + } + var tagTemplate: String { + didSet { save() } + } + var publishAsDraft: Bool { + didSet { save() } + } var name: String { - fileURL.deletingPathExtension().lastPathComponent + container.name } var outputDirectory: URL { - customOutputDirectory ?? Preferences.defaultOutputDirectory(for: fileURL) + customOutputDirectory ?? Preferences.defaultOutputDirectory(for: container) + } + + var supportedPlatforms: Set { + schemeInfo?.supportedPlatforms ?? [] } + /// The chosen platforms the scheme actually supports, in a stable order. var selectedPlatforms: [Platform] { - Platform.allCases.filter(platforms.contains) + let chosen = chosenPlatforms ?? supportedPlatforms + + return Platform.allCases.filter { chosen.contains($0) && supportedPlatforms.contains($0) } + } + + var version: String? { + customVersion ?? schemeInfo?.marketingVersion } var canBuild: Bool { - infoState == .loaded && scheme != nil && configuration != nil && !platforms.isEmpty && !build.isRunning + infoState == .loaded && schemeInfoState == .loaded && scheme != nil && configuration != nil + && !selectedPlatforms.isEmpty && !build.isRunning } // MARK: - Initial methods - init(fileURL: URL) { - self.fileURL = fileURL - let settings = Preferences.settings(for: fileURL) + init(container: ProjectContainer) { + self.container = container + let settings = Preferences.settings(for: container) scheme = settings.scheme configuration = settings.configuration - platforms = Set(settings.platforms) + chosenPlatforms = settings.platforms.map(Set.init) customOutputDirectory = settings.outputPath.map { URL(fileURLWithPath: $0, isDirectory: true) } + signingIdentity = settings.signingIdentity + customVersion = settings.version + distribution = settings.distribution ?? .manual + downloadURLTemplate = settings.downloadURLTemplate ?? "" + publishCommand = settings.publishCommand ?? "" + tagTemplate = settings.tagTemplate ?? "v{version}" + publishAsDraft = settings.publishAsDraft ?? false } // MARK: - Public methods + func load() async { + async let remote = GitRemote.origin(of: container.directoryURL) + async let identities = try? SigningIdentity.available() + await loadInfo() + gitRemote = await remote + signingIdentities = await identities ?? [] + if let identity = signingIdentity, !signingIdentities.isEmpty, !signingIdentities.contains(identity) { + // The certificate expired or was removed; don't fail every build until the user notices. + signingIdentity = nil + } + } + func loadInfo() async { infoState = .loading do { - let info = try await ProjectInfo.load(project: fileURL) + let info = try await ProjectInfo.load(container) schemes = info.schemes configurations = info.configurations - if scheme.map(schemes.contains) != true { - scheme = schemes.first { $0 == name } ?? schemes.first - } if configuration.map(configurations.contains) != true { configuration = configurations.first { $0 == "Release" } ?? configurations.last } - infoState = schemes.isEmpty ? .failed("The project has no shared or user schemes.") : .loaded - } catch ProcessError.failed(_, let output) { - let message = output - .components(separatedBy: .newlines) - .first { $0.contains("error:") } ?? output.trimmingCharacters(in: .whitespacesAndNewlines) - infoState = .failed(message.isEmpty ? "xcodebuild could not read the project." : message) + guard !schemes.isEmpty else { + infoState = .failed(String(localized: "The project has no shared or user schemes.")) + + return + } + + infoState = .loaded + if let scheme, schemes.contains(scheme) { + await loadSchemeInfo() + } else { + // Setting the scheme triggers loadSchemeInfo(). + scheme = schemes.first { $0 == name } ?? schemes.first + } + } catch { + infoState = .failed(Self.message(for: error)) + } + } + + func loadSchemeInfo() async { + guard let scheme else { return } + + schemeInfoState = .loading + do { + let info = try await SchemeBuildInfo.load(container, scheme: scheme) + guard scheme == self.scheme else { return } + + schemeInfo = info + schemeInfoState = info.supportedPlatforms.isEmpty + ? .failed(String(localized: "The scheme “\(scheme)” doesn’t build for any platform FrameGen supports.")) + : .loaded } catch { - infoState = .failed(error.localizedDescription) + guard scheme == self.scheme else { return } + + schemeInfo = nil + schemeInfoState = .failed(Self.message(for: error)) + } + } + + func setPlatform(_ platform: Platform, isOn: Bool) { + var chosen = chosenPlatforms ?? supportedPlatforms + if isOn { + chosen.insert(platform) + } else { + chosen.remove(platform) + } + chosenPlatforms = chosen + } + + /// The request for the current options, or `nil` while they are incomplete. + func makeBuildRequest() -> BuildRequest? { + guard let scheme, let configuration, !selectedPlatforms.isEmpty else { return nil } + + let template: String? = switch distribution { + case .manual, .command: + downloadURLTemplate.isEmpty ? nil : downloadURLTemplate + case .gitHubRelease: + nil + } + + return BuildRequest(container: container, + scheme: scheme, + configuration: configuration, + platforms: selectedPlatforms, + outputDirectory: outputDirectory, + signingIdentity: signingIdentity, + keepArchives: Preferences.keepArchives, + version: version, + downloadURLTemplate: template) + } + + /// Where "Publish" uploads the zip, or `nil` when distribution is manual or incomplete. + var publishDestination: PublishDestination? { + switch distribution { + case .manual: + return nil + case .gitHubRelease: + guard let gitRemote else { return nil } + + return .gitHub(GitHubRelease(remote: gitRemote, tagTemplate: tagTemplate, isDraft: publishAsDraft)) + case .command: + guard !publishCommand.isEmpty, !downloadURLTemplate.isEmpty else { return nil } + + return .command(CommandPublish(commandTemplate: publishCommand, downloadURLTemplate: downloadURLTemplate)) } } + /// The equivalent `framegen build` command for CI. + func buildCommandLine() -> String? { + guard let request = makeBuildRequest() else { return nil } + + return BuildArguments(request: request, publish: publishDestination).commandLine() + } + // MARK: - Private methods + private static func message(for error: Error) -> String { + if case let ProcessError.failed(_, output) = error { + let line = output.components(separatedBy: .newlines).first { $0.contains("error:") } + ?? output.trimmingCharacters(in: .whitespacesAndNewlines) + + return line.isEmpty ? String(localized: "xcodebuild could not read the project.") : line + } + + return error.localizedDescription + } + private func save() { - let settings = ProjectSettings(scheme: scheme, - configuration: configuration, - platforms: selectedPlatforms, - outputPath: customOutputDirectory?.path) - Preferences.setSettings(settings, for: fileURL) + var settings = ProjectSettings() + settings.scheme = scheme + settings.configuration = configuration + settings.platforms = chosenPlatforms.map { Platform.allCases.filter($0.contains) } + settings.outputPath = customOutputDirectory?.path + settings.signingIdentity = signingIdentity + settings.version = customVersion + settings.distribution = distribution + settings.downloadURLTemplate = downloadURLTemplate.isEmpty ? nil : downloadURLTemplate + settings.publishCommand = publishCommand.isEmpty ? nil : publishCommand + settings.tagTemplate = tagTemplate + settings.publishAsDraft = publishAsDraft + Preferences.setSettings(settings, for: container) } } diff --git a/FrameGen/Models/ProjectInfo.swift b/FrameGen/Models/ProjectInfo.swift deleted file mode 100644 index c53aa54..0000000 --- a/FrameGen/Models/ProjectInfo.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// ProjectInfo.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 19.10.2026. -// Copyright © 2026 Ramiz Kichibekov. All rights reserved. -// - -import Foundation - -/// The part of `xcodebuild -list -json` FrameGen needs. -nonisolated struct ProjectInfo: Decodable, Sendable { - - let schemes: [String] - let configurations: [String] - - private enum CodingKeys: String, CodingKey { - case project - } - - private struct Project: Decodable { - let schemes: [String]? - let configurations: [String]? - } - - init(from decoder: Decoder) throws { - let project = try decoder.container(keyedBy: CodingKeys.self).decode(Project.self, forKey: .project) - schemes = project.schemes ?? [] - configurations = project.configurations?.isEmpty == false ? project.configurations ?? [] : ["Release"] - } - - @concurrent - static func load(project: URL) async throws -> ProjectInfo { - let output = try await XcodeCommand.list(project: project).output() - - return try JSONDecoder().decode(ProjectInfo.self, from: Data(output.utf8)) - } - -} diff --git a/FrameGen/Models/Workspace.swift b/FrameGen/Models/Workspace.swift index 4f0f976..cb63807 100644 --- a/FrameGen/Models/Workspace.swift +++ b/FrameGen/Models/Workspace.swift @@ -6,62 +6,63 @@ // Copyright © 2026 Ramiz Kichibekov. All rights reserved. // +import FrameGenKit import Foundation import Observation enum WorkspaceError: LocalizedError { case projectNotFound(URL) - + var errorDescription: String? { switch self { case .projectNotFound(let url): - return "“\(url.lastPathComponent)” doesn’t contain an Xcode project." + return String(localized: "“\(url.lastPathComponent)” doesn’t contain an Xcode project or workspace.") } } - + var recoverySuggestion: String? { - "Choose an .xcodeproj file or the folder that contains it." + String(localized: "Choose an .xcodeproj or .xcworkspace, or the folder that contains it.") } } /// The app-wide state: the opened project and the recently opened ones. @Observable final class Workspace { - + // MARK: - Properties - + private(set) var project: Project? - private(set) var recentProjects: [URL] = Preferences.recentProjects { + private(set) var recentProjects: [ProjectContainer] = Preferences.recentProjects { didSet { Preferences.setRecentProjects(recentProjects) } } - + // MARK: - Public methods - + func open(_ url: URL) throws { - guard let fileURL = ProjectLocator.projectFile(for: url) else { + guard let container = ProjectLocator.container(for: url) else { throw WorkspaceError.projectNotFound(url) } - - recentProjects = [fileURL] + recentProjects.filter { $0 != fileURL } - guard project?.fileURL != fileURL else { return } - - let project = Project(fileURL: fileURL) + + recentProjects = [container] + recentProjects.filter { $0 != container } + guard project?.container != container else { return } + + let project = Project(container: container) self.project = project Task { - await project.loadInfo() + await project.load() } } - + func closeProject() { project = nil } - - func removeRecentProject(_ url: URL) { - recentProjects.removeAll { $0 == url } + + func removeRecentProject(_ container: ProjectContainer) { + recentProjects.removeAll { $0 == container } } - + func clearRecentProjects() { recentProjects = [] } - + } diff --git a/FrameGen/Resources/InfoPlist.xcstrings b/FrameGen/Resources/InfoPlist.xcstrings new file mode 100644 index 0000000..c85cd3b --- /dev/null +++ b/FrameGen/Resources/InfoPlist.xcstrings @@ -0,0 +1,70 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "NSDesktopFolderUsageDescription" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "FrameGen reads Xcode projects and saves built XCFrameworks in the folders you choose." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "FrameGen читает проекты Xcode и сохраняет собранные XCFramework в выбранные вами папки." + } + } + } + }, + "NSDocumentsFolderUsageDescription" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "FrameGen reads Xcode projects and saves built XCFrameworks in the folders you choose." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "FrameGen читает проекты Xcode и сохраняет собранные XCFramework в выбранные вами папки." + } + } + } + }, + "NSDownloadsFolderUsageDescription" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "FrameGen reads Xcode projects and saves built XCFrameworks in the folders you choose." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "FrameGen читает проекты Xcode и сохраняет собранные XCFramework в выбранные вами папки." + } + } + } + }, + "NSHumanReadableCopyright" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2023–2026 Ramiz Kichibekov. All rights reserved." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "© 2023–2026 Рамиз Кичибеков. Все права защищены." + } + } + } + } + }, + "version" : "1.0" +} diff --git a/FrameGen/Resources/Localizable.xcstrings b/FrameGen/Resources/Localizable.xcstrings new file mode 100644 index 0000000..8cc94ea --- /dev/null +++ b/FrameGen/Resources/Localizable.xcstrings @@ -0,0 +1,2032 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%@ is ready" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ готов" + } + } + } + }, + "%@ zipped" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ в zip-архиве" + } + } + } + }, + "%@ · %@ · %lld platforms" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@ · %lld platform" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@ · %lld platforms" + } + } + } + } + }, + "ru" : { + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@ · %lld платформы" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@ · %lld платформ" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@ · %lld платформа" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %@ · %lld платформы" + } + } + } + } + } + } + }, + "%@ · %lld platforms" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld platform" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld platforms" + } + } + } + } + }, + "ru" : { + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld платформы" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld платформ" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld платформа" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld платформы" + } + } + } + } + } + } + }, + "%@, %@" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@, %@" + } + } + } + }, + "%@.xcframework is ready" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@.xcframework готов" + } + } + } + }, + "%lld platforms" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld platform" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld platforms" + } + } + } + } + }, + "ru" : { + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld платформы" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld платформ" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld платформа" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld платформы" + } + } + } + } + } + } + }, + "A project can override this in its Output section. Results go to a subfolder named after the project." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проект может переопределить это в разделе «Результат». Результаты сохраняются в подпапку с именем проекта." + } + } + } + }, + "About %@" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "О программе %@" + } + } + } + }, + "Add the target to Package.swift. Private hosts need credentials in ~/.netrc for SwiftPM to download the zip." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавьте цель в Package.swift. Чтобы SwiftPM мог скачать zip-архив с закрытого хоста, укажите учетные данные в ~/.netrc." + } + } + } + }, + "After building:" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "После сборки:" + } + } + } + }, + "Apple TV devices" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройства Apple TV" + } + } + } + }, + "Apple Vision Pro devices" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройства Apple Vision Pro" + } + } + } + }, + "Apple Watch devices" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройства Apple Watch" + } + } + } + }, + "Apps that embed the XCFramework can verify who built it. Apple requires signatures for commonly used SDKs. Signing needs a network connection for the timestamp." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приложения, в которые встроен XCFramework, смогут проверить, кто его собрал. Apple требует подпись для распространенных SDK. Для получения метки времени при подписи нужно подключение к сети." + } + } + } + }, + "Archive for %@" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Архив для %@" + } + } + } + }, + "Archives are needed only to rebuild the XCFramework by hand. dSYMs are already inside the XCFramework." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Архивы нужны, только чтобы пересобрать XCFramework вручную. dSYM уже находятся внутри XCFramework." + } + } + } + }, + "Archiving for %@ failed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось создать архив для %@" + } + } + } + }, + "Bitbucket Downloads" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitbucket Downloads" + } + } + } + }, + "Bring All to Front" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Все окна — на передний план" + } + } + } + }, + "Build" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сборка" + } + } + } + }, + "Build Again" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Собрать снова" + } + } + } + }, + "Build Log" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал сборки" + } + } + } + }, + "Build XCFramework" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Собрать XCFramework" + } + } + } + }, + "Build log" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал сборки" + } + } + } + }, + "Build the XCFramework (⌘B)" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Собрать XCFramework (⌘B)" + } + } + } + }, + "Building" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Идет сборка" + } + } + } + }, + "Building %@" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сборка %@" + } + } + } + }, + "Building… %@" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сборка… %@" + } + } + } + }, + "Built in %@" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Собрано за %@" + } + } + } + }, + "Cancel" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отменить" + } + } + } + }, + "Checksum" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контрольная сумма" + } + } + } + }, + "Choose" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать" + } + } + } + }, + "Choose Output Folder" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать папку результатов" + } + } + } + }, + "Choose a Folder for Results" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите папку для результатов" + } + } + } + }, + "Choose an .xcodeproj or .xcworkspace, or the folder that contains it." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите .xcodeproj или .xcworkspace либо папку, в которой он находится." + } + } + } + }, + "Choose an Xcode project, a workspace or a folder that contains one." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите проект Xcode, рабочее пространство или папку, в которой они находятся." + } + } + } + }, + "Choose…" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать…" + } + } + } + }, + "Clear Menu" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить меню" + } + } + } + }, + "Close Project" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть проект" + } + } + } + }, + "Close Window" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть окно" + } + } + } + }, + "Close the project and show recent projects (⇧⌘W)" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть проект и показать недавние проекты (⇧⌘W)" + } + } + } + }, + "Configuration" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Конфигурация" + } + } + } + }, + "Contains the .xcframework, its zip archive and a SwiftPM binaryTarget snippet. Intermediate archives go to the Archives subfolder." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Содержит .xcframework, его zip-архив и фрагмент binaryTarget для SwiftPM. Промежуточные архивы сохраняются в подпапку Archives." + } + } + } + }, + "Copied" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопировано" + } + } + } + }, + "Copied to the clipboard" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопировано в буфер обмена" + } + } + } + }, + "Copy" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать" + } + } + } + }, + "Copy Build Command" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать команду сборки" + } + } + } + }, + "Copy Command" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать команду" + } + } + } + }, + "Copy Log" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать журнал" + } + } + } + }, + "Copy Snippet" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать фрагмент" + } + } + } + }, + "Copy URL" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать URL" + } + } + } + }, + "Copy the equivalent framegen command for CI (⌥⌘C)" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопировать аналогичную команду framegen для CI (⌥⌘C)" + } + } + } + }, + "Couldn’t read the project" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось прочитать проект" + } + } + } + }, + "Couldn’t read the scheme’s build settings" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось прочитать настройки сборки схемы" + } + } + } + }, + "Create XCFramework" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создание XCFramework" + } + } + } + }, + "Create as a draft" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать как черновик" + } + } + } + }, + "Creating the XCFramework failed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось создать XCFramework" + } + } + } + }, + "Custom command" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Своя команда" + } + } + } + }, + "Cut" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вырезать" + } + } + } + }, + "Detecting supported platforms…" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Определение поддерживаемых платформ…" + } + } + } + }, + "Distribution" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Распространение" + } + } + } + }, + "Done" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Готово" + } + } + } + }, + "Don’t Sign" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не подписывать" + } + } + } + }, + "Download URL" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL для загрузки" + } + } + } + }, + "Draft" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Черновик" + } + } + } + }, + "Each platform is archived separately and becomes one slice of the XCFramework." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Каждая платформа архивируется отдельно и становится одним срезом XCFramework." + } + } + } + }, + "Each platform is archived separately and becomes one slice of the XCFramework. Not supported by this scheme: %@." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Каждая платформа архивируется отдельно и становится одним срезом XCFramework. Не поддерживаются этой схемой: %@." + } + } + } + }, + "Edit" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Правка" + } + } + } + }, + "Edit Options" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить параметры" + } + } + } + }, + "Errors" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибки" + } + } + } + }, + "Failed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка" + } + } + } + }, + "File" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файл" + } + } + } + }, + "Files" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файлы" + } + } + } + }, + "Fill from Preset" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заполнить из шаблона" + } + } + } + }, + "Folder" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Папка" + } + } + } + }, + "GitHub Release" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Релиз GitHub" + } + } + } + }, + "GitLab Package Registry" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitLab Package Registry" + } + } + } + }, + "Help" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Справка" + } + } + } + }, + "Hide %@" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть %@" + } + } + } + }, + "Hide Others" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть остальные" + } + } + } + }, + "Hide build log" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть журнал сборки" + } + } + } + }, + "Identity" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сертификат" + } + } + } + }, + "In a shared folder:" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В общей папке:" + } + } + } + }, + "In a “build” folder next to the project" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В папке «build» рядом с проектом" + } + } + } + }, + "Keep intermediate archives" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранять промежуточные архивы" + } + } + } + }, + "Learn About Privacy Manifests" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "О манифестах конфиденциальности" + } + } + } + }, + "Minimize" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Свернуть" + } + } + } + }, + "Native Mac apps" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нативные приложения для Mac" + } + } + } + }, + "No GitHub remote" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет удаленного репозитория GitHub" + } + } + } + }, + "No Recent Projects" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет недавних проектов" + } + } + } + }, + "No folder chosen" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Папка не выбрана" + } + } + } + }, + "No privacy manifest" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет манифеста конфиденциальности" + } + } + } + }, + "No signing certificates were found in the keychain." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В связке ключей не найдено сертификатов подписи." + } + } + } + }, + "Notify me when a build finishes in the background" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомлять о завершении сборки в фоне" + } + } + } + }, + "Open" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть" + } + } + } + }, + "Open Project" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть проект" + } + } + } + }, + "Open Project…" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть проект…" + } + } + } + }, + "Open Recent" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть недавние" + } + } + } + }, + "Optional; fills the binaryTarget snippet" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Необязательно; подставляется во фрагмент binaryTarget" + } + } + } + }, + "Output" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Результат" + } + } + } + }, + "Output folder" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Папка результатов" + } + } + } + }, + "Packaging the XCFramework failed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось упаковать XCFramework" + } + } + } + }, + "Paste" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вставить" + } + } + } + }, + "Platforms" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Платформы" + } + } + } + }, + "Platforms appear once the scheme’s build settings are read." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Платформы появятся после чтения настроек сборки схемы." + } + } + } + }, + "Preparing…" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подготовка…" + } + } + } + }, + "PrivacyInfo.xcprivacy is missing in %@. Apple requires privacy manifests in commonly used third-party SDKs." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В %@ отсутствует PrivacyInfo.xcprivacy. Apple требует манифест конфиденциальности в распространенных сторонних SDK." + } + } + } + }, + "Product" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продукт" + } + } + } + }, + "Projects" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проекты" + } + } + } + }, + "Publish" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Опубликовать" + } + } + } + }, + "Publish to GitHub" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Опубликовать на GitHub" + } + } + } + }, + "Published" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Опубликовано" + } + } + } + }, + "Publishing failed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось опубликовать" + } + } + } + }, + "Quit %@" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Завершить %@" + } + } + } + }, + "Reading schemes and configurations…" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чтение схем и конфигураций…" + } + } + } + }, + "Recent Projects" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Недавние проекты" + } + } + } + }, + "Recent projects" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Недавние проекты" + } + } + } + }, + "Redo" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторить" + } + } + } + }, + "Release tag" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тег релиза" + } + } + } + }, + "Remove from Recent Projects" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить из недавних проектов" + } + } + } + }, + "Repository" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Репозиторий" + } + } + } + }, + "Runs in zsh; {path} is the zip" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "zsh; {path} — путь к zip" + } + } + } + }, + "Save results:" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранять результаты:" + } + } + } + }, + "Scheme" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Схема" + } + } + } + }, + "Select All" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать все" + } + } + } + }, + "Services" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Службы" + } + } + } + }, + "Settings" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки" + } + } + } + }, + "Settings…" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки…" + } + } + } + }, + "Shared output folder" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Общая папка результатов" + } + } + } + }, + "Show All" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать все" + } + } + } + }, + "Show Output in Finder" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать результаты в Finder" + } + } + } + }, + "Show build log" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать журнал сборки" + } + } + } + }, + "Show in Finder" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать в Finder" + } + } + } + }, + "Show the XCFramework in Finder" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать XCFramework в Finder" + } + } + } + }, + "Show the output folder in Finder (⇧⌘R)" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать папку результатов в Finder (⇧⌘R)" + } + } + } + }, + "Sign XCFramework" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подписать XCFramework" + } + } + } + }, + "Signing" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подпись" + } + } + } + }, + "Signing identity" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сертификат подписи" + } + } + } + }, + "Signing the XCFramework failed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось подписать XCFramework" + } + } + } + }, + "Simulator on Apple silicon and Intel Macs" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Симулятор на Mac с Apple silicon и Intel" + } + } + } + }, + "Stop" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановить" + } + } + } + }, + "Stop and Close" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановить и закрыть" + } + } + } + }, + "Stop and Open" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановить и открыть" + } + } + } + }, + "Stop and Quit" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановить и завершить" + } + } + } + }, + "Stop building %@?" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановить сборку %@?" + } + } + } + }, + "Stop the build (⌘.)" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановить сборку (⌘.)" + } + } + } + }, + "Succeeded" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Успешно" + } + } + } + }, + "Swift Package Manager" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swift Package Manager" + } + } + } + }, + "SwiftPM can download the zip from %@. The snippet below uses this URL." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "SwiftPM может скачать zip-архив с %@. Этот URL используется во фрагменте ниже." + } + } + } + }, + "The XCFramework build in progress will be stopped." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текущая сборка XCFramework будет остановлена." + } + } + } + }, + "The build couldn’t start" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось начать сборку" + } + } + } + }, + "The build of %@ failed. Open FrameGen to see the errors." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сборка %@ завершилась с ошибкой. Откройте FrameGen, чтобы увидеть ошибки." + } + } + } + }, + "The build of %@ finished." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сборка %@ завершена." + } + } + } + }, + "The command exited with code %d. See the highlighted lines below or the full log." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Команда завершилась с кодом %d. См. выделенные строки ниже или полный журнал." + } + } + } + }, + "The origin remote isn’t on github.com; GitHub CLI must be signed in to that host." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаленный репозиторий origin находится не на github.com; в GitHub CLI нужно войти на этом хосте." + } + } + } + }, + "The project has no shared or user schemes." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В проекте нет общих или пользовательских схем." + } + } + } + }, + "The scheme “%@” doesn’t build for any platform FrameGen supports." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Схема «%@» не собирается ни для одной платформы, поддерживаемой FrameGen." + } + } + } + }, + "Try Again" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторить попытку" + } + } + } + }, + "Turn an Xcode framework project into an XCFramework, a zip and a SwiftPM checksum." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Превратите проект фреймворка Xcode в XCFramework, zip-архив и контрольную сумму для SwiftPM." + } + } + } + }, + "Turn on at least one platform." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включите хотя бы одну платформу." + } + } + } + }, + "Undo" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отменить" + } + } + } + }, + "Upload command" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Команда загрузки" + } + } + } + }, + "Upload manually" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузить вручную" + } + } + } + }, + "Upload the zip anywhere SwiftPM can download it over HTTPS. Placeholders: {name}, {version}, {zip}." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузите zip-архив туда, откуда SwiftPM сможет скачать его по HTTPS. Подстановки: {name}, {version}, {zip}." + } + } + } + }, + "Upload the zip, replace the URL in the snippet with its download link and add the target to Package.swift. Set a download URL or a publish destination in the project options to skip this step." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузите zip-архив, замените URL во фрагменте ссылкой для скачивания и добавьте цель в Package.swift. Чтобы пропустить этот шаг, укажите URL для загрузки или место публикации в параметрах проекта." + } + } + } + }, + "Uploading…" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка…" + } + } + } + }, + "Use Default" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По умолчанию" + } + } + } + }, + "Use the location from Settings" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать папку из настроек" + } + } + } + }, + "Used by {version} in URLs and release tags" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подставляется в {version} в URL и тегах релиза" + } + } + } + }, + "Uses GitHub CLI (gh). The release is created if it doesn’t exist, otherwise the zip replaces its asset." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использует GitHub CLI (gh). Если релиза нет, он будет создан, иначе zip-архив заменит его файл." + } + } + } + }, + "Version" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия" + } + } + } + }, + "Window" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Окно" + } + } + } + }, + "Works with GitLab, Bitbucket, S3, Artifactory, Nexus or any internal server. Values are shell-quoted; define tokens in ~/.zprofile. Placeholders: {name}, {version}, {zip}, {path}, {checksum}." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Работает с GitLab, Bitbucket, S3, Artifactory, Nexus и любым внутренним сервером. Значения экранируются для shell; токены задайте в ~/.zprofile. Подстановки: {name}, {version}, {zip}, {path}, {checksum}." + } + } + } + }, + "Zip and compute checksum" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создание zip-архива и контрольной суммы" + } + } + } + }, + "Zoom" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить масштаб" + } + } + } + }, + "done" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "готово" + } + } + } + }, + "failed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ошибка" + } + } + } + }, + "iOS Simulator" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Симулятор iOS" + } + } + } + }, + "iPad apps running on the Mac" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приложения для iPad на Mac" + } + } + } + }, + "iPhone and iPad devices" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройства iPhone и iPad" + } + } + } + }, + "in progress" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "выполняется" + } + } + } + }, + "or drop an .xcodeproj or its folder anywhere in this window" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "или перетащите .xcodeproj или его папку в любое место этого окна" + } + } + } + }, + "signed by %@" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "подписано: %@" + } + } + } + }, + "tvOS Simulator" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Симулятор tvOS" + } + } + } + }, + "visionOS Simulator" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Симулятор visionOS" + } + } + } + }, + "waiting" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ожидание" + } + } + } + }, + "watchOS Simulator" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Симулятор watchOS" + } + } + } + }, + "xcodebuild could not read the project." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "xcodebuild не удалось прочитать проект." + } + } + } + }, + "“%@” doesn’t contain an Xcode project or workspace." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "«%@» не содержит проекта Xcode или рабочего пространства." + } + } + } + } + }, + "version" : "1.0" +} diff --git a/FrameGen/Services/BuildNotifier.swift b/FrameGen/Services/BuildNotifier.swift new file mode 100644 index 0000000..0d70bd4 --- /dev/null +++ b/FrameGen/Services/BuildNotifier.swift @@ -0,0 +1,53 @@ +// +// BuildNotifier.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit +import UserNotifications + +/// Tells the user a build finished while they were working in another app. +/// Builds take minutes, so this is the one event worth a notification. +final class BuildNotifier { + + enum Outcome { + case succeeded(projectName: String, frameworkName: String) + case failed(projectName: String, title: String) + } + + private var didRequestAuthorization = false + + /// Asks once, when the first build starts: the moment the notification's purpose is obvious. + func requestAuthorizationIfNeeded() { + guard Preferences.notifyWhenDone, !didRequestAuthorization else { return } + + didRequestAuthorization = true + Task { + _ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) + } + } + + func notify(_ outcome: Outcome) { + guard Preferences.notifyWhenDone, !NSApp.isActive else { return } + + let content = UNMutableNotificationContent() + switch outcome { + case let .succeeded(projectName, frameworkName): + content.title = String(localized: "\(frameworkName).xcframework is ready") + content.body = String(localized: "The build of \(projectName) finished.") + content.sound = .default + case let .failed(projectName, title): + content.title = title + content.body = String(localized: "The build of \(projectName) failed. Open FrameGen to see the errors.") + content.sound = .defaultCritical + } + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + Task { + try? await UNUserNotificationCenter.current().add(request) + } + } + +} diff --git a/FrameGen/Services/Preferences.swift b/FrameGen/Services/Preferences.swift index 91adce5..2fa1099 100644 --- a/FrameGen/Services/Preferences.swift +++ b/FrameGen/Services/Preferences.swift @@ -6,14 +6,16 @@ // Copyright © 2023 Ramiz Kichibekov. All rights reserved. // +import FrameGenKit import Foundation -/// Keys shared by `@AppStorage` in the Settings window and the models. +/// Keys shared by the Settings window and the models. enum PreferenceKey { static let outputLocation = "outputLocation" static let customOutputPath = "customOutputPath" static let revealInFinder = "revealInFinder" static let keepArchives = "keepArchives" + static let notifyWhenDone = "notifyWhenDone" static let recentProjects = "recentProjects" static let projectSettings = "projectSettings" } @@ -25,12 +27,53 @@ enum OutputLocation: String, CaseIterable, Identifiable { var id: String { rawValue } } +/// How a project's zip is distributed after building. +enum DistributionMode: String, Codable, CaseIterable, Sendable { + /// Upload by hand; an optional download URL template still fills the snippet. + case manual + case gitHubRelease + case command +} + /// What FrameGen remembers about each project between launches. +/// +/// Every field is optional and platforms decode leniently: a field added later, or a platform +/// removed in a future version, must never make the whole settings dictionary unreadable. nonisolated struct ProjectSettings: Codable, Equatable, Sendable { var scheme: String? var configuration: String? - var platforms: [Platform] = Platform.allCases + /// `nil` means every platform the scheme supports. + var platforms: [Platform]? var outputPath: String? + var signingIdentity: SigningIdentity? + var version: String? + var distribution: DistributionMode? + var downloadURLTemplate: String? + var publishCommand: String? + var tagTemplate: String? + var publishAsDraft: Bool? + + init() {} + + private enum CodingKeys: String, CodingKey { + case scheme, configuration, platforms, outputPath, signingIdentity, version, distribution + case downloadURLTemplate, publishCommand, tagTemplate, publishAsDraft + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + scheme = try? container.decodeIfPresent(String.self, forKey: .scheme) + configuration = try? container.decodeIfPresent(String.self, forKey: .configuration) + platforms = (try? container.decodeIfPresent([String].self, forKey: .platforms))?.compactMap(Platform.init(rawValue:)) + outputPath = try? container.decodeIfPresent(String.self, forKey: .outputPath) + signingIdentity = try? container.decodeIfPresent(SigningIdentity.self, forKey: .signingIdentity) + version = try? container.decodeIfPresent(String.self, forKey: .version) + distribution = try? container.decodeIfPresent(DistributionMode.self, forKey: .distribution) + downloadURLTemplate = try? container.decodeIfPresent(String.self, forKey: .downloadURLTemplate) + publishCommand = try? container.decodeIfPresent(String.self, forKey: .publishCommand) + tagTemplate = try? container.decodeIfPresent(String.self, forKey: .tagTemplate) + publishAsDraft = try? container.decodeIfPresent(Bool.self, forKey: .publishAsDraft) + } } enum Preferences { @@ -41,7 +84,8 @@ enum Preferences { static func registerDefaults() { defaults.register(defaults: [PreferenceKey.outputLocation: OutputLocation.insideProject.rawValue, PreferenceKey.revealInFinder: false, - PreferenceKey.keepArchives: true]) + PreferenceKey.keepArchives: true, + PreferenceKey.notifyWhenDone: true]) } // MARK: - Output @@ -54,39 +98,54 @@ enum Preferences { defaults.bool(forKey: PreferenceKey.revealInFinder) } + static var notifyWhenDone: Bool { + defaults.bool(forKey: PreferenceKey.notifyWhenDone) + } + /// `/build`, or `/` when a shared folder is set in Settings. - static func defaultOutputDirectory(for projectFileURL: URL) -> URL { - let projectDirectory = projectFileURL.deletingLastPathComponent() + static func defaultOutputDirectory(for container: ProjectContainer) -> URL { let location = OutputLocation(rawValue: defaults.string(forKey: PreferenceKey.outputLocation) ?? "") ?? .insideProject if location == .customFolder, let path = defaults.string(forKey: PreferenceKey.customOutputPath), !path.isEmpty { - return URL(fileURLWithPath: path, isDirectory: true) - .appendingPathComponent(projectFileURL.deletingPathExtension().lastPathComponent, isDirectory: true) + return URL(fileURLWithPath: path, isDirectory: true).appendingPathComponent(container.name, isDirectory: true) } - return projectDirectory.appendingPathComponent("build", isDirectory: true) + return container.directoryURL.appendingPathComponent("build", isDirectory: true) } // MARK: - Recent projects - static var recentProjects: [URL] { + static var recentProjects: [ProjectContainer] { (defaults.stringArray(forKey: PreferenceKey.recentProjects) ?? []) .map { URL(fileURLWithPath: $0) } .filter { FileManager.default.fileExists(atPath: $0.path) } + .compactMap(ProjectContainer.init(url:)) } - static func setRecentProjects(_ urls: [URL]) { - defaults.set(urls.prefix(maxRecentProjects).map(\.path), forKey: PreferenceKey.recentProjects) + static func setRecentProjects(_ containers: [ProjectContainer]) { + defaults.set(containers.prefix(maxRecentProjects).map(\.url.path), forKey: PreferenceKey.recentProjects) } // MARK: - Per-project settings - static func settings(for projectFileURL: URL) -> ProjectSettings { - allProjectSettings()[projectFileURL.path] ?? ProjectSettings() + /// Settings saved before workspaces were supported are keyed by the `.xcodeproj` path; + /// they are carried over when the same project now opens through its workspace. + static func settings(for container: ProjectContainer) -> ProjectSettings { + let all = allProjectSettings() + if let settings = all[container.url.path] { + return settings + } + if container.kind == .workspace, + let projectURL = ProjectInfo.mainProject(inWorkspace: container.url), + let settings = all[projectURL.path] { + return settings + } + + return ProjectSettings() } - static func setSettings(_ settings: ProjectSettings, for projectFileURL: URL) { + static func setSettings(_ settings: ProjectSettings, for container: ProjectContainer) { var all = allProjectSettings() - all[projectFileURL.path] = settings + all[container.url.path] = settings if let data = try? JSONEncoder().encode(all) { defaults.set(data, forKey: PreferenceKey.projectSettings) } diff --git a/FrameGen/Services/ProjectLocator.swift b/FrameGen/Services/ProjectLocator.swift deleted file mode 100644 index 5fa88da..0000000 --- a/FrameGen/Services/ProjectLocator.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// ProjectLocator.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 18.05.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import AppKit -import UniformTypeIdentifiers - -enum ProjectLocator { - - static let projectType = UTType("com.apple.xcode.project") ?? .package - - /// Accepts an `.xcodeproj` or a folder and returns the project file to build. - /// Folders are searched a few levels deep only, so dropping a home folder stays fast. - static func projectFile(for url: URL, maxDepth: Int = 3) -> URL? { - if url.pathExtension == "xcodeproj" { - return url - } - guard (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true, url.path != "/" else { - return nil - } - - var level = [url] - for _ in 0.. URL? { - let panel = NSOpenPanel() - panel.title = "Open Project" - panel.message = "Choose an Xcode project or a folder that contains one." - panel.prompt = "Open" - panel.canChooseFiles = true - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.allowedContentTypes = [projectType, .folder] - - return panel.runModal() == .OK ? panel.url : nil - } - - @MainActor - static func chooseFolder(title: String, startingAt directory: URL?) -> URL? { - let panel = NSOpenPanel() - panel.title = title - panel.prompt = "Choose" - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.canCreateDirectories = true - panel.allowsMultipleSelection = false - panel.directoryURL = directory - - return panel.runModal() == .OK ? panel.url : nil - } - -} diff --git a/FrameGen/Services/ProjectPicker.swift b/FrameGen/Services/ProjectPicker.swift new file mode 100644 index 0000000..4a7d92d --- /dev/null +++ b/FrameGen/Services/ProjectPicker.swift @@ -0,0 +1,44 @@ +// +// ProjectPicker.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 18.05.2023. +// Copyright © 2023 Ramiz Kichibekov. All rights reserved. +// + +import AppKit +import UniformTypeIdentifiers + +/// Open panels for projects and folders. Finding the project inside a choice is FrameGenKit's `ProjectLocator`. +enum ProjectPicker { + + static let projectType = UTType("com.apple.xcode.project") ?? .package + static let workspaceType = UTType("com.apple.dt.document.workspace") ?? .package + + static func chooseProject() -> URL? { + let panel = NSOpenPanel() + panel.title = String(localized: "Open Project") + panel.message = String(localized: "Choose an Xcode project, a workspace or a folder that contains one.") + panel.prompt = String(localized: "Open") + panel.canChooseFiles = true + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [workspaceType, projectType, .folder] + + return panel.runModal() == .OK ? panel.url : nil + } + + static func chooseFolder(title: String, startingAt directory: URL?) -> URL? { + let panel = NSOpenPanel() + panel.title = title + panel.prompt = String(localized: "Choose") + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.allowsMultipleSelection = false + panel.directoryURL = directory + + return panel.runModal() == .OK ? panel.url : nil + } + +} diff --git a/FrameGen/Services/XcodeCommand.swift b/FrameGen/Services/XcodeCommand.swift deleted file mode 100644 index 540379a..0000000 --- a/FrameGen/Services/XcodeCommand.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// XcodeCommand.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 21.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Foundation - -/// Builds the command lines FrameGen runs. Arguments are passed as an array, -/// so paths with spaces or quotes never need escaping. -nonisolated enum XcodeCommand { - - private static let xcodebuildURL = URL(fileURLWithPath: "/usr/bin/xcodebuild") - private static let dittoURL = URL(fileURLWithPath: "/usr/bin/ditto") - - static func list(project: URL) -> ProcessRunner { - ProcessRunner(executableURL: xcodebuildURL, - arguments: ["-list", "-json", "-project", project.path], - mergesStandardError: false) - } - - static func archive(project: URL, - scheme: String, - configuration: String, - platform: Platform, - archiveURL: URL) -> ProcessRunner { - ProcessRunner(executableURL: xcodebuildURL, - arguments: ["archive", - "-project", project.path, - "-scheme", scheme, - "-configuration", configuration, - "-destination", platform.destination, - "-archivePath", archiveURL.path, - "SKIP_INSTALL=NO", - "BUILD_LIBRARIES_FOR_DISTRIBUTION=YES", - // Recent Xcode versions no longer derive these from BUILD_LIBRARIES_FOR_DISTRIBUTION, - // and -create-xcframework refuses Swift frameworks without a .swiftinterface. - "SWIFT_ENABLE_LIBRARY_EVOLUTION=YES", - "SWIFT_EMIT_MODULE_INTERFACE=YES"]) - } - - static func createXCFramework(slices: [XCFrameworkBuilder.Slice], output: URL) -> ProcessRunner { - let sliceArguments = slices.flatMap { slice in - ["-framework", slice.frameworkURL.path] + slice.debugSymbolURLs.flatMap { ["-debug-symbols", $0.path] } - } - - return ProcessRunner(executableURL: xcodebuildURL, - arguments: ["-create-xcframework"] + sliceArguments + ["-output", output.path]) - } - - static func zip(source: URL, destination: URL) -> ProcessRunner { - ProcessRunner(executableURL: dittoURL, - arguments: ["-c", "-k", "--sequesterRsrc", "--keepParent", source.path, destination.path]) - } - -} diff --git a/FrameGen/Views/CalloutView.swift b/FrameGen/Views/CalloutView.swift index 4fc7bdb..fbf82e4 100644 --- a/FrameGen/Views/CalloutView.swift +++ b/FrameGen/Views/CalloutView.swift @@ -12,11 +12,14 @@ import AppKit final class CalloutView: NSView { enum Style { + case success case warning case error var symbol: String { switch self { + case .success: + return "checkmark.circle.fill" case .warning: return "exclamationmark.triangle.fill" case .error: @@ -26,6 +29,8 @@ final class CalloutView: NSView { var color: NSColor { switch self { + case .success: + return .systemGreen case .warning: return .systemOrange case .error: diff --git a/FrameGen/Views/CopyButton.swift b/FrameGen/Views/CopyButton.swift index 254ca8b..2cdc8e7 100644 --- a/FrameGen/Views/CopyButton.swift +++ b/FrameGen/Views/CopyButton.swift @@ -19,7 +19,7 @@ final class CopyButton: NSButton { // MARK: - Initial methods - init(title: String = "Copy", value: @escaping () -> String) { + init(title: String = String(localized: "Copy"), value: @escaping () -> String) { self.value = value defaultTitle = title super.init(frame: .zero) @@ -45,10 +45,10 @@ final class CopyButton: NSButton { NSPasteboard.general.clearContents() NSPasteboard.general.setString(value(), forType: .string) - title = "Copied" + title = String(localized: "Copied") image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) NSAccessibility.post(element: self, notification: .announcementRequested, - userInfo: [.announcement: "Copied to the clipboard"]) + userInfo: [.announcement: String(localized: "Copied to the clipboard")]) resetTask?.cancel() resetTask = Task { [weak self] in try? await Task.sleep(for: .seconds(1.5)) diff --git a/FrameGen/Views/LogView.swift b/FrameGen/Views/LogView.swift index 0561090..bb53de3 100644 --- a/FrameGen/Views/LogView.swift +++ b/FrameGen/Views/LogView.swift @@ -38,7 +38,7 @@ final class LogView: NSView { textView.drawsBackground = true textView.backgroundColor = .textBackgroundColor textView.textContainerInset = NSSize(width: 8, height: 8) - textView.setAccessibilityLabel("Build log") + textView.setAccessibilityLabel(String(localized: "Build log")) scrollView.borderType = .noBorder scrollView.hasVerticalScroller = true scrollView.wantsLayer = true diff --git a/FrameGen/Views/RecentProjectCellView.swift b/FrameGen/Views/RecentProjectCellView.swift index c465d6b..b508495 100644 --- a/FrameGen/Views/RecentProjectCellView.swift +++ b/FrameGen/Views/RecentProjectCellView.swift @@ -52,7 +52,7 @@ final class RecentProjectCellView: NSTableCellView { nameLabel.stringValue = url.deletingPathExtension().lastPathComponent pathLabel.stringValue = Formatters.path(url.deletingLastPathComponent()) toolTip = url.path - setAccessibilityLabel("\(nameLabel.stringValue), \(pathLabel.stringValue)") + setAccessibilityLabel(String(localized: "\(nameLabel.stringValue), \(pathLabel.stringValue)")) } } diff --git a/FrameGen/Views/StatusHeaderView.swift b/FrameGen/Views/StatusHeaderView.swift index 4806044..af1c78f 100644 --- a/FrameGen/Views/StatusHeaderView.swift +++ b/FrameGen/Views/StatusHeaderView.swift @@ -50,7 +50,7 @@ final class StatusHeaderView: NSView { spinner.controlSize = .regular spinner.translatesAutoresizingMaskIntoConstraints = false spinner.startAnimation(nil) - spinner.setAccessibilityLabel("Building") + spinner.setAccessibilityLabel(String(localized: "Building")) setIcon(spinner) } diff --git a/FrameGen/Views/StepRowView.swift b/FrameGen/Views/StepRowView.swift index 8957483..1810d5c 100644 --- a/FrameGen/Views/StepRowView.swift +++ b/FrameGen/Views/StepRowView.swift @@ -58,7 +58,7 @@ final class StepRowView: NSView { durationLabel.stringValue = "" titleLabel.textColor = .labelColor } - setAccessibilityLabel("\(step.title), \(Self.accessibilityStatus(step.status))") + setAccessibilityLabel(String(localized: "\(step.title), \(Self.accessibilityStatus(step.status))")) // Recreating the indicator on every tick would restart the spinner animation. guard shownStatus.map({ Self.sameKind($0, step.status) }) != true else { return } @@ -100,13 +100,13 @@ final class StepRowView: NSView { private static func accessibilityStatus(_ status: BuildStep.Status) -> String { switch status { case .pending: - return "waiting" + return String(localized: "waiting") case .running: - return "in progress" + return String(localized: "in progress") case .succeeded: - return "done" + return String(localized: "done") case .failed: - return "failed" + return String(localized: "failed") } } diff --git a/FrameGen/Window/MainWindowController.swift b/FrameGen/Window/MainWindowController.swift index 81b7080..081590f 100644 --- a/FrameGen/Window/MainWindowController.swift +++ b/FrameGen/Window/MainWindowController.swift @@ -7,6 +7,7 @@ // import AppKit +import FrameGenKit /// Owns the main window, its toolbar and the project commands from the menu bar. /// It sits in the responder chain of the window, so menu items reach it whatever view has focus. @@ -57,7 +58,7 @@ final class MainWindowController: NSWindowController { // MARK: - Public methods func openProject(at url: URL) { - guard confirmStoppingBuild(action: "Open") else { return } + guard confirmStoppingBuild(action: String(localized: "Stop and Open")) else { return } do { buildController.cancel() @@ -68,15 +69,16 @@ final class MainWindowController: NSWindowController { } /// Asks before interrupting a running build; returns whether the caller may continue. + /// `action` is the confirm button title, e.g. “Stop and Quit”. func confirmStoppingBuild(action: String) -> Bool { guard let project, project.build.isRunning else { return true } let alert = NSAlert() alert.alertStyle = .warning - alert.messageText = "Stop building \(project.name)?" - alert.informativeText = "The XCFramework build in progress will be stopped." - alert.addButton(withTitle: "Stop and \(action)") - alert.addButton(withTitle: "Cancel") + alert.messageText = String(localized: "Stop building \(project.name)?") + alert.informativeText = String(localized: "The XCFramework build in progress will be stopped.") + alert.addButton(withTitle: action) + alert.addButton(withTitle: String(localized: "Cancel")) guard alert.runModal() == .alertFirstButtonReturn else { return false } buildController.cancel() @@ -88,7 +90,7 @@ final class MainWindowController: NSWindowController { @objc func openDocument(_ sender: Any?) { - guard let url = ProjectLocator.chooseProject() else { return } + guard let url = ProjectPicker.chooseProject() else { return } openProject(at: url) } @@ -107,7 +109,7 @@ final class MainWindowController: NSWindowController { @objc func closeProject(_ sender: Any?) { - guard confirmStoppingBuild(action: "Close") else { return } + guard confirmStoppingBuild(action: String(localized: "Stop and Close")) else { return } workspace.closeProject() } @@ -124,6 +126,21 @@ final class MainWindowController: NSWindowController { buildController.cancel() } + @objc + func copyBuildCommand(_ sender: Any?) { + guard let command = project?.buildCommandLine() else { return } + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(command, forType: .string) + } + + @objc + func publishXCFramework(_ sender: Any?) { + guard let project else { return } + + buildController.publish(project) + } + @objc func showOutputInFinder(_ sender: Any?) { guard let project else { return } @@ -142,8 +159,8 @@ final class MainWindowController: NSWindowController { if let project { window.title = project.name - window.subtitle = Formatters.path(project.fileURL.deletingLastPathComponent()) - window.representedURL = project.fileURL + window.subtitle = Formatters.path(project.container.directoryURL) + window.representedURL = project.container.url // Read the build state so the toolbar re-validates when a build starts or ends. _ = project.canBuild } else { @@ -160,6 +177,12 @@ final class MainWindowController: NSWindowController { return project != nil case #selector(buildXCFramework(_:)): return project?.canBuild == true + case #selector(copyBuildCommand(_:)): + return project?.makeBuildRequest() != nil + case #selector(publishXCFramework(_:)): + guard let project, case .succeeded = project.build.phase else { return false } + + return project.publishDestination != nil && project.build.publishState != .publishing case #selector(stopBuild(_:)): return project?.build.isRunning == true case #selector(clearRecentProjects(_:)): @@ -202,17 +225,17 @@ extension MainWindowController: NSToolbarDelegate { willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { switch itemIdentifier { case ToolbarItem.closeProject: - return item(itemIdentifier, label: "Projects", symbol: "chevron.backward", - toolTip: "Close the project and show recent projects (⇧⌘W)", action: #selector(closeProject(_:))) + return item(itemIdentifier, label: String(localized: "Projects"), symbol: "chevron.backward", + toolTip: String(localized: "Close the project and show recent projects (⇧⌘W)"), action: #selector(closeProject(_:))) case ToolbarItem.build: - return item(itemIdentifier, label: "Build", symbol: "play.fill", - toolTip: "Build the XCFramework (⌘B)", action: #selector(buildXCFramework(_:))) + return item(itemIdentifier, label: String(localized: "Build"), symbol: "play.fill", + toolTip: String(localized: "Build the XCFramework (⌘B)"), action: #selector(buildXCFramework(_:))) case ToolbarItem.stop: - return item(itemIdentifier, label: "Stop", symbol: "stop.fill", - toolTip: "Stop the build (⌘.)", action: #selector(stopBuild(_:))) + return item(itemIdentifier, label: String(localized: "Stop"), symbol: "stop.fill", + toolTip: String(localized: "Stop the build (⌘.)"), action: #selector(stopBuild(_:))) case ToolbarItem.showInFinder: - return item(itemIdentifier, label: "Show in Finder", symbol: "folder", - toolTip: "Show the output folder in Finder (⇧⌘R)", action: #selector(showOutputInFinder(_:))) + return item(itemIdentifier, label: String(localized: "Show in Finder"), symbol: "folder", + toolTip: String(localized: "Show the output folder in Finder (⇧⌘R)"), action: #selector(showOutputInFinder(_:))) default: return nil } diff --git a/FrameGenCLI/main.swift b/FrameGenCLI/main.swift new file mode 100644 index 0000000..6fa15fb --- /dev/null +++ b/FrameGenCLI/main.swift @@ -0,0 +1,13 @@ +// +// main.swift +// framegen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import FrameGenCLI +import Foundation + +// The tool ships inside FrameGen.app (Contents/Helpers/framegen); all of its logic lives in FrameGenCLI. +exit(await FrameGenTool.run(Array(CommandLine.arguments.dropFirst()))) diff --git a/FrameGenTests/BuildStateTests.swift b/FrameGenTests/BuildStateTests.swift index 9a82de2..10884ef 100644 --- a/FrameGenTests/BuildStateTests.swift +++ b/FrameGenTests/BuildStateTests.swift @@ -8,6 +8,7 @@ import Foundation import Testing +import FrameGenKit @testable import FrameGen @MainActor @@ -75,6 +76,7 @@ struct BuildStateTests { func stepTitles() { #expect(BuildStep(kind: .archive(.iOSSimulator)).title == "Archive for iOS Simulator") #expect(BuildStep(kind: .archive(.macCatalyst)).failureTitle == "Archiving for Mac Catalyst failed") + #expect(BuildStep(kind: .sign).title == "Sign XCFramework") } } diff --git a/FrameGenTests/ProjectInfoTests.swift b/FrameGenTests/ProjectInfoTests.swift deleted file mode 100644 index fec2ae0..0000000 --- a/FrameGenTests/ProjectInfoTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// ProjectInfoTests.swift -// FrameGenTests -// -// Created by Рамиз Кичибеков on 19.10.2026. -// Copyright © 2026 Ramiz Kichibekov. All rights reserved. -// - -import Foundation -import Testing -@testable import FrameGen - -@Suite("Reading xcodebuild -list -json") -struct ProjectInfoTests { - - private func decode(_ json: String) throws -> ProjectInfo { - try JSONDecoder().decode(ProjectInfo.self, from: Data(json.utf8)) - } - - @Test("Reads schemes and configurations") - func readsSchemesAndConfigurations() throws { - let info = try decode(""" - { - "project" : { - "configurations" : ["Debug", "Release", "Staging"], - "name" : "Kit", - "schemes" : ["Kit", "KitTests"], - "targets" : ["Kit"] - } - } - """) - - #expect(info.schemes == ["Kit", "KitTests"]) - #expect(info.configurations == ["Debug", "Release", "Staging"]) - } - - @Test("Falls back to Release when configurations are missing or empty", arguments: [ - #"{ "project" : { "schemes" : ["Kit"] } }"#, - #"{ "project" : { "configurations" : [], "schemes" : ["Kit"] } }"#, - ]) - func defaultsConfiguration(json: String) throws { - let info = try decode(json) - - #expect(info.configurations == ["Release"]) - #expect(info.schemes == ["Kit"]) - } - - @Test("A project without schemes has an empty list") - func missingSchemes() throws { - let info = try decode(#"{ "project" : { "configurations" : ["Release"] } }"#) - - #expect(info.schemes.isEmpty) - } - - @Test("Output without a project object is rejected") - func rejectsWorkspaceOutput() { - #expect(throws: DecodingError.self) { - try decode(#"{ "workspace" : { "name" : "App", "schemes" : ["App"] } }"#) - } - } - -} diff --git a/FrameGenTests/ProjectLocatorTests.swift b/FrameGenTests/ProjectLocatorTests.swift deleted file mode 100644 index 6169613..0000000 --- a/FrameGenTests/ProjectLocatorTests.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// ProjectLocatorTests.swift -// FrameGenTests -// -// Created by Рамиз Кичибеков on 19.10.2026. -// Copyright © 2026 Ramiz Kichibekov. All rights reserved. -// - -import Foundation -import Testing -@testable import FrameGen - -@MainActor -@Suite("Finding the project in a dropped item") -struct ProjectLocatorTests { - - @Test("A project file is used as is") - func projectFile() throws { - let directory = try TemporaryDirectory() - let project = try directory.makeDirectory("Kit.xcodeproj") - - #expect(ProjectLocator.projectFile(for: project) == project) - } - - @Test("A project directly inside the folder is found") - func topLevelProject() throws { - let directory = try TemporaryDirectory() - let project = try directory.makeDirectory("Kit/Kit.xcodeproj") - - #expect(ProjectLocator.projectFile(for: directory.url.appendingPathComponent("Kit"))?.standardizedFileURL - == project.standardizedFileURL) - } - - @Test("A nested project is found, preferring the shallowest one") - func nestedProject() throws { - let directory = try TemporaryDirectory() - let shallow = try directory.makeDirectory("Repo/Sources/Kit.xcodeproj") - try directory.makeDirectory("Repo/Examples/Demo/Demo/Demo.xcodeproj") - - let found = ProjectLocator.projectFile(for: directory.url.appendingPathComponent("Repo")) - - #expect(found?.standardizedFileURL == shallow.standardizedFileURL) - } - - @Test("Projects deeper than the search limit are ignored") - func depthLimit() throws { - let directory = try TemporaryDirectory() - try directory.makeDirectory("Repo/a/b/c/Kit.xcodeproj") - let root = directory.url.appendingPathComponent("Repo") - - #expect(ProjectLocator.projectFile(for: root, maxDepth: 3) == nil) - #expect(ProjectLocator.projectFile(for: root, maxDepth: 4) != nil) - } - - @Test("Package contents and hidden folders aren't searched") - func skipsPackagesAndHiddenFolders() throws { - let directory = try TemporaryDirectory() - try directory.makeDirectory("Repo/.build/Kit.xcodeproj") - try directory.makeDirectory("Repo/Tool.app/Contents/Kit.xcodeproj") - - #expect(ProjectLocator.projectFile(for: directory.url.appendingPathComponent("Repo")) == nil) - } - - @Test("Files and missing paths aren't projects") - func invalidItems() throws { - let directory = try TemporaryDirectory() - let file = try directory.makeFile("notes.txt") - - #expect(ProjectLocator.projectFile(for: file) == nil) - #expect(ProjectLocator.projectFile(for: directory.url.appendingPathComponent("missing")) == nil) - } - -} diff --git a/FrameGenTests/ProjectSettingsTests.swift b/FrameGenTests/ProjectSettingsTests.swift new file mode 100644 index 0000000..74ff600 --- /dev/null +++ b/FrameGenTests/ProjectSettingsTests.swift @@ -0,0 +1,74 @@ +// +// ProjectSettingsTests.swift +// FrameGenTests +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import FrameGenKit +import Foundation +import Testing +@testable import FrameGen + +@Suite("Saved project settings") +struct ProjectSettingsTests { + + private func decode(_ json: String) throws -> ProjectSettings { + try JSONDecoder().decode(ProjectSettings.self, from: Data(json.utf8)) + } + + @Test("Settings saved by FrameGen 2.0 still load") + func readsVersionTwoSettings() throws { + let settings = try decode(#""" + {"scheme":"Kit","configuration":"Release","platforms":["iOS","iOSSimulator","macCatalyst"],"outputPath":"/tmp/out"} + """#) + + #expect(settings.scheme == "Kit") + #expect(settings.configuration == "Release") + #expect(settings.platforms == [.iOS, .iOSSimulator, .macCatalyst]) + #expect(settings.outputPath == "/tmp/out") + #expect(settings.distribution == nil) + #expect(settings.signingIdentity == nil) + } + + @Test("Unknown platforms and fields are skipped instead of failing") + func toleratesUnknownValues() throws { + let settings = try decode(#""" + {"scheme":"Kit","platforms":["iOS","holoOS"],"distribution":"carrierPigeon","futureField":42} + """#) + + #expect(settings.scheme == "Kit") + #expect(settings.platforms == [.iOS]) + #expect(settings.distribution == nil) + } + + @Test("A settings dictionary with one odd entry keeps every project") + func dictionarySurvivesBadEntry() throws { + let data = Data(#""" + {"/a/A.xcodeproj":{"scheme":"A","platforms":["tvOS"]},"/b/B.xcworkspace":{"scheme":7,"platforms":"iOS"}} + """#.utf8) + let all = try JSONDecoder().decode([String: ProjectSettings].self, from: data) + + #expect(all["/a/A.xcodeproj"]?.platforms == [.tvOS]) + #expect(all["/b/B.xcworkspace"]?.scheme == nil) + #expect(all["/b/B.xcworkspace"]?.platforms == nil) + } + + @Test("Settings round-trip through JSON") + func roundTrip() throws { + var settings = ProjectSettings() + settings.scheme = "Kit" + settings.platforms = [.macOS, .visionOS] + settings.signingIdentity = SigningIdentity(sha1: String(repeating: "A", count: 40), name: "Apple Distribution: Company") + settings.distribution = .command + settings.publishCommand = "aws s3 cp {path} s3://bucket/{zip}" + settings.downloadURLTemplate = "https://bucket.s3.amazonaws.com/{zip}" + settings.publishAsDraft = true + + let decoded = try JSONDecoder().decode(ProjectSettings.self, from: JSONEncoder().encode(settings)) + + #expect(decoded == settings) + } + +} diff --git a/FrameGenTests/XcodeCommandTests.swift b/FrameGenTests/XcodeCommandTests.swift deleted file mode 100644 index f3aba29..0000000 --- a/FrameGenTests/XcodeCommandTests.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// XcodeCommandTests.swift -// FrameGenTests -// -// Created by Рамиз Кичибеков on 19.10.2026. -// Copyright © 2026 Ramiz Kichibekov. All rights reserved. -// - -import Foundation -import Testing -@testable import FrameGen - -@Suite("xcodebuild and ditto command lines") -struct XcodeCommandTests { - - private let project = URL(fileURLWithPath: "/Users/me/My \"Quoted\" Projects/Kit/Kit.xcodeproj") - - @Test("list asks for JSON and keeps stderr out of it") - func list() { - let runner = XcodeCommand.list(project: project) - - #expect(runner.executableURL.path == "/usr/bin/xcodebuild") - #expect(runner.arguments == ["-list", "-json", "-project", project.path]) - #expect(runner.mergesStandardError == false) - } - - @Test("archive passes paths with spaces and quotes as single arguments", arguments: Platform.allCases) - func archive(platform: Platform) { - let archiveURL = URL(fileURLWithPath: "/Users/me/Out Dir/Archives/My Kit-\(platform.archiveSuffix).xcarchive") - let runner = XcodeCommand.archive(project: project, - scheme: "My Kit", - configuration: "Release", - platform: platform, - archiveURL: archiveURL) - - #expect(runner.executableURL.path == "/usr/bin/xcodebuild") - #expect(runner.mergesStandardError) - #expect(runner.arguments == ["archive", - "-project", project.path, - "-scheme", "My Kit", - "-configuration", "Release", - "-destination", platform.destination, - "-archivePath", archiveURL.path, - "SKIP_INSTALL=NO", - "BUILD_LIBRARIES_FOR_DISTRIBUTION=YES", - "SWIFT_ENABLE_LIBRARY_EVOLUTION=YES", - "SWIFT_EMIT_MODULE_INTERFACE=YES"]) - } - - @Test("Mac Catalyst builds for every Mac architecture") - func catalystDestination() { - #expect(Platform.macCatalyst.destination == "generic/platform=macOS,variant=Mac Catalyst") - #expect(!Platform.macCatalyst.destination.contains("arch=")) - } - - @Test("create-xcframework lists every framework with its debug symbols") - func createXCFramework() { - let first = XCFrameworkBuilder.Slice(frameworkURL: URL(fileURLWithPath: "/a/Kit.framework"), - debugSymbolURLs: [URL(fileURLWithPath: "/a/Kit.framework.dSYM")]) - let second = XCFrameworkBuilder.Slice(frameworkURL: URL(fileURLWithPath: "/b/Kit.framework"), - debugSymbolURLs: []) - let output = URL(fileURLWithPath: "/out dir/Kit.xcframework") - let runner = XcodeCommand.createXCFramework(slices: [first, second], output: output) - - #expect(runner.arguments == ["-create-xcframework", - "-framework", "/a/Kit.framework", - "-debug-symbols", "/a/Kit.framework.dSYM", - "-framework", "/b/Kit.framework", - "-output", "/out dir/Kit.xcframework"]) - } - - @Test("zip keeps the bundle folder and resource forks out of the archive") - func zip() { - let runner = XcodeCommand.zip(source: URL(fileURLWithPath: "/out/Kit.xcframework"), - destination: URL(fileURLWithPath: "/out/Kit.xcframework.zip")) - - #expect(runner.executableURL.path == "/usr/bin/ditto") - #expect(runner.arguments == ["-c", "-k", "--sequesterRsrc", "--keepParent", - "/out/Kit.xcframework", "/out/Kit.xcframework.zip"]) - } - -} diff --git a/Packages/FrameGenKit/Package.swift b/Packages/FrameGenKit/Package.swift new file mode 100644 index 0000000..d74f3ed --- /dev/null +++ b/Packages/FrameGenKit/Package.swift @@ -0,0 +1,50 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +// Matches the app's "approachable concurrency" so nonisolated async code behaves the same in +// FrameGen.app, the framegen command-line tool and the package tests. +let swiftSettings: [SwiftSetting] = [ + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("MemberImportVisibility"), +] + +let package = Package( + name: "FrameGenKit", + platforms: [ + .macOS("27.0"), + ], + products: [ + .library(name: "FrameGenKit", targets: ["FrameGenKit"]), + .library(name: "FrameGenCLI", targets: ["FrameGenCLI"]), + .executable(name: "framegen-dev", targets: ["framegen-dev"]), + ], + targets: [ + .target( + name: "FrameGenKit", + swiftSettings: swiftSettings + ), + .target( + name: "FrameGenCLI", + dependencies: ["FrameGenKit"], + swiftSettings: swiftSettings + ), + // `swift run framegen-dev …` during development; releases ship the Xcode-built tool inside FrameGen.app. + .executableTarget( + name: "framegen-dev", + dependencies: ["FrameGenCLI"], + swiftSettings: swiftSettings + ), + .testTarget( + name: "FrameGenKitTests", + dependencies: ["FrameGenKit"], + swiftSettings: swiftSettings + ), + .testTarget( + name: "FrameGenCLITests", + dependencies: ["FrameGenCLI", "FrameGenKit"], + swiftSettings: swiftSettings + ), + ] +) diff --git a/Packages/FrameGenKit/Sources/FrameGenCLI/FrameGenTool.swift b/Packages/FrameGenKit/Sources/FrameGenCLI/FrameGenTool.swift new file mode 100644 index 0000000..8dcd5de --- /dev/null +++ b/Packages/FrameGenKit/Sources/FrameGenCLI/FrameGenTool.swift @@ -0,0 +1,266 @@ +// +// FrameGenTool.swift +// FrameGenCLI +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import FrameGenKit +import Foundation + +/// The `framegen` command-line tool. Progress goes to stderr and results to stdout, +/// so `framegen build … --json | jq` works in CI. +public enum FrameGenTool { + + public enum ExitCode { + public static let success: Int32 = 0 + public static let failure: Int32 = 1 + public static let usage: Int32 = 2 + public static let canceled: Int32 = 130 + } + + /// Runs the tool with the arguments after the executable name and returns the exit code. + public static func run(_ arguments: [String], + currentDirectory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath), + output: @escaping (String) -> Void = { print($0) }, + diagnostics: @escaping (String) -> Void = { FileHandle.standardError.write(Data(($0 + "\n").utf8)) }) async -> Int32 { + var arguments = arguments[...] + let command = arguments.popFirst() ?? "help" + do { + switch command { + case "build": + return try await build(Array(arguments), currentDirectory: currentDirectory, output: output, diagnostics: diagnostics) + case "list": + return try await list(Array(arguments), currentDirectory: currentDirectory, output: output) + case "identities": + for identity in try await SigningIdentity.available() { + output("\(identity.sha1) \(identity.name)") + } + + return ExitCode.success + case "help", "--help", "-h": + output(usage) + + return ExitCode.success + case "--version": + output("framegen \(version)") + + return ExitCode.success + default: + diagnostics("error: Unknown command “\(command)”.\n") + diagnostics(usage) + + return ExitCode.usage + } + } catch let error as CommandLineError { + diagnostics("error: \(error.localizedDescription)") + + return ExitCode.usage + } catch is CancellationError { + return ExitCode.canceled + } catch ProcessError.canceled { + return ExitCode.canceled + } catch let error as PipelineError { + diagnostics("error: \(describe(error.step)) failed. \(error.localizedDescription)") + error.highlights.forEach { diagnostics(" \($0)") } + + return ExitCode.failure + } catch { + diagnostics("error: \(error.localizedDescription)") + + return ExitCode.failure + } + } + + // MARK: - Commands + + private static func build(_ arguments: [String], + currentDirectory: URL, + output: @escaping (String) -> Void, + diagnostics: @escaping (String) -> Void) async throws -> Int32 { + var parsed = try await BuildArguments(parsing: arguments, currentDirectory: currentDirectory) + if parsed.usesDetectedPlatforms { + let info = try await SchemeBuildInfo.load(parsed.request.container, scheme: parsed.request.scheme) + parsed.request.platforms = Platform.allCases.filter(info.supportedPlatforms.contains) + diagnostics("Building for every supported platform: \(parsed.request.orderedPlatforms.map(\.cliName).joined(separator: ", "))") + } + if let query = parsed.signQuery { + parsed.request.signingIdentity = try await resolveIdentity(query) + } + + var steps: [StepKind] = [] + let summary = try await XCFrameworkPipeline.run(parsed.request) { event in + switch event { + case .planned(let planned): + steps = planned + case .stepStarted(let index): + diagnostics("==> [\(index + 1)/\(steps.count)] \(describe(steps[index]))") + case let .stepFinished(_, duration): + diagnostics(" done in \(Int(duration.rounded()))s") + case .stepFailed: + break + case .log(let line): + if parsed.isVerbose { + diagnostics(" \(line)") + } + } + } + + var result = summary + if let destination = parsed.publish { + diagnostics("==> Publishing \(summary.plan.zipURL.lastPathComponent)") + let url = try await Publisher.publish(summary, version: parsed.request.version, to: destination) { line in + if parsed.isVerbose { + diagnostics(" \(line)") + } + } + result = try summary.withDownloadURL(url) + } + + output(parsed.printsJSON ? try json(for: result) : text(for: result)) + + return ExitCode.success + } + + private static func list(_ arguments: [String], currentDirectory: URL, output: (String) -> Void) async throws -> Int32 { + var path: String? + var scheme: String? + var remaining = arguments[...] + while let argument = remaining.popFirst() { + switch argument { + case "--project", "--workspace", "--path": + guard let value = remaining.popFirst() else { throw CommandLineError.missingValue(argument) } + + path = value + case "--scheme": + guard let value = remaining.popFirst() else { throw CommandLineError.missingValue(argument) } + + scheme = value + default: + throw CommandLineError.unknownOption(argument) + } + } + + let url = URL(fileURLWithPath: path ?? ".", relativeTo: currentDirectory).standardizedFileURL + guard let container = ProjectLocator.container(for: url) else { throw CommandLineError.projectNotFound(url.path) } + + let info = try await ProjectInfo.load(container) + output("\(container.kind == .project ? "Project" : "Workspace"): \(container.url.path)") + output("Schemes: \(info.schemes.joined(separator: ", "))") + output("Configurations: \(info.configurations.joined(separator: ", "))") + if let scheme = scheme ?? info.schemes.first(where: { $0 == container.name }) ?? info.schemes.first { + let buildInfo = try await SchemeBuildInfo.load(container, scheme: scheme) + let platforms = Platform.allCases.filter(buildInfo.supportedPlatforms.contains).map(\.cliName) + output("Platforms for \(scheme): \(platforms.joined(separator: ", "))") + if let version = buildInfo.marketingVersion { + output("Version: \(version)") + } + } + + return ExitCode.success + } + + // MARK: - Helpers + + private static func resolveIdentity(_ query: String) async throws -> SigningIdentity { + let identities = try await SigningIdentity.available() + let matches = identities.filter { $0.sha1.caseInsensitiveCompare(query) == .orderedSame || $0.name.contains(query) } + guard matches.count == 1, let identity = matches.first else { + throw CommandLineError.invalidValue(option: "--sign", + value: matches.isEmpty ? "\(query) (no matching identity; see “framegen identities”)" + : "\(query) (matches \(matches.count) identities; pass the SHA-1)") + } + + return identity + } + + static func describe(_ step: StepKind) -> String { + switch step { + case .archive(let platform): + return "Archive for \(platform.cliName)" + case .createXCFramework: + return "Create XCFramework" + case .sign: + return "Sign XCFramework" + case .package: + return "Zip and compute checksum" + } + } + + static func text(for summary: XCFrameworkBuilder.Summary) -> String { + var lines = ["XCFramework: \(summary.plan.xcframeworkURL.path)", + "Zip: \(summary.plan.zipURL.path)", + "Checksum: \(summary.checksum)"] + if let signedBy = summary.signedBy { + lines.append("Signed by: \(signedBy)") + } + if !summary.librariesWithoutPrivacyManifest.isEmpty { + lines.append("Warning: no PrivacyInfo.xcprivacy in \(summary.librariesWithoutPrivacyManifest.joined(separator: ", "))") + } + lines += ["", summary.packageSnippet.trimmingCharacters(in: .newlines)] + + return lines.joined(separator: "\n") + } + + static func json(for summary: XCFrameworkBuilder.Summary) throws -> String { + struct Output: Encodable { + let xcframework: String + let zip: String + let checksum: String + let downloadURL: String? + let snippet: String + let signedBy: String? + let librariesWithoutPrivacyManifest: [String] + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(Output(xcframework: summary.plan.xcframeworkURL.path, + zip: summary.plan.zipURL.path, + checksum: summary.checksum, + downloadURL: summary.downloadURL?.absoluteString, + snippet: summary.packageSnippet, + signedBy: summary.signedBy, + librariesWithoutPrivacyManifest: summary.librariesWithoutPrivacyManifest)) + + return String(decoding: data, as: UTF8.self) + } + + /// The app's marketing version when embedded in FrameGen.app, otherwise the package build. + static var version: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" + } + + static let usage = """ + Usage: framegen [options] + + Commands: + build Build an XCFramework, zip it and compute its SwiftPM checksum + list Show the schemes, configurations and supported platforms of a project + identities List the code signing identities in the keychain + help Show this help + + build options: + --project | --workspace .xcodeproj, .xcworkspace or a folder containing one (required) + --scheme Framework scheme (required) + --configuration Build configuration (default: Release) + --platform Comma-separated or repeated; default: every supported platform + \(Platform.allCases.map(\.cliName).joined(separator: ", ")) + --output Output folder (default: ./build) + --sign Sign the XCFramework with this identity + --no-archives Delete the intermediate .xcarchive folders + --version Release version for {version} in templates + --download-url