diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8d55b02 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +# CI for FrameGen: builds the macOS app and runs its unit tests with Xcode 27 +# on every push to main, every pull request targeting main, and on demand. +# +# Note: GitHub-hosted macOS runners may lag behind Xcode 27. If the +# "Select Xcode 27" step fails because no Xcode 27 is installed, switch +# `runs-on` to `[self-hosted, macOS, ARM64]`. + +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build-and-test: + name: Build and test + runs-on: macos-latest + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Select Xcode 27 + shell: bash + run: | + set -euo pipefail + xcode_app="$(ls -d /Applications/Xcode_27*.app 2>/dev/null | sort -V | tail -n 1 || true)" + if [[ -z "$xcode_app" ]]; then + echo "::error::Xcode 27 is not installed on this runner. GitHub-hosted runners don't have Xcode 27 yet; use a self-hosted runner with labels: self-hosted, macOS, ARM64." + exit 1 + fi + echo "Selecting $xcode_app" + sudo xcode-select -s "$xcode_app" + xcodebuild -version + + - name: Build and test + shell: bash + run: | + set -o pipefail + if command -v xcbeautify >/dev/null 2>&1; then + xcodebuild test \ + -project FrameGen.xcodeproj \ + -scheme FrameGen \ + -destination 'platform=macOS,arch=arm64' \ + -resultBundlePath TestResults.xcresult \ + CODE_SIGNING_ALLOWED=NO \ + | xcbeautify --renderer github-actions + else + xcodebuild test \ + -project FrameGen.xcodeproj \ + -scheme FrameGen \ + -destination 'platform=macOS,arch=arm64' \ + -resultBundlePath TestResults.xcresult \ + CODE_SIGNING_ALLOWED=NO + fi + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: TestResults + path: TestResults.xcresult + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6dec9ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# macOS +.DS_Store + +# Xcode user state +xcuserdata/ +*.xcuserstate +*.xcscmblueprint + +# Build products +build/ +DerivedData/ +*.xcarchive diff --git a/FrameGen.xcodeproj/project.pbxproj b/FrameGen.xcodeproj/project.pbxproj index 131def5..fbaa76f 100644 --- a/FrameGen.xcodeproj/project.pbxproj +++ b/FrameGen.xcodeproj/project.pbxproj @@ -3,106 +3,123 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ - 4B11625529F2919200E81326 /* DropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11625429F2919200E81326 /* DropView.swift */; }; - 4B11625729F2BD2C00E81326 /* MainWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11625629F2BD2C00E81326 /* MainWindowController.swift */; }; - 4B11625929F2C27800E81326 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11625829F2C27800E81326 /* main.swift */; }; - 4B11625C29F2C8D700E81326 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11625B29F2C8D700E81326 /* MainWindow.swift */; }; - 4B11626429F2E09700E81326 /* ShellManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11626329F2E09700E81326 /* ShellManager.swift */; }; - 4B11626829F2F90D00E81326 /* TaskError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11626729F2F90D00E81326 /* TaskError.swift */; }; - 4B11626B29F2FCFB00E81326 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11626A29F2FCFB00E81326 /* Color.swift */; }; - 4B11626D29F3029500E81326 /* MainViewController+ContentViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11626C29F3029500E81326 /* MainViewController+ContentViewProtocol.swift */; }; - 4B11626F29F3035B00E81326 /* AppBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11626E29F3035B00E81326 /* AppBuilder.swift */; }; - 4B11627329F31AC100E81326 /* MainPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11627229F31AC100E81326 /* MainPresenter.swift */; }; - 4B11627629F31CAA00E81326 /* MainPresenter+MainPresenterOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11627529F31CAA00E81326 /* MainPresenter+MainPresenterOutputProtocol.swift */; }; - 4B11627829F31D5500E81326 /* Script.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11627729F31D5500E81326 /* Script.swift */; }; - 4B11627F29F4298B00E81326 /* MainMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11627E29F4298B00E81326 /* MainMenu.swift */; }; - 4B11628629F43A9900E81326 /* OptionsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11628529F43A9900E81326 /* OptionsPresenter.swift */; }; - 4B11628929F43B2B00E81326 /* OptionsPresenter+OptionsPresenterOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11628829F43B2B00E81326 /* OptionsPresenter+OptionsPresenterOutputProtocol.swift */; }; - 4B11628B29F43B4C00E81326 /* OptionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11628A29F43B4C00E81326 /* OptionsViewController.swift */; }; - 4B11628D29F445EA00E81326 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11628C29F445EA00E81326 /* ContentView.swift */; }; - 4B11629029F4476F00E81326 /* ContentView+DropViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11628F29F4476F00E81326 /* ContentView+DropViewProtocol.swift */; }; - 4B11629229F447FD00E81326 /* ContentView+NSOpenSavePanelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11629129F447FD00E81326 /* ContentView+NSOpenSavePanelDelegate.swift */; }; - 4B11629529F44E1600E81326 /* OptionsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11629429F44E1600E81326 /* OptionsContentView.swift */; }; - 4B11629929F4539800E81326 /* CheckboxButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11629829F4539800E81326 /* CheckboxButton.swift */; }; - 4B11629C29F46CAF00E81326 /* OptionsViewController+OptionsContentDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11629B29F46CAF00E81326 /* OptionsViewController+OptionsContentDelegate.swift */; }; - 4B1162A029F4741300E81326 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B11629F29F4741300E81326 /* String.swift */; }; - 4B1162AB29F50BCF00E81326 /* Command.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1162AA29F50BCF00E81326 /* Command.swift */; }; - 4B1162AD29F5148100E81326 /* OptionsSchemeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1162AC29F5148100E81326 /* OptionsSchemeView.swift */; }; - 4B1162AF29F5160E00E81326 /* OptionsSchemeView+NSComboBoxDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1162AE29F5160E00E81326 /* OptionsSchemeView+NSComboBoxDelegate.swift */; }; - 4B1162B129F5172000E81326 /* OptionsConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1162B029F5172000E81326 /* OptionsConfigurationView.swift */; }; - 4B1162B329F5178200E81326 /* OptionsConfigurationView+NSComboBoxDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1162B229F5178200E81326 /* OptionsConfigurationView+NSComboBoxDelegate.swift */; }; - 4B3E7C132A101FDD00CFB7C4 /* Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4B3E7C122A101FDD00CFB7C4 /* Info.plist */; }; - 4B72450229F99FC600A4587F /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B72450129F99FC600A4587F /* SettingsViewController.swift */; }; - 4B72450429F9A11C00A4587F /* SettingsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B72450329F9A11C00A4587F /* SettingsPresenter.swift */; }; - 4B72450729F9A15B00A4587F /* SettingsPresenter+SettingsPresenterOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B72450629F9A15B00A4587F /* SettingsPresenter+SettingsPresenterOutputProtocol.swift */; }; - 4B72450929F9A1A300A4587F /* SettingsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B72450829F9A1A300A4587F /* SettingsContentView.swift */; }; - 4B72450C29F9A21700A4587F /* SettingsContentView+NSOpenSavePanelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B72450B29F9A21700A4587F /* SettingsContentView+NSOpenSavePanelDelegate.swift */; }; - 4B72451229F9A7D500A4587F /* SettingsContentView+NSComboBoxDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B72451129F9A7D500A4587F /* SettingsContentView+NSComboBoxDelegate.swift */; }; - 4B72451829F9B39200A4587F /* PathView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B72451729F9B39200A4587F /* PathView.swift */; }; - 4B72451E29FAF12600A4587F /* SettingsViewController+SettingsContentViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B72451D29FAF12600A4587F /* SettingsViewController+SettingsContentViewProtocol.swift */; }; - 4B79A0A52A15F66A0005A7D8 /* URL+FindDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B79A0A42A15F66A0005A7D8 /* URL+FindDirectory.swift */; }; - 4B9E804A29E1DB6300DFC001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9E804929E1DB6300DFC001 /* AppDelegate.swift */; }; - 4B9E804C29E1DB6300DFC001 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9E804B29E1DB6300DFC001 /* MainViewController.swift */; }; - 4B9E804E29E1DB6400DFC001 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B9E804D29E1DB6400DFC001 /* Assets.xcassets */; }; - 4BC35B442A0764AD00C9E8B3 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC35B432A0764AD00C9E8B3 /* SettingsManager.swift */; }; - 4BC35B472A076C3900C9E8B3 /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC35B462A076C3900C9E8B3 /* Folder.swift */; }; - 4BC35B492A0E2CAF00C9E8B3 /* SettingsContentView+PathViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC35B482A0E2CAF00C9E8B3 /* SettingsContentView+PathViewProtocol.swift */; }; + 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 */; }; + 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 */; }; + 667B8115596F3B5D06AD38AF /* StatusHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 691DA398C1E950CFC39A7C38 /* StatusHeaderView.swift */; }; + 7160E94D8052C5104595A3FE /* BuildProgressViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DBCCE4E30D222BF3BECF700 /* BuildProgressViewController.swift */; }; + 7199163DAC1FC47989EF33BF /* BuildState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D17DD1538E46A00CA7E1AC6E /* BuildState.swift */; }; + 7203F75BE629B48F6A108854 /* StepRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95C079BCABFB21562F1A77E3 /* StepRowView.swift */; }; + 8FCDE32D67EB07691C5D42B2 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC26B0688D03B1E6A31160F /* Workspace.swift */; }; + 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 */; }; + BFA764D171B9D01638EA2F22 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B45E7F623E222BDDFA1F654C /* SettingsViewController.swift */; }; + C35120A9DFA9DF3305695F3C /* MainWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 497574E1E99A9498BAC6CA79 /* MainWindowController.swift */; }; + CCC6D13920A73B2DDA8D81F7 /* ConfigurationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9C4340FF1A0CE3CA5644BD /* ConfigurationViewController.swift */; }; + 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 PBXContainerItemProxy section */ + DFAC54EC022B5FA0CE9E7935 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4B9E803E29E1DB6300DFC001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4B9E804529E1DB6300DFC001; + remoteInfo = FrameGen; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ - 4B11625429F2919200E81326 /* DropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropView.swift; sourceTree = ""; }; - 4B11625629F2BD2C00E81326 /* MainWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowController.swift; sourceTree = ""; }; - 4B11625829F2C27800E81326 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; - 4B11625B29F2C8D700E81326 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = ""; }; - 4B11626329F2E09700E81326 /* ShellManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellManager.swift; sourceTree = ""; }; - 4B11626729F2F90D00E81326 /* TaskError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskError.swift; sourceTree = ""; }; - 4B11626A29F2FCFB00E81326 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; - 4B11626C29F3029500E81326 /* MainViewController+ContentViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainViewController+ContentViewProtocol.swift"; sourceTree = ""; }; - 4B11626E29F3035B00E81326 /* AppBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBuilder.swift; sourceTree = ""; }; - 4B11627229F31AC100E81326 /* MainPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainPresenter.swift; sourceTree = ""; }; - 4B11627529F31CAA00E81326 /* MainPresenter+MainPresenterOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainPresenter+MainPresenterOutputProtocol.swift"; sourceTree = ""; }; - 4B11627729F31D5500E81326 /* Script.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Script.swift; sourceTree = ""; }; - 4B11627E29F4298B00E81326 /* MainMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenu.swift; sourceTree = ""; }; - 4B11628529F43A9900E81326 /* OptionsPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionsPresenter.swift; sourceTree = ""; }; - 4B11628829F43B2B00E81326 /* OptionsPresenter+OptionsPresenterOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OptionsPresenter+OptionsPresenterOutputProtocol.swift"; sourceTree = ""; }; - 4B11628A29F43B4C00E81326 /* OptionsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionsViewController.swift; sourceTree = ""; }; - 4B11628C29F445EA00E81326 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - 4B11628F29F4476F00E81326 /* ContentView+DropViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+DropViewProtocol.swift"; sourceTree = ""; }; - 4B11629129F447FD00E81326 /* ContentView+NSOpenSavePanelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+NSOpenSavePanelDelegate.swift"; sourceTree = ""; }; - 4B11629429F44E1600E81326 /* OptionsContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionsContentView.swift; sourceTree = ""; }; - 4B11629829F4539800E81326 /* CheckboxButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxButton.swift; sourceTree = ""; }; - 4B11629B29F46CAF00E81326 /* OptionsViewController+OptionsContentDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OptionsViewController+OptionsContentDelegate.swift"; sourceTree = ""; }; - 4B11629F29F4741300E81326 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; - 4B1162AA29F50BCF00E81326 /* Command.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Command.swift; sourceTree = ""; }; - 4B1162AC29F5148100E81326 /* OptionsSchemeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionsSchemeView.swift; sourceTree = ""; }; - 4B1162AE29F5160E00E81326 /* OptionsSchemeView+NSComboBoxDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OptionsSchemeView+NSComboBoxDelegate.swift"; sourceTree = ""; }; - 4B1162B029F5172000E81326 /* OptionsConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionsConfigurationView.swift; sourceTree = ""; }; - 4B1162B229F5178200E81326 /* OptionsConfigurationView+NSComboBoxDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OptionsConfigurationView+NSComboBoxDelegate.swift"; sourceTree = ""; }; - 4B3E7C122A101FDD00CFB7C4 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 4B72450129F99FC600A4587F /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; - 4B72450329F9A11C00A4587F /* SettingsPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPresenter.swift; sourceTree = ""; }; - 4B72450629F9A15B00A4587F /* SettingsPresenter+SettingsPresenterOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsPresenter+SettingsPresenterOutputProtocol.swift"; sourceTree = ""; }; - 4B72450829F9A1A300A4587F /* SettingsContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsContentView.swift; sourceTree = ""; }; - 4B72450B29F9A21700A4587F /* SettingsContentView+NSOpenSavePanelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsContentView+NSOpenSavePanelDelegate.swift"; sourceTree = ""; }; - 4B72451129F9A7D500A4587F /* SettingsContentView+NSComboBoxDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsContentView+NSComboBoxDelegate.swift"; sourceTree = ""; }; - 4B72451729F9B39200A4587F /* PathView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathView.swift; sourceTree = ""; }; - 4B72451D29FAF12600A4587F /* SettingsViewController+SettingsContentViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsViewController+SettingsContentViewProtocol.swift"; sourceTree = ""; }; - 4B79A0A42A15F66A0005A7D8 /* URL+FindDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+FindDirectory.swift"; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 38968303FC1D994728815881 /* ObservationLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObservationLoop.swift"; sourceTree = ""; }; + 3BC8A8EA20663C18ADBAFBDB /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "main.swift"; sourceTree = ""; }; + 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; }; - 4B9E804929E1DB6300DFC001 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 4B9E804B29E1DB6300DFC001 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; - 4B9E804D29E1DB6400DFC001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 4B9E805229E1DB6400DFC001 /* FrameGen.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FrameGen.entitlements; sourceTree = ""; }; - 4BC35B432A0764AD00C9E8B3 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; - 4BC35B462A076C3900C9E8B3 /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = ""; }; - 4BC35B482A0E2CAF00C9E8B3 /* SettingsContentView+PathViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsContentView+PathViewProtocol.swift"; sourceTree = ""; }; + 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 = ""; }; + 6C5AFC913CD2476947CAA048 /* ProjectViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProjectViewController.swift"; sourceTree = ""; }; + 6DD71D8ABDA20B7FDE977313 /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WelcomeViewController.swift"; sourceTree = ""; }; + 6F05721EF31C6FBA8F6997EA /* CopyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CopyButton.swift"; sourceTree = ""; }; + 8DBCCE4E30D222BF3BECF700 /* BuildProgressViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BuildProgressViewController.swift"; sourceTree = ""; }; + 95C079BCABFB21562F1A77E3 /* StepRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StepRowView.swift"; sourceTree = ""; }; + 9B9C4340FF1A0CE3CA5644BD /* ConfigurationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationViewController.swift"; sourceTree = ""; }; + A2ADBDC6A0E62B606F018CE9 /* BuildResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BuildResultViewController.swift"; sourceTree = ""; }; + A49B69A0EEB823AC45D033C7 /* FrameGen.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "FrameGen.entitlements"; sourceTree = ""; }; + A597B54F34A37EE715070A5E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Assets.xcassets"; sourceTree = ""; }; + AE478835E220022471AA72BD /* CalloutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CalloutView.swift"; sourceTree = ""; }; + 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 */ + 0B0976A84F3A3080B1A0C9FB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4B9E804329E1DB6300DFC001 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -113,296 +130,134 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 4B11625229F2917500E81326 /* Application */ = { - isa = PBXGroup; - children = ( - 4B11627D29F4297D00E81326 /* Menus */, - 4B11625829F2C27800E81326 /* main.swift */, - 4B9E804929E1DB6300DFC001 /* AppDelegate.swift */, - 4B9E804D29E1DB6400DFC001 /* Assets.xcassets */, - 4B3E7C122A101FDD00CFB7C4 /* Info.plist */, - 4B9E805229E1DB6400DFC001 /* FrameGen.entitlements */, - ); - path = Application; - sourceTree = ""; - }; - 4B11625329F2918100E81326 /* View */ = { - isa = PBXGroup; - children = ( - 4B11628E29F4471000E81326 /* Extensions */, - 4B11628C29F445EA00E81326 /* ContentView.swift */, - 4B11625429F2919200E81326 /* DropView.swift */, - ); - path = View; - sourceTree = ""; - }; - 4B11625A29F2C8BD00E81326 /* Window */ = { - isa = PBXGroup; - children = ( - 4B11625B29F2C8D700E81326 /* MainWindow.swift */, - 4B11625629F2BD2C00E81326 /* MainWindowController.swift */, - ); - path = Window; - sourceTree = ""; - }; - 4B11625D29F2D49B00E81326 /* Flows */ = { + 04FD085B5B8E47DAD5FC6F22 /* FrameGenTests */ = { isa = PBXGroup; children = ( - 4B11626E29F3035B00E81326 /* AppBuilder.swift */, - 4B11625E29F2D4A000E81326 /* Main */, - 4B11628229F438B900E81326 /* Options */, - 4B7244FD29F99F9800A4587F /* Settings */, + 400431A229984A2BA6021A3A /* TestSupport.swift */, + 6559978338F272BEE8F5BEE0 /* ProjectInfoTests.swift */, + C99569F0C0D2242246D1458D /* XcodeCommandTests.swift */, + 76E9A0DCFA013DC175158D45 /* BuildStateTests.swift */, + E27F8FBFB4AC5D119EA82AD2 /* ProjectLocatorTests.swift */, + F45CABAA2BB083405EBA03BD /* XCFrameworkBuilderTests.swift */, + 5C7D1B7BC7E3148E64AD39E1 /* ProcessRunnerTests.swift */, ); - path = Flows; + path = FrameGenTests; sourceTree = ""; }; - 4B11625E29F2D4A000E81326 /* Main */ = { - isa = PBXGroup; - children = ( - 4B11625329F2918100E81326 /* View */, - 4B11627129F31AB400E81326 /* Presenter */, - 4B11627029F31AAF00E81326 /* Controller */, - ); - path = Main; - sourceTree = ""; - }; - 4B11625F29F2D4A400E81326 /* Extensions */ = { - isa = PBXGroup; - children = ( - 4B11626C29F3029500E81326 /* MainViewController+ContentViewProtocol.swift */, - ); - path = Extensions; - sourceTree = ""; - }; - 4B11626229F2E08800E81326 /* Managers */ = { - isa = PBXGroup; - children = ( - 4BC35B422A07648E00C9E8B3 /* DefaultSettings */, - 4B11626529F2F8FB00E81326 /* Helpers */, - 4B11626329F2E09700E81326 /* ShellManager.swift */, - ); - path = Managers; - sourceTree = ""; - }; - 4B11626529F2F8FB00E81326 /* Helpers */ = { - isa = PBXGroup; - children = ( - 4B11626629F2F90100E81326 /* Error */, - ); - path = Helpers; - sourceTree = ""; - }; - 4B11626629F2F90100E81326 /* Error */ = { - isa = PBXGroup; - children = ( - 4B11626729F2F90D00E81326 /* TaskError.swift */, - ); - path = Error; - sourceTree = ""; - }; - 4B11626929F2FCF000E81326 /* Resources */ = { - isa = PBXGroup; - children = ( - 4B11626A29F2FCFB00E81326 /* Color.swift */, - 4B11627729F31D5500E81326 /* Script.swift */, - 4B1162AA29F50BCF00E81326 /* Command.swift */, - ); - path = Resources; - sourceTree = ""; - }; - 4B11627029F31AAF00E81326 /* Controller */ = { - isa = PBXGroup; - children = ( - 4B11625F29F2D4A400E81326 /* Extensions */, - 4B9E804B29E1DB6300DFC001 /* MainViewController.swift */, - ); - path = Controller; - sourceTree = ""; - }; - 4B11627129F31AB400E81326 /* Presenter */ = { + 4B9E804829E1DB6300DFC001 /* FrameGen */ = { isa = PBXGroup; children = ( - 4B11627429F31C9D00E81326 /* Extensions */, - 4B11627229F31AC100E81326 /* MainPresenter.swift */, + 1F2B9BC7173AB73CCC8B7520 /* Application */, + B6D0FAE1681BEF3FAFD1F4B8 /* Window */, + C9AAEB47152BD95C8FFCBCAC /* Controllers */, + 17D8D6279D7A52D62BCE90D5 /* Models */, + 59A4223E3A17BE169F5ED5EB /* Services */, + AF15D962BB6607F7BDFCEBB5 /* Views */, + 8B5C18498E792DA0E4239E8C /* Support */, ); - path = Presenter; + path = FrameGen; sourceTree = ""; }; - 4B11627429F31C9D00E81326 /* Extensions */ = { + 1F2B9BC7173AB73CCC8B7520 /* Application */ = { isa = PBXGroup; children = ( - 4B11627529F31CAA00E81326 /* MainPresenter+MainPresenterOutputProtocol.swift */, + 3BC8A8EA20663C18ADBAFBDB /* main.swift */, + 542D3D512896B00E109C50B9 /* AppDelegate.swift */, + 8048E68718C79012FF072833 /* Menus */, + A597B54F34A37EE715070A5E /* Assets.xcassets */, + 01A165050B2E052A7F7EF7E3 /* Info.plist */, + A49B69A0EEB823AC45D033C7 /* FrameGen.entitlements */, ); - path = Extensions; + path = Application; sourceTree = ""; }; - 4B11627D29F4297D00E81326 /* Menus */ = { + 8048E68718C79012FF072833 /* Menus */ = { isa = PBXGroup; children = ( - 4B11627E29F4298B00E81326 /* MainMenu.swift */, + 161D8634A9AF92289AD7E263 /* MainMenu.swift */, ); path = Menus; sourceTree = ""; }; - 4B11628229F438B900E81326 /* Options */ = { - isa = PBXGroup; - children = ( - 4B11629329F44DFA00E81326 /* View */, - 4B11628429F43A8000E81326 /* Presenter */, - 4B11628329F43A7B00E81326 /* Controller */, - ); - path = Options; - sourceTree = ""; - }; - 4B11628329F43A7B00E81326 /* Controller */ = { - isa = PBXGroup; - children = ( - 4B11629A29F46C9E00E81326 /* Extensions */, - 4B11628A29F43B4C00E81326 /* OptionsViewController.swift */, - ); - path = Controller; - sourceTree = ""; - }; - 4B11628429F43A8000E81326 /* Presenter */ = { - isa = PBXGroup; - children = ( - 4B11628729F43B1F00E81326 /* Extensions */, - 4B11628529F43A9900E81326 /* OptionsPresenter.swift */, - ); - path = Presenter; - sourceTree = ""; - }; - 4B11628729F43B1F00E81326 /* Extensions */ = { - isa = PBXGroup; - children = ( - 4B11628829F43B2B00E81326 /* OptionsPresenter+OptionsPresenterOutputProtocol.swift */, - ); - path = Extensions; - sourceTree = ""; - }; - 4B11628E29F4471000E81326 /* Extensions */ = { - isa = PBXGroup; - children = ( - 4B11628F29F4476F00E81326 /* ContentView+DropViewProtocol.swift */, - 4B11629129F447FD00E81326 /* ContentView+NSOpenSavePanelDelegate.swift */, - ); - path = Extensions; - sourceTree = ""; - }; - 4B11629329F44DFA00E81326 /* View */ = { - isa = PBXGroup; - children = ( - 4B1162A329F47A7D00E81326 /* Extensions */, - 4B11629429F44E1600E81326 /* OptionsContentView.swift */, - 4B11629829F4539800E81326 /* CheckboxButton.swift */, - 4B1162AC29F5148100E81326 /* OptionsSchemeView.swift */, - 4B1162B029F5172000E81326 /* OptionsConfigurationView.swift */, - ); - path = View; - sourceTree = ""; - }; - 4B11629A29F46C9E00E81326 /* Extensions */ = { - isa = PBXGroup; - children = ( - 4B11629B29F46CAF00E81326 /* OptionsViewController+OptionsContentDelegate.swift */, - ); - path = Extensions; - sourceTree = ""; - }; - 4B11629D29F4740100E81326 /* Core */ = { - isa = PBXGroup; - children = ( - 4B11629E29F4740700E81326 /* Extensions */, - ); - path = Core; - sourceTree = ""; - }; - 4B11629E29F4740700E81326 /* Extensions */ = { - isa = PBXGroup; - children = ( - 4B11629F29F4741300E81326 /* String.swift */, - 4B79A0A42A15F66A0005A7D8 /* URL+FindDirectory.swift */, - ); - path = Extensions; - sourceTree = ""; - }; - 4B1162A329F47A7D00E81326 /* Extensions */ = { - isa = PBXGroup; - children = ( - 4B1162AE29F5160E00E81326 /* OptionsSchemeView+NSComboBoxDelegate.swift */, - 4B1162B229F5178200E81326 /* OptionsConfigurationView+NSComboBoxDelegate.swift */, - ); - path = Extensions; - sourceTree = ""; - }; - 4B7244FD29F99F9800A4587F /* Settings */ = { - isa = PBXGroup; - children = ( - 4B7244FF29F99FA500A4587F /* View */, - 4B72450029F99FA800A4587F /* Presenter */, - 4B7244FE29F99FA000A4587F /* Controller */, - ); - path = Settings; - sourceTree = ""; - }; - 4B7244FE29F99FA000A4587F /* Controller */ = { + B6D0FAE1681BEF3FAFD1F4B8 /* Window */ = { isa = PBXGroup; children = ( - 4B72451C29FAF11C00A4587F /* Extensions */, - 4B72450129F99FC600A4587F /* SettingsViewController.swift */, + 2C03DDBD84F4DC30B7D63863 /* MainWindow.swift */, + 497574E1E99A9498BAC6CA79 /* MainWindowController.swift */, ); - path = Controller; + path = Window; sourceTree = ""; }; - 4B7244FF29F99FA500A4587F /* View */ = { + C9AAEB47152BD95C8FFCBCAC /* Controllers */ = { isa = PBXGroup; children = ( - 4B72450A29F9A20700A4587F /* Extensions */, - 4B72450829F9A1A300A4587F /* SettingsContentView.swift */, - 4B72451729F9B39200A4587F /* PathView.swift */, + 20115268FEF39A7B7DD03FAE /* BuildController.swift */, + CA714FFE139D2B24154FE823 /* RootViewController.swift */, + 6DD71D8ABDA20B7FDE977313 /* WelcomeViewController.swift */, + 6C5AFC913CD2476947CAA048 /* ProjectViewController.swift */, + 9B9C4340FF1A0CE3CA5644BD /* ConfigurationViewController.swift */, + 8DBCCE4E30D222BF3BECF700 /* BuildProgressViewController.swift */, + A2ADBDC6A0E62B606F018CE9 /* BuildResultViewController.swift */, + 35FB9740B88E1413A0E101E2 /* BuildFailureViewController.swift */, + B45E7F623E222BDDFA1F654C /* SettingsViewController.swift */, ); - path = View; + path = Controllers; sourceTree = ""; }; - 4B72450029F99FA800A4587F /* Presenter */ = { + 17D8D6279D7A52D62BCE90D5 /* Models */ = { isa = PBXGroup; children = ( - 4B72450529F9A14C00A4587F /* Extensions */, - 4B72450329F9A11C00A4587F /* SettingsPresenter.swift */, + 4EF1ECD9C14BBEA60A7F12B5 /* Platform.swift */, + 2301D103F3056B6F9E2C2E8A /* ProjectInfo.swift */, + DB8A81626F8D17947E596BD7 /* Project.swift */, + D17DD1538E46A00CA7E1AC6E /* BuildState.swift */, + 4FC26B0688D03B1E6A31160F /* Workspace.swift */, ); - path = Presenter; + path = Models; sourceTree = ""; }; - 4B72450529F9A14C00A4587F /* Extensions */ = { + 59A4223E3A17BE169F5ED5EB /* Services */ = { isa = PBXGroup; children = ( - 4B72450629F9A15B00A4587F /* SettingsPresenter+SettingsPresenterOutputProtocol.swift */, + D74D9541D23263177A094521 /* ProcessRunner.swift */, + D5B8E679A3BEA53C6A6764CB /* XcodeCommand.swift */, + 5C3B5DCD4782CBA9371395BB /* ProjectLocator.swift */, + 3CA69EB3841AECDDA8FE2CA3 /* Preferences.swift */, + 0F0109521FC69BF5C9CCA7D5 /* XCFrameworkBuilder.swift */, ); - path = Extensions; + path = Services; sourceTree = ""; }; - 4B72450A29F9A20700A4587F /* Extensions */ = { + AF15D962BB6607F7BDFCEBB5 /* Views */ = { isa = PBXGroup; children = ( - 4B72450B29F9A21700A4587F /* SettingsContentView+NSOpenSavePanelDelegate.swift */, - 4B72451129F9A7D500A4587F /* SettingsContentView+NSComboBoxDelegate.swift */, - 4BC35B482A0E2CAF00C9E8B3 /* SettingsContentView+PathViewProtocol.swift */, + FFDF40A968E5585F39C46B1E /* FormSectionView.swift */, + 691DA398C1E950CFC39A7C38 /* StatusHeaderView.swift */, + 95C079BCABFB21562F1A77E3 /* StepRowView.swift */, + 5E1FD55B3D225874AAB28D70 /* LogView.swift */, + AE478835E220022471AA72BD /* CalloutView.swift */, + 6F05721EF31C6FBA8F6997EA /* CopyButton.swift */, + 583D28F8D69380D2DA9AA7FB /* DropTargetView.swift */, + 02B7A6B784EA7222D67C3D67 /* RecentProjectCellView.swift */, + 25C1041CA7BE5CFB6B539B79 /* ScrollingContentView.swift */, ); - path = Extensions; + path = Views; sourceTree = ""; }; - 4B72451C29FAF11C00A4587F /* Extensions */ = { + 8B5C18498E792DA0E4239E8C /* Support */ = { isa = PBXGroup; children = ( - 4B72451D29FAF12600A4587F /* SettingsViewController+SettingsContentViewProtocol.swift */, + 38968303FC1D994728815881 /* ObservationLoop.swift */, + 5D3CF33D3219D0380C3B71EC /* AppKit+Helpers.swift */, ); - path = Extensions; + path = Support; sourceTree = ""; }; 4B9E803D29E1DB6300DFC001 = { isa = PBXGroup; children = ( 4B9E804829E1DB6300DFC001 /* FrameGen */, + 04FD085B5B8E47DAD5FC6F22 /* FrameGenTests */, 4B9E804729E1DB6300DFC001 /* Products */, ); sourceTree = ""; @@ -411,43 +266,34 @@ isa = PBXGroup; children = ( 4B9E804629E1DB6300DFC001 /* FrameGen.app */, + 6F9A1917669D5240DD64C7D3 /* FrameGenTests.xctest */, ); name = Products; sourceTree = ""; }; - 4B9E804829E1DB6300DFC001 /* FrameGen */ = { - isa = PBXGroup; - children = ( - 4B11629D29F4740100E81326 /* Core */, - 4B11626929F2FCF000E81326 /* Resources */, - 4B11626229F2E08800E81326 /* Managers */, - 4B11625229F2917500E81326 /* Application */, - 4B11625D29F2D49B00E81326 /* Flows */, - 4B11625A29F2C8BD00E81326 /* Window */, +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 3D43AFCF71BAC5808E8553C9 /* FrameGenTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 00ED3C2737010EC9CD749A93 /* Build configuration list for PBXNativeTarget "FrameGenTests" */; + buildPhases = ( + C1D5A675EB10CD4EBD4D6C18 /* Sources */, + 0B0976A84F3A3080B1A0C9FB /* Frameworks */, + 8772E477F959EAB55992157A /* Resources */, ); - path = FrameGen; - sourceTree = ""; - }; - 4BC35B422A07648E00C9E8B3 /* DefaultSettings */ = { - isa = PBXGroup; - children = ( - 4BC35B452A076C2300C9E8B3 /* Model */, - 4BC35B432A0764AD00C9E8B3 /* SettingsManager.swift */, + buildRules = ( ); - path = DefaultSettings; - sourceTree = ""; - }; - 4BC35B452A076C2300C9E8B3 /* Model */ = { - isa = PBXGroup; - children = ( - 4BC35B462A076C3900C9E8B3 /* Folder.swift */, + dependencies = ( + 151FDAAC81FA51443326CBBC /* PBXTargetDependency */, ); - path = Model; - sourceTree = ""; + name = FrameGenTests; + packageProductDependencies = ( + ); + productName = FrameGenTests; + productReference = 6F9A1917669D5240DD64C7D3 /* FrameGenTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ 4B9E804529E1DB6300DFC001 /* FrameGen */ = { isa = PBXNativeTarget; buildConfigurationList = 4B9E805529E1DB6400DFC001 /* Build configuration list for PBXNativeTarget "FrameGen" */; @@ -474,17 +320,20 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1430; - LastUpgradeCheck = 1430; + LastSwiftUpdateCheck = 2710; + LastUpgradeCheck = 2710; ORGANIZATIONNAME = "Ramiz Kichibekov"; TargetAttributes = { 4B9E804529E1DB6300DFC001 = { CreatedOnToolsVersion = 14.3; }; + 3D43AFCF71BAC5808E8553C9 = { + CreatedOnToolsVersion = 27.1; + TestTargetID = 4B9E804529E1DB6300DFC001; + }; }; }; buildConfigurationList = 4B9E804129E1DB6300DFC001 /* Build configuration list for PBXProject "FrameGen" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -492,6 +341,8 @@ Base, ); mainGroup = 4B9E803D29E1DB6300DFC001; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; packageReferences = ( ); productRefGroup = 4B9E804729E1DB6300DFC001 /* Products */; @@ -499,79 +350,142 @@ projectRoot = ""; targets = ( 4B9E804529E1DB6300DFC001 /* FrameGen */, + 3D43AFCF71BAC5808E8553C9 /* FrameGenTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 8772E477F959EAB55992157A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4B9E804429E1DB6300DFC001 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4B3E7C132A101FDD00CFB7C4 /* Info.plist in Resources */, - 4B9E804E29E1DB6400DFC001 /* Assets.xcassets in Resources */, + D9C293F961041AED3D12059B /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 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 */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4B9E804229E1DB6300DFC001 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4B11626429F2E09700E81326 /* ShellManager.swift in Sources */, - 4B11628629F43A9900E81326 /* OptionsPresenter.swift in Sources */, - 4B11626829F2F90D00E81326 /* TaskError.swift in Sources */, - 4B11627629F31CAA00E81326 /* MainPresenter+MainPresenterOutputProtocol.swift in Sources */, - 4B1162AB29F50BCF00E81326 /* Command.swift in Sources */, - 4B11627F29F4298B00E81326 /* MainMenu.swift in Sources */, - 4B11629029F4476F00E81326 /* ContentView+DropViewProtocol.swift in Sources */, - 4B9E804C29E1DB6300DFC001 /* MainViewController.swift in Sources */, - 4B11629529F44E1600E81326 /* OptionsContentView.swift in Sources */, - 4B11628D29F445EA00E81326 /* ContentView.swift in Sources */, - 4B11629229F447FD00E81326 /* ContentView+NSOpenSavePanelDelegate.swift in Sources */, - 4BC35B472A076C3900C9E8B3 /* Folder.swift in Sources */, - 4B72450729F9A15B00A4587F /* SettingsPresenter+SettingsPresenterOutputProtocol.swift in Sources */, - 4B11626B29F2FCFB00E81326 /* Color.swift in Sources */, - 4B72450929F9A1A300A4587F /* SettingsContentView.swift in Sources */, - 4BC35B442A0764AD00C9E8B3 /* SettingsManager.swift in Sources */, - 4B11626D29F3029500E81326 /* MainViewController+ContentViewProtocol.swift in Sources */, - 4BC35B492A0E2CAF00C9E8B3 /* SettingsContentView+PathViewProtocol.swift in Sources */, - 4B1162B129F5172000E81326 /* OptionsConfigurationView.swift in Sources */, - 4B11629C29F46CAF00E81326 /* OptionsViewController+OptionsContentDelegate.swift in Sources */, - 4B1162AF29F5160E00E81326 /* OptionsSchemeView+NSComboBoxDelegate.swift in Sources */, - 4B11625929F2C27800E81326 /* main.swift in Sources */, - 4B11627329F31AC100E81326 /* MainPresenter.swift in Sources */, - 4B11625729F2BD2C00E81326 /* MainWindowController.swift in Sources */, - 4B1162AD29F5148100E81326 /* OptionsSchemeView.swift in Sources */, - 4B72450229F99FC600A4587F /* SettingsViewController.swift in Sources */, - 4B11628B29F43B4C00E81326 /* OptionsViewController.swift in Sources */, - 4B1162B329F5178200E81326 /* OptionsConfigurationView+NSComboBoxDelegate.swift in Sources */, - 4B1162A029F4741300E81326 /* String.swift in Sources */, - 4B11626F29F3035B00E81326 /* AppBuilder.swift in Sources */, - 4B9E804A29E1DB6300DFC001 /* AppDelegate.swift in Sources */, - 4B11625C29F2C8D700E81326 /* MainWindow.swift in Sources */, - 4B72450429F9A11C00A4587F /* SettingsPresenter.swift in Sources */, - 4B72450C29F9A21700A4587F /* SettingsContentView+NSOpenSavePanelDelegate.swift in Sources */, - 4B11627829F31D5500E81326 /* Script.swift in Sources */, - 4B11628929F43B2B00E81326 /* OptionsPresenter+OptionsPresenterOutputProtocol.swift in Sources */, - 4B72451E29FAF12600A4587F /* SettingsViewController+SettingsContentViewProtocol.swift in Sources */, - 4B11625529F2919200E81326 /* DropView.swift in Sources */, - 4B72451829F9B39200A4587F /* PathView.swift in Sources */, - 4B72451229F9A7D500A4587F /* SettingsContentView+NSComboBoxDelegate.swift in Sources */, - 4B79A0A52A15F66A0005A7D8 /* URL+FindDirectory.swift in Sources */, - 4B11629929F4539800E81326 /* CheckboxButton.swift in Sources */, + 2597E05CC40E0C90671816AE /* main.swift in Sources */, + 95BE9E0747F32914E5DF0C5E /* AppDelegate.swift in Sources */, + 2503C114253538F9FC97A806 /* MainMenu.swift in Sources */, + E8EEE8C6D022ABC749637026 /* MainWindow.swift in Sources */, + C35120A9DFA9DF3305695F3C /* MainWindowController.swift in Sources */, + A9DA7D28376389BB59D48E9C /* BuildController.swift in Sources */, + 9FB6582E9CF3B5E655518C9C /* RootViewController.swift in Sources */, + E78E021CFB56A255344F6289 /* WelcomeViewController.swift in Sources */, + 1C2B4D17E814365889F847CA /* ProjectViewController.swift in Sources */, + CCC6D13920A73B2DDA8D81F7 /* ConfigurationViewController.swift in Sources */, + 7160E94D8052C5104595A3FE /* BuildProgressViewController.swift in Sources */, + 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 */, + E46CBC25563ED06F9741CAB0 /* LogView.swift in Sources */, + DC6FFD5CE46BBB0AE9E0898B /* CalloutView.swift in Sources */, + 4D5F3E69A95E08AE6A7DB554 /* CopyButton.swift in Sources */, + F19DAA76C7C1B1E8FDD65F9E /* DropTargetView.swift in Sources */, + CDA37F41DA66C2E2BB0ED52A /* RecentProjectCellView.swift in Sources */, + EC29396BF8521B43E7E15C9C /* ScrollingContentView.swift in Sources */, + DFFE6552400FF06C1ED2712B /* ObservationLoop.swift in Sources */, + AA475ABF6A527A9AC924422F /* AppKit+Helpers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 151FDAAC81FA51443326CBBC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4B9E804529E1DB6300DFC001 /* FrameGen */; + targetProxy = DFAC54EC022B5FA0CE9E7935 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 38928D5A7559732E301B8C90 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8AD8N54BY6; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "ru.ramiz-kichibekov.frameGenTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FrameGen.app/Contents/MacOS/FrameGen"; + }; + name = Debug; + }; + 32C9FB063D3A950B3809FAB8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8AD8N54BY6; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "ru.ramiz-kichibekov.frameGenTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FrameGen.app/Contents/MacOS/FrameGen"; + }; + name = Release; + }; 4B9E805329E1DB6400DFC001 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -601,8 +515,10 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; @@ -618,8 +534,9 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 13.3; + MACOSX_DEPLOYMENT_TARGET = 27.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -632,6 +549,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -661,9 +579,11 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -672,8 +592,9 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 13.3; + MACOSX_DEPLOYMENT_TARGET = 27.0; MTL_ENABLE_DEBUG_INFO = NO; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_FAST_MATH = YES; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -686,27 +607,30 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_USE_OPTIMIZATION_PROFILE = YES; + ARCHS = arm64; CODE_SIGN_ENTITLEMENTS = FrameGen/Application/FrameGen.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 8AD8N54BY6; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = FrameGen/Application/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023–2026 Ramiz Kichibekov. All rights reserved."; INFOPLIST_KEY_NSPrincipalClass = NSApplication; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = "ru.ramiz-kichibekov.frameGen"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -715,33 +639,45 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_USE_OPTIMIZATION_PROFILE = YES; + ARCHS = arm64; CODE_SIGN_ENTITLEMENTS = FrameGen/Application/FrameGen.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 8AD8N54BY6; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = FrameGen/Application/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023–2026 Ramiz Kichibekov. All rights reserved."; INFOPLIST_KEY_NSPrincipalClass = NSApplication; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = "ru.ramiz-kichibekov.frameGen"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 00ED3C2737010EC9CD749A93 /* Build configuration list for PBXNativeTarget "FrameGenTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 38928D5A7559732E301B8C90 /* Debug */, + 32C9FB063D3A950B3809FAB8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 4B9E804129E1DB6300DFC001 /* Build configuration list for PBXProject "FrameGen" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/FrameGen.xcodeproj/project.xcworkspace/xcuserdata/ramizkichibekov.xcuserdatad/UserInterfaceState.xcuserstate b/FrameGen.xcodeproj/project.xcworkspace/xcuserdata/ramizkichibekov.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index e5e2abb..0000000 Binary files a/FrameGen.xcodeproj/project.xcworkspace/xcuserdata/ramizkichibekov.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/FrameGen.xcodeproj/xcshareddata/xcschemes/FrameGen.xcscheme b/FrameGen.xcodeproj/xcshareddata/xcschemes/FrameGen.xcscheme index b0803bc..764ab70 100644 --- a/FrameGen.xcodeproj/xcshareddata/xcschemes/FrameGen.xcscheme +++ b/FrameGen.xcodeproj/xcshareddata/xcschemes/FrameGen.xcscheme @@ -1,6 +1,6 @@ + + + + + + - - diff --git a/FrameGen.xcodeproj/xcuserdata/ramizkichibekov.xcuserdatad/xcschemes/xcschememanagement.plist b/FrameGen.xcodeproj/xcuserdata/ramizkichibekov.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index ac82e6c..0000000 --- a/FrameGen.xcodeproj/xcuserdata/ramizkichibekov.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,43 +0,0 @@ - - - - - SchemeUserState - - FrameGen.xcscheme_^#shared#^_ - - orderHint - 0 - - Promises (Playground) 1.xcscheme - - isShown - - orderHint - 2 - - Promises (Playground) 2.xcscheme - - isShown - - orderHint - 3 - - Promises (Playground).xcscheme - - isShown - - orderHint - 1 - - - SuppressBuildableAutocreation - - 4B9E804529E1DB6300DFC001 - - primary - - - - - diff --git a/FrameGen/Application/AppDelegate.swift b/FrameGen/Application/AppDelegate.swift index 9bf04bd..defec03 100644 --- a/FrameGen/Application/AppDelegate.swift +++ b/FrameGen/Application/AppDelegate.swift @@ -1,22 +1,55 @@ // // AppDelegate.swift -// generateFramework +// FrameGen // // Created by Рамиз Кичибеков on 08.04.2023. +// Copyright © 2023 Ramiz Kichibekov. All rights reserved. // -import Cocoa +import AppKit final class AppDelegate: NSObject, NSApplicationDelegate { - - private var windowController: NSWindowController! - - func applicationDidFinishLaunching(_ aNotification: Notification) { - windowController = MainWindowController() - windowController.showWindow(self) + + // MARK: - Properties + + private let workspace = Workspace() + private lazy var mainWindowController = MainWindowController(workspace: workspace) + private lazy var settingsWindowController = SettingsWindowController() + + // MARK: - NSApplicationDelegate + + func applicationWillFinishLaunching(_ notification: Notification) { + Preferences.registerDefaults() + } + + func applicationDidFinishLaunching(_ notification: Notification) { + mainWindowController.showWindow(self) + } + + func application(_ application: NSApplication, open urls: [URL]) { + guard let url = urls.first else { return } + + mainWindowController.showWindow(self) + mainWindowController.openProject(at: url) } - + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + mainWindowController.confirmStoppingBuild(action: "Quit") ? .terminateNow : .terminateCancel + } + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { - return true + true } + + // MARK: - Actions + + @objc + func showSettings(_ sender: Any?) { + settingsWindowController.showWindow(sender) + } + } diff --git a/FrameGen/Application/FrameGen.entitlements b/FrameGen/Application/FrameGen.entitlements index 93d01f7..e89b7f3 100644 --- a/FrameGen/Application/FrameGen.entitlements +++ b/FrameGen/Application/FrameGen.entitlements @@ -2,15 +2,7 @@ - com.apple.security.automation.apple-events - - com.apple.security.files.user-selected.executable - com.apple.security.app-sandbox - com.apple.security.files.downloads.read-write - - com.apple.security.files.user-selected.read-write - diff --git a/FrameGen/Application/Info.plist b/FrameGen/Application/Info.plist index bf188ca..3b85472 100644 --- a/FrameGen/Application/Info.plist +++ b/FrameGen/Application/Info.plist @@ -2,15 +2,27 @@ - LSSupportsOpeningDocumentsInPlace - - UISupportsDocumentBrowser - - NSDownloadsFolderUsageDescription - Access to the downloads is necessary to search for folders containing Xcode project files for generating frameworks. - NSDocumentsFolderUsageDescription - Access to the documents is necessary to search for folders containing Xcode project files for generating frameworks. + CFBundleDocumentTypes + + + CFBundleTypeName + Xcode Project + CFBundleTypeRole + Viewer + LSHandlerRank + None + LSItemContentTypes + + com.apple.xcode.project + public.folder + + + NSDesktopFolderUsageDescription - Access to the desktop is necessary to search for folders containing Xcode project files for generating frameworks. + FrameGen reads Xcode projects and saves built XCFrameworks in the folders you choose. + NSDocumentsFolderUsageDescription + FrameGen reads Xcode projects and saves built XCFrameworks in the folders you choose. + NSDownloadsFolderUsageDescription + FrameGen reads Xcode projects and saves built XCFrameworks in the folders you choose. diff --git a/FrameGen/Application/Menus/MainMenu.swift b/FrameGen/Application/Menus/MainMenu.swift index 7e449df..63140fa 100644 --- a/FrameGen/Application/Menus/MainMenu.swift +++ b/FrameGen/Application/Menus/MainMenu.swift @@ -1,67 +1,146 @@ // // MainMenu.swift -// generateFramework +// FrameGen // // Created by Рамиз Кичибеков on 22.04.2023. +// Copyright © 2023 Ramiz Kichibekov. All rights reserved. // -import Cocoa +import AppKit -final class MainMenu: NSMenu { +/// 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. +final class MainMenu: NSObject { // MARK: - Properties - private lazy var applicationName = ProcessInfo.processInfo.processName + let menu = NSMenu(title: "Main Menu") + private let applicationName = ProcessInfo.processInfo.processName + private let recentProjectsMenu = NSMenu(title: "Open Recent") // MARK: - Initial methods - override init(title: String) { - super.init(title: title) + override init() { + super.init() - configureMenu() - } - - @available(*, unavailable) - required init(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + recentProjectsMenu.delegate = self + menu.items = [submenu(applicationName, items: applicationItems()), + submenu("File", items: fileItems()), + submenu("Edit", items: editItems()), + submenu("Product", items: productItems()), + windowMenu(), + helpMenu()] } // MARK: - Private methods - - private func configureMenu() { - let mainMenu = NSMenuItem() - mainMenu.submenu = NSMenu(title: "menuItem") - let hideOtherItem = NSMenuItem(title: "Hide Others", - action: #selector(NSApplication.hideOtherApplications(_:)), - keyEquivalent: "h") - hideOtherItem.keyEquivalentModifierMask = .option - let submenuItems = [NSMenuItem(title: "About \(applicationName)", - action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), - keyEquivalent: ""), - .separator(), - NSMenuItem(title: "Hide \(applicationName)", - action: #selector(NSApplication.hide(_:)), - keyEquivalent: "h"), - hideOtherItem, - NSMenuItem(title: "Show all", - action: #selector(NSApplication.unhideAllApplications(_:)), - keyEquivalent: ""), - .separator(), - NSMenuItem(title: "Quit \(applicationName)", - action: #selector(NSApplication.terminate(_:)), - keyEquivalent: "q")] - mainMenu.submenu?.items = submenuItems - - let windowMenu = NSMenuItem() - windowMenu.submenu = NSMenu(title: "Window") - windowMenu.submenu?.items = [NSMenuItem(title: "Minmize", - action: #selector(NSWindow.miniaturize(_:)), - keyEquivalent: "m"), - NSMenuItem(title: "Zoom", - action: #selector(NSWindow.performZoom(_:)), - keyEquivalent: "")] - - items = [mainMenu, windowMenu] + + private func applicationItems() -> [NSMenuItem] { + let services = NSMenuItem(title: "Services", action: nil, keyEquivalent: "") + services.submenu = NSMenu(title: "Services") + NSApp.servicesMenu = services.submenu + + return [item("About \(applicationName)", #selector(NSApplication.orderFrontStandardAboutPanel(_:))), + .separator(), + item("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(_:))), + .separator(), + item("Quit \(applicationName)", #selector(NSApplication.terminate(_:)), key: "q")] } - + + private func fileItems() -> [NSMenuItem] { + let openRecent = NSMenuItem(title: "Open Recent", action: nil, keyEquivalent: "") + openRecent.submenu = recentProjectsMenu + + return [item("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")] + } + + private func editItems() -> [NSMenuItem] { + [item("Undo", Selector(("undo:")), key: "z"), + item("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")] + } + + private func productItems() -> [NSMenuItem] { + [item("Build XCFramework", #selector(MainWindowController.buildXCFramework(_:)), key: "b"), + item("Stop", #selector(MainWindowController.stopBuild(_:)), key: "."), + .separator(), + item("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(_:))), + .separator(), + item("Bring All to Front", #selector(NSApplication.arrangeInFront(_:)))]) + NSApp.windowsMenu = menuItem.submenu + + return menuItem + } + + private func helpMenu() -> NSMenuItem { + let menuItem = submenu("Help", items: []) + NSApp.helpMenu = menuItem.submenu + + return menuItem + } + + private func submenu(_ title: String, items: [NSMenuItem]) -> NSMenuItem { + let menuItem = NSMenuItem(title: title, action: nil, keyEquivalent: "") + let menu = NSMenu(title: title) + menu.items = items + menuItem.submenu = menu + + return menuItem + } + + private func item(_ title: String, + _ action: Selector, + key: String = "", + modifiers: NSEvent.ModifierFlags = .command) -> NSMenuItem { + let menuItem = NSMenuItem(title: title, action: action, keyEquivalent: key) + menuItem.keyEquivalentModifierMask = modifiers + + return menuItem + } + +} + +// MARK: - NSMenuDelegate + +extension MainMenu: NSMenuDelegate { + + func menuNeedsUpdate(_ menu: NSMenu) { + guard menu === recentProjectsMenu else { return } + + let recentProjects = Preferences.recentProjects + menu.items = recentProjects.map { url in + let menuItem = NSMenuItem(title: url.deletingPathExtension().lastPathComponent, + action: #selector(MainWindowController.openRecentProject(_:)), + keyEquivalent: "") + menuItem.representedObject = url + menuItem.toolTip = Formatters.path(url) + menuItem.image = NSWorkspace.shared.icon(forFile: url.path) + menuItem.image?.size = NSSize(width: 16, height: 16) + + return menuItem + } + if !recentProjects.isEmpty { + menu.addItem(.separator()) + } + menu.addItem(item("Clear Menu", #selector(MainWindowController.clearRecentProjects(_:)))) + } + } diff --git a/FrameGen/Application/main.swift b/FrameGen/Application/main.swift index 28fc64b..476cecd 100644 --- a/FrameGen/Application/main.swift +++ b/FrameGen/Application/main.swift @@ -1,16 +1,17 @@ // // main.swift -// generateFramework +// FrameGen // // Created by Рамиз Кичибеков on 21.04.2023. +// Copyright © 2023 Ramiz Kichibekov. All rights reserved. // -import Cocoa +import AppKit let application = NSApplication.shared let delegate = AppDelegate() let mainMenu = MainMenu() application.delegate = delegate -application.mainMenu = mainMenu +application.mainMenu = mainMenu.menu _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) diff --git a/FrameGen/Controllers/BuildController.swift b/FrameGen/Controllers/BuildController.swift new file mode 100644 index 0000000..54a0a7f --- /dev/null +++ b/FrameGen/Controllers/BuildController.swift @@ -0,0 +1,138 @@ +// +// BuildController.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// 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`. +final class BuildController { + + // MARK: - Properties + + private var task: Task? + + // MARK: - Public methods + + func start(_ project: Project) { + guard project.canBuild, let scheme = project.scheme, let configuration = project.configuration 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)]) + task = Task { + await run(project: project.fileURL, + scheme: scheme, + configuration: configuration, + platforms: platforms, + outputDirectory: project.outputDirectory, + state: state) + task = nil + } + } + + func cancel() { + task?.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) + + return plan + } + 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) + + return try await XCFrameworkBuilder.finalize(plan) + } + + if !Preferences.keepArchives { + try? FileManager.default.removeItem(at: archivesDirectory) + } + state.finish(.succeeded(summary)) + if Preferences.revealInFinder { + NSWorkspace.shared.activateFileViewerSelecting([summary.plan.xcframeworkURL]) + } + } catch is CancellationError { + state.reset() + } catch ProcessError.canceled { + state.reset() + } catch { + state.finish(.failed(Self.failure(for: error, step: state.steps[stepIndex], log: state.log))) + } + 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 new file mode 100644 index 0000000..65b4a5f --- /dev/null +++ b/FrameGen/Controllers/BuildFailureViewController.swift @@ -0,0 +1,93 @@ +// +// BuildFailureViewController.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// Explains which step failed, highlights the compiler errors and keeps the full log at hand. +final class BuildFailureViewController: NSViewController { + + // MARK: - Properties + + private let project: Project + private let logView = LogView() + + // MARK: - Initial methods + + init(project: Project) { + self.project = project + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life cycle + + override func loadView() { + let contentView = ScrollingContentView() + view = contentView + guard case .failed(let failure) = project.build.phase else { return } + + let header = StatusHeaderView() + header.setSymbol("xmark.octagon.fill", color: .systemRed, accessibilityDescription: "Failed") + header.titleLabel.stringValue = failure.title + header.subtitleLabel.stringValue = failure.reason + contentView.stackView.addFullWidthArrangedSubview(header) + + let stepsSection = FormSectionView(title: nil) + stepsSection.setRows(project.build.steps.map { step in + let row = StepRowView() + row.configure(with: step) + + return row + }) + contentView.stackView.addFullWidthArrangedSubview(stepsSection) + + if !failure.highlights.isEmpty { + let section = FormSectionView(title: "Errors") + let text = NSTextField.text(failure.highlights.joined(separator: "\n"), + font: .monospaced(.callout), color: .labelColor, lines: 0, selectable: true) + text.preferredMaxLayoutWidth = Metrics.contentWidth - 24 + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(text) + text.pinEdges(to: container, insets: NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)) + section.setRows([container]) + contentView.stackView.addFullWidthArrangedSubview(section) + } + + let logTitle = NSTextField.text("Build Log", font: .preferred(.headline)) + let copyLog = CopyButton(title: "Copy Log") { [weak self] in + self?.project.build.log.joined(separator: "\n") ?? "" + } + copyLog.controlSize = .small + logView.heightAnchor.constraint(equalToConstant: 220).isActive = true + logView.update(lines: project.build.log) + contentView.stackView.addFullWidthArrangedSubview(NSStackView.horizontal([logTitle, .spacer(), copyLog])) + contentView.stackView.addFullWidthArrangedSubview(logView) + + let backButton = NSButton.push("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, + action: #selector(MainWindowController.buildXCFramework(_:))) + retryButton.controlSize = .large + retryButton.keyEquivalent = "\r" + contentView.setBottomBar(leading: [backButton], trailing: [retryButton]) + } + + // MARK: - Actions + + @objc + private func back(_ sender: Any?) { + project.build.reset() + } + +} diff --git a/FrameGen/Controllers/BuildProgressViewController.swift b/FrameGen/Controllers/BuildProgressViewController.swift new file mode 100644 index 0000000..63df25f --- /dev/null +++ b/FrameGen/Controllers/BuildProgressViewController.swift @@ -0,0 +1,137 @@ +// +// BuildProgressViewController.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// Live progress of a build: the step list, the current xcodebuild action and the log. +final class BuildProgressViewController: NSViewController { + + // MARK: - Properties + + private let project: Project + private var observation: ObservationLoop? + private var clockTask: Task? + + private let contentView = ScrollingContentView() + private let header = StatusHeaderView() + private let activityLabel = NSTextField.text(font: .monospaced(.callout), color: .secondaryLabelColor) + private let stepsSection = FormSectionView(title: nil) + private var stepRows: [StepRowView] = [] + private let logDisclosure = NSButton() + private let logView = LogView() + private let logHeightConstraint: NSLayoutConstraint + + // MARK: - Initial methods + + init(project: Project) { + self.project = project + logHeightConstraint = logView.heightAnchor.constraint(equalToConstant: 240) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + clockTask?.cancel() + } + + // MARK: - Life cycle + + override func loadView() { + header.setSpinner() + 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 + self?.project.build.log.joined(separator: "\n") ?? "" + } + copyLog.controlSize = .small + let logHeader = NSStackView.horizontal([logDisclosure, logTitle, .spacer(), copyLog], spacing: 4) + logHeightConstraint.isActive = true + logView.isHidden = true + + let stopButton = NSButton.push("Stop", symbol: "stop.fill", target: nil, action: #selector(MainWindowController.stopBuild(_:))) + stopButton.controlSize = .large + stopButton.keyEquivalent = "\u{1b}" + stopButton.toolTip = "Stop the build (⌘.)" + + [header, activityLabel, stepsSection, logHeader, logView].forEach(contentView.stackView.addFullWidthArrangedSubview) + contentView.stackView.setCustomSpacing(Metrics.spacing, after: header) + contentView.stackView.setCustomSpacing(Metrics.spacing, after: logHeader) + contentView.setBottomBar(leading: [], trailing: [stopButton]) + view = contentView + } + + override func viewDidLoad() { + super.viewDidLoad() + + observation = ObservationLoop { [weak self] in + self?.update() + } + // Durations of running steps tick every second even when xcodebuild is quiet. + clockTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + self?.updateClock() + } + } + } + + override func viewWillDisappear() { + super.viewWillDisappear() + clockTask?.cancel() + observation?.cancel() + } + + // MARK: - Private methods + + private func update() { + let build = project.build + let platformCount = build.steps.count - 2 + header.titleLabel.stringValue = "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")" + } + + private func updateClock() { + let build = project.build + for (row, step) in zip(stepRows, build.steps) { + row.configure(with: step) + } + view.window?.subtitle = "Building… \(Formatters.duration(build.duration))" + } + + // MARK: - Actions + + @objc + private func toggleLog(_ sender: NSButton) { + logView.isHidden = sender.state == .off + sender.setAccessibilityLabel(logView.isHidden ? "Show build log" : "Hide build log") + if !logView.isHidden { + logView.update(lines: project.build.log) + } + } + +} diff --git a/FrameGen/Controllers/BuildResultViewController.swift b/FrameGen/Controllers/BuildResultViewController.swift new file mode 100644 index 0000000..837d481 --- /dev/null +++ b/FrameGen/Controllers/BuildResultViewController.swift @@ -0,0 +1,146 @@ +// +// BuildResultViewController.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// Shows what was built and everything needed to publish it as a SwiftPM binary target. +final class BuildResultViewController: NSViewController { + + // MARK: - Properties + + private let project: Project + + private static let privacyManifestURL = URL(string: "https://developer.apple.com/documentation/bundleresources/privacy-manifest-files")! + + // MARK: - Initial methods + + init(project: Project) { + self.project = project + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life cycle + + override func loadView() { + let contentView = ScrollingContentView() + view = contentView + guard case .succeeded(let summary) = project.build.phase else { return } + + 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) + + if !summary.librariesWithoutPrivacyManifest.isEmpty { + let learnMore = NSButton.push("Learn About Privacy Manifests", target: self, action: #selector(openPrivacyDocumentation(_:))) + learnMore.bezelStyle = .inline + contentView.stackView.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.", + action: learnMore)) + } + + contentView.stackView.addFullWidthArrangedSubview(makeFilesSection(summary)) + contentView.stackView.addFullWidthArrangedSubview(makePackageSection(summary)) + + let doneButton = NSButton.push("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(_:))) + buildAgain.controlSize = .large + let showInFinder = NSButton.push("Show in Finder", symbol: "folder", target: self, action: #selector(showInFinder(_:))) + showInFinder.controlSize = .large + showInFinder.keyEquivalent = "\r" + contentView.setBottomBar(leading: [doneButton], trailing: [buildAgain, showInFinder]) + } + + // MARK: - Private methods + + private func makeFilesSection(_ summary: XCFrameworkBuilder.Summary) -> NSView { + let section = FormSectionView(title: "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")!, + target: self, + action: #selector(revealFile(_:))) + reveal.isBordered = false + reveal.contentTintColor = .secondaryLabelColor + reveal.toolTip = "Show in Finder" + reveal.identifier = NSUserInterfaceItemIdentifier(url.path) + let row = FormRowView(title: url.lastPathComponent, + subtitle: Formatters.path(url.deletingLastPathComponent()), + accessory: reveal) + row.toolTip = url.path + + return row + } + section.setRows(rows) + + return section + } + + private func makePackageSection(_ summary: XCFrameworkBuilder.Summary) -> NSView { + let section = FormSectionView(title: "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 snippet = NSTextField.text(summary.packageSnippet.trimmingCharacters(in: .newlines), + font: .monospaced(.callout), lines: 0, selectable: true) + let copySnippet = CopyButton(title: "Copy Snippet") { summary.packageSnippet } + let snippetStack = NSStackView.vertical([snippet, copySnippet], spacing: 10) + let snippetContainer = NSView() + snippetContainer.translatesAutoresizingMaskIntoConstraints = false + snippetContainer.addSubview(snippetStack) + 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." + + return section + } + + // MARK: - Actions + + @objc + private func done(_ sender: Any?) { + project.build.reset() + } + + @objc + private func showInFinder(_ sender: Any?) { + guard case .succeeded(let summary) = project.build.phase else { return } + + NSWorkspace.shared.activateFileViewerSelecting([summary.plan.xcframeworkURL]) + } + + @objc + private func revealFile(_ sender: NSButton) { + guard let path = sender.identifier?.rawValue else { return } + + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } + + @objc + private func openPrivacyDocumentation(_ sender: Any?) { + NSWorkspace.shared.open(Self.privacyManifestURL) + } + +} diff --git a/FrameGen/Controllers/ConfigurationViewController.swift b/FrameGen/Controllers/ConfigurationViewController.swift new file mode 100644 index 0000000..d525b65 --- /dev/null +++ b/FrameGen/Controllers/ConfigurationViewController.swift @@ -0,0 +1,266 @@ +// +// ConfigurationViewController.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 22.04.2023. +// Copyright © 2023 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// The build options of a project: scheme, configuration, platforms and output folder. +final class ConfigurationViewController: NSViewController { + + // MARK: - Properties + + private let project: Project + 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 schemePopUp = NSPopUpButton() + private let configurationPopUp = NSPopUpButton() + private var platformSwitches: [Platform: NSSwitch] = [:] + private let outputPathControl = NSPathControl() + private let resetOutputButton = NSButton() + private let summaryLabel = NSTextField.text(color: .secondaryLabelColor) + private let buildButton = NSButton() + private var shownInfoState: Project.InfoState? + + // MARK: - Initial methods + + init(project: Project) { + self.project = project + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life cycle + + override func loadView() { + configureBuildSection() + configurePlatformsSection() + configureOutputSection() + + buildButton.title = "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)" + + contentView.stackView.addFullWidthArrangedSubview(statusContainer) + [buildSection, platformsSection, outputSection].forEach(contentView.stackView.addFullWidthArrangedSubview) + contentView.setBottomBar(leading: [summaryLabel], trailing: [buildButton]) + view = contentView + } + + override func viewDidLoad() { + super.viewDidLoad() + + observation = ObservationLoop { [weak self] in + self?.update() + } + // The default output folder comes from Settings, which is stored in UserDefaults rather than the model. + NotificationCenter.default.addObserver(self, + selector: #selector(defaultsDidChange(_:)), + name: UserDefaults.didChangeNotification, + object: nil) + } + + override func viewWillDisappear() { + super.viewWillDisappear() + observation?.cancel() + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Private methods + + private func configureBuildSection() { + schemePopUp.target = self + schemePopUp.action = #selector(didSelectScheme(_:)) + schemePopUp.setAccessibilityLabel("Scheme") + configurationPopUp.target = self + configurationPopUp.action = #selector(didSelectConfiguration(_:)) + configurationPopUp.setAccessibilityLabel("Configuration") + [schemePopUp, configurationPopUp].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.widthAnchor.constraint(greaterThanOrEqualToConstant: 200).isActive = true + } + buildSection.setRows([FormRowView(title: "Scheme", accessory: schemePopUp), + FormRowView(title: "Configuration", accessory: configurationPopUp)]) + } + + 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 configureOutputSection() { + outputPathControl.pathStyle = .standard + outputPathControl.isEditable = false + outputPathControl.backgroundColor = .clear + outputPathControl.translatesAutoresizingMaskIntoConstraints = false + outputPathControl.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + outputPathControl.target = self + outputPathControl.doubleAction = #selector(showOutputFolder(_:)) + outputPathControl.setAccessibilityLabel("Output folder") + + let chooseButton = NSButton.push("Choose…", target: self, action: #selector(chooseOutputFolder(_:))) + resetOutputButton.title = "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]) + } + + 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 + configurationPopUp.isEnabled = isLoaded + + for (platform, toggle) in platformSwitches { + toggle.state = project.platforms.contains(platform) ? .on : .off + } + + 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." + + buildButton.isEnabled = project.canBuild + summaryLabel.stringValue = summary() + } + + private func updateStatus() { + guard project.infoState != shownInfoState else { return } + + shownInfoState = project.infoState + 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)) + } + statusContainer.isHidden = statusContainer.arrangedSubviews.isEmpty + } + + private func reloadItems(of popUp: NSPopUpButton, with titles: [String], selected: String?) { + if popUp.itemTitles != titles { + popUp.removeAllItems() + popUp.addItems(withTitles: titles) + } + if let selected, titles.contains(selected) { + popUp.selectItem(withTitle: selected) + } + } + + private func summary() -> String { + 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." + } + + let count = project.platforms.count + + return "\(scheme) · \(configuration) · \(count) platform\(count == 1 ? "" : "s")" + } + + // MARK: - Actions + + @objc + private func didSelectScheme(_ sender: NSPopUpButton) { + project.scheme = sender.titleOfSelectedItem + } + + @objc + private func didSelectConfiguration(_ sender: NSPopUpButton) { + project.configuration = sender.titleOfSelectedItem + } + + @objc + 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) + } + } + + @objc + private func chooseOutputFolder(_ sender: Any?) { + guard let url = ProjectLocator.chooseFolder(title: "Choose Output Folder", startingAt: project.outputDirectory) else { return } + + project.customOutputDirectory = url + } + + @objc + private func resetOutputFolder(_ sender: Any?) { + project.customOutputDirectory = nil + } + + @objc + private func showOutputFolder(_ sender: Any?) { + NSWorkspace.shared.reveal(project.outputDirectory) + } + + @objc + private func defaultsDidChange(_ notification: Notification) { + update() + } + + @objc + private func reloadProject(_ sender: Any?) { + Task { + await project.loadInfo() + } + } + +} diff --git a/FrameGen/Controllers/ProjectViewController.swift b/FrameGen/Controllers/ProjectViewController.swift new file mode 100644 index 0000000..e7f449c --- /dev/null +++ b/FrameGen/Controllers/ProjectViewController.swift @@ -0,0 +1,83 @@ +// +// ProjectViewController.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// Shows the screen that matches the build phase: configuration, progress, result or failure. +final class ProjectViewController: NSViewController { + + private enum Screen { + case configuration, progress, result, failure + } + + // MARK: - Properties + + private let project: Project + private let buildController: BuildController + private var observation: ObservationLoop? + private var screen: Screen? + + // MARK: - Initial methods + + init(project: Project, buildController: BuildController) { + self.project = project + self.buildController = buildController + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life cycle + + override func loadView() { + view = NSView() + } + + override func viewDidLoad() { + super.viewDidLoad() + + observation = ObservationLoop { [weak self] in + self?.showCurrentScreen() + } + } + + // MARK: - Private methods + + private func showCurrentScreen() { + let next: Screen + switch project.build.phase { + case .idle: + next = .configuration + case .running: + next = .progress + case .succeeded: + next = .result + case .failed: + next = .failure + } + guard next != screen else { return } + + screen = next + let controller: NSViewController + switch next { + case .configuration: + controller = ConfigurationViewController(project: project) + case .progress: + controller = BuildProgressViewController(project: project) + case .result: + controller = BuildResultViewController(project: project) + case .failure: + controller = BuildFailureViewController(project: project) + } + setChild(controller, in: view) + } + +} diff --git a/FrameGen/Controllers/RootViewController.swift b/FrameGen/Controllers/RootViewController.swift new file mode 100644 index 0000000..2600f08 --- /dev/null +++ b/FrameGen/Controllers/RootViewController.swift @@ -0,0 +1,78 @@ +// +// RootViewController.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// Shows the welcome screen or the opened project, and accepts projects dropped anywhere in the window. +final class RootViewController: NSViewController { + + // MARK: - Properties + + private let workspace: Workspace + private let buildController: BuildController + private var observation: ObservationLoop? + private weak var shownProject: Project? + private var isShowingWelcome = false + + // MARK: - Initial methods + + init(workspace: Workspace, buildController: BuildController) { + self.workspace = workspace + self.buildController = buildController + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life cycle + + override func loadView() { + let dropView = DropTargetView() + dropView.canAcceptDrop = { [weak self] in + self?.workspace.project?.build.isRunning != true + } + dropView.onDrop = { [weak self] url in + self?.open(url) + } + view = dropView + } + + override func viewDidLoad() { + super.viewDidLoad() + + observation = ObservationLoop { [weak self] in + self?.showCurrentScreen() + } + } + + // MARK: - Private methods + + private func showCurrentScreen() { + if let project = workspace.project { + guard project !== shownProject else { return } + + shownProject = project + isShowingWelcome = false + setChild(ProjectViewController(project: project, buildController: buildController), in: view) + } else { + guard !isShowingWelcome else { return } + + shownProject = nil + isShowingWelcome = true + setChild(WelcomeViewController(workspace: workspace, openProject: { [weak self] url in self?.open(url) }), in: view) + } + } + + private func open(_ url: URL) { + (view.window?.windowController as? MainWindowController)?.openProject(at: url) + } + +} diff --git a/FrameGen/Controllers/SettingsViewController.swift b/FrameGen/Controllers/SettingsViewController.swift new file mode 100644 index 0000000..e1b9b7f --- /dev/null +++ b/FrameGen/Controllers/SettingsViewController.swift @@ -0,0 +1,159 @@ +// +// SettingsViewController.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 26.04.2023. +// Copyright © 2023 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +final class SettingsWindowController: NSWindowController { + + init() { + let window = NSWindow(contentViewController: SettingsViewController()) + window.title = "Settings" + window.styleMask = [.titled, .closable] + window.isReleasedWhenClosed = false + window.setFrameAutosaveName("SettingsWindow") + super.init(window: window) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func showWindow(_ sender: Any?) { + if window?.isVisible == false { + window?.center() + } + super.showWindow(sender) + } + +} + +/// App-wide defaults: where results go and what happens after a build. +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 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) + + // MARK: - Life cycle + + override func loadView() { + [insideProjectRadio, customFolderRadio].forEach { + $0.target = self + $0.action = #selector(didChangeLocation(_:)) + } + customPathControl.pathStyle = .standard + customPathControl.isEditable = false + customPathControl.backgroundColor = .clear + customPathControl.translatesAutoresizingMaskIntoConstraints = false + customPathControl.setAccessibilityLabel("Shared output folder") + chooseButton.title = "Choose…" + chooseButton.bezelStyle = .push + chooseButton.target = self + chooseButton.action = #selector(chooseFolder(_:)) + [revealCheckbox, keepArchivesCheckbox].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.", + 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.", + font: .preferred(.footnote), color: .secondaryLabelColor, lines: 0) + archivesHint.preferredMaxLayoutWidth = 380 + + let grid = NSGridView(views: [[label("Save results:"), insideProjectRadio], + [NSGridCell.emptyContentView, customFolderRadio], + [NSGridCell.emptyContentView, customRow], + [NSGridCell.emptyContentView, locationHint], + [label("After building:"), revealCheckbox], + [NSGridCell.emptyContentView, keepArchivesCheckbox], + [NSGridCell.emptyContentView, archivesHint]]) + grid.column(at: 0).xPlacement = .trailing + grid.rowAlignment = .firstBaseline + grid.columnSpacing = Metrics.spacing + grid.rowSpacing = Metrics.spacing + grid.row(at: 4).topPadding = Metrics.groupSpacing + grid.translatesAutoresizingMaskIntoConstraints = false + + let root = NSView() + root.addSubview(grid) + grid.pinEdges(to: root, insets: NSEdgeInsets(top: Metrics.margin, left: Metrics.margin + 10, + bottom: Metrics.margin, right: Metrics.margin + 10)) + view = root + } + + override func viewWillAppear() { + super.viewWillAppear() + update() + } + + // MARK: - Private methods + + private func label(_ text: String) -> NSTextField { + let label = NSTextField.text(text) + label.alignment = .right + + return label + } + + private var customPath: String? { + defaults.string(forKey: PreferenceKey.customOutputPath).flatMap { $0.isEmpty ? nil : $0 } + } + + private func update() { + let location = OutputLocation(rawValue: defaults.string(forKey: PreferenceKey.outputLocation) ?? "") ?? .insideProject + 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.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 + } + + // MARK: - Actions + + @objc + private func didChangeLocation(_ sender: NSButton) { + let location: OutputLocation = sender === customFolderRadio ? .customFolder : .insideProject + defaults.set(location.rawValue, forKey: PreferenceKey.outputLocation) + if location == .customFolder, customPath == nil { + chooseFolder(sender) + } + update() + } + + @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) { + defaults.set(url.path, forKey: PreferenceKey.customOutputPath) + } else if customPath == nil { + defaults.set(OutputLocation.insideProject.rawValue, forKey: PreferenceKey.outputLocation) + } + update() + } + + @objc + private func didToggleOption(_ sender: NSButton) { + let key = sender === revealCheckbox ? PreferenceKey.revealInFinder : PreferenceKey.keepArchives + defaults.set(sender.state == .on, forKey: key) + } + +} diff --git a/FrameGen/Controllers/WelcomeViewController.swift b/FrameGen/Controllers/WelcomeViewController.swift new file mode 100644 index 0000000..44983b4 --- /dev/null +++ b/FrameGen/Controllers/WelcomeViewController.swift @@ -0,0 +1,254 @@ +// +// WelcomeViewController.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 21.04.2023. +// Copyright © 2023 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// The start screen: what FrameGen does, how to open a project, and the recent projects. +final class WelcomeViewController: NSViewController { + + // MARK: - Properties + + private let workspace: Workspace + private let openProject: (URL) -> Void + private var observation: ObservationLoop? + private var recentProjects: [URL] = [] + + private let tableView = NSTableView() + private let emptyLabel = NSTextField.text("No Recent Projects", font: .preferred(.title3), color: .tertiaryLabelColor) + + // MARK: - Initial methods + + init(workspace: Workspace, openProject: @escaping (URL) -> Void) { + self.workspace = workspace + self.openProject = openProject + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life cycle + + override func loadView() { + let heroPane = makeHeroPane() + let recentsPane = makeRecentsPane() + let divider = NSBox() + divider.boxType = .separator + divider.translatesAutoresizingMaskIntoConstraints = false + + let root = NSView() + [heroPane, divider, recentsPane].forEach(root.addSubview) + NSLayoutConstraint.activate([heroPane.leadingAnchor.constraint(equalTo: root.leadingAnchor), + heroPane.topAnchor.constraint(equalTo: root.safeAreaLayoutGuide.topAnchor), + heroPane.bottomAnchor.constraint(equalTo: root.bottomAnchor), + heroPane.widthAnchor.constraint(equalTo: root.widthAnchor, multiplier: 0.55), + divider.leadingAnchor.constraint(equalTo: heroPane.trailingAnchor), + divider.topAnchor.constraint(equalTo: root.safeAreaLayoutGuide.topAnchor), + divider.bottomAnchor.constraint(equalTo: root.bottomAnchor), + recentsPane.leadingAnchor.constraint(equalTo: divider.trailingAnchor), + recentsPane.trailingAnchor.constraint(equalTo: root.trailingAnchor), + recentsPane.topAnchor.constraint(equalTo: root.safeAreaLayoutGuide.topAnchor), + recentsPane.bottomAnchor.constraint(equalTo: root.bottomAnchor)]) + view = root + } + + override func viewDidLoad() { + super.viewDidLoad() + + observation = ObservationLoop { [weak self] in + self?.reloadRecentProjects() + } + } + + override func viewDidAppear() { + super.viewDidAppear() + view.window?.makeFirstResponder(recentProjects.isEmpty ? nil : tableView) + if tableView.selectedRow < 0, !recentProjects.isEmpty { + tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + } + } + + // MARK: - Private methods + + private func makeHeroPane() -> NSView { + let icon = NSImageView(image: NSApp.applicationIconImage) + icon.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([icon.widthAnchor.constraint(equalToConstant: 112), + icon.heightAnchor.constraint(equalToConstant: 112)]) + 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.", + 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(_:))) + // Return opens the selected recent project, so this button relies on ⌘O instead of a default key equivalent. + openButton.bezelColor = .controlAccentColor + openButton.controlSize = .large + + let dropHint = makeDropHint() + let stack = NSStackView.vertical([icon, title, subtitle, openButton, dropHint], spacing: 12, alignment: .centerX) + stack.setCustomSpacing(4, after: title) + stack.setCustomSpacing(Metrics.groupSpacing, after: subtitle) + stack.setCustomSpacing(Metrics.groupSpacing, after: openButton) + + let pane = NSView() + pane.translatesAutoresizingMaskIntoConstraints = false + pane.addSubview(stack) + NSLayoutConstraint.activate([stack.centerXAnchor.constraint(equalTo: pane.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: pane.centerYAnchor), + stack.leadingAnchor.constraint(greaterThanOrEqualTo: pane.leadingAnchor, constant: Metrics.margin)]) + + return pane + } + + 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", + font: .preferred(.callout), color: .secondaryLabelColor, lines: 0) + label.alignment = .center + label.preferredMaxLayoutWidth = 220 + let stack = NSStackView.vertical([icon, label], spacing: 6, alignment: .centerX) + + let box = NSBox() + box.boxType = .custom + box.cornerRadius = Metrics.cornerRadius + box.borderColor = .separatorColor + box.fillColor = .clear + box.contentViewMargins = .zero + box.translatesAutoresizingMaskIntoConstraints = false + if let contentView = box.contentView { + contentView.addSubview(stack) + stack.pinEdges(to: contentView, insets: NSEdgeInsets(top: 14, left: 16, bottom: 14, right: 16)) + } + box.widthAnchor.constraint(equalToConstant: 280).isActive = true + + return box + } + + private func makeRecentsPane() -> NSView { + let header = NSTextField.text("Recent Projects", font: .preferred(.headline), color: .secondaryLabelColor) + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("project")) + tableView.addTableColumn(column) + tableView.headerView = nil + tableView.style = .inset + tableView.rowHeight = 46 + tableView.backgroundColor = .clear + tableView.dataSource = self + tableView.delegate = self + tableView.target = self + tableView.doubleAction = #selector(openClickedProject(_:)) + tableView.menu = makeContextMenu() + tableView.setAccessibilityLabel("Recent projects") + + let scrollView = NSScrollView() + scrollView.documentView = tableView + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.translatesAutoresizingMaskIntoConstraints = false + + let pane = NSVisualEffectView() + pane.material = .sidebar + pane.blendingMode = .behindWindow + pane.translatesAutoresizingMaskIntoConstraints = false + [header, scrollView, emptyLabel].forEach(pane.addSubview) + NSLayoutConstraint.activate([header.leadingAnchor.constraint(equalTo: pane.leadingAnchor, constant: Metrics.margin), + header.topAnchor.constraint(equalTo: pane.topAnchor, constant: Metrics.margin), + scrollView.topAnchor.constraint(equalTo: header.bottomAnchor, constant: Metrics.spacing), + scrollView.leadingAnchor.constraint(equalTo: pane.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: pane.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: pane.bottomAnchor), + emptyLabel.centerXAnchor.constraint(equalTo: pane.centerXAnchor), + emptyLabel.centerYAnchor.constraint(equalTo: pane.centerYAnchor)]) + + return pane + } + + 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(.separator()) + menu.addItem(withTitle: "Remove from Recent Projects", action: #selector(removeClickedProject(_:)), keyEquivalent: "") + menu.items.forEach { $0.target = self } + + return menu + } + + private func reloadRecentProjects() { + recentProjects = workspace.recentProjects + tableView.reloadData() + emptyLabel.isHidden = !recentProjects.isEmpty + } + + /// The right-clicked row for context menu actions, otherwise the selected one. + private var targetProject: URL? { + let row = tableView.clickedRow >= 0 ? tableView.clickedRow : tableView.selectedRow + + return recentProjects.indices.contains(row) ? recentProjects[row] : nil + } + + // MARK: - Actions + + @objc + private func openClickedProject(_ sender: Any?) { + guard let url = targetProject else { return } + + openProject(url) + } + + @objc + private func showClickedProjectInFinder(_ sender: Any?) { + guard let url = targetProject else { return } + + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + @objc + private func removeClickedProject(_ sender: Any?) { + guard let url = targetProject else { return } + + workspace.removeRecentProject(url) + } + + override func keyDown(with event: NSEvent) { + switch event.specialKey { + case .carriageReturn, .enter: + openClickedProject(nil) + case .delete, .deleteForward: + removeClickedProject(nil) + default: + super.keyDown(with: event) + } + } + +} + +// MARK: - NSTableViewDataSource, NSTableViewDelegate + +extension WelcomeViewController: NSTableViewDataSource, NSTableViewDelegate { + + func numberOfRows(in tableView: NSTableView) -> Int { + recentProjects.count + } + + 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]) + + return cell + } + +} diff --git a/FrameGen/Core/Extensions/String.swift b/FrameGen/Core/Extensions/String.swift deleted file mode 100644 index 6472eb2..0000000 --- a/FrameGen/Core/Extensions/String.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// String.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 22.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Foundation - -extension String { - - func splitAtFirst(delimiter: String) -> (after: String, before: String)? { - guard - let upperIndex = (range(of: delimiter)?.upperBound), - let lowerIndex = (range(of: delimiter)?.lowerBound) - else { - return nil - } - - let firstPart = String(prefix(upTo: lowerIndex)) - let lastPart = String(suffix(from: upperIndex)) - - return (firstPart, lastPart) - } - -} diff --git a/FrameGen/Core/Extensions/URL+FindDirectory.swift b/FrameGen/Core/Extensions/URL+FindDirectory.swift deleted file mode 100644 index 5a23df6..0000000 --- a/FrameGen/Core/Extensions/URL+FindDirectory.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// URL+FindDirectory.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 18.05.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Foundation - -extension URL { - - var xcodeProjectDirectory: URL? { - if path() == "/" { - return nil - } - let fileManager = FileManager.default - guard - let enumerator = fileManager.enumerator(at: self, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles, .skipsPackageDescendants]) - else { - return nil - } - - for case let fileURL as URL in enumerator where fileURL.pathExtension == "xcodeproj" { - return fileURL.deletingLastPathComponent() - } - - return nil - } - -} diff --git a/FrameGen/Flows/AppBuilder.swift b/FrameGen/Flows/AppBuilder.swift deleted file mode 100644 index af9915e..0000000 --- a/FrameGen/Flows/AppBuilder.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// AppBuilder.swift -// generateFramework -// -// Created by Рамиз Кичибеков on 21.04.2023. -// - -import Cocoa - -struct AppBuilder { - - static func createMainModule(frame: NSRect) -> NSViewController { - let presenter = MainPresenter() - let controller = MainViewController(frame: frame, presenter: presenter) - presenter.view = controller - - return controller - } - - static func createOptionsModule(path: String) -> NSViewController { - let presenter = OptionsPresenter(shellManager: ShellManager(), - settingsManager: SettingsManager.shared, - path: path) - let controller = OptionsViewController(presenter: presenter) - presenter.view = controller - - return controller - } - - static func createSettingsModule() -> NSViewController { - let presenter = SettingsPresenter(settingsManager: SettingsManager.shared) - let controller = SettingsViewController(presenter: presenter) - presenter.view = controller - - return controller - } - -} diff --git a/FrameGen/Flows/Main/Controller/Extensions/MainViewController+ContentViewProtocol.swift b/FrameGen/Flows/Main/Controller/Extensions/MainViewController+ContentViewProtocol.swift deleted file mode 100644 index f487627..0000000 --- a/FrameGen/Flows/Main/Controller/Extensions/MainViewController+ContentViewProtocol.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// MainViewController+ContentViewProtocol.swift -// generateFramework -// -// Created by Рамиз Кичибеков on 21.04.2023. -// - -import Cocoa - -extension MainViewController: ContentViewProtocol { - - func fileSelected(_ path: String) { - presenter.makeOptions(path: path) - } - - func didTapSettings() { - presenter.makeSettings() - } - -} diff --git a/FrameGen/Flows/Main/Controller/MainViewController.swift b/FrameGen/Flows/Main/Controller/MainViewController.swift deleted file mode 100644 index 34e3997..0000000 --- a/FrameGen/Flows/Main/Controller/MainViewController.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// MainViewController.swift -// generateFramework -// -// Created by Рамиз Кичибеков on 08.04.2023. -// - -import Cocoa - -protocol MainViewInputProtocol: AnyObject { - - var presenter: MainPresenterOutputProtocol { get } - - func openOptions(path: String) - func openSettings() - -} - -final class MainViewController: NSViewController, MainViewInputProtocol { - - // MARK: - Properties - - private let contentView = ContentView() - private let frame: NSRect - let presenter: MainPresenterOutputProtocol - - // MARK: - Initial methods - - required init(frame: NSRect, presenter: MainPresenterOutputProtocol) { - self.presenter = presenter - self.frame = frame - super.init(nibName: nil, bundle: .main) - } - - @available(*, unavailable) - init() { - fatalError("init has not been implemented") - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Life cycle - - override func loadView() { - let visualEffectView = NSVisualEffectView(frame: frame) - visualEffectView.blendingMode = .behindWindow - visualEffectView.material = .fullScreenUI - view = visualEffectView - } - - override func viewDidLoad() { - super.viewDidLoad() - - configureController() - configureLayout() - } - - // MARK: - Public methods - - func openOptions(path: String) { - let optionsController = AppBuilder.createOptionsModule(path: path) - presentAsSheet(optionsController) - } - - func openSettings() { - let settingsController = AppBuilder.createSettingsModule() - presentAsSheet(settingsController) - } - - // MARK: - Private methods - - private func configureController() { - contentView.delegate = self - contentView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(contentView) - } - - private func configureLayout() { - let constraints = [contentView.topAnchor.constraint(equalTo: view.topAnchor), - contentView.leftAnchor.constraint(equalTo: view.leftAnchor), - view.rightAnchor.constraint(equalTo: contentView.rightAnchor), - view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)] - NSLayoutConstraint.activate(constraints) - } - -} diff --git a/FrameGen/Flows/Main/Presenter/Extensions/MainPresenter+MainPresenterOutputProtocol.swift b/FrameGen/Flows/Main/Presenter/Extensions/MainPresenter+MainPresenterOutputProtocol.swift deleted file mode 100644 index 06fe0f8..0000000 --- a/FrameGen/Flows/Main/Presenter/Extensions/MainPresenter+MainPresenterOutputProtocol.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// MainPresenter+MainPresenterOutputProtocol.swift -// generateFramework -// -// Created by Рамиз Кичибеков on 21.04.2023. -// - -import Foundation - -extension MainPresenter: MainPresenterOutputProtocol { - - func makeOptions(path: String) { - view?.openOptions(path: path) - } - - func makeSettings() { - view?.openSettings() - } - -} diff --git a/FrameGen/Flows/Main/Presenter/MainPresenter.swift b/FrameGen/Flows/Main/Presenter/MainPresenter.swift deleted file mode 100644 index c51b765..0000000 --- a/FrameGen/Flows/Main/Presenter/MainPresenter.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// MainPresenter.swift -// generateFramework -// -// Created by Рамиз Кичибеков on 21.04.2023. -// - -import Foundation - -protocol MainPresenterOutputProtocol: AnyObject { - func makeOptions(path: String) - func makeSettings() -} - -protocol MainPresenterInputProtocol { - var view: MainViewInputProtocol? { get } -} - -final class MainPresenter: MainPresenterInputProtocol { - - var view: MainViewInputProtocol? - - required init() { - - } - -} diff --git a/FrameGen/Flows/Main/View/ContentView.swift b/FrameGen/Flows/Main/View/ContentView.swift deleted file mode 100644 index b9ecaeb..0000000 --- a/FrameGen/Flows/Main/View/ContentView.swift +++ /dev/null @@ -1,150 +0,0 @@ -// -// ContentView.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 22.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -protocol ContentViewProtocol: AnyObject { - - func fileSelected(_ path: String) - func didTapSettings() - -} - -final class ContentView: NSView { - - // MARK: - Properties - - weak var delegate: ContentViewProtocol? - - private enum Constants { - static let offset: CGFloat = 32 - static let settingsButtonSize = NSSize(width: 25, height: 25) - } - - private let projectImageView: NSImageView = { - guard let image = NSImage(named: "project") else { - fatalError("The project image not found") - } - - let imageView = NSImageView(image: image) - imageView.contentTintColor = NSColor.labelColor - imageView.translatesAutoresizingMaskIntoConstraints = false - - return imageView - }() - private lazy var addFileButton: NSButton = { - let button = NSButton(title: "Add a project directory", - target: self, - action: #selector(didTapAddFileButton(_ :))) - button.bezelColor = .systemBlue - button.translatesAutoresizingMaskIntoConstraints = false - - return button - }() - private(set) lazy var settingsButton: NSButton = { - let image = NSImage(systemSymbolName: "gear", accessibilityDescription: nil) - let button = NSButton(image: image!, - target: self, - action: #selector(didTapSettings(_ :))) - button.imageScaling = .scaleProportionallyUpOrDown - button.wantsLayer = true - button.layer?.backgroundColor = NSColor.clear.cgColor - button.isBordered = false - button.translatesAutoresizingMaskIntoConstraints = false - - return button - }() - private let openPanel: NSOpenPanel = { - let panel = NSOpenPanel() - panel.worksWhenModal = true - panel.title = "Choose a project directory" - panel.showsResizeIndicator = true - panel.canChooseDirectories = true - panel.canChooseFiles = false - panel.allowsMultipleSelection = false - - return panel - }() - private let dropView = DropView() - - // MARK: - Initial methods - - required init() { - super.init(frame: .zero) - - configureView() - configureConstraints() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Private methods - - private func configureView() { - openPanel.delegate = self - dropView.delegate = self - dropView.translatesAutoresizingMaskIntoConstraints = false - addSubview(dropView) - addSubview(addFileButton) - addSubview(projectImageView, positioned: .below, relativeTo: dropView) - addSubview(settingsButton) - } - - private func configureConstraints() { - let constraints = [projectImageView.topAnchor.constraint(equalTo: topAnchor, - constant: Constants.offset * 3), - projectImageView.centerXAnchor.constraint(equalTo: centerXAnchor), - projectImageView.leftAnchor.constraint(greaterThanOrEqualTo: leftAnchor, - constant: Constants.offset), - rightAnchor.constraint(greaterThanOrEqualTo: projectImageView.rightAnchor, - constant: Constants.offset), - addFileButton.topAnchor.constraint(equalTo: projectImageView.bottomAnchor, - constant: Constants.offset), - addFileButton.centerXAnchor.constraint(equalTo: centerXAnchor), - bottomAnchor.constraint(greaterThanOrEqualTo: addFileButton.bottomAnchor, - constant: Constants.offset), - dropView.topAnchor.constraint(equalTo: topAnchor), - dropView.leftAnchor.constraint(equalTo: leftAnchor), - rightAnchor.constraint(equalTo: dropView.rightAnchor), - bottomAnchor.constraint(equalTo: dropView.bottomAnchor), - settingsButton.topAnchor.constraint(equalTo: topAnchor, - constant: Constants.offset / 4), - rightAnchor.constraint(equalTo: settingsButton.rightAnchor, - constant: Constants.offset / 4), - settingsButton.widthAnchor.constraint(equalToConstant: Constants.settingsButtonSize.width), - settingsButton.heightAnchor.constraint(equalToConstant: Constants.settingsButtonSize.height)] - NSLayoutConstraint.activate(constraints) - } - - // MARK: - Actions - - @objc - private func didTapAddFileButton(_ sender: NSButton) { - guard let window = window else { return } - - Task { - let response = await openPanel.beginSheetModal(for: window) - switch response { - case .OK: - guard let url = openPanel.url else { return } - - delegate?.fileSelected(url.path()) - default: break - } - } - } - - @objc - private func didTapSettings(_ sender: NSButton) { - delegate?.didTapSettings() - } - -} diff --git a/FrameGen/Flows/Main/View/DropView.swift b/FrameGen/Flows/Main/View/DropView.swift deleted file mode 100644 index d5e9d9b..0000000 --- a/FrameGen/Flows/Main/View/DropView.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// DropView.swift -// generateFramework -// -// Created by Рамиз Кичибеков on 21.04.2023. -// - -import Cocoa - -protocol DropViewProtocol: AnyObject { - - func fileSelected(path: String) - -} - -final class DropView: NSView { - - // MARK: - Properties - - weak var delegate: DropViewProtocol? - private let defaultDropColor: NSColor = Color.dropLayerColor - private let defaultColor: NSColor = .clear - - // MARK: - Initial methods - - required init?(coder: NSCoder) { - super.init(coder: coder) - - configureView() - } - - init() { - super.init(frame: .zero) - - configureView() - } - - // MARK: - Life cycle - - override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { - if checkFile(sender) { - animate { - self.layer?.backgroundColor = self.defaultDropColor.withAlphaComponent(0.3).cgColor - } - - return .copy - } else { - return NSDragOperation() - } - } - - override func draggingExited(_ sender: NSDraggingInfo?) { - animate { - self.layer?.backgroundColor = self.defaultColor.cgColor - } - } - - override func draggingEnded(_ sender: NSDraggingInfo) { - animate { - self.layer?.backgroundColor = self.defaultColor.cgColor - } - } - - override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { - guard - checkFile(sender), - let path = makePathFrom(draggingInfo: sender), - checkIsDirectory(path: path) - else { - return false - } - - delegate?.fileSelected(path: path) - - return true - } - - // MARK: - Private methods - - private func configureView() { - wantsLayer = true - layer?.backgroundColor = defaultColor.cgColor - - registerForDraggedTypes([NSPasteboard.PasteboardType.URL, NSPasteboard.PasteboardType.fileURL]) - } - - private func checkFile(_ drag: NSDraggingInfo) -> Bool { - guard - let path = makePathFrom(draggingInfo: drag), - checkIsDirectory(path: path) - else { - return false - } - - var isDirectory: ObjCBool = false - let fileExist = FileManager().fileExists(atPath: path, isDirectory: &isDirectory) - if fileExist { - return isDirectory.boolValue - } - - return false - } - - private func makePathFrom(draggingInfo: NSDraggingInfo) -> String? { - let pasteboard = draggingInfo.draggingPasteboard - let board = pasteboard.propertyList(forType: NSPasteboard.PasteboardType(rawValue: "NSFilenamesPboardType")) as? [String] - let path = board?.first - - return path - } - - private func checkIsDirectory(path: String) -> Bool { - let filePathURL = URL(filePath: path) - if !filePathURL.pathExtension.isEmpty { - return false - } - - return true - } - - private func animate(duration: TimeInterval = 0.3, - timingFunction: CAMediaTimingFunction = CAMediaTimingFunction(name: .easeInEaseOut), - animations: () -> Void, - completionHandler: (() -> Void)? = nil) { - NSAnimationContext.runAnimationGroup({ context in - context.allowsImplicitAnimation = true - - context.duration = duration - context.timingFunction = timingFunction - animations() - }, completionHandler: completionHandler) - } - -} diff --git a/FrameGen/Flows/Main/View/Extensions/ContentView+DropViewProtocol.swift b/FrameGen/Flows/Main/View/Extensions/ContentView+DropViewProtocol.swift deleted file mode 100644 index 4719b39..0000000 --- a/FrameGen/Flows/Main/View/Extensions/ContentView+DropViewProtocol.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// ContentView+DropViewProtocol.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 22.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -extension ContentView: DropViewProtocol { - - func fileSelected(path: String) { - delegate?.fileSelected(path) - } - -} diff --git a/FrameGen/Flows/Main/View/Extensions/ContentView+NSOpenSavePanelDelegate.swift b/FrameGen/Flows/Main/View/Extensions/ContentView+NSOpenSavePanelDelegate.swift deleted file mode 100644 index 3726759..0000000 --- a/FrameGen/Flows/Main/View/Extensions/ContentView+NSOpenSavePanelDelegate.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// ContentView+NSOpenSavePanelDelegate.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 22.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -extension ContentView: NSOpenSavePanelDelegate { - - func panel(_ sender: Any, shouldEnable url: URL) -> Bool { - guard let xcodeProjectDirectoryURL = url.xcodeProjectDirectory else { - return false - } - - return true - } - -} diff --git a/FrameGen/Flows/Options/Controller/Extensions/OptionsViewController+OptionsContentDelegate.swift b/FrameGen/Flows/Options/Controller/Extensions/OptionsViewController+OptionsContentDelegate.swift deleted file mode 100644 index 8a1dbfb..0000000 --- a/FrameGen/Flows/Options/Controller/Extensions/OptionsViewController+OptionsContentDelegate.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// OptionsViewController+OptionsContentDelegate.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 22.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -extension OptionsViewController: OptionsContentDelegate { - - func dismissView() { - presenter.cancelAllScripts() - dismiss(self) - } - - func runScripts(_ scripts: [Script]) { - presenter.runScripts(scripts) - } - - func didSelectIndex(box: OptionsContentView.ComboboxType, at index: Int) { - switch box { - case .scheme: - let scheme = data.schemes[index] - presenter.setScheme(scheme) - case .configuration: - let configuration = data.configurations[index] - presenter.setConfiguration(configuration) - } - } - -} diff --git a/FrameGen/Flows/Options/Controller/OptionsViewController.swift b/FrameGen/Flows/Options/Controller/OptionsViewController.swift deleted file mode 100644 index 2c14dad..0000000 --- a/FrameGen/Flows/Options/Controller/OptionsViewController.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// OptionsViewController.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 22.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -protocol OptionsViewInputProtocol: AnyObject { - var presenter: OptionsPresenterOutputProtocol { get } - func updateData(_ data: (configurations: [String], schemes: [String])) - func scriptsDidCompete(with error: Error?, message: String?) -} - -final class OptionsViewController: NSViewController, OptionsViewInputProtocol { - - // MARK: - Properties - - let presenter: OptionsPresenterOutputProtocol - private(set) var data: (configurations: [String], schemes: [String]) = ([], []) { - didSet { - contentView.insertItems(data) - } - } - private(set) lazy var contentView = OptionsContentView(delegate: self) - - // MARK: - Initial methods - - required init(presenter: OptionsPresenterOutputProtocol) { - self.presenter = presenter - super.init(nibName: nil, bundle: .main) - } - - @available(*, unavailable) - init() { - fatalError("init has not been implemented") - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Life cycle - - override func loadView() { - guard let presentingView = presentingViewController?.view else { - super.loadView() - - return - } - - view = NSView(frame: NSRect(x: .zero, y: .zero, - width: presentingView.frame.width * 1.4, - height: presentingView.frame.height * 0.6)) - } - - override func viewDidLoad() { - super.viewDidLoad() - - configureController() - configureConstraints() - } - - // MARK: - Public methods - - func updateData(_ data: (configurations: [String], schemes: [String])) { - self.data = data - } - - func scriptsDidCompete(with error: Error?, message: String?) { - defer { - contentView.stopIndicators() - } - - if let error { - let alert = NSAlert(error: error) - alert.alertStyle = .warning - alert.addButton(withTitle: "OK") - let result = alert.runModal() - switch result { - default: dismissView() - } - } else { - let alert = NSAlert() - alert.alertStyle = .informational - if let message { - alert.informativeText = message - } - alert.addButton(withTitle: "OK") - let result = alert.runModal() - switch result { - default: dismissView() - } - } - } - - // MARK: - Private methods - - private func configureController() { - presenter.makeDataFromProject() - contentView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(contentView) - } - - private func configureConstraints() { - let constraints = [contentView.topAnchor.constraint(equalTo: view.topAnchor), - contentView.leftAnchor.constraint(equalTo: view.leftAnchor), - view.rightAnchor.constraint(equalTo: contentView.rightAnchor), - view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)] - NSLayoutConstraint.activate(constraints) - } - -} diff --git a/FrameGen/Flows/Options/Presenter/Extensions/OptionsPresenter+OptionsPresenterOutputProtocol.swift b/FrameGen/Flows/Options/Presenter/Extensions/OptionsPresenter+OptionsPresenterOutputProtocol.swift deleted file mode 100644 index 248e573..0000000 --- a/FrameGen/Flows/Options/Presenter/Extensions/OptionsPresenter+OptionsPresenterOutputProtocol.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// OptionsPresenter+OptionsPresenterOutputProtocol.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 22.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Foundation - -extension OptionsPresenter: OptionsPresenterOutputProtocol { - - func setScheme(_ scheme: String) { - command.setScheme(scheme) - } - - func setConfiguration(_ configuration: String) { - command.setConfiguration(configuration) - } - - func makeDataFromProject() { - xcodeVersionCheck() - var data: (configurations: [String], schemes: [String]) = ([], []) - shellManager.shell(.getSchemesFromProject(path)) { - switch $0 { - case .success(let result): - switch result { - case let .output(message, log): - if let splitSuccess = log.splitAtFirst(delimiter: "Build Configurations:")? - .before - .trimmingCharacters(in: .whitespacesAndNewlines), - let configurations = splitSuccess.splitAtFirst(delimiter: "If no build configuration is specified and -scheme is not passed then \"Release\" is used.")? - .after - .components(separatedBy: "\n") - .map({ $0.trimmingCharacters(in: .whitespacesAndNewlines) }) { - data.configurations = configurations.filter { !$0.isEmpty } - } else { - data.configurations = ["Release"] - } - guard - let schemes = log.splitAtFirst(delimiter: "Schemes:")? - .before - .trimmingCharacters(in: .whitespacesAndNewlines) - .components(separatedBy: "\n") - else { - DispatchQueue.main.async { - self.view?.updateData(data) - } - - return - } - - data.schemes = schemes.compactMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } - DispatchQueue.main.async { - self.view?.updateData(data) - } - } - case .failure: - DispatchQueue.main.async { - self.view?.updateData(data) - } - } - } - } - - func cancelAllScripts() { - shellManager.cancelAllScripts() - } - - func runScripts(_ scripts: [Script]) { - command.setScripts(scripts) - guard !command.scripts.isEmpty else { return } - - let settingsManager: SettingsManagerProtocol = SettingsManager.shared - let folder = settingsManager.getFolder().pathForProcess - command.scripts.forEach { script in - shellManager.shell(.generateFramework(path: path, folder: folder, command: command, script: script)) { - switch $0 { - case .success(let result): - switch result { - case let .output(message, log): - debugPrint(log) - if scripts.count >= 1, scripts.last == script { - DispatchQueue.main.async { - self.view?.scriptsDidCompete(with: nil, message: message) - } - } - } - case .failure(let error): - debugPrint(error.localizedDescription) - DispatchQueue.main.async { - self.view?.scriptsDidCompete(with: error, message: nil) - } - } - } - } - } - - private func xcodeVersionCheck() { - shellManager.shell(.xcodeVersion) { - switch $0 { - case .success(let success): - debugPrint(success) - case .failure(let failure): - debugPrint(failure) - } - } - } - -} diff --git a/FrameGen/Flows/Options/Presenter/OptionsPresenter.swift b/FrameGen/Flows/Options/Presenter/OptionsPresenter.swift deleted file mode 100644 index 5e9a6a3..0000000 --- a/FrameGen/Flows/Options/Presenter/OptionsPresenter.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// OptionsPresenter.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 22.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Foundation - -protocol OptionsPresenterOutputProtocol: AnyObject { - func makeDataFromProject() - func cancelAllScripts() - func runScripts(_ scripts: [Script]) - func setScheme(_ scheme: String) - func setConfiguration(_ configuration: String) -} - -protocol OptionsPresenterInputProtocol { - var view: OptionsViewInputProtocol? { get } - var shellManager: ShellManagerProtocol { get } - var settingsManager: SettingsManagerProtocol { get } - var path: String { get } - var command: Command { get } -} - -final class OptionsPresenter: OptionsPresenterInputProtocol { - - var view: OptionsViewInputProtocol? - let shellManager: ShellManagerProtocol - let settingsManager: SettingsManagerProtocol - let path: String - private(set) var command = Command() - - required init(shellManager: ShellManagerProtocol, settingsManager: SettingsManagerProtocol, path: String) { - self.shellManager = shellManager - self.settingsManager = settingsManager - self.path = path - } - -} diff --git a/FrameGen/Flows/Options/View/CheckboxButton.swift b/FrameGen/Flows/Options/View/CheckboxButton.swift deleted file mode 100644 index cd55728..0000000 --- a/FrameGen/Flows/Options/View/CheckboxButton.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// CheckboxButton.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 22.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -final class CheckboxButton: NSButton { - - // MARK: - Properties - - enum CheckboxType: Int { - case iPhoneOS = 0 - case simulator = 1 - case macCatalyst = 2 - - var title: String { - switch self { - case .iPhoneOS: - return "iOS" - case .simulator: - return "Simulator" - case .macCatalyst: - return "Mac catalyst" - } - } - - var scriptType: Script { - switch self { - case .iPhoneOS: - return .iPhoneOS - case .simulator: - return .simulatorOS - case .macCatalyst: - return .macCatalyst - } - } - } - let type: CheckboxType - - // MARK: - Initial methods - - required init(type: CheckboxType, target: Any?, action: Selector?) { - self.type = type - super.init(frame: .zero) - - setButtonType(.switch) - title = type.title - self.target = target as AnyObject? - self.action = action - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - -} diff --git a/FrameGen/Flows/Options/View/Extensions/OptionsConfigurationView+NSComboBoxDelegate.swift b/FrameGen/Flows/Options/View/Extensions/OptionsConfigurationView+NSComboBoxDelegate.swift deleted file mode 100644 index 2330c5e..0000000 --- a/FrameGen/Flows/Options/View/Extensions/OptionsConfigurationView+NSComboBoxDelegate.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// OptionsConfigurationView+NSComboBoxDelegate.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 23.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -extension OptionsConfigurationView: NSComboBoxDelegate { - - func comboBoxSelectionDidChange(_ notification: Notification) { - guard let box = notification.object as? NSComboBox else { return } - - completionHandler?(box.indexOfSelectedItem) - } - -} diff --git a/FrameGen/Flows/Options/View/Extensions/OptionsSchemeView+NSComboBoxDelegate.swift b/FrameGen/Flows/Options/View/Extensions/OptionsSchemeView+NSComboBoxDelegate.swift deleted file mode 100644 index 39f5a07..0000000 --- a/FrameGen/Flows/Options/View/Extensions/OptionsSchemeView+NSComboBoxDelegate.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// OptionsSchemeView+NSComboBoxDelegate.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 23.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -extension OptionsSchemeView: NSComboBoxDelegate { - - func comboBoxSelectionDidChange(_ notification: Notification) { - guard let box = notification.object as? NSComboBox else { return } - - completionHandler?(box.indexOfSelectedItem) - } - -} diff --git a/FrameGen/Flows/Options/View/OptionsConfigurationView.swift b/FrameGen/Flows/Options/View/OptionsConfigurationView.swift deleted file mode 100644 index aaf5d71..0000000 --- a/FrameGen/Flows/Options/View/OptionsConfigurationView.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// OptionsConfigurationView.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 23.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -final class OptionsConfigurationView: NSView { - - // MARK: - Properties - - private enum Constants { - static let offset: CGFloat = 8 - } - - var completionHandler: ((Int) -> Void)? - - private let configurationLabel: NSTextField = { - let label = NSTextField(labelWithString: "Choose a configuration:") - label.translatesAutoresizingMaskIntoConstraints = false - - return label - }() - private lazy var configurationCombobox: NSComboBox = { - let box = NSComboBox() - box.isEditable = false - box.isEnabled = true - box.delegate = self - box.translatesAutoresizingMaskIntoConstraints = false - - return box - }() - - // MARK: - Initial methods - - required init() { - super.init(frame: .zero) - - configureView() - configureConstraints() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Public methods - - func insertConfigurations(_ configurations: [String]) { - configurationCombobox.isEnabled = !configurations.isEmpty - if !configurations.isEmpty { - configurationCombobox.addItems(withObjectValues: configurations) - configurationCombobox.selectItem(at: .zero) - configurationCombobox.reloadData() - completionHandler?(.zero) - } - } - - // MARK: - Private methods - - private func configureView() { - addSubview(configurationLabel) - addSubview(configurationCombobox) - } - - private func configureConstraints() { - let constraints = [configurationLabel.topAnchor.constraint(equalTo: topAnchor), - configurationLabel.leadingAnchor.constraint(equalTo: leadingAnchor), - bottomAnchor.constraint(equalTo: configurationLabel.bottomAnchor), - configurationCombobox.centerYAnchor.constraint(equalTo: configurationLabel.centerYAnchor), - configurationCombobox.leftAnchor.constraint(equalTo: configurationLabel.rightAnchor, - constant: Constants.offset), - rightAnchor.constraint(equalTo: configurationCombobox.rightAnchor)] - NSLayoutConstraint.activate(constraints) - } - -} diff --git a/FrameGen/Flows/Options/View/OptionsContentView.swift b/FrameGen/Flows/Options/View/OptionsContentView.swift deleted file mode 100644 index 4669e02..0000000 --- a/FrameGen/Flows/Options/View/OptionsContentView.swift +++ /dev/null @@ -1,218 +0,0 @@ -// -// OptionsContentView.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 22.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -protocol OptionsContentDelegate: AnyObject { - func dismissView() - func runScripts(_ scripts: [Script]) - func didSelectIndex(box: OptionsContentView.ComboboxType, at index: Int) -} - -final class OptionsContentView: NSView { - - // MARK: - Properties - - private weak var delegate: OptionsContentDelegate? - - enum ComboboxType { - case scheme, configuration - } - - private enum Constants { - static let offset: CGFloat = 32 - } - - private let schemeView = OptionsSchemeView() - private let configurationView = OptionsConfigurationView() - - private let checkboxStackView: NSStackView = { - let stack = NSStackView() - stack.orientation = .horizontal - stack.spacing = .zero - stack.alignment = .centerY - stack.distribution = .fillEqually - stack.translatesAutoresizingMaskIntoConstraints = false - - return stack - }() - - private let activityIndicator: NSProgressIndicator = { - let indicator = NSProgressIndicator() - indicator.isHidden = true - indicator.isIndeterminate = true - indicator.isDisplayedWhenStopped = true - indicator.style = .spinning - indicator.minValue = .zero - indicator.maxValue = 100 - indicator.translatesAutoresizingMaskIntoConstraints = false - - return indicator - }() - - private let buttonsStackView: NSStackView = { - let stack = NSStackView() - stack.orientation = .horizontal - stack.spacing = Constants.offset - stack.alignment = .centerY - stack.distribution = .fillEqually - stack.translatesAutoresizingMaskIntoConstraints = false - - return stack - }() - - private lazy var cancelButton: NSButton = { - let button = NSButton(title: "Cancel", - target: self, - action: #selector(didTapCancel(_ :))) - - return button - }() - private lazy var generateButton: NSButton = { - let button = NSButton(title: "Generate", - target: self, - action: #selector(didTapGenerate(_ :))) - button.bezelColor = .systemBlue - button.isEnabled = false - - return button - }() - - private var includeScripts: [Script] = [] { - didSet { - generateButton.isEnabled = !includeScripts.isEmpty - } - } - - // MARK: - Initial methods - - required init(delegate: OptionsContentDelegate?) { - self.delegate = delegate - super.init(frame: .zero) - - configureView() - configureCheckboxes() - configureConstraints() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Public methods - - func insertItems(_ data: (configurations: [String], schemes: [String])) { - schemeView.completionHandler = { [weak self] in - self?.delegate?.didSelectIndex(box: .scheme, at: $0) - } - configurationView.completionHandler = { [weak self] in - self?.delegate?.didSelectIndex(box: .configuration, at: $0) - } - schemeView.insertSchemes(data.schemes) - configurationView.insertConfigurations(data.configurations) - } - - func stopIndicators() { - activityIndicator.stopAnimation(self) - activityIndicator.isHidden = true - generateButton.isEnabled = true - } - - // MARK: - Private methods - - private func configureView() { - schemeView.translatesAutoresizingMaskIntoConstraints = false - configurationView.translatesAutoresizingMaskIntoConstraints = false - addSubview(schemeView) - addSubview(configurationView) - addSubview(activityIndicator) - addSubview(checkboxStackView) - addSubview(buttonsStackView) - buttonsStackView.addArrangedSubview(cancelButton) - buttonsStackView.addArrangedSubview(generateButton) - } - - private func configureConstraints() { - let constraints = [schemeView.topAnchor.constraint(equalTo: topAnchor, - constant: Constants.offset), - schemeView.leftAnchor.constraint(greaterThanOrEqualTo: leftAnchor, - constant: Constants.offset), - schemeView.centerXAnchor.constraint(equalTo: centerXAnchor), - rightAnchor.constraint(greaterThanOrEqualTo: schemeView.rightAnchor, - constant: Constants.offset), - configurationView.topAnchor.constraint(equalTo: schemeView.bottomAnchor, - constant: Constants.offset / 2), - configurationView.leftAnchor.constraint(equalTo: schemeView.leftAnchor), - configurationView.centerXAnchor.constraint(equalTo: centerXAnchor), - schemeView.rightAnchor.constraint(equalTo: configurationView.rightAnchor), - activityIndicator.centerXAnchor.constraint(equalTo: centerXAnchor), - activityIndicator.centerYAnchor.constraint(equalTo: centerYAnchor), - checkboxStackView.leftAnchor.constraint(greaterThanOrEqualTo: leftAnchor, - constant: Constants.offset), - checkboxStackView.centerXAnchor.constraint(equalTo: centerXAnchor), - rightAnchor.constraint(greaterThanOrEqualTo: checkboxStackView.rightAnchor, - constant: Constants.offset), - buttonsStackView.topAnchor.constraint(equalTo: checkboxStackView.bottomAnchor, - constant: Constants.offset), - buttonsStackView.centerXAnchor.constraint(equalTo: centerXAnchor), - buttonsStackView.leftAnchor.constraint(greaterThanOrEqualTo: leftAnchor, - constant: Constants.offset), - rightAnchor.constraint(greaterThanOrEqualTo: buttonsStackView.rightAnchor, - constant: Constants.offset), - bottomAnchor.constraint(equalTo: buttonsStackView.bottomAnchor, - constant: Constants.offset)] - NSLayoutConstraint.activate(constraints) - } - - private func configureCheckboxes() { - for index in 0..<3 { - guard let type = CheckboxButton.CheckboxType(rawValue: index) else { return } - - let checkbox = CheckboxButton(type: type, - target: self, - action: #selector(didTapCheckbox(_ :))) - checkboxStackView.addArrangedSubview(checkbox) - } - } - - private func configureScripts(_ sender: CheckboxButton) { - let scriptType = sender.type.scriptType - switch sender.state { - case .on: - if !includeScripts.contains(scriptType) { - includeScripts.append(scriptType) - } - default: - if includeScripts.contains(scriptType) { - includeScripts.removeAll(where: { $0 == scriptType }) - } - } - } - - // MARK: - Actions - - @objc - private func didTapCancel(_ sender: NSButton) { - delegate?.dismissView() - } - - @objc - private func didTapGenerate(_ sender: NSButton) { - sender.isEnabled = false - activityIndicator.isHidden = false - activityIndicator.startAnimation(self) - delegate?.runScripts(includeScripts) - } - - @objc - private func didTapCheckbox(_ sender: CheckboxButton) { - configureScripts(sender) - } - -} diff --git a/FrameGen/Flows/Options/View/OptionsSchemeView.swift b/FrameGen/Flows/Options/View/OptionsSchemeView.swift deleted file mode 100644 index a945725..0000000 --- a/FrameGen/Flows/Options/View/OptionsSchemeView.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// OptionsSchemeView.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 23.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -final class OptionsSchemeView: NSView { - - // MARK: - Properties - - private enum Constants { - static let offset: CGFloat = 8 - } - - var completionHandler: ((Int) -> Void)? - - private let schemeLabel: NSTextField = { - let label = NSTextField(labelWithString: "Choose a scheme:") - label.translatesAutoresizingMaskIntoConstraints = false - - return label - }() - private lazy var schemeCombobox: NSComboBox = { - let box = NSComboBox() - box.isEditable = false - box.isEnabled = true - box.delegate = self - box.translatesAutoresizingMaskIntoConstraints = false - - return box - }() - - // MARK: - Initial methods - - required init() { - super.init(frame: .zero) - - configureView() - configureConstraints() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Public methods - - func insertSchemes(_ schemes: [String]) { - schemeCombobox.isEnabled = !schemes.isEmpty - if !schemes.isEmpty { - schemeCombobox.addItems(withObjectValues: schemes) - schemeCombobox.selectItem(at: .zero) - schemeCombobox.reloadData() - completionHandler?(.zero) - } - } - - // MARK: - Private methods - - private func configureView() { - addSubview(schemeLabel) - addSubview(schemeCombobox) - } - - private func configureConstraints() { - let constraints = [schemeLabel.topAnchor.constraint(equalTo: topAnchor), - schemeLabel.leadingAnchor.constraint(equalTo: leadingAnchor), - bottomAnchor.constraint(equalTo: schemeLabel.bottomAnchor), - schemeCombobox.centerYAnchor.constraint(equalTo: schemeLabel.centerYAnchor), - schemeCombobox.leftAnchor.constraint(equalTo: schemeLabel.rightAnchor, - constant: Constants.offset), - rightAnchor.constraint(equalTo: schemeCombobox.rightAnchor)] - NSLayoutConstraint.activate(constraints) - } - -} diff --git a/FrameGen/Flows/Settings/Controller/Extensions/SettingsViewController+SettingsContentViewProtocol.swift b/FrameGen/Flows/Settings/Controller/Extensions/SettingsViewController+SettingsContentViewProtocol.swift deleted file mode 100644 index 4cb091f..0000000 --- a/FrameGen/Flows/Settings/Controller/Extensions/SettingsViewController+SettingsContentViewProtocol.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// SettingsViewController+SettingsContentViewProtocol.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 27.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -extension SettingsViewController: SettingsContentViewProtocol { - - func dismissView() { - dismiss(self) - } - - func fileSelected(_ path: String) { - presenter.fileSelected(path) - } - -} diff --git a/FrameGen/Flows/Settings/Controller/SettingsViewController.swift b/FrameGen/Flows/Settings/Controller/SettingsViewController.swift deleted file mode 100644 index 8455e5d..0000000 --- a/FrameGen/Flows/Settings/Controller/SettingsViewController.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// SettingsViewController.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 26.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -protocol SettingsViewInputProtocol: AnyObject { - - var presenter: SettingsPresenterOutputProtocol { get } - -} - -final class SettingsViewController: NSViewController, SettingsViewInputProtocol { - - // MARK: - Properties - - let presenter: SettingsPresenterOutputProtocol - - private let contentView = SettingsContentView() - - // MARK: - Initial methods - - required init(presenter: SettingsPresenterOutputProtocol) { - self.presenter = presenter - super.init(nibName: nil, bundle: .main) - } - - @available(*, unavailable) - init() { - fatalError("init has not been implemented") - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Life cycle - - override func loadView() { - guard let presentingView = presentingViewController?.view else { - super.loadView() - - return - } - - contentView.frame = NSRect(x: .zero, y: .zero, - width: presentingView.frame.width * 1.6, - height: presentingView.frame.height * 0.4) - view = contentView - } - - override func viewDidLoad() { - super.viewDidLoad() - - configureController() - } - - // MARK: - Private methods - - private func configureController() { - contentView.delegate = self - } - -} diff --git a/FrameGen/Flows/Settings/Presenter/Extensions/SettingsPresenter+SettingsPresenterOutputProtocol.swift b/FrameGen/Flows/Settings/Presenter/Extensions/SettingsPresenter+SettingsPresenterOutputProtocol.swift deleted file mode 100644 index 2e1ad30..0000000 --- a/FrameGen/Flows/Settings/Presenter/Extensions/SettingsPresenter+SettingsPresenterOutputProtocol.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// SettingsPresenter+SettingsPresenterOutputProtocol.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 26.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Foundation - -extension SettingsPresenter: SettingsPresenterOutputProtocol { - - func fileSelected(_ path: String) { - settingsManager.setNewPath(path) - } - -} diff --git a/FrameGen/Flows/Settings/Presenter/SettingsPresenter.swift b/FrameGen/Flows/Settings/Presenter/SettingsPresenter.swift deleted file mode 100644 index 4d4e79f..0000000 --- a/FrameGen/Flows/Settings/Presenter/SettingsPresenter.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// SettingsPresenter.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 26.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Foundation - -protocol SettingsPresenterOutputProtocol: AnyObject { - - func fileSelected(_ path: String) - -} - -protocol SettingsPresenterInputProtocol { - var view: SettingsViewInputProtocol? { get } - var settingsManager: SettingsManagerProtocol { get } -} - -final class SettingsPresenter: SettingsPresenterInputProtocol { - - var view: SettingsViewInputProtocol? - let settingsManager: SettingsManagerProtocol - - required init(settingsManager: SettingsManagerProtocol) { - self.settingsManager = settingsManager - } - -} diff --git a/FrameGen/Flows/Settings/View/Extensions/SettingsContentView+NSComboBoxDelegate.swift b/FrameGen/Flows/Settings/View/Extensions/SettingsContentView+NSComboBoxDelegate.swift deleted file mode 100644 index 15e739c..0000000 --- a/FrameGen/Flows/Settings/View/Extensions/SettingsContentView+NSComboBoxDelegate.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// SettingsContentView+NSComboBoxDelegate.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 26.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -extension SettingsContentView: NSComboBoxDelegate { - - func comboBoxSelectionDidChange(_ notification: Notification) { - guard - let box = notification.object as? NSComboBox, - let locationType = LocationType(rawValue: box.indexOfSelectedItem) - else { - return - } - - self.locationType = locationType - } - -} diff --git a/FrameGen/Flows/Settings/View/Extensions/SettingsContentView+NSOpenSavePanelDelegate.swift b/FrameGen/Flows/Settings/View/Extensions/SettingsContentView+NSOpenSavePanelDelegate.swift deleted file mode 100644 index 8048856..0000000 --- a/FrameGen/Flows/Settings/View/Extensions/SettingsContentView+NSOpenSavePanelDelegate.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// SettingsContentView+NSOpenSavePanelDelegate.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 26.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -extension SettingsContentView: NSOpenSavePanelDelegate { - - func panel(_ sender: Any, shouldEnable url: URL) -> Bool { - guard let xcodeProjectDirectoryURL = url.xcodeProjectDirectory else { - return false - } - - return true - } - -} diff --git a/FrameGen/Flows/Settings/View/Extensions/SettingsContentView+PathViewProtocol.swift b/FrameGen/Flows/Settings/View/Extensions/SettingsContentView+PathViewProtocol.swift deleted file mode 100644 index 8930bb7..0000000 --- a/FrameGen/Flows/Settings/View/Extensions/SettingsContentView+PathViewProtocol.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// SettingsContentView+PathViewProtocol.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 12.05.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -extension SettingsContentView: PathViewProtocol { - - func didTapChooseFolder() { - Task { - let response = await openPanel.begin() - switch response { - case .OK: - guard let url = openPanel.url else { return } - - delegate?.fileSelected(url.path()) - pathView.updateState(.custom) - default: break - } - } - } - -} diff --git a/FrameGen/Flows/Settings/View/PathView.swift b/FrameGen/Flows/Settings/View/PathView.swift deleted file mode 100644 index 0b66c8f..0000000 --- a/FrameGen/Flows/Settings/View/PathView.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// PathView.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 26.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa - -protocol PathViewProtocol: AnyObject { - func didTapChooseFolder() -} - -final class PathView: NSView { - - // MARK: - Properties - - enum PathStateType { - case `default` - case custom - - var isEditable: Bool { - switch self { - case .default: return false - case .custom: return true - } - } - - var isHiddenFolderButton: Bool { - switch self { - case .default: return true - case .custom: return false - } - } - } - - weak var delegate: PathViewProtocol? - - private var state: PathStateType = .default { - didSet { - updateContent() - } - } - - private let textField: NSTextField = { - let textField = NSTextField() - textField.isEditable = false - textField.translatesAutoresizingMaskIntoConstraints = false - - return textField - }() - private lazy var folderButton: NSButton = { - let image = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil) - let button = NSButton(image: image!, - target: self, - action: #selector(didTapFolder(_: ))) - button.contentTintColor = .systemBlue - button.imageScaling = .scaleNone - button.wantsLayer = true - button.layer?.backgroundColor = NSColor.clear.cgColor - button.isBordered = false - button.isHidden = true - button.translatesAutoresizingMaskIntoConstraints = false - - return button - }() - - // MARK: - Initial methods - - required init() { - super.init(frame: .zero) - - configureView() - configureLayout() - updateContent() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Public methods - - func updateState(_ newState: PathStateType) { - state = newState - } - - // MARK: - Private methods - - private func configureView() { - addSubview(textField) - addSubview(folderButton) - } - - private func configureLayout() { - let constraints = [textField.topAnchor.constraint(equalTo: topAnchor), - textField.leadingAnchor.constraint(equalTo: leadingAnchor), - bottomAnchor.constraint(equalTo: textField.bottomAnchor), - folderButton.topAnchor.constraint(equalTo: topAnchor), - folderButton.leadingAnchor.constraint(equalTo: textField.trailingAnchor), - folderButton.widthAnchor.constraint(equalToConstant: 40), - trailingAnchor.constraint(equalTo: folderButton.trailingAnchor), - bottomAnchor.constraint(equalTo: folderButton.bottomAnchor)] - NSLayoutConstraint.activate(constraints) - } - - private func updateContent() { - textField.isEditable = state.isEditable - folderButton.isHidden = state.isHiddenFolderButton - textField.stringValue = configurePath() - } - - private func configurePath() -> String { - let manager = SettingsManager.shared - let defaultFolder = manager.getDefaultFolder() - let folder = manager.getFolder() - - switch state { - case .default: - return getDefaultPath() - case .custom: - if folder.path != defaultFolder.path { - return folder.path - } else { - return getDefaultPath() - } - } - } - - private func getDefaultPath() -> String { - let manager = SettingsManager.shared - let defaultFolder = manager.getDefaultFolder() - - return "${PROJECT_DIR}/\(defaultFolder.path)/" - } - - // MARK: - Actions - - @objc - private func didTapFolder(_ sender: NSButton) { - delegate?.didTapChooseFolder() - } - -} diff --git a/FrameGen/Flows/Settings/View/SettingsContentView.swift b/FrameGen/Flows/Settings/View/SettingsContentView.swift deleted file mode 100644 index ae1506d..0000000 --- a/FrameGen/Flows/Settings/View/SettingsContentView.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// SettingsContentView.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 26.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Cocoa -import QuartzCore - -protocol SettingsContentViewProtocol: AnyObject { - - func dismissView() - func fileSelected(_ path: String) - -} - -final class SettingsContentView: NSView { - - // MARK: - Properties - - weak var delegate: SettingsContentViewProtocol? - - private enum Constants { - static let offset: CGFloat = 32 - - enum Checkbox { - static let width: CGFloat = 75 - } - } - - enum LocationType: Int, CaseIterable { - case `default` - case custom - - var title: String { - switch self { - case .default: - return "Default" - case .custom: - return "Custom" - } - } - } - - var locationType: LocationType = .default { - didSet { - pathView.updateState(locationType == .default ? .default : .custom) - } - } - - private let titleLabel: NSTextField = { - let label = NSTextField(labelWithString: "Locations") - label.alignment = .center - label.translatesAutoresizingMaskIntoConstraints = false - - return label - }() - private let archivesLabel: NSTextField = { - let label = NSTextField(labelWithString: "Archives:") - label.translatesAutoresizingMaskIntoConstraints = false - - return label - }() - private lazy var typeCombobox: NSComboBox = { - let box = NSComboBox() - box.isEditable = false - box.addItems(withObjectValues: LocationType.allCases.compactMap({ $0.title })) - box.selectItem(at: .zero) - box.isEnabled = true - box.delegate = self - box.shadow = NSShadow() - box.translatesAutoresizingMaskIntoConstraints = false - - return box - }() - private(set) lazy var openPanel: NSOpenPanel = { - let panel = NSOpenPanel(contentRect: frame, styleMask: .closable, backing: .buffered, defer: true) - panel.worksWhenModal = true - panel.title = "Choose a new location" - panel.showsResizeIndicator = true - panel.canChooseDirectories = true - panel.canChooseFiles = false - panel.allowsMultipleSelection = false - - return panel - }() - private lazy var okButton: NSButton = { - let button = NSButton(title: "OK", - target: self, - action: #selector(didTapOK(_ :))) - button.bezelColor = .systemBlue - button.translatesAutoresizingMaskIntoConstraints = false - - return button - }() - private(set) lazy var pathView: PathView = { - let view = PathView() - view.delegate = self - view.wantsLayer = true - view.translatesAutoresizingMaskIntoConstraints = false - - return view - }() - - // MARK: - Initial methods - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - - configureView() - configureConstraints() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Private methods - - private func configureView() { - openPanel.delegate = self - addSubview(titleLabel) - addSubview(archivesLabel) - addSubview(typeCombobox) - addSubview(pathView) - addSubview(okButton) - } - - private func configureConstraints() { - let constraints = [titleLabel.topAnchor.constraint(equalTo: topAnchor, - constant: Constants.offset / 4), - titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, - constant: Constants.offset), - trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor, - constant: Constants.offset), - archivesLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, - constant: Constants.offset / 2), - archivesLabel.leadingAnchor.constraint(equalTo: leadingAnchor, - constant: Constants.offset), - typeCombobox.centerYAnchor.constraint(equalTo: archivesLabel.centerYAnchor), - typeCombobox.widthAnchor.constraint(equalToConstant: Constants.Checkbox.width), - typeCombobox.leftAnchor.constraint(equalTo: archivesLabel.rightAnchor, - constant: Constants.offset / 4), - pathView.topAnchor.constraint(equalTo: typeCombobox.topAnchor), - pathView.centerYAnchor.constraint(equalTo: typeCombobox.centerYAnchor), - pathView.leftAnchor.constraint(equalTo: typeCombobox.rightAnchor, - constant: Constants.offset / 4), - rightAnchor.constraint(equalTo: pathView.rightAnchor, - constant: Constants.offset), - pathView.bottomAnchor.constraint(equalTo: typeCombobox.bottomAnchor), - okButton.topAnchor.constraint(equalTo: archivesLabel.bottomAnchor, - constant: Constants.offset / 2), - okButton.centerXAnchor.constraint(equalTo: centerXAnchor), - bottomAnchor.constraint(equalTo: okButton.bottomAnchor, - constant: Constants.offset / 2)] - NSLayoutConstraint.activate(constraints) - } - - // MARK: - Actions - - @objc - private func didTapOK(_ sender: NSButton) { - delegate?.dismissView() - } - -} diff --git a/FrameGen/Managers/DefaultSettings/Model/Folder.swift b/FrameGen/Managers/DefaultSettings/Model/Folder.swift deleted file mode 100644 index 2b41165..0000000 --- a/FrameGen/Managers/DefaultSettings/Model/Folder.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Folder.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 07.05.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Foundation - -final class Folder { - - // MARK: - Properties - - enum FolderType { - case `default`(String) - case custom(String) - } - - let path: String - let pathForProcess: String - - // MARK: - Initial methods - - init(type: FolderType) { - switch type { - case .default(let path): - self.path = path - pathForProcess = "./\(path)/" - case .custom(let path): - self.path = path - pathForProcess = path - } - } - -} diff --git a/FrameGen/Managers/DefaultSettings/SettingsManager.swift b/FrameGen/Managers/DefaultSettings/SettingsManager.swift deleted file mode 100644 index 7e6f214..0000000 --- a/FrameGen/Managers/DefaultSettings/SettingsManager.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// SettingsManager.swift -// FrameGen -// -// Created by Рамиз Кичибеков on 07.05.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Foundation - -protocol SettingsManagerProtocol: AnyObject { - func getDefaultFolder() -> Folder - func getFolder() -> Folder - func setNewPath(_ path: String) -} - -final class SettingsManager: SettingsManagerProtocol { - - // MARK: - Properties - - static let shared = SettingsManager() - private var currentFolder: Folder? - private let defaultFolder = Folder(type: .default("build")) - - // MARK: - Initial methods - - private init() { - - } - - // MARK: - Public methods - - func getDefaultFolder() -> Folder { - return defaultFolder - } - - func getFolder() -> Folder { - return currentFolder ?? defaultFolder - } - - func setNewPath(_ path: String) { - currentFolder = Folder(type: .custom(path)) - } - -} diff --git a/FrameGen/Managers/Helpers/Error/TaskError.swift b/FrameGen/Managers/Helpers/Error/TaskError.swift deleted file mode 100644 index 901fa0b..0000000 --- a/FrameGen/Managers/Helpers/Error/TaskError.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// TaskError.swift -// generateFramework -// -// Created by Рамиз Кичибеков on 21.04.2023. -// - -import Foundation - -enum TaskError: Error { - case taskFailure(code: Int, message: String) - case parsingError(data: Data) -} - -extension TaskError: LocalizedError { - - var errorDescription: String? { - switch self { - case let .taskFailure(code, message): - return "Code = \(code). Message = \(message)" - case let .parsingError(data): - let output = String(data: data, encoding: .utf8) - - return output - } - } -} diff --git a/FrameGen/Managers/ShellManager.swift b/FrameGen/Managers/ShellManager.swift deleted file mode 100644 index cbb8dc7..0000000 --- a/FrameGen/Managers/ShellManager.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// ShellManager.swift -// generateFramework -// -// Created by Рамиз Кичибеков on 21.04.2023. -// - -import Foundation - -protocol ShellManagerProtocol: AnyObject { - - func shell(_ commandType: ShellManager.CommandType, completionHandler: @escaping (Result) -> Void) - func cancelAllScripts() - -} - -final class ShellManager: ShellManagerProtocol { - - // MARK: - Properties - - enum CommandType { - case xcodeVersion - case getSchemesFromProject(String) - case generateFramework(path: String, folder: String, command: Command, script: Script) - case custom(String) - - var command: String { - switch self { - case .xcodeVersion: - return "/usr/bin/xcodebuild -version" - case .getSchemesFromProject(let path): - return "cd \(path); xcodebuild -list" - case let .generateFramework(path, folder, command, script): - return """ - cd \(path); - xcodebuild archive \ - -scheme '\(command.scheme)' \ - -configuration \(command.configuration) \ - -destination '\(script.destination)' \ - -archivePath '\(folder)\(command.scheme)\(script.archiveName)' \ - SKIP_INSTALL=NO \ - BUILD_LIBRARIES_FOR_DISTRIBUTION=YES - """ - case .custom(let command): - return command - } - } - } - - enum ResultType { - case output(message: String, log: String) - } - - private let queue = OperationQueue() - private var process: Process? - - // MARK: - Initial methods - - required init() { - queue.name = "Shell Queue" - queue.qualityOfService = .default - queue.maxConcurrentOperationCount = 1 - } - - // MARK: - Public methods - - func cancelAllScripts() { - queue.cancelAllOperations() - if process?.isRunning == true { - process?.terminate() - process = nil - } - } - - func shell(_ commandType: CommandType, completionHandler: @escaping (Result) -> Void) { - queue.addBarrierBlock { [weak self] in - guard let self else { return } - - let task = Process() - self.process = task - task.launchPath = "/bin/bash" - task.arguments = ["-c", commandType.command] - - let outputPipe = Pipe() - task.standardOutput = outputPipe - let errorPipe = Pipe() - task.standardError = errorPipe - - do { - try task.run() - } catch { - completionHandler(.failure(error)) - } - - let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() - guard let output = String(data: outputData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else { - completionHandler(.failure(TaskError.parsingError(data: outputData))) - - return - } - - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - guard let errorOutput = String(data: errorData, encoding: .utf8) else { - completionHandler(.failure(TaskError.parsingError(data: errorData))) - - return - } - - task.waitUntilExit() - let status = Int(task.terminationStatus) - - switch task.terminationReason { - case .uncaughtSignal: - completionHandler(.success(.output(message: "The operation was canceled.", - log: output))) - default: - if status == .zero { - let message: String - switch commandType { - case .xcodeVersion, .getSchemesFromProject, .custom: - message = "" - case .generateFramework: - message = "Framework generation completed successfully." - } - completionHandler(.success(.output(message: message, log: output))) - } else { - completionHandler(.failure(TaskError.taskFailure(code: status, message: errorOutput))) - } - } - } - } - -} diff --git a/FrameGen/Models/BuildState.swift b/FrameGen/Models/BuildState.swift new file mode 100644 index 0000000..471cccd --- /dev/null +++ b/FrameGen/Models/BuildState.swift @@ -0,0 +1,142 @@ +// +// BuildState.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import Foundation +import Observation + +struct BuildStep: Identifiable { + + enum Kind: Equatable { + case archive(Platform) + case createXCFramework + case package + } + + enum Status: Equatable { + case pending + case running(since: Date) + case succeeded(duration: TimeInterval) + case failed + } + + let kind: Kind + var status: Status = .pending + + var id: String { title } + + var title: String { + switch kind { + case .archive(let platform): + return "Archive for \(platform.title)" + case .createXCFramework: + return "Create XCFramework" + case .package: + return "Zip and compute checksum" + } + } + + var failureTitle: String { + switch kind { + case .archive(let platform): + return "Archiving for \(platform.title) failed" + case .createXCFramework: + return "Creating the XCFramework failed" + case .package: + return "Packaging the XCFramework failed" + } + } +} + +struct BuildFailure { + let title: String + let reason: String + /// The `error:` lines of the log, or its tail when the tool printed none. + let highlights: [String] +} + +/// The progress and outcome of one XCFramework build. +@Observable +final class BuildState { + + enum Phase { + case idle + case running + case succeeded(XCFrameworkBuilder.Summary) + case failed(BuildFailure) + } + + // MARK: - Properties + + var phase: Phase = .idle + var steps: [BuildStep] = [] + /// The most recent xcodebuild action, e.g. "SwiftCompile normal arm64 Compiling Kit.swift". + var activity = "" + private(set) var log: [String] = [] + private(set) var startDate: Date? + private(set) var endDate: Date? + + var isRunning: Bool { + if case .running = phase { return true } + + return false + } + + var duration: TimeInterval { + guard let startDate else { return .zero } + + return (endDate ?? .now).timeIntervalSince(startDate) + } + + // MARK: - Public methods + + func start(steps: [BuildStep]) { + self.steps = steps + log = [] + activity = "Preparing…" + startDate = .now + endDate = nil + phase = .running + } + + func finish(_ phase: Phase) { + endDate = .now + activity = "" + self.phase = phase + } + + func reset() { + steps = [] + log = [] + activity = "" + startDate = nil + endDate = nil + phase = .idle + } + + func appendLog(_ line: String) { + log.append(line) + if let action = Self.action(from: line) { + activity = action + } + } + + // MARK: - Private methods + + /// xcodebuild prints every build action as `Action arguments (in target 'X' from project 'Y')`. + private static func action(from line: String) -> String? { + guard + let first = line.first, first.isUppercase, + let range = line.range(of: " (in target ") + else { + return nil + } + + return String(line[.. { + didSet { save() } + } + /// `nil` means the default location from Settings. + var customOutputDirectory: URL? { + didSet { save() } + } + + var name: String { + fileURL.deletingPathExtension().lastPathComponent + } + + var outputDirectory: URL { + customOutputDirectory ?? Preferences.defaultOutputDirectory(for: fileURL) + } + + var selectedPlatforms: [Platform] { + Platform.allCases.filter(platforms.contains) + } + + var canBuild: Bool { + infoState == .loaded && scheme != nil && configuration != nil && !platforms.isEmpty && !build.isRunning + } + + // MARK: - Initial methods + + init(fileURL: URL) { + self.fileURL = fileURL + let settings = Preferences.settings(for: fileURL) + scheme = settings.scheme + configuration = settings.configuration + platforms = Set(settings.platforms) + customOutputDirectory = settings.outputPath.map { URL(fileURLWithPath: $0, isDirectory: true) } + } + + // MARK: - Public methods + + func loadInfo() async { + infoState = .loading + do { + let info = try await ProjectInfo.load(project: fileURL) + 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) + } catch { + infoState = .failed(error.localizedDescription) + } + } + + // MARK: - Private methods + + private func save() { + let settings = ProjectSettings(scheme: scheme, + configuration: configuration, + platforms: selectedPlatforms, + outputPath: customOutputDirectory?.path) + Preferences.setSettings(settings, for: fileURL) + } + +} diff --git a/FrameGen/Models/ProjectInfo.swift b/FrameGen/Models/ProjectInfo.swift new file mode 100644 index 0000000..c53aa54 --- /dev/null +++ b/FrameGen/Models/ProjectInfo.swift @@ -0,0 +1,39 @@ +// +// 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 new file mode 100644 index 0000000..4f0f976 --- /dev/null +++ b/FrameGen/Models/Workspace.swift @@ -0,0 +1,67 @@ +// +// Workspace.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +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." + } + } + + var recoverySuggestion: String? { + "Choose an .xcodeproj file 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 { + didSet { Preferences.setRecentProjects(recentProjects) } + } + + // MARK: - Public methods + + func open(_ url: URL) throws { + guard let fileURL = ProjectLocator.projectFile(for: url) else { + throw WorkspaceError.projectNotFound(url) + } + + recentProjects = [fileURL] + recentProjects.filter { $0 != fileURL } + guard project?.fileURL != fileURL else { return } + + let project = Project(fileURL: fileURL) + self.project = project + Task { + await project.loadInfo() + } + } + + func closeProject() { + project = nil + } + + func removeRecentProject(_ url: URL) { + recentProjects.removeAll { $0 == url } + } + + func clearRecentProjects() { + recentProjects = [] + } + +} diff --git a/FrameGen/Resources/Color.swift b/FrameGen/Resources/Color.swift deleted file mode 100644 index 4e2b534..0000000 --- a/FrameGen/Resources/Color.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Color.swift -// generateFramework -// -// Created by Рамиз Кичибеков on 21.04.2023. -// - -import Cocoa - -enum Color { - - static let backgroundColor = NSColor(red: 202 / 255, - green: 240 / 255, - blue: 248 / 255, - alpha: 1) - static let iconTintColor = NSColor(red: 144 / 255, - green: 224 / 255, - blue: 239 / 255, alpha: 1) - static let dropLayerColor = NSColor(red: .zero, green: 180 / 255, - blue: 216 / 255, alpha: 1) -} diff --git a/FrameGen/Resources/Command.swift b/FrameGen/Resources/Command.swift deleted file mode 100644 index 417730e..0000000 --- a/FrameGen/Resources/Command.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// Command.swift -// Frameworks generate -// -// Created by Рамиз Кичибеков on 23.04.2023. -// Copyright © 2023 Ramiz Kichibekov. All rights reserved. -// - -import Foundation - -final class Command { - - // MARK: - Properties - - private(set) var scheme: String - private(set) var configuration: String - private(set) var scripts: [Script] - - // MARK: - Initial methods - - required init(scheme: String, configuration: String, scripts: [Script]) { - self.scheme = scheme - self.configuration = configuration - self.scripts = scripts - } - - convenience init() { - self.init(scheme: "", configuration: "", scripts: []) - } - - // MARK: - Public methods - - func setScheme(_ scheme: String) { - self.scheme = scheme - } - - func setConfiguration(_ configuration: String) { - self.configuration = configuration - } - - func setScripts(_ scripts: [Script]) { - self.scripts = scripts - } - -} diff --git a/FrameGen/Resources/Script.swift b/FrameGen/Resources/Script.swift deleted file mode 100644 index 7a8bda2..0000000 --- a/FrameGen/Resources/Script.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Script.swift -// generateFramework -// -// Created by Рамиз Кичибеков on 21.04.2023. -// - -import Foundation - -enum Script { - case iPhoneOS - case simulatorOS - case macCatalyst - - var destination: String { - switch self { - case .iPhoneOS: - return "generic/platform=iOS" - case .simulatorOS: - return "generic/platform=iOS Simulator" - case .macCatalyst: - return "platform=macOS,arch=x86_64,variant=Mac Catalyst" - } - } - - var archiveName: String { - switch self { - case .iPhoneOS: - return ".framework-iphoneos.xcarchive" - case .simulatorOS: - return ".framework-iphonesimulator.xcarchive" - case .macCatalyst: - return ".framework-catalyst.xcarchive" - } - } -} diff --git a/FrameGen/Services/Preferences.swift b/FrameGen/Services/Preferences.swift new file mode 100644 index 0000000..91adce5 --- /dev/null +++ b/FrameGen/Services/Preferences.swift @@ -0,0 +1,101 @@ +// +// Preferences.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 07.05.2023. +// Copyright © 2023 Ramiz Kichibekov. All rights reserved. +// + +import Foundation + +/// Keys shared by `@AppStorage` in 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 recentProjects = "recentProjects" + static let projectSettings = "projectSettings" +} + +enum OutputLocation: String, CaseIterable, Identifiable { + case insideProject + case customFolder + + var id: String { rawValue } +} + +/// What FrameGen remembers about each project between launches. +nonisolated struct ProjectSettings: Codable, Equatable, Sendable { + var scheme: String? + var configuration: String? + var platforms: [Platform] = Platform.allCases + var outputPath: String? +} + +enum Preferences { + + private static let defaults = UserDefaults.standard + private static let maxRecentProjects = 10 + + static func registerDefaults() { + defaults.register(defaults: [PreferenceKey.outputLocation: OutputLocation.insideProject.rawValue, + PreferenceKey.revealInFinder: false, + PreferenceKey.keepArchives: true]) + } + + // MARK: - Output + + static var keepArchives: Bool { + defaults.bool(forKey: PreferenceKey.keepArchives) + } + + static var revealInFinder: Bool { + defaults.bool(forKey: PreferenceKey.revealInFinder) + } + + /// `/build`, or `/` when a shared folder is set in Settings. + static func defaultOutputDirectory(for projectFileURL: URL) -> URL { + let projectDirectory = projectFileURL.deletingLastPathComponent() + 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 projectDirectory.appendingPathComponent("build", isDirectory: true) + } + + // MARK: - Recent projects + + static var recentProjects: [URL] { + (defaults.stringArray(forKey: PreferenceKey.recentProjects) ?? []) + .map { URL(fileURLWithPath: $0) } + .filter { FileManager.default.fileExists(atPath: $0.path) } + } + + static func setRecentProjects(_ urls: [URL]) { + defaults.set(urls.prefix(maxRecentProjects).map(\.path), forKey: PreferenceKey.recentProjects) + } + + // MARK: - Per-project settings + + static func settings(for projectFileURL: URL) -> ProjectSettings { + allProjectSettings()[projectFileURL.path] ?? ProjectSettings() + } + + static func setSettings(_ settings: ProjectSettings, for projectFileURL: URL) { + var all = allProjectSettings() + all[projectFileURL.path] = settings + if let data = try? JSONEncoder().encode(all) { + defaults.set(data, forKey: PreferenceKey.projectSettings) + } + } + + private static func allProjectSettings() -> [String: ProjectSettings] { + guard let data = defaults.data(forKey: PreferenceKey.projectSettings) else { return [:] } + + return (try? JSONDecoder().decode([String: ProjectSettings].self, from: data)) ?? [:] + } + +} diff --git a/FrameGen/Services/ProcessRunner.swift b/FrameGen/Services/ProcessRunner.swift new file mode 100644 index 0000000..9b64bc9 --- /dev/null +++ b/FrameGen/Services/ProcessRunner.swift @@ -0,0 +1,153 @@ +// +// ProcessRunner.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 21.04.2023. +// Copyright © 2023 Ramiz Kichibekov. All rights reserved. +// + +import Foundation + +nonisolated enum ProcessError: Error { + case failed(status: Int32, output: String) + case canceled +} + +extension ProcessError: LocalizedError { + + nonisolated var errorDescription: String? { + switch self { + case let .failed(status, _): + return "The command exited with code \(status)." + case .canceled: + return "The operation was canceled." + } + } + +} + +/// Runs a command-line tool. Arguments are passed as an array, so paths never need shell escaping. +nonisolated struct ProcessRunner: Sendable { + + // MARK: - Properties + + let executableURL: URL + var arguments: [String] = [] + /// When `true`, stderr is interleaved with stdout, which keeps build logs in their natural order. + /// Disable it for commands whose stdout must stay machine-readable, like `xcodebuild -list -json`. + var mergesStandardError = true + + // MARK: - Public methods + + /// Streams the output line by line. Cancelling the consuming task interrupts the process, + /// the same way Ctrl-C does in Terminal. + func lines() -> AsyncThrowingStream { + let (stream, continuation) = AsyncThrowingStream.makeStream(of: String.self) + let task = Task { + do { + _ = try await execute { continuation.yield($0) } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + + return stream + } + + /// Runs the process to completion and returns its stdout. + @concurrent + func output() async throws -> String { + try await execute { _ in } + } + + // MARK: - Private methods + + @concurrent + private func execute(onLine: @escaping @Sendable (String) -> Void) async throws -> String { + try Task.checkCancellation() + + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = mergesStandardError ? outputPipe : errorPipe + + let (exit, exitContinuation) = AsyncStream.makeStream(of: Never.self) + process.terminationHandler = { _ in exitContinuation.finish() } + try process.run() + let control = ProcessControl(process: process) + + return try await withTaskCancellationHandler { + // Both pipes are read at the same time: a pipe nobody reads blocks the process once its buffer fills. + async let errorOutput = mergesStandardError ? "" : Self.readAll(errorPipe.fileHandleForReading) + var output = "" + do { + for try await line in outputPipe.fileHandleForReading.bytes.lines { + output += line + "\n" + onLine(line) + } + } catch is CancellationError { + // The process was interrupted; wait for it to exit below. + } + for await _ in exit {} + let errors = await errorOutput + + if control.isCanceled || process.terminationReason == .uncaughtSignal { + throw ProcessError.canceled + } + guard process.terminationStatus == .zero else { + throw ProcessError.failed(status: process.terminationStatus, output: output + errors) + } + + return output + } onCancel: { + control.interrupt() + } + } + + @concurrent + private static func readAll(_ handle: FileHandle) async -> String { + var data = Data() + do { + for try await byte in handle.bytes { + data.append(byte) + } + } catch { + // Whatever was read before the failure is still useful for the error message. + } + + return String(decoding: data, as: UTF8.self) + } + +} + +/// Lets the cancellation handler, which runs on an arbitrary thread, interrupt the process. +nonisolated private final class ProcessControl: @unchecked Sendable { + + private let lock = NSLock() + private let process: Process + private var canceled = false + + init(process: Process) { + self.process = process + } + + var isCanceled: Bool { + lock.withLock { canceled } + } + + func interrupt() { + lock.withLock { + canceled = true + // xcodebuild handles SIGINT gracefully and stops the whole build, including its child compilers. + if process.isRunning { + process.interrupt() + } + } + } + +} diff --git a/FrameGen/Services/ProjectLocator.swift b/FrameGen/Services/ProjectLocator.swift new file mode 100644 index 0000000..5fa88da --- /dev/null +++ b/FrameGen/Services/ProjectLocator.swift @@ -0,0 +1,75 @@ +// +// 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/XCFrameworkBuilder.swift b/FrameGen/Services/XCFrameworkBuilder.swift new file mode 100644 index 0000000..86d7ef2 --- /dev/null +++ b/FrameGen/Services/XCFrameworkBuilder.swift @@ -0,0 +1,159 @@ +// +// XCFrameworkBuilder.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import CryptoKit +import Foundation + +nonisolated enum XCFrameworkBuilderError: Error { + case frameworkNotFound(archivePath: String) + case mismatchedFrameworks(names: [String]) +} + +extension XCFrameworkBuilderError: LocalizedError { + + nonisolated var errorDescription: String? { + switch self { + case .frameworkNotFound(let archivePath): + return "No framework was found in the archive at \(archivePath). Make sure the scheme builds a framework target." + case .mismatchedFrameworks(let names): + return "Archives contain different frameworks: \(names.joined(separator: ", "))." + } + } + +} + +/// Turns the archives produced by `xcodebuild archive` into a distributable XCFramework. +nonisolated enum XCFrameworkBuilder { + + // MARK: - Properties + + struct Slice: Sendable { + let frameworkURL: URL + let debugSymbolURLs: [URL] + } + + struct Plan: Sendable { + let frameworkName: String + let slices: [Slice] + let xcframeworkURL: URL + let zipURL: URL + let snippetURL: URL + } + + struct Summary: Sendable { + let plan: Plan + let checksum: String + let packageSnippet: String + let zipSize: Int64 + let librariesWithoutPrivacyManifest: [String] + } + + // MARK: - Public methods + + /// Finds the framework and its dSYM in every archive and removes stale output, + /// because `xcodebuild -create-xcframework` refuses to overwrite an existing bundle. + @concurrent + static func makePlan(archiveURLs: [URL], outputDirectoryURL: URL) async throws -> Plan { + let fileManager = FileManager.default + var slices: [Slice] = [] + var names: [String] = [] + for archiveURL in archiveURLs { + let frameworksURL = archiveURL.appendingPathComponent("Products/Library/Frameworks", isDirectory: true) + let contents = (try? fileManager.contentsOfDirectory(at: frameworksURL, includingPropertiesForKeys: nil)) ?? [] + guard let frameworkURL = contents.first(where: { $0.pathExtension == "framework" }) else { + throw XCFrameworkBuilderError.frameworkNotFound(archivePath: archiveURL.path) + } + + let dSYMURL = archiveURL + .appendingPathComponent("dSYMs", isDirectory: true) + .appendingPathComponent(frameworkURL.lastPathComponent + ".dSYM", isDirectory: true) + let debugSymbolURLs = fileManager.fileExists(atPath: dSYMURL.path) ? [dSYMURL] : [] + slices.append(Slice(frameworkURL: frameworkURL, debugSymbolURLs: debugSymbolURLs)) + names.append(frameworkURL.deletingPathExtension().lastPathComponent) + } + + let uniqueNames = Array(Set(names)).sorted() + guard uniqueNames.count == 1, let frameworkName = uniqueNames.first else { + throw XCFrameworkBuilderError.mismatchedFrameworks(names: uniqueNames) + } + + let xcframeworkURL = outputDirectoryURL.appendingPathComponent(frameworkName + ".xcframework", isDirectory: true) + let zipURL = outputDirectoryURL.appendingPathComponent(frameworkName + ".xcframework.zip") + let snippetURL = outputDirectoryURL.appendingPathComponent(frameworkName + ".binaryTarget.txt") + for url in [xcframeworkURL, zipURL, snippetURL] where fileManager.fileExists(atPath: url.path) { + try fileManager.removeItem(at: url) + } + + return Plan(frameworkName: frameworkName, + slices: slices, + xcframeworkURL: xcframeworkURL, + zipURL: zipURL, + snippetURL: snippetURL) + } + + /// Computes the SwiftPM checksum of the zip, checks privacy manifests and writes the `binaryTarget` snippet. + @concurrent + static func finalize(_ plan: Plan) async throws -> Summary { + let checksum = try checksum(of: plan.zipURL) + let snippet = packageSnippet(name: plan.frameworkName, zipName: plan.zipURL.lastPathComponent, checksum: checksum) + try snippet.write(to: plan.snippetURL, atomically: true, encoding: .utf8) + let zipSize = (try? plan.zipURL.resourceValues(forKeys: [.fileSizeKey]).fileSize).map(Int64.init) ?? .zero + + return Summary(plan: plan, + checksum: checksum, + packageSnippet: snippet, + zipSize: zipSize, + librariesWithoutPrivacyManifest: librariesWithoutPrivacyManifest(in: plan.xcframeworkURL)) + } + + // MARK: - Private methods + + /// SwiftPM's checksum is the SHA-256 of the archive, the same value `swift package compute-checksum` prints. + private static func checksum(of url: URL) throws -> String { + let data = try Data(contentsOf: url, options: .mappedIfSafe) + + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func packageSnippet(name: String, zipName: String, checksum: String) -> String { + """ + .binaryTarget( + name: "\(name)", + url: "https://example.com/\(zipName)", + checksum: "\(checksum)" + ) + + """ + } + + /// Returns the library identifiers (e.g. `ios-arm64`) whose framework has no `PrivacyInfo.xcprivacy`. + private static func librariesWithoutPrivacyManifest(in xcframeworkURL: URL) -> [String] { + let fileManager = FileManager.default + let libraries = (try? fileManager.contentsOfDirectory(at: xcframeworkURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: .skipsHiddenFiles)) ?? [] + + return libraries + .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } + .filter { libraryURL in + let frameworks = (try? fileManager.contentsOfDirectory(at: libraryURL, includingPropertiesForKeys: nil)) ?? [] + + return frameworks + .filter { $0.pathExtension == "framework" } + .contains { frameworkURL in + // iOS frameworks are flat, macOS and Mac Catalyst ones keep resources under Versions/A. + let candidates = ["PrivacyInfo.xcprivacy", "Versions/A/Resources/PrivacyInfo.xcprivacy"] + + return !candidates.contains { fileManager.fileExists(atPath: frameworkURL.appendingPathComponent($0).path) } + } + } + .map(\.lastPathComponent) + .sorted() + } + +} diff --git a/FrameGen/Services/XcodeCommand.swift b/FrameGen/Services/XcodeCommand.swift new file mode 100644 index 0000000..540379a --- /dev/null +++ b/FrameGen/Services/XcodeCommand.swift @@ -0,0 +1,58 @@ +// +// 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/Support/AppKit+Helpers.swift b/FrameGen/Support/AppKit+Helpers.swift new file mode 100644 index 0000000..510ea2a --- /dev/null +++ b/FrameGen/Support/AppKit+Helpers.swift @@ -0,0 +1,192 @@ +// +// AppKit+Helpers.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +enum Metrics { + static let margin: CGFloat = 20 + static let spacing: CGFloat = 8 + static let groupSpacing: CGFloat = 20 + static let cornerRadius: CGFloat = 10 + static let contentWidth: CGFloat = 560 +} + +extension NSTextField { + + static func text(_ string: String = "", + font: NSFont = .systemFont(ofSize: NSFont.systemFontSize), + color: NSColor = .labelColor, + lines: Int = 1, + selectable: Bool = false) -> NSTextField { + let label = lines == 1 ? NSTextField(labelWithString: string) : NSTextField(wrappingLabelWithString: string) + label.font = font + label.textColor = color + label.maximumNumberOfLines = lines + label.isSelectable = selectable + label.lineBreakMode = lines == 1 ? .byTruncatingTail : .byWordWrapping + label.translatesAutoresizingMaskIntoConstraints = false + + return label + } + +} + +extension NSFont { + + static func preferred(_ style: NSFont.TextStyle, weight: NSFont.Weight? = nil) -> NSFont { + let font = NSFont.preferredFont(forTextStyle: style) + guard let weight else { return font } + + return .systemFont(ofSize: font.pointSize, weight: weight) + } + + static func monospaced(_ style: NSFont.TextStyle = .body) -> NSFont { + .monospacedSystemFont(ofSize: NSFont.preferredFont(forTextStyle: style).pointSize, weight: .regular) + } + +} + +extension NSImageView { + + static func symbol(_ name: String, + pointSize: CGFloat, + weight: NSFont.Weight = .regular, + color: NSColor? = nil, + accessibilityDescription: String? = nil) -> NSImageView { + let image = NSImage(systemSymbolName: name, accessibilityDescription: accessibilityDescription) + let view = NSImageView(image: image ?? NSImage()) + view.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: pointSize, weight: weight) + view.contentTintColor = color + view.translatesAutoresizingMaskIntoConstraints = false + + return view + } + +} + +extension NSButton { + + static func push(_ title: String, symbol: String? = nil, target: AnyObject?, action: Selector) -> NSButton { + let button = NSButton(title: title, target: target, action: action) + if let symbol { + button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) + button.imagePosition = .imageLeading + } + button.translatesAutoresizingMaskIntoConstraints = false + + return button + } + +} + +extension NSStackView { + + static func vertical(_ views: [NSView], spacing: CGFloat = Metrics.spacing, alignment: NSLayoutConstraint.Attribute = .leading) -> NSStackView { + let stack = NSStackView(views: views) + stack.orientation = .vertical + stack.spacing = spacing + stack.alignment = alignment + stack.translatesAutoresizingMaskIntoConstraints = false + + return stack + } + + static func horizontal(_ views: [NSView], spacing: CGFloat = Metrics.spacing, alignment: NSLayoutConstraint.Attribute = .centerY) -> NSStackView { + let stack = NSStackView(views: views) + stack.orientation = .horizontal + stack.spacing = spacing + stack.alignment = alignment + stack.translatesAutoresizingMaskIntoConstraints = false + + return stack + } + +} + +extension NSStackView { + + /// Vertical stacks don't stretch their views across, so the width is pinned explicitly. + func addFullWidthArrangedSubview(_ view: NSView) { + addArrangedSubview(view) + view.widthAnchor.constraint(equalTo: widthAnchor).isActive = true + } + +} + +extension NSView { + + func pinEdges(to view: NSView, insets: NSEdgeInsets = NSEdgeInsetsZero) { + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: insets.left), + view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: insets.right), + topAnchor.constraint(equalTo: view.topAnchor, constant: insets.top), + view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: insets.bottom)]) + } + + /// A flexible view that pushes its stack-view neighbours apart. + static func spacer() -> NSView { + let view = NSView() + view.translatesAutoresizingMaskIntoConstraints = false + view.setContentHuggingPriority(.init(1), for: .horizontal) + view.setContentHuggingPriority(.init(1), for: .vertical) + + return view + } + +} + +extension NSViewController { + + /// Replaces the only child view controller, filling the given container. + func setChild(_ child: NSViewController?, in container: NSView) { + children.forEach { existing in + existing.view.removeFromSuperview() + existing.removeFromParent() + } + guard let child else { return } + + addChild(child) + container.addSubview(child.view) + child.view.pinEdges(to: container) + } + +} + +enum Formatters { + + static func duration(_ interval: TimeInterval) -> String { + Duration.seconds(max(interval, .zero)).formatted(.time(pattern: .minuteSecond)) + } + + static func fileSize(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } + + /// Shortens paths inside the home folder to `~/…`. + static func path(_ url: URL) -> String { + (url.path as NSString).abbreviatingWithTildeInPath + } + +} + +extension NSWorkspace { + + /// Selects the item in Finder, or opens its closest existing parent folder. + func reveal(_ url: URL) { + var candidate = url + while !FileManager.default.fileExists(atPath: candidate.path), candidate.pathComponents.count > 1 { + candidate.deleteLastPathComponent() + } + if candidate == url { + activateFileViewerSelecting([url]) + } else { + open(candidate) + } + } + +} diff --git a/FrameGen/Support/ObservationLoop.swift b/FrameGen/Support/ObservationLoop.swift new file mode 100644 index 0000000..f1a4b9a --- /dev/null +++ b/FrameGen/Support/ObservationLoop.swift @@ -0,0 +1,37 @@ +// +// ObservationLoop.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import Observation + +/// Calls `apply` now and again after any `@Observable` property it read changes. +/// Changes that arrive together are coalesced into a single call on the main actor. +final class ObservationLoop { + + private let apply: () -> Void + private var isCancelled = false + + init(_ apply: @escaping () -> Void) { + self.apply = apply + track() + } + + func cancel() { + isCancelled = true + } + + private func track() { + guard !isCancelled else { return } + + withObservationTracking(apply) { [weak self] in + Task { @MainActor in + self?.track() + } + } + } + +} diff --git a/FrameGen/Views/CalloutView.swift b/FrameGen/Views/CalloutView.swift new file mode 100644 index 0000000..4fc7bdb --- /dev/null +++ b/FrameGen/Views/CalloutView.swift @@ -0,0 +1,76 @@ +// +// CalloutView.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// A tinted inline message with an optional action, used instead of modal alerts. +final class CalloutView: NSView { + + enum Style { + case warning + case error + + var symbol: String { + switch self { + case .warning: + return "exclamationmark.triangle.fill" + case .error: + return "xmark.octagon.fill" + } + } + + var color: NSColor { + switch self { + case .warning: + return .systemOrange + case .error: + return .systemRed + } + } + } + + // MARK: - Initial methods + + init(style: Style, title: String, message: String, action: NSButton? = nil) { + super.init(frame: .zero) + + translatesAutoresizingMaskIntoConstraints = false + let box = NSBox() + box.boxType = .custom + box.cornerRadius = Metrics.cornerRadius + box.borderColor = style.color.withAlphaComponent(0.5) + box.fillColor = style.color.withAlphaComponent(0.1) + box.contentViewMargins = .zero + box.translatesAutoresizingMaskIntoConstraints = false + addSubview(box) + box.pinEdges(to: self) + + let icon = NSImageView.symbol(style.symbol, pointSize: 16, color: style.color) + icon.setContentHuggingPriority(.required, for: .horizontal) + let titleLabel = NSTextField.text(title, font: .preferred(.body, weight: .semibold), lines: 0) + let messageLabel = NSTextField.text(message, font: .preferred(.callout), color: .secondaryLabelColor, lines: 0, selectable: true) + var textViews: [NSView] = [titleLabel, messageLabel] + if let action { + textViews.append(action) + } + let textStack = NSStackView.vertical(textViews, spacing: 4) + let stack = NSStackView.horizontal([icon, textStack], spacing: 10, alignment: .top) + box.contentView?.addSubview(stack) + if let contentView = box.contentView { + stack.pinEdges(to: contentView, insets: NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)) + } + titleLabel.preferredMaxLayoutWidth = Metrics.contentWidth - 60 + messageLabel.preferredMaxLayoutWidth = Metrics.contentWidth - 60 + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/FrameGen/Views/CopyButton.swift b/FrameGen/Views/CopyButton.swift new file mode 100644 index 0000000..254ca8b --- /dev/null +++ b/FrameGen/Views/CopyButton.swift @@ -0,0 +1,62 @@ +// +// CopyButton.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// Copies a value to the pasteboard and briefly confirms it with a checkmark. +final class CopyButton: NSButton { + + // MARK: - Properties + + private let value: () -> String + private let defaultTitle: String + private var resetTask: Task? + + // MARK: - Initial methods + + init(title: String = "Copy", value: @escaping () -> String) { + self.value = value + defaultTitle = title + super.init(frame: .zero) + + self.title = title + bezelStyle = .push + image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: nil) + imagePosition = .imageLeading + target = self + action = #selector(copyValue(_:)) + translatesAutoresizingMaskIntoConstraints = false + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Actions + + @objc + private func copyValue(_ sender: Any?) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value(), forType: .string) + + title = "Copied" + image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil) + NSAccessibility.post(element: self, notification: .announcementRequested, + userInfo: [.announcement: "Copied to the clipboard"]) + resetTask?.cancel() + resetTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(1.5)) + guard let self, !Task.isCancelled else { return } + + title = defaultTitle + image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: nil) + } + } + +} diff --git a/FrameGen/Views/DropTargetView.swift b/FrameGen/Views/DropTargetView.swift new file mode 100644 index 0000000..6844ade --- /dev/null +++ b/FrameGen/Views/DropTargetView.swift @@ -0,0 +1,87 @@ +// +// DropTargetView.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 26.04.2023. +// Copyright © 2023 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// Accepts a dragged project or folder anywhere in the window and outlines the window while dragging. +final class DropTargetView: NSView { + + // MARK: - Properties + + /// Returns whether the drop can be accepted right now, e.g. not while building. + var canAcceptDrop: () -> Bool = { true } + var onDrop: (URL) -> Void = { _ in } + + private var isTargeted = false { + didSet { updateHighlight() } + } + + // MARK: - Initial methods + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + wantsLayer = true + registerForDraggedTypes([.fileURL]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateHighlight() + } + + // MARK: - NSDraggingDestination + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + guard canAcceptDrop(), fileURL(from: sender) != nil else { return [] } + + isTargeted = true + + return .link + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + isTargeted = false + } + + override func draggingEnded(_ sender: NSDraggingInfo) { + isTargeted = false + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + isTargeted = false + guard canAcceptDrop(), let url = fileURL(from: sender) else { return false } + + onDrop(url) + + return true + } + + // MARK: - Private methods + + /// A layer border is composited above sublayers, so it outlines the window over any content. + private func updateHighlight() { + effectiveAppearance.performAsCurrentDrawingAppearance { + layer?.borderWidth = isTargeted ? 3 : 0 + layer?.borderColor = NSColor.controlAccentColor.cgColor + layer?.backgroundColor = isTargeted ? NSColor.controlAccentColor.withAlphaComponent(0.06).cgColor : nil + } + } + + private func fileURL(from info: NSDraggingInfo) -> URL? { + let options: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true] + + return (info.draggingPasteboard.readObjects(forClasses: [NSURL.self], options: options) as? [URL])?.first + } + +} diff --git a/FrameGen/Views/FormSectionView.swift b/FrameGen/Views/FormSectionView.swift new file mode 100644 index 0000000..71456d6 --- /dev/null +++ b/FrameGen/Views/FormSectionView.swift @@ -0,0 +1,133 @@ +// +// FormSectionView.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// A titled, rounded group of rows separated by hairlines, in the style of System Settings. +final class FormSectionView: NSView { + + // MARK: - Properties + + private let rowsStack = NSStackView.vertical([], spacing: .zero, alignment: .leading) + private let footerLabel = NSTextField.text(font: .preferred(.footnote), color: .secondaryLabelColor, lines: 0) + + var footer: String? { + didSet { + footerLabel.stringValue = footer ?? "" + footerLabel.isHidden = footer?.isEmpty ?? true + } + } + + // MARK: - Initial methods + + init(title: String?, rows: [NSView] = []) { + super.init(frame: .zero) + + let box = NSBox() + box.boxType = .custom + box.cornerRadius = Metrics.cornerRadius + box.borderColor = .separatorColor + box.fillColor = .quinarySystemFill + box.contentViewMargins = .zero + box.translatesAutoresizingMaskIntoConstraints = false + if let contentView = box.contentView { + contentView.addSubview(rowsStack) + rowsStack.pinEdges(to: contentView) + } + + var views: [NSView] = [] + if let title { + let titleLabel = NSTextField.text(title, font: .preferred(.headline)) + titleLabel.setAccessibilityRole(.staticText) + views.append(titleLabel) + } + views += [box, footerLabel] + footerLabel.isHidden = true + + let stack = NSStackView.vertical(views, spacing: Metrics.spacing, alignment: .leading) + addSubview(stack) + stack.pinEdges(to: self) + box.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + footerLabel.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + translatesAutoresizingMaskIntoConstraints = false + + setRows(rows) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public methods + + func setRows(_ rows: [NSView]) { + rowsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + for (index, row) in rows.enumerated() { + if index > 0 { + let separator = NSBox() + separator.boxType = .separator + separator.translatesAutoresizingMaskIntoConstraints = false + let inset = NSView() + inset.translatesAutoresizingMaskIntoConstraints = false + inset.addSubview(separator) + separator.pinEdges(to: inset, insets: NSEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)) + rowsStack.addFullWidthArrangedSubview(inset) + } + rowsStack.addFullWidthArrangedSubview(row) + } + } + +} + +/// A form row: optional icon, title with an optional subtitle, and a trailing accessory. +final class FormRowView: NSView { + + // MARK: - Properties + + let titleLabel: NSTextField + let subtitleLabel: NSTextField + + // MARK: - Initial methods + + init(title: String, subtitle: String? = nil, symbol: String? = nil, accessory: NSView? = nil) { + titleLabel = .text(title) + subtitleLabel = .text(subtitle ?? "", font: .preferred(.subheadline), color: .secondaryLabelColor) + super.init(frame: .zero) + + translatesAutoresizingMaskIntoConstraints = false + subtitleLabel.isHidden = subtitle == nil + subtitleLabel.lineBreakMode = .byTruncatingMiddle + titleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let textStack = NSStackView.vertical([titleLabel, subtitleLabel], spacing: 2) + var views: [NSView] = [] + if let symbol { + let icon = NSImageView.symbol(symbol, pointSize: 17, color: .controlAccentColor) + icon.widthAnchor.constraint(equalToConstant: 28).isActive = true + views.append(icon) + } + views += [textStack, .spacer()] + if let accessory { + accessory.setContentCompressionResistancePriority(.required, for: .horizontal) + views.append(accessory) + } + + let stack = NSStackView.horizontal(views, spacing: 10) + addSubview(stack) + stack.pinEdges(to: self, insets: NSEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)) + heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/FrameGen/Views/LogView.swift b/FrameGen/Views/LogView.swift new file mode 100644 index 0000000..0561090 --- /dev/null +++ b/FrameGen/Views/LogView.swift @@ -0,0 +1,93 @@ +// +// LogView.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// A read-only, selectable monospaced text view that appends lines incrementally +/// and keeps following the end unless the user scrolled up. +final class LogView: NSView { + + // MARK: - Properties + + private let scrollView = NSTextView.scrollableTextView() + private var textView: NSTextView { + scrollView.documentView as! NSTextView + } + private var renderedLineCount = 0 + + var text: String { + textView.string + } + + // MARK: - Initial methods + + init() { + super.init(frame: .zero) + + translatesAutoresizingMaskIntoConstraints = false + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = false + textView.font = .monospaced(.callout) + textView.textColor = .labelColor + textView.drawsBackground = true + textView.backgroundColor = .textBackgroundColor + textView.textContainerInset = NSSize(width: 8, height: 8) + textView.setAccessibilityLabel("Build log") + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = true + scrollView.wantsLayer = true + scrollView.layer?.cornerRadius = 8 + scrollView.layer?.borderWidth = 1 + scrollView.layer?.borderColor = NSColor.separatorColor.cgColor + addSubview(scrollView) + scrollView.pinEdges(to: self) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + effectiveAppearance.performAsCurrentDrawingAppearance { + scrollView.layer?.borderColor = NSColor.separatorColor.cgColor + } + } + + // MARK: - Public methods + + /// Appends only the lines added since the last call; a shorter array means a new build started. + func update(lines: [String]) { + if lines.count < renderedLineCount { + textView.string = "" + renderedLineCount = 0 + } + guard lines.count > renderedLineCount, let storage = textView.textStorage else { return } + + let isFollowingEnd = isScrolledToEnd + let newText = lines[renderedLineCount...].joined(separator: "\n") + "\n" + let attributes: [NSAttributedString.Key: Any] = [.font: NSFont.monospaced(.callout), + .foregroundColor: NSColor.labelColor] + storage.append(NSAttributedString(string: newText, attributes: attributes)) + renderedLineCount = lines.count + if isFollowingEnd { + textView.scrollToEndOfDocument(nil) + } + } + + // MARK: - Private methods + + private var isScrolledToEnd: Bool { + let visible = scrollView.contentView.documentVisibleRect + + return visible.maxY >= textView.bounds.maxY - 40 + } + +} diff --git a/FrameGen/Views/RecentProjectCellView.swift b/FrameGen/Views/RecentProjectCellView.swift new file mode 100644 index 0000000..c465d6b --- /dev/null +++ b/FrameGen/Views/RecentProjectCellView.swift @@ -0,0 +1,58 @@ +// +// RecentProjectCellView.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +final class RecentProjectCellView: NSTableCellView { + + static let identifier = NSUserInterfaceItemIdentifier("RecentProjectCell") + + // MARK: - Properties + + private let iconView = NSImageView() + private let nameLabel = NSTextField.text(font: .preferred(.body, weight: .semibold)) + private let pathLabel = NSTextField.text(font: .preferred(.subheadline), color: .secondaryLabelColor) + + // MARK: - Initial methods + + init() { + super.init(frame: .zero) + + identifier = Self.identifier + iconView.translatesAutoresizingMaskIntoConstraints = false + iconView.imageScaling = .scaleProportionallyUpOrDown + NSLayoutConstraint.activate([iconView.widthAnchor.constraint(equalToConstant: 32), + iconView.heightAnchor.constraint(equalToConstant: 32)]) + pathLabel.lineBreakMode = .byTruncatingMiddle + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + pathLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let textStack = NSStackView.vertical([nameLabel, pathLabel], spacing: 1) + let stack = NSStackView.horizontal([iconView, textStack], spacing: 10) + addSubview(stack) + stack.pinEdges(to: self, insets: NSEdgeInsets(top: 6, left: 6, bottom: 6, right: 6)) + textField = nameLabel + imageView = iconView + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public methods + + func configure(with url: URL) { + iconView.image = NSWorkspace.shared.icon(forFile: url.path) + nameLabel.stringValue = url.deletingPathExtension().lastPathComponent + pathLabel.stringValue = Formatters.path(url.deletingLastPathComponent()) + toolTip = url.path + setAccessibilityLabel("\(nameLabel.stringValue), \(pathLabel.stringValue)") + } + +} diff --git a/FrameGen/Views/ScrollingContentView.swift b/FrameGen/Views/ScrollingContentView.swift new file mode 100644 index 0000000..7c4334b --- /dev/null +++ b/FrameGen/Views/ScrollingContentView.swift @@ -0,0 +1,85 @@ +// +// ScrollingContentView.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// A vertically scrolling, top-aligned column of content with a readable maximum width, +/// and an optional bar pinned to the bottom of the window. +final class ScrollingContentView: NSView { + + // MARK: - Properties + + let stackView = NSStackView.vertical([], spacing: Metrics.groupSpacing, alignment: .leading) + private let scrollView = NSScrollView() + private let barContainer = NSView() + + // MARK: - Initial methods + + init() { + super.init(frame: .zero) + + let documentView = FlippedView() + documentView.translatesAutoresizingMaskIntoConstraints = false + documentView.addSubview(stackView) + scrollView.documentView = documentView + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.automaticallyAdjustsContentInsets = true + scrollView.translatesAutoresizingMaskIntoConstraints = false + barContainer.translatesAutoresizingMaskIntoConstraints = false + addSubview(scrollView) + addSubview(barContainer) + + let preferredWidth = stackView.widthAnchor.constraint(equalToConstant: Metrics.contentWidth) + preferredWidth.priority = .defaultHigh + NSLayoutConstraint.activate([scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: barContainer.topAnchor), + barContainer.leadingAnchor.constraint(equalTo: leadingAnchor), + barContainer.trailingAnchor.constraint(equalTo: trailingAnchor), + barContainer.bottomAnchor.constraint(equalTo: bottomAnchor), + documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor), + stackView.topAnchor.constraint(equalTo: documentView.topAnchor, constant: Metrics.margin), + documentView.bottomAnchor.constraint(equalTo: stackView.bottomAnchor, constant: Metrics.margin), + stackView.centerXAnchor.constraint(equalTo: documentView.centerXAnchor), + stackView.leadingAnchor.constraint(greaterThanOrEqualTo: documentView.leadingAnchor, constant: Metrics.margin), + preferredWidth]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public methods + + /// Pins a bar of buttons to the bottom: `leading` views on the left, `trailing` views on the right. + func setBottomBar(leading: [NSView], trailing: [NSView]) { + barContainer.subviews.forEach { $0.removeFromSuperview() } + let separator = NSBox() + separator.boxType = .separator + separator.translatesAutoresizingMaskIntoConstraints = false + let stack = NSStackView.horizontal(leading + [.spacer()] + trailing, spacing: Metrics.spacing + 4) + barContainer.addSubview(separator) + barContainer.addSubview(stack) + NSLayoutConstraint.activate([separator.topAnchor.constraint(equalTo: barContainer.topAnchor), + separator.leadingAnchor.constraint(equalTo: barContainer.leadingAnchor), + separator.trailingAnchor.constraint(equalTo: barContainer.trailingAnchor), + stack.topAnchor.constraint(equalTo: separator.bottomAnchor, constant: 14), + stack.leadingAnchor.constraint(equalTo: barContainer.leadingAnchor, constant: Metrics.margin), + barContainer.trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: Metrics.margin), + barContainer.bottomAnchor.constraint(equalTo: stack.bottomAnchor, constant: 14)]) + } + +} + +private final class FlippedView: NSView { + override var isFlipped: Bool { true } +} diff --git a/FrameGen/Views/StatusHeaderView.swift b/FrameGen/Views/StatusHeaderView.swift new file mode 100644 index 0000000..4806044 --- /dev/null +++ b/FrameGen/Views/StatusHeaderView.swift @@ -0,0 +1,66 @@ +// +// StatusHeaderView.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// A large status symbol with a title and a secondary line, used at the top of build screens. +final class StatusHeaderView: NSView { + + // MARK: - Properties + + private let iconContainer = NSView() + let titleLabel = NSTextField.text(font: .preferred(.title2, weight: .semibold), lines: 2) + let subtitleLabel = NSTextField.text(font: .preferred(.body), color: .secondaryLabelColor, lines: 3) + + // MARK: - Initial methods + + init() { + super.init(frame: .zero) + + translatesAutoresizingMaskIntoConstraints = false + iconContainer.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([iconContainer.widthAnchor.constraint(equalToConstant: 44), + iconContainer.heightAnchor.constraint(equalToConstant: 44)]) + let textStack = NSStackView.vertical([titleLabel, subtitleLabel], spacing: 4) + let stack = NSStackView.horizontal([iconContainer, textStack], spacing: 14, alignment: .centerY) + addSubview(stack) + stack.pinEdges(to: self) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public methods + + func setSymbol(_ name: String, color: NSColor, accessibilityDescription: String) { + setIcon(NSImageView.symbol(name, pointSize: 34, weight: .medium, color: color, + accessibilityDescription: accessibilityDescription)) + } + + func setSpinner() { + let spinner = NSProgressIndicator() + spinner.style = .spinning + spinner.controlSize = .regular + spinner.translatesAutoresizingMaskIntoConstraints = false + spinner.startAnimation(nil) + spinner.setAccessibilityLabel("Building") + setIcon(spinner) + } + + // MARK: - Private methods + + private func setIcon(_ view: NSView) { + iconContainer.subviews.forEach { $0.removeFromSuperview() } + iconContainer.addSubview(view) + NSLayoutConstraint.activate([view.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor), + view.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor)]) + } + +} diff --git a/FrameGen/Views/StepRowView.swift b/FrameGen/Views/StepRowView.swift new file mode 100644 index 0000000..8957483 --- /dev/null +++ b/FrameGen/Views/StepRowView.swift @@ -0,0 +1,113 @@ +// +// StepRowView.swift +// FrameGen +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import AppKit + +/// One pipeline step: a status indicator, the step title and its duration. +final class StepRowView: NSView { + + // MARK: - Properties + + private let statusContainer = NSView() + private let titleLabel = NSTextField.text() + private let durationLabel = NSTextField.text(font: .monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular), + color: .secondaryLabelColor) + private var shownStatus: BuildStep.Status? + + // MARK: - Initial methods + + init() { + super.init(frame: .zero) + + translatesAutoresizingMaskIntoConstraints = false + statusContainer.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([statusContainer.widthAnchor.constraint(equalToConstant: 20), + statusContainer.heightAnchor.constraint(equalToConstant: 20)]) + let stack = NSStackView.horizontal([statusContainer, titleLabel, .spacer(), durationLabel], spacing: 10) + addSubview(stack) + stack.pinEdges(to: self, insets: NSEdgeInsets(top: 9, left: 12, bottom: 9, right: 12)) + setAccessibilityElement(true) + setAccessibilityRole(.staticText) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public methods + + func configure(with step: BuildStep, now: Date = .now) { + titleLabel.stringValue = step.title + switch step.status { + case .pending: + durationLabel.stringValue = "" + titleLabel.textColor = .secondaryLabelColor + case .running(let since): + durationLabel.stringValue = Formatters.duration(now.timeIntervalSince(since)) + titleLabel.textColor = .labelColor + case .succeeded(let duration): + durationLabel.stringValue = Formatters.duration(duration) + titleLabel.textColor = .labelColor + case .failed: + durationLabel.stringValue = "" + titleLabel.textColor = .labelColor + } + setAccessibilityLabel("\(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 } + + shownStatus = step.status + statusContainer.subviews.forEach { $0.removeFromSuperview() } + let indicator: NSView + switch step.status { + case .pending: + indicator = NSImageView.symbol("circle.dashed", pointSize: 15, color: .tertiaryLabelColor) + case .running: + let spinner = NSProgressIndicator() + spinner.style = .spinning + spinner.controlSize = .small + spinner.translatesAutoresizingMaskIntoConstraints = false + spinner.startAnimation(nil) + indicator = spinner + case .succeeded: + indicator = NSImageView.symbol("checkmark.circle.fill", pointSize: 15, color: .systemGreen) + case .failed: + indicator = NSImageView.symbol("xmark.octagon.fill", pointSize: 15, color: .systemRed) + } + statusContainer.addSubview(indicator) + NSLayoutConstraint.activate([indicator.centerXAnchor.constraint(equalTo: statusContainer.centerXAnchor), + indicator.centerYAnchor.constraint(equalTo: statusContainer.centerYAnchor)]) + } + + // MARK: - Private methods + + private static func sameKind(_ lhs: BuildStep.Status, _ rhs: BuildStep.Status) -> Bool { + switch (lhs, rhs) { + case (.pending, .pending), (.running, .running), (.succeeded, .succeeded), (.failed, .failed): + return true + default: + return false + } + } + + private static func accessibilityStatus(_ status: BuildStep.Status) -> String { + switch status { + case .pending: + return "waiting" + case .running: + return "in progress" + case .succeeded: + return "done" + case .failed: + return "failed" + } + } + +} diff --git a/FrameGen/Window/MainWindow.swift b/FrameGen/Window/MainWindow.swift index 2766628..280a821 100644 --- a/FrameGen/Window/MainWindow.swift +++ b/FrameGen/Window/MainWindow.swift @@ -1,44 +1,46 @@ // // MainWindow.swift -// generateFramework +// FrameGen // // Created by Рамиз Кичибеков on 21.04.2023. +// Copyright © 2023 Ramiz Kichibekov. All rights reserved. // -import Cocoa +import AppKit final class MainWindow: NSWindow { - + // MARK: - Properties - - private var observation: NSKeyValueObservation? - + private enum Constants { - static let appName = "FrameGen" - static let defaultSize = NSRect(x: .zero, y: .zero, width: 300, height: 500) + static let defaultSize = NSSize(width: 780, height: 620) + static let minimumSize = NSSize(width: 620, height: 520) + static let autosaveName = "MainWindow" } - + // MARK: - Initial methods - - convenience init() { - self.init(contentRect: Constants.defaultSize, - styleMask: [.miniaturizable, .resizable, .titled, .closable], + + init() { + super.init(contentRect: NSRect(origin: .zero, size: Constants.defaultSize), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, - defer: false, - screen: .main) - - title = Constants.appName - delegate = self - contentViewController = AppBuilder.createMainModule(frame: Constants.defaultSize) - observation = NSApp.observe(\.effectiveAppearance) { app, _ in - app.effectiveAppearance.performAsCurrentDrawingAppearance { - self.appearance = app.effectiveAppearance - } - } + defer: false) + + title = "FrameGen" + toolbarStyle = .unified + contentMinSize = Constants.minimumSize + collectionBehavior.insert(.fullScreenPrimary) + isReleasedWhenClosed = false } -} - -extension MainWindow: NSWindowDelegate { + // MARK: - Public methods + /// Installing a content view controller resizes the window to the controller's view, + /// so the default size and the saved frame are applied afterwards. + func restoreFrame() { + setContentSize(Constants.defaultSize) + center() + setFrameAutosaveName(Constants.autosaveName) + } + } diff --git a/FrameGen/Window/MainWindowController.swift b/FrameGen/Window/MainWindowController.swift index 03b768c..81b7080 100644 --- a/FrameGen/Window/MainWindowController.swift +++ b/FrameGen/Window/MainWindowController.swift @@ -1,36 +1,238 @@ // // MainWindowController.swift -// generateFramework +// FrameGen // // Created by Рамиз Кичибеков on 21.04.2023. +// Copyright © 2023 Ramiz Kichibekov. All rights reserved. // -import Cocoa +import AppKit +/// 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. final class MainWindowController: NSWindowController { - - // MARK: - Initial methods - - required init() { - let window = MainWindow() - super.init(window: window) + + // MARK: - Properties + + private enum ToolbarItem { + static let closeProject = NSToolbarItem.Identifier("closeProject") + static let build = NSToolbarItem.Identifier("build") + static let stop = NSToolbarItem.Identifier("stop") + static let showInFinder = NSToolbarItem.Identifier("showInFinder") } - - @available(*, unavailable) - override init(window: NSWindow?) { - fatalError("init(window:) has not been implemented") + + private let workspace: Workspace + private let buildController = BuildController() + private var observation: ObservationLoop? + + private var project: Project? { + workspace.project + } + + // MARK: - Initial methods + + init(workspace: Workspace) { + self.workspace = workspace + super.init(window: MainWindow()) + + let toolbar = NSToolbar(identifier: "MainToolbar") + toolbar.delegate = self + toolbar.displayMode = .iconOnly + toolbar.allowsUserCustomization = true + toolbar.autosavesConfiguration = true + window?.toolbar = toolbar + contentViewController = RootViewController(workspace: workspace, buildController: buildController) + (window as? MainWindow)?.restoreFrame() + + observation = ObservationLoop { [weak self] in + self?.updateWindow() + } } - + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - // MARK: - Life cycle - - override func showWindow(_ sender: Any?) { - window?.center() - window?.makeKeyAndOrderFront(sender) + + // MARK: - Public methods + + func openProject(at url: URL) { + guard confirmStoppingBuild(action: "Open") else { return } + + do { + buildController.cancel() + try workspace.open(url) + } catch { + presentError(error, modalFor: window!, delegate: nil, didPresent: nil, contextInfo: nil) + } } - + + /// Asks before interrupting a running build; returns whether the caller may continue. + 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") + guard alert.runModal() == .alertFirstButtonReturn else { return false } + + buildController.cancel() + + return true + } + + // MARK: - Actions + + @objc + func openDocument(_ sender: Any?) { + guard let url = ProjectLocator.chooseProject() else { return } + + openProject(at: url) + } + + @objc + func openRecentProject(_ sender: NSMenuItem) { + guard let url = sender.representedObject as? URL else { return } + + openProject(at: url) + } + + @objc + func clearRecentProjects(_ sender: Any?) { + workspace.clearRecentProjects() + } + + @objc + func closeProject(_ sender: Any?) { + guard confirmStoppingBuild(action: "Close") else { return } + + workspace.closeProject() + } + + @objc + func buildXCFramework(_ sender: Any?) { + guard let project else { return } + + buildController.start(project) + } + + @objc + func stopBuild(_ sender: Any?) { + buildController.cancel() + } + + @objc + func showOutputInFinder(_ sender: Any?) { + guard let project else { return } + + if case .succeeded(let summary) = project.build.phase { + NSWorkspace.shared.activateFileViewerSelecting([summary.plan.xcframeworkURL]) + } else { + NSWorkspace.shared.reveal(project.outputDirectory) + } + } + + // MARK: - Private methods + + private func updateWindow() { + guard let window else { return } + + if let project { + window.title = project.name + window.subtitle = Formatters.path(project.fileURL.deletingLastPathComponent()) + window.representedURL = project.fileURL + // Read the build state so the toolbar re-validates when a build starts or ends. + _ = project.canBuild + } else { + window.title = "FrameGen" + window.subtitle = "" + window.representedURL = nil + } + window.toolbar?.validateVisibleItems() + } + + private func isEnabled(_ action: Selector?) -> Bool { + switch action { + case #selector(closeProject(_:)), #selector(showOutputInFinder(_:)): + return project != nil + case #selector(buildXCFramework(_:)): + return project?.canBuild == true + case #selector(stopBuild(_:)): + return project?.build.isRunning == true + case #selector(clearRecentProjects(_:)): + return !workspace.recentProjects.isEmpty + default: + return true + } + } + +} + +// MARK: - NSMenuItemValidation, NSToolbarItemValidation + +extension MainWindowController: NSMenuItemValidation, NSToolbarItemValidation { + + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + isEnabled(menuItem.action) + } + + func validateToolbarItem(_ item: NSToolbarItem) -> Bool { + isEnabled(item.action) + } + +} + +// MARK: - NSToolbarDelegate + +extension MainWindowController: NSToolbarDelegate { + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [ToolbarItem.closeProject, .flexibleSpace, ToolbarItem.showInFinder, ToolbarItem.stop, ToolbarItem.build] + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + toolbarDefaultItemIdentifiers(toolbar) + [.space] + } + + func toolbar(_ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + 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(_:))) + case ToolbarItem.build: + return item(itemIdentifier, label: "Build", symbol: "play.fill", + toolTip: "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(_:))) + case ToolbarItem.showInFinder: + return item(itemIdentifier, label: "Show in Finder", symbol: "folder", + toolTip: "Show the output folder in Finder (⇧⌘R)", action: #selector(showOutputInFinder(_:))) + default: + return nil + } + } + + private func item(_ identifier: NSToolbarItem.Identifier, + label: String, + symbol: String, + toolTip: String, + action: Selector) -> NSToolbarItem { + let item = NSToolbarItem(itemIdentifier: identifier) + item.label = label + item.paletteLabel = label + item.toolTip = toolTip + item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) + item.isBordered = true + item.target = self + item.action = action + + return item + } + } diff --git a/FrameGenTests/BuildStateTests.swift b/FrameGenTests/BuildStateTests.swift new file mode 100644 index 0000000..9a82de2 --- /dev/null +++ b/FrameGenTests/BuildStateTests.swift @@ -0,0 +1,80 @@ +// +// BuildStateTests.swift +// FrameGenTests +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import Foundation +import Testing +@testable import FrameGen + +@MainActor +@Suite("Build progress state") +struct BuildStateTests { + + @Test("The current action comes from xcodebuild action lines") + func extractsActivity() { + let state = BuildState() + state.start(steps: [BuildStep(kind: .archive(.iOS))]) + + state.appendLog("SwiftCompile normal arm64 Compiling\\ Kit.swift /src/My\\ Kit/Kit.swift (in target 'Kit' from project 'Kit')") + + #expect(state.activity == "SwiftCompile normal arm64 Compiling Kit.swift /src/My Kit/Kit.swift") + #expect(state.log.count == 1) + } + + @Test("Other lines are logged but keep the previous action", arguments: [ + " cd /Users/me/Kit", + "/src/Kit.swift:3:1: error: expected declaration (in target 'Kit' from project 'Kit')", + "** ARCHIVE SUCCEEDED **", + "", + ]) + func ignoresOtherLines(line: String) { + let state = BuildState() + state.start(steps: []) + state.appendLog("Ld /build/Kit normal (in target 'Kit' from project 'Kit')") + + state.appendLog(line) + + #expect(state.activity == "Ld /build/Kit normal") + #expect(state.log.last == line) + } + + @Test("Starting a build resets the previous one") + func startResets() { + let state = BuildState() + state.start(steps: [BuildStep(kind: .archive(.iOS))]) + state.appendLog("old line") + state.finish(.idle) + + state.start(steps: [BuildStep(kind: .createXCFramework), BuildStep(kind: .package)]) + + #expect(state.isRunning) + #expect(state.log.isEmpty) + #expect(state.steps.map(\.kind) == [.createXCFramework, .package]) + #expect(state.steps.allSatisfy { $0.status == .pending }) + } + + @Test("Reset returns to idle") + func reset() { + let state = BuildState() + state.start(steps: [BuildStep(kind: .package)]) + state.appendLog("line") + + state.reset() + + #expect(!state.isRunning) + #expect(state.steps.isEmpty) + #expect(state.log.isEmpty) + #expect(state.duration == .zero) + } + + @Test("Step titles name the platform") + func stepTitles() { + #expect(BuildStep(kind: .archive(.iOSSimulator)).title == "Archive for iOS Simulator") + #expect(BuildStep(kind: .archive(.macCatalyst)).failureTitle == "Archiving for Mac Catalyst failed") + } + +} diff --git a/FrameGenTests/ProcessRunnerTests.swift b/FrameGenTests/ProcessRunnerTests.swift new file mode 100644 index 0000000..356144f --- /dev/null +++ b/FrameGenTests/ProcessRunnerTests.swift @@ -0,0 +1,103 @@ +// +// ProcessRunnerTests.swift +// FrameGenTests +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import Testing +import Foundation +@testable import FrameGen + +@Suite struct ProcessRunnerTests { + + // MARK: - Helpers + + private func shell(_ script: String, mergesStandardError: Bool = true) -> ProcessRunner { + ProcessRunner(executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", script], + mergesStandardError: mergesStandardError) + } + + // MARK: - Tests + + @Test("output returns the command's stdout") + func outputReturnsStandardOutput() async throws { + let output = try await shell(#"printf 'a\nb\n'"#).output() + + #expect(output == "a\nb\n") + } + + @Test("lines yields every line in order") + func linesYieldsLinesInOrder() async throws { + var lines: [String] = [] + for try await line in shell(#"printf 'one\ntwo\nthree\n'"#).lines() { + lines.append(line) + } + + #expect(lines == ["one", "two", "three"]) + } + + @Test("A non-zero exit throws failed with the status and the output") + func nonZeroExitThrowsFailed() async throws { + await #expect { + _ = try await shell("echo boom; exit 3").output() + } throws: { error in + guard case let ProcessError.failed(status, output) = error else { + return false + } + + return status == 3 && output.contains("boom") + } + } + + @Test("Merged stderr is included in the failure output") + func mergedStandardErrorIsInFailureOutput() async throws { + await #expect { + _ = try await shell("echo oops >&2; exit 1").output() + } throws: { error in + guard case let ProcessError.failed(status, output) = error else { + return false + } + + return status == 1 && output.contains("oops") + } + } + + @Test("Separate stderr is kept out of the returned stdout") + func separateStandardErrorIsNotInOutput() async throws { + let output = try await shell("echo out; echo err >&2", mergesStandardError: false).output() + + #expect(output == "out\n") + #expect(!output.contains("err")) + } + + @Test("Cancelling the task interrupts the process and throws canceled", .timeLimit(.minutes(1))) + func cancellationThrowsCanceled() async throws { + let runner = shell("exec sleep 30") + let task = Task { try await runner.output() } + try await Task.sleep(for: .milliseconds(500)) + task.cancel() + + await #expect { + _ = try await task.value + } throws: { error in + guard case ProcessError.canceled = error else { + return false + } + + return true + } + } + + @Test("Running a missing executable throws") + func missingExecutableThrows() async throws { + let runner = ProcessRunner(executableURL: URL(fileURLWithPath: "/nonexistent/tool")) + + await #expect(throws: (any Error).self) { + _ = try await runner.output() + } + } + +} diff --git a/FrameGenTests/ProjectInfoTests.swift b/FrameGenTests/ProjectInfoTests.swift new file mode 100644 index 0000000..fec2ae0 --- /dev/null +++ b/FrameGenTests/ProjectInfoTests.swift @@ -0,0 +1,62 @@ +// +// 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 new file mode 100644 index 0000000..6169613 --- /dev/null +++ b/FrameGenTests/ProjectLocatorTests.swift @@ -0,0 +1,73 @@ +// +// 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/TestSupport.swift b/FrameGenTests/TestSupport.swift new file mode 100644 index 0000000..5d48994 --- /dev/null +++ b/FrameGenTests/TestSupport.swift @@ -0,0 +1,52 @@ +// +// TestSupport.swift +// FrameGenTests +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import Foundation + +/// A unique folder in the temporary directory, removed when the value is released. +final class TemporaryDirectory { + + let url: URL + + init() throws { + // /var is a symlink to /private/var and FileManager listings return the /private form. + // URL.resolvingSymlinksInPath() strips /private again, so realpath(3) gives the canonical root. + let root = FileManager.default.temporaryDirectory + let canonicalRoot = realpath(root.path, nil).map { pointer in + defer { free(pointer) } + + return URL(fileURLWithPath: String(cString: pointer), isDirectory: true) + } ?? root + url = canonicalRoot.appendingPathComponent("FrameGenTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + deinit { + try? FileManager.default.removeItem(at: url) + } + + /// Creates the folder (and its parents) at a path relative to the temporary directory. + @discardableResult + func makeDirectory(_ path: String) throws -> URL { + let directory = url.appendingPathComponent(path, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + return directory + } + + /// Writes a file (creating its parent folders) at a path relative to the temporary directory. + @discardableResult + func makeFile(_ path: String, contents: Data = Data()) throws -> URL { + let file = url.appendingPathComponent(path) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try contents.write(to: file) + + return file + } + +} diff --git a/FrameGenTests/XCFrameworkBuilderTests.swift b/FrameGenTests/XCFrameworkBuilderTests.swift new file mode 100644 index 0000000..5f12c37 --- /dev/null +++ b/FrameGenTests/XCFrameworkBuilderTests.swift @@ -0,0 +1,172 @@ +// +// XCFrameworkBuilderTests.swift +// FrameGenTests +// +// Created by Рамиз Кичибеков on 19.10.2026. +// Copyright © 2026 Ramiz Kichibekov. All rights reserved. +// + +import Testing +import Foundation +import CryptoKit +@testable import FrameGen + +@Suite struct XCFrameworkBuilderTests { + + // MARK: - Helpers + + /// Creates `/Archives/.xcarchive` with a framework and, optionally, its dSYM. + @discardableResult + private func makeArchive(_ archiveName: String, + framework: String? = "Kit", + includesDebugSymbols: Bool = true, + in directory: TemporaryDirectory) throws -> URL { + let archivePath = "Archives/\(archiveName).xcarchive" + let archiveURL = try directory.makeDirectory(archivePath) + try directory.makeDirectory("\(archivePath)/Products/Library/Frameworks") + if let framework { + try directory.makeDirectory("\(archivePath)/Products/Library/Frameworks/\(framework).framework") + if includesDebugSymbols { + try directory.makeDirectory("\(archivePath)/dSYMs/\(framework).framework.dSYM") + } + } + + return archiveURL + } + + // MARK: - makePlan + + @Test("makePlan finds the framework, its dSYM and the output locations") + func planForSingleArchive() async throws { + let directory = try TemporaryDirectory() + let archiveURL = try makeArchive("Kit-iphoneos", in: directory) + let outputURL = try directory.makeDirectory("Output") + + let plan = try await XCFrameworkBuilder.makePlan(archiveURLs: [archiveURL], outputDirectoryURL: outputURL) + + #expect(plan.frameworkName == "Kit") + let slice = try #require(plan.slices.first) + #expect(plan.slices.count == 1) + let expectedFramework = archiveURL.appendingPathComponent("Products/Library/Frameworks/Kit.framework") + #expect(slice.frameworkURL.path == expectedFramework.path) + let expectedDebugSymbols = archiveURL.appendingPathComponent("dSYMs/Kit.framework.dSYM") + #expect(slice.debugSymbolURLs.map(\.path) == [expectedDebugSymbols.path]) + #expect(plan.xcframeworkURL.path == outputURL.appendingPathComponent("Kit.xcframework").path) + #expect(plan.zipURL.path == outputURL.appendingPathComponent("Kit.xcframework.zip").path) + #expect(plan.snippetURL.path == outputURL.appendingPathComponent("Kit.binaryTarget.txt").path) + } + + @Test("makePlan leaves debug symbols empty when the archive has no dSYM") + func planWithoutDebugSymbols() async throws { + let directory = try TemporaryDirectory() + let archiveURL = try makeArchive("Kit-iphoneos", includesDebugSymbols: false, in: directory) + let outputURL = try directory.makeDirectory("Output") + + let plan = try await XCFrameworkBuilder.makePlan(archiveURLs: [archiveURL], outputDirectoryURL: outputURL) + + let slice = try #require(plan.slices.first) + #expect(slice.debugSymbolURLs.isEmpty) + } + + @Test("makePlan returns slices in archive order") + func planKeepsArchiveOrder() async throws { + let directory = try TemporaryDirectory() + let simulatorURL = try makeArchive("Kit-iphonesimulator", in: directory) + let deviceURL = try makeArchive("Kit-iphoneos", in: directory) + let outputURL = try directory.makeDirectory("Output") + + let plan = try await XCFrameworkBuilder.makePlan(archiveURLs: [simulatorURL, deviceURL], + outputDirectoryURL: outputURL) + + #expect(plan.slices.count == 2) + let frameworkPaths = plan.slices.map(\.frameworkURL.path) + let expectedPaths = [simulatorURL, deviceURL].map { + $0.appendingPathComponent("Products/Library/Frameworks/Kit.framework").path + } + #expect(frameworkPaths == expectedPaths) + } + + @Test("makePlan throws frameworkNotFound when an archive has no framework") + func planThrowsWhenFrameworkIsMissing() async throws { + let directory = try TemporaryDirectory() + let archiveURL = try makeArchive("Empty-iphoneos", framework: nil, in: directory) + let outputURL = try directory.makeDirectory("Output") + + await #expect { + _ = try await XCFrameworkBuilder.makePlan(archiveURLs: [archiveURL], outputDirectoryURL: outputURL) + } throws: { error in + guard case let XCFrameworkBuilderError.frameworkNotFound(archivePath) = error else { + return false + } + + return archivePath == archiveURL.path + } + } + + @Test("makePlan throws mismatchedFrameworks when archives contain different frameworks") + func planThrowsForMismatchedFrameworks() async throws { + let directory = try TemporaryDirectory() + let kitURL = try makeArchive("Kit-iphoneos", framework: "Kit", in: directory) + let otherURL = try makeArchive("Other-iphonesimulator", framework: "Other", in: directory) + let outputURL = try directory.makeDirectory("Output") + + await #expect { + _ = try await XCFrameworkBuilder.makePlan(archiveURLs: [kitURL, otherURL], outputDirectoryURL: outputURL) + } throws: { error in + guard case let XCFrameworkBuilderError.mismatchedFrameworks(names) = error else { + return false + } + + return names == ["Kit", "Other"] + } + } + + @Test("makePlan removes stale output from a previous run") + func planRemovesStaleOutput() async throws { + let directory = try TemporaryDirectory() + let archiveURL = try makeArchive("Kit-iphoneos", in: directory) + let outputURL = try directory.makeDirectory("Output") + try directory.makeFile("Output/Kit.xcframework/Info.plist", contents: Data("old".utf8)) + try directory.makeFile("Output/Kit.xcframework.zip", contents: Data("old".utf8)) + try directory.makeFile("Output/Kit.binaryTarget.txt", contents: Data("old".utf8)) + + let plan = try await XCFrameworkBuilder.makePlan(archiveURLs: [archiveURL], outputDirectoryURL: outputURL) + + let fileManager = FileManager.default + #expect(!fileManager.fileExists(atPath: plan.xcframeworkURL.path)) + #expect(!fileManager.fileExists(atPath: plan.zipURL.path)) + #expect(!fileManager.fileExists(atPath: plan.snippetURL.path)) + #expect(fileManager.fileExists(atPath: outputURL.path)) + } + + // MARK: - finalize + + @Test("finalize computes the checksum, finds missing privacy manifests and writes the snippet") + func finalizeSummarizesTheOutput() async throws { + let directory = try TemporaryDirectory() + let archiveURL = try makeArchive("Kit-iphoneos", in: directory) + let outputURL = try directory.makeDirectory("Output") + let plan = try await XCFrameworkBuilder.makePlan(archiveURLs: [archiveURL], outputDirectoryURL: outputURL) + + let xcframework = "Output/Kit.xcframework" + try directory.makeFile("\(xcframework)/ios-arm64/Kit.framework/PrivacyInfo.xcprivacy") + try directory.makeFile("\(xcframework)/ios-arm64_x86_64-maccatalyst/Kit.framework/Versions/A/Resources/PrivacyInfo.xcprivacy") + try directory.makeDirectory("\(xcframework)/ios-arm64_x86_64-simulator/Kit.framework") + try directory.makeFile("\(xcframework)/Info.plist", contents: Data("plist".utf8)) + let zipBytes = Data("FrameGen fake zip contents".utf8) + try zipBytes.write(to: plan.zipURL) + let expectedChecksum = SHA256.hash(data: zipBytes).map { String(format: "%02x", $0) }.joined() + + let summary = try await XCFrameworkBuilder.finalize(plan) + + #expect(summary.checksum == expectedChecksum) + #expect(summary.zipSize == Int64(zipBytes.count)) + #expect(summary.librariesWithoutPrivacyManifest == ["ios-arm64_x86_64-simulator"]) + #expect(summary.packageSnippet.contains(#"name: "Kit""#)) + #expect(summary.packageSnippet.contains("Kit.xcframework.zip")) + #expect(summary.packageSnippet.contains(expectedChecksum)) + let writtenSnippet = try String(contentsOf: plan.snippetURL, encoding: .utf8) + #expect(writtenSnippet == summary.packageSnippet) + } + +} diff --git a/FrameGenTests/XcodeCommandTests.swift b/FrameGenTests/XcodeCommandTests.swift new file mode 100644 index 0000000..f3aba29 --- /dev/null +++ b/FrameGenTests/XcodeCommandTests.swift @@ -0,0 +1,82 @@ +// +// 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/README.md b/README.md index 0bc8b83..c0fbe85 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,97 @@ -# FrameGen +# FrameGen -FrameGen is a powerful application designed to streamline the process of creating xcarchive files from your xcodeproj projects. With an intuitive user interface and a wide range of functional capabilities, FrameGen allows you to easily and quickly generate ready-to-distribute archives for your iOS and Mac applications. +FrameGen is a Mac app that turns an Xcode framework project into a distributable **XCFramework**. Pick a scheme and the platforms, press Build, and FrameGen archives every platform, assembles the XCFramework with its debug symbols, zips it and gives you a ready-to-paste Swift Package Manager `binaryTarget`. -#### Key features of FrameGen: +Project options: scheme, configuration, platforms and output folder -Automatic Xcarchive Generation: FrameGen enables you to efficiently create xcarchive files from your xcodeproj projects. Simply select your project, initiate the generation process, and FrameGen will handle all the necessary steps to create a ready-to-use xcarchive. +## Features -Custom Export Path: FrameGen provides the option to choose the export path where the generated xcarchive file will be saved. This allows you to organize and structure your working directory for easy access and utilization of the created archives. +- **One-click XCFramework.** Runs `xcodebuild archive` for each selected platform, then `xcodebuild -create-xcframework` with the dSYM of every slice. +- **Ready for SwiftPM.** Zips the XCFramework, computes its checksum and writes a `.binaryTarget(...)` snippet next to it. +- **Privacy manifest check.** Warns when a slice ships without `PrivacyInfo.xcprivacy`, which Apple requires in commonly used third-party SDKs. +- **Live progress.** Shows each step with its status and duration, the current xcodebuild action and the full build log. +- **Readable failures.** Shows which step failed and pulls the compiler `error:` lines out of the log. +- **Remembers your choices.** Keeps the scheme, configuration, platforms and output folder of every project, plus a list of recent projects. +- **A native Mac app.** Menu bar commands with keyboard shortcuts, a toolbar, drag and drop, Dark Mode and VoiceOver labels. -Platform Support: FrameGen offers flexibility in selecting the supported platforms for xcarchive generation. You can specify whether you need to create archives for iOS devices, iOS Simulator, or Mac Catalyst. This allows you to adapt the generation process to fit the requirements of your project and ensure proper platform support. +Supported platforms: iOS, iOS Simulator and Mac Catalyst. -User-Friendly Interface: FrameGen is designed for ease of use. With an intuitive interface, you can quickly navigate and configure the parameters for xcarchive generation. Through automated processes and clear instructions, you can efficiently create archives and focus on your application development. +## Screenshots -With FrameGen in your toolkit, you can greatly simplify the process of generating xcarchive files from your xcodeproj projects. Effortlessly create ready-to-distribute archives for different platforms, optimizing your workflow and enhancing your iOS and Mac app development. +| Start | Building | +| --- | --- | +| Welcome screen with recent projects | Build progress with steps and log | -## Main Page +Result with files, checksum and binaryTarget snippet - +## Requirements -## Choose export location +- A Mac with Apple silicon running macOS 27 or later. +- Xcode 27 or later, selected with `xcode-select`. +- A project with a shared framework scheme. - +## Usage -## Configuration framework +1. Open a project with **File › Open Project…** (⌘O), pick one from **Recent Projects**, or drop an `.xcodeproj` or its folder onto the window or the Dock icon. +2. Choose the scheme, the configuration and the platforms. +3. Press **Build XCFramework** (⌘B). Stop a build with ⌘. or Esc. +4. When the build finishes, use **Show in Finder**, copy the checksum or copy the `binaryTarget` snippet. - +FrameGen writes these files to `build/` next to the project. You can choose another folder per project, or set a shared default in **Settings** (⌘,). -### General scripts +``` +build/ +├── SampleKit.xcframework +├── SampleKit.xcframework.zip +├── SampleKit.binaryTarget.txt +└── Archives/ # one .xcarchive per platform; optional, see Settings +``` + +Archives are built with `SKIP_INSTALL=NO`, `BUILD_LIBRARIES_FOR_DISTRIBUTION=YES`, `SWIFT_ENABLE_LIBRARY_EVOLUTION=YES` and `SWIFT_EMIT_MODULE_INTERFACE=YES`, so Swift frameworks get the module interfaces that `-create-xcframework` needs. + +### Keyboard shortcuts -#### You can see general scripts in ```enum CommandType``` +| Action | Shortcut | +| --- | --- | +| Open Project | ⌘O | +| Close Project | ⇧⌘W | +| Build XCFramework | ⌘B | +| Stop | ⌘. | +| Show Output in Finder | ⇧⌘R | +| Settings | ⌘, | +## Building from source + +Open `FrameGen.xcodeproj` in Xcode 27 and run the **FrameGen** scheme, or build it from the command line: + +```bash +xcodebuild -project FrameGen.xcodeproj -scheme FrameGen -configuration Release build ``` -final class ShellManager: ShellManagerProtocol + +Run the unit tests (Swift Testing) with: + +```bash +xcodebuild test -project FrameGen.xcodeproj -scheme FrameGen -destination 'platform=macOS,arch=arm64' CODE_SIGNING_ALLOWED=NO ``` + +CI runs the same command on every pull request to `main` (see `.github/workflows/ci.yml`). + +The app runs `xcodebuild` on your projects, so it can't use the App Sandbox. That also means it can't ship on the Mac App Store. Distribute it signed with a Developer ID and notarized. + +## Architecture + +FrameGen uses AppKit with Apple's Model-View-Controller pattern and Swift 6 concurrency. The main actor is the default isolation. + +| Layer | Contents | +| --- | --- | +| `Models` | `Workspace`, `Project` and `BuildState` are `@Observable` classes; `Platform` and `ProjectInfo` are value types. | +| `Controllers` | `NSViewController` subclasses for every screen, plus `BuildController`, which runs the build pipeline with `async`/`await` and task cancellation. | +| `Views` | Reusable `NSView` subclasses: form sections, step rows, log view, callouts, drop target. | +| `Services` | `ProcessRunner` streams process output as an `AsyncThrowingStream`. `XcodeCommand` builds the xcodebuild command lines. `XCFrameworkBuilder` finds the slices, computes the checksum and checks privacy manifests. | +| `Window`, `Application` | The main window and its toolbar, the menu bar and the app delegate. | + +View controllers follow their models with `withObservationTracking` (see `Support/ObservationLoop.swift`), so the UI updates whenever the state changes. + +## License + +FrameGen is available under the MIT license. See [LICENSE](LICENSE). diff --git a/image/chooseLocation.png b/image/chooseLocation.png deleted file mode 100644 index c046432..0000000 Binary files a/image/chooseLocation.png and /dev/null differ diff --git a/image/configuration.png b/image/configuration.png index 6dc60dc..ace96a4 100644 Binary files a/image/configuration.png and b/image/configuration.png differ diff --git a/image/mainPage.png b/image/mainPage.png deleted file mode 100644 index 4695717..0000000 Binary files a/image/mainPage.png and /dev/null differ diff --git a/image/progress.png b/image/progress.png new file mode 100644 index 0000000..8a71ab8 Binary files /dev/null and b/image/progress.png differ diff --git a/image/result.png b/image/result.png new file mode 100644 index 0000000..13b0b2c Binary files /dev/null and b/image/result.png differ diff --git a/image/welcome.png b/image/welcome.png new file mode 100644 index 0000000..5906fd5 Binary files /dev/null and b/image/welcome.png differ