├── .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
4 |
5 | The definitive screen capture utility for macOS, designed with simplicity and efficiency in mind.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | ---
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
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 | 
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 | [](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 |
--------------------------------------------------------------------------------
/ishare/Util/Assets.xcassets/GlyphIcon.imageset/glyph_black.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/ishare/Util/Assets.xcassets/GlyphIcon.imageset/glyph_white.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------