├── .github ├── CODEOWNERS ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── main.yml │ └── pages.yml ├── .gitignore ├── .markdownlint.json ├── LICENSE ├── README.md ├── crowdin.yml ├── examples ├── README.md ├── chibisafe.iscu ├── copyparty.iscu ├── discord.iscu ├── lumen.iscu └── zipline.iscu ├── ishare.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ ├── WorkspaceSettings.xcsettings │ │ └── swiftpm │ │ └── Package.resolved ├── xcshareddata │ └── xcschemes │ │ ├── GitHub.xcscheme │ │ └── ishare.xcscheme └── xcuserdata │ └── adrian.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── ishare ├── App.swift ├── Capture │ ├── CaptureEngine.swift │ ├── ContentSharingPickerManager.swift │ ├── ImageCapture.swift │ ├── PowerMeter.swift │ ├── ScreenRecorder.swift │ └── VideoCapture.swift ├── Config.xcconfig ├── Http │ ├── Custom.swift │ ├── Imgur.swift │ ├── Upload.swift │ └── UploadManager.swift ├── Info.plist ├── Localizable.xcstrings ├── Util │ ├── AppState.swift │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── AppIcon-128.png │ │ │ ├── AppIcon-128@2x.png │ │ │ ├── AppIcon-16.png │ │ │ ├── AppIcon-16@2x.png │ │ │ ├── AppIcon-256.png │ │ │ ├── AppIcon-256@2x.png │ │ │ ├── AppIcon-32.png │ │ │ ├── AppIcon-32@2x.png │ │ │ ├── AppIcon-512.png │ │ │ ├── AppIcon-512@2x.png │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── GlyphIcon.imageset │ │ │ ├── Contents.json │ │ │ ├── glyph.svg │ │ │ ├── glyph_black.svg │ │ │ └── glyph_white.svg │ │ ├── ISCU.iconset │ │ │ ├── icon_128x128.png │ │ │ ├── icon_128x128@2x.png │ │ │ ├── icon_16x16.png │ │ │ ├── icon_16x16@2x.png │ │ │ ├── icon_256x256.png │ │ │ ├── icon_256x256@2x.png │ │ │ ├── icon_32x32.png │ │ │ ├── icon_32x32@2x.png │ │ │ ├── icon_512x512.png │ │ │ └── icon_512x512@2x.png │ │ └── Imgur.iconset │ │ │ ├── icon_128x128.png │ │ │ ├── icon_128x128@2x.png │ │ │ ├── icon_16x16.png │ │ │ ├── icon_16x16@2x.png │ │ │ ├── icon_256x256.png │ │ │ ├── icon_256x256@2x.png │ │ │ ├── icon_32x32.png │ │ │ ├── icon_32x32@2x.png │ │ │ ├── icon_512x512.png │ │ │ └── icon_512x512@2x.png │ ├── Constants.swift │ ├── CustomUploader.swift │ ├── LocalizableManager.swift │ ├── PasteboardExtension.swift │ ├── PreviewImage.swift │ └── ToastPopover.swift ├── Views │ ├── HistoryGridView.swift │ ├── MainMenuView.swift │ ├── Settings │ │ └── UploaderSettings.swift │ └── SettingsMenuView.swift └── ishare.entitlements └── sharemenuext ├── Info.plist ├── ShareViewController.swift └── sharemenuext.entitlements /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @castdrian @thehairy 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: castdrian 2 | ko_fi: castdrian 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Desktop (please complete the following information):** 28 | 29 | - OS: [e.g. iOS] 30 | - Browser [e.g. chrome, safari] 31 | - Version [e.g. 22] 32 | 33 | **Smartphone (please complete the following information):** 34 | 35 | - Device: [e.g. iPhone6] 36 | - OS: [e.g. iOS8.1] 37 | - Browser [e.g. stock browser, safari] 38 | - Version [e.g. 22] 39 | 40 | **Additional context** 41 | Add any other context about the problem here. 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release App 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**/*.md' 9 | - '**.github/workflows/*' 10 | - '**examples/*' 11 | workflow_dispatch: 12 | 13 | permissions: 14 | contents: write 15 | pages: write 16 | id-token: write 17 | 18 | concurrency: 19 | group: "pages" 20 | cancel-in-progress: false 21 | 22 | jobs: 23 | release: 24 | runs-on: macos-14 25 | 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 0 31 | 32 | - name: Xcode Select Version 33 | uses: maxim-lobanov/setup-xcode@v1 34 | with: 35 | xcode-version: '16.1' 36 | 37 | - name: Setup Certificate 38 | uses: apple-actions/import-codesign-certs@v2 39 | with: 40 | p12-file-base64: ${{ secrets.P12_CERTIFICATE_BASE64 }} 41 | p12-password: ${{ secrets.P12_PASSWORD }} 42 | 43 | - name: Get Next Version 44 | id: semver 45 | uses: ietf-tools/semver-action@v1 46 | with: 47 | skipInvalidTags: true 48 | noVersionBumpBehavior: "error" 49 | majorList: "major, breaking" 50 | patchAll: true 51 | token: ${{ env.GITHUB_TOKEN }} 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | 55 | - name: Bump version in Config.xcconfig 56 | run: | 57 | CURRENT_BUILD=$(grep BUILD_NUMBER ishare/Config.xcconfig | cut -d ' ' -f 3) 58 | NEW_BUILD=$((CURRENT_BUILD + 1)) 59 | sed -i '' "s/BUILD_NUMBER = $CURRENT_BUILD/BUILD_NUMBER = $NEW_BUILD/" ishare/Config.xcconfig 60 | 61 | CURRENT_VERSION=$(grep VERSION ishare/Config.xcconfig | cut -d ' ' -f 3) 62 | NEW_VERSION=${{ steps.semver.outputs.nextStrict }} 63 | sed -i '' "s/VERSION = $CURRENT_VERSION/VERSION = $NEW_VERSION/" ishare/Config.xcconfig 64 | 65 | - name: Build Changelog 66 | uses: dlavrenuek/conventional-changelog-action@v1.2.3 67 | id: changelog 68 | with: 69 | from: ${{ steps.semver.outputs.current }} 70 | to: HEAD 71 | 72 | - name: Build macOS app 73 | run: xcodebuild archive -scheme "GitHub" -configuration "Release" -archivePath "build/ishare.xcarchive" -destination "generic/platform=macOS,name=Any Mac" CODE_SIGN_IDENTITY="" CODE_SIGNING_ALLOWED=NO "OTHER_SWIFT_FLAGS=${inherited} -D GITHUB_RELEASE" | xcbeautify 74 | 75 | - name: Sign, Package and Notarize .app 76 | run: | 77 | cp -R "build/ishare.xcarchive/Products/Applications/"*.app "build/ishare.app" 78 | cd build 79 | codesign -s "Developer ID Application" -f --timestamp -o runtime --deep "ishare.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" 80 | codesign -s "Developer ID Application" -f --timestamp -o runtime --deep "ishare.app/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer" 81 | codesign -s "Developer ID Application" -f --timestamp -o runtime --deep "ishare.app/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app/Contents/MacOS/Updater" 82 | codesign -s "Developer ID Application" -f --timestamp -o runtime --deep "ishare.app/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" 83 | codesign -s "Developer ID Application" -f --timestamp -o runtime --deep "ishare.app/Contents/MacOS/ishare" 84 | codesign -s "Developer ID Application" -f --timestamp -o runtime --deep "ishare.app" 85 | npm install --global create-dmg 86 | create-dmg "ishare.app" --overwrite 87 | mv *.dmg ishare.dmg 88 | DMG_FILE="ishare.dmg" 89 | echo "DMG_FILE=$DMG_FILE" >> $GITHUB_ENV 90 | xcrun notarytool submit "$DMG_FILE" --wait --apple-id "${{ secrets.NOTARIZATION_USERNAME }}" --password "${{ secrets.NOTARIZATION_PASSWORD }}" --team-id "L988J7YMK5" 91 | xcrun stapler staple "$DMG_FILE" 92 | 93 | - name: Configure Sparkle 94 | run: | 95 | curl -L -o Sparkle-2.4.2.tar.xz https://github.com/sparkle-project/Sparkle/releases/download/2.4.2/Sparkle-2.4.2.tar.xz 96 | tar -xJf Sparkle-2.4.2.tar.xz 97 | mkdir update 98 | mv "./build/$DMG_FILE" update/ 99 | echo "${{ steps.changelog.outputs.body }}" > RELEASE.md 100 | chmod +x ./bin/generate_appcast 101 | 102 | - name: Convert Markdown to HTML 103 | uses: jaywcjlove/markdown-to-html-cli@main 104 | with: 105 | source: RELEASE.md 106 | output: ./update/${DMG_FILE%.dmg}.html 107 | github-corners: false 108 | 109 | - name: Generate appcast.xml 110 | run: echo "$EDDSA_PRIVATE_KEY" | ./bin/generate_appcast --ed-key-file - --link https://isharemac.app --embed-release-notes --download-url-prefix https://github.com/castdrian/ishare/releases/latest/download/ update/ 111 | env: 112 | EDDSA_PRIVATE_KEY: ${{ secrets.EDDSA_PRIVATE_KEY }} 113 | ARCHIVES_SOURCE_DIR: . 114 | 115 | - name: Archive appcast.xml as artifact 116 | uses: actions/upload-artifact@v4 117 | with: 118 | name: appcast 119 | path: ./update/appcast.xml 120 | 121 | - name: Commit & Push changes 122 | uses: EndBug/add-and-commit@v9 123 | with: 124 | add: 'ishare/Config.xcconfig' 125 | default_author: github_actions 126 | fetch: false 127 | message: 'Bump version [skip ci]' 128 | push: true 129 | 130 | - name: Create GitHub Release 131 | uses: softprops/action-gh-release@v2 132 | with: 133 | tag_name: ${{ steps.semver.outputs.next }} 134 | body_path: RELEASE.md 135 | files: ./update/*.dmg 136 | fail_on_unmatched_files: true 137 | token: ${{ env.GITHUB_TOKEN }} 138 | env: 139 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 140 | 141 | pages: 142 | environment: 143 | name: github-pages 144 | url: ${{ steps.deployment.outputs.page_url }} 145 | runs-on: ubuntu-latest 146 | needs: release 147 | steps: 148 | - name: Checkout 149 | uses: actions/checkout@v4 150 | 151 | - name: Download appcast.xml artifact 152 | uses: actions/download-artifact@v4 153 | with: 154 | name: appcast 155 | path: ./ 156 | 157 | - name: Setup Pages 158 | uses: actions/configure-pages@v3 159 | 160 | - name: Build with Jekyll 161 | uses: actions/jekyll-build-pages@v1 162 | with: 163 | source: ./ 164 | destination: ./_site 165 | 166 | - name: Upload artifact 167 | uses: actions/upload-pages-artifact@v3 168 | 169 | - name: Deploy to GitHub Pages 170 | id: deployment 171 | uses: actions/deploy-pages@v4 172 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy README to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'README.md' 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: write 13 | pages: write 14 | id-token: write 15 | 16 | concurrency: 17 | group: "pages" 18 | cancel-in-progress: false 19 | 20 | jobs: 21 | deploy: 22 | environment: 23 | name: github-pages 24 | url: ${{ steps.deployment.outputs.page_url }} 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 0 31 | 32 | - name: Check if only README.md changed 33 | run: | 34 | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then 35 | echo "Manually triggered - skipping file check." 36 | echo "only_readme_changed=true" >> $GITHUB_ENV 37 | else 38 | FILE_LIST=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }}) 39 | if [ "$FILE_LIST" = "README.md" ]; then 40 | echo "Only README.md changed." 41 | echo "only_readme_changed=true" >> $GITHUB_ENV 42 | else 43 | echo "Other files changed." 44 | echo "only_readme_changed=false" >> $GITHUB_ENV 45 | fi 46 | fi 47 | 48 | - name: Download and Save appcast.xml 49 | if: env.only_readme_changed == 'true' 50 | run: | 51 | curl -o ./appcast.xml https://isharemac.app/appcast.xml 52 | echo "File downloaded and saved as appcast.xml" 53 | 54 | - name: Setup Pages 55 | if: env.only_readme_changed == 'true' 56 | uses: actions/configure-pages@v4 57 | 58 | - name: Build with Jekyll 59 | if: env.only_readme_changed == 'true' 60 | uses: actions/jekyll-build-pages@v1 61 | with: 62 | source: ./ 63 | destination: ./_site 64 | 65 | - name: Upload artifact 66 | if: env.only_readme_changed == 'true' 67 | uses: actions/upload-pages-artifact@v3 68 | 69 | - name: Deploy to GitHub Pages 70 | if: env.only_readme_changed == 'true' 71 | id: deployment 72 | uses: actions/deploy-pages@v4 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | .DS_Store 6 | 7 | ## User settings 8 | xcuserdata/ 9 | 10 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 11 | *.xcscmblueprint 12 | *.xccheckout 13 | 14 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 15 | build/ 16 | DerivedData/ 17 | *.moved-aside 18 | *.pbxuser 19 | !default.pbxuser 20 | *.mode1v3 21 | !default.mode1v3 22 | *.mode2v3 23 | !default.mode2v3 24 | *.perspectivev3 25 | !default.perspectivev3 26 | 27 | ## Obj-C/Swift specific 28 | *.hmap 29 | 30 | ## App packaging 31 | *.ipa 32 | *.dSYM.zip 33 | *.dSYM 34 | 35 | ## Playgrounds 36 | timeline.xctimeline 37 | playground.xcworkspace 38 | 39 | # Swift Package Manager 40 | # 41 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 42 | # Packages/ 43 | # Package.pins 44 | # Package.resolved 45 | # *.xcodeproj 46 | # 47 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 48 | # hence it is not needed unless you have added a package configuration file to your project 49 | # .swiftpm 50 | 51 | .build/ 52 | 53 | # CocoaPods 54 | # 55 | # We recommend against adding the Pods directory to your .gitignore. However 56 | # you should judge for yourself, the pros and cons are mentioned at: 57 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 58 | # 59 | # Pods/ 60 | # 61 | # Add this line if you want to avoid checking in source code from the Xcode workspace 62 | # *.xcworkspace 63 | 64 | # Carthage 65 | # 66 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 67 | # Carthage/Checkouts 68 | 69 | Carthage/Build/ 70 | 71 | # Accio dependency management 72 | Dependencies/ 73 | .accio/ 74 | 75 | # fastlane 76 | # 77 | # It is recommended to not store the screenshots in the git repo. 78 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 79 | # For more information about the recommended setup visit: 80 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 81 | 82 | fastlane/report.xml 83 | fastlane/Preview.html 84 | fastlane/screenshots/**/*.png 85 | fastlane/test_output 86 | 87 | # Code Injection 88 | # 89 | # After new code Injection tools there's a generated folder /iOSInjectionProject 90 | # https://github.com/johnno1962/injectionforxcode 91 | 92 | iOSInjectionProject/ 93 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": false, 3 | "MD033": false, 4 | "MD041": false 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

3 | ishare ishare logo 4 |

5 |

The definitive screen capture utility for macOS, designed with simplicity and efficiency in mind.

6 |

7 | 8 |

9 | 10 | Sponsor 11 | 12 | 13 | Ko-FI 14 | 15 | 16 | Discord 17 | 18 | 19 | GitHub 20 | 21 |

22 | 23 |

24 | 25 | Build and Release App 26 | 27 | 28 | GitHub release 29 | 30 | 31 | License 32 | 33 | 34 | issues 35 | 36 | 37 | stars 38 | 39 |

40 | 41 | --- 42 | 43 |

44 | 45 | Download ishare 46 | 47 |

48 |

49 | 50 | Download Latest Release 51 | 52 |

53 |

54 | 55 | Translate Project 56 | 57 |

58 | 59 | ## 🚀 Features 60 | 61 |
62 | Versatile Screen Capture 63 | 64 | - **Custom Region**: Instantly and easily define and capture specific portions of your screen. 65 | - **Window Capture**: Capture individual application windows without any clutter. 66 | - **Entire Display Capture**: Snapshot your whole screen with a single action. 67 | 68 |
69 | 70 |
71 | Flexible Screen Recording 72 | 73 | - **Video Recording**: Record videos of entire screens or specific windows. 74 | - **GIF Recording**: Capture your moments in GIF format, perfect for quick shares. 75 | - **Customizable Codecs and Compression**: Fine-tune the parameters of the output video files. 76 | 77 |
78 | 79 |
80 | Easy Uploading 81 | 82 | - **Custom Upload Destinations**: Define your own server or service to upload your media. 83 | - **Built-in Imgur Uploader**: Quickly upload your results to Imgur automatically. 84 | 85 |
86 | 87 |
88 | High Customizability 89 | 90 | - **Custom Keybinds**: Set keyboard shortcuts that match your workflow. 91 | - **File Format Preferences**: Choose the formats for your screenshots (e.g. PNG, JPG) and recordings. 92 | - **Custom File Naming**: Define your own prefix for filenames, so you always know which app took the shot. 93 | - **Custom Save Path**: Decide where exactly on your system you want to save your captures and recordings. 94 | - **Application Exclusions**: Exclude specific apps from being recorded. 95 | 96 |
97 | 98 |
99 | Automatic Updates 100 | 101 | Always stay on the cutting edge with built-in automatic updates. 102 |
103 | 104 | ![ishare_menu](https://github.com/iGerman00/ishare/assets/36676880/3a546afb-90ee-4b85-8b38-6029ccd67565) 105 | 106 | ## 🛠 Custom Uploader Setup 107 | 108 | By default, ishare supports and opens `.iscu` files for configuration. They are text files containing JSON data according to the `iscu` spec: 109 | 110 | **Note:** Version 2.0.0 introduces breaking changes. Follow the migration guide for updates and consider reinstallation if you encounter issues post-update. 111 | 112 |
113 | 114 | 📝 Specification (2.0.0 and newer) 115 | 116 | 117 | The custom uploader specification since version 2.0.0+ has the following structure: 118 | 119 | ```json 120 | { 121 | "name": "Custom Uploader Name", 122 | "requestURL": "https://uploader.com/upload", 123 | "headers": { // optional 124 | "Authorization": "Bearer YOUR_AUTH_TOKEN" 125 | }, 126 | "formData": { // optional 127 | "additionalData": "value" 128 | }, 129 | "fileFormName": "file", // optional 130 | "requestBodyType": "multipartFormData", // optional, can be "multipartFormData" or "binary" 131 | "responseURL": "https://uploader.com/{{jsonproperty}}", 132 | "deletionURL": "https://uploader.com/{{jsonproperty}}", // optional 133 | "deleteRequestType": "DELETE" // optional, can be "DELETE" or "GET" 134 | } 135 | ``` 136 | 137 | All properties are case insensitive. 138 | 139 | This new specification allows for more dynamic URL construction and handles deletion URLs. 140 | For `responseURL` and `deletionURL`, JSON properties that are derived from the response payload can be defined as `{{jsonProperty}}`. There is support for nesting (`upload.url`) and arrays (`files[0].url`). 141 |
142 | 143 | ## ⚙️ Migration from Previous Specification 144 | 145 |
146 | Click to expand 147 | 148 | ### Key changes 149 | 150 | - `responseURL` replaces `responseProp`. 151 | - New optional field `deletionURL`. 152 | - Updated URL templating syntax. 153 | 154 | ### Migration steps 155 | 156 | 1. Replace `responseProp` with `responseURL`, ensuring the URL includes placeholders for dynamic values. 157 | 2. If your service provides a deletion link, add the `deletionURL` field. 158 | 3. Update URL placeholders to match the new syntax: 159 | 160 | For example, 161 | 162 | ```json 163 | "responseProp": "fileId" 164 | ``` 165 | 166 | Turns into: 167 | 168 | ```json 169 | "responseURL": "{{fileId}}" 170 | ``` 171 | 172 | ### Example migration 173 | 174 | Before: 175 | 176 | ```json 177 | { 178 | "name": "uploader", 179 | "requestURL": "https://uploader.com/upload", 180 | "responseProp": "fileUrl" 181 | } 182 | ``` 183 | 184 | After: 185 | 186 | ```json 187 | { 188 | "name": "uploader", 189 | "requestURL": "https://uploader.com/upload", 190 | "responseURL": "{{fileUrl}}" // also supported: "https://uploader.com/{{fileId}}" 191 | } 192 | ``` 193 | 194 |
195 | 196 | ## 📤 Compatible Uploader Services 197 | 198 | ishare is confirmed to be compatible with the following upload services: 199 | 200 | - [chibisafe](https://github.com/chibisafe/chibisafe) 201 | - [copyparty](https://github.com/9001/copyparty) 202 | - [lumen](https://github.com/ChecksumDev/lumen) 203 | - [zipline](https://github.com/diced/zipline) 204 | - [discord webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) 205 | 206 | ## 🤝 Contributors 207 | 208 | [![Contributors](https://contrib.rocks/image?repo=castdrian/ishare)](https://github.com/castdrian/ishare/graphs/contributors) 209 | 210 | ## 🙌 Credits 211 | 212 | - Special thanks to [Inna Strazhnik](https://www.behance.net/strazhnik) for the app icon 213 | 214 | ## 📜 License 215 | 216 | Released under [GPL-3.0](/LICENSE) by [@castdrian](https://github.com/castdrian) 217 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | files: 2 | - source: ishare/Localizable.xcstrings 3 | translation: ishare/Localizable.xcstrings 4 | multilingual: 1 5 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # ishare examples 2 | 3 | This folder contains example uploader files for ishare. 4 | 5 | ## 📤 Compatible Uploader Services 6 | 7 | ishare is confirmed to be compatible with the following uploader services: 8 | 9 | - [chibisafe](https://github.com/chibisafe/chibisafe) 10 | - [lumen](https://github.com/ChecksumDev/lumen) 11 | - [zipline](https://github.com/diced/zipline) 12 | - [discord webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) 13 | -------------------------------------------------------------------------------- /examples/chibisafe.iscu: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chibisafe", 3 | "requestURL": "https://chibisafe.moe/api/upload", 4 | "headers": { 5 | "x-api-key": "API_KEY" 6 | }, 7 | "fileFormName": "file[]", 8 | "responseURL": "{{url}}", 9 | "deletionURL": "{{deleteUrl}}", 10 | "deleteRequestType": "DELETE" 11 | } -------------------------------------------------------------------------------- /examples/copyparty.iscu: -------------------------------------------------------------------------------- 1 | { 2 | "name": "copyparty", 3 | "requestURL": "http://127.0.0.1:3923/screenshots/", 4 | "headers": { 5 | "pw": "VALUE", 6 | "accept": "json" 7 | }, 8 | "fileFormName": "f", 9 | "responseURL": "{{files[0].url}}", 10 | } -------------------------------------------------------------------------------- /examples/discord.iscu: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord", 3 | "requestURL": "WEBHOOK_URL", 4 | "formData": { 5 | "username": "ishare", 6 | "avatar_url": "https://raw.githubusercontent.com/castdrian/ishare/main/ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png" 7 | }, 8 | "responseURL": "{{attachments[0].url}}" 9 | } -------------------------------------------------------------------------------- /examples/lumen.iscu: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lumen", 3 | "requestURL": "http://localhost:8080/upload", 4 | "headers": { 5 | "x-api-key": "API_KEY" 6 | }, 7 | "requestBodyType": "binary", 8 | "responseURL": "http://localhost:8080/{{id}}.{{ext}}?key={{key}}&nonce={{nonce}}", 9 | "deletionURL": "http://localhost:8080/{{id}}.{{ext}}/delete?key={{key}}&nonce={{nonce}}&api_key=API_KEY" 10 | } 11 | 12 | -------------------------------------------------------------------------------- /examples/zipline.iscu: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zipline", 3 | "requestURL": "https://your-zipline-host.com/api/upload", 4 | "headers": { 5 | "Authorization": "API_KEY", 6 | }, 7 | "responseURL": "{{files[0].url}}", 8 | "fileFormName": "file" 9 | } 10 | -------------------------------------------------------------------------------- /ishare.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ishare.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ishare.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /ishare.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "457379c019910912d45c64f6cf194185cf499a36d1e88eb6d8948ac28538815f", 3 | "pins" : [ 4 | { 5 | "identity" : "alamofire", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/Alamofire/Alamofire.git", 8 | "state" : { 9 | "revision" : "513364f870f6bfc468f9d2ff0a95caccc10044c5", 10 | "version" : "5.10.2" 11 | } 12 | }, 13 | { 14 | "identity" : "bezelnotification", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/BlueHuskyStudios/BezelNotification.git", 17 | "state" : { 18 | "revision" : "dcdd70b3abb50007d49a8ce23c14e07079d8b0f2", 19 | "version" : "2.1.0" 20 | } 21 | }, 22 | { 23 | "identity" : "defaults", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/sindresorhus/Defaults", 26 | "state" : { 27 | "revision" : "d8a954e69ff13b0f7805f3757c8f8d0c8ef5a8cb", 28 | "version" : "9.0.0-beta.3" 29 | } 30 | }, 31 | { 32 | "identity" : "keyboardshortcuts", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/sindresorhus/KeyboardShortcuts", 35 | "state" : { 36 | "revision" : "c3c361f409b8dbe1eab186078b41c330a6a82c9a", 37 | "version" : "2.2.2" 38 | } 39 | }, 40 | { 41 | "identity" : "launchatlogin-modern", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/sindresorhus/LaunchAtLogin-Modern", 44 | "state" : { 45 | "revision" : "a04ec1c363be3627734f6dad757d82f5d4fa8fcc", 46 | "version" : "1.1.0" 47 | } 48 | }, 49 | { 50 | "identity" : "menubarextraaccess", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/orchetect/MenuBarExtraAccess.git", 53 | "state" : { 54 | "revision" : "f041bb68e9d464c907e240cf3d74f9086a10ad41", 55 | "version" : "1.2.0" 56 | } 57 | }, 58 | { 59 | "identity" : "settingsaccess", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/orchetect/SettingsAccess", 62 | "state" : { 63 | "revision" : "0fd73c8b5892e88acb13adb7f36a4ba9293a0061", 64 | "version" : "1.4.0" 65 | } 66 | }, 67 | { 68 | "identity" : "sparkle", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/sparkle-project/Sparkle", 71 | "state" : { 72 | "revision" : "0ef1ee0220239b3776f433314515fd849025673f", 73 | "version" : "2.6.4" 74 | } 75 | }, 76 | { 77 | "identity" : "swift-cross-kit-types", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/RougeWare/Swift-Cross-Kit-Types.git", 80 | "state" : { 81 | "revision" : "d64faa8d5e988352dff45e03a13da4f90c3a70e5", 82 | "version" : "1.0.0" 83 | } 84 | }, 85 | { 86 | "identity" : "swift-function-tools", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/RougeWare/Swift-Function-Tools.git", 89 | "state" : { 90 | "revision" : "a854f4ed89b7e404019b5a09d24e067acc15432d", 91 | "version" : "1.2.4" 92 | } 93 | }, 94 | { 95 | "identity" : "swiftyjson", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/SwiftyJSON/SwiftyJSON.git", 98 | "state" : { 99 | "revision" : "af76cf3ef710b6ca5f8c05f3a31307d44a3c5828", 100 | "version" : "5.0.2" 101 | } 102 | }, 103 | { 104 | "identity" : "zip", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/marmelroy/Zip.git", 107 | "state" : { 108 | "revision" : "67fa55813b9e7b3b9acee9c0ae501def28746d76", 109 | "version" : "2.1.2" 110 | } 111 | } 112 | ], 113 | "version" : 3 114 | } 115 | -------------------------------------------------------------------------------- /ishare.xcodeproj/xcshareddata/xcschemes/GitHub.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 61 | 63 | 64 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /ishare.xcodeproj/xcshareddata/xcschemes/ishare.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /ishare.xcodeproj/xcuserdata/adrian.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | GitHub.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 1 11 | 12 | Playground (Playground) 1.xcscheme 13 | 14 | isShown 15 | 16 | orderHint 17 | 6 18 | 19 | Playground (Playground) 2.xcscheme 20 | 21 | isShown 22 | 23 | orderHint 24 | 7 25 | 26 | Playground (Playground).xcscheme 27 | 28 | isShown 29 | 30 | orderHint 31 | 9 32 | 33 | ishare.xcscheme_^#shared#^_ 34 | 35 | orderHint 36 | 0 37 | 38 | sharemenuext.xcscheme_^#shared#^_ 39 | 40 | orderHint 41 | 2 42 | 43 | 44 | SuppressBuildableAutocreation 45 | 46 | 3A1E39B72A66915700F6CBBF 47 | 48 | primary 49 | 50 | 51 | 3A29945F2CFF79CB0079CBB8 52 | 53 | primary 54 | 55 | 56 | 3A9A9FD32A5C84B5007BA5C9 57 | 58 | primary 59 | 60 | 61 | 3ABDF2E82BD5EE250045BDE5 62 | 63 | primary 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /ishare/App.swift: -------------------------------------------------------------------------------- 1 | // 2 | // App.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 10.07.23. 6 | // 7 | 8 | import Defaults 9 | import MenuBarExtraAccess 10 | import SwiftUI 11 | 12 | #if canImport(Sparkle) 13 | import Sparkle 14 | #endif 15 | 16 | protocol UpdaterProtocol { 17 | init() 18 | func checkForUpdates() 19 | } 20 | 21 | #if GITHUB_RELEASE 22 | class SparkleUpdater: NSObject, @preconcurrency UpdaterProtocol, SPUUpdaterDelegate { 23 | let updaterController: SPUStandardUpdaterController 24 | 25 | override required init() { 26 | updaterController = SPUStandardUpdaterController( 27 | startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) 28 | super.init() 29 | } 30 | 31 | @MainActor func checkForUpdates() { 32 | updaterController.checkForUpdates(nil) 33 | } 34 | } 35 | #endif 36 | 37 | @main 38 | struct ishare: App { 39 | @Default(.menuBarIcon) var menubarIcon 40 | @Default(.showMainMenu) var showMainMenu 41 | @StateObject private var appState = AppState() 42 | @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate 43 | 44 | var body: some Scene { 45 | MenuBarExtra { 46 | MainMenuView() 47 | } label: { 48 | switch menubarIcon { 49 | case .DEFAULT: Image(nsImage: GlyphIcon) 50 | case .APPICON: Image(nsImage: AppIcon) 51 | case .SYSTEM: Image(systemName: "photo.on.rectangle.angled") 52 | } 53 | } 54 | .menuBarExtraAccess(isPresented: $showMainMenu) 55 | Settings { 56 | SettingsMenuView() 57 | .environmentObject(LocalizableManager.shared) 58 | } 59 | } 60 | } 61 | 62 | @MainActor 63 | class AppDelegate: NSObject, NSApplicationDelegate { 64 | private static let sharedInstance = AppDelegate() 65 | static var shared: AppDelegate { sharedInstance } 66 | 67 | var recordGif = false 68 | let screenRecorder = ScreenRecorder() 69 | 70 | #if GITHUB_RELEASE 71 | private let updater: SparkleUpdater 72 | 73 | override init() { 74 | self.updater = SparkleUpdater() 75 | super.init() 76 | } 77 | #endif 78 | 79 | func applicationDidFinishLaunching(_: Notification) { 80 | NSLog("Application finished launching") 81 | } 82 | 83 | func application(_: NSApplication, open urls: [URL]) { 84 | if urls.first!.isFileURL { 85 | NSLog("Attempting to import ISCU file from: %@", urls.first!.path) 86 | importIscu(urls.first!) 87 | } 88 | 89 | if let url = urls.first { 90 | NSLog("Processing URL scheme: %@", url.absoluteString) 91 | let path = url.host 92 | let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems 93 | 94 | if path == "upload" { 95 | if let fileItem = queryItems?.first(where: { $0.name == "file" }) { 96 | if let encodedFileURLString = fileItem.value, 97 | let decodedFileURLString = encodedFileURLString.removingPercentEncoding, 98 | let fileURL = URL(string: decodedFileURLString) 99 | { 100 | NSLog("Processing upload request for file: %@", fileURL.absoluteString) 101 | 102 | @Default(.uploadType) var uploadType 103 | NSLog("Using upload type: %@", String(describing: uploadType)) 104 | let localFileURL = fileURL 105 | 106 | uploadFile(fileURL: fileURL, uploadType: uploadType) { 107 | Task { @MainActor in 108 | NSLog("Upload completed, showing toast notification") 109 | showToast(fileURL: localFileURL) { 110 | NSSound.beep() 111 | } 112 | } 113 | } 114 | } else { 115 | NSLog("Error: Failed to process file URL from query parameters") 116 | } 117 | } 118 | } 119 | } 120 | } 121 | 122 | @MainActor 123 | func stopRecording() { 124 | let wasRecordingGif = recordGif 125 | let recorder = screenRecorder 126 | 127 | Task { 128 | recorder.stop { result in 129 | Task { @MainActor in 130 | switch result { 131 | case let .success(url): 132 | print("Recording stopped successfully. URL: \(url)") 133 | postRecordingTasks(url, wasRecordingGif) 134 | case let .failure(error): 135 | print("Error while stopping recording: \(error.localizedDescription)") 136 | } 137 | } 138 | } 139 | } 140 | } 141 | 142 | func checkForUpdates() { 143 | #if GITHUB_RELEASE 144 | updater.checkForUpdates() 145 | #endif 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /ishare/Capture/CaptureEngine.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CaptureEngine.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 29.07.23. 6 | // 7 | 8 | @preconcurrency import AVFAudio 9 | import Combine 10 | import Defaults 11 | import Foundation 12 | import ScreenCaptureKit 13 | 14 | /// A structure that contains the video data to render. 15 | struct CapturedFrame: Sendable { 16 | static let invalid = CapturedFrame(surface: nil, contentRect: .zero, contentScale: 0, scaleFactor: 0) 17 | 18 | let surface: IOSurface? 19 | let contentRect: CGRect 20 | let contentScale: CGFloat 21 | let scaleFactor: CGFloat 22 | var size: CGSize { contentRect.size } 23 | } 24 | 25 | /// An object that wraps an instance of `SCStream`, and returns its results as an `AsyncThrowingStream`. 26 | class CaptureEngine: NSObject, @unchecked Sendable, SCRecordingOutputDelegate { 27 | private var recordMP4: Bool = false 28 | private var useHEVC: Bool = false 29 | private var recordAudio: Bool = false 30 | private var recordMic: Bool = false 31 | private var recordPointer: Bool = false 32 | private var recordClicks: Bool = false 33 | 34 | private var stream: SCStream? 35 | private var fileURL: URL? 36 | private let videoSampleBufferQueue = DispatchQueue(label: "com.example.apple-samplecode.VideoSampleBufferQueue") 37 | private let audioSampleBufferQueue = DispatchQueue(label: "com.example.apple-samplecode.AudioSampleBufferQueue") 38 | private let micAudioSampleBufferQueue = DispatchQueue(label: "com.example.apple-samplecode.AudioSampleBufferQueue") 39 | 40 | // Performs average and peak power calculations on the audio samples. 41 | private let powerMeter = PowerMeter() 42 | var audioLevels: AudioLevels { powerMeter.levels } 43 | 44 | // Store the the startCapture continuation, so that you can cancel it when you call stopCapture(). 45 | private var continuation: AsyncThrowingStream.Continuation? 46 | 47 | private var startTime = Date() 48 | private var streamOutput: CaptureEngineStreamOutput? 49 | 50 | /// - Tag: StartCapture 51 | @MainActor func startCapture(configuration: SCStreamConfiguration, filter: SCContentFilter, fileURL: URL) -> AsyncThrowingStream { 52 | let config = configuration 53 | let contentFilter = filter 54 | let outputURL = fileURL 55 | 56 | @Default(.recordMP4) var recordMP4 57 | @Default(.useHEVC) var useHEVC 58 | @Default(.recordAudio) var recordAudio 59 | @Default(.recordMic) var recordMic 60 | @Default(.recordPointer) var recordPointer 61 | @Default(.recordClicks) var recordClicks 62 | 63 | self.recordMP4 = recordMP4 64 | self.useHEVC = useHEVC 65 | self.recordAudio = recordAudio 66 | self.recordMic = recordMic 67 | self.recordPointer = recordPointer 68 | self.recordClicks = recordClicks 69 | 70 | return AsyncThrowingStream { continuation in 71 | // The stream output object. 72 | let output = CaptureEngineStreamOutput(continuation: continuation) 73 | streamOutput = output 74 | 75 | streamOutput!.capturedFrameHandler = { continuation.yield($0) } 76 | streamOutput!.pcmBufferHandler = { self.powerMeter.process(buffer: $0) } 77 | self.startTime = Date() 78 | 79 | do { 80 | config.capturesAudio = recordAudio 81 | config.captureMicrophone = recordMic 82 | config.showsCursor = recordPointer 83 | config.showMouseClicks = recordClicks 84 | 85 | stream = SCStream(filter: contentFilter, configuration: config, delegate: streamOutput) 86 | self.fileURL = outputURL 87 | 88 | // Add a stream output to capture screen content. 89 | try stream?.addStreamOutput(streamOutput!, type: .screen, sampleHandlerQueue: videoSampleBufferQueue) 90 | try stream?.addStreamOutput(streamOutput!, type: .audio, sampleHandlerQueue: audioSampleBufferQueue) 91 | try stream?.addStreamOutput(streamOutput!, type: .microphone, sampleHandlerQueue: micAudioSampleBufferQueue) 92 | 93 | let recordingConfiguration = SCRecordingOutputConfiguration() 94 | 95 | recordingConfiguration.outputURL = outputURL 96 | recordingConfiguration.outputFileType = self.recordMP4 ? .mp4 : .mov 97 | recordingConfiguration.videoCodecType = self.useHEVC ? .hevc : .h264 98 | 99 | let recordingOutput = SCRecordingOutput(configuration: recordingConfiguration, delegate: self) 100 | 101 | try stream?.addRecordingOutput(recordingOutput) 102 | 103 | stream?.startCapture() 104 | } catch { 105 | continuation.finish(throwing: error) 106 | } 107 | } 108 | } 109 | 110 | func stopCapture() async -> @Sendable (@escaping @Sendable (Result) -> Void) -> Void { 111 | { [weak self] completion in 112 | guard let self else { return } 113 | enum ScreenRecorderError: Error { 114 | case missingFileURL 115 | } 116 | 117 | guard let url = fileURL else { 118 | completion(.failure(ScreenRecorderError.missingFileURL)) 119 | return 120 | } 121 | 122 | // Stop the stream 123 | stream?.stopCapture() 124 | stream = nil 125 | 126 | // Return the file URL 127 | completion(.success(url)) 128 | } 129 | } 130 | 131 | /// - Tag: UpdateStreamConfiguration 132 | func update(configuration: SCStreamConfiguration, filter: SCContentFilter) async { 133 | struct SendableParams: @unchecked Sendable { 134 | let configuration: SCStreamConfiguration 135 | let filter: SCContentFilter 136 | } 137 | 138 | let params = SendableParams(configuration: configuration, filter: filter) 139 | 140 | do { 141 | try await stream?.updateConfiguration(params.configuration) 142 | try await stream?.updateContentFilter(params.filter) 143 | } catch { 144 | print("Failed to update the stream session: \(String(describing: error))") 145 | } 146 | } 147 | } 148 | 149 | /// A class that handles output from an SCStream, and handles stream errors. 150 | @MainActor 151 | private class CaptureEngineStreamOutput: NSObject, SCStreamOutput, SCStreamDelegate { 152 | var pcmBufferHandler: (@Sendable (AVAudioPCMBuffer) -> Void)? 153 | var capturedFrameHandler: (@Sendable (CapturedFrame) -> Void)? 154 | 155 | // Store the the startCapture continuation, so you can cancel it if an error occurs. 156 | private var continuation: AsyncThrowingStream.Continuation? 157 | 158 | init(continuation: AsyncThrowingStream.Continuation?) { 159 | self.continuation = continuation 160 | } 161 | 162 | nonisolated func stream(_: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) { 163 | // Return early if the sample buffer is invalid. 164 | guard sampleBuffer.isValid else { return } 165 | 166 | // Determine which type of data the sample buffer contains. 167 | switch outputType { 168 | case .screen: 169 | // Create a CapturedFrame structure for a video sample buffer. 170 | guard let frame = createFrame(for: sampleBuffer) else { return } 171 | Task { @MainActor [self] in 172 | capturedFrameHandler?(frame) 173 | } 174 | case .audio: 175 | // Create an AVAudioPCMBuffer from an audio sample buffer. 176 | guard let samples = createPCMBuffer(for: sampleBuffer) else { return } 177 | Task { @MainActor [self] in 178 | pcmBufferHandler?(samples) 179 | } 180 | case .microphone: 181 | guard let samples = createPCMBuffer(for: sampleBuffer) else { return } 182 | Task { @MainActor [self] in 183 | pcmBufferHandler?(samples) 184 | } 185 | @unknown default: 186 | fatalError("Encountered unknown stream output type: \(outputType)") 187 | } 188 | } 189 | 190 | nonisolated func stream(_: SCStream, didStopWithError error: any Error) { 191 | if (error as NSError).code == -3817 { 192 | // User stopped the stream. Call AppDelegate's method to stop recording gracefully 193 | Task { @MainActor in 194 | let pickerManager = ContentSharingPickerManager.shared 195 | pickerManager.deactivatePicker() 196 | AppDelegate.shared.stopRecording() 197 | } 198 | } else { 199 | // Handle other errors 200 | print("Stream stopped with error: \(error.localizedDescription)") 201 | } 202 | // Finish the AsyncThrowingStream if it's still running 203 | Task { @MainActor [self] in 204 | continuation?.finish(throwing: error) 205 | } 206 | } 207 | 208 | /// Create a `CapturedFrame` for the video sample buffer. 209 | private nonisolated func createFrame(for sampleBuffer: CMSampleBuffer) -> CapturedFrame? { 210 | // Retrieve the array of metadata attachments from the sample buffer. 211 | guard let attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, 212 | createIfNecessary: false) as? [[SCStreamFrameInfo: Any]], 213 | let attachments = attachmentsArray.first else { return nil } 214 | 215 | // Validate the status of the frame. If it isn't `.complete`, return nil. 216 | guard let statusRawValue = attachments[SCStreamFrameInfo.status] as? Int, 217 | let status = SCFrameStatus(rawValue: statusRawValue), 218 | status == .complete else { return nil } 219 | 220 | // Get the pixel buffer that contains the image data. 221 | guard let pixelBuffer = sampleBuffer.imageBuffer else { return nil } 222 | 223 | // Get the backing IOSurface. 224 | guard let surfaceRef = CVPixelBufferGetIOSurface(pixelBuffer)?.takeUnretainedValue() else { return nil } 225 | let surface = unsafeBitCast(surfaceRef, to: IOSurface.self) 226 | 227 | // Retrieve the content rectangle, scale, and scale factor. 228 | guard let contentRectDict = attachments[.contentRect], 229 | let contentRect = CGRect(dictionaryRepresentation: contentRectDict as! CFDictionary), 230 | let contentScale = attachments[.contentScale] as? CGFloat, 231 | let scaleFactor = attachments[.scaleFactor] as? CGFloat else { return nil } 232 | 233 | // Create a new frame with the relevant data. 234 | let frame = CapturedFrame(surface: surface, 235 | contentRect: contentRect, 236 | contentScale: contentScale, 237 | scaleFactor: scaleFactor) 238 | return frame 239 | } 240 | 241 | // Creates an AVAudioPCMBuffer instance on which to perform an average and peak audio level calculation. 242 | private nonisolated func createPCMBuffer(for sampleBuffer: CMSampleBuffer) -> AVAudioPCMBuffer? { 243 | var ablPointer: UnsafePointer? 244 | try? sampleBuffer.withAudioBufferList { audioBufferList, _ in 245 | ablPointer = audioBufferList.unsafePointer 246 | } 247 | guard let audioBufferList = ablPointer, 248 | let absd = sampleBuffer.formatDescription?.audioStreamBasicDescription, 249 | let format = AVAudioFormat(standardFormatWithSampleRate: absd.mSampleRate, channels: absd.mChannelsPerFrame) else { return nil } 250 | return AVAudioPCMBuffer(pcmFormat: format, bufferListNoCopy: audioBufferList) 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /ishare/Capture/ContentSharingPickerManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentSharingPickerManager.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 20.01.24. 6 | // 7 | 8 | import Defaults 9 | import Foundation 10 | @preconcurrency import ScreenCaptureKit 11 | 12 | actor CallbackStore { 13 | private var contentSelected: (@Sendable (SCContentFilter, SCStream?) -> Void)? 14 | private var contentSelectionFailed: (@Sendable (any Error) -> Void)? 15 | private var contentSelectionCancelled: (@Sendable (SCStream?) -> Void)? 16 | 17 | func setContentSelectedCallback(_ callback: @Sendable @escaping (SCContentFilter, SCStream?) -> Void) { 18 | contentSelected = callback 19 | } 20 | 21 | func setContentSelectionFailedCallback(_ callback: @Sendable @escaping (any Error) -> Void) { 22 | contentSelectionFailed = callback 23 | } 24 | 25 | func setContentSelectionCancelledCallback(_ callback: @Sendable @escaping (SCStream?) -> Void) { 26 | contentSelectionCancelled = callback 27 | } 28 | 29 | func getContentSelectedCallback() -> (@Sendable (SCContentFilter, SCStream?) -> Void)? { 30 | contentSelected 31 | } 32 | 33 | func getContentSelectionFailedCallback() -> (@Sendable (any Error) -> Void)? { 34 | contentSelectionFailed 35 | } 36 | 37 | func getContentSelectionCancelledCallback() -> (@Sendable (SCStream?) -> Void)? { 38 | contentSelectionCancelled 39 | } 40 | } 41 | 42 | @MainActor 43 | class ContentSharingPickerManager: NSObject, SCContentSharingPickerObserver { 44 | static let shared = ContentSharingPickerManager() 45 | private let picker = SCContentSharingPicker.shared 46 | private let callbackStore = CallbackStore() 47 | 48 | func setContentSelectedCallback(_ callback: @Sendable @escaping (SCContentFilter, SCStream?) -> Void) async { 49 | await callbackStore.setContentSelectedCallback(callback) 50 | } 51 | 52 | func setContentSelectionFailedCallback(_ callback: @Sendable @escaping (any Error) -> Void) async { 53 | await callbackStore.setContentSelectionFailedCallback(callback) 54 | } 55 | 56 | func setContentSelectionCancelledCallback(_ callback: @Sendable @escaping (SCStream?) -> Void) async { 57 | await callbackStore.setContentSelectionCancelledCallback(callback) 58 | } 59 | 60 | @Default(.ignoredBundleIdentifiers) var ignoredBundleIdentifiers 61 | 62 | func setupPicker(stream: SCStream) { 63 | picker.add(self) 64 | picker.isActive = true 65 | 66 | var pickerConfig = SCContentSharingPickerConfiguration() 67 | pickerConfig.excludedBundleIDs = ignoredBundleIdentifiers 68 | pickerConfig.allowsChangingSelectedContent = true 69 | 70 | picker.setConfiguration(pickerConfig, for: stream) 71 | } 72 | 73 | func showPicker() { 74 | picker.present() 75 | } 76 | 77 | func deactivatePicker() { 78 | picker.isActive = false 79 | picker.remove(self) 80 | } 81 | 82 | nonisolated func contentSharingPicker(_: SCContentSharingPicker, didUpdateWith filter: SCContentFilter, for stream: SCStream?) { 83 | struct SendableParams: @unchecked Sendable { 84 | let filter: SCContentFilter 85 | let stream: SCStream? 86 | } 87 | let params = SendableParams(filter: filter, stream: stream) 88 | 89 | Task { @MainActor in 90 | if let callback = await ContentSharingPickerManager.shared.callbackStore.getContentSelectedCallback() { 91 | callback(params.filter, params.stream) 92 | } 93 | } 94 | } 95 | 96 | nonisolated func contentSharingPicker(_: SCContentSharingPicker, didCancelFor stream: SCStream?) { 97 | struct SendableParams: @unchecked Sendable { 98 | let stream: SCStream? 99 | } 100 | let params = SendableParams(stream: stream) 101 | 102 | Task { @MainActor in 103 | if let callback = await ContentSharingPickerManager.shared.callbackStore.getContentSelectionCancelledCallback() { 104 | callback(params.stream) 105 | } 106 | } 107 | } 108 | 109 | nonisolated func contentSharingPickerStartDidFailWithError(_ error: any Error) { 110 | struct SendableParams: @unchecked Sendable { 111 | let error: any Error 112 | } 113 | let params = SendableParams(error: error) 114 | 115 | Task { @MainActor in 116 | if let callback = await ContentSharingPickerManager.shared.callbackStore.getContentSelectionFailedCallback() { 117 | callback(params.error) 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /ishare/Capture/ImageCapture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageCapture.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 11.07.23. 6 | // 7 | 8 | import AppKit 9 | import Defaults 10 | import Foundation 11 | 12 | enum CaptureType: String { 13 | case SCREEN = "-t" 14 | case WINDOW = "-wt" 15 | case REGION = "-it" 16 | } 17 | 18 | enum FileType: String, CaseIterable, Identifiable, Defaults.Serializable { 19 | case PNG = "png" 20 | case JPG = "jpg" 21 | case PDF = "pdf" 22 | case TIFF = "tiff" 23 | case HEIC = "heic" 24 | var id: Self { self } 25 | } 26 | 27 | @MainActor 28 | func captureScreen(type: CaptureType, display: Int = 1) async { 29 | NSLog("Starting screen capture with type: %@, display: %d", type.rawValue, display) 30 | 31 | let capturePath = Defaults[.capturePath] 32 | let fileType = Defaults[.captureFileType] 33 | let fileName = Defaults[.captureFileName] 34 | let copyToClipboard = Defaults[.copyToClipboard] 35 | let openInFinder = Defaults[.openInFinder] 36 | let uploadMedia = Defaults[.uploadMedia] 37 | let captureBinary = Defaults[.captureBinary] 38 | let uploadType = Defaults[.uploadType] 39 | let saveToDisk = Defaults[.saveToDisk] 40 | 41 | let suffix = await getCaptureNameSuffix(type: type, display: display) 42 | 43 | let timestamp = Int(Date().timeIntervalSince1970) 44 | let uniqueFilename = "\(fileName)-\(timestamp)\(suffix).\(fileType.rawValue)" 45 | 46 | var path = "\(capturePath)\(uniqueFilename)" 47 | path = NSString(string: path).expandingTildeInPath 48 | 49 | let task = Process() 50 | task.launchPath = captureBinary 51 | task.arguments = type == CaptureType.SCREEN ? [type.rawValue, fileType.rawValue, "-D", "\(display)", path] : [type.rawValue, fileType.rawValue, path] 52 | 53 | NSLog("Executing capture command: %@ %@", captureBinary, task.arguments?.joined(separator: " ") ?? "") 54 | task.launch() 55 | task.waitUntilExit() 56 | 57 | let fileURL = URL(fileURLWithPath: path) 58 | 59 | if !FileManager.default.fileExists(atPath: fileURL.path) { 60 | NSLog("Error: Capture file not created at path: %@", path) 61 | return 62 | } 63 | NSLog("Screen capture completed successfully") 64 | 65 | if copyToClipboard { 66 | let pasteboard = NSPasteboard.general 67 | pasteboard.clearContents() 68 | pasteboard.setString(fileURL.absoluteString, forType: .fileURL) 69 | } 70 | 71 | if openInFinder { 72 | NSWorkspace.shared.activateFileViewerSelecting([fileURL]) 73 | } 74 | 75 | if uploadMedia { 76 | let shouldSaveToDisk = saveToDisk 77 | let localFileURL = fileURL 78 | uploadFile(fileURL: fileURL, uploadType: uploadType) { 79 | Task { @MainActor in 80 | showToast(fileURL: localFileURL) { 81 | NSSound.beep() 82 | 83 | if !shouldSaveToDisk { 84 | do { 85 | try FileManager.default.removeItem(at: localFileURL) 86 | } catch { 87 | print("Error deleting the file: \(error)") 88 | } 89 | } 90 | } 91 | } 92 | } 93 | } else { 94 | let shouldSaveToDisk = saveToDisk 95 | let localFileURL = fileURL 96 | Task { @MainActor in 97 | showToast(fileURL: localFileURL) { 98 | NSSound.beep() 99 | 100 | if !shouldSaveToDisk { 101 | do { 102 | try FileManager.default.removeItem(at: localFileURL) 103 | } catch { 104 | print("Error deleting the file: \(error)") 105 | } 106 | } 107 | } 108 | } 109 | } 110 | shareBasedOnPreferences(fileURL) 111 | } 112 | 113 | @MainActor 114 | private func getCaptureNameSuffix(type: CaptureType, display: Int) async -> String { 115 | switch type { 116 | case .WINDOW: 117 | if let frontmostApp = NSWorkspace.shared.frontmostApplication { 118 | let appName = frontmostApp.localizedName ?? "window" 119 | return "-\(appName.lowercased())" 120 | } 121 | return "-window" 122 | 123 | case .SCREEN: 124 | if let screen = NSScreen.screens[safe: display - 1] { 125 | if let displayName = screen.localizedName { 126 | return "-\(displayName.lowercased())" 127 | } 128 | return "-display-\(display)" 129 | } 130 | return "-screen" 131 | 132 | case .REGION: 133 | if let frontmostApp = NSWorkspace.shared.frontmostApplication { 134 | let appName = frontmostApp.localizedName ?? "region" 135 | return "-\(appName.lowercased())" 136 | } 137 | return "-region" 138 | } 139 | } 140 | 141 | // Helper extension for safe array access 142 | extension Collection { 143 | subscript(safe index: Index) -> Element? { 144 | indices.contains(index) ? self[index] : nil 145 | } 146 | } 147 | 148 | // Helper extension for NSScreen to get display name 149 | extension NSScreen { 150 | var localizedName: String? { 151 | // Get the display's bounds to help identify it 152 | let bounds = frame 153 | let width = Int(bounds.width) 154 | let height = Int(bounds.height) 155 | 156 | if let displayID = deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID { 157 | // Check if this is the main display 158 | if CGDisplayIsMain(displayID) != 0 { 159 | return "main-\(width)x\(height)" 160 | } 161 | return "display-\(width)x\(height)" 162 | } 163 | return nil 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /ishare/Capture/PowerMeter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PowerMeter.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 29.07.23. 6 | // 7 | 8 | import Accelerate 9 | import AVFoundation 10 | import Foundation 11 | 12 | struct AudioLevels { 13 | static let zero = AudioLevels(level: 0, peakLevel: 0) 14 | let level: Float 15 | let peakLevel: Float 16 | } 17 | 18 | // The protocol for the object that provides peak and average power levels to adopt. 19 | protocol AudioLevelProvider { 20 | var levels: AudioLevels { get } 21 | } 22 | 23 | class PowerMeter: AudioLevelProvider { 24 | private let kMinLevel: Float = 0.000_000_01 // -160 dB 25 | 26 | private struct PowerLevels { 27 | let average: Float 28 | let peak: Float 29 | } 30 | 31 | private var values = [PowerLevels]() 32 | 33 | private var meterTableAverage = MeterTable() 34 | private var meterTablePeak = MeterTable() 35 | 36 | var levels: AudioLevels { 37 | if values.isEmpty { return AudioLevels(level: 0.0, peakLevel: 0.0) } 38 | return AudioLevels(level: meterTableAverage.valueForPower(values[0].average), 39 | peakLevel: meterTablePeak.valueForPower(values[0].peak)) 40 | } 41 | 42 | func processSilence() { 43 | if values.isEmpty { return } 44 | values = [] 45 | } 46 | 47 | // Calculates the average (rms) and peak level of each channel in the PCM buffer and caches data. 48 | func process(buffer: AVAudioPCMBuffer) { 49 | var powerLevels = [PowerLevels]() 50 | let channelCount = Int(buffer.format.channelCount) 51 | let length = vDSP_Length(buffer.frameLength) 52 | 53 | if let floatData = buffer.floatChannelData { 54 | for channel in 0 ..< channelCount { 55 | powerLevels.append(calculatePowers(data: floatData[channel], strideFrames: buffer.stride, length: length)) 56 | } 57 | } else if let int16Data = buffer.int16ChannelData { 58 | for channel in 0 ..< channelCount { 59 | // Convert the data from int16 to float values before calculating the power values. 60 | var floatChannelData: [Float] = Array(repeating: Float(0.0), count: Int(buffer.frameLength)) 61 | vDSP_vflt16(int16Data[channel], buffer.stride, &floatChannelData, buffer.stride, length) 62 | var scalar = Float(INT16_MAX) 63 | vDSP_vsdiv(floatChannelData, buffer.stride, &scalar, &floatChannelData, buffer.stride, length) 64 | 65 | powerLevels.append(calculatePowers(data: floatChannelData, strideFrames: buffer.stride, length: length)) 66 | } 67 | } else if let int32Data = buffer.int32ChannelData { 68 | for channel in 0 ..< channelCount { 69 | // Convert the data from int32 to float values before calculating the power values. 70 | var floatChannelData: [Float] = Array(repeating: Float(0.0), count: Int(buffer.frameLength)) 71 | vDSP_vflt32(int32Data[channel], buffer.stride, &floatChannelData, buffer.stride, length) 72 | var scalar = Float(INT32_MAX) 73 | vDSP_vsdiv(floatChannelData, buffer.stride, &scalar, &floatChannelData, buffer.stride, length) 74 | 75 | powerLevels.append(calculatePowers(data: floatChannelData, strideFrames: buffer.stride, length: length)) 76 | } 77 | } 78 | values = powerLevels 79 | } 80 | 81 | private func calculatePowers(data: UnsafePointer, strideFrames: Int, length: vDSP_Length) -> PowerLevels { 82 | var max: Float = 0.0 83 | vDSP_maxv(data, strideFrames, &max, length) 84 | if max < kMinLevel { 85 | max = kMinLevel 86 | } 87 | 88 | var rms: Float = 0.0 89 | vDSP_rmsqv(data, strideFrames, &rms, length) 90 | if rms < kMinLevel { 91 | rms = kMinLevel 92 | } 93 | 94 | return PowerLevels(average: 20.0 * log10(rms), peak: 20.0 * log10(max)) 95 | } 96 | } 97 | 98 | private struct MeterTable { 99 | // The decibel value of the minimum displayed amplitude. 100 | private let kMinDB: Float = -60.0 101 | 102 | // The table needs to be large enough so that there are no large gaps in the response. 103 | private let tableSize = 300 104 | 105 | private let scaleFactor: Float 106 | private var meterTable = [Float]() 107 | 108 | init() { 109 | let dbResolution = kMinDB / Float(tableSize - 1) 110 | scaleFactor = 1.0 / dbResolution 111 | 112 | // This controls the curvature of the response. 113 | // 2.0 is the square root, 3.0 is the cube root. 114 | let root: Float = 2.0 115 | 116 | let rroot = 1.0 / root 117 | let minAmp = dbToAmp(dBValue: kMinDB) 118 | let ampRange = 1.0 - minAmp 119 | let invAmpRange = 1.0 / ampRange 120 | 121 | for index in 0 ..< tableSize { 122 | let decibels = Float(index) * dbResolution 123 | let amp = dbToAmp(dBValue: decibels) 124 | let adjAmp = (amp - minAmp) * invAmpRange 125 | meterTable.append(powf(adjAmp, rroot)) 126 | } 127 | } 128 | 129 | private func dbToAmp(dBValue: Float) -> Float { 130 | powf(10.0, 0.05 * dBValue) 131 | } 132 | 133 | func valueForPower(_ power: Float) -> Float { 134 | if power < kMinDB { 135 | return 0.0 136 | } else if power >= 0.0 { 137 | return 1.0 138 | } else { 139 | let index = Int(power) * Int(scaleFactor) 140 | return meterTable[index] 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /ishare/Capture/ScreenRecorder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScreenRecorder.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 29.07.23. 6 | // 7 | 8 | import Combine 9 | import Defaults 10 | import Foundation 11 | @preconcurrency import ScreenCaptureKit 12 | import SwiftUI 13 | import AVFoundation 14 | 15 | class AudioLevelsProvider: ObservableObject { 16 | @Published var audioLevels = AudioLevels.zero 17 | } 18 | 19 | @MainActor 20 | class ScreenRecorder: ObservableObject { 21 | @Default(.recordMic) var recordMic 22 | 23 | @Published var isRunning = false 24 | @Published var isAppAudioExcluded = false 25 | @Published private(set) var audioLevelsProvider = AudioLevelsProvider() 26 | 27 | private var scaleFactor: Int { Int(NSScreen.main?.backingScaleFactor ?? 2) } 28 | private var audioMeterCancellable: AnyCancellable? 29 | private let captureEngine = CaptureEngine() 30 | 31 | var canRecord: Bool { 32 | get async { 33 | do { 34 | NSLog("Checking screen recording permissions") 35 | try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) 36 | NSLog("Screen recording permissions granted") 37 | 38 | if recordMic { 39 | } 40 | 41 | return true 42 | } catch { 43 | NSLog("Screen recording permissions denied: %@", error.localizedDescription) 44 | return false 45 | } 46 | } 47 | } 48 | 49 | func start(_ fileURL: URL) async { 50 | guard !isRunning else { 51 | NSLog("Recording already in progress") 52 | return 53 | } 54 | NSLog("Starting screen recording to: %@", fileURL.path) 55 | isRunning = true 56 | 57 | let pickerManager = ContentSharingPickerManager.shared 58 | let localFileURL = fileURL 59 | 60 | await pickerManager.setContentSelectedCallback { [weak self] filter, _ in 61 | Task { @MainActor [weak self] in 62 | guard let self else { return } 63 | await self.startCapture(with: filter, fileURL: localFileURL) 64 | } 65 | } 66 | 67 | await pickerManager.setContentSelectionCancelledCallback { [weak self] _ in 68 | Task { @MainActor [weak self] in 69 | guard let self else { return } 70 | self.isRunning = false 71 | self.stop { _ in } 72 | } 73 | } 74 | 75 | await pickerManager.setContentSelectionFailedCallback { [weak self] _ in 76 | Task { @MainActor [weak self] in 77 | guard let self else { return } 78 | self.isRunning = false 79 | } 80 | } 81 | 82 | let config = SCStreamConfiguration() 83 | let dummyFilter = SCContentFilter() 84 | let stream = SCStream(filter: dummyFilter, configuration: config, delegate: nil) 85 | 86 | pickerManager.setupPicker(stream: stream) 87 | pickerManager.showPicker() 88 | } 89 | 90 | func stop(completion: @escaping @Sendable (Result) -> Void) { 91 | Task { 92 | let stopClosure = await captureEngine.stopCapture() 93 | stopClosure { result in 94 | Task { @MainActor in 95 | switch result { 96 | case let .success(url): 97 | completion(.success(url)) 98 | case let .failure(error): 99 | completion(.failure(error)) 100 | } 101 | } 102 | } 103 | stopAudioMetering() 104 | isRunning = false 105 | } 106 | } 107 | 108 | private func startAudioMetering() { 109 | audioMeterCancellable = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect().sink { [weak self] _ in 110 | guard let self else { return } 111 | audioLevelsProvider.audioLevels = captureEngine.audioLevels 112 | } 113 | } 114 | 115 | private func stopAudioMetering() { 116 | audioMeterCancellable?.cancel() 117 | audioLevelsProvider.audioLevels = AudioLevels.zero 118 | } 119 | 120 | private func startCapture(with filter: SCContentFilter, fileURL: URL) async { 121 | @Default(.useHDR) var useHDR 122 | 123 | let ptRect = filter.contentRect 124 | let pxScale = CGFloat(filter.pointPixelScale) 125 | let pxRect = CGRect(x: ptRect.origin.x * pxScale, y: ptRect.origin.y * pxScale, width: ptRect.width * pxScale, height: ptRect.height * pxScale) 126 | 127 | let config = useHDR ? SCStreamConfiguration(preset: .captureHDRStreamCanonicalDisplay) : SCStreamConfiguration() 128 | config.width = Int(round(pxRect.width / 2) * 2) 129 | config.height = Int(round(pxRect.height / 2) * 2) 130 | config.scalesToFit = false 131 | 132 | isRunning = true 133 | 134 | do { 135 | // Iterating over frames to keep the stream active 136 | for try await _ in captureEngine.startCapture(configuration: config, filter: filter, fileURL: fileURL) {} 137 | } catch { 138 | // Handle errors if necessary 139 | print(error.localizedDescription) 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /ishare/Capture/VideoCapture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoCapture.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 24.07.23. 6 | // 7 | 8 | @preconcurrency import AppKit 9 | import AVFoundation 10 | import BezelNotification 11 | import Cocoa 12 | import Defaults 13 | import Foundation 14 | import ScreenCaptureKit 15 | import SwiftUI 16 | 17 | @MainActor 18 | func recordScreen(gif: Bool? = false) { 19 | NSLog("Starting screen recording, gif mode: %@", String(describing: gif)) 20 | @Default(.openInFinder) var openInFinder 21 | @Default(.recordingPath) var recordingPath 22 | @Default(.recordingFileName) var fileName 23 | @Default(.recordMP4) var recordMP4 24 | 25 | // Get the suffix based on frontmost application 26 | let suffix = if let frontmostApp = NSWorkspace.shared.frontmostApplication { 27 | "-\(frontmostApp.localizedName?.lowercased() ?? "screen")" 28 | } else { 29 | "-screen" 30 | } 31 | 32 | let timestamp = Int(Date().timeIntervalSince1970) 33 | let uniqueFilename = "\(fileName)-\(timestamp)\(suffix)" 34 | let path = NSString(string: "\(recordingPath)\(uniqueFilename).\(recordMP4 ? "mp4" : "mov")").expandingTildeInPath 35 | NSLog("Recording to path: %@ with suffix: %@", path, suffix) 36 | 37 | let fileURL = URL(fileURLWithPath: path) 38 | 39 | if gif ?? false { 40 | AppDelegate.shared.recordGif = true 41 | } 42 | 43 | Task { 44 | if await AppDelegate.shared.screenRecorder.canRecord { 45 | NSLog("Starting screen recording") 46 | await AppDelegate.shared.screenRecorder.start(fileURL) 47 | } else { 48 | NSLog("Screen recording permission denied") 49 | BezelNotification.show(messageText: "Missing permission", icon: ToastIcon) 50 | } 51 | } 52 | } 53 | 54 | @MainActor func postRecordingTasks(_ URL: URL, _ recordGif: Bool) { 55 | @Default(.copyToClipboard) var copyToClipboard 56 | @Default(.openInFinder) var openInFinder 57 | @Default(.recordingPath) var recordingPath 58 | @Default(.recordingFileName) var fileName 59 | @Default(.uploadType) var uploadType 60 | @Default(.uploadMedia) var uploadMedia 61 | @Default(.saveToDisk) var saveToDisk 62 | 63 | func processGif(from url: URL, completion: @escaping (URL?) -> Void) { 64 | let semaphore = DispatchSemaphore(value: 0) 65 | 66 | Task { 67 | do { 68 | let gifURL = try await exportGif(from: url) 69 | completion(gifURL) 70 | } catch { 71 | print("Error processing GIF: \(error)") 72 | completion(nil) 73 | } 74 | semaphore.signal() 75 | } 76 | semaphore.wait() 77 | } 78 | 79 | var fileURL = URL 80 | 81 | if recordGif { 82 | processGif(from: fileURL) { resultingURL in 83 | if let newURL = resultingURL { 84 | fileURL = newURL 85 | } 86 | } 87 | } 88 | 89 | if !FileManager.default.fileExists(atPath: fileURL.path) { 90 | return 91 | } 92 | 93 | if copyToClipboard { 94 | let pasteboard = NSPasteboard.general 95 | pasteboard.clearContents() 96 | 97 | pasteboard.setString(fileURL.absoluteString, forType: .fileURL) 98 | } 99 | 100 | if openInFinder { 101 | NSWorkspace.shared.activateFileViewerSelecting([fileURL]) 102 | } 103 | 104 | if uploadMedia { 105 | let shouldSaveToDisk = saveToDisk 106 | let localFileURL = fileURL 107 | uploadFile(fileURL: fileURL, uploadType: uploadType) { 108 | Task { @MainActor in 109 | showToast(fileURL: localFileURL) { 110 | NSSound.beep() 111 | 112 | if !shouldSaveToDisk { 113 | do { 114 | try FileManager.default.removeItem(at: localFileURL) 115 | } catch { 116 | print("Error deleting the file: \(error)") 117 | } 118 | } 119 | } 120 | } 121 | } 122 | } else { 123 | let shouldSaveToDisk = saveToDisk 124 | let localFileURL = fileURL 125 | Task { @MainActor in 126 | showToast(fileURL: localFileURL) { 127 | NSSound.beep() 128 | 129 | if !shouldSaveToDisk { 130 | do { 131 | try FileManager.default.removeItem(at: localFileURL) 132 | } catch { 133 | print("Error deleting the file: \(error)") 134 | } 135 | } 136 | } 137 | } 138 | } 139 | AppDelegate.shared.recordGif = false 140 | shareBasedOnPreferences(fileURL) 141 | } 142 | 143 | func exportGif(from videoURL: URL) async throws -> URL { 144 | let asset = AVURLAsset(url: videoURL) 145 | 146 | let duration: CMTime = try await asset.load(.duration) 147 | let videoTracks = try await asset.loadTracks(withMediaType: .video) 148 | guard let firstVideoTrack = videoTracks.first else { 149 | throw NSError(domain: "com.castdrian.ishare", code: 3, userInfo: [NSLocalizedDescriptionKey: "No video track found in the asset"]) 150 | } 151 | let size = try await firstVideoTrack.load(.naturalSize) 152 | 153 | let totalDuration = duration.seconds 154 | let frameRate: CGFloat = 30 155 | let totalFrames = Int(totalDuration * TimeInterval(frameRate)) 156 | var timeValues: [CMTime] = [] 157 | 158 | for frameNumber in 0 ..< totalFrames { 159 | let time = CMTime(seconds: Double(frameNumber) / Double(frameRate), preferredTimescale: Int32(NSEC_PER_SEC)) 160 | timeValues.append(time) 161 | } 162 | 163 | let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height) 164 | 165 | let generator = AVAssetImageGenerator(asset: asset) 166 | generator.requestedTimeToleranceBefore = CMTime.zero 167 | generator.requestedTimeToleranceAfter = CMTime.zero 168 | generator.appliesPreferredTrackTransform = true 169 | generator.maximumSize = rect.size 170 | 171 | let delayBetweenFrames: TimeInterval = 1.0 / TimeInterval(frameRate) 172 | let fileProperties: [String: Any] = [ 173 | kCGImagePropertyGIFDictionary as String: [ 174 | kCGImagePropertyGIFLoopCount: 0, 175 | ], 176 | ] 177 | let frameProperties: [String: Any] = [ 178 | kCGImagePropertyGIFDictionary as String: [ 179 | kCGImagePropertyGIFDelayTime: delayBetweenFrames, 180 | ], 181 | ] 182 | 183 | let outputURL = videoURL.deletingPathExtension().appendingPathExtension("gif") 184 | let imageDestination = CGImageDestinationCreateWithURL(outputURL as CFURL, UTType.gif.identifier as CFString, totalFrames, nil)! 185 | CGImageDestinationSetProperties(imageDestination, fileProperties as CFDictionary) 186 | 187 | let localTimeValues = timeValues 188 | let localFrameProperties = frameProperties as CFDictionary 189 | return try await withCheckedThrowingContinuation { continuation in 190 | generator.generateCGImagesAsynchronously(forTimes: localTimeValues.map { NSValue(time: $0) }) { requestedTime, resultingImage, _, _, _ in 191 | if let image = resultingImage { 192 | CGImageDestinationAddImage(imageDestination, image, localFrameProperties) 193 | } 194 | if requestedTime == localTimeValues.last { 195 | let success = CGImageDestinationFinalize(imageDestination) 196 | if success { 197 | do { 198 | try FileManager.default.removeItem(at: videoURL) 199 | continuation.resume(returning: outputURL) 200 | } catch { 201 | continuation.resume(throwing: NSError(domain: "com.castdrian.ishare", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to delete the original video"])) 202 | } 203 | } else { 204 | continuation.resume(throwing: NSError(domain: "com.castdrian.ishare", code: 2, userInfo: [NSLocalizedDescriptionKey: "Gif export failed"])) 205 | } 206 | } 207 | } 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /ishare/Config.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Config.xcconfig 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 06.08.23. 6 | // 7 | 8 | BUILD_NUMBER = 64 9 | VERSION = 4.2.3 10 | -------------------------------------------------------------------------------- /ishare/Http/Custom.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Custom.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 15.07.23. 6 | // 7 | 8 | import Alamofire 9 | import AppKit 10 | import BezelNotification 11 | import Defaults 12 | import Foundation 13 | import SwiftyJSON 14 | 15 | enum CustomUploadError: Error { 16 | case responseParsing 17 | case responseRetrieval 18 | case fileReadError 19 | } 20 | 21 | @MainActor 22 | func customUpload( 23 | fileURL: URL, specification: CustomUploader, 24 | callback: (@Sendable ((any Error)?, URL?) -> Void)? = nil, 25 | completion: @Sendable @escaping () -> Void 26 | ) { 27 | NSLog("Starting custom upload for file: %@", fileURL.path) 28 | NSLog("Using uploader: %@", specification.name) 29 | 30 | guard specification.isValid() else { 31 | NSLog("Error: Invalid uploader specification") 32 | completion() 33 | return 34 | } 35 | 36 | let url = URL(string: specification.requestURL)! 37 | NSLog("Uploading to endpoint: %@", url.absoluteString) 38 | 39 | var headers = HTTPHeaders(specification.headers ?? [:]) 40 | let fileName = fileURL.lastPathComponent 41 | headers.add(name: "x-file-name", value: fileName) 42 | 43 | switch specification.requestBodyType { 44 | case .multipartFormData, .none: 45 | uploadMultipartFormData( 46 | fileURL: fileURL, url: url, headers: headers, specification: specification, 47 | callback: callback, completion: completion 48 | ) 49 | case .binary: 50 | uploadBinaryData( 51 | fileURL: fileURL, url: url, headers: &headers, specification: specification, 52 | callback: callback, completion: completion 53 | ) 54 | } 55 | } 56 | 57 | @MainActor 58 | private func uploadMultipartFormData( 59 | fileURL: URL, url: URL, headers: HTTPHeaders, specification: CustomUploader, 60 | callback: (@Sendable ((any Error)?, URL?) -> Void)?, completion: @Sendable @escaping () -> Void 61 | ) { 62 | let uploadManager = UploadManager.shared 63 | let localCallback = callback 64 | let localCompletion = completion 65 | 66 | AF.upload( 67 | multipartFormData: { multipartFormData in 68 | if let formData = specification.formData { 69 | for (key, value) in formData { 70 | multipartFormData.append(value.data(using: .utf8)!, withName: key) 71 | } 72 | } 73 | 74 | let mimeType = mimeTypeForPathExtension(fileURL.pathExtension) 75 | let lowercasedFileName = fileNameWithLowercaseExtension(from: fileURL) 76 | multipartFormData.append( 77 | fileURL, withName: specification.fileFormName ?? "file", 78 | fileName: lowercasedFileName, mimeType: mimeType 79 | ) 80 | 81 | }, to: url, method: .post, headers: headers 82 | ) 83 | .uploadProgress { progress in 84 | Task { @MainActor in 85 | uploadManager.updateProgress(fraction: progress.fractionCompleted) 86 | } 87 | } 88 | .response { response in 89 | Task { @MainActor in 90 | uploadManager.uploadCompleted() 91 | print(response) 92 | if let data = response.data { 93 | handleResponse( 94 | data: data, specification: specification, callback: localCallback, 95 | completion: localCompletion 96 | ) 97 | } else { 98 | localCallback?(CustomUploadError.responseRetrieval, nil) 99 | localCompletion() 100 | } 101 | } 102 | } 103 | } 104 | 105 | @MainActor 106 | private func uploadBinaryData( 107 | fileURL: URL, url: URL, headers: inout HTTPHeaders, specification: CustomUploader, 108 | callback: (@Sendable ((any Error)?, URL?) -> Void)?, completion: @Sendable @escaping () -> Void 109 | ) { 110 | let uploadManager = UploadManager.shared 111 | let localCallback = callback 112 | let localCompletion = completion 113 | let mimeType = mimeTypeForPathExtension(fileURL.pathExtension) 114 | headers.add(name: "Content-Type", value: mimeType) 115 | 116 | AF.upload(fileURL, to: url, method: .post, headers: headers) 117 | .uploadProgress { progress in 118 | Task { @MainActor in 119 | uploadManager.updateProgress(fraction: progress.fractionCompleted) 120 | } 121 | } 122 | .response { response in 123 | Task { @MainActor in 124 | uploadManager.uploadCompleted() 125 | if let data = response.data { 126 | handleResponse( 127 | data: data, specification: specification, callback: localCallback, 128 | completion: localCompletion 129 | ) 130 | } else { 131 | localCallback?(CustomUploadError.responseRetrieval, nil) 132 | localCompletion() 133 | } 134 | } 135 | } 136 | } 137 | 138 | @MainActor 139 | private func handleResponse( 140 | data: Data, specification: CustomUploader, callback: (@Sendable ((any Error)?, URL?) -> Void)?, 141 | completion: @Sendable () -> Void 142 | ) { 143 | let json = JSON(data) 144 | let fileUrl = constructUrl(from: specification.responseURL, using: json) 145 | let deletionUrl = constructUrl(from: specification.deletionURL, using: json) 146 | 147 | if let fileUrl = URL(string: fileUrl) { 148 | let historyItem = HistoryItem(fileUrl: fileUrl.absoluteString, deletionUrl: deletionUrl) 149 | addToUploadHistory(historyItem) 150 | 151 | let pasteboard = NSPasteboard.general 152 | pasteboard.clearContents() 153 | pasteboard.setString(fileUrl.absoluteString, forType: .string) 154 | callback?(nil, fileUrl) 155 | } else { 156 | callback?(CustomUploadError.responseParsing, nil) 157 | } 158 | completion() 159 | } 160 | 161 | private func constructUrl(from format: String?, using json: JSON) -> String { 162 | guard let format else { return "" } 163 | let (taggedUrl, tags) = tagPlaceholders(in: format) 164 | var url = taggedUrl 165 | 166 | for (tag, keyPath) in tags { 167 | if let replacement = getNestedJSONValue(json: json, keyPath: keyPath) { 168 | url = url.replacingOccurrences(of: tag, with: replacement) 169 | } 170 | } 171 | 172 | return url 173 | } 174 | 175 | private func tagPlaceholders(in url: String) -> (taggedUrl: String, tags: [(String, String)]) { 176 | var taggedUrl = url 177 | var tags: [(String, String)] = [] 178 | 179 | let pattern = "\\{\\{([^}]+)\\}\\}" 180 | let regex = try? NSRegularExpression(pattern: pattern, options: []) 181 | let nsrange = NSRange(url.startIndex ..< url.endIndex, in: url) 182 | 183 | regex?.enumerateMatches(in: url, options: [], range: nsrange) { match, _, _ in 184 | if let match, let range = Range(match.range(at: 1), in: url) { 185 | let key = String(url[range]) 186 | let tag = "%%\(key)_TAG%%" 187 | tags.append((tag, key)) 188 | taggedUrl = taggedUrl.replacingOccurrences(of: "{{\(key)}}", with: tag) 189 | } 190 | } 191 | return (taggedUrl, tags) 192 | } 193 | 194 | private func getNestedJSONValue(json: JSON, keyPath: String) -> String? { 195 | var currentJSON = json 196 | let keyPathElements = keyPath.components(separatedBy: ".") 197 | 198 | for element in keyPathElements { 199 | // Splitting the element to handle nested arrays and objects 200 | let subElements = element.split(whereSeparator: { $0 == "[" || $0 == "]" }).map(String.init) 201 | 202 | for subElement in subElements { 203 | if let index = Int(subElement) { 204 | // Access array by index 205 | currentJSON = currentJSON[index] 206 | } else { 207 | // Access object by key 208 | currentJSON = currentJSON[subElement] 209 | } 210 | } 211 | 212 | // Check if the JSON element is valid 213 | if currentJSON == JSON.null { 214 | return "failed to extract json value for \(element)" 215 | } 216 | } 217 | 218 | return currentJSON.stringValue 219 | } 220 | 221 | private func fileNameWithLowercaseExtension(from url: URL) -> String { 222 | let fileName = url.lastPathComponent 223 | let fileExtension = url.pathExtension.lowercased() 224 | return fileName.replacingOccurrences(of: url.pathExtension, with: fileExtension) 225 | } 226 | 227 | func mimeTypeForPathExtension(_ ext: String) -> String { 228 | switch ext.lowercased() { 229 | case "jpg", "jpeg": 230 | "image/jpeg" 231 | case "png": 232 | "image/png" 233 | case "gif": 234 | "image/gif" 235 | case "pdf": 236 | "application/pdf" 237 | case "mp4": 238 | "video/mp4" 239 | case "mov": 240 | "video/quicktime" 241 | default: 242 | "application/octet-stream" 243 | } 244 | } 245 | 246 | @MainActor 247 | func performDeletionRequest( 248 | deletionUrl: String, completion: @Sendable @escaping (Result) -> Void 249 | ) { 250 | guard let url = URL(string: deletionUrl) else { 251 | completion(.failure(CustomUploadError.responseParsing)) 252 | return 253 | } 254 | 255 | @Default(.activeCustomUploader) var activeCustomUploader 256 | let uploader = CustomUploader.allCases.first(where: { $0.id == activeCustomUploader }) 257 | let headers = HTTPHeaders(uploader?.headers ?? [:]) 258 | 259 | func sendRequest(with method: HTTPMethod) { 260 | AF.request(url, method: method, headers: headers).response { response in 261 | Task { @MainActor in 262 | switch response.result { 263 | case .success: 264 | completion(.success("Deleted file successfully")) 265 | case let .failure(error): 266 | completion(.failure(error)) 267 | } 268 | } 269 | } 270 | } 271 | 272 | switch uploader?.deleteRequestType { 273 | case .get: 274 | sendRequest(with: .get) 275 | case .delete: 276 | sendRequest(with: .delete) 277 | case nil: 278 | sendRequest(with: .get) 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /ishare/Http/Imgur.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Imgur.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 14.07.23. 6 | // 7 | 8 | import Alamofire 9 | import AppKit 10 | import BezelNotification 11 | import Defaults 12 | import Foundation 13 | import SwiftyJSON 14 | 15 | @MainActor func imgurUpload(_ fileURL: URL, completion: @Sendable @escaping () -> Void) { 16 | NSLog("Starting Imgur upload for file: %@", fileURL.path) 17 | @Default(.imgurClientId) var imgurClientId 18 | let uploadManager = UploadManager.shared 19 | 20 | let url = "https://api.imgur.com/3/upload" 21 | 22 | let fileFormName = determineFileFormName(for: fileURL) 23 | let fileName = "ishare.\(fileURL.pathExtension)" 24 | let mimeType = mimeTypeForPathExtension(fileURL.pathExtension) 25 | NSLog("Using file form name: %@, filename: %@", fileFormName, fileName) 26 | 27 | AF.upload(multipartFormData: { multipartFormData in 28 | multipartFormData.append(fileURL, withName: fileFormName, fileName: fileName, mimeType: mimeType) 29 | }, to: url, method: .post, headers: ["Authorization": "Client-ID " + imgurClientId]) 30 | .uploadProgress { progress in 31 | Task { @MainActor in 32 | uploadManager.updateProgress(fraction: progress.fractionCompleted) 33 | } 34 | } 35 | .response { response in 36 | Task { @MainActor in 37 | uploadManager.uploadCompleted() 38 | if let data = response.data { 39 | let json = JSON(data) 40 | if let link = json["data"]["link"].string { 41 | print("Image uploaded successfully. Link: \(link)") 42 | 43 | let pasteboard = NSPasteboard.general 44 | pasteboard.clearContents() 45 | pasteboard.setString(link, forType: .string) 46 | 47 | let historyItem = HistoryItem(fileUrl: link) 48 | addToUploadHistory(historyItem) 49 | completion() 50 | } else { 51 | print("Error parsing response or retrieving image link") 52 | showErrorNotification() 53 | completion() 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | @MainActor 61 | private func showErrorNotification() { 62 | BezelNotification.show(messageText: "An error occured", icon: ToastIcon) 63 | } 64 | 65 | func determineFileFormName(for fileURL: URL) -> String { 66 | switch fileURL.pathExtension.lowercased() { 67 | case "mp4", "mov": 68 | "video" 69 | default: 70 | "image" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /ishare/Http/Upload.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Upload.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 15.07.23. 6 | // 7 | 8 | import Defaults 9 | import Foundation 10 | 11 | enum UploadType: String, CaseIterable, Identifiable, Codable, Defaults.Serializable { 12 | case IMGUR, CUSTOM 13 | 14 | var id: Self { self } 15 | } 16 | 17 | @MainActor func uploadFile(fileURL: URL, uploadType: UploadType, completion: @Sendable @escaping () -> Void) { 18 | let activeUploader = Defaults[.activeCustomUploader] 19 | 20 | switch uploadType { 21 | case .IMGUR: 22 | imgurUpload(fileURL, completion: completion) 23 | case .CUSTOM: 24 | guard let specification = CustomUploader.allCases.first(where: { $0.id == activeUploader }) else { 25 | print("Custom uploader specification not found") 26 | completion() 27 | return 28 | } 29 | customUpload(fileURL: fileURL, specification: specification, completion: completion) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ishare/Http/UploadManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploadManager.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 04.01.24. 6 | // 7 | 8 | import AppKit 9 | import Foundation 10 | import SwiftUI 11 | 12 | @MainActor 13 | final class UploadManager: @unchecked Sendable { 14 | static let shared = UploadManager() 15 | private var progress = Progress() 16 | private var statusItem: NSStatusItem? 17 | private var hostingView: NSHostingView? 18 | 19 | private init() { 20 | setupMenu() 21 | } 22 | 23 | private func setupMenu() { 24 | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) 25 | 26 | if let button = statusItem?.button { 27 | hostingView = NSHostingView(rootView: CircularProgressView(progress: 0)) 28 | let viewSize: CGFloat = 18 29 | let xPosition = (button.bounds.width - viewSize) / 2 30 | let yPosition = (button.bounds.height - viewSize) / 2 31 | hostingView?.frame = CGRect(x: xPosition, y: yPosition, width: viewSize, height: viewSize) 32 | button.addSubview(hostingView!) 33 | } 34 | } 35 | 36 | func updateProgress(fraction: Double) { 37 | NSLog("Upload progress: %.2f%%", fraction * 100) 38 | Task { @MainActor in 39 | self.progress.completedUnitCount = Int64(fraction * 100) 40 | self.hostingView?.rootView = CircularProgressView(progress: fraction) 41 | } 42 | } 43 | 44 | func uploadCompleted() { 45 | NSLog("Upload completed, removing status item") 46 | Task { @MainActor in 47 | if let item = self.statusItem { 48 | NSStatusBar.system.removeStatusItem(item) 49 | self.statusItem = nil 50 | } 51 | } 52 | } 53 | } 54 | 55 | struct CircularProgressView: View { 56 | let progress: Double 57 | 58 | var body: some View { 59 | ZStack { 60 | Circle() 61 | .stroke(lineWidth: 2.0) 62 | .opacity(0.3) 63 | .foregroundColor(Color.gray) 64 | 65 | Circle() 66 | .trim(from: 0.0, to: CGFloat(min(progress, 1.0))) 67 | .stroke(Color.red, style: StrokeStyle(lineWidth: 2.0, lineCap: .round)) 68 | .rotationEffect(Angle(degrees: 270.0)) 69 | .animation(.linear, value: progress) 70 | } 71 | .frame(width: 14, height: 14) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /ishare/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SUEnableInstallerLauncherService 6 | 7 | CFBundleDocumentTypes 8 | 9 | 10 | CFBundleTypeIconFile 11 | 12 | CFBundleTypeIconSystemGenerated 13 | 1 14 | CFBundleTypeName 15 | ishare custom uploader 16 | CFBundleTypeRole 17 | Editor 18 | LSHandlerRank 19 | Owner 20 | LSItemContentTypes 21 | 22 | com.castdrian.ishare 23 | 24 | 25 | 26 | CFBundleURLTypes 27 | 28 | 29 | CFBundleTypeRole 30 | Editor 31 | CFBundleURLIconFile 32 | ISCU 33 | CFBundleURLName 34 | dev.adrian.ishare 35 | CFBundleURLSchemes 36 | 37 | ishare 38 | 39 | 40 | 41 | ITSAppUsesNonExemptEncryption 42 | 43 | NSMicrophoneUsageDescription 44 | NSMicrophoneUsageDescription 45 | 46 | ishare requires access to your microphone if you wish to record it 47 | SUFeedURL 48 | https://isharemac.app/appcast.xml 49 | SUPublicEDKey 50 | iehPcJsCkc5hgiz6Ehb3X4USID60fQbIAFLNfWhA+44= 51 | UTExportedTypeDeclarations 52 | 53 | 54 | UTTypeConformsTo 55 | 56 | public.content 57 | public.data 58 | 59 | UTTypeDescription 60 | ishare custom uploader 61 | UTTypeIconFile 62 | 63 | UTTypeIcons 64 | 65 | UTTypeIconBadgeName 66 | ISCU 67 | UTTypeIconText 68 | ISCU 69 | 70 | UTTypeIdentifier 71 | com.castdrian.ishare 72 | UTTypeTagSpecification 73 | 74 | public.filename-extension 75 | 76 | iscu 77 | 78 | public.mime-type 79 | 80 | application/json 81 | 82 | 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /ishare/Util/AppState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppState.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 12.07.23. 6 | // 7 | 8 | import Defaults 9 | import KeyboardShortcuts 10 | import SwiftUI 11 | import UniformTypeIdentifiers 12 | 13 | @MainActor 14 | final class AppState: ObservableObject { 15 | static let shared = AppState() 16 | 17 | @Default(.showMainMenu) var showMainMenu 18 | @Default(.uploadHistory) var uploadHistory 19 | 20 | init() { 21 | setupKeyboardShortcuts() 22 | } 23 | 24 | func setupKeyboardShortcuts() { 25 | // Regular shortcuts 26 | KeyboardShortcuts.onKeyUp(for: .captureRegion) { 27 | NSLog("Capture region shortcut triggered") 28 | Task { @MainActor in 29 | await captureScreen(type: .REGION) 30 | } 31 | } 32 | 33 | KeyboardShortcuts.onKeyUp(for: .captureWindow) { 34 | Task { @MainActor in 35 | await captureScreen(type: .WINDOW) 36 | } 37 | } 38 | 39 | KeyboardShortcuts.onKeyUp(for: .captureScreen) { 40 | Task { @MainActor in 41 | await captureScreen(type: .SCREEN) 42 | } 43 | } 44 | 45 | KeyboardShortcuts.onKeyUp(for: .recordScreen) { 46 | let screenRecorder = AppDelegate.shared.screenRecorder 47 | if screenRecorder.isRunning { 48 | let pickerManager = ContentSharingPickerManager.shared 49 | pickerManager.deactivatePicker() 50 | AppDelegate.shared.stopRecording() 51 | } else { 52 | recordScreen() 53 | } 54 | } 55 | 56 | KeyboardShortcuts.onKeyUp(for: .recordGif) { 57 | let screenRecorder = AppDelegate.shared.screenRecorder 58 | if screenRecorder.isRunning { 59 | let pickerManager = ContentSharingPickerManager.shared 60 | pickerManager.deactivatePicker() 61 | AppDelegate.shared.stopRecording() 62 | } else { 63 | recordScreen(gif: true) 64 | } 65 | } 66 | 67 | KeyboardShortcuts.onKeyUp(for: .openMostRecentItem) { 68 | guard let mostRecentItem = AppState.shared.uploadHistory.last, 69 | let fileUrlString = mostRecentItem.fileUrl, 70 | let fileURL = URL(string: fileUrlString) 71 | else { 72 | return 73 | } 74 | NSWorkspace.shared.open(fileURL) 75 | } 76 | 77 | KeyboardShortcuts.onKeyUp(for: .uploadPasteBoardItem) { 78 | let pasteboard = NSPasteboard.general 79 | 80 | // First check for media file URL 81 | if let mediaURL = pasteboard.mediaURL { 82 | uploadFile(fileURL: mediaURL, uploadType: Defaults[.uploadType]) { 83 | Task { @MainActor in 84 | showToast(fileURL: mediaURL) { 85 | NSSound.beep() 86 | } 87 | } 88 | } 89 | return 90 | } 91 | 92 | // Then check for raw image data 93 | if let image = pasteboard.imageFromData, 94 | let tiffData = image.tiffRepresentation, 95 | let bitmapImage = NSBitmapImageRep(data: tiffData), 96 | let data = bitmapImage.representation(using: .png, properties: [:]) { 97 | 98 | let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("pasteboard_image.png") 99 | try? data.write(to: tempURL) 100 | 101 | uploadFile(fileURL: tempURL, uploadType: Defaults[.uploadType]) { 102 | Task { @MainActor in 103 | showToast(fileURL: tempURL) { 104 | NSSound.beep() 105 | try? FileManager.default.removeItem(at: tempURL) 106 | } 107 | } 108 | } 109 | } 110 | } 111 | 112 | // Force upload shortcuts 113 | KeyboardShortcuts.onKeyUp(for: .captureRegionForceUpload) { 114 | NSLog("Force upload capture region shortcut triggered") 115 | Task { @MainActor in 116 | Defaults[.uploadMedia] = true 117 | await captureScreen(type: .REGION) 118 | Defaults[.uploadMedia] = false 119 | } 120 | } 121 | 122 | KeyboardShortcuts.onKeyUp(for: .captureWindowForceUpload) { 123 | Task { @MainActor in 124 | Defaults[.uploadMedia] = true 125 | await captureScreen(type: .WINDOW) 126 | Defaults[.uploadMedia] = false 127 | } 128 | } 129 | 130 | KeyboardShortcuts.onKeyUp(for: .captureScreenForceUpload) { 131 | Task { @MainActor in 132 | Defaults[.uploadMedia] = true 133 | await captureScreen(type: .SCREEN) 134 | Defaults[.uploadMedia] = false 135 | } 136 | } 137 | 138 | KeyboardShortcuts.onKeyUp(for: .recordScreenForceUpload) { 139 | let screenRecorder = AppDelegate.shared.screenRecorder 140 | if screenRecorder.isRunning { 141 | let pickerManager = ContentSharingPickerManager.shared 142 | pickerManager.deactivatePicker() 143 | AppDelegate.shared.stopRecording() 144 | } else { 145 | Defaults[.uploadMedia] = true 146 | recordScreen() 147 | Defaults[.uploadMedia] = false 148 | } 149 | } 150 | 151 | KeyboardShortcuts.onKeyUp(for: .recordGifForceUpload) { 152 | let screenRecorder = AppDelegate.shared.screenRecorder 153 | if screenRecorder.isRunning { 154 | let pickerManager = ContentSharingPickerManager.shared 155 | pickerManager.deactivatePicker() 156 | AppDelegate.shared.stopRecording() 157 | } else { 158 | Defaults[.uploadMedia] = true 159 | recordScreen(gif: true) 160 | Defaults[.uploadMedia] = false 161 | } 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "universal", 6 | "reference" : "systemPinkColor" 7 | }, 8 | "idiom" : "universal" 9 | } 10 | ], 11 | "info" : { 12 | "author" : "xcode", 13 | "version" : 1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-128@2x.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-16@2x.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-256@2x.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-32@2x.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "AppIcon-16.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "AppIcon-16@2x.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "AppIcon-32.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "AppIcon-32@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "AppIcon-128.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "AppIcon-128@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "AppIcon-256.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "AppIcon-256@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "AppIcon-512.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "AppIcon-512@2x.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/GlyphIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "glyph.svg", 5 | "idiom" : "universal" 6 | }, 7 | { 8 | "appearances" : [ 9 | { 10 | "appearance" : "luminosity", 11 | "value" : "light" 12 | } 13 | ], 14 | "filename" : "glyph_black.svg", 15 | "idiom" : "universal" 16 | }, 17 | { 18 | "appearances" : [ 19 | { 20 | "appearance" : "luminosity", 21 | "value" : "dark" 22 | } 23 | ], 24 | "filename" : "glyph_white.svg", 25 | "idiom" : "universal" 26 | } 27 | ], 28 | "info" : { 29 | "author" : "xcode", 30 | "version" : 1 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/GlyphIcon.imageset/glyph.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/GlyphIcon.imageset/glyph_black.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/GlyphIcon.imageset/glyph_white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/ISCU.iconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/ISCU.iconset/icon_128x128.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/ISCU.iconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/ISCU.iconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/ISCU.iconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/ISCU.iconset/icon_16x16.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/ISCU.iconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/ISCU.iconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/ISCU.iconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/ISCU.iconset/icon_256x256.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/ISCU.iconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/ISCU.iconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/ISCU.iconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/ISCU.iconset/icon_32x32.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/ISCU.iconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/ISCU.iconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/ISCU.iconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/ISCU.iconset/icon_512x512.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/ISCU.iconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/ISCU.iconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/Imgur.iconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/Imgur.iconset/icon_128x128.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/Imgur.iconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/Imgur.iconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/Imgur.iconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/Imgur.iconset/icon_16x16.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/Imgur.iconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/Imgur.iconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/Imgur.iconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/Imgur.iconset/icon_256x256.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/Imgur.iconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/Imgur.iconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/Imgur.iconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/Imgur.iconset/icon_32x32.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/Imgur.iconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/Imgur.iconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/Imgur.iconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/Imgur.iconset/icon_512x512.png -------------------------------------------------------------------------------- /ishare/Util/Assets.xcassets/Imgur.iconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itoolio/ishare/c805b3756e742ba40903ad9f65bdeaa47b4a48c4/ishare/Util/Assets.xcassets/Imgur.iconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /ishare/Util/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 12.07.23. 6 | // 7 | 8 | import Alamofire 9 | import AVFoundation 10 | import BezelNotification 11 | import Carbon 12 | import Defaults 13 | import KeyboardShortcuts 14 | import ScreenCaptureKit 15 | import SwiftUI 16 | import SwiftyJSON 17 | import Zip 18 | 19 | extension KeyboardShortcuts.Name { 20 | static let noKeybind = Self("noKeybind") 21 | static let toggleMainMenu = Self("toggleMainMenu", default: .init(.s, modifiers: [.option, .command])) 22 | static let captureRegion = Self("captureRegion", default: .init(.p, modifiers: [.option, .command])) 23 | static let captureWindow = Self("captureWindow", default: .init(.p, modifiers: [.control, .option])) 24 | static let captureScreen = Self("captureScreen", default: .init(.x, modifiers: [.option, .command])) 25 | static let recordScreen = Self("recordScreen", default: .init(.z, modifiers: [.control, .option])) 26 | static let recordGif = Self("recordGif", default: .init(.g, modifiers: [.control, .option])) 27 | static let openHistoryWindow = Self("openHistoryWindow", default: .init(.k, modifiers: [.command, .option])) 28 | 29 | static let openMostRecentItem = Self("openMostRecentItem", default: .init(.o, modifiers: [.control, .option])) 30 | static let uploadPasteBoardItem = Self("uploadPasteBoardItem", default: .init(.u, modifiers: [.control, .option])) 31 | 32 | 33 | // Force upload variants 34 | static let captureRegionForceUpload = Self("captureRegionForceUpload", default: .init(.p, modifiers: [.shift, .option, .command])) 35 | static let captureWindowForceUpload = Self("captureWindowForceUpload", default: .init(.p, modifiers: [.shift, .control, .option])) 36 | static let captureScreenForceUpload = Self("captureScreenForceUpload", default: .init(.x, modifiers: [.shift, .option, .command])) 37 | static let recordScreenForceUpload = Self("recordScreenForceUpload", default: .init(.z, modifiers: [.shift, .control, .option])) 38 | static let recordGifForceUpload = Self("recordGifForceUpload", default: .init(.g, modifiers: [.shift, .control, .option])) 39 | } 40 | 41 | extension Defaults.Keys { 42 | static let showMainMenu = Key("showMainMenu", default: false, iCloud: true) 43 | static let copyToClipboard = Key("copyToClipboard", default: true, iCloud: true) 44 | static let openInFinder = Key("openInFinder", default: false, iCloud: true) 45 | static let saveToDisk = Key("saveToDisk", default: true, iCloud: true) 46 | static let uploadMedia = Key("uploadMedia", default: false, iCloud: true) 47 | static let capturePath = Key("capturePath", default: "~/Pictures/", iCloud: true) 48 | static let recordingPath = Key("recordingPath", default: "~/Pictures/", iCloud: true) 49 | static let captureFileType = Key("captureFileType", default: .PNG, iCloud: true) 50 | static let captureFileName = Key("captureFileName", default: "ishare", iCloud: true) 51 | static let recordingFileName = Key("recordingFileName", default: "ishare", iCloud: true) 52 | static let imgurClientId = Key("imgurClientId", default: "867afe9433c0a53", iCloud: true) 53 | static let captureBinary = Key("captureBinary", default: "/usr/sbin/screencapture", iCloud: true) 54 | static let activeCustomUploader = Key("activeCustomUploader", default: nil, iCloud: true) 55 | static let savedCustomUploaders = Key?>("savedCustomUploaders", iCloud: true) 56 | static let uploadType = Key("uploadType", default: .IMGUR, iCloud: true) 57 | static let uploadDestination = Key("uploadDestination", default: .builtIn(.IMGUR), iCloud: true) 58 | static let recordMP4 = Key("recordMP4", default: true, iCloud: true) 59 | static let useHEVC = Key("useHEVC", default: false, iCloud: true) 60 | static let useHDR = Key("useHDR", default: false, iCloud: true) 61 | static let recordAudio = Key("recordAudio", default: true, iCloud: true) 62 | static let recordMic = Key("recordMic", default: false, iCloud: true) 63 | static let recordPointer = Key("recordPointer", default: true, iCloud: true) 64 | static let recordClicks = Key("recordClicks", default: false, iCloud: true) 65 | static let builtInShare = Key("builtInShare", default: .init(), iCloud: true) 66 | static let toastTimeout = Key("toastTimeout", default: 2, iCloud: true) 67 | static let menuBarIcon = Key("menuBarIcon", default: .DEFAULT, iCloud: true) 68 | static let uploadHistory = Key<[HistoryItem]>("uploadHistory", default: [], iCloud: true) 69 | static let ignoredBundleIdentifiers = Key<[String]>("ignoredApps", default: [], iCloud: true) 70 | static let forceUploadModifier = Key("forceUploadModifier", default: .shift) 71 | static let storedLanguage = Key("storedlanguage", default: Locale.current.identifier.starts(with: "en-AU") ? .aussie : .english, iCloud: true) 72 | static let aussieMode = Key("aussieMode", default: Locale.current.identifier.starts(with: "en-AU"), iCloud: true) 73 | } 74 | 75 | extension KeyboardShortcuts.Shortcut { 76 | func toKeyEquivalent() -> KeyEquivalent? { 77 | let carbonKeyCode = UInt16(carbonKeyCode) 78 | let maxNameLength = 4 79 | var nameBuffer = [UniChar](repeating: 0, count: maxNameLength) 80 | var nameLength = 0 81 | 82 | let modifierKeys = UInt32(alphaLock >> 8) & 0xFF // Caps Lock 83 | var deadKeys: UInt32 = 0 84 | let keyboardType = UInt32(LMGetKbdType()) 85 | 86 | let source = TISCopyCurrentKeyboardLayoutInputSource().takeRetainedValue() 87 | guard let ptr = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { 88 | NSLog("Could not get keyboard layout data") 89 | return nil 90 | } 91 | let layoutData = Unmanaged.fromOpaque(ptr).takeUnretainedValue() as Data 92 | let osStatus = layoutData.withUnsafeBytes { 93 | UCKeyTranslate($0.bindMemory(to: UCKeyboardLayout.self).baseAddress, carbonKeyCode, UInt16(kUCKeyActionDown), 94 | modifierKeys, keyboardType, UInt32(kUCKeyTranslateNoDeadKeysMask), 95 | &deadKeys, maxNameLength, &nameLength, &nameBuffer) 96 | } 97 | guard osStatus == noErr else { 98 | NSLog("Code: 0x%04X Status: %+i", carbonKeyCode, osStatus) 99 | return nil 100 | } 101 | 102 | return KeyEquivalent(Character(String(utf16CodeUnits: nameBuffer, count: nameLength))) 103 | } 104 | 105 | func toEventModifiers() -> SwiftUI.EventModifiers { 106 | var modifiers: SwiftUI.EventModifiers = [] 107 | 108 | if self.modifiers.contains(NSEvent.ModifierFlags.command) { 109 | modifiers.update(with: EventModifiers.command) 110 | } 111 | 112 | if self.modifiers.contains(NSEvent.ModifierFlags.control) { 113 | modifiers.update(with: EventModifiers.control) 114 | } 115 | 116 | if self.modifiers.contains(NSEvent.ModifierFlags.option) { 117 | modifiers.update(with: EventModifiers.option) 118 | } 119 | 120 | if self.modifiers.contains(NSEvent.ModifierFlags.shift) { 121 | modifiers.update(with: EventModifiers.shift) 122 | } 123 | 124 | if self.modifiers.contains(NSEvent.ModifierFlags.capsLock) { 125 | modifiers.update(with: EventModifiers.capsLock) 126 | } 127 | 128 | if self.modifiers.contains(NSEvent.ModifierFlags.numericPad) { 129 | modifiers.update(with: EventModifiers.numericPad) 130 | } 131 | 132 | return modifiers 133 | } 134 | } 135 | 136 | extension utsname { 137 | static var sMachine: String { 138 | var utsname = utsname() 139 | uname(&utsname) 140 | return withUnsafePointer(to: &utsname.machine) { 141 | $0.withMemoryRebound(to: CChar.self, capacity: Int(_SYS_NAMELEN)) { 142 | String(cString: $0) 143 | } 144 | } 145 | } 146 | 147 | static var isAppleSilicon: Bool { 148 | sMachine == "arm64" 149 | } 150 | } 151 | 152 | @MainActor 153 | func selectFolder(completion: @escaping (URL?) -> Void) { 154 | let folderPicker = NSOpenPanel() 155 | folderPicker.canChooseDirectories = true 156 | folderPicker.canChooseFiles = false 157 | folderPicker.allowsMultipleSelection = false 158 | folderPicker.canDownloadUbiquitousContents = true 159 | folderPicker.canResolveUbiquitousConflicts = true 160 | 161 | folderPicker.begin { response in 162 | if response == .OK { 163 | completion(folderPicker.urls.first) 164 | } else { 165 | completion(nil) 166 | } 167 | } 168 | } 169 | 170 | @MainActor 171 | func importIscu(_ url: URL) { 172 | NSLog("Starting ISCU import process for file: %@", url.path) 173 | if let keyWindow = NSApplication.shared.keyWindow { 174 | let alert = NSAlert() 175 | alert.messageText = "Import ISCU" 176 | alert.informativeText = "Do you want to import this custom uploader?" 177 | alert.addButton(withTitle: "Import") 178 | alert.addButton(withTitle: "Cancel") 179 | NSLog("Showing import confirmation dialog") 180 | alert.beginSheetModal(for: keyWindow) { response in 181 | if response == .alertFirstButtonReturn { 182 | NSLog("User confirmed import") 183 | alert.window.orderOut(nil) 184 | importFile(url) { success, error in 185 | Task { @MainActor in 186 | if success { 187 | NSLog("ISCU import successful") 188 | let successAlert = NSAlert() 189 | successAlert.messageText = "Import Successful" 190 | successAlert.informativeText = "The custom uploader has been imported successfully." 191 | successAlert.addButton(withTitle: "OK") 192 | successAlert.runModal() 193 | } else if let error { 194 | NSLog("ISCU import failed: %@", error.localizedDescription) 195 | let errorAlert = NSAlert() 196 | errorAlert.messageText = "Import Error" 197 | errorAlert.informativeText = error.localizedDescription 198 | errorAlert.addButton(withTitle: "OK") 199 | errorAlert.runModal() 200 | } 201 | } 202 | } 203 | } else { 204 | NSLog("User cancelled import") 205 | } 206 | } 207 | } 208 | } 209 | 210 | @MainActor func importFile(_ url: URL, completion: @escaping (Bool, (any Error)?) -> Void) { 211 | do { 212 | let data = try Data(contentsOf: url) 213 | let decoder = JSONDecoder() 214 | let uploader = try decoder.decode(CustomUploader.self, from: data) 215 | 216 | @Default(.savedCustomUploaders) var savedCustomUploaders 217 | @Default(.activeCustomUploader) var activeCustomUploader 218 | @Default(.uploadType) var uploadType 219 | 220 | if var uploaders = savedCustomUploaders { 221 | uploaders.remove(uploader) 222 | uploaders.insert(uploader) 223 | savedCustomUploaders = uploaders 224 | } else { 225 | savedCustomUploaders = Set([uploader]) 226 | } 227 | 228 | activeCustomUploader = uploader.id 229 | uploadType = .CUSTOM 230 | 231 | completion(true, nil) // Success callback 232 | } catch { 233 | completion(false, error) // Error callback 234 | } 235 | } 236 | 237 | struct Contributor: Codable { 238 | let login: String 239 | let avatarURL: URL 240 | 241 | enum CodingKeys: String, CodingKey { 242 | case login 243 | case avatarURL = "avatar_url" 244 | } 245 | } 246 | 247 | @MainActor 248 | let AppIcon: NSImage = { 249 | let appIconImage = NSImage(named: "AppIcon") 250 | let ratio = (appIconImage?.size.height)! / (appIconImage?.size.width)! 251 | let newSize = NSSize(width: 18, height: 18 / ratio) 252 | let resizedImage = NSImage(size: newSize) 253 | resizedImage.lockFocus() 254 | appIconImage?.draw(in: NSRect(origin: .zero, size: newSize), from: NSRect(origin: .zero, size: appIconImage!.size), operation: .copy, fraction: 1.0) 255 | resizedImage.unlockFocus() 256 | return resizedImage 257 | }() 258 | 259 | @MainActor 260 | let GlyphIcon: NSImage = { 261 | let appIconImage = NSImage(named: "GlyphIcon")! 262 | let ratio = appIconImage.size.height / appIconImage.size.width 263 | let newSize = NSSize(width: 18, height: 18 / ratio) 264 | let resizedImage = NSImage(size: newSize) 265 | resizedImage.lockFocus() 266 | appIconImage.draw(in: NSRect(origin: .zero, size: newSize), from: NSRect(origin: .zero, size: appIconImage.size), operation: .copy, fraction: 1.0) 267 | resizedImage.unlockFocus() 268 | return resizedImage 269 | }() 270 | 271 | @MainActor 272 | let ImgurIcon: NSImage = { 273 | let appIconImage = NSImage(named: "Imgur") 274 | let ratio = (appIconImage?.size.height)! / (appIconImage?.size.width)! 275 | let newSize = NSSize(width: 18, height: 18 / ratio) 276 | let resizedImage = NSImage(size: newSize) 277 | resizedImage.lockFocus() 278 | appIconImage?.draw(in: NSRect(origin: .zero, size: newSize), from: NSRect(origin: .zero, size: appIconImage!.size), operation: .copy, fraction: 1.0) 279 | resizedImage.unlockFocus() 280 | return resizedImage 281 | }() 282 | 283 | @MainActor 284 | let ToastIcon: NSImage = { 285 | let toastIconImage = NSImage(named: "AppIcon") 286 | let ratio = (toastIconImage?.size.height)! / (toastIconImage?.size.width)! 287 | let newSize = NSSize(width: 100, height: 100 / ratio) 288 | let resizedImage = NSImage(size: newSize) 289 | resizedImage.lockFocus() 290 | toastIconImage?.draw(in: NSRect(origin: .zero, size: newSize), from: NSRect(origin: .zero, size: toastIconImage!.size), operation: .copy, fraction: 1.0) 291 | resizedImage.unlockFocus() 292 | return resizedImage 293 | }() 294 | 295 | @MainActor 296 | func icon(forAppWithName appName: String) -> NSImage? { 297 | if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: appName) { 298 | return NSWorkspace.shared.icon(forFile: appURL.path) 299 | } 300 | return nil 301 | } 302 | 303 | let airdropIconPath = Bundle.path(forResource: "AirDrop", ofType: "icns", inDirectory: "/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources") 304 | 305 | @MainActor let airdropIcon = NSImage(contentsOfFile: airdropIconPath!) 306 | 307 | struct SharingPreferences: Codable, Defaults.Serializable { 308 | var airdrop: Bool = false 309 | var photos: Bool = false 310 | var messages: Bool = false 311 | var mail: Bool = false 312 | } 313 | 314 | @MainActor 315 | func shareBasedOnPreferences(_ fileURL: URL) { 316 | NSLog("Processing share preferences for file: %@", fileURL.path) 317 | let preferences = Defaults[.builtInShare] 318 | 319 | if preferences.airdrop { 320 | NSLog("Sharing via AirDrop") 321 | NSSharingService(named: .sendViaAirDrop)?.perform(withItems: [fileURL]) 322 | } 323 | 324 | if preferences.photos { 325 | NSLog("Adding to Photos") 326 | NSSharingService(named: .addToIPhoto)?.perform(withItems: [fileURL]) 327 | } 328 | 329 | if preferences.messages { 330 | NSLog("Sharing via Messages") 331 | NSSharingService(named: .composeMessage)?.perform(withItems: [fileURL]) 332 | } 333 | 334 | if preferences.mail { 335 | NSLog("Sharing via Mail") 336 | NSSharingService(named: .composeEmail)?.perform(withItems: [fileURL]) 337 | } 338 | } 339 | 340 | enum MenuBarIcon: Codable, CaseIterable, Identifiable, Defaults.Serializable { 341 | case DEFAULT 342 | case APPICON 343 | case SYSTEM 344 | var id: Self { self } 345 | } 346 | 347 | struct HistoryItem: Codable, Hashable, Defaults.Serializable { 348 | var fileUrl: String? 349 | var deletionUrl: String? 350 | var id: Self { self } 351 | } 352 | 353 | func addToUploadHistory(_ item: HistoryItem) { 354 | NSLog("Adding item to upload history: %@", item.fileUrl ?? "nil") 355 | var history = Defaults[.uploadHistory] 356 | history.insert(item, at: 0) 357 | if history.count > 50 { 358 | NSLog("Upload history exceeded 50 items, removing oldest entry") 359 | history.removeLast() 360 | } 361 | Defaults[.uploadHistory] = history 362 | } 363 | 364 | @MainActor 365 | struct ExcludedAppsView: View { 366 | @Environment(\.presentationMode) var presentationMode 367 | @Default(.ignoredBundleIdentifiers) var ignoredBundleIdentifiers 368 | 369 | var body: some View { 370 | VStack { 371 | Text("Select apps to exclude") 372 | .font(.title) 373 | 374 | Divider() 375 | 376 | ScrollView { 377 | ForEach(NSWorkspace.shared.runningApplications.sorted { $0.localizedName ?? "" < $1.localizedName ?? "" }.filter { $0.bundleIdentifier != Bundle.main.bundleIdentifier }, id: \.self) { app in 378 | Toggle(isOn: Binding( 379 | get: { 380 | ignoredBundleIdentifiers.contains(app.bundleIdentifier ?? "") 381 | }, 382 | set: { newValue in 383 | if newValue { 384 | ignoredBundleIdentifiers.append(app.bundleIdentifier ?? "") 385 | } else { 386 | ignoredBundleIdentifiers.removeAll { $0 == app.bundleIdentifier } 387 | } 388 | } 389 | )) { 390 | Text(app.localizedName ?? app.bundleIdentifier ?? "unknown") 391 | } 392 | .toggleStyle(.checkbox) 393 | } 394 | } 395 | 396 | Spacer() 397 | 398 | Button("Close") { 399 | presentationMode.wrappedValue.dismiss() 400 | } 401 | .padding(.bottom) 402 | } 403 | .padding() 404 | } 405 | } 406 | 407 | enum ForceUploadModifier: String, CaseIterable, Identifiable, Defaults.Serializable { 408 | case shift = "⇧" 409 | case control = "⌃" 410 | case option = "⌥" 411 | case command = "⌘" 412 | 413 | var id: Self { self } 414 | 415 | var modifierFlag: NSEvent.ModifierFlags { 416 | switch self { 417 | case .shift: .shift 418 | case .control: .control 419 | case .option: .option 420 | case .command: .command 421 | } 422 | } 423 | } 424 | 425 | enum UploadDestination: Equatable, Hashable, Codable, Defaults.Serializable { 426 | case builtIn(UploadType) 427 | case custom(UUID?) 428 | } 429 | 430 | @MainActor 431 | class WindowHolder: Sendable { 432 | static let shared = WindowHolder() 433 | var historyWindowController: HistoryWindowController? 434 | } 435 | 436 | @MainActor 437 | class HistoryWindowController: NSWindowController { 438 | convenience init(contentView: NSView) { 439 | let window = NSWindow( 440 | contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), 441 | styleMask: [.titled, .closable], 442 | backing: .buffered, defer: false 443 | ) 444 | window.center() 445 | window.contentView = contentView 446 | self.init(window: window) 447 | } 448 | 449 | override func windowDidLoad() { 450 | super.windowDidLoad() 451 | } 452 | } 453 | 454 | @MainActor 455 | func openHistoryWindow(uploadHistory _: [HistoryItem]) { 456 | if WindowHolder.shared.historyWindowController == nil { 457 | let historyView = HistoryGridView() 458 | let hostingController = NSHostingController(rootView: historyView) 459 | let windowController = HistoryWindowController(contentView: hostingController.view) 460 | windowController.window?.title = "History".localized() 461 | 462 | windowController.showWindow(nil) 463 | NSApp.activate(ignoringOtherApps: true) 464 | 465 | WindowHolder.shared.historyWindowController = windowController 466 | } else { 467 | WindowHolder.shared.historyWindowController?.window?.makeKeyAndOrderFront(nil) 468 | NSApp.activate(ignoringOtherApps: true) 469 | } 470 | } 471 | -------------------------------------------------------------------------------- /ishare/Util/CustomUploader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomUploader.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 15.07.23. 6 | // 7 | 8 | import Defaults 9 | import Foundation 10 | 11 | enum RequestBodyType: String, Codable { 12 | case multipartFormData = "multipartformdata" 13 | case binary 14 | } 15 | 16 | enum DeleteRequestType: String, Codable { 17 | case get = "GET" 18 | case delete = "DELETE" 19 | 20 | init(from decoder: any Decoder) throws { 21 | let container = try decoder.singleValueContainer() 22 | let rawValue = try container.decode(String.self) 23 | switch rawValue.uppercased() { 24 | case "GET": 25 | self = .get 26 | case "DELETE": 27 | self = .delete 28 | default: 29 | throw DecodingError.dataCorruptedError( 30 | in: container, debugDescription: "Invalid delete request type: \(rawValue)" 31 | ) 32 | } 33 | } 34 | } 35 | 36 | struct CustomUploader: Codable, Hashable, Equatable, CaseIterable, Identifiable, Defaults 37 | .Serializable 38 | { 39 | var id: UUID 40 | let name: String 41 | let requestURL: String 42 | let headers: [String: String]? 43 | let formData: [String: String]? 44 | let fileFormName: String? 45 | let requestBodyType: RequestBodyType? 46 | let responseURL: String 47 | let deletionURL: String? 48 | let deleteRequestType: DeleteRequestType? 49 | 50 | init( 51 | id: UUID = UUID(), name: String, requestURL: String, headers: [String: String]?, 52 | formData: [String: String]?, fileFormName: String?, requestBodyType: RequestBodyType? = nil, 53 | responseURL: String, deletionURL: String? = nil, deleteRequestType: DeleteRequestType? = nil 54 | ) { 55 | self.id = id 56 | self.name = name 57 | self.requestURL = requestURL 58 | self.headers = headers 59 | self.formData = formData 60 | self.fileFormName = fileFormName 61 | self.requestBodyType = requestBodyType 62 | self.responseURL = responseURL 63 | self.deletionURL = deletionURL 64 | self.deleteRequestType = deleteRequestType 65 | } 66 | 67 | enum CodingKeys: String, CodingKey { 68 | case id 69 | case name 70 | case requestURL = "requesturl" 71 | case headers 72 | case formData = "formdata" 73 | case fileFormName = "fileformname" 74 | case requestBodyType = "requestbodytype" 75 | case responseURL = "responseurl" 76 | case deletionURL = "deletionurl" 77 | case deleteRequestType = "deleterequesttype" 78 | } 79 | 80 | init(from decoder: any Decoder) throws { 81 | let container = try decoder.container(keyedBy: DynamicCodingKey.self) 82 | 83 | id = 84 | try container.decodeDynamicIfPresent( 85 | UUID.self, forKey: DynamicCodingKey(stringValue: "id")! 86 | ) ?? UUID() 87 | name = try container.decodeDynamic( 88 | String.self, forKey: DynamicCodingKey(stringValue: "name")! 89 | ) 90 | requestURL = try container.decodeDynamic( 91 | String.self, forKey: DynamicCodingKey(stringValue: "requesturl")! 92 | ) 93 | headers = try container.decodeDynamicIfPresent( 94 | [String: String].self, forKey: DynamicCodingKey(stringValue: "headers")! 95 | ) 96 | formData = try container.decodeDynamicIfPresent( 97 | [String: String].self, forKey: DynamicCodingKey(stringValue: "formdata")! 98 | ) 99 | fileFormName = try container.decodeDynamicIfPresent( 100 | String.self, forKey: DynamicCodingKey(stringValue: "fileformname")! 101 | ) 102 | requestBodyType = try container.decodeDynamicIfPresent( 103 | RequestBodyType.self, forKey: DynamicCodingKey(stringValue: "requestbodytype")! 104 | ) 105 | responseURL = try container.decodeDynamic( 106 | String.self, forKey: DynamicCodingKey(stringValue: "responseurl")! 107 | ) 108 | deletionURL = try container.decodeDynamicIfPresent( 109 | String.self, forKey: DynamicCodingKey(stringValue: "deletionurl")! 110 | ) 111 | deleteRequestType = try container.decodeDynamicIfPresent( 112 | DeleteRequestType.self, forKey: DynamicCodingKey(stringValue: "deleterequesttype")! 113 | ) 114 | } 115 | 116 | static var allCases: [CustomUploader] { 117 | Defaults[.savedCustomUploaders]?.sorted(by: { $0.name < $1.name }) ?? [] 118 | } 119 | 120 | static func == (lhs: CustomUploader, rhs: CustomUploader) -> Bool { 121 | lhs.id == rhs.id 122 | } 123 | 124 | func hash(into hasher: inout Hasher) { 125 | hasher.combine(id) 126 | } 127 | 128 | static func fromJSON(_ json: Data) throws -> CustomUploader { 129 | // Convert JSON data to a dictionary 130 | guard 131 | let jsonObject = try JSONSerialization.jsonObject(with: json, options: []) 132 | as? [String: Any] 133 | else { 134 | throw DecodingError.dataCorrupted( 135 | DecodingError.Context(codingPath: [], debugDescription: "Invalid JSON structure")) 136 | } 137 | 138 | // Convert all keys in the dictionary to lowercase 139 | let lowercasedKeysJsonObject = jsonObject.reduce(into: [String: Any]()) { result, element in 140 | result[element.key.lowercased()] = element.value 141 | } 142 | 143 | // Encode the modified dictionary back to Data 144 | let modifiedJsonData = try JSONSerialization.data( 145 | withJSONObject: lowercasedKeysJsonObject, options: [] 146 | ) 147 | 148 | // Decode using the modified JSON data 149 | let decoder = JSONDecoder() 150 | return try decoder.decode(CustomUploader.self, from: modifiedJsonData) 151 | } 152 | 153 | func toJSON() throws -> Data { 154 | let encoder = JSONEncoder() 155 | return try encoder.encode(self) 156 | } 157 | 158 | func isValid() -> Bool { 159 | guard !requestURL.isEmpty, !responseURL.isEmpty else { 160 | return false 161 | } 162 | 163 | if let headers { 164 | guard headers as (any Codable) is [String: String] else { 165 | return false 166 | } 167 | } 168 | 169 | if let formData { 170 | guard formData as (any Codable) is [String: String] else { 171 | return false 172 | } 173 | } 174 | 175 | if let fileFormName { 176 | guard !fileFormName.isEmpty else { 177 | return false 178 | } 179 | } 180 | 181 | return true 182 | } 183 | } 184 | 185 | struct DynamicCodingKey: CodingKey { 186 | var stringValue: String 187 | var intValue: Int? 188 | 189 | init?(stringValue: String) { 190 | self.stringValue = stringValue 191 | } 192 | 193 | init?(intValue: Int) { 194 | stringValue = String(intValue) 195 | self.intValue = intValue 196 | } 197 | 198 | static func key(named name: String) -> DynamicCodingKey { 199 | DynamicCodingKey(stringValue: name)! 200 | } 201 | } 202 | 203 | extension KeyedDecodingContainer { 204 | func decodeDynamic(_: T.Type, forKey key: DynamicCodingKey) throws -> T { 205 | let keyString = key.stringValue.lowercased() 206 | guard let dynamicKey = allKeys.first(where: { $0.stringValue.lowercased() == keyString }) 207 | else { 208 | throw DecodingError.keyNotFound( 209 | key, 210 | DecodingError.Context( 211 | codingPath: codingPath, 212 | debugDescription: "No value associated with key \(keyString)" 213 | ) 214 | ) 215 | } 216 | return try decode(T.self, forKey: dynamicKey) 217 | } 218 | 219 | func decodeDynamicIfPresent(_: T.Type, forKey key: DynamicCodingKey) throws -> T? { 220 | let keyString = key.stringValue.lowercased() 221 | guard let dynamicKey = allKeys.first(where: { $0.stringValue.lowercased() == keyString }) 222 | else { 223 | return nil 224 | } 225 | return try decodeIfPresent(T.self, forKey: dynamicKey) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /ishare/Util/LocalizableManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalizableManager.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 16/1/25. 6 | // 7 | 8 | import Defaults 9 | import Foundation 10 | import SwiftUI 11 | 12 | enum LanguageTypes: String, CaseIterable, RawRepresentable, Defaults.Serializable { 13 | case english = "en" 14 | case aussie = "en-AU" 15 | case arabic = "ar" 16 | case chinese = "zh-CN" 17 | case french = "fr" 18 | case german = "de" 19 | case hindi = "hi" 20 | case japanese = "ja" 21 | case korean = "ko" 22 | case spanish = "es" 23 | case turkish = "tr" 24 | case ukrainian = "uk" 25 | 26 | var name: String { 27 | switch self { 28 | case .english: "English" 29 | case .aussie: "English (Australia)" 30 | case .arabic: "عربي" 31 | case .chinese: "中文" 32 | case .french: "Français" 33 | case .german: "Deutsch" 34 | case .hindi: "हिन्दी" 35 | case .japanese: "日本語" 36 | case .korean: "한국어" 37 | case .spanish: "Español" 38 | case .turkish: "Türkçe" 39 | case .ukrainian: "Українська" 40 | } 41 | } 42 | } 43 | 44 | @MainActor 45 | extension Bundle { 46 | private static var bundle: Bundle! 47 | 48 | static func setLanguage(language: String) { 49 | let path = Bundle.main.path(forResource: language, ofType: "lproj") 50 | bundle = path != nil ? Bundle(path: path!) : Bundle.main 51 | } 52 | 53 | static func localizedBundle() -> Bundle { 54 | bundle ?? Bundle.main 55 | } 56 | } 57 | 58 | extension String { 59 | @MainActor func localized() -> String { 60 | Bundle.localizedBundle().localizedString(forKey: self, value: nil, table: nil) 61 | } 62 | } 63 | 64 | @MainActor 65 | class LocalizableManager: ObservableObject { 66 | static let shared = LocalizableManager() 67 | 68 | @Default(.storedLanguage) var storedLanguage 69 | @Default(.aussieMode) var aussieMode 70 | 71 | @Published var currentLanguage: LanguageTypes = .english { 72 | didSet { 73 | storedLanguage = currentLanguage 74 | Bundle.setLanguage(language: currentLanguage.rawValue) 75 | } 76 | } 77 | 78 | @Published var showRestartAlert = false 79 | private var pendingLanguage: LanguageTypes? 80 | 81 | func changeLanguage(to language: LanguageTypes) { 82 | guard language != currentLanguage else { return } 83 | pendingLanguage = language 84 | showRestartAlert = true 85 | } 86 | 87 | func confirmLanguageChange() { 88 | guard let newLanguage = pendingLanguage else { return } 89 | currentLanguage = newLanguage 90 | if currentLanguage == .aussie { 91 | aussieMode = true 92 | } else { 93 | aussieMode = false 94 | } 95 | Task { @MainActor in 96 | NSApplication.shared.terminate(nil) 97 | } 98 | } 99 | 100 | private init() { 101 | currentLanguage = storedLanguage 102 | Bundle.setLanguage(language: storedLanguage.rawValue) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /ishare/Util/PasteboardExtension.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import UniformTypeIdentifiers 3 | 4 | extension NSPasteboard { 5 | var mediaURL: URL? { 6 | guard let urls = readObjects(forClasses: [NSURL.self], options: nil) as? [URL], 7 | let url = urls.first else { return nil } 8 | 9 | guard let type = UTType(filenameExtension: url.pathExtension) else { return nil } 10 | return type.conforms(to: .image) || type.conforms(to: .audiovisualContent) ? url : nil 11 | } 12 | 13 | var imageFromData: NSImage? { 14 | guard let data = data(forType: .tiff) ?? data(forType: .png) else { return nil } 15 | return NSImage(data: data) 16 | } 17 | 18 | var hasMediaContent: Bool { 19 | return mediaURL != nil || imageFromData != nil 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ishare/Util/PreviewImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewImage.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 30.08.23. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | import SwiftUI 11 | 12 | enum ImagePhase { 13 | case empty 14 | case success(NSImage) 15 | case failure 16 | } 17 | 18 | struct PreviewImage: View { 19 | @State private var image: NSImage? = nil 20 | @State private var phase: ImagePhase = .empty 21 | 22 | let url: URL? 23 | let content: (ImagePhase) -> Content 24 | 25 | init(url: URL?, @ViewBuilder content: @escaping (ImagePhase) -> Content) { 26 | self.url = url 27 | self.content = content 28 | } 29 | 30 | var body: some View { 31 | content(phase) 32 | .onAppear { 33 | guard let url else { 34 | return 35 | } 36 | let config = URLSessionConfiguration.default 37 | config.httpMaximumConnectionsPerHost = 100 38 | let session = URLSession(configuration: config) 39 | let task = session.dataTask(with: url) { data, _, _ in 40 | if let data, let uiImage = NSImage(data: data) { 41 | DispatchQueue.main.async { 42 | image = uiImage 43 | phase = .success(uiImage) 44 | } 45 | } else { 46 | DispatchQueue.main.async { 47 | phase = .failure 48 | } 49 | } 50 | } 51 | task.resume() 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /ishare/Util/ToastPopover.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import Defaults 3 | import SwiftUI 4 | 5 | struct ToastPopoverView: View { 6 | let thumbnailImage: NSImage 7 | let fileURL: URL 8 | @Default(.saveToDisk) var saveToDisk 9 | @State private var isDragging = false 10 | 11 | var body: some View { 12 | GeometryReader { geometry in 13 | Image(nsImage: thumbnailImage) 14 | .resizable() 15 | .aspectRatio(contentMode: .fit) 16 | .frame(maxWidth: geometry.size.width - 40, maxHeight: geometry.size.height - 20) 17 | .frame(width: geometry.size.width, height: geometry.size.height) 18 | .background(Color(NSColor.windowBackgroundColor).opacity(0.9)) 19 | .foregroundColor(Color(NSColor.labelColor)) 20 | .cornerRadius(10) 21 | .animation(Animation.easeInOut(duration: 1.0), value: thumbnailImage) 22 | .opacity(isDragging ? 0 : 1) 23 | .onTapGesture { 24 | if saveToDisk { 25 | NSWorkspace.shared.selectFile(fileURL.path, inFileViewerRootedAtPath: "") 26 | } 27 | } 28 | .onDrag { 29 | isDragging = true 30 | let itemProvider = NSItemProvider(object: fileURL as NSURL) 31 | itemProvider.suggestedName = fileURL.lastPathComponent 32 | return itemProvider 33 | } 34 | .onDrop(of: [UTType.url], isTargeted: nil) { _ -> Bool in 35 | isDragging = false 36 | return true 37 | } 38 | } 39 | } 40 | } 41 | 42 | @MainActor 43 | func showToast(fileURL: URL, completion: (@Sendable () -> Void)? = nil) { 44 | if fileURL.pathExtension == "mov" || fileURL.pathExtension == "mp4" { 45 | let localCompletion = completion 46 | Task.detached(priority: .userInitiated) { 47 | let asset = AVURLAsset(url: fileURL) 48 | let imageGenerator = AVAssetImageGenerator(asset: asset) 49 | imageGenerator.appliesPreferredTrackTransform = true 50 | let time = CMTime(seconds: 2, preferredTimescale: 60) 51 | 52 | do { 53 | let cgImage = try await imageGenerator.image(at: time) 54 | let imageData = cgImage.image.dataProvider?.data 55 | let width = cgImage.image.width 56 | let height = cgImage.image.height 57 | 58 | guard imageData != nil else { 59 | throw NSError(domain: "ImageErrorDomain", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to get image data"]) 60 | } 61 | 62 | await MainActor.run { 63 | let thumbnailImage = NSImage(cgImage: cgImage.image, size: CGSize(width: width, height: height)) 64 | showThumbnailAndToast(fileURL: fileURL, thumbnailImage: thumbnailImage, completion: localCompletion) 65 | } 66 | } catch { 67 | print("Error generating thumbnail: \(error)") 68 | } 69 | } 70 | } else { 71 | showThumbnailAndToast(fileURL: fileURL, thumbnailImage: NSImage(contentsOf: fileURL)!, completion: completion) 72 | } 73 | } 74 | 75 | @MainActor 76 | private func showThumbnailAndToast(fileURL: URL, thumbnailImage: NSImage, completion: (() -> Void)? = nil) { 77 | let toastTimeout = Defaults[.toastTimeout] 78 | let localCompletion = completion 79 | let toastWindow = NSWindow( 80 | contentRect: NSRect(x: 0, y: 0, width: 340, height: 240), 81 | styleMask: [.borderless], 82 | backing: .buffered, 83 | defer: false 84 | ) 85 | toastWindow.backgroundColor = .clear 86 | toastWindow.isOpaque = false 87 | toastWindow.level = .floating 88 | toastWindow.contentView = NSHostingView( 89 | rootView: ToastPopoverView(thumbnailImage: thumbnailImage, fileURL: fileURL) 90 | ) 91 | 92 | toastWindow.makeKeyAndOrderFront(nil) 93 | let screenSize = NSScreen.main?.frame.size ?? .zero 94 | let originX = screenSize.width - toastWindow.frame.width - 20 95 | let originY = screenSize.height - toastWindow.frame.height - 20 96 | toastWindow.setFrameOrigin(NSPoint(x: originX, y: originY)) 97 | 98 | let fadeDuration = 0.2 99 | toastWindow.alphaValue = 0.0 100 | 101 | NSAnimationContext.runAnimationGroup({ context in 102 | context.duration = fadeDuration 103 | toastWindow.animator().alphaValue = 1.0 104 | }) { 105 | DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(Int(toastTimeout))) { 106 | NSAnimationContext.runAnimationGroup({ context in 107 | context.duration = fadeDuration 108 | toastWindow.animator().alphaValue = 0.0 109 | }) { 110 | toastWindow.orderOut(nil) 111 | localCompletion?() 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /ishare/Views/HistoryGridView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistoryGridView.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 29.12.23. 6 | // 7 | 8 | import Alamofire 9 | import BezelNotification 10 | import Defaults 11 | import Foundation 12 | import SwiftUI 13 | 14 | struct HistoryGridView: View { 15 | @Default(.uploadHistory) var uploadHistory 16 | var body: some View { 17 | ScrollView { 18 | LazyVGrid(columns: [GridItem(.adaptive(minimum: 100, maximum: .infinity), spacing: 3)], spacing: 3) { 19 | ForEach(uploadHistory, id: \.self) { item in 20 | if let urlStr = item.fileUrl, let url = URL(string: urlStr), url.pathExtension.lowercased() == "mp4" || url.pathExtension.lowercased() == "mov" { 21 | ContextMenuWrapper(item: item) { 22 | VideoThumbnailView(url: url) 23 | .frame(width: 100, height: 100) 24 | } 25 | } else { 26 | ContextMenuWrapper(item: item) { 27 | HistoryItemView(urlString: item.fileUrl ?? "") 28 | .frame(width: 100, height: 100) 29 | } 30 | } 31 | } 32 | } 33 | } 34 | .frame(minWidth: 600, minHeight: 400) 35 | } 36 | } 37 | 38 | struct ContextMenuWrapper: View { 39 | @Default(.uploadHistory) var uploadHistory 40 | let content: Content 41 | let item: HistoryItem 42 | 43 | init(item: HistoryItem, @ViewBuilder content: () -> Content) { 44 | self.item = item 45 | self.content = content() 46 | } 47 | 48 | var body: some View { 49 | content 50 | .contextMenu { 51 | Button("Copy URL".localized()) { 52 | NSPasteboard.general.declareTypes([.string], owner: nil) 53 | NSPasteboard.general.setString(item.fileUrl ?? "", forType: .string) 54 | BezelNotification.show(messageText: "Copied URL".localized(), icon: ToastIcon) 55 | } 56 | Button("Open in Browser".localized()) { 57 | if let url = URL(string: item.fileUrl ?? "") { 58 | NSWorkspace.shared.open(url) 59 | } 60 | } 61 | Button("Delete".localized()) { 62 | if let deletionUrl = item.deletionUrl { 63 | performDeletionRequest(deletionUrl: deletionUrl) { result in 64 | DispatchQueue.main.async { 65 | switch result { 66 | case let .success(message): 67 | print(message) 68 | if let index = uploadHistory.firstIndex(of: item) { 69 | uploadHistory.remove(at: index) 70 | BezelNotification.show(messageText: "Deleted".lowercased(), icon: ToastIcon) 71 | } 72 | case let .failure(error): 73 | print("Deletion error: \(error.localizedDescription)") 74 | if let index = uploadHistory.firstIndex(of: item) { 75 | uploadHistory.remove(at: index) 76 | BezelNotification.show(messageText: "Deleted".localized(), icon: ToastIcon) 77 | } 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | } 85 | } 86 | 87 | struct VideoThumbnailView: View { 88 | var url: URL 89 | 90 | var body: some View { 91 | Image(systemName: "video") 92 | .resizable() 93 | .aspectRatio(contentMode: .fit) 94 | .frame(width: 100, height: 100) 95 | } 96 | } 97 | 98 | struct HistoryItemView: View { 99 | var urlString: String 100 | 101 | var body: some View { 102 | if let url = URL(string: urlString) { 103 | AsyncImage(url: url) { image in 104 | image.resizable() 105 | } placeholder: { 106 | ProgressView() 107 | } 108 | .aspectRatio(contentMode: .fit) 109 | .frame(width: 100, height: 100) 110 | } else { 111 | Image(systemName: "photo") 112 | .resizable() 113 | .aspectRatio(contentMode: .fit) 114 | .frame(width: 100, height: 100) 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /ishare/Views/MainMenuView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainMenuView.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 12.07.23. 6 | // 7 | 8 | import BezelNotification 9 | import Defaults 10 | import ScreenCaptureKit 11 | import SettingsAccess 12 | import SwiftUI 13 | import UniformTypeIdentifiers 14 | 15 | #if canImport(Sparkle) 16 | import Sparkle 17 | #endif 18 | 19 | struct MainMenuView: View { 20 | @EnvironmentObject var localizableManager: LocalizableManager 21 | 22 | @Default(.copyToClipboard) var copyToClipboard 23 | @Default(.openInFinder) var openInFinder 24 | @Default(.saveToDisk) var saveToDisk 25 | @Default(.uploadMedia) var uploadMedia 26 | @Default(.uploadType) var uploadType 27 | @Default(.activeCustomUploader) var activeCustomUploader 28 | @Default(.savedCustomUploaders) var savedCustomUploaders 29 | @Default(.uploadDestination) var uploadDestination 30 | @Default(.builtInShare) var builtInShare 31 | @Default(.uploadHistory) var uploadHistory 32 | 33 | var body: some View { 34 | VStack { 35 | Menu { 36 | Button { 37 | Task { 38 | await captureScreen(type: .REGION) 39 | } 40 | } label: { 41 | Image(systemName: "uiwindow.split.2x1") 42 | Text("Capture Region".localized()) 43 | }.globalKeyboardShortcut(.captureRegion) 44 | 45 | Button { 46 | Task { 47 | await captureScreen(type: .WINDOW) 48 | } 49 | } label: { 50 | Image(systemName: "macwindow.on.rectangle") 51 | Text("Capture Window".localized()) 52 | }.globalKeyboardShortcut(.captureWindow) 53 | 54 | ForEach(NSScreen.screens.indices, id: \.self) { index in 55 | let screen = NSScreen.screens[index] 56 | let screenName = screen.localizedName 57 | Button { 58 | Task { 59 | await captureScreen(type: .SCREEN, display: index + 1) 60 | } 61 | } label: { 62 | Image(systemName: "macwindow") 63 | Text("Capture \(screenName ?? "Screen")".localized()) 64 | }.globalKeyboardShortcut(index == 0 ? .captureScreen : .noKeybind) 65 | } 66 | } label: { 67 | Image(systemName: "photo.on.rectangle.angled") 68 | Text("Capture".localized()) 69 | } 70 | 71 | Button { 72 | recordScreen() 73 | } label: { 74 | Image(systemName: "menubar.dock.rectangle.badge.record") 75 | Text("Record".localized()) 76 | }.globalKeyboardShortcut(.recordScreen).disabled( 77 | AppDelegate.shared.screenRecorder.isRunning) 78 | 79 | Button { 80 | recordScreen(gif: true) 81 | } label: { 82 | Image(systemName: "photo.stack") 83 | Text("Record GIF".localized()) 84 | }.globalKeyboardShortcut(.recordGif).disabled( 85 | AppDelegate.shared.screenRecorder.isRunning) 86 | } 87 | VStack { 88 | Menu { 89 | Toggle(isOn: $copyToClipboard) { 90 | Image(systemName: "clipboard") 91 | Text("Copy to Clipboard".localized()) 92 | }.toggleStyle(.checkbox) 93 | 94 | Toggle(isOn: $saveToDisk) { 95 | Image(systemName: "internaldrive") 96 | Text("Save to Disk".localized()) 97 | } 98 | .toggleStyle(.checkbox) 99 | .onChange(of: saveToDisk) { 100 | if !saveToDisk { 101 | openInFinder = false 102 | } 103 | } 104 | 105 | Toggle(isOn: $openInFinder) { 106 | Image(systemName: "folder") 107 | Text("Open in Finder".localized()) 108 | } 109 | .toggleStyle(.checkbox) 110 | .disabled(!saveToDisk) 111 | 112 | Toggle(isOn: $uploadMedia) { 113 | Image(systemName: "icloud.and.arrow.up") 114 | Text("Upload Media".localized()) 115 | }.toggleStyle(.checkbox) 116 | 117 | Divider().frame(height: 1).foregroundColor(Color.gray.opacity(0.5)) 118 | 119 | Toggle(isOn: $builtInShare.airdrop) { 120 | Image(nsImage: airdropIcon ?? NSImage()) 121 | Text("AirDrop".localized()) 122 | }.toggleStyle(.checkbox) 123 | 124 | Toggle(isOn: $builtInShare.photos) { 125 | Image(nsImage: icon(forAppWithName: "com.apple.Photos") ?? NSImage()) 126 | Text("Photos".localized()) 127 | }.toggleStyle(.checkbox) 128 | 129 | Toggle(isOn: $builtInShare.messages) { 130 | Image(nsImage: icon(forAppWithName: "com.apple.MobileSMS") ?? NSImage()) 131 | Text("Messages".localized()) 132 | }.toggleStyle(.checkbox) 133 | 134 | Toggle(isOn: $builtInShare.mail) { 135 | Image(nsImage: icon(forAppWithName: "com.apple.Mail") ?? NSImage()) 136 | Text("Mail".localized()) 137 | }.toggleStyle(.checkbox) 138 | } label: { 139 | Image(systemName: "list.bullet.clipboard") 140 | Text("Post Media Tasks".localized()) 141 | } 142 | 143 | Picker(selection: $uploadDestination) { 144 | ForEach(UploadType.allCases.filter { $0 != .CUSTOM }, id: \.self) { uploadType in 145 | Button { 146 | } label: { 147 | Image(nsImage: ImgurIcon) 148 | Text(uploadType.rawValue.capitalized) 149 | }.tag(UploadDestination.builtIn(uploadType)) 150 | } 151 | if let customUploaders = savedCustomUploaders { 152 | if !customUploaders.isEmpty { 153 | Divider() 154 | ForEach(CustomUploader.allCases, id: \.self) { uploader in 155 | Button { 156 | } label: { 157 | Image(nsImage: AppIcon) 158 | Text(uploader.name) 159 | }.tag(UploadDestination.custom(uploader.id)) 160 | } 161 | } 162 | } 163 | } label: { 164 | Image(systemName: "icloud.and.arrow.up") 165 | Text("Upload Destination".localized()) 166 | } 167 | .onChange(of: uploadDestination) { 168 | if case .builtIn = uploadDestination { 169 | activeCustomUploader = nil 170 | uploadType = .IMGUR 171 | BezelNotification.show( 172 | messageText: "Selected \(uploadType.rawValue.capitalized)".localized(), 173 | icon: ToastIcon) 174 | } else if case let .custom(customUploader) = uploadDestination { 175 | activeCustomUploader = customUploader 176 | uploadType = .CUSTOM 177 | BezelNotification.show( 178 | messageText: "Selected Custom".localized(), icon: ToastIcon) 179 | } 180 | } 181 | .pickerStyle(MenuPickerStyle()) 182 | 183 | if !uploadHistory.isEmpty { 184 | Menu { 185 | Button { 186 | Task { @MainActor in 187 | openHistoryWindow(uploadHistory: uploadHistory) 188 | } 189 | } label: { 190 | Image(systemName: "clock.arrow.circlepath") 191 | Text("Open History Window".localized()) 192 | }.globalKeyboardShortcut(.openHistoryWindow) 193 | 194 | Divider() 195 | 196 | ForEach(uploadHistory.prefix(10), id: \.self) { item in 197 | Button { 198 | NSPasteboard.general.declareTypes([.string], owner: nil) 199 | NSPasteboard.general.setString(item.fileUrl ?? "", forType: .string) 200 | BezelNotification.show( 201 | messageText: "Copied URL".localized(), icon: ToastIcon) 202 | } label: { 203 | HStack { 204 | if let urlStr = item.fileUrl, let url = URL(string: urlStr), 205 | url.pathExtension.lowercased() == "mp4" 206 | || url.pathExtension.lowercased() == "mov" 207 | { 208 | Image(systemName: "video") 209 | .resizable() 210 | .scaledToFit() 211 | .frame(width: 30, height: 30) 212 | } else { 213 | PreviewImage(url: URL(string: item.fileUrl ?? "")) { phase in 214 | switch phase { 215 | case let .success(nsImage): 216 | Image(nsImage: nsImage).resizable() 217 | .scaledToFit() 218 | .frame(width: 30, height: 30) 219 | case .failure: 220 | Image(systemName: "exclamationmark.triangle.fill") 221 | .foregroundColor(.red) 222 | case .empty: 223 | ProgressView() 224 | .frame(width: 30, height: 30) 225 | } 226 | } 227 | .frame(width: 30, height: 30) 228 | } 229 | } 230 | } 231 | } 232 | } label: { 233 | Image(systemName: "clock.arrow.circlepath") 234 | Text("History".localized()) 235 | } 236 | } 237 | 238 | Divider() 239 | 240 | SettingsLink { 241 | Image(systemName: "gearshape") 242 | Text("Settings".localized()) 243 | } preAction: { 244 | NSApp.activate(ignoringOtherApps: true) 245 | } postAction: { 246 | } 247 | .keyboardShortcut("s") 248 | 249 | Button { 250 | NSApplication.shared.activate(ignoringOtherApps: true) 251 | 252 | let options: [NSApplication.AboutPanelOptionKey: Any] = [ 253 | NSApplication.AboutPanelOptionKey(rawValue: "Copyright".localized()): 254 | "© \(Calendar.current.component(.year, from: Date())) ADRIAN CASTRO" 255 | ] 256 | 257 | NSApplication.shared.orderFrontStandardAboutPanel(options: options) 258 | } label: { 259 | Image(systemName: "info.circle") 260 | Text("About ishare".localized()) 261 | } 262 | .keyboardShortcut("a") 263 | 264 | #if GITHUB_RELEASE 265 | Button { 266 | NSWorkspace.shared.open(URL(string: "https://github.com/sponsors/castdrian")!) 267 | } label: { 268 | Image(systemName: "heart.circle") 269 | Text("Donate".localized()) 270 | }.keyboardShortcut("d") 271 | 272 | Button { 273 | AppDelegate.shared.checkForUpdates() 274 | } label: { 275 | Image(systemName: "arrow.triangle.2.circlepath") 276 | Text("Check for Updates".localized()) 277 | }.keyboardShortcut("u") 278 | 279 | #endif 280 | 281 | Button { 282 | NSApplication.shared.terminate(nil) 283 | } label: { 284 | Image(systemName: "power.circle") 285 | Text("Quit".localized()) 286 | }.keyboardShortcut("q") 287 | } 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /ishare/Views/Settings/UploaderSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UploaderSettings.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 15.07.23. 6 | // UI reworked by iGerman on 22.04.24. 7 | // 8 | 9 | import Defaults 10 | import SwiftUI 11 | 12 | struct UploaderSettingsView: View { 13 | @Default(.activeCustomUploader) var activeCustomUploader 14 | @Default(.savedCustomUploaders) var savedCustomUploaders 15 | @Default(.uploadType) var uploadType 16 | @Default(.aussieMode) var aussieMode 17 | 18 | @State private var isAddSheetPresented = false 19 | @State private var isImportSheetPresented = false 20 | @State private var editingUploader: CustomUploader? 21 | 22 | var body: some View { 23 | VStack { 24 | if let uploaders = savedCustomUploaders { 25 | if uploaders.isEmpty { 26 | HStack(alignment: .center) { 27 | VStack { 28 | Text("You have no saved uploaders".localized()) 29 | .frame(maxWidth: .infinity, maxHeight: .infinity) 30 | .font(.largeTitle) 31 | .foregroundColor(.secondary) 32 | } 33 | } 34 | } else { 35 | ForEach(uploaders.sorted(by: { $0.name < $1.name }), id: \.self) { uploader in 36 | HStack { 37 | Text(uploader.name) 38 | .font(.subheadline) 39 | 40 | Spacer() 41 | 42 | Button(action: { 43 | deleteCustomUploader(uploader) 44 | }) { 45 | Image(systemName: "trash") 46 | .resizable() 47 | .aspectRatio(contentMode: .fill) 48 | .frame(width: 12, height: 12) 49 | } 50 | .buttonStyle(BorderlessButtonStyle()) 51 | .help("Delete Uploader".localized()) 52 | 53 | Button(action: { 54 | testCustomUploader(uploader) 55 | }) { 56 | Image(systemName: "icloud.and.arrow.up") 57 | .resizable() 58 | .aspectRatio(contentMode: .fill) 59 | .frame(width: 12, height: 12) 60 | } 61 | .buttonStyle(BorderlessButtonStyle()) 62 | .help("Test Uploader".localized()) 63 | 64 | Button(action: { 65 | editingUploader = uploader 66 | isAddSheetPresented.toggle() 67 | }) { 68 | Image(systemName: "pencil") 69 | .resizable() 70 | .aspectRatio(contentMode: .fill) 71 | .frame(width: 12, height: 12) 72 | } 73 | .buttonStyle(BorderlessButtonStyle()) 74 | .help("Edit Uploader".localized()) 75 | 76 | Button(action: { 77 | exportUploader(uploader) 78 | }) { 79 | Image(systemName: "square.and.arrow.up") 80 | .resizable() 81 | .aspectRatio(contentMode: .fill) 82 | .frame(width: 12, height: 12) 83 | } 84 | .buttonStyle(BorderlessButtonStyle()) 85 | .help("Export Uploader".localized()) 86 | } 87 | .padding(.horizontal) 88 | .padding(.vertical, 4) 89 | }.padding(.top) 90 | } 91 | } 92 | 93 | Divider().padding(.horizontal) 94 | Spacer() 95 | 96 | HStack { 97 | Button(action: { 98 | editingUploader = nil 99 | isAddSheetPresented.toggle() 100 | }) { 101 | Text("Create".localized()) 102 | } 103 | .buttonStyle(DefaultButtonStyle()) 104 | 105 | Button(action: { 106 | isImportSheetPresented.toggle() 107 | }) { 108 | Text("Import".localized()) 109 | } 110 | .buttonStyle(DefaultButtonStyle()) 111 | 112 | Button(action: { 113 | clearAllUploaders() 114 | }) { 115 | Text("Clear All".localized()) 116 | .foregroundColor(.red) 117 | } 118 | .buttonStyle(DefaultButtonStyle()) 119 | } 120 | .padding(.bottom) 121 | } 122 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 123 | .sheet(isPresented: $isAddSheetPresented) { 124 | AddCustomUploaderView(uploader: $editingUploader) 125 | .frame(minWidth: 450) 126 | } 127 | .sheet(isPresented: $isImportSheetPresented) { 128 | ImportCustomUploaderView() 129 | .frame(minWidth: 350) 130 | } 131 | } 132 | 133 | private func deleteCustomUploader(_ uploader: CustomUploader) { 134 | guard var uploaders = savedCustomUploaders else { return } 135 | uploaders = uploaders.filter { $0.id != uploader.id } 136 | savedCustomUploaders = uploaders 137 | 138 | if uploader.id == activeCustomUploader { 139 | activeCustomUploader = nil 140 | uploadType = .IMGUR 141 | } 142 | } 143 | 144 | private func testCustomUploader(_ uploader: CustomUploader) { 145 | guard let iconImage = NSImage(named: NSImage.applicationIconName) else { 146 | print("Failed to get app icon image") 147 | return 148 | } 149 | 150 | guard let tiffData = iconImage.tiffRepresentation, 151 | let bitmapImage = NSBitmapImageRep(data: tiffData), 152 | let pngData = bitmapImage.representation(using: .png, properties: [:]) 153 | else { 154 | print("Failed to convert image to PNG data") 155 | return 156 | } 157 | 158 | let fileManager = FileManager.default 159 | let temporaryDirectory = fileManager.temporaryDirectory 160 | let fileURL = temporaryDirectory.appendingPathComponent("appIconImage.png") 161 | 162 | do { 163 | try pngData.write(to: fileURL) 164 | } catch { 165 | print("Failed to write image file: \(error)") 166 | return 167 | } 168 | 169 | let callback = { @Sendable (error: (any Error)?, finalURL: URL?) -> Void in 170 | Task { @MainActor in 171 | if let error { 172 | print("Upload error: \(error)") 173 | let alert = NSAlert() 174 | alert.alertStyle = .critical 175 | alert.messageText = "Upload Error".localized() 176 | alert.informativeText = "An error occurred during the upload process.".localized() 177 | alert.runModal() 178 | } else if let url = finalURL { 179 | print("Final URL: \(url)") 180 | let alert = NSAlert() 181 | alert.alertStyle = .informational 182 | alert.messageText = "Upload Successful".localized() 183 | alert.informativeText = "The file was uploaded successfully.".localized() 184 | alert.runModal() 185 | } 186 | } 187 | } 188 | 189 | customUpload(fileURL: fileURL, specification: uploader, callback: callback) {} 190 | 191 | DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { 192 | do { 193 | try FileManager.default.removeItem(at: fileURL) 194 | } catch { 195 | print("Failed to delete temporary file: \(error)") 196 | } 197 | } 198 | } 199 | 200 | private func clearAllUploaders() { 201 | savedCustomUploaders = nil 202 | activeCustomUploader = nil 203 | uploadType = .IMGUR 204 | } 205 | 206 | private func exportUploader(_ uploader: CustomUploader) { 207 | let data = try! JSONEncoder().encode(uploader) 208 | let savePanel = NSSavePanel() 209 | savePanel.allowedContentTypes = [.init(filenameExtension: "iscu")!] 210 | savePanel.nameFieldStringValue = "\(uploader.name).iscu" 211 | 212 | savePanel.begin { result in 213 | if result == .OK, let url = savePanel.url { 214 | do { 215 | try data.write(to: url) 216 | } catch { 217 | print("Error exporting uploader: \(error)") 218 | } 219 | } 220 | } 221 | } 222 | } 223 | 224 | struct AddCustomUploaderView: View { 225 | @Environment(\.presentationMode) var presentationMode 226 | @Default(.savedCustomUploaders) var savedCustomUploaders 227 | @Default(.aussieMode) var aussieMode 228 | @Binding var uploader: CustomUploader? 229 | 230 | @State private var uploaderName: String = "" 231 | @State private var requestURL: String = "" 232 | @State private var responseURL: String = "" 233 | @State private var deletionURL: String = "" 234 | @State private var fileFormName: String = "" 235 | @State private var header: [CustomEntryModel] = [] 236 | @State private var formData: [CustomEntryModel] = [] 237 | 238 | var body: some View { 239 | ScrollView { 240 | Text(uploader == nil ? "Create Custom Uploader".localized() : "Edit Custom Uploader".localized()) 241 | .font(.title) 242 | .padding() 243 | Divider().padding(.horizontal) 244 | 245 | VStack(alignment: .leading) { 246 | Group { 247 | InputField(label: "Name*".lowercased(), text: $uploaderName) 248 | HStack { 249 | InputField(label: "Request URL*".localized(), text: $requestURL) 250 | InputField(label: "Response URL*".localized(), text: $responseURL) 251 | } 252 | HStack { 253 | InputField(label: "Deletion URL".localized(), text: $deletionURL) 254 | InputField(label: "File Form Name".localized(), text: $fileFormName) 255 | } 256 | } 257 | .padding(.bottom) 258 | 259 | Divider().padding(.vertical) 260 | HeaderView() 261 | Divider().padding(.vertical) 262 | FormDataView() 263 | Divider().padding(.vertical) 264 | 265 | Text("*required".localized()).font(.footnote).frame(maxWidth: .infinity, alignment: .leading).opacity(0.5) 266 | 267 | Button(action: { 268 | saveCustomUploader() 269 | }) { 270 | Text("Save".localized()) 271 | .frame(maxWidth: .infinity) 272 | } 273 | .padding() 274 | } 275 | .padding() 276 | } 277 | .onAppear { 278 | if let uploader { 279 | uploaderName = uploader.name 280 | requestURL = uploader.requestURL 281 | responseURL = uploader.responseURL 282 | deletionURL = uploader.deletionURL ?? "" 283 | fileFormName = uploader.fileFormName ?? "" 284 | header = uploader.headers?.map { CustomEntryModel(key: $0.key, value: $0.value) } ?? [] 285 | formData = uploader.formData?.map { CustomEntryModel(key: $0.key, value: $0.value) } ?? [] 286 | } 287 | } 288 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 289 | } 290 | 291 | private struct InputField: View { 292 | let label: String 293 | @Binding var text: String 294 | 295 | var body: some View { 296 | VStack(alignment: .leading) { 297 | TextField(label, text: $text) 298 | .padding(.top, 4) 299 | } 300 | } 301 | } 302 | 303 | private func HeaderView() -> some View { 304 | EntryListView(title: "Headers".localized(), entries: $header) 305 | } 306 | 307 | private func FormDataView() -> some View { 308 | EntryListView(title: "Form Data".localized(), entries: $formData) 309 | } 310 | 311 | struct EntryListView: View { 312 | let title: String 313 | @Binding var entries: [CustomEntryModel] 314 | 315 | var body: some View { 316 | Section(header: HStack { 317 | Text(title) 318 | .font(.headline) 319 | Spacer() 320 | Button(action: { 321 | entries.append(CustomEntryModel(key: "", value: "")) 322 | }) { 323 | Image(systemName: "plus") 324 | } 325 | }) { 326 | ForEach(entries) { entry in 327 | if entry == entries.last { 328 | CustomEntryView(entry: $entries[entries.firstIndex(of: entry)!]) 329 | .padding(.horizontal) 330 | } 331 | } 332 | 333 | if !entries.isEmpty { 334 | Divider() 335 | HStack { 336 | VStack(alignment: .leading) { 337 | Text("Name".localized()).frame(maxWidth: .infinity) 338 | } 339 | Divider() 340 | VStack(alignment: .leading) { 341 | Text("Value".localized()).frame(maxWidth: .infinity) 342 | } 343 | Button(action: {}) { 344 | Image(systemName: "minus.circle") 345 | }.opacity(0) 346 | .disabled(true) 347 | } 348 | .frame(maxWidth: .infinity) 349 | Divider() 350 | } 351 | 352 | ForEach(entries.indices, id: \.self) { index in 353 | HStack { 354 | VStack(alignment: .leading) { 355 | Text(entries[index].key) 356 | .padding(1) 357 | .frame(minWidth: 0, maxWidth: .infinity) 358 | .font(.system(.body, design: .monospaced)) 359 | } 360 | Divider() 361 | VStack(alignment: .leading) { 362 | Text(entries[index].value) 363 | .padding(1) 364 | .frame(minWidth: 0, maxWidth: .infinity) 365 | .font(.system(.body, design: .monospaced)) 366 | } 367 | Button(action: { 368 | entries.remove(at: index) 369 | }) { 370 | Image(systemName: "minus.circle").foregroundColor(.red) 371 | } 372 | } 373 | .frame(maxWidth: .infinity) 374 | Rectangle() 375 | .frame(width: .infinity, height: 1) 376 | .opacity(0.1) 377 | .padding(0) 378 | } 379 | } 380 | } 381 | } 382 | 383 | private func saveCustomUploader() { 384 | var headerData: [String: String] { 385 | header.reduce(into: [String: String]()) { result, entry in 386 | result[entry.key] = entry.value 387 | } 388 | } 389 | 390 | var formDataModel: [String: String] { 391 | formData.reduce(into: [String: String]()) { result, entry in 392 | result[entry.key] = entry.value 393 | } 394 | } 395 | 396 | let newUploader = CustomUploader( 397 | name: uploaderName, 398 | requestURL: requestURL, 399 | headers: header.isEmpty ? nil : headerData, 400 | formData: formData.isEmpty ? nil : formDataModel, 401 | fileFormName: fileFormName.isEmpty ? nil : fileFormName, 402 | responseURL: responseURL, 403 | deletionURL: deletionURL.isEmpty ? nil : deletionURL 404 | ) 405 | 406 | if var uploaders = savedCustomUploaders { 407 | uploaders.remove(newUploader) 408 | uploaders.insert(newUploader) 409 | savedCustomUploaders = uploaders 410 | } else { 411 | savedCustomUploaders = Set([newUploader]) 412 | } 413 | 414 | presentationMode.wrappedValue.dismiss() 415 | } 416 | 417 | private struct CustomEntryView: View { 418 | @Binding var entry: CustomEntryModel 419 | 420 | var body: some View { 421 | HStack { 422 | VStack(alignment: .leading) { 423 | TextField("Name".localized(), text: $entry.key) 424 | } 425 | VStack(alignment: .leading) { 426 | TextField("Value".localized(), text: $entry.value) 427 | } 428 | } 429 | } 430 | } 431 | } 432 | 433 | struct ImportCustomUploaderView: View { 434 | @Environment(\.presentationMode) var presentationMode 435 | @Default(.savedCustomUploaders) var savedCustomUploaders 436 | @Default(.activeCustomUploader) var activeCustomUploader 437 | @Default(.uploadType) var uploadType 438 | @Default(.aussieMode) var aussieMode 439 | 440 | @State private var selectedFileURLs: [URL] = [] 441 | @State private var importError: ImportError? 442 | 443 | func selectFile(completion: @escaping (URL?) -> Void) { 444 | let filePicker = NSOpenPanel() 445 | filePicker.canChooseDirectories = false 446 | filePicker.canChooseFiles = true 447 | filePicker.allowsMultipleSelection = false 448 | filePicker.canDownloadUbiquitousContents = true 449 | filePicker.canResolveUbiquitousConflicts = true 450 | 451 | filePicker.begin { response in 452 | if response == .OK { 453 | completion(filePicker.urls.first) 454 | } else { 455 | completion(nil) 456 | } 457 | } 458 | } 459 | 460 | var body: some View { 461 | VStack { 462 | Text("Import Custom Uploader".localized()) 463 | .font(.title) 464 | 465 | Divider() 466 | 467 | // Drag and Drop Receptacle 468 | RoundedRectangle(cornerRadius: 12) 469 | .frame(height: 150) 470 | .foregroundColor(.gray.opacity(0.2)) 471 | .overlay( 472 | VStack { 473 | Image(systemName: "arrow.down.doc") 474 | .resizable() 475 | .scaledToFit() 476 | .frame(width: 50, height: 50) 477 | .foregroundColor(.gray) 478 | Text("Drag and drop .iscu files here or click to select".localized()) 479 | .foregroundColor(.gray) 480 | } 481 | ) 482 | .onTapGesture { 483 | selectFile { fileURL in 484 | if let url = fileURL { 485 | selectedFileURLs.append(url) 486 | importUploader() 487 | } 488 | } 489 | } 490 | .onDrop(of: ["public.file-url"], isTargeted: nil) { providers in 491 | providers.first?.loadItem(forTypeIdentifier: "public.file-url", options: nil) { item, _ in 492 | if let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) { 493 | DispatchQueue.main.async { 494 | selectedFileURLs.append(url) 495 | importUploader() 496 | } 497 | } 498 | } 499 | return true 500 | } 501 | 502 | if let fileURL = selectedFileURLs.first { 503 | Text("Selected File: \(fileURL.lastPathComponent)".localized()) 504 | .foregroundColor(.secondary) 505 | } 506 | 507 | Spacer() 508 | 509 | Button("Cancel".localized()) { 510 | presentationMode.wrappedValue.dismiss() 511 | } 512 | .padding() 513 | } 514 | .padding() 515 | .alert(item: $importError) { error in 516 | Alert( 517 | title: Text("Error".localized()), 518 | message: Text(error.localizedDescription), 519 | dismissButton: .default(Text("OK".localized())) 520 | ) 521 | } 522 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 523 | } 524 | 525 | private func importUploader() { 526 | guard let fileURL = selectedFileURLs.first else { return } 527 | 528 | do { 529 | let data = try Data(contentsOf: fileURL) 530 | let decoder = JSONDecoder() 531 | let uploader = try decoder.decode(CustomUploader.self, from: data) 532 | 533 | if var uploaders = savedCustomUploaders { 534 | uploaders.remove(uploader) 535 | uploaders.insert(uploader) 536 | savedCustomUploaders = uploaders 537 | } else { 538 | savedCustomUploaders = Set([uploader]) 539 | } 540 | 541 | activeCustomUploader = uploader.id 542 | uploadType = .CUSTOM 543 | presentationMode.wrappedValue.dismiss() 544 | } catch { 545 | importError = ImportError(error: error) 546 | print("Error importing custom uploader: \(error)") 547 | return 548 | } 549 | 550 | selectedFileURLs = [] 551 | presentationMode.wrappedValue.dismiss() 552 | } 553 | } 554 | 555 | struct ImportError: Identifiable { 556 | let id = UUID() 557 | let error: any Error 558 | 559 | var localizedDescription: String { 560 | error.localizedDescription 561 | } 562 | } 563 | 564 | struct CustomEntryModel: Identifiable, Equatable { 565 | let id = UUID() 566 | var key: String 567 | var value: String 568 | } 569 | 570 | #Preview { 571 | UploaderSettingsView() 572 | } 573 | -------------------------------------------------------------------------------- /ishare/Views/SettingsMenuView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsMenuView.swift 3 | // ishare 4 | // 5 | // Created by Adrian Castro on 12.07.23. 6 | // UI reworked by iGerman on 22.04.24. 7 | // 8 | 9 | import BezelNotification 10 | import Defaults 11 | import KeyboardShortcuts 12 | import LaunchAtLogin 13 | import ScreenCaptureKit 14 | import SwiftUI 15 | import UniformTypeIdentifiers 16 | 17 | struct SettingsMenuView: View { 18 | @Default(.aussieMode) var aussieMode 19 | 20 | let appVersionString: String = 21 | Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String 22 | 23 | var body: some View { 24 | NavigationView { 25 | VStack { 26 | List { 27 | NavigationLink(destination: GeneralSettingsView()) { 28 | Label { 29 | Text("General".localized()) 30 | } icon: { 31 | Image(systemName: "gearshape") 32 | } 33 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 34 | } 35 | NavigationLink(destination: UploaderSettingsView()) { 36 | Label { 37 | Text("Uploaders".localized()) 38 | } icon: { 39 | Image(systemName: "icloud.and.arrow.up") 40 | } 41 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 42 | } 43 | NavigationLink(destination: KeybindSettingsView()) { 44 | Label { 45 | Text("Keybinds".localized()) 46 | } icon: { 47 | Image(systemName: "command.circle") 48 | } 49 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 50 | } 51 | NavigationLink(destination: CaptureSettingsView()) { 52 | Label { 53 | Text("Image files".localized()) 54 | } icon: { 55 | Image(systemName: "photo") 56 | } 57 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 58 | } 59 | NavigationLink(destination: RecordingSettingsView()) { 60 | Label { 61 | Text("Video files".localized()) 62 | } icon: { 63 | Image(systemName: "menubar.dock.rectangle.badge.record") 64 | } 65 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 66 | } 67 | NavigationLink(destination: AdvancedSettingsView()) { 68 | Label { 69 | Text("Advanced".localized()) 70 | } icon: { 71 | Image(systemName: "hammer.circle") 72 | } 73 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 74 | } 75 | } 76 | .listStyle(SidebarListStyle()) 77 | 78 | Spacer() 79 | Divider().padding(.horizontal) 80 | VStack { 81 | Text("v" + appVersionString) 82 | Link(destination: URL(string: "https://github.com/castdrian/ishare")!) { 83 | Text("GitHub".localized()) 84 | } 85 | } 86 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 87 | .padding() 88 | .frame(maxWidth: .infinity, alignment: .center) 89 | } 90 | .frame(minWidth: 200, idealWidth: 200, maxWidth: 300, maxHeight: .infinity) 91 | 92 | GeneralSettingsView() 93 | } 94 | .frame(minWidth: 600, maxWidth: 600, minHeight: 450, maxHeight: 450) 95 | .navigationTitle("Settings".localized()) 96 | } 97 | } 98 | 99 | struct GeneralSettingsView: View { 100 | @EnvironmentObject var localizableManager: LocalizableManager 101 | 102 | @Default(.menuBarIcon) var menubarIcon 103 | @Default(.toastTimeout) var toastTimeout 104 | @Default(.aussieMode) var aussieMode 105 | @Default(.uploadHistory) var uploadHistory 106 | 107 | let appImage = NSImage(named: "AppIcon") ?? AppIcon 108 | 109 | struct MenuButtonStyle: ButtonStyle { 110 | var backgroundColor: Color 111 | 112 | func makeBody(configuration: Self.Configuration) -> some View { 113 | configuration.label 114 | .font(.headline) 115 | .padding(10) 116 | .background(backgroundColor) 117 | .cornerRadius(5) 118 | } 119 | } 120 | 121 | var body: some View { 122 | ZStack { 123 | VStack(alignment: .leading, spacing: 30) { 124 | VStack(alignment: .leading, spacing: 40) { 125 | VStack(alignment: .leading, spacing: 10) { 126 | Text("Language".localized()) 127 | Picker( 128 | "", 129 | selection: Binding( 130 | get: { localizableManager.currentLanguage }, 131 | set: { localizableManager.changeLanguage(to: $0) } 132 | ) 133 | ) { 134 | ForEach(LanguageTypes.allCases, id: \.self) { language in 135 | Text(language.name) 136 | .tag(language) 137 | } 138 | } 139 | .frame(width: 120) 140 | .labelsHidden() 141 | } 142 | 143 | VStack(alignment: .leading, spacing: 10) { 144 | LaunchAtLogin.Toggle { 145 | Text("Launch at login".localized()) 146 | } 147 | Toggle("Land down under".localized(), isOn: $aussieMode) 148 | } 149 | 150 | VStack(alignment: .leading, spacing: 10) { 151 | Text("Menu Bar Icon".localized()) 152 | HStack { 153 | ForEach(MenuBarIcon.allCases, id: \.self) { choice in 154 | Button(action: { 155 | menubarIcon = choice 156 | }) { 157 | switch choice { 158 | case .DEFAULT: 159 | Image(nsImage: GlyphIcon) 160 | .resizable() 161 | .aspectRatio(contentMode: .fill) 162 | .frame(width: 20, height: 5) 163 | case .APPICON: 164 | Image(nsImage: AppIcon) 165 | .resizable() 166 | .aspectRatio(contentMode: .fill) 167 | .frame(width: 20, height: 5) 168 | case .SYSTEM: 169 | Image(systemName: "photo.on.rectangle.angled") 170 | .resizable() 171 | .aspectRatio(contentMode: .fill) 172 | .frame(width: 20, height: 5) 173 | } 174 | } 175 | .buttonStyle( 176 | MenuButtonStyle( 177 | backgroundColor: 178 | menubarIcon == choice ? .accentColor : .clear) 179 | ) 180 | } 181 | } 182 | } 183 | } 184 | .padding(.top, 30) 185 | 186 | Spacer() 187 | 188 | VStack(alignment: .leading) { 189 | Text("Toast Timeout: \(Int(toastTimeout)) seconds".localized()) 190 | Slider(value: $toastTimeout, in: 1...10, step: 1) 191 | .frame(maxWidth: .infinity) 192 | } 193 | .padding(.bottom, 30) 194 | } 195 | .padding(30) 196 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 197 | 198 | if localizableManager.showRestartAlert { 199 | Color.black.opacity(0.4) 200 | .edgesIgnoringSafeArea(.all) 201 | .zIndex(1) 202 | 203 | VStack(spacing: 15) { 204 | Text("Language Change".localized()) 205 | .font(.headline) 206 | Text( 207 | "The app needs to close to apply the language change. Please reopen the app after it closes." 208 | .localized()) 209 | .multilineTextAlignment(.center) 210 | .padding(.horizontal) 211 | 212 | HStack(spacing: 20) { 213 | Button("Restart Now".localized(), role: .destructive) { 214 | localizableManager.confirmLanguageChange() 215 | } 216 | Button("Cancel".localized(), role: .cancel) { 217 | localizableManager.showRestartAlert = false 218 | } 219 | } 220 | } 221 | .padding() 222 | .background(Color(NSColor.windowBackgroundColor)) 223 | .cornerRadius(12) 224 | .shadow(radius: 10) 225 | .padding(40) 226 | .zIndex(2) 227 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 228 | } 229 | } 230 | } 231 | } 232 | 233 | 234 | struct KeybindSettingsView: View { 235 | @Default(.forceUploadModifier) var forceUploadModifier 236 | @Default(.aussieMode) var aussieMode 237 | 238 | var body: some View { 239 | VStack(spacing: 20) { 240 | Form { 241 | Section { 242 | VStack(spacing: 10) { 243 | KeyboardShortcuts.Recorder( 244 | "Open Main Menu:".localized(), name: .toggleMainMenu) 245 | KeyboardShortcuts.Recorder( 246 | "Open History Window:".localized(), name: .openHistoryWindow) 247 | KeyboardShortcuts.Recorder( 248 | "Capture Region:".localized(), name: .captureRegion) 249 | KeyboardShortcuts.Recorder( 250 | "Capture Window:".localized(), name: .captureWindow) 251 | KeyboardShortcuts.Recorder( 252 | "Capture Screen:".localized(), name: .captureScreen) 253 | KeyboardShortcuts.Recorder( 254 | "Record Screen:".localized(), name: .recordScreen) 255 | KeyboardShortcuts.Recorder("Record GIF:".localized(), name: .recordGif) 256 | KeyboardShortcuts.Recorder("Open most recent item:".localized(), name: .openMostRecentItem) 257 | KeyboardShortcuts.Recorder("Upload from Pasteboard:".localized(), name: .uploadPasteBoardItem) 258 | 259 | Divider() 260 | .padding(.vertical, 5) 261 | 262 | HStack { 263 | Text("Force Upload Modifier:".localized()) 264 | Picker("", selection: $forceUploadModifier) { 265 | ForEach(ForceUploadModifier.allCases) { modifier in 266 | Text(modifier.rawValue) 267 | .tag(modifier) 268 | } 269 | } 270 | .frame(width: 100) 271 | } 272 | } 273 | .padding(.vertical, 5) 274 | } header: { 275 | Text("Keybinds".localized()) 276 | .font(.headline) 277 | .padding(.bottom, 5) 278 | } 279 | } 280 | .formStyle(.grouped) 281 | 282 | Button(action: { 283 | KeyboardShortcuts.reset([ 284 | .toggleMainMenu, .openHistoryWindow, 285 | .captureRegion, .captureWindow, .captureScreen, 286 | .recordScreen, .recordGif, 287 | .captureRegionForceUpload, .captureWindowForceUpload, .captureScreenForceUpload, 288 | .recordScreenForceUpload, .recordGifForceUpload, 289 | ]) 290 | BezelNotification.show(messageText: "Reset keybinds".localized(), icon: ToastIcon) 291 | }) { 292 | Text("Reset All Keybinds".localized()) 293 | .foregroundColor(.red) 294 | .frame(maxWidth: .infinity) 295 | } 296 | .padding(.horizontal) 297 | } 298 | .padding() 299 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 300 | } 301 | } 302 | 303 | struct CaptureSettingsView: View { 304 | @Default(.capturePath) var capturePath 305 | @Default(.captureFileType) var fileType 306 | @Default(.captureFileName) var fileName 307 | @Default(.aussieMode) var aussieMode 308 | 309 | var body: some View { 310 | VStack(alignment: .leading, spacing: 30) { 311 | VStack(alignment: .leading, spacing: 15) { 312 | Text("Image path:".localized()).font(.headline) 313 | HStack { 314 | TextField(text: $capturePath) {} 315 | Button(action: { 316 | selectFolder { folderURL in 317 | if let url = folderURL { 318 | capturePath = url.path() 319 | } 320 | } 321 | }) { 322 | Image(systemName: "folder.fill") 323 | }.help("Pick a folder".localized()) 324 | } 325 | } 326 | 327 | VStack(alignment: .leading, spacing: 15) { 328 | Text("File prefix:".localized()).font(.headline) 329 | HStack { 330 | TextField(String(), text: $fileName) 331 | Button(action: { 332 | fileName = Defaults.Keys.captureFileName.defaultValue 333 | }) { 334 | Image(systemName: "arrow.clockwise") 335 | }.help("Set to default".localized()) 336 | } 337 | } 338 | 339 | VStack(alignment: .leading, spacing: 15) { 340 | Text("Format:".localized()).font(.headline) 341 | Picker("Format:".localized(), selection: $fileType) { 342 | ForEach(FileType.allCases, id: \.self) { 343 | Text($0.rawValue.uppercased()) 344 | } 345 | } 346 | .labelsHidden() 347 | } 348 | } 349 | .padding(30) 350 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 351 | } 352 | } 353 | 354 | struct RecordingSettingsView: View { 355 | @Default(.recordingPath) var recordingPath 356 | @Default(.recordingFileName) var fileName 357 | @Default(.recordMP4) var recordMP4 358 | @Default(.useHEVC) var useHEVC 359 | @Default(.useHDR) var useHDR 360 | @Default(.aussieMode) var aussieMode 361 | @Default(.recordAudio) var recordAudio 362 | @Default(.recordMic) var recordMic 363 | @Default(.recordPointer) var recordPointer 364 | @Default(.recordClicks) var recordClicks 365 | 366 | @State private var isExcludedAppSheetPresented = false 367 | 368 | var body: some View { 369 | VStack(alignment: .leading, spacing: 30) { 370 | VStack(alignment: .leading, spacing: 15) { 371 | HStack(spacing: 30) { 372 | VStack(alignment: .leading) { 373 | Toggle("Record .mp4 instead of .mov".localized(), isOn: $recordMP4) 374 | Toggle("Use HEVC".localized(), isOn: $useHEVC) 375 | Toggle("Use HDR".localized(), isOn: $useHDR) 376 | Toggle("Record audio".localized(), isOn: $recordAudio) 377 | Toggle("Record microphone".localized(), isOn: $recordMic) 378 | .onChange(of: recordMic) { _, newValue in 379 | if newValue { 380 | Task { 381 | switch AVCaptureDevice.authorizationStatus(for: .audio) { 382 | case .authorized: 383 | NSLog("Microphone permissions already granted") 384 | case .notDetermined: 385 | NSLog("Requesting microphone permissions") 386 | let granted = await AVCaptureDevice.requestAccess( 387 | for: .audio) 388 | if !granted { 389 | NSLog("Microphone permissions denied") 390 | await MainActor.run { 391 | recordMic = false 392 | } 393 | } 394 | NSLog("Microphone permissions granted") 395 | case .denied, .restricted: 396 | NSLog("Microphone permissions denied or restricted") 397 | await MainActor.run { 398 | recordMic = false 399 | } 400 | @unknown default: 401 | NSLog("Unknown microphone permission status") 402 | await MainActor.run { 403 | recordMic = false 404 | } 405 | } 406 | } 407 | } 408 | } 409 | Toggle("Record pointer".localized(), isOn: $recordPointer) 410 | Toggle("Record clicks".localized(), isOn: $recordClicks) 411 | } 412 | } 413 | } 414 | 415 | VStack(alignment: .leading, spacing: 15) { 416 | Text("Video path:".localized()).font(.headline) 417 | HStack { 418 | TextField(text: $recordingPath) {} 419 | Button(action: { 420 | selectFolder { folderURL in 421 | if let url = folderURL { 422 | recordingPath = url.path() 423 | } 424 | } 425 | }) { 426 | Image(systemName: "folder.fill") 427 | }.help("Pick a folder".localized()) 428 | } 429 | } 430 | 431 | VStack(alignment: .leading, spacing: 15) { 432 | Text("File prefix:".localized()).font(.headline) 433 | HStack { 434 | TextField(String(), text: $fileName) 435 | Button(action: { 436 | fileName = Defaults.Keys.recordingFileName.defaultValue 437 | }) { 438 | Image(systemName: "arrow.clockwise") 439 | }.help("Set to default".localized()) 440 | } 441 | } 442 | 443 | Button("Excluded applications".localized()) { 444 | isExcludedAppSheetPresented.toggle() 445 | } 446 | .padding(.top) 447 | } 448 | .padding(30) 449 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 450 | } 451 | } 452 | 453 | struct AdvancedSettingsView: View { 454 | @State private var showingAlert: Bool = false 455 | @Default(.imgurClientId) var imgurClientId 456 | @Default(.captureBinary) var captureBinary 457 | @Default(.aussieMode) var aussieMode 458 | 459 | var body: some View { 460 | ZStack { 461 | VStack { 462 | Spacer() 463 | VStack(alignment: .leading) { 464 | Text("Imgur Client ID:".localized()).font(.headline) 465 | HStack { 466 | TextField(String(), text: $imgurClientId) 467 | Button(action: { 468 | imgurClientId = Defaults.Keys.imgurClientId.defaultValue 469 | }) { 470 | Image(systemName: "arrow.clockwise") 471 | }.help("Set to default".localized()) 472 | } 473 | } 474 | Spacer() 475 | VStack(alignment: .leading) { 476 | Text("Screencapture binary:".localized()).font(.headline) 477 | HStack { 478 | TextField(String(), text: $captureBinary) 479 | Button(action: { 480 | captureBinary = Defaults.Keys.captureBinary.defaultValue 481 | BezelNotification.show( 482 | messageText: "Reset captureBinary".localized(), icon: ToastIcon) 483 | }) { 484 | Image(systemName: "arrow.clockwise") 485 | }.help("Set to default".localized()) 486 | } 487 | } 488 | Spacer() 489 | } 490 | .padding() 491 | .rotationEffect(aussieMode ? .degrees(180) : .zero) 492 | 493 | if showingAlert { 494 | Color.black.opacity(0.4) 495 | .edgesIgnoringSafeArea(.all) 496 | .zIndex(1) 497 | 498 | VStack(spacing: 15) { 499 | Text("Advanced Settings".localized()) 500 | .font(.headline) 501 | Text("Warning! Only modify these settings if you know what you're doing!".localized()) 502 | .multilineTextAlignment(.center) 503 | .padding(.horizontal) 504 | 505 | Button("I understand".localized()) { 506 | withAnimation { 507 | showingAlert = false 508 | } 509 | } 510 | .buttonStyle(.borderedProminent) 511 | } 512 | .padding() 513 | .background(Color(NSColor.windowBackgroundColor)) 514 | .cornerRadius(12) 515 | .shadow(radius: 10) 516 | .padding(40) 517 | .zIndex(2) 518 | .rotationEffect(aussieMode ? .degrees(180) : .zero) // 🌀 Flip this too! 519 | } 520 | } 521 | .onAppear { 522 | showingAlert = true 523 | } 524 | } 525 | } 526 | 527 | #Preview { 528 | NavigationView { 529 | SettingsMenuView() 530 | .environmentObject(LocalizableManager.shared) 531 | } 532 | } 533 | -------------------------------------------------------------------------------- /ishare/ishare.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.icloud-container-identifiers 6 | 7 | com.apple.security.temporary-exception.mach-lookup.global-name 8 | 9 | $(PRODUCT_BUNDLE_IDENTIFIER)-spks 10 | $(PRODUCT_BUNDLE_IDENTIFIER)-spki 11 | 12 | com.apple.developer.ubiquity-kvstore-identifier 13 | $(TeamIdentifierPrefix)$(CFBundleIdentifier) 14 | com.apple.security.app-sandbox 15 | 16 | com.apple.security.application-groups 17 | 18 | $(TeamIdentifierPrefix)group.dev.adrian.ishare 19 | 20 | com.apple.security.assets.pictures.read-write 21 | 22 | com.apple.security.files.user-selected.read-write 23 | 24 | com.apple.security.network.client 25 | 26 | com.apple.security.temporary-exception.mach-register.global-name 27 | com.apple.screencapture.interactive 28 | 29 | 30 | -------------------------------------------------------------------------------- /sharemenuext/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AppGroupIdentifier 6 | $(TeamIdentifierPrefix)group.dev.adrian.ishare 7 | NSExtension 8 | 9 | NSExtensionAttributes 10 | 11 | NSExtensionActivationRule 12 | 13 | NSExtensionActivationSupportsImageWithMaxCount 14 | 1 15 | NSExtensionActivationSupportsMovieWithMaxCount 16 | 1 17 | 18 | NSExtensionPointIdentifier 19 | com.apple.share-services 20 | NSExtensionPrincipalClass 21 | $(PRODUCT_MODULE_NAME).ShareViewController 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /sharemenuext/ShareViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareViewController.swift 3 | // sharemenuext 4 | // 5 | // Created by Adrian Castro on 19.07.24. 6 | // 7 | 8 | import Cocoa 9 | import Social 10 | import UniformTypeIdentifiers 11 | 12 | @MainActor 13 | class ShareViewController: SLComposeServiceViewController { 14 | override func loadView() { 15 | // Do nothing to avoid displaying any UI 16 | view = NSView() 17 | } 18 | 19 | override func viewDidLoad() { 20 | super.viewDidLoad() 21 | sendFileToApp() 22 | } 23 | 24 | func sendFileToApp() { 25 | NSLog("Share extension activated") 26 | 27 | let supportedTypes: [UTType] = [ 28 | .quickTimeMovie, 29 | .mpeg4Movie, 30 | .png, 31 | .jpeg, 32 | .heic, 33 | .tiff, 34 | .gif, 35 | .webP, 36 | ] 37 | 38 | guard let extensionContext, 39 | let item = (extensionContext.inputItems as? [NSExtensionItem])?.first, 40 | let provider = item.attachments?.first(where: { provider in 41 | supportedTypes.contains(where: { provider.hasItemConformingToTypeIdentifier($0.identifier) }) 42 | }) 43 | else { 44 | NSLog("Error: No valid attachment found in share extension") 45 | extensionContext?.completeRequest(returningItems: nil) 46 | return 47 | } 48 | 49 | let typeIdentifier = supportedTypes.first { provider.hasItemConformingToTypeIdentifier($0.identifier) }?.identifier ?? UTType.data.identifier 50 | let localTypeIdentifier = typeIdentifier 51 | 52 | NSLog("Processing shared item of type: %@", typeIdentifier) 53 | 54 | provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { [weak self] item, _ in 55 | guard let item else { return } 56 | 57 | let processItem = { (item: any NSSecureCoding) -> URL? in 58 | if let urlItem = item as? URL { 59 | return urlItem 60 | } else if let data = item as? Data { 61 | guard let sharedContainerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as! String) else { 62 | NSLog("Failed to get shared container URL") 63 | return nil 64 | } 65 | 66 | let tempDir = sharedContainerURL.appendingPathComponent("tmp", isDirectory: true) 67 | 68 | let fileManager = FileManager.default 69 | do { 70 | try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: nil) 71 | } catch { 72 | NSLog("Failed to create temporary directory in shared container: \(error)") 73 | return nil 74 | } 75 | 76 | let utType = UTType(localTypeIdentifier) 77 | let fileExtension = utType?.preferredFilenameExtension ?? "dat" 78 | let fileName = UUID().uuidString + "." + fileExtension 79 | let fileURL = tempDir.appendingPathComponent(fileName) 80 | 81 | do { 82 | try data.write(to: fileURL) 83 | return fileURL 84 | } catch { 85 | NSLog("Failed to write data to shared container URL: \(error)") 86 | return nil 87 | } 88 | } 89 | return nil 90 | } 91 | 92 | if let url = processItem(item) { 93 | if let encodedURLString = url.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), 94 | let shareURL = URL(string: "ishare://upload?file=\(encodedURLString)") 95 | { 96 | Task { @MainActor in 97 | NSWorkspace.shared.open(shareURL) 98 | self?.extensionContext?.completeRequest(returningItems: nil) 99 | } 100 | } 101 | } else { 102 | Task { @MainActor in 103 | self?.extensionContext?.completeRequest(returningItems: nil) 104 | } 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /sharemenuext/sharemenuext.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | $(TeamIdentifierPrefix)group.dev.adrian.ishare 10 | 11 | com.apple.security.files.user-selected.read-only 12 | 13 | 14 | 15 | --------------------------------------------------------------------------------